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

[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
Kristina 7 месяцев назад
Родитель
Сommit
aa2ce6f675

+ 30 - 5
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
+  )
+}

+ 106 - 4
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<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(