[web] set default payment method on customers

GitOrigin-RevId: 3603b80f11b70493c04e145d976c540ea718512a
This commit is contained in:
Simon Gardner
2026-01-26 13:51:34 +00:00
committed by Copybot
parent cee51f16ff
commit 83971b4a8a
3 changed files with 195 additions and 0 deletions

View File

@@ -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]
}

View File

@@ -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)
})

View File

@@ -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(