@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.
Files changed (42) hide show
  1. package/package.json +9 -9
  2. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +27 -0
  3. package/src/billing-foundation/__tests__/checkout-core.test.ts +35 -0
  4. package/src/billing-foundation/__tests__/sync-subscription.integration.test.ts +469 -0
  5. package/src/billing-foundation/changes.json +14 -0
  6. package/src/billing-foundation/checkout-core.ts +8 -4
  7. package/src/billing-foundation/constants.ts +6 -0
  8. package/src/billing-foundation/feature.ts +37 -2
  9. package/src/billing-foundation/handlers/process-event.write.ts +112 -106
  10. package/src/billing-foundation/handlers/switch-plan.write.ts +7 -0
  11. package/src/billing-foundation/handlers/sync-subscription.write.ts +164 -0
  12. package/src/billing-foundation/i18n.ts +9 -0
  13. package/src/billing-foundation/index.ts +1 -0
  14. package/src/billing-foundation/plan-catalog.ts +22 -5
  15. package/src/billing-foundation/types.ts +27 -0
  16. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +28 -0
  17. package/src/billing-foundation/web/billing-plans-panel.tsx +8 -1
  18. package/src/channel-email/__tests__/email-channel.test.ts +92 -0
  19. package/src/channel-email/changes.json +9 -1
  20. package/src/channel-email/email-channel.ts +31 -6
  21. package/src/delivery/__tests__/delivery.integration.test.ts +101 -26
  22. package/src/delivery/changes.json +7 -0
  23. package/src/delivery/feature.ts +1 -1
  24. package/src/delivery/handlers/unsubscribe-address.write.ts +1 -1
  25. package/src/delivery/handlers/unsubscribe-user.write.ts +1 -1
  26. package/src/delivery/index.ts +2 -1
  27. package/src/delivery/public-names.ts +1 -1
  28. package/src/delivery/unsubscribe.ts +167 -89
  29. package/src/step-dispatcher/__tests__/feature.boot.test.ts +9 -2
  30. package/src/step-dispatcher/__tests__/webhook-runner.test.ts +187 -35
  31. package/src/step-dispatcher/changes.json +7 -0
  32. package/src/step-dispatcher/feature.ts +48 -15
  33. package/src/step-dispatcher/index.ts +3 -1
  34. package/src/step-dispatcher/webhook-runner.ts +59 -19
  35. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +117 -0
  36. package/src/subscription-stripe/feature.ts +5 -1
  37. package/src/subscription-stripe/plugin-methods.ts +53 -0
  38. package/src/subscription-stripe/verify-webhook.ts +52 -28
  39. package/src/tenant-handover/__tests__/claim.integration.test.ts +62 -4
  40. package/src/tenant-handover/changes.json +7 -0
  41. package/src/tenant-handover/handlers/claim.write.ts +1 -0
  42. package/src/tenant-handover/move-entity-graph.ts +30 -44
@@ -8,12 +8,13 @@
8
8
  // the audit trail lives in the event log only — no separate status table.
9
9
 
10
10
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { SYSTEM_USER_ID } from "@cosmicdrift/kumiko-types/identifiers";
11
12
  import * as z from "zod";
12
- import { type MailSpec, performMailDispatch } from "./mail-runner";
13
+ import { mailSpecSchema, performMailDispatch } from "./mail-runner";
13
14
  import {
14
15
  performWebhookDispatch,
15
16
  WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
16
- type WebhookSpec,
17
+ webhookSpecSchema,
17
18
  } from "./webhook-runner";
18
19
 
19
20
  export const stepDispatcherEnvSchema = z.object({
@@ -30,21 +31,33 @@ export const STEP_DISPATCH_REQUESTED_TYPE = "kumiko:system:step.dispatch-request
30
31
  export const STEP_DISPATCHED_TYPE = "kumiko:system:step.dispatched";
31
32
  export const STEP_DISPATCH_FAILED_TYPE = "kumiko:system:step.dispatch-failed";
32
33
 
33
- type DispatchRequestedPayload =
34
- | {
35
- readonly stepKind: "webhook.send";
36
- readonly spec: WebhookSpec;
37
- readonly retry?: { readonly times: number; readonly backoff: "exponential" | "linear" };
38
- }
39
- | {
40
- readonly stepKind: "mail.send";
41
- readonly spec: MailSpec;
42
- };
34
+ // Runtime-validated instead of cast — `event.payload` is `unknown` at the
35
+ // MSP-apply boundary (unsafeAppendEvent), so a payload in an older or
36
+ // foreign shape must end as dispatch-failed, never reach performWebhookDispatch.
37
+ const dispatchRequestedPayloadSchema = z.discriminatedUnion("stepKind", [
38
+ z.object({
39
+ stepKind: z.literal("webhook.send"),
40
+ spec: webhookSpecSchema,
41
+ retry: z.object({ times: z.number(), backoff: z.enum(["exponential", "linear"]) }).optional(),
42
+ }),
43
+ z.object({ stepKind: z.literal("mail.send"), spec: mailSpecSchema }),
44
+ ]);
45
+
46
+ // zod issue messages can echo the invalid value (e.g. a rejected url) back
47
+ // into the tenant-visible dispatch-failed event — keep this generic.
48
+ const INVALID_DISPATCH_PAYLOAD_ERROR = "invalid dispatch payload";
49
+
50
+ const rawStepKindSchema = z.object({ stepKind: z.string() });
51
+
52
+ function rawStepKindOf(payload: unknown): string {
53
+ const parsed = rawStepKindSchema.safeParse(payload);
54
+ return parsed.success ? parsed.data.stepKind : "unknown";
55
+ }
43
56
 
44
57
  export function createStepDispatcherFeature(): FeatureDefinition {
45
58
  return defineFeature("step-dispatcher", (r) => {
46
59
  r.describe(
47
- "Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`.",
60
+ "Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`.",
48
61
  );
49
62
  r.uiHints({
50
63
  displayLabel: "Step Dispatcher · Deferred Side-Effects",
@@ -52,15 +65,35 @@ export function createStepDispatcherFeature(): FeatureDefinition {
52
65
  recommended: false,
53
66
  });
54
67
  r.envSchema(stepDispatcherEnvSchema);
68
+ r.requires("secrets");
55
69
 
56
70
  r.multiStreamProjection({
57
71
  name: "step-dispatcher",
58
72
  apply: {
59
73
  [STEP_DISPATCH_REQUESTED_TYPE]: async (event, _tx, ctx) => {
60
- const payload = event.payload as DispatchRequestedPayload;
74
+ const parsed = dispatchRequestedPayloadSchema.safeParse(event.payload);
75
+ if (!parsed.success) {
76
+ await ctx.unsafeAppendEvent({
77
+ aggregateId: event.aggregateId,
78
+ aggregateType: STEP_DISPATCH_AGGREGATE_TYPE,
79
+ type: STEP_DISPATCH_FAILED_TYPE,
80
+ payload: {
81
+ stepKind: rawStepKindOf(event.payload),
82
+ error: INVALID_DISPATCH_PAYLOAD_ERROR,
83
+ attempt: 1,
84
+ },
85
+ });
86
+ // skip: invalid payload already recorded via step.dispatch-failed above, nothing left to dispatch
87
+ return;
88
+ }
89
+ const payload = parsed.data;
61
90
  const result =
62
91
  payload.stepKind === "webhook.send"
63
- ? await performWebhookDispatch(payload.spec)
92
+ ? await performWebhookDispatch(payload.spec, {
93
+ tenantId: event.tenantId,
94
+ userId: event.metadata.userId || SYSTEM_USER_ID,
95
+ secrets: ctx.secrets,
96
+ })
64
97
  : await performMailDispatch(payload.spec);
65
98
  if (result.ok) {
66
99
  await ctx.unsafeAppendEvent({
@@ -15,9 +15,11 @@ export {
15
15
  readAllowedPrivateWebhookHostsFromEnv,
16
16
  setWebhookFetch,
17
17
  setWebhookHostLookup,
18
- setWebhookSecretResolver,
19
18
  WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
19
+ WEBHOOK_AUTH_SECRET_KEY_PREFIX,
20
+ type WebhookDispatchDeps,
20
21
  type WebhookDispatchResult,
21
22
  type WebhookSpec,
23
+ webhookAuthSecretKey,
22
24
  webhookSpecSchema,
23
25
  } from "./webhook-runner";
@@ -14,6 +14,11 @@
14
14
  // tenant can never grant themselves the bypass. Own key, not the mail
15
15
  // features' — step-dispatcher has no dependency relation to mail-transport-
16
16
  // smtp/inbound-provider-imap and shouldn't require mounting them.
17
+ //
18
+ // `auth.secret` resolves through the secrets feature under the tenant-owned
19
+ // namespace `step-dispatcher:webhook-auth.<secret>` — the target URL is
20
+ // tenant/request-controlled, so a webhook can never read a platform-wide or
21
+ // another tenant's secret.
17
22
 
18
23
  import type { lookup } from "node:dns/promises";
19
24
  import {
@@ -23,6 +28,8 @@ import {
23
28
  resolvePublicHostname,
24
29
  } from "@cosmicdrift/kumiko-framework/http";
25
30
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
31
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
32
+ import type { TenantId } from "@cosmicdrift/kumiko-types/identifiers";
26
33
  import * as z from "zod";
27
34
 
28
35
  const log = createFallbackLogger("step-dispatcher");
@@ -51,6 +58,25 @@ export function setWebhookHostLookup(fn: typeof lookup | undefined): void {
51
58
  webhookHostLookup = fn;
52
59
  }
53
60
 
61
+ // Tenant-owned namespace every webhook auth secret lives under in the
62
+ // secrets feature. Applied at resolution time, never at step-build time,
63
+ // so `auth.secret` stays a short, human-picked name (e.g. "smtp.password")
64
+ // while the stored key stays collision-free with every other feature's
65
+ // secrets.
66
+ export const WEBHOOK_AUTH_SECRET_KEY_PREFIX = "step-dispatcher:webhook-auth.";
67
+
68
+ export function webhookAuthSecretKey(name: string): string {
69
+ return `${WEBHOOK_AUTH_SECRET_KEY_PREFIX}${name}`;
70
+ }
71
+
72
+ const WEBHOOK_AUTH_SECRET_NAME_MAX_LENGTH = 100 - WEBHOOK_AUTH_SECRET_KEY_PREFIX.length;
73
+
74
+ const webhookAuthSecretNameSchema = z
75
+ .string()
76
+ .min(1)
77
+ .max(WEBHOOK_AUTH_SECRET_NAME_MAX_LENGTH)
78
+ .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/);
79
+
54
80
  export const webhookSpecSchema = z.object({
55
81
  url: z.string(),
56
82
  method: z.enum(["POST", "PUT", "PATCH"]),
@@ -58,8 +84,12 @@ export const webhookSpecSchema = z.object({
58
84
  body: z.unknown().optional(),
59
85
  auth: z
60
86
  .union([
61
- z.object({ kind: z.literal("bearer"), secretRef: z.string() }),
62
- z.object({ kind: z.literal("header"), name: z.string(), secretRef: z.string() }),
87
+ z.object({ kind: z.literal("bearer"), secret: webhookAuthSecretNameSchema }),
88
+ z.object({
89
+ kind: z.literal("header"),
90
+ name: z.string(),
91
+ secret: webhookAuthSecretNameSchema,
92
+ }),
63
93
  ])
64
94
  .optional(),
65
95
  });
@@ -70,31 +100,38 @@ export type WebhookDispatchResult =
70
100
  | { readonly ok: true; readonly status: number }
71
101
  | { readonly ok: false; readonly error: string };
72
102
 
73
- // Resolves a secretRef via the test-injectable secret-store. Default
74
- // implementation reads from process.env at the prefix WEBHOOK_SECRET_.
75
- // Tests pass a custom resolver via setWebhookSecretResolver.
76
- let secretResolver: (ref: string) => string | undefined = (ref) =>
77
- process.env[`WEBHOOK_SECRET_${ref}`];
78
-
79
- export function setWebhookSecretResolver(fn: (ref: string) => string | undefined): void {
80
- secretResolver = fn;
81
- }
82
-
83
103
  let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis);
84
104
 
85
105
  export function setWebhookFetch(fn: typeof fetch): void {
86
106
  fetchImpl = fn;
87
107
  }
88
108
 
89
- function buildWebhookHeaders(
109
+ export type WebhookDispatchDeps = {
110
+ readonly tenantId: TenantId;
111
+ readonly userId: string;
112
+ readonly secrets: SecretsContext | undefined;
113
+ };
114
+
115
+ // Never includes the secret name or value — spec.auth.secret is a
116
+ // tenant-chosen name, but the error still reaches the tenant via the
117
+ // dispatch-failed event, so it stays generic.
118
+ const WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR = "webhook auth secret is not available";
119
+
120
+ async function buildWebhookHeaders(
90
121
  spec: WebhookSpec,
91
- ): { ok: true; headers: Record<string, string> } | { ok: false; error: string } {
122
+ deps: WebhookDispatchDeps,
123
+ ): Promise<{ ok: true; headers: Record<string, string> } | { ok: false; error: string }> {
92
124
  const headers: Record<string, string> = { "content-type": "application/json", ...spec.headers };
93
125
  if (!spec.auth) return { ok: true, headers };
94
- const secret = secretResolver(spec.auth.secretRef);
95
- if (!secret) {
96
- return { ok: false, error: `secret "${spec.auth.secretRef}" not configured` };
126
+ if (!deps.secrets) return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
127
+ const revealed = await deps.secrets.get(deps.tenantId, webhookAuthSecretKey(spec.auth.secret), {
128
+ userId: deps.userId,
129
+ handlerName: "step-dispatcher:webhook.send",
130
+ });
131
+ if (!revealed) {
132
+ return { ok: false, error: WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR };
97
133
  }
134
+ const secret = revealed.reveal();
98
135
  if (spec.auth.kind === "bearer") {
99
136
  headers["authorization"] = `Bearer ${secret}`;
100
137
  } else {
@@ -128,7 +165,10 @@ async function resolveWebhookFetchTarget(
128
165
  }
129
166
  }
130
167
 
131
- export async function performWebhookDispatch(spec: WebhookSpec): Promise<WebhookDispatchResult> {
168
+ export async function performWebhookDispatch(
169
+ spec: WebhookSpec,
170
+ deps: WebhookDispatchDeps,
171
+ ): Promise<WebhookDispatchResult> {
132
172
  // Host-egress guard at the primitive boundary: only http(s), the target
133
173
  // host must resolve to a public address (unless operator-allowlisted),
134
174
  // and redirects are never followed — a 3xx could point at an internal/
@@ -145,7 +185,7 @@ export async function performWebhookDispatch(spec: WebhookSpec): Promise<Webhook
145
185
  return { ok: false, error: `unsupported url scheme "${url.protocol}"` };
146
186
  }
147
187
 
148
- const headers = buildWebhookHeaders(spec);
188
+ const headers = await buildWebhookHeaders(spec, deps);
149
189
  if (!headers.ok) return headers;
150
190
 
151
191
  const target = await resolveWebhookFetchTarget(spec.url, url, headers.headers);
@@ -26,6 +26,7 @@ import {
26
26
  createStripePortalSession,
27
27
  createStripePriceCache,
28
28
  createStripeRetrievePrices,
29
+ createStripeRetrieveSubscription,
29
30
  } from "../plugin-methods";
30
31
  import type { StripeCtxRuntime } from "../runtime";
31
32
 
@@ -830,3 +831,119 @@ describe("createStripePlanSwitchSession", () => {
830
831
  expect(retrieveMock).not.toHaveBeenCalled();
831
832
  });
832
833
  });
834
+
835
+ // =============================================================================
836
+ // retrieveSubscription — sync-subscriptions backfill, same status/tier/
837
+ // period-end/cancel_at mapping as verify-webhook.ts's mapStripeSubscriptionState.
838
+ // =============================================================================
839
+
840
+ const PRICE_TO_TIER = { price_retrieve_pro: "pro" };
841
+
842
+ function stripeSubscriptionForRetrieve(overrides: Record<string, unknown> = {}) {
843
+ return stripeSubscription({
844
+ status: "active",
845
+ items: {
846
+ data: [
847
+ {
848
+ id: "si_001",
849
+ price: stripePrice({ id: "price_retrieve_pro" }),
850
+ quantity: 1,
851
+ current_period_end: 1_800_000_000,
852
+ },
853
+ ],
854
+ },
855
+ cancel_at: null,
856
+ cancel_at_period_end: false,
857
+ ...overrides,
858
+ });
859
+ }
860
+
861
+ describe("createStripeRetrieveSubscription", () => {
862
+ test("maps a live subscription to a ProviderSubscriptionSnapshot", async () => {
863
+ const stripe = buildStripe();
864
+ spyOn(stripe.subscriptions, "retrieve").mockResolvedValue(stripeSubscriptionForRetrieve());
865
+ const retrieve = createStripeRetrieveSubscription(ctxRuntime(stripe), {
866
+ priceToTier: PRICE_TO_TIER,
867
+ });
868
+
869
+ const result = await retrieve(stubCtx, "sub_switch_001");
870
+
871
+ expect(result).toEqual({
872
+ providerCustomerId: "cus_switch_001",
873
+ providerSubscriptionId: "sub_switch_001",
874
+ status: "active",
875
+ tier: "pro",
876
+ currentPeriodEnd: "2027-01-15T08:00:00Z",
877
+ cancelAt: null,
878
+ rawPayload: JSON.stringify(stripeSubscriptionForRetrieve()),
879
+ });
880
+ });
881
+
882
+ test("a resource_missing StripeInvalidRequestError resolves to null instead of throwing", async () => {
883
+ const stripe = buildStripe();
884
+ spyOn(stripe.subscriptions, "retrieve").mockRejectedValue(
885
+ new Stripe.errors.StripeInvalidRequestError({
886
+ message: "No such subscription: 'sub_gone'",
887
+ code: "resource_missing",
888
+ }),
889
+ );
890
+ const retrieve = createStripeRetrieveSubscription(ctxRuntime(stripe), {
891
+ priceToTier: PRICE_TO_TIER,
892
+ });
893
+
894
+ expect(await retrieve(stubCtx, "sub_gone")).toBeNull();
895
+ });
896
+
897
+ test("an unmapped priceId resolves to null (same as verify-webhook's silent-drop)", async () => {
898
+ const stripe = buildStripe();
899
+ spyOn(stripe.subscriptions, "retrieve").mockResolvedValue(
900
+ stripeSubscriptionForRetrieve({
901
+ items: {
902
+ data: [
903
+ {
904
+ id: "si_001",
905
+ price: stripePrice({ id: "price_unmapped" }),
906
+ quantity: 1,
907
+ current_period_end: 1_800_000_000,
908
+ },
909
+ ],
910
+ },
911
+ }),
912
+ );
913
+ const retrieve = createStripeRetrieveSubscription(ctxRuntime(stripe), {
914
+ priceToTier: PRICE_TO_TIER,
915
+ });
916
+
917
+ expect(await retrieve(stubCtx, "sub_switch_001")).toBeNull();
918
+ });
919
+
920
+ test("a non-resource_missing Stripe error rethrows", async () => {
921
+ const stripe = buildStripe();
922
+ spyOn(stripe.subscriptions, "retrieve").mockRejectedValue(
923
+ new Stripe.errors.StripeInvalidRequestError({
924
+ message: "rate limited",
925
+ code: "rate_limit",
926
+ }),
927
+ );
928
+ const retrieve = createStripeRetrieveSubscription(ctxRuntime(stripe), {
929
+ priceToTier: PRICE_TO_TIER,
930
+ });
931
+
932
+ await expect(retrieve(stubCtx, "sub_switch_001")).rejects.toBeInstanceOf(
933
+ Stripe.errors.StripeInvalidRequestError,
934
+ );
935
+ });
936
+
937
+ test("a scheduled cancel_at is mapped through", async () => {
938
+ const stripe = buildStripe();
939
+ spyOn(stripe.subscriptions, "retrieve").mockResolvedValue(
940
+ stripeSubscriptionForRetrieve({ cancel_at: 1_850_000_000 }),
941
+ );
942
+ const retrieve = createStripeRetrieveSubscription(ctxRuntime(stripe), {
943
+ priceToTier: PRICE_TO_TIER,
944
+ });
945
+
946
+ const result = await retrieve(stubCtx, "sub_switch_001");
947
+ expect(result?.cancelAt).toBe("2028-08-16T00:53:20Z");
948
+ });
949
+ });
@@ -53,6 +53,7 @@ import {
53
53
  createStripePortalSession,
54
54
  createStripePriceCache,
55
55
  createStripeRetrievePrices,
56
+ createStripeRetrieveSubscription,
56
57
  } from "./plugin-methods";
57
58
  import { createStripeRuntimes } from "./runtime";
58
59
  import { verifyAndParseStripeWebhook } from "./verify-webhook";
@@ -144,7 +145,7 @@ export function createSubscriptionStripeFeature(
144
145
  validateOneOffPriceIds(options.oneOffPriceIds ?? []);
145
146
  return defineFeature(SUBSCRIPTION_STRIPE_FEATURE, (r) => {
146
147
  r.describe(
147
- 'Stripe payment provider plugin for `billing-foundation`. Reads its Stripe API key + webhook secret from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant) and a `billingLive` **system config** flag — all at runtime, so keys rotate and prod goes live without a redeploy. The `mask` on each key derives the sysadmin settings screen + nav, so no app wires a hand-written config UI. Mount via `createSubscriptionStripeFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret` options are env→secrets bridge fallbacks. The plugin always mounts — `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true, so sk_test_ keys in prod never produce a live checkout. Implements webhook verify, checkout, portal, cancel, `retrievePrices` (10-minute TTL-cached price lookup for the billing-plans catalog) and `createPlanSwitchSession` (auto-provisions a Customer-Portal configuration for switching an existing subscription to another plan tier — each tier needs its own Stripe product), plus `isBillingEnabled` (billingLive + an api-key existence probe, no secret read).',
148
+ 'Stripe payment provider plugin for `billing-foundation`. Reads its Stripe API key + webhook secret from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant) and a `billingLive` **system config** flag — all at runtime, so keys rotate and prod goes live without a redeploy. The `mask` on each key derives the sysadmin settings screen + nav, so no app wires a hand-written config UI. Mount via `createSubscriptionStripeFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret` options are env→secrets bridge fallbacks. The plugin always mounts — `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true, so sk_test_ keys in prod never produce a live checkout. Implements webhook verify, checkout, portal, cancel, `retrievePrices` (10-minute TTL-cached price lookup for the billing-plans catalog), `createPlanSwitchSession` (auto-provisions a Customer-Portal configuration for switching an existing subscription to another plan tier — each tier needs its own Stripe product), `retrieveSubscription` (live provider-side snapshot for the `sync-subscriptions` backfill job, mapped identically to the webhook path), plus `isBillingEnabled` (billingLive + an api-key existence probe, no secret read).',
148
149
  );
149
150
  r.uiHints({
150
151
  displayLabel: "Billing · Stripe",
@@ -234,6 +235,9 @@ export function createSubscriptionStripeFeature(
234
235
  oneOffPriceIds: options.oneOffPriceIds ?? [],
235
236
  isBillingEnabled: runtimes.ctx.isBillingEnabled,
236
237
  retrievePrices: createStripeRetrievePrices(runtimes.ctx, priceCache),
238
+ retrieveSubscription: createStripeRetrieveSubscription(runtimes.ctx, {
239
+ priceToTier: options.priceToTier ?? {},
240
+ }),
237
241
  createPlanSwitchSession: createStripePlanSwitchSession(
238
242
  runtimes.ctx,
239
243
  priceCache,
@@ -18,6 +18,7 @@ import { createHash } from "node:crypto";
18
18
  import {
19
19
  KNOWN_RECURRING_INTERVALS,
20
20
  type ProviderPrice,
21
+ type ProviderSubscriptionSnapshot,
21
22
  type RecurringInterval,
22
23
  type SubscriptionProviderPlugin,
23
24
  } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
@@ -25,6 +26,7 @@ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
25
26
  import { ConflictError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
26
27
  import Stripe from "stripe";
27
28
  import type { StripeCtxRuntime } from "./runtime";
29
+ import { mapStripeSubscriptionState } from "./verify-webhook";
28
30
 
29
31
  // =============================================================================
30
32
  // createCheckoutSession
@@ -127,6 +129,57 @@ export function createStripeCancelSubscription(runtime: StripeCtxRuntime) {
127
129
  };
128
130
  }
129
131
 
132
+ // =============================================================================
133
+ // retrieveSubscription — live provider-side snapshot for the
134
+ // sync-subscriptions backfill job
135
+ // =============================================================================
136
+ //
137
+ // Same status/tier/period-end/cancel_at mapping as verify-webhook.ts, via
138
+ // the shared mapStripeSubscriptionState — the sync path and the webhook path
139
+ // must never disagree on what a given Stripe subscription object means.
140
+
141
+ export type StripeRetrieveSubscriptionOptions = {
142
+ readonly priceToTier: Readonly<Record<string, string>>;
143
+ };
144
+
145
+ function isResourceMissingStripeError(error: unknown): boolean {
146
+ return (
147
+ error instanceof Stripe.errors.StripeInvalidRequestError && error.code === "resource_missing"
148
+ );
149
+ }
150
+
151
+ export function createStripeRetrieveSubscription(
152
+ runtime: StripeCtxRuntime,
153
+ { priceToTier }: StripeRetrieveSubscriptionOptions,
154
+ ) {
155
+ return async (
156
+ ctx: HandlerContext,
157
+ providerSubscriptionId: string,
158
+ ): Promise<ProviderSubscriptionSnapshot | null> => {
159
+ const stripe = await runtime.clientForCtx(ctx);
160
+ let subscription: Stripe.Subscription;
161
+ try {
162
+ subscription = await stripe.subscriptions.retrieve(providerSubscriptionId);
163
+ } catch (error) {
164
+ if (isResourceMissingStripeError(error)) return null;
165
+ throw error;
166
+ }
167
+
168
+ const state = mapStripeSubscriptionState(subscription, priceToTier);
169
+ if (!state) return null;
170
+
171
+ return {
172
+ providerCustomerId:
173
+ typeof subscription.customer === "string"
174
+ ? subscription.customer
175
+ : subscription.customer.id,
176
+ providerSubscriptionId: subscription.id,
177
+ ...state,
178
+ rawPayload: JSON.stringify(subscription),
179
+ };
180
+ };
181
+ }
182
+
130
183
  // =============================================================================
131
184
  // retrievePrices — bulk price lookup for the billing-plans catalog
132
185
  // =============================================================================
@@ -137,32 +137,14 @@ export function verifyAndParseStripeWebhook(
137
137
  return null;
138
138
  }
139
139
 
140
- // 5. Price-to-tier-Mapping. Stripe-subscription hat items[0].price.id.
141
- const priceId = sub.items.data[0]?.price.id;
142
- if (!priceId) {
140
+ // 5+6. Price-to-tier-Mapping + status/period-end/cancel_at — shared with
141
+ // plugin-methods.ts's createStripeRetrieveSubscription so both the
142
+ // webhook and the sync-subscriptions backfill map Stripe state
143
+ // identically.
144
+ const state = mapStripeSubscriptionState(sub, options.priceToTier);
145
+ if (!state) {
143
146
  return null;
144
147
  }
145
- const tier = options.priceToTier[priceId];
146
- if (!tier) {
147
- // Price-id nicht im Mapping → App-Owner hat den Stripe-price
148
- // angelegt aber nicht zur tier zugeordnet. Drop silent.
149
- return null;
150
- }
151
-
152
- // 6. Status-Mapping + period-end. Stripe hat den period-end seit
153
- // 2024 vom subscription-level auf item-level migriert (=
154
- // subscription.items.data[i].current_period_end). Wir lesen
155
- // das vom ersten item; multi-item-subs (Add-Ons) sind kein
156
- // Phase-5-Scope.
157
- const status = mapStripeStatus(sub.status);
158
- const periodEndUnixSec = sub.items.data[0]?.current_period_end ?? 0;
159
- // Stripe returns Unix-seconds; Temporal.Instant.fromEpochMilliseconds
160
- // expects ms. Multiply, then ISO. (No-Date-API-Guard forbids
161
- // `new Date()` — static import above, not the ambient global, see #1490.)
162
- const currentPeriodEnd = Temporal.Instant.fromEpochMilliseconds(
163
- periodEndUnixSec * 1000,
164
- ).toString();
165
- const cancelAt = stripeCancelAtIso(sub, currentPeriodEnd);
166
148
 
167
149
  return {
168
150
  providerEventId: event.id,
@@ -171,15 +153,57 @@ export function verifyAndParseStripeWebhook(
171
153
  tenantId,
172
154
  providerCustomerId: typeof sub.customer === "string" ? sub.customer : sub.customer.id,
173
155
  providerSubscriptionId: sub.id,
174
- status,
175
- tier,
176
- currentPeriodEnd,
177
- cancelAt,
156
+ ...state,
178
157
  rawPayload: JSON.stringify(event),
179
158
  };
180
159
  };
181
160
  }
182
161
 
162
+ // =============================================================================
163
+ // Shared subscription-state mapping (webhook + retrieveSubscription)
164
+ // =============================================================================
165
+
166
+ export type MappedStripeSubscriptionState = {
167
+ readonly status: SubscriptionStatus;
168
+ readonly tier: string;
169
+ readonly currentPeriodEnd: string;
170
+ readonly cancelAt: string | null;
171
+ };
172
+
173
+ /** Maps a Stripe subscription's price/status/period-end/cancel_at onto the
174
+ * foundation's normalized shape. Null when the subscription's price isn't
175
+ * in `priceToTier` (the app owner created the Stripe price but never
176
+ * mapped it to a tier) or the item has no price id at all. */
177
+ export function mapStripeSubscriptionState(
178
+ sub: Pick<Stripe.Subscription, "status" | "items" | "cancel_at" | "cancel_at_period_end">,
179
+ priceToTier: Readonly<Record<string, string>>,
180
+ ): MappedStripeSubscriptionState | null {
181
+ const priceId = sub.items.data[0]?.price.id;
182
+ if (!priceId) {
183
+ return null;
184
+ }
185
+ const tier = priceToTier[priceId];
186
+ if (!tier) {
187
+ return null;
188
+ }
189
+
190
+ // Since 2024 Stripe reports period-end per item
191
+ // (subscription.items.data[i].current_period_end), not per subscription.
192
+ // We read the first item; multi-item subscriptions (add-ons) aren't
193
+ // supported.
194
+ const status = mapStripeStatus(sub.status);
195
+ const periodEndUnixSec = sub.items.data[0]?.current_period_end ?? 0;
196
+ // Stripe returns Unix-seconds; Temporal.Instant.fromEpochMilliseconds
197
+ // expects ms. Multiply, then ISO. (No-Date-API-Guard forbids
198
+ // `new Date()` — static import above, not the ambient global, see #1490.)
199
+ const currentPeriodEnd = Temporal.Instant.fromEpochMilliseconds(
200
+ periodEndUnixSec * 1000,
201
+ ).toString();
202
+ const cancelAt = stripeCancelAtIso(sub, currentPeriodEnd);
203
+
204
+ return { status, tier, currentPeriodEnd, cancelAt };
205
+ }
206
+
183
207
  // =============================================================================
184
208
  // Helpers
185
209
  // =============================================================================