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 a5025ca96e..8e14e24073 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 @@ -367,47 +367,33 @@ export function getTaxIdType(country, taxIdValue, postalCode) { * * @param {Stripe.PaymentMethod[]} paymentMethods * @param {string} stripeCustomerId + * @param {object|null} billingInfo - Recurly billing info object * @returns {Stripe.PaymentMethod} valid payment method * @throws {Error} if no valid payment method found */ -export function coalesceOrThrowPaymentMethod(paymentMethods, stripeCustomerId) { +export function coalesceOrThrowPaymentMethod( + paymentMethods, + stripeCustomerId, + billingInfo +) { if (paymentMethods.length === 0) { throw new Error( `Stripe customer ${stripeCustomerId} has no usable payment method` ) } - const now = new Date() + const matchingPaymentMethods = paymentMethods.filter( + method => + method.card?.last4 === billingInfo?.paymentMethod?.lastFour && + method.card?.exp_month === billingInfo?.paymentMethod?.expMonth && + method.card?.exp_year === billingInfo?.paymentMethod?.expYear + ) - 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) { + if (matchingPaymentMethods.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] + return matchingPaymentMethods[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 eb175a9cae..24ca1556ac 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 @@ -234,115 +234,128 @@ test('getCanadaTaxIdType returns null when format is unknown/ambiguous', () => { test('coalesceOrThrowPaymentMethod throws when payment methods array is empty', () => { assert.throws( - () => coalesceOrThrowPaymentMethod([], 'cus_123'), + () => 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', () => { +test('coalesceOrThrowPaymentMethod throws when no payment methods match billing info', () => { const paymentMethods = [ { id: 'pm_1', - card: { exp_month: 12, exp_year: 2030 }, + card: { last4: '1234', exp_month: 12, exp_year: 2030 }, }, + ] + const billingInfo = { + paymentMethod: { lastFour: '5678', expMonth: 12, expYear: 2030 }, + } + assert.throws( + () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', billingInfo), + /Stripe customer cus_123 has no usable payment method/ + ) +}) + +test('coalesceOrThrowPaymentMethod returns matching payment method', () => { + const paymentMethod = { + id: 'pm_match', + card: { last4: '1234', exp_month: 12, exp_year: 2030 }, + } + const paymentMethods = [paymentMethod] + const billingInfo = { + paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 }, + } + const result = coalesceOrThrowPaymentMethod( + paymentMethods, + 'cus_123', + billingInfo + ) + assert.equal(result, paymentMethod) +}) + +test('coalesceOrThrowPaymentMethod matches first of multiple matching methods', () => { + const paymentMethod1 = { + id: 'pm_1', + card: { last4: '1234', exp_month: 12, exp_year: 2030 }, + } + const paymentMethod2 = { + id: 'pm_2', + card: { last4: '1234', exp_month: 12, exp_year: 2030 }, + } + const paymentMethods = [paymentMethod1, paymentMethod2] + const billingInfo = { + paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 }, + } + const result = coalesceOrThrowPaymentMethod( + paymentMethods, + 'cus_123', + billingInfo + ) + assert.equal(result, paymentMethod1) +}) + +test('coalesceOrThrowPaymentMethod filters out non-matching methods', () => { + const matchingMethod = { + id: 'pm_match', + card: { last4: '1234', exp_month: 12, exp_year: 2030 }, + } + const nonMatchingMethod = { + id: 'pm_no_match', + card: { last4: '5678', exp_month: 12, exp_year: 2030 }, + } + const paymentMethods = [nonMatchingMethod, matchingMethod] + const billingInfo = { + paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 }, + } + const result = coalesceOrThrowPaymentMethod( + paymentMethods, + 'cus_123', + billingInfo + ) + assert.equal(result, matchingMethod) +}) + +test('coalesceOrThrowPaymentMethod handles null billingInfo', () => { + const paymentMethods = [ { - id: 'pm_2', - card: { exp_month: 12, exp_year: 2031 }, + id: 'pm_1', + card: { last4: '1234', exp_month: 12, exp_year: 2030 }, }, ] assert.throws( - () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123'), - /Stripe customer cus_123 has multiple usable payment methods/ + () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', null), + /Stripe customer cus_123 has no usable payment method/ ) }) -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 handles missing paymentMethod in billingInfo', () => { + const paymentMethods = [ + { + id: 'pm_1', + card: { last4: '1234', exp_month: 12, exp_year: 2030 }, + }, + ] + const billingInfo = {} + assert.throws( + () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', billingInfo), + /Stripe customer cus_123 has no usable payment method/ + ) }) -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 }, +test('coalesceOrThrowPaymentMethod handles missing card in payment method', () => { + const paymentMethods = [ + { id: 'pm_1' }, // no card property + { + id: 'pm_2', + card: { last4: '1234', exp_month: 12, exp_year: 2030 }, + }, + ] + const billingInfo = { + paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 }, } const result = coalesceOrThrowPaymentMethod( - [expiredMethod, validMethod], - 'cus_123' + paymentMethods, + 'cus_123', + billingInfo ) - 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) + assert.equal(result.id, 'pm_2') }) 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 a4a5160188..8f2d053761 100644 --- a/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs +++ b/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs @@ -973,7 +973,8 @@ async function processCustomer( ) const paymentMethod = coalesceOrThrowPaymentMethod( paymentMethods, - stripeCustomerId + stripeCustomerId, + billingInfo ) /** @type {Record} */