Ledger Invoicing
Model accrual invoicing (Rechnungswesen) on the ledger primitive. Issuing an
invoice and receiving its payment are two separate transactions, and
revenue is recognised when the invoice is issued — not when the cash
arrives. This is the difference between banking (cash moves) and accounting
(obligations are recorded as they arise).
What it shows
Section titled “What it shows”Accrual accounting recognises revenue when an invoice is issued, not when the cash arrives: the invoice and its payment are two separate transactions, VAT is held as a liability, and the receivable clears to zero once paid.
- Accrual: revenue at invoice time — issuing the invoice debits Forderungen
(accounts receivable) and credits Erlöse (income). The revenue is on the P&L
immediately; the flow asserts it via
report:income-statementbefore any payment exists. - VAT is a liability, not income — the gross €1,190 splits into €1,000 net
revenue and €190 Umsatzsteuer. The VAT is money you owe the tax office, so it
lands on a
liabilityaccount, never inflating revenue. - The receivable has a lifecycle — it’s raised at the gross amount on invoice and cleared to 0 on payment (debit Bank / credit Forderungen). An open receivable balance is your “unpaid invoices” total, with no extra bookkeeping.
- Reports re-derive, payment doesn’t double-count income — booking the payment moves cash and settles the receivable; it touches no income account, so revenue stays €1,000.
The invoicing flow
Section titled “The invoicing 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 invoicingFlow …):
// Ledger Invoicing — accrual accounting (Rechnungswesen).//// Issuing an invoice and receiving its payment are TWO separate transactions,// and revenue is recognised when the invoice is ISSUED (accrual), not when the// cash arrives://// 1. Issue: debit Forderungen (asset, accounts receivable) for the GROSS// amount, credit Erlöse (income) for the NET and Umsatzsteuer// (liability) for the VAT. Revenue hits the P&L now.// 2. Payment: debit Bank (asset), credit Forderungen — the receivable nets// back to 0 once the customer has paid.//// This recipe's integration test runs `invoicingFlow` below against the real// dispatcher + DB.
import { LedgerHandlers, LedgerQueries } from "@cosmicdrift/kumiko-bundled-features/ledger";
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;};
async function openAccount(client: LedgerClient, name: string, type: string): Promise<string> { const { id } = await client.write<{ id: string }>(LedgerHandlers.createAccount, { name, type }); return id;}
// Issue an invoice, observe that the revenue is already on the P&L before any// cash moves, then book the incoming payment. Returns the figures the test// asserts: accrual revenue, the cleared receivable, cash in, VAT owed.export async function invoicingFlow(client: LedgerClient) { const receivables = await openAccount(client, "Forderungen", "asset"); const revenue = await openAccount(client, "Erlöse", "income"); const vat = await openAccount(client, "Umsatzsteuer", "liability"); const bank = await openAccount(client, "Bank", "asset");
// Invoice: net €1,000 + 19% VAT = €1,190 gross. One balanced 3-line entry. await client.write(LedgerHandlers.createTransaction, { date: "2026-02-01", description: "Rechnung 2026-001", reference: "INV-2026-001", lines: [ { accountId: receivables, amount: 119000 }, // gross owed to us { accountId: revenue, amount: -100000 }, // net revenue → income { accountId: vat, amount: -19000 }, // VAT collected → liability ], });
// Revenue is recognised at invoice time — it's on the P&L before any payment. const beforePayment = await client.query<{ income: number; netIncome: number }>( LedgerQueries.reportIncomeStatement, {}, );
// Payment arrives → Bank up, receivable cleared. Income does NOT change here. await client.write(LedgerHandlers.createTransaction, { date: "2026-02-20", description: "Zahlung Rechnung 2026-001", reference: "INV-2026-001", lines: [ { accountId: bank, amount: 119000 }, { accountId: receivables, amount: -119000 }, ], });
// The payment moved cash and cleared the receivable — it touched no income // account, so the recognised revenue is unchanged. const afterPayment = await client.query<{ income: number }>( LedgerQueries.reportIncomeStatement, {}, );
const balances = await client.query<BalancesReport>(LedgerQueries.reportBalances, {}); const balanceOf = (id: string) => balances.accounts.find((a) => a.id === id)?.balance ?? 0;
return { revenueBeforePayment: beforePayment.income, // 100000 — accrual, cash-independent revenueAfterPayment: afterPayment.income, // 100000 — payment doesn't double-count receivablesAfterPayment: balanceOf(receivables), // 0 — invoice settled bank: balanceOf(bank), // 119000 — gross cash received vatOwed: balanceOf(vat), // 19000 — liability to the tax office trialBalance: balances.trialBalance, // 0 };}Banking vs. invoicing
Section titled “Banking vs. invoicing”Both recipes use the same primitive — accounts + balanced transactions — but record different things:
| ledger-banking | ledger-invoicing | |
|---|---|---|
| Accounts | bank accounts are assets (opening balance seeded from equity) | asset + income + liability |
| A transaction is… | a transfer (cash moves) | an obligation (invoice) or its settlement |
| Revenue recognised | n/a | at invoice time (accrual) |
| Reports surfaced | balances | balances + P&L |
bun test src/__tests__/feature.integration.test.tsSource
Section titled “Source”📄 On GitHub: samples/recipes/ledger-invoicing