@cosmicdrift/kumiko-bundled-features 0.387.0 → 0.388.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-dispatch.d.ts +7 -7
- package/dist/agent-tools/tool-dispatch.js +9 -9
- package/dist/audit/handlers/audit-events.js +1 -1
- package/dist/auth-mfa/feature-exports.d.ts +2 -2
- package/dist/auth-mfa/feature.js +8 -0
- package/dist/auth-mfa/web/i18n.js +5 -1
- package/dist/auth-mfa/web/mfa-code-or-recovery-field.d.ts +11 -0
- package/dist/auth-mfa/web/mfa-code-or-recovery-field.js +12 -0
- package/dist/auth-mfa/web/mfa-disable-dialog.js +14 -3
- package/dist/auth-mfa/web/mfa-regenerate-recovery-dialog.js +14 -3
- package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +10 -5
- package/dist/auth-mfa/web/mfa-verify-screen.js +26 -5
- package/dist/billing-foundation/checkout-core.d.ts +9 -14
- package/dist/billing-foundation/checkout-core.js +24 -39
- package/dist/billing-foundation/consumer-protection/confirmation-mail.js +8 -2
- package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +1 -0
- package/dist/billing-foundation/consumer-protection/consent-gate.js +1 -0
- package/dist/billing-foundation/consumer-protection/issue-confirmation.js +9 -3
- package/dist/billing-foundation/consumer-protection/mail-notifications.js +3 -1
- package/dist/billing-foundation/consumer-protection/termination-receipt-limit.d.ts +4 -2
- package/dist/billing-foundation/consumer-protection/termination-receipt-limit.js +6 -5
- package/dist/billing-foundation/consumer-protection/termination-record.d.ts +5 -1
- package/dist/billing-foundation/consumer-protection/termination-record.js +27 -17
- package/dist/billing-foundation/consumer-protection/termination-request.js +12 -7
- package/dist/billing-foundation/consumer-protection/termination-revoke.js +12 -7
- package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -1
- package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -0
- package/dist/billing-foundation/consumer-protection/withdrawal-period.d.ts +5 -2
- package/dist/billing-foundation/consumer-protection/withdrawal-period.js +8 -9
- package/dist/billing-foundation/events.d.ts +1 -0
- package/dist/billing-foundation/events.js +1 -0
- package/dist/billing-foundation/feature.js +3 -1
- package/dist/billing-foundation/handlers/billing-plans.query.js +35 -5
- package/dist/billing-foundation/handlers/create-checkout-session.write.js +1 -0
- package/dist/billing-foundation/handlers/plan-switch-preview.query.d.ts +13 -0
- package/dist/billing-foundation/handlers/plan-switch-preview.query.js +48 -0
- package/dist/billing-foundation/handlers/process-event.write.d.ts +6 -0
- package/dist/billing-foundation/handlers/process-event.write.js +8 -2
- package/dist/billing-foundation/handlers/start-plan-checkout.write.js +32 -5
- package/dist/billing-foundation/handlers/switch-plan.write.d.ts +2 -2
- package/dist/billing-foundation/handlers/switch-plan.write.js +56 -64
- package/dist/billing-foundation/handlers/sync-subscription.write.js +12 -1
- package/dist/billing-foundation/i18n.js +22 -0
- package/dist/billing-foundation/index.d.ts +1 -1
- package/dist/billing-foundation/plan-catalog.d.ts +21 -5
- package/dist/billing-foundation/plan-catalog.js +25 -16
- package/dist/billing-foundation/plan-switch.d.ts +12 -0
- package/dist/billing-foundation/plan-switch.js +60 -0
- package/dist/billing-foundation/types.d.ts +57 -1
- package/dist/billing-foundation/types.js +9 -0
- package/dist/billing-foundation/validate-options.js +17 -0
- package/dist/billing-foundation/web/billing-plans-panel.js +78 -32
- package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +17 -3
- package/dist/billing-foundation/web/checkout-consent-dialog.js +14 -10
- package/dist/billing-foundation/web/constants.d.ts +4 -0
- package/dist/billing-foundation/web/constants.js +4 -0
- package/dist/billing-foundation/web/plan-price.d.ts +15 -0
- package/dist/billing-foundation/web/plan-price.js +32 -0
- package/dist/billing-foundation/web/plan-switch-confirm-dialog.d.ts +15 -0
- package/dist/billing-foundation/web/plan-switch-confirm-dialog.js +66 -0
- package/dist/cap-counter/cap-reservation.js +5 -1
- package/dist/cap-counter/enforce-cap.js +2 -1
- package/dist/cap-counter/stock-cap-guard.js +7 -1
- package/dist/channel-email/feature.js +1 -1
- package/dist/config/handlers/cascade.query.d.ts +5 -0
- package/dist/config/handlers/cascade.query.js +4 -3
- package/dist/config/handlers/values.query.d.ts +5 -0
- package/dist/config/handlers/values.query.js +4 -3
- package/dist/config/write-helpers.d.ts +1 -0
- package/dist/config/write-helpers.js +18 -9
- package/dist/crypto-shredding/handlers/forget-subject.write.js +13 -10
- package/dist/delivery/channel-context.js +6 -3
- package/dist/delivery/db/queries/preferences.js +2 -2
- package/dist/delivery/delivery-service.js +22 -7
- package/dist/delivery/handlers/preferences.query.d.ts +1 -1
- package/dist/delivery/handlers/preferences.query.js +20 -1
- package/dist/delivery/handlers/unsubscribe-page-chrome.query.js +1 -1
- package/dist/delivery/unsubscribe-link.d.ts +3 -1
- package/dist/delivery/unsubscribe-page-strings.d.ts +1 -0
- package/dist/delivery/unsubscribe-page-strings.js +1 -0
- package/dist/delivery/unsubscribe-page.d.ts +2 -0
- package/dist/delivery/unsubscribe-page.js +5 -0
- package/dist/delivery/unsubscribe.js +25 -7
- package/dist/file-foundation/index.d.ts +1 -1
- package/dist/file-foundation/index.js +1 -1
- package/dist/file-foundation/read-file-ref-content.d.ts +3 -0
- package/dist/file-foundation/read-file-ref-content.js +6 -1
- package/dist/file-provider-s3/env-named-feature.d.ts +0 -2
- package/dist/form-draft/db/queries/cleanup.js +2 -2
- package/dist/form-draft/decrypt-draft.js +5 -1
- package/dist/host-routing/dispatch.js +11 -20
- package/dist/inbound-mail-foundation/handlers/update-account.write.d.ts +2 -2
- package/dist/inbound-mail-foundation/handlers/update-account.write.js +12 -4
- package/dist/inbound-mail-foundation/i18n.js +0 -1
- package/dist/inbound-mail-foundation/screens.js +0 -3
- package/dist/inbound-mail-foundation/types.d.ts +4 -3
- package/dist/inbound-mail-foundation/watch-supervisor.js +28 -11
- package/dist/jobs/feature.d.ts +1 -0
- package/dist/jobs/feature.js +8 -4
- package/dist/jobs/handlers/tenant-failures.query.d.ts +1 -1
- package/dist/jobs/handlers/tenant-failures.query.js +45 -41
- package/dist/jobs/handlers/tenant-runs.query.d.ts +1 -1
- package/dist/jobs/handlers/tenant-runs.query.js +54 -50
- package/dist/jobs/job-run-logger.js +5 -4
- package/dist/mail-foundation/feature.d.ts +3 -3
- package/dist/mail-foundation/feature.js +3 -0
- package/dist/personal-access-tokens/feature.d.ts +1 -1
- package/dist/personal-access-tokens/feature.js +4 -0
- package/dist/renderer-pdf/render-table-pdf.d.ts +1 -1
- package/dist/renderer-pdf/render-table-pdf.js +22 -10
- package/dist/sessions/feature.d.ts +2 -2
- package/dist/sessions/feature.js +4 -0
- package/dist/shared/decrypt-stored-pii.d.ts +1 -0
- package/dist/shared/decrypt-stored-pii.js +6 -1
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/shared/mail-template-body.d.ts +6 -2
- package/dist/shared/mail-template-body.js +7 -4
- package/dist/step-dispatcher/feature.js +1 -0
- package/dist/subscription-dodo/client.d.ts +17 -0
- package/dist/subscription-dodo/client.js +31 -0
- package/dist/subscription-dodo/constants.d.ts +12 -0
- package/dist/subscription-dodo/constants.js +19 -0
- package/dist/subscription-dodo/feature.d.ts +37 -0
- package/dist/subscription-dodo/feature.js +144 -0
- package/dist/subscription-dodo/index.d.ts +2 -0
- package/dist/subscription-dodo/index.js +4 -0
- package/dist/subscription-dodo/plugin-methods.d.ts +23 -0
- package/dist/subscription-dodo/plugin-methods.js +260 -0
- package/dist/subscription-dodo/runtime.d.ts +27 -0
- package/dist/subscription-dodo/runtime.js +89 -0
- package/dist/subscription-dodo/verify-webhook.d.ts +37 -0
- package/dist/subscription-dodo/verify-webhook.js +189 -0
- package/dist/subscription-stripe/feature.js +1 -0
- package/dist/subscription-stripe/runtime.js +7 -6
- package/dist/template-resolver/api.d.ts +3 -2
- package/dist/template-resolver/api.js +24 -2
- package/dist/template-resolver/compile-document-body.js +1 -0
- package/dist/template-resolver/feature.js +2 -2
- package/dist/template-resolver/handlers/bundle-import.js +3 -3
- package/dist/template-resolver/handlers/copy-locale.write.d.ts +5 -1
- package/dist/template-resolver/handlers/copy-locale.write.js +22 -7
- package/dist/template-resolver/handlers/diff-to-system.query.js +3 -2
- package/dist/template-resolver/handlers/document-base.js +3 -2
- package/dist/template-resolver/handlers/find-by-id.query.js +3 -2
- package/dist/template-resolver/handlers/list.query.js +5 -6
- package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
- package/dist/template-resolver/handlers/overview.query.js +6 -3
- package/dist/template-resolver/handlers/tenant-override-counts.query.js +1 -3
- package/dist/template-resolver/handlers/tenant-override-selection.d.ts +2 -1
- package/dist/template-resolver/handlers/tenant-overrides.query.js +28 -5
- package/dist/template-resolver/handlers/usage.query.d.ts +2 -1
- package/dist/template-resolver/handlers/usage.query.js +26 -16
- package/dist/template-resolver/handlers/validate.query.js +7 -2
- package/dist/template-resolver/seeding.js +26 -10
- package/dist/template-resolver/store-template-resource.d.ts +1 -0
- package/dist/template-resolver/store-template-resource.js +19 -4
- package/dist/template-resolver/template-delivery-policy.d.ts +0 -5
- package/dist/template-resolver/template-delivery-policy.js +0 -6
- package/dist/template-resolver/template-meta.js +7 -4
- package/dist/template-resolver/template-preview.js +4 -3
- package/dist/template-resolver/web/constants.d.ts +2 -2
- package/dist/template-resolver/web/constants.js +3 -3
- package/dist/template-resolver/web/index.d.ts +1 -0
- package/dist/template-resolver/web/index.js +1 -0
- package/dist/template-resolver/web/mail-layout-constants.d.ts +5 -0
- package/dist/template-resolver/web/mail-layout-constants.js +7 -0
- package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/member-directory.query.js +10 -3
- package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
- package/dist/tenant/handlers/tenant-directory.query.js +8 -4
- package/dist/tenant-lifecycle/constants.d.ts +1 -0
- package/dist/tenant-lifecycle/constants.js +4 -0
- package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
- package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +2 -3
- package/dist/tier-engine/handlers/set-tenant-tier.write.js +4 -1
- package/dist/tier-engine/web/constants.js +2 -2
- package/dist/user-data-rights/run-export-jobs.js +3 -4
- package/dist/user-data-rights/run-forget-cleanup.js +2 -1
- package/dist/waitlist/handlers/export-purge.write.js +2 -1
- package/dist/waitlist/handlers/submit.write.js +5 -5
- package/dist/waitlist/import.d.ts +1 -0
- package/dist/waitlist/import.js +4 -0
- package/dist/waitlist/legacy-migrate.js +17 -6
- package/dist/waitlist/link-on-signup.js +18 -6
- package/dist/waitlist/options.d.ts +3 -0
- package/package.json +19 -9
- package/src/agent-tools/changes.json +6 -0
- package/src/audit/changes.json +12 -0
- package/src/auth-mfa/changes.json +6 -0
- package/src/billing-foundation/changes.json +55 -0
- package/src/cap-counter/changes.json +18 -0
- package/src/channel-email/changes.json +6 -0
- package/src/config/changes.json +12 -0
- package/src/crypto-shredding/changes.json +6 -0
- package/src/delivery/changes.json +30 -0
- package/src/form-draft/changes.json +6 -0
- package/src/host-routing/changes.json +6 -0
- package/src/inbound-mail-foundation/changes.json +18 -0
- package/src/jobs/changes.json +13 -0
- package/src/mail-foundation/changes.json +6 -0
- package/src/renderer-pdf/changes.json +6 -0
- package/src/step-dispatcher/changes.json +6 -0
- package/src/subscription-dodo/changes.json +14 -0
- package/src/subscription-stripe/changes.json +12 -0
- package/src/template-resolver/changes.json +48 -0
- package/src/tenant/changes.json +6 -0
- package/src/tenant-lifecycle/changes.json +6 -0
- package/src/tier-engine/changes.json +6 -0
- package/src/waitlist/changes.json +18 -0
- package/dist/billing-foundation/consumer-protection/stored-user-email.d.ts +0 -1
- package/dist/billing-foundation/consumer-protection/stored-user-email.js +0 -8
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type HandlerContext, type QueryHandlerDef, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
-
import { type ProviderCancelOutcome } from "../events.ts";
|
|
3
|
+
import { type ContractTerminationRequestedPayload, type ProviderCancelOutcome } from "../events.ts";
|
|
4
4
|
import { type SubscriptionView } from "../get-subscription-for-tenant.ts";
|
|
5
5
|
import type { ConsumerProtectionOptions, ResolvedBillingFoundationOptions } from "../types.ts";
|
|
6
6
|
import type { OperatorNoticeReason } from "./termination-texts.ts";
|
|
@@ -74,6 +74,10 @@ export declare const terminableSubscriptionSchema: z.ZodObject<{}, z.core.$stric
|
|
|
74
74
|
export declare function createTerminableSubscriptionQuery(): QueryHandlerDef;
|
|
75
75
|
export declare function createDeclareContractTerminationHandler(): WriteHandlerDef;
|
|
76
76
|
export declare function createRecordContractTerminationHandler(options: ResolvedBillingFoundationOptions, consumerProtection: ConsumerProtectionOptions): WriteHandlerDef;
|
|
77
|
+
export declare function findRecordedTermination(events: readonly {
|
|
78
|
+
type: string;
|
|
79
|
+
payload: unknown;
|
|
80
|
+
}[], requestId: string): ContractTerminationRequestedPayload | undefined;
|
|
77
81
|
export declare function hasRevocation(events: readonly {
|
|
78
82
|
type: string;
|
|
79
83
|
payload: unknown;
|
|
@@ -173,23 +173,9 @@ export function createRecordContractTerminationHandler(options, consumerProtecti
|
|
|
173
173
|
});
|
|
174
174
|
// The public job runs at least once: a second run must not call the
|
|
175
175
|
// provider or append again.
|
|
176
|
-
const alreadyRecorded = stream.events
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
: undefined)
|
|
180
|
-
.find((recordedPayload) => recordedPayload?.requestId === payload.requestId);
|
|
181
|
-
if (alreadyRecorded) {
|
|
182
|
-
return {
|
|
183
|
-
isSuccess: true,
|
|
184
|
-
data: {
|
|
185
|
-
receivedAtIso: alreadyRecorded.receivedAtIso,
|
|
186
|
-
effectiveAtIso: alreadyRecorded.effectiveAtIso,
|
|
187
|
-
providerCancel: alreadyRecorded.providerCancel,
|
|
188
|
-
noticeReason: null,
|
|
189
|
-
withdrawalPeriodExpired: false,
|
|
190
|
-
},
|
|
191
|
-
};
|
|
192
|
-
}
|
|
176
|
+
const alreadyRecorded = findRecordedTermination(stream.events, payload.requestId);
|
|
177
|
+
if (alreadyRecorded)
|
|
178
|
+
return recordedTerminationResult(alreadyRecorded);
|
|
193
179
|
// A revoked public declaration never reaches the provider.
|
|
194
180
|
if (hasRevocation(stream.events, payload.requestId)) {
|
|
195
181
|
return revokedRecordResult(receivedAtIso);
|
|
@@ -214,6 +200,11 @@ export function createRecordContractTerminationHandler(options, consumerProtecti
|
|
|
214
200
|
aggregateId: subscriptionAggregateId(tenantId),
|
|
215
201
|
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
216
202
|
});
|
|
203
|
+
// A concurrent run with the same requestId may have recorded while we were
|
|
204
|
+
// at the provider; a second append would duplicate the event and the notice.
|
|
205
|
+
const recordedMeanwhile = findRecordedTermination(streamAfterProviderCall.events, payload.requestId);
|
|
206
|
+
if (recordedMeanwhile)
|
|
207
|
+
return recordedTerminationResult(recordedMeanwhile);
|
|
217
208
|
// A revocation that landed during the provider call found no cancel to take back.
|
|
218
209
|
if (hasRevocation(streamAfterProviderCall.events, payload.requestId)) {
|
|
219
210
|
await takeBackLateRevokedCancel(ctx, consumerProtection, {
|
|
@@ -258,6 +249,25 @@ export function createRecordContractTerminationHandler(options, consumerProtecti
|
|
|
258
249
|
},
|
|
259
250
|
};
|
|
260
251
|
}
|
|
252
|
+
export function findRecordedTermination(events, requestId) {
|
|
253
|
+
return events
|
|
254
|
+
.map((e) => e.type === CONTRACT_TERMINATION_REQUESTED_EVENT_QN
|
|
255
|
+
? contractTerminationRequestedPayloadSchema.safeParse(e.payload).data
|
|
256
|
+
: undefined)
|
|
257
|
+
.find((recordedPayload) => recordedPayload?.requestId === requestId);
|
|
258
|
+
}
|
|
259
|
+
function recordedTerminationResult(recorded) {
|
|
260
|
+
return {
|
|
261
|
+
isSuccess: true,
|
|
262
|
+
data: {
|
|
263
|
+
receivedAtIso: recorded.receivedAtIso,
|
|
264
|
+
effectiveAtIso: recorded.effectiveAtIso,
|
|
265
|
+
providerCancel: recorded.providerCancel,
|
|
266
|
+
noticeReason: null,
|
|
267
|
+
withdrawalPeriodExpired: false,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
261
271
|
export function hasRevocation(events, requestId) {
|
|
262
272
|
return events.some((e) => e.type === CONTRACT_TERMINATION_REVOKED_EVENT_QN &&
|
|
263
273
|
contractTerminationRevokedPayloadSchema.safeParse(e.payload).data?.requestId === requestId);
|
|
@@ -6,13 +6,13 @@ import { createSystemUser, declareEscapeHatch, SYSTEM_TENANT_ID, } from "@cosmic
|
|
|
6
6
|
import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
|
|
7
7
|
import { generateId } from "@cosmicdrift/kumiko-framework/utils";
|
|
8
8
|
import * as z from "zod";
|
|
9
|
+
import { decryptStoredUserEmail } from "../../shared/index.js";
|
|
9
10
|
import { TenantQueries } from "../../tenant/index.js";
|
|
10
11
|
import { UserQueries } from "../../user/index.js";
|
|
11
12
|
import { CONTRACT_TERMINATION_DECLARATION_TYPES, CONTRACT_TERMINATION_KINDS, } from "../events.js";
|
|
12
13
|
import { purchaseRolesOf } from "../plan-catalog.js";
|
|
13
14
|
import { CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE, SubscriptionCancelTimings, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, } from "../web/constants.js";
|
|
14
15
|
import { resolveConsentLocale } from "./consent-text.js";
|
|
15
|
-
import { openStoredUserEmail } from "./stored-user-email.js";
|
|
16
16
|
import { terminationReceiptVariables } from "./termination-mail.js";
|
|
17
17
|
import { notifyOperator, requireNotify } from "./termination-notify.js";
|
|
18
18
|
import { mayMailReceipt } from "./termination-receipt-limit.js";
|
|
@@ -54,9 +54,16 @@ function needsOperatorNotice(declaration) {
|
|
|
54
54
|
...(declaration.terminationKind === "extraordinary" ? ["extraordinary"] : []),
|
|
55
55
|
];
|
|
56
56
|
}
|
|
57
|
+
function receiptWithheldReasons(verdict) {
|
|
58
|
+
if (verdict === "limited")
|
|
59
|
+
return ["receipt_rate_limited"];
|
|
60
|
+
if (verdict === "unavailable")
|
|
61
|
+
return ["receipt_not_sent"];
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
57
64
|
async function sendTerminationMails(ctx, consumerProtection, plan) {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
65
|
+
const receiptVerdict = await mayMailReceipt(ctx, plan.declaration.email);
|
|
66
|
+
if (receiptVerdict === "allowed") {
|
|
60
67
|
await requireNotify(ctx)(CONTRACT_TERMINATION_RECEIPT_NOTIFICATION_TYPE, {
|
|
61
68
|
route: { email: plan.declaration.email },
|
|
62
69
|
data: terminationReceiptVariables({
|
|
@@ -74,9 +81,7 @@ async function sendTerminationMails(ctx, consumerProtection, plan) {
|
|
|
74
81
|
priority: "critical",
|
|
75
82
|
});
|
|
76
83
|
}
|
|
77
|
-
const reasons =
|
|
78
|
-
? plan.reasons
|
|
79
|
-
: [...plan.reasons, "receipt_rate_limited"];
|
|
84
|
+
const reasons = [...plan.reasons, ...receiptWithheldReasons(receiptVerdict)];
|
|
80
85
|
// skip: no operator notice needed for this declaration
|
|
81
86
|
if (reasons.length === 0)
|
|
82
87
|
return;
|
|
@@ -217,7 +222,7 @@ export function createTerminateContractHandler(options, consumerProtection) {
|
|
|
217
222
|
id: event.user.id,
|
|
218
223
|
}));
|
|
219
224
|
const callerEmail = caller.success && caller.data.email && caller.data.isDeleted !== true
|
|
220
|
-
? await
|
|
225
|
+
? await decryptStoredUserEmail(caller.data.email, "billing-foundation:terminate-contract")
|
|
221
226
|
: null;
|
|
222
227
|
if (callerEmail === null) {
|
|
223
228
|
throw new InternalError({
|
|
@@ -7,11 +7,11 @@ import { access, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine"
|
|
|
7
7
|
import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
|
|
8
8
|
import * as z from "zod";
|
|
9
9
|
import { subscriptionAggregateId } from "../aggregate-id.js";
|
|
10
|
-
import { CONTRACT_TERMINATION_DECLARED_EVENT_QN,
|
|
10
|
+
import { CONTRACT_TERMINATION_DECLARED_EVENT_QN, CONTRACT_TERMINATION_REVOKED_EVENT_QN, contractTerminationDeclaredPayloadSchema, SUBSCRIPTION_AGGREGATE_TYPE, } from "../events.js";
|
|
11
11
|
import { getSubscriptionForTenant } from "../get-subscription-for-tenant.js";
|
|
12
12
|
import { SubscriptionFoundationHandlers } from "../web/constants.js";
|
|
13
13
|
import { notifyOperator } from "./termination-notify.js";
|
|
14
|
-
import { hasRevocation, resumeAtProvider } from "./termination-record.js";
|
|
14
|
+
import { findRecordedTermination, hasRevocation, resumeAtProvider } from "./termination-record.js";
|
|
15
15
|
export const REVOCATION_PATHS = {
|
|
16
16
|
de: "/legal/kuendigen/zuruecknehmen",
|
|
17
17
|
en: "/legal/cancel/revoke",
|
|
@@ -102,10 +102,7 @@ export function createRecordContractTerminationRevocationHandler(options, consum
|
|
|
102
102
|
}
|
|
103
103
|
if (hasRevocation(stream.events, payload.requestId))
|
|
104
104
|
return outcome("already-revoked");
|
|
105
|
-
const requested = stream.events
|
|
106
|
-
.filter((e) => e.type === CONTRACT_TERMINATION_REQUESTED_EVENT_QN)
|
|
107
|
-
.map((e) => contractTerminationRequestedPayloadSchema.safeParse(e.payload).data)
|
|
108
|
-
.find((candidate) => candidate?.requestId === payload.requestId);
|
|
105
|
+
const requested = findRecordedTermination(stream.events, payload.requestId);
|
|
109
106
|
const subscription = await getSubscriptionForTenant(ctx, tenantId);
|
|
110
107
|
const effectiveAtIso = requested?.effectiveAtIso ?? subscription?.currentPeriodEnd.toString();
|
|
111
108
|
const now = options.now();
|
|
@@ -122,6 +119,14 @@ export function createRecordContractTerminationRevocationHandler(options, consum
|
|
|
122
119
|
aggregateId: subscriptionAggregateId(tenantId),
|
|
123
120
|
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
124
121
|
});
|
|
122
|
+
// A cancel recorded between the first fetch and now was never resumed above.
|
|
123
|
+
const requestedMeanwhile = requested === undefined
|
|
124
|
+
? findRecordedTermination(streamAfterProviderCall.events, payload.requestId)
|
|
125
|
+
: undefined;
|
|
126
|
+
if (requestedMeanwhile?.providerCancel === "period-end") {
|
|
127
|
+
reasons.push(...(await resumeAtProvider(ctx, await getSubscriptionForTenant(ctx, tenantId))));
|
|
128
|
+
}
|
|
129
|
+
const providerCancel = (requested ?? requestedMeanwhile)?.providerCancel;
|
|
125
130
|
const revoked = {
|
|
126
131
|
requestId: payload.requestId,
|
|
127
132
|
revokedAtIso: now.toString(),
|
|
@@ -141,7 +146,7 @@ export function createRecordContractTerminationRevocationHandler(options, consum
|
|
|
141
146
|
},
|
|
142
147
|
reasons,
|
|
143
148
|
tenantId,
|
|
144
|
-
...(
|
|
149
|
+
...(providerCancel !== undefined && { providerCancel }),
|
|
145
150
|
});
|
|
146
151
|
return outcome("revoked");
|
|
147
152
|
},
|
|
@@ -74,6 +74,6 @@ export type RevokePageTexts = {
|
|
|
74
74
|
readonly invalidTitle: string;
|
|
75
75
|
readonly invalidBody: string;
|
|
76
76
|
};
|
|
77
|
-
export declare const OPERATOR_NOTICE_REASONS: readonly ["unmatched", "ambiguous", "withdrawal", "extraordinary", "provider_cannot_cancel", "provider_error", "no_active_subscription", "recording_failed", "withdrawal_period_expired", "receipt_rate_limited", "revoked", "revocation_resume_manual", "revocation_resume_failed"];
|
|
77
|
+
export declare const OPERATOR_NOTICE_REASONS: readonly ["unmatched", "ambiguous", "withdrawal", "extraordinary", "provider_cannot_cancel", "provider_error", "no_active_subscription", "recording_failed", "withdrawal_period_expired", "receipt_rate_limited", "receipt_not_sent", "revoked", "revocation_resume_manual", "revocation_resume_failed"];
|
|
78
78
|
export type OperatorNoticeReason = (typeof OPERATOR_NOTICE_REASONS)[number];
|
|
79
79
|
export declare const TERMINATION_TEXTS: Readonly<Record<ConsentLocale, TerminationTexts>>;
|
|
@@ -9,6 +9,7 @@ export const OPERATOR_NOTICE_REASONS = [
|
|
|
9
9
|
"recording_failed",
|
|
10
10
|
"withdrawal_period_expired",
|
|
11
11
|
"receipt_rate_limited",
|
|
12
|
+
"receipt_not_sent",
|
|
12
13
|
"revoked",
|
|
13
14
|
"revocation_resume_manual",
|
|
14
15
|
"revocation_resume_failed",
|
|
@@ -55,6 +56,7 @@ export const TERMINATION_TEXTS = {
|
|
|
55
56
|
recording_failed: "Die Erklärung konnte nicht gespeichert werden",
|
|
56
57
|
withdrawal_period_expired: "Widerruf nach Ablauf der Widerrufsfrist: zum Periodenende gekündigt, Anspruch prüfen",
|
|
57
58
|
receipt_rate_limited: "Keine Eingangsbestätigung versendet: Limit pro Empfängeradresse erreicht",
|
|
59
|
+
receipt_not_sent: "Keine Eingangsbestätigung versendet: Limit-Prüfung nicht verfügbar (Redis-Fehler oder fehlende Konfiguration)",
|
|
58
60
|
revoked: "Die Erklärung wurde vom Postfachinhaber über „Diese Kündigung war ich nicht“ zurückgenommen",
|
|
59
61
|
revocation_resume_manual: "Die Kündigung beim Zahlungsanbieter bitte manuell zurücknehmen (keine Schnittstelle dafür)",
|
|
60
62
|
revocation_resume_failed: "Die Kündigung beim Zahlungsanbieter konnte nicht zurückgenommen werden, bitte manuell zurücknehmen",
|
|
@@ -154,6 +156,7 @@ export const TERMINATION_TEXTS = {
|
|
|
154
156
|
recording_failed: "The declaration could not be stored",
|
|
155
157
|
withdrawal_period_expired: "Withdrawal after the withdrawal period: cancelled at period end, review the claim",
|
|
156
158
|
receipt_rate_limited: "No receipt mail sent: the per-recipient limit was reached",
|
|
159
|
+
receipt_not_sent: "No receipt mail sent: the limit check was unavailable (limiter error or missing configuration)",
|
|
157
160
|
revoked: 'The declaration was taken back by the mailbox owner ("I did not request this cancellation")',
|
|
158
161
|
revocation_resume_manual: "Undo the cancellation at the payment provider manually (no API for it)",
|
|
159
162
|
revocation_resume_failed: "Undoing the cancellation at the payment provider failed, undo it manually",
|
|
@@ -4,7 +4,10 @@ type PeriodStartEvent = {
|
|
|
4
4
|
readonly type: string;
|
|
5
5
|
readonly createdAt: Temporal.Instant;
|
|
6
6
|
};
|
|
7
|
-
/** The period runs from the latest
|
|
8
|
-
*
|
|
7
|
+
/** The period runs from the latest subscription start on the stream. A checkout
|
|
8
|
+
* consent alone never restarts it: it is written before the buyer reaches the
|
|
9
|
+
* provider, so an abandoned plan-change checkout would otherwise reopen the
|
|
10
|
+
* period of a long-concluded contract. A stream without a subscription start
|
|
11
|
+
* has no provable start and counts as expired. */
|
|
9
12
|
export declare function isWithinWithdrawalPeriod(events: readonly PeriodStartEvent[], now: Temporal.Instant): boolean;
|
|
10
13
|
export {};
|
|
@@ -1,24 +1,23 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { SUBSCRIPTION_CREATED_EVENT_QN } from "../events.js";
|
|
2
2
|
// § 355 BGB: the withdrawal period is 14 days from conclusion of the contract.
|
|
3
3
|
export const WITHDRAWAL_PERIOD_DAYS = 14;
|
|
4
4
|
const HOURS_PER_DAY = 24;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* A stream with neither has no provable start and counts as expired. */
|
|
5
|
+
/** The period runs from the latest subscription start on the stream. A checkout
|
|
6
|
+
* consent alone never restarts it: it is written before the buyer reaches the
|
|
7
|
+
* provider, so an abandoned plan-change checkout would otherwise reopen the
|
|
8
|
+
* period of a long-concluded contract. A stream without a subscription start
|
|
9
|
+
* has no provable start and counts as expired. */
|
|
11
10
|
export function isWithinWithdrawalPeriod(events, now) {
|
|
12
11
|
let latestStart;
|
|
13
12
|
for (const event of events) {
|
|
14
|
-
if (
|
|
13
|
+
if (event.type !== SUBSCRIPTION_CREATED_EVENT_QN)
|
|
15
14
|
continue;
|
|
16
15
|
if (latestStart === undefined ||
|
|
17
16
|
event.createdAt.epochMilliseconds > latestStart.epochMilliseconds) {
|
|
18
17
|
latestStart = event.createdAt;
|
|
19
18
|
}
|
|
20
19
|
}
|
|
21
|
-
// skip: no
|
|
20
|
+
// skip: no subscription-created event, nothing proves a running period
|
|
22
21
|
if (latestStart === undefined)
|
|
23
22
|
return false;
|
|
24
23
|
const periodEnd = latestStart.add({ hours: WITHDRAWAL_PERIOD_DAYS * HOURS_PER_DAY });
|
|
@@ -66,6 +66,7 @@ export declare const checkoutConsentRecordedPayloadSchema: z.ZodObject<{
|
|
|
66
66
|
termsTemplateVersion: z.ZodNumber;
|
|
67
67
|
locale: z.ZodString;
|
|
68
68
|
actorUserId: z.ZodString;
|
|
69
|
+
providerName: z.ZodOptional<z.ZodString>;
|
|
69
70
|
itemLabelKey: z.ZodOptional<z.ZodString>;
|
|
70
71
|
itemLabelParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>>;
|
|
71
72
|
}, z.core.$strip>;
|
|
@@ -104,6 +104,7 @@ export const checkoutConsentRecordedPayloadSchema = z.object({
|
|
|
104
104
|
termsTemplateVersion: z.number(),
|
|
105
105
|
locale: z.string().min(2).max(35),
|
|
106
106
|
actorUserId: z.string().min(1),
|
|
107
|
+
providerName: z.string().min(1).max(100).optional(),
|
|
107
108
|
itemLabelKey: z.string().min(1).max(200).optional(),
|
|
108
109
|
itemLabelParams: z.record(z.string(), z.union([z.string().max(100), z.number()])).optional(),
|
|
109
110
|
});
|
|
@@ -57,6 +57,7 @@ import { createBillingPlansQuery } from "./handlers/billing-plans.query.js";
|
|
|
57
57
|
import { createCheckoutSessionHandler } from "./handlers/create-checkout-session.write.js";
|
|
58
58
|
import { createPortalSessionHandler } from "./handlers/create-portal-session.write.js";
|
|
59
59
|
import { listSubscriptionsQuery } from "./handlers/list-subscriptions.query.js";
|
|
60
|
+
import { createPlanSwitchPreviewQuery } from "./handlers/plan-switch-preview.query.js";
|
|
60
61
|
import { createProcessEventHandler } from "./handlers/process-event.write.js";
|
|
61
62
|
import { processPaymentEventHandler } from "./handlers/process-payment-event.write.js";
|
|
62
63
|
import { createStartPlanCheckoutHandler } from "./handlers/start-plan-checkout.write.js";
|
|
@@ -89,7 +90,7 @@ export function createBillingFoundationFeature(options = {}) {
|
|
|
89
90
|
}),
|
|
90
91
|
};
|
|
91
92
|
return defineFeature(BILLING_FOUNDATION_FEATURE, (r) => {
|
|
92
|
-
r.describe("Plugin host for subscription billing
|
|
93
|
+
r.describe("Plugin host for subscription billing. Manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers, a `plan-switch-preview` query and a dormant billing-plans dashboard screen/panel from the catalog. Several providers can be mounted at once: new checkouts are offered by every enabled provider (enabled means keys set and billingLive on, or simply mounted for plugins without `isBillingEnabled`), the customer picks one in the plan panel and `start-plan-checkout` takes a `providerName`. An existing subscription stays with the provider that stored it, so the plan list and every switch follow that provider. A plan switch is either hosted (`createPlanSwitchSession` answers a url) or in-app (`previewPlanSwitch` shows the proration, `applyPlanSwitch` runs after the customer confirms; `switch-plan` then answers `{ applied: true }` and records the consent). `consumerProtection.providerTexts` overrides the checkout note and the VAT note per provider. Also ships `billing-foundation:write:sync-subscription` (pulls a provider plugin's live subscription state via `retrieveSubscription` and appends drift as a `subscription.updated` event, which catches changes made on the provider's own dashboard that never reached us as a webhook) and the `sync-subscriptions` job (manual-trigger + runOnBoot, perTenant) that dispatches it. Low-level building block: use `subscription-stripe`, `subscription-mollie` or `subscription-dodo` unless you are writing a new payment provider.");
|
|
93
94
|
r.uiHints({
|
|
94
95
|
displayLabel: "Billing · Foundation",
|
|
95
96
|
category: "billing",
|
|
@@ -183,6 +184,7 @@ export function createBillingFoundationFeature(options = {}) {
|
|
|
183
184
|
if (widened.catalog) {
|
|
184
185
|
const { catalog } = widened;
|
|
185
186
|
r.queryHandler(createBillingPlansQuery(widened, catalog));
|
|
187
|
+
r.queryHandler(createPlanSwitchPreviewQuery(catalog));
|
|
186
188
|
r.writeHandler(createStartPlanCheckoutHandler(widened, catalog));
|
|
187
189
|
r.writeHandler(createSwitchPlanHandler(widened, catalog));
|
|
188
190
|
r.screen(createBillingPlansScreen(catalog.viewRoles));
|
|
@@ -2,20 +2,20 @@
|
|
|
2
2
|
// benefits, the caller's current tier and per-plan action. Only registered
|
|
3
3
|
// when `createBillingFoundationFeature` gets a `catalog`.
|
|
4
4
|
import * as z from "zod";
|
|
5
|
-
import {
|
|
5
|
+
import { filterOfferedProviders, listCatalogProviders, } from "../checkout-core.js";
|
|
6
6
|
import { CONSENT_TEXTS, consentTextVersion, } from "../consumer-protection/consent-text.js";
|
|
7
|
-
import {
|
|
7
|
+
import { buildProviderPlanViews, loadBillingPlansBase, toBillingPlansResult, } from "../plan-catalog.js";
|
|
8
8
|
import { isLegalLinkSet, } from "../types.js";
|
|
9
9
|
const billingPlansSchema = z.object({}).strict();
|
|
10
10
|
export function createBillingPlansQuery(options, catalog) {
|
|
11
11
|
return {
|
|
12
12
|
name: "billing-plans",
|
|
13
|
-
description: "Lists the purchasable plans with live price, benefits, the caller's current tier and which action (checkout | switch | current | unavailable | paymentPending) each plan offers, plus the optional price-less contact card.",
|
|
13
|
+
description: "Lists the purchasable plans with live price, benefits, the caller's current tier and which action (checkout | switch | current | unavailable | paymentPending) each plan offers, plus the optional price-less contact card. With several live providers it also lists each provider's own plans under `providers`.",
|
|
14
14
|
schema: billingPlansSchema,
|
|
15
15
|
access: { roles: catalog.viewRoles },
|
|
16
16
|
handler: async (_query, ctx) => {
|
|
17
|
-
const
|
|
18
|
-
const result = await
|
|
17
|
+
const base = await loadBillingPlansBase(ctx, catalog, options.now);
|
|
18
|
+
const result = await resolveProviderPlans(ctx, options, catalog, base);
|
|
19
19
|
const { consumerProtection } = options;
|
|
20
20
|
if (!consumerProtection)
|
|
21
21
|
return result;
|
|
@@ -32,6 +32,36 @@ export function createBillingPlansQuery(options, catalog) {
|
|
|
32
32
|
},
|
|
33
33
|
};
|
|
34
34
|
}
|
|
35
|
+
// A tenant with a blocking subscription sees only the provider holding it
|
|
36
|
+
// (a second provider's prices would be a checkout it cannot start); otherwise
|
|
37
|
+
// every live provider is offered.
|
|
38
|
+
async function resolveProviderPlans(ctx, options, catalog, base) {
|
|
39
|
+
const candidates = listCatalogProviders(ctx, catalog);
|
|
40
|
+
if (base.blockingSubscription && base.subscriptionView) {
|
|
41
|
+
const holder = candidates.find((c) => c.name === base.subscriptionView?.providerName);
|
|
42
|
+
return toBillingPlansResult(catalog, base, await buildProviderPlanViews(ctx, holder?.plugin ?? null, catalog, base));
|
|
43
|
+
}
|
|
44
|
+
const offered = await filterOfferedProviders(ctx, candidates);
|
|
45
|
+
const first = offered[0] ?? candidates[0] ?? null;
|
|
46
|
+
const firstViews = await buildProviderPlanViews(ctx, first?.plugin ?? null, catalog, base);
|
|
47
|
+
const result = toBillingPlansResult(catalog, base, firstViews);
|
|
48
|
+
const providerTexts = options.consumerProtection?.providerTexts;
|
|
49
|
+
const hasNote = first !== null && providerTexts?.[first.name]?.checkoutNote !== undefined;
|
|
50
|
+
if (offered.length <= 1 && !(offered.length === 1 && hasNote))
|
|
51
|
+
return result;
|
|
52
|
+
const providers = await Promise.all(offered.map((provider, index) => providerView(ctx, catalog, base, provider, providerTexts, index === 0 ? firstViews : null)));
|
|
53
|
+
return { ...result, providers };
|
|
54
|
+
}
|
|
55
|
+
async function providerView(ctx, catalog, base, provider, providerTexts, known) {
|
|
56
|
+
const views = known ?? (await buildProviderPlanViews(ctx, provider.plugin, catalog, base));
|
|
57
|
+
const note = providerTexts?.[provider.name]?.checkoutNote;
|
|
58
|
+
return {
|
|
59
|
+
name: provider.name,
|
|
60
|
+
displayName: provider.plugin.displayName ?? provider.name,
|
|
61
|
+
...(note !== undefined && { note }),
|
|
62
|
+
plans: views.plans,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
35
65
|
function legalLinksPerLocale(legalLinks) {
|
|
36
66
|
return isLegalLinkSet(legalLinks) ? { de: legalLinks, en: legalLinks } : legalLinks;
|
|
37
67
|
}
|
|
@@ -90,6 +90,7 @@ export function createCheckoutSessionHandler(options) {
|
|
|
90
90
|
tier: mode === "subscription" ? (plugin.priceToTier?.[payload.priceId] ?? null) : null,
|
|
91
91
|
priceId: payload.priceId,
|
|
92
92
|
price,
|
|
93
|
+
providerName: payload.providerName,
|
|
93
94
|
...(orderItem && { orderItem }),
|
|
94
95
|
});
|
|
95
96
|
return {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import type { BillingPlanCatalog, PlanSwitchPreview } from "../types.ts";
|
|
3
|
+
export type PlanSwitchPreviewResult = {
|
|
4
|
+
readonly preview: PlanSwitchPreview;
|
|
5
|
+
readonly target: {
|
|
6
|
+
readonly tier: string;
|
|
7
|
+
readonly unitAmount: number;
|
|
8
|
+
readonly currency: string;
|
|
9
|
+
readonly interval: "day" | "week" | "month" | "year" | null;
|
|
10
|
+
readonly intervalCount: number | null;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export declare function createPlanSwitchPreviewQuery(catalog: BillingPlanCatalog): QueryHandlerDef;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// plan-switch-preview — what switching to a tier would cost right now, for
|
|
2
|
+
// providers that switch plans in-app (previewPlanSwitch + applyPlanSwitch).
|
|
3
|
+
// Only registered when `createBillingFoundationFeature` gets a `catalog`.
|
|
4
|
+
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
|
+
import * as z from "zod";
|
|
6
|
+
import { isNonEmptyStringArray } from "../checkout-core.js";
|
|
7
|
+
import { purchaseRolesOf } from "../plan-catalog.js";
|
|
8
|
+
import { preparePlanSwitch } from "../plan-switch.js";
|
|
9
|
+
export function createPlanSwitchPreviewQuery(catalog) {
|
|
10
|
+
const plans = catalog.plans;
|
|
11
|
+
if (!isNonEmptyStringArray(plans)) {
|
|
12
|
+
throw new Error("plan-switch-preview: catalog.plans must not be empty");
|
|
13
|
+
}
|
|
14
|
+
const schema = z.object({ tier: z.enum(plans) }).strict();
|
|
15
|
+
return {
|
|
16
|
+
name: "plan-switch-preview",
|
|
17
|
+
description: "Previews what switching the existing subscription to another plan tier costs right now (amount due, tax, credit, effective date) plus the target plan's recurring price. Only for providers that switch plans in-app.",
|
|
18
|
+
schema,
|
|
19
|
+
access: { roles: purchaseRolesOf(catalog) },
|
|
20
|
+
handler: async (query, ctx) => {
|
|
21
|
+
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
22
|
+
const { tier } = query.payload;
|
|
23
|
+
const prepared = await preparePlanSwitch(ctx, catalog, tier, "plan-switch-preview");
|
|
24
|
+
const { plugin } = prepared.provider;
|
|
25
|
+
if (!plugin.previewPlanSwitch || !plugin.applyPlanSwitch) {
|
|
26
|
+
throw new UnprocessableError("plan_switch_not_supported", {
|
|
27
|
+
i18nKey: "billing-foundation.errors.planSwitchNotSupported",
|
|
28
|
+
message: `subscription-foundation: provider "${prepared.provider.name}" switches plans on a hosted page and has no in-app preview`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const preview = await plugin.previewPlanSwitch(ctx, {
|
|
32
|
+
providerSubscriptionId: prepared.subscription.providerSubscriptionId,
|
|
33
|
+
targetPriceId: prepared.target.priceId,
|
|
34
|
+
});
|
|
35
|
+
const { price } = prepared.target;
|
|
36
|
+
return {
|
|
37
|
+
preview,
|
|
38
|
+
target: {
|
|
39
|
+
tier,
|
|
40
|
+
unitAmount: price.unitAmount ?? 0,
|
|
41
|
+
currency: price.currency,
|
|
42
|
+
interval: price.interval,
|
|
43
|
+
intervalCount: price.intervalCount,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -27,6 +27,12 @@ export declare const processEventSchema: z.ZodObject<{
|
|
|
27
27
|
consentId: z.ZodOptional<z.ZodString>;
|
|
28
28
|
}, z.core.$strip>;
|
|
29
29
|
export type ProcessEventPayload = z.infer<typeof processEventSchema>;
|
|
30
|
+
export declare function isLateStateEvent(storedEvents: readonly {
|
|
31
|
+
readonly payload: unknown;
|
|
32
|
+
readonly metadata: {
|
|
33
|
+
readonly headers?: Record<string, unknown>;
|
|
34
|
+
};
|
|
35
|
+
}[], payload: ProcessEventPayload): boolean;
|
|
30
36
|
export declare function clampProviderEventAt(payload: ProcessEventPayload, now: Temporal.Instant): ProcessEventPayload;
|
|
31
37
|
export declare function appendSubscriptionEvent(ctx: HandlerContext, tenantId: string, rawPayload: ProcessEventPayload, now?: Temporal.Instant): Promise<{
|
|
32
38
|
readonly duplicate: boolean;
|
|
@@ -65,7 +65,7 @@ const STATE_EVENT_TYPES = new Set([
|
|
|
65
65
|
SubscriptionEventTypes.updated,
|
|
66
66
|
SubscriptionEventTypes.canceled,
|
|
67
67
|
]);
|
|
68
|
-
function isLateStateEvent(storedEvents, payload) {
|
|
68
|
+
export function isLateStateEvent(storedEvents, payload) {
|
|
69
69
|
if (payload.providerEventAtIso === undefined || !STATE_EVENT_TYPES.has(payload.type)) {
|
|
70
70
|
return false;
|
|
71
71
|
}
|
|
@@ -79,7 +79,13 @@ function isLateStateEvent(storedEvents, payload) {
|
|
|
79
79
|
// A stored value that does not parse can never prove a newer event; skip it instead of
|
|
80
80
|
// failing every later delivery of this tenant's stream.
|
|
81
81
|
try {
|
|
82
|
-
|
|
82
|
+
const order = Temporal.Instant.compare(Temporal.Instant.from(storedAt), incoming);
|
|
83
|
+
if (order !== 0)
|
|
84
|
+
return order > 0;
|
|
85
|
+
// Provider timestamps have second resolution: on a tie a stored cancellation wins over
|
|
86
|
+
// a non-terminal state, so a reordered same-second `updated` cannot revive a canceled one.
|
|
87
|
+
return (propertyOf(stored.payload, "status") === SubscriptionStatuses.canceled &&
|
|
88
|
+
payload.status !== SubscriptionStatuses.canceled);
|
|
83
89
|
}
|
|
84
90
|
catch {
|
|
85
91
|
return false;
|
|
@@ -2,30 +2,56 @@
|
|
|
2
2
|
// the catalog while the tenant has no non-terminal subscription. Resolves
|
|
3
3
|
// the tier's live price server-side and opens a hosted checkout for it.
|
|
4
4
|
// Only registered when `createBillingFoundationFeature` gets a `catalog`.
|
|
5
|
-
import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
5
|
+
import { FeatureDisabledError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
|
|
6
6
|
import * as z from "zod";
|
|
7
|
-
import { assertBillingEnabled, assertNoActiveSubscription, isNonEmptyStringArray, joinBaseUrl,
|
|
7
|
+
import { assertBillingEnabled, assertNoActiveSubscription, isNonEmptyStringArray, joinBaseUrl, listOfferedCatalogProviders, openCheckout, } from "../checkout-core.js";
|
|
8
8
|
import { consentCheckoutFields, consentPayloadSchema, prepareConsent, recordConsent, } from "../consumer-protection/consent-gate.js";
|
|
9
9
|
import { purchaseRolesOf, resolvePlanPrices } from "../plan-catalog.js";
|
|
10
|
+
import { BILLING_FOUNDATION_FEATURE } from "../web/constants.js";
|
|
11
|
+
/** The provider this checkout goes to: the named one if it is live, the only
|
|
12
|
+
* live one if the buyer had no choice, otherwise a client error. */
|
|
13
|
+
async function pickCheckoutProvider(ctx, catalog, requestedName) {
|
|
14
|
+
const offered = await listOfferedCatalogProviders(ctx, catalog);
|
|
15
|
+
if (requestedName !== undefined) {
|
|
16
|
+
const chosen = offered.find((provider) => provider.name === requestedName);
|
|
17
|
+
if (!chosen) {
|
|
18
|
+
throw new UnprocessableError("provider_not_offered", {
|
|
19
|
+
i18nKey: "billing-foundation.errors.providerNotOffered",
|
|
20
|
+
message: `subscription-foundation: provider "${requestedName}" is not offered by this catalog right now`,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
return chosen;
|
|
24
|
+
}
|
|
25
|
+
const [only, second] = offered;
|
|
26
|
+
if (!only)
|
|
27
|
+
throw new FeatureDisabledError(BILLING_FOUNDATION_FEATURE, "start-plan-checkout");
|
|
28
|
+
if (second) {
|
|
29
|
+
throw new UnprocessableError("provider_required", {
|
|
30
|
+
i18nKey: "billing-foundation.errors.providerRequired",
|
|
31
|
+
message: "subscription-foundation: several providers are offered, name one as providerName",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return only;
|
|
35
|
+
}
|
|
10
36
|
export function createStartPlanCheckoutHandler(options, catalog) {
|
|
11
37
|
const plans = catalog.plans;
|
|
12
38
|
if (!isNonEmptyStringArray(plans)) {
|
|
13
39
|
throw new Error("start-plan-checkout: catalog.plans must not be empty");
|
|
14
40
|
}
|
|
15
41
|
const consumerProtection = options.consumerProtection;
|
|
16
|
-
const tierSchema = z.object({ tier: z.enum(plans) });
|
|
42
|
+
const tierSchema = z.object({ tier: z.enum(plans), providerName: z.string().min(1).optional() });
|
|
17
43
|
const schema = (consumerProtection
|
|
18
44
|
? tierSchema.extend({ consent: consentPayloadSchema.optional() })
|
|
19
45
|
: tierSchema).strict();
|
|
20
46
|
return {
|
|
21
47
|
name: "start-plan-checkout",
|
|
22
|
-
description: "Starts a hosted checkout for a plan tier when the tenant has no active subscription; the server picks price and redirect URLs.",
|
|
48
|
+
description: "Starts a hosted checkout for a plan tier when the tenant has no active subscription; the server picks price and redirect URLs. Name the provider with providerName when the catalog offers several.",
|
|
23
49
|
schema,
|
|
24
50
|
access: { roles: purchaseRolesOf(catalog) },
|
|
25
51
|
handler: async (event, ctx) => {
|
|
26
52
|
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
27
53
|
const payload = event.payload;
|
|
28
|
-
const { name: providerName, plugin } =
|
|
54
|
+
const { name: providerName, plugin } = await pickCheckoutProvider(ctx, catalog, payload.providerName);
|
|
29
55
|
await assertBillingEnabled(ctx, plugin, "start-plan-checkout");
|
|
30
56
|
const existing = await assertNoActiveSubscription(ctx, options.now());
|
|
31
57
|
const prices = await resolvePlanPrices(ctx, plugin, catalog);
|
|
@@ -61,6 +87,7 @@ export function createStartPlanCheckoutHandler(options, catalog) {
|
|
|
61
87
|
tier: payload.tier,
|
|
62
88
|
priceId: resolved.priceId,
|
|
63
89
|
price: resolved.price,
|
|
90
|
+
providerName,
|
|
64
91
|
});
|
|
65
92
|
}
|
|
66
93
|
return { isSuccess: true, data: { url: result.url } };
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
-
import type {
|
|
3
|
-
export declare function createSwitchPlanHandler(options:
|
|
2
|
+
import type { BillingPlanCatalog, ResolvedBillingFoundationOptions } from "../types.ts";
|
|
3
|
+
export declare function createSwitchPlanHandler(options: ResolvedBillingFoundationOptions, catalog: BillingPlanCatalog): WriteHandlerDef;
|