@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.
- package/package.json +7 -6
- package/src/folders/web/__tests__/folder-manager.test.tsx +168 -0
- package/src/folders/web/folder-manager.tsx +286 -73
- package/src/folders/web/i18n.ts +6 -0
- package/src/folders/web/index.ts +1 -1
- 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
|
@@ -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
|
+
});
|