@wopr-network/platform-core 1.47.0 → 1.49.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 (86) hide show
  1. package/dist/billing/crypto/__tests__/unified-checkout.test.d.ts +1 -0
  2. package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
  3. package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
  4. package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
  5. package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
  6. package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
  7. package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
  8. package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
  9. package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
  10. package/dist/billing/crypto/btc/types.d.ts +3 -1
  11. package/dist/billing/crypto/btc/watcher.d.ts +6 -1
  12. package/dist/billing/crypto/btc/watcher.js +20 -6
  13. package/dist/billing/crypto/charge-store.d.ts +27 -2
  14. package/dist/billing/crypto/charge-store.js +67 -1
  15. package/dist/billing/crypto/charge-store.test.js +180 -1
  16. package/dist/billing/crypto/client.d.ts +2 -0
  17. package/dist/billing/crypto/cursor-store.d.ts +10 -3
  18. package/dist/billing/crypto/cursor-store.js +21 -1
  19. package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
  20. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +31 -4
  21. package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
  22. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
  23. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
  24. package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
  25. package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
  26. package/dist/billing/crypto/evm/eth-watcher.js +27 -13
  27. package/dist/billing/crypto/evm/types.d.ts +5 -1
  28. package/dist/billing/crypto/evm/watcher.d.ts +9 -1
  29. package/dist/billing/crypto/evm/watcher.js +36 -13
  30. package/dist/billing/crypto/index.d.ts +3 -3
  31. package/dist/billing/crypto/index.js +1 -1
  32. package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
  33. package/dist/billing/crypto/key-server-webhook.js +76 -15
  34. package/dist/billing/crypto/types.d.ts +16 -0
  35. package/dist/billing/crypto/unified-checkout.d.ts +8 -17
  36. package/dist/billing/crypto/unified-checkout.js +17 -131
  37. package/dist/billing/crypto/watcher-service.d.ts +22 -2
  38. package/dist/billing/crypto/watcher-service.js +71 -30
  39. package/dist/billing/payment-processor.d.ts +2 -0
  40. package/dist/billing/payment-processor.test.js +1 -0
  41. package/dist/billing/stripe/stripe-payment-processor.d.ts +1 -0
  42. package/dist/billing/stripe/stripe-payment-processor.js +27 -4
  43. package/dist/billing/stripe/stripe-payment-processor.test.js +95 -1
  44. package/dist/db/schema/crypto.d.ts +68 -0
  45. package/dist/db/schema/crypto.js +8 -0
  46. package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
  47. package/dist/monetization/stripe/stripe-payment-processor.d.ts +1 -0
  48. package/dist/monetization/stripe/stripe-payment-processor.js +25 -3
  49. package/dist/monetization/stripe/stripe-payment-processor.test.js +73 -1
  50. package/dist/trpc/org-remove-payment-method-router.test.js +1 -0
  51. package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
  52. package/drizzle/migrations/meta/_journal.json +7 -0
  53. package/package.json +1 -1
  54. package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
  55. package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
  56. package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
  57. package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
  58. package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
  59. package/src/billing/crypto/btc/types.ts +3 -1
  60. package/src/billing/crypto/btc/watcher.ts +22 -6
  61. package/src/billing/crypto/charge-store.test.ts +204 -1
  62. package/src/billing/crypto/charge-store.ts +86 -2
  63. package/src/billing/crypto/client.ts +2 -0
  64. package/src/billing/crypto/cursor-store.ts +31 -3
  65. package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
  66. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +31 -4
  67. package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
  68. package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
  69. package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
  70. package/src/billing/crypto/evm/eth-watcher.ts +34 -14
  71. package/src/billing/crypto/evm/types.ts +5 -1
  72. package/src/billing/crypto/evm/watcher.ts +39 -13
  73. package/src/billing/crypto/index.ts +12 -3
  74. package/src/billing/crypto/key-server-webhook.ts +92 -21
  75. package/src/billing/crypto/types.ts +18 -0
  76. package/src/billing/crypto/unified-checkout.ts +20 -179
  77. package/src/billing/crypto/watcher-service.ts +85 -32
  78. package/src/billing/payment-processor.test.ts +1 -0
  79. package/src/billing/payment-processor.ts +3 -0
  80. package/src/billing/stripe/stripe-payment-processor.test.ts +113 -1
  81. package/src/billing/stripe/stripe-payment-processor.ts +33 -5
  82. package/src/db/schema/crypto.ts +8 -0
  83. package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
  84. package/src/monetization/stripe/stripe-payment-processor.test.ts +89 -1
  85. package/src/monetization/stripe/stripe-payment-processor.ts +31 -4
  86. package/src/trpc/org-remove-payment-method-router.test.ts +1 -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 this.stripe.customers.listPaymentMethods(mapping.processor_customer_id);
158
-
159
- return methods.data.map((pm, index) => ({
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: index === 0,
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
- if (!pm.customer || pm.customer !== mapping.processor_customer_id) {
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
 
@@ -30,6 +30,14 @@ export const cryptoCharges = pgTable(
30
30
  expectedAmount: text("expected_amount"),
31
31
  /** Running total of received crypto in native units. Accumulates across partial payments. */
32
32
  receivedAmount: text("received_amount"),
33
+ /** Number of blockchain confirmations observed so far. */
34
+ confirmations: integer("confirmations").notNull().default(0),
35
+ /** Required confirmations for settlement (copied from payment method at creation). */
36
+ confirmationsRequired: integer("confirmations_required").notNull().default(1),
37
+ /** Blockchain transaction hash for the payment. */
38
+ txHash: text("tx_hash"),
39
+ /** Amount received so far in USD cents (integer). Converted from crypto at time of receipt. */
40
+ amountReceivedCents: integer("amount_received_cents").notNull().default(0),
33
41
  },
34
42
  (table) => [
35
43
  index("idx_crypto_charges_tenant").on(table.tenantId),
@@ -164,7 +164,8 @@ describe("handleCryptoWebhook (monetization layer)", () => {
164
164
  await handleCryptoWebhook(deps, makePayload({ status: "partial" }));
165
165
 
166
166
  const charge = await chargeStore.getByReferenceId("chg-test-001");
167
- expect(charge?.status).toBe("partial");
167
+ // DB stores legacy status values; "partial" maps to "Processing" internally
168
+ expect(charge?.status).toBe("Processing");
168
169
  });
169
170
 
170
171
  it("settles charge when status is confirmed", async () => {
@@ -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 this.stripe.customers.listPaymentMethods(mapping.processor_customer_id);
158
-
159
- return methods.data.map((pm, index) => ({
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: index === 0,
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) {
@@ -44,6 +44,7 @@ function makeMockProcessor(methods: SavedPaymentMethod[]): IPaymentProcessor {
44
44
  getCustomerEmail: vi.fn().mockResolvedValue(""),
45
45
  updateCustomerEmail: vi.fn(),
46
46
  listInvoices: vi.fn().mockResolvedValue([]),
47
+ setDefaultPaymentMethod: vi.fn().mockResolvedValue(undefined),
47
48
  };
48
49
  }
49
50