Просмотр исходного кода

[web] set default payment method on customers

GitOrigin-RevId: 3603b80f11b70493c04e145d976c540ea718512a
Simon Gardner 8 месяцев назад
Родитель
Сommit
83971b4a8a

+ 49 - 0
services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs

@@ -362,3 +362,52 @@ export function getTaxIdType(country, taxIdValue, postalCode) {
 
   return countryTaxIdTypes[upperCountry] || null
 }
+
+/**
+ *
+ * @param {Stripe.PaymentMethod[]} paymentMethods
+ * @param {string} stripeCustomerId
+ * @returns {Stripe.PaymentMethod} valid payment method
+ * @throws {Error} if no valid payment method found
+ */
+export function coalesceOrThrowPaymentMethod(paymentMethods, stripeCustomerId) {
+  if (paymentMethods.length === 0) {
+    throw new Error(
+      `Stripe customer ${stripeCustomerId} has no usable payment method`
+    )
+  }
+
+  const now = new Date()
+
+  const nonExpiredPaymentMethods = paymentMethods.filter(method => {
+    if (!method.card?.exp_month || !method.card?.exp_year) {
+      return false
+    }
+
+    const expirationDate = new Date(
+      method.card.exp_year,
+      method.card.exp_month,
+      0,
+      23,
+      59,
+      59,
+      999
+    )
+
+    return expirationDate >= now
+  })
+
+  if (nonExpiredPaymentMethods.length === 0) {
+    throw new Error(
+      `Stripe customer ${stripeCustomerId} has no usable payment method`
+    )
+  }
+
+  if (nonExpiredPaymentMethods.length > 1) {
+    throw new Error(
+      `Stripe customer ${stripeCustomerId} has multiple usable payment methods`
+    )
+  }
+
+  return nonExpiredPaymentMethods[0]
+}

+ 116 - 0
services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs

@@ -16,6 +16,7 @@ import {
   coalesceOrEqualOrThrowName,
   coalesceOrThrowVATNumber,
   getCanadaTaxIdType,
+  coalesceOrThrowPaymentMethod,
 } from './migrate_recurly_customers_to_stripe.helpers.mjs'
 
 test('coalesceOrEqualOrThrow returns primary when set', () => {
@@ -230,3 +231,118 @@ test('getCanadaTaxIdType returns null when format is unknown/ambiguous', () => {
   assert.equal(getCanadaTaxIdType('RT0002', null), null)
   assert.equal(getCanadaTaxIdType('PST12345678', null), null)
 })
+
+test('coalesceOrThrowPaymentMethod throws when payment methods array is empty', () => {
+  assert.throws(
+    () => coalesceOrThrowPaymentMethod([], 'cus_123'),
+    /Stripe customer cus_123 has no usable payment method/
+  )
+})
+
+test('coalesceOrThrowPaymentMethod throws when no payment methods have card info', () => {
+  const paymentMethods = [{ id: 'pm_1', card: null }, { id: 'pm_2' }]
+  assert.throws(
+    () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123'),
+    /Stripe customer cus_123 has no usable payment method/
+  )
+})
+
+test('coalesceOrThrowPaymentMethod throws when all payment methods are expired', () => {
+  const paymentMethods = [
+    {
+      id: 'pm_expired',
+      card: { exp_month: 1, exp_year: 2020 },
+    },
+  ]
+  assert.throws(
+    () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123'),
+    /Stripe customer cus_123 has no usable payment method/
+  )
+})
+
+test('coalesceOrThrowPaymentMethod throws when multiple non-expired payment methods exist', () => {
+  const paymentMethods = [
+    {
+      id: 'pm_1',
+      card: { exp_month: 12, exp_year: 2030 },
+    },
+    {
+      id: 'pm_2',
+      card: { exp_month: 12, exp_year: 2031 },
+    },
+  ]
+  assert.throws(
+    () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123'),
+    /Stripe customer cus_123 has multiple usable payment methods/
+  )
+})
+
+test('coalesceOrThrowPaymentMethod returns single non-expired payment method', () => {
+  const paymentMethod = {
+    id: 'pm_valid',
+    card: { exp_month: 12, exp_year: 2030 },
+  }
+  const result = coalesceOrThrowPaymentMethod([paymentMethod], 'cus_123')
+  assert.equal(result, paymentMethod)
+})
+
+test('coalesceOrThrowPaymentMethod filters out expired and returns valid method', () => {
+  const expiredMethod = {
+    id: 'pm_expired',
+    card: { exp_month: 1, exp_year: 2020 },
+  }
+  const validMethod = {
+    id: 'pm_valid',
+    card: { exp_month: 12, exp_year: 2030 },
+  }
+  const result = coalesceOrThrowPaymentMethod(
+    [expiredMethod, validMethod],
+    'cus_123'
+  )
+  assert.equal(result, validMethod)
+})
+
+test('coalesceOrThrowPaymentMethod keeps payment method expiring this month', () => {
+  const now = new Date()
+  const currentMonth = now.getMonth() + 1 // JavaScript months are 0-indexed, Stripe uses 1-indexed
+  const currentYear = now.getFullYear()
+
+  const paymentMethod = {
+    id: 'pm_expiring_this_month',
+    card: { exp_month: currentMonth, exp_year: currentYear },
+  }
+  const result = coalesceOrThrowPaymentMethod([paymentMethod], 'cus_123')
+  assert.equal(result, paymentMethod)
+})
+
+test('coalesceOrThrowPaymentMethod filters out method without exp_month', () => {
+  const invalidMethod = {
+    id: 'pm_invalid',
+    card: { exp_year: 2030 },
+  }
+  const validMethod = {
+    id: 'pm_valid',
+    card: { exp_month: 12, exp_year: 2030 },
+  }
+  const result = coalesceOrThrowPaymentMethod(
+    [invalidMethod, validMethod],
+    'cus_123'
+  )
+  assert.equal(result, validMethod)
+})
+
+test('coalesceOrThrowPaymentMethod filters out method without exp_year', () => {
+  const invalidMethod = {
+    id: 'pm_invalid',
+    card: { exp_month: 12 },
+  }
+  const validMethod = {
+    id: 'pm_valid',
+    card: { exp_month: 12, exp_year: 2030 },
+  }
+  const result = coalesceOrThrowPaymentMethod(
+    [invalidMethod, validMethod],
+    'cus_123'
+  )
+  assert.equal(result, validMethod)
+})

+ 30 - 0
services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs

@@ -83,6 +83,7 @@ import { scriptRunner } from '../lib/ScriptRunner.mjs'
 import {
   coalesceOrEqualOrThrowAddress,
   coalesceOrEqualOrThrowName,
+  coalesceOrThrowPaymentMethod,
   coalesceOrThrowVATNumber,
   getTaxIdType,
 } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
@@ -575,6 +576,23 @@ async function fetchTargetStripeCustomer(stripeClient, stripeCustomerId) {
   return customer
 }
 
+/**
+ * Fetch existing customer's payment method from the target Stripe account by ID.
+ *
+ * @param {Stripe} stripeClient - The Stripe client for the target account
+ * @param {string} stripeCustomerId - The Stripe customer ID
+ * @returns {Promise<Stripe.PaymentMethod[]>}
+ */
+async function fetchTargetStripeCustomerPaymentMethods(
+  stripeClient,
+  stripeCustomerId
+) {
+  await throttleStripe()
+  const paymentMethods =
+    await stripeClient.customers.listPaymentMethods(stripeCustomerId)
+  return paymentMethods.data
+}
+
 /**
  * Replace a customer's tax IDs (delete any existing, then create the desired one).
  *
@@ -906,6 +924,15 @@ async function processCustomer(row, rowNumber, commit) {
     //   customerParams.metadata.ccEmails = account.ccEmails
     // }
 
+    const paymentMethods = await fetchTargetStripeCustomerPaymentMethods(
+      stripeClient,
+      stripeCustomerId
+    )
+    const paymentMethod = coalesceOrThrowPaymentMethod(
+      paymentMethods,
+      stripeCustomerId
+    )
+
     /** @type {Record<string, string>} */
     const metadata = {}
     if (account.createdAt) {
@@ -919,6 +946,9 @@ async function processCustomer(row, rowNumber, commit) {
       metadata,
       ...(address ? { address } : {}),
       ...(companyName ? { business_name: companyName } : {}),
+      ...(paymentMethod
+        ? { invoice_settings: { default_payment_method: paymentMethod.id } }
+        : {}),
     }
 
     logDebug(