@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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +7 -1
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
- package/packages/bundled-features/src/auth-email-password/bootstrap.ts +29 -8
- package/packages/bundled-features/src/auth-email-password/changes.json +37 -0
- package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
- package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
- package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
- package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
- package/packages/bundled-features/src/billing-foundation/changes.json +36 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
- package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +10 -1
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
- package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
- package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
- package/packages/bundled-features/src/billing-foundation/i18n.ts +3 -0
- package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +17 -5
- package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
- package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
- package/packages/bundled-features/src/cap-counter/changes.json +6 -0
- package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
- package/packages/bundled-features/src/channel-email/email-channel.ts +17 -4
- package/packages/bundled-features/src/config/changes.json +6 -0
- package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
- package/packages/bundled-features/src/config/resolver.ts +15 -12
- package/packages/bundled-features/src/config/write-helpers.ts +23 -0
- package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
- package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +48 -27
- package/packages/bundled-features/src/custom-fields/changes.json +6 -0
- package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
- package/packages/bundled-features/src/delivery/attachment-ref.ts +21 -0
- package/packages/bundled-features/src/delivery/changes.json +18 -0
- package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
- package/packages/bundled-features/src/delivery/index.ts +2 -0
- package/packages/bundled-features/src/delivery/jobs.ts +51 -14
- package/packages/bundled-features/src/delivery/types.ts +7 -1
- package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
- package/packages/bundled-features/src/file-foundation/changes.json +6 -0
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
- package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
- package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
- package/packages/bundled-features/src/jobs/changes.json +19 -0
- package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
- package/packages/bundled-features/src/jobs/feature.ts +7 -4
- package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
- package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
- package/packages/bundled-features/src/jobs/job-run-logger.ts +52 -17
- package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
- package/packages/bundled-features/src/ledger/changes.json +7 -0
- package/packages/bundled-features/src/ledger/entity.ts +3 -1
- package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
- package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
- package/packages/bundled-features/src/secrets/changes.json +6 -0
- package/packages/bundled-features/src/secrets/feature.ts +4 -3
- package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
- package/packages/bundled-features/src/shared/changes.json +6 -0
- package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
- package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
- package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
- package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
- package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
- package/packages/bundled-features/src/template-resolver/README.md +4 -2
- package/packages/bundled-features/src/template-resolver/api.ts +3 -2
- package/packages/bundled-features/src/template-resolver/changes.json +68 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +77 -13
- package/packages/bundled-features/src/template-resolver/constants.ts +12 -2
- package/packages/bundled-features/src/template-resolver/document-schema.ts +26 -4
- package/packages/bundled-features/src/template-resolver/feature.ts +4 -1
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +31 -13
- package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +5 -44
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
- package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
- package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
- package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
- package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
- package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +90 -17
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
- package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
- package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
- package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
- package/packages/bundled-features/src/template-resolver/index.ts +12 -1
- package/packages/bundled-features/src/template-resolver/layout-appearance.ts +40 -0
- package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
- package/packages/bundled-features/src/template-resolver/seeding.ts +25 -4
- package/packages/bundled-features/src/template-resolver/store-template-resource.ts +2 -2
- package/packages/bundled-features/src/template-resolver/table.ts +13 -1
- package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
- package/packages/bundled-features/src/template-resolver/template-meta.ts +87 -24
- package/packages/bundled-features/src/template-resolver/template-preview.ts +159 -56
- package/packages/bundled-features/src/template-resolver/template-resources.ts +44 -4
- package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
- package/packages/bundled-features/src/template-resolver/template-validation.ts +1 -0
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +63 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +12 -3
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +349 -134
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +11 -0
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +25 -4
- package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
- package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +10 -3
- package/packages/bundled-features/src/tier-engine/changes.json +6 -0
- package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
- package/packages/bundled-features/src/user/seeding.ts +19 -5
- package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
- package/packages/bundled-features/src/user-profile/changes.json +1 -1
- package/packages/bundled-features/src/waitlist/options.ts +1 -1
- package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
- package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
- package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
- package/samples/apps/ui-walkthrough/package.json +9 -0
- package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
- package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +4 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +5 -3
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts
CHANGED
|
@@ -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
|
},
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts
CHANGED
|
@@ -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
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 (
|
|
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
|
|
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(
|
|
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(
|
|
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,
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts
CHANGED
|
@@ -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",
|
package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts
ADDED
|
@@ -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
|
+
}
|
package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
203
|
-
//
|
|
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
|
-
|
|
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
|
|
181
|
+
`[subscription-tier-sync] tier sync for tenant ${tenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
|
|
173
182
|
);
|
|
174
|
-
if (
|
|
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
|
-
|
|
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: {
|
|
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) {
|
|
@@ -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 {
|
|
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 } =
|
|
221
|
-
|
|
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);
|