@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.
- package/package.json +9 -9
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +222 -4
- package/src/billing-foundation/__tests__/feature.test.ts +9 -1
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +8 -2
- package/src/billing-foundation/aggregate-id.ts +42 -0
- package/src/billing-foundation/constants.ts +16 -0
- package/src/billing-foundation/db/queries/payment-projection.ts +19 -0
- package/src/billing-foundation/entities.ts +29 -0
- package/src/billing-foundation/events.ts +32 -0
- package/src/billing-foundation/feature.ts +35 -3
- package/src/billing-foundation/handlers/process-payment-event.write.ts +117 -0
- package/src/billing-foundation/index.ts +12 -3
- package/src/billing-foundation/projection.ts +40 -2
- package/src/billing-foundation/tenant-destroy-hook.ts +38 -3
- package/src/billing-foundation/types.ts +63 -17
- package/src/billing-foundation/webhook-handler.ts +49 -20
- package/src/crypto-shredding/__tests__/forget-subject-record.integration.test.ts +291 -0
- package/src/crypto-shredding/constants.ts +2 -0
- package/src/crypto-shredding/handlers/forget-subject.write.ts +72 -4
- package/src/subscription-stripe/__tests__/feature.test.ts +2 -0
- package/src/subscription-stripe/__tests__/payment-checkout.test.ts +167 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +37 -12
- package/src/subscription-stripe/constants.ts +7 -0
- package/src/subscription-stripe/verify-webhook.ts +110 -4
|
@@ -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 =
|
|
106
|
-
|
|
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 =
|
|
152
|
-
|
|
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 =
|
|
220
|
-
|
|
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 =
|
|
261
|
-
|
|
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
|
-
// **
|
|
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 {
|
|
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
|
+
}
|