ledger
Double-entry bookkeeping primitive. Owns two event-sourced entities — the per-tenant account chart of accounts (read_ledger_accounts, self-referential via parentId, typed asset/liability/equity/income/expense) and immutable transaction journal entries (read_ledger_transactions) whose balanced posting lines are embedded as jsonb (Σ amount = 0, signed integer minor units). The account catalog uses the generic entity handlers (create, update, list, detail); create-transaction books a balanced entry (Σ=0 and ≥2 distinct accounts enforced at the command boundary, referential integrity against accounts checked) and reverse-transaction books its Storno mirror — there is deliberately NO transaction update/delete, so a posted entry is an immutable fact and the audit trail stays intact. Recurring schedules (read_ledger_schedules) layer Dauerauftrag templates on top — a schedule names debit/credit accounts, an amount and a monthly interval, from which the Soll (forecast) is a pure projection (projectSchedule) needing no bookings, and confirm-schedule-period materialises one period as an idempotent, reversal-aware balanced entry referencing scheduleReference(id, period); only confirming writes. Balances and reports (balance sheet, P&L, cashflow) derive as pure queries over the postings. Everything financial — banking, accounting, rent cashflow, credits, invoices — models as accounts + balanced transactions on top. Pin roles with createLedgerFeature({ roles }) or adopt the host model with { access }; pass { toggleable: { default: false } } to tier-gate the whole feature.
Quick example
Section titled “Quick example”From recipes-ledger-banking — the smallest working mount:
// Ledger Banking — money on accounts, moved by balanced transactions.//// Banking is the simplest double-entry case: each bank account is an `asset`,// and a transfer is ONE transaction that debits the receiving account (+) and// credits the sending one (−). The two lines sum to 0 — that is what makes the// entry "balanced". No balance is ever stored; it's a pure query over the// postings (ledger:query:report:balances).//// This recipe's integration test runs `bankingFlow` below against the real// dispatcher + DB.
// The bundle exports its qualified handler/query names — use them instead of// hardcoding the wire strings.import { LedgerHandlers, LedgerQueries } from "@cosmicdrift/kumiko-bundled-features/ledger";
// The minimal surface the flow needs from a host dispatcher. An app's client// satisfies this; the integration test adapts the test stack to it.export type LedgerClient = { write: <T>(type: string, payload: unknown) => Promise<T>; query: <T>(type: string, payload: unknown) => Promise<T>;};
type BalancesReport = { accounts: Array<{ id: string; name: string; balance: number }>; trialBalance: number;};
// Open an account in the tenant's chart of accounts → returns its id.async function openAccount(client: LedgerClient, name: string, type: string): Promise<string> { const { id } = await client.write<{ id: string }>(LedgerHandlers.createAccount, { name, type }); return id;}
// A transfer is one balanced transaction: debit the receiver (+amount), credit// the sender (−amount). Amounts are signed integer minor units (cents).async function transfer( client: LedgerClient, opts: { from: string; to: string; amount: number; description: string; date: string },): Promise<void> { await client.write(LedgerHandlers.createTransaction, { date: opts.date, description: opts.description, lines: [ { accountId: opts.to, amount: opts.amount }, { accountId: opts.from, amount: -opts.amount }, ], });}
// Fund a checking account from equity, move €200 of it to savings, then read// the closing balances back. Returns the balances + the trial balance (Σ of all// raw postings — 0 on a consistent ledger).export async function bankingFlow(client: LedgerClient) { const opening = await openAccount(client, "Anfangsbestand", "equity"); const checking = await openAccount(client, "Girokonto", "asset"); const savings = await openAccount(client, "Sparkonto", "asset");
await transfer(client, { from: opening, to: checking, amount: 100000, description: "Anfangsbestand Girokonto", date: "2026-01-01", });
await transfer(client, { from: checking, to: savings, amount: 20000, description: "Sparrate Januar", date: "2026-01-05", });
const report = await client.query<BalancesReport>(LedgerQueries.reportBalances, {}); const balanceOf = (id: string) => report.accounts.find((a) => a.id === id)?.balance ?? 0;
return { checking: balanceOf(checking), // 80000 — €1,000 funded − €200 moved out savings: balanceOf(savings), // 20000 — €200 moved in trialBalance: report.trialBalance, // 0 — the books are consistent };}📄 On GitHub: samples/recipes/ledger-banking/src/usage.ts
How it fits
Section titled “How it fits”What this feature needs to run (Requires, top) and the write commands it provides (Provides, bottom).
flowchart TB
n_ledger["ledger"]
subgraph how_provides["Provides"]
n_cmd_ledger_write_account_create(["create"])
n_cmd_ledger_write_account_update(["update"])
n_cmd_ledger_write_confirm_schedule_period(["confirm-schedule-period"])
n_cmd_ledger_write_create_transaction(["create-transaction"])
n_cmd_ledger_write_reverse_transaction(["reverse-transaction"])
n_cmd_ledger_write_schedule_create(["create"])
n_cmd_more(["+1 more"])
end
n_ledger --> n_cmd_ledger_write_account_create
n_ledger --> n_cmd_ledger_write_account_update
n_ledger --> n_cmd_ledger_write_confirm_schedule_period
n_ledger --> n_cmd_ledger_write_create_transaction
n_ledger --> n_cmd_ledger_write_reverse_transaction
n_ledger --> n_cmd_ledger_write_schedule_create
n_ledger --> n_cmd_more
Provides — write commands this feature registers (dispatch them through the command bus):
ledger:write:account:createledger:write:account:updateledger:write:confirm-schedule-periodledger:write:create-transactionledger:write:reverse-transactionledger:write:schedule:createledger:write:schedule:update
Getting started
Section titled “Getting started”Start with recipes-ledger-banking for a step-by-step walkthrough with runnable code and integration tests.
Dependencies
Section titled “Dependencies”- Requires: none
- Activation: always on (not toggleable)