Ledger Banking
Model bank accounts and transfers on the ledger primitive. This is the
simplest double-entry case: bank accounts are assets (the opening balance is
seeded from an equity account), a transfer is one
balanced transaction, and a balance is a pure query over the postings —
never a stored field you keep in sync.
What it shows
Section titled “What it shows”A transfer moves money between two bank accounts as a single balanced transaction, and every balance is a pure query over the postings rather than a stored field you keep in sync.
- A transfer is one transaction, not two updates — moving €200 from
checking to savings is a single
ledger:write:create-transactionthat debits savings (+20000) and credits checking (−20000). The two lines sum to 0, which is what makes the entry balanced; the framework rejects anything else. - Balances are derived, never stored — there is no
balancecolumn on an account.ledger:query:report:balancessums the postings on the fly and applies each account’s natural sign (asset = debit-normal). - The trial balance is the safety net — because every entry sums to 0, the sum of all postings is always 0. The flow asserts it after the transfers: if it ever drifts, the books are inconsistent.
- Cents are signed integers — amounts are integer minor units;
+is a debit,−is a credit. No floats, no rounding drift.
The banking flow
Section titled “The banking flow”The flow below is embedded from usage.ts and is run end-to-end against the
real dispatcher + DB by this recipe’s integration test (the documented bankingFlow …):
// 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 };}Why no feature.ts?
Section titled “Why no feature.ts?”Unlike most recipes, banking mounts no host feature — the ledger bundle is
the host. Accounts and transactions are its own entities, so the recipe just
dispatches ledger:* handlers. Mount it once with createLedgerFeature() and
the whole chart of accounts + journal is available.
bun test src/__tests__/feature.integration.test.tsSource
Section titled “Source”📄 On GitHub: samples/recipes/ledger-banking