Skip to content

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.

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

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):

Start with recipes-ledger-banking for a step-by-step walkthrough with runnable code and integration tests.

  • Requires: none
  • Activation: always on (not toggleable)