[migration] re-use paypal methods if billing agreement ids match (#33720)

GitOrigin-RevId: 4a324c1cdde84dabf620a2616a0aa27242cf041e
This commit is contained in:
Simon Gardner
2026-05-20 11:49:28 +01:00
committed by Copybot
parent bb0dc07d22
commit 5cfd7b6c6a
@@ -637,12 +637,30 @@ async function fetchTargetStripeCustomerPaymentMethods(
stripeCustomerId,
context
) {
const paymentMethods = await rateLimiters.requestWithRetries(
stripeClient.serviceName,
() => stripeClient.customers.listPaymentMethods(stripeCustomerId),
{ ...context, stripeApi: 'customers.listPaymentMethods' }
)
return paymentMethods.data
const paymentMethods = []
let startingAfter
while (true) {
const page = await rateLimiters.requestWithRetries(
stripeClient.serviceName,
() =>
stripeClient.customers.listPaymentMethods(stripeCustomerId, {
limit: 100,
...(startingAfter ? { starting_after: startingAfter } : {}),
}),
{ ...context, stripeApi: 'customers.listPaymentMethods' }
)
paymentMethods.push(...page.data)
if (!page.has_more || page.data.length === 0) {
break
}
startingAfter = page.data[page.data.length - 1].id
}
return paymentMethods
}
/**
@@ -724,6 +742,123 @@ async function createPayPalPaymentMethod(
return setupIntent.payment_method
}
// Some Stripe API objects can be returned as either an ID string or a full object depending on context.
// This helper normalizes to just the ID string for easier comparison
function normalizeExpandableId(value) {
if (!value) return null
if (typeof value === 'string') return value
if (typeof value === 'object' && typeof value.id === 'string') {
return value.id
}
return null
}
async function findPayPalPaymentMethodByBillingAgreementId(
stripeClient,
stripeCustomerId,
paypalPaymentMethods,
billingAgreementId,
context
) {
if (!billingAgreementId) {
return {
paymentMethod: null,
reason: 'billing_agreement_id_not_available',
}
}
if (paypalPaymentMethods.length === 0) {
return {
paymentMethod: null,
reason: 'no_existing_paypal_payment_methods',
}
}
const matchedPaymentMethods = []
for (const paymentMethod of paypalPaymentMethods) {
let matched = false
let startingAfter
while (true) {
const setupIntents = await rateLimiters.requestWithRetries(
stripeClient.serviceName,
() =>
stripeClient.setupIntents.list({
payment_method: paymentMethod.id,
limit: 100,
...(startingAfter ? { starting_after: startingAfter } : {}),
}),
{ ...context, stripeApi: 'setupIntents.list' }
)
for (const setupIntent of setupIntents.data) {
if (setupIntent.status !== 'succeeded') continue
const setupIntentCustomerId = normalizeExpandableId(
setupIntent.customer
)
if (
setupIntentCustomerId &&
setupIntentCustomerId !== stripeCustomerId
) {
continue
}
const mandateId = normalizeExpandableId(setupIntent.mandate)
if (!mandateId) continue
const mandate = await rateLimiters.requestWithRetries(
stripeClient.serviceName,
() => stripeClient.mandates.retrieve(mandateId),
{ ...context, stripeApi: 'mandates.retrieve' }
)
const mandateBaid =
mandate?.payment_method_details?.paypal?.billing_agreement_id || null
if (mandateBaid === billingAgreementId) {
matchedPaymentMethods.push(paymentMethod)
matched = true
break
}
}
if (matched || !setupIntents.has_more || setupIntents.data.length === 0) {
break
}
startingAfter = setupIntents.data[setupIntents.data.length - 1].id
}
}
if (matchedPaymentMethods.length === 1) {
return {
paymentMethod: matchedPaymentMethods[0],
reason: 'reuse_payment_method_matching_mandate_billing_agreement_id',
}
}
if (matchedPaymentMethods.length > 1) {
logDebug(
"multiple payment methods matched billing agreement ID, we'll reuse the most recently created one",
{
...context,
paymentMethodIds: matchedPaymentMethods.map(method => method.id),
}
)
matchedPaymentMethods.sort((a, b) => b.created - a.created)
return {
paymentMethod: matchedPaymentMethods[0],
reason: 'reuse_payment_method_matching_mandate_billing_agreement_id',
}
}
return {
paymentMethod: null,
reason: 'no_payment_method_matches_billing_agreement_id',
}
}
/**
* Determines the payment method to set on the Stripe customer.
*
@@ -747,7 +882,10 @@ async function getPaymentMethod(
commit,
context
) {
if (billingInfo?.paymentMethod?.object === 'paypal_billing_agreement') {
const isPayPalBillingAgreement =
billingInfo?.paymentMethod?.object === 'paypal_billing_agreement'
if (isPayPalBillingAgreement) {
const addressCountry = address?.country
if (
addressCountry === 'CA' ||
@@ -758,12 +896,75 @@ async function getPaymentMethod(
`PayPal billing agreement migration is not supported for ${addressCountry} customers`
)
}
}
const paymentMethods = await fetchTargetStripeCustomerPaymentMethods(
stripeClient,
stripeCustomerId,
context
)
if (isPayPalBillingAgreement) {
const paypalPaymentMethods = paymentMethods.filter(
method => method.type === 'paypal'
)
const billingAgreementId = billingInfo.paymentMethod.billingAgreementId
if (!billingAgreementId) {
throw new Error(
`PayPal billing agreement migration requires billingAgreementId for Stripe customer ${stripeCustomerId}`
)
}
logDebug(
'Evaluating existing PayPal payment methods by billing agreement ID',
{
...context,
step: 'evaluate_paypal_payment_methods',
paypalPaymentMethodCount: paypalPaymentMethods.length,
paypalPaymentMethodIds: paypalPaymentMethods.map(method => method.id),
},
{ verboseOnly: true }
)
const { paymentMethod: baidMatchedPaymentMethod, reason: baidMatchReason } =
await findPayPalPaymentMethodByBillingAgreementId(
stripeClient,
stripeCustomerId,
paypalPaymentMethods,
billingAgreementId,
context
)
if (baidMatchedPaymentMethod) {
logDebug(
'Reusing existing PayPal payment method by billing agreement ID',
{
...context,
paymentMethodId: baidMatchedPaymentMethod.id,
reason: baidMatchReason,
step: 'reuse_paypal_payment_method',
},
{ verboseOnly: true }
)
return baidMatchedPaymentMethod
}
if (commit) {
logDebug(
'No PayPal payment method matched billing agreement ID; creating setup intent',
{
...context,
reason: baidMatchReason,
step: 'create_paypal_setup_intent',
},
{ verboseOnly: true }
)
return await createPayPalPaymentMethod(
stripeClient,
stripeCustomerId,
billingInfo.paymentMethod.billingAgreementId,
billingAgreementId,
context
)
} else {
@@ -775,11 +976,6 @@ async function getPaymentMethod(
}
}
const paymentMethods = await fetchTargetStripeCustomerPaymentMethods(
stripeClient,
stripeCustomerId,
context
)
return coalesceOrThrowPaymentMethod(
paymentMethods,
stripeCustomerId,