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 ofasset,liability,revenue,expense,equitydescription— optional longer descriptionis_system— true for built-in BAS accounts, false for tenant-added accountsis_active— soft-disable flagparent_code— optional parent account for groupingvat_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:
entriesis non-empty. A zero-entry transaction is a no-op; the service rejects it rather than silently ignoring it.- 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. - Each entry amount is positive. Entry type determines the side; a negative amount would be ambiguous.
- Account codes exist in the chart. An unknown code raises before any insert.
- Period is derived from
booking_dateif 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.
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:
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.