@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.
- package/dist/billing/payment-processor.d.ts +2 -0
- package/dist/billing/payment-processor.test.js +1 -0
- package/dist/billing/stripe/stripe-payment-processor.d.ts +1 -0
- package/dist/billing/stripe/stripe-payment-processor.js +27 -4
- package/dist/billing/stripe/stripe-payment-processor.test.js +95 -1
- package/dist/monetization/stripe/stripe-payment-processor.d.ts +1 -0
- package/dist/monetization/stripe/stripe-payment-processor.js +25 -3
- package/dist/monetization/stripe/stripe-payment-processor.test.js +73 -1
- package/dist/trpc/index.d.ts +1 -0
- package/dist/trpc/index.js +1 -0
- package/dist/trpc/org-remove-payment-method-router.d.ts +23 -0
- package/dist/trpc/org-remove-payment-method-router.js +61 -0
- package/dist/trpc/org-remove-payment-method-router.test.d.ts +1 -0
- package/dist/trpc/org-remove-payment-method-router.test.js +167 -0
- package/package.json +1 -1
- package/src/billing/payment-processor.test.ts +1 -0
- package/src/billing/payment-processor.ts +3 -0
- package/src/billing/stripe/stripe-payment-processor.test.ts +113 -1
- package/src/billing/stripe/stripe-payment-processor.ts +33 -5
- package/src/monetization/stripe/stripe-payment-processor.test.ts +89 -1
- package/src/monetization/stripe/stripe-payment-processor.ts +31 -4
- package/src/trpc/index.ts +4 -0
- package/src/trpc/org-remove-payment-method-router.test.ts +189 -0
- package/src/trpc/org-remove-payment-method-router.ts +80 -0
|
@@ -120,7 +120,7 @@ describe("StripePaymentProcessor", () => {
|
|
|
120
120
|
expect(result).toEqual([]);
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
it("returns formatted payment methods with card label", async () => {
|
|
123
|
+
it("returns formatted payment methods with card label and reads actual Stripe default", async () => {
|
|
124
124
|
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
125
125
|
vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
|
|
126
126
|
data: [
|
|
@@ -128,6 +128,10 @@ describe("StripePaymentProcessor", () => {
|
|
|
128
128
|
{ id: "pm_2", card: { brand: "mastercard", last4: "5555" } },
|
|
129
129
|
],
|
|
130
130
|
} as unknown as Stripe.Response<Stripe.ApiList<Stripe.PaymentMethod>>);
|
|
131
|
+
vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
|
|
132
|
+
deleted: false,
|
|
133
|
+
invoice_settings: { default_payment_method: "pm_1" },
|
|
134
|
+
} as unknown as Stripe.Response<Stripe.Customer>);
|
|
131
135
|
|
|
132
136
|
const result = await processor.listPaymentMethods("tenant-1");
|
|
133
137
|
expect(result).toEqual([
|
|
@@ -136,11 +140,49 @@ describe("StripePaymentProcessor", () => {
|
|
|
136
140
|
]);
|
|
137
141
|
});
|
|
138
142
|
|
|
143
|
+
it("marks second PM as default when Stripe says so", async () => {
|
|
144
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
145
|
+
vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
|
|
146
|
+
data: [
|
|
147
|
+
{ id: "pm_1", card: { brand: "visa", last4: "4242" } },
|
|
148
|
+
{ id: "pm_2", card: { brand: "mastercard", last4: "5555" } },
|
|
149
|
+
],
|
|
150
|
+
} as unknown as Stripe.Response<Stripe.ApiList<Stripe.PaymentMethod>>);
|
|
151
|
+
vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
|
|
152
|
+
deleted: false,
|
|
153
|
+
invoice_settings: { default_payment_method: "pm_2" },
|
|
154
|
+
} as unknown as Stripe.Response<Stripe.Customer>);
|
|
155
|
+
|
|
156
|
+
const result = await processor.listPaymentMethods("tenant-1");
|
|
157
|
+
expect(result).toEqual([
|
|
158
|
+
{ id: "pm_1", label: "Visa ending 4242", isDefault: false },
|
|
159
|
+
{ id: "pm_2", label: "Mastercard ending 5555", isDefault: true },
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("marks no PM as default when customer has no default set", async () => {
|
|
164
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
165
|
+
vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
|
|
166
|
+
data: [{ id: "pm_1", card: { brand: "visa", last4: "4242" } }],
|
|
167
|
+
} as unknown as Stripe.Response<Stripe.ApiList<Stripe.PaymentMethod>>);
|
|
168
|
+
vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
|
|
169
|
+
deleted: false,
|
|
170
|
+
invoice_settings: { default_payment_method: null },
|
|
171
|
+
} as unknown as Stripe.Response<Stripe.Customer>);
|
|
172
|
+
|
|
173
|
+
const result = await processor.listPaymentMethods("tenant-1");
|
|
174
|
+
expect(result).toEqual([{ id: "pm_1", label: "Visa ending 4242", isDefault: false }]);
|
|
175
|
+
});
|
|
176
|
+
|
|
139
177
|
it("uses generic label for non-card payment methods", async () => {
|
|
140
178
|
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
141
179
|
vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
|
|
142
180
|
data: [{ id: "pm_bank", card: undefined }],
|
|
143
181
|
} as unknown as Stripe.Response<Stripe.ApiList<Stripe.PaymentMethod>>);
|
|
182
|
+
vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
|
|
183
|
+
deleted: false,
|
|
184
|
+
invoice_settings: { default_payment_method: "pm_bank" },
|
|
185
|
+
} as unknown as Stripe.Response<Stripe.Customer>);
|
|
144
186
|
|
|
145
187
|
const result = await processor.listPaymentMethods("tenant-1");
|
|
146
188
|
expect(result).toEqual([{ id: "pm_bank", label: "Payment method pm_bank", isDefault: true }]);
|
|
@@ -192,6 +234,76 @@ describe("StripePaymentProcessor", () => {
|
|
|
192
234
|
});
|
|
193
235
|
});
|
|
194
236
|
|
|
237
|
+
// --- setDefaultPaymentMethod ---
|
|
238
|
+
|
|
239
|
+
describe("setDefaultPaymentMethod", () => {
|
|
240
|
+
it("throws when tenant has no Stripe customer", async () => {
|
|
241
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(null);
|
|
242
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(
|
|
243
|
+
"No Stripe customer found for tenant: tenant-1",
|
|
244
|
+
);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("throws PaymentMethodOwnershipError when PM belongs to different customer", async () => {
|
|
248
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
249
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
250
|
+
id: "pm_1",
|
|
251
|
+
customer: "cus_OTHER",
|
|
252
|
+
} as unknown as Stripe.Response<Stripe.PaymentMethod>);
|
|
253
|
+
|
|
254
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("throws PaymentMethodOwnershipError when PM has no customer", async () => {
|
|
258
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
259
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
260
|
+
id: "pm_1",
|
|
261
|
+
customer: null,
|
|
262
|
+
} as unknown as Stripe.Response<Stripe.PaymentMethod>);
|
|
263
|
+
|
|
264
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("calls stripe.customers.update with invoice_settings when ownership matches", async () => {
|
|
268
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
269
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
270
|
+
id: "pm_1",
|
|
271
|
+
customer: "cus_123",
|
|
272
|
+
} as unknown as Stripe.Response<Stripe.PaymentMethod>);
|
|
273
|
+
vi.mocked(mocks.stripe.customers.update).mockResolvedValue({} as unknown as Stripe.Response<Stripe.Customer>);
|
|
274
|
+
|
|
275
|
+
await processor.setDefaultPaymentMethod("tenant-1", "pm_1");
|
|
276
|
+
expect(mocks.stripe.customers.update).toHaveBeenCalledWith("cus_123", {
|
|
277
|
+
invoice_settings: { default_payment_method: "pm_1" },
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("succeeds when PM customer is an expanded Customer object", async () => {
|
|
282
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
283
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
284
|
+
id: "pm_1",
|
|
285
|
+
customer: { id: "cus_123", object: "customer" },
|
|
286
|
+
} as unknown as Stripe.Response<Stripe.PaymentMethod>);
|
|
287
|
+
vi.mocked(mocks.stripe.customers.update).mockResolvedValue({} as unknown as Stripe.Response<Stripe.Customer>);
|
|
288
|
+
|
|
289
|
+
await processor.setDefaultPaymentMethod("tenant-1", "pm_1");
|
|
290
|
+
expect(mocks.stripe.customers.update).toHaveBeenCalledWith("cus_123", {
|
|
291
|
+
invoice_settings: { default_payment_method: "pm_1" },
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("propagates Stripe API errors", async () => {
|
|
296
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
297
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
298
|
+
id: "pm_1",
|
|
299
|
+
customer: "cus_123",
|
|
300
|
+
} as unknown as Stripe.Response<Stripe.PaymentMethod>);
|
|
301
|
+
vi.mocked(mocks.stripe.customers.update).mockRejectedValue(new Error("Stripe API error"));
|
|
302
|
+
|
|
303
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow("Stripe API error");
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
195
307
|
// --- getCustomerEmail ---
|
|
196
308
|
|
|
197
309
|
describe("getCustomerEmail", () => {
|
|
@@ -154,15 +154,42 @@ export class StripePaymentProcessor implements IPaymentProcessor {
|
|
|
154
154
|
return [];
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
const methods = await
|
|
158
|
-
|
|
159
|
-
|
|
157
|
+
const [methods, customer] = await Promise.all([
|
|
158
|
+
this.stripe.customers.listPaymentMethods(mapping.processor_customer_id),
|
|
159
|
+
this.stripe.customers.retrieve(mapping.processor_customer_id),
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
const defaultPmId =
|
|
163
|
+
!customer.deleted && customer.invoice_settings?.default_payment_method
|
|
164
|
+
? typeof customer.invoice_settings.default_payment_method === "string"
|
|
165
|
+
? customer.invoice_settings.default_payment_method
|
|
166
|
+
: customer.invoice_settings.default_payment_method.id
|
|
167
|
+
: null;
|
|
168
|
+
|
|
169
|
+
return methods.data.map((pm) => ({
|
|
160
170
|
id: pm.id,
|
|
161
171
|
label: formatPaymentMethodLabel(pm),
|
|
162
|
-
isDefault:
|
|
172
|
+
isDefault: defaultPmId ? pm.id === defaultPmId : false,
|
|
163
173
|
}));
|
|
164
174
|
}
|
|
165
175
|
|
|
176
|
+
async setDefaultPaymentMethod(tenant: string, paymentMethodId: string): Promise<void> {
|
|
177
|
+
const mapping = await this.tenantRepo.getByTenant(tenant);
|
|
178
|
+
if (!mapping) {
|
|
179
|
+
throw new Error(`No Stripe customer found for tenant: ${tenant}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const pm = await this.stripe.paymentMethods.retrieve(paymentMethodId);
|
|
183
|
+
const pmCustomerId = typeof pm.customer === "string" ? pm.customer : (pm.customer?.id ?? null);
|
|
184
|
+
if (!pmCustomerId || pmCustomerId !== mapping.processor_customer_id) {
|
|
185
|
+
throw new PaymentMethodOwnershipError();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await this.stripe.customers.update(mapping.processor_customer_id, {
|
|
189
|
+
invoice_settings: { default_payment_method: paymentMethodId },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
166
193
|
async detachPaymentMethod(tenant: string, paymentMethodId: string): Promise<void> {
|
|
167
194
|
const mapping = await this.tenantRepo.getByTenant(tenant);
|
|
168
195
|
if (!mapping) {
|
|
@@ -170,7 +197,8 @@ export class StripePaymentProcessor implements IPaymentProcessor {
|
|
|
170
197
|
}
|
|
171
198
|
|
|
172
199
|
const pm = await this.stripe.paymentMethods.retrieve(paymentMethodId);
|
|
173
|
-
|
|
200
|
+
const pmCustomerId = typeof pm.customer === "string" ? pm.customer : (pm.customer?.id ?? null);
|
|
201
|
+
if (!pmCustomerId || pmCustomerId !== mapping.processor_customer_id) {
|
|
174
202
|
throw new PaymentMethodOwnershipError();
|
|
175
203
|
}
|
|
176
204
|
|
|
@@ -129,7 +129,7 @@ describe("StripePaymentProcessor", () => {
|
|
|
129
129
|
expect(result).toEqual([]);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
-
it("returns formatted payment methods with card label", async () => {
|
|
132
|
+
it("returns formatted payment methods with card label and reads actual Stripe default", async () => {
|
|
133
133
|
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
134
134
|
vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
|
|
135
135
|
data: [
|
|
@@ -137,6 +137,10 @@ describe("StripePaymentProcessor", () => {
|
|
|
137
137
|
{ id: "pm_2", card: { brand: "mastercard", last4: "5555" } },
|
|
138
138
|
],
|
|
139
139
|
} as unknown as Stripe.Response<Stripe.ApiList<Stripe.PaymentMethod>>);
|
|
140
|
+
vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
|
|
141
|
+
deleted: false,
|
|
142
|
+
invoice_settings: { default_payment_method: "pm_1" },
|
|
143
|
+
} as unknown as Stripe.Response<Stripe.Customer>);
|
|
140
144
|
|
|
141
145
|
const result = await processor.listPaymentMethods("tenant-1");
|
|
142
146
|
expect(result).toEqual([
|
|
@@ -150,6 +154,10 @@ describe("StripePaymentProcessor", () => {
|
|
|
150
154
|
vi.mocked(mocks.stripe.customers.listPaymentMethods).mockResolvedValue({
|
|
151
155
|
data: [{ id: "pm_bank", card: undefined }],
|
|
152
156
|
} as unknown as Stripe.Response<Stripe.ApiList<Stripe.PaymentMethod>>);
|
|
157
|
+
vi.mocked(mocks.stripe.customers.retrieve).mockResolvedValue({
|
|
158
|
+
deleted: false,
|
|
159
|
+
invoice_settings: { default_payment_method: "pm_bank" },
|
|
160
|
+
} as unknown as Stripe.Response<Stripe.Customer>);
|
|
153
161
|
|
|
154
162
|
const result = await processor.listPaymentMethods("tenant-1");
|
|
155
163
|
expect(result).toEqual([{ id: "pm_bank", label: "Payment method pm_bank", isDefault: true }]);
|
|
@@ -201,6 +209,86 @@ describe("StripePaymentProcessor", () => {
|
|
|
201
209
|
});
|
|
202
210
|
});
|
|
203
211
|
|
|
212
|
+
// --- setDefaultPaymentMethod ---
|
|
213
|
+
|
|
214
|
+
describe("setDefaultPaymentMethod", () => {
|
|
215
|
+
it("throws when tenant has no Stripe customer", async () => {
|
|
216
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(null);
|
|
217
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(
|
|
218
|
+
"No Stripe customer found for tenant: tenant-1",
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("throws PaymentMethodOwnershipError when PM has no customer", async () => {
|
|
223
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
224
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
225
|
+
id: "pm_1",
|
|
226
|
+
customer: null,
|
|
227
|
+
} as unknown as Stripe.Response<Stripe.PaymentMethod>);
|
|
228
|
+
|
|
229
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("throws PaymentMethodOwnershipError when PM belongs to different customer", async () => {
|
|
233
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
234
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
235
|
+
id: "pm_1",
|
|
236
|
+
customer: "cus_OTHER",
|
|
237
|
+
} as unknown as Stripe.Response<Stripe.PaymentMethod>);
|
|
238
|
+
|
|
239
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("throws PaymentMethodOwnershipError when PM customer is an expanded object with different id", async () => {
|
|
243
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
244
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
245
|
+
id: "pm_1",
|
|
246
|
+
customer: { id: "cus_OTHER", object: "customer" },
|
|
247
|
+
} as unknown as Stripe.Response<Stripe.PaymentMethod>);
|
|
248
|
+
|
|
249
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow(PaymentMethodOwnershipError);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("sets default when ownership matches (string customer)", async () => {
|
|
253
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
254
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
255
|
+
id: "pm_1",
|
|
256
|
+
customer: "cus_123",
|
|
257
|
+
} as unknown as Stripe.Response<Stripe.PaymentMethod>);
|
|
258
|
+
vi.mocked(mocks.stripe.customers.update).mockResolvedValue({} as unknown as Stripe.Response<Stripe.Customer>);
|
|
259
|
+
|
|
260
|
+
await processor.setDefaultPaymentMethod("tenant-1", "pm_1");
|
|
261
|
+
expect(mocks.stripe.customers.update).toHaveBeenCalledWith("cus_123", {
|
|
262
|
+
invoice_settings: { default_payment_method: "pm_1" },
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("sets default when ownership matches (expanded Customer object)", async () => {
|
|
267
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
268
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
269
|
+
id: "pm_1",
|
|
270
|
+
customer: { id: "cus_123", object: "customer" },
|
|
271
|
+
} as unknown as Stripe.Response<Stripe.PaymentMethod>);
|
|
272
|
+
vi.mocked(mocks.stripe.customers.update).mockResolvedValue({} as unknown as Stripe.Response<Stripe.Customer>);
|
|
273
|
+
|
|
274
|
+
await processor.setDefaultPaymentMethod("tenant-1", "pm_1");
|
|
275
|
+
expect(mocks.stripe.customers.update).toHaveBeenCalledWith("cus_123", {
|
|
276
|
+
invoice_settings: { default_payment_method: "pm_1" },
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("propagates Stripe errors", async () => {
|
|
281
|
+
vi.mocked(mocks.tenantRepo.getByTenant).mockResolvedValue(makeTenantRow());
|
|
282
|
+
vi.mocked(mocks.stripe.paymentMethods.retrieve).mockResolvedValue({
|
|
283
|
+
id: "pm_1",
|
|
284
|
+
customer: "cus_123",
|
|
285
|
+
} as unknown as Stripe.Response<Stripe.PaymentMethod>);
|
|
286
|
+
vi.mocked(mocks.stripe.customers.update).mockRejectedValue(new Error("Stripe network error"));
|
|
287
|
+
|
|
288
|
+
await expect(processor.setDefaultPaymentMethod("tenant-1", "pm_1")).rejects.toThrow("Stripe network error");
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
204
292
|
// --- getCustomerEmail ---
|
|
205
293
|
|
|
206
294
|
describe("getCustomerEmail", () => {
|
|
@@ -154,15 +154,42 @@ export class StripePaymentProcessor implements IPaymentProcessor {
|
|
|
154
154
|
return [];
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
const methods = await
|
|
158
|
-
|
|
159
|
-
|
|
157
|
+
const [methods, customer] = await Promise.all([
|
|
158
|
+
this.stripe.customers.listPaymentMethods(mapping.processor_customer_id),
|
|
159
|
+
this.stripe.customers.retrieve(mapping.processor_customer_id),
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
const defaultPmId =
|
|
163
|
+
!customer.deleted && customer.invoice_settings?.default_payment_method
|
|
164
|
+
? typeof customer.invoice_settings.default_payment_method === "string"
|
|
165
|
+
? customer.invoice_settings.default_payment_method
|
|
166
|
+
: customer.invoice_settings.default_payment_method.id
|
|
167
|
+
: null;
|
|
168
|
+
|
|
169
|
+
return methods.data.map((pm) => ({
|
|
160
170
|
id: pm.id,
|
|
161
171
|
label: formatPaymentMethodLabel(pm),
|
|
162
|
-
isDefault:
|
|
172
|
+
isDefault: defaultPmId ? pm.id === defaultPmId : false,
|
|
163
173
|
}));
|
|
164
174
|
}
|
|
165
175
|
|
|
176
|
+
async setDefaultPaymentMethod(tenant: string, paymentMethodId: string): Promise<void> {
|
|
177
|
+
const mapping = await this.tenantRepo.getByTenant(tenant);
|
|
178
|
+
if (!mapping) {
|
|
179
|
+
throw new Error(`No Stripe customer found for tenant: ${tenant}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const pm = await this.stripe.paymentMethods.retrieve(paymentMethodId);
|
|
183
|
+
const pmCustomerId = typeof pm.customer === "string" ? pm.customer : (pm.customer?.id ?? null);
|
|
184
|
+
if (!pmCustomerId || pmCustomerId !== mapping.processor_customer_id) {
|
|
185
|
+
throw new PaymentMethodOwnershipError();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
await this.stripe.customers.update(mapping.processor_customer_id, {
|
|
189
|
+
invoice_settings: { default_payment_method: paymentMethodId },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
166
193
|
async detachPaymentMethod(tenant: string, paymentMethodId: string): Promise<void> {
|
|
167
194
|
const mapping = await this.tenantRepo.getByTenant(tenant);
|
|
168
195
|
if (!mapping) {
|
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,189 @@
|
|
|
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
|
+
setDefaultPaymentMethod: vi.fn().mockResolvedValue(undefined),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeMockAutoTopupSettings(overrides?: Partial<AutoTopupSettings>): IAutoTopupSettingsRepository {
|
|
52
|
+
const settings: AutoTopupSettings | null = overrides
|
|
53
|
+
? ({
|
|
54
|
+
tenantId: "org-1",
|
|
55
|
+
usageEnabled: false,
|
|
56
|
+
usageThreshold: { toCentsRounded: () => 0 },
|
|
57
|
+
usageTopup: { toCentsRounded: () => 0 },
|
|
58
|
+
usageConsecutiveFailures: 0,
|
|
59
|
+
usageChargeInFlight: false,
|
|
60
|
+
scheduleEnabled: false,
|
|
61
|
+
scheduleAmount: { toCentsRounded: () => 0 },
|
|
62
|
+
scheduleIntervalHours: 0,
|
|
63
|
+
scheduleNextAt: null,
|
|
64
|
+
scheduleConsecutiveFailures: 0,
|
|
65
|
+
createdAt: new Date().toISOString(),
|
|
66
|
+
updatedAt: new Date().toISOString(),
|
|
67
|
+
...overrides,
|
|
68
|
+
} as AutoTopupSettings)
|
|
69
|
+
: null;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
getByTenant: vi.fn().mockResolvedValue(settings),
|
|
73
|
+
upsert: vi.fn(),
|
|
74
|
+
setUsageChargeInFlight: vi.fn(),
|
|
75
|
+
tryAcquireUsageInFlight: vi.fn(),
|
|
76
|
+
incrementUsageFailures: vi.fn(),
|
|
77
|
+
resetUsageFailures: vi.fn(),
|
|
78
|
+
disableUsage: vi.fn(),
|
|
79
|
+
incrementScheduleFailures: vi.fn(),
|
|
80
|
+
resetScheduleFailures: vi.fn(),
|
|
81
|
+
disableSchedule: vi.fn(),
|
|
82
|
+
advanceScheduleNextAt: vi.fn(),
|
|
83
|
+
listDueScheduled: vi.fn().mockResolvedValue([]),
|
|
84
|
+
getMaxConsecutiveFailures: vi.fn().mockResolvedValue(0),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function authedContext() {
|
|
89
|
+
return { user: { id: "u1", roles: ["user"] }, tenantId: "org-1" };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function unauthedContext() {
|
|
93
|
+
return { user: undefined, tenantId: undefined };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Tests
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe("orgRemovePaymentMethod router", () => {
|
|
101
|
+
let processor: IPaymentProcessor;
|
|
102
|
+
let autoTopupStore: IAutoTopupSettingsRepository;
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
processor = makeMockProcessor([{ id: "pm_1", label: "Visa ending 4242", isDefault: true }]);
|
|
106
|
+
autoTopupStore = makeMockAutoTopupSettings();
|
|
107
|
+
setTrpcOrgMemberRepo(makeMockOrgMemberRepo());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
function buildCaller(deps: { processor: IPaymentProcessor; autoTopupSettingsStore?: IAutoTopupSettingsRepository }) {
|
|
111
|
+
const subRouter = createOrgRemovePaymentMethodRouter(() => ({
|
|
112
|
+
processor: deps.processor,
|
|
113
|
+
autoTopupSettingsStore: deps.autoTopupSettingsStore,
|
|
114
|
+
}));
|
|
115
|
+
const appRouter = router({ org: subRouter });
|
|
116
|
+
return createCallerFactory(appRouter);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
it("successfully removes a payment method", async () => {
|
|
120
|
+
const caller = buildCaller({ processor, autoTopupSettingsStore: autoTopupStore })(authedContext());
|
|
121
|
+
const result = await caller.org.orgRemovePaymentMethod({
|
|
122
|
+
orgId: "org-1",
|
|
123
|
+
paymentMethodId: "pm_1",
|
|
124
|
+
});
|
|
125
|
+
expect(result).toEqual({ removed: true });
|
|
126
|
+
expect(processor.detachPaymentMethod).toHaveBeenCalledWith("org-1", "pm_1");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("rejects unauthenticated users", async () => {
|
|
130
|
+
const caller = buildCaller({ processor, autoTopupSettingsStore: autoTopupStore })(unauthedContext());
|
|
131
|
+
await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
|
|
132
|
+
code: "UNAUTHORIZED",
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects when removing last PM with auto-topup usage enabled", async () => {
|
|
137
|
+
const usageStore = makeMockAutoTopupSettings({ usageEnabled: true });
|
|
138
|
+
const caller = buildCaller({
|
|
139
|
+
processor,
|
|
140
|
+
autoTopupSettingsStore: usageStore,
|
|
141
|
+
})(authedContext());
|
|
142
|
+
await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
|
|
143
|
+
code: "FORBIDDEN",
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("rejects when removing last PM with auto-topup schedule enabled", async () => {
|
|
148
|
+
const scheduleStore = makeMockAutoTopupSettings({ scheduleEnabled: true });
|
|
149
|
+
const caller = buildCaller({
|
|
150
|
+
processor,
|
|
151
|
+
autoTopupSettingsStore: scheduleStore,
|
|
152
|
+
})(authedContext());
|
|
153
|
+
await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
|
|
154
|
+
code: "FORBIDDEN",
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("allows removing non-last PM even with auto-topup enabled", async () => {
|
|
159
|
+
const multiProcessor = makeMockProcessor([
|
|
160
|
+
{ id: "pm_1", label: "Visa ending 4242", isDefault: true },
|
|
161
|
+
{ id: "pm_2", label: "Mastercard ending 5555", isDefault: false },
|
|
162
|
+
]);
|
|
163
|
+
const usageStore = makeMockAutoTopupSettings({ usageEnabled: true });
|
|
164
|
+
const caller = buildCaller({
|
|
165
|
+
processor: multiProcessor,
|
|
166
|
+
autoTopupSettingsStore: usageStore,
|
|
167
|
+
})(authedContext());
|
|
168
|
+
const result = await caller.org.orgRemovePaymentMethod({
|
|
169
|
+
orgId: "org-1",
|
|
170
|
+
paymentMethodId: "pm_1",
|
|
171
|
+
});
|
|
172
|
+
expect(result).toEqual({ removed: true });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("returns FORBIDDEN when detachPaymentMethod throws PaymentMethodOwnershipError", async () => {
|
|
176
|
+
const { PaymentMethodOwnershipError } = await import("../billing/payment-processor.js");
|
|
177
|
+
const ownershipErrorProcessor = makeMockProcessor([]);
|
|
178
|
+
(ownershipErrorProcessor.detachPaymentMethod as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
179
|
+
new PaymentMethodOwnershipError(),
|
|
180
|
+
);
|
|
181
|
+
const caller = buildCaller({
|
|
182
|
+
processor: ownershipErrorProcessor,
|
|
183
|
+
autoTopupSettingsStore: autoTopupStore,
|
|
184
|
+
})(authedContext());
|
|
185
|
+
await expect(caller.org.orgRemovePaymentMethod({ orgId: "org-1", paymentMethodId: "pm_1" })).rejects.toMatchObject({
|
|
186
|
+
code: "FORBIDDEN",
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { TRPCError } from "@trpc/server";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { IPaymentProcessor } from "../billing/payment-processor.js";
|
|
4
|
+
import { PaymentMethodOwnershipError } from "../billing/payment-processor.js";
|
|
5
|
+
import type { IAutoTopupSettingsRepository } from "../credits/auto-topup-settings-repository.js";
|
|
6
|
+
import { orgAdminProcedure, router } from "./init.js";
|
|
7
|
+
|
|
8
|
+
export interface OrgRemovePaymentMethodDeps {
|
|
9
|
+
processor: IPaymentProcessor;
|
|
10
|
+
autoTopupSettingsStore?: IAutoTopupSettingsRepository;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createOrgRemovePaymentMethodRouter(getDeps: () => OrgRemovePaymentMethodDeps) {
|
|
14
|
+
return router({
|
|
15
|
+
orgRemovePaymentMethod: orgAdminProcedure
|
|
16
|
+
.input(
|
|
17
|
+
z.object({
|
|
18
|
+
orgId: z.string().min(1),
|
|
19
|
+
paymentMethodId: z.string().min(1),
|
|
20
|
+
}),
|
|
21
|
+
)
|
|
22
|
+
.mutation(async ({ input }) => {
|
|
23
|
+
const { processor, autoTopupSettingsStore } = getDeps();
|
|
24
|
+
|
|
25
|
+
if (!autoTopupSettingsStore) {
|
|
26
|
+
console.warn(
|
|
27
|
+
"orgRemovePaymentMethod: autoTopupSettingsStore not provided — last-payment-method guard is inactive",
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Guard: prevent removing the last payment method when auto-topup is enabled
|
|
32
|
+
if (autoTopupSettingsStore) {
|
|
33
|
+
const methods = await processor.listPaymentMethods(input.orgId);
|
|
34
|
+
if (methods.length <= 1) {
|
|
35
|
+
const settings = await autoTopupSettingsStore.getByTenant(input.orgId);
|
|
36
|
+
if (settings && (settings.usageEnabled || settings.scheduleEnabled)) {
|
|
37
|
+
throw new TRPCError({
|
|
38
|
+
code: "FORBIDDEN",
|
|
39
|
+
message: "Cannot remove last payment method while auto-topup is enabled. Disable auto-topup first.",
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
await processor.detachPaymentMethod(input.orgId, input.paymentMethodId);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (err instanceof PaymentMethodOwnershipError) {
|
|
49
|
+
throw new TRPCError({
|
|
50
|
+
code: "FORBIDDEN",
|
|
51
|
+
message: "Payment method does not belong to this organization",
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
throw new TRPCError({
|
|
55
|
+
code: "INTERNAL_SERVER_ERROR",
|
|
56
|
+
message: "Failed to remove payment method. Please try again.",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// TOCTOU guard: re-check count after detach. A concurrent request
|
|
61
|
+
// may have already removed another payment method between our pre-check
|
|
62
|
+
// and this detach, leaving the org with 0 methods while auto-topup is
|
|
63
|
+
// still enabled. Warn operators — the method is already gone.
|
|
64
|
+
if (autoTopupSettingsStore) {
|
|
65
|
+
const remaining = await processor.listPaymentMethods(input.orgId);
|
|
66
|
+
if (remaining.length === 0) {
|
|
67
|
+
const settings = await autoTopupSettingsStore.getByTenant(input.orgId);
|
|
68
|
+
if (settings && (settings.usageEnabled || settings.scheduleEnabled)) {
|
|
69
|
+
console.warn(
|
|
70
|
+
"orgRemovePaymentMethod: TOCTOU — org %s now has 0 payment methods with auto-topup enabled. Operator must add a payment method or disable auto-topup.",
|
|
71
|
+
input.orgId,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { removed: true };
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
}
|