@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "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.
|
|
134
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
135
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
136
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
168
|
-
"@cosmicdrift/kumiko-locale-es": "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
|
-
|
|
54
|
-
|
|
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
|
});
|