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

Stripe migration improvements from stage 5

GitOrigin-RevId: 897941bf1a51f8f1639489fcd3f542af671b7ac7
This commit is contained in:
Andrew Rumble
2026-05-27 14:39:13 +01:00
committed by Copybot
parent dddefc9e30
commit 26b9d029f3
3 changed files with 146 additions and 8 deletions
@@ -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,
@@ -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)
})
@@ -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(