Skip to content

Bookkeeping

Craft Easy ships with a full double-entry ledger: every financial event — a payment, a refund, an invoice, a settlement, a payout — posts a balanced transaction to a chart of accounts. Balances are queryable by period. The whole ledger can be exported to SIE4 for import into Swedish accounting software. If you turn this module on, the rest of the financial stack automatically posts the right entries; if you leave it off, payments still work but you have no accounting trail.

This page explains the model, the chart of accounts, the transaction rules, and the SIE4 export. For the business objects that drive the ledger, see Payments, Invoicing, and Settlements.

Double-entry basics

In a double-entry system, every transaction has at least two ledger entries that together balance: the sum of debits equals the sum of credits. The point is that money cannot appear from nowhere — if one account receives value, another account must have given it up.

A customer pays 1000 SEK for an order. The platform's bank account gains 1000, and the platform's revenue account gains 1000. That is two entries:

Entry Account Debit Credit
1 1930 Bank 1000
2 3000 Sales 1000

Debit sum = 1000. Credit sum = 1000. The transaction balances. The bank account grew (debit increases an asset), and the sales account grew (credit increases a revenue account). If either side is missing or the amounts disagree, the transaction is rejected at insert time.

This sounds rigid, but it is exactly the rigidity that makes the books auditable: you cannot lose a krona, and you cannot manufacture one.

The LedgerEntry model

craft_easy.models.bookkeeping.LedgerEntry:

Field Purpose
transaction_id Groups entries belonging to the same transaction
entry_type debit or credit
account_code BAS account code as a string, e.g. "1930" for bank
account_name Display name copied from the account at post time
amount Always positive; entry_type tells you whether it is on the debit or credit side
currency ISO 4217
description Short description of the transaction
reference_type, reference_id Link back to the business object (payment, invoice, settlement)
booking_date The date the transaction is booked in the books
period YYYY-MM, computed from booking_date if omitted
verification_number Auto-generated sequential ID within a period, for human referencing
is_posted, posted_at Posting state — unposted entries can be amended, posted entries are frozen
exported_at, export_provider Set when the entry has been exported to an external accounting system

Indexes: transaction_id, (account_code, booking_date), period, (reference_type, reference_id). The query patterns are: "show me every entry in this transaction", "show me the balance of account X in period Y", "show me every entry caused by this invoice".

All amounts are Decimal — no floats anywhere. Rounding happens only at display time, not inside calculations.

The chart of accounts

craft_easy.models.chart_of_accounts.Account represents a single account in the chart. Each account has:

  • code — the BAS code (Swedish standard), e.g. "1930", "2610"
  • name — display name, e.g. "Bank account"
  • account_type — one of asset, liability, revenue, expense, equity
  • description — optional longer description
  • is_system — true for built-in BAS accounts, false for tenant-added accounts
  • is_active — soft-disable flag
  • parent_code — optional parent account for grouping
  • vat_code — optional VAT classification

DEFAULT_ACCOUNTS — the BAS baseline

Craft Easy ships with a default chart based on the Swedish BAS 2025 account plan. Setting SEED_DEFAULT_ACCOUNTS=True on first boot seeds these accounts automatically:

Assets (1xxx)

Code Name
1510 Accounts receivable
1580 Short-term receivables
1710 Prepaid expenses
1910 Cash
1920 PlusGiro
1930 Bank account
1940 Other bank account
1950 Client funds account

Liabilities (2xxx)

Code Name
2440 Accounts payable
2610 Output VAT 25%
2620 Output VAT 12%
2630 Output VAT 6%
2640 Input VAT
2710 Payroll tax withheld
2731 Social security contributions
2910 Accrued wages
2990 Accrued expenses

Equity (2xxx)

Code Name
2010 Equity
2091 Retained earnings
2099 Net income for the year

Revenue (3xxx)

Code Name
3000 Sales
3010 Subscription revenue
3040 Commission revenue
3590 Other operating revenue
3740 Rounding

Expenses (4xxx-8xxx)

Cost of goods sold, rent, consumables, repairs, travel, office supplies, telecom, insurance, accounting fees, bank charges, services, salaries, employer contributions, depreciation, interest — the full BAS expense list is seeded.

The exact codes and names match BAS 2025. If your deployment serves jurisdictions outside Sweden, set SEED_DEFAULT_ACCOUNTS=False and seed your own chart through the CRUD endpoint.

Creating a transaction

craft_easy.core.bookkeeping.service.BookkeepingService:

from craft_easy.core.bookkeeping.service import BookkeepingService

svc = BookkeepingService()

transaction_id = await svc.create_transaction(
    entries=[
        {"entry_type": "debit",  "account_code": "1930", "amount": Decimal("1000"), "account_name": "Bank"},
        {"entry_type": "credit", "account_code": "3000", "amount": Decimal("1000"), "account_name": "Sales"},
    ],
    description="Order #1234 payment",
    reference_type="payment",
    reference_id=payment.id,
    tenant_id=tenant.id,
)

create_transaction enforces:

  1. entries is non-empty. A zero-entry transaction is a no-op; the service rejects it rather than silently ignoring it.
  2. Debits equal credits. sum(e.amount for debits) == sum(e.amount for credits). Mismatched sums raise with the exact debit/credit breakdown in the error detail.
  3. Each entry amount is positive. Entry type determines the side; a negative amount would be ambiguous.
  4. Account codes exist in the chart. An unknown code raises before any insert.
  5. Period is derived from booking_date if not provided (format "2026-04" for April 2026).

The service returns a transaction_id that you can use to look up all entries in the transaction later.

A worked example — an invoice with VAT

A tenant issues an invoice for 1000 SEK plus 250 SEK (25%) VAT:

await svc.create_transaction(
    entries=[
        # Customer owes us 1250 SEK
        {"entry_type": "debit",  "account_code": "1510", "amount": Decimal("1250")},
        # Revenue excluding VAT — 1000 SEK
        {"entry_type": "credit", "account_code": "3000", "amount": Decimal("1000")},
        # VAT owed to tax authority — 250 SEK
        {"entry_type": "credit", "account_code": "2610", "amount": Decimal("250")},
    ],
    description="Invoice 2026-000123",
    reference_type="invoice",
    reference_id=invoice.id,
    tenant_id=tenant.id,
)

Three entries. Debit sum = 1250. Credit sum = 1000 + 250 = 1250. Balanced. The customer's AR balance grows by 1250, revenue grows by 1000, and VAT payable grows by 250 — every krona accounted for.

When the customer pays:

await svc.create_transaction(
    entries=[
        {"entry_type": "debit",  "account_code": "1930", "amount": Decimal("1250")},  # Bank
        {"entry_type": "credit", "account_code": "1510", "amount": Decimal("1250")},  # AR
    ],
    description="Payment for invoice 2026-000123",
    reference_type="payment",
    reference_id=payment.id,
    tenant_id=tenant.id,
)

AR goes down (credit reduces an asset), bank goes up (debit increases an asset). The invoice is now fully accounted for across two transactions.

Querying balances

balance = await svc.get_balance(
    account_code="1930",
    period="2026-04",
    tenant_id=tenant.id,
)
# Returns Decimal — debit sum minus credit sum for the account in the period

For assets and expenses, a positive balance means "more debit than credit" — the account has value. For liabilities, revenue, and equity, a positive balance (in the normal sign sense) still means "more credit than debit", so some accounting systems flip signs when displaying those. Craft Easy returns the raw debit-minus-credit figure; flipping signs for display is a UI decision.

To get a trial balance (every account's balance for a period) loop over the chart:

balances = {
    acc.code: await svc.get_balance(acc.code, period="2026-04", tenant_id=tenant.id)
    for acc in await Account.find_all().to_list()
}

For large charts, run this through a single aggregation pipeline — the CRUD layer has a helper for it.

Posting entries

Fresh entries are unposted. An unposted entry can still be amended if the bookkeeper notices an error (a typo in the description, a wrong booking_date). Once the period is closed, entries are posted, and they become immutable.

await svc.post_entries(transaction_id)

Posting is a hard line. After posting, the service rejects any update to the entry; corrections require a new, reversing transaction. This matches how real accounting software works and gives you an unambiguous audit trail.

Typical posting schedule: every transaction is created unposted, a nightly job posts transactions older than 24 hours, and period-close posts the last unposted entries. Tenants who want faster posting can post on every create, at the cost of losing the 24-hour "oops" window.

SIE4 export

SIE4 is the Swedish accounting exchange format. Every Swedish accounting package — Fortnox, Visma, Bokio, SpeedLedger, you name it — can import SIE4, which means you can hand your tenant a single file and they are done for the period.

from craft_easy.core.bookkeeping.sie4 import generate_sie4

sie4_text = await generate_sie4(
    period="2026-04",
    tenant_id=tenant.id,
    accounts=None,  # defaults to every account touched in the period
)

The returned string is the full SIE4 file, ready to be written to disk or streamed to the caller. An export endpoint is available:

GET /bookkeeping/export/sie4?period=2026-04

After a successful export, the service sets exported_at and export_provider="sie4" on every entry in the period. That is your safety net against double-importing — the next export can either skip already-exported entries or include them with a warning, depending on how the caller wants to handle it.

Other exports

core/bookkeeping/export/ is designed to accept additional export plugins. Fortnox direct API export, Visma eEkonomi direct export, BigQuery mirrors for BI — each one is a separate module that implements the same "read entries, emit format" contract. SIE4 is the one built-in baseline because it works with every Swedish accounting tool without needing API credentials.

Routes

POST /bookkeeping/transactions              # create a transaction (validates balance)
GET  /bookkeeping/transactions/{txn_id}     # fetch all entries in a transaction
GET  /accounts                              # list chart of accounts
POST /accounts                              # add a custom account
GET  /accounts/{account_code}/balance       # query balance (params: period, tenant_id)
GET  /bookkeeping/export/sie4               # SIE4 export

The transaction endpoint is the main entry point — every other financial subsystem calls it either directly or via BookkeepingService. Batch variants (batch_create_transactions, batch_post) exist for end-of-day runners that process thousands of entries at once.

Why you want this turned on

A Craft Easy deployment without bookkeeping still functions — payments complete, invoices render, settlements go out — but every financial conversation becomes hearsay because there is no double-entry trail. Turning it on costs almost nothing (the default chart is seeded automatically, and the existing financial services call BookkeepingService for you) and gives you:

  • Trial balances by period, per tenant.
  • A one-click SIE4 export that any Swedish accountant can consume.
  • Reversing transactions for corrections, with a clean audit chain.
  • A single source of truth for "where is this krona?" questions that every finance team eventually asks.

Read Invoicing for the business objects that produce ledger entries, Settlements & Payouts for the period close that relies on balances, and Client Funds for the separate-bookkeeping track for money held on behalf of customers.