@cosmicdrift/kumiko-bundled-features 0.90.3 → 0.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { accountEntity, transactionEntity } from "./entity";
3
+
4
+ // Shared tables + executors for the account + transaction handlers. Built once
5
+ // (side-effect-free). The tables back the report query-handlers (selectMany over
6
+ // the entity projections — see handlers/reports.query.ts).
7
+ export const { table: accountTable, executor: accountExecutor } = createEntityExecutor(
8
+ "account",
9
+ accountEntity,
10
+ );
11
+ export const { table: transactionTable, executor: transactionExecutor } = createEntityExecutor(
12
+ "transaction",
13
+ transactionEntity,
14
+ );
@@ -0,0 +1,109 @@
1
+ // ledger — double-entry bookkeeping as a host-agnostic primitive.
2
+ //
3
+ // Two event-sourced entities:
4
+ // 1. `account` (read_ledger_accounts) — per-tenant chart of accounts (parentId tree).
5
+ // 2. `transaction` (read_ledger_transactions) — journal entries with embedded,
6
+ // balanced posting lines (Σ amount = 0). IMMUTABLE: no update/delete handler;
7
+ // corrections are reverse-transaction (Storno) entries.
8
+ //
9
+ // Everything financial — banking, accounting, rent cashflow, credits, invoices —
10
+ // is accounts + balanced transactions on top. Balances and reports (Bilanz, GuV,
11
+ // Cashflow) are pure queries over the postings (Phase 1), never stored state.
12
+ //
13
+ // Spec: kumiko-platform/docs/plans/ledger-feature.md
14
+
15
+ import {
16
+ type AccessRule,
17
+ defineEntityCreateHandler,
18
+ defineEntityDetailHandler,
19
+ defineEntityListHandler,
20
+ defineEntityUpdateHandler,
21
+ defineFeature,
22
+ type FeatureRegistrar,
23
+ } from "@cosmicdrift/kumiko-framework/engine";
24
+ import { DEFAULT_LEDGER_ACCESS, LEDGER_FEATURE_NAME } from "./constants";
25
+ import { accountEntity, transactionEntity } from "./entity";
26
+ import { createCreateTransactionHandler } from "./handlers/create-transaction.write";
27
+ import {
28
+ createBalanceSheetHandler,
29
+ createBalancesReportHandler,
30
+ createIncomeStatementHandler,
31
+ } from "./handlers/reports.query";
32
+ import { createReverseTransactionHandler } from "./handlers/reverse-transaction.write";
33
+
34
+ // Opt-in tier-gating (mirrors folders): when set, the feature declares itself
35
+ // r.toggleable so the dispatcher gate + tier-engine can switch the WHOLE ledger
36
+ // on/off per tenant. Use { default: false } for fail-closed gating.
37
+ type LedgerToggleable = { readonly default: boolean };
38
+
39
+ function registerLedger(
40
+ r: FeatureRegistrar<typeof LEDGER_FEATURE_NAME>,
41
+ access: AccessRule,
42
+ toggleable: LedgerToggleable | undefined,
43
+ ): void {
44
+ r.describe(
45
+ "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. 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.",
46
+ );
47
+ r.uiHints({
48
+ displayLabel: "Ledger",
49
+ category: "data",
50
+ recommended: false,
51
+ });
52
+
53
+ if (toggleable !== undefined) r.toggleable(toggleable);
54
+
55
+ r.entity("account", accountEntity);
56
+ r.entity("transaction", transactionEntity);
57
+
58
+ // Chart of accounts — plain CRUD, no custom logic. No delete in v1: removing an
59
+ // account that has postings would orphan them; a posting-aware guard lands with
60
+ // the postings projection (Phase 1).
61
+ r.writeHandler(defineEntityCreateHandler("account", accountEntity, { access }));
62
+ r.writeHandler(defineEntityUpdateHandler("account", accountEntity, { access }));
63
+ r.queryHandler(defineEntityListHandler("account", accountEntity, { access }));
64
+ r.queryHandler(defineEntityDetailHandler("account", accountEntity, { access }));
65
+
66
+ // Journal entries — immutable. Only create (balanced) + reverse (Storno). No
67
+ // update/delete handler is registered, so a posted entry cannot be mutated.
68
+ r.writeHandler(createCreateTransactionHandler(access));
69
+ r.writeHandler(createReverseTransactionHandler(access));
70
+ r.queryHandler(defineEntityListHandler("transaction", transactionEntity, { access }));
71
+ r.queryHandler(defineEntityDetailHandler("transaction", transactionEntity, { access }));
72
+
73
+ // Reports — pure aggregations over the posted entries (account balances,
74
+ // GuV, Bilanz with the current result folded into equity).
75
+ r.queryHandler(createBalancesReportHandler(access));
76
+ r.queryHandler(createIncomeStatementHandler(access));
77
+ r.queryHandler(createBalanceSheetHandler(access));
78
+ }
79
+
80
+ export const ledgerFeature = defineFeature(LEDGER_FEATURE_NAME, (r) =>
81
+ registerLedger(r, DEFAULT_LEDGER_ACCESS, undefined),
82
+ );
83
+
84
+ export type LedgerFeatureOptions = {
85
+ /** Access rule for all ledger write/read paths. Default { roles: ["TenantAdmin","TenantMember"] }.
86
+ * Takes precedence over `roles`. */
87
+ readonly access?: AccessRule;
88
+ /** Shorthand for { access: { roles } }. Ignored when `access` is set. */
89
+ readonly roles?: readonly string[];
90
+ /** Make the whole feature tier-gatable via the tier-engine. Use { default: false }
91
+ * for fail-closed gating. Omit to keep the ledger always-on (default). */
92
+ readonly toggleable?: LedgerToggleable;
93
+ };
94
+
95
+ function resolveAccess(opts: LedgerFeatureOptions): AccessRule {
96
+ if (opts.access !== undefined) return opts.access;
97
+ if (opts.roles !== undefined) return { roles: opts.roles };
98
+ return DEFAULT_LEDGER_ACCESS;
99
+ }
100
+
101
+ // Options wrapper. Without options returns the module-level singleton (no
102
+ // rebuild). access/roles/toggleable build a fresh feature-definition.
103
+ export function createLedgerFeature(opts: LedgerFeatureOptions = {}): typeof ledgerFeature {
104
+ if (opts.access === undefined && opts.roles === undefined && opts.toggleable === undefined) {
105
+ return ledgerFeature;
106
+ }
107
+ const access = resolveAccess(opts);
108
+ return defineFeature(LEDGER_FEATURE_NAME, (r) => registerLedger(r, access, opts.toggleable));
109
+ }
@@ -0,0 +1,44 @@
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
4
+ import { DEFAULT_LEDGER_ACCESS } from "../constants";
5
+ import { accountExecutor, transactionExecutor } from "../executor";
6
+ import { type CreateTransactionPayload, createTransactionPayloadSchema } from "../schemas";
7
+
8
+ // create-transaction — books a balanced journal entry. The Σ=0 and ≥2-accounts
9
+ // invariants are enforced by createTransactionPayloadSchema (command boundary).
10
+ // This handler adds referential integrity (every posting's account must exist —
11
+ // there is no FK in an event-sourced store) and assigns a fresh id. Entries are
12
+ // posted by default and immutable thereafter (no update/delete handler exists).
13
+ export function createCreateTransactionHandler(
14
+ access: AccessRule = DEFAULT_LEDGER_ACCESS,
15
+ ): WriteHandlerDef {
16
+ return {
17
+ name: "create-transaction",
18
+ schema: createTransactionPayloadSchema,
19
+ access,
20
+ handler: async (event, ctx) => {
21
+ const payload = event.payload as CreateTransactionPayload; // @cast-boundary engine-payload
22
+
23
+ for (const accountId of new Set(payload.lines.map((l) => l.accountId))) {
24
+ const account = await accountExecutor.detail({ id: accountId }, event.user, ctx.db);
25
+ if (!account) return writeFailure(new NotFoundError("account", accountId));
26
+ }
27
+
28
+ return transactionExecutor.create(
29
+ {
30
+ id: generateId(),
31
+ date: payload.date,
32
+ description: payload.description,
33
+ reference: payload.reference ?? null,
34
+ status: payload.status ?? "posted",
35
+ lines: payload.lines,
36
+ },
37
+ event.user,
38
+ ctx.db,
39
+ );
40
+ },
41
+ };
42
+ }
43
+
44
+ export const createTransactionHandler: WriteHandlerDef = createCreateTransactionHandler();
@@ -0,0 +1,79 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { AccessRule, QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { z } from "zod";
4
+ import { DEFAULT_LEDGER_ACCESS } from "../constants";
5
+ import { accountTable, transactionTable } from "../executor";
6
+ import {
7
+ accountBalances,
8
+ balanceSheet,
9
+ incomeStatement,
10
+ type LedgerAccount,
11
+ type LedgerEntry,
12
+ toAccounts,
13
+ toEntries,
14
+ } from "../reports";
15
+
16
+ // Reports take an optional reporting period. Balance-sheet is cumulative → pass
17
+ // { to: asOf }; income-statement is a period → { from, to }.
18
+ const periodSchema = z.object({
19
+ from: z.string().min(1).max(32).optional(),
20
+ to: z.string().min(1).max(32).optional(),
21
+ });
22
+
23
+ type QueryCtx = Parameters<QueryHandlerDef["handler"]>[1];
24
+
25
+ async function loadBooks(
26
+ ctx: QueryCtx,
27
+ ): Promise<{ accounts: LedgerAccount[]; entries: LedgerEntry[] }> {
28
+ const accountRows = await selectMany(ctx.db.raw, accountTable, { tenantId: ctx.user.tenantId });
29
+ const txRows = await selectMany(ctx.db.raw, transactionTable, { tenantId: ctx.user.tenantId });
30
+ return { accounts: toAccounts(accountRows), entries: toEntries(txRows) };
31
+ }
32
+
33
+ // Per-account balances (natural sign) + the trial balance (Σ raw = 0 invariant).
34
+ export function createBalancesReportHandler(
35
+ access: AccessRule = DEFAULT_LEDGER_ACCESS,
36
+ ): QueryHandlerDef {
37
+ return {
38
+ name: "report:balances",
39
+ schema: periodSchema,
40
+ access,
41
+ handler: async (query, ctx) => {
42
+ const period = periodSchema.parse(query);
43
+ const { accounts, entries } = await loadBooks(ctx);
44
+ return accountBalances(accounts, entries, period);
45
+ },
46
+ };
47
+ }
48
+
49
+ // GuV — income − expense over the period.
50
+ export function createIncomeStatementHandler(
51
+ access: AccessRule = DEFAULT_LEDGER_ACCESS,
52
+ ): QueryHandlerDef {
53
+ return {
54
+ name: "report:income-statement",
55
+ schema: periodSchema,
56
+ access,
57
+ handler: async (query, ctx) => {
58
+ const period = periodSchema.parse(query);
59
+ const { accounts, entries } = await loadBooks(ctx);
60
+ return incomeStatement(accounts, entries, period);
61
+ },
62
+ };
63
+ }
64
+
65
+ // Bilanz as of `to` — current result folded into equity so it balances.
66
+ export function createBalanceSheetHandler(
67
+ access: AccessRule = DEFAULT_LEDGER_ACCESS,
68
+ ): QueryHandlerDef {
69
+ return {
70
+ name: "report:balance-sheet",
71
+ schema: periodSchema,
72
+ access,
73
+ handler: async (query, ctx) => {
74
+ const period = periodSchema.parse(query);
75
+ const { accounts, entries } = await loadBooks(ctx);
76
+ return balanceSheet(accounts, entries, period);
77
+ },
78
+ };
79
+ }
@@ -0,0 +1,48 @@
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { generateId } from "@cosmicdrift/kumiko-framework/utils";
4
+ import { DEFAULT_LEDGER_ACCESS } from "../constants";
5
+ import { transactionExecutor } from "../executor";
6
+ import type { Posting } from "../schemas";
7
+ import { type ReverseTransactionPayload, reverseTransactionPayloadSchema } from "../schemas";
8
+
9
+ // reverse-transaction (Storno) — the ONLY correction path for a posted entry.
10
+ // Books the mirror image (every amount negated) as a new posted entry that
11
+ // references the original, leaving the original untouched. This is why
12
+ // transactions need no update/delete: the audit trail stays intact and the
13
+ // mirror entry still satisfies Σ=0 (negating a zero-sum set stays zero-sum).
14
+ export function createReverseTransactionHandler(
15
+ access: AccessRule = DEFAULT_LEDGER_ACCESS,
16
+ ): WriteHandlerDef {
17
+ return {
18
+ name: "reverse-transaction",
19
+ schema: reverseTransactionPayloadSchema,
20
+ access,
21
+ handler: async (event, ctx) => {
22
+ const payload = event.payload as ReverseTransactionPayload; // @cast-boundary engine-payload
23
+
24
+ const original = await transactionExecutor.detail({ id: payload.id }, event.user, ctx.db);
25
+ if (!original) return writeFailure(new NotFoundError("transaction", payload.id));
26
+
27
+ const originalLines = original["lines"] as readonly Posting[]; // @cast-boundary db-row
28
+ const lines = originalLines.map((l) => ({ accountId: l.accountId, amount: -l.amount }));
29
+
30
+ return transactionExecutor.create(
31
+ {
32
+ id: generateId(),
33
+ // Pass the original's date through untyped — create takes Record<string,
34
+ // unknown>, so no guess at the projection's date runtime type.
35
+ date: payload.date ?? original["date"],
36
+ description: payload.description ?? `Storno: ${String(original["description"])}`,
37
+ reference: payload.id,
38
+ status: "posted",
39
+ lines,
40
+ },
41
+ event.user,
42
+ ctx.db,
43
+ );
44
+ },
45
+ };
46
+ }
47
+
48
+ export const reverseTransactionHandler: WriteHandlerDef = createReverseTransactionHandler();
@@ -0,0 +1,52 @@
1
+ export {
2
+ ACCOUNT_TYPES,
3
+ type AccountType,
4
+ DEFAULT_LEDGER_ACCESS,
5
+ DEFAULT_LEDGER_ROLES,
6
+ LEDGER_FEATURE_NAME,
7
+ LedgerHandlers,
8
+ LedgerQueries,
9
+ TRANSACTION_STATUS,
10
+ type TransactionStatus,
11
+ } from "./constants";
12
+ export { accountEntity, transactionEntity } from "./entity";
13
+ export {
14
+ createLedgerFeature,
15
+ type LedgerFeatureOptions,
16
+ ledgerFeature,
17
+ } from "./feature";
18
+ export {
19
+ createCreateTransactionHandler,
20
+ createTransactionHandler,
21
+ } from "./handlers/create-transaction.write";
22
+ export {
23
+ createBalanceSheetHandler,
24
+ createBalancesReportHandler,
25
+ createIncomeStatementHandler,
26
+ } from "./handlers/reports.query";
27
+ export {
28
+ createReverseTransactionHandler,
29
+ reverseTransactionHandler,
30
+ } from "./handlers/reverse-transaction.write";
31
+ export {
32
+ type AccountBalance,
33
+ accountBalances,
34
+ type BalanceSheet,
35
+ type BalancesReport,
36
+ balanceSheet,
37
+ type IncomeStatement,
38
+ incomeStatement,
39
+ type LedgerAccount,
40
+ type LedgerEntry,
41
+ type Period,
42
+ rawBalances,
43
+ } from "./reports";
44
+ export {
45
+ accountTypeSchema,
46
+ type CreateTransactionPayload,
47
+ createTransactionPayloadSchema,
48
+ type Posting,
49
+ postingSchema,
50
+ type ReverseTransactionPayload,
51
+ reverseTransactionPayloadSchema,
52
+ } from "./schemas";
@@ -0,0 +1,164 @@
1
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
2
+ import type { AccountType } from "./constants";
3
+ import type { Posting } from "./schemas";
4
+
5
+ // Pure report aggregation over posted journal entries. No DB, no IO — the query
6
+ // handlers fetch accounts + transactions (selectMany) and feed them here, so the
7
+ // accounting logic is fully unit-testable. v1 aggregates over the transaction
8
+ // list directly (query-first); a flat read_ledger_postings projection is the
9
+ // documented materialisation upgrade for when this is measurably slow.
10
+
11
+ export type LedgerAccount = {
12
+ readonly id: string;
13
+ readonly name: string;
14
+ readonly type: AccountType;
15
+ };
16
+
17
+ export type LedgerEntry = {
18
+ readonly status: string;
19
+ readonly date: string;
20
+ readonly lines: readonly Posting[];
21
+ };
22
+
23
+ export type Period = { readonly from?: string; readonly to?: string };
24
+
25
+ // asset/expense are debit-normal (a positive raw balance = normal side);
26
+ // liability/equity/income are credit-normal (negative raw = normal side).
27
+ const DEBIT_NORMAL: ReadonlySet<AccountType> = new Set<AccountType>(["asset", "expense"]);
28
+
29
+ function naturalBalance(type: AccountType, raw: number): number {
30
+ const n = DEBIT_NORMAL.has(type) ? raw : -raw;
31
+ return n === 0 ? 0 : n; // avoid -0 for credit-normal accounts with a 0 balance
32
+ }
33
+
34
+ // jsonb lines surface as a parsed array or a JSON string depending on the driver
35
+ // path — normalise so the pure fns always get Posting[].
36
+ export function normalizeLines(raw: unknown): Posting[] {
37
+ const value = typeof raw === "string" ? parseJsonSafe<unknown>(raw, null) : raw;
38
+ return Array.isArray(value) ? (value as Posting[]) : []; // @cast-boundary db-row
39
+ }
40
+
41
+ export function toAccounts(rows: readonly Record<string, unknown>[]): LedgerAccount[] {
42
+ return rows.map((r) => ({
43
+ id: r["id"] as string, // @cast-boundary db-row
44
+ name: r["name"] as string, // @cast-boundary db-row
45
+ type: r["type"] as AccountType, // @cast-boundary db-row
46
+ }));
47
+ }
48
+
49
+ export function toEntries(rows: readonly Record<string, unknown>[]): LedgerEntry[] {
50
+ return rows.map((r) => ({
51
+ status: r["status"] as string, // @cast-boundary db-row
52
+ date: String(r["date"]),
53
+ lines: normalizeLines(r["lines"]),
54
+ }));
55
+ }
56
+
57
+ function inPeriod(date: string, period?: Period): boolean {
58
+ if (period?.from !== undefined && date < period.from) return false;
59
+ if (period?.to !== undefined && date > period.to) return false;
60
+ return true;
61
+ }
62
+
63
+ // Raw signed balance (Soll +, Haben −) per accountId over POSTED entries in the
64
+ // period. Draft entries never count toward the books.
65
+ export function rawBalances(entries: readonly LedgerEntry[], period?: Period): Map<string, number> {
66
+ const out = new Map<string, number>();
67
+ for (const e of entries) {
68
+ if (e.status !== "posted") continue;
69
+ if (!inPeriod(e.date, period)) continue;
70
+ for (const l of e.lines) {
71
+ out.set(l.accountId, (out.get(l.accountId) ?? 0) + l.amount);
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ export type AccountBalance = {
78
+ readonly id: string;
79
+ readonly name: string;
80
+ readonly type: AccountType;
81
+ readonly balance: number; // natural (sign-corrected by type)
82
+ };
83
+
84
+ export type BalancesReport = {
85
+ readonly accounts: readonly AccountBalance[];
86
+ // Σ of every RAW balance — 0 on a consistent ledger (the trial balance),
87
+ // because every entry sums to 0. The golden invariant.
88
+ readonly trialBalance: number;
89
+ };
90
+
91
+ export function accountBalances(
92
+ accounts: readonly LedgerAccount[],
93
+ entries: readonly LedgerEntry[],
94
+ period?: Period,
95
+ ): BalancesReport {
96
+ const raw = rawBalances(entries, period);
97
+ const rows = accounts.map((a) => ({
98
+ id: a.id,
99
+ name: a.name,
100
+ type: a.type,
101
+ balance: naturalBalance(a.type, raw.get(a.id) ?? 0),
102
+ }));
103
+ let trial = 0;
104
+ for (const v of raw.values()) trial += v;
105
+ return { accounts: rows, trialBalance: trial };
106
+ }
107
+
108
+ export type IncomeStatement = {
109
+ readonly income: number;
110
+ readonly expense: number;
111
+ readonly netIncome: number;
112
+ };
113
+
114
+ export function incomeStatement(
115
+ accounts: readonly LedgerAccount[],
116
+ entries: readonly LedgerEntry[],
117
+ period?: Period,
118
+ ): IncomeStatement {
119
+ const raw = rawBalances(entries, period);
120
+ let income = 0;
121
+ let expense = 0;
122
+ for (const a of accounts) {
123
+ const nat = naturalBalance(a.type, raw.get(a.id) ?? 0);
124
+ if (a.type === "income") income += nat;
125
+ else if (a.type === "expense") expense += nat;
126
+ }
127
+ return { income, expense, netIncome: income - expense };
128
+ }
129
+
130
+ export type BalanceSheet = {
131
+ readonly assets: number;
132
+ readonly liabilities: number;
133
+ readonly equity: number; // includes currentResult
134
+ readonly currentResult: number; // laufendes Ergebnis = net income to date
135
+ readonly balances: boolean; // assets === liabilities + equity
136
+ };
137
+
138
+ // Balance sheet as of a date — pass the period as { to: asOf } (cumulative). The
139
+ // current result (net income to date) is folded into equity as the "laufendes
140
+ // Ergebnis" line, so the statement balances WITHOUT a period-close: assets =
141
+ // liabilities + equity holds because the trial balance is 0.
142
+ export function balanceSheet(
143
+ accounts: readonly LedgerAccount[],
144
+ entries: readonly LedgerEntry[],
145
+ period?: Period,
146
+ ): BalanceSheet {
147
+ const raw = rawBalances(entries, period);
148
+ let assets = 0;
149
+ let liabilities = 0;
150
+ let equityBase = 0;
151
+ let income = 0;
152
+ let expense = 0;
153
+ for (const a of accounts) {
154
+ const nat = naturalBalance(a.type, raw.get(a.id) ?? 0);
155
+ if (a.type === "asset") assets += nat;
156
+ else if (a.type === "liability") liabilities += nat;
157
+ else if (a.type === "equity") equityBase += nat;
158
+ else if (a.type === "income") income += nat;
159
+ else if (a.type === "expense") expense += nat;
160
+ }
161
+ const currentResult = income - expense;
162
+ const equity = equityBase + currentResult;
163
+ return { assets, liabilities, equity, currentResult, balances: assets === liabilities + equity };
164
+ }
@@ -0,0 +1,51 @@
1
+ import { z } from "zod";
2
+ import { ACCOUNT_TYPES, TRANSACTION_STATUS } from "./constants";
3
+
4
+ // A posting line. amount is integer minor units (cents), SIGNED: Soll/debit > 0,
5
+ // Haben/credit < 0. Integer → the balance check is exact (=== 0), no float epsilon.
6
+ export const postingSchema = z.object({
7
+ accountId: z.string().min(1).max(64),
8
+ amount: z.number().int(),
9
+ });
10
+ export type Posting = z.infer<typeof postingSchema>;
11
+
12
+ const sumIsZero = (lines: readonly Posting[]): boolean =>
13
+ lines.reduce((s, l) => s + l.amount, 0) === 0;
14
+
15
+ const touchesTwoAccounts = (lines: readonly Posting[]): boolean =>
16
+ new Set(lines.map((l) => l.accountId)).size >= 2;
17
+
18
+ // create-transaction — a balanced journal entry. The two invariants of
19
+ // double-entry live here at the command boundary: every entry balances (Σ=0)
20
+ // and moves value between at least two distinct accounts.
21
+ export const createTransactionPayloadSchema = z
22
+ .object({
23
+ date: z.string().min(1).max(32), // ISO booking date
24
+ description: z.string().min(1).max(200),
25
+ reference: z.string().max(120).optional(),
26
+ // Defaults to "posted" in the handler. draft lifecycle (editable) lands with
27
+ // the Soll/recurring work — Phase 0 only ever creates posted entries.
28
+ status: z.enum(TRANSACTION_STATUS).optional(),
29
+ lines: z.array(postingSchema).min(2),
30
+ })
31
+ .refine((p) => sumIsZero(p.lines), {
32
+ message: "Transaction must balance: Σ of posting amounts must equal 0",
33
+ path: ["lines"],
34
+ })
35
+ .refine((p) => touchesTwoAccounts(p.lines), {
36
+ message: "Transaction must touch at least 2 distinct accounts",
37
+ path: ["lines"],
38
+ });
39
+ export type CreateTransactionPayload = z.infer<typeof createTransactionPayloadSchema>;
40
+
41
+ // reverse-transaction (Storno) — corrects a posted entry by booking its mirror,
42
+ // never by mutating it. Optional date/description for the reversing entry.
43
+ export const reverseTransactionPayloadSchema = z.object({
44
+ id: z.string().min(1).max(64),
45
+ date: z.string().min(1).max(32).optional(),
46
+ description: z.string().min(1).max(200).optional(),
47
+ });
48
+ export type ReverseTransactionPayload = z.infer<typeof reverseTransactionPayloadSchema>;
49
+
50
+ // Re-export so callers building accounts have the type vocabulary in one place.
51
+ export const accountTypeSchema = z.enum(ACCOUNT_TYPES);