@cosmicdrift/kumiko-bundled-features 0.296.0 → 0.299.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 +11 -37
- package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +159 -119
- package/src/billing-foundation/__tests__/webhook-handler.integration.test.ts +174 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/feature.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +1 -1
- package/src/billing-foundation/index.ts +2 -4
- package/src/billing-foundation/subscription-tier-sync.ts +110 -83
- package/src/billing-foundation/webhook-handler.ts +117 -187
- package/src/inbound-mail-foundation/__tests__/connect-routes.integration.test.ts +52 -81
- package/src/inbound-mail-foundation/changes.json +6 -0
- package/src/inbound-mail-foundation/connect-routes.ts +218 -197
- package/src/inbound-mail-foundation/index.ts +1 -2
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +7 -40
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +18 -60
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +0 -200
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
// → read-your-own-write semantics.
|
|
21
21
|
// 4. **process-event-handler**: programmatic write-handler den der
|
|
22
22
|
// webhook-handler aufruft, dispatcht zu type-passendem appendEvent.
|
|
23
|
-
// 5. **
|
|
24
|
-
// `/api/subscription/webhook/:providerName`.
|
|
23
|
+
// 5. **createSubscriptionWebhookRoute**: factory for the `entry:"signature"`
|
|
24
|
+
// extraRoute `/api/subscription/webhook/:providerName`.
|
|
25
25
|
// 6. **payment-received event + read_payments projection**: one-off-
|
|
26
26
|
// payments (checkout mode "payment") get their own event, own
|
|
27
27
|
// per-tenant aggregate, and own `process-payment-event` write-handler
|
|
@@ -85,7 +85,7 @@ import { paymentTenantDestroyHook, subscriptionTenantDestroyHook } from "./tenan
|
|
|
85
85
|
|
|
86
86
|
export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE, (r) => {
|
|
87
87
|
r.describe(
|
|
88
|
-
"Plugin host for subscription billing \u2014 manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `
|
|
88
|
+
"Plugin host for subscription billing \u2014 manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. Low-level building block \u2014 use `subscription-stripe` or `subscription-mollie` unless you are writing a new payment provider.",
|
|
89
89
|
);
|
|
90
90
|
r.uiHints({
|
|
91
91
|
displayLabel: "Billing \u00b7 Foundation",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// process-event — programmatic write-handler den der webhook-handler
|
|
2
|
-
// (
|
|
2
|
+
// (createSubscriptionWebhookRoute) aufruft NACHDEM Plugin den raw-body
|
|
3
3
|
// verifiziert + zu SubscriptionEvent normalisiert hat.
|
|
4
4
|
//
|
|
5
5
|
// **ES-Pattern:**
|
|
@@ -49,7 +49,6 @@ export {
|
|
|
49
49
|
effectiveTierFromSubscription,
|
|
50
50
|
SUBSCRIPTION_WEBHOOK_PATH,
|
|
51
51
|
type SubscriptionTierSyncDeps,
|
|
52
|
-
type SystemWriteResult,
|
|
53
52
|
} from "./subscription-tier-sync";
|
|
54
53
|
export type {
|
|
55
54
|
PaymentEvent,
|
|
@@ -57,7 +56,6 @@ export type {
|
|
|
57
56
|
SubscriptionProviderPlugin,
|
|
58
57
|
} from "./types";
|
|
59
58
|
export {
|
|
60
|
-
|
|
61
|
-
type
|
|
62
|
-
type SubscriptionWebhookHandler,
|
|
59
|
+
createSubscriptionWebhookRoute,
|
|
60
|
+
type SubscriptionWebhookRouteOptions,
|
|
63
61
|
} from "./webhook-handler";
|
|
@@ -1,38 +1,31 @@
|
|
|
1
1
|
// Generic Stripe/PayPal-webhook → tier-engine sync route. Extracted from the
|
|
2
2
|
// near-identical webhook-route.ts app copies in show-pony and publicstatus
|
|
3
|
-
// (infra#446) — the only per-app variables were the TierName union/default
|
|
4
|
-
//
|
|
3
|
+
// (infra#446) — the only per-app variables were the TierName union/default,
|
|
4
|
+
// so it is now a factory parameter.
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
TierEngineHandlers,
|
|
8
|
+
TierEngineQueries,
|
|
8
9
|
tierAssignmentAggregateId,
|
|
9
10
|
} from "@cosmicdrift/kumiko-bundled-features/tier-engine";
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
import type {
|
|
12
|
+
ExtraRouteDefinition,
|
|
13
|
+
SignatureExtraRouteDeps,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/api";
|
|
15
|
+
import type { TenantId, WriteResult } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
16
|
import { subscriptionAggregateId } from "./aggregate-id";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import type { SubscriptionProviderPlugin } from "./types";
|
|
17
|
-
import { createSubscriptionWebhookHandler } from "./webhook-handler";
|
|
17
|
+
import { SubscriptionFoundationQueries, SubscriptionStatuses } from "./constants";
|
|
18
|
+
import { createSubscriptionWebhookRoute } from "./webhook-handler";
|
|
18
19
|
|
|
20
|
+
// Outside /api — signature routes carry their own auth (verify()) and are
|
|
21
|
+
// not JWT-guarded, so provider dashboards (Stripe, PayPal, ...) point here.
|
|
19
22
|
export const SUBSCRIPTION_WEBHOOK_PATH = "/webhooks/subscription/:providerName";
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
};
|
|
26
|
-
|
|
24
|
+
// No db/tierAssignmentTable dep: extraRoutes are built before buildServer
|
|
25
|
+
// exists (no db at construction time), and handlers never see a raw db
|
|
26
|
+
// escape hatch either — the sync reads and writes exclusively through
|
|
27
|
+
// dispatchSystemQuery/dispatchSystemWrite in afterDispatch below.
|
|
27
28
|
export type SubscriptionTierSyncDeps<TTier extends string> = {
|
|
28
|
-
readonly db: DbRunner;
|
|
29
|
-
readonly registry: Registry;
|
|
30
|
-
readonly dispatchSystemWrite: (args: {
|
|
31
|
-
readonly handlerQn: string;
|
|
32
|
-
readonly payload: unknown;
|
|
33
|
-
readonly tenantId: TenantId;
|
|
34
|
-
}) => Promise<SystemWriteResult>;
|
|
35
|
-
readonly tierAssignmentTable: EntityTable<EntityDefinition>;
|
|
36
29
|
readonly isTierName: (value: string) => value is TTier;
|
|
37
30
|
readonly defaultTier: TTier;
|
|
38
31
|
// "log" (default): sync failure only warns, the webhook still reports
|
|
@@ -53,96 +46,130 @@ export function effectiveTierFromSubscription<TTier extends string>(
|
|
|
53
46
|
return usable && typeof tier === "string" && isTierName(tier) ? tier : defaultTier;
|
|
54
47
|
}
|
|
55
48
|
|
|
49
|
+
// Narrows an unknown dispatchSystemQuery result to its `{ rows }` list
|
|
50
|
+
// shape without an unchecked cast — a query returning something else
|
|
51
|
+
// (handler bug, or the handlerQn resolving to a non-list query) fails
|
|
52
|
+
// loudly instead of crashing on `.find` against `undefined`.
|
|
53
|
+
function asRows(result: unknown): ReadonlyArray<Record<string, unknown>> {
|
|
54
|
+
if (
|
|
55
|
+
typeof result === "object" &&
|
|
56
|
+
result !== null &&
|
|
57
|
+
Array.isArray((result as { rows?: unknown }).rows)
|
|
58
|
+
) {
|
|
59
|
+
return (result as { rows: ReadonlyArray<Record<string, unknown>> }).rows;
|
|
60
|
+
}
|
|
61
|
+
throw new Error("expected a { rows: [...] } list-query result");
|
|
62
|
+
}
|
|
63
|
+
|
|
56
64
|
export function createSubscriptionTierSync<TTier extends string>(
|
|
57
65
|
deps: SubscriptionTierSyncDeps<TTier>,
|
|
58
66
|
) {
|
|
59
67
|
async function syncTierFromSubscription(
|
|
60
68
|
tenantId: TenantId,
|
|
69
|
+
routeDeps: SignatureExtraRouteDeps,
|
|
61
70
|
): Promise<{ code: string; message: string } | null> {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
try {
|
|
72
|
+
const subscriptionId = subscriptionAggregateId(tenantId);
|
|
73
|
+
const subscriptionRows = asRows(
|
|
74
|
+
await routeDeps.dispatchSystemQuery({
|
|
75
|
+
handlerQn: SubscriptionFoundationQueries.listSubscriptions,
|
|
76
|
+
payload: {},
|
|
77
|
+
tenantId,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
const sub = subscriptionRows.find((row) => row["id"] === subscriptionId);
|
|
81
|
+
if (!sub) return null;
|
|
82
|
+
|
|
83
|
+
const effective = effectiveTierFromSubscription(
|
|
84
|
+
typeof sub["status"] === "string" ? sub["status"] : undefined,
|
|
85
|
+
typeof sub["tier"] === "string" ? sub["tier"] : undefined,
|
|
86
|
+
deps.isTierName,
|
|
87
|
+
deps.defaultTier,
|
|
88
|
+
);
|
|
68
89
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
90
|
+
const tierAssignmentRows = asRows(
|
|
91
|
+
await routeDeps.dispatchSystemQuery({
|
|
92
|
+
handlerQn: TierEngineQueries.list,
|
|
93
|
+
payload: {},
|
|
94
|
+
tenantId,
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
const assignment = tierAssignmentRows.find((row) => row["tenantId"] === tenantId);
|
|
98
|
+
if (
|
|
99
|
+
!assignment ||
|
|
100
|
+
typeof assignment["id"] !== "string" ||
|
|
101
|
+
typeof assignment["version"] !== "number"
|
|
102
|
+
) {
|
|
103
|
+
const created = await routeDeps.dispatchSystemWrite({
|
|
104
|
+
handlerQn: TierEngineHandlers.create,
|
|
105
|
+
payload: { id: tierAssignmentAggregateId(tenantId), tier: effective },
|
|
106
|
+
tenantId,
|
|
107
|
+
});
|
|
108
|
+
if (!created.isSuccess) {
|
|
109
|
+
return {
|
|
110
|
+
code: "tier_sync_failed",
|
|
111
|
+
message: `tier-engine create with "${effective}" failed: ${created.error?.code ?? "unknown"}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
if (assignment["tier"] === effective) return null;
|
|
75
117
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
typeof assignment.id !== "string" ||
|
|
84
|
-
typeof assignment.version !== "number"
|
|
85
|
-
) {
|
|
86
|
-
const created = await deps.dispatchSystemWrite({
|
|
87
|
-
handlerQn: TierEngineHandlers.create,
|
|
88
|
-
payload: { id: tierAssignmentAggregateId(tenantId), tier: effective },
|
|
118
|
+
const result = await routeDeps.dispatchSystemWrite({
|
|
119
|
+
handlerQn: TierEngineHandlers.update,
|
|
120
|
+
payload: {
|
|
121
|
+
id: assignment["id"],
|
|
122
|
+
version: assignment["version"],
|
|
123
|
+
changes: { tier: effective },
|
|
124
|
+
},
|
|
89
125
|
tenantId,
|
|
90
126
|
});
|
|
91
|
-
if (!
|
|
127
|
+
if (!result.isSuccess) {
|
|
92
128
|
return {
|
|
93
129
|
code: "tier_sync_failed",
|
|
94
|
-
message: `tier-engine
|
|
130
|
+
message: `tier-engine update to "${effective}" failed: ${result.error?.code ?? "unknown"}`,
|
|
95
131
|
};
|
|
96
132
|
}
|
|
97
133
|
return null;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
tenantId,
|
|
105
|
-
});
|
|
106
|
-
if (!result.isSuccess) {
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// dispatchSystemQuery throws (AccessDenied/NotFound/Validation) rather
|
|
136
|
+
// than returning an error envelope — e.g. a consumer that mounts
|
|
137
|
+
// billing-foundation without tier-engine. Surface it the same way as
|
|
138
|
+
// a failed dispatchSystemWrite instead of letting it bubble into the
|
|
139
|
+
// webhook response as an unhandled 500.
|
|
107
140
|
return {
|
|
108
141
|
code: "tier_sync_failed",
|
|
109
|
-
message:
|
|
142
|
+
message: error instanceof Error ? error.message : "tier sync threw a non-Error value",
|
|
110
143
|
};
|
|
111
144
|
}
|
|
112
|
-
return null;
|
|
113
145
|
}
|
|
114
146
|
|
|
115
|
-
function
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
handlerQn,
|
|
121
|
-
payload,
|
|
122
|
-
tenantId: targetTenantId,
|
|
123
|
-
});
|
|
124
|
-
if (!result.isSuccess) return result;
|
|
125
|
-
const syncError = await syncTierFromSubscription(targetTenantId);
|
|
147
|
+
function createWebhookRoute(): ExtraRouteDefinition {
|
|
148
|
+
return createSubscriptionWebhookRoute({
|
|
149
|
+
path: SUBSCRIPTION_WEBHOOK_PATH,
|
|
150
|
+
afterDispatch: async (dispatched: WriteResult, tenantId, routeDeps): Promise<WriteResult> => {
|
|
151
|
+
const syncError = await syncTierFromSubscription(tenantId, routeDeps);
|
|
126
152
|
if (syncError) {
|
|
127
153
|
// biome-ignore lint/suspicious/noConsole: operator visibility for a post-commit sync failure
|
|
128
154
|
console.warn(
|
|
129
|
-
`[subscription-tier-sync] tier sync failed for tenant ${
|
|
155
|
+
`[subscription-tier-sync] tier sync failed for tenant ${tenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
|
|
130
156
|
);
|
|
131
157
|
if (deps.onSyncError === "fail-webhook") {
|
|
132
|
-
return {
|
|
158
|
+
return {
|
|
159
|
+
isSuccess: false,
|
|
160
|
+
error: {
|
|
161
|
+
code: syncError.code,
|
|
162
|
+
httpStatus: 500,
|
|
163
|
+
i18nKey: "errors.subscriptionTierSyncFailed",
|
|
164
|
+
message: syncError.message,
|
|
165
|
+
},
|
|
166
|
+
};
|
|
133
167
|
}
|
|
134
168
|
}
|
|
135
|
-
return
|
|
136
|
-
},
|
|
137
|
-
resolveProvider: (providerName) => {
|
|
138
|
-
const usage = deps.registry
|
|
139
|
-
.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION)
|
|
140
|
-
.find((u) => u.entityName === providerName);
|
|
141
|
-
return usage?.options as SubscriptionProviderPlugin | undefined;
|
|
169
|
+
return dispatched;
|
|
142
170
|
},
|
|
143
171
|
});
|
|
144
|
-
app.post(SUBSCRIPTION_WEBHOOK_PATH, handler);
|
|
145
172
|
}
|
|
146
173
|
|
|
147
|
-
return {
|
|
174
|
+
return { createWebhookRoute };
|
|
148
175
|
}
|
|
@@ -1,213 +1,153 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// `/api/subscription/webhook/stripe`, PayPal auf
|
|
7
|
-
// `/api/subscription/webhook/paypal`. Eine Hono-Route, alle Plugins
|
|
8
|
-
// gleichzeitig aktiv.
|
|
9
|
-
//
|
|
10
|
-
// Beispiel-Verwendung in bin/server.ts (runDevApp wie runProdApp liefern
|
|
11
|
-
// `registry` + `dispatchSystemWrite` in den extraRoutes-deps):
|
|
12
|
-
//
|
|
13
|
-
// await runDevApp({
|
|
14
|
-
// features: APP_FEATURES,
|
|
15
|
-
// extraRoutes: (app, deps) => {
|
|
16
|
-
// const handler = createSubscriptionWebhookHandler({
|
|
17
|
-
// dispatchWrite: ({ handlerQn, payload, tenantId }) =>
|
|
18
|
-
// deps.dispatchSystemWrite({ handlerQn, payload, tenantId: tenantId as TenantId }),
|
|
19
|
-
// resolveProvider: (name) =>
|
|
20
|
-
// deps.registry.getExtensionUsages("subscriptionProvider")
|
|
21
|
-
// .find((u) => u.entityName === name)?.options as
|
|
22
|
-
// SubscriptionProviderPlugin | undefined,
|
|
23
|
-
// });
|
|
24
|
-
// app.post("/api/subscription/webhook/:providerName", handler);
|
|
25
|
-
// },
|
|
26
|
-
// });
|
|
27
|
-
//
|
|
28
|
-
// Was der handler macht:
|
|
29
|
-
// 1. providerName aus dem URL-Pfad lesen
|
|
30
|
-
// 2. raw-body via c.req.text() lesen (NICHT JSON-parsen — Stripe-Sig
|
|
31
|
-
// prüft exakte bytes)
|
|
32
|
-
// 3. Headers sammeln + an Plugin durchreichen
|
|
33
|
-
// 4. Plugin-Lookup im Registry via "subscriptionProvider"-extension
|
|
34
|
-
// und dem providerName aus dem URL-Pfad
|
|
35
|
-
// 5. plugin.verifyAndParseWebhook(raw, headers, ctx) → SubscriptionEvent | null
|
|
36
|
-
// 6. Bei null (= Plugin filtert event-type raus): 200 OK ohne side-effects
|
|
37
|
-
// 7. Bei Event: ctx.write("billing-foundation:write:process-event")
|
|
38
|
-
// mit der vom Plugin aufgelösten tenantId
|
|
39
|
-
// 8. Returnt 200 OK an Provider
|
|
40
|
-
//
|
|
41
|
-
// **Auth:** kein JWT/Cookie. Authentifizierung läuft via Provider-
|
|
42
|
-
// Webhook-Sig im Plugin. Kein `c.get("user")`-call hier.
|
|
1
|
+
// Multi-provider: the plugin is selected via the `:providerName` path-param
|
|
2
|
+
// (Stripe → /stripe, PayPal → /paypal), one route mounts every plugin at once.
|
|
3
|
+
// rawBody is intentionally NOT JSON-parsed before verify() — Stripe's
|
|
4
|
+
// signature check needs the exact bytes. No JWT/cookie auth: the provider's
|
|
5
|
+
// webhook signature in verify() IS the auth, so no c.get("user") here.
|
|
43
6
|
|
|
44
|
-
import
|
|
45
|
-
import type { Context
|
|
7
|
+
import { ExtraRouteRejection, signatureRoute } from "@cosmicdrift/kumiko-framework/api";
|
|
8
|
+
import type { Context } from "hono";
|
|
46
9
|
import {
|
|
47
10
|
BILLING_FOUNDATION_FEATURE,
|
|
48
11
|
BillingEventKinds,
|
|
49
12
|
SUBSCRIPTION_PROVIDER_EXTENSION,
|
|
50
13
|
SubscriptionFoundationHandlers,
|
|
51
14
|
} from "./constants";
|
|
52
|
-
import type { SubscriptionProviderPlugin } from "./types";
|
|
15
|
+
import type { PaymentEvent, SubscriptionEvent, SubscriptionProviderPlugin } from "./types";
|
|
53
16
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
readonly error?: unknown;
|
|
70
|
-
}>;
|
|
17
|
+
export type SubscriptionWebhookRouteOptions = {
|
|
18
|
+
/** Route path — MUST carry the `:providerName` path-param. Default
|
|
19
|
+
* "/api/subscription/webhook/:providerName". */
|
|
20
|
+
readonly path?: string;
|
|
21
|
+
/** Runs after a successful dispatchSystemWrite, before the 200 response —
|
|
22
|
+
* lets callers (e.g. subscription-tier-sync) chain a side-effect without
|
|
23
|
+
* duplicating the provider-resolve/payload-mapping logic above. Returning
|
|
24
|
+
* a failed WriteResult turns the response into the same 500 a dispatch
|
|
25
|
+
* failure would produce. */
|
|
26
|
+
readonly afterDispatch?: (
|
|
27
|
+
dispatched: import("@cosmicdrift/kumiko-framework/engine").WriteResult,
|
|
28
|
+
tenantId: import("@cosmicdrift/kumiko-framework/engine").TenantId,
|
|
29
|
+
deps: import("@cosmicdrift/kumiko-framework/api").SignatureExtraRouteDeps,
|
|
30
|
+
) => Promise<import("@cosmicdrift/kumiko-framework/engine").WriteResult>;
|
|
31
|
+
};
|
|
71
32
|
|
|
72
|
-
|
|
73
|
-
* Pfad und returnt den passenden Plugin (= entityName-match in
|
|
74
|
-
* registry.getExtensionUsages("subscriptionProvider")). */
|
|
75
|
-
readonly resolveProvider: (providerName: string) => SubscriptionProviderPlugin | undefined;
|
|
33
|
+
const DEFAULT_WEBHOOK_PATH = "/api/subscription/webhook/:providerName";
|
|
76
34
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
35
|
+
function resolveProvider(
|
|
36
|
+
registry: import("@cosmicdrift/kumiko-framework/engine").Registry,
|
|
37
|
+
providerName: string,
|
|
38
|
+
): SubscriptionProviderPlugin | undefined {
|
|
39
|
+
return registry
|
|
40
|
+
.getExtensionUsages(SUBSCRIPTION_PROVIDER_EXTENSION)
|
|
41
|
+
.find((u) => u.entityName === providerName)?.options as SubscriptionProviderPlugin | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type VerifiedWebhook = {
|
|
45
|
+
readonly providerName: string;
|
|
46
|
+
readonly event: SubscriptionEvent | PaymentEvent | null;
|
|
85
47
|
};
|
|
86
48
|
|
|
87
49
|
/**
|
|
88
|
-
*
|
|
89
|
-
* `
|
|
50
|
+
* Builds the `entry:"signature"` extraRoute definition. Mount via
|
|
51
|
+
* `extraRoutes: [createSubscriptionWebhookRoute()]`.
|
|
90
52
|
*/
|
|
91
|
-
export function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
53
|
+
export function createSubscriptionWebhookRoute(options: SubscriptionWebhookRouteOptions = {}) {
|
|
54
|
+
const path = options.path ?? DEFAULT_WEBHOOK_PATH;
|
|
55
|
+
return signatureRoute<VerifiedWebhook>({
|
|
56
|
+
method: "POST",
|
|
57
|
+
path,
|
|
58
|
+
entry: "signature",
|
|
59
|
+
verify: async ({ rawBody, headers, params }, deps) => {
|
|
60
|
+
const providerName = params["providerName"];
|
|
61
|
+
if (!providerName) {
|
|
62
|
+
throw new ExtraRouteRejection(
|
|
63
|
+
400,
|
|
64
|
+
{
|
|
65
|
+
error: {
|
|
66
|
+
code: "subscription_provider_path_missing",
|
|
67
|
+
message: `${BILLING_FOUNDATION_FEATURE}: mount the route with a :providerName path-param so each provider has its own URL (Stripe-Dashboard → /stripe, PayPal-Dashboard → /paypal).`,
|
|
68
|
+
},
|
|
101
69
|
},
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// 3. Plugin-Lookup via path-segment. Jeder gemountete Plugin hat
|
|
115
|
-
// sich mit `r.useExtension("subscriptionProvider", entityName,
|
|
116
|
-
// {...})` registriert; entityName matcht hier den path-segment.
|
|
117
|
-
const plugin = deps.resolveProvider(providerName);
|
|
118
|
-
if (!plugin) {
|
|
119
|
-
return c.json(
|
|
120
|
-
{
|
|
121
|
-
error: {
|
|
122
|
-
code: "subscription_provider_not_registered",
|
|
123
|
-
message: `${BILLING_FOUNDATION_FEATURE}: provider "${providerName}" not registered as '${SUBSCRIPTION_PROVIDER_EXTENSION}'-plugin. Mount the matching subscription-${providerName} feature.`,
|
|
70
|
+
"subscription webhook route mounted without :providerName",
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const plugin = resolveProvider(deps.registry, providerName);
|
|
74
|
+
if (!plugin) {
|
|
75
|
+
throw new ExtraRouteRejection(
|
|
76
|
+
404,
|
|
77
|
+
{
|
|
78
|
+
error: {
|
|
79
|
+
code: "subscription_provider_not_registered",
|
|
80
|
+
message: `${BILLING_FOUNDATION_FEATURE}: provider "${providerName}" not registered as '${SUBSCRIPTION_PROVIDER_EXTENSION}'-plugin. Mount the matching subscription-${providerName} feature.`,
|
|
81
|
+
},
|
|
124
82
|
},
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
{
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
83
|
+
`subscription provider "${providerName}" not registered`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
// Throws on sig-mismatch — the ExtraRoute wrapper maps any non-
|
|
87
|
+
// ExtraRouteRejection throw to 401 extra_route_signature_invalid
|
|
88
|
+
// (= config-bug, retry won't help, provider stop).
|
|
89
|
+
const event = await plugin.verifyAndParseWebhook(rawBody, headers, deps.secrets);
|
|
90
|
+
return { providerName, event };
|
|
91
|
+
},
|
|
92
|
+
handler: async (c: Context, verified, deps) => {
|
|
93
|
+
if (verified.event === null) {
|
|
94
|
+
return c.json({ ignored: true }, 200);
|
|
95
|
+
}
|
|
96
|
+
const parsed = verified.event;
|
|
97
|
+
const tenantId = parsed.tenantId as import("@cosmicdrift/kumiko-framework/engine").TenantId;
|
|
98
|
+
if (parsed.kind === BillingEventKinds.payment) {
|
|
99
|
+
const dispatched = await deps.dispatchSystemWrite({
|
|
100
|
+
handlerQn: SubscriptionFoundationHandlers.processPaymentEvent,
|
|
101
|
+
tenantId,
|
|
102
|
+
payload: {
|
|
103
|
+
providerEventId: parsed.providerEventId,
|
|
104
|
+
providerName: parsed.providerName,
|
|
105
|
+
providerCustomerId: parsed.providerCustomerId,
|
|
106
|
+
priceId: parsed.priceId,
|
|
107
|
+
rawPayload: parsed.rawPayload,
|
|
145
108
|
},
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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,
|
|
109
|
+
});
|
|
110
|
+
return respondFromDispatch(
|
|
111
|
+
c,
|
|
112
|
+
dispatched.isSuccess && options.afterDispatch
|
|
113
|
+
? await options.afterDispatch(dispatched, tenantId, deps)
|
|
114
|
+
: dispatched,
|
|
115
|
+
"subscription_payment_webhook_processing_failed",
|
|
116
|
+
"Internal error processing payment event",
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const dispatched = await deps.dispatchSystemWrite({
|
|
120
|
+
handlerQn: SubscriptionFoundationHandlers.processEvent,
|
|
121
|
+
tenantId,
|
|
166
122
|
payload: {
|
|
167
123
|
providerEventId: parsed.providerEventId,
|
|
168
124
|
providerName: parsed.providerName,
|
|
125
|
+
type: parsed.type,
|
|
169
126
|
providerCustomerId: parsed.providerCustomerId,
|
|
170
|
-
|
|
127
|
+
providerSubscriptionId: parsed.providerSubscriptionId,
|
|
128
|
+
status: parsed.status,
|
|
129
|
+
tier: parsed.tier,
|
|
130
|
+
currentPeriodEndIso: parsed.currentPeriodEnd,
|
|
171
131
|
rawPayload: parsed.rawPayload,
|
|
172
132
|
},
|
|
173
133
|
});
|
|
174
134
|
return respondFromDispatch(
|
|
175
135
|
c,
|
|
176
|
-
dispatched
|
|
177
|
-
|
|
178
|
-
|
|
136
|
+
dispatched.isSuccess && options.afterDispatch
|
|
137
|
+
? await options.afterDispatch(dispatched, tenantId, deps)
|
|
138
|
+
: dispatched,
|
|
139
|
+
"subscription_webhook_processing_failed",
|
|
140
|
+
"Internal error processing subscription event",
|
|
179
141
|
);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
const dispatched = await deps.dispatchWrite({
|
|
183
|
-
handlerQn: SubscriptionFoundationHandlers.processEvent,
|
|
184
|
-
tenantId: parsed.tenantId,
|
|
185
|
-
payload: {
|
|
186
|
-
providerEventId: parsed.providerEventId,
|
|
187
|
-
providerName: parsed.providerName,
|
|
188
|
-
type: parsed.type,
|
|
189
|
-
providerCustomerId: parsed.providerCustomerId,
|
|
190
|
-
providerSubscriptionId: parsed.providerSubscriptionId,
|
|
191
|
-
status: parsed.status,
|
|
192
|
-
tier: parsed.tier,
|
|
193
|
-
currentPeriodEndIso: parsed.currentPeriodEnd,
|
|
194
|
-
rawPayload: parsed.rawPayload,
|
|
195
|
-
},
|
|
196
|
-
});
|
|
197
|
-
return respondFromDispatch(
|
|
198
|
-
c,
|
|
199
|
-
dispatched,
|
|
200
|
-
"subscription_webhook_processing_failed",
|
|
201
|
-
"Internal error processing subscription event",
|
|
202
|
-
);
|
|
203
|
-
};
|
|
142
|
+
},
|
|
143
|
+
});
|
|
204
144
|
}
|
|
205
145
|
|
|
206
146
|
/** Shared 500/200-mapping for both dispatch branches above. Internal error →
|
|
207
147
|
* provider should retry, hence 500 instead of 401/404 (transient, not a config-bug). */
|
|
208
148
|
function respondFromDispatch(
|
|
209
149
|
c: Context,
|
|
210
|
-
dispatched:
|
|
150
|
+
dispatched: import("@cosmicdrift/kumiko-framework/engine").WriteResult,
|
|
211
151
|
errorCode: string,
|
|
212
152
|
errorMessage: string,
|
|
213
153
|
): Response {
|
|
@@ -219,13 +159,3 @@ function respondFromDispatch(
|
|
|
219
159
|
}
|
|
220
160
|
return c.json({ processed: true, ...((dispatched.data as object) ?? {}) }, 200); // @cast-boundary engine-bridge
|
|
221
161
|
}
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Convenience für TypeScript-IDE: `Hono.post(...)`-Call-Type damit der
|
|
225
|
-
* App-Owner-Code typed bleibt ohne Hono-types zu importieren.
|
|
226
|
-
*/
|
|
227
|
-
export type SubscriptionWebhookHandler = ReturnType<typeof createSubscriptionWebhookHandler>;
|
|
228
|
-
|
|
229
|
-
// Re-export für convenience: App-Owner kann den TenantId-type aus dem
|
|
230
|
-
// gleichen Modul importieren (vermeidet ein zweites Framework-import).
|
|
231
|
-
export type { Hono, TenantId };
|