[web] handle duplicate customer accounts better (#31677)

* [web] extract card comparison function

* [web] throw if more than 1 duplicate customer account

* [web] attempt to pick the pre-existing account

* [web] overwrite email of duplicate account

* Update services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs

* [web] handle setting being undefined

* [web] allow other account to be reused if migrating account is using paypal

* [web] allow matching for customers with no payment method

GitOrigin-RevId: 7d8eeb4734a20c09ce3e0d73095e28696e212549
This commit is contained in:
Kristina
2026-03-06 09:07:40 +00:00
committed by Copybot
parent 20898db17f
commit aa2ce6f675
2 changed files with 136 additions and 9 deletions
@@ -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
)
}
@@ -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<void>}
*/
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(