Просмотр исходного кода

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

GitOrigin-RevId: 4a324c1cdde84dabf620a2616a0aa27242cf041e
Simon Gardner 4 месяцев назад
Родитель
Сommit
5cfd7b6c6a
1 измененных файлов с 209 добавлено и 13 удалено
  1. 209 13
      services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs

+ 209 - 13
services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs

@@ -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,