@cosmicdrift/kumiko-bundled-features 0.316.0 → 0.318.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.
Files changed (57) hide show
  1. package/package.json +10 -9
  2. package/src/auth-email-password/web/__tests__/use-url-token.test.tsx +1 -1
  3. package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +83 -4
  4. package/src/billing-foundation/__tests__/billing-plans-admin-only.integration.test.ts +158 -0
  5. package/src/billing-foundation/__tests__/billing-plans.integration.test.ts +800 -0
  6. package/src/billing-foundation/__tests__/checkout-core.test.ts +149 -0
  7. package/src/billing-foundation/__tests__/constants.test.ts +72 -0
  8. package/src/billing-foundation/__tests__/feature.test.ts +119 -1
  9. package/src/billing-foundation/changes.json +18 -0
  10. package/src/billing-foundation/checkout-core.ts +259 -0
  11. package/src/billing-foundation/constants.ts +98 -0
  12. package/src/billing-foundation/feature.ts +155 -87
  13. package/src/billing-foundation/get-subscription-for-tenant.ts +5 -0
  14. package/src/billing-foundation/handlers/billing-plans.query.ts +32 -0
  15. package/src/billing-foundation/handlers/create-checkout-session.write.ts +42 -44
  16. package/src/billing-foundation/handlers/create-portal-session.write.ts +80 -59
  17. package/src/billing-foundation/handlers/start-plan-checkout.write.ts +75 -0
  18. package/src/billing-foundation/handlers/switch-plan.write.ts +115 -0
  19. package/src/billing-foundation/i18n.ts +62 -0
  20. package/src/billing-foundation/index.ts +16 -1
  21. package/src/billing-foundation/plan-catalog.ts +204 -0
  22. package/src/billing-foundation/projection.ts +17 -7
  23. package/src/billing-foundation/screens.ts +25 -0
  24. package/src/billing-foundation/types.ts +147 -1
  25. package/src/billing-foundation/validate-options.ts +66 -0
  26. package/src/billing-foundation/web/__tests__/billing-plans-panel.test.tsx +352 -0
  27. package/src/billing-foundation/web/billing-plans-panel.tsx +241 -0
  28. package/src/billing-foundation/web/client-plugin.tsx +21 -0
  29. package/src/billing-foundation/web/index.ts +8 -0
  30. package/src/cap-counter/__tests__/stock-cap-guard.integration.test.ts +88 -0
  31. package/src/cap-counter/__tests__/with-cap-enforcement.integration.test.ts +102 -0
  32. package/src/cap-counter/aggregate-id.ts +4 -17
  33. package/src/cap-counter/book-cap-usage.ts +73 -43
  34. package/src/cap-counter/changes.json +6 -0
  35. package/src/cap-counter/handlers/increment.write.ts +2 -3
  36. package/src/cap-counter/index.ts +1 -0
  37. package/src/cap-counter/stock-cap-guard.ts +17 -5
  38. package/src/cap-counter/with-cap-enforcement.ts +4 -2
  39. package/src/cap-overview/__tests__/cap-limit-from-config.integration.test.ts +141 -0
  40. package/src/cap-overview/changes.json +6 -0
  41. package/src/cap-overview/handlers/caps-usage.query.ts +1 -1
  42. package/src/cap-overview/handlers/tenant-caps-list.query.ts +26 -1
  43. package/src/cap-overview/types.ts +6 -1
  44. package/src/subscription-mollie/changes.json +8 -1
  45. package/src/subscription-mollie/feature.ts +1 -0
  46. package/src/subscription-stripe/__tests__/feature.test.ts +5 -1
  47. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +517 -3
  48. package/src/subscription-stripe/__tests__/runtime.test.ts +35 -0
  49. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +54 -1
  50. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +2 -2
  51. package/src/subscription-stripe/changes.json +18 -0
  52. package/src/subscription-stripe/feature.ts +15 -1
  53. package/src/subscription-stripe/plugin-methods.ts +344 -1
  54. package/src/subscription-stripe/runtime.ts +23 -4
  55. package/src/subscription-stripe/verify-webhook.ts +7 -1
  56. package/src/tier-engine/tier-resolver.ts +4 -3
  57. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.316.0",
3
+ "version": "0.318.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>",
@@ -55,6 +55,7 @@
55
55
  "./ledger": "./src/ledger/index.ts",
56
56
  "./ledger/web": "./src/ledger/web/index.ts",
57
57
  "./billing-foundation": "./src/billing-foundation/index.ts",
58
+ "./billing-foundation/web": "./src/billing-foundation/web/index.ts",
58
59
  "./subscription-stripe": "./src/subscription-stripe/index.ts",
59
60
  "./subscription-mollie": "./src/subscription-mollie/index.ts",
60
61
  "./foundation-shared": "./src/foundation-shared/index.ts",
@@ -133,12 +134,12 @@
133
134
  "./workflow-runner": "./src/workflow-runner/index.ts"
134
135
  },
135
136
  "dependencies": {
136
- "@cosmicdrift/kumiko-dispatcher-live": "0.316.0",
137
- "@cosmicdrift/kumiko-framework": "0.316.0",
138
- "@cosmicdrift/kumiko-headless": "0.316.0",
139
- "@cosmicdrift/kumiko-renderer": "0.316.0",
140
- "@cosmicdrift/kumiko-renderer-web": "0.316.0",
141
- "@cosmicdrift/kumiko-types": "0.316.0",
137
+ "@cosmicdrift/kumiko-dispatcher-live": "0.318.0",
138
+ "@cosmicdrift/kumiko-framework": "0.318.0",
139
+ "@cosmicdrift/kumiko-headless": "0.318.0",
140
+ "@cosmicdrift/kumiko-renderer": "0.318.0",
141
+ "@cosmicdrift/kumiko-renderer-web": "0.318.0",
142
+ "@cosmicdrift/kumiko-types": "0.318.0",
142
143
  "@mollie/api-client": "^4.5.0",
143
144
  "@node-rs/argon2": "^2.0.2",
144
145
  "@types/mailparser": "^3.4.6",
@@ -167,8 +168,8 @@
167
168
  ],
168
169
  "devDependencies": {
169
170
  "@testing-library/user-event": "^14.6.1",
170
- "@cosmicdrift/kumiko-locale-de": "0.316.0",
171
- "@cosmicdrift/kumiko-locale-es": "0.316.0",
171
+ "@cosmicdrift/kumiko-locale-de": "0.318.0",
172
+ "@cosmicdrift/kumiko-locale-es": "0.318.0",
172
173
  "jsqr": "^1.4.0"
173
174
  }
174
175
  }
@@ -4,7 +4,7 @@ import { useUrlToken } from "../auth-form-primitives";
4
4
 
5
5
  // #774: magic-link tokens must not linger in browser history / Referer.
6
6
  // useUrlToken reads the token once, then scrubs the param via replaceState.
7
- // (dom.preload resets window.location to http://localhost/ after each test.)
7
+ // (preload/dom resets window.location to http://localhost/ after each test.)
8
8
  describe("useUrlToken (magic-link history hygiene)", () => {
9
9
  test("reads ?token= and strips it from the URL, keeping other params", () => {
10
10
  window.history.replaceState(null, "", "http://localhost/reset?token=secret-abc&keep=1");
@@ -47,7 +47,7 @@ import {
47
47
  SubscriptionFoundationHandlers,
48
48
  SubscriptionStatuses,
49
49
  } from "../constants";
50
- import { billingFoundationFeature } from "../feature";
50
+ import { createBillingFoundationFeature } from "../feature";
51
51
  import { paymentsProjectionTable, subscriptionsProjectionTable } from "../projection";
52
52
  import type { PaymentEvent, SubscriptionProviderPlugin } from "../types";
53
53
  import { createSubscriptionWebhookRoute } from "../webhook-handler";
@@ -73,6 +73,10 @@ const mockProviderFeature = defineFeature("test-mock-provider", (r) => {
73
73
  r.requires("billing-foundation");
74
74
  const plugin: SubscriptionProviderPlugin = {
75
75
  verifyAndParseWebhook: async () => null,
76
+ // Only the two tiers scenario 6's subscription-mode checkouts actually
77
+ // exercise — the hardened create-checkout-session now requires every
78
+ // mode:"subscription" priceId to resolve through priceToTier.
79
+ priceToTier: { price_pro_test: "pro", price_business_test: "business" },
76
80
  createCheckoutSession: async (_ctx, options) => {
77
81
  mockCheckoutCalls.push({
78
82
  priceId: options.priceId,
@@ -131,7 +135,7 @@ beforeAll(async () => {
131
135
  createTenantFeature(),
132
136
  createComplianceProfilesFeature(),
133
137
  createTenantLifecycleFeature(),
134
- billingFoundationFeature,
138
+ createBillingFoundationFeature({ baseUrl: "https://example.com" }),
135
139
  mockProviderFeature,
136
140
  mockPaymentProviderFeature,
137
141
  ],
@@ -480,7 +484,10 @@ describe("scenario 5: Provider-Wechsel mid-period (Disney+-Pattern)", () => {
480
484
  describe("scenario 6: create-checkout-session — Plugin-routing", () => {
481
485
  test("happy-path: valid provider → URL durchgereicht + plugin mit korrekten args aufgerufen", async () => {
482
486
  mockCheckoutCalls.length = 0;
483
- const admin = adminFor(3009);
487
+ // Not 3009 — scenario 5 reuses that tenant number and leaves it with a
488
+ // non-terminal "pro" subscription; openCheckout's new existing-
489
+ // subscription conflict-gate would reject this checkout for it.
490
+ const admin = adminFor(3016);
484
491
  const result = (await stack.http.writeOk(
485
492
  "billing-foundation:write:create-checkout-session",
486
493
  {
@@ -525,7 +532,26 @@ describe("scenario 6: create-checkout-session — Plugin-routing", () => {
525
532
 
526
533
  test("optional providerCustomerId wird durchgereicht (Plan-Wechsel-Flow)", async () => {
527
534
  mockCheckoutCalls.length = 0;
528
- const admin = adminFor(3012);
535
+ // Not 3012 — that tenant already gets its own subscription in the
536
+ // crypto-shredding test further down and would collide.
537
+ const admin = adminFor(3018);
538
+
539
+ // The provider customer-id must belong to the caller's own subscription
540
+ // (even if canceled) — otherwise the foreign_provider_customer guard fires.
541
+ await stack.http.writeOk(
542
+ SubscriptionFoundationHandlers.processEvent,
543
+ {
544
+ ...buildEvent({
545
+ providerEventId: "evt_3018_create",
546
+ providerCustomerId: "cus_existing_xyz",
547
+ providerSubscriptionId: "sub_3018",
548
+ status: SubscriptionStatuses.canceled,
549
+ }),
550
+ providerName: "mock",
551
+ },
552
+ admin,
553
+ );
554
+
529
555
  await stack.http.writeOk(
530
556
  "billing-foundation:write:create-checkout-session",
531
557
  {
@@ -540,6 +566,59 @@ describe("scenario 6: create-checkout-session — Plugin-routing", () => {
540
566
  expect(mockCheckoutCalls[0]?.providerCustomerId).toBe("cus_existing_xyz");
541
567
  });
542
568
 
569
+ test("fremde providerCustomerId wird abgelehnt (foreign_provider_customer)", async () => {
570
+ mockCheckoutCalls.length = 0;
571
+ const other = adminFor(3019);
572
+ await stack.http.writeOk(
573
+ SubscriptionFoundationHandlers.processEvent,
574
+ {
575
+ ...buildEvent({
576
+ providerEventId: "evt_3019_create",
577
+ providerCustomerId: "cus_belongs_to_tenant_3019",
578
+ providerSubscriptionId: "sub_3019",
579
+ }),
580
+ providerName: "mock",
581
+ },
582
+ other,
583
+ );
584
+
585
+ const admin = adminFor(3017);
586
+ const error = await stack.http.writeErr(
587
+ "billing-foundation:write:create-checkout-session",
588
+ {
589
+ providerName: "mock",
590
+ priceId: "price_pro_test",
591
+ successUrl: "https://example.com/s",
592
+ cancelUrl: "https://example.com/c",
593
+ providerCustomerId: "cus_belongs_to_tenant_3019",
594
+ },
595
+ admin,
596
+ );
597
+ expect(error.httpStatus).toBe(422);
598
+ expect(error.i18nKey).toBe("billing-foundation.errors.foreignProviderCustomer");
599
+ expect(mockCheckoutCalls).toHaveLength(0);
600
+ });
601
+
602
+ test("fremde providerCustomerId wird auch im mode:payment abgelehnt", async () => {
603
+ mockCheckoutCalls.length = 0;
604
+ const admin = adminFor(3020);
605
+ const error = await stack.http.writeErr(
606
+ "billing-foundation:write:create-checkout-session",
607
+ {
608
+ providerName: "mock",
609
+ priceId: "price_topup_test",
610
+ successUrl: "https://example.com/s",
611
+ cancelUrl: "https://example.com/c",
612
+ providerCustomerId: "cus_belongs_to_tenant_3019",
613
+ mode: "payment",
614
+ },
615
+ admin,
616
+ );
617
+ expect(error.httpStatus).toBe(422);
618
+ expect(error.i18nKey).toBe("billing-foundation.errors.foreignProviderCustomer");
619
+ expect(mockCheckoutCalls).toHaveLength(0);
620
+ });
621
+
543
622
  test("optional mode wird durchgereicht (One-off-Payment-Flow, fw#2755)", async () => {
544
623
  mockCheckoutCalls.length = 0;
545
624
  const admin = adminFor(3014);
@@ -0,0 +1,158 @@
1
+ // Admin-only billing-plans catalog — a separate stack (own viewRoles/
2
+ // purchaseRoles: ["Admin"]) instead of a describe-block inside
3
+ // billing-plans.integration.test.ts, matching how show-pony pins a distinct
4
+ // role-shape against its own setupTestStack rather than layering it onto a
5
+ // shared one. Proves the catalog's role-gate isn't hardcoded to
6
+ // TenantAdmin/SystemAdmin: an app that restricts billing to a custom "Admin"
7
+ // role locks TenantAdmin out of both the query and the purchase-handlers.
8
+
9
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
+ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
11
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
12
+ import {
13
+ createTestUser,
14
+ setupTestStack,
15
+ type TestStack,
16
+ testTenantId,
17
+ unsafeCreateEntityTable,
18
+ } from "@cosmicdrift/kumiko-framework/stack";
19
+ import {
20
+ createComplianceProfilesFeature,
21
+ tenantComplianceProfileEntity,
22
+ } from "../../compliance-profiles";
23
+ import { createConfigFeature } from "../../config";
24
+ import { createTenantFeature } from "../../tenant/feature";
25
+ import { tenantEntity } from "../../tenant/schema/tenant";
26
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
27
+ import { subscriptionAggregateId } from "../aggregate-id";
28
+ import { SubscriptionFoundationHandlers, SubscriptionStatuses } from "../constants";
29
+ import { createBillingFoundationFeature } from "../feature";
30
+ import { subscriptionsProjectionTable } from "../projection";
31
+ import type { BillingPlanCatalog, ProviderPrice, SubscriptionProviderPlugin } from "../types";
32
+
33
+ const PRICE_TO_TIER: Readonly<Record<string, string>> = { price_pro: "pro" };
34
+
35
+ const PRICES: readonly ProviderPrice[] = [
36
+ {
37
+ priceId: "price_pro",
38
+ unitAmount: 1900,
39
+ currency: "usd",
40
+ interval: "month",
41
+ intervalCount: 1,
42
+ active: true,
43
+ metadata: {},
44
+ },
45
+ ];
46
+
47
+ const checkoutCalls: Array<{ priceId: string }> = [];
48
+
49
+ const adminOnlyProviderFeature = defineFeature("test-mock-admin-only-provider", (r) => {
50
+ r.requires("billing-foundation");
51
+ const plugin: SubscriptionProviderPlugin = {
52
+ verifyAndParseWebhook: async () => null,
53
+ priceToTier: PRICE_TO_TIER,
54
+ retrievePrices: async (_ctx, priceIds) => PRICES.filter((p) => priceIds.includes(p.priceId)),
55
+ createCheckoutSession: async (_ctx, options) => {
56
+ checkoutCalls.push({ priceId: options.priceId });
57
+ return { url: `https://mock.example/checkout/${options.priceId}` };
58
+ },
59
+ };
60
+ r.useExtension("subscriptionProvider", "mock-admin-only-provider", plugin);
61
+ });
62
+
63
+ async function resolveCurrentTier(db: TenantDb, tenantId: string): Promise<string> {
64
+ const rows = await db.selectMany(
65
+ subscriptionsProjectionTable,
66
+ { id: subscriptionAggregateId(tenantId) },
67
+ { limit: 1 },
68
+ );
69
+ const row = rows[0];
70
+ if (!row) return "free";
71
+ const status = row["status"] as string;
72
+ return status === SubscriptionStatuses.active || status === SubscriptionStatuses.trialing
73
+ ? (row["tier"] as string)
74
+ : "free";
75
+ }
76
+
77
+ const adminOnlyCatalog: BillingPlanCatalog<"pro"> = {
78
+ plans: ["pro"],
79
+ tierLabelKey: (tier) => `plan.${tier}.label`,
80
+ benefits: () => [],
81
+ resolveCurrentTier,
82
+ viewRoles: ["Admin"],
83
+ purchaseRoles: ["Admin"],
84
+ successPath: "/billing/success",
85
+ cancelPath: "/billing/cancel",
86
+ providerName: "mock-admin-only-provider",
87
+ };
88
+
89
+ let stack: TestStack;
90
+
91
+ beforeAll(async () => {
92
+ stack = await setupTestStack({
93
+ features: [
94
+ createConfigFeature(),
95
+ createTenantFeature(),
96
+ createComplianceProfilesFeature(),
97
+ createTenantLifecycleFeature(),
98
+ createBillingFoundationFeature({
99
+ baseUrl: "https://app.example.com",
100
+ catalog: adminOnlyCatalog,
101
+ }),
102
+ adminOnlyProviderFeature,
103
+ ],
104
+ });
105
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
106
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
107
+ });
108
+
109
+ afterAll(async () => {
110
+ await stack.cleanup();
111
+ });
112
+
113
+ describe("billing-plans catalog with viewRoles/purchaseRoles restricted to a custom Admin role", () => {
114
+ test("an Admin can query the catalog and start a checkout through to the mock provider", async () => {
115
+ const admin = createTestUser({ id: 8001, tenantId: testTenantId(8001), roles: ["Admin"] });
116
+
117
+ const result = (await stack.http.queryOk(
118
+ "billing-foundation:query:billing-plans",
119
+ {},
120
+ admin,
121
+ )) as {
122
+ canPurchase: boolean;
123
+ plans: Array<{ tier: string; action: string }>;
124
+ };
125
+ expect(result.canPurchase).toBe(true);
126
+ expect(result.plans.find((p) => p.tier === "pro")?.action).toBe("checkout");
127
+
128
+ const checkout = (await stack.http.writeOk(
129
+ SubscriptionFoundationHandlers.startPlanCheckout,
130
+ { tier: "pro" },
131
+ admin,
132
+ )) as { url: string };
133
+ expect(checkout.url).toBe("https://mock.example/checkout/price_pro");
134
+ expect(checkoutCalls).toEqual([{ priceId: "price_pro" }]);
135
+ });
136
+
137
+ test("a TenantAdmin without the Admin role is rejected on both query and start-plan-checkout", async () => {
138
+ const tenantAdmin = createTestUser({
139
+ id: 8002,
140
+ tenantId: testTenantId(8002),
141
+ roles: ["TenantAdmin"],
142
+ });
143
+
144
+ const queryError = await stack.http.queryErr(
145
+ "billing-foundation:query:billing-plans",
146
+ {},
147
+ tenantAdmin,
148
+ );
149
+ expect(queryError.httpStatus).toBe(403);
150
+
151
+ const writeError = await stack.http.writeErr(
152
+ SubscriptionFoundationHandlers.startPlanCheckout,
153
+ { tier: "pro" },
154
+ tenantAdmin,
155
+ );
156
+ expect(writeError.httpStatus).toBe(403);
157
+ });
158
+ });