@cosmicdrift/kumiko-samples 0.363.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.
Files changed (140) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +7 -1
  3. package/packages/bundled-features/src/audit/changes.json +6 -0
  4. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  5. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
  6. package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
  7. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  8. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  9. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  10. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  11. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  12. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  13. package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
  14. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  15. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  17. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  24. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  25. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  26. package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
  27. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
  28. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  29. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  30. package/packages/bundled-features/src/cap-counter/changes.json +6 -0
  31. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  32. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  33. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  34. package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
  35. package/packages/bundled-features/src/config/changes.json +6 -0
  36. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  37. package/packages/bundled-features/src/config/resolver.ts +15 -12
  38. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  39. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  40. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
  41. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  42. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  43. package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
  44. package/packages/bundled-features/src/delivery/changes.json +18 -0
  45. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  46. package/packages/bundled-features/src/delivery/index.ts +2 -0
  47. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  48. package/packages/bundled-features/src/delivery/types.ts +7 -1
  49. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  50. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  51. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  52. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  53. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  56. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  57. package/packages/bundled-features/src/jobs/changes.json +19 -0
  58. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  59. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  60. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  61. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  62. package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
  63. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  64. package/packages/bundled-features/src/ledger/changes.json +7 -0
  65. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  66. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  67. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  68. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  69. package/packages/bundled-features/src/secrets/changes.json +6 -0
  70. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  71. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  72. package/packages/bundled-features/src/shared/changes.json +6 -0
  73. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  74. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  75. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  76. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  77. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  78. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  79. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  80. package/packages/bundled-features/src/template-resolver/changes.json +68 -0
  81. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
  82. package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
  83. package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
  84. package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
  85. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
  86. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
  87. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  88. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  89. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  90. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  91. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  92. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  93. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
  94. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
  95. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  96. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  97. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  98. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  99. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  100. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  101. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  102. package/packages/bundled-features/src/template-resolver/index.ts +12 -1
  103. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
  104. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  105. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  106. package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
  107. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
  108. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  109. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  110. package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
  111. package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
  112. package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
  113. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  114. package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
  115. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  116. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
  117. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
  118. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
  119. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
  120. package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
  121. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  122. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
  123. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  124. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  125. package/packages/bundled-features/src/user/changes.json +6 -0
  126. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  127. package/packages/bundled-features/src/user/seeding.ts +19 -5
  128. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  129. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  130. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  131. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  132. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  133. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  134. package/samples/apps/ui-walkthrough/package.json +9 -0
  135. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  136. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  137. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  138. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  139. package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
  140. package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
@@ -35,6 +35,7 @@ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from
35
35
  import { CONSENT_LOCALES, FALLBACK_CONSENT_LOCALE } from "./consent-locale.js";
36
36
  import { notifyOperator } from "./termination-notify.js";
37
37
  import type { OperatorNoticeReason } from "./termination-texts.js";
38
+ import { isWithinWithdrawalPeriod } from "./withdrawal-period.js";
38
39
 
39
40
  const REQUESTED_CANCEL_TIMINGS = [
40
41
  SubscriptionCancelTimings.periodEnd,
@@ -74,6 +75,8 @@ export type RecordContractTerminationResult = {
74
75
  OperatorNoticeReason,
75
76
  "provider_cannot_cancel" | "provider_error" | "no_active_subscription"
76
77
  > | null;
78
+ /** An account withdrawal arrived after the period: cancelled at period end instead. */
79
+ readonly withdrawalPeriodExpired: boolean;
77
80
  };
78
81
 
79
82
  export const recordUnmatchedContractTerminationSchema = z
@@ -210,14 +213,19 @@ export function createRecordContractTerminationHandler(
210
213
  effectiveAtIso: alreadyRecorded.effectiveAtIso,
211
214
  providerCancel: alreadyRecorded.providerCancel,
212
215
  noticeReason: null,
216
+ withdrawalPeriodExpired: false,
213
217
  } satisfies RecordContractTerminationResult,
214
218
  };
215
219
  }
216
220
 
221
+ const withdrawalPeriodExpired =
222
+ payload.declarationType === "withdrawal" &&
223
+ payload.when === SubscriptionCancelTimings.immediately &&
224
+ !isWithinWithdrawalPeriod(stream.events, options.now());
217
225
  const { providerCancel, effectiveAtIso, noticeReason } = await cancelAtProvider(
218
226
  ctx,
219
227
  tenantId,
220
- payload.when,
228
+ withdrawalPeriodExpired ? SubscriptionCancelTimings.periodEnd : payload.when,
221
229
  receivedAtIso,
222
230
  );
223
231
 
@@ -260,6 +268,7 @@ export function createRecordContractTerminationHandler(
260
268
  effectiveAtIso,
261
269
  providerCancel,
262
270
  noticeReason,
271
+ withdrawalPeriodExpired,
263
272
  } satisfies RecordContractTerminationResult,
264
273
  };
265
274
  },
@@ -33,10 +33,10 @@ import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from
33
33
  import { resolveConsentLocale } from "./consent-text.js";
34
34
  import { type TerminationDeclaration, terminationReceiptVariables } from "./termination-mail.js";
35
35
  import { notifyOperator, type OperatorNoticePlan, requireNotify } from "./termination-notify.js";
36
+ import { mayMailReceipt } from "./termination-receipt-limit.js";
36
37
  import type { RecordContractTerminationResult } from "./termination-record.js";
37
38
  import type { OperatorNoticeReason } from "./termination-texts.js";
38
39
 
39
- const TENANT_ADMIN_ROLE = "TenantAdmin";
40
40
  const EXTRAORDINARY_REASON_MESSAGE = "reason is required for an extraordinary termination";
41
41
 
42
42
  const authUserRowSchema = z.object({
@@ -109,28 +109,38 @@ async function sendTerminationMails(
109
109
  consumerProtection: ConsumerProtectionOptions,
110
110
  plan: TerminationMailPlan,
111
111
  ): Promise<void> {
112
- await requireNotify(ctx)(CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE, {
113
- route: { email: plan.declaration.email },
114
- data: terminationReceiptVariables({
112
+ const receiptAllowed = await mayMailReceipt(ctx, plan.declaration.email);
113
+ if (receiptAllowed) {
114
+ await requireNotify(ctx)(CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE, {
115
+ route: { email: plan.declaration.email },
116
+ data: terminationReceiptVariables({
117
+ locale: plan.locale,
118
+ requestId: plan.requestId,
119
+ receivedAtIso: plan.receivedAtIso,
120
+ declaration: {
121
+ declarationType: plan.declaration.declarationType,
122
+ terminationKind: plan.declaration.terminationKind,
123
+ },
124
+ operatorEmail: consumerProtection.operatorEmail,
125
+ }),
115
126
  locale: plan.locale,
116
- requestId: plan.requestId,
117
- receivedAtIso: plan.receivedAtIso,
118
- declaration: plan.declaration,
119
- operatorEmail: consumerProtection.operatorEmail,
120
- }),
121
- locale: plan.locale,
122
- priority: "critical",
123
- });
127
+ priority: "critical",
128
+ });
129
+ }
130
+ const reasons = receiptAllowed
131
+ ? plan.reasons
132
+ : [...plan.reasons, "receipt_rate_limited" as const];
124
133
  // skip: no operator notice needed for this declaration
125
- if (plan.reasons.length === 0) return;
126
- await notifyOperator(ctx, consumerProtection, plan);
134
+ if (reasons.length === 0) return;
135
+ await notifyOperator(ctx, consumerProtection, { ...plan, reasons });
127
136
  }
128
137
 
129
138
  // Tenants the entered email can terminate a contract for: tenants where the
130
- // user is TenantAdmin and the subscription is not terminal. Every identity
139
+ // user holds a purchase role and the subscription is not terminal. Every identity
131
140
  // below comes from the trusted lookups, never from the payload.
132
141
  async function findTerminableTenantIds(
133
142
  ctx: HandlerContext,
143
+ options: ResolvedBillingFoundationOptions,
134
144
  requestTenantId: string,
135
145
  email: string,
136
146
  ): Promise<readonly string[]> {
@@ -148,10 +158,11 @@ async function findTerminableTenantIds(
148
158
  }),
149
159
  );
150
160
  if (!memberships.success) return [];
161
+ const purchaseRoles = purchaseRolesOf(options.catalog);
151
162
  const adminTenantIds = [
152
163
  ...new Set(
153
164
  memberships.data
154
- .filter((membership) => membership.roles.includes(TENANT_ADMIN_ROLE))
165
+ .filter((membership) => membership.roles.some((role) => purchaseRoles.includes(role)))
155
166
  .map((membership) => membership.tenantId),
156
167
  ),
157
168
  ];
@@ -180,7 +191,6 @@ export function createRequestContractTerminationHandler(
180
191
  schema: requestContractTerminationSchema,
181
192
  access: { roles: ["anonymous"] },
182
193
  rateLimit: { per: "ip+handler", limit: 5, windowSeconds: 600 },
183
- additionalRateLimits: [{ per: { payloadField: "email" }, limit: 3, windowSeconds: 86400 }],
184
194
  ...(consumerProtection.terminationScope === "platform" && { tenantlessAnonymous: true }),
185
195
  escapeHatch: {
186
196
  reason:
@@ -196,7 +206,12 @@ export function createRequestContractTerminationHandler(
196
206
  const locale = resolveConsentLocale(payload.locale);
197
207
  let matchedTenantId: string | undefined;
198
208
 
199
- const candidates = await findTerminableTenantIds(ctx, event.user.tenantId, payload.email);
209
+ const candidates = await findTerminableTenantIds(
210
+ ctx,
211
+ options,
212
+ event.user.tenantId,
213
+ payload.email,
214
+ );
200
215
  const [onlyCandidate] = candidates;
201
216
  if (candidates.length === 1 && onlyCandidate !== undefined) {
202
217
  matchedTenantId = onlyCandidate;
@@ -257,7 +272,7 @@ export function createTerminateContractHandler(
257
272
  return {
258
273
  name: "terminate-contract",
259
274
  description:
260
- "Terminates the tenant's subscription at the end of the billing period, or withdraws from the contract and cancels it immediately; sends a receipt to the caller's email.",
275
+ "Terminates the tenant's subscription at the end of the billing period, or withdraws from the contract and cancels it immediately within the 14-day withdrawal period (at period end after it); sends a receipt to the caller's email.",
261
276
  schema: terminateContractSchema,
262
277
  access: { roles: purchaseRolesOf(options.catalog) },
263
278
  escapeHatch: {
@@ -313,6 +328,7 @@ export function createTerminateContractHandler(
313
328
  },
314
329
  reasons: [
315
330
  ...(data.noticeReason !== null ? [data.noticeReason] : []),
331
+ ...(data.withdrawalPeriodExpired ? (["withdrawal_period_expired"] as const) : []),
316
332
  ...needsOperatorNotice(payload),
317
333
  ],
318
334
  tenantId,
@@ -72,6 +72,8 @@ export const OPERATOR_NOTICE_REASONS = [
72
72
  "provider_error",
73
73
  "no_active_subscription",
74
74
  "recording_failed",
75
+ "withdrawal_period_expired",
76
+ "receipt_rate_limited",
75
77
  ] as const;
76
78
  export type OperatorNoticeReason = (typeof OPERATOR_NOTICE_REASONS)[number];
77
79
 
@@ -116,6 +118,10 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
116
118
  provider_error: "Die Kündigung beim Zahlungsanbieter ist fehlgeschlagen",
117
119
  no_active_subscription: "Keine laufende Subscription beim Anbieter",
118
120
  recording_failed: "Die Erklärung konnte nicht gespeichert werden",
121
+ withdrawal_period_expired:
122
+ "Widerruf nach Ablauf der Widerrufsfrist: zum Periodenende gekündigt, Anspruch prüfen",
123
+ receipt_rate_limited:
124
+ "Keine Eingangsbestätigung versendet: Limit pro Empfängeradresse erreicht",
119
125
  },
120
126
  noticeReasons: "Grund der Benachrichtigung",
121
127
  channel: "Kanal",
@@ -199,6 +205,9 @@ export const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>
199
205
  provider_error: "Cancelling at the payment provider failed",
200
206
  no_active_subscription: "No running subscription at the provider",
201
207
  recording_failed: "The declaration could not be stored",
208
+ withdrawal_period_expired:
209
+ "Withdrawal after the withdrawal period: cancelled at period end, review the claim",
210
+ receipt_rate_limited: "No receipt mail sent: the per-recipient limit was reached",
202
211
  },
203
212
  noticeReasons: "Reason for this notice",
204
213
  channel: "Channel",
@@ -0,0 +1,35 @@
1
+ import type { Temporal } from "@cosmicdrift/kumiko-types/temporal";
2
+ import { CHECKOUT_CONSENT_RECORDED_EVENT_QN, SUBSCRIPTION_CREATED_EVENT_QN } from "../events.js";
3
+
4
+ // § 355 BGB: the withdrawal period is 14 days from conclusion of the contract.
5
+ export const WITHDRAWAL_PERIOD_DAYS = 14;
6
+ const HOURS_PER_DAY = 24;
7
+
8
+ const PERIOD_START_EVENT_TYPES: ReadonlySet<string> = new Set([
9
+ CHECKOUT_CONSENT_RECORDED_EVENT_QN,
10
+ SUBSCRIPTION_CREATED_EVENT_QN,
11
+ ]);
12
+
13
+ type PeriodStartEvent = { readonly type: string; readonly createdAt: Temporal.Instant };
14
+
15
+ /** The period runs from the latest consent or subscription start on the stream.
16
+ * A stream with neither has no provable start and counts as expired. */
17
+ export function isWithinWithdrawalPeriod(
18
+ events: readonly PeriodStartEvent[],
19
+ now: Temporal.Instant,
20
+ ): boolean {
21
+ let latestStart: Temporal.Instant | undefined;
22
+ for (const event of events) {
23
+ if (!PERIOD_START_EVENT_TYPES.has(event.type)) continue;
24
+ if (
25
+ latestStart === undefined ||
26
+ event.createdAt.epochMilliseconds > latestStart.epochMilliseconds
27
+ ) {
28
+ latestStart = event.createdAt;
29
+ }
30
+ }
31
+ // skip: no consent or subscription-created event, nothing proves a running period
32
+ if (latestStart === undefined) return false;
33
+ const periodEnd = latestStart.add({ hours: WITHDRAWAL_PERIOD_DAYS * HOURS_PER_DAY });
34
+ return now.epochMilliseconds <= periodEnd.epochMilliseconds;
35
+ }
@@ -23,7 +23,7 @@
23
23
  // hand-picked priceId.
24
24
 
25
25
  import type { HandlerContext, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
26
- import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
26
+ import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
27
27
  import * as z from "zod";
28
28
  import {
29
29
  assertCheckoutAllowed,
@@ -32,6 +32,7 @@ import {
32
32
  startProviderCheckout,
33
33
  } from "../checkout-core.js";
34
34
  import {
35
+ assertValidOrderItem,
35
36
  type ConsentPayload,
36
37
  consentCheckoutFields,
37
38
  consentPayloadSchema,
@@ -104,11 +105,12 @@ export function createCheckoutSessionHandler(
104
105
 
105
106
  const mode = payload.mode ?? "subscription";
106
107
  const plugin = await assertCheckoutAllowed(ctx, openOptions, checkoutInput);
107
- const price = await lookupPrice(ctx, plugin, payload.priceId);
108
+ const price = await lookupPrice(ctx, plugin, payload.providerName, payload.priceId);
108
109
  const orderItem =
109
110
  mode === "payment"
110
111
  ? await consumerProtection.oneOffItemLabel?.(ctx, payload.priceId)
111
112
  : undefined;
113
+ assertValidOrderItem(orderItem, payload.priceId);
112
114
  const consent = await prepareConsent(ctx, consumerProtection, payload.consent, mode);
113
115
  const result = await startProviderCheckout(ctx, plugin, {
114
116
  ...checkoutInput,
@@ -134,11 +136,17 @@ export function createCheckoutSessionHandler(
134
136
  async function lookupPrice(
135
137
  ctx: HandlerContext,
136
138
  plugin: SubscriptionProviderPlugin,
139
+ providerName: string,
137
140
  priceId: string,
138
141
  ): Promise<ProviderPrice> {
142
+ if (!plugin.retrievePrices) {
143
+ throw new InternalError({
144
+ message: `billing-foundation: provider "${providerName}" lacks retrievePrices, required by consumerProtection`,
145
+ });
146
+ }
139
147
  let price: ProviderPrice | undefined;
140
148
  try {
141
- const prices = (await plugin.retrievePrices?.(ctx, [priceId])) ?? [];
149
+ const prices = await plugin.retrievePrices(ctx, [priceId]);
142
150
  price = prices.find((p) => p.priceId === priceId);
143
151
  } catch (cause) {
144
152
  throw priceUnavailable(priceId, cause);
@@ -21,6 +21,7 @@ import * as z from "zod";
21
21
  import { subscriptionAggregateId } from "../aggregate-id.js";
22
22
  import {
23
23
  isSubscriptionBlockingCheckout,
24
+ isSwitchableSubscriptionStatus,
24
25
  SubscriptionEventTypes,
25
26
  SubscriptionStatuses,
26
27
  } from "../constants.js";
@@ -199,19 +200,24 @@ export async function appendSubscriptionEvent(
199
200
  // Tenant-Admin direkt.
200
201
  // One subscription aggregate exists per tenant, but a tenant can legitimately hold two provider
201
202
  // subscriptions over time (a stale `incomplete` one superseded by a fresh checkout). A late or
202
- // retried event of the old subscription must not overwrite the row of the live one, so only a
203
- // `created` event may replace a blocking row that belongs to a different provider subscription.
203
+ // retried event of the old subscription must not overwrite the row of the live one, so an event of a
204
+ // different provider subscription only replaces a row that no longer blocks a checkout. The exception
205
+ // is a `created` of a subscription that is itself live (a provider switch); a late `created` of a
206
+ // never-completed `incomplete` one stays stale, since delivery order is not guaranteed.
204
207
  function isStaleSubscriptionEvent(
205
208
  current: SubscriptionView | null,
206
209
  payload: ProcessEventPayload,
207
210
  now: Temporal.Instant,
208
211
  ): boolean {
209
212
  if (current === null) return false;
210
- if (payload.type === SubscriptionEventTypes.created) return false;
211
213
  const sameSubscription =
212
214
  current.providerName === payload.providerName &&
213
215
  current.providerSubscriptionId === payload.providerSubscriptionId;
214
- return !sameSubscription && isSubscriptionBlockingCheckout(current, now);
216
+ if (sameSubscription || !isSubscriptionBlockingCheckout(current, now)) return false;
217
+ const supersedesCurrent =
218
+ payload.type === SubscriptionEventTypes.created &&
219
+ isSwitchableSubscriptionStatus(payload.status);
220
+ return !supersedesCurrent;
215
221
  }
216
222
 
217
223
  export function createProcessEventHandler(now: () => Temporal.Instant): WriteHandlerDef {
@@ -95,6 +95,9 @@ export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>>
95
95
  "billing-foundation.errors.redirectOriginNotAllowed": {
96
96
  en: "This redirect URL is not allowed for this app.",
97
97
  },
98
+ "billing-foundation.errors.providerCustomerUnknown": {
99
+ en: "The billing account for this subscription no longer exists. Subscribe again to continue.",
100
+ },
98
101
  "billing-foundation.errors.providerHasNoPriceCatalog": {
99
102
  en: "This provider has no known prices configured.",
100
103
  },
@@ -38,6 +38,10 @@ export type SubscriptionTierSyncDeps<TTier extends string> = {
38
38
  readonly onSyncError?: "log" | "fail-webhook";
39
39
  };
40
40
 
41
+ // A manual grant that differs from what billing would assign is reported with this
42
+ // code but never fails the webhook: a provider retry cannot resolve it.
43
+ export const TIER_SYNC_MANUAL_GRANT_SKIPPED = "tier_sync_manual_grant_skipped";
44
+
41
45
  export function effectiveTierFromSubscription<TTier extends string>(
42
46
  status: string | undefined,
43
47
  tier: string | undefined,
@@ -82,7 +86,7 @@ export function createSubscriptionTierSync<TTier extends string>(
82
86
  ) {
83
87
  async function syncTierFromSubscription(
84
88
  tenantId: TenantId,
85
- routeDeps: SignatureExtraRouteDeps,
89
+ routeDeps: Pick<SignatureExtraRouteDeps, "dispatchSystemQuery" | "dispatchSystemWrite">,
86
90
  ): Promise<{ code: string; message: string } | null> {
87
91
  try {
88
92
  const subscriptionId = subscriptionAggregateId(tenantId);
@@ -129,8 +133,13 @@ export function createSubscriptionTierSync<TTier extends string>(
129
133
  }
130
134
  return null;
131
135
  }
132
- if (assignment.source === TierAssignmentSources.manual) return null;
133
136
  if (assignment.tier === effective) return null;
137
+ if (assignment.source === TierAssignmentSources.manual) {
138
+ return {
139
+ code: TIER_SYNC_MANUAL_GRANT_SKIPPED,
140
+ message: `manual tier grant "${String(assignment.tier)}" kept although the subscription yields "${effective}"`,
141
+ };
142
+ }
134
143
 
135
144
  const result = await routeDeps.dispatchSystemWrite({
136
145
  handlerQn: TierEngineHandlers.update,
@@ -169,9 +178,12 @@ export function createSubscriptionTierSync<TTier extends string>(
169
178
  if (syncError) {
170
179
  // biome-ignore lint/suspicious/noConsole: operator visibility for a post-commit sync failure
171
180
  console.warn(
172
- `[subscription-tier-sync] tier sync failed for tenant ${tenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
181
+ `[subscription-tier-sync] tier sync for tenant ${tenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
173
182
  );
174
- if (deps.onSyncError === "fail-webhook") {
183
+ if (
184
+ deps.onSyncError === "fail-webhook" &&
185
+ syncError.code !== TIER_SYNC_MANUAL_GRANT_SKIPPED
186
+ ) {
175
187
  return {
176
188
  isSuccess: false,
177
189
  error: {
@@ -188,5 +200,5 @@ export function createSubscriptionTierSync<TTier extends string>(
188
200
  });
189
201
  }
190
202
 
191
- return { createWebhookRoute };
203
+ return { createWebhookRoute, syncTierFromSubscription };
192
204
  }
@@ -396,7 +396,10 @@ export function isLegalLinkSet(
396
396
 
397
397
  /** Consumer-protection consent gate (§ 312j / § 356 BGB style). When set,
398
398
  * `start-plan-checkout` and `create-checkout-session` require a `consent`
399
- * payload and record it as an event before the buyer reaches the provider. */
399
+ * payload and record it as an event before the buyer reaches the provider.
400
+ * Every provider used with `create-checkout-session` must implement
401
+ * `retrievePrices`: the live price is part of the consent record, and a
402
+ * provider without it fails the checkout with an InternalError. */
400
403
  export type ConsumerProtectionOptions = {
401
404
  /** Slug of the template-resolver `text-block` holding the terms text; its
402
405
  * content hash is stored with every recorded consent. */
@@ -411,7 +414,9 @@ export type ConsumerProtectionOptions = {
411
414
  readonly terminationScope?: TerminationScope;
412
415
  /** Names the item of a one-off payment (mode "payment") for the consent record
413
416
  * and the confirmation mail; the label key is resolved through the registry
414
- * i18n, so it must be qualified with the registering feature. */
417
+ * i18n, so it must be qualified with the registering feature. The label key
418
+ * is 1-200 characters, string params at most 100; a violation fails the
419
+ * checkout with an InternalError before the provider session is created. */
415
420
  readonly oneOffItemLabel?: (
416
421
  ctx: HandlerContext,
417
422
  priceId: string,
@@ -44,6 +44,8 @@ export async function reserveEnforcedCalendarCap(
44
44
  profile: cap.profile,
45
45
  notify: cap.notify,
46
46
  amount: cap.amount,
47
+ ...(cap.periodEndIso !== undefined && { periodEndIso: cap.periodEndIso }),
48
+ ...(cap.i18nKey !== undefined && { i18nKey: cap.i18nKey }),
47
49
  markSoftWarnedOutsideTransaction: outsideTransaction,
48
50
  });
49
51
  }
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "breaking",
5
+ "title": "CapExceededError carries resetAt and retryAfterSeconds, API sets Retry-After",
6
+ "migration": "Periodic caps no longer need RateLimitError({ windowSeconds: 0, ... }). Where you catch CapExceededError from enforceCap and rethrow it as RateLimitError, drop the catch and let CapExceededError pass: the response stays 429 with a correct Retry-After, error.code becomes cap_exceeded instead of rate_limited, and error.details carries resetAt and retryAfterSeconds. Keep a feature-specific message with enforceCap({ ..., i18nKey }) and pass a period end other than the calendar month with periodEndIso (e.g. a budget's resetsAtIso). Clients matching on rate_limited for these caps must also accept cap_exceeded. Any KumikoError with retryAfterSeconds (including RateLimitError thrown in a handler) now gets a Retry-After header from /api/write, /api/batch and /api/query."
7
+ },
2
8
  {
3
9
  "version": "0.361.0",
4
10
  "type": "improvement",
@@ -66,14 +66,38 @@ export function assertBelowHardCap(
66
66
  readonly capName: string;
67
67
  readonly limit: number;
68
68
  readonly profile: CapToleranceProfileName;
69
+ // Rolling caps have no fixed period, so they pass neither period field.
70
+ readonly periodStartIso?: string;
71
+ readonly periodEndIso?: string;
72
+ readonly i18nKey?: string;
69
73
  },
70
74
  ): void {
71
75
  const tolerance = CAP_TOLERANCES[cap.profile];
72
76
  if (valueSeenByLastUnit >= cap.limit * tolerance.hard) {
73
- throw new CapExceededError(cap.capName, cap.limit, valueSeenByLastUnit, tolerance);
77
+ const resetAt = resolvePeriodEndIso(cap.periodStartIso, cap.periodEndIso);
78
+ throw new CapExceededError(cap.capName, cap.limit, valueSeenByLastUnit, tolerance, {
79
+ ...(resetAt !== undefined && { resetAt }),
80
+ ...(cap.i18nKey !== undefined && { i18nKey: cap.i18nKey }),
81
+ });
74
82
  }
75
83
  }
76
84
 
85
+ function resolvePeriodEndIso(
86
+ periodStartIso: string | undefined,
87
+ periodEndIso: string | undefined,
88
+ ): string | undefined {
89
+ if (periodStartIso === undefined) return periodEndIso;
90
+ if (periodEndIso === undefined) return calendarMonthPeriodEndIso(periodStartIso);
91
+ const start = Temporal.Instant.from(periodStartIso);
92
+ const end = Temporal.Instant.from(periodEndIso);
93
+ if (Temporal.Instant.compare(end, start) <= 0) {
94
+ throw new Error(
95
+ `cap-counter: periodEndIso (${periodEndIso}) must be after periodStartIso (${periodStartIso})`,
96
+ );
97
+ }
98
+ return end.toString();
99
+ }
100
+
77
101
  // =============================================================================
78
102
  // Enforce-Cap helper
79
103
  // =============================================================================
@@ -109,6 +133,9 @@ export async function enforceCap(
109
133
  // Units the caller is about to book in one go (default 1), checked with
110
134
  // the same outcome as booking them one by one.
111
135
  readonly amount?: number;
136
+ // Default: end of the calendar month that starts at periodStartIso.
137
+ readonly periodEndIso?: string;
138
+ readonly i18nKey?: string;
112
139
  },
113
140
  ): Promise<EnforceCapResult> {
114
141
  const { user, readDb } = resolveCapCounterScope(ctx, false);
@@ -211,23 +238,54 @@ export async function enforceRollingCap(
211
238
  * Thrown by enforceCap/enforceRollingCap when value ≥ hard-threshold.
212
239
  * Extends `KumikoError` — the dispatcher auto-maps this to HTTP 429
213
240
  * with `code: "cap_exceeded"`, no caller-side catch needed.
241
+ *
242
+ * Calendar caps carry `resetAt` (end of the period) and `retryAfterSeconds`, which the API
243
+ * answers as Retry-After. Rolling caps have no fixed reset instant, so both stay undefined.
214
244
  */
215
245
  export class CapExceededError extends KumikoError {
216
246
  readonly code = "cap_exceeded" as const;
217
247
  readonly httpStatus = 429;
248
+ readonly resetAt: string | undefined;
218
249
 
219
250
  constructor(
220
251
  readonly capName: string,
221
252
  readonly limit: number,
222
253
  readonly currentValue: number,
223
254
  readonly tolerance: CapToleranceProfile,
255
+ options?: {
256
+ readonly resetAt?: string;
257
+ readonly i18nKey?: string;
258
+ readonly cause?: Error;
259
+ // Deterministic tests only.
260
+ readonly now?: Temporal.Instant;
261
+ },
224
262
  ) {
263
+ const resetAt = options?.resetAt;
264
+ const retryAfterSeconds =
265
+ resetAt === undefined
266
+ ? undefined
267
+ : Math.max(
268
+ 0,
269
+ Math.ceil(
270
+ Temporal.Instant.from(resetAt).epochMilliseconds / 1000 -
271
+ (options?.now ?? Temporal.Now.instant()).epochMilliseconds / 1000,
272
+ ),
273
+ );
225
274
  super({
226
- message: `Cap "${capName}" exceeded: current=${currentValue}, limit=${limit}, hard-threshold=${limit * tolerance.hard}. Upgrade tier or wait for next period reset.`,
227
- i18nKey: "errors.cap.exceeded",
228
- i18nParams: { capName, limit, currentValue },
229
- details: { capName, limit, currentValue, tolerance },
275
+ message: `Cap "${capName}" exceeded: current=${currentValue}, limit=${limit}, hard-threshold=${limit * tolerance.hard}. Upgrade tier or wait for next period reset.${resetAt === undefined ? "" : ` Resets at ${resetAt}.`}`,
276
+ i18nKey: options?.i18nKey ?? "errors.cap.exceeded",
277
+ i18nParams: { capName, limit, currentValue, ...(resetAt !== undefined && { resetAt }) },
278
+ details: {
279
+ capName,
280
+ limit,
281
+ currentValue,
282
+ tolerance,
283
+ ...(resetAt !== undefined && { resetAt, retryAfterSeconds }),
284
+ },
285
+ ...(retryAfterSeconds !== undefined && { retryAfterSeconds }),
286
+ ...(options?.cause && { cause: options.cause }),
230
287
  });
288
+ this.resetAt = resetAt;
231
289
  }
232
290
  }
233
291
 
@@ -257,6 +315,14 @@ export function currentCalendarMonthStartIso(
257
315
  return start.toInstant().toString();
258
316
  }
259
317
 
318
+ export function calendarMonthPeriodEndIso(periodStartIso: string): string {
319
+ return Temporal.Instant.from(periodStartIso)
320
+ .toZonedDateTimeISO("UTC")
321
+ .add({ months: 1 })
322
+ .toInstant()
323
+ .toString();
324
+ }
325
+
260
326
  // =============================================================================
261
327
  // Notification-Wiring helpers — convenience-wrapper für enforceCap +
262
328
  // enforceRollingCap, die einen Caller-supplied delivery-emit beim
@@ -299,6 +365,8 @@ export async function enforceCapAndMaybeNotify(
299
365
  readonly profile: CapToleranceProfileName;
300
366
  readonly notify: SoftHitNotifier;
301
367
  readonly amount?: number;
368
+ readonly periodEndIso?: string;
369
+ readonly i18nKey?: string;
302
370
  readonly markSoftWarnedOutsideTransaction?: boolean;
303
371
  },
304
372
  ): Promise<EnforceCapResult> {
@@ -308,6 +376,8 @@ export async function enforceCapAndMaybeNotify(
308
376
  limit: options.limit,
309
377
  profile: options.profile,
310
378
  ...(options.amount !== undefined && { amount: options.amount }),
379
+ ...(options.periodEndIso !== undefined && { periodEndIso: options.periodEndIso }),
380
+ ...(options.i18nKey !== undefined && { i18nKey: options.i18nKey }),
311
381
  });
312
382
 
313
383
  if (result.state === "soft-hit" && result.crossed) {
@@ -29,6 +29,7 @@ export {
29
29
  CapExceededError,
30
30
  type CapToleranceProfile,
31
31
  type CapToleranceProfileName,
32
+ calendarMonthPeriodEndIso,
32
33
  currentCalendarMonthStartIso,
33
34
  type EnforceCapResult,
34
35
  enforceCap,
@@ -53,6 +53,9 @@ import { chainReservations } from "./reservation-chain.js";
53
53
  export type CalendarCapDef = {
54
54
  readonly capName: string;
55
55
  readonly periodStartIso: string;
56
+ // Default: end of the calendar month that starts at periodStartIso.
57
+ readonly periodEndIso?: string;
58
+ readonly i18nKey?: string;
56
59
  readonly limit: number;
57
60
  readonly profile: CapToleranceProfileName;
58
61
  /** Increment-amount post-success. Default 1. */
@@ -191,13 +191,20 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
191
191
  );
192
192
  }
193
193
  const unsubscribeUrl = message.data?.["unsubscribeUrl"];
194
- const { subject, html, text, attachments } = await ctx.renderMail({
194
+ const { subject, html, text, attachments, fromName, replyTo } = await ctx.renderMail({
195
195
  slug: notificationType,
196
196
  locale: message.locale ?? "en",
197
197
  variables: message.data ?? {},
198
198
  ...(typeof unsubscribeUrl === "string" && { unsubscribeUrl }),
199
199
  });
200
- return { html, subject, text, ...(attachments !== undefined && { attachments }) };
200
+ return {
201
+ html,
202
+ subject,
203
+ text,
204
+ ...(fromName !== undefined && { fromName }),
205
+ ...(replyTo !== undefined && { replyTo }),
206
+ ...(attachments !== undefined && { attachments }),
207
+ };
201
208
  }
202
209
 
203
210
  return {
@@ -217,8 +224,14 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
217
224
  },
218
225
 
219
226
  async send(address, message, ctx, rendered) {
220
- const { html, subject, text, attachments } = rendered ?? (await renderMessage(message, ctx));
221
- const envelope = emailEnvelopeFrom(message.data);
227
+ const { html, subject, text, attachments, fromName, replyTo } =
228
+ rendered ?? (await renderMessage(message, ctx));
229
+ // Template-level sender defaults; notify data wins.
230
+ const envelope = {
231
+ ...(fromName && { fromName }),
232
+ ...(replyTo && { replyTo }),
233
+ ...emailEnvelopeFrom(message.data),
234
+ };
222
235
  const resolvedAttachments = await resolveAttachments(
223
236
  [
224
237
  ...(attachments ?? []).map((ref): AttachmentSource => ({ ref, origin: "template" })),
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.364.0",
4
+ "type": "fix",
5
+ "title": "Config key validate() receives a db scoped to the written tenant",
6
+ "detail": "A SystemAdmin writing a config value for another tenant previously handed the validator a system-mode db that could read every tenant. `ConfigValidateContext.db` is now a `TenantDb` locked to the tenant the value is written for. The validator runs inside the write transaction, so a slow query there should set its own timeout."
7
+ },
2
8
  {
3
9
  "version": "0.338.0",
4
10
  "type": "improvement",
@@ -4,7 +4,7 @@ import {
4
4
  encryptPiiValueForSubject,
5
5
  type KmsContext,
6
6
  } from "@cosmicdrift/kumiko-framework/crypto";
7
- import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
7
+ import { createEventStoreExecutor, scopeTenantDbToTenant } from "@cosmicdrift/kumiko-framework/db";
8
8
  import {
9
9
  ConfigScopes,
10
10
  defineWriteHandler,
@@ -111,7 +111,7 @@ export const setWrite = defineWriteHandler({
111
111
  scope,
112
112
  tenantId,
113
113
  userId,
114
- db,
114
+ db: scopeTenantDbToTenant(db, tenantId),
115
115
  });
116
116
  } catch (error) {
117
117
  if (isKumikoError(error)) return writeFailure(error);