@cosmicdrift/kumiko-framework 0.362.0 → 0.364.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/AGENTS.md +307 -0
- package/agents/examples/access-control.md +355 -0
- package/agents/examples/anonymous-access-multitenant.md +443 -0
- package/agents/examples/anonymous-access.md +300 -0
- package/agents/examples/apex-landing.md +528 -0
- package/agents/examples/apex-surface-auth.md +355 -0
- package/agents/examples/api-worker-split.md +434 -0
- package/agents/examples/auth-claims.md +351 -0
- package/agents/examples/auth-foundation-providers.md +155 -0
- package/agents/examples/auth-mfa.md +323 -0
- package/agents/examples/basic-entity.md +420 -0
- package/agents/examples/boot-check.md +122 -0
- package/agents/examples/chat-channels.md +254 -0
- package/agents/examples/compliance-profiles-demo.md +307 -0
- package/agents/examples/cross-feature-events.md +317 -0
- package/agents/examples/crypto-shredding-hr.md +314 -0
- package/agents/examples/currencies-global.md +609 -0
- package/agents/examples/currencies-per-tenant.md +453 -0
- package/agents/examples/custom-fields-basic.md +314 -0
- package/agents/examples/custom-handlers.md +397 -0
- package/agents/examples/delivery-notifications.md +432 -0
- package/agents/examples/designer-demo.md +86 -0
- package/agents/examples/e2e-generator.md +211 -0
- package/agents/examples/embedded-entity-form.md +598 -0
- package/agents/examples/embedded.md +649 -0
- package/agents/examples/encrypted-tenant-config.md +405 -0
- package/agents/examples/error-contract.md +539 -0
- package/agents/examples/event-sourcing.md +1287 -0
- package/agents/examples/feature-toggles.md +263 -0
- package/agents/examples/field-access.md +271 -0
- package/agents/examples/files-post-processing.md +433 -0
- package/agents/examples/i18n.md +166 -0
- package/agents/examples/idempotency.md +179 -0
- package/agents/examples/inbound-mail-triage.md +315 -0
- package/agents/examples/lane-routing.md +246 -0
- package/agents/examples/ledger-banking.md +214 -0
- package/agents/examples/ledger-invoicing.md +235 -0
- package/agents/examples/legal-pages.md +393 -0
- package/agents/examples/lifecycle-hooks.md +368 -0
- package/agents/examples/managed-config.md +428 -0
- package/agents/examples/managed-pages.md +353 -0
- package/agents/examples/notes-history-basic.md +345 -0
- package/agents/examples/ownership.md +396 -0
- package/agents/examples/pdf-audit-export.md +270 -0
- package/agents/examples/pipeline-basics.md +792 -0
- package/agents/examples/public-share-token.md +351 -0
- package/agents/examples/rate-limiting.md +211 -0
- package/agents/examples/realtime-sse.md +187 -0
- package/agents/examples/record-detail-layout.md +297 -0
- package/agents/examples/reference-data.md +151 -0
- package/agents/examples/relations.md +280 -0
- package/agents/examples/screens-nav.md +353 -0
- package/agents/examples/search.md +233 -0
- package/agents/examples/secrets-demo.md +417 -0
- package/agents/examples/security-baseline.md +187 -0
- package/agents/examples/seed-migration.md +99 -0
- package/agents/examples/session-revocation.md +259 -0
- package/agents/examples/state-machine.md +920 -0
- package/agents/examples/tags-basic.md +401 -0
- package/agents/examples/tenant-isolation.md +173 -0
- package/agents/examples/tenant-settings.md +275 -0
- package/agents/examples/tier-admin.md +347 -0
- package/agents/examples/timezones.md +366 -0
- package/agents/examples/user-data-rights.md +410 -0
- package/agents/examples/user-profile.md +226 -0
- package/agents/examples/webhook-step.md +597 -0
- package/agents/examples/wizard-form.md +379 -0
- package/agents/examples/workflow-engine.md +1731 -0
- package/agents/examples/writeform-section.md +300 -0
- package/agents/examples.md +74 -0
- package/agents/features.md +768 -0
- package/agents/widgets.md +181 -0
- package/dist/api/auth-middleware.d.ts +2 -0
- package/dist/api/auth-middleware.js +14 -5
- package/dist/api/auth-routes.js +5 -3
- package/dist/api/extra-route.d.ts +3 -0
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +1 -1
- package/dist/api/redis-sse-broker.js +10 -8
- package/dist/api/routes.js +48 -19
- package/dist/api/server.d.ts +2 -1
- package/dist/api/server.js +18 -6
- package/dist/api/session-cookies.d.ts +1 -0
- package/dist/api/session-cookies.js +20 -5
- package/dist/api/sse-route.js +16 -1
- package/dist/bun-db/index.d.ts +1 -1
- package/dist/bun-db/index.js +1 -1
- package/dist/bun-db/query.d.ts +12 -5
- package/dist/bun-db/query.js +88 -94
- package/dist/crypto/kek-source.js +5 -3
- package/dist/crypto/pii-field-encryption.d.ts +4 -1
- package/dist/crypto/pii-field-encryption.js +2 -2
- package/dist/db/apply-entity-event.js +2 -2
- package/dist/db/collect-table-metas.js +11 -7
- package/dist/db/cursor.js +2 -1
- package/dist/db/dialect.d.ts +1 -0
- package/dist/db/dialect.js +28 -13
- package/dist/db/eagerload.js +2 -2
- package/dist/db/entity-table-from-registry.js +1 -1
- package/dist/db/entity-table-meta.js +5 -13
- package/dist/db/event-store-executor-context.d.ts +5 -6
- package/dist/db/event-store-executor-context.js +29 -27
- package/dist/db/event-store-executor-read.js +40 -25
- package/dist/db/event-store-executor-write.js +18 -20
- package/dist/db/index.d.ts +1 -1
- package/dist/db/index.js +1 -1
- package/dist/db/lock-guard.d.ts +4 -0
- package/dist/db/lock-guard.js +14 -0
- package/dist/db/migrate-generator.js +6 -4
- package/dist/db/migrate-runner.js +32 -35
- package/dist/db/parent-ref-clause.js +2 -2
- package/dist/db/queries/backfill-pii.js +11 -9
- package/dist/db/queries/es-ops.js +1 -1
- package/dist/db/queries/event-consumer.js +17 -17
- package/dist/db/queries/event-store-admin.js +6 -6
- package/dist/db/queries/event-store-transfer.js +2 -2
- package/dist/db/queries/event-store.js +2 -2
- package/dist/db/queries/pii-backfill-state.js +2 -2
- package/dist/db/queries/raw-sql.js +1 -1
- package/dist/db/queries/test-stack.js +1 -1
- package/dist/db/reference-data.js +3 -5
- package/dist/db/table-builder.js +6 -4
- package/dist/db/table-introspection.d.ts +16 -0
- package/dist/db/table-introspection.js +76 -0
- package/dist/db/tenant-db-runner.d.ts +4 -0
- package/dist/db/tenant-db-runner.js +17 -0
- package/dist/db/tenant-db.d.ts +5 -2
- package/dist/db/tenant-db.js +34 -14
- package/dist/derivatives/derivatives-context.js +1 -1
- package/dist/derivatives/variant-key.js +2 -1
- package/dist/engine/boot-validator/access-declarations.js +2 -2
- package/dist/engine/boot-validator/api-ext.js +3 -3
- package/dist/engine/boot-validator/boot-check.js +1 -1
- package/dist/engine/boot-validator/config-deps.js +13 -13
- package/dist/engine/boot-validator/detail-screens.js +1 -1
- package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
- package/dist/engine/boot-validator/entity-handler.js +84 -35
- package/dist/engine/boot-validator/entity-list-screens.js +3 -3
- package/dist/engine/boot-validator/handler-field-references.js +19 -9
- package/dist/engine/boot-validator/i18n-keys.js +2 -2
- package/dist/engine/boot-validator/nav.js +1 -1
- package/dist/engine/boot-validator/pii-retention.js +9 -9
- package/dist/engine/boot-validator/projection-list-screens.js +5 -5
- package/dist/engine/boot-validator/query-output-columns.js +7 -7
- package/dist/engine/boot-validator/screens.d.ts +2 -1
- package/dist/engine/boot-validator/screens.js +155 -96
- package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
- package/dist/engine/changeset-changes.js +3 -3
- package/dist/engine/define-handler.d.ts +2 -2
- package/dist/engine/define-handler.js +11 -7
- package/dist/engine/embedded-derived.js +10 -6
- package/dist/engine/extensions/escape-hatch-usage.js +2 -1
- package/dist/engine/factories.js +5 -6
- package/dist/engine/feature-ast/extractors/events.js +43 -33
- package/dist/engine/feature-ast/extractors/handlers.js +2 -0
- package/dist/engine/feature-ast/patcher.d.ts +3 -3
- package/dist/engine/feature-ast/patcher.js +3 -3
- package/dist/engine/feature-ast/pattern-change-schema.d.ts +10 -8
- package/dist/engine/feature-ast/pattern-change-schema.js +4 -3
- package/dist/engine/feature-ast/patterns.d.ts +3 -3
- package/dist/engine/feature-ast/render.js +16 -5
- package/dist/engine/feature-changelog.d.ts +2 -1
- package/dist/engine/feature-changelog.js +8 -6
- package/dist/engine/feature-config-events-jobs.d.ts +17 -7
- package/dist/engine/feature-config-events-jobs.js +20 -5
- package/dist/engine/feature-entity-handlers.d.ts +2 -2
- package/dist/engine/feature-entity-handlers.js +12 -3
- package/dist/engine/feature-ui-extensions.js +2 -3
- package/dist/engine/handler-field-references.d.ts +21 -2
- package/dist/engine/handler-field-references.js +55 -17
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.js +1 -0
- package/dist/engine/ownership.js +7 -20
- package/dist/engine/pattern-library/mixed-schemas.js +0 -1
- package/dist/engine/registry-ingest.d.ts +2 -1
- package/dist/engine/registry-ingest.js +85 -2
- package/dist/engine/registry-validate.d.ts +1 -0
- package/dist/engine/registry-validate.js +38 -8
- package/dist/engine/registry.js +2 -1
- package/dist/engine/schema-builder.js +3 -4
- package/dist/engine/soft-delete-cleanup.js +12 -4
- package/dist/engine/steps/call-feature.js +1 -3
- package/dist/engine/steps/retry.js +2 -1
- package/dist/engine/steps/unsafe-projection-delete.js +2 -2
- package/dist/engine/steps/unsafe-projection-upsert.js +4 -15
- package/dist/engine/template-variables.d.ts +32 -0
- package/dist/engine/template-variables.js +80 -0
- package/dist/engine/tier-resolver-extension.js +2 -3
- package/dist/engine/types/index.d.ts +2 -2
- package/dist/engine/validate-projection-allowlist.js +12 -13
- package/dist/engine/where-rule-lint.js +8 -7
- package/dist/env/_zod-introspect.d.ts +1 -0
- package/dist/errors/classes.js +1 -0
- package/dist/errors/kumiko-error.d.ts +2 -0
- package/dist/errors/kumiko-error.js +2 -0
- package/dist/errors/reasons.d.ts +1 -0
- package/dist/errors/reasons.js +2 -0
- package/dist/errors/write-error-info.js +2 -0
- package/dist/event-store/tenant-db-append.js +2 -2
- package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
- package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
- package/dist/files/bind-pending-file-refs.d.ts +2 -0
- package/dist/files/bind-pending-file-refs.js +3 -3
- package/dist/files/bind-referenced-files.write.d.ts +1 -1
- package/dist/files/bind-referenced-files.write.js +22 -13
- package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
- package/dist/files/create-file-ref-from-bytes.js +67 -4
- package/dist/files/create-file-ref.d.ts +22 -0
- package/dist/files/create-file-ref.js +53 -25
- package/dist/files/file-handle.d.ts +1 -1
- package/dist/files/file-handle.js +8 -0
- package/dist/files/file-routes.js +2 -2
- package/dist/files/index.d.ts +2 -2
- package/dist/files/index.js +1 -1
- package/dist/files/load-save-file-refs.d.ts +10 -0
- package/dist/files/load-save-file-refs.js +47 -0
- package/dist/files/local-provider.js +2 -1
- package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
- package/dist/files/purge-expired-file-ref-bytes.js +45 -19
- package/dist/i18n/index.d.ts +1 -1
- package/dist/i18n/index.js +1 -1
- package/dist/i18n/mail-registry.d.ts +2 -0
- package/dist/i18n/mail-registry.js +7 -0
- package/dist/jobs/index.d.ts +1 -1
- package/dist/jobs/job-runner.d.ts +4 -1
- package/dist/jobs/job-runner.js +13 -9
- package/dist/observability/escape-hatch-report.d.ts +1 -1
- package/dist/observability/escape-hatch-report.js +17 -5
- package/dist/pipeline/cascade-handler.js +5 -2
- package/dist/pipeline/dispatch-batch.js +79 -31
- package/dist/pipeline/dispatch-query.js +2 -3
- package/dist/pipeline/dispatch-shared.d.ts +7 -2
- package/dist/pipeline/dispatch-shared.js +142 -34
- package/dist/pipeline/dispatch-write.js +34 -22
- package/dist/pipeline/event-dispatcher-delivery.js +2 -3
- package/dist/pipeline/index.d.ts +2 -0
- package/dist/pipeline/index.js +2 -0
- package/dist/pipeline/member-read-only-transaction.js +1 -1
- package/dist/pipeline/notify-scope.d.ts +13 -0
- package/dist/pipeline/notify-scope.js +32 -0
- package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
- package/dist/pipeline/pre-transaction-reservations.js +28 -9
- package/dist/pipeline/system-hooks.js +2 -1
- package/dist/pipeline/system-identity-switch.js +2 -2
- package/dist/rate-limit/bucket.d.ts +3 -3
- package/dist/rate-limit/bucket.js +14 -4
- package/dist/redis/cache-sync-bus.js +9 -3
- package/dist/search/meilisearch-adapter.js +39 -13
- package/dist/secrets/stored-envelope.js +7 -7
- package/dist/stack/push-entity-projection-tables.js +2 -3
- package/dist/stack/table-helpers.js +10 -26
- package/dist/stack/test-stack.js +2 -5
- package/dist/testing/multipart-helper.d.ts +0 -6
- package/dist/testing/multipart-helper.js +5 -5
- package/dist/testing/without-ambient-temporal.js +4 -4
- package/dist/utils/is-plain-object.d.ts +1 -2
- package/dist/utils/is-plain-object.js +1 -4
- package/package.json +7 -5
- package/src/changes.json +251 -8
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/lane-routing. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# lane-routing
|
|
4
|
+
|
|
5
|
+
runIn lane-routing: r.job pinned to api/worker deploy-lanes, event-chain via ctx.appendEvent, verified end-to-end through createAllInOneEntrypoint.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Lane Routing
|
|
10
|
+
|
|
11
|
+
Pin jobs to deploy lanes (`api` | `worker`) with event-triggered fan-out.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- `r.job({ runIn: "worker" })` after an HTTP write
|
|
16
|
+
- End-to-end verification via `createAllInOneEntrypoint`
|
|
17
|
+
|
|
18
|
+
## Source
|
|
19
|
+
|
|
20
|
+
Feature entry point: `src/feature.ts`.
|
|
21
|
+
|
|
22
|
+
## Tests
|
|
23
|
+
|
|
24
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
cd samples/recipes/lane-routing
|
|
28
|
+
bun test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Source files
|
|
32
|
+
|
|
33
|
+
### src/__tests__/feature.integration.test.ts
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// Lane-Routing Sample — End-to-End Proof
|
|
37
|
+
//
|
|
38
|
+
// What we're proving (via createAllInOneEntrypoint + BullMQ + Hono — no
|
|
39
|
+
// framework-internal shortcuts):
|
|
40
|
+
// 1. A write lands on the HTTP surface.
|
|
41
|
+
// 2. The command-dispatcher fires `jobRunner.handleEvent` as an
|
|
42
|
+
// afterCommit-hook (welle-2.5-gap fix, auto-wired by the entrypoint).
|
|
43
|
+
// 3. Both `runIn: "worker"` jobs fan out onto the `kumiko-jobs-worker`
|
|
44
|
+
// queue and execute — the worker-side BullMQ worker picks them up,
|
|
45
|
+
// the api-side one does not (even in all-in-one, the lane-scoped
|
|
46
|
+
// dispatch() routes the job to the right queue).
|
|
47
|
+
//
|
|
48
|
+
// If the feature were wired with `runLocalJobs: true`-style api-lane
|
|
49
|
+
// jobs, the scaling.md pick-guide would apply — this sample stays
|
|
50
|
+
// worker-only because that's the prod default.
|
|
51
|
+
|
|
52
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
53
|
+
import { createRegistry } from "@cosmicdrift/kumiko-framework/engine";
|
|
54
|
+
import { createAllInOneEntrypoint } from "@cosmicdrift/kumiko-framework/entrypoint";
|
|
55
|
+
import {
|
|
56
|
+
createArchivedStreamsTable,
|
|
57
|
+
createEventsTable,
|
|
58
|
+
} from "@cosmicdrift/kumiko-framework/event-store";
|
|
59
|
+
import { createEventConsumerStateTable } from "@cosmicdrift/kumiko-framework/pipeline";
|
|
60
|
+
import {
|
|
61
|
+
createTestDb,
|
|
62
|
+
createTestRedis,
|
|
63
|
+
type TestDb,
|
|
64
|
+
type TestRedis,
|
|
65
|
+
TestUsers,
|
|
66
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
67
|
+
import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
68
|
+
import { createLaneRoutingFeature, renderedReceipts, sentConfirmations } from "../feature";
|
|
69
|
+
|
|
70
|
+
const JWT = "lane-routing-sample-secret-minimum-32-chars!";
|
|
71
|
+
const adminUser = TestUsers.admin;
|
|
72
|
+
|
|
73
|
+
let testDb: TestDb;
|
|
74
|
+
let testRedis: TestRedis;
|
|
75
|
+
|
|
76
|
+
beforeAll(async () => {
|
|
77
|
+
[testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
|
|
78
|
+
await createEventsTable(testDb.db);
|
|
79
|
+
await createArchivedStreamsTable(testDb.db);
|
|
80
|
+
await createEventConsumerStateTable(testDb.db);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterAll(async () => {
|
|
84
|
+
await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(() => {
|
|
88
|
+
renderedReceipts.length = 0;
|
|
89
|
+
sentConfirmations.length = 0;
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("lane-routing sample", () => {
|
|
93
|
+
test("order.create fans out to render-receipt + send-confirmation on the worker lane", async () => {
|
|
94
|
+
const registry = createRegistry([createLaneRoutingFeature()]);
|
|
95
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
96
|
+
const entry = createAllInOneEntrypoint({
|
|
97
|
+
registry,
|
|
98
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
99
|
+
jwtSecret: JWT,
|
|
100
|
+
redisUrl,
|
|
101
|
+
queueNamePrefix: `lane-routing-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
102
|
+
});
|
|
103
|
+
await entry.start();
|
|
104
|
+
try {
|
|
105
|
+
const token = await entry.jwt.sign(adminUser);
|
|
106
|
+
const res = await entry.app.request("/api/write", {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: {
|
|
109
|
+
"Content-Type": "application/json",
|
|
110
|
+
Authorization: `Bearer ${token}`,
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify({
|
|
113
|
+
type: "orders:write:order:create",
|
|
114
|
+
payload: { customerName: "Acme GmbH", amount: 299 },
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
const result = (await res.json()) as { isSuccess: boolean };
|
|
118
|
+
expect(result.isSuccess).toBe(true);
|
|
119
|
+
|
|
120
|
+
// Both jobs are declared runIn:"worker" — they enqueue into
|
|
121
|
+
// kumiko-jobs-worker-*, and the all-in-one worker runner consumes
|
|
122
|
+
// them. Without welle-2.6 the enqueue would drop silently (welle-2.5
|
|
123
|
+
// gap) or the routing would hit the wrong queue.
|
|
124
|
+
await waitFor(() => {
|
|
125
|
+
expect(renderedReceipts).toHaveLength(1);
|
|
126
|
+
expect(sentConfirmations).toHaveLength(1);
|
|
127
|
+
expect(renderedReceipts[0]?.customerName).toBe("Acme GmbH");
|
|
128
|
+
expect(sentConfirmations[0]?.amount).toBe(299);
|
|
129
|
+
});
|
|
130
|
+
} finally {
|
|
131
|
+
await entry.stop();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### src/feature.ts
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
// Lane-Routing Sample
|
|
141
|
+
//
|
|
142
|
+
// Shows `r.job({ runIn: "worker" })` end-to-end: an HTTP write emits an
|
|
143
|
+
// event, the event fans out to two worker-lane jobs via BullMQ, and the
|
|
144
|
+
// jobs actually execute on the worker lane (kumiko-jobs-worker queue).
|
|
145
|
+
//
|
|
146
|
+
// Deploy-shape: any of the three entrypoints. The test uses
|
|
147
|
+
// createAllInOneEntrypoint so the Single Integration-Test covers the full
|
|
148
|
+
// path without having to coordinate two processes.
|
|
149
|
+
|
|
150
|
+
import {
|
|
151
|
+
createEntity,
|
|
152
|
+
createNumberField,
|
|
153
|
+
createTextField,
|
|
154
|
+
defineFeature,
|
|
155
|
+
type FeatureDefinition,
|
|
156
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
157
|
+
import { z } from "zod";
|
|
158
|
+
|
|
159
|
+
// In-memory collectors so the integration-test can assert both jobs ran
|
|
160
|
+
// and got the right payload. A real app would do DB writes, HTTP calls,
|
|
161
|
+
// PDF rendering, mail sending — whatever the side-effect is, it's
|
|
162
|
+
// opaque to the framework.
|
|
163
|
+
export const renderedReceipts: Array<{ customerName: string; amount: number }> = [];
|
|
164
|
+
export const sentConfirmations: Array<{ customerName: string; amount: number }> = [];
|
|
165
|
+
|
|
166
|
+
// Entity-Dekl sichert den qualified-name-Prefix "orders:write:order:create"
|
|
167
|
+
// für den Write-Handler unten und damit den Job-Trigger auf diesem Event.
|
|
168
|
+
// Persistenz passiert im Custom-Handler nicht (id ist hardcoded für den
|
|
169
|
+
// Sample-Test) — in einer echten App würde ein entityWriteHandler in die DB
|
|
170
|
+
// schreiben.
|
|
171
|
+
const orderEntity = createEntity({
|
|
172
|
+
table: "read_orders",
|
|
173
|
+
fields: {
|
|
174
|
+
customerName: createTextField({ personal: { of: "id" }, find: "none", required: true }),
|
|
175
|
+
amount: createNumberField({ required: true }),
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
export function createLaneRoutingFeature(): FeatureDefinition {
|
|
180
|
+
return defineFeature("orders", (r) => {
|
|
181
|
+
r.entity("order", orderEntity);
|
|
182
|
+
|
|
183
|
+
// Write handler — runs in the API process (or wherever the command
|
|
184
|
+
// lands). Event "orders:write:order:create" is the auto-generated
|
|
185
|
+
// write-event both jobs trigger on.
|
|
186
|
+
r.writeHandler(
|
|
187
|
+
"order:create",
|
|
188
|
+
z.object({
|
|
189
|
+
customerName: z.string().min(1),
|
|
190
|
+
amount: z.number().positive(),
|
|
191
|
+
}),
|
|
192
|
+
async (event) => ({
|
|
193
|
+
isSuccess: true as const,
|
|
194
|
+
data: {
|
|
195
|
+
id: 1,
|
|
196
|
+
customerName: event.payload.customerName,
|
|
197
|
+
amount: event.payload.amount,
|
|
198
|
+
},
|
|
199
|
+
}),
|
|
200
|
+
{
|
|
201
|
+
access: {
|
|
202
|
+
openToAll: {
|
|
203
|
+
reason:
|
|
204
|
+
"demo recipe: any signed-in user may place an order; the handler is a stub with no persistence",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
// Receipt-rendering: heavy CPU work. Pinned to `worker` so the
|
|
211
|
+
// BullMQ consumer on the API process never picks it up — even if the
|
|
212
|
+
// API happens to run runLocalJobs for something else. `runIn` is the
|
|
213
|
+
// Welle-2.6 contract that makes this stable across deploy shapes.
|
|
214
|
+
r.job(
|
|
215
|
+
"render-receipt",
|
|
216
|
+
{
|
|
217
|
+
trigger: { on: "orders:write:order:create" },
|
|
218
|
+
runIn: "worker",
|
|
219
|
+
},
|
|
220
|
+
async (payload) => {
|
|
221
|
+
renderedReceipts.push({
|
|
222
|
+
customerName: payload["customerName"] as string,
|
|
223
|
+
amount: payload["amount"] as number,
|
|
224
|
+
});
|
|
225
|
+
},
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// Confirmation-mail: external I/O. Also worker-lane. Both jobs fan
|
|
229
|
+
// out from the same event in parallel — BullMQ handles their order
|
|
230
|
+
// independently.
|
|
231
|
+
r.job(
|
|
232
|
+
"send-confirmation",
|
|
233
|
+
{
|
|
234
|
+
trigger: { on: "orders:write:order:create" },
|
|
235
|
+
runIn: "worker",
|
|
236
|
+
},
|
|
237
|
+
async (payload) => {
|
|
238
|
+
sentConfirmations.push({
|
|
239
|
+
customerName: payload["customerName"] as string,
|
|
240
|
+
amount: payload["amount"] as number,
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
```
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/ledger-banking. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# ledger-banking
|
|
4
|
+
|
|
5
|
+
Bank accounts + transfers on the ledger primitive — balances are pure queries, never stored.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Ledger Banking
|
|
10
|
+
|
|
11
|
+
Model bank accounts and transfers on the `ledger` primitive. This is the
|
|
12
|
+
simplest double-entry case: every account is an `asset`, a transfer is **one
|
|
13
|
+
balanced transaction**, and a balance is a **pure query** over the postings —
|
|
14
|
+
never a stored field you keep in sync.
|
|
15
|
+
|
|
16
|
+
## What it shows
|
|
17
|
+
|
|
18
|
+
A transfer moves money between two bank accounts as a single balanced transaction, and every balance is a pure query over the postings rather than a stored field you keep in sync.
|
|
19
|
+
|
|
20
|
+
- **A transfer is one transaction, not two updates** — moving €200 from
|
|
21
|
+
checking to savings is a single `ledger:write:create-transaction` that debits
|
|
22
|
+
savings (`+20000`) and credits checking (`−20000`). The two lines sum to 0,
|
|
23
|
+
which is what makes the entry _balanced_; the framework rejects anything else.
|
|
24
|
+
- **Balances are derived, never stored** — there is no `balance` column on an
|
|
25
|
+
account. `ledger:query:report:balances` sums the postings on the fly and
|
|
26
|
+
applies each account's natural sign (asset = debit-normal).
|
|
27
|
+
- **The trial balance is the safety net** — because every entry sums to 0, the
|
|
28
|
+
sum of _all_ postings is always 0. The flow asserts it after the transfers:
|
|
29
|
+
if it ever drifts, the books are inconsistent.
|
|
30
|
+
- **Cents are signed integers** — amounts are integer minor units; `+` is a
|
|
31
|
+
debit, `−` is a credit. No floats, no rounding drift.
|
|
32
|
+
|
|
33
|
+
## The banking flow
|
|
34
|
+
|
|
35
|
+
The flow below is embedded from `usage.ts` and is run end-to-end against the
|
|
36
|
+
real dispatcher + DB by this recipe's integration test (`the documented
|
|
37
|
+
bankingFlow …`):
|
|
38
|
+
|
|
39
|
+
```ts file=<rootDir>/_samples/recipes-ledger-banking/src/usage.ts
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Why no `feature.ts`?
|
|
43
|
+
|
|
44
|
+
Unlike most recipes, banking mounts no host feature — the `ledger` bundle _is_
|
|
45
|
+
the host. Accounts and transactions are its own entities, so the recipe just
|
|
46
|
+
dispatches `ledger:*` handlers. Mount it once with `createLedgerFeature()` and
|
|
47
|
+
the whole chart of accounts + journal is available.
|
|
48
|
+
|
|
49
|
+
## Run
|
|
50
|
+
|
|
51
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bun test src/__tests__/feature.integration.test.ts
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Source files
|
|
58
|
+
|
|
59
|
+
### src/__tests__/feature.integration.test.ts
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
// Ledger Banking — integration test.
|
|
63
|
+
//
|
|
64
|
+
// Proves the documented bankingFlow end-to-end via the real dispatcher + DB:
|
|
65
|
+
// two asset accounts funded from equity, one transfer between them, and the
|
|
66
|
+
// balances read back as a pure query. The trial balance stays 0 — the golden
|
|
67
|
+
// double-entry invariant.
|
|
68
|
+
|
|
69
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
70
|
+
import {
|
|
71
|
+
accountEntity,
|
|
72
|
+
createLedgerFeature,
|
|
73
|
+
transactionEntity,
|
|
74
|
+
} from "@cosmicdrift/kumiko-bundled-features/ledger";
|
|
75
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
76
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
77
|
+
import {
|
|
78
|
+
createTestUser,
|
|
79
|
+
setupTestStack,
|
|
80
|
+
type TestStack,
|
|
81
|
+
unsafeCreateEntityTable,
|
|
82
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
83
|
+
import { bankingFlow, type LedgerClient } from "../usage";
|
|
84
|
+
|
|
85
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
86
|
+
const ledger = createLedgerFeature();
|
|
87
|
+
|
|
88
|
+
let stack: TestStack;
|
|
89
|
+
|
|
90
|
+
// Adapt the test stack to the host-facing LedgerClient the docs embed uses.
|
|
91
|
+
const client: LedgerClient = {
|
|
92
|
+
write: <T>(type: string, payload: unknown) => stack.http.writeOk<T>(type, payload, admin),
|
|
93
|
+
query: <T>(type: string, payload: unknown) => stack.http.queryOk<T>(type, payload, admin),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
beforeAll(async () => {
|
|
97
|
+
stack = await setupTestStack({ features: [ledger] });
|
|
98
|
+
await unsafeCreateEntityTable(stack.db, accountEntity);
|
|
99
|
+
await unsafeCreateEntityTable(stack.db, transactionEntity);
|
|
100
|
+
await createEventsTable(stack.db);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
afterAll(async () => {
|
|
104
|
+
await stack.cleanup();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
beforeEach(async () => {
|
|
108
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
109
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_accounts");
|
|
110
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_transactions");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("ledger-banking recipe — transfer + derived balance", () => {
|
|
114
|
+
test("the documented bankingFlow funds, transfers, and reads balances back", async () => {
|
|
115
|
+
const result = await bankingFlow(client);
|
|
116
|
+
|
|
117
|
+
expect(result.checking).toBe(80000);
|
|
118
|
+
expect(result.savings).toBe(20000);
|
|
119
|
+
// A transfer never destroys value — the books stay consistent.
|
|
120
|
+
expect(result.trialBalance).toBe(0);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### src/usage.ts
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// Ledger Banking — money on accounts, moved by balanced transactions.
|
|
129
|
+
//
|
|
130
|
+
// Banking is the simplest double-entry case: each bank account is an `asset`,
|
|
131
|
+
// and a transfer is ONE transaction that debits the receiving account (+) and
|
|
132
|
+
// credits the sending one (−). The two lines sum to 0 — that is what makes the
|
|
133
|
+
// entry "balanced". No balance is ever stored; it's a pure query over the
|
|
134
|
+
// postings (ledger:query:report:balances).
|
|
135
|
+
//
|
|
136
|
+
// This recipe's integration test runs `bankingFlow` below against the real
|
|
137
|
+
// dispatcher + DB.
|
|
138
|
+
|
|
139
|
+
// The bundle exports its qualified handler/query names — use them instead of
|
|
140
|
+
// hardcoding the wire strings.
|
|
141
|
+
import {
|
|
142
|
+
type AccountType,
|
|
143
|
+
LedgerHandlers,
|
|
144
|
+
LedgerQueries,
|
|
145
|
+
} from "@cosmicdrift/kumiko-bundled-features/ledger";
|
|
146
|
+
|
|
147
|
+
// The minimal surface the flow needs from a host dispatcher. An app's client
|
|
148
|
+
// satisfies this; the integration test adapts the test stack to it.
|
|
149
|
+
export type LedgerClient = {
|
|
150
|
+
write: <T>(type: string, payload: unknown) => Promise<T>;
|
|
151
|
+
query: <T>(type: string, payload: unknown) => Promise<T>;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
type BalancesReport = {
|
|
155
|
+
accounts: Array<{ id: string; name: string; balance: number }>;
|
|
156
|
+
trialBalance: number;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// Open an account in the tenant's chart of accounts → returns its id.
|
|
160
|
+
async function openAccount(client: LedgerClient, name: string, type: AccountType): Promise<string> {
|
|
161
|
+
const { id } = await client.write<{ id: string }>(LedgerHandlers.createAccount, { name, type });
|
|
162
|
+
return id;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// A transfer is one balanced transaction: debit the receiver (+amount), credit
|
|
166
|
+
// the sender (−amount). Amounts are signed integer minor units (cents).
|
|
167
|
+
async function transfer(
|
|
168
|
+
client: LedgerClient,
|
|
169
|
+
opts: { from: string; to: string; amount: number; description: string; date: string },
|
|
170
|
+
): Promise<void> {
|
|
171
|
+
await client.write(LedgerHandlers.createTransaction, {
|
|
172
|
+
date: opts.date,
|
|
173
|
+
description: opts.description,
|
|
174
|
+
lines: [
|
|
175
|
+
{ accountId: opts.to, amount: opts.amount },
|
|
176
|
+
{ accountId: opts.from, amount: -opts.amount },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Fund a checking account from equity, move €200 of it to savings, then read
|
|
182
|
+
// the closing balances back. Returns the balances + the trial balance (Σ of all
|
|
183
|
+
// raw postings — 0 on a consistent ledger).
|
|
184
|
+
export async function bankingFlow(client: LedgerClient) {
|
|
185
|
+
const opening = await openAccount(client, "Anfangsbestand", "equity");
|
|
186
|
+
const checking = await openAccount(client, "Girokonto", "asset");
|
|
187
|
+
const savings = await openAccount(client, "Sparkonto", "asset");
|
|
188
|
+
|
|
189
|
+
await transfer(client, {
|
|
190
|
+
from: opening,
|
|
191
|
+
to: checking,
|
|
192
|
+
amount: 100000,
|
|
193
|
+
description: "Anfangsbestand Girokonto",
|
|
194
|
+
date: "2026-01-01",
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await transfer(client, {
|
|
198
|
+
from: checking,
|
|
199
|
+
to: savings,
|
|
200
|
+
amount: 20000,
|
|
201
|
+
description: "Sparrate Januar",
|
|
202
|
+
date: "2026-01-05",
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const report = await client.query<BalancesReport>(LedgerQueries.reportBalances, {});
|
|
206
|
+
const balanceOf = (id: string) => report.accounts.find((a) => a.id === id)?.balance ?? 0;
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
checking: balanceOf(checking), // 80000 — €1,000 funded − €200 moved out
|
|
210
|
+
savings: balanceOf(savings), // 20000 — €200 moved in
|
|
211
|
+
trialBalance: report.trialBalance, // 0 — the books are consistent
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
```
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/ledger-invoicing. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# ledger-invoicing
|
|
4
|
+
|
|
5
|
+
Accrual invoicing on the ledger primitive — revenue at invoice time, VAT as a liability, receivable cleared on payment.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Ledger Invoicing
|
|
10
|
+
|
|
11
|
+
Model accrual invoicing (Rechnungswesen) on the `ledger` primitive. Issuing an
|
|
12
|
+
invoice and receiving its payment are **two separate transactions**, and
|
|
13
|
+
revenue is recognised when the invoice is **issued** — not when the cash
|
|
14
|
+
arrives. This is the difference between banking (cash moves) and accounting
|
|
15
|
+
(obligations are recorded as they arise).
|
|
16
|
+
|
|
17
|
+
## What it shows
|
|
18
|
+
|
|
19
|
+
Accrual accounting recognises revenue when an invoice is issued, not when the cash arrives: the invoice and its payment are two separate transactions, VAT is held as a liability, and the receivable clears to zero once paid.
|
|
20
|
+
|
|
21
|
+
- **Accrual: revenue at invoice time** — issuing the invoice debits Forderungen
|
|
22
|
+
(accounts receivable) and credits Erlöse (income). The revenue is on the P&L
|
|
23
|
+
immediately; the flow asserts it via `report:income-statement` _before_ any
|
|
24
|
+
payment exists.
|
|
25
|
+
- **VAT is a liability, not income** — the gross €1,190 splits into €1,000 net
|
|
26
|
+
revenue and €190 Umsatzsteuer. The VAT is money you owe the tax office, so it
|
|
27
|
+
lands on a `liability` account, never inflating revenue.
|
|
28
|
+
- **The receivable has a lifecycle** — it's raised at the gross amount on
|
|
29
|
+
invoice and cleared to 0 on payment (debit Bank / credit Forderungen). An open
|
|
30
|
+
receivable balance _is_ your "unpaid invoices" total, with no extra bookkeeping.
|
|
31
|
+
- **Reports re-derive, payment doesn't double-count income** — booking the
|
|
32
|
+
payment moves cash and settles the receivable; it touches no income account,
|
|
33
|
+
so revenue stays €1,000.
|
|
34
|
+
|
|
35
|
+
## The invoicing flow
|
|
36
|
+
|
|
37
|
+
The flow below is embedded from `usage.ts` and is run end-to-end against the
|
|
38
|
+
real dispatcher + DB by this recipe's integration test (`the documented
|
|
39
|
+
invoicingFlow …`):
|
|
40
|
+
|
|
41
|
+
```ts file=<rootDir>/_samples/recipes-ledger-invoicing/src/usage.ts
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Banking vs. invoicing
|
|
45
|
+
|
|
46
|
+
Both recipes use the same primitive — accounts + balanced transactions — but
|
|
47
|
+
record different things:
|
|
48
|
+
|
|
49
|
+
| | [ledger-banking](../ledger-banking/) | ledger-invoicing |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| Accounts | all `asset` (bank accounts) | `asset` + `income` + `liability` |
|
|
52
|
+
| A transaction is… | a transfer (cash moves) | an obligation (invoice) or its settlement |
|
|
53
|
+
| Revenue recognised | n/a | at invoice time (accrual) |
|
|
54
|
+
| Reports surfaced | balances | balances + P&L |
|
|
55
|
+
|
|
56
|
+
## Run
|
|
57
|
+
|
|
58
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
bun test src/__tests__/feature.integration.test.ts
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Source files
|
|
65
|
+
|
|
66
|
+
### src/__tests__/feature.integration.test.ts
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// Ledger Invoicing — integration test.
|
|
70
|
+
//
|
|
71
|
+
// Proves the documented invoicingFlow end-to-end via the real dispatcher + DB:
|
|
72
|
+
// - issuing an invoice recognises revenue immediately (accrual), splits VAT
|
|
73
|
+
// off as a liability, and raises a receivable for the gross amount
|
|
74
|
+
// - the payment clears the receivable and brings in the cash — without
|
|
75
|
+
// touching the P&L a second time
|
|
76
|
+
// - the trial balance stays 0 throughout
|
|
77
|
+
|
|
78
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
79
|
+
import {
|
|
80
|
+
accountEntity,
|
|
81
|
+
createLedgerFeature,
|
|
82
|
+
transactionEntity,
|
|
83
|
+
} from "@cosmicdrift/kumiko-bundled-features/ledger";
|
|
84
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
85
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
86
|
+
import {
|
|
87
|
+
createTestUser,
|
|
88
|
+
setupTestStack,
|
|
89
|
+
type TestStack,
|
|
90
|
+
unsafeCreateEntityTable,
|
|
91
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
92
|
+
import { invoicingFlow, type LedgerClient } from "../usage";
|
|
93
|
+
|
|
94
|
+
const admin = createTestUser({ roles: ["TenantAdmin"] });
|
|
95
|
+
const ledger = createLedgerFeature();
|
|
96
|
+
|
|
97
|
+
let stack: TestStack;
|
|
98
|
+
|
|
99
|
+
const client: LedgerClient = {
|
|
100
|
+
write: <T>(type: string, payload: unknown) => stack.http.writeOk<T>(type, payload, admin),
|
|
101
|
+
query: <T>(type: string, payload: unknown) => stack.http.queryOk<T>(type, payload, admin),
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
beforeAll(async () => {
|
|
105
|
+
stack = await setupTestStack({ features: [ledger] });
|
|
106
|
+
await unsafeCreateEntityTable(stack.db, accountEntity);
|
|
107
|
+
await unsafeCreateEntityTable(stack.db, transactionEntity);
|
|
108
|
+
await createEventsTable(stack.db);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterAll(async () => {
|
|
112
|
+
await stack.cleanup();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
beforeEach(async () => {
|
|
116
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
117
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_accounts");
|
|
118
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_ledger_transactions");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("ledger-invoicing recipe — accrual + receivable lifecycle", () => {
|
|
122
|
+
test("the documented invoicingFlow recognises revenue at invoice time, clears AR on payment", async () => {
|
|
123
|
+
const result = await invoicingFlow(client);
|
|
124
|
+
|
|
125
|
+
// Revenue recognised when invoiced — before any cash moved.
|
|
126
|
+
expect(result.revenueBeforePayment).toBe(100000);
|
|
127
|
+
// Payment doesn't double-count income — revenue is still €1,000 afterwards.
|
|
128
|
+
expect(result.revenueAfterPayment).toBe(100000);
|
|
129
|
+
// Receivable settled once paid.
|
|
130
|
+
expect(result.receivablesAfterPayment).toBe(0);
|
|
131
|
+
// Gross cash in; VAT held as a liability owed to the tax office.
|
|
132
|
+
expect(result.bank).toBe(119000);
|
|
133
|
+
expect(result.vatOwed).toBe(19000);
|
|
134
|
+
expect(result.trialBalance).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### src/usage.ts
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
// Ledger Invoicing — accrual accounting (Rechnungswesen).
|
|
143
|
+
//
|
|
144
|
+
// Issuing an invoice and receiving its payment are TWO separate transactions,
|
|
145
|
+
// and revenue is recognised when the invoice is ISSUED (accrual), not when the
|
|
146
|
+
// cash arrives:
|
|
147
|
+
//
|
|
148
|
+
// 1. Issue: debit Forderungen (asset, accounts receivable) for the GROSS
|
|
149
|
+
// amount, credit Erlöse (income) for the NET and Umsatzsteuer
|
|
150
|
+
// (liability) for the VAT. Revenue hits the P&L now.
|
|
151
|
+
// 2. Payment: debit Bank (asset), credit Forderungen — the receivable nets
|
|
152
|
+
// back to 0 once the customer has paid.
|
|
153
|
+
//
|
|
154
|
+
// This recipe's integration test runs `invoicingFlow` below against the real
|
|
155
|
+
// dispatcher + DB.
|
|
156
|
+
|
|
157
|
+
import {
|
|
158
|
+
type AccountType,
|
|
159
|
+
LedgerHandlers,
|
|
160
|
+
LedgerQueries,
|
|
161
|
+
} from "@cosmicdrift/kumiko-bundled-features/ledger";
|
|
162
|
+
|
|
163
|
+
export type LedgerClient = {
|
|
164
|
+
write: <T>(type: string, payload: unknown) => Promise<T>;
|
|
165
|
+
query: <T>(type: string, payload: unknown) => Promise<T>;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
type BalancesReport = {
|
|
169
|
+
accounts: Array<{ id: string; name: string; balance: number }>;
|
|
170
|
+
trialBalance: number;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
async function openAccount(client: LedgerClient, name: string, type: AccountType): Promise<string> {
|
|
174
|
+
const { id } = await client.write<{ id: string }>(LedgerHandlers.createAccount, { name, type });
|
|
175
|
+
return id;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Issue an invoice, observe that the revenue is already on the P&L before any
|
|
179
|
+
// cash moves, then book the incoming payment. Returns the figures the test
|
|
180
|
+
// asserts: accrual revenue, the cleared receivable, cash in, VAT owed.
|
|
181
|
+
export async function invoicingFlow(client: LedgerClient) {
|
|
182
|
+
const receivables = await openAccount(client, "Forderungen", "asset");
|
|
183
|
+
const revenue = await openAccount(client, "Erlöse", "income");
|
|
184
|
+
const vat = await openAccount(client, "Umsatzsteuer", "liability");
|
|
185
|
+
const bank = await openAccount(client, "Bank", "asset");
|
|
186
|
+
|
|
187
|
+
// Invoice: net €1,000 + 19% VAT = €1,190 gross. One balanced 3-line entry.
|
|
188
|
+
await client.write(LedgerHandlers.createTransaction, {
|
|
189
|
+
date: "2026-02-01",
|
|
190
|
+
description: "Rechnung 2026-001",
|
|
191
|
+
reference: "INV-2026-001",
|
|
192
|
+
lines: [
|
|
193
|
+
{ accountId: receivables, amount: 119000 }, // gross owed to us
|
|
194
|
+
{ accountId: revenue, amount: -100000 }, // net revenue → income
|
|
195
|
+
{ accountId: vat, amount: -19000 }, // VAT collected → liability
|
|
196
|
+
],
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// Revenue is recognised at invoice time — it's on the P&L before any payment.
|
|
200
|
+
const beforePayment = await client.query<{ income: number; netIncome: number }>(
|
|
201
|
+
LedgerQueries.reportIncomeStatement,
|
|
202
|
+
{},
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// Payment arrives → Bank up, receivable cleared. Income does NOT change here.
|
|
206
|
+
await client.write(LedgerHandlers.createTransaction, {
|
|
207
|
+
date: "2026-02-20",
|
|
208
|
+
description: "Zahlung Rechnung 2026-001",
|
|
209
|
+
reference: "INV-2026-001",
|
|
210
|
+
lines: [
|
|
211
|
+
{ accountId: bank, amount: 119000 },
|
|
212
|
+
{ accountId: receivables, amount: -119000 },
|
|
213
|
+
],
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// The payment moved cash and cleared the receivable — it touched no income
|
|
217
|
+
// account, so the recognised revenue is unchanged.
|
|
218
|
+
const afterPayment = await client.query<{ income: number }>(
|
|
219
|
+
LedgerQueries.reportIncomeStatement,
|
|
220
|
+
{},
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const balances = await client.query<BalancesReport>(LedgerQueries.reportBalances, {});
|
|
224
|
+
const balanceOf = (id: string) => balances.accounts.find((a) => a.id === id)?.balance ?? 0;
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
revenueBeforePayment: beforePayment.income, // 100000 — accrual, cash-independent
|
|
228
|
+
revenueAfterPayment: afterPayment.income, // 100000 — payment doesn't double-count
|
|
229
|
+
receivablesAfterPayment: balanceOf(receivables), // 0 — invoice settled
|
|
230
|
+
bank: balanceOf(bank), // 119000 — gross cash received
|
|
231
|
+
vatOwed: balanceOf(vat), // 19000 — liability to the tax office
|
|
232
|
+
trialBalance: balances.trialBalance, // 0
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
```
|