@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.
@@ -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. **createSubscriptionWebhookHandler**: factory für die HTTP-Route
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 `createSubscriptionWebhookHandler` factory for the `/api/subscription/webhook/:providerName` route. Low-level building block \u2014 use `subscription-stripe` or `subscription-mollie` unless you are writing a new payment provider.",
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
- // (createSubscriptionWebhookHandler) aufruft NACHDEM Plugin den raw-body
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
- createSubscriptionWebhookHandler,
61
- type SubscriptionWebhookDeps,
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
- // and the tier-assignment table handle, so both are now factory parameters.
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 { type DbRunner, type EntityTable, fetchOne } from "@cosmicdrift/kumiko-framework/db";
11
- import type { EntityDefinition, Registry, TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
- import type { Hono } from "hono";
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 { SUBSCRIPTION_PROVIDER_EXTENSION, SubscriptionStatuses } from "./constants";
15
- import { subscriptionsProjectionTable } from "./projection";
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
- export type SystemWriteResult = {
22
- readonly isSuccess: boolean;
23
- readonly data?: unknown;
24
- readonly error?: { readonly code?: string; readonly message?: string };
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
- const sub = await fetchOne<{ status?: unknown; tier?: unknown }>(
63
- deps.db,
64
- subscriptionsProjectionTable,
65
- { id: subscriptionAggregateId(tenantId) },
66
- );
67
- if (!sub) return null;
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
- const effective = effectiveTierFromSubscription(
70
- typeof sub.status === "string" ? sub.status : undefined,
71
- typeof sub.tier === "string" ? sub.tier : undefined,
72
- deps.isTierName,
73
- deps.defaultTier,
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
- const assignment = await fetchOne<{ id?: unknown; version?: unknown; tier?: unknown }>(
77
- deps.db,
78
- deps.tierAssignmentTable,
79
- { tenantId },
80
- );
81
- if (
82
- !assignment ||
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 (!created.isSuccess) {
127
+ if (!result.isSuccess) {
92
128
  return {
93
129
  code: "tier_sync_failed",
94
- message: `tier-engine create with "${effective}" failed: ${created.error?.code ?? "unknown"}`,
130
+ message: `tier-engine update to "${effective}" failed: ${result.error?.code ?? "unknown"}`,
95
131
  };
96
132
  }
97
133
  return null;
98
- }
99
- if (assignment.tier === effective) return null;
100
-
101
- const result = await deps.dispatchSystemWrite({
102
- handlerQn: TierEngineHandlers.update,
103
- payload: { id: assignment.id, version: assignment.version, changes: { tier: effective } },
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: `tier-engine update to "${effective}" failed: ${result.error?.code ?? "unknown"}`,
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 wireSubscriptionWebhookRoute(app: Hono): void {
116
- const handler = createSubscriptionWebhookHandler({
117
- dispatchWrite: async ({ handlerQn, payload, tenantId }) => {
118
- const targetTenantId = tenantId as TenantId;
119
- const result = await deps.dispatchSystemWrite({
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 ${targetTenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
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 { isSuccess: false, error: syncError };
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 result;
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 { wireSubscriptionWebhookRoute };
174
+ return { createWebhookRoute };
148
175
  }
@@ -1,213 +1,153 @@
1
- // createSubscriptionWebhookHandler — Hono-route-factory den der App-
2
- // Owner via `extraRoutes` in seinem bin/server.ts mountet.
3
- //
4
- // **Multi-Provider:** der Plugin wird via Pfad-Parameter
5
- // `:providerName` ausgewählt — Stripe-Dashboard zeigt auf
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 type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
45
- import type { Context, Hono } from "hono";
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
- * Dependencies the App-Owner gibt dem webhook-handler — beide direkt aus
56
- * den `extraRoutes`-deps ableitbar (`dispatchSystemWrite` + `registry`),
57
- * siehe Beispiel im Header.
58
- */
59
- export type SubscriptionWebhookDeps = {
60
- /** Schreibt durch den Standard-Dispatcher mit einem auto-konstruierten
61
- * SystemUser. Muss `process-event` als SystemAdmin durchlassen. */
62
- readonly dispatchWrite: (args: {
63
- readonly handlerQn: string;
64
- readonly payload: unknown;
65
- readonly tenantId: string;
66
- }) => Promise<{
67
- readonly isSuccess: boolean;
68
- readonly data?: unknown;
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
- /** Plugin-Lookup-Function — bekommt den providerName aus dem URL-
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
- /** Optionaler system-scoped SecretsContext, durchgereicht an
78
- * `verifyAndParseWebhook` (3. Arg). Erlaubt Plugins, ihre app-wide-
79
- * Credentials (Stripe api-key/webhook-secret) zur Laufzeit aus
80
- * secrets unter SYSTEM_TENANT_ID zu lesen statt aus einem mount-time-
81
- * Closure. Der App-Owner baut ihn via `createSecretsContext({ db,
82
- * masterKeyProvider })` aus den extraRoutes-deps. Fehlt er, fallen
83
- * Plugins auf ihren Closure-Fallback zurück. */
84
- readonly systemSecrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
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
- * Returnt einen Hono-handler. Mounten via
89
- * `app.post("/api/subscription/webhook/:providerName", handler)`.
50
+ * Builds the `entry:"signature"` extraRoute definition. Mount via
51
+ * `extraRoutes: [createSubscriptionWebhookRoute()]`.
90
52
  */
91
- export function createSubscriptionWebhookHandler(deps: SubscriptionWebhookDeps) {
92
- return async (c: Context): Promise<Response> => {
93
- // 1. providerName aus URL-Pfad. Hono-Standard via c.req.param.
94
- const providerName = c.req.param("providerName");
95
- if (!providerName || providerName.length === 0) {
96
- return c.json(
97
- {
98
- error: {
99
- code: "subscription_provider_path_missing",
100
- message: `${BILLING_FOUNDATION_FEATURE}: Mount the route as POST /api/subscription/webhook/:providerName so each provider has its own URL (Stripe-Dashboard → /stripe, PayPal-Dashboard → /paypal).`,
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
- 400,
104
- );
105
- }
106
-
107
- // 2. Raw-body. Provider-Sigs werden gegen die exakten bytes verifiziert.
108
- const rawBody = await c.req.text();
109
- const headers: Record<string, string> = {};
110
- c.req.raw.headers.forEach((value, key) => {
111
- headers[key.toLowerCase()] = value;
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
- 404,
127
- );
128
- }
129
-
130
- // 4. Plugin verifies + parses. **Pre-tenant-resolution** — kein
131
- // ctx, Plugin liest seinen webhook-secret aus eigener
132
- // module-load-Closure (ENV-VAR oder system-config).
133
- // Throws on sig-mismatch — wir mappen auf 401 (= "config-bug,
134
- // retry won't help, Provider stopp").
135
- let parsed: Awaited<ReturnType<SubscriptionProviderPlugin["verifyAndParseWebhook"]>>;
136
- try {
137
- parsed = await plugin.verifyAndParseWebhook(rawBody, headers, deps.systemSecrets);
138
- } catch (e) {
139
- const msg = e instanceof Error ? e.message : String(e);
140
- return c.json(
141
- {
142
- error: {
143
- code: "subscription_webhook_signature_invalid",
144
- message: `Plugin "${providerName}" rejected webhook: ${msg}`,
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
- 401,
148
- );
149
- }
150
-
151
- // 5. Plugin returned null = "ich kenne diesen event-type nicht / ist
152
- // nicht relevant". 200 OK damit der Provider keine retries macht.
153
- if (parsed === null) {
154
- return c.json({ ignored: true }, 200);
155
- }
156
-
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,
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
- priceId: parsed.priceId,
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
- "subscription_payment_webhook_processing_failed",
178
- "Internal error processing payment event",
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: Awaited<ReturnType<SubscriptionWebhookDeps["dispatchWrite"]>>,
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 };