@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,609 @@
|
|
|
1
|
+
<!-- Generated by `bun run gen:agent-docs` from samples/recipes/currencies-global. Do not edit. -->
|
|
2
|
+
|
|
3
|
+
# currencies-global
|
|
4
|
+
|
|
5
|
+
Shared money fields across tenants. Commas still matter.
|
|
6
|
+
|
|
7
|
+
## README
|
|
8
|
+
|
|
9
|
+
# Currencies Global
|
|
10
|
+
|
|
11
|
+
Multi-currency money with a global rate table.
|
|
12
|
+
|
|
13
|
+
## What it shows
|
|
14
|
+
|
|
15
|
+
- Money field type + global FX rates
|
|
16
|
+
- Conversion helpers shared across tenants
|
|
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/currencies-global
|
|
28
|
+
bun test
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Source files
|
|
32
|
+
|
|
33
|
+
### src/__tests__/feature.integration.test.ts
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// Money Sample — Integration Test
|
|
37
|
+
// Proves: money fields store correctly, per-tenant currency validation works,
|
|
38
|
+
// reference data seeding, custom currencies, assertExistsIn from framework
|
|
39
|
+
//
|
|
40
|
+
// ┌──────────┬────────────────────────────┬──────────────────────────┐
|
|
41
|
+
// │ │ Tenant 1 │ Tenant 2 │
|
|
42
|
+
// ├──────────┼────────────────────────────┼──────────────────────────┤
|
|
43
|
+
// │ Standard │ EUR, USD │ USD, GBP, JPY │
|
|
44
|
+
// │ Custom │ BHD, XYZ │ TRY, KRW, BRL │
|
|
45
|
+
// │ Inactive │ — │ KRW (isActive: false) │
|
|
46
|
+
// ├──────────┼────────────────────────────┼──────────────────────────┤
|
|
47
|
+
// │ Allowed │ EUR, USD, BHD, XYZ │ USD, GBP, JPY, TRY, BRL │
|
|
48
|
+
// │ Denied │ GBP, TRY, KRW, ... │ EUR, BHD, XYZ, KRW, ... │
|
|
49
|
+
// └──────────┴────────────────────────────┴──────────────────────────┘
|
|
50
|
+
// XYZ is NOT in DEFAULT_CURRENCIES — proves custom currencies work end-to-end
|
|
51
|
+
|
|
52
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
53
|
+
import { seedReferenceData } from "@cosmicdrift/kumiko-framework/db";
|
|
54
|
+
import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
55
|
+
import {
|
|
56
|
+
createTestUser,
|
|
57
|
+
setupTestStack,
|
|
58
|
+
type TestStack,
|
|
59
|
+
TestUsers,
|
|
60
|
+
unsafeCreateEntityTable,
|
|
61
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
62
|
+
import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
63
|
+
import { currencyEntity, currencyTable } from "../entities/currency";
|
|
64
|
+
import { invoiceEntity } from "../entities/invoice";
|
|
65
|
+
import { tenantCurrencyEntity } from "../entities/tenant-currency";
|
|
66
|
+
import { currenciesGlobalFeature } from "../feature";
|
|
67
|
+
|
|
68
|
+
// The executor returns the rehydrated read-form: money is the combined
|
|
69
|
+
// { amount, currency }, not the flat amount/amountCurrency columns insertOne leaked.
|
|
70
|
+
const asMoney = (v: unknown) => v as { amount: number; currency: string };
|
|
71
|
+
|
|
72
|
+
let stack: TestStack;
|
|
73
|
+
|
|
74
|
+
const tenant1Admin = TestUsers.admin;
|
|
75
|
+
const tenant2Admin = createTestUser({ id: 2, tenantId: "00000000-0000-4000-8000-000000000002" });
|
|
76
|
+
|
|
77
|
+
beforeAll(async () => {
|
|
78
|
+
stack = await setupTestStack({
|
|
79
|
+
features: [currenciesGlobalFeature],
|
|
80
|
+
systemHooks: [],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await unsafeCreateEntityTable(stack.db, currencyEntity);
|
|
84
|
+
await unsafeCreateEntityTable(stack.db, tenantCurrencyEntity);
|
|
85
|
+
await unsafeCreateEntityTable(stack.db, invoiceEntity);
|
|
86
|
+
|
|
87
|
+
// Seed global currency table — same as r.referenceData() does at boot
|
|
88
|
+
const tables = new Map([["currency", currencyTable]]);
|
|
89
|
+
await seedReferenceData(currenciesGlobalFeature.referenceData, tables, stack.db);
|
|
90
|
+
|
|
91
|
+
// --- Assign currencies to tenant 1: EUR, USD (standard) + BHD, XYZ (custom) ---
|
|
92
|
+
for (const currencyCode of ["EUR", "USD", "BHD", "XYZ"]) {
|
|
93
|
+
await stack.http.writeOk<SaveContext>(
|
|
94
|
+
"currencies-global:write:tenant-currency:assign",
|
|
95
|
+
{ currencyCode },
|
|
96
|
+
tenant1Admin,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Assign currencies to tenant 2: USD, GBP, JPY (standard) + TRY, KRW, BRL (custom) ---
|
|
101
|
+
for (const currencyCode of ["USD", "GBP", "JPY", "TRY", "KRW", "BRL"]) {
|
|
102
|
+
await stack.http.writeOk<SaveContext>(
|
|
103
|
+
"currencies-global:write:tenant-currency:assign",
|
|
104
|
+
{ currencyCode, isActive: currencyCode !== "KRW" },
|
|
105
|
+
tenant2Admin,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterAll(async () => {
|
|
111
|
+
await stack.cleanup();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// --- Tenant 1: EUR, USD, BHD, XYZ allowed ---
|
|
115
|
+
|
|
116
|
+
describe("tenant 1: allowed currencies", () => {
|
|
117
|
+
test("create invoice with EUR (standard) succeeds", async () => {
|
|
118
|
+
const data = await stack.http.writeOk<SaveContext>(
|
|
119
|
+
"currencies-global:write:invoice:create",
|
|
120
|
+
{ title: "Invoice T1-EUR", amount: 150050, amountCurrency: "EUR" },
|
|
121
|
+
tenant1Admin,
|
|
122
|
+
);
|
|
123
|
+
expect(data.isNew).toBe(true);
|
|
124
|
+
expect(asMoney(data.data["amount"]).amount).toBe(150050);
|
|
125
|
+
expect(asMoney(data.data["amount"]).currency).toBe("EUR");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("create invoice with BHD (custom, in DEFAULT_CURRENCIES) succeeds", async () => {
|
|
129
|
+
const data = await stack.http.writeOk<SaveContext>(
|
|
130
|
+
"currencies-global:write:invoice:create",
|
|
131
|
+
{ title: "Invoice T1-BHD", amount: 25075, amountCurrency: "BHD" },
|
|
132
|
+
tenant1Admin,
|
|
133
|
+
);
|
|
134
|
+
expect(data.isNew).toBe(true);
|
|
135
|
+
expect(asMoney(data.data["amount"]).currency).toBe("BHD");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("create invoice with XYZ (NOT in DEFAULT_CURRENCIES) succeeds", async () => {
|
|
139
|
+
const data = await stack.http.writeOk<SaveContext>(
|
|
140
|
+
"currencies-global:write:invoice:create",
|
|
141
|
+
{ title: "Invoice T1-XYZ", amount: 42, amountCurrency: "XYZ" },
|
|
142
|
+
tenant1Admin,
|
|
143
|
+
);
|
|
144
|
+
expect(data.isNew).toBe(true);
|
|
145
|
+
expect(asMoney(data.data["amount"]).currency).toBe("XYZ");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("create invoice with shipping cost in different currency", async () => {
|
|
149
|
+
const data = await stack.http.writeOk<SaveContext>(
|
|
150
|
+
"currencies-global:write:invoice:create",
|
|
151
|
+
{
|
|
152
|
+
title: "Invoice with shipping",
|
|
153
|
+
amount: 1000,
|
|
154
|
+
amountCurrency: "EUR",
|
|
155
|
+
shippingCost: 4999,
|
|
156
|
+
shippingCostCurrency: "USD",
|
|
157
|
+
},
|
|
158
|
+
tenant1Admin,
|
|
159
|
+
);
|
|
160
|
+
expect(data.isNew).toBe(true);
|
|
161
|
+
expect(asMoney(data.data["amount"]).currency).toBe("EUR");
|
|
162
|
+
expect(asMoney(data.data["shippingCost"]).currency).toBe("USD");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// --- Tenant 1: denied currencies ---
|
|
167
|
+
|
|
168
|
+
describe("tenant 1: denied currencies", () => {
|
|
169
|
+
test("create invoice with GBP fails (not assigned to tenant 1)", async () => {
|
|
170
|
+
const error = await stack.http.writeErr(
|
|
171
|
+
"currencies-global:write:invoice:create",
|
|
172
|
+
{ title: "Should fail", amount: 100, amountCurrency: "GBP" },
|
|
173
|
+
tenant1Admin,
|
|
174
|
+
);
|
|
175
|
+
expectErrorIncludes(error, "currency_not_allowed");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("create invoice with TRY fails (not assigned to tenant 1)", async () => {
|
|
179
|
+
const error = await stack.http.writeErr(
|
|
180
|
+
"currencies-global:write:invoice:create",
|
|
181
|
+
{ title: "Should fail", amount: 100, amountCurrency: "TRY" },
|
|
182
|
+
tenant1Admin,
|
|
183
|
+
);
|
|
184
|
+
expectErrorIncludes(error, "currency_not_allowed");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// --- Tenant 2: USD, GBP, JPY, TRY, BRL allowed (KRW inactive) ---
|
|
189
|
+
|
|
190
|
+
describe("tenant 2: allowed currencies", () => {
|
|
191
|
+
test("create invoice with GBP succeeds", async () => {
|
|
192
|
+
const data = await stack.http.writeOk<SaveContext>(
|
|
193
|
+
"currencies-global:write:invoice:create",
|
|
194
|
+
{ title: "Invoice T2-GBP", amount: 99999, amountCurrency: "GBP" },
|
|
195
|
+
tenant2Admin,
|
|
196
|
+
);
|
|
197
|
+
expect(data.isNew).toBe(true);
|
|
198
|
+
expect(asMoney(data.data["amount"]).currency).toBe("GBP");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("create invoice with TRY (custom) succeeds", async () => {
|
|
202
|
+
const data = await stack.http.writeOk<SaveContext>(
|
|
203
|
+
"currencies-global:write:invoice:create",
|
|
204
|
+
{ title: "Invoice T2-TRY", amount: 50000, amountCurrency: "TRY" },
|
|
205
|
+
tenant2Admin,
|
|
206
|
+
);
|
|
207
|
+
expect(data.isNew).toBe(true);
|
|
208
|
+
expect(asMoney(data.data["amount"]).currency).toBe("TRY");
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// --- Tenant 2: denied currencies ---
|
|
213
|
+
|
|
214
|
+
describe("tenant 2: denied currencies", () => {
|
|
215
|
+
test("create invoice with EUR fails (not assigned to tenant 2)", async () => {
|
|
216
|
+
const error = await stack.http.writeErr(
|
|
217
|
+
"currencies-global:write:invoice:create",
|
|
218
|
+
{ title: "Should fail", amount: 100, amountCurrency: "EUR" },
|
|
219
|
+
tenant2Admin,
|
|
220
|
+
);
|
|
221
|
+
expectErrorIncludes(error, "currency_not_allowed");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("create invoice with KRW fails (inactive in tenant 2)", async () => {
|
|
225
|
+
const error = await stack.http.writeErr(
|
|
226
|
+
"currencies-global:write:invoice:create",
|
|
227
|
+
{ title: "Should fail", amount: 100, amountCurrency: "KRW" },
|
|
228
|
+
tenant2Admin,
|
|
229
|
+
);
|
|
230
|
+
expectErrorIncludes(error, "currency_not_allowed");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("create invoice with BHD fails (not assigned to tenant 2)", async () => {
|
|
234
|
+
const error = await stack.http.writeErr(
|
|
235
|
+
"currencies-global:write:invoice:create",
|
|
236
|
+
{ title: "Should fail", amount: 100, amountCurrency: "BHD" },
|
|
237
|
+
tenant2Admin,
|
|
238
|
+
);
|
|
239
|
+
expectErrorIncludes(error, "currency_not_allowed");
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// --- Cross-tenant isolation ---
|
|
244
|
+
|
|
245
|
+
describe("tenant isolation", () => {
|
|
246
|
+
test("tenant 2 cannot use tenant 1's BHD", async () => {
|
|
247
|
+
const error = await stack.http.writeErr(
|
|
248
|
+
"currencies-global:write:invoice:create",
|
|
249
|
+
{ title: "Cross-tenant", amount: 100, amountCurrency: "BHD" },
|
|
250
|
+
tenant2Admin,
|
|
251
|
+
);
|
|
252
|
+
expectErrorIncludes(error, "currency_not_allowed");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("tenant 2 cannot use tenant 1's XYZ (not in DEFAULT_CURRENCIES)", async () => {
|
|
256
|
+
const error = await stack.http.writeErr(
|
|
257
|
+
"currencies-global:write:invoice:create",
|
|
258
|
+
{ title: "Cross-tenant", amount: 100, amountCurrency: "XYZ" },
|
|
259
|
+
tenant2Admin,
|
|
260
|
+
);
|
|
261
|
+
expectErrorIncludes(error, "currency_not_allowed");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("tenant 1 cannot use tenant 2's TRY", async () => {
|
|
265
|
+
const error = await stack.http.writeErr(
|
|
266
|
+
"currencies-global:write:invoice:create",
|
|
267
|
+
{ title: "Cross-tenant", amount: 100, amountCurrency: "TRY" },
|
|
268
|
+
tenant1Admin,
|
|
269
|
+
);
|
|
270
|
+
expectErrorIncludes(error, "currency_not_allowed");
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// --- Shipping currency validation ---
|
|
275
|
+
|
|
276
|
+
describe("shipping currency validation", () => {
|
|
277
|
+
test("valid amount + invalid shipping currency fails", async () => {
|
|
278
|
+
const error = await stack.http.writeErr(
|
|
279
|
+
"currencies-global:write:invoice:create",
|
|
280
|
+
{
|
|
281
|
+
title: "Bad shipping",
|
|
282
|
+
amount: 100,
|
|
283
|
+
amountCurrency: "EUR",
|
|
284
|
+
shippingCost: 10,
|
|
285
|
+
shippingCostCurrency: "GBP", // not assigned to tenant 1
|
|
286
|
+
},
|
|
287
|
+
tenant1Admin,
|
|
288
|
+
);
|
|
289
|
+
expectErrorIncludes(error, "currency_not_allowed");
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// --- Detail query ---
|
|
294
|
+
|
|
295
|
+
describe("invoice detail", () => {
|
|
296
|
+
test("detail returns the correct invoice by id", async () => {
|
|
297
|
+
const _first = await stack.http.writeOk<SaveContext>(
|
|
298
|
+
"currencies-global:write:invoice:create",
|
|
299
|
+
{ title: "Detail-First", amount: 100, amountCurrency: "EUR" },
|
|
300
|
+
tenant1Admin,
|
|
301
|
+
);
|
|
302
|
+
const second = await stack.http.writeOk<SaveContext>(
|
|
303
|
+
"currencies-global:write:invoice:create",
|
|
304
|
+
{ title: "Detail-Second", amount: 200, amountCurrency: "USD" },
|
|
305
|
+
tenant1Admin,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const detail = await stack.http.queryOk<Record<string, unknown>>(
|
|
309
|
+
"currencies-global:query:invoice:detail",
|
|
310
|
+
{ id: second.id },
|
|
311
|
+
tenant1Admin,
|
|
312
|
+
);
|
|
313
|
+
expect(detail["id"]).toBe(second.id);
|
|
314
|
+
expect(detail["title"]).toBe("Detail-Second");
|
|
315
|
+
expect(detail["amountCurrency"]).toBe("USD");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("detail returns null for other tenant's invoice", async () => {
|
|
319
|
+
const created = await stack.http.writeOk<SaveContext>(
|
|
320
|
+
"currencies-global:write:invoice:create",
|
|
321
|
+
{ title: "Tenant1-Only", amount: 50, amountCurrency: "EUR" },
|
|
322
|
+
tenant1Admin,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const detail = await stack.http.queryOk<null>(
|
|
326
|
+
"currencies-global:query:invoice:detail",
|
|
327
|
+
{ id: created.id },
|
|
328
|
+
tenant2Admin,
|
|
329
|
+
);
|
|
330
|
+
expect(detail).toBeNull();
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### src/entities/currency.ts
|
|
336
|
+
|
|
337
|
+
```ts
|
|
338
|
+
// Currency — global reference data table
|
|
339
|
+
// Seeded from CURRENCY_CATALOG in feature.ts via r.referenceData()
|
|
340
|
+
|
|
341
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
342
|
+
import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
|
|
343
|
+
|
|
344
|
+
export const currencyEntity = createEntity({
|
|
345
|
+
table: "read_sample_currencies",
|
|
346
|
+
fields: {
|
|
347
|
+
code: createTextField({ personal: false, reason: "is_reference_data", required: true }),
|
|
348
|
+
name: createTextField({ personal: false, reason: "is_reference_data", required: true }),
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
export const currencyTable = buildEntityTable("currency", currencyEntity);
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### src/entities/invoice.ts
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
359
|
+
import {
|
|
360
|
+
createEntity,
|
|
361
|
+
createMoneyField,
|
|
362
|
+
createTextField,
|
|
363
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
364
|
+
|
|
365
|
+
export const invoiceEntity = createEntity({
|
|
366
|
+
table: "read_sample_invoices",
|
|
367
|
+
fields: {
|
|
368
|
+
title: createTextField({ personal: false, reason: "is_business_data", required: true }),
|
|
369
|
+
amount: createMoneyField({ required: true }),
|
|
370
|
+
shippingCost: createMoneyField(),
|
|
371
|
+
},
|
|
372
|
+
defaultCurrency: "EUR",
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
export const invoiceTable = buildEntityTable("invoice", invoiceEntity);
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### src/entities/tenant-currency.ts
|
|
379
|
+
|
|
380
|
+
```ts
|
|
381
|
+
// TenantCurrency — which currencies each tenant is allowed to use
|
|
382
|
+
// References currency.code, adds isActive flag per tenant
|
|
383
|
+
|
|
384
|
+
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
385
|
+
import {
|
|
386
|
+
createBooleanField,
|
|
387
|
+
createEntity,
|
|
388
|
+
createTextField,
|
|
389
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
390
|
+
|
|
391
|
+
export const tenantCurrencyEntity = createEntity({
|
|
392
|
+
table: "read_sample_tenant_currencies",
|
|
393
|
+
fields: {
|
|
394
|
+
currencyCode: createTextField({ personal: false, reason: "is_reference_data", required: true }),
|
|
395
|
+
isActive: createBooleanField({ default: true }),
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
export const tenantCurrencyTable = buildEntityTable("tenant-currency", tenantCurrencyEntity);
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
### src/feature.ts
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
// Money Shared Sample
|
|
406
|
+
// Shows: Shared (global) currencies via r.referenceData(), per-tenant assignment
|
|
407
|
+
//
|
|
408
|
+
// Pattern: Currencies are global master data — tenants pick which ones they use.
|
|
409
|
+
// Use this when: All tenants share the same currency catalog (ISO 4217 etc.)
|
|
410
|
+
//
|
|
411
|
+
// Tables:
|
|
412
|
+
// currency — global reference data (seeded from CURRENCY_CATALOG below)
|
|
413
|
+
// tenantCurrency — which currencies each tenant may use (with isActive flag)
|
|
414
|
+
// invoice — money fields validated against tenantCurrency
|
|
415
|
+
|
|
416
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
417
|
+
import { currencyEntity } from "./entities/currency";
|
|
418
|
+
import { invoiceEntity } from "./entities/invoice";
|
|
419
|
+
import { tenantCurrencyEntity } from "./entities/tenant-currency";
|
|
420
|
+
import { invoiceCreate } from "./handlers/invoice-create.write";
|
|
421
|
+
import { invoiceDetail } from "./handlers/invoice-detail.query";
|
|
422
|
+
import { tenantCurrencyAssign } from "./handlers/tenant-currency-assign.write";
|
|
423
|
+
|
|
424
|
+
export { currencyEntity } from "./entities/currency";
|
|
425
|
+
export { invoiceEntity } from "./entities/invoice";
|
|
426
|
+
export { tenantCurrencyEntity } from "./entities/tenant-currency";
|
|
427
|
+
|
|
428
|
+
// All currencies available in this app — seeded as global reference data
|
|
429
|
+
const CURRENCY_CATALOG = [
|
|
430
|
+
// Standard currencies
|
|
431
|
+
{ code: "EUR", name: "Euro" },
|
|
432
|
+
{ code: "USD", name: "US Dollar" },
|
|
433
|
+
{ code: "GBP", name: "British Pound" },
|
|
434
|
+
{ code: "CHF", name: "Swiss Franc" },
|
|
435
|
+
{ code: "JPY", name: "Japanese Yen" },
|
|
436
|
+
{ code: "SEK", name: "Swedish Krona" },
|
|
437
|
+
{ code: "NOK", name: "Norwegian Krone" },
|
|
438
|
+
{ code: "DKK", name: "Danish Krone" },
|
|
439
|
+
{ code: "PLN", name: "Polish Zloty" },
|
|
440
|
+
{ code: "CZK", name: "Czech Koruna" },
|
|
441
|
+
{ code: "CAD", name: "Canadian Dollar" },
|
|
442
|
+
{ code: "AUD", name: "Australian Dollar" },
|
|
443
|
+
{ code: "NZD", name: "New Zealand Dollar" },
|
|
444
|
+
{ code: "CNY", name: "Chinese Yuan" },
|
|
445
|
+
{ code: "INR", name: "Indian Rupee" },
|
|
446
|
+
// Custom currencies added by this app
|
|
447
|
+
{ code: "BHD", name: "Bahraini Dinar" },
|
|
448
|
+
{ code: "SAR", name: "Saudi Riyal" },
|
|
449
|
+
{ code: "TRY", name: "Turkish Lira" },
|
|
450
|
+
{ code: "KRW", name: "South Korean Won" },
|
|
451
|
+
{ code: "BRL", name: "Brazilian Real" },
|
|
452
|
+
{ code: "XYZ", name: "Custom Token" },
|
|
453
|
+
] as const;
|
|
454
|
+
|
|
455
|
+
export const currenciesGlobalFeature = defineFeature("currenciesGlobal", (r) => {
|
|
456
|
+
const currency = r.entity("currency", currencyEntity);
|
|
457
|
+
r.entity("tenant-currency", tenantCurrencyEntity);
|
|
458
|
+
r.entity("invoice", invoiceEntity);
|
|
459
|
+
|
|
460
|
+
// Seed global currency table — available for all tenants to pick from
|
|
461
|
+
r.referenceData(currency, [...CURRENCY_CATALOG], { upsertKey: "code" });
|
|
462
|
+
|
|
463
|
+
r.writeHandler(tenantCurrencyAssign);
|
|
464
|
+
r.writeHandler(invoiceCreate);
|
|
465
|
+
r.queryHandler(invoiceDetail);
|
|
466
|
+
});
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### src/handlers/invoice-create.write.ts
|
|
470
|
+
|
|
471
|
+
```ts
|
|
472
|
+
import { assertExistsIn, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
473
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
474
|
+
import { failUnprocessable } from "@cosmicdrift/kumiko-framework/errors";
|
|
475
|
+
import { z } from "zod";
|
|
476
|
+
import { invoiceEntity, invoiceTable } from "../entities/invoice";
|
|
477
|
+
import { tenantCurrencyTable } from "../entities/tenant-currency";
|
|
478
|
+
|
|
479
|
+
const invoiceCrud = createEventStoreExecutor(invoiceTable, invoiceEntity, {
|
|
480
|
+
entityName: "invoice",
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
export const invoiceCreate = defineWriteHandler({
|
|
484
|
+
name: "invoice:create",
|
|
485
|
+
schema: z.object({
|
|
486
|
+
title: z.string().min(1),
|
|
487
|
+
amount: z.number(),
|
|
488
|
+
amountCurrency: z.string().length(3),
|
|
489
|
+
shippingCost: z.number().optional(),
|
|
490
|
+
shippingCostCurrency: z.string().length(3).optional(),
|
|
491
|
+
}),
|
|
492
|
+
access: { roles: ["Admin"] },
|
|
493
|
+
handler: async (event, ctx) => {
|
|
494
|
+
const { title, amount, amountCurrency, shippingCost, shippingCostCurrency } = event.payload;
|
|
495
|
+
|
|
496
|
+
// Validate amount currency against tenant's allowed currencies. This is a
|
|
497
|
+
// business-rule violation (currency not permitted here), not a missing
|
|
498
|
+
// entity, so 422 + reason="currency_not_allowed" is the right shape.
|
|
499
|
+
const amountMissing = await assertExistsIn(ctx.db, tenantCurrencyTable, {
|
|
500
|
+
field: "currencyCode",
|
|
501
|
+
value: amountCurrency,
|
|
502
|
+
where: { isActive: true },
|
|
503
|
+
});
|
|
504
|
+
if (amountMissing) {
|
|
505
|
+
return failUnprocessable("currency_not_allowed", {
|
|
506
|
+
field: "amountCurrency",
|
|
507
|
+
value: amountCurrency,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (shippingCostCurrency) {
|
|
512
|
+
const shippingMissing = await assertExistsIn(ctx.db, tenantCurrencyTable, {
|
|
513
|
+
field: "currencyCode",
|
|
514
|
+
value: shippingCostCurrency,
|
|
515
|
+
where: { isActive: true },
|
|
516
|
+
});
|
|
517
|
+
if (shippingMissing) {
|
|
518
|
+
return failUnprocessable("currency_not_allowed", {
|
|
519
|
+
field: "shippingCostCurrency",
|
|
520
|
+
value: shippingCostCurrency,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Money fields go in as the combined { amount, currency } API form; the
|
|
526
|
+
// executor flattens them into the invoice's own amount/amountCurrency columns.
|
|
527
|
+
return invoiceCrud.create(
|
|
528
|
+
{
|
|
529
|
+
title,
|
|
530
|
+
amount: { amount, currency: amountCurrency },
|
|
531
|
+
...(shippingCost !== undefined
|
|
532
|
+
? { shippingCost: { amount: shippingCost, currency: shippingCostCurrency } }
|
|
533
|
+
: {}),
|
|
534
|
+
},
|
|
535
|
+
event.user,
|
|
536
|
+
ctx.db,
|
|
537
|
+
);
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
### src/handlers/invoice-detail.query.ts
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
546
|
+
import { z } from "zod";
|
|
547
|
+
import { invoiceTable } from "../entities/invoice";
|
|
548
|
+
|
|
549
|
+
export const invoiceDetail = defineQueryHandler({
|
|
550
|
+
name: "invoice:detail",
|
|
551
|
+
schema: z.object({ id: z.uuid() }),
|
|
552
|
+
access: {
|
|
553
|
+
openToAll: {
|
|
554
|
+
reason:
|
|
555
|
+
"demo recipe: any signed-in user may look up any invoice by id; the sample " +
|
|
556
|
+
"focuses on currency formatting, not access control",
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
handler: async (query, ctx) => {
|
|
560
|
+
const [row] = await ctx.db.selectMany(invoiceTable, { id: query.payload.id });
|
|
561
|
+
return (row as Record<string, unknown>) ?? null;
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
### src/handlers/tenant-currency-assign.write.ts
|
|
567
|
+
|
|
568
|
+
```ts
|
|
569
|
+
// Assigns an existing currency to a tenant (creates tenantCurrency entry)
|
|
570
|
+
// The currency must exist in the global currency table
|
|
571
|
+
|
|
572
|
+
import { assertExistsIn, createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
573
|
+
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
574
|
+
import { writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
575
|
+
import { z } from "zod";
|
|
576
|
+
import { currencyTable } from "../entities/currency";
|
|
577
|
+
import { tenantCurrencyEntity, tenantCurrencyTable } from "../entities/tenant-currency";
|
|
578
|
+
|
|
579
|
+
const tenantCurrencyCrud = createEventStoreExecutor(tenantCurrencyTable, tenantCurrencyEntity, {
|
|
580
|
+
entityName: "tenant-currency",
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
export const tenantCurrencyAssign = defineWriteHandler({
|
|
584
|
+
name: "tenant-currency:assign",
|
|
585
|
+
schema: z.object({
|
|
586
|
+
currencyCode: z.string().length(3),
|
|
587
|
+
isActive: z.boolean().optional(),
|
|
588
|
+
}),
|
|
589
|
+
access: { roles: ["Admin"] },
|
|
590
|
+
handler: async (event, ctx) => {
|
|
591
|
+
// Validate that the currency exists in the global reference table (no tenantId — global data)
|
|
592
|
+
const notFound = await assertExistsIn(ctx.db, currencyTable, {
|
|
593
|
+
field: "code",
|
|
594
|
+
value: event.payload.currencyCode,
|
|
595
|
+
entityName: "currency",
|
|
596
|
+
});
|
|
597
|
+
if (notFound) return writeFailure(notFound);
|
|
598
|
+
|
|
599
|
+
return tenantCurrencyCrud.create(
|
|
600
|
+
{
|
|
601
|
+
currencyCode: event.payload.currencyCode,
|
|
602
|
+
isActive: event.payload.isActive ?? true,
|
|
603
|
+
},
|
|
604
|
+
event.user,
|
|
605
|
+
ctx.db,
|
|
606
|
+
);
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
```
|