@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.251.0",
3
+ "version": "0.252.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -130,12 +130,12 @@
130
130
  "./workflow-runner": "./src/workflow-runner/index.ts"
131
131
  },
132
132
  "dependencies": {
133
- "@cosmicdrift/kumiko-dispatcher-live": "0.251.0",
134
- "@cosmicdrift/kumiko-framework": "0.251.0",
135
- "@cosmicdrift/kumiko-headless": "0.251.0",
136
- "@cosmicdrift/kumiko-renderer": "0.251.0",
137
- "@cosmicdrift/kumiko-renderer-web": "0.251.0",
138
- "@cosmicdrift/kumiko-types": "0.251.0",
133
+ "@cosmicdrift/kumiko-dispatcher-live": "0.252.0",
134
+ "@cosmicdrift/kumiko-framework": "0.252.0",
135
+ "@cosmicdrift/kumiko-headless": "0.252.0",
136
+ "@cosmicdrift/kumiko-renderer": "0.252.0",
137
+ "@cosmicdrift/kumiko-renderer-web": "0.252.0",
138
+ "@cosmicdrift/kumiko-types": "0.252.0",
139
139
  "@mollie/api-client": "^4.5.0",
140
140
  "@node-rs/argon2": "^2.0.2",
141
141
  "@types/mailparser": "^3.4.6",
@@ -164,7 +164,7 @@
164
164
  "devDependencies": {
165
165
  "@testing-library/user-event": "^14.6.1",
166
166
  "@types/qrcode": "^1.5.5",
167
- "@cosmicdrift/kumiko-locale-de": "0.251.0",
168
- "@cosmicdrift/kumiko-locale-es": "0.251.0"
167
+ "@cosmicdrift/kumiko-locale-de": "0.252.0",
168
+ "@cosmicdrift/kumiko-locale-es": "0.252.0"
169
169
  }
170
170
  }
@@ -22,7 +22,7 @@ import {
22
22
  PII_ERASED_SENTINEL,
23
23
  } from "@cosmicdrift/kumiko-framework/crypto";
24
24
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
25
- import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
25
+ import { defineFeature, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
26
26
  import {
27
27
  createEventsTable,
28
28
  isStreamArchived,
@@ -36,6 +36,7 @@ import {
36
36
  unsafeCreateEntityTable,
37
37
  } from "@cosmicdrift/kumiko-framework/stack";
38
38
  import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
39
+ import { Hono } from "hono";
39
40
  import {
40
41
  ComplianceProfileHandlers,
41
42
  createComplianceProfilesFeature,
@@ -44,16 +45,18 @@ import {
44
45
  import { createConfigFeature } from "../../config";
45
46
  import { createTenantFeature } from "../../tenant/feature";
46
47
  import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
47
- import { subscriptionAggregateId } from "../aggregate-id";
48
+ import { paymentAggregateId, subscriptionAggregateId } from "../aggregate-id";
48
49
  import {
50
+ BillingEventKinds,
49
51
  SubscriptionEventTypes,
50
52
  SubscriptionFoundationHandlers,
51
53
  SubscriptionStatuses,
52
54
  } from "../constants";
53
55
  import { billingFoundationFeature } from "../feature";
54
- import { subscriptionsProjectionTable } from "../projection";
56
+ import { paymentsProjectionTable, subscriptionsProjectionTable } from "../projection";
55
57
  import { subscriptionTenantDestroyHook } from "../tenant-destroy-hook";
56
- import type { SubscriptionProviderPlugin } from "../types";
58
+ import type { PaymentEvent, SubscriptionProviderPlugin } from "../types";
59
+ import { createSubscriptionWebhookHandler } from "../webhook-handler";
57
60
 
58
61
  // =============================================================================
59
62
  // Mock-plugin für create-checkout-session + create-portal-session-Tests.
@@ -98,12 +101,35 @@ const mockProviderFeature = defineFeature("test-mock-provider", (r) => {
98
101
  r.useExtension("subscriptionProvider", "mock", plugin);
99
102
  });
100
103
 
104
+ // Second mock provider — parses its rawBody straight into a PaymentEvent
105
+ // (no real Stripe-signing needed here, that's subscription-stripe's job).
106
+ // Exercises the webhook-handler's payment branch end-to-end (scenario 11).
107
+ const mockPaymentProviderFeature = defineFeature("test-mock-payment-provider", (r) => {
108
+ r.requires("billing-foundation");
109
+ const plugin: SubscriptionProviderPlugin = {
110
+ verifyAndParseWebhook: async (rawBody): Promise<PaymentEvent | null> => {
111
+ const parsed = JSON.parse(rawBody) as Record<string, string>;
112
+ return {
113
+ kind: BillingEventKinds.payment,
114
+ providerEventId: parsed["providerEventId"] ?? "evt_mock_payment",
115
+ providerName: "mock-payment",
116
+ tenantId: parsed["tenantId"] ?? "tenant-mock",
117
+ providerCustomerId: parsed["providerCustomerId"] ?? "cus_mock",
118
+ priceId: parsed["priceId"] ?? "price_mock",
119
+ rawPayload: rawBody,
120
+ };
121
+ },
122
+ };
123
+ r.useExtension("subscriptionProvider", "mock-payment", plugin);
124
+ });
125
+
101
126
  // =============================================================================
102
127
  // Setup
103
128
  // =============================================================================
104
129
 
105
130
  let stack: TestStack;
106
131
  let db: DbConnection;
132
+ let paymentWebhookApp: Hono;
107
133
 
108
134
  beforeAll(async () => {
109
135
  stack = await setupTestStack({
@@ -114,6 +140,7 @@ beforeAll(async () => {
114
140
  createTenantLifecycleFeature(),
115
141
  billingFoundationFeature,
116
142
  mockProviderFeature,
143
+ mockPaymentProviderFeature,
117
144
  ],
118
145
  });
119
146
  db = stack.db;
@@ -126,6 +153,34 @@ beforeAll(async () => {
126
153
  // see feature.ts), so process-event.write.ts calls configuredPiiSubjectKms()
127
154
  // directly and needs one configured, same as run{Prod,Dev}App do at boot.
128
155
  configurePiiSubjectKms(new InMemoryKmsAdapter());
156
+
157
+ // Webhook-app for scenario 11 — same mountWebhook shape as
158
+ // stripe-foundation.integration.test.ts, exercising the real
159
+ // createSubscriptionWebhookHandler payment-branch (webhook-handler.ts).
160
+ paymentWebhookApp = new Hono();
161
+ paymentWebhookApp.post(
162
+ "/api/subscription/webhook/:providerName",
163
+ createSubscriptionWebhookHandler({
164
+ dispatchWrite: async ({ handlerQn, payload, tenantId }) => {
165
+ const systemUser = createTestUser({
166
+ id: 1,
167
+ tenantId: tenantId as TenantId,
168
+ roles: ["SystemAdmin"],
169
+ });
170
+ const res = await stack.http.write(handlerQn, payload, systemUser);
171
+ const body = await res.json();
172
+ return body.isSuccess
173
+ ? { isSuccess: true, data: body.data }
174
+ : { isSuccess: false, error: body.error };
175
+ },
176
+ resolveProvider: (providerName) => {
177
+ const usage = stack.registry
178
+ .getExtensionUsages("subscriptionProvider")
179
+ .find((u) => u.entityName === providerName);
180
+ return usage?.options as SubscriptionProviderPlugin | undefined;
181
+ },
182
+ }),
183
+ );
129
184
  });
130
185
 
131
186
  afterAll(async () => {
@@ -807,3 +862,166 @@ describe("scenario 10: PII is encrypted at rest, not just erasable on destroy",
807
862
  // (`{kind: "user"}`), not the tenant-subject fields billing uses. Closing
808
863
  // that gap is new framework capability, not a wiring fix.
809
864
  });
865
+
866
+ // =============================================================================
867
+ // Scenario 11 — one-off payments (fw#2791): own aggregate, own
868
+ // read_payments-row, idempotent like process-event above.
869
+ // =============================================================================
870
+
871
+ function buildPaymentEventPayload(
872
+ overrides: Partial<{
873
+ providerEventId: string;
874
+ providerCustomerId: string;
875
+ priceId: string;
876
+ rawPayload: string;
877
+ }> = {},
878
+ ) {
879
+ return {
880
+ providerEventId: overrides.providerEventId ?? "evt_payment_default",
881
+ providerName: "stripe",
882
+ providerCustomerId: overrides.providerCustomerId ?? "cus_payment_default",
883
+ priceId: overrides.priceId ?? "price_topup_test",
884
+ rawPayload: overrides.rawPayload ?? '{"raw":"payment-payload"}',
885
+ };
886
+ }
887
+
888
+ describe("scenario 11: one-off payment — own aggregate, own read_payments-row", () => {
889
+ test("first payment for tenant → read_payments-row created, duplicate=false", async () => {
890
+ const admin = adminFor(4001);
891
+ const result = (await stack.http.writeOk(
892
+ SubscriptionFoundationHandlers.processPaymentEvent,
893
+ buildPaymentEventPayload({
894
+ providerEventId: "evt_4001_payment",
895
+ providerCustomerId: "cus_4001",
896
+ priceId: "price_topup_test",
897
+ }),
898
+ admin,
899
+ )) as Record<string, unknown>;
900
+
901
+ expect(result["duplicate"]).toBe(false);
902
+ expect(result["paymentAggregateId"]).toBe(paymentAggregateId(admin.tenantId));
903
+
904
+ const rows = await selectMany<{ tenantId: string; priceId: string; providerName: string }>(
905
+ db,
906
+ paymentsProjectionTable,
907
+ { tenantId: admin.tenantId },
908
+ );
909
+ expect(rows).toHaveLength(1);
910
+ expect(rows[0]?.priceId).toBe("price_topup_test");
911
+ expect(rows[0]?.providerName).toBe("stripe");
912
+ });
913
+
914
+ test("idempotency: second call with same providerEventId → duplicate=true, no second row", async () => {
915
+ const admin = adminFor(4002);
916
+
917
+ const first = (await stack.http.writeOk(
918
+ SubscriptionFoundationHandlers.processPaymentEvent,
919
+ buildPaymentEventPayload({
920
+ providerEventId: "evt_4002_retry",
921
+ providerCustomerId: "cus_4002",
922
+ }),
923
+ admin,
924
+ )) as Record<string, unknown>;
925
+ expect(first["duplicate"]).toBe(false);
926
+
927
+ const second = (await stack.http.writeOk(
928
+ SubscriptionFoundationHandlers.processPaymentEvent,
929
+ buildPaymentEventPayload({
930
+ providerEventId: "evt_4002_retry",
931
+ providerCustomerId: "cus_4002",
932
+ priceId: "price_should_be_ignored",
933
+ }),
934
+ admin,
935
+ )) as Record<string, unknown>;
936
+ expect(second["duplicate"]).toBe(true);
937
+
938
+ const rows = await selectMany(db, paymentsProjectionTable, { tenantId: admin.tenantId });
939
+ expect(rows).toHaveLength(1); // dedup'd — no second row
940
+
941
+ const esEvents = await loadAggregate(db, paymentAggregateId(admin.tenantId), admin.tenantId);
942
+ expect(esEvents).toHaveLength(1);
943
+ });
944
+
945
+ test("second payment for the same tenant → second row (payments are facts, not state — no upsert)", async () => {
946
+ const admin = adminFor(4003);
947
+ await stack.http.writeOk(
948
+ SubscriptionFoundationHandlers.processPaymentEvent,
949
+ buildPaymentEventPayload({
950
+ providerEventId: "evt_4003_first",
951
+ providerCustomerId: "cus_4003",
952
+ }),
953
+ admin,
954
+ );
955
+ await stack.http.writeOk(
956
+ SubscriptionFoundationHandlers.processPaymentEvent,
957
+ buildPaymentEventPayload({
958
+ providerEventId: "evt_4003_second",
959
+ providerCustomerId: "cus_4003",
960
+ }),
961
+ admin,
962
+ );
963
+
964
+ const rows = await selectMany(db, paymentsProjectionTable, { tenantId: admin.tenantId });
965
+ expect(rows).toHaveLength(2);
966
+ });
967
+
968
+ test("idempotency anchor is tenant-scoped — same providerEventId for TWO tenants is NOT duplicate, each gets its own row", async () => {
969
+ // Same shape as scenario 4's cross-tenant pin: two tenants can
970
+ // legitimately see the same providerEventId (multiple Stripe accounts,
971
+ // test/prod mix). paymentRowId includes tenantId in its key — without
972
+ // that, the second INSERT would silently no-op (ON CONFLICT DO NOTHING)
973
+ // against the first tenant's row instead of creating its own.
974
+ const adminA = adminFor(4005);
975
+ const adminB = adminFor(4006);
976
+ const SHARED_EVT = "evt_shared_payment_id";
977
+
978
+ const a = (await stack.http.writeOk(
979
+ SubscriptionFoundationHandlers.processPaymentEvent,
980
+ buildPaymentEventPayload({ providerEventId: SHARED_EVT, providerCustomerId: "cus_a_shared" }),
981
+ adminA,
982
+ )) as Record<string, unknown>;
983
+ const b = (await stack.http.writeOk(
984
+ SubscriptionFoundationHandlers.processPaymentEvent,
985
+ buildPaymentEventPayload({ providerEventId: SHARED_EVT, providerCustomerId: "cus_b_shared" }),
986
+ adminB,
987
+ )) as Record<string, unknown>;
988
+
989
+ expect(a["duplicate"]).toBe(false);
990
+ expect(b["duplicate"]).toBe(false);
991
+
992
+ const rowsA = await selectMany(db, paymentsProjectionTable, { tenantId: adminA.tenantId });
993
+ const rowsB = await selectMany(db, paymentsProjectionTable, { tenantId: adminB.tenantId });
994
+ expect(rowsA).toHaveLength(1);
995
+ expect(rowsB).toHaveLength(1);
996
+ });
997
+
998
+ test("webhook-handler payment-branch (createSubscriptionWebhookHandler): POST → row, retry POST → duplicate:true, still one row", async () => {
999
+ const tenantId = testTenantId(4004);
1000
+ const body = JSON.stringify({
1001
+ providerEventId: "evt_webhook_payment_001",
1002
+ tenantId,
1003
+ providerCustomerId: "cus_webhook_4004",
1004
+ priceId: "price_webhook_test",
1005
+ });
1006
+
1007
+ const first = await paymentWebhookApp.request("/api/subscription/webhook/mock-payment", {
1008
+ method: "POST",
1009
+ body,
1010
+ });
1011
+ expect(first.status).toBe(200);
1012
+ const firstBody = (await first.json()) as Record<string, unknown>;
1013
+ expect(firstBody["processed"]).toBe(true);
1014
+ expect(firstBody["duplicate"]).toBe(false);
1015
+
1016
+ const second = await paymentWebhookApp.request("/api/subscription/webhook/mock-payment", {
1017
+ method: "POST",
1018
+ body,
1019
+ });
1020
+ expect(second.status).toBe(200);
1021
+ const secondBody = (await second.json()) as Record<string, unknown>;
1022
+ expect(secondBody["duplicate"]).toBe(true);
1023
+
1024
+ const rows = await selectMany(db, paymentsProjectionTable, { tenantId });
1025
+ expect(rows).toHaveLength(1);
1026
+ });
1027
+ });
@@ -1,7 +1,7 @@
1
1
  // feature.ts contract tests for subscription-foundation.
2
2
 
3
3
  import { describe, expect, test } from "bun:test";
4
- import { subscriptionAggregateId } from "../aggregate-id";
4
+ import { paymentAggregateId, paymentRowId, subscriptionAggregateId } from "../aggregate-id";
5
5
  import {
6
6
  BILLING_FOUNDATION_FEATURE,
7
7
  SUBSCRIPTION_PROVIDER_EXTENSION,
@@ -85,6 +85,14 @@ describe("aggregate-id namespace — drift-pin", () => {
85
85
  test("subscriptionAggregateId stable per tenantId", () => {
86
86
  expect(subscriptionAggregateId("tenant-1")).toBe("bfe0d98f-293c-5215-af7a-3282629aa5d3");
87
87
  });
88
+
89
+ test("paymentAggregateId stable per tenantId", () => {
90
+ expect(paymentAggregateId("tenant-1")).toBe("ea425f43-38a8-53c5-98b2-084affd718c1");
91
+ });
92
+
93
+ test("paymentRowId stable per (tenantId, providerName, providerEventId)", () => {
94
+ expect(paymentRowId("tenant-1", "mock", "evt_1")).toBe("51117d31-4394-5f4e-a123-b30bc37f30b8");
95
+ });
88
96
  });
89
97
 
90
98
  describe("normalized constants — provider-agnostic event-types + statuses", () => {
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { BillingEventKinds } from "../constants";
3
4
  import type { SubscriptionProviderPlugin } from "../types";
4
5
 
5
6
  export type SubscriptionProviderContractFixture = {
@@ -50,8 +51,13 @@ export function describeSubscriptionProviderContract(
50
51
  if (!webhook) return;
51
52
  const event = await plugin.verifyAndParseWebhook(webhook.rawBody, webhook.headers);
52
53
  expect(event).not.toBeNull();
53
- expect(event?.tenantId).toBe(webhook.expectedTenantId);
54
- expect(event?.tier).toBe(webhook.expectedTier);
54
+ if (!event || event.kind === BillingEventKinds.payment) {
55
+ throw new Error(
56
+ "describeSubscriptionProviderContract: webhook fixture must parse into a SubscriptionEvent, not a PaymentEvent",
57
+ );
58
+ }
59
+ expect(event.tenantId).toBe(webhook.expectedTenantId);
60
+ expect(event.tier).toBe(webhook.expectedTier);
55
61
  });
56
62
 
57
63
  test("createCheckoutSession returns a hosted-page url", async () => {
@@ -9,6 +9,18 @@ import { v5 as uuidv5 } from "uuid";
9
9
  /** Pro Plattform-Tenant existiert genau EIN subscription-Aggregate. */
10
10
  const SUBSCRIPTION_NAMESPACE = "5c3b2d1e-9a4f-4e8c-b7a3-1f8d6c2e9a4b";
11
11
 
12
+ /** Exactly ONE payment-aggregate-stream exists per platform tenant (it
13
+ * collects many payment-received events — one stream per tenant, not one
14
+ * per payment). Generated 2026-09-11, likewise set in stone. */
15
+ const PAYMENT_NAMESPACE = "dec0b897-646d-4da3-be3c-e9a35a83f4e0";
16
+
17
+ /** Namespace for deriving a `read_payments` row-id (PK) from
18
+ * `(tenantId, providerName, providerEventId)` — see `paymentRowId` below.
19
+ * StoredEvent.id is a bigserial (global chronological sequence), not a
20
+ * UUID, so it can't back a uuid-typed PK. Generated 2026-09-11, set in
21
+ * stone (same rationale as the two namespaces above). */
22
+ const PAYMENT_ROW_NAMESPACE = "3a1c9f4e-6b2d-4c8a-9e5f-7d4b1a2c8e6f";
23
+
12
24
  /**
13
25
  * Deterministic aggregate-id für die subscription eines Plattform-
14
26
  * Tenants. EINE Subscription pro Tenant (Add-Ons sind line-items in
@@ -20,3 +32,33 @@ const SUBSCRIPTION_NAMESPACE = "5c3b2d1e-9a4f-4e8c-b7a3-1f8d6c2e9a4b";
20
32
  export function subscriptionAggregateId(tenantId: string): string {
21
33
  return uuidv5(tenantId, SUBSCRIPTION_NAMESPACE);
22
34
  }
35
+
36
+ /**
37
+ * Deterministic aggregate-id for a platform tenant's payment-stream. A
38
+ * tenant can make arbitrarily many one-off-payments — all land as
39
+ * payment-received events on the same stream (like the subscription-
40
+ * stream); the read_payments-projection creates its own row per event
41
+ * (PK = paymentRowId(...), not aggregateId).
42
+ */
43
+ // @wrapper-known uuid-domain
44
+ export function paymentAggregateId(tenantId: string): string {
45
+ return uuidv5(tenantId, PAYMENT_NAMESPACE);
46
+ }
47
+
48
+ /** Deterministic `read_payments` row-id — keyed off `(tenantId, providerName,
49
+ * providerEventId)` rather than the event-store's bigserial `event.id` (not
50
+ * a UUID) or the shared per-tenant aggregateId (one row per payment). */
51
+ // @wrapper-known uuid-domain
52
+ export function paymentRowId(
53
+ tenantId: string,
54
+ providerName: string,
55
+ providerEventId: string,
56
+ ): string {
57
+ // tenantId is part of the key because the idempotency scan in
58
+ // process-payment-event.write.ts is per-tenant: two tenants can
59
+ // legitimately see the same providerEventId (multiple Stripe accounts,
60
+ // test/prod mix) — without it, the second tenant's INSERT would silently
61
+ // no-op (ON CONFLICT DO NOTHING) against the first tenant's row, losing
62
+ // the payment with no error anywhere.
63
+ return uuidv5(`${tenantId}:${providerName}:${providerEventId}`, PAYMENT_ROW_NAMESPACE);
64
+ }
@@ -21,6 +21,11 @@ export const SubscriptionFoundationHandlers = {
21
21
  * current subscription, ruft plugin.createPortalSession, returnt
22
22
  * hosted-portal-URL. */
23
23
  createPortalSession: "billing-foundation:write:create-portal-session",
24
+ /** Programmatic entry-point for the webhook-handler on a one-off-payment
25
+ * (checkout mode "payment"). Its own per-tenant aggregate-stream
26
+ * (payment-aggregate), separate from the subscription-aggregate — a
27
+ * payment is not a subscription-state transition. */
28
+ processPaymentEvent: "billing-foundation:write:process-payment-event",
24
29
  } as const;
25
30
 
26
31
  // Qualified query handler names.
@@ -57,6 +62,17 @@ export const SubscriptionStatuses = {
57
62
  } as const;
58
63
  export type SubscriptionStatus = (typeof SubscriptionStatuses)[keyof typeof SubscriptionStatuses];
59
64
 
65
+ // Discriminator for verifyAndParseWebhook's return union. `subscription` is
66
+ // optional on SubscriptionEvent (kept backward-compatible for plugins like
67
+ // subscription-mollie that pre-date this field) and required on
68
+ // PaymentEvent — TS narrows `parsed.kind === BillingEventKinds.payment`
69
+ // correctly either way since only PaymentEvent's `kind` can equal it.
70
+ export const BillingEventKinds = {
71
+ subscription: "subscription",
72
+ payment: "payment",
73
+ } as const;
74
+ export type BillingEventKind = (typeof BillingEventKinds)[keyof typeof BillingEventKinds];
75
+
60
76
  // **Multi-Provider von Tag 1:** subscription-foundation hat KEIN
61
77
  // `provider`-config-key. Alle gemounteten Plugins sind aktiv parallel —
62
78
  // der Endkunde wählt beim Subscribe-Klick zwischen Karte/PayPal/
@@ -0,0 +1,19 @@
1
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+
4
+ // INSERT-once, not UPSERT: unlike subscriptionsProjectionTable (one row per
5
+ // tenant, state overwritten on every event), a payment row is an immutable
6
+ // fact keyed by paymentRowId(tenantId, providerName, providerEventId) — ON
7
+ // CONFLICT DO NOTHING makes a projection rebuild/replay idempotent without
8
+ // ever mutating an existing row.
9
+ export async function insertPaymentProjectionRow(
10
+ tx: DbRunner,
11
+ tableName: string,
12
+ insertCols: Record<string, unknown>,
13
+ ): Promise<void> {
14
+ const insertKeys = Object.keys(insertCols);
15
+ const insertPlaceholders = insertKeys.map((_, i) => `$${i + 1}`);
16
+ const sqlText = `INSERT INTO "${tableName}" (${insertKeys.map((k) => `"${k}"`).join(", ")}) VALUES (${insertPlaceholders.join(", ")}) ON CONFLICT ("id") DO NOTHING`;
17
+ // kumiko-lint-ignore raw-sql tableName + column keys are code-controlled, not user input: tableName is buildEntityTable()'s static table name and insertKeys come from applyPaymentReceived's hardcoded object-literal columns in projection.ts — only values flow through $1..$n
18
+ await asRawClient(tx).unsafe(sqlText, Object.values(insertCols));
19
+ }
@@ -74,3 +74,32 @@ export const subscriptionEntity = createEntity({
74
74
  // `tenantOwned`/`pii`/`userOwned` field doesn't need a matching manual
75
75
  // update at each call site.
76
76
  export const SUBSCRIPTION_PII_FIELDS = collectPiiSubjectFields(subscriptionEntity);
77
+
78
+ // =============================================================================
79
+ // `payment` — one row per one-off-payment (= read-model)
80
+ // =============================================================================
81
+ //
82
+ // Inline-Projection-Target for the payment-received event (see feature.ts).
83
+ // Unlike subscriptionEntity (one row per tenant, UPSERTed), a tenant can have
84
+ // many payments — one INSERT-once row per event, PK = paymentRowId(...) (see
85
+ // projection.ts / aggregate-id.ts). Source-of-truth is the event-store stream `payment` with
86
+ // aggregate-id = paymentAggregateId(tenantId) — one stream per tenant
87
+ // collecting all of that tenant's payment-received events.
88
+ export const paymentEntity = createEntity({
89
+ table: "read_payments",
90
+ fields: {
91
+ providerName: createTextField({ required: true, maxLength: 50 }),
92
+ // Same `personal: "tenant"` rationale as subscriptionEntity above —
93
+ // crypto-shreds on tenant-destroy (#800) via eraseSubjectKeys.
94
+ providerCustomerId: createTextField({
95
+ required: true,
96
+ maxLength: 1000,
97
+ personal: "tenant",
98
+ find: "none",
99
+ }),
100
+ priceId: createTextField({ required: true, maxLength: 200 }),
101
+ },
102
+ });
103
+
104
+ // See the SUBSCRIPTION_PII_FIELDS comment above — same manual-wiring reason.
105
+ export const PAYMENT_PII_FIELDS = collectPiiSubjectFields(paymentEntity);
@@ -73,3 +73,35 @@ export type SubscriptionEventHeaders = {
73
73
  readonly providerName: string;
74
74
  readonly rawPayload: string;
75
75
  };
76
+
77
+ // =============================================================================
78
+ // payment-received — one-off-payments (checkout mode "payment")
79
+ // =============================================================================
80
+ //
81
+ // Own aggregate-type + event, separate from the 5 subscription events above.
82
+ // A one-off-payment is not a subscription-state transition (no status/tier/
83
+ // currentPeriodEnd) — it materializes as its own `read_payments` row (one
84
+ // row per payment) via the payment-aggregate stream (one stream per tenant,
85
+ // see aggregate-id.ts).
86
+
87
+ export const PAYMENT_AGGREGATE_TYPE = "payment" as const;
88
+
89
+ export const PAYMENT_RECEIVED_EVENT_SHORT = "payment-received" as const;
90
+ export const PAYMENT_RECEIVED_EVENT_QN =
91
+ `${BILLING_FOUNDATION_FEATURE}:event:${PAYMENT_RECEIVED_EVENT_SHORT}` as const;
92
+
93
+ export const paymentEventPayloadSchema = z.object({
94
+ providerName: z.string().min(1).max(50),
95
+ // 1000, not 200: `tenantOwned: true` on paymentEntity (see entities.ts) —
96
+ // this stores the PII-ciphertext, not the raw provider id. Mirrors
97
+ // subscriptionEventPayloadSchema's same rationale.
98
+ providerCustomerId: z.string().min(1).max(1000),
99
+ priceId: z.string().min(1).max(200),
100
+ });
101
+ export type PaymentEventPayload = z.infer<typeof paymentEventPayloadSchema>;
102
+
103
+ export type PaymentEventHeaders = {
104
+ readonly providerEventId: string;
105
+ readonly providerName: string;
106
+ readonly rawPayload: string;
107
+ };
@@ -22,6 +22,10 @@
22
22
  // webhook-handler aufruft, dispatcht zu type-passendem appendEvent.
23
23
  // 5. **createSubscriptionWebhookHandler**: factory für die HTTP-Route
24
24
  // `/api/subscription/webhook/:providerName`.
25
+ // 6. **payment-received event + read_payments projection**: one-off-
26
+ // payments (checkout mode "payment") get their own event, own
27
+ // per-tenant aggregate, and own `process-payment-event` write-handler
28
+ // — not a sixth SubscriptionEventTypes value (fw#2791).
25
29
  //
26
30
  // **Was diese Foundation NICHT macht:**
27
31
  // - Kein r.entity für `subscription`. Die Tabelle ist eine reine
@@ -43,12 +47,16 @@
43
47
 
44
48
  import { defineFeature, EXT_TENANT_DATA } from "@cosmicdrift/kumiko-framework/engine";
45
49
  import { BILLING_FOUNDATION_FEATURE, SUBSCRIPTION_PROVIDER_EXTENSION } from "./constants";
46
- import { subscriptionEntity } from "./entities";
50
+ import { paymentEntity, subscriptionEntity } from "./entities";
47
51
  import {
48
52
  INVOICE_PAID_EVENT_QN,
49
53
  INVOICE_PAID_EVENT_SHORT,
50
54
  INVOICE_PAYMENT_FAILED_EVENT_QN,
51
55
  INVOICE_PAYMENT_FAILED_EVENT_SHORT,
56
+ PAYMENT_AGGREGATE_TYPE,
57
+ PAYMENT_RECEIVED_EVENT_QN,
58
+ PAYMENT_RECEIVED_EVENT_SHORT,
59
+ paymentEventPayloadSchema,
52
60
  SUBSCRIPTION_AGGREGATE_TYPE,
53
61
  SUBSCRIPTION_CANCELED_EVENT_QN,
54
62
  SUBSCRIPTION_CANCELED_EVENT_SHORT,
@@ -62,19 +70,22 @@ import { createCheckoutSessionHandler } from "./handlers/create-checkout-session
62
70
  import { createPortalSessionHandler } from "./handlers/create-portal-session.write";
63
71
  import { listSubscriptionsQuery } from "./handlers/list-subscriptions.query";
64
72
  import { processEventHandler } from "./handlers/process-event.write";
73
+ import { processPaymentEventHandler } from "./handlers/process-payment-event.write";
65
74
  import {
66
75
  applyInvoicePaid,
67
76
  applyInvoicePaymentFailed,
77
+ applyPaymentReceived,
68
78
  applySubscriptionCanceled,
69
79
  applySubscriptionCreated,
70
80
  applySubscriptionUpdated,
81
+ paymentsProjectionTable,
71
82
  subscriptionsProjectionTable,
72
83
  } from "./projection";
73
- import { subscriptionTenantDestroyHook } from "./tenant-destroy-hook";
84
+ import { paymentTenantDestroyHook, subscriptionTenantDestroyHook } from "./tenant-destroy-hook";
74
85
 
75
86
  export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE, (r) => {
76
87
  r.describe(
77
- "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 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 `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.",
78
89
  );
79
90
  r.uiHints({
80
91
  displayLabel: "Billing \u00b7 Foundation",
@@ -100,6 +111,10 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
100
111
  r.defineEvent(INVOICE_PAYMENT_FAILED_EVENT_SHORT, subscriptionEventPayloadSchema, {
101
112
  piiFields: "none",
102
113
  });
114
+ // Own event, own aggregate-type — a one-off-payment is not a subscription
115
+ // state transition. piiFields: "none" for the same reason as the 5 above:
116
+ // providerCustomerId is tenantOwned ciphertext, not plaintext personal data.
117
+ r.defineEvent(PAYMENT_RECEIVED_EVENT_SHORT, paymentEventPayloadSchema, { piiFields: "none" });
103
118
 
104
119
  // Inline projection: materialized current state in `read_subscriptions`.
105
120
  // Apply läuft in derselben TX wie ctx.unsafeAppendEvent — read-your-
@@ -118,6 +133,17 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
118
133
  },
119
134
  });
120
135
 
136
+ // Second inline projection: `read_payments`, one row per one-off-payment.
137
+ r.projection({
138
+ name: "payment",
139
+ source: PAYMENT_AGGREGATE_TYPE,
140
+ table: paymentsProjectionTable,
141
+ entity: paymentEntity,
142
+ apply: {
143
+ [PAYMENT_RECEIVED_EVENT_QN]: applyPaymentReceived,
144
+ },
145
+ });
146
+
121
147
  // Plugin extension-point. Provider-Plugins registrieren sich hier.
122
148
  r.extendsRegistrar(SUBSCRIPTION_PROVIDER_EXTENSION, {
123
149
  onRegister: () => {
@@ -133,6 +159,9 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
133
159
  r.writeHandler(processEventHandler);
134
160
  r.writeHandler(createCheckoutSessionHandler);
135
161
  r.writeHandler(createPortalSessionHandler);
162
+ // - process-payment-event: programmatic entry-point from the webhook-
163
+ // handler for one-off-payments; appends onto the payment-aggregate
164
+ r.writeHandler(processPaymentEventHandler);
136
165
 
137
166
  // Custom list-query auf der subscription-projection (raw drizzle-
138
167
  // table; kein r.entity weil Schreiben via projection-apply läuft).
@@ -141,4 +170,7 @@ export const billingFoundationFeature = defineFeature(BILLING_FOUNDATION_FEATURE
141
170
  r.useExtension(EXT_TENANT_DATA, "subscription", {
142
171
  destroy: subscriptionTenantDestroyHook,
143
172
  });
173
+ r.useExtension(EXT_TENANT_DATA, "payment", {
174
+ destroy: paymentTenantDestroyHook,
175
+ });
144
176
  });