@cosmicdrift/kumiko-bundled-features 0.337.1 → 0.338.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/dist/agent-tools/tool-catalog.js +37 -17
- package/dist/auth-email-password/handlers/invite-accept-with-login.write.js +1 -0
- package/dist/auth-email-password/handlers/invite-accept.write.js +1 -0
- package/dist/auth-email-password/handlers/invite-info.query.js +1 -0
- package/dist/auth-email-password/handlers/invite-signup-complete.write.js +1 -0
- package/dist/auth-email-password/handlers/signup-confirm.write.js +1 -0
- package/dist/auth-email-password/handlers/signup-request.write.js +1 -1
- package/dist/auth-mfa/handlers/enable-start-preauth.write.js +1 -0
- package/dist/billing-foundation/aggregate-id.d.ts +2 -0
- package/dist/billing-foundation/aggregate-id.js +8 -0
- package/dist/billing-foundation/checkout-core.d.ts +3 -0
- package/dist/billing-foundation/checkout-core.js +3 -0
- package/dist/billing-foundation/constants.d.ts +31 -0
- package/dist/billing-foundation/constants.js +35 -0
- package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +21 -0
- package/dist/billing-foundation/consumer-protection/confirmation-mail.js +125 -0
- package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +32 -0
- package/dist/billing-foundation/consumer-protection/consent-gate.js +88 -0
- package/dist/billing-foundation/consumer-protection/consent-locale.d.ts +5 -0
- package/dist/billing-foundation/consumer-protection/consent-locale.js +17 -0
- package/dist/billing-foundation/consumer-protection/consent-text.d.ts +11 -0
- package/dist/billing-foundation/consumer-protection/consent-text.js +28 -0
- package/dist/billing-foundation/consumer-protection/issue-confirmation.d.ts +15 -0
- package/dist/billing-foundation/consumer-protection/issue-confirmation.js +149 -0
- package/dist/billing-foundation/consumer-protection/register-consent.d.ts +3 -0
- package/dist/billing-foundation/consumer-protection/register-consent.js +63 -0
- package/dist/billing-foundation/consumer-protection/register-termination.d.ts +3 -0
- package/dist/billing-foundation/consumer-protection/register-termination.js +12 -0
- package/dist/billing-foundation/consumer-protection/termination-mail.d.ts +43 -0
- package/dist/billing-foundation/consumer-protection/termination-mail.js +76 -0
- package/dist/billing-foundation/consumer-protection/termination-pages.d.ts +8 -0
- package/dist/billing-foundation/consumer-protection/termination-pages.js +239 -0
- package/dist/billing-foundation/consumer-protection/termination-record.d.ts +51 -0
- package/dist/billing-foundation/consumer-protection/termination-record.js +162 -0
- package/dist/billing-foundation/consumer-protection/termination-request.d.ts +45 -0
- package/dist/billing-foundation/consumer-protection/termination-request.js +263 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +62 -0
- package/dist/billing-foundation/consumer-protection/termination-texts.js +168 -0
- package/dist/billing-foundation/events.d.ts +86 -2
- package/dist/billing-foundation/events.js +76 -2
- package/dist/billing-foundation/feature.js +6 -2
- package/dist/billing-foundation/handlers/billing-plans.query.js +19 -1
- package/dist/billing-foundation/handlers/create-checkout-session.write.js +33 -2
- package/dist/billing-foundation/handlers/process-event.write.d.ts +2 -2
- package/dist/billing-foundation/handlers/process-event.write.js +43 -15
- package/dist/billing-foundation/handlers/process-payment-event.write.d.ts +2 -1
- package/dist/billing-foundation/handlers/process-payment-event.write.js +6 -2
- package/dist/billing-foundation/handlers/start-plan-checkout.write.js +15 -1
- package/dist/billing-foundation/handlers/sync-subscription.write.js +0 -1
- package/dist/billing-foundation/i18n.js +58 -0
- package/dist/billing-foundation/index.d.ts +3 -2
- package/dist/billing-foundation/index.js +2 -1
- package/dist/billing-foundation/types.d.ts +49 -9
- package/dist/billing-foundation/validate-options.js +47 -10
- package/dist/billing-foundation/web/billing-plans-panel.js +40 -3
- package/dist/billing-foundation/web/cancel-contract-dialog.d.ts +7 -0
- package/dist/billing-foundation/web/cancel-contract-dialog.js +57 -0
- package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +28 -0
- package/dist/billing-foundation/web/checkout-consent-dialog.js +38 -0
- package/dist/billing-foundation/webhook-handler.js +5 -2
- package/dist/cap-counter/with-cap-enforcement.js +7 -3
- package/dist/cap-overview/access.d.ts +2 -1
- package/dist/cap-overview/access.js +26 -5
- package/dist/cap-overview/feature.d.ts +4 -0
- package/dist/cap-overview/feature.js +8 -6
- package/dist/cap-overview/handlers/caps-usage.query.d.ts +1 -1
- package/dist/cap-overview/handlers/caps-usage.query.js +6 -4
- package/dist/cap-overview/handlers/tenant-caps-list.query.js +12 -7
- package/dist/cap-overview/index.d.ts +2 -2
- package/dist/cap-overview/index.js +2 -2
- package/dist/cap-overview/screens.d.ts +1 -1
- package/dist/cap-overview/screens.js +17 -16
- package/dist/config/handlers/set.write.js +18 -2
- package/dist/crypto-shredding/feature.js +3 -2
- package/dist/crypto-shredding/handlers/forget-subject.write.d.ts +1 -1
- package/dist/crypto-shredding/handlers/forget-subject.write.js +24 -13
- package/dist/custom-fields/feature.js +9 -6
- package/dist/custom-fields/handlers/clear-custom-field.write.js +1 -0
- package/dist/custom-fields/handlers/define-tenant-field.write.js +1 -0
- package/dist/custom-fields/handlers/set-custom-field.write.js +1 -0
- package/dist/delivery/delivery-service.js +9 -7
- package/dist/document-ingest-foundation/feature.js +3 -2
- package/dist/form-draft/handlers/discard.write.js +1 -0
- package/dist/form-draft/handlers/save.write.js +1 -0
- package/dist/inbound-mail-foundation/handlers/ingest-message.write.js +1 -0
- package/dist/managed-pages/handlers/set.write.js +1 -0
- package/dist/notes-history/executor.d.ts +2 -0
- package/dist/notes-history/executor.js +9 -1
- package/dist/notes-history/feature.d.ts +2 -3
- package/dist/notes-history/feature.js +2 -1
- package/dist/notes-history/handlers/add-note.write.d.ts +2 -1
- package/dist/notes-history/handlers/add-note.write.js +20 -3
- package/dist/notes-history-user-data/hooks.js +26 -8
- package/dist/sessions/handlers/revoke-all-for-user.write.js +1 -0
- package/dist/subscription-mollie/plugin-methods.js +1 -0
- package/dist/subscription-mollie/verify-webhook.js +5 -3
- package/dist/subscription-stripe/plugin-methods.d.ts +3 -1
- package/dist/subscription-stripe/plugin-methods.js +78 -13
- package/dist/subscription-stripe/verify-webhook.js +7 -3
- package/dist/tags/executor.d.ts +2 -0
- package/dist/tags/executor.js +9 -1
- package/dist/tags/feature.d.ts +4 -3
- package/dist/tags/feature.js +4 -2
- package/dist/tags/handlers/assign-tag.write.d.ts +2 -1
- package/dist/tags/handlers/assign-tag.write.js +4 -4
- package/dist/tags/handlers/remove-tag.write.d.ts +2 -1
- package/dist/tags/handlers/remove-tag.write.js +3 -3
- package/dist/tenant-handover/handlers/claim.write.js +1 -0
- package/dist/tenant-lifecycle/handlers/cancel-destruction.write.js +1 -0
- package/dist/tenant-lifecycle/handlers/request-destruction.write.js +1 -0
- package/dist/tier-engine/handlers/get-tenant-tier.query.js +1 -0
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +1 -0
- package/dist/user/handlers/detail.query.js +1 -0
- package/dist/user/handlers/list.query.js +1 -0
- package/dist/user-data-rights/handlers/cancel-deletion.write.js +1 -0
- package/dist/user-data-rights/handlers/download-attempt-list.query.js +1 -0
- package/dist/user-data-rights/handlers/download-by-job.query.js +1 -0
- package/dist/user-data-rights/handlers/download-by-token.query.js +1 -0
- package/dist/user-data-rights/handlers/export-status.query.js +1 -0
- package/dist/user-data-rights/handlers/lift-restriction.write.js +1 -0
- package/dist/user-data-rights/handlers/my-audit-log.query.js +1 -0
- package/dist/user-data-rights/handlers/request-deletion-by-email.write.js +1 -0
- package/dist/user-data-rights/handlers/request-export.write.js +1 -0
- package/dist/user-data-rights/handlers/run-forget-cleanup.write.js +1 -0
- package/dist/user-data-rights/run-forget-cleanup.js +30 -15
- package/package.json +9 -9
- package/src/agent-tools/changes.json +14 -0
- package/src/billing-foundation/changes.json +49 -0
- package/src/cap-counter/changes.json +7 -0
- package/src/cap-overview/changes.json +14 -0
- package/src/config/changes.json +7 -0
- package/src/crypto-shredding/changes.json +13 -0
- package/src/custom-fields/changes.json +7 -0
- package/src/delivery/changes.json +7 -0
- package/src/notes-history/changes.json +7 -0
- package/src/subscription-mollie/changes.json +13 -0
- package/src/subscription-stripe/changes.json +13 -0
- package/src/tags/changes.json +7 -0
- package/src/user-data-rights/changes.json +6 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hasAccess } from "@cosmicdrift/kumiko-framework/engine";
|
|
1
|
+
import { hasAccess, normalizeAccessEntry } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import { FILTER_OPS, isRecord } from "./filter-ops.js";
|
|
3
3
|
const MAX_TOOL_NAME_LENGTH = 64;
|
|
4
4
|
/** Field types that declare `filterable` (per `packages/framework/src/engine/types/fields.ts`).
|
|
@@ -74,14 +74,28 @@ function isDetailHandlerQn(qn, entityName) {
|
|
|
74
74
|
function isExplicitlyAgentHidden(def) {
|
|
75
75
|
return def.agent?.expose === false;
|
|
76
76
|
}
|
|
77
|
-
|
|
77
|
+
/** The model's filters feed the WHERE clause, and only the output is field-filtered — a field
|
|
78
|
+
* whose read access is role- or row-restricted would leak through `totalCount` and hit/miss
|
|
79
|
+
* probing. Only fields every one of these roles may read without a row condition are offered. */
|
|
80
|
+
function isReadableWithoutRowCondition(field, roles) {
|
|
81
|
+
const accessMap = normalizeAccessEntry(field.access?.read);
|
|
82
|
+
if (!accessMap || Object.keys(accessMap).length === 0)
|
|
83
|
+
return true;
|
|
84
|
+
return roles.every((role) => accessMap[role] === "all");
|
|
85
|
+
}
|
|
86
|
+
/** Entity CRUD tools are registry-derived, so an explicit `agent.risk` on the handler is read
|
|
87
|
+
* here; without a hint the read tools stay "low". */
|
|
88
|
+
function entityHandlerRisk(registry, qn) {
|
|
89
|
+
return registry.getAllQueryHandlers().get(qn)?.agent?.risk ?? "low";
|
|
90
|
+
}
|
|
91
|
+
function addToolsForListHandler(registry, qn, entityName, entity, roles, sink) {
|
|
78
92
|
const searchableFields = registry.getSearchableFields(entityName);
|
|
79
93
|
if (searchableFields.length > 0) {
|
|
80
94
|
const tool = buildSearchTool(entityName, searchableFields);
|
|
81
95
|
addTool(sink, tool, { kind: "search", entityName, qn });
|
|
82
96
|
}
|
|
83
97
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
84
|
-
if (!isFilterable(field))
|
|
98
|
+
if (!isFilterable(field) || !isReadableWithoutRowCondition(field, roles))
|
|
85
99
|
continue;
|
|
86
100
|
const fieldSchema = jsonSchemaTypeForField(field);
|
|
87
101
|
if (!fieldSchema)
|
|
@@ -98,7 +112,7 @@ function entityDisplayLabel(entity, entityName, locale) {
|
|
|
98
112
|
return label;
|
|
99
113
|
return entityName;
|
|
100
114
|
}
|
|
101
|
-
function buildGetTool(entityName, qn, label) {
|
|
115
|
+
function buildGetTool(entityName, qn, label, risk) {
|
|
102
116
|
return {
|
|
103
117
|
tool: {
|
|
104
118
|
name: `get_${entityName}`,
|
|
@@ -110,7 +124,7 @@ function buildGetTool(entityName, qn, label) {
|
|
|
110
124
|
additionalProperties: false,
|
|
111
125
|
},
|
|
112
126
|
},
|
|
113
|
-
descriptor: { kind: "server", op: "query", qn, risk
|
|
127
|
+
descriptor: { kind: "server", op: "query", qn, risk, entity: entityName, detail: true },
|
|
114
128
|
};
|
|
115
129
|
}
|
|
116
130
|
function buildListInputSchema(searchableFields, filterableFields) {
|
|
@@ -140,7 +154,7 @@ function buildListInputSchema(searchableFields, filterableFields) {
|
|
|
140
154
|
properties["limit"] = { type: "integer", minimum: 1, maximum: 200 };
|
|
141
155
|
return { type: "object", properties, required: [], additionalProperties: false };
|
|
142
156
|
}
|
|
143
|
-
function buildListTool(entityName, qn, label, searchableFields, filterableFields) {
|
|
157
|
+
function buildListTool(entityName, qn, label, searchableFields, filterableFields, risk) {
|
|
144
158
|
const descriptionParts = [`List ${label} records.`];
|
|
145
159
|
if (searchableFields.length > 0) {
|
|
146
160
|
descriptionParts.push(`Free-text search over: ${searchableFields.join(", ")}.`);
|
|
@@ -159,16 +173,22 @@ function buildListTool(entityName, qn, label, searchableFields, filterableFields
|
|
|
159
173
|
kind: "server",
|
|
160
174
|
op: "query",
|
|
161
175
|
qn,
|
|
162
|
-
risk
|
|
176
|
+
risk,
|
|
163
177
|
entity: entityName,
|
|
164
178
|
list: { searchableFields, filterableFields },
|
|
165
179
|
},
|
|
166
180
|
};
|
|
167
181
|
}
|
|
168
|
-
function filterableFieldsOf(entity) {
|
|
182
|
+
function filterableFieldsOf(entity, definition, roles) {
|
|
169
183
|
if (!entity)
|
|
170
184
|
return [];
|
|
171
|
-
return entity.fields
|
|
185
|
+
return entity.fields
|
|
186
|
+
.filter((field) => field.filterable === true)
|
|
187
|
+
.map((field) => field.name)
|
|
188
|
+
.filter((name) => {
|
|
189
|
+
const fieldDefinition = definition?.fields[name];
|
|
190
|
+
return fieldDefinition !== undefined && isReadableWithoutRowCondition(fieldDefinition, roles);
|
|
191
|
+
});
|
|
172
192
|
}
|
|
173
193
|
function compareByCodePoint(a, b) {
|
|
174
194
|
return a < b ? -1 : a > b ? 1 : 0;
|
|
@@ -321,7 +341,7 @@ function addRegistrySearchTools(registry, roleFilter, denyQns, sink) {
|
|
|
321
341
|
const entity = registry.getEntity(entityName);
|
|
322
342
|
if (!entity)
|
|
323
343
|
continue;
|
|
324
|
-
addToolsForListHandler(registry, qn, entityName, entity, sink);
|
|
344
|
+
addToolsForListHandler(registry, qn, entityName, entity, roleFilter.roles, sink);
|
|
325
345
|
}
|
|
326
346
|
}
|
|
327
347
|
function collectEntityHandlerQns(registry, roleFilter, denyQns) {
|
|
@@ -352,21 +372,21 @@ function collectEntityHandlerQns(registry, roleFilter, denyQns) {
|
|
|
352
372
|
}
|
|
353
373
|
return { detailQnByEntity, listQnByEntity, entityListDetailQns };
|
|
354
374
|
}
|
|
355
|
-
function addGetTools(detailQnByEntity, entityByName, locale, sink) {
|
|
375
|
+
function addGetTools(registry, detailQnByEntity, entityByName, locale, sink) {
|
|
356
376
|
const getEntries = [...detailQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
|
|
357
377
|
for (const [entityName, qn] of getEntries) {
|
|
358
378
|
const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
|
|
359
|
-
const { tool, descriptor } = buildGetTool(entityName, qn, label);
|
|
379
|
+
const { tool, descriptor } = buildGetTool(entityName, qn, label, entityHandlerRisk(registry, qn));
|
|
360
380
|
addTool(sink, tool, descriptor);
|
|
361
381
|
}
|
|
362
382
|
}
|
|
363
|
-
function addListTools(registry, listQnByEntity, entityByName, locale, sink) {
|
|
383
|
+
function addListTools(registry, listQnByEntity, entityByName, roles, locale, sink) {
|
|
364
384
|
const listEntries = [...listQnByEntity.entries()].sort((a, b) => compareByCodePoint(a[0], b[0]));
|
|
365
385
|
for (const [entityName, qn] of listEntries) {
|
|
366
386
|
const label = entityDisplayLabel(entityByName.get(entityName), entityName, locale);
|
|
367
387
|
const searchableFields = registry.getSearchableFields(entityName);
|
|
368
|
-
const filterableFields = filterableFieldsOf(entityByName.get(entityName));
|
|
369
|
-
const { tool, descriptor } = buildListTool(entityName, qn, label, searchableFields, filterableFields);
|
|
388
|
+
const filterableFields = filterableFieldsOf(entityByName.get(entityName), registry.getEntity(entityName), roles);
|
|
389
|
+
const { tool, descriptor } = buildListTool(entityName, qn, label, searchableFields, filterableFields, entityHandlerRisk(registry, qn));
|
|
370
390
|
addTool(sink, tool, descriptor);
|
|
371
391
|
}
|
|
372
392
|
}
|
|
@@ -455,8 +475,8 @@ export function buildToolCatalog(registry, manifest, options) {
|
|
|
455
475
|
const denyQns = new Set(options.denyQns ?? []);
|
|
456
476
|
addRegistrySearchTools(registry, roleFilter, denyQns, sink);
|
|
457
477
|
const { detailQnByEntity, listQnByEntity, entityListDetailQns } = collectEntityHandlerQns(registry, roleFilter, denyQns);
|
|
458
|
-
addGetTools(detailQnByEntity, entityByName, locale, sink);
|
|
459
|
-
addListTools(registry, listQnByEntity, entityByName, locale, sink);
|
|
478
|
+
addGetTools(registry, detailQnByEntity, entityByName, locale, sink);
|
|
479
|
+
addListTools(registry, listQnByEntity, entityByName, roleFilter.roles, locale, sink);
|
|
460
480
|
addQueryHandlerTools(manifest, entityListDetailQns, denyQns, sink);
|
|
461
481
|
if (options.mode !== "read-only") {
|
|
462
482
|
addWriteHandlerTools(manifest, detailQnByEntity, denyQns, sink);
|
|
@@ -53,6 +53,7 @@ export function createInviteAcceptWithLoginHandler(opts = {}) {
|
|
|
53
53
|
access: { roles: ["anonymous"] },
|
|
54
54
|
rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
|
|
55
55
|
escapeHatch: {
|
|
56
|
+
grants: ["unsafeRaw"],
|
|
56
57
|
reason: "Anonymous invite-accept has no session in the invited tenant yet — checks existing " +
|
|
57
58
|
"membership via ctx.queryAs(SYSTEM, tenant:query:memberships) of the invitation's tenant. " +
|
|
58
59
|
"Also reads the pending invitation by id (the invitee is not yet a member of the " +
|
|
@@ -51,6 +51,7 @@ export function createInviteAcceptHandler() {
|
|
|
51
51
|
},
|
|
52
52
|
agent: { expose: false },
|
|
53
53
|
escapeHatch: {
|
|
54
|
+
grants: ["unsafeRaw"],
|
|
54
55
|
reason: INVITE_ACCEPT_ESCAPE_HATCH_REASON,
|
|
55
56
|
},
|
|
56
57
|
// kumiko-lint-ignore complexity-budget invite branches (auth/anon/burn) stay in one handler
|
|
@@ -34,6 +34,7 @@ export const inviteInfoQuery = defineQueryHandler({
|
|
|
34
34
|
rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
|
|
35
35
|
agent: { expose: false },
|
|
36
36
|
escapeHatch: {
|
|
37
|
+
grants: ["unsafeRaw"],
|
|
37
38
|
reason: READ_PENDING_INVITATION_REASON,
|
|
38
39
|
},
|
|
39
40
|
description: "Anonymous, read-only invite lookup for the invite-acceptance page: reveals the invited " +
|
|
@@ -51,6 +51,7 @@ export function createInviteSignupCompleteHandler() {
|
|
|
51
51
|
rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
|
|
52
52
|
agent: { expose: false },
|
|
53
53
|
escapeHatch: {
|
|
54
|
+
grants: ["unsafeRaw", "globalWrites"],
|
|
54
55
|
reason: INVITE_SIGNUP_COMPLETE_ESCAPE_HATCH_REASON,
|
|
55
56
|
},
|
|
56
57
|
handler: async (event, ctx) => {
|
|
@@ -78,6 +78,7 @@ export function createSignupConfirmHandler() {
|
|
|
78
78
|
rateLimit: { per: "ip+handler", limit: 20, windowSeconds: 60 },
|
|
79
79
|
agent: { expose: false },
|
|
80
80
|
escapeHatch: {
|
|
81
|
+
grants: ["unsafeRaw", "systemIdentity"],
|
|
81
82
|
reason: SIGNUP_CONFIRM_PROVISION_REASON,
|
|
82
83
|
},
|
|
83
84
|
handler: async (event, ctx) => {
|
|
@@ -55,7 +55,7 @@ export function createSignupRequestHandler(opts) {
|
|
|
55
55
|
access: { roles: ["anonymous"] },
|
|
56
56
|
rateLimit: { per: "ip+handler", limit: 10, windowSeconds: 60 },
|
|
57
57
|
description: "Starts magic-link self-registration by mailing a fresh activation link to an address and invalidating any link still outstanding for it; the answer looks the same whether or not the address is already registered.",
|
|
58
|
-
escapeHatch: { reason: HANDOVER_GRANT_VERIFY_REASON },
|
|
58
|
+
escapeHatch: { reason: HANDOVER_GRANT_VERIFY_REASON, grants: ["unsafeRaw"] },
|
|
59
59
|
handler: async (event, ctx) => {
|
|
60
60
|
// Silent no-op when off, matching the route's own always-200
|
|
61
61
|
// anti-enumeration contract (registerTokenRequestRoute swallows every
|
|
@@ -30,6 +30,7 @@ export function createEnableStartPreauthHandler(opts) {
|
|
|
30
30
|
// Same secret-bearing result as enable-start.
|
|
31
31
|
agent: { expose: false },
|
|
32
32
|
escapeHatch: {
|
|
33
|
+
grants: ["unsafeRaw"],
|
|
33
34
|
reason: ENABLE_START_PREAUTH_TENANT_REASON,
|
|
34
35
|
},
|
|
35
36
|
handler: async (event, ctx) => {
|
|
@@ -18,3 +18,5 @@ export declare function paymentAggregateId(tenantId: string): string;
|
|
|
18
18
|
* providerEventId)` rather than the event-store's bigserial `event.id` (not
|
|
19
19
|
* a UUID) or the shared per-tenant aggregateId (one row per payment). */
|
|
20
20
|
export declare function paymentRowId(tenantId: string, providerName: string, providerEventId: string): string;
|
|
21
|
+
/** Stream id of one unmatched termination declaration, derived from its request id. */
|
|
22
|
+
export declare function terminationUnmatchedAggregateId(requestId: string): string;
|
|
@@ -16,6 +16,9 @@ const PAYMENT_NAMESPACE = "dec0b897-646d-4da3-be3c-e9a35a83f4e0";
|
|
|
16
16
|
* UUID, so it can't back a uuid-typed PK. Generated 2026-09-11, set in
|
|
17
17
|
* stone (same rationale as the two namespaces above). */
|
|
18
18
|
const PAYMENT_ROW_NAMESPACE = "3a1c9f4e-6b2d-4c8a-9e5f-7d4b1a2c8e6f";
|
|
19
|
+
/** Namespace for the system-tenant stream of an unmatched termination
|
|
20
|
+
* declaration (one stream per request). Generated 2026-10-03, set in stone. */
|
|
21
|
+
const TERMINATION_UNMATCHED_NAMESPACE = "7e2f4b9a-3c1d-4a68-8d05-b6c9e1f27a34";
|
|
19
22
|
/**
|
|
20
23
|
* Deterministic aggregate-id für die subscription eines Plattform-
|
|
21
24
|
* Tenants. EINE Subscription pro Tenant (Add-Ons sind line-items in
|
|
@@ -51,3 +54,8 @@ export function paymentRowId(tenantId, providerName, providerEventId) {
|
|
|
51
54
|
// the payment with no error anywhere.
|
|
52
55
|
return uuidv5(`${tenantId}:${providerName}:${providerEventId}`, PAYMENT_ROW_NAMESPACE);
|
|
53
56
|
}
|
|
57
|
+
/** Stream id of one unmatched termination declaration, derived from its request id. */
|
|
58
|
+
// @wrapper-known uuid-domain
|
|
59
|
+
export function terminationUnmatchedAggregateId(requestId) {
|
|
60
|
+
return uuidv5(requestId, TERMINATION_UNMATCHED_NAMESPACE);
|
|
61
|
+
}
|
|
@@ -88,6 +88,9 @@ export type OpenCheckoutInput = {
|
|
|
88
88
|
readonly cancelUrl: string;
|
|
89
89
|
readonly providerCustomerId?: string;
|
|
90
90
|
readonly mode?: "subscription" | "payment";
|
|
91
|
+
readonly consentId?: string;
|
|
92
|
+
readonly locale?: string;
|
|
93
|
+
readonly submitMessage?: string;
|
|
91
94
|
};
|
|
92
95
|
/** The one checkout entry-point create-checkout-session and
|
|
93
96
|
* start-plan-checkout both funnel through. `mode: "payment"` (one-off
|
|
@@ -282,6 +282,9 @@ export async function openCheckout(ctx, options, input) {
|
|
|
282
282
|
// pre-hardening wire-contract for callers that never sent `mode`
|
|
283
283
|
// (create-checkout-session's own regression pin).
|
|
284
284
|
...(input.mode && { mode: input.mode }),
|
|
285
|
+
...(input.consentId && { consentId: input.consentId }),
|
|
286
|
+
...(input.locale && { locale: input.locale }),
|
|
287
|
+
...(input.submitMessage && { submitMessage: input.submitMessage }),
|
|
285
288
|
});
|
|
286
289
|
return { url: result.url, providerName: input.providerName };
|
|
287
290
|
}
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
3
3
|
export declare const BILLING_FOUNDATION_FEATURE: "billing-foundation";
|
|
4
4
|
export declare const SUBSCRIPTION_PROVIDER_EXTENSION: "subscriptionProvider";
|
|
5
|
+
/** delivery notification type of the § 312f contract confirmation mail. */
|
|
6
|
+
export declare const CONTRACT_CONFIRMATION_NOTIFICATION_TYPE: "billing-foundation:contract-confirmation";
|
|
7
|
+
/** delivery notification types of the § 312k termination receipt and the
|
|
8
|
+
* operator notice. */
|
|
9
|
+
export declare const CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE: "billing-foundation:contract-termination-receipt";
|
|
10
|
+
export declare const CONTRACT_TERMINATION_OPERATOR_NOTIFICATION_TYPE: "billing-foundation:contract-termination-operator-notice";
|
|
5
11
|
export declare const SubscriptionFoundationHandlers: {
|
|
6
12
|
/** Programmatic entry-point für den webhook-handler. Receives the
|
|
7
13
|
* parsed SubscriptionEvent (vom Plugin schon verifiziert) + macht
|
|
@@ -22,6 +28,22 @@ export declare const SubscriptionFoundationHandlers: {
|
|
|
22
28
|
* (payment-aggregate), separate from the subscription-aggregate — a
|
|
23
29
|
* payment is not a subscription-state transition. */
|
|
24
30
|
readonly processPaymentEvent: "billing-foundation:write:process-payment-event";
|
|
31
|
+
/** System-only: sends the § 312f contract confirmation mail for a recorded
|
|
32
|
+
* checkout consent (consumerProtection only). */
|
|
33
|
+
readonly issueContractConfirmation: "billing-foundation:write:issue-contract-confirmation";
|
|
34
|
+
/** System-only: records a termination/withdrawal on the tenant's
|
|
35
|
+
* subscription stream and asks the provider to cancel (consumerProtection
|
|
36
|
+
* only). */
|
|
37
|
+
readonly recordContractTermination: "billing-foundation:write:record-contract-termination";
|
|
38
|
+
/** System-only: records a public declaration no single tenant could be
|
|
39
|
+
* matched to, on a system-tenant stream (consumerProtection only). */
|
|
40
|
+
readonly recordUnmatchedContractTermination: "billing-foundation:write:record-unmatched-contract-termination";
|
|
41
|
+
/** Anonymous § 312k cancellation/withdrawal declaration (consumerProtection
|
|
42
|
+
* only). */
|
|
43
|
+
readonly requestContractTermination: "billing-foundation:write:request-contract-termination";
|
|
44
|
+
/** Tenant-Admin terminates or withdraws from the contract of their own
|
|
45
|
+
* tenant (consumerProtection only). */
|
|
46
|
+
readonly terminateContract: "billing-foundation:write:terminate-contract";
|
|
25
47
|
/** Tenant-Admin/purchase-role picks a plan tier from the catalog with no
|
|
26
48
|
* existing non-terminal subscription — starts a hosted checkout for the
|
|
27
49
|
* matching price. Only registered when `createBillingFoundationFeature`
|
|
@@ -47,6 +69,9 @@ export declare const SubscriptionFoundationQueries: {
|
|
|
47
69
|
* current tier and per-plan action. Only registered when a `catalog` is
|
|
48
70
|
* configured. */
|
|
49
71
|
readonly billingPlans: "billing-foundation:query:billing-plans";
|
|
72
|
+
/** System-only probe: does the caller tenant hold a non-terminal
|
|
73
|
+
* subscription (consumerProtection only). */
|
|
74
|
+
readonly terminableSubscription: "billing-foundation:query:terminable-subscription";
|
|
50
75
|
};
|
|
51
76
|
export declare const SubscriptionEventTypes: {
|
|
52
77
|
readonly created: "subscription.created";
|
|
@@ -69,6 +94,12 @@ export declare const BillingEventKinds: {
|
|
|
69
94
|
readonly payment: "payment";
|
|
70
95
|
};
|
|
71
96
|
export type BillingEventKind = (typeof BillingEventKinds)[keyof typeof BillingEventKinds];
|
|
97
|
+
export declare const SubscriptionCancelTimings: {
|
|
98
|
+
readonly periodEnd: "period-end";
|
|
99
|
+
readonly immediately: "immediately";
|
|
100
|
+
};
|
|
101
|
+
export type SubscriptionCancelTiming = (typeof SubscriptionCancelTimings)[keyof typeof SubscriptionCancelTimings];
|
|
102
|
+
export declare function parseProviderConsentId(raw: unknown): string | undefined;
|
|
72
103
|
export declare const BILLING_PLANS_SCREEN_ID: "billing-plans";
|
|
73
104
|
export declare const BILLING_PLANS_PANEL_COMPONENT: "BillingPlansPanel";
|
|
74
105
|
export declare const BillingPlanActions: {
|
|
@@ -12,6 +12,12 @@ export const BILLING_FOUNDATION_FEATURE = "billing-foundation";
|
|
|
12
12
|
// Extension-point name fuer Provider-Plugins (subscription-stripe,
|
|
13
13
|
// subscription-mollie, ...).
|
|
14
14
|
export const SUBSCRIPTION_PROVIDER_EXTENSION = "subscriptionProvider";
|
|
15
|
+
/** delivery notification type of the § 312f contract confirmation mail. */
|
|
16
|
+
export const CONTRACT_CONFIRMATION_NOTIFICATION_TYPE = "billing-foundation:contract-confirmation";
|
|
17
|
+
/** delivery notification types of the § 312k termination receipt and the
|
|
18
|
+
* operator notice. */
|
|
19
|
+
export const CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE = "billing-foundation:contract-termination-receipt";
|
|
20
|
+
export const CONTRACT_TERMINATION_OPERATOR_NOTIFICATION_TYPE = "billing-foundation:contract-termination-operator-notice";
|
|
15
21
|
// Qualified write handler names (QN format: scope:type:name).
|
|
16
22
|
export const SubscriptionFoundationHandlers = {
|
|
17
23
|
/** Programmatic entry-point für den webhook-handler. Receives the
|
|
@@ -33,6 +39,22 @@ export const SubscriptionFoundationHandlers = {
|
|
|
33
39
|
* (payment-aggregate), separate from the subscription-aggregate — a
|
|
34
40
|
* payment is not a subscription-state transition. */
|
|
35
41
|
processPaymentEvent: "billing-foundation:write:process-payment-event",
|
|
42
|
+
/** System-only: sends the § 312f contract confirmation mail for a recorded
|
|
43
|
+
* checkout consent (consumerProtection only). */
|
|
44
|
+
issueContractConfirmation: "billing-foundation:write:issue-contract-confirmation",
|
|
45
|
+
/** System-only: records a termination/withdrawal on the tenant's
|
|
46
|
+
* subscription stream and asks the provider to cancel (consumerProtection
|
|
47
|
+
* only). */
|
|
48
|
+
recordContractTermination: "billing-foundation:write:record-contract-termination",
|
|
49
|
+
/** System-only: records a public declaration no single tenant could be
|
|
50
|
+
* matched to, on a system-tenant stream (consumerProtection only). */
|
|
51
|
+
recordUnmatchedContractTermination: "billing-foundation:write:record-unmatched-contract-termination",
|
|
52
|
+
/** Anonymous § 312k cancellation/withdrawal declaration (consumerProtection
|
|
53
|
+
* only). */
|
|
54
|
+
requestContractTermination: "billing-foundation:write:request-contract-termination",
|
|
55
|
+
/** Tenant-Admin terminates or withdraws from the contract of their own
|
|
56
|
+
* tenant (consumerProtection only). */
|
|
57
|
+
terminateContract: "billing-foundation:write:terminate-contract",
|
|
36
58
|
/** Tenant-Admin/purchase-role picks a plan tier from the catalog with no
|
|
37
59
|
* existing non-terminal subscription — starts a hosted checkout for the
|
|
38
60
|
* matching price. Only registered when `createBillingFoundationFeature`
|
|
@@ -59,6 +81,9 @@ export const SubscriptionFoundationQueries = {
|
|
|
59
81
|
* current tier and per-plan action. Only registered when a `catalog` is
|
|
60
82
|
* configured. */
|
|
61
83
|
billingPlans: "billing-foundation:query:billing-plans",
|
|
84
|
+
/** System-only probe: does the caller tenant hold a non-terminal
|
|
85
|
+
* subscription (consumerProtection only). */
|
|
86
|
+
terminableSubscription: "billing-foundation:query:terminable-subscription",
|
|
62
87
|
};
|
|
63
88
|
// Normalized subscription-event types — provider-agnostic.
|
|
64
89
|
// Alle Provider-Plugins normalisieren ihre eigenen event-types auf einen
|
|
@@ -90,6 +115,16 @@ export const BillingEventKinds = {
|
|
|
90
115
|
subscription: "subscription",
|
|
91
116
|
payment: "payment",
|
|
92
117
|
};
|
|
118
|
+
export const SubscriptionCancelTimings = {
|
|
119
|
+
periodEnd: "period-end",
|
|
120
|
+
immediately: "immediately",
|
|
121
|
+
};
|
|
122
|
+
// consentId is echoed back by the provider (checkout metadata), so it is
|
|
123
|
+
// untrusted input: bounded length, id-ish charset only.
|
|
124
|
+
const CONSENT_ID_PATTERN = /^[A-Za-z0-9_-]{1,100}$/;
|
|
125
|
+
export function parseProviderConsentId(raw) {
|
|
126
|
+
return typeof raw === "string" && CONSENT_ID_PATTERN.test(raw) ? raw : undefined;
|
|
127
|
+
}
|
|
93
128
|
// Billing-plans screen/panel identifiers — the dormant dashboard app-builders
|
|
94
129
|
// mount their own catalog-derived nav entry onto.
|
|
95
130
|
export const BILLING_PLANS_SCREEN_ID = "billing-plans";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { CheckoutConsentRecordedPayload } from "../events.js";
|
|
2
|
+
export type ContractConfirmationContent = {
|
|
3
|
+
readonly subject: string;
|
|
4
|
+
readonly header: string;
|
|
5
|
+
readonly sections: readonly {
|
|
6
|
+
readonly text: string;
|
|
7
|
+
}[];
|
|
8
|
+
readonly footer: string;
|
|
9
|
+
};
|
|
10
|
+
export type RenderContractConfirmationArgs = {
|
|
11
|
+
readonly consent: CheckoutConsentRecordedPayload;
|
|
12
|
+
readonly consentGivenAtIso: string;
|
|
13
|
+
readonly contractStartIso: string;
|
|
14
|
+
readonly currentPeriodEndIso?: string;
|
|
15
|
+
readonly vatNote: Readonly<Record<string, string>>;
|
|
16
|
+
readonly operatorEmail: string;
|
|
17
|
+
readonly termsContent: string;
|
|
18
|
+
};
|
|
19
|
+
/** Structured mail content (header/sections/footer) for the email channel's
|
|
20
|
+
* renderer; the renderer owns HTML escaping, this only builds plain text. */
|
|
21
|
+
export declare function renderContractConfirmation(args: RenderContractConfirmationArgs): ContractConfirmationContent;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { Temporal } from "temporal-polyfill";
|
|
2
|
+
import { CONSENT_TEXTS, resolveConsentLocale } from "./consent-text.js";
|
|
3
|
+
const LABELS = {
|
|
4
|
+
de: {
|
|
5
|
+
subject: "Vertragsbestätigung",
|
|
6
|
+
intro: "vielen Dank für Ihre Bestellung. Hiermit bestätigen wir den Vertragsschluss mit folgendem Inhalt.",
|
|
7
|
+
plan: "Tarif",
|
|
8
|
+
oneOffPayment: "Einmalzahlung",
|
|
9
|
+
price: "Preis",
|
|
10
|
+
oneOff: "einmalig",
|
|
11
|
+
contractStart: "Vertragsbeginn",
|
|
12
|
+
currentPeriodEnd: "Aktueller Abrechnungszeitraum bis",
|
|
13
|
+
consentHeading: "Ihre Zustimmungen",
|
|
14
|
+
consentGivenAt: "Erteilt am",
|
|
15
|
+
termsHeading: "Allgemeine Geschäftsbedingungen",
|
|
16
|
+
contact: "Fragen zu Ihrem Vertrag richten Sie bitte an",
|
|
17
|
+
every: "alle",
|
|
18
|
+
intervals: {
|
|
19
|
+
day: { one: "Tag", many: "Tage" },
|
|
20
|
+
week: { one: "Woche", many: "Wochen" },
|
|
21
|
+
month: { one: "Monat", many: "Monate" },
|
|
22
|
+
year: { one: "Jahr", many: "Jahre" },
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
en: {
|
|
26
|
+
subject: "Contract confirmation",
|
|
27
|
+
intro: "thank you for your order. We hereby confirm the conclusion of the contract with the following content.",
|
|
28
|
+
plan: "Plan",
|
|
29
|
+
oneOffPayment: "One-off payment",
|
|
30
|
+
price: "Price",
|
|
31
|
+
oneOff: "one-off",
|
|
32
|
+
contractStart: "Contract start",
|
|
33
|
+
currentPeriodEnd: "Current billing period ends",
|
|
34
|
+
consentHeading: "Your consents",
|
|
35
|
+
consentGivenAt: "Given on",
|
|
36
|
+
termsHeading: "Terms and conditions",
|
|
37
|
+
contact: "Questions about your contract go to",
|
|
38
|
+
every: "every",
|
|
39
|
+
intervals: {
|
|
40
|
+
day: { one: "day", many: "days" },
|
|
41
|
+
week: { one: "week", many: "weeks" },
|
|
42
|
+
month: { one: "month", many: "months" },
|
|
43
|
+
year: { one: "year", many: "years" },
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
function formatPrice(consent, locale) {
|
|
48
|
+
const labels = LABELS[locale];
|
|
49
|
+
if (consent.unitAmount === null || consent.currency === null)
|
|
50
|
+
return "-";
|
|
51
|
+
let amount;
|
|
52
|
+
try {
|
|
53
|
+
const format = new Intl.NumberFormat(locale, { style: "currency", currency: consent.currency });
|
|
54
|
+
const fractionDigits = format.resolvedOptions().maximumFractionDigits ?? 2;
|
|
55
|
+
amount = format.format(consent.unitAmount / 10 ** fractionDigits);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Currency codes come from the provider; an unknown one must not block the mail.
|
|
59
|
+
amount = `${consent.unitAmount} ${consent.currency.toUpperCase()}`;
|
|
60
|
+
}
|
|
61
|
+
if (consent.interval === null)
|
|
62
|
+
return `${amount} (${labels.oneOff})`;
|
|
63
|
+
const unit = labels.intervals[consent.interval];
|
|
64
|
+
const count = consent.intervalCount ?? 1;
|
|
65
|
+
const intervalText = unit === undefined
|
|
66
|
+
? consent.interval
|
|
67
|
+
: count === 1
|
|
68
|
+
? unit.one
|
|
69
|
+
: `${labels.every} ${count} ${unit.many}`;
|
|
70
|
+
return count === 1 ? `${amount} / ${intervalText}` : `${amount}, ${intervalText}`;
|
|
71
|
+
}
|
|
72
|
+
function formatDate(iso, locale) {
|
|
73
|
+
let epochMilliseconds;
|
|
74
|
+
try {
|
|
75
|
+
epochMilliseconds = Temporal.Instant.from(iso).epochMilliseconds;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return iso;
|
|
79
|
+
}
|
|
80
|
+
return new Intl.DateTimeFormat(locale, {
|
|
81
|
+
dateStyle: "long",
|
|
82
|
+
timeStyle: "short",
|
|
83
|
+
timeZone: "UTC",
|
|
84
|
+
}).format(epochMilliseconds);
|
|
85
|
+
}
|
|
86
|
+
function paragraphs(text) {
|
|
87
|
+
return text
|
|
88
|
+
.split(/\n{2,}/)
|
|
89
|
+
.map((part) => part.trim())
|
|
90
|
+
.filter((part) => part.length > 0)
|
|
91
|
+
.map((part) => ({ text: part }));
|
|
92
|
+
}
|
|
93
|
+
/** Structured mail content (header/sections/footer) for the email channel's
|
|
94
|
+
* renderer; the renderer owns HTML escaping, this only builds plain text. */
|
|
95
|
+
export function renderContractConfirmation(args) {
|
|
96
|
+
const locale = resolveConsentLocale(args.consent.locale);
|
|
97
|
+
const labels = LABELS[locale];
|
|
98
|
+
const texts = CONSENT_TEXTS[locale];
|
|
99
|
+
const { consent } = args;
|
|
100
|
+
const contractLines = [
|
|
101
|
+
`${labels.plan}: ${consent.tier ?? labels.oneOffPayment}`,
|
|
102
|
+
`${labels.price}: ${formatPrice(consent, locale)}`,
|
|
103
|
+
`${labels.contractStart}: ${formatDate(args.contractStartIso, locale)}`,
|
|
104
|
+
...(args.currentPeriodEndIso !== undefined
|
|
105
|
+
? [`${labels.currentPeriodEnd}: ${formatDate(args.currentPeriodEndIso, locale)}`]
|
|
106
|
+
: []),
|
|
107
|
+
];
|
|
108
|
+
const vatNote = args.vatNote[locale] ?? args.vatNote[resolveConsentLocale()];
|
|
109
|
+
return {
|
|
110
|
+
subject: labels.subject,
|
|
111
|
+
header: labels.subject,
|
|
112
|
+
sections: [
|
|
113
|
+
{ text: labels.intro },
|
|
114
|
+
...contractLines.map((text) => ({ text })),
|
|
115
|
+
...(vatNote !== undefined ? [{ text: vatNote }] : []),
|
|
116
|
+
{ text: labels.consentHeading },
|
|
117
|
+
{ text: texts.earlyPerformance },
|
|
118
|
+
{ text: texts.withdrawalLoss },
|
|
119
|
+
{ text: `${labels.consentGivenAt}: ${formatDate(args.consentGivenAtIso, locale)}` },
|
|
120
|
+
{ text: labels.termsHeading },
|
|
121
|
+
...paragraphs(args.termsContent),
|
|
122
|
+
],
|
|
123
|
+
footer: `${labels.contact} ${args.operatorEmail}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import type { ConsumerProtectionOptions, ProviderPrice } from "../types.js";
|
|
4
|
+
export type CheckoutMode = "subscription" | "payment";
|
|
5
|
+
export declare const consentPayloadSchema: z.ZodObject<{
|
|
6
|
+
earlyPerformanceRequested: z.ZodLiteral<true>;
|
|
7
|
+
withdrawalLossAcknowledged: z.ZodLiteral<true>;
|
|
8
|
+
consentTextVersion: z.ZodString;
|
|
9
|
+
locale: z.ZodString;
|
|
10
|
+
}, z.core.$strict>;
|
|
11
|
+
export type ConsentPayload = z.infer<typeof consentPayloadSchema>;
|
|
12
|
+
export type PreparedConsent = {
|
|
13
|
+
readonly consentId: string;
|
|
14
|
+
readonly mode: CheckoutMode;
|
|
15
|
+
readonly locale: string;
|
|
16
|
+
readonly submitMessage: string;
|
|
17
|
+
readonly consentTextVersion: string;
|
|
18
|
+
readonly termsHash: string;
|
|
19
|
+
readonly termsTemplateVersion: number;
|
|
20
|
+
};
|
|
21
|
+
export type ConsentPriceDetails = {
|
|
22
|
+
readonly tier: string | null;
|
|
23
|
+
readonly priceId: string;
|
|
24
|
+
readonly price: Pick<ProviderPrice, "unitAmount" | "currency" | "interval" | "intervalCount"> | null;
|
|
25
|
+
};
|
|
26
|
+
export declare function prepareConsent(ctx: HandlerContext, options: ConsumerProtectionOptions, consent: ConsentPayload, mode: CheckoutMode): Promise<PreparedConsent>;
|
|
27
|
+
export declare function consentCheckoutFields(prepared: PreparedConsent): {
|
|
28
|
+
readonly consentId: string;
|
|
29
|
+
readonly locale: string;
|
|
30
|
+
readonly submitMessage: string;
|
|
31
|
+
};
|
|
32
|
+
export declare function recordConsent(ctx: HandlerContext, prepared: PreparedConsent, details: ConsentPriceDetails): Promise<void>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Consent gate shared by start-plan-checkout and create-checkout-session:
|
|
2
|
+
// validate + prepare before the provider call, record only after it succeeded.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
|
+
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
import { requireTemplateResolver, TEXT_BLOCK_KIND, TemplateNotFoundError, } from "../../template-resolver/index.js";
|
|
8
|
+
import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id.js";
|
|
9
|
+
import { CHECKOUT_CONSENT_RECORDED_EVENT_QN, PAYMENT_AGGREGATE_TYPE, SUBSCRIPTION_AGGREGATE_TYPE, } from "../events.js";
|
|
10
|
+
import { consentTextVersion, resolveConsentLocale, submitMessageFor } from "./consent-text.js";
|
|
11
|
+
export const consentPayloadSchema = z
|
|
12
|
+
.object({
|
|
13
|
+
earlyPerformanceRequested: z.literal(true),
|
|
14
|
+
withdrawalLossAcknowledged: z.literal(true),
|
|
15
|
+
consentTextVersion: z.string().min(1).max(64),
|
|
16
|
+
locale: z.string().min(2).max(35),
|
|
17
|
+
})
|
|
18
|
+
.strict();
|
|
19
|
+
export async function prepareConsent(ctx, options, consent, mode) {
|
|
20
|
+
const locale = resolveConsentLocale(consent.locale);
|
|
21
|
+
if (consent.consentTextVersion !== consentTextVersion(locale)) {
|
|
22
|
+
throw new UnprocessableError("consent_text_outdated", {
|
|
23
|
+
i18nKey: "billing-foundation.errors.consentTextOutdated",
|
|
24
|
+
message: `billing-foundation: consent text version "${consent.consentTextVersion}" is not current for locale "${locale}"`,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const resolver = requireTemplateResolver(ctx, "billing-foundation");
|
|
28
|
+
let terms;
|
|
29
|
+
try {
|
|
30
|
+
terms = await resolver.resolveTemplate({
|
|
31
|
+
tenantId: ctx.user.tenantId,
|
|
32
|
+
slug: options.termsTextBlock,
|
|
33
|
+
kind: TEXT_BLOCK_KIND,
|
|
34
|
+
locale,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof TemplateNotFoundError) {
|
|
39
|
+
throw new UnprocessableError("terms_unavailable", {
|
|
40
|
+
i18nKey: "billing-foundation.errors.termsUnavailable",
|
|
41
|
+
message: `billing-foundation: terms text block "${options.termsTextBlock}" could not be resolved`,
|
|
42
|
+
cause: error,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
consentId: generateId(),
|
|
49
|
+
mode,
|
|
50
|
+
locale,
|
|
51
|
+
submitMessage: submitMessageFor(locale, mode),
|
|
52
|
+
consentTextVersion: consent.consentTextVersion,
|
|
53
|
+
termsHash: createHash("sha256").update(terms.content).digest("hex"),
|
|
54
|
+
termsTemplateVersion: terms.version,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function consentCheckoutFields(prepared) {
|
|
58
|
+
return {
|
|
59
|
+
consentId: prepared.consentId,
|
|
60
|
+
locale: prepared.locale,
|
|
61
|
+
submitMessage: prepared.submitMessage,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export async function recordConsent(ctx, prepared, details) {
|
|
65
|
+
const payload = {
|
|
66
|
+
consentId: prepared.consentId,
|
|
67
|
+
mode: prepared.mode,
|
|
68
|
+
tier: details.tier,
|
|
69
|
+
priceId: details.priceId,
|
|
70
|
+
unitAmount: details.price?.unitAmount ?? null,
|
|
71
|
+
currency: details.price?.currency ?? null,
|
|
72
|
+
interval: details.price?.interval ?? null,
|
|
73
|
+
intervalCount: details.price?.intervalCount ?? null,
|
|
74
|
+
consentTextVersion: prepared.consentTextVersion,
|
|
75
|
+
termsHash: prepared.termsHash,
|
|
76
|
+
termsTemplateVersion: prepared.termsTemplateVersion,
|
|
77
|
+
locale: prepared.locale,
|
|
78
|
+
actorUserId: String(ctx.user.id),
|
|
79
|
+
};
|
|
80
|
+
const tenantId = ctx.user.tenantId;
|
|
81
|
+
const isSubscription = prepared.mode === "subscription";
|
|
82
|
+
await ctx.unsafeAppendEvent({
|
|
83
|
+
aggregateId: isSubscription ? subscriptionAggregateId(tenantId) : paymentAggregateId(tenantId),
|
|
84
|
+
aggregateType: isSubscription ? SUBSCRIPTION_AGGREGATE_TYPE : PAYMENT_AGGREGATE_TYPE,
|
|
85
|
+
type: CHECKOUT_CONSENT_RECORDED_EVENT_QN,
|
|
86
|
+
payload,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const CONSENT_LOCALES: readonly ["de", "en"];
|
|
2
|
+
export type ConsentLocale = (typeof CONSENT_LOCALES)[number];
|
|
3
|
+
export declare const FALLBACK_CONSENT_LOCALE: ConsentLocale;
|
|
4
|
+
/** Exact match, else the language part ("de-AT" → "de"), else the fallback. */
|
|
5
|
+
export declare function resolveConsentLocale(input?: string): ConsentLocale;
|