@cosmicdrift/kumiko-bundled-features 0.320.0 → 0.321.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 +9 -9
- package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
- package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
- package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
- package/src/billing-foundation/changes.json +14 -0
- package/src/billing-foundation/checkout-core.ts +8 -4
- package/src/billing-foundation/constants.ts +6 -0
- package/src/billing-foundation/feature.ts +37 -2
- package/src/billing-foundation/handlers/process-event.write.ts +112 -106
- package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
- package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
- package/src/billing-foundation/i18n.ts +9 -0
- package/src/billing-foundation/index.ts +1 -0
- package/src/billing-foundation/plan-catalog.ts +22 -5
- package/src/billing-foundation/types.ts +27 -0
- package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
- package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
- package/src/channel-email/__tests__/email-channel.test.ts +92 -0
- package/src/channel-email/changes.json +9 -1
- package/src/channel-email/email-channel.ts +31 -6
- package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
- package/src/delivery/changes.json +7 -0
- package/src/delivery/feature.ts +1 -1
- package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
- package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
- package/src/delivery/index.ts +2 -1
- package/src/delivery/public-names.ts +1 -1
- package/src/delivery/unsubscribe.ts +167 -89
- package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
- package/src/step-dispatcher/__tests__/webhook-runner.test.ts +187 -35
- package/src/step-dispatcher/changes.json +7 -0
- package/src/step-dispatcher/feature.ts +48 -15
- package/src/step-dispatcher/index.ts +3 -1
- package/src/step-dispatcher/webhook-runner.ts +59 -19
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
- package/src/subscription-stripe/feature.ts +5 -1
- package/src/subscription-stripe/plugin-methods.ts +53 -0
- package/src/subscription-stripe/verify-webhook.ts +52 -28
- package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
- package/src/tenant-handover/changes.json +7 -0
- package/src/tenant-handover/handlers/claim.write.ts +1 -0
- package/src/tenant-handover/move-entity-graph.ts +30 -44
|
@@ -59,7 +59,11 @@ import {
|
|
|
59
59
|
// `Temporal` TYPE `ResolvedBillingFoundationOptions.now`'s return type
|
|
60
60
|
// resolves against, see event-store.ts's own import comment (#1438).
|
|
61
61
|
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
62
|
-
import {
|
|
62
|
+
import {
|
|
63
|
+
BILLING_FOUNDATION_FEATURE,
|
|
64
|
+
SUBSCRIPTION_PROVIDER_EXTENSION,
|
|
65
|
+
SubscriptionFoundationHandlers,
|
|
66
|
+
} from "./constants";
|
|
63
67
|
import { paymentEntity, subscriptionEntity } from "./entities";
|
|
64
68
|
import {
|
|
65
69
|
INVOICE_PAID_EVENT_QN,
|
|
@@ -87,6 +91,7 @@ import { processEventHandler } from "./handlers/process-event.write";
|
|
|
87
91
|
import { processPaymentEventHandler } from "./handlers/process-payment-event.write";
|
|
88
92
|
import { createStartPlanCheckoutHandler } from "./handlers/start-plan-checkout.write";
|
|
89
93
|
import { createSwitchPlanHandler } from "./handlers/switch-plan.write";
|
|
94
|
+
import { syncSubscriptionHandler } from "./handlers/sync-subscription.write";
|
|
90
95
|
import { BILLING_FOUNDATION_I18N } from "./i18n";
|
|
91
96
|
import {
|
|
92
97
|
applyInvoicePaid,
|
|
@@ -137,7 +142,7 @@ export function createBillingFoundationFeature<TTier extends string = string>(
|
|
|
137
142
|
|
|
138
143
|
return defineFeature(BILLING_FOUNDATION_FEATURE, (r) => {
|
|
139
144
|
r.describe(
|
|
140
|
-
"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 and a dormant billing-plans dashboard screen/panel from the catalog. Low-level building block — use `subscription-stripe` or `subscription-mollie` unless you are writing a new payment provider.",
|
|
145
|
+
"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 and a dormant billing-plans dashboard screen/panel from the catalog. 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 — 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` or `subscription-mollie` unless you are writing a new payment provider.",
|
|
141
146
|
);
|
|
142
147
|
r.uiHints({
|
|
143
148
|
displayLabel: "Billing · Foundation",
|
|
@@ -215,6 +220,12 @@ export function createBillingFoundationFeature<TTier extends string = string>(
|
|
|
215
220
|
// - process-payment-event: programmatic entry-point from the webhook-
|
|
216
221
|
// handler for one-off-payments; appends onto the payment-aggregate
|
|
217
222
|
r.writeHandler(processPaymentEventHandler);
|
|
223
|
+
// - sync-subscription: backfill entry-point for the sync-subscriptions
|
|
224
|
+
// job below; pulls the live provider state and appends drift as a
|
|
225
|
+
// subscription.updated event. Registered unconditionally (not
|
|
226
|
+
// catalog-gated) — it operates on whatever subscription already
|
|
227
|
+
// exists for the tenant, independent of the billing-plans catalog.
|
|
228
|
+
r.writeHandler(syncSubscriptionHandler);
|
|
218
229
|
|
|
219
230
|
// Custom list-query on the subscription-projection (raw drizzle
|
|
220
231
|
// table; no r.entity since writes go through projection-apply).
|
|
@@ -233,6 +244,30 @@ export function createBillingFoundationFeature<TTier extends string = string>(
|
|
|
233
244
|
r.screen(createBillingPlansScreen(catalog.viewRoles));
|
|
234
245
|
}
|
|
235
246
|
|
|
247
|
+
// Backfill job: catches provider-side drift (e.g. a cancel_at set on
|
|
248
|
+
// the provider's own dashboard) that never reached us as a webhook.
|
|
249
|
+
// manual-trigger + runOnBoot (not cron) — this is an on-demand/boot
|
|
250
|
+
// reconciliation pass, not a recurring sweep; an app that wants a
|
|
251
|
+
// recurring sync can dispatch billing-foundation:job:sync-subscriptions
|
|
252
|
+
// from its own cron job. perTenant + runOnBoot are compatible (only
|
|
253
|
+
// bootGate is not). Registered unconditionally, same reasoning as the
|
|
254
|
+
// sync-subscription write-handler above.
|
|
255
|
+
r.job({
|
|
256
|
+
name: "sync-subscriptions",
|
|
257
|
+
trigger: { manual: true },
|
|
258
|
+
perTenant: true,
|
|
259
|
+
runOnBoot: true,
|
|
260
|
+
handler: async (_payload, ctx) => {
|
|
261
|
+
const result = await ctx.write(SubscriptionFoundationHandlers.syncSubscription, {});
|
|
262
|
+
if (!result.isSuccess) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`billing-foundation:sync-subscriptions: sync-subscription write failed: ${JSON.stringify(result.error)}`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
ctx.log.info(`[billing-foundation:sync-subscriptions] ${JSON.stringify(result.data)}`);
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
|
|
236
271
|
r.useExtension(EXT_TENANT_DATA, "subscription", {
|
|
237
272
|
destroy: subscriptionTenantDestroyHook,
|
|
238
273
|
escapeHatch: { reason: SUBSCRIPTION_TENANT_DESTROY_ARCHIVE_REASON },
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
configuredPiiSubjectKms,
|
|
17
17
|
encryptPiiFieldValues,
|
|
18
18
|
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
19
|
-
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import type { HandlerContext, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
20
20
|
import * as z from "zod";
|
|
21
21
|
import { subscriptionAggregateId } from "../aggregate-id";
|
|
22
22
|
import { SubscriptionEventTypes, SubscriptionStatuses } from "../constants";
|
|
@@ -66,7 +66,7 @@ export const processEventSchema = z.object({
|
|
|
66
66
|
cancelAtIso: z.string().min(1).nullable().optional(),
|
|
67
67
|
rawPayload: z.string().min(1),
|
|
68
68
|
});
|
|
69
|
-
type ProcessEventPayload = z.infer<typeof processEventSchema>;
|
|
69
|
+
export type ProcessEventPayload = z.infer<typeof processEventSchema>;
|
|
70
70
|
|
|
71
71
|
// Map normalized SubscriptionEventType → fully-qualified ES event-name.
|
|
72
72
|
const NORMALIZED_TO_ES_EVENT: Readonly<Record<string, string>> = {
|
|
@@ -77,6 +77,114 @@ const NORMALIZED_TO_ES_EVENT: Readonly<Record<string, string>> = {
|
|
|
77
77
|
[SubscriptionEventTypes.invoicePaymentFailed]: INVOICE_PAYMENT_FAILED_EVENT_QN,
|
|
78
78
|
} satisfies Readonly<Record<string, string>>;
|
|
79
79
|
|
|
80
|
+
// =============================================================================
|
|
81
|
+
// appendSubscriptionEvent — the append-body, shared by processEventHandler
|
|
82
|
+
// (webhook path) and sync-subscription.write.ts (backfill path). Both call
|
|
83
|
+
// this with the same normalized payload shape; the difference is only in
|
|
84
|
+
// who constructs `payload` and what `providerEventId` they use.
|
|
85
|
+
// =============================================================================
|
|
86
|
+
|
|
87
|
+
export async function appendSubscriptionEvent(
|
|
88
|
+
ctx: HandlerContext,
|
|
89
|
+
tenantId: string,
|
|
90
|
+
payload: ProcessEventPayload,
|
|
91
|
+
): Promise<{ readonly duplicate: boolean; readonly subscriptionAggregateId: string }> {
|
|
92
|
+
const aggId = subscriptionAggregateId(tenantId);
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------
|
|
95
|
+
// 1. Idempotency: load the subscription stream and check whether this
|
|
96
|
+
// providerEventId was already seen. A provider retry storm (Stripe
|
|
97
|
+
// resends up to 5x within 4h) hits the same stream and finds the
|
|
98
|
+
// event id in metadata.
|
|
99
|
+
//
|
|
100
|
+
// **Performance caveat:** O(N) per stream. With 5 years of history
|
|
101
|
+
// (monthly recurring = ~60 events) still <50ms. For much longer
|
|
102
|
+
// streams, optimize via snapshot or a per-tenant dedup table as the
|
|
103
|
+
// idempotency anchor (like cap-counter).
|
|
104
|
+
// ---------------------------------------------------------------
|
|
105
|
+
const existingEvents = await ctx.loadAggregate(aggId);
|
|
106
|
+
const alreadySeen = existingEvents.some((e) => {
|
|
107
|
+
const headers = e.metadata.headers ?? {};
|
|
108
|
+
return (
|
|
109
|
+
headers["providerEventId"] === payload.providerEventId &&
|
|
110
|
+
headers["providerName"] === payload.providerName
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
if (alreadySeen) {
|
|
114
|
+
return { duplicate: true, subscriptionAggregateId: aggId };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------
|
|
118
|
+
// 2. Map normalized event-type → ES event-FQN.
|
|
119
|
+
// ---------------------------------------------------------------
|
|
120
|
+
const esEventType = NORMALIZED_TO_ES_EVENT[payload.type];
|
|
121
|
+
if (!esEventType) {
|
|
122
|
+
// Schema validation above should already catch this; defensive against
|
|
123
|
+
// drift between the SubscriptionEventTypes enum and the NORMALIZED map.
|
|
124
|
+
throw new Error(`subscription-foundation: no ES event-type mapping for "${payload.type}"`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------
|
|
128
|
+
// 3. Encrypt the two provider-subject PII fields before they touch
|
|
129
|
+
// storage — this is the ONLY write path onto the subscription
|
|
130
|
+
// stream, so encrypting here covers both the event-log payload AND
|
|
131
|
+
// (via projection.ts copying the event fields as-is) the
|
|
132
|
+
// read_subscriptions row with a single call. The subject is the
|
|
133
|
+
// TENANT (tenantOwned) — tenant-destroy's subject-keys stage
|
|
134
|
+
// (eraseSubjectKeys) erases exactly this key, so both copies become
|
|
135
|
+
// genuinely unreadable (#800) once that stage runs, not just
|
|
136
|
+
// "encrypted at rest". No adapter configured = engine off (fields
|
|
137
|
+
// stay plaintext, pre-#724-phase-C behavior) — mirrors how the
|
|
138
|
+
// event-store-executor treats an absent piiKms().
|
|
139
|
+
// ---------------------------------------------------------------
|
|
140
|
+
const piiKms = configuredPiiSubjectKms();
|
|
141
|
+
const encryptedFields = piiKms
|
|
142
|
+
? await encryptPiiFieldValues(
|
|
143
|
+
{
|
|
144
|
+
tenantId,
|
|
145
|
+
providerCustomerId: payload.providerCustomerId,
|
|
146
|
+
providerSubscriptionId: payload.providerSubscriptionId,
|
|
147
|
+
},
|
|
148
|
+
subscriptionEntity,
|
|
149
|
+
SUBSCRIPTION_PII_FIELDS,
|
|
150
|
+
piiKms,
|
|
151
|
+
{ requestId: `billing-foundation:process-event:${payload.providerEventId}`, tenantId },
|
|
152
|
+
{ tenantId, entityName: SUBSCRIPTION_AGGREGATE_TYPE },
|
|
153
|
+
)
|
|
154
|
+
: {
|
|
155
|
+
providerCustomerId: payload.providerCustomerId,
|
|
156
|
+
providerSubscriptionId: payload.providerSubscriptionId,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------
|
|
160
|
+
// 4. Append the event onto the subscription stream. The inline projection
|
|
161
|
+
// materializes the read_subscriptions row in the same TX.
|
|
162
|
+
// ---------------------------------------------------------------
|
|
163
|
+
const eventPayload: SubscriptionEventPayload = {
|
|
164
|
+
providerName: payload.providerName,
|
|
165
|
+
providerCustomerId: encryptedFields["providerCustomerId"] as string,
|
|
166
|
+
providerSubscriptionId: encryptedFields["providerSubscriptionId"] as string,
|
|
167
|
+
status: payload.status,
|
|
168
|
+
tier: payload.tier,
|
|
169
|
+
currentPeriodEndIso: payload.currentPeriodEndIso,
|
|
170
|
+
...(payload.cancelAtIso !== undefined && { cancelAtIso: payload.cancelAtIso }),
|
|
171
|
+
};
|
|
172
|
+
const headers: SubscriptionEventHeaders = {
|
|
173
|
+
providerEventId: payload.providerEventId,
|
|
174
|
+
providerName: payload.providerName,
|
|
175
|
+
rawPayload: payload.rawPayload,
|
|
176
|
+
};
|
|
177
|
+
await ctx.unsafeAppendEvent({
|
|
178
|
+
aggregateId: aggId,
|
|
179
|
+
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
180
|
+
type: esEventType,
|
|
181
|
+
payload: eventPayload,
|
|
182
|
+
headers,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return { duplicate: false, subscriptionAggregateId: aggId };
|
|
186
|
+
}
|
|
187
|
+
|
|
80
188
|
// =============================================================================
|
|
81
189
|
// Handler
|
|
82
190
|
// =============================================================================
|
|
@@ -92,109 +200,7 @@ export const processEventHandler: WriteHandlerDef = {
|
|
|
92
200
|
handler: async (event, ctx) => {
|
|
93
201
|
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
94
202
|
const payload = event.payload as ProcessEventPayload;
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
// ---------------------------------------------------------------
|
|
99
|
-
// 1. Idempotency: load subscription-stream + check ob dieser
|
|
100
|
-
// providerEventId bereits gesehen wurde. Provider-Retry-Storm
|
|
101
|
-
// (Stripe sendet bis zu 5x in 4h) trifft denselben Stream und
|
|
102
|
-
// findet den event-id in metadata.
|
|
103
|
-
//
|
|
104
|
-
// **Performance-caveat:** O(N) pro stream. Bei 5 Jahren history
|
|
105
|
-
// (recurring monatlich = ~60 events) noch <50ms. Bei deutlich
|
|
106
|
-
// längeren streams optimieren via snapshot oder per-tenant
|
|
107
|
-
// dedup-table als idempotency-anchor (analog cap-counter).
|
|
108
|
-
// ---------------------------------------------------------------
|
|
109
|
-
const existingEvents = await ctx.loadAggregate(aggId);
|
|
110
|
-
const alreadySeen = existingEvents.some((e) => {
|
|
111
|
-
const headers = e.metadata.headers ?? {};
|
|
112
|
-
return (
|
|
113
|
-
headers["providerEventId"] === payload.providerEventId &&
|
|
114
|
-
headers["providerName"] === payload.providerName
|
|
115
|
-
);
|
|
116
|
-
});
|
|
117
|
-
if (alreadySeen) {
|
|
118
|
-
return {
|
|
119
|
-
isSuccess: true as const,
|
|
120
|
-
data: { duplicate: true as const, subscriptionAggregateId: aggId },
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ---------------------------------------------------------------
|
|
125
|
-
// 2. Map normalized event-type → ES event-FQN.
|
|
126
|
-
// ---------------------------------------------------------------
|
|
127
|
-
const esEventType = NORMALIZED_TO_ES_EVENT[payload.type];
|
|
128
|
-
if (!esEventType) {
|
|
129
|
-
// Schema-validation oben sollte das schon fangen, aber defensive
|
|
130
|
-
// gegen drift im SubscriptionEventTypes-enum vs NORMALIZED-Map.
|
|
131
|
-
throw new Error(`subscription-foundation: no ES event-type mapping for "${payload.type}"`);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ---------------------------------------------------------------
|
|
135
|
-
// 3. Encrypt the two provider-subject PII fields before they touch
|
|
136
|
-
// storage — this is the ONLY write path onto the subscription
|
|
137
|
-
// stream, so encrypting here covers both the event-log payload AND
|
|
138
|
-
// (via projection.ts copying the event fields as-is) the
|
|
139
|
-
// read_subscriptions row with a single call. The subject is the
|
|
140
|
-
// TENANT (tenantOwned) — tenant-destroy's subject-keys stage
|
|
141
|
-
// (eraseSubjectKeys) erases exactly this key, so both copies become
|
|
142
|
-
// genuinely unreadable (#800) once that stage runs, not just
|
|
143
|
-
// "encrypted at rest". No adapter configured = engine off (fields
|
|
144
|
-
// stay plaintext, pre-#724-phase-C behavior) — mirrors how the
|
|
145
|
-
// event-store-executor treats an absent piiKms().
|
|
146
|
-
// ---------------------------------------------------------------
|
|
147
|
-
const piiKms = configuredPiiSubjectKms();
|
|
148
|
-
const encryptedFields = piiKms
|
|
149
|
-
? await encryptPiiFieldValues(
|
|
150
|
-
{
|
|
151
|
-
tenantId,
|
|
152
|
-
providerCustomerId: payload.providerCustomerId,
|
|
153
|
-
providerSubscriptionId: payload.providerSubscriptionId,
|
|
154
|
-
},
|
|
155
|
-
subscriptionEntity,
|
|
156
|
-
SUBSCRIPTION_PII_FIELDS,
|
|
157
|
-
piiKms,
|
|
158
|
-
{ requestId: `billing-foundation:process-event:${payload.providerEventId}`, tenantId },
|
|
159
|
-
{ tenantId, entityName: SUBSCRIPTION_AGGREGATE_TYPE },
|
|
160
|
-
)
|
|
161
|
-
: {
|
|
162
|
-
providerCustomerId: payload.providerCustomerId,
|
|
163
|
-
providerSubscriptionId: payload.providerSubscriptionId,
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
// ---------------------------------------------------------------
|
|
167
|
-
// 4. Append event auf den subscription-stream. Inline-projection
|
|
168
|
-
// materialisiert die read_subscriptions-row in derselben TX.
|
|
169
|
-
// ---------------------------------------------------------------
|
|
170
|
-
const eventPayload: SubscriptionEventPayload = {
|
|
171
|
-
providerName: payload.providerName,
|
|
172
|
-
providerCustomerId: encryptedFields["providerCustomerId"] as string,
|
|
173
|
-
providerSubscriptionId: encryptedFields["providerSubscriptionId"] as string,
|
|
174
|
-
status: payload.status,
|
|
175
|
-
tier: payload.tier,
|
|
176
|
-
currentPeriodEndIso: payload.currentPeriodEndIso,
|
|
177
|
-
...(payload.cancelAtIso !== undefined && { cancelAtIso: payload.cancelAtIso }),
|
|
178
|
-
};
|
|
179
|
-
const headers: SubscriptionEventHeaders = {
|
|
180
|
-
providerEventId: payload.providerEventId,
|
|
181
|
-
providerName: payload.providerName,
|
|
182
|
-
rawPayload: payload.rawPayload,
|
|
183
|
-
};
|
|
184
|
-
await ctx.unsafeAppendEvent({
|
|
185
|
-
aggregateId: aggId,
|
|
186
|
-
aggregateType: SUBSCRIPTION_AGGREGATE_TYPE,
|
|
187
|
-
type: esEventType,
|
|
188
|
-
payload: eventPayload,
|
|
189
|
-
headers,
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
return {
|
|
193
|
-
isSuccess: true as const,
|
|
194
|
-
data: {
|
|
195
|
-
duplicate: false as const,
|
|
196
|
-
subscriptionAggregateId: aggId,
|
|
197
|
-
},
|
|
198
|
-
};
|
|
203
|
+
const result = await appendSubscriptionEvent(ctx, event.user.tenantId, payload);
|
|
204
|
+
return { isSuccess: true as const, data: result };
|
|
199
205
|
},
|
|
200
206
|
};
|
|
@@ -57,6 +57,13 @@ export function createSwitchPlanHandler(
|
|
|
57
57
|
"tenant has no switchable subscription; use billing-foundation:write:start-plan-checkout to start one",
|
|
58
58
|
});
|
|
59
59
|
}
|
|
60
|
+
if (sub.cancelAt !== null) {
|
|
61
|
+
throw new ConflictError({
|
|
62
|
+
i18nKey: "billing-foundation.errors.cancellationScheduled",
|
|
63
|
+
message:
|
|
64
|
+
"subscription has a scheduled cancellation; reactivate it via billing-foundation:write:create-portal-session before switching plans",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
60
67
|
if (payload.tier === sub.tier) {
|
|
61
68
|
throw new ConflictError({
|
|
62
69
|
i18nKey: "billing-foundation.errors.alreadyOnPlan",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// sync-subscription — backfill handler for the `sync-subscriptions` job
|
|
2
|
+
// (registered in feature.ts). Pulls the live provider-side state of the
|
|
3
|
+
// tenant's subscription and appends it as a `subscription.updated` (or
|
|
4
|
+
// `subscription.canceled`, when the snapshot's own status is terminal)
|
|
5
|
+
// event when it has drifted from `read_subscriptions` — catches
|
|
6
|
+
// provider-side changes (e.g. a `cancel_at` set on the provider's own
|
|
7
|
+
// dashboard, or a cancellation) that never reached us as a webhook.
|
|
8
|
+
//
|
|
9
|
+
// SYSTEM_ROLE, not just "SystemAdmin": the `sync-subscriptions` job's own
|
|
10
|
+
// systemUser only carries `roles: ["system"]` (createSystemUser(tenantId),
|
|
11
|
+
// no extra roles — see job-runner.ts), unlike the webhook path's
|
|
12
|
+
// dispatchSystemWrite which adds "SystemAdmin". Same access shape as
|
|
13
|
+
// tenant/handlers/memberships.query.ts and active-tenant-ids.query.ts for
|
|
14
|
+
// the same reason. A human SystemAdmin can still dispatch this directly.
|
|
15
|
+
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { SYSTEM_ROLE, type WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
// Aliased — an un-aliased `Temporal` would shadow the ambient global
|
|
19
|
+
// `Temporal` TYPE `SubscriptionView.currentPeriodEnd`/`.cancelAt` resolve
|
|
20
|
+
// against, same reasoning as constants.ts's own import.
|
|
21
|
+
import { Temporal as TemporalPolyfill } from "temporal-polyfill";
|
|
22
|
+
import * as z from "zod";
|
|
23
|
+
import { findProviderPlugin } from "../checkout-core";
|
|
24
|
+
import { isTerminalSubscriptionStatus, SubscriptionEventTypes } from "../constants";
|
|
25
|
+
import { getSubscriptionForTenant } from "../get-subscription-for-tenant";
|
|
26
|
+
import { appendSubscriptionEvent } from "./process-event.write";
|
|
27
|
+
|
|
28
|
+
export const syncSubscriptionSchema = z.object({}).strict();
|
|
29
|
+
|
|
30
|
+
export type SyncSubscriptionSkipReason =
|
|
31
|
+
| "no_live_subscription"
|
|
32
|
+
| "provider_cannot_retrieve"
|
|
33
|
+
| "not_found"
|
|
34
|
+
| "unchanged";
|
|
35
|
+
|
|
36
|
+
export type SyncSubscriptionResult =
|
|
37
|
+
| { readonly synced: true }
|
|
38
|
+
| { readonly synced: false; readonly reason: SyncSubscriptionSkipReason };
|
|
39
|
+
|
|
40
|
+
// Parses both sides fresh via the polyfill before comparing — tolerant of a
|
|
41
|
+
// provider snapshot's ISO string using a different (but equivalent)
|
|
42
|
+
// representation than `Temporal.Instant#toString()`'s canonical form
|
|
43
|
+
// (offset spelling, sub-second precision, ...), unlike a plain `===` on the
|
|
44
|
+
// raw strings.
|
|
45
|
+
function isoInstantsEqual(a: string, b: string): boolean {
|
|
46
|
+
return (
|
|
47
|
+
TemporalPolyfill.Instant.compare(
|
|
48
|
+
TemporalPolyfill.Instant.from(a),
|
|
49
|
+
TemporalPolyfill.Instant.from(b),
|
|
50
|
+
) === 0
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Deterministic per (snapshot-content, current stream head) — not per-call.
|
|
55
|
+
// Folding in `lastChangedAt` (not just the snapshot's own fields) closes a
|
|
56
|
+
// gap a content-only hash has: dashboard-cancel → sync(A) → dashboard-
|
|
57
|
+
// reactivate → sync(B) → dashboard-cancel-again → sync would hash back to
|
|
58
|
+
// A's already-seen providerEventId and get silently treated as a duplicate,
|
|
59
|
+
// leaving the row stuck on B even though the provider is on A again.
|
|
60
|
+
// `lastChangedAt` advances on every applied event, so the third sync's hash
|
|
61
|
+
// differs from the first's even though the snapshot content matches.
|
|
62
|
+
function syncProviderEventId(
|
|
63
|
+
snapshot: {
|
|
64
|
+
readonly providerSubscriptionId: string;
|
|
65
|
+
readonly status: string;
|
|
66
|
+
readonly tier: string;
|
|
67
|
+
readonly currentPeriodEnd: string;
|
|
68
|
+
readonly cancelAt: string | null;
|
|
69
|
+
},
|
|
70
|
+
lastChangedAt: string,
|
|
71
|
+
): string {
|
|
72
|
+
const hashInput = [
|
|
73
|
+
snapshot.providerSubscriptionId,
|
|
74
|
+
snapshot.status,
|
|
75
|
+
snapshot.tier,
|
|
76
|
+
snapshot.currentPeriodEnd,
|
|
77
|
+
snapshot.cancelAt ?? "",
|
|
78
|
+
lastChangedAt,
|
|
79
|
+
].join("|");
|
|
80
|
+
return `sync:${createHash("sha256").update(hashInput).digest("hex")}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const syncSubscriptionHandler: WriteHandlerDef = {
|
|
84
|
+
name: "sync-subscription",
|
|
85
|
+
agent: { expose: false },
|
|
86
|
+
schema: syncSubscriptionSchema,
|
|
87
|
+
access: { roles: [SYSTEM_ROLE, "SystemAdmin"] },
|
|
88
|
+
handler: async (event, ctx) => {
|
|
89
|
+
const tenantId = event.user.tenantId;
|
|
90
|
+
const sub = await getSubscriptionForTenant(ctx, tenantId);
|
|
91
|
+
if (!sub || isTerminalSubscriptionStatus(sub.status)) {
|
|
92
|
+
return {
|
|
93
|
+
isSuccess: true as const,
|
|
94
|
+
data: { synced: false, reason: "no_live_subscription" } satisfies SyncSubscriptionResult,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Non-throwing — a provider deregistered since the subscription was
|
|
99
|
+
// created is the same "can't reconcile right now" outcome as a plugin
|
|
100
|
+
// that never implemented retrieveSubscription, not a hard failure.
|
|
101
|
+
const found = findProviderPlugin(ctx, sub.providerName);
|
|
102
|
+
if (!found?.plugin.retrieveSubscription) {
|
|
103
|
+
return {
|
|
104
|
+
isSuccess: true as const,
|
|
105
|
+
data: {
|
|
106
|
+
synced: false,
|
|
107
|
+
reason: "provider_cannot_retrieve",
|
|
108
|
+
} satisfies SyncSubscriptionResult,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const snapshot = await found.plugin.retrieveSubscription(ctx, sub.providerSubscriptionId);
|
|
113
|
+
if (!snapshot) {
|
|
114
|
+
return {
|
|
115
|
+
isSuccess: true as const,
|
|
116
|
+
data: { synced: false, reason: "not_found" } satisfies SyncSubscriptionResult,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const cancelAtUnchanged =
|
|
121
|
+
snapshot.cancelAt === null
|
|
122
|
+
? sub.cancelAt === null
|
|
123
|
+
: sub.cancelAt !== null && isoInstantsEqual(snapshot.cancelAt, sub.cancelAt.toString());
|
|
124
|
+
const unchanged =
|
|
125
|
+
snapshot.status === sub.status &&
|
|
126
|
+
snapshot.tier === sub.tier &&
|
|
127
|
+
isoInstantsEqual(snapshot.currentPeriodEnd, sub.currentPeriodEnd.toString()) &&
|
|
128
|
+
cancelAtUnchanged;
|
|
129
|
+
if (unchanged) {
|
|
130
|
+
return {
|
|
131
|
+
isSuccess: true as const,
|
|
132
|
+
data: { synced: false, reason: "unchanged" } satisfies SyncSubscriptionResult,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const appendResult = await appendSubscriptionEvent(ctx, tenantId, {
|
|
137
|
+
providerEventId: syncProviderEventId(snapshot, sub.lastChangedAt.toString()),
|
|
138
|
+
providerName: sub.providerName,
|
|
139
|
+
// A snapshot that already landed on a terminal status (canceled on the
|
|
140
|
+
// provider's own dashboard, never arrived as a webhook) must append as
|
|
141
|
+
// a real `canceled` event, not `updated` — anything downstream that
|
|
142
|
+
// reads the event-log's own type (not just the projection's status
|
|
143
|
+
// column, which both apply-functions patch the same way) needs the
|
|
144
|
+
// accurate event-type to react to a cancellation.
|
|
145
|
+
type: isTerminalSubscriptionStatus(snapshot.status)
|
|
146
|
+
? SubscriptionEventTypes.canceled
|
|
147
|
+
: SubscriptionEventTypes.updated,
|
|
148
|
+
providerCustomerId: snapshot.providerCustomerId,
|
|
149
|
+
providerSubscriptionId: snapshot.providerSubscriptionId,
|
|
150
|
+
status: snapshot.status,
|
|
151
|
+
tier: snapshot.tier,
|
|
152
|
+
currentPeriodEndIso: snapshot.currentPeriodEnd,
|
|
153
|
+
cancelAtIso: snapshot.cancelAt,
|
|
154
|
+
rawPayload: snapshot.rawPayload,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
isSuccess: true as const,
|
|
159
|
+
data: (appendResult.duplicate
|
|
160
|
+
? { synced: false, reason: "unchanged" }
|
|
161
|
+
: { synced: true }) satisfies SyncSubscriptionResult,
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
};
|
|
@@ -24,9 +24,15 @@ export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>>
|
|
|
24
24
|
"billing-foundation.plans.cancelScheduled": {
|
|
25
25
|
en: "Your subscription is scheduled to end on {date}.",
|
|
26
26
|
},
|
|
27
|
+
"billing-foundation.plans.switchRequiresReactivation": {
|
|
28
|
+
en: "Reactivate your subscription before switching plans.",
|
|
29
|
+
},
|
|
27
30
|
"billing-foundation.plans.paymentPending": {
|
|
28
31
|
en: "Payment is still being completed.",
|
|
29
32
|
},
|
|
33
|
+
"billing-foundation.plans.pastDue": {
|
|
34
|
+
en: "Your last payment failed. Update your payment method to avoid interruption.",
|
|
35
|
+
},
|
|
30
36
|
"billing-foundation.plans.priceUnavailable": { en: "Price not available" },
|
|
31
37
|
"billing-foundation.plans.purchaseNotAllowed": {
|
|
32
38
|
en: "Only administrators can change the plan.",
|
|
@@ -50,6 +56,9 @@ export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>>
|
|
|
50
56
|
"billing-foundation.errors.noActiveSubscription": {
|
|
51
57
|
en: "This tenant has no active subscription to switch.",
|
|
52
58
|
},
|
|
59
|
+
"billing-foundation.errors.cancellationScheduled": {
|
|
60
|
+
en: "This subscription is scheduled to cancel. Reactivate it before switching plans.",
|
|
61
|
+
},
|
|
53
62
|
"billing-foundation.errors.alreadyOnPlan": { en: "This tenant is already on that plan." },
|
|
54
63
|
"billing-foundation.errors.planSwitchNotSupported": {
|
|
55
64
|
en: "This provider does not support switching plans.",
|
|
@@ -103,8 +103,27 @@ type ActiveSubscription = {
|
|
|
103
103
|
readonly status: string;
|
|
104
104
|
readonly tier: string;
|
|
105
105
|
readonly terminal: boolean;
|
|
106
|
+
readonly cancelAt: string | null;
|
|
106
107
|
};
|
|
107
108
|
|
|
109
|
+
/** Whether an existing subscription can move to `tier` via the provider's
|
|
110
|
+
* plan-switch session — pulled out of resolvePlanAction so that function
|
|
111
|
+
* stays under the guard's complexity budget. A pending cancellation
|
|
112
|
+
* (`cancelAt` set) must be reactivated via create-portal-session before a
|
|
113
|
+
* switch is allowed — same gate `switch-plan.write.ts` enforces server-side. */
|
|
114
|
+
function canSwitchToTier(
|
|
115
|
+
tier: string,
|
|
116
|
+
subscription: ActiveSubscription,
|
|
117
|
+
plugin: SubscriptionProviderPlugin | null,
|
|
118
|
+
): boolean {
|
|
119
|
+
return (
|
|
120
|
+
isSwitchableSubscriptionStatus(subscription.status) &&
|
|
121
|
+
tier !== subscription.tier &&
|
|
122
|
+
subscription.cancelAt === null &&
|
|
123
|
+
plugin?.createPlanSwitchSession !== undefined
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
108
127
|
/** One plan-row's action — pulled out of `buildBillingPlans`' map callback so
|
|
109
128
|
* that function's own complexity stays under the guard's budget. paymentPending
|
|
110
129
|
* is checked before the general unavailable-fallback: the tier a not-yet-
|
|
@@ -133,11 +152,9 @@ function resolvePlanAction(
|
|
|
133
152
|
|
|
134
153
|
if (!subscription || subscription.terminal) return BillingPlanActions.checkout;
|
|
135
154
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
plugin?.createPlanSwitchSession;
|
|
140
|
-
return canSwitch ? BillingPlanActions.switch : BillingPlanActions.unavailable;
|
|
155
|
+
return canSwitchToTier(tier, subscription, plugin)
|
|
156
|
+
? BillingPlanActions.switch
|
|
157
|
+
: BillingPlanActions.unavailable;
|
|
141
158
|
}
|
|
142
159
|
|
|
143
160
|
/** `plugin === null` — no provider is registered/resolvable for this
|
|
@@ -281,6 +281,33 @@ export type SubscriptionProviderPlugin = {
|
|
|
281
281
|
readonly returnUrl: string;
|
|
282
282
|
},
|
|
283
283
|
) => Promise<{ readonly url: string }>;
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Fetches the live provider-side state of one subscription, for the
|
|
287
|
+
* `sync-subscriptions` backfill job — catches drift (e.g. a `cancel_at`
|
|
288
|
+
* set on the provider's own dashboard) that never reached us as a
|
|
289
|
+
* webhook. Missing → sync-subscription reports `provider_cannot_retrieve`
|
|
290
|
+
* instead of throwing. Null → the implementation could not resolve a
|
|
291
|
+
* snapshot for this id — the subscription no longer exists at the
|
|
292
|
+
* provider (deleted account, ...), or its state no longer maps onto this
|
|
293
|
+
* app's config (e.g. Stripe's price-to-tier — same silent-drop the
|
|
294
|
+
* webhook path already applies to an unmapped price).
|
|
295
|
+
*/
|
|
296
|
+
readonly retrieveSubscription?: (
|
|
297
|
+
ctx: HandlerContext,
|
|
298
|
+
providerSubscriptionId: string,
|
|
299
|
+
) => Promise<ProviderSubscriptionSnapshot | null>;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
export type ProviderSubscriptionSnapshot = {
|
|
303
|
+
readonly providerCustomerId: string;
|
|
304
|
+
readonly providerSubscriptionId: string;
|
|
305
|
+
readonly status: SubscriptionStatus;
|
|
306
|
+
readonly tier: string;
|
|
307
|
+
/** ISO instant string, same shape as `SubscriptionEventPayload.currentPeriodEndIso`. */
|
|
308
|
+
readonly currentPeriodEnd: string;
|
|
309
|
+
readonly cancelAt: string | null;
|
|
310
|
+
readonly rawPayload: string;
|
|
284
311
|
};
|
|
285
312
|
|
|
286
313
|
// =============================================================================
|
|
@@ -351,6 +351,34 @@ describe("BillingPlansPanel", () => {
|
|
|
351
351
|
expect(screen.queryByTestId("billing-plan-card-pro-cta")).toBeNull();
|
|
352
352
|
});
|
|
353
353
|
|
|
354
|
+
test("a scheduled cancellation shows cancelScheduled and switchRequiresReactivation together", () => {
|
|
355
|
+
queryState = {
|
|
356
|
+
data: result({
|
|
357
|
+
subscription: subscription({ cancelAt: "2024-03-01T00:00:00Z" }),
|
|
358
|
+
plans: [plan({ isCurrent: true, action: BillingPlanActions.current })],
|
|
359
|
+
}),
|
|
360
|
+
loading: false,
|
|
361
|
+
error: null,
|
|
362
|
+
};
|
|
363
|
+
renderPanel();
|
|
364
|
+
const banner = screen.getByTestId("billing-plans-panel-cancel-scheduled");
|
|
365
|
+
expect(banner.textContent).toContain("billing-foundation.plans.cancelScheduled");
|
|
366
|
+
expect(banner.textContent).toContain("billing-foundation.plans.switchRequiresReactivation");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("past_due status renders the past-due banner", () => {
|
|
370
|
+
queryState = {
|
|
371
|
+
data: result({
|
|
372
|
+
subscription: subscription({ status: "past_due" }),
|
|
373
|
+
plans: [plan({ isCurrent: true, action: BillingPlanActions.current })],
|
|
374
|
+
}),
|
|
375
|
+
loading: false,
|
|
376
|
+
error: null,
|
|
377
|
+
};
|
|
378
|
+
renderPanel();
|
|
379
|
+
expect(screen.getByTestId("billing-plans-panel-past-due")).toBeTruthy();
|
|
380
|
+
});
|
|
381
|
+
|
|
354
382
|
test("price=null renders the price-unavailable fallback and a disabled cta", () => {
|
|
355
383
|
queryState = {
|
|
356
384
|
data: result({ plans: [plan({ price: null, action: BillingPlanActions.unavailable })] }),
|