[migration scripts] detect account drift between Recurly and Stripe (#32315)

* add a script to detect drift between recurly and stripe
* only flag when Recurly updated since subscription created in Stripe
* add --comparison-date option instead of stripe subscription created time
* reduce API calls by checking Recurly update time earlier

GitOrigin-RevId: f0ecb5644f7445603da020a46e32622fcd2eebf4
This commit is contained in:
Kristina
2026-03-25 09:05:43 +00:00
committed by Copybot
parent 205573c01a
commit fe7dad5644
5 changed files with 2027 additions and 290 deletions
@@ -39,6 +39,8 @@ import fs from 'node:fs'
import path from 'node:path'
import * as csv from 'csv'
import minimist from 'minimist'
import Settings from '@overleaf/settings'
import recurly from 'recurly'
import PQueue from 'p-queue'
import { z } from '../../app/src/infrastructure/Validation.mjs'
import { scriptRunner } from '../lib/ScriptRunner.mjs'
@@ -56,6 +58,7 @@ import UserAnalyticsIdCache from '../../app/src/Features/Analytics/UserAnalytics
import CustomerIoHandler from '../../modules/customer-io/app/src/CustomerIoHandler.mjs'
import { ReportError } from './helpers.mjs'
import isEqual from 'lodash/isEqual.js'
import { compareAccountFields } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
import {
createRateLimitedApiWrappers,
DEFAULT_RECURLY_RATE_LIMIT,
@@ -71,6 +74,16 @@ const preloadedProductMetadata = new Map()
// rate limiters - initialized in main()
let rateLimiters
// Recurly SDK client - initialized at module level
const recurlyApiKey =
process.env.RECURLY_API_KEY || Settings.apis?.recurly?.apiKey
if (!recurlyApiKey) {
throw new Error(
'Recurly API key is not set. Set RECURLY_API_KEY env var or configure Settings.apis.recurly.apiKey'
)
}
const recurlyClient = new recurly.Client(recurlyApiKey)
function usage() {
console.error(`Usage: node scripts/stripe/finalize-stripe-subscription-migration.mjs [OPTS] [INPUT-FILE]
@@ -366,16 +379,13 @@ async function processMigration(input, commit) {
)
}
// 5. Fetch Recurly subscription
// 5. Fetch Recurly subscription and account
let recurlySubscription
try {
recurlySubscription = await rateLimiters.requestWithRetries(
'recurly',
() =>
RecurlyWrapper.promises.getSubscription(
previousRecurlySubscriptionId,
{}
),
recurlyClient.getSubscription(`uuid-${previousRecurlySubscriptionId}`),
{
operation: 'getSubscription',
recurlySubscriptionId: previousRecurlySubscriptionId,
@@ -388,12 +398,41 @@ async function processMigration(input, commit) {
)
}
let recurlyAccount
try {
recurlyAccount = await rateLimiters.requestWithRetries(
'recurly',
() => recurlyClient.getAccount(`code-${overleafUserId}`),
{
operation: 'getAccount',
overleafUserId,
}
)
} catch (err) {
throw new ReportError(
'no-recurly-account',
`Recurly account not found: ${err.message}`
)
}
// 6. Detect changes between Recurly and Stripe
const changes = detectChanges(recurlySubscription, stripeSubscription, region)
if (changes.length > 0) {
const subscriptionChanges = detectSubscriptionChanges(
recurlySubscription,
stripeSubscription,
region
)
const accountChanges = await detectAccountChanges(
overleafUserId,
stripeCustomerId,
stripeClient,
recurlyAccount,
recurlySubscription.collectionMethod || null
)
const allChanges = [...subscriptionChanges, ...accountChanges]
if (allChanges.length > 0) {
throw new ReportError(
'changes-detected',
`Changes detected between Recurly and Stripe: ${changes.join('; ')}`
`Changes detected between Recurly and Stripe: ${allChanges.join('; ')}`
)
}
@@ -450,38 +489,42 @@ function formatItems(items) {
.join(', ')
}
function detectChanges(recurlySubscription, stripeSubscription, region) {
function detectSubscriptionChanges(
recurlySubscription,
stripeSubscription,
region
) {
const changes = []
// Extract item details from Recurly subscription
const targetRecurlySubscription =
recurlySubscription.pending_subscription || recurlySubscription
recurlySubscription.pendingChange || recurlySubscription
const recurlyPlanItem =
PlansLocator.convertLegacyGroupPlanCodeToConsolidatedGroupPlanCodeIfNeeded(
targetRecurlySubscription.plan.plan_code
targetRecurlySubscription.plan.code
)
const simplifiedPlanCode = recurlyPlanItem.planCode.replace(
/_free_trial.*$/,
''
)
const additionalLicenseQuantity =
(targetRecurlySubscription.subscription_add_ons || []).find(
addOn => addOn.add_on_code === 'additional-license'
(targetRecurlySubscription.addOns || []).find(
addOn => addOn.addOn.code === 'additional-license'
)?.quantity || 0
const recurlyItems = [
{
code: simplifiedPlanCode,
quantity: recurlyPlanItem.quantity + additionalLicenseQuantity,
amount:
targetRecurlySubscription.unit_amount_in_cents /
Math.round(targetRecurlySubscription.unitAmount * 100) /
recurlyPlanItem.quantity,
},
...(targetRecurlySubscription.subscription_add_ons || [])
.filter(addOn => addOn.add_on_code !== 'additional-license')
...(targetRecurlySubscription.addOns || [])
.filter(addOn => addOn.addOn.code !== 'additional-license')
.map(addOn => ({
code: addOn.add_on_code,
code: addOn.addOn.code,
quantity: addOn.quantity,
amount: addOn.unit_amount_in_cents,
amount: Math.round(addOn.unitAmount * 100),
})),
].sort((a, b) => a.code.localeCompare(b.code))
@@ -526,6 +569,109 @@ function detectChanges(recurlySubscription, stripeSubscription, region) {
return changes
}
/**
* Detect account-level drift between the Recurly account and the migrated Stripe customer.
*
* Uses the Recurly SDK account (which includes billing info), and re-retrieves
* the Stripe customer with expanded tax_ids and default_payment_method so the
* comparison can cover all the fields that the customer-migration script set.
*
* @param {string} overleafUserId - Recurly account code / Overleaf user ID
* @param {string} stripeCustomerId - Stripe customer ID
* @param {object} stripeClient - Stripe client (from getRegionClient)
* @param {object} account - Recurly SDK account object (from recurlyClient.getAccount)
* @param {string|null} collectionMethod - Recurly subscription collection method
* @returns {Promise<string[]>} - Array of change descriptions (empty = no drift)
*/
async function detectAccountChanges(
overleafUserId,
stripeCustomerId,
stripeClient,
account,
collectionMethod
) {
const context = { overleafUserId, stripeCustomerId }
// Fetch the Stripe customer with tax_ids and payment method expanded
const stripeCustomer = await rateLimiters.requestWithRetries(
stripeClient.serviceName,
() =>
stripeClient.stripe.customers.retrieve(stripeCustomerId, {
expand: ['tax_ids', 'invoice_settings.default_payment_method'],
}),
{ ...context, operation: 'customers.retrieve' }
)
if (stripeCustomer.deleted) {
return [`Stripe customer ${stripeCustomerId} has been deleted`]
}
// Pre-fetch payment methods if needed for comparison
let stripePaymentMethods = []
const isPaypalBillingAgreement =
account.billingInfo?.paymentMethod?.object === 'paypal_billing_agreement'
if (!isPaypalBillingAgreement && account.billingInfo?.paymentMethod) {
const result = await rateLimiters.requestWithRetries(
stripeClient.serviceName,
() => stripeClient.stripe.customers.listPaymentMethods(stripeCustomerId),
{ ...context, operation: 'customers.listPaymentMethods' }
)
stripePaymentMethods = result.data
}
const diffs = await compareAccountFields({
account,
stripeCustomer,
overleafUserId,
fetchCollectionMethod: async () => collectionMethod,
stripePaymentMethods,
stripeServiceName: stripeClient.serviceName,
})
return formatDiffsAsChanges(diffs)
}
/**
* Convert structured diffs from compareAccountFields into human-readable change descriptions.
*/
function formatDiffsAsChanges(diffs) {
const changes = []
for (const [field, diff] of Object.entries(diffs)) {
if (field === 'address') {
changes.push(
`Address: Recurly=${JSON.stringify(diff.recurly)}, Stripe=${JSON.stringify(diff.stripe)}`
)
} else if (field === 'cc_emails') {
changes.push(
`CC emails: Recurly=[${[...diff.recurly].sort().join(',')}], Stripe=[${[...(diff.stripe || [])].sort().join(',')}]`
)
} else if (field === 'tax_id') {
const stripeStr = diff.stripe
? diff.stripe.map(t => `{type:${t.type}, value:${t.value}}`).join(', ')
: '(none)'
changes.push(
`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)'}`
)
} else if (field.startsWith('metadata.')) {
const key = field.slice('metadata.'.length)
changes.push(
`Metadata ${key}: Recurly=${diff.recurly || '(empty)'}, Stripe=${diff.stripe || '(empty)'}`
)
} else {
const label =
field.charAt(0).toUpperCase() + field.slice(1).replace(/_/g, ' ')
changes.push(
`${label}: Recurly=${diff.recurly || '(empty)'}, Stripe=${diff.stripe || '(empty)'}`
)
}
}
return changes
}
async function performCutover(
mongoSubscription,
stripeSubscription,
@@ -568,9 +714,7 @@ async function performCutover(
// Step 3: Postpone Recurly billing by +10 years if Recurly subscription is active
if (recurlySubscription.state !== 'canceled') {
const currentBillingDate = new Date(
recurlySubscription.current_period_ends_at
)
const currentBillingDate = new Date(recurlySubscription.currentPeriodEndsAt)
const postponedDate = new Date(currentBillingDate)
postponedDate.setFullYear(currentBillingDate.getFullYear() + 10)