@cosmicdrift/kumiko-bundled-features 0.320.0 → 0.322.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/agent-tools/__tests__/tool-catalog.test.ts +16 -1
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +130 -3
- package/src/agent-tools/agent-manifest.ts +13 -3
- package/src/agent-tools/tool-dispatch.ts +17 -12
- 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/crypto-shredding/handlers/forget-subject.write.ts +3 -1
- 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/__tests__/members-facet-i18n.test.ts +3 -2
- 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
- package/src/tenant-settings/__tests__/settings-hub-i18n.test.ts +6 -10
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +3 -1
|
@@ -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 {
|
|
13
|
+
import { mailSpecSchema, performMailDispatch } from "./mail-runner";
|
|
13
14
|
import {
|
|
14
15
|
performWebhookDispatch,
|
|
15
16
|
WEBHOOK_ALLOWED_PRIVATE_HOSTS_ENV_VAR,
|
|
16
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
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"),
|
|
62
|
-
z.object({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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(
|
|
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)
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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
|
// =============================================================================
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// so adding a facet option without translating it fails here rather than in
|
|
7
7
|
// production.
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
|
+
import { translationValueOtherText } from "@cosmicdrift/kumiko-framework/ui-types";
|
|
9
10
|
import { localeDeBundle } from "@cosmicdrift/kumiko-locale-de";
|
|
10
11
|
import { localeEsBundle } from "@cosmicdrift/kumiko-locale-es";
|
|
11
12
|
import { MEMBERS_SCREEN_ID } from "../constants";
|
|
@@ -67,7 +68,7 @@ describe("members status filter labels are localized in every shipped locale (fw
|
|
|
67
68
|
const label = translations[key]?.["en"];
|
|
68
69
|
expect(label).toBeString();
|
|
69
70
|
expect(label).not.toBe(key);
|
|
70
|
-
expect((label
|
|
71
|
+
expect((label === undefined ? "" : translationValueOtherText(label)).trim()).not.toBe("");
|
|
71
72
|
}
|
|
72
73
|
});
|
|
73
74
|
|
|
@@ -77,7 +78,7 @@ describe("members status filter labels are localized in every shipped locale (fw
|
|
|
77
78
|
const label = translations[key]?.["en"];
|
|
78
79
|
expect(label).toBeString();
|
|
79
80
|
expect(label).not.toBe(key);
|
|
80
|
-
expect((label
|
|
81
|
+
expect((label === undefined ? "" : translationValueOtherText(label)).trim()).not.toBe("");
|
|
81
82
|
}
|
|
82
83
|
});
|
|
83
84
|
|