@cosmicdrift/kumiko-bundled-features 0.251.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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// process-payment-event — programmatic write-handler that the webhook-
|
|
2
|
+
// handler calls AFTER a plugin has verified a one-off-payment webhook and
|
|
3
|
+
// normalized it into a PaymentEvent.
|
|
4
|
+
//
|
|
5
|
+
// **ES-Pattern (mirrors process-event.write.ts):**
|
|
6
|
+
// 1. Idempotency-check: loads the payment-stream and scans for an
|
|
7
|
+
// already-seen `metadata.providerEventId`. A provider-replay with
|
|
8
|
+
// the same event-id → duplicate=true, no second append.
|
|
9
|
+
// 2. ctx.unsafeAppendEvent — inline-projection materializes a new
|
|
10
|
+
// `read_payments`-row (INSERT-once, not UPSERT — every payment is
|
|
11
|
+
// its own fact, not a state-update).
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
configuredPiiSubjectKms,
|
|
15
|
+
encryptPiiFieldValues,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
17
|
+
import type { WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { paymentAggregateId } from "../aggregate-id";
|
|
20
|
+
import { PAYMENT_PII_FIELDS, paymentEntity } from "../entities";
|
|
21
|
+
import {
|
|
22
|
+
PAYMENT_AGGREGATE_TYPE,
|
|
23
|
+
PAYMENT_RECEIVED_EVENT_QN,
|
|
24
|
+
type PaymentEventHeaders,
|
|
25
|
+
type PaymentEventPayload,
|
|
26
|
+
} from "../events";
|
|
27
|
+
|
|
28
|
+
export const processPaymentEventSchema = z.object({
|
|
29
|
+
providerEventId: z.string().min(1).max(200),
|
|
30
|
+
providerName: z.string().min(1).max(50),
|
|
31
|
+
providerCustomerId: z.string().min(1).max(200),
|
|
32
|
+
priceId: z.string().min(1).max(200),
|
|
33
|
+
rawPayload: z.string().min(1),
|
|
34
|
+
});
|
|
35
|
+
type ProcessPaymentEventPayload = z.infer<typeof processPaymentEventSchema>;
|
|
36
|
+
|
|
37
|
+
// SystemAdmin-only: this handler is called exclusively by the programmatic
|
|
38
|
+
// webhook-handler (with an internal SystemUser), never directly by the
|
|
39
|
+
// tenant-admin.
|
|
40
|
+
export const processPaymentEventHandler: WriteHandlerDef = {
|
|
41
|
+
name: "process-payment-event",
|
|
42
|
+
agent: { expose: false },
|
|
43
|
+
schema: processPaymentEventSchema,
|
|
44
|
+
access: { roles: ["SystemAdmin"] },
|
|
45
|
+
handler: async (event, ctx) => {
|
|
46
|
+
// @cast-boundary engine-payload — dispatcher-zod-validated payload
|
|
47
|
+
const payload = event.payload as ProcessPaymentEventPayload;
|
|
48
|
+
const tenantId = event.user.tenantId;
|
|
49
|
+
const aggId = paymentAggregateId(tenantId);
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------
|
|
52
|
+
// 1. Idempotency: same scan pattern as process-event.write.ts — see
|
|
53
|
+
// that file's comment for the O(N)-per-stream performance caveat
|
|
54
|
+
// (a tenant with hundreds of purchases scans hundreds of events;
|
|
55
|
+
// optimize via a per-tenant providerEventId-index if that bites).
|
|
56
|
+
// ---------------------------------------------------------------
|
|
57
|
+
const existingEvents = await ctx.loadAggregate(aggId);
|
|
58
|
+
const alreadySeen = existingEvents.some((e) => {
|
|
59
|
+
const headers = e.metadata.headers ?? {};
|
|
60
|
+
return (
|
|
61
|
+
headers["providerEventId"] === payload.providerEventId &&
|
|
62
|
+
headers["providerName"] === payload.providerName
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
if (alreadySeen) {
|
|
66
|
+
return {
|
|
67
|
+
isSuccess: true as const,
|
|
68
|
+
data: { duplicate: true as const, paymentAggregateId: aggId },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------
|
|
73
|
+
// 2. Encrypt the provider-subject PII field before it touches storage
|
|
74
|
+
// — same rationale as process-event.write.ts's step 3.
|
|
75
|
+
// ---------------------------------------------------------------
|
|
76
|
+
const piiKms = configuredPiiSubjectKms();
|
|
77
|
+
const encryptedFields = piiKms
|
|
78
|
+
? await encryptPiiFieldValues(
|
|
79
|
+
{ tenantId, providerCustomerId: payload.providerCustomerId },
|
|
80
|
+
paymentEntity,
|
|
81
|
+
PAYMENT_PII_FIELDS,
|
|
82
|
+
piiKms,
|
|
83
|
+
{
|
|
84
|
+
requestId: `billing-foundation:process-payment-event:${payload.providerEventId}`,
|
|
85
|
+
tenantId,
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
: { providerCustomerId: payload.providerCustomerId };
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------
|
|
91
|
+
// 3. Append event on the payment-stream. Inline-projection
|
|
92
|
+
// materializes a new read_payments-row in the same TX.
|
|
93
|
+
// ---------------------------------------------------------------
|
|
94
|
+
const eventPayload: PaymentEventPayload = {
|
|
95
|
+
providerName: payload.providerName,
|
|
96
|
+
providerCustomerId: encryptedFields["providerCustomerId"] as string,
|
|
97
|
+
priceId: payload.priceId,
|
|
98
|
+
};
|
|
99
|
+
const headers: PaymentEventHeaders = {
|
|
100
|
+
providerEventId: payload.providerEventId,
|
|
101
|
+
providerName: payload.providerName,
|
|
102
|
+
rawPayload: payload.rawPayload,
|
|
103
|
+
};
|
|
104
|
+
await ctx.unsafeAppendEvent({
|
|
105
|
+
aggregateId: aggId,
|
|
106
|
+
aggregateType: PAYMENT_AGGREGATE_TYPE,
|
|
107
|
+
type: PAYMENT_RECEIVED_EVENT_QN,
|
|
108
|
+
payload: eventPayload,
|
|
109
|
+
headers,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
isSuccess: true as const,
|
|
114
|
+
data: { duplicate: false as const, paymentAggregateId: aggId },
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Public API of the subscription-foundation bundled-feature.
|
|
2
2
|
|
|
3
|
-
export { subscriptionAggregateId } from "./aggregate-id";
|
|
3
|
+
export { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id";
|
|
4
4
|
export {
|
|
5
5
|
BILLING_FOUNDATION_FEATURE,
|
|
6
|
+
type BillingEventKind,
|
|
7
|
+
BillingEventKinds,
|
|
6
8
|
SUBSCRIPTION_PROVIDER_EXTENSION,
|
|
7
9
|
type SubscriptionEventType,
|
|
8
10
|
SubscriptionEventTypes,
|
|
@@ -11,12 +13,18 @@ export {
|
|
|
11
13
|
type SubscriptionStatus,
|
|
12
14
|
SubscriptionStatuses,
|
|
13
15
|
} from "./constants";
|
|
14
|
-
export { subscriptionEntity } from "./entities";
|
|
16
|
+
export { paymentEntity, subscriptionEntity } from "./entities";
|
|
15
17
|
export {
|
|
16
18
|
INVOICE_PAID_EVENT_QN,
|
|
17
19
|
INVOICE_PAID_EVENT_SHORT,
|
|
18
20
|
INVOICE_PAYMENT_FAILED_EVENT_QN,
|
|
19
21
|
INVOICE_PAYMENT_FAILED_EVENT_SHORT,
|
|
22
|
+
PAYMENT_AGGREGATE_TYPE,
|
|
23
|
+
PAYMENT_RECEIVED_EVENT_QN,
|
|
24
|
+
PAYMENT_RECEIVED_EVENT_SHORT,
|
|
25
|
+
type PaymentEventHeaders,
|
|
26
|
+
type PaymentEventPayload,
|
|
27
|
+
paymentEventPayloadSchema,
|
|
20
28
|
SUBSCRIPTION_AGGREGATE_TYPE,
|
|
21
29
|
SUBSCRIPTION_CANCELED_EVENT_QN,
|
|
22
30
|
SUBSCRIPTION_CANCELED_EVENT_SHORT,
|
|
@@ -30,7 +38,7 @@ export {
|
|
|
30
38
|
} from "./events";
|
|
31
39
|
export { billingFoundationFeature } from "./feature";
|
|
32
40
|
export { getSubscriptionForTenant, type SubscriptionView } from "./get-subscription-for-tenant";
|
|
33
|
-
export { subscriptionsProjectionTable } from "./projection";
|
|
41
|
+
export { paymentsProjectionTable, subscriptionsProjectionTable } from "./projection";
|
|
34
42
|
export {
|
|
35
43
|
createSubscriptionTierSync,
|
|
36
44
|
effectiveTierFromSubscription,
|
|
@@ -39,6 +47,7 @@ export {
|
|
|
39
47
|
type SystemWriteResult,
|
|
40
48
|
} from "./subscription-tier-sync";
|
|
41
49
|
export type {
|
|
50
|
+
PaymentEvent,
|
|
42
51
|
SubscriptionEvent,
|
|
43
52
|
SubscriptionProviderPlugin,
|
|
44
53
|
} from "./types";
|
|
@@ -16,15 +16,22 @@
|
|
|
16
16
|
|
|
17
17
|
import { buildEntityTable } from "@cosmicdrift/kumiko-framework/db";
|
|
18
18
|
import { defineApply } from "@cosmicdrift/kumiko-framework/engine";
|
|
19
|
+
import { paymentRowId } from "./aggregate-id";
|
|
20
|
+
import { insertPaymentProjectionRow } from "./db/queries/payment-projection";
|
|
19
21
|
import { upsertSubscriptionProjectionRow } from "./db/queries/subscription-projection";
|
|
20
|
-
import { subscriptionEntity } from "./entities";
|
|
21
|
-
import type { SubscriptionEventPayload } from "./events";
|
|
22
|
+
import { paymentEntity, subscriptionEntity } from "./entities";
|
|
23
|
+
import type { PaymentEventPayload, SubscriptionEventPayload } from "./events";
|
|
22
24
|
|
|
23
25
|
// Drizzle-table-instance aus dem entity-shape. Wird sowohl von der
|
|
24
26
|
// projection-apply als auch von list-query / get-helper genutzt damit
|
|
25
27
|
// alle drei Stellen denselben column-namespace teilen.
|
|
26
28
|
export const subscriptionsProjectionTable = buildEntityTable("subscription", subscriptionEntity);
|
|
27
29
|
|
|
30
|
+
// Same production-deployment caveat as subscriptionsProjectionTable above:
|
|
31
|
+
// raw drizzle-pgTable, not an r.entity — apps mounting billing-foundation in
|
|
32
|
+
// production must add read_payments to their own drizzle/generate.ts too.
|
|
33
|
+
export const paymentsProjectionTable = buildEntityTable("payment", paymentEntity);
|
|
34
|
+
|
|
28
35
|
// =============================================================================
|
|
29
36
|
// Shared helpers
|
|
30
37
|
// =============================================================================
|
|
@@ -152,3 +159,34 @@ export const applyInvoicePaymentFailed = defineApply<SubscriptionEventPayload>(
|
|
|
152
159
|
await upsert(tx, event, { status: p.status, tier: p.tier }, p);
|
|
153
160
|
},
|
|
154
161
|
);
|
|
162
|
+
|
|
163
|
+
// =============================================================================
|
|
164
|
+
// payment-received apply — one row per event, no UPSERT
|
|
165
|
+
// =============================================================================
|
|
166
|
+
|
|
167
|
+
/** payment-received → INSERT-once row keyed by a deterministic uuid derived
|
|
168
|
+
* from (tenantId, providerName, providerEventId) — NOT `event.id`, which is
|
|
169
|
+
* the event-store's global bigserial sequence (not a UUID, can't back this
|
|
170
|
+
* uuid-typed PK) — and not aggregateId, which is shared by every payment on
|
|
171
|
+
* the same tenant's payment-stream. tenantId is part of the key because the
|
|
172
|
+
* idempotency scan is per-tenant: without it, two tenants sharing the same
|
|
173
|
+
* providerEventId would collide on ON CONFLICT DO NOTHING and silently lose
|
|
174
|
+
* the second tenant's row. ON CONFLICT DO NOTHING also makes a projection
|
|
175
|
+
* rebuild idempotent without mutating an existing row. */
|
|
176
|
+
export const applyPaymentReceived = defineApply<PaymentEventPayload>(async (event, tx) => {
|
|
177
|
+
const tableName = (paymentsProjectionTable as { tableName: string }).tableName;
|
|
178
|
+
const providerEventId = event.metadata.headers?.["providerEventId"];
|
|
179
|
+
const providerName = event.metadata.headers?.["providerName"];
|
|
180
|
+
if (typeof providerEventId !== "string" || typeof providerName !== "string") {
|
|
181
|
+
throw new Error(
|
|
182
|
+
"applyPaymentReceived: event.metadata.headers is missing providerEventId/providerName — process-payment-event.write.ts always sets both",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
await insertPaymentProjectionRow(tx, tableName, {
|
|
186
|
+
id: paymentRowId(event.tenantId, providerName, providerEventId),
|
|
187
|
+
tenant_id: event.tenantId,
|
|
188
|
+
provider_name: event.payload.providerName,
|
|
189
|
+
provider_customer_id: event.payload.providerCustomerId,
|
|
190
|
+
price_id: event.payload.priceId,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -2,9 +2,9 @@ import { deleteMany, type EntityTableMeta, updateMany } from "@cosmicdrift/kumik
|
|
|
2
2
|
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { archiveStream } from "@cosmicdrift/kumiko-framework/event-store";
|
|
4
4
|
import { resolveProfileForTenant } from "../compliance-profiles";
|
|
5
|
-
import { subscriptionAggregateId } from "./aggregate-id";
|
|
6
|
-
import { SUBSCRIPTION_AGGREGATE_TYPE } from "./events";
|
|
7
|
-
import { subscriptionsProjectionTable } from "./projection";
|
|
5
|
+
import { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id";
|
|
6
|
+
import { PAYMENT_AGGREGATE_TYPE, SUBSCRIPTION_AGGREGATE_TYPE } from "./events";
|
|
7
|
+
import { paymentsProjectionTable, subscriptionsProjectionTable } from "./projection";
|
|
8
8
|
|
|
9
9
|
// providerCustomerId/providerSubscriptionId are `personal: "tenant"` on the
|
|
10
10
|
// entity (envelope-encrypted with the tenant subject key, see entities.ts) —
|
|
@@ -44,3 +44,38 @@ export async function subscriptionTenantDestroyHook(ctx: {
|
|
|
44
44
|
reason: "tenant_destroy",
|
|
45
45
|
});
|
|
46
46
|
}
|
|
47
|
+
|
|
48
|
+
/** Tenant-destroy hook for payment PII (#800). Unlike subscriptionTenantDestroyHook
|
|
49
|
+
* (one row per tenant, targeted by `id`), a tenant can have many payment
|
|
50
|
+
* rows — the predicate here is `tenantId`, not `id`. The payment-stream
|
|
51
|
+
* itself is still one stream per tenant (paymentAggregateId), so
|
|
52
|
+
* archiveStream targets that single aggregateId as usual. */
|
|
53
|
+
export async function paymentTenantDestroyHook(ctx: {
|
|
54
|
+
readonly db: import("@cosmicdrift/kumiko-framework/db").DbRunner;
|
|
55
|
+
readonly tenantId: TenantId;
|
|
56
|
+
}): Promise<void> {
|
|
57
|
+
const { profile } = await resolveProfileForTenant({
|
|
58
|
+
db: ctx.db,
|
|
59
|
+
tenantId: ctx.tenantId,
|
|
60
|
+
});
|
|
61
|
+
const aggregateId = paymentAggregateId(ctx.tenantId);
|
|
62
|
+
if (profile.key === "de-hr-dsgvo-hgb") {
|
|
63
|
+
await updateMany(
|
|
64
|
+
ctx.db,
|
|
65
|
+
paymentsProjectionTable as EntityTableMeta,
|
|
66
|
+
{ providerCustomerId: REDACTED_PII_VALUE },
|
|
67
|
+
{ tenantId: ctx.tenantId },
|
|
68
|
+
);
|
|
69
|
+
} else {
|
|
70
|
+
await deleteMany(ctx.db, paymentsProjectionTable as EntityTableMeta, {
|
|
71
|
+
tenantId: ctx.tenantId,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
await archiveStream(ctx.db, {
|
|
75
|
+
tenantId: ctx.tenantId,
|
|
76
|
+
aggregateId,
|
|
77
|
+
aggregateType: PAYMENT_AGGREGATE_TYPE,
|
|
78
|
+
archivedBy: "tenant-lifecycle:destroy",
|
|
79
|
+
reason: "tenant_destroy",
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
|
|
27
27
|
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
28
28
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
29
|
-
import type { SubscriptionEventType, SubscriptionStatus } from "./constants";
|
|
29
|
+
import type { BillingEventKinds, SubscriptionEventType, SubscriptionStatus } from "./constants";
|
|
30
30
|
|
|
31
31
|
// =============================================================================
|
|
32
32
|
// Normalisierter Webhook-Event
|
|
@@ -37,6 +37,12 @@ import type { SubscriptionEventType, SubscriptionStatus } from "./constants";
|
|
|
37
37
|
// Plugin abstrahiert.
|
|
38
38
|
|
|
39
39
|
export type SubscriptionEvent = {
|
|
40
|
+
/** Discriminator against PaymentEvent. Optional (instead of required) so
|
|
41
|
+
* existing plugins like subscription-mollie, which don't set this field,
|
|
42
|
+
* stay backward-compatible without changes — TS still narrows
|
|
43
|
+
* `parsed.kind === BillingEventKinds.payment` correctly, because only
|
|
44
|
+
* PaymentEvent["kind"] can take that value. */
|
|
45
|
+
readonly kind?: typeof BillingEventKinds.subscription;
|
|
40
46
|
/** Provider-eigene Event-ID — UNIQUE-key für Idempotency.
|
|
41
47
|
* Stripe: "evt_..."; Mollie: payment-id oder subscription-id. */
|
|
42
48
|
readonly providerEventId: string;
|
|
@@ -68,6 +74,40 @@ export type SubscriptionEvent = {
|
|
|
68
74
|
readonly rawPayload: string;
|
|
69
75
|
};
|
|
70
76
|
|
|
77
|
+
// =============================================================================
|
|
78
|
+
// Normalized One-off-Payment-Event
|
|
79
|
+
// =============================================================================
|
|
80
|
+
//
|
|
81
|
+
// Separate type instead of a sixth SubscriptionEventTypes value: a one-off-
|
|
82
|
+
// payment is not a subscription-state transition (no status/tier/
|
|
83
|
+
// currentPeriodEnd — a purchase either happened or it didn't) and
|
|
84
|
+
// materializes into its own `read_payments`-row instead of overwriting the
|
|
85
|
+
// subscription-row. Provider-plugins that don't support one-off-payments
|
|
86
|
+
// (e.g. Mollie-recurring-only) never deliver this type — verifyAndParseWebhook
|
|
87
|
+
// still returns just SubscriptionEvent | null for them, unchanged.
|
|
88
|
+
|
|
89
|
+
export type PaymentEvent = {
|
|
90
|
+
/** Required — distinguishes this union member at runtime from
|
|
91
|
+
* SubscriptionEvent (whose `kind` is optional and never "payment"). */
|
|
92
|
+
readonly kind: typeof BillingEventKinds.payment;
|
|
93
|
+
/** Provider's own event-ID — UNIQUE key for idempotency. */
|
|
94
|
+
readonly providerEventId: string;
|
|
95
|
+
/** Discriminator — which plugin delivered this event. */
|
|
96
|
+
readonly providerName: string;
|
|
97
|
+
/** Platform-tenant-ID. **Must** come from provider-verified metadata
|
|
98
|
+
* (e.g. the PaymentIntent metadata the app itself set at checkout-
|
|
99
|
+
* create time) — never from a freely-choosable payload field, or an
|
|
100
|
+
* attacker could attribute payments to someone else's tenant. */
|
|
101
|
+
readonly tenantId: string;
|
|
102
|
+
/** Provider's own customer-id. */
|
|
103
|
+
readonly providerCustomerId: string;
|
|
104
|
+
/** Provider's own price/plan-ID of the purchased item. */
|
|
105
|
+
readonly priceId: string;
|
|
106
|
+
/** Raw provider-payload — archived 1:1 into payment-event.rawPayload.
|
|
107
|
+
* Plugin delivers this as a JSON-stringified string. */
|
|
108
|
+
readonly rawPayload: string;
|
|
109
|
+
};
|
|
110
|
+
|
|
71
111
|
// =============================================================================
|
|
72
112
|
// Plugin-Contract
|
|
73
113
|
// =============================================================================
|
|
@@ -75,31 +115,37 @@ export type SubscriptionEvent = {
|
|
|
75
115
|
export type SubscriptionProviderPlugin = {
|
|
76
116
|
/**
|
|
77
117
|
* Verify webhook signature + parse provider-event into normalized
|
|
78
|
-
* form. **Pre-tenant-resolution** —
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
118
|
+
* form. **Pre-tenant-resolution** — no HandlerContext, because at
|
|
119
|
+
* sig-verify time the tenant hasn't been resolved yet (the plugin
|
|
120
|
+
* does the tenant-resolution itself from the provider-payload
|
|
121
|
+
* metadata).
|
|
82
122
|
*
|
|
83
|
-
* App-wide-secret = App-Owner's
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* SecretsContext,
|
|
87
|
-
*
|
|
88
|
-
*
|
|
123
|
+
* App-wide-secret = the App-Owner's own provider account. The plugin
|
|
124
|
+
* reads it either from a mount-time closure OR — rotatable at
|
|
125
|
+
* runtime — from the optional `systemSecrets` arg (system-scoped
|
|
126
|
+
* SecretsContext, passed through by the webhook-handler).
|
|
127
|
+
* `systemSecrets` is undefined when the App-Owner hasn't wired one;
|
|
128
|
+
* plugins then fall back to their closure fallback.
|
|
89
129
|
*
|
|
90
|
-
* Returns null
|
|
91
|
-
* foundation
|
|
130
|
+
* Returns null for events the plugin doesn't understand or that the
|
|
131
|
+
* foundation doesn't need (= filter out, foundation returns 200
|
|
92
132
|
* "ignored").
|
|
93
133
|
*
|
|
94
|
-
* **Throws**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
134
|
+
* **Throws** on sig-mismatch — the webhook-handler maps that to 401
|
|
135
|
+
* so the provider doesn't retry (sig-fail = config-bug, not
|
|
136
|
+
* transient).
|
|
137
|
+
*
|
|
138
|
+
* Returns `PaymentEvent` for one-off-payment-webhooks (checkout mode
|
|
139
|
+
* "payment"). Plugins without one-off-payment support never deliver this
|
|
140
|
+
* union member — the return-type extension is covariant, so a
|
|
141
|
+
* `Promise<SubscriptionEvent | null>` implementor stays assignable
|
|
142
|
+
* without any change.
|
|
97
143
|
*/
|
|
98
144
|
readonly verifyAndParseWebhook: (
|
|
99
145
|
rawBody: string,
|
|
100
146
|
headers: Record<string, string>,
|
|
101
147
|
systemSecrets?: SecretsContext,
|
|
102
|
-
) => Promise<SubscriptionEvent | null>;
|
|
148
|
+
) => Promise<SubscriptionEvent | PaymentEvent | null>;
|
|
103
149
|
|
|
104
150
|
/**
|
|
105
151
|
* **Post-tenant-resolution** — wird aus dem
|
|
@@ -45,6 +45,7 @@ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
|
45
45
|
import type { Context, Hono } from "hono";
|
|
46
46
|
import {
|
|
47
47
|
BILLING_FOUNDATION_FEATURE,
|
|
48
|
+
BillingEventKinds,
|
|
48
49
|
SUBSCRIPTION_PROVIDER_EXTENSION,
|
|
49
50
|
SubscriptionFoundationHandlers,
|
|
50
51
|
} from "./constants";
|
|
@@ -153,9 +154,31 @@ export function createSubscriptionWebhookHandler(deps: SubscriptionWebhookDeps)
|
|
|
153
154
|
return c.json({ ignored: true }, 200);
|
|
154
155
|
}
|
|
155
156
|
|
|
156
|
-
// 6. Dispatch
|
|
157
|
-
//
|
|
158
|
-
//
|
|
157
|
+
// 6. Dispatch to the type-matching write-handler. Payment-events (own
|
|
158
|
+
// aggregate, own `read_payments`-row) go to process-payment-event;
|
|
159
|
+
// everything else (kind is undefined or "subscription") keeps going
|
|
160
|
+
// through process-event, unchanged. Every handler handles idempotency
|
|
161
|
+
// internally via deterministic aggregate-id + stream-scan.
|
|
162
|
+
if (parsed.kind === BillingEventKinds.payment) {
|
|
163
|
+
const dispatched = await deps.dispatchWrite({
|
|
164
|
+
handlerQn: SubscriptionFoundationHandlers.processPaymentEvent,
|
|
165
|
+
tenantId: parsed.tenantId,
|
|
166
|
+
payload: {
|
|
167
|
+
providerEventId: parsed.providerEventId,
|
|
168
|
+
providerName: parsed.providerName,
|
|
169
|
+
providerCustomerId: parsed.providerCustomerId,
|
|
170
|
+
priceId: parsed.priceId,
|
|
171
|
+
rawPayload: parsed.rawPayload,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
return respondFromDispatch(
|
|
175
|
+
c,
|
|
176
|
+
dispatched,
|
|
177
|
+
"subscription_payment_webhook_processing_failed",
|
|
178
|
+
"Internal error processing payment event",
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
159
182
|
const dispatched = await deps.dispatchWrite({
|
|
160
183
|
handlerQn: SubscriptionFoundationHandlers.processEvent,
|
|
161
184
|
tenantId: parsed.tenantId,
|
|
@@ -171,26 +194,32 @@ export function createSubscriptionWebhookHandler(deps: SubscriptionWebhookDeps)
|
|
|
171
194
|
rawPayload: parsed.rawPayload,
|
|
172
195
|
},
|
|
173
196
|
});
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
error: {
|
|
181
|
-
code: "subscription_webhook_processing_failed",
|
|
182
|
-
message: "Internal error processing subscription event",
|
|
183
|
-
details: dispatched.error,
|
|
184
|
-
},
|
|
185
|
-
},
|
|
186
|
-
500,
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
return c.json({ processed: true, ...((dispatched.data as object) ?? {}) }, 200); // @cast-boundary engine-bridge
|
|
197
|
+
return respondFromDispatch(
|
|
198
|
+
c,
|
|
199
|
+
dispatched,
|
|
200
|
+
"subscription_webhook_processing_failed",
|
|
201
|
+
"Internal error processing subscription event",
|
|
202
|
+
);
|
|
191
203
|
};
|
|
192
204
|
}
|
|
193
205
|
|
|
206
|
+
/** Shared 500/200-mapping for both dispatch branches above. Internal error →
|
|
207
|
+
* provider should retry, hence 500 instead of 401/404 (transient, not a config-bug). */
|
|
208
|
+
function respondFromDispatch(
|
|
209
|
+
c: Context,
|
|
210
|
+
dispatched: Awaited<ReturnType<SubscriptionWebhookDeps["dispatchWrite"]>>,
|
|
211
|
+
errorCode: string,
|
|
212
|
+
errorMessage: string,
|
|
213
|
+
): Response {
|
|
214
|
+
if (!dispatched.isSuccess) {
|
|
215
|
+
return c.json(
|
|
216
|
+
{ error: { code: errorCode, message: errorMessage, details: dispatched.error } },
|
|
217
|
+
500,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return c.json({ processed: true, ...((dispatched.data as object) ?? {}) }, 200); // @cast-boundary engine-bridge
|
|
221
|
+
}
|
|
222
|
+
|
|
194
223
|
/**
|
|
195
224
|
* Convenience für TypeScript-IDE: `Hono.post(...)`-Call-Type damit der
|
|
196
225
|
* App-Owner-Code typed bleibt ohne Hono-types zu importieren.
|