@cosmicdrift/kumiko-bundled-features 0.250.0 → 0.252.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.
@@ -8,7 +8,9 @@
8
8
  // Aufwand ohne Test-Wert.
9
9
 
10
10
  import { describe, expect, test } from "bun:test";
11
+ import type { SubscriptionEvent } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
11
12
  import {
13
+ BillingEventKinds,
12
14
  SubscriptionEventTypes,
13
15
  SubscriptionStatuses,
14
16
  } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
@@ -35,6 +37,21 @@ function webhookRuntime(webhookSecret = TEST_SECRET): StripeWebhookRuntime {
35
37
  return { resolve: async () => ({ stripe: stripeForFixtures, webhookSecret }) };
36
38
  }
37
39
 
40
+ /** This file's fixtures are all subscription/invoice events — never
41
+ * checkout.session.* (that's payment-checkout.test.ts's job). Narrows the
42
+ * widened `verifyAndParseStripeWebhook` return type back down via the
43
+ * `kind` discriminator, so the rest of this file can keep asserting on
44
+ * SubscriptionEvent-only fields without a union check at every call-site. */
45
+ function asSubscriptionVerifier(
46
+ verifyFn: ReturnType<typeof verifyAndParseStripeWebhook>,
47
+ ): (rawBody: string, headers: Record<string, string>) => Promise<SubscriptionEvent | null> {
48
+ return async (rawBody, headers) => {
49
+ const event = await verifyFn(rawBody, headers);
50
+ if (!event || event.kind === BillingEventKinds.payment) return null;
51
+ return event;
52
+ };
53
+ }
54
+
38
55
  function buildSubscriptionEvent(overrides: {
39
56
  eventType?: string;
40
57
  eventId?: string;
@@ -102,9 +119,11 @@ async function signEvent(payload: string, secret = TEST_SECRET): Promise<string>
102
119
  // =============================================================================
103
120
 
104
121
  describe("verifyAndParseStripeWebhook — sig-verify", () => {
105
- const verify = verifyAndParseStripeWebhook(webhookRuntime(), {
106
- priceToTier: { price_pro_monthly: "pro" },
107
- });
122
+ const verify = asSubscriptionVerifier(
123
+ verifyAndParseStripeWebhook(webhookRuntime(), {
124
+ priceToTier: { price_pro_monthly: "pro" },
125
+ }),
126
+ );
108
127
 
109
128
  test("happy path: valid sig + bekannter event-type → SubscriptionEvent", async () => {
110
129
  const payload = JSON.stringify(buildSubscriptionEvent({}));
@@ -148,9 +167,11 @@ describe("verifyAndParseStripeWebhook — sig-verify", () => {
148
167
  // =============================================================================
149
168
 
150
169
  describe("verifyAndParseStripeWebhook — event-filter", () => {
151
- const verify = verifyAndParseStripeWebhook(webhookRuntime(), {
152
- priceToTier: { price_pro_monthly: "pro" },
153
- });
170
+ const verify = asSubscriptionVerifier(
171
+ verifyAndParseStripeWebhook(webhookRuntime(), {
172
+ priceToTier: { price_pro_monthly: "pro" },
173
+ }),
174
+ );
154
175
 
155
176
  test("unbekannter event-type → null (foundation 200 ignored)", async () => {
156
177
  // customer.created ist gültiger Stripe-event aber nicht in unserer
@@ -216,9 +237,11 @@ describe("verifyAndParseStripeWebhook — event-filter", () => {
216
237
  // =============================================================================
217
238
 
218
239
  describe("verifyAndParseStripeWebhook — tenant-resolution + price-to-tier", () => {
219
- const verify = verifyAndParseStripeWebhook(webhookRuntime(), {
220
- priceToTier: { price_pro_monthly: "pro", price_business_yearly: "business" },
221
- });
240
+ const verify = asSubscriptionVerifier(
241
+ verifyAndParseStripeWebhook(webhookRuntime(), {
242
+ priceToTier: { price_pro_monthly: "pro", price_business_yearly: "business" },
243
+ }),
244
+ );
222
245
 
223
246
  test("metadata.tenantId fehlt → null (App-Owner-Bug, foundation 200 ignored)", async () => {
224
247
  const ev = buildSubscriptionEvent({});
@@ -257,9 +280,11 @@ describe("verifyAndParseStripeWebhook — tenant-resolution + price-to-tier", ()
257
280
 
258
281
  describe("verifyAndParseStripeWebhook — kumiko-framework#1525: no ambient Temporal global", () => {
259
282
  test("computes currentPeriodEnd without relying on globalThis.Temporal", async () => {
260
- const verify = verifyAndParseStripeWebhook(webhookRuntime(), {
261
- priceToTier: { price_pro_monthly: "pro" },
262
- });
283
+ const verify = asSubscriptionVerifier(
284
+ verifyAndParseStripeWebhook(webhookRuntime(), {
285
+ priceToTier: { price_pro_monthly: "pro" },
286
+ }),
287
+ );
263
288
  const payload = JSON.stringify(buildSubscriptionEvent({ currentPeriodEndUnix: 1_780_000_000 }));
264
289
  const sig = await signEvent(payload);
265
290
 
@@ -25,4 +25,11 @@ export const StripeEventTypes = {
25
25
  customerSubscriptionDeleted: "customer.subscription.deleted",
26
26
  invoicePaid: "invoice.paid",
27
27
  invoicePaymentFailed: "invoice.payment_failed",
28
+ // One-off-payment checkout (mode: "payment") completion. Both fire for a
29
+ // successful payment — `completed` for synchronous methods (card),
30
+ // `async_payment_succeeded` for delayed ones (SEPA, bank transfers). Never
31
+ // fed into mapStripeEventType — handled by a dedicated branch in
32
+ // verify-webhook.ts before the subscription-event switch runs.
33
+ checkoutSessionCompleted: "checkout.session.completed",
34
+ checkoutSessionAsyncPaymentSucceeded: "checkout.session.async_payment_succeeded",
28
35
  } as const;
@@ -2,10 +2,13 @@
2
2
  // event-mapping. Wird vom Plugin-Build (feature.ts) als
3
3
  // `verifyAndParseWebhook` registriert.
4
4
  //
5
- // **Drei Schritte:**
5
+ // **Steps:**
6
6
  // 1. Sig-verify via stripe.webhooks.constructEvent (HMAC-SHA-256
7
7
  // gegen rawBody + Stripe-Signature-Header). Wirft bei mismatch
8
8
  // oder älter als 5min (Replay-Protection).
9
+ // 1b. checkout.session.completed / .async_payment_succeeded branch off
10
+ // here into a PaymentEvent (one-off-payment, own aggregate) — see
11
+ // "One-off-payment" section below. Everything else falls through.
9
12
  // 2. Event-type-Filter: nur die 5 event-types die wir auf
10
13
  // SubscriptionEventTypes mappen kommen weiter; alles andere
11
14
  // returnt null (foundation antwortet 200 ignored).
@@ -31,8 +34,12 @@
31
34
  // returnt der Plugin defensiv null — der nächste subscription-event
32
35
  // wird den state korrekt handhaben.
33
36
 
34
- import type { SubscriptionEvent } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
37
+ import type {
38
+ PaymentEvent,
39
+ SubscriptionEvent,
40
+ } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
35
41
  import {
42
+ BillingEventKinds,
36
43
  type SubscriptionEventType,
37
44
  SubscriptionEventTypes,
38
45
  type SubscriptionStatus,
@@ -69,7 +76,7 @@ export function verifyAndParseStripeWebhook(
69
76
  rawBody: string,
70
77
  headers: Record<string, string>,
71
78
  systemSecrets?: SecretsContext,
72
- ) => Promise<SubscriptionEvent | null> {
79
+ ) => Promise<SubscriptionEvent | PaymentEvent | null> {
73
80
  return async (rawBody, headers, systemSecrets) => {
74
81
  const sigHeader = headers["stripe-signature"];
75
82
  if (!sigHeader) {
@@ -83,7 +90,9 @@ export function verifyAndParseStripeWebhook(
83
90
 
84
91
  // 1. Sig-verify. constructEvent throws bei mismatch (= invalid sig)
85
92
  // oder timestamp-tolerance-violation (default 5min). Foundation
86
- // mapped throw → HTTP 401.
93
+ // mapped throw → HTTP 401. Applies to BOTH branches below — the
94
+ // payment-branch only branches off AFTER this check; there's no
95
+ // path that bypasses signature verification.
87
96
  let event: Stripe.Event;
88
97
  try {
89
98
  event = await stripe.webhooks.constructEventAsync(rawBody, sigHeader, webhookSecret);
@@ -92,6 +101,15 @@ export function verifyAndParseStripeWebhook(
92
101
  throw new Error(`subscription-stripe: webhook signature verify failed — ${msg}`);
93
102
  }
94
103
 
104
+ // 1b. One-off-payment events (checkout mode "payment") branch off BEFORE
105
+ // the mapStripeEventType filter below — they don't map to a
106
+ // SubscriptionEventType and must not touch mapStripeEventType's
107
+ // 5-item whitelist (drift-pin in verify-webhook.test.ts still
108
+ // expects null for "checkout.session.completed").
109
+ if (isCheckoutSessionEventType(event.type)) {
110
+ return await parsePaymentEvent(event, stripe);
111
+ }
112
+
95
113
  // 2. Event-type-Filter — wir kennen nur 5.
96
114
  const normalizedType = mapStripeEventType(event.type);
97
115
  if (!normalizedType) {
@@ -247,3 +265,91 @@ async function extractSubscriptionFromEvent(
247
265
  return null;
248
266
  }
249
267
  }
268
+
269
+ // =============================================================================
270
+ // One-off-payment (checkout mode "payment") extraction
271
+ // =============================================================================
272
+
273
+ function isCheckoutSessionEventType(stripeType: string): boolean {
274
+ return (
275
+ stripeType === StripeEventTypes.checkoutSessionCompleted ||
276
+ stripeType === StripeEventTypes.checkoutSessionAsyncPaymentSucceeded
277
+ );
278
+ }
279
+
280
+ /** Parses a checkout.session.completed / .async_payment_succeeded event into
281
+ * a PaymentEvent, or null if it isn't a paid one-off-payment session. */
282
+ async function parsePaymentEvent(
283
+ event: Stripe.Event,
284
+ stripe: Stripe,
285
+ ): Promise<PaymentEvent | null> {
286
+ const session = event.data.object as Stripe.Checkout.Session; // @cast-boundary engine-bridge
287
+ // mode !== "payment" excludes subscription-checkout sessions (same event types);
288
+ // payment_status !== "paid" excludes an unpaid/expired session. Null here — not an
289
+ // error — maps to foundation's 200 "ignored", same as the subscription-path's filters.
290
+ if (session.mode !== "payment" || session.payment_status !== "paid") {
291
+ return null;
292
+ }
293
+
294
+ // Lazy-fetch with expand: the raw webhook payload carries neither
295
+ // line_items (needed for priceId) nor an expanded payment_intent (needed
296
+ // for tenantId — see below). Same lazy-fetch pattern as
297
+ // extractSubscriptionFromEvent's invoice-branch above, same defensive
298
+ // null on API failure (session gone/expired between webhook + retrieve).
299
+ let expanded: Stripe.Checkout.Session;
300
+ try {
301
+ expanded = await stripe.checkout.sessions.retrieve(session.id, {
302
+ expand: ["line_items", "payment_intent"],
303
+ });
304
+ } catch {
305
+ return null;
306
+ }
307
+
308
+ // Tenant-resolution: mode:"payment" checkout sessions carry tenantId on
309
+ // the PaymentIntent's OWN metadata (plugin-methods.ts sets it via
310
+ // payment_intent_data.metadata), NOT on session-level metadata —
311
+ // session.metadata is unset for this mode. Reading it off the expanded,
312
+ // Stripe-verified PaymentIntent (not a freely-choosable payload field) is
313
+ // what keeps tenant-attribution trustworthy: the webhook signature proves
314
+ // this whole object chain came from Stripe, and the metadata itself was
315
+ // set by our own backend at checkout-creation time.
316
+ const paymentIntent = expanded.payment_intent;
317
+ if (!paymentIntent || typeof paymentIntent === "string") {
318
+ // Not expanded (Stripe-API-drift) or absent — no verified tenant-claim
319
+ // to trust. Drop silent, same as the subscription-path's missing-
320
+ // metadata case.
321
+ return null;
322
+ }
323
+ const tenantId = paymentIntent.metadata?.["tenantId"];
324
+ if (!tenantId || tenantId.length === 0) {
325
+ return null;
326
+ }
327
+
328
+ const priceId = expanded.line_items?.data[0]?.price?.id;
329
+ if (!priceId) {
330
+ return null;
331
+ }
332
+
333
+ // providerCustomerId: session.customer is null for guest checkouts (no
334
+ // customer_creation configured) — fall back to the PaymentIntent's own
335
+ // customer, which Stripe sets independently of the session-level field.
336
+ const sessionCustomer = expanded.customer;
337
+ const providerCustomerId =
338
+ (typeof sessionCustomer === "string" ? sessionCustomer : sessionCustomer?.id) ??
339
+ (typeof paymentIntent.customer === "string"
340
+ ? paymentIntent.customer
341
+ : paymentIntent.customer?.id);
342
+ if (!providerCustomerId) {
343
+ return null;
344
+ }
345
+
346
+ return {
347
+ kind: BillingEventKinds.payment,
348
+ providerEventId: event.id,
349
+ providerName: STRIPE_PROVIDER_NAME,
350
+ tenantId,
351
+ providerCustomerId,
352
+ priceId,
353
+ rawPayload: JSON.stringify(event),
354
+ };
355
+ }