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 0eb404967f..de9e71a359 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 @@ -947,11 +947,8 @@ export function coalesceOrThrowPaymentMethod( ) } - 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 matchingPaymentMethods = paymentMethods.filter(method => + areStripeAndRecurlyCardDetailsEqual(method, billingInfo?.paymentMethod) ) if (matchingPaymentMethods.length === 0) { @@ -962,3 +959,31 @@ export function coalesceOrThrowPaymentMethod( return matchingPaymentMethods[0] } + +/** + * Compare Stripe card details with Recurly card details to determine if they likely represent the same card. + * + * Checks last4 and expiry month/year. + * + * @param {Stripe.PaymentMethod} stripePaymentMethod + * @param {object} recurlyPaymentMethod - Recurly billingInfo.paymentMethod object + * @returns {boolean} true if details match, false otherwise + */ +export function areStripeAndRecurlyCardDetailsEqual( + stripePaymentMethod, + recurlyPaymentMethod +) { + if ( + stripePaymentMethod.type !== 'card' || + !stripePaymentMethod?.card || + !recurlyPaymentMethod?.lastFour + ) { + return false + } + + return ( + stripePaymentMethod.card.last4 === recurlyPaymentMethod.lastFour && + stripePaymentMethod.card.exp_month === recurlyPaymentMethod.expMonth && + stripePaymentMethod.card.exp_year === recurlyPaymentMethod.expYear + ) +} 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 07f51482ab..ae87b00bc9 100644 --- a/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs +++ b/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs @@ -91,6 +91,7 @@ import * as csv from 'csv' import { scriptRunner } from '../lib/ScriptRunner.mjs' import { + areStripeAndRecurlyCardDetailsEqual, coalesceOrEqualOrThrowAddress, coalesceOrEqualOrThrowName, coalesceOrThrowPaymentMethod, @@ -602,8 +603,50 @@ async function fetchOtherStripeCustomerByUserId( { ...context, stripeApi: 'customers.search' } ) - return ( - results.data?.find(customer => customer.id !== stripeCustomerId) || null + const matchingCustomers = results.data?.filter( + customer => customer.id !== stripeCustomerId + ) + + if (matchingCustomers.length > 1) { + throw new Error( + `Multiple Stripe customers found with userId metadata "${userId}": ${matchingCustomers.map(c => c.id).join(', ')}` + ) + } + + return matchingCustomers[0] || null +} + +/** + * Mark a Stripe customer as a duplicate of another customer. + * + * @param {Stripe} stripeClient + * @param {string} stripeCustomerId + * @param {string} recurlyAccountCode + * @param {object} context + * @returns {Promise} + */ +async function markCustomerAsDuplicate( + stripeClient, + stripeCustomerId, + recurlyAccountCode, + context +) { + const email = + Settings.duplicateStripeCustomerAccountEmail?.replace( + '@', + `+${stripeCustomerId}@` + ) || '' + await rateLimiters.requestWithRetries( + stripeClient.serviceName, + () => + stripeClient.customers.update(stripeCustomerId, { + email, + metadata: { + userId: '', + duplicateUserId: recurlyAccountCode, + }, + }), + { ...context, stripeApi: 'customers.update' } ) } @@ -937,9 +980,68 @@ async function resolveStripeCustomer({ if (otherMatchingCustomer) { if (stripeCustomerId) { - throw new Error( - `Found another Stripe customer with matching userId metadata: ${otherMatchingCustomer.id}` + const otherCustomerPaymentMethods = + await fetchTargetStripeCustomerPaymentMethods( + stripeClient, + otherMatchingCustomer.id, + stripeContext + ) + const isRecurlyPaymentMethodPaypal = + account?.billingInfo?.paymentMethod?.object === + 'paypal_billing_agreement' + const isRecurlyPaymentMethodManual = !account?.billingInfo?.paymentMethod // billing info may be missing for manually billed customers + const hasMatchingPaymentMethod = otherCustomerPaymentMethods.some( + method => + areStripeAndRecurlyCardDetailsEqual( + method, + account?.billingInfo?.paymentMethod + ) ) + if ( + isRecurlyPaymentMethodPaypal || + isRecurlyPaymentMethodManual || + hasMatchingPaymentMethod + ) { + logDebug( + 'Found another Stripe customer with matching userId metadata, reusing', + { + ...context, + nextStripeCustomerId: otherMatchingCustomer.id, + }, + { verboseOnly: true } + ) + if (commit) { + await markCustomerAsDuplicate( + stripeClient, + stripeCustomerId, + recurlyAccountCode, + stripeContext + ) + logDebug( + 'Marked CSV customer as a duplicate of the existing Stripe customer', + { + ...context, + nextStripeCustomerId: otherMatchingCustomer.id, + }, + { verboseOnly: true } + ) + } else { + logDebug( + 'DRY RUN: Would mark CSV customer as a duplicate of the existing Stripe customer', + { + ...context, + nextStripeCustomerId: otherMatchingCustomer.id, + step: 'mark_duplicate', + }, + { verboseOnly: true } + ) + } + return otherMatchingCustomer + } else { + throw new Error( + `Found another Stripe customer with matching userId metadata but no matching payment method: ${otherMatchingCustomer.id}` + ) + } } logDebug(