@wopr-network/platform-core 1.46.0 → 1.48.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.
@@ -105,6 +105,8 @@ export interface IPaymentProcessor {
105
105
  charge(opts: ChargeOpts): Promise<ChargeResult>;
106
106
  /** Detach a payment method from the tenant's account. */
107
107
  detachPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
108
+ /** Set a payment method as the tenant's default for future invoices. */
109
+ setDefaultPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
108
110
  /** Get the billing email for a tenant's customer account. Returns "" if no customer exists. */
109
111
  getCustomerEmail(tenantId: string): Promise<string>;
110
112
  /** Update the billing email for a tenant's customer account. */
@@ -45,6 +45,7 @@ describe("IPaymentProcessor types", () => {
45
45
  setupPaymentMethod: async () => ({ clientSecret: "cs" }),
46
46
  listPaymentMethods: async () => [],
47
47
  detachPaymentMethod: async () => undefined,
48
+ setDefaultPaymentMethod: async () => undefined,
48
49
  charge: async () => ({ success: true }),
49
50
  getCustomerEmail: async () => "",
50
51
  updateCustomerEmail: async () => undefined,
@@ -41,6 +41,7 @@ export declare class StripePaymentProcessor implements IPaymentProcessor {
41
41
  }>;
42
42
  setupPaymentMethod(tenant: string): Promise<SetupResult>;
43
43
  listPaymentMethods(tenant: string): Promise<SavedPaymentMethod[]>;
44
+ setDefaultPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
44
45
  detachPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
45
46
  getCustomerEmail(tenantId: string): Promise<string>;
46
47
  updateCustomerEmail(tenantId: string, email: string): Promise<void>;
@@ -86,20 +86,43 @@ export class StripePaymentProcessor {
86
86
  if (!mapping) {
87
87
  return [];
88
88
  }
89
- const methods = await this.stripe.customers.listPaymentMethods(mapping.processor_customer_id);
90
- return methods.data.map((pm, index) => ({
89
+ const [methods, customer] = await Promise.all([
90
+ this.stripe.customers.listPaymentMethods(mapping.processor_customer_id),
91
+ this.stripe.customers.retrieve(mapping.processor_customer_id),
92
+ ]);
93
+ const defaultPmId = !customer.deleted && customer.invoice_settings?.default_payment_method
94
+ ? typeof customer.invoice_settings.default_payment_method === "string"
95
+ ? customer.invoice_settings.default_payment_method
96
+ : customer.invoice_settings.default_payment_method.id
97
+ : null;
98
+ return methods.data.map((pm) => ({
91
99
  id: pm.id,
92
100
  label: formatPaymentMethodLabel(pm),
93
- isDefault: index === 0,
101
+ isDefault: defaultPmId ? pm.id === defaultPmId : false,
94
102
  }));
95
103
  }
104
+ async setDefaultPaymentMethod(tenant, paymentMethodId) {
105
+ const mapping = await this.tenantRepo.getByTenant(tenant);
106
+ if (!mapping) {
107
+ throw new Error(`No Stripe customer found for tenant: ${tenant}`);
108
+ }
109
+ const pm = await this.stripe.paymentMethods.retrieve(paymentMethodId);
110
+ const pmCustomerId = typeof pm.customer === "string" ? pm.customer : (pm.customer?.id ?? null);
111
+ if (!pmCustomerId || pmCustomerId !== mapping.processor_customer_id) {
112
+ throw new PaymentMethodOwnershipError();
113
+ }
114
+ await this.stripe.customers.update(mapping.processor_customer_id, {
115
+ invoice_settings: { default_payment_method: paymentMethodId },
116
+ });
117
+ }
96
118
  async detachPaymentMethod(tenant, paymentMethodId) {
97
119
  const mapping = await this.tenantRepo.getByTenant(tenant);
98
120
  if (!mapping) {
99
121
  throw new Error(`No Stripe customer found for tenant: ${tenant}`);
100
122
  }
101
123
  const pm = await this.stripe.paymentMethods.retrieve(paymentMethodId);
102
- if (!pm.customer || pm.customer !== mapping.processor_customer_id) {
124
+ const pmCustomerId = typeof pm.customer === "string" ? pm.customer : (pm.customer?.id ?? null);
125
+ if (!pmCustomerId || pmCustomerId !== mapping.processor_customer_id) {
103
126
  throw new PaymentMethodOwnershipError();
104
127
  }
105
128
  await this.stripe.paymentMethods.detach(paymentMethodId);
@@ -96,7 +96,7 @@ describe("StripePaymentProcessor", () => {
96
96
  const result = await processor.listPaymentMethods("tenant-1");
97
97
  expect(result).toEqual([]);
98
98
  });
99
- it("returns formatted payment methods with card label", async () => {
99
+ it("returns formatted payment methods with card label and reads actual Stripe default", async () => {
100
100
  vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
101
101
  vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
102
102
  data: [
@@ -104,17 +104,55 @@ describe("StripePaymentProcessor", () => {
104
104
  { id: "pm_2", card: { brand: "mastercard", last4: "5555" } },
105
105
  ],
106
106
  });
107
+ vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
108
+ deleted: false,
109
+ invoice_settings: { default_payment_method: "pm_1" },
110
+ });
107
111
  const result = await processor.listPaymentMethods("tenant-1");
108
112
  expect(result).toEqual([
109
113
  { id: "pm_1", label: "Visa ending 4242", isDefault: true },
110
114
  { id: "pm_2", label: "Mastercard ending 5555", isDefault: false },
111
115
  ]);
112
116
  });
117
+ it("marks second PM as default when Stripe says so", async () => {
118
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
119
+ vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
120
+ data: [
121
+ { id: "pm_1", card: { brand: "visa", last4: "4242" } },
122
+ { id: "pm_2", card: { brand: "mastercard", last4: "5555" } },
123
+ ],
124
+ });
125
+ vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
126
+ deleted: false,
127
+ invoice_settings: { default_payment_method: "pm_2" },
128
+ });
129
+ const result = await processor.listPaymentMethods("tenant-1");
130
+ expect(result).toEqual([
131
+ { id: "pm_1", label: "Visa ending 4242", isDefault: false },
132
+ { id: "pm_2", label: "Mastercard ending 5555", isDefault: true },
133
+ ]);
134
+ });
135
+ it("marks no PM as default when customer has no default set", async () => {
136
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
137
+ vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
138
+ data: [{ id: "pm_1", card: { brand: "visa", last4: "4242" } }],
139
+ });
140
+ vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
141
+ deleted: false,
142
+ invoice_settings: { default_payment_method: null },
143
+ });
144
+ const result = await processor.listPaymentMethods("tenant-1");
145
+ expect(result).toEqual([{ id: "pm_1", label: "Visa ending 4242", isDefault: false }]);
146
+ });
113
147
  it("uses generic label for non-card payment methods", async () => {
114
148
  vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
115
149
  vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
116
150
  data: [{ id: "pm_bank", card: undefined }],
117
151
  });
152
+ vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
153
+ deleted: false,
154
+ invoice_settings: { default_payment_method: "pm_bank" },
155
+ });
118
156
  const result = await processor.listPaymentMethods("tenant-1");
119
157
  expect(result).toEqual([{ id: "pm_bank", label: "Payment method pm_bank", isDefault: true }]);
120
158
  });
@@ -152,6 +190,62 @@ describe("StripePaymentProcessor", () => {
152
190
  expect(mocks.stripe.paymentMethods.detach).toHaveBeenCalledWith("pm_1");
153
191
  });
154
192
  });
193
+ // --- setDefaultPaymentMethod ---
194
+ describe("setDefaultPaymentMethod", () => {
195
+ it("throws when tenant has no Stripe customer", async () => {
196
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(null);
197
+ await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow("No Stripe customer found for tenant: tenant-1");
198
+ });
199
+ it("throws PaymentMethodOwnershipError when PM belongs to different customer", async () => {
200
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
201
+ vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
202
+ id: "pm_1",
203
+ customer: "cus_OTHER",
204
+ });
205
+ await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
206
+ });
207
+ it("throws PaymentMethodOwnershipError when PM has no customer", async () => {
208
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
209
+ vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
210
+ id: "pm_1",
211
+ customer: null,
212
+ });
213
+ await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
214
+ });
215
+ it("calls stripe.customers.update with invoice_settings when ownership matches", async () => {
216
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
217
+ vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
218
+ id: "pm_1",
219
+ customer: "cus_123",
220
+ });
221
+ vi.mocked(mocks.stripe.customers.update).mockResolvedValue({});
222
+ await processor.setDefaultPaymentMethod("tenant-1", "pm_1");
223
+ expect(mocks.stripe.customers.update).toHaveBeenCalledWith("cus_123", {
224
+ invoice_settings: { default_payment_method: "pm_1" },
225
+ });
226
+ });
227
+ it("succeeds when PM customer is an expanded Customer object", async () => {
228
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
229
+ vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
230
+ id: "pm_1",
231
+ customer: { id: "cus_123", object: "customer" },
232
+ });
233
+ vi.mocked(mocks.stripe.customers.update).mockResolvedValue({});
234
+ await processor.setDefaultPaymentMethod("tenant-1", "pm_1");
235
+ expect(mocks.stripe.customers.update).toHaveBeenCalledWith("cus_123", {
236
+ invoice_settings: { default_payment_method: "pm_1" },
237
+ });
238
+ });
239
+ it("propagates Stripe API errors", async () => {
240
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
241
+ vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
242
+ id: "pm_1",
243
+ customer: "cus_123",
244
+ });
245
+ vi.mocked(mocks.stripe.customers.update).mockRejectedValue(new Error("Stripe API error"));
246
+ await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow("Stripe API error");
247
+ });
248
+ });
155
249
  // --- getCustomerEmail ---
156
250
  describe("getCustomerEmail", () => {
157
251
  it("returns empty string when tenant has no Stripe customer", async () => {
@@ -33,6 +33,7 @@ export declare class StripePaymentProcessor implements IPaymentProcessor {
33
33
  }>;
34
34
  setupPaymentMethod(tenant: string): Promise<SetupResult>;
35
35
  listPaymentMethods(tenant: string): Promise<SavedPaymentMethod[]>;
36
+ setDefaultPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
36
37
  detachPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
37
38
  getCustomerEmail(tenantId: string): Promise<string>;
38
39
  updateCustomerEmail(tenantId: string, email: string): Promise<void>;
@@ -89,13 +89,35 @@ export class StripePaymentProcessor {
89
89
  if (!mapping) {
90
90
  return [];
91
91
  }
92
- const methods = await this.stripe.customers.listPaymentMethods(mapping.processor_customer_id);
93
- return methods.data.map((pm, index) => ({
92
+ const [methods, customer] = await Promise.all([
93
+ this.stripe.customers.listPaymentMethods(mapping.processor_customer_id),
94
+ this.stripe.customers.retrieve(mapping.processor_customer_id),
95
+ ]);
96
+ const defaultPmId = !customer.deleted && customer.invoice_settings?.default_payment_method
97
+ ? typeof customer.invoice_settings.default_payment_method === "string"
98
+ ? customer.invoice_settings.default_payment_method
99
+ : customer.invoice_settings.default_payment_method.id
100
+ : null;
101
+ return methods.data.map((pm) => ({
94
102
  id: pm.id,
95
103
  label: formatPaymentMethodLabel(pm),
96
- isDefault: index === 0,
104
+ isDefault: defaultPmId ? pm.id === defaultPmId : false,
97
105
  }));
98
106
  }
107
+ async setDefaultPaymentMethod(tenant, paymentMethodId) {
108
+ const mapping = await this.tenantRepo.getByTenant(tenant);
109
+ if (!mapping) {
110
+ throw new Error(`No Stripe customer found for tenant: ${tenant}`);
111
+ }
112
+ const pm = await this.stripe.paymentMethods.retrieve(paymentMethodId);
113
+ const pmCustomerId = typeof pm.customer === "string" ? pm.customer : (pm.customer?.id ?? null);
114
+ if (!pmCustomerId || pmCustomerId !== mapping.processor_customer_id) {
115
+ throw new PaymentMethodOwnershipError();
116
+ }
117
+ await this.stripe.customers.update(mapping.processor_customer_id, {
118
+ invoice_settings: { default_payment_method: paymentMethodId },
119
+ });
120
+ }
99
121
  async detachPaymentMethod(tenant, paymentMethodId) {
100
122
  const mapping = await this.tenantRepo.getByTenant(tenant);
101
123
  if (!mapping) {
@@ -102,7 +102,7 @@ describe("StripePaymentProcessor", () => {
102
102
  const result = await processor.listPaymentMethods("tenant-1");
103
103
  expect(result).toEqual([]);
104
104
  });
105
- it("returns formatted payment methods with card label", async () => {
105
+ it("returns formatted payment methods with card label and reads actual Stripe default", async () => {
106
106
  vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
107
107
  vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
108
108
  data: [
@@ -110,6 +110,10 @@ describe("StripePaymentProcessor", () => {
110
110
  { id: "pm_2", card: { brand: "mastercard", last4: "5555" } },
111
111
  ],
112
112
  });
113
+ vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
114
+ deleted: false,
115
+ invoice_settings: { default_payment_method: "pm_1" },
116
+ });
113
117
  const result = await processor.listPaymentMethods("tenant-1");
114
118
  expect(result).toEqual([
115
119
  { id: "pm_1", label: "Visa ending 4242", isDefault: true },
@@ -121,6 +125,10 @@ describe("StripePaymentProcessor", () => {
121
125
  vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
122
126
  data: [{ id: "pm_bank", card: undefined }],
123
127
  });
128
+ vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
129
+ deleted: false,
130
+ invoice_settings: { default_payment_method: "pm_bank" },
131
+ });
124
132
  const result = await processor.listPaymentMethods("tenant-1");
125
133
  expect(result).toEqual([{ id: "pm_bank", label: "Payment method pm_bank", isDefault: true }]);
126
134
  });
@@ -158,6 +166,70 @@ describe("StripePaymentProcessor", () => {
158
166
  expect(mocks.stripe.paymentMethods.detach).toHaveBeenCalledWith("pm_1");
159
167
  });
160
168
  });
169
+ // --- setDefaultPaymentMethod ---
170
+ describe("setDefaultPaymentMethod", () => {
171
+ it("throws when tenant has no Stripe customer", async () => {
172
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(null);
173
+ await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow("No Stripe customer found for tenant: tenant-1");
174
+ });
175
+ it("throws PaymentMethodOwnershipError when PM has no customer", async () => {
176
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
177
+ vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
178
+ id: "pm_1",
179
+ customer: null,
180
+ });
181
+ await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
182
+ });
183
+ it("throws PaymentMethodOwnershipError when PM belongs to different customer", async () => {
184
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
185
+ vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
186
+ id: "pm_1",
187
+ customer: "cus_OTHER",
188
+ });
189
+ await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
190
+ });
191
+ it("throws PaymentMethodOwnershipError when PM customer is an expanded object with different id", async () => {
192
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
193
+ vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
194
+ id: "pm_1",
195
+ customer: { id: "cus_OTHER", object: "customer" },
196
+ });
197
+ await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
198
+ });
199
+ it("sets default when ownership matches (string customer)", async () => {
200
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
201
+ vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
202
+ id: "pm_1",
203
+ customer: "cus_123",
204
+ });
205
+ vi.mocked(mocks.stripe.customers.update).mockResolvedValue({});
206
+ await processor.setDefaultPaymentMethod("tenant-1", "pm_1");
207
+ expect(mocks.stripe.customers.update).toHaveBeenCalledWith("cus_123", {
208
+ invoice_settings: { default_payment_method: "pm_1" },
209
+ });
210
+ });
211
+ it("sets default when ownership matches (expanded Customer object)", async () => {
212
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
213
+ vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
214
+ id: "pm_1",
215
+ customer: { id: "cus_123", object: "customer" },
216
+ });
217
+ vi.mocked(mocks.stripe.customers.update).mockResolvedValue({});
218
+ await processor.setDefaultPaymentMethod("tenant-1", "pm_1");
219
+ expect(mocks.stripe.customers.update).toHaveBeenCalledWith("cus_123", {
220
+ invoice_settings: { default_payment_method: "pm_1" },
221
+ });
222
+ });
223
+ it("propagates Stripe errors", async () => {
224
+ vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
225
+ vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
226
+ id: "pm_1",
227
+ customer: "cus_123",
228
+ });
229
+ vi.mocked(mocks.stripe.customers.update).mockRejectedValue(new Error("Stripe network error"));
230
+ await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow("Stripe network error");
231
+ });
232
+ });
161
233
  // --- getCustomerEmail ---
162
234
  describe("getCustomerEmail", () => {
163
235
  it("returns empty string when tenant has no Stripe customer", async () => {
@@ -2,3 +2,4 @@ export { createAdminFleetUpdateRouter } from "./admin-fleet-update-router.js";
2
2
  export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
3
3
  export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, type TRPCContext, tenantProcedure, } from "./init.js";
4
4
  export { createNotificationTemplateRouter } from "./notification-template-router.js";
5
+ export { createOrgRemovePaymentMethodRouter, type OrgRemovePaymentMethodDeps, } from "./org-remove-payment-method-router.js";
@@ -2,3 +2,4 @@ export { createAdminFleetUpdateRouter } from "./admin-fleet-update-router.js";
2
2
  export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
3
3
  export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, tenantProcedure, } from "./init.js";
4
4
  export { createNotificationTemplateRouter } from "./notification-template-router.js";
5
+ export { createOrgRemovePaymentMethodRouter, } from "./org-remove-payment-method-router.js";
@@ -0,0 +1,23 @@
1
+ import type { IPaymentProcessor } from "../billing/payment-processor.js";
2
+ import type { IAutoTopupSettingsRepository } from "../credits/auto-topup-settings-repository.js";
3
+ export interface OrgRemovePaymentMethodDeps {
4
+ processor: IPaymentProcessor;
5
+ autoTopupSettingsStore?: IAutoTopupSettingsRepository;
6
+ }
7
+ export declare function createOrgRemovePaymentMethodRouter(getDeps: () => OrgRemovePaymentMethodDeps): import("@trpc/server").TRPCBuiltRouter<{
8
+ ctx: import("./init.js").TRPCContext;
9
+ meta: object;
10
+ errorShape: import("@trpc/server").TRPCDefaultErrorShape;
11
+ transformer: false;
12
+ }, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
13
+ orgRemovePaymentMethod: import("@trpc/server").TRPCMutationProcedure<{
14
+ input: {
15
+ orgId: string;
16
+ paymentMethodId: string;
17
+ };
18
+ output: {
19
+ removed: boolean;
20
+ };
21
+ meta: object;
22
+ }>;
23
+ }>>;
@@ -0,0 +1,61 @@
1
+ import { TRPCError } from "@trpc/server";
2
+ import { z } from "zod";
3
+ import { PaymentMethodOwnershipError } from "../billing/payment-processor.js";
4
+ import { orgAdminProcedure, router } from "./init.js";
5
+ export function createOrgRemovePaymentMethodRouter(getDeps) {
6
+ return router({
7
+ orgRemovePaymentMethod: orgAdminProcedure
8
+ .input(z.object({
9
+ orgId: z.string().min(1),
10
+ paymentMethodId: z.string().min(1),
11
+ }))
12
+ .mutation(async ({ input }) => {
13
+ const { processor, autoTopupSettingsStore } = getDeps();
14
+ if (!autoTopupSettingsStore) {
15
+ console.warn("orgRemovePaymentMethod: autoTopupSettingsStore not provided — last-payment-method guard is inactive");
16
+ }
17
+ // Guard: prevent removing the last payment method when auto-topup is enabled
18
+ if (autoTopupSettingsStore) {
19
+ const methods = await processor.listPaymentMethods(input.orgId);
20
+ if (methods.length <= 1) {
21
+ const settings = await autoTopupSettingsStore.getByTenant(input.orgId);
22
+ if (settings && (settings.usageEnabled || settings.scheduleEnabled)) {
23
+ throw new TRPCError({
24
+ code: "FORBIDDEN",
25
+ message: "Cannot remove last payment method while auto-topup is enabled. Disable auto-topup first.",
26
+ });
27
+ }
28
+ }
29
+ }
30
+ try {
31
+ await processor.detachPaymentMethod(input.orgId, input.paymentMethodId);
32
+ }
33
+ catch (err) {
34
+ if (err instanceof PaymentMethodOwnershipError) {
35
+ throw new TRPCError({
36
+ code: "FORBIDDEN",
37
+ message: "Payment method does not belong to this organization",
38
+ });
39
+ }
40
+ throw new TRPCError({
41
+ code: "INTERNAL_SERVER_ERROR",
42
+ message: "Failed to remove payment method. Please try again.",
43
+ });
44
+ }
45
+ // TOCTOU guard: re-check count after detach. A concurrent request
46
+ // may have already removed another payment method between our pre-check
47
+ // and this detach, leaving the org with 0 methods while auto-topup is
48
+ // still enabled. Warn operators — the method is already gone.
49
+ if (autoTopupSettingsStore) {
50
+ const remaining = await processor.listPaymentMethods(input.orgId);
51
+ if (remaining.length === 0) {
52
+ const settings = await autoTopupSettingsStore.getByTenant(input.orgId);
53
+ if (settings && (settings.usageEnabled || settings.scheduleEnabled)) {
54
+ console.warn("orgRemovePaymentMethod: TOCTOU — org %s now has 0 payment methods with auto-topup enabled. Operator must add a payment method or disable auto-topup.", input.orgId);
55
+ }
56
+ }
57
+ }
58
+ return { removed: true };
59
+ }),
60
+ });
61
+ }
@@ -0,0 +1,167 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { createCallerFactory, router, setTrpcOrgMemberRepo } from "./init.js";
3
+ import { createOrgRemovePaymentMethodRouter } from "./org-remove-payment-method-router.js";
4
+ // ---------------------------------------------------------------------------
5
+ // Mock helpers
6
+ // ---------------------------------------------------------------------------
7
+ function makeMockOrgMemberRepo(overrides) {
8
+ return {
9
+ listMembers: vi.fn().mockResolvedValue([]),
10
+ addMember: vi.fn().mockResolvedValue(undefined),
11
+ updateMemberRole: vi.fn().mockResolvedValue(undefined),
12
+ removeMember: vi.fn().mockResolvedValue(undefined),
13
+ findMember: vi.fn().mockResolvedValue({ userId: "u1", role: "owner" }),
14
+ countAdminsAndOwners: vi.fn().mockResolvedValue(1),
15
+ listInvites: vi.fn().mockResolvedValue([]),
16
+ createInvite: vi.fn().mockResolvedValue(undefined),
17
+ findInviteById: vi.fn().mockResolvedValue(null),
18
+ findInviteByToken: vi.fn().mockResolvedValue(null),
19
+ deleteInvite: vi.fn().mockResolvedValue(undefined),
20
+ deleteAllMembers: vi.fn().mockResolvedValue(undefined),
21
+ deleteAllInvites: vi.fn().mockResolvedValue(undefined),
22
+ listOrgsByUser: vi.fn().mockResolvedValue([]),
23
+ markInviteAccepted: vi.fn().mockResolvedValue(undefined),
24
+ ...overrides,
25
+ };
26
+ }
27
+ function makeMockProcessor(methods) {
28
+ return {
29
+ name: "mock",
30
+ createCheckoutSession: vi.fn(),
31
+ handleWebhook: vi.fn(),
32
+ supportsPortal: vi.fn().mockReturnValue(false),
33
+ createPortalSession: vi.fn(),
34
+ setupPaymentMethod: vi.fn(),
35
+ listPaymentMethods: vi.fn().mockResolvedValue(methods),
36
+ charge: vi.fn(),
37
+ detachPaymentMethod: vi.fn().mockResolvedValue(undefined),
38
+ getCustomerEmail: vi.fn().mockResolvedValue(""),
39
+ updateCustomerEmail: vi.fn(),
40
+ listInvoices: vi.fn().mockResolvedValue([]),
41
+ setDefaultPaymentMethod: vi.fn().mockResolvedValue(undefined),
42
+ };
43
+ }
44
+ function makeMockAutoTopupSettings(overrides) {
45
+ const settings = overrides
46
+ ? {
47
+ tenantId: "org-1",
48
+ usageEnabled: false,
49
+ usageThreshold: { toCentsRounded: () => 0 },
50
+ usageTopup: { toCentsRounded: () => 0 },
51
+ usageConsecutiveFailures: 0,
52
+ usageChargeInFlight: false,
53
+ scheduleEnabled: false,
54
+ scheduleAmount: { toCentsRounded: () => 0 },
55
+ scheduleIntervalHours: 0,
56
+ scheduleNextAt: null,
57
+ scheduleConsecutiveFailures: 0,
58
+ createdAt: new Date().toISOString(),
59
+ updatedAt: new Date().toISOString(),
60
+ ...overrides,
61
+ }
62
+ : null;
63
+ return {
64
+ getByTenant: vi.fn().mockResolvedValue(settings),
65
+ upsert: vi.fn(),
66
+ setUsageChargeInFlight: vi.fn(),
67
+ tryAcquireUsageInFlight: vi.fn(),
68
+ incrementUsageFailures: vi.fn(),
69
+ resetUsageFailures: vi.fn(),
70
+ disableUsage: vi.fn(),
71
+ incrementScheduleFailures: vi.fn(),
72
+ resetScheduleFailures: vi.fn(),
73
+ disableSchedule: vi.fn(),
74
+ advanceScheduleNextAt: vi.fn(),
75
+ listDueScheduled: vi.fn().mockResolvedValue([]),
76
+ getMaxConsecutiveFailures: vi.fn().mockResolvedValue(0),
77
+ };
78
+ }
79
+ function authedContext() {
80
+ return { user: { id: "u1", roles: ["user"] }, tenantId: "org-1" };
81
+ }
82
+ function unauthedContext() {
83
+ return { user: undefined, tenantId: undefined };
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Tests
87
+ // ---------------------------------------------------------------------------
88
+ describe("orgRemovePaymentMethod router", () => {
89
+ let processor;
90
+ let autoTopupStore;
91
+ beforeEach(() => {
92
+ processor = makeMockProcessor([{ id: "pm_1", label: "Visa ending 4242", isDefault: true }]);
93
+ autoTopupStore = makeMockAutoTopupSettings();
94
+ setTrpcOrgMemberRepo(makeMockOrgMemberRepo());
95
+ });
96
+ function buildCaller(deps) {
97
+ const subRouter = createOrgRemovePaymentMethodRouter(() => ({
98
+ processor: deps.processor,
99
+ autoTopupSettingsStore: deps.autoTopupSettingsStore,
100
+ }));
101
+ const appRouter = router({ org: subRouter });
102
+ return createCallerFactory(appRouter);
103
+ }
104
+ it("successfully removes a payment method", async () => {
105
+ const caller = buildCaller({ processor, autoTopupSettingsStore: autoTopupStore })(authedContext());
106
+ const result = await caller.org.orgRemovePaymentMethod({
107
+ orgId: "org-1",
108
+ paymentMethodId: "pm_1",
109
+ });
110
+ expect(result).toEqual({ removed: true });
111
+ expect(processor.detachPaymentMethod).toHaveBeenCalledWith("org-1", "pm_1");
112
+ });
113
+ it("rejects unauthenticated users", async () => {
114
+ const caller = buildCaller({ processor, autoTopupSettingsStore: autoTopupStore })(unauthedContext());
115
+ await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
116
+ code: "UNAUTHORIZED",
117
+ });
118
+ });
119
+ it("rejects when removing last PM with auto-topup usage enabled", async () => {
120
+ const usageStore = makeMockAutoTopupSettings({ usageEnabled: true });
121
+ const caller = buildCaller({
122
+ processor,
123
+ autoTopupSettingsStore: usageStore,
124
+ })(authedContext());
125
+ await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
126
+ code: "FORBIDDEN",
127
+ });
128
+ });
129
+ it("rejects when removing last PM with auto-topup schedule enabled", async () => {
130
+ const scheduleStore = makeMockAutoTopupSettings({ scheduleEnabled: true });
131
+ const caller = buildCaller({
132
+ processor,
133
+ autoTopupSettingsStore: scheduleStore,
134
+ })(authedContext());
135
+ await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
136
+ code: "FORBIDDEN",
137
+ });
138
+ });
139
+ it("allows removing non-last PM even with auto-topup enabled", async () => {
140
+ const multiProcessor = makeMockProcessor([
141
+ { id: "pm_1", label: "Visa ending 4242", isDefault: true },
142
+ { id: "pm_2", label: "Mastercard ending 5555", isDefault: false },
143
+ ]);
144
+ const usageStore = makeMockAutoTopupSettings({ usageEnabled: true });
145
+ const caller = buildCaller({
146
+ processor: multiProcessor,
147
+ autoTopupSettingsStore: usageStore,
148
+ })(authedContext());
149
+ const result = await caller.org.orgRemovePaymentMethod({
150
+ orgId: "org-1",
151
+ paymentMethodId: "pm_1",
152
+ });
153
+ expect(result).toEqual({ removed: true });
154
+ });
155
+ it("returns FORBIDDEN when detachPaymentMethod throws PaymentMethodOwnershipError", async () => {
156
+ const { PaymentMethodOwnershipError } = await import("../billing/payment-processor.js");
157
+ const ownershipErrorProcessor = makeMockProcessor([]);
158
+ ownershipErrorProcessor.detachPaymentMethod.mockRejectedValue(new PaymentMethodOwnershipError());
159
+ const caller = buildCaller({
160
+ processor: ownershipErrorProcessor,
161
+ autoTopupSettingsStore: autoTopupStore,
162
+ })(authedContext());
163
+ await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
164
+ code: "FORBIDDEN",
165
+ });
166
+ });
167
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.46.0",
3
+ "version": "1.48.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -63,6 +63,7 @@ describe("IPaymentProcessor types", () => {
63
63
  setupPaymentMethod: async () => ({ clientSecret: "cs" }),
64
64
  listPaymentMethods: async () => [],
65
65
  detachPaymentMethod: async () => undefined,
66
+ setDefaultPaymentMethod: async () => undefined,
66
67
  charge: async () => ({ success: true }),
67
68
  getCustomerEmail: async () => "",
68
69
  updateCustomerEmail: async () => undefined,
@@ -125,6 +125,9 @@ export interface IPaymentProcessor {
125
125
  /** Detach a payment method from the tenant's account. */
126
126
  detachPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
127
127
 
128
+ /** Set a payment method as the tenant's default for future invoices. */
129
+ setDefaultPaymentMethod(tenant: string, paymentMethodId: string): Promise<void>;
130
+
128
131
  /** Get the billing email for a tenant's customer account. Returns "" if no customer exists. */
129
132
  getCustomerEmail(tenantId: string): Promise<string>;
130
133