@cosmicdrift/kumiko-bundled-features 0.94.0 → 0.96.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 +39 -0
- package/src/folders/web/folder-manager.tsx +19 -5
- package/src/ledger/__tests__/feature.test.ts +9 -4
- package/src/ledger/__tests__/ledger.integration.test.ts +112 -1
- package/src/ledger/__tests__/recurring.test.ts +136 -0
- package/src/ledger/constants.ts +13 -0
- package/src/ledger/entity.ts +27 -1
- package/src/ledger/executor.ts +5 -1
- package/src/ledger/feature.ts +13 -2
- package/src/ledger/handlers/confirm-schedule-period.write.ts +100 -0
- package/src/ledger/index.ts +19 -1
- package/src/ledger/recurring.ts +140 -0
- package/src/ledger/schemas.ts +14 -0
- package/src/ledger/web/index.ts +35 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.96.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>",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"./folders/web": "./src/folders/web/index.ts",
|
|
37
37
|
"./folders-user-data": "./src/folders-user-data/index.ts",
|
|
38
38
|
"./ledger": "./src/ledger/index.ts",
|
|
39
|
+
"./ledger/web": "./src/ledger/web/index.ts",
|
|
39
40
|
"./billing-foundation": "./src/billing-foundation/index.ts",
|
|
40
41
|
"./subscription-stripe": "./src/subscription-stripe/index.ts",
|
|
41
42
|
"./subscription-mollie": "./src/subscription-mollie/index.ts",
|
|
@@ -90,11 +91,11 @@
|
|
|
90
91
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
91
92
|
},
|
|
92
93
|
"dependencies": {
|
|
93
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
94
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
95
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
96
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
97
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
94
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.96.0",
|
|
95
|
+
"@cosmicdrift/kumiko-framework": "0.96.0",
|
|
96
|
+
"@cosmicdrift/kumiko-headless": "0.96.0",
|
|
97
|
+
"@cosmicdrift/kumiko-renderer": "0.96.0",
|
|
98
|
+
"@cosmicdrift/kumiko-renderer-web": "0.96.0",
|
|
98
99
|
"@mollie/api-client": "^4.5.0",
|
|
99
100
|
"@node-rs/argon2": "^2.0.2",
|
|
100
101
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -165,4 +165,43 @@ describe("FolderManager filing mode", () => {
|
|
|
165
165
|
expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.createFolder, { name: "Inbox" }),
|
|
166
166
|
);
|
|
167
167
|
});
|
|
168
|
+
|
|
169
|
+
test("a mixed-type tree files each leaf under its own entityType (per-leaf override + fallback)", async () => {
|
|
170
|
+
folderRows = [
|
|
171
|
+
{ id: "f1", name: "A", parentId: null, version: 1 },
|
|
172
|
+
{ id: "f2", name: "B", parentId: null, version: 1 },
|
|
173
|
+
];
|
|
174
|
+
const mixed: FolderFiling = {
|
|
175
|
+
entityType: "credit", // tree default
|
|
176
|
+
leavesByFolder: new Map([["f1", [{ id: "b-1", label: "Bauspar 1", entityType: "bauspar" }]]]),
|
|
177
|
+
unfiled: [{ id: "c-1", label: "Credit 1" }], // no override → inherits "credit"
|
|
178
|
+
unfiledLabel: "Unfiled",
|
|
179
|
+
onReassigned: () => {},
|
|
180
|
+
};
|
|
181
|
+
render(
|
|
182
|
+
<Wrapper>
|
|
183
|
+
<FolderManager filing={mixed} />
|
|
184
|
+
</Wrapper>,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// The bauspar leaf carries its own entityType into the set-folder write…
|
|
188
|
+
dropLeaf("folder-node-f2", "b-1");
|
|
189
|
+
await waitFor(() =>
|
|
190
|
+
expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.setFolder, {
|
|
191
|
+
folderId: "f2",
|
|
192
|
+
entityType: "bauspar",
|
|
193
|
+
entityId: "b-1",
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// …while a leaf without an override still falls back to filing.entityType.
|
|
198
|
+
dropLeaf("folder-node-f1", "c-1");
|
|
199
|
+
await waitFor(() =>
|
|
200
|
+
expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.setFolder, {
|
|
201
|
+
folderId: "f1",
|
|
202
|
+
entityType: "credit",
|
|
203
|
+
entityId: "c-1",
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
});
|
|
168
207
|
});
|
|
@@ -47,6 +47,10 @@ export type FolderLeaf = {
|
|
|
47
47
|
readonly label: string;
|
|
48
48
|
readonly trailing?: ReactNode;
|
|
49
49
|
readonly onOpen?: () => void;
|
|
50
|
+
// Overrides filing.entityType for this leaf — lets one filing tree hold leaves
|
|
51
|
+
// of mixed entity types (e.g. credits + Bausparverträge), each filed/cleared
|
|
52
|
+
// under its own type. Omit for single-type trees.
|
|
53
|
+
readonly entityType?: string;
|
|
50
54
|
};
|
|
51
55
|
|
|
52
56
|
// Opt-in filing binding. Host computes the grouping (it already holds the
|
|
@@ -133,12 +137,21 @@ export function FolderManager({
|
|
|
133
137
|
const rows = catalog.data?.rows ?? [];
|
|
134
138
|
const tree = buildFolderTree(rows);
|
|
135
139
|
|
|
136
|
-
// entityId → current folder (null = unfiled) for the drop no-op guard
|
|
140
|
+
// entityId → current folder (null = unfiled) for the drop no-op guard, and
|
|
141
|
+
// entityId → entityType so a mixed-type tree files each leaf under its own type.
|
|
137
142
|
const currentFolderByEntity = new Map<string, string | null>();
|
|
143
|
+
const typeByEntity = new Map<string, string>();
|
|
138
144
|
if (filing !== undefined) {
|
|
145
|
+
const typeOf = (leaf: FolderLeaf): string => leaf.entityType ?? filing.entityType;
|
|
139
146
|
for (const [folderId, leaves] of filing.leavesByFolder)
|
|
140
|
-
for (const leaf of leaves)
|
|
141
|
-
|
|
147
|
+
for (const leaf of leaves) {
|
|
148
|
+
currentFolderByEntity.set(leaf.id, folderId);
|
|
149
|
+
typeByEntity.set(leaf.id, typeOf(leaf));
|
|
150
|
+
}
|
|
151
|
+
for (const leaf of filing.unfiled) {
|
|
152
|
+
currentFolderByEntity.set(leaf.id, null);
|
|
153
|
+
typeByEntity.set(leaf.id, typeOf(leaf));
|
|
154
|
+
}
|
|
142
155
|
}
|
|
143
156
|
|
|
144
157
|
const toggleCollapse = (id: string): void =>
|
|
@@ -228,12 +241,13 @@ export function FolderManager({
|
|
|
228
241
|
if (filing === undefined) return;
|
|
229
242
|
if ((currentFolderByEntity.get(entityId) ?? null) === folderId) return;
|
|
230
243
|
setErrorKey(null);
|
|
244
|
+
const entityType = typeByEntity.get(entityId) ?? filing.entityType;
|
|
231
245
|
const ok =
|
|
232
246
|
folderId === null
|
|
233
|
-
? await writeOk(FoldersHandlers.clearFolder, { entityType
|
|
247
|
+
? await writeOk(FoldersHandlers.clearFolder, { entityType, entityId })
|
|
234
248
|
: await writeOk(FoldersHandlers.setFolder, {
|
|
235
249
|
folderId,
|
|
236
|
-
entityType
|
|
250
|
+
entityType,
|
|
237
251
|
entityId,
|
|
238
252
|
});
|
|
239
253
|
if (ok) {
|
|
@@ -20,11 +20,11 @@ function writeAccess(
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
describe("createLedgerFeature shape", () => {
|
|
23
|
-
test("registers account + transaction entities,
|
|
23
|
+
test("registers account + transaction + schedule entities, 7 write-handlers, 9 query-handlers", () => {
|
|
24
24
|
const feature = createLedgerFeature();
|
|
25
25
|
|
|
26
26
|
expect(Object.keys(feature.entities ?? {})).toEqual(
|
|
27
|
-
expect.arrayContaining(["account", "transaction"]),
|
|
27
|
+
expect.arrayContaining(["account", "transaction", "schedule"]),
|
|
28
28
|
);
|
|
29
29
|
|
|
30
30
|
expect(Object.keys(feature.writeHandlers)).toEqual(
|
|
@@ -33,9 +33,12 @@ describe("createLedgerFeature shape", () => {
|
|
|
33
33
|
expect.stringMatching(/account:update/),
|
|
34
34
|
expect.stringMatching(/create-transaction/),
|
|
35
35
|
expect.stringMatching(/reverse-transaction/),
|
|
36
|
+
expect.stringMatching(/schedule:create/),
|
|
37
|
+
expect.stringMatching(/schedule:update/),
|
|
38
|
+
expect.stringMatching(/confirm-schedule-period/),
|
|
36
39
|
]),
|
|
37
40
|
);
|
|
38
|
-
expect(Object.keys(feature.writeHandlers)).toHaveLength(
|
|
41
|
+
expect(Object.keys(feature.writeHandlers)).toHaveLength(7);
|
|
39
42
|
|
|
40
43
|
expect(Object.keys(feature.queryHandlers)).toEqual(
|
|
41
44
|
expect.arrayContaining([
|
|
@@ -43,12 +46,14 @@ describe("createLedgerFeature shape", () => {
|
|
|
43
46
|
expect.stringMatching(/account:detail/),
|
|
44
47
|
expect.stringMatching(/transaction:list/),
|
|
45
48
|
expect.stringMatching(/transaction:detail/),
|
|
49
|
+
expect.stringMatching(/schedule:list/),
|
|
50
|
+
expect.stringMatching(/schedule:detail/),
|
|
46
51
|
expect.stringMatching(/report:balances/),
|
|
47
52
|
expect.stringMatching(/report:income-statement/),
|
|
48
53
|
expect.stringMatching(/report:balance-sheet/),
|
|
49
54
|
]),
|
|
50
55
|
);
|
|
51
|
-
expect(Object.keys(feature.queryHandlers)).toHaveLength(
|
|
56
|
+
expect(Object.keys(feature.queryHandlers)).toHaveLength(9);
|
|
52
57
|
});
|
|
53
58
|
|
|
54
59
|
test("transaction is immutable: NO update/delete handler is registered", () => {
|
|
@@ -19,8 +19,9 @@ import {
|
|
|
19
19
|
unsafeCreateEntityTable,
|
|
20
20
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
21
|
import { type AccountType, LedgerHandlers, LedgerQueries } from "../constants";
|
|
22
|
-
import { accountEntity, transactionEntity } from "../entity";
|
|
22
|
+
import { accountEntity, scheduleEntity, transactionEntity } from "../entity";
|
|
23
23
|
import { createLedgerFeature } from "../feature";
|
|
24
|
+
import { scheduleReference } from "../recurring";
|
|
24
25
|
import type { Posting } from "../schemas";
|
|
25
26
|
|
|
26
27
|
const ledgerFeature = createLedgerFeature();
|
|
@@ -31,6 +32,7 @@ beforeAll(async () => {
|
|
|
31
32
|
stack = await setupTestStack({ features: [ledgerFeature] });
|
|
32
33
|
await unsafeCreateEntityTable(stack.db, accountEntity);
|
|
33
34
|
await unsafeCreateEntityTable(stack.db, transactionEntity);
|
|
35
|
+
await unsafeCreateEntityTable(stack.db, scheduleEntity);
|
|
34
36
|
await createEventsTable(stack.db);
|
|
35
37
|
});
|
|
36
38
|
|
|
@@ -42,6 +44,7 @@ beforeEach(async () => {
|
|
|
42
44
|
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
43
45
|
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_accounts");
|
|
44
46
|
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_transactions");
|
|
47
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_schedules");
|
|
45
48
|
});
|
|
46
49
|
|
|
47
50
|
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
@@ -298,3 +301,111 @@ describe("ledger integration — reports (end-to-end)", () => {
|
|
|
298
301
|
expect(r.balances).toBe(true);
|
|
299
302
|
});
|
|
300
303
|
});
|
|
304
|
+
|
|
305
|
+
describe("ledger integration — confirm-schedule-period (recurring)", () => {
|
|
306
|
+
async function createSchedule(
|
|
307
|
+
debitAccountId: string,
|
|
308
|
+
creditAccountId: string,
|
|
309
|
+
over: { amount?: number; description?: string; startDate?: string } = {},
|
|
310
|
+
): Promise<string> {
|
|
311
|
+
const s = await stack.http.writeOk<{ id: string }>(
|
|
312
|
+
LedgerHandlers.createSchedule,
|
|
313
|
+
{
|
|
314
|
+
description: over.description ?? "Miete WE1",
|
|
315
|
+
startDate: over.startDate ?? "2026-01-01",
|
|
316
|
+
interval: "monthly",
|
|
317
|
+
amount: over.amount ?? 50000,
|
|
318
|
+
debitAccountId,
|
|
319
|
+
creditAccountId,
|
|
320
|
+
},
|
|
321
|
+
admin,
|
|
322
|
+
);
|
|
323
|
+
return s.id;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function confirm(
|
|
327
|
+
scheduleId: string,
|
|
328
|
+
period: string,
|
|
329
|
+
amount?: number,
|
|
330
|
+
): Promise<{ id: string }> {
|
|
331
|
+
return stack.http.writeOk<{ id: string }>(
|
|
332
|
+
LedgerHandlers.confirmSchedulePeriod,
|
|
333
|
+
amount === undefined ? { scheduleId, period } : { scheduleId, period, amount },
|
|
334
|
+
admin,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
test("confirm books a balanced entry tagged with the schedule reference", async () => {
|
|
339
|
+
const bank = await createAccount("Bank", "asset");
|
|
340
|
+
const rent = await createAccount("Mieterträge", "income");
|
|
341
|
+
const scheduleId = await createSchedule(bank, rent);
|
|
342
|
+
|
|
343
|
+
await confirm(scheduleId, "2026-01");
|
|
344
|
+
|
|
345
|
+
const rows = await listTransactions();
|
|
346
|
+
expect(rows).toHaveLength(1);
|
|
347
|
+
expect(rows[0]?.["reference"]).toBe(scheduleReference(scheduleId, "2026-01"));
|
|
348
|
+
expect(rows[0]?.["description"]).toBe("Miete WE1");
|
|
349
|
+
const lines = linesOf(rows[0] as Record<string, unknown>);
|
|
350
|
+
expect(lines.find((l) => l.accountId === bank)?.amount).toBe(50000);
|
|
351
|
+
expect(lines.find((l) => l.accountId === rent)?.amount).toBe(-50000);
|
|
352
|
+
expect(trialBalance(rows)).toBe(0);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("re-confirming the same period is an idempotent no-op (no second booking)", async () => {
|
|
356
|
+
const bank = await createAccount("Bank", "asset");
|
|
357
|
+
const rent = await createAccount("Mieterträge", "income");
|
|
358
|
+
const scheduleId = await createSchedule(bank, rent);
|
|
359
|
+
|
|
360
|
+
const first = await confirm(scheduleId, "2026-01");
|
|
361
|
+
const second = await confirm(scheduleId, "2026-01");
|
|
362
|
+
|
|
363
|
+
expect(second.id).toBe(first.id);
|
|
364
|
+
expect(await listTransactions()).toHaveLength(1);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("amount override books the actual received amount", async () => {
|
|
368
|
+
const bank = await createAccount("Bank", "asset");
|
|
369
|
+
const rent = await createAccount("Mieterträge", "income");
|
|
370
|
+
const scheduleId = await createSchedule(bank, rent, { amount: 50000 });
|
|
371
|
+
|
|
372
|
+
await confirm(scheduleId, "2026-02", 48000);
|
|
373
|
+
|
|
374
|
+
const rows = await listTransactions();
|
|
375
|
+
expect(
|
|
376
|
+
linesOf(rows[0] as Record<string, unknown>).find((l) => l.accountId === bank)?.amount,
|
|
377
|
+
).toBe(48000);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("confirm fails (404) when the schedule names a non-existent account", async () => {
|
|
381
|
+
const bank = await createAccount("Bank", "asset");
|
|
382
|
+
const scheduleId = await createSchedule(bank, "00000000-0000-4000-8000-00000000dead");
|
|
383
|
+
|
|
384
|
+
const err = await stack.http.writeErr(
|
|
385
|
+
LedgerHandlers.confirmSchedulePeriod,
|
|
386
|
+
{ scheduleId, period: "2026-01" },
|
|
387
|
+
admin,
|
|
388
|
+
);
|
|
389
|
+
expect(err.httpStatus).toBe(404);
|
|
390
|
+
expect(await listTransactions()).toHaveLength(0);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("a stornoed period is re-confirmable → a fresh booking is written", async () => {
|
|
394
|
+
const bank = await createAccount("Bank", "asset");
|
|
395
|
+
const rent = await createAccount("Mieterträge", "income");
|
|
396
|
+
const scheduleId = await createSchedule(bank, rent);
|
|
397
|
+
|
|
398
|
+
const booked = await confirm(scheduleId, "2026-01");
|
|
399
|
+
await stack.http.writeOk(LedgerHandlers.reverseTransaction, { id: booked.id }, admin);
|
|
400
|
+
|
|
401
|
+
// Original + Storno present; the period now reads as un-booked → re-confirm books anew.
|
|
402
|
+
expect(await listTransactions()).toHaveLength(2);
|
|
403
|
+
const reconfirmed = await confirm(scheduleId, "2026-01", 48000);
|
|
404
|
+
expect(reconfirmed.id).not.toBe(booked.id);
|
|
405
|
+
|
|
406
|
+
const rows = await listTransactions();
|
|
407
|
+
expect(rows).toHaveLength(3);
|
|
408
|
+
// Books net to zero (original cancels Storno) plus the fresh 48000 confirmation.
|
|
409
|
+
expect(trialBalance(rows)).toBe(0);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
type LedgerTxRow,
|
|
4
|
+
mergeScheduleActuals,
|
|
5
|
+
projectSchedule,
|
|
6
|
+
type ScheduleDef,
|
|
7
|
+
scheduleReference,
|
|
8
|
+
} from "../recurring";
|
|
9
|
+
|
|
10
|
+
// Pure projection + Soll/Ist merge — no DB, no Date API (the window/asOf are
|
|
11
|
+
// params), so these are deterministic and cover the recurring primitive's logic
|
|
12
|
+
// the integration test then proves end-to-end against a real dispatcher.
|
|
13
|
+
|
|
14
|
+
const monthly = (over: Partial<ScheduleDef> = {}): ScheduleDef => ({
|
|
15
|
+
startDate: "2026-01-15",
|
|
16
|
+
interval: "monthly",
|
|
17
|
+
amount: 50000,
|
|
18
|
+
...over,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("projectSchedule", () => {
|
|
22
|
+
test("projects one period per month across the window, clamped to startDate", () => {
|
|
23
|
+
const periods = projectSchedule(monthly(), { from: "2025-11", to: "2026-04" });
|
|
24
|
+
expect(periods.map((p) => p.period)).toEqual(["2026-01", "2026-02", "2026-03", "2026-04"]);
|
|
25
|
+
expect(periods.every((p) => p.amount === 50000)).toBe(true);
|
|
26
|
+
expect(periods[0]).toEqual({ period: "2026-01", date: "2026-01-01", amount: 50000 });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("clamps the tail to endDate (inclusive)", () => {
|
|
30
|
+
const periods = projectSchedule(monthly({ endDate: "2026-03-31" }), {
|
|
31
|
+
from: "2026-01",
|
|
32
|
+
to: "2026-12",
|
|
33
|
+
});
|
|
34
|
+
expect(periods.map((p) => p.period)).toEqual(["2026-01", "2026-02", "2026-03"]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("open-ended schedule projects to the window end", () => {
|
|
38
|
+
const periods = projectSchedule(monthly(), { from: "2026-01", to: "2026-02" });
|
|
39
|
+
expect(periods.map((p) => p.period)).toEqual(["2026-01", "2026-02"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("window entirely before startDate yields nothing", () => {
|
|
43
|
+
expect(projectSchedule(monthly(), { from: "2025-01", to: "2025-12" })).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("tolerates full ISO dates in the window bounds", () => {
|
|
47
|
+
const periods = projectSchedule(monthly(), { from: "2026-02-10", to: "2026-03-05" });
|
|
48
|
+
expect(periods.map((p) => p.period)).toEqual(["2026-02", "2026-03"]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("mergeScheduleActuals", () => {
|
|
53
|
+
const ref = (period: string): string => scheduleReference("s1", period);
|
|
54
|
+
const confirmTx = (id: string, period: string, amount = 50000): LedgerTxRow => ({
|
|
55
|
+
id,
|
|
56
|
+
reference: ref(period),
|
|
57
|
+
lines: [
|
|
58
|
+
{ accountId: "bank", amount },
|
|
59
|
+
{ accountId: "rent", amount: -amount },
|
|
60
|
+
],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const projection = projectSchedule(monthly(), { from: "2026-01", to: "2026-04" });
|
|
64
|
+
|
|
65
|
+
test("posted for a confirmed month, open for past-unbooked, forecast for future", () => {
|
|
66
|
+
const months = mergeScheduleActuals(
|
|
67
|
+
"s1",
|
|
68
|
+
projection,
|
|
69
|
+
[confirmTx("tx-jan", "2026-01")],
|
|
70
|
+
"2026-03",
|
|
71
|
+
);
|
|
72
|
+
expect(months.map((m) => [m.period, m.status])).toEqual([
|
|
73
|
+
["2026-01", "posted"],
|
|
74
|
+
["2026-02", "open"],
|
|
75
|
+
["2026-03", "open"],
|
|
76
|
+
["2026-04", "forecast"],
|
|
77
|
+
]);
|
|
78
|
+
const jan = months[0];
|
|
79
|
+
expect(jan).toMatchObject({ planned: 50000, actual: 50000, txId: "tx-jan" });
|
|
80
|
+
expect(months[1]).toMatchObject({ actual: null, txId: null });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("a reversed (stornoed) confirmation drops the month back to open + re-confirmable", () => {
|
|
84
|
+
const tx: LedgerTxRow[] = [
|
|
85
|
+
confirmTx("tx-jan", "2026-01"),
|
|
86
|
+
// Storno mirror: references the confirming tx's id (reverse-transaction shape).
|
|
87
|
+
{
|
|
88
|
+
id: "tx-storno",
|
|
89
|
+
reference: "tx-jan",
|
|
90
|
+
lines: [
|
|
91
|
+
{ accountId: "bank", amount: -50000 },
|
|
92
|
+
{ accountId: "rent", amount: 50000 },
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
];
|
|
96
|
+
const months = mergeScheduleActuals("s1", projection, tx, "2026-03");
|
|
97
|
+
expect(months[0]).toMatchObject({
|
|
98
|
+
period: "2026-01",
|
|
99
|
+
status: "open",
|
|
100
|
+
actual: null,
|
|
101
|
+
txId: null,
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a re-confirmation after Storno posts again with the new tx", () => {
|
|
106
|
+
const tx: LedgerTxRow[] = [
|
|
107
|
+
confirmTx("tx-jan", "2026-01"),
|
|
108
|
+
{
|
|
109
|
+
id: "tx-storno",
|
|
110
|
+
reference: "tx-jan",
|
|
111
|
+
lines: [
|
|
112
|
+
{ accountId: "bank", amount: -50000 },
|
|
113
|
+
{ accountId: "rent", amount: 50000 },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
confirmTx("tx-jan-2", "2026-01", 48000), // re-confirmed, came in short
|
|
117
|
+
];
|
|
118
|
+
const months = mergeScheduleActuals("s1", projection, tx, "2026-03");
|
|
119
|
+
expect(months[0]).toMatchObject({ status: "posted", actual: 48000, txId: "tx-jan-2" });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("ignores transactions of other schedules", () => {
|
|
123
|
+
const tx: LedgerTxRow[] = [
|
|
124
|
+
{
|
|
125
|
+
id: "tx-other",
|
|
126
|
+
reference: scheduleReference("s2", "2026-01"),
|
|
127
|
+
lines: [
|
|
128
|
+
{ accountId: "bank", amount: 50000 },
|
|
129
|
+
{ accountId: "rent", amount: -50000 },
|
|
130
|
+
],
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
const months = mergeScheduleActuals("s1", projection, tx, "2026-03");
|
|
134
|
+
expect(months[0]).toMatchObject({ status: "open", txId: null });
|
|
135
|
+
});
|
|
136
|
+
});
|
package/src/ledger/constants.ts
CHANGED
|
@@ -17,6 +17,11 @@ export const LedgerHandlers = {
|
|
|
17
17
|
updateAccount: "ledger:write:account:update",
|
|
18
18
|
createTransaction: "ledger:write:create-transaction",
|
|
19
19
|
reverseTransaction: "ledger:write:reverse-transaction",
|
|
20
|
+
// Recurring schedules — the catalog's CRUD verbs plus the confirm verb that
|
|
21
|
+
// books one projected period as a balanced, idempotent entry.
|
|
22
|
+
createSchedule: "ledger:write:schedule:create",
|
|
23
|
+
updateSchedule: "ledger:write:schedule:update",
|
|
24
|
+
confirmSchedulePeriod: "ledger:write:confirm-schedule-period",
|
|
20
25
|
} as const;
|
|
21
26
|
|
|
22
27
|
export const LedgerQueries = {
|
|
@@ -24,6 +29,8 @@ export const LedgerQueries = {
|
|
|
24
29
|
accountDetail: "ledger:query:account:detail",
|
|
25
30
|
transactionList: "ledger:query:transaction:list",
|
|
26
31
|
transactionDetail: "ledger:query:transaction:detail",
|
|
32
|
+
scheduleList: "ledger:query:schedule:list",
|
|
33
|
+
scheduleDetail: "ledger:query:schedule:detail",
|
|
27
34
|
// Reports — pure aggregations over the posted entries (Phase 1).
|
|
28
35
|
reportBalances: "ledger:query:report:balances",
|
|
29
36
|
reportIncomeStatement: "ledger:query:report:income-statement",
|
|
@@ -39,6 +46,12 @@ export type AccountType = (typeof ACCOUNT_TYPES)[number];
|
|
|
39
46
|
export const TRANSACTION_STATUS = ["draft", "posted"] as const;
|
|
40
47
|
export type TransactionStatus = (typeof TRANSACTION_STATUS)[number];
|
|
41
48
|
|
|
49
|
+
// Recurrence steps a schedule can project. Monthly is the only step v1 needs
|
|
50
|
+
// (rent, loan rate, salary); weekly/quarterly/yearly add to projectSchedule when
|
|
51
|
+
// a schedule actually requires them.
|
|
52
|
+
export const SCHEDULE_INTERVALS = ["monthly"] as const;
|
|
53
|
+
export type ScheduleInterval = (typeof SCHEDULE_INTERVALS)[number];
|
|
54
|
+
|
|
42
55
|
// Default RBAC for every ledger path. A ledger is sensitive (it's the books), but
|
|
43
56
|
// like folders it adopts the host's model — apps pin roles via
|
|
44
57
|
// createLedgerFeature({ roles }) or { access }. Default: both tenant roles.
|
package/src/ledger/entity.ts
CHANGED
|
@@ -2,10 +2,11 @@ import {
|
|
|
2
2
|
createDateField,
|
|
3
3
|
createEntity,
|
|
4
4
|
createJsonbField,
|
|
5
|
+
createNumberField,
|
|
5
6
|
createSelectField,
|
|
6
7
|
createTextField,
|
|
7
8
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
-
import { ACCOUNT_TYPES, TRANSACTION_STATUS } from "./constants";
|
|
9
|
+
import { ACCOUNT_TYPES, SCHEDULE_INTERVALS, TRANSACTION_STATUS } from "./constants";
|
|
9
10
|
|
|
10
11
|
// account — a node in the chart of accounts. Event-sourced (create/update/list/
|
|
11
12
|
// detail via the standard handlers); the framework projects `read_ledger_accounts`
|
|
@@ -52,3 +53,28 @@ export const transactionEntity = createEntity({
|
|
|
52
53
|
lines: createJsonbField(),
|
|
53
54
|
},
|
|
54
55
|
});
|
|
56
|
+
|
|
57
|
+
// schedule — a recurring booking template (Dauerauftrag): "book `amount` from
|
|
58
|
+
// debitAccount to creditAccount every period from startDate". Event-sourced CRUD
|
|
59
|
+
// (create/update/list/detail); the framework projects `read_ledger_schedules`. It
|
|
60
|
+
// holds NO bookings — the Soll (forecast) is a pure projection (projectSchedule)
|
|
61
|
+
// and the Ist is materialised one period at a time by confirm-schedule-period,
|
|
62
|
+
// which books a balanced transaction referencing scheduleReference(id, period).
|
|
63
|
+
// amount is stored positive (minor units); the confirm handler assigns the signs.
|
|
64
|
+
export const scheduleEntity = createEntity({
|
|
65
|
+
table: "read_ledger_schedules",
|
|
66
|
+
fields: {
|
|
67
|
+
description: createTextField({
|
|
68
|
+
required: true,
|
|
69
|
+
maxLength: 200,
|
|
70
|
+
allowPlaintext: "is-business-data",
|
|
71
|
+
}),
|
|
72
|
+
startDate: createDateField({ required: true }),
|
|
73
|
+
// Absent → open-ended (projects to the window's end).
|
|
74
|
+
endDate: createDateField(),
|
|
75
|
+
interval: createSelectField({ options: SCHEDULE_INTERVALS, required: true }),
|
|
76
|
+
amount: createNumberField({ required: true }),
|
|
77
|
+
debitAccountId: createTextField({ required: true, maxLength: 64 }),
|
|
78
|
+
creditAccountId: createTextField({ required: true, maxLength: 64 }),
|
|
79
|
+
},
|
|
80
|
+
});
|
package/src/ledger/executor.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createEntityExecutor } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import { accountEntity, transactionEntity } from "./entity";
|
|
2
|
+
import { accountEntity, scheduleEntity, transactionEntity } from "./entity";
|
|
3
3
|
|
|
4
4
|
// Shared tables + executors for the account + transaction handlers. Built once
|
|
5
5
|
// (side-effect-free). The tables back the report query-handlers (selectMany over
|
|
@@ -12,3 +12,7 @@ export const { table: transactionTable, executor: transactionExecutor } = create
|
|
|
12
12
|
"transaction",
|
|
13
13
|
transactionEntity,
|
|
14
14
|
);
|
|
15
|
+
export const { table: scheduleTable, executor: scheduleExecutor } = createEntityExecutor(
|
|
16
|
+
"schedule",
|
|
17
|
+
scheduleEntity,
|
|
18
|
+
);
|
package/src/ledger/feature.ts
CHANGED
|
@@ -22,7 +22,8 @@ import {
|
|
|
22
22
|
type FeatureRegistrar,
|
|
23
23
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
24
24
|
import { DEFAULT_LEDGER_ACCESS, LEDGER_FEATURE_NAME } from "./constants";
|
|
25
|
-
import { accountEntity, transactionEntity } from "./entity";
|
|
25
|
+
import { accountEntity, scheduleEntity, transactionEntity } from "./entity";
|
|
26
|
+
import { createConfirmSchedulePeriodHandler } from "./handlers/confirm-schedule-period.write";
|
|
26
27
|
import { createCreateTransactionHandler } from "./handlers/create-transaction.write";
|
|
27
28
|
import {
|
|
28
29
|
createBalanceSheetHandler,
|
|
@@ -42,7 +43,7 @@ function registerLedger(
|
|
|
42
43
|
toggleable: LedgerToggleable | undefined,
|
|
43
44
|
): void {
|
|
44
45
|
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
|
+
"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. Recurring schedules (`read_ledger_schedules`) layer Dauerauftrag templates on top — a schedule names debit/credit accounts, an amount and a monthly interval, from which the Soll (forecast) is a pure projection (projectSchedule) needing no bookings, and confirm-schedule-period materialises one period as an idempotent, reversal-aware balanced entry referencing scheduleReference(id, period); only confirming writes. 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
|
);
|
|
47
48
|
r.uiHints({
|
|
48
49
|
displayLabel: "Ledger",
|
|
@@ -54,6 +55,7 @@ function registerLedger(
|
|
|
54
55
|
|
|
55
56
|
r.entity("account", accountEntity);
|
|
56
57
|
r.entity("transaction", transactionEntity);
|
|
58
|
+
r.entity("schedule", scheduleEntity);
|
|
57
59
|
|
|
58
60
|
// Chart of accounts — plain CRUD, no custom logic. No delete in v1: removing an
|
|
59
61
|
// account that has postings would orphan them; a posting-aware guard lands with
|
|
@@ -70,6 +72,15 @@ function registerLedger(
|
|
|
70
72
|
r.queryHandler(defineEntityListHandler("transaction", transactionEntity, { access }));
|
|
71
73
|
r.queryHandler(defineEntityDetailHandler("transaction", transactionEntity, { access }));
|
|
72
74
|
|
|
75
|
+
// Recurring schedules — CRUD catalog plus confirm-schedule-period, which books
|
|
76
|
+
// one projected period as a balanced, idempotent + reversal-aware entry. The
|
|
77
|
+
// Soll (forecast) is a pure projection (projectSchedule); only confirming writes.
|
|
78
|
+
r.writeHandler(defineEntityCreateHandler("schedule", scheduleEntity, { access }));
|
|
79
|
+
r.writeHandler(defineEntityUpdateHandler("schedule", scheduleEntity, { access }));
|
|
80
|
+
r.writeHandler(createConfirmSchedulePeriodHandler(access));
|
|
81
|
+
r.queryHandler(defineEntityListHandler("schedule", scheduleEntity, { access }));
|
|
82
|
+
r.queryHandler(defineEntityDetailHandler("schedule", scheduleEntity, { access }));
|
|
83
|
+
|
|
73
84
|
// Reports — pure aggregations over the posted entries (account balances,
|
|
74
85
|
// GuV, Bilanz with the current result folded into equity).
|
|
75
86
|
r.queryHandler(createBalancesReportHandler(access));
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
2
|
+
import type {
|
|
3
|
+
AccessRule,
|
|
4
|
+
WriteHandlerDef,
|
|
5
|
+
WriteResult,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
|
+
import { DEFAULT_LEDGER_ACCESS } from "../constants";
|
|
10
|
+
import {
|
|
11
|
+
accountExecutor,
|
|
12
|
+
scheduleExecutor,
|
|
13
|
+
transactionExecutor,
|
|
14
|
+
transactionTable,
|
|
15
|
+
} from "../executor";
|
|
16
|
+
import { scheduleReference } from "../recurring";
|
|
17
|
+
import { type ConfirmSchedulePeriodPayload, confirmSchedulePeriodPayloadSchema } from "../schemas";
|
|
18
|
+
|
|
19
|
+
// confirm-schedule-period — turn ONE projected period of a schedule into a posted,
|
|
20
|
+
// balanced transaction (debit +amount / credit −amount), tagged with
|
|
21
|
+
// scheduleReference(scheduleId, period) so the host can merge Soll vs. Ist.
|
|
22
|
+
//
|
|
23
|
+
// Idempotent + reversal-aware: if an ACTIVE (non-reversed) booking for this
|
|
24
|
+
// reference already exists, it's a no-op; a booking that was reversed (Storno)
|
|
25
|
+
// leaves the period re-confirmable. Referential integrity (both accounts must
|
|
26
|
+
// exist) mirrors create-transaction — a schedule could name a bogus account, the
|
|
27
|
+
// generic CRUD create doesn't check.
|
|
28
|
+
export function createConfirmSchedulePeriodHandler(
|
|
29
|
+
access: AccessRule = DEFAULT_LEDGER_ACCESS,
|
|
30
|
+
): WriteHandlerDef {
|
|
31
|
+
return {
|
|
32
|
+
name: "confirm-schedule-period",
|
|
33
|
+
schema: confirmSchedulePeriodPayloadSchema,
|
|
34
|
+
access,
|
|
35
|
+
handler: async (event, ctx) => {
|
|
36
|
+
const payload = event.payload as ConfirmSchedulePeriodPayload; // @cast-boundary engine-payload
|
|
37
|
+
|
|
38
|
+
const schedule = await scheduleExecutor.detail(
|
|
39
|
+
{ id: payload.scheduleId },
|
|
40
|
+
event.user,
|
|
41
|
+
ctx.db,
|
|
42
|
+
);
|
|
43
|
+
if (!schedule) return writeFailure(new NotFoundError("schedule", payload.scheduleId));
|
|
44
|
+
|
|
45
|
+
const debitAccountId = String(schedule["debitAccountId"]);
|
|
46
|
+
const creditAccountId = String(schedule["creditAccountId"]);
|
|
47
|
+
for (const accountId of [debitAccountId, creditAccountId]) {
|
|
48
|
+
const account = await accountExecutor.detail({ id: accountId }, event.user, ctx.db);
|
|
49
|
+
if (!account) return writeFailure(new NotFoundError("account", accountId));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const reference = scheduleReference(payload.scheduleId, payload.period);
|
|
53
|
+
|
|
54
|
+
// Scan this tenant's transactions (same full-tenant read the reports do) to
|
|
55
|
+
// find an active booking for this reference. A tx is reversed when another
|
|
56
|
+
// tx's reference names its id (the Storno mirror).
|
|
57
|
+
// ponytail: read-then-write, so two confirms racing the same period could
|
|
58
|
+
// double-book; add a unique index on (tenantId, reference) when concurrent
|
|
59
|
+
// confirms become real.
|
|
60
|
+
const txRows = await selectMany(ctx.db.raw, transactionTable, {
|
|
61
|
+
tenantId: event.user.tenantId,
|
|
62
|
+
});
|
|
63
|
+
const txIds = new Set(txRows.map((r) => String(r["id"])));
|
|
64
|
+
const reversedTxIds = new Set(
|
|
65
|
+
txRows
|
|
66
|
+
.filter((r) => r["reference"] != null && txIds.has(String(r["reference"])))
|
|
67
|
+
.map((r) => String(r["reference"])),
|
|
68
|
+
);
|
|
69
|
+
const active = txRows.find(
|
|
70
|
+
(r) => r["reference"] === reference && !reversedTxIds.has(String(r["id"])),
|
|
71
|
+
);
|
|
72
|
+
if (active) {
|
|
73
|
+
const ok: WriteResult<{ id: string; alreadyBooked: true }> = {
|
|
74
|
+
isSuccess: true,
|
|
75
|
+
data: { id: String(active["id"]), alreadyBooked: true },
|
|
76
|
+
};
|
|
77
|
+
return ok;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const amount = payload.amount ?? Number(schedule["amount"]);
|
|
81
|
+
return transactionExecutor.create(
|
|
82
|
+
{
|
|
83
|
+
id: generateId(),
|
|
84
|
+
date: payload.date ?? `${payload.period}-01`,
|
|
85
|
+
description: String(schedule["description"]),
|
|
86
|
+
reference,
|
|
87
|
+
status: "posted",
|
|
88
|
+
lines: [
|
|
89
|
+
{ accountId: debitAccountId, amount },
|
|
90
|
+
{ accountId: creditAccountId, amount: -amount },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
event.user,
|
|
94
|
+
ctx.db,
|
|
95
|
+
);
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const confirmSchedulePeriodHandler: WriteHandlerDef = createConfirmSchedulePeriodHandler();
|
package/src/ledger/index.ts
CHANGED
|
@@ -6,15 +6,21 @@ export {
|
|
|
6
6
|
LEDGER_FEATURE_NAME,
|
|
7
7
|
LedgerHandlers,
|
|
8
8
|
LedgerQueries,
|
|
9
|
+
SCHEDULE_INTERVALS,
|
|
10
|
+
type ScheduleInterval,
|
|
9
11
|
TRANSACTION_STATUS,
|
|
10
12
|
type TransactionStatus,
|
|
11
13
|
} from "./constants";
|
|
12
|
-
export { accountEntity, transactionEntity } from "./entity";
|
|
14
|
+
export { accountEntity, scheduleEntity, transactionEntity } from "./entity";
|
|
13
15
|
export {
|
|
14
16
|
createLedgerFeature,
|
|
15
17
|
type LedgerFeatureOptions,
|
|
16
18
|
ledgerFeature,
|
|
17
19
|
} from "./feature";
|
|
20
|
+
export {
|
|
21
|
+
confirmSchedulePeriodHandler,
|
|
22
|
+
createConfirmSchedulePeriodHandler,
|
|
23
|
+
} from "./handlers/confirm-schedule-period.write";
|
|
18
24
|
export {
|
|
19
25
|
createCreateTransactionHandler,
|
|
20
26
|
createTransactionHandler,
|
|
@@ -28,6 +34,16 @@ export {
|
|
|
28
34
|
createReverseTransactionHandler,
|
|
29
35
|
reverseTransactionHandler,
|
|
30
36
|
} from "./handlers/reverse-transaction.write";
|
|
37
|
+
export {
|
|
38
|
+
type LedgerTxRow,
|
|
39
|
+
mergeScheduleActuals,
|
|
40
|
+
type ProjectedPeriod,
|
|
41
|
+
projectSchedule,
|
|
42
|
+
type ScheduleDef,
|
|
43
|
+
type ScheduleMonth,
|
|
44
|
+
type ScheduleMonthStatus,
|
|
45
|
+
scheduleReference,
|
|
46
|
+
} from "./recurring";
|
|
31
47
|
export {
|
|
32
48
|
type AccountBalance,
|
|
33
49
|
accountBalances,
|
|
@@ -43,7 +59,9 @@ export {
|
|
|
43
59
|
} from "./reports";
|
|
44
60
|
export {
|
|
45
61
|
accountTypeSchema,
|
|
62
|
+
type ConfirmSchedulePeriodPayload,
|
|
46
63
|
type CreateTransactionPayload,
|
|
64
|
+
confirmSchedulePeriodPayloadSchema,
|
|
47
65
|
createTransactionPayloadSchema,
|
|
48
66
|
type Posting,
|
|
49
67
|
postingSchema,
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// Recurring schedules on top of the ledger primitive. A `schedule` is a standing
|
|
2
|
+
// booking template ("Miete ab 1.1.2026, 500 €/Monat") from which two things fall
|
|
3
|
+
// out WITHOUT booking every month up front:
|
|
4
|
+
//
|
|
5
|
+
// • projectSchedule() — the Soll (forecast): pure projection over a window,
|
|
6
|
+
// no transactions needed (chart, "trägt sich das?").
|
|
7
|
+
// • mergeScheduleActuals() — Soll vs. Ist: the projection left-joined against
|
|
8
|
+
// the posted confirmations (per `reference`), so each
|
|
9
|
+
// month is posted | open | forecast.
|
|
10
|
+
//
|
|
11
|
+
// confirm-schedule-period (handler) turns one projected month into ONE balanced
|
|
12
|
+
// transaction tagged with `scheduleReference(id, period)`. Both functions here are
|
|
13
|
+
// pure — no DB, no Date API (windows/asOf come in as params) — so the no-date-api
|
|
14
|
+
// guard stays green and the forecast is deterministic.
|
|
15
|
+
|
|
16
|
+
import type { ScheduleInterval } from "./constants";
|
|
17
|
+
import type { Posting } from "./schemas";
|
|
18
|
+
|
|
19
|
+
export type ScheduleDef = {
|
|
20
|
+
readonly startDate: string; // ISO; first period (day ignored for monthly)
|
|
21
|
+
readonly endDate?: string | null; // ISO; last period inclusive, or open-ended
|
|
22
|
+
readonly interval: ScheduleInterval;
|
|
23
|
+
readonly amount: number; // positive minor units; the handler assigns debit/credit signs
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type ProjectedPeriod = {
|
|
27
|
+
readonly period: string; // "YYYY-MM"
|
|
28
|
+
readonly date: string; // ISO booking date (first of the period month)
|
|
29
|
+
readonly amount: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ScheduleMonthStatus = "posted" | "open" | "forecast";
|
|
33
|
+
|
|
34
|
+
export type ScheduleMonth = {
|
|
35
|
+
readonly period: string;
|
|
36
|
+
readonly planned: number; // Soll from the schedule
|
|
37
|
+
readonly actual: number | null; // Ist (booked) or null
|
|
38
|
+
readonly txId: string | null; // the confirming transaction, for un-confirm (Storno)
|
|
39
|
+
readonly status: ScheduleMonthStatus;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// The transaction shape the host feeds the merge (transaction:list rows with the
|
|
43
|
+
// jsonb `lines` already parsed — driver-normalised, like the integration test's
|
|
44
|
+
// linesOf helper).
|
|
45
|
+
export type LedgerTxRow = {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly reference: string | null;
|
|
48
|
+
readonly lines: readonly Posting[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// "schedule:<id>:<period>" — the stable idempotency + merge key on a confirmation.
|
|
52
|
+
export function scheduleReference(scheduleId: string, period: string): string {
|
|
53
|
+
return `schedule:${scheduleId}:${period}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// "YYYY-MM" ⇄ a month index (year*12 + monthOfYear), so window math is pure
|
|
57
|
+
// integer arithmetic — no Date object (no-date-api guard) and no DST/timezone
|
|
58
|
+
// drift. isoMonth tolerates a full ISO date ("2026-01-15" → "2026-01").
|
|
59
|
+
function isoMonth(iso: string): string {
|
|
60
|
+
return iso.slice(0, 7);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function monthIndex(ym: string): number {
|
|
64
|
+
const [y, m] = ym.split("-");
|
|
65
|
+
return Number(y) * 12 + (Number(m) - 1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function indexToMonth(index: number): string {
|
|
69
|
+
const year = Math.floor(index / 12);
|
|
70
|
+
const month = (index % 12) + 1;
|
|
71
|
+
return `${year}-${String(month).padStart(2, "0")}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// The Soll: every period the schedule covers within [window.from, window.to],
|
|
75
|
+
// clamped to the schedule's own [startDate, endDate]. Monthly only in v1.
|
|
76
|
+
// ponytail: monthly-only; add weekly/quarterly/yearly to the interval union +
|
|
77
|
+
// the step here when a schedule needs it.
|
|
78
|
+
export function projectSchedule(
|
|
79
|
+
schedule: ScheduleDef,
|
|
80
|
+
window: { readonly from: string; readonly to: string },
|
|
81
|
+
): ProjectedPeriod[] {
|
|
82
|
+
const firstIndex = Math.max(
|
|
83
|
+
monthIndex(isoMonth(schedule.startDate)),
|
|
84
|
+
monthIndex(isoMonth(window.from)),
|
|
85
|
+
);
|
|
86
|
+
const lastIndex = Math.min(
|
|
87
|
+
schedule.endDate != null ? monthIndex(isoMonth(schedule.endDate)) : Number.POSITIVE_INFINITY,
|
|
88
|
+
monthIndex(isoMonth(window.to)),
|
|
89
|
+
);
|
|
90
|
+
const periods: ProjectedPeriod[] = [];
|
|
91
|
+
for (let index = firstIndex; index <= lastIndex; index++) {
|
|
92
|
+
const period = indexToMonth(index);
|
|
93
|
+
periods.push({ period, date: `${period}-01`, amount: schedule.amount });
|
|
94
|
+
}
|
|
95
|
+
return periods;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Soll vs. Ist. A month is `posted` when a confirmation for its reference exists
|
|
99
|
+
// AND has not been reversed (Storno); a reversed confirmation drops the month back
|
|
100
|
+
// to `open` so it can be re-confirmed. Past/current unbooked months are `open`,
|
|
101
|
+
// future ones `forecast` (asOf is the boundary — month-granular).
|
|
102
|
+
export function mergeScheduleActuals(
|
|
103
|
+
scheduleId: string,
|
|
104
|
+
projection: readonly ProjectedPeriod[],
|
|
105
|
+
transactions: readonly LedgerTxRow[],
|
|
106
|
+
asOf: string,
|
|
107
|
+
): ScheduleMonth[] {
|
|
108
|
+
const txIds = new Set(transactions.map((t) => t.id));
|
|
109
|
+
// A Storno mirror carries reference = the reversed tx's id, so any tx whose
|
|
110
|
+
// reference names another tx marks that other tx reversed.
|
|
111
|
+
const reversedTxIds = new Set(
|
|
112
|
+
transactions
|
|
113
|
+
.filter((t) => t.reference != null && txIds.has(t.reference))
|
|
114
|
+
.map((t) => t.reference as string),
|
|
115
|
+
);
|
|
116
|
+
const asOfIndex = monthIndex(isoMonth(asOf));
|
|
117
|
+
|
|
118
|
+
return projection.map((projected) => {
|
|
119
|
+
const reference = scheduleReference(scheduleId, projected.period);
|
|
120
|
+
const active = transactions.find((t) => t.reference === reference && !reversedTxIds.has(t.id));
|
|
121
|
+
if (active) {
|
|
122
|
+
return {
|
|
123
|
+
period: projected.period,
|
|
124
|
+
planned: projected.amount,
|
|
125
|
+
actual: Math.abs(active.lines[0]?.amount ?? 0),
|
|
126
|
+
txId: active.id,
|
|
127
|
+
status: "posted",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const status: ScheduleMonthStatus =
|
|
131
|
+
monthIndex(projected.period) <= asOfIndex ? "open" : "forecast";
|
|
132
|
+
return {
|
|
133
|
+
period: projected.period,
|
|
134
|
+
planned: projected.amount,
|
|
135
|
+
actual: null,
|
|
136
|
+
txId: null,
|
|
137
|
+
status,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
package/src/ledger/schemas.ts
CHANGED
|
@@ -47,5 +47,19 @@ export const reverseTransactionPayloadSchema = z.object({
|
|
|
47
47
|
});
|
|
48
48
|
export type ReverseTransactionPayload = z.infer<typeof reverseTransactionPayloadSchema>;
|
|
49
49
|
|
|
50
|
+
// confirm-schedule-period — materialise ONE projected period of a schedule as a
|
|
51
|
+
// posted, balanced entry. `amount` overrides the schedule's amount for that period
|
|
52
|
+
// (e.g. rent came in short); `date` overrides the booking date (default: 1st of
|
|
53
|
+
// the period). Idempotent + reversal-aware in the handler via the schedule
|
|
54
|
+
// reference, so re-confirming a booked month is a no-op and a stornoed month can
|
|
55
|
+
// be re-confirmed.
|
|
56
|
+
export const confirmSchedulePeriodPayloadSchema = z.object({
|
|
57
|
+
scheduleId: z.string().min(1).max(64),
|
|
58
|
+
period: z.string().regex(/^\d{4}-\d{2}$/, "period must be YYYY-MM"),
|
|
59
|
+
amount: z.number().int().positive().optional(),
|
|
60
|
+
date: z.string().min(1).max(32).optional(),
|
|
61
|
+
});
|
|
62
|
+
export type ConfirmSchedulePeriodPayload = z.infer<typeof confirmSchedulePeriodPayloadSchema>;
|
|
63
|
+
|
|
50
64
|
// Re-export so callers building accounts have the type vocabulary in one place.
|
|
51
65
|
export const accountTypeSchema = z.enum(ACCOUNT_TYPES);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Client-safe ledger surface: QN constants + the pure recurring helpers
|
|
3
|
+
// (projection + Soll/Ist merge), nothing else. The full `../ledger` entry
|
|
4
|
+
// re-exports the feature/handlers/executor, which pull bun-db/postgres — a
|
|
5
|
+
// browser bundle that imports from there fails on Node builtins. A client screen
|
|
6
|
+
// (e.g. a rent-cashflow view) imports the dispatch QNs + the pure forecast/merge
|
|
7
|
+
// from HERE, then dispatches via the renderer's useDispatcher.
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
ACCOUNT_TYPES,
|
|
11
|
+
type AccountType,
|
|
12
|
+
LEDGER_FEATURE_NAME,
|
|
13
|
+
LedgerHandlers,
|
|
14
|
+
LedgerQueries,
|
|
15
|
+
SCHEDULE_INTERVALS,
|
|
16
|
+
type ScheduleInterval,
|
|
17
|
+
TRANSACTION_STATUS,
|
|
18
|
+
type TransactionStatus,
|
|
19
|
+
} from "../constants";
|
|
20
|
+
export {
|
|
21
|
+
type LedgerTxRow,
|
|
22
|
+
mergeScheduleActuals,
|
|
23
|
+
type ProjectedPeriod,
|
|
24
|
+
projectSchedule,
|
|
25
|
+
type ScheduleDef,
|
|
26
|
+
type ScheduleMonth,
|
|
27
|
+
type ScheduleMonthStatus,
|
|
28
|
+
scheduleReference,
|
|
29
|
+
} from "../recurring";
|
|
30
|
+
export {
|
|
31
|
+
type ConfirmSchedulePeriodPayload,
|
|
32
|
+
confirmSchedulePeriodPayloadSchema,
|
|
33
|
+
type Posting,
|
|
34
|
+
postingSchema,
|
|
35
|
+
} from "../schemas";
|