diff --git a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs index 52799eaa8c..a5025ca96e 100644 --- a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs +++ b/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] +} diff --git a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs index afeeeac0b7..eb175a9cae 100644 --- a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs +++ b/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) +}) diff --git a/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs b/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs index 52d9b0ec47..c0e5fb6dac 100644 --- a/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs +++ b/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} + */ +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} */ 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(