From 26b9d029f3214a81d7f2ea7e2e6f0c294700aded Mon Sep 17 00:00:00 2001 From: Andrew Rumble Date: Wed, 27 May 2026 14:39:13 +0100 Subject: [PATCH] Merge pull request #33708 from overleaf/jlm-stripe-stage5-fixes Stripe migration improvements from stage 5 GitOrigin-RevId: 897941bf1a51f8f1639489fcd3f542af671b7ac7 --- ...te_recurly_customers_to_stripe.helpers.mjs | 26 +++++- ..._customers_to_stripe.helpers.node.test.mjs | 93 +++++++++++++++++++ ...finalize-stripe-subscription-migration.mjs | 35 +++++-- 3 files changed, 146 insertions(+), 8 deletions(-) diff --git a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs index ed05085b15..0469dc0776 100644 --- a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs +++ b/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, diff --git a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs index dbbcf43d66..85ff0c4643 100644 --- a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs +++ b/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) +}) diff --git a/services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs b/services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs index 2ddbe01008..cbbe6c9d2d 100755 --- a/services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs +++ b/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(