Skip to content

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

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-statement before 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 liability account, 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 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
};
}

Both recipes use the same primitive — accounts + balanced transactions — but record different things:

ledger-bankingledger-invoicing
Accountsbank 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 recognisedn/aat invoice time (accrual)
Reports surfacedbalancesbalances + P&L
Terminal window
bun test src/__tests__/feature.integration.test.ts

📄 On GitHub: samples/recipes/ledger-invoicing