Procházet zdrojové kódy

Merge pull request #33708 from overleaf/jlm-stripe-stage5-fixes

Stripe migration improvements from stage 5

GitOrigin-RevId: 897941bf1a51f8f1639489fcd3f542af671b7ac7
Andrew Rumble před 4 měsíci
rodič
revize
26b9d029f3

+ 25 - 1
services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs

@@ -1561,7 +1561,31 @@ export async function compareAccountFields({
   const paymentMethodIsMissing =
     (!stripeDefaultPaymentMethod && expectedPaymentMethod) ||
     (stripeDefaultPaymentMethod && !expectedPaymentMethod)
-  if (
+
+  const bothArePaypal =
+    expectedPaymentMethod?.type === 'paypal' &&
+    stripeDefaultPaymentMethod?.type === 'paypal'
+
+  if (bothArePaypal) {
+    // Both are PayPal — the payment method itself matches, but flag if
+    // Recurly billing info was updated after the Stripe payment method was
+    // created.
+    if (recurlyPaymentMethodIsNewer) {
+      diffs.default_payment_method = {
+        recurly: {
+          type: 'paypal',
+          updatedAt: account.billingInfo?.updatedAt?.toISOString() || null,
+        },
+        stripe: {
+          type: 'paypal',
+          created: stripeDefaultPaymentMethod?.created
+            ? new Date(stripeDefaultPaymentMethod.created * 1000).toISOString()
+            : null,
+        },
+        bothPaypal: true,
+      }
+    }
+  } else if (
     paymentMethodIsMissing ||
     (!areStripeAndRecurlyCardDetailsEqual(
       stripeDefaultPaymentMethod,

+ 93 - 0
services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs

@@ -1053,3 +1053,96 @@ test('compareAccountFields detects tax_exempt drift', async () => {
   assert.equal(diffs.tax_exempt.recurly, 'exempt')
   assert.equal(diffs.tax_exempt.stripe, 'none')
 })
+
+test('compareAccountFields reports bothPaypal diff when Recurly billingInfo.updatedAt is newer', async () => {
+  const recurlyUpdatedAt = new Date('2025-06-01T12:00:00Z')
+  const stripeCreatedEpoch = new Date('2025-05-01T12:00:00Z').getTime() / 1000
+
+  const account = {
+    email: 'user@example.com',
+    customFields: [],
+    billingInfo: {
+      updatedAt: recurlyUpdatedAt,
+      paymentMethod: { object: 'paypal_billing_agreement' },
+    },
+    address: { country: 'GB' },
+  }
+  const stripeCustomer = {
+    email: 'user@example.com',
+    metadata: {
+      recurlyAccountCode: '',
+      userId: 'user123',
+      taxInfoPending: '',
+    },
+    tax_exempt: 'none',
+    invoice_settings: {
+      default_payment_method: {
+        type: 'paypal',
+        created: stripeCreatedEpoch,
+      },
+    },
+  }
+
+  const diffs = await compareAccountFields({
+    account,
+    stripeCustomer,
+    overleafUserId: 'user123',
+    fetchCollectionMethod: async () => null,
+    stripePaymentMethods: [],
+    stripeServiceName: 'stripe-uk',
+  })
+
+  assert.ok(diffs.default_payment_method)
+  assert.equal(diffs.default_payment_method.bothPaypal, true)
+  assert.equal(diffs.default_payment_method.recurly.type, 'paypal')
+  assert.equal(
+    diffs.default_payment_method.recurly.updatedAt,
+    recurlyUpdatedAt.toISOString()
+  )
+  assert.equal(diffs.default_payment_method.stripe.type, 'paypal')
+  assert.equal(
+    diffs.default_payment_method.stripe.created,
+    new Date(stripeCreatedEpoch * 1000).toISOString()
+  )
+})
+
+test('compareAccountFields reports no diff when both are PayPal and Recurly is not newer', async () => {
+  const recurlyUpdatedAt = new Date('2025-04-01T12:00:00Z')
+  const stripeCreatedEpoch = new Date('2025-05-01T12:00:00Z').getTime() / 1000
+
+  const account = {
+    email: 'user@example.com',
+    customFields: [],
+    billingInfo: {
+      updatedAt: recurlyUpdatedAt,
+      paymentMethod: { object: 'paypal_billing_agreement' },
+    },
+    address: { country: 'GB' },
+  }
+  const stripeCustomer = {
+    email: 'user@example.com',
+    metadata: {
+      recurlyAccountCode: '',
+      userId: 'user123',
+      taxInfoPending: '',
+    },
+    tax_exempt: 'none',
+    invoice_settings: {
+      default_payment_method: {
+        type: 'paypal',
+        created: stripeCreatedEpoch,
+      },
+    },
+  }
+
+  const diffs = await compareAccountFields({
+    account,
+    stripeCustomer,
+    overleafUserId: 'user123',
+    fetchCollectionMethod: async () => null,
+    stripePaymentMethods: [],
+    stripeServiceName: 'stripe-uk',
+  })
+
+  assert.equal(diffs.default_payment_method, undefined)
+})

+ 28 - 7
services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs

@@ -57,7 +57,6 @@ import PlansLocator from '../../app/src/Features/Subscription/PlansLocator.mjs'
 import UserAnalyticsIdCache from '../../app/src/Features/Analytics/UserAnalyticsIdCache.mjs'
 import CustomerIoHandler from '../../modules/customer-io/app/src/CustomerIoHandler.mjs'
 import { ReportError, convertToMinorUnits } from './helpers.mjs'
-import isEqual from 'lodash/isEqual.js'
 import { compareAccountFields } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
 import {
   createRateLimitedApiWrappers,
@@ -71,6 +70,9 @@ import {
 
 const preloadedProductMetadata = new Map()
 
+// Tolerance for comparing amounts (handles repeating decimals in per-seat prices)
+const AMOUNT_TOLERANCE = 1e-6
+
 // rate limiters - initialized in main()
 let rateLimiters
 
@@ -556,13 +558,26 @@ function detectSubscriptionChanges(
             ? productMetadata?.addOnCode
             : productMetadata?.planCode,
         quantity: item.quantity,
-        amount: item.price.unit_amount,
+        amount:
+          item.price.unit_amount != null
+            ? item.price.unit_amount
+            : parseFloat(item.price.unit_amount_decimal),
       }
     })
     .sort((a, b) => a.code.localeCompare(b.code))
 
-  // Compare items
-  if (!isEqual(recurlyItems, stripeItems)) {
+  // Compare items (use tolerance for amounts due to repeating decimals in per-seat prices)
+  const itemsMatch =
+    recurlyItems.length === stripeItems.length &&
+    recurlyItems.every((rItem, i) => {
+      const sItem = stripeItems[i]
+      return (
+        rItem.code === sItem.code &&
+        rItem.quantity === sItem.quantity &&
+        Math.abs(rItem.amount - sItem.amount) < AMOUNT_TOLERANCE
+      )
+    })
+  if (!itemsMatch) {
     changes.push(
       `Items: Recurly=[${formatItems(recurlyItems)}], Stripe=[${formatItems(stripeItems)}]`
     )
@@ -662,9 +677,15 @@ function formatDiffsAsChanges(diffs) {
         `Tax ID: Recurly={type:${diff.recurly.type}, value:${diff.recurly.value}}, Stripe=${stripeStr}`
       )
     } else if (field === 'default_payment_method') {
-      changes.push(
-        `Payment method: Recurly=${diff.recurly.type || diff.recurly.last4 || '(none)'}, Stripe=${diff.stripe.type || '(none)'}`
-      )
+      if (diff.bothPaypal) {
+        changes.push(
+          `Payment method: both PayPal, but Recurly billing info updatedAt (${diff.recurly.updatedAt}) is newer than Stripe payment method created (${diff.stripe.created})`
+        )
+      } else {
+        changes.push(
+          `Payment method: Recurly=${diff.recurly.type || diff.recurly.last4 || '(none)'}, Stripe=${diff.stripe.type || '(none)'}`
+        )
+      }
     } else if (field.startsWith('metadata.')) {
       const key = field.slice('metadata.'.length)
       changes.push(