@wopr-network/platform-core 1.45.0 → 1.47.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 (50) hide show
  1. package/dist/billing/stripe/billing-period-summary-repository.d.ts +22 -0
  2. package/dist/billing/stripe/billing-period-summary-repository.js +14 -0
  3. package/dist/billing/stripe/index.d.ts +10 -0
  4. package/dist/billing/stripe/index.js +5 -0
  5. package/dist/billing/stripe/metered-price-map.d.ts +26 -0
  6. package/dist/billing/stripe/metered-price-map.js +75 -0
  7. package/dist/billing/stripe/stripe-payment-processor.test.js +1 -0
  8. package/dist/billing/stripe/stripe-usage-reconciliation.d.ts +31 -0
  9. package/dist/billing/stripe/stripe-usage-reconciliation.js +84 -0
  10. package/dist/billing/stripe/stripe-usage-reconciliation.test.d.ts +1 -0
  11. package/dist/billing/stripe/stripe-usage-reconciliation.test.js +109 -0
  12. package/dist/billing/stripe/tenant-store.d.ts +9 -0
  13. package/dist/billing/stripe/tenant-store.js +7 -0
  14. package/dist/billing/stripe/usage-report-repository.d.ts +39 -0
  15. package/dist/billing/stripe/usage-report-repository.js +30 -0
  16. package/dist/billing/stripe/usage-report-repository.test.d.ts +1 -0
  17. package/dist/billing/stripe/usage-report-repository.test.js +77 -0
  18. package/dist/billing/stripe/usage-report-writer.d.ts +41 -0
  19. package/dist/billing/stripe/usage-report-writer.js +95 -0
  20. package/dist/billing/stripe/usage-report-writer.test.d.ts +1 -0
  21. package/dist/billing/stripe/usage-report-writer.test.js +167 -0
  22. package/dist/gateway/credit-gate.js +5 -0
  23. package/dist/gateway/credit-gate.test.js +53 -0
  24. package/dist/gateway/types.d.ts +2 -0
  25. package/dist/monetization/stripe/stripe-payment-processor.test.js +1 -0
  26. package/dist/trpc/index.d.ts +1 -0
  27. package/dist/trpc/index.js +1 -0
  28. package/dist/trpc/org-remove-payment-method-router.d.ts +23 -0
  29. package/dist/trpc/org-remove-payment-method-router.js +61 -0
  30. package/dist/trpc/org-remove-payment-method-router.test.d.ts +1 -0
  31. package/dist/trpc/org-remove-payment-method-router.test.js +166 -0
  32. package/package.json +1 -1
  33. package/src/billing/stripe/billing-period-summary-repository.ts +32 -0
  34. package/src/billing/stripe/index.ts +17 -0
  35. package/src/billing/stripe/metered-price-map.ts +95 -0
  36. package/src/billing/stripe/stripe-payment-processor.test.ts +1 -0
  37. package/src/billing/stripe/stripe-usage-reconciliation.test.ts +127 -0
  38. package/src/billing/stripe/stripe-usage-reconciliation.ts +129 -0
  39. package/src/billing/stripe/tenant-store.ts +9 -0
  40. package/src/billing/stripe/usage-report-repository.test.ts +87 -0
  41. package/src/billing/stripe/usage-report-repository.ts +77 -0
  42. package/src/billing/stripe/usage-report-writer.test.ts +194 -0
  43. package/src/billing/stripe/usage-report-writer.ts +139 -0
  44. package/src/gateway/credit-gate.test.ts +62 -0
  45. package/src/gateway/credit-gate.ts +6 -0
  46. package/src/gateway/types.ts +2 -0
  47. package/src/monetization/stripe/stripe-payment-processor.test.ts +1 -0
  48. package/src/trpc/index.ts +4 -0
  49. package/src/trpc/org-remove-payment-method-router.test.ts +188 -0
  50. package/src/trpc/org-remove-payment-method-router.ts +80 -0
@@ -0,0 +1,194 @@
1
+ import crypto from "node:crypto";
2
+ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { Credit } from "../../credits/credit.js";
4
+ import type { PlatformDb } from "../../db/index.js";
5
+ import { billingPeriodSummaries } from "../../db/schema/meter-events.js";
6
+ import { tenantCustomers } from "../../db/schema/tenant-customers.js";
7
+ import { createTestDb, truncateAllTables } from "../../test/db.js";
8
+ import { DrizzleBillingPeriodSummaryRepository } from "./billing-period-summary-repository.js";
9
+ import type { MeteredPriceConfig } from "./metered-price-map.js";
10
+ import { DrizzleTenantCustomerRepository } from "./tenant-store.js";
11
+ import { DrizzleStripeUsageReportRepository } from "./usage-report-repository.js";
12
+ import { runUsageReportWriter } from "./usage-report-writer.js";
13
+
14
+ vi.mock("../../config/logger.js", () => ({
15
+ logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
16
+ }));
17
+
18
+ describe("runUsageReportWriter", () => {
19
+ let db: PlatformDb;
20
+ let pool: import("@electric-sql/pglite").PGlite;
21
+ let reportRepo: DrizzleStripeUsageReportRepository;
22
+ let tenantRepo: DrizzleTenantCustomerRepository;
23
+ let billingPeriodSummaryRepo: DrizzleBillingPeriodSummaryRepository;
24
+
25
+ const NOW = Date.now();
26
+ const PERIOD_START = 1700000000000;
27
+ const PERIOD_END = 1700003600000;
28
+
29
+ const mockCreateMeterEvent = vi.fn().mockResolvedValue({ identifier: "evt_xxx" });
30
+
31
+ const mockStripe = {
32
+ billing: {
33
+ meterEvents: { create: mockCreateMeterEvent },
34
+ },
35
+ } as unknown as import("stripe").default;
36
+
37
+ const priceMap = new Map<string, MeteredPriceConfig>([["chat-completions", { eventName: "chat_completions_usage" }]]);
38
+
39
+ beforeAll(async () => {
40
+ const t = await createTestDb();
41
+ pool = t.pool;
42
+ db = t.db;
43
+ reportRepo = new DrizzleStripeUsageReportRepository(db);
44
+ tenantRepo = new DrizzleTenantCustomerRepository(db);
45
+ billingPeriodSummaryRepo = new DrizzleBillingPeriodSummaryRepository(db);
46
+ });
47
+
48
+ beforeEach(async () => {
49
+ await truncateAllTables(pool);
50
+ vi.clearAllMocks();
51
+ });
52
+
53
+ afterAll(async () => {
54
+ await pool.close();
55
+ });
56
+
57
+ async function seedMeteredTenant(tenant: string) {
58
+ await db.insert(tenantCustomers).values({
59
+ tenant,
60
+ processorCustomerId: `cus_${tenant}`,
61
+ processor: "stripe",
62
+ inferenceMode: "metered",
63
+ createdAt: NOW,
64
+ updatedAt: NOW,
65
+ });
66
+ }
67
+
68
+ async function seedBillingPeriod(tenant: string, opts?: { capability?: string; totalCharge?: number }) {
69
+ await db.insert(billingPeriodSummaries).values({
70
+ id: crypto.randomUUID(),
71
+ tenant,
72
+ capability: opts?.capability ?? "chat-completions",
73
+ provider: "openrouter",
74
+ eventCount: 10,
75
+ totalCost: opts?.totalCharge ?? Credit.fromCents(100).toRaw(),
76
+ totalCharge: opts?.totalCharge ?? Credit.fromCents(100).toRaw(),
77
+ totalDuration: 0,
78
+ periodStart: PERIOD_START,
79
+ periodEnd: PERIOD_END,
80
+ updatedAt: NOW,
81
+ });
82
+ }
83
+
84
+ it("reports usage for metered tenants to Stripe and inserts local record", async () => {
85
+ await seedMeteredTenant("t1");
86
+ await seedBillingPeriod("t1");
87
+
88
+ const result = await runUsageReportWriter({
89
+ stripe: mockStripe,
90
+ tenantRepo,
91
+ billingPeriodSummaryRepo,
92
+ usageReportRepo: reportRepo,
93
+ meteredPriceMap: priceMap,
94
+ periodStart: PERIOD_START,
95
+ periodEnd: PERIOD_END,
96
+ });
97
+
98
+ expect(result.reportsCreated).toBe(1);
99
+ expect(result.errors).toHaveLength(0);
100
+ expect(mockCreateMeterEvent).toHaveBeenCalledOnce();
101
+
102
+ const stored = await reportRepo.getByTenantAndPeriod("t1", "chat-completions", "openrouter", PERIOD_START);
103
+ expect(stored).toBeTruthy();
104
+ expect(stored?.valueCents).toBe(100);
105
+ });
106
+
107
+ it("skips non-metered tenants", async () => {
108
+ // Insert a managed-mode tenant
109
+ await db.insert(tenantCustomers).values({
110
+ tenant: "t2",
111
+ processorCustomerId: "cus_t2",
112
+ processor: "stripe",
113
+ inferenceMode: "managed",
114
+ createdAt: NOW,
115
+ updatedAt: NOW,
116
+ });
117
+ await seedBillingPeriod("t2");
118
+
119
+ const result = await runUsageReportWriter({
120
+ stripe: mockStripe,
121
+ tenantRepo,
122
+ billingPeriodSummaryRepo,
123
+ usageReportRepo: reportRepo,
124
+ meteredPriceMap: priceMap,
125
+ periodStart: PERIOD_START,
126
+ periodEnd: PERIOD_END,
127
+ });
128
+
129
+ expect(result.tenantsProcessed).toBe(0);
130
+ expect(mockCreateMeterEvent).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it("skips already-reported periods (idempotent)", async () => {
134
+ await seedMeteredTenant("t1");
135
+ await seedBillingPeriod("t1");
136
+
137
+ // Pre-insert a report for this period
138
+ await reportRepo.insert({
139
+ id: crypto.randomUUID(),
140
+ tenant: "t1",
141
+ capability: "chat-completions",
142
+ provider: "openrouter",
143
+ periodStart: PERIOD_START,
144
+ periodEnd: PERIOD_END,
145
+ eventName: "chat_completions_usage",
146
+ valueCents: 100,
147
+ reportedAt: NOW,
148
+ });
149
+
150
+ const result = await runUsageReportWriter({
151
+ stripe: mockStripe,
152
+ tenantRepo,
153
+ billingPeriodSummaryRepo,
154
+ usageReportRepo: reportRepo,
155
+ meteredPriceMap: priceMap,
156
+ periodStart: PERIOD_START,
157
+ periodEnd: PERIOD_END,
158
+ });
159
+
160
+ expect(result.reportsSkipped).toBe(1);
161
+ expect(result.reportsCreated).toBe(0);
162
+ expect(mockCreateMeterEvent).not.toHaveBeenCalled();
163
+ });
164
+
165
+ it("skips zero-usage periods", async () => {
166
+ await seedMeteredTenant("t1");
167
+ await db.insert(billingPeriodSummaries).values({
168
+ id: crypto.randomUUID(),
169
+ tenant: "t1",
170
+ capability: "chat-completions",
171
+ provider: "openrouter",
172
+ eventCount: 0,
173
+ totalCost: 0,
174
+ totalCharge: 0,
175
+ totalDuration: 0,
176
+ periodStart: PERIOD_START,
177
+ periodEnd: PERIOD_END,
178
+ updatedAt: NOW,
179
+ });
180
+
181
+ const result = await runUsageReportWriter({
182
+ stripe: mockStripe,
183
+ tenantRepo,
184
+ billingPeriodSummaryRepo,
185
+ usageReportRepo: reportRepo,
186
+ meteredPriceMap: priceMap,
187
+ periodStart: PERIOD_START,
188
+ periodEnd: PERIOD_END,
189
+ });
190
+
191
+ expect(result.reportsCreated).toBe(0);
192
+ expect(mockCreateMeterEvent).not.toHaveBeenCalled();
193
+ });
194
+ });
@@ -0,0 +1,139 @@
1
+ import crypto from "node:crypto";
2
+ import type Stripe from "stripe";
3
+ import { logger } from "../../config/logger.js";
4
+ import { Credit } from "../../credits/credit.js";
5
+ import type { IBillingPeriodSummaryRepository } from "./billing-period-summary-repository.js";
6
+ import type { MeteredPriceConfig } from "./metered-price-map.js";
7
+ import type { ITenantCustomerRepository } from "./tenant-store.js";
8
+ import type { IStripeUsageReportRepository } from "./usage-report-repository.js";
9
+
10
+ export interface UsageReportWriterConfig {
11
+ stripe: Stripe;
12
+ tenantRepo: ITenantCustomerRepository;
13
+ billingPeriodSummaryRepo: IBillingPeriodSummaryRepository;
14
+ usageReportRepo: IStripeUsageReportRepository;
15
+ meteredPriceMap: ReadonlyMap<string, MeteredPriceConfig>;
16
+ /** Start of the billing period to report (unix epoch ms, inclusive). */
17
+ periodStart: number;
18
+ /** End of the billing period to report (unix epoch ms, exclusive). */
19
+ periodEnd: number;
20
+ }
21
+
22
+ export interface UsageReportResult {
23
+ tenantsProcessed: number;
24
+ reportsCreated: number;
25
+ reportsSkipped: number;
26
+ errors: Array<{ tenant: string; capability: string; error: string }>;
27
+ }
28
+
29
+ /**
30
+ * Report metered usage to Stripe for all metered tenants in a given billing period.
31
+ *
32
+ * Uses Stripe's Billing Meters API (stripe.billing.meterEvents.create) — the v20
33
+ * replacement for the legacy subscriptionItems.createUsageRecord API.
34
+ *
35
+ * Flow:
36
+ * 1. Query billingPeriodSummaries for the period window
37
+ * 2. Filter to tenants with inferenceMode === "metered"
38
+ * 3. For each (tenant, capability, provider) tuple:
39
+ * a. Check if already reported (idempotent via stripeUsageReports unique index)
40
+ * b. Submit meter event to Stripe with idempotency identifier
41
+ * c. Insert into stripeUsageReports
42
+ */
43
+ export async function runUsageReportWriter(cfg: UsageReportWriterConfig): Promise<UsageReportResult> {
44
+ const result: UsageReportResult = {
45
+ tenantsProcessed: 0,
46
+ reportsCreated: 0,
47
+ reportsSkipped: 0,
48
+ errors: [],
49
+ };
50
+
51
+ // 1. Find all metered tenants
52
+ const meteredTenants = await cfg.tenantRepo.listMetered();
53
+
54
+ if (meteredTenants.length === 0) return result;
55
+
56
+ const meteredTenantIds = new Set(meteredTenants.map((t) => t.tenant));
57
+ const customerIdMap = new Map(meteredTenants.map((t) => [t.tenant, t.processorCustomerId]));
58
+
59
+ // 2. Query billing period summaries for this period
60
+ const summaries = await cfg.billingPeriodSummaryRepo.listByPeriodWindow(cfg.periodStart, cfg.periodEnd);
61
+
62
+ // 3. Filter to metered tenants only
63
+ const meteredSummaries = summaries.filter((s) => meteredTenantIds.has(s.tenant));
64
+
65
+ const processedTenants = new Set<string>();
66
+
67
+ for (const summary of meteredSummaries) {
68
+ const { tenant, capability, provider, totalCharge } = summary;
69
+
70
+ // Skip zero usage
71
+ if (totalCharge <= 0) continue;
72
+
73
+ // Skip capabilities without a metered price config
74
+ const priceConfig = cfg.meteredPriceMap.get(capability);
75
+ if (!priceConfig) continue;
76
+
77
+ processedTenants.add(tenant);
78
+
79
+ try {
80
+ // Check if already reported (idempotent)
81
+ const existing = await cfg.usageReportRepo.getByTenantAndPeriod(
82
+ tenant,
83
+ capability,
84
+ provider,
85
+ summary.periodStart,
86
+ );
87
+ if (existing) {
88
+ result.reportsSkipped++;
89
+ continue;
90
+ }
91
+
92
+ // Look up Stripe customer ID
93
+ const customerId = customerIdMap.get(tenant);
94
+ if (!customerId) {
95
+ result.errors.push({ tenant, capability, error: "No Stripe customer ID" });
96
+ continue;
97
+ }
98
+
99
+ // Convert nanodollars to cents
100
+ const valueCents = Credit.fromRaw(totalCharge).toCentsRounded();
101
+
102
+ // Build a stable idempotency identifier: tenant + capability + provider + periodStart
103
+ const identifier = `${tenant}:${capability}:${provider}:${summary.periodStart}`;
104
+
105
+ // Submit to Stripe Billing Meters API (v20+)
106
+ await cfg.stripe.billing.meterEvents.create({
107
+ event_name: priceConfig.eventName,
108
+ payload: {
109
+ stripe_customer_id: customerId,
110
+ value: String(valueCents),
111
+ },
112
+ identifier,
113
+ timestamp: Math.floor(summary.periodStart / 1000),
114
+ });
115
+
116
+ // Insert local record
117
+ await cfg.usageReportRepo.insert({
118
+ id: crypto.randomUUID(),
119
+ tenant,
120
+ capability,
121
+ provider,
122
+ periodStart: summary.periodStart,
123
+ periodEnd: summary.periodEnd,
124
+ eventName: priceConfig.eventName,
125
+ valueCents,
126
+ reportedAt: Date.now(),
127
+ });
128
+
129
+ result.reportsCreated++;
130
+ } catch (err) {
131
+ const msg = err instanceof Error ? err.message : String(err);
132
+ logger.error("Failed to report usage to Stripe", { tenant, capability, error: msg });
133
+ result.errors.push({ tenant, capability, error: msg });
134
+ }
135
+ }
136
+
137
+ result.tenantsProcessed = processedTenants.size;
138
+ return result;
139
+ }
@@ -240,3 +240,65 @@ describe("debitCredits with allowNegative and onBalanceExhausted", () => {
240
240
  expect(onBalanceExhausted).not.toHaveBeenCalled();
241
241
  });
242
242
  });
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // creditBalanceCheck — metered tenant bypass
246
+ // ---------------------------------------------------------------------------
247
+
248
+ async function buildHonoContextWithMode(
249
+ tenantId: string,
250
+ inferenceMode: string,
251
+ ): Promise<import("hono").Context<GatewayAuthEnv>> {
252
+ let capturedCtx!: import("hono").Context<GatewayAuthEnv>;
253
+ const app = new Hono<GatewayAuthEnv>();
254
+ app.get("/test", (c) => {
255
+ c.set("gatewayTenant", {
256
+ id: tenantId,
257
+ spendLimits: { maxSpendPerHour: null, maxSpendPerMonth: null },
258
+ inferenceMode,
259
+ } as GatewayTenant);
260
+ capturedCtx = c;
261
+ return c.json({});
262
+ });
263
+ await app.request("/test");
264
+ return capturedCtx;
265
+ }
266
+
267
+ describe("creditBalanceCheck metered tenant bypass", () => {
268
+ beforeEach(async () => {
269
+ await truncateAllTables(pool);
270
+ await new DrizzleLedger(db).seedSystemAccounts();
271
+ });
272
+
273
+ it("skips balance check for metered tenants even with zero balance", async () => {
274
+ const ledger = new DrizzleLedger(db);
275
+ // No credits — would normally fail with credits_exhausted
276
+ const c = await buildHonoContextWithMode("metered-t1", "metered");
277
+ const deps: CreditGateDeps = { creditLedger: ledger, topUpUrl: "/billing" };
278
+ const error = await creditBalanceCheck(c, deps, 0);
279
+ expect(error).toBeNull();
280
+ });
281
+
282
+ it("still debits metered tenants for P&L tracking", async () => {
283
+ const ledger = new DrizzleLedger(db);
284
+ await ledger.credit("metered-t1", Credit.fromCents(0), "purchase", { description: "setup" }).catch(() => {});
285
+ const mockLedger = {
286
+ debit: vi.fn().mockResolvedValue(undefined),
287
+ balance: vi.fn(),
288
+ credit: vi.fn(),
289
+ } as unknown as import("@wopr-network/platform-core/credits").ILedger;
290
+ const deps: CreditGateDeps = { creditLedger: mockLedger, topUpUrl: "/billing" };
291
+ await debitCredits(deps, "metered-tenant", 0.05, 1.5, "chat-completions", "openrouter");
292
+ expect(mockLedger.debit).toHaveBeenCalled();
293
+ });
294
+
295
+ it("non-metered (managed) tenant still gets balance checked", async () => {
296
+ const ledger = new DrizzleLedger(db);
297
+ // No credits seeded — should fail
298
+ const c = await buildHonoContextWithMode("managed-t1", "managed");
299
+ const deps: CreditGateDeps = { creditLedger: ledger, topUpUrl: "/billing" };
300
+ const error = await creditBalanceCheck(c, deps, 0);
301
+ // Balance is 0, within grace buffer — passes
302
+ expect(error).toBeNull();
303
+ });
304
+ });
@@ -61,6 +61,12 @@ export async function creditBalanceCheck(
61
61
  return null;
62
62
  }
63
63
 
64
+ // Metered tenants are invoiced via Stripe subscription, not prepaid credits.
65
+ // Skip balance enforcement but still allow debit (for P&L tracking).
66
+ if (tenant.inferenceMode === "metered") {
67
+ return null;
68
+ }
69
+
64
70
  const balance = await deps.creditLedger.balance(tenant.id);
65
71
  const required = Math.max(0, estimatedCostCents);
66
72
  const graceBuffer = deps.graceBufferCents ?? 50; // default -$0.50
@@ -57,6 +57,8 @@ export interface GatewayTenant {
57
57
  instanceId?: string;
58
58
  /** User-configured spending caps (null fields = no cap). */
59
59
  spendingCaps?: SpendingCaps;
60
+ /** Billing mode — "metered" tenants are invoiced via Stripe, not prepaid credits. */
61
+ inferenceMode?: "metered" | "managed" | "byok";
60
62
  }
61
63
 
62
64
  /** Fetch function type for dependency injection in tests. */
@@ -59,6 +59,7 @@ function createMocks() {
59
59
  setInferenceMode: vi.fn(),
60
60
  list: vi.fn(),
61
61
  buildCustomerIdMap: vi.fn(),
62
+ listMetered: vi.fn(),
62
63
  };
63
64
 
64
65
  const creditLedger: ILedger = {
package/src/trpc/index.ts CHANGED
@@ -13,3 +13,7 @@ export {
13
13
  tenantProcedure,
14
14
  } from "./init.js";
15
15
  export { createNotificationTemplateRouter } from "./notification-template-router.js";
16
+ export {
17
+ createOrgRemovePaymentMethodRouter,
18
+ type OrgRemovePaymentMethodDeps,
19
+ } from "./org-remove-payment-method-router.js";
@@ -0,0 +1,188 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { IPaymentProcessor, SavedPaymentMethod } from "../billing/payment-processor.js";
3
+ import type { AutoTopupSettings, IAutoTopupSettingsRepository } from "../credits/auto-topup-settings-repository.js";
4
+ import type { IOrgMemberRepository } from "../tenancy/org-member-repository.js";
5
+ import { createCallerFactory, router, setTrpcOrgMemberRepo } from "./init.js";
6
+ import { createOrgRemovePaymentMethodRouter } from "./org-remove-payment-method-router.js";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Mock helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ function makeMockOrgMemberRepo(overrides?: Partial<IOrgMemberRepository>): IOrgMemberRepository {
13
+ return {
14
+ listMembers: vi.fn().mockResolvedValue([]),
15
+ addMember: vi.fn().mockResolvedValue(undefined),
16
+ updateMemberRole: vi.fn().mockResolvedValue(undefined),
17
+ removeMember: vi.fn().mockResolvedValue(undefined),
18
+ findMember: vi.fn().mockResolvedValue({ userId: "u1", role: "owner" }),
19
+ countAdminsAndOwners: vi.fn().mockResolvedValue(1),
20
+ listInvites: vi.fn().mockResolvedValue([]),
21
+ createInvite: vi.fn().mockResolvedValue(undefined),
22
+ findInviteById: vi.fn().mockResolvedValue(null),
23
+ findInviteByToken: vi.fn().mockResolvedValue(null),
24
+ deleteInvite: vi.fn().mockResolvedValue(undefined),
25
+ deleteAllMembers: vi.fn().mockResolvedValue(undefined),
26
+ deleteAllInvites: vi.fn().mockResolvedValue(undefined),
27
+ listOrgsByUser: vi.fn().mockResolvedValue([]),
28
+ markInviteAccepted: vi.fn().mockResolvedValue(undefined),
29
+ ...overrides,
30
+ };
31
+ }
32
+
33
+ function makeMockProcessor(methods: SavedPaymentMethod[]): IPaymentProcessor {
34
+ return {
35
+ name: "mock",
36
+ createCheckoutSession: vi.fn(),
37
+ handleWebhook: vi.fn(),
38
+ supportsPortal: vi.fn().mockReturnValue(false),
39
+ createPortalSession: vi.fn(),
40
+ setupPaymentMethod: vi.fn(),
41
+ listPaymentMethods: vi.fn().mockResolvedValue(methods),
42
+ charge: vi.fn(),
43
+ detachPaymentMethod: vi.fn().mockResolvedValue(undefined),
44
+ getCustomerEmail: vi.fn().mockResolvedValue(""),
45
+ updateCustomerEmail: vi.fn(),
46
+ listInvoices: vi.fn().mockResolvedValue([]),
47
+ };
48
+ }
49
+
50
+ function makeMockAutoTopupSettings(overrides?: Partial<AutoTopupSettings>): IAutoTopupSettingsRepository {
51
+ const settings: AutoTopupSettings | null = overrides
52
+ ? ({
53
+ tenantId: "org-1",
54
+ usageEnabled: false,
55
+ usageThreshold: { toCentsRounded: () => 0 },
56
+ usageTopup: { toCentsRounded: () => 0 },
57
+ usageConsecutiveFailures: 0,
58
+ usageChargeInFlight: false,
59
+ scheduleEnabled: false,
60
+ scheduleAmount: { toCentsRounded: () => 0 },
61
+ scheduleIntervalHours: 0,
62
+ scheduleNextAt: null,
63
+ scheduleConsecutiveFailures: 0,
64
+ createdAt: new Date().toISOString(),
65
+ updatedAt: new Date().toISOString(),
66
+ ...overrides,
67
+ } as AutoTopupSettings)
68
+ : null;
69
+
70
+ return {
71
+ getByTenant: vi.fn().mockResolvedValue(settings),
72
+ upsert: vi.fn(),
73
+ setUsageChargeInFlight: vi.fn(),
74
+ tryAcquireUsageInFlight: vi.fn(),
75
+ incrementUsageFailures: vi.fn(),
76
+ resetUsageFailures: vi.fn(),
77
+ disableUsage: vi.fn(),
78
+ incrementScheduleFailures: vi.fn(),
79
+ resetScheduleFailures: vi.fn(),
80
+ disableSchedule: vi.fn(),
81
+ advanceScheduleNextAt: vi.fn(),
82
+ listDueScheduled: vi.fn().mockResolvedValue([]),
83
+ getMaxConsecutiveFailures: vi.fn().mockResolvedValue(0),
84
+ };
85
+ }
86
+
87
+ function authedContext() {
88
+ return { user: { id: "u1", roles: ["user"] }, tenantId: "org-1" };
89
+ }
90
+
91
+ function unauthedContext() {
92
+ return { user: undefined, tenantId: undefined };
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Tests
97
+ // ---------------------------------------------------------------------------
98
+
99
+ describe("orgRemovePaymentMethod router", () => {
100
+ let processor: IPaymentProcessor;
101
+ let autoTopupStore: IAutoTopupSettingsRepository;
102
+
103
+ beforeEach(() => {
104
+ processor = makeMockProcessor([{ id: "pm_1", label: "Visa ending 4242", isDefault: true }]);
105
+ autoTopupStore = makeMockAutoTopupSettings();
106
+ setTrpcOrgMemberRepo(makeMockOrgMemberRepo());
107
+ });
108
+
109
+ function buildCaller(deps: { processor: IPaymentProcessor; autoTopupSettingsStore?: IAutoTopupSettingsRepository }) {
110
+ const subRouter = createOrgRemovePaymentMethodRouter(() => ({
111
+ processor: deps.processor,
112
+ autoTopupSettingsStore: deps.autoTopupSettingsStore,
113
+ }));
114
+ const appRouter = router({ org: subRouter });
115
+ return createCallerFactory(appRouter);
116
+ }
117
+
118
+ it("successfully removes a payment method", async () => {
119
+ const caller = buildCaller({ processor, autoTopupSettingsStore: autoTopupStore })(authedContext());
120
+ const result = await caller.org.orgRemovePaymentMethod({
121
+ orgId: "org-1",
122
+ paymentMethodId: "pm_1",
123
+ });
124
+ expect(result).toEqual({ removed: true });
125
+ expect(processor.detachPaymentMethod).toHaveBeenCalledWith("org-1", "pm_1");
126
+ });
127
+
128
+ it("rejects unauthenticated users", async () => {
129
+ const caller = buildCaller({ processor, autoTopupSettingsStore: autoTopupStore })(unauthedContext());
130
+ await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
131
+ code: "UNAUTHORIZED",
132
+ });
133
+ });
134
+
135
+ it("rejects when removing last PM with auto-topup usage enabled", async () => {
136
+ const usageStore = makeMockAutoTopupSettings({ usageEnabled: true });
137
+ const caller = buildCaller({
138
+ processor,
139
+ autoTopupSettingsStore: usageStore,
140
+ })(authedContext());
141
+ await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
142
+ code: "FORBIDDEN",
143
+ });
144
+ });
145
+
146
+ it("rejects when removing last PM with auto-topup schedule enabled", async () => {
147
+ const scheduleStore = makeMockAutoTopupSettings({ scheduleEnabled: true });
148
+ const caller = buildCaller({
149
+ processor,
150
+ autoTopupSettingsStore: scheduleStore,
151
+ })(authedContext());
152
+ await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
153
+ code: "FORBIDDEN",
154
+ });
155
+ });
156
+
157
+ it("allows removing non-last PM even with auto-topup enabled", async () => {
158
+ const multiProcessor = makeMockProcessor([
159
+ { id: "pm_1", label: "Visa ending 4242", isDefault: true },
160
+ { id: "pm_2", label: "Mastercard ending 5555", isDefault: false },
161
+ ]);
162
+ const usageStore = makeMockAutoTopupSettings({ usageEnabled: true });
163
+ const caller = buildCaller({
164
+ processor: multiProcessor,
165
+ autoTopupSettingsStore: usageStore,
166
+ })(authedContext());
167
+ const result = await caller.org.orgRemovePaymentMethod({
168
+ orgId: "org-1",
169
+ paymentMethodId: "pm_1",
170
+ });
171
+ expect(result).toEqual({ removed: true });
172
+ });
173
+
174
+ it("returns FORBIDDEN when detachPaymentMethod throws PaymentMethodOwnershipError", async () => {
175
+ const { PaymentMethodOwnershipError } = await import("../billing/payment-processor.js");
176
+ const ownershipErrorProcessor = makeMockProcessor([]);
177
+ (ownershipErrorProcessor.detachPaymentMethod as ReturnType<typeof vi.fn>).mockRejectedValue(
178
+ new PaymentMethodOwnershipError(),
179
+ );
180
+ const caller = buildCaller({
181
+ processor: ownershipErrorProcessor,
182
+ autoTopupSettingsStore: autoTopupStore,
183
+ })(authedContext());
184
+ await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
185
+ code: "FORBIDDEN",
186
+ });
187
+ });
188
+ });