@cosmicdrift/kumiko-bundled-features 0.90.2 → 0.91.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.
- package/package.json +7 -6
- package/src/ledger/__tests__/feature.test.ts +164 -0
- package/src/ledger/__tests__/ledger.integration.test.ts +300 -0
- package/src/ledger/__tests__/reports.test.ts +122 -0
- package/src/ledger/constants.ts +47 -0
- package/src/ledger/entity.ts +54 -0
- package/src/ledger/executor.ts +14 -0
- package/src/ledger/feature.ts +109 -0
- package/src/ledger/handlers/create-transaction.write.ts +44 -0
- package/src/ledger/handlers/reports.query.ts +79 -0
- package/src/ledger/handlers/reverse-transaction.write.ts +48 -0
- package/src/ledger/index.ts +52 -0
- package/src/ledger/reports.ts +164 -0
- package/src/ledger/schemas.ts +51 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.91.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"./folders": "./src/folders/index.ts",
|
|
36
36
|
"./folders/web": "./src/folders/web/index.ts",
|
|
37
37
|
"./folders-user-data": "./src/folders-user-data/index.ts",
|
|
38
|
+
"./ledger": "./src/ledger/index.ts",
|
|
38
39
|
"./billing-foundation": "./src/billing-foundation/index.ts",
|
|
39
40
|
"./subscription-stripe": "./src/subscription-stripe/index.ts",
|
|
40
41
|
"./subscription-mollie": "./src/subscription-mollie/index.ts",
|
|
@@ -89,11 +90,11 @@
|
|
|
89
90
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
90
91
|
},
|
|
91
92
|
"dependencies": {
|
|
92
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
93
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
94
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
95
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
96
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
93
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.91.0",
|
|
94
|
+
"@cosmicdrift/kumiko-framework": "0.91.0",
|
|
95
|
+
"@cosmicdrift/kumiko-headless": "0.91.0",
|
|
96
|
+
"@cosmicdrift/kumiko-renderer": "0.91.0",
|
|
97
|
+
"@cosmicdrift/kumiko-renderer-web": "0.91.0",
|
|
97
98
|
"@mollie/api-client": "^4.5.0",
|
|
98
99
|
"@node-rs/argon2": "^2.0.2",
|
|
99
100
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { DEFAULT_LEDGER_ROLES } from "../constants";
|
|
3
|
+
import { createLedgerFeature } from "../feature";
|
|
4
|
+
import { createTransactionPayloadSchema, reverseTransactionPayloadSchema } from "../schemas";
|
|
5
|
+
|
|
6
|
+
// Unit tests: feature-shape, role-options, and the two double-entry invariants
|
|
7
|
+
// that live in the command schema (balance + ≥2 accounts). The ES-loop behaviour
|
|
8
|
+
// (posting projection, Storno, tenant-isolation) needs a real stack →
|
|
9
|
+
// ledger.integration.test.ts.
|
|
10
|
+
|
|
11
|
+
function writeAccess(
|
|
12
|
+
feature: ReturnType<typeof createLedgerFeature>,
|
|
13
|
+
nameMatch: string,
|
|
14
|
+
): readonly string[] {
|
|
15
|
+
const entry = Object.entries(feature.writeHandlers).find(([qn]) => qn.includes(nameMatch));
|
|
16
|
+
if (!entry) throw new Error(`handler ${nameMatch} not registered`);
|
|
17
|
+
const access = entry[1].access;
|
|
18
|
+
if (!access || !("roles" in access)) throw new Error(`handler ${nameMatch} has no roles`);
|
|
19
|
+
return access.roles;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe("createLedgerFeature shape", () => {
|
|
23
|
+
test("registers account + transaction entities, 4 write-handlers, 4 query-handlers", () => {
|
|
24
|
+
const feature = createLedgerFeature();
|
|
25
|
+
|
|
26
|
+
expect(Object.keys(feature.entities ?? {})).toEqual(
|
|
27
|
+
expect.arrayContaining(["account", "transaction"]),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(Object.keys(feature.writeHandlers)).toEqual(
|
|
31
|
+
expect.arrayContaining([
|
|
32
|
+
expect.stringMatching(/account:create/),
|
|
33
|
+
expect.stringMatching(/account:update/),
|
|
34
|
+
expect.stringMatching(/create-transaction/),
|
|
35
|
+
expect.stringMatching(/reverse-transaction/),
|
|
36
|
+
]),
|
|
37
|
+
);
|
|
38
|
+
expect(Object.keys(feature.writeHandlers)).toHaveLength(4);
|
|
39
|
+
|
|
40
|
+
expect(Object.keys(feature.queryHandlers)).toEqual(
|
|
41
|
+
expect.arrayContaining([
|
|
42
|
+
expect.stringMatching(/account:list/),
|
|
43
|
+
expect.stringMatching(/account:detail/),
|
|
44
|
+
expect.stringMatching(/transaction:list/),
|
|
45
|
+
expect.stringMatching(/transaction:detail/),
|
|
46
|
+
expect.stringMatching(/report:balances/),
|
|
47
|
+
expect.stringMatching(/report:income-statement/),
|
|
48
|
+
expect.stringMatching(/report:balance-sheet/),
|
|
49
|
+
]),
|
|
50
|
+
);
|
|
51
|
+
expect(Object.keys(feature.queryHandlers)).toHaveLength(7);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("transaction is immutable: NO update/delete handler is registered", () => {
|
|
55
|
+
const feature = createLedgerFeature();
|
|
56
|
+
const writeQns = Object.keys(feature.writeHandlers);
|
|
57
|
+
expect(writeQns.some((qn) => qn.includes("transaction:update"))).toBe(false);
|
|
58
|
+
expect(writeQns.some((qn) => qn.includes("transaction:delete"))).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("account has no delete handler in v1 (postings-aware guard deferred)", () => {
|
|
62
|
+
const feature = createLedgerFeature();
|
|
63
|
+
expect(Object.keys(feature.writeHandlers).some((qn) => qn.includes("account:delete"))).toBe(
|
|
64
|
+
false,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("createLedgerFeature access-options", () => {
|
|
70
|
+
test("without options: singleton with default roles on every path", () => {
|
|
71
|
+
const feature = createLedgerFeature();
|
|
72
|
+
expect(feature).toBe(createLedgerFeature());
|
|
73
|
+
for (const path of ["account:create", "create-transaction", "reverse-transaction"]) {
|
|
74
|
+
expect(writeAccess(feature, path)).toEqual([...DEFAULT_LEDGER_ROLES]);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("roles option overrides every write-path", () => {
|
|
79
|
+
const feature = createLedgerFeature({ roles: ["Accountant"] });
|
|
80
|
+
expect(writeAccess(feature, "create-transaction")).toEqual(["Accountant"]);
|
|
81
|
+
expect(writeAccess(feature, "account:create")).toEqual(["Accountant"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("toggleable:{default:false} makes the feature tier-gatable, fail-closed", () => {
|
|
85
|
+
const feature = createLedgerFeature({ toggleable: { default: false } });
|
|
86
|
+
expect(feature.toggleableDefault).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("createTransactionPayloadSchema — double-entry invariants", () => {
|
|
91
|
+
const ok = {
|
|
92
|
+
date: "2026-01-15",
|
|
93
|
+
description: "Miete Januar",
|
|
94
|
+
lines: [
|
|
95
|
+
{ accountId: "acc-bank", amount: 100000 },
|
|
96
|
+
{ accountId: "acc-rent-income", amount: -100000 },
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
test("accepts a balanced entry across two accounts", () => {
|
|
101
|
+
expect(createTransactionPayloadSchema.safeParse(ok).success).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("rejects an unbalanced entry (Σ ≠ 0)", () => {
|
|
105
|
+
const bad = {
|
|
106
|
+
...ok,
|
|
107
|
+
lines: [
|
|
108
|
+
{ accountId: "acc-bank", amount: 100000 },
|
|
109
|
+
{ accountId: "acc-rent-income", amount: -90000 },
|
|
110
|
+
],
|
|
111
|
+
};
|
|
112
|
+
expect(createTransactionPayloadSchema.safeParse(bad).success).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("rejects fewer than 2 lines", () => {
|
|
116
|
+
const bad = { ...ok, lines: [{ accountId: "acc-bank", amount: 0 }] };
|
|
117
|
+
expect(createTransactionPayloadSchema.safeParse(bad).success).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("rejects 2 balanced lines on the SAME account (no value moved)", () => {
|
|
121
|
+
const bad = {
|
|
122
|
+
...ok,
|
|
123
|
+
lines: [
|
|
124
|
+
{ accountId: "acc-bank", amount: 100000 },
|
|
125
|
+
{ accountId: "acc-bank", amount: -100000 },
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
expect(createTransactionPayloadSchema.safeParse(bad).success).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("rejects non-integer amounts (cents are integers)", () => {
|
|
132
|
+
const bad = {
|
|
133
|
+
...ok,
|
|
134
|
+
lines: [
|
|
135
|
+
{ accountId: "acc-bank", amount: 1000.5 },
|
|
136
|
+
{ accountId: "acc-rent-income", amount: -1000.5 },
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
expect(createTransactionPayloadSchema.safeParse(bad).success).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("accepts a balanced split across three accounts (e.g. credit rate)", () => {
|
|
143
|
+
const split = {
|
|
144
|
+
date: "2026-01-01",
|
|
145
|
+
description: "Kreditrate (Zins + Tilgung)",
|
|
146
|
+
lines: [
|
|
147
|
+
{ accountId: "acc-bank", amount: -150000 }, // Zahlung raus
|
|
148
|
+
{ accountId: "acc-interest-expense", amount: 50000 }, // Zins
|
|
149
|
+
{ accountId: "acc-loan-liability", amount: 100000 }, // Tilgung mindert Schuld
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
expect(createTransactionPayloadSchema.safeParse(split).success).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("reverseTransactionPayloadSchema", () => {
|
|
157
|
+
test("accepts an id-only Storno request", () => {
|
|
158
|
+
expect(reverseTransactionPayloadSchema.safeParse({ id: "tx-1" }).success).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("rejects a missing id", () => {
|
|
162
|
+
expect(reverseTransactionPayloadSchema.safeParse({}).success).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// Full-stack integration for the ledger bundle. Drives account + transaction
|
|
2
|
+
// through the real dispatcher + entity-projection + DB. Proves the double-entry
|
|
3
|
+
// primitive end-to-end:
|
|
4
|
+
// - create-transaction books a balanced entry; the embedded lines round-trip
|
|
5
|
+
// - an unbalanced entry (Σ ≠ 0) is rejected at the command boundary (400)
|
|
6
|
+
// - a posting to a non-existent account is rejected (404, referential integrity)
|
|
7
|
+
// - reverse-transaction books the negated mirror referencing the original
|
|
8
|
+
// - the TRIAL BALANCE (Σ of every posting amount) is 0 — the invariant that
|
|
9
|
+
// always holds, unlike the balance-sheet equation (which needs period close)
|
|
10
|
+
// - multi-tenant isolation: each tenant has its own books
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
13
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
14
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
|
+
import {
|
|
16
|
+
createTestUser,
|
|
17
|
+
setupTestStack,
|
|
18
|
+
type TestStack,
|
|
19
|
+
unsafeCreateEntityTable,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
import { type AccountType, LedgerHandlers, LedgerQueries } from "../constants";
|
|
22
|
+
import { accountEntity, transactionEntity } from "../entity";
|
|
23
|
+
import { createLedgerFeature } from "../feature";
|
|
24
|
+
import type { Posting } from "../schemas";
|
|
25
|
+
|
|
26
|
+
const ledgerFeature = createLedgerFeature();
|
|
27
|
+
|
|
28
|
+
let stack: TestStack;
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
stack = await setupTestStack({ features: [ledgerFeature] });
|
|
32
|
+
await unsafeCreateEntityTable(stack.db, accountEntity);
|
|
33
|
+
await unsafeCreateEntityTable(stack.db, transactionEntity);
|
|
34
|
+
await createEventsTable(stack.db);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterAll(async () => {
|
|
38
|
+
await stack.cleanup();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
beforeEach(async () => {
|
|
42
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
43
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_accounts");
|
|
44
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_transactions");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
48
|
+
const otherTenant = createTestUser({
|
|
49
|
+
roles: ["TenantAdmin"],
|
|
50
|
+
tenantId: "00000000-0000-4000-8000-0000000000aa",
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function createAccount(name: string, type: AccountType, user = admin): Promise<string> {
|
|
54
|
+
const acc = await stack.http.writeOk<{ id: string }>(
|
|
55
|
+
LedgerHandlers.createAccount,
|
|
56
|
+
{ name, type },
|
|
57
|
+
user,
|
|
58
|
+
);
|
|
59
|
+
return acc.id;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function createTransaction(
|
|
63
|
+
lines: readonly Posting[],
|
|
64
|
+
opts: { date?: string; description?: string } = {},
|
|
65
|
+
user = admin,
|
|
66
|
+
): Promise<{ id: string }> {
|
|
67
|
+
return stack.http.writeOk<{ id: string }>(
|
|
68
|
+
LedgerHandlers.createTransaction,
|
|
69
|
+
{
|
|
70
|
+
date: opts.date ?? "2026-01-15",
|
|
71
|
+
description: opts.description ?? "Test entry",
|
|
72
|
+
lines,
|
|
73
|
+
},
|
|
74
|
+
user,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function listTransactions(user = admin): Promise<Array<Record<string, unknown>>> {
|
|
79
|
+
const res = await stack.http.queryOk<{ rows: Array<Record<string, unknown>> }>(
|
|
80
|
+
LedgerQueries.transactionList,
|
|
81
|
+
{},
|
|
82
|
+
user,
|
|
83
|
+
);
|
|
84
|
+
return res.rows;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// jsonb may surface as a parsed array or a string depending on the driver path —
|
|
88
|
+
// normalise so the assertions don't depend on it.
|
|
89
|
+
function linesOf(row: Record<string, unknown>): Posting[] {
|
|
90
|
+
const raw = row["lines"];
|
|
91
|
+
return (typeof raw === "string" ? JSON.parse(raw) : raw) as Posting[]; // @cast-boundary db-row
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The golden double-entry invariant: every entry sums to 0, so the sum over ALL
|
|
95
|
+
// postings of ALL transactions is 0.
|
|
96
|
+
function trialBalance(rows: Array<Record<string, unknown>>): number {
|
|
97
|
+
return rows.reduce((s, r) => s + linesOf(r).reduce((t, l) => t + l.amount, 0), 0);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe("ledger integration — create-transaction (balance invariant)", () => {
|
|
101
|
+
test("books a balanced entry; lines round-trip; status posted", async () => {
|
|
102
|
+
const bank = await createAccount("Bank", "asset");
|
|
103
|
+
const rent = await createAccount("Mieterträge", "income");
|
|
104
|
+
|
|
105
|
+
const tx = await createTransaction(
|
|
106
|
+
[
|
|
107
|
+
{ accountId: bank, amount: 100000 },
|
|
108
|
+
{ accountId: rent, amount: -100000 },
|
|
109
|
+
],
|
|
110
|
+
{ description: "Miete Januar" },
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const rows = await listTransactions();
|
|
114
|
+
expect(rows).toHaveLength(1);
|
|
115
|
+
expect(rows[0]?.["id"]).toBe(tx.id);
|
|
116
|
+
expect(rows[0]?.["status"]).toBe("posted");
|
|
117
|
+
expect(rows[0]?.["description"]).toBe("Miete Januar");
|
|
118
|
+
expect(linesOf(rows[0] as Record<string, unknown>)).toHaveLength(2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("rejects an unbalanced entry (Σ ≠ 0) — no transaction written", async () => {
|
|
122
|
+
const bank = await createAccount("Bank", "asset");
|
|
123
|
+
const rent = await createAccount("Mieterträge", "income");
|
|
124
|
+
|
|
125
|
+
const err = await stack.http.writeErr(
|
|
126
|
+
LedgerHandlers.createTransaction,
|
|
127
|
+
{
|
|
128
|
+
date: "2026-01-15",
|
|
129
|
+
description: "schief",
|
|
130
|
+
lines: [
|
|
131
|
+
{ accountId: bank, amount: 100000 },
|
|
132
|
+
{ accountId: rent, amount: -90000 },
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
admin,
|
|
136
|
+
);
|
|
137
|
+
expect(err.httpStatus).toBe(400);
|
|
138
|
+
expect(await listTransactions()).toHaveLength(0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("rejects a posting to a non-existent account (404) — referential integrity", async () => {
|
|
142
|
+
const bank = await createAccount("Bank", "asset");
|
|
143
|
+
|
|
144
|
+
const err = await stack.http.writeErr(
|
|
145
|
+
LedgerHandlers.createTransaction,
|
|
146
|
+
{
|
|
147
|
+
date: "2026-01-15",
|
|
148
|
+
description: "ghost",
|
|
149
|
+
lines: [
|
|
150
|
+
{ accountId: bank, amount: 100000 },
|
|
151
|
+
{ accountId: "00000000-0000-4000-8000-00000000dead", amount: -100000 },
|
|
152
|
+
],
|
|
153
|
+
},
|
|
154
|
+
admin,
|
|
155
|
+
);
|
|
156
|
+
expect(err.httpStatus).toBe(404);
|
|
157
|
+
expect(await listTransactions()).toHaveLength(0);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("ledger integration — reverse-transaction (Storno)", () => {
|
|
162
|
+
test("books the negated mirror referencing the original; both remain", async () => {
|
|
163
|
+
const bank = await createAccount("Bank", "asset");
|
|
164
|
+
const rent = await createAccount("Mieterträge", "income");
|
|
165
|
+
|
|
166
|
+
const tx = await createTransaction(
|
|
167
|
+
[
|
|
168
|
+
{ accountId: bank, amount: 100000 },
|
|
169
|
+
{ accountId: rent, amount: -100000 },
|
|
170
|
+
],
|
|
171
|
+
{ description: "Miete" },
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
await stack.http.writeOk(LedgerHandlers.reverseTransaction, { id: tx.id }, admin);
|
|
175
|
+
|
|
176
|
+
const rows = await listTransactions();
|
|
177
|
+
expect(rows).toHaveLength(2);
|
|
178
|
+
|
|
179
|
+
const storno = rows.find((r) => r["reference"] === tx.id);
|
|
180
|
+
expect(storno).toBeDefined();
|
|
181
|
+
const stornoLines = linesOf(storno as Record<string, unknown>);
|
|
182
|
+
expect(stornoLines.find((l) => l.accountId === bank)?.amount).toBe(-100000);
|
|
183
|
+
expect(stornoLines.find((l) => l.accountId === rent)?.amount).toBe(100000);
|
|
184
|
+
|
|
185
|
+
// Original + Storno cancel → books net to zero.
|
|
186
|
+
expect(trialBalance(rows)).toBe(0);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("ledger integration — trial balance (golden invariant)", () => {
|
|
191
|
+
test("Σ of every posting across all entries is 0", async () => {
|
|
192
|
+
const bank = await createAccount("Bank", "asset");
|
|
193
|
+
const rent = await createAccount("Mieterträge", "income");
|
|
194
|
+
const expense = await createAccount("Aufwand", "expense");
|
|
195
|
+
|
|
196
|
+
await createTransaction([
|
|
197
|
+
{ accountId: bank, amount: 100000 },
|
|
198
|
+
{ accountId: rent, amount: -100000 },
|
|
199
|
+
]);
|
|
200
|
+
// Credit rate split: Zins (expense) + Tilgung … here just expense vs bank.
|
|
201
|
+
await createTransaction([
|
|
202
|
+
{ accountId: expense, amount: 30000 },
|
|
203
|
+
{ accountId: bank, amount: -30000 },
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
expect(trialBalance(await listTransactions())).toBe(0);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("ledger integration — multi-tenant isolation", () => {
|
|
211
|
+
test("tenant B sees neither tenant A's accounts nor transactions", async () => {
|
|
212
|
+
const bank = await createAccount("Bank", "asset", admin);
|
|
213
|
+
const rent = await createAccount("Mieterträge", "income", admin);
|
|
214
|
+
await createTransaction(
|
|
215
|
+
[
|
|
216
|
+
{ accountId: bank, amount: 100000 },
|
|
217
|
+
{ accountId: rent, amount: -100000 },
|
|
218
|
+
],
|
|
219
|
+
{},
|
|
220
|
+
admin,
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
expect(await listTransactions(otherTenant)).toHaveLength(0);
|
|
224
|
+
const otherAccounts = await stack.http.queryOk<{ rows: unknown[] }>(
|
|
225
|
+
LedgerQueries.accountList,
|
|
226
|
+
{},
|
|
227
|
+
otherTenant,
|
|
228
|
+
);
|
|
229
|
+
expect(otherAccounts.rows).toHaveLength(0);
|
|
230
|
+
|
|
231
|
+
expect(await listTransactions(admin)).toHaveLength(1);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("ledger integration — reports (end-to-end)", () => {
|
|
236
|
+
// Books: capital injection + rent income + an expense.
|
|
237
|
+
async function seedBooks() {
|
|
238
|
+
const bank = await createAccount("Bank", "asset");
|
|
239
|
+
const equity = await createAccount("Eigenkapital", "equity");
|
|
240
|
+
const rent = await createAccount("Mieterträge", "income");
|
|
241
|
+
const expense = await createAccount("Aufwand", "expense");
|
|
242
|
+
await createTransaction(
|
|
243
|
+
[
|
|
244
|
+
{ accountId: bank, amount: 500000 },
|
|
245
|
+
{ accountId: equity, amount: -500000 },
|
|
246
|
+
],
|
|
247
|
+
{ date: "2026-01-01", description: "Kapital" },
|
|
248
|
+
);
|
|
249
|
+
await createTransaction(
|
|
250
|
+
[
|
|
251
|
+
{ accountId: bank, amount: 100000 },
|
|
252
|
+
{ accountId: rent, amount: -100000 },
|
|
253
|
+
],
|
|
254
|
+
{ date: "2026-01-15", description: "Miete" },
|
|
255
|
+
);
|
|
256
|
+
await createTransaction(
|
|
257
|
+
[
|
|
258
|
+
{ accountId: expense, amount: 30000 },
|
|
259
|
+
{ accountId: bank, amount: -30000 },
|
|
260
|
+
],
|
|
261
|
+
{ date: "2026-01-20", description: "Aufwand" },
|
|
262
|
+
);
|
|
263
|
+
return { bank };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
test("balances report: natural balances + trial balance 0", async () => {
|
|
267
|
+
const { bank } = await seedBooks();
|
|
268
|
+
const r = await stack.http.queryOk<{
|
|
269
|
+
accounts: Array<{ id: string; balance: number }>;
|
|
270
|
+
trialBalance: number;
|
|
271
|
+
}>(LedgerQueries.reportBalances, {}, admin);
|
|
272
|
+
expect(r.trialBalance).toBe(0);
|
|
273
|
+
expect(r.accounts.find((a) => a.id === bank)?.balance).toBe(570000);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("income statement: income − expense = net income", async () => {
|
|
277
|
+
await seedBooks();
|
|
278
|
+
const r = await stack.http.queryOk<{
|
|
279
|
+
income: number;
|
|
280
|
+
expense: number;
|
|
281
|
+
netIncome: number;
|
|
282
|
+
}>(LedgerQueries.reportIncomeStatement, {}, admin);
|
|
283
|
+
expect(r).toEqual({ income: 100000, expense: 30000, netIncome: 70000 });
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("balance sheet balances with the current result in equity", async () => {
|
|
287
|
+
await seedBooks();
|
|
288
|
+
const r = await stack.http.queryOk<{
|
|
289
|
+
assets: number;
|
|
290
|
+
liabilities: number;
|
|
291
|
+
equity: number;
|
|
292
|
+
currentResult: number;
|
|
293
|
+
balances: boolean;
|
|
294
|
+
}>(LedgerQueries.reportBalanceSheet, {}, admin);
|
|
295
|
+
expect(r.assets).toBe(570000);
|
|
296
|
+
expect(r.currentResult).toBe(70000);
|
|
297
|
+
expect(r.equity).toBe(570000);
|
|
298
|
+
expect(r.balances).toBe(true);
|
|
299
|
+
});
|
|
300
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
accountBalances,
|
|
4
|
+
balanceSheet,
|
|
5
|
+
incomeStatement,
|
|
6
|
+
type LedgerAccount,
|
|
7
|
+
type LedgerEntry,
|
|
8
|
+
} from "../reports";
|
|
9
|
+
|
|
10
|
+
// Pure report math — no DB. A small but complete set of books:
|
|
11
|
+
// 1. owner capital: bank +500000 / equity −500000
|
|
12
|
+
// 2. rent income: bank +100000 / rent −100000
|
|
13
|
+
// 3. an expense: expense +30000 / bank −30000
|
|
14
|
+
const accounts: LedgerAccount[] = [
|
|
15
|
+
{ id: "bank", name: "Bank", type: "asset" },
|
|
16
|
+
{ id: "equity", name: "Eigenkapital", type: "equity" },
|
|
17
|
+
{ id: "rent", name: "Mieterträge", type: "income" },
|
|
18
|
+
{ id: "expense", name: "Aufwand", type: "expense" },
|
|
19
|
+
{ id: "loan", name: "Darlehen", type: "liability" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const posted: LedgerEntry[] = [
|
|
23
|
+
{
|
|
24
|
+
status: "posted",
|
|
25
|
+
date: "2026-01-01",
|
|
26
|
+
lines: [
|
|
27
|
+
{ accountId: "bank", amount: 500000 },
|
|
28
|
+
{ accountId: "equity", amount: -500000 },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
status: "posted",
|
|
33
|
+
date: "2026-01-15",
|
|
34
|
+
lines: [
|
|
35
|
+
{ accountId: "bank", amount: 100000 },
|
|
36
|
+
{ accountId: "rent", amount: -100000 },
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
status: "posted",
|
|
41
|
+
date: "2026-01-20",
|
|
42
|
+
lines: [
|
|
43
|
+
{ accountId: "expense", amount: 30000 },
|
|
44
|
+
{ accountId: "bank", amount: -30000 },
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function balanceOf(report: ReturnType<typeof accountBalances>, id: string): number {
|
|
50
|
+
return report.accounts.find((a) => a.id === id)?.balance ?? Number.NaN;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe("accountBalances — natural balances + trial balance", () => {
|
|
54
|
+
test("natural balances are sign-corrected by account type", () => {
|
|
55
|
+
const r = accountBalances(accounts, posted);
|
|
56
|
+
expect(balanceOf(r, "bank")).toBe(570000); // asset, debit-normal
|
|
57
|
+
expect(balanceOf(r, "equity")).toBe(500000); // equity, credit-normal → flipped
|
|
58
|
+
expect(balanceOf(r, "rent")).toBe(100000); // income, credit-normal → flipped
|
|
59
|
+
expect(balanceOf(r, "expense")).toBe(30000); // expense, debit-normal
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("an account with no postings has a 0 balance", () => {
|
|
63
|
+
expect(balanceOf(accountBalances(accounts, posted), "loan")).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("the trial balance (Σ raw) is 0 — the golden invariant", () => {
|
|
67
|
+
expect(accountBalances(accounts, posted).trialBalance).toBe(0);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("draft entries never count toward the books", () => {
|
|
71
|
+
const withDraft: LedgerEntry[] = [
|
|
72
|
+
...posted,
|
|
73
|
+
{
|
|
74
|
+
status: "draft",
|
|
75
|
+
date: "2026-02-01",
|
|
76
|
+
lines: [
|
|
77
|
+
{ accountId: "bank", amount: 999999 },
|
|
78
|
+
{ accountId: "rent", amount: -999999 },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
];
|
|
82
|
+
expect(balanceOf(accountBalances(accounts, withDraft), "bank")).toBe(570000);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("period filter excludes entries outside [from, to]", () => {
|
|
86
|
+
// Only the rent entry (2026-01-15) falls inside.
|
|
87
|
+
const r = accountBalances(accounts, posted, { from: "2026-01-10", to: "2026-01-18" });
|
|
88
|
+
expect(balanceOf(r, "bank")).toBe(100000);
|
|
89
|
+
expect(balanceOf(r, "rent")).toBe(100000);
|
|
90
|
+
expect(r.trialBalance).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("incomeStatement (GuV)", () => {
|
|
95
|
+
test("income − expense = net income", () => {
|
|
96
|
+
expect(incomeStatement(accounts, posted)).toEqual({
|
|
97
|
+
income: 100000,
|
|
98
|
+
expense: 30000,
|
|
99
|
+
netIncome: 70000,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("balanceSheet (Bilanz)", () => {
|
|
105
|
+
test("balances with the current result folded into equity", () => {
|
|
106
|
+
const b = balanceSheet(accounts, posted);
|
|
107
|
+
expect(b.assets).toBe(570000);
|
|
108
|
+
expect(b.liabilities).toBe(0);
|
|
109
|
+
expect(b.currentResult).toBe(70000); // laufendes Ergebnis
|
|
110
|
+
expect(b.equity).toBe(570000); // 500000 capital + 70000 result
|
|
111
|
+
expect(b.balances).toBe(true); // assets === liabilities + equity
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("balances even before any income/expense is closed (current result line)", () => {
|
|
115
|
+
// Just the capital injection → assets 500000, equity 500000, result 0.
|
|
116
|
+
const onlyCapital = [posted[0] as LedgerEntry];
|
|
117
|
+
const b = balanceSheet(accounts, onlyCapital);
|
|
118
|
+
expect(b.assets).toBe(500000);
|
|
119
|
+
expect(b.currentResult).toBe(0);
|
|
120
|
+
expect(b.balances).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// ledger bundle constants — feature-name + qualified handler/query names.
|
|
3
|
+
//
|
|
4
|
+
// Spec: kumiko-platform/docs/plans/ledger-feature.md
|
|
5
|
+
|
|
6
|
+
import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
export const LEDGER_FEATURE_NAME = "ledger";
|
|
9
|
+
|
|
10
|
+
// Qualified names (QN format: scope:type:name). The account catalog uses the
|
|
11
|
+
// generic defineEntity*Handler (create/update/list/detail) → "account:<verb>"
|
|
12
|
+
// qualified to "ledger:write:account:<verb>". Transactions are immutable: only
|
|
13
|
+
// create-transaction (balanced, Σ=0) and reverse-transaction (Storno) exist —
|
|
14
|
+
// no update/delete, so a posted journal entry can never be mutated.
|
|
15
|
+
export const LedgerHandlers = {
|
|
16
|
+
createAccount: "ledger:write:account:create",
|
|
17
|
+
updateAccount: "ledger:write:account:update",
|
|
18
|
+
createTransaction: "ledger:write:create-transaction",
|
|
19
|
+
reverseTransaction: "ledger:write:reverse-transaction",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export const LedgerQueries = {
|
|
23
|
+
accountList: "ledger:query:account:list",
|
|
24
|
+
accountDetail: "ledger:query:account:detail",
|
|
25
|
+
transactionList: "ledger:query:transaction:list",
|
|
26
|
+
transactionDetail: "ledger:query:transaction:detail",
|
|
27
|
+
// Reports — pure aggregations over the posted entries (Phase 1).
|
|
28
|
+
reportBalances: "ledger:query:report:balances",
|
|
29
|
+
reportIncomeStatement: "ledger:query:report:income-statement",
|
|
30
|
+
reportBalanceSheet: "ledger:query:report:balance-sheet",
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
// Account types double-entry needs to interpret a balance: asset/expense are
|
|
34
|
+
// debit-normal, liability/equity/income are credit-normal. The report layer
|
|
35
|
+
// (Phase 1) flips signs by type; the primitive only stores them.
|
|
36
|
+
export const ACCOUNT_TYPES = ["asset", "liability", "equity", "income", "expense"] as const;
|
|
37
|
+
export type AccountType = (typeof ACCOUNT_TYPES)[number];
|
|
38
|
+
|
|
39
|
+
export const TRANSACTION_STATUS = ["draft", "posted"] as const;
|
|
40
|
+
export type TransactionStatus = (typeof TRANSACTION_STATUS)[number];
|
|
41
|
+
|
|
42
|
+
// Default RBAC for every ledger path. A ledger is sensitive (it's the books), but
|
|
43
|
+
// like folders it adopts the host's model — apps pin roles via
|
|
44
|
+
// createLedgerFeature({ roles }) or { access }. Default: both tenant roles.
|
|
45
|
+
export const DEFAULT_LEDGER_ROLES = ["TenantAdmin", "TenantMember"] as const;
|
|
46
|
+
|
|
47
|
+
export const DEFAULT_LEDGER_ACCESS: AccessRule = { roles: DEFAULT_LEDGER_ROLES };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDateField,
|
|
3
|
+
createEntity,
|
|
4
|
+
createJsonbField,
|
|
5
|
+
createSelectField,
|
|
6
|
+
createTextField,
|
|
7
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { ACCOUNT_TYPES, TRANSACTION_STATUS } from "./constants";
|
|
9
|
+
|
|
10
|
+
// account — a node in the chart of accounts. Event-sourced (create/update/list/
|
|
11
|
+
// detail via the standard handlers); the framework projects `read_ledger_accounts`
|
|
12
|
+
// from its CRUD events. `parentId` null → root account; otherwise the chart-of-
|
|
13
|
+
// accounts tree. `type` drives how the report layer interprets a balance. No
|
|
14
|
+
// money column — balances are DERIVED from postings (Phase 1), never stored.
|
|
15
|
+
// tenantId is a base column set by the framework → each tenant has its own books.
|
|
16
|
+
export const accountEntity = createEntity({
|
|
17
|
+
table: "read_ledger_accounts",
|
|
18
|
+
fields: {
|
|
19
|
+
name: createTextField({ required: true, maxLength: 120 }),
|
|
20
|
+
type: createSelectField({ options: ACCOUNT_TYPES, required: true }),
|
|
21
|
+
// Optional account number (Kontonummer / SKR code) — free text in v1.
|
|
22
|
+
code: createTextField({ maxLength: 32 }),
|
|
23
|
+
// Parent account id, or absent for a root account. No FK (event-sourced).
|
|
24
|
+
parentId: createTextField({ maxLength: 64 }),
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// transaction — a journal entry. The balanced posting lines live embedded as
|
|
29
|
+
// `lines` (jsonb: { accountId, amount }[], Σ amount = 0), so an entry is atomic:
|
|
30
|
+
// the Σ=0 invariant holds within a single command, no cross-row write. The
|
|
31
|
+
// framework projects `read_ledger_transactions`; Phase 1 adds a flat
|
|
32
|
+
// `read_ledger_postings` projection (one row per line) for per-account/period
|
|
33
|
+
// report queries.
|
|
34
|
+
//
|
|
35
|
+
// IMMUTABLE: the feature registers NO update/delete handler for transaction. A
|
|
36
|
+
// posted entry is a fact; corrections are reverse-transaction (Storno) entries.
|
|
37
|
+
// `status` carries draft|posted for the later Soll/Ist work — Phase 0 posts only.
|
|
38
|
+
export const transactionEntity = createEntity({
|
|
39
|
+
table: "read_ledger_transactions",
|
|
40
|
+
fields: {
|
|
41
|
+
date: createDateField({ required: true }),
|
|
42
|
+
// Journal narration ("Miete Januar", "Storno: …") is accounting data, not
|
|
43
|
+
// user-generated PII → allowPlaintext silences the user-content heuristic.
|
|
44
|
+
description: createTextField({
|
|
45
|
+
required: true,
|
|
46
|
+
maxLength: 200,
|
|
47
|
+
allowPlaintext: "is-business-data",
|
|
48
|
+
}),
|
|
49
|
+
// For a Storno entry this points at the reversed transaction's id.
|
|
50
|
+
reference: createTextField({ maxLength: 120 }),
|
|
51
|
+
status: createSelectField({ options: TRANSACTION_STATUS, required: true }),
|
|
52
|
+
lines: createJsonbField(),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
@@ -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);
|