Преглед изворни кода

[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
Kristina пре 4 месеци
родитељ
комит
fe7dad5644

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

@@ -1116,8 +1116,9 @@ export function areStripeAndRecurlyCardDetailsEqual(
   recurlyPaymentMethod
 ) {
   if (
+    !stripePaymentMethod ||
     stripePaymentMethod.type !== 'card' ||
-    !stripePaymentMethod?.card ||
+    !stripePaymentMethod.card ||
     !recurlyPaymentMethod?.lastFour
   ) {
     return false
@@ -1129,3 +1130,461 @@ export function areStripeAndRecurlyCardDetailsEqual(
     stripePaymentMethod.card.exp_year === recurlyPaymentMethod.expYear
   )
 }
+
+// =============================================================================
+// ADDRESS / STRING COMPARISON HELPERS
+// =============================================================================
+
+export function normalizeComparableString(value) {
+  if (value == null) return ''
+  return String(value).trim()
+}
+
+export function hasAnyAddressValue(address) {
+  if (!address || typeof address !== 'object') return false
+  return Object.values(address).some(v => normalizeComparableString(v) !== '')
+}
+
+export function ccEmailsToArray(ccEmails) {
+  if (ccEmails == null || ccEmails === undefined) return []
+  const normalisedEmails = String(ccEmails)
+    .split(/[\s,;]+/)
+    .filter(Boolean)
+  return [...new Set(normalisedEmails)]
+}
+
+export function normalizeTaxIdValue(taxId) {
+  const value = normalizeComparableString(taxId)
+  return value.replace(/[.\-_\s:/]/g, '').toLowerCase()
+}
+
+// =============================================================================
+// CUSTOM FIELDS
+// =============================================================================
+
+export const RECURLY_CUSTOM_FIELD_NAMES = [
+  'channel',
+  'Industry',
+  'ol_sales_person',
+  'MigratedfromFreeAgent',
+]
+
+/**
+ * Extract Recurly custom fields as Stripe metadata.
+ *
+ * Expects an account object with a `customFields` array (Recurly npm SDK format)
+ * where each element has `name` and `value` properties.
+ *
+ * @param {object} account - Recurly account object (npm SDK format)
+ * @returns {{ metadata: Record<string, string>, counts: Record<string, number> }}
+ */
+export function extractRecurlyCustomFieldMetadata(account) {
+  const customFields = account?.customFields
+  if (!Array.isArray(customFields)) {
+    throw new Error(
+      'Unexpected Recurly response: account.customFields is missing or not an array'
+    )
+  }
+
+  /** @type {Record<string, string>} */
+  const metadata = {}
+
+  const counts = {
+    channel: 0,
+    Industry: 0,
+    ol_sales_person: 0,
+    MigratedfromFreeAgent: 0,
+    noCustomFields: 0,
+  }
+
+  if (customFields.length === 0) {
+    counts.noCustomFields = 1
+    return { metadata, counts }
+  }
+
+  for (const field of customFields) {
+    const name = field?.name?.trim()
+    if (!RECURLY_CUSTOM_FIELD_NAMES.includes(name)) continue
+    const rawValue = field?.value
+    if (rawValue == null) continue
+    const value = String(rawValue).trim()
+    if (!value) continue
+    metadata[name] = value
+    counts[name] = 1
+  }
+  return { metadata, counts }
+}
+
+function normalizeComparableAddress(address) {
+  if (!address || typeof address !== 'object') return null
+  const norm = {
+    line1: normalizeComparableString(address.line1),
+    line2: normalizeComparableString(address.line2),
+    city: normalizeComparableString(address.city),
+    state: normalizeComparableString(address.state),
+    postal_code: normalizeComparableString(address.postal_code),
+    country: normalizeComparableString(address.country).toUpperCase(),
+  }
+  return hasAnyAddressValue(norm) ? norm : null
+}
+
+export function addressesEqual(a, b) {
+  const na = normalizeComparableAddress(a)
+  const nb = normalizeComparableAddress(b)
+  if (!na && !nb) return true
+  if (!na || !nb) return false
+  return (
+    na.line1 === nb.line1 &&
+    na.line2 === nb.line2 &&
+    na.city === nb.city &&
+    na.state === nb.state &&
+    na.postal_code === nb.postal_code &&
+    na.country === nb.country
+  )
+}
+
+// =============================================================================
+// CUSTOMER IDENTITY RESOLUTION
+// =============================================================================
+
+/**
+ * Resolve customer name, address, company, and VAT number from Recurly account data.
+ *
+ * For most customers, billing info and account info agree (or only one source is set),
+ * and the standard coalesce logic applies (billing info preferred, account as fallback).
+ *
+ * When any field has conflicting values across both sources, `fetchCollectionMethod`
+ * is called to determine which source wins:
+ *
+ * - automatic (web sales): billing info is used for the Stripe customer record.
+ * - manual (manual billing): account info is used for the Stripe customer record,
+ *   and billing info is returned separately to be copied to the payment method's
+ *   billing_details.
+ *
+ * @param {object} account - Recurly account object
+ * @param {() => Promise<string|null>} fetchCollectionMethod - Async callback that returns the
+ *   subscription's collection method ('automatic' or 'manual') when a conflict is detected.
+ * @returns {Promise<{
+ *   name: string|null,
+ *   address: import('stripe').Stripe.AddressParam|null,
+ *   companyName: string|null,
+ *   vatNumber: string|null,
+ *   collectionMethod: string|null,
+ *   billingInfoForPaymentMethod: object|null
+ * }>}
+ */
+export async function resolveCustomerIdentity(account, fetchCollectionMethod) {
+  const billingName = extractNameFromBillingInfo(account)
+  const accountName = extractNameFromAccount(account)
+  const nameConflict =
+    billingName !== null && accountName !== null && billingName !== accountName
+
+  const billingAddress = normalizeRecurlyAddressToStripe(
+    account.billingInfo?.address
+  )
+  const accountAddress = normalizeRecurlyAddressToStripe(account?.address)
+  const addressConflict =
+    billingAddress !== null &&
+    accountAddress !== null &&
+    !addressesEqual(billingAddress, accountAddress)
+
+  const billingCompany = account.billingInfo?.company?.trim() || null
+  const accountCompany = account.company?.trim() || null
+  const companyConflict =
+    billingCompany !== null &&
+    accountCompany !== null &&
+    billingCompany !== accountCompany
+
+  const billingVat = account.billingInfo?.vatNumber?.trim() || null
+  const accountVat = account?.vatNumber?.trim() || null
+  const vatConflict =
+    billingVat !== null && accountVat !== null && billingVat !== accountVat
+
+  const hasConflict =
+    nameConflict || addressConflict || companyConflict || vatConflict
+
+  let name,
+    address,
+    companyName,
+    vatNumber,
+    collectionMethod,
+    billingInfoForPaymentMethod
+
+  if (!hasConflict) {
+    name = billingName ?? accountName
+    address = billingAddress ?? accountAddress
+    companyName = billingCompany ?? accountCompany
+    vatNumber = billingVat ?? accountVat
+    collectionMethod = null
+    billingInfoForPaymentMethod = null
+  } else {
+    collectionMethod = await fetchCollectionMethod()
+
+    if (!collectionMethod) {
+      throw new Error(
+        'Conflict between billing info and account fields, but no subscription found to determine collection method'
+      )
+    }
+
+    if (collectionMethod === 'automatic') {
+      name = billingName ?? accountName
+      address = billingAddress ?? accountAddress
+      companyName = billingCompany ?? accountCompany
+      vatNumber = billingVat ?? accountVat
+      billingInfoForPaymentMethod = null
+    } else if (collectionMethod === 'manual') {
+      name = accountName ?? billingName
+      address = accountAddress ?? billingAddress
+      companyName = accountCompany ?? billingCompany
+      vatNumber = accountVat ?? billingVat
+      billingInfoForPaymentMethod = account.billingInfo
+    } else {
+      throw new Error(`Unexpected collectionMethod: ${collectionMethod}`)
+    }
+  }
+
+  if (vatNumber && address?.country === 'GB') {
+    vatNumber = normalisedGBVATNumber(vatNumber)
+  }
+
+  return {
+    name,
+    address,
+    companyName,
+    vatNumber,
+    collectionMethod,
+    billingInfoForPaymentMethod,
+  }
+}
+
+// =============================================================================
+// ACCOUNT COMPARISON
+// =============================================================================
+
+/**
+ * Compare account-level fields between a Recurly account and a Stripe customer.
+ *
+ * This encapsulates the drift-detection logic shared by both the finalize script
+ * and the compare script.
+ *
+ * @param {object} options
+ * @param {object} options.account - Recurly account (npm SDK camelCase format)
+ * @param {object} options.stripeCustomer - Stripe customer (expanded with tax_ids, default_payment_method)
+ * @param {string} options.overleafUserId - Overleaf user ID (for metadata.userId comparison)
+ * @param {() => Promise<string|null>} options.fetchCollectionMethod - Callback to get subscription collection method
+ * @param {Array} options.stripePaymentMethods - Pre-fetched Stripe payment methods
+ * @param {string} options.stripeServiceName - 'stripe-us' or 'stripe-uk'
+ * @returns {Promise<Record<string, { recurly: any, stripe: any }>>} - Per-field diffs (empty = no drift)
+ */
+export async function compareAccountFields({
+  account,
+  stripeCustomer,
+  overleafUserId,
+  fetchCollectionMethod,
+  stripePaymentMethods,
+  stripeServiceName,
+}) {
+  const { name, address, companyName, vatNumber } =
+    await resolveCustomerIdentity(account, fetchCollectionMethod)
+
+  const diffs = {}
+
+  // Email
+  if (
+    normalizeComparableString(account.email) !==
+    normalizeComparableString(stripeCustomer.email)
+  ) {
+    diffs.email = {
+      recurly: account.email || null,
+      stripe: stripeCustomer.email || null,
+    }
+  }
+
+  // Name
+  if (
+    normalizeComparableString(name) !==
+    normalizeComparableString(stripeCustomer.name)
+  ) {
+    diffs.name = {
+      recurly: name || null,
+      stripe: stripeCustomer.name || null,
+    }
+  }
+
+  // Address
+  if (address) {
+    if (!addressesEqual(address, stripeCustomer.address)) {
+      diffs.address = {
+        recurly: address,
+        stripe: stripeCustomer.address || null,
+      }
+    }
+  }
+
+  // Business name (company)
+  if (companyName) {
+    if (
+      normalizeComparableString(companyName) !==
+      normalizeComparableString(stripeCustomer.business_name)
+    ) {
+      diffs.business_name = {
+        recurly: companyName,
+        stripe: stripeCustomer.business_name || null,
+      }
+    }
+  }
+
+  // Metadata
+  const expectedMetadata = {}
+  if (account.createdAt) {
+    expectedMetadata.recurlyCreatedAt = account.createdAt.toISOString()
+  }
+  expectedMetadata.recurlyAccountCode = ''
+  expectedMetadata.userId = overleafUserId
+
+  const { metadata: customFieldMetadata } =
+    extractRecurlyCustomFieldMetadata(account)
+  Object.assign(expectedMetadata, customFieldMetadata)
+
+  // Tax ID
+  let expectedTaxIdType = null
+  let expectedTaxIdValue = null
+  if (vatNumber) {
+    const taxIdTypeResult = getTaxIdType(
+      address?.country,
+      vatNumber,
+      address?.postal_code,
+      false
+    )
+    if (taxIdTypeResult.type && address?.country) {
+      expectedTaxIdType = taxIdTypeResult.type
+      expectedTaxIdValue = vatNumber
+    } else {
+      expectedMetadata.taxInfoPending = vatNumber
+    }
+  }
+  if (!expectedMetadata.taxInfoPending) {
+    expectedMetadata.taxInfoPending = ''
+  }
+
+  for (const [key, expectedValue] of Object.entries(expectedMetadata)) {
+    const stripeValue = stripeCustomer.metadata?.[key] ?? ''
+    if (
+      normalizeComparableString(expectedValue) !==
+      normalizeComparableString(stripeValue)
+    ) {
+      diffs[`metadata.${key}`] = {
+        recurly: expectedValue,
+        stripe: stripeValue || null,
+      }
+    }
+  }
+
+  // Tax exempt
+  const expectedTaxExempt = account.taxExempt ? 'exempt' : 'none'
+  if (expectedTaxExempt !== (stripeCustomer.tax_exempt || 'none')) {
+    diffs.tax_exempt = {
+      recurly: expectedTaxExempt,
+      stripe: stripeCustomer.tax_exempt || 'none',
+    }
+  }
+
+  // CC emails
+  const expectedCcEmails = ccEmailsToArray(account.ccEmails)
+  const stripeCcEmails = stripeCustomer.additional_emails?.cc || []
+  const recurlyCcSorted = [...expectedCcEmails].sort()
+  const stripeCcSorted = [...stripeCcEmails].sort()
+  if (JSON.stringify(recurlyCcSorted) !== JSON.stringify(stripeCcSorted)) {
+    diffs.cc_emails = {
+      recurly: expectedCcEmails,
+      stripe: stripeCcEmails,
+    }
+  }
+
+  // Tax ID
+  if (expectedTaxIdType && expectedTaxIdValue) {
+    const normalizedExpectedTaxIdValue = normalizeTaxIdValue(expectedTaxIdValue)
+    const stripeTaxIds = stripeCustomer.tax_ids?.data || []
+    const matchingTaxId = stripeTaxIds.find(
+      tid =>
+        tid.type === expectedTaxIdType &&
+        normalizedExpectedTaxIdValue &&
+        normalizeTaxIdValue(tid.value) === normalizedExpectedTaxIdValue
+    )
+    if (!matchingTaxId) {
+      diffs.tax_id = {
+        recurly: { type: expectedTaxIdType, value: expectedTaxIdValue },
+        stripe:
+          stripeTaxIds.length > 0
+            ? stripeTaxIds.map(t => ({ type: t.type, value: t.value }))
+            : null,
+      }
+    }
+  }
+
+  // Default payment method
+  const stripeDefaultPaymentMethod =
+    stripeCustomer.invoice_settings?.default_payment_method
+  const isPaypalBillingAgreement =
+    account.billingInfo?.paymentMethod?.object === 'paypal_billing_agreement'
+
+  let expectedPaymentMethod = null
+  if (
+    isPaypalBillingAgreement &&
+    !['US', 'CA'].includes(address?.country) &&
+    stripeServiceName !== 'stripe-us'
+  ) {
+    expectedPaymentMethod = { type: 'paypal' }
+  }
+
+  if (
+    !isPaypalBillingAgreement &&
+    stripePaymentMethods.length > 0 &&
+    account.billingInfo &&
+    account.billingInfo.paymentMethod
+  ) {
+    expectedPaymentMethod = account.billingInfo.paymentMethod
+  }
+
+  const stripePaymentMethodCreatedAt = stripeDefaultPaymentMethod?.created
+  const recurlyPaymentMethodUpdatedAt =
+    (account.billingInfo?.updatedAt?.getTime() || 0) / 1000
+  const recurlyPaymentMethodIsNewer =
+    stripePaymentMethodCreatedAt < recurlyPaymentMethodUpdatedAt
+  const paymentMethodIsMissing =
+    (!stripeDefaultPaymentMethod && expectedPaymentMethod) ||
+    (stripeDefaultPaymentMethod && !expectedPaymentMethod)
+  if (
+    paymentMethodIsMissing ||
+    (!areStripeAndRecurlyCardDetailsEqual(
+      stripeDefaultPaymentMethod,
+      expectedPaymentMethod
+    ) &&
+      recurlyPaymentMethodIsNewer)
+  ) {
+    diffs.default_payment_method = {
+      recurly: {
+        type: expectedPaymentMethod?.type || 'card',
+        last4: expectedPaymentMethod?.lastFour || null,
+        expiry: expectedPaymentMethod?.lastFour
+          ? `${expectedPaymentMethod.expMonth}/${expectedPaymentMethod.expYear}`
+          : null,
+        updatedAt: account.billingInfo?.updatedAt?.toISOString() || null,
+      },
+      stripe: {
+        type: stripeDefaultPaymentMethod?.type || null,
+        last4: stripeDefaultPaymentMethod?.card
+          ? stripeDefaultPaymentMethod.card.last4
+          : null,
+        expiry: stripeDefaultPaymentMethod?.card
+          ? `${stripeDefaultPaymentMethod.card.exp_month}/${stripeDefaultPaymentMethod.card.exp_year}`
+          : null,
+        created: stripeDefaultPaymentMethod?.created
+          ? new Date(stripeDefaultPaymentMethod.created * 1000).toISOString()
+          : null,
+      },
+    }
+  }
+
+  return diffs
+}

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

@@ -37,6 +37,15 @@ import {
   getTaxIdType,
   coalesceOrThrowPaymentMethod,
   normalisedGBVATNumber,
+  normalizeComparableString,
+  hasAnyAddressValue,
+  ccEmailsToArray,
+  normalizeTaxIdValue,
+  extractRecurlyCustomFieldMetadata,
+  addressesEqual,
+  resolveCustomerIdentity,
+  compareAccountFields,
+  areStripeAndRecurlyCardDetailsEqual,
 } from './migrate_recurly_customers_to_stripe.helpers.mjs'
 
 test('extractNameFromAccount returns normalized full name when first and last are present', () => {
@@ -479,3 +488,568 @@ test('normalisedGBVATNumber preserves Northern Ireland XI numbers', () => {
   // XI numbers should not be modified to GB
   assert.equal(normalisedGBVATNumber('XI123456789'), 'XI123456789')
 })
+
+test('normalizeComparableString returns empty string for null/undefined', () => {
+  assert.equal(normalizeComparableString(null), '')
+  assert.equal(normalizeComparableString(undefined), '')
+})
+
+test('normalizeComparableString trims whitespace', () => {
+  assert.equal(normalizeComparableString('  hello  '), 'hello')
+  assert.equal(normalizeComparableString(' '), '')
+})
+
+test('normalizeComparableString converts non-strings', () => {
+  assert.equal(normalizeComparableString(42), '42')
+  assert.equal(normalizeComparableString(0), '0')
+})
+
+test('hasAnyAddressValue returns false for null/undefined/non-object', () => {
+  assert.equal(hasAnyAddressValue(null), false)
+  assert.equal(hasAnyAddressValue(undefined), false)
+  assert.equal(hasAnyAddressValue('string'), false)
+})
+
+test('hasAnyAddressValue returns false for all-empty address', () => {
+  assert.equal(hasAnyAddressValue({ line1: '', line2: '', city: '' }), false)
+  assert.equal(hasAnyAddressValue({ line1: '  ', city: null }), false)
+})
+
+test('hasAnyAddressValue returns true when any field is non-empty', () => {
+  assert.equal(hasAnyAddressValue({ line1: '123 Main St' }), true)
+  assert.equal(hasAnyAddressValue({ country: 'US' }), true)
+})
+
+test('ccEmailsToArray returns empty array for null/undefined', () => {
+  assert.deepEqual(ccEmailsToArray(null), [])
+  assert.deepEqual(ccEmailsToArray(undefined), [])
+})
+
+test('ccEmailsToArray splits on commas, semicolons, and whitespace', () => {
+  assert.deepEqual(ccEmailsToArray('a@b.com,c@d.com'), ['a@b.com', 'c@d.com'])
+  assert.deepEqual(ccEmailsToArray('a@b.com;c@d.com'), ['a@b.com', 'c@d.com'])
+  assert.deepEqual(ccEmailsToArray('a@b.com c@d.com'), ['a@b.com', 'c@d.com'])
+})
+
+test('ccEmailsToArray deduplicates', () => {
+  assert.deepEqual(ccEmailsToArray('a@b.com,a@b.com,c@d.com'), [
+    'a@b.com',
+    'c@d.com',
+  ])
+})
+
+test('ccEmailsToArray handles empty string', () => {
+  assert.deepEqual(ccEmailsToArray(''), [])
+})
+
+test('normalizeTaxIdValue strips punctuation and lowercases', () => {
+  assert.equal(normalizeTaxIdValue('GB.123-456_789'), 'gb123456789')
+  assert.equal(normalizeTaxIdValue('  DE:123/456 '), 'de123456')
+})
+
+test('normalizeTaxIdValue handles null/undefined', () => {
+  assert.equal(normalizeTaxIdValue(null), '')
+  assert.equal(normalizeTaxIdValue(undefined), '')
+})
+
+test('extractRecurlyCustomFieldMetadata extracts known fields', () => {
+  const account = {
+    customFields: [
+      { name: 'channel', value: 'web' },
+      { name: 'Industry', value: 'Education' },
+      { name: 'unknown_field', value: 'ignored' },
+    ],
+  }
+  const { metadata, counts } = extractRecurlyCustomFieldMetadata(account)
+  assert.deepEqual(metadata, { channel: 'web', Industry: 'Education' })
+  assert.equal(counts.channel, 1)
+  assert.equal(counts.Industry, 1)
+  assert.equal(counts.ol_sales_person, 0)
+})
+
+test('extractRecurlyCustomFieldMetadata returns empty for no custom fields', () => {
+  const { metadata, counts } = extractRecurlyCustomFieldMetadata({
+    customFields: [],
+  })
+  assert.deepEqual(metadata, {})
+  assert.equal(counts.noCustomFields, 1)
+})
+
+test('extractRecurlyCustomFieldMetadata throws for missing customFields', () => {
+  assert.throws(
+    () => extractRecurlyCustomFieldMetadata({}),
+    /account.customFields is missing or not an array/
+  )
+})
+
+test('extractRecurlyCustomFieldMetadata skips null/empty values', () => {
+  const account = {
+    customFields: [
+      { name: 'channel', value: null },
+      { name: 'Industry', value: '' },
+      { name: 'ol_sales_person', value: '  ' },
+    ],
+  }
+  const { metadata } = extractRecurlyCustomFieldMetadata(account)
+  assert.deepEqual(metadata, {})
+})
+
+test('addressesEqual returns true for two null addresses', () => {
+  assert.equal(addressesEqual(null, null), true)
+})
+
+test('addressesEqual returns false when one is null', () => {
+  assert.equal(
+    addressesEqual({ line1: '123 Main St', country: 'US' }, null),
+    false
+  )
+  assert.equal(
+    addressesEqual(null, { line1: '123 Main St', country: 'US' }),
+    false
+  )
+})
+
+test('addressesEqual returns true for matching addresses', () => {
+  const a = {
+    line1: '123 Main St',
+    line2: '',
+    city: 'London',
+    state: '',
+    postal_code: 'SW1A 1AA',
+    country: 'GB',
+  }
+  const b = {
+    line1: '123 Main St',
+    line2: '',
+    city: 'London',
+    state: '',
+    postal_code: 'SW1A 1AA',
+    country: 'gb',
+  }
+  assert.equal(addressesEqual(a, b), true)
+})
+
+test('addressesEqual returns false for differing addresses', () => {
+  const a = {
+    line1: '123 Main St',
+    city: 'London',
+    country: 'GB',
+  }
+  const b = {
+    line1: '456 High St',
+    city: 'London',
+    country: 'GB',
+  }
+  assert.equal(addressesEqual(a, b), false)
+})
+
+test('addressesEqual normalizes whitespace', () => {
+  const a = { line1: ' 123 Main St ', country: ' GB ' }
+  const b = { line1: '123 Main St', country: 'GB' }
+  assert.equal(addressesEqual(a, b), true)
+})
+
+test('areStripeAndRecurlyCardDetailsEqual returns false for null inputs', () => {
+  assert.equal(areStripeAndRecurlyCardDetailsEqual(null, null), false)
+  assert.equal(
+    areStripeAndRecurlyCardDetailsEqual(null, { lastFour: '1234' }),
+    false
+  )
+  assert.equal(
+    areStripeAndRecurlyCardDetailsEqual(
+      { type: 'card', card: { last4: '1234', exp_month: 12, exp_year: 2030 } },
+      null
+    ),
+    false
+  )
+})
+
+test('areStripeAndRecurlyCardDetailsEqual returns false for non-card type', () => {
+  assert.equal(
+    areStripeAndRecurlyCardDetailsEqual(
+      { type: 'paypal' },
+      { lastFour: '1234' }
+    ),
+    false
+  )
+})
+
+test('areStripeAndRecurlyCardDetailsEqual returns true for matching cards', () => {
+  assert.equal(
+    areStripeAndRecurlyCardDetailsEqual(
+      { type: 'card', card: { last4: '4242', exp_month: 6, exp_year: 2025 } },
+      { lastFour: '4242', expMonth: 6, expYear: 2025 }
+    ),
+    true
+  )
+})
+
+test('areStripeAndRecurlyCardDetailsEqual returns false for mismatched last4', () => {
+  assert.equal(
+    areStripeAndRecurlyCardDetailsEqual(
+      { type: 'card', card: { last4: '4242', exp_month: 6, exp_year: 2025 } },
+      { lastFour: '1111', expMonth: 6, expYear: 2025 }
+    ),
+    false
+  )
+})
+
+test('resolveCustomerIdentity uses billing info when no conflict', async () => {
+  const account = {
+    billingInfo: {
+      firstName: 'Bill',
+      lastName: 'User',
+      address: { street1: '123 Bill St', country: 'US' },
+      company: 'Bill Corp',
+      vatNumber: 'US123',
+    },
+    firstName: 'Account',
+    lastName: 'User',
+    address: { street1: '456 Acct St', country: 'US' },
+    company: 'Acct Corp',
+    vatNumber: 'US123',
+  }
+  // No conflict on vatNumber (same), but name differs -> conflict
+  const result = await resolveCustomerIdentity(account, async () => 'automatic')
+  assert.equal(result.name, 'Bill User')
+})
+
+test('resolveCustomerIdentity uses account info for manual collection', async () => {
+  const account = {
+    billingInfo: {
+      firstName: 'Bill',
+      lastName: 'User',
+      address: { street1: '123 Bill St', country: 'US' },
+    },
+    firstName: 'Account',
+    lastName: 'User',
+    address: { street1: '456 Acct St', country: 'US' },
+  }
+  const result = await resolveCustomerIdentity(account, async () => 'manual')
+  assert.equal(result.name, 'Account User')
+  assert.notEqual(result.billingInfoForPaymentMethod, null)
+})
+
+test('resolveCustomerIdentity returns billing info preferred when no conflict', async () => {
+  const account = {
+    billingInfo: {
+      firstName: 'Same',
+      lastName: 'Name',
+      vatNumber: 'GB123456789',
+    },
+    firstName: 'Same',
+    lastName: 'Name',
+    vatNumber: 'GB123456789',
+  }
+  const result = await resolveCustomerIdentity(account, async () => {
+    throw new Error('should not be called')
+  })
+  assert.equal(result.name, 'Same Name')
+  assert.equal(result.collectionMethod, null)
+})
+
+test('resolveCustomerIdentity does not call fetchCollectionMethod when no conflict', async () => {
+  let called = false
+  const account = {
+    billingInfo: { firstName: 'Only', lastName: 'Billing' },
+  }
+  await resolveCustomerIdentity(account, async () => {
+    called = true
+    return 'automatic'
+  })
+  assert.equal(called, false)
+})
+
+test('resolveCustomerIdentity throws when conflict but no collection method', async () => {
+  const account = {
+    billingInfo: {
+      firstName: 'Bill',
+      lastName: 'Name',
+    },
+    firstName: 'Acct',
+    lastName: 'Name',
+  }
+  await assert.rejects(
+    () => resolveCustomerIdentity(account, async () => null),
+    /no subscription found to determine collection method/
+  )
+})
+
+test('resolveCustomerIdentity normalises GB VAT', async () => {
+  const account = {
+    billingInfo: {
+      firstName: 'Test',
+      lastName: 'User',
+      address: { country: 'GB' },
+      vatNumber: '123456789',
+    },
+  }
+  const result = await resolveCustomerIdentity(account, async () => 'automatic')
+  assert.equal(result.vatNumber, 'GB123456789')
+})
+
+test('compareAccountFields returns empty diffs when everything matches', async () => {
+  const account = {
+    email: 'user@example.com',
+    firstName: 'Test',
+    lastName: 'User',
+    customFields: [],
+    billingInfo: {
+      firstName: 'Test',
+      lastName: 'User',
+    },
+  }
+  const stripeCustomer = {
+    email: 'user@example.com',
+    name: 'Test User',
+    metadata: {
+      recurlyAccountCode: '',
+      userId: 'user123',
+      taxInfoPending: '',
+    },
+    tax_exempt: 'none',
+  }
+
+  const diffs = await compareAccountFields({
+    account,
+    stripeCustomer,
+    overleafUserId: 'user123',
+    fetchCollectionMethod: async () => null,
+    stripePaymentMethods: [],
+    stripeServiceName: 'stripe-uk',
+  })
+
+  assert.deepEqual(diffs, {})
+})
+
+test('compareAccountFields detects email drift', async () => {
+  const account = {
+    email: 'new@example.com',
+    customFields: [],
+    billingInfo: {},
+  }
+  const stripeCustomer = {
+    email: 'old@example.com',
+    metadata: {
+      recurlyAccountCode: '',
+      userId: 'user123',
+      taxInfoPending: '',
+    },
+    tax_exempt: 'none',
+  }
+
+  const diffs = await compareAccountFields({
+    account,
+    stripeCustomer,
+    overleafUserId: 'user123',
+    fetchCollectionMethod: async () => null,
+    stripePaymentMethods: [],
+    stripeServiceName: 'stripe-uk',
+  })
+
+  assert.ok(diffs.email)
+  assert.equal(diffs.email.recurly, 'new@example.com')
+  assert.equal(diffs.email.stripe, 'old@example.com')
+})
+
+test('compareAccountFields detects name drift', async () => {
+  const account = {
+    email: 'user@example.com',
+    customFields: [],
+    billingInfo: {
+      firstName: 'New',
+      lastName: 'Name',
+    },
+  }
+  const stripeCustomer = {
+    email: 'user@example.com',
+    name: 'Old Name',
+    metadata: {
+      recurlyAccountCode: '',
+      userId: 'user123',
+      taxInfoPending: '',
+    },
+    tax_exempt: 'none',
+  }
+
+  const diffs = await compareAccountFields({
+    account,
+    stripeCustomer,
+    overleafUserId: 'user123',
+    fetchCollectionMethod: async () => null,
+    stripePaymentMethods: [],
+    stripeServiceName: 'stripe-uk',
+  })
+
+  assert.ok(diffs.name)
+  assert.equal(diffs.name.recurly, 'New Name')
+  assert.equal(diffs.name.stripe, 'Old Name')
+})
+
+test('compareAccountFields detects tax ID drift', async () => {
+  const account = {
+    email: 'user@example.com',
+    customFields: [],
+    billingInfo: {
+      firstName: 'Test',
+      lastName: 'User',
+      address: { country: 'DE' },
+      vatNumber: 'DE123456789',
+    },
+  }
+  const stripeCustomer = {
+    email: 'user@example.com',
+    name: 'Test User',
+    address: { country: 'DE' },
+    metadata: {
+      recurlyAccountCode: '',
+      userId: 'user123',
+      taxInfoPending: '',
+    },
+    tax_exempt: 'none',
+    tax_ids: { data: [] },
+  }
+
+  const diffs = await compareAccountFields({
+    account,
+    stripeCustomer,
+    overleafUserId: 'user123',
+    fetchCollectionMethod: async () => null,
+    stripePaymentMethods: [],
+    stripeServiceName: 'stripe-uk',
+  })
+
+  assert.ok(diffs.tax_id)
+  assert.equal(diffs.tax_id.recurly.type, 'eu_vat')
+  assert.equal(diffs.tax_id.recurly.value, 'DE123456789')
+})
+
+test('compareAccountFields does not report tax ID diff when values match after normalization', async () => {
+  const account = {
+    email: 'user@example.com',
+    customFields: [],
+    billingInfo: {
+      firstName: 'Test',
+      lastName: 'User',
+      address: { country: 'DE' },
+      vatNumber: 'DE-123.456.789',
+    },
+  }
+  const stripeCustomer = {
+    email: 'user@example.com',
+    name: 'Test User',
+    address: { country: 'DE' },
+    metadata: {
+      recurlyAccountCode: '',
+      userId: 'user123',
+      taxInfoPending: '',
+    },
+    tax_exempt: 'none',
+    tax_ids: {
+      data: [{ type: 'eu_vat', value: 'DE123456789' }],
+    },
+  }
+
+  const diffs = await compareAccountFields({
+    account,
+    stripeCustomer,
+    overleafUserId: 'user123',
+    fetchCollectionMethod: async () => null,
+    stripePaymentMethods: [],
+    stripeServiceName: 'stripe-uk',
+  })
+
+  assert.equal(diffs.tax_id, undefined)
+})
+
+test('compareAccountFields handles no payment methods on either side without crashing', async () => {
+  const account = {
+    email: 'user@example.com',
+    customFields: [],
+    billingInfo: {},
+  }
+  const stripeCustomer = {
+    email: 'user@example.com',
+    metadata: {
+      recurlyAccountCode: '',
+      userId: 'user123',
+      taxInfoPending: '',
+    },
+    tax_exempt: 'none',
+    invoice_settings: { default_payment_method: null },
+  }
+
+  // This should not throw (regression test for null payment method crash)
+  const diffs = await compareAccountFields({
+    account,
+    stripeCustomer,
+    overleafUserId: 'user123',
+    fetchCollectionMethod: async () => null,
+    stripePaymentMethods: [],
+    stripeServiceName: 'stripe-uk',
+  })
+
+  assert.equal(diffs.default_payment_method, undefined)
+})
+
+test('compareAccountFields detects metadata drift', async () => {
+  const account = {
+    email: 'user@example.com',
+    customFields: [{ name: 'channel', value: 'web' }],
+    billingInfo: {},
+  }
+  const stripeCustomer = {
+    email: 'user@example.com',
+    metadata: {
+      recurlyAccountCode: '',
+      userId: 'user123',
+      taxInfoPending: '',
+      channel: 'api',
+    },
+    tax_exempt: 'none',
+  }
+
+  const diffs = await compareAccountFields({
+    account,
+    stripeCustomer,
+    overleafUserId: 'user123',
+    fetchCollectionMethod: async () => null,
+    stripePaymentMethods: [],
+    stripeServiceName: 'stripe-uk',
+  })
+
+  assert.ok(diffs['metadata.channel'])
+  assert.equal(diffs['metadata.channel'].recurly, 'web')
+  assert.equal(diffs['metadata.channel'].stripe, 'api')
+})
+
+test('compareAccountFields detects tax_exempt drift', async () => {
+  const account = {
+    email: 'user@example.com',
+    customFields: [],
+    billingInfo: {},
+    taxExempt: true,
+  }
+  const stripeCustomer = {
+    email: 'user@example.com',
+    metadata: {
+      recurlyAccountCode: '',
+      userId: 'user123',
+      taxInfoPending: '',
+    },
+    tax_exempt: 'none',
+  }
+
+  const diffs = await compareAccountFields({
+    account,
+    stripeCustomer,
+    overleafUserId: 'user123',
+    fetchCollectionMethod: async () => null,
+    stripePaymentMethods: [],
+    stripeServiceName: 'stripe-uk',
+  })
+
+  assert.ok(diffs.tax_exempt)
+  assert.equal(diffs.tax_exempt.recurly, 'exempt')
+  assert.equal(diffs.tax_exempt.stripe, 'none')
+})

+ 804 - 0
services/web/scripts/recurly/compare_recurly_stripe_customers.mjs

@@ -0,0 +1,804 @@
+#!/usr/bin/env node
+
+/**
+ * This script compares existing Stripe customer records with data from Recurly
+ * to detect any drift since the last migration run.
+ *
+ * It is a read-only companion to migrate_recurly_customers_to_stripe.mjs.
+ * It uses the same normalization logic but makes no changes to Stripe.
+ *
+ * Input CSV format:
+ *   recurly_account_code,target_stripe_account,stripe_customer_id
+ *
+ * Output files:
+ *   --output (comparison file): One row per customer with match/mismatch status
+ *     Format: recurly_account_code,target_stripe_account,stripe_customer_id,status,diffs
+ *
+ *   <output>_details.json: Detailed diff for each customer with mismatches
+ *     Format: Array of { recurly_account_code, stripe_customer_id, diffs: { field: { recurly, stripe } } }
+ *
+ *   <output>_errors.csv: Records that failed
+ *     Format: recurly_account_code,target_stripe_account,stripe_customer_id,error
+ *
+ * Usage:
+ *   node scripts/recurly/compare_recurly_stripe_customers.mjs --input customers.csv --output comparison.csv --comparison-date 2026-03-19
+ *
+ * Options:
+ *   --input, -i <file>     Path to input CSV file (required)
+ *   --output, -o <file>    Path to output CSV file (required)
+ *   --comparison-date <date> Date in YYYY-MM-DD to compare against (required)
+ *   --limit, -l <n>        Limit number of records processed (default: no limit)
+ *   --concurrency, -c <n>  Number of customers to process concurrently (default: 10)
+ *   --recurly-rate-limit <n>     Requests per second for Recurly (default: 10)
+ *   --recurly-api-retries <n>    Number of retries on Recurly 429s (default: 5)
+ *   --recurly-retry-delay-ms <n> Delay between Recurly retries in ms (default: 1000)
+ *   --stripe-rate-limit <n>      Requests per second for Stripe (default: 50)
+ *   --stripe-api-retries <n>     Number of retries on Stripe 429s (default: 5)
+ *   --stripe-retry-delay-ms <n>  Delay between Stripe retries in ms (default: 1000)
+ *   --verbose, -v          Enable debug logging
+ *
+ * Note, prior to running this script, environment variables must have been loaded from config/local.env
+ *
+ * ```
+ * set -a
+ * source ../../config/local.env
+ * set +a
+ * ```
+ */
+
+import Settings from '@overleaf/settings'
+import Stripe from 'stripe'
+import recurly from 'recurly'
+import minimist from 'minimist'
+import PQueue from 'p-queue'
+import fs from 'node:fs'
+import * as csv from 'csv'
+import { scriptRunner } from '../lib/ScriptRunner.mjs'
+
+import { compareAccountFields } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
+import {
+  createRateLimitedApiWrappers,
+  DEFAULT_RECURLY_RATE_LIMIT,
+  DEFAULT_STRIPE_RATE_LIMIT,
+  DEFAULT_RECURLY_API_RETRIES,
+  DEFAULT_RECURLY_RETRY_DELAY_MS,
+  DEFAULT_STRIPE_API_RETRIES,
+  DEFAULT_STRIPE_RETRY_DELAY_MS,
+} from '../stripe/RateLimiter.mjs'
+
+// =============================================================================
+// STRIPE CLIENT SETUP
+// =============================================================================
+
+const stripeClients = {}
+
+function getRegionClient(region) {
+  const regionLower = String(region || '')
+    .trim()
+    .toLowerCase()
+
+  if (regionLower !== 'us' && regionLower !== 'uk') {
+    throw new Error(
+      `Unknown Stripe region: ${region}. Expected stripe-us or stripe-uk.`
+    )
+  }
+
+  if (stripeClients[regionLower]) return stripeClients[regionLower]
+
+  const secretKey =
+    regionLower === 'us'
+      ? Settings.apis?.stripeUS?.secretKey ||
+        process.env.STRIPE_OL_SECRET_KEY ||
+        process.env.STRIPE_OL_US_SECRET_KEY
+      : Settings.apis?.stripeUK?.secretKey ||
+        process.env.STRIPE_OL_UK_SECRET_KEY
+
+  if (!secretKey || !String(secretKey).trim()) {
+    throw new Error(
+      `No Stripe secret key configured for region ${regionLower}.`
+    )
+  }
+
+  const client = new Stripe(secretKey, {
+    httpClient: Stripe.createFetchHttpClient(),
+    telemetry: false,
+  })
+
+  client.serviceName = `stripe-${regionLower}`
+  stripeClients[regionLower] = client
+  return client
+}
+
+// =============================================================================
+// RECURLY CLIENT SETUP
+// =============================================================================
+
+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)
+
+// =============================================================================
+// LOGGING UTILITIES
+// =============================================================================
+
+function timestamp() {
+  return new Date().toISOString()
+}
+
+function logWarn(message, context = {}) {
+  const contextStr =
+    Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
+  console.warn(`[${timestamp()}] WARN: ${message}${contextStr}`)
+}
+
+function logError(message, error = null, context = {}) {
+  const contextStr =
+    Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
+  console.error(`[${timestamp()}] ERROR: ${message}${contextStr}`)
+  if (error?.stack) {
+    console.error(`[${timestamp()}] STACK: ${error.stack}`)
+  }
+}
+
+let DEBUG_MODE = false
+
+function logDebug(message, context = {}, { verboseOnly = false } = {}) {
+  if (verboseOnly && !DEBUG_MODE) return
+  const contextStr =
+    Object.keys(context).length > 0 ? ` ${JSON.stringify(context)}` : ''
+  const level = verboseOnly ? 'DEBUG' : 'INFO'
+  console.log(`[${timestamp()}] ${level}: ${message}${contextStr}`)
+}
+
+// =============================================================================
+// DATA FETCHING
+// =============================================================================
+
+let rateLimiters
+
+async function fetchRecurlyData(accountCode, context) {
+  return await rateLimiters.requestWithRetries(
+    'recurly',
+    () => recurlyClient.getAccount(`code-${accountCode}`),
+    context
+  )
+}
+
+async function fetchRecurlySubscription(accountCode, context) {
+  // Try live subscriptions first, then fall back to expired.
+  for (const state of ['live', 'expired']) {
+    const subscriptions = await rateLimiters.requestWithRetries(
+      'recurly',
+      async () => {
+        const pager = recurlyClient.listAccountSubscriptions(
+          `code-${accountCode}`,
+          { params: { state, order: 'desc', sort: 'updated_at' } }
+        )
+        const results = []
+        for await (const subscription of pager.each()) {
+          results.push(subscription)
+        }
+        return results
+      },
+      context
+    )
+    if (subscriptions.length > 0) {
+      // Return the most recently updated subscription in this state
+      return subscriptions[0]
+    }
+  }
+  return null
+}
+
+async function fetchTargetStripeCustomer(
+  stripeClient,
+  stripeCustomerId,
+  context
+) {
+  const customer = await rateLimiters.requestWithRetries(
+    stripeClient.serviceName,
+    () =>
+      stripeClient.customers.retrieve(stripeCustomerId, {
+        expand: ['tax_ids', 'invoice_settings.default_payment_method'],
+      }),
+    { ...context, stripeApi: 'customers.retrieve' }
+  )
+  if (customer.deleted) {
+    throw new Error(`Stripe customer ${stripeCustomerId} has been deleted`)
+  }
+  return customer
+}
+
+async function fetchTargetStripeCustomerPaymentMethods(
+  stripeClient,
+  stripeCustomerId,
+  context
+) {
+  const paymentMethods = await rateLimiters.requestWithRetries(
+    stripeClient.serviceName,
+    () => stripeClient.customers.listPaymentMethods(stripeCustomerId),
+    { ...context, stripeApi: 'customers.listPaymentMethods' }
+  )
+  return paymentMethods.data
+}
+
+// =============================================================================
+// COMPARISON LOGIC
+// =============================================================================
+
+/**
+ * Compare a single customer's Recurly data against Stripe.
+ */
+async function compareCustomer(row, rowNumber, comparisonDate) {
+  const {
+    recurly_account_code: recurlyAccountCode,
+    target_stripe_account: targetStripeAccount,
+    stripe_customer_id: stripeCustomerId,
+  } = row
+
+  const context = {
+    rowNumber,
+    recurlyAccountCode,
+    targetStripeAccount,
+    stripeCustomerId,
+  }
+
+  const stripeContext = {
+    rowNumber,
+    stripeCustomerId,
+    stripeAccount: targetStripeAccount,
+  }
+
+  const result = {
+    recurly_account_code: recurlyAccountCode,
+    target_stripe_account: targetStripeAccount,
+    stripe_customer_id: stripeCustomerId,
+    status: '', // 'match', 'mismatch', or 'error'
+    diffs: '',
+    error: '',
+    diffDetails: null,
+  }
+
+  try {
+    if (!recurlyAccountCode) throw new Error('Missing recurly_account_code')
+    if (!targetStripeAccount) throw new Error('Missing target_stripe_account')
+    if (!stripeCustomerId) throw new Error('Missing stripe_customer_id')
+
+    const region = String(targetStripeAccount || '')
+      .trim()
+      .toLowerCase()
+      .replace(/^stripe-/, '')
+    const stripeClient = getRegionClient(region)
+
+    const account = await fetchRecurlyData(recurlyAccountCode, context)
+    const recurlyAccountUpdatedAt = account.updatedAt?.getTime() || 0
+
+    // If Recurly account was not updated after the comparison date, consider it a match
+    if (recurlyAccountUpdatedAt <= comparisonDate.getTime()) {
+      result.status = 'match'
+      result.diffs = ''
+      return result
+    }
+
+    const stripeCustomer = await fetchTargetStripeCustomer(
+      stripeClient,
+      stripeCustomerId,
+      stripeContext
+    )
+
+    const stripePaymentMethods = await fetchTargetStripeCustomerPaymentMethods(
+      stripeClient,
+      stripeCustomerId,
+      stripeContext
+    )
+
+    const diffs = await compareAccountFields({
+      account,
+      stripeCustomer,
+      overleafUserId: recurlyAccountCode,
+      fetchCollectionMethod: async () => {
+        const subscription = await fetchRecurlySubscription(
+          recurlyAccountCode,
+          context
+        )
+        return subscription?.collectionMethod || null
+      },
+      stripePaymentMethods,
+      stripeServiceName: stripeClient.serviceName,
+    })
+
+    // Determine result
+    const diffKeys = Object.keys(diffs)
+    if (diffKeys.length === 0) {
+      result.status = 'match'
+      result.diffs = ''
+    } else {
+      result.status = 'mismatch'
+      result.diffs = diffKeys.join('; ')
+      result.diffDetails = {
+        recurly_account_code: recurlyAccountCode,
+        stripe_customer_id: stripeCustomerId,
+        target_stripe_account: targetStripeAccount,
+        comparison_date: comparisonDate.toISOString(),
+        recurly_account_updated_at: account.updatedAt?.toISOString(),
+        diffs,
+      }
+
+      logDebug('Customer has diffs', {
+        ...context,
+        diffFields: diffKeys,
+      })
+    }
+  } catch (error) {
+    result.status = 'error'
+    const errorDetails = [error.message]
+    if (error.code) errorDetails.push(`code=${error.code}`)
+    if (error.type) errorDetails.push(`type=${error.type}`)
+    if (error.statusCode) errorDetails.push(`statusCode=${error.statusCode}`)
+    result.error = errorDetails.join('; ')
+    logError('Failed to compare customer', error, context)
+  }
+
+  return result
+}
+
+// =============================================================================
+// CSV HELPERS
+// =============================================================================
+
+function formatCsvRow(columns, row) {
+  const values = columns.map(col => {
+    const raw = row[col]
+    const val = raw == null ? '' : String(raw)
+    if (val.includes(',') || val.includes('"') || val.includes('\n')) {
+      return `"${val.replace(/"/g, '""')}"`
+    }
+    return val
+  })
+  return values.join(',') + '\n'
+}
+
+function createJsonArrayWriter(jsonPath) {
+  const stream = fs.createWriteStream(jsonPath, { flags: 'w' })
+  stream.write('[\n')
+  let wroteAny = false
+
+  function write(value) {
+    const serialized = JSON.stringify(value, null, 2)
+    if (wroteAny) stream.write(',\n')
+    stream.write(serialized)
+    wroteAny = true
+  }
+
+  async function close() {
+    stream.write('\n]\n')
+    stream.end()
+    await new Promise((resolve, reject) => {
+      stream.on('finish', resolve)
+      stream.on('error', reject)
+    })
+  }
+
+  return { write, close }
+}
+
+// =============================================================================
+// CLI
+// =============================================================================
+
+function usage() {
+  console.error(
+    'Compare Recurly customer data against migrated Stripe customers'
+  )
+  console.error('')
+  console.error('Usage:')
+  console.error(
+    '  node scripts/recurly/compare_recurly_stripe_customers.mjs [options]'
+  )
+  console.error('')
+  console.error('Options:')
+  console.error('  --input, -i <file>   Path to input CSV file (required)')
+  console.error('  --output, -o <file>  Path to output CSV file (required)')
+  console.error(
+    '  --comparison-date <date> Date in YYYY-MM-DD to compare against (required)'
+  )
+  console.error(
+    '  --limit, -l <n>      Limit number of records processed (default: no limit)'
+  )
+  console.error(
+    '  --concurrency, -c <n> Number of customers to process concurrently (default: 10)'
+  )
+  console.error(
+    '  --recurly-rate-limit <n> Requests per second for Recurly (default: 10)'
+  )
+  console.error(
+    '  --recurly-api-retries <n> Number of retries on Recurly 429s (default: 5)'
+  )
+  console.error(
+    '  --recurly-retry-delay-ms <n> Delay between Recurly retries in ms (default: 1000)'
+  )
+  console.error(
+    '  --stripe-rate-limit <n>  Requests per second for Stripe (default: 50)'
+  )
+  console.error(
+    '  --stripe-api-retries <n> Number of retries on Stripe 429s (default: 5)'
+  )
+  console.error(
+    '  --stripe-retry-delay-ms <n> Delay between Stripe retries in ms (default: 1000)'
+  )
+  console.error('  --verbose, -v         Enable debug logging')
+}
+
+function parseConcurrency(value, { defaultValue = 10 } = {}) {
+  if (value === undefined || value === null || value === '') return defaultValue
+  const parsed = Number(value)
+  if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1) {
+    throw new Error(
+      `Invalid --concurrency value: ${value}. Expected a positive integer.`
+    )
+  }
+  return parsed
+}
+
+function parseRateLimit(value, { defaultValue, name }) {
+  if (value === undefined || value === null || value === '') return defaultValue
+  const parsed = Number(value)
+  if (!Number.isFinite(parsed) || parsed <= 0) {
+    throw new Error(
+      `Invalid --${name} value: ${value}. Expected a positive number.`
+    )
+  }
+  return parsed
+}
+
+function parseNonNegativeInt(value, { defaultValue, name }) {
+  if (value === undefined || value === null || value === '') return defaultValue
+  const parsed = Number(value)
+  if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
+    throw new Error(
+      `Invalid --${name} value: ${value}. Expected a non-negative integer.`
+    )
+  }
+  return parsed
+}
+
+function parseArgs() {
+  return minimist(process.argv.slice(2), {
+    alias: {
+      i: 'input',
+      o: 'output',
+      h: 'help',
+      v: 'verbose',
+      c: 'concurrency',
+      l: 'limit',
+    },
+    string: [
+      'input',
+      'output',
+      'comparison-date',
+      'limit',
+      'recurly-rate-limit',
+      'recurly-api-retries',
+      'recurly-retry-delay-ms',
+      'stripe-rate-limit',
+      'stripe-api-retries',
+      'stripe-retry-delay-ms',
+    ],
+    boolean: ['verbose', 'help'],
+    default: {
+      verbose: false,
+      concurrency: 10,
+      'recurly-rate-limit': DEFAULT_RECURLY_RATE_LIMIT,
+      'recurly-api-retries': DEFAULT_RECURLY_API_RETRIES,
+      'recurly-retry-delay-ms': DEFAULT_RECURLY_RETRY_DELAY_MS,
+      'stripe-rate-limit': DEFAULT_STRIPE_RATE_LIMIT,
+      'stripe-api-retries': DEFAULT_STRIPE_API_RETRIES,
+      'stripe-retry-delay-ms': DEFAULT_STRIPE_RETRY_DELAY_MS,
+    },
+  })
+}
+
+// =============================================================================
+// MAIN
+// =============================================================================
+
+async function main(trackProgress) {
+  const startTime = new Date()
+  const args = parseArgs()
+  const {
+    input: inputPath,
+    output: outputPath,
+    'comparison-date': comparisonDateRaw,
+    verbose,
+    help,
+    concurrency: concurrencyRaw,
+    limit: limitRaw,
+    'recurly-rate-limit': recurlyRateLimitRaw,
+    'recurly-api-retries': recurlyApiRetriesRaw,
+    'recurly-retry-delay-ms': recurlyRetryDelayMsRaw,
+    'stripe-rate-limit': stripeRateLimitRaw,
+    'stripe-api-retries': stripeApiRetriesRaw,
+    'stripe-retry-delay-ms': stripeRetryDelayMsRaw,
+  } = args
+
+  let concurrency,
+    recurlyRateLimit,
+    recurlyApiRetriesValue,
+    recurlyRetryDelayMsValue,
+    stripeRateLimitPerSecond,
+    stripeApiRetriesValue,
+    stripeRetryDelayMsValue,
+    limit,
+    comparisonDate
+  try {
+    concurrency = parseConcurrency(concurrencyRaw, { defaultValue: 10 })
+    limit = parseNonNegativeInt(limitRaw, {
+      defaultValue: null,
+      name: 'limit',
+    })
+    recurlyRateLimit = parseRateLimit(recurlyRateLimitRaw, {
+      defaultValue: DEFAULT_RECURLY_RATE_LIMIT,
+      name: 'recurly-rate-limit',
+    })
+    recurlyApiRetriesValue = parseNonNegativeInt(recurlyApiRetriesRaw, {
+      defaultValue: DEFAULT_RECURLY_API_RETRIES,
+      name: 'recurly-api-retries',
+    })
+    recurlyRetryDelayMsValue = parseNonNegativeInt(recurlyRetryDelayMsRaw, {
+      defaultValue: DEFAULT_RECURLY_RETRY_DELAY_MS,
+      name: 'recurly-retry-delay-ms',
+    })
+    stripeRateLimitPerSecond = parseRateLimit(stripeRateLimitRaw, {
+      defaultValue: DEFAULT_STRIPE_RATE_LIMIT,
+      name: 'stripe-rate-limit',
+    })
+    stripeApiRetriesValue = parseNonNegativeInt(stripeApiRetriesRaw, {
+      defaultValue: DEFAULT_STRIPE_API_RETRIES,
+      name: 'stripe-api-retries',
+    })
+    stripeRetryDelayMsValue = parseNonNegativeInt(stripeRetryDelayMsRaw, {
+      defaultValue: DEFAULT_STRIPE_RETRY_DELAY_MS,
+      name: 'stripe-retry-delay-ms',
+    })
+    if (!comparisonDateRaw) {
+      throw new Error('--comparison-date is required')
+    }
+    const dateMatch = comparisonDateRaw.match(/^(\d{4})-(\d{2})-(\d{2})$/)
+    if (!dateMatch) {
+      throw new Error('--comparison-date must be in YYYY-MM-DD format')
+    }
+    const [, year, month, day] = dateMatch
+    comparisonDate = new Date(`${year}-${month}-${day}T00:00:00.000Z`)
+    if (isNaN(comparisonDate.getTime())) {
+      throw new Error('Invalid date provided for --comparison-date')
+    }
+  } catch (error) {
+    logError(error.message)
+    usage()
+    process.exit(1)
+  }
+
+  rateLimiters = createRateLimitedApiWrappers({
+    recurlyRateLimit,
+    recurlyApiRetries: recurlyApiRetriesValue,
+    recurlyRetryDelayMs: recurlyRetryDelayMsValue,
+    stripeRateLimit: stripeRateLimitPerSecond,
+    stripeApiRetries: stripeApiRetriesValue,
+    stripeRetryDelayMs: stripeRetryDelayMsValue,
+    logDebug,
+    logWarn,
+  })
+
+  DEBUG_MODE = !!verbose
+
+  if (help || !inputPath || !outputPath) {
+    usage()
+    process.exit(help ? 0 : 1)
+  }
+
+  const errorsPath = outputPath.replace(/\.csv$/, '_errors.csv')
+  const detailsJsonPath = outputPath.replace(/\.csv$/, '_details.json')
+
+  logDebug('Starting comparison', {
+    inputPath,
+    outputPath,
+    errorsPath,
+    detailsJsonPath,
+    comparisonDate: comparisonDate.toISOString(),
+    concurrency,
+    ...(limit != null ? { limit } : {}),
+  })
+  await trackProgress('Starting comparison')
+
+  // Output CSV columns
+  const outputColumns = [
+    'recurly_account_code',
+    'target_stripe_account',
+    'stripe_customer_id',
+    'status',
+    'diffs',
+  ]
+  const errorColumns = [
+    'recurly_account_code',
+    'target_stripe_account',
+    'stripe_customer_id',
+    'error',
+  ]
+
+  const outputStream = fs.createWriteStream(outputPath, { flags: 'w' })
+  outputStream.write(outputColumns.join(',') + '\n')
+
+  const errorsStream = fs.createWriteStream(errorsPath, { flags: 'w' })
+  errorsStream.write(errorColumns.join(',') + '\n')
+
+  const detailsWriter = createJsonArrayWriter(detailsJsonPath)
+
+  try {
+    let totalInInput = 0
+    let processedCount = 0
+    let matchCount = 0
+    let mismatchCount = 0
+    let errorCount = 0
+    let queuedCount = 0
+
+    const inputStream = fs.createReadStream(inputPath)
+    const parser = csv.parse({
+      columns: true,
+      trim: true,
+      bom: true,
+      skip_empty_lines: true,
+      relax_column_count: true,
+      relax_column_count_less: true,
+    })
+    inputStream.pipe(parser)
+
+    const queue = new PQueue({ concurrency })
+    const maxQueueSize = concurrency
+
+    let rowNumber = 0
+    let limitReached = false
+
+    try {
+      for await (const row of parser) {
+        rowNumber++
+        totalInInput++
+
+        const thisRowNumber = rowNumber
+
+        if (limit != null && queuedCount >= limit) {
+          limitReached = true
+          logDebug('Record limit reached', { limit, queuedCount })
+          break
+        }
+
+        if (queue.size >= maxQueueSize) {
+          await queue.onSizeLessThan(maxQueueSize)
+        }
+
+        queuedCount++
+        queue.add(async () => {
+          let result
+          try {
+            result = await compareCustomer(row, thisRowNumber, comparisonDate)
+          } catch (error) {
+            result = {
+              ...row,
+              status: 'error',
+              diffs: '',
+              error: error?.message || String(error),
+              diffDetails: null,
+            }
+            logError('Unhandled error', error, {
+              rowNumber: thisRowNumber,
+              accountCode: row.recurly_account_code,
+            })
+          }
+
+          processedCount++
+
+          if (result.status === 'match') {
+            matchCount++
+            outputStream.write(formatCsvRow(outputColumns, result))
+          } else if (result.status === 'mismatch') {
+            mismatchCount++
+            outputStream.write(formatCsvRow(outputColumns, result))
+            if (result.diffDetails) {
+              detailsWriter.write(result.diffDetails)
+            }
+          } else {
+            errorCount++
+            errorsStream.write(formatCsvRow(errorColumns, result))
+          }
+
+          const progressInterval = DEBUG_MODE ? 100 : 1000
+          if (processedCount % progressInterval === 0) {
+            logDebug('Progress', {
+              processedCount,
+              matchCount,
+              mismatchCount,
+              errorCount,
+            })
+            await trackProgress(
+              `Progress: ${processedCount} processed, ${matchCount} match, ${mismatchCount} mismatch, ${errorCount} errors`
+            )
+          }
+        })
+      }
+    } finally {
+      await queue.onIdle()
+    }
+
+    if (limitReached) {
+      await trackProgress(`Limit reached (${limit}).`)
+    }
+
+    // Final summary
+    const endTime = new Date()
+    const durationMs = endTime.getTime() - startTime.getTime()
+    const durationSeconds = Math.floor(durationMs / 1000)
+
+    const finalStats = rateLimiters.getRateLimiterStats()
+
+    await trackProgress('=== COMPARISON SUMMARY ===')
+    await trackProgress(`Total in input: ${totalInInput}`)
+    await trackProgress(`Processed: ${processedCount}`)
+    await trackProgress(`  Match: ${matchCount}`)
+    await trackProgress(`  Mismatch: ${mismatchCount}`)
+    await trackProgress(`  Error: ${errorCount}`)
+    await trackProgress(`Duration: ${durationSeconds}s`)
+    await trackProgress(`Output: ${outputPath}`)
+    await trackProgress(`Errors: ${errorsPath} (${errorCount} records)`)
+    await trackProgress(
+      `Details: ${detailsJsonPath} (${mismatchCount} records)`
+    )
+    await trackProgress(
+      `API calls - Recurly: ${finalStats.recurly.totalRequests}, Stripe: ${finalStats.stripe.totalRequests}`
+    )
+
+    logDebug('Comparison complete', {
+      totalInInput,
+      processedCount,
+      matchCount,
+      mismatchCount,
+      errorCount,
+    })
+
+    return errorCount === 0 && mismatchCount === 0 ? 0 : 1
+  } finally {
+    outputStream.end()
+    errorsStream.end()
+
+    const results = await Promise.allSettled([
+      new Promise((resolve, reject) => {
+        outputStream.on('finish', resolve)
+        outputStream.on('error', reject)
+      }),
+      new Promise((resolve, reject) => {
+        errorsStream.on('finish', resolve)
+        errorsStream.on('error', reject)
+      }),
+      detailsWriter.close(),
+    ])
+
+    for (const result of results) {
+      if (result.status === 'rejected') {
+        logWarn('Failed to close output stream', {
+          error: result.reason?.message || String(result.reason),
+        })
+      }
+    }
+  }
+}
+
+try {
+  const exitCode = await scriptRunner(main)
+  process.exit(exitCode ?? 0)
+} catch (error) {
+  logError('Script failed with unhandled error', error)
+  process.exit(1)
+}

+ 24 - 268
services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs

@@ -93,13 +93,17 @@ import { scriptRunner } from '../lib/ScriptRunner.mjs'
 import {
   areStripeAndRecurlyCardDetailsEqual,
   coalesceOrThrowPaymentMethod,
-  extractNameFromAccount,
-  extractNameFromBillingInfo,
   getTaxIdType,
-  normalisedGBVATNumber,
   normalizeRecurlyAddressToStripe,
   normalizeName,
+  addressesEqual,
+  resolveCustomerIdentity,
   sanitizeAccount,
+  normalizeComparableString,
+  hasAnyAddressValue,
+  ccEmailsToArray,
+  RECURLY_CUSTOM_FIELD_NAMES,
+  extractRecurlyCustomFieldMetadata,
 } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
 import {
   createRateLimitedApiWrappers,
@@ -182,66 +186,6 @@ if (!recurlyApiKey) {
 }
 const recurlyClient = new recurly.Client(recurlyApiKey)
 
-// =============================================================================
-// CUSTOM FIELDS
-// =============================================================================
-
-const RECURLY_CUSTOM_FIELD_NAMES = [
-  'channel',
-  'Industry',
-  'ol_sales_person',
-  'MigratedfromFreeAgent',
-]
-
-function getRecurlyCustomFields(account) {
-  const customFields = account?.customFields
-  if (customFields == null) {
-    throw new Error(
-      'Recurly account is missing customFields (empty array is acceptable)'
-    )
-  }
-  if (!Array.isArray(customFields)) {
-    throw new Error('Recurly account customFields is not an array')
-  }
-  return customFields
-}
-
-function extractRecurlyCustomFieldMetadata(account) {
-  const customFields = getRecurlyCustomFields(account)
-
-  /** @type {Record<string, string>} */
-  const metadata = {}
-
-  const counts = {
-    channel: 0,
-    Industry: 0,
-    ol_sales_person: 0,
-    MigratedfromFreeAgent: 0,
-    noCustomFields: 0,
-  }
-
-  if (customFields.length === 0) {
-    counts.noCustomFields = 1
-    return { metadata, counts }
-  }
-
-  for (const field of customFields) {
-    const name = field?.name?.trim()
-    if (!RECURLY_CUSTOM_FIELD_NAMES.includes(name)) continue
-
-    const rawValue = field?.value
-    if (rawValue == null) continue
-
-    const value = String(rawValue).trim()
-    if (!value) continue
-
-    metadata[name] = value
-    counts[name] = 1
-  }
-
-  return { metadata, counts }
-}
-
 // =============================================================================
 // LOGGING UTILITIES
 // =============================================================================
@@ -926,65 +870,8 @@ function isStripeTaxIdInvalidError(error) {
   return error.code === 'tax_id_invalid'
 }
 
-function normalizeComparableString(value) {
-  if (value == null) return ''
-  return String(value).trim()
-}
-
 const STRIPE_METADATA_MAX_ALT_EMAILS = 5
 
-function ccEmailsToArray(ccEmails) {
-  if (ccEmails == null || ccEmails === undefined) {
-    return []
-  }
-
-  // regex splits on commas, semicolons or whitespace and trims each email
-  // empty values are filtered out
-  const normalisedEmails = String(ccEmails)
-    .split(/[\s,;]+/)
-    .filter(Boolean)
-
-  const deDupedEmails = [...new Set(normalisedEmails)]
-
-  return deDupedEmails
-}
-
-function hasAnyAddressValue(address) {
-  if (!address || typeof address !== 'object') return false
-  return Object.values(address).some(v => normalizeComparableString(v) !== '')
-}
-
-function normalizeComparableAddress(address) {
-  if (!address || typeof address !== 'object') return null
-
-  const norm = {
-    line1: normalizeComparableString(address.line1),
-    line2: normalizeComparableString(address.line2),
-    city: normalizeComparableString(address.city),
-    state: normalizeComparableString(address.state),
-    postal_code: normalizeComparableString(address.postal_code),
-    country: normalizeComparableString(address.country).toUpperCase(),
-  }
-
-  return hasAnyAddressValue(norm) ? norm : null
-}
-
-function addressesEqual(a, b) {
-  const na = normalizeComparableAddress(a)
-  const nb = normalizeComparableAddress(b)
-  if (!na && !nb) return true
-  if (!na || !nb) return false
-
-  return (
-    na.line1 === nb.line1 &&
-    na.line2 === nb.line2 &&
-    na.city === nb.city &&
-    na.state === nb.state &&
-    na.postal_code === nb.postal_code &&
-    na.country === nb.country
-  )
-}
-
 // =============================================================================
 // MAIN PROCESSING
 // =============================================================================
@@ -1216,153 +1103,6 @@ async function updatePaymentMethodBillingDetails(
   )
 }
 
-/**
- * Resolve customer name, address, company, and VAT number from Recurly account data.
- *
- * For most customers, billing info and account info agree (or only one source is set),
- * and the standard coalesce logic applies (billing info preferred, account as fallback).
- *
- * When any field has conflicting values across both sources, the subscription's
- * collection_method is fetched to determine which source wins:
- *
- * - automatic (web sales): billing info is used for the Stripe customer record.
- * - manual (manual billing): account info is used for the Stripe customer record,
- *   and billing info is returned separately to be copied to the payment method's
- *   billing_details.
- *
- * @param {object} account - Recurly account object
- * @param {string} recurlyAccountCode - Account code (used for subscription lookup on conflict)
- * @param {object} context - Logging context
- * @returns {Promise<{
- *   name: string|null,
- *   address: import('stripe').Stripe.AddressParam|null,
- *   companyName: string|null,
- *   vatNumber: string|null,
- *   collectionMethod: string|null,
- *   billingInfoForPaymentMethod: object|null
- * }>}
- */
-async function resolveCustomerIdentity(account, recurlyAccountCode, context) {
-  // Detect conflicts between billing info and account fields
-  const billingName = extractNameFromBillingInfo(account)
-  const accountName = extractNameFromAccount(account)
-  const nameConflict =
-    billingName !== null && accountName !== null && billingName !== accountName
-
-  const billingAddress = normalizeRecurlyAddressToStripe(
-    account.billingInfo?.address
-  )
-  const accountAddress = normalizeRecurlyAddressToStripe(account?.address)
-  const addressConflict =
-    billingAddress !== null &&
-    accountAddress !== null &&
-    !addressesEqual(billingAddress, accountAddress)
-
-  const billingCompany = account.billingInfo?.company?.trim() || null
-  const accountCompany = account.company?.trim() || null
-  const companyConflict =
-    billingCompany !== null &&
-    accountCompany !== null &&
-    billingCompany !== accountCompany
-
-  const billingVat = account.billingInfo?.vatNumber?.trim() || null
-  const accountVat = account?.vatNumber?.trim() || null
-  const vatConflict =
-    billingVat !== null && accountVat !== null && billingVat !== accountVat
-
-  const hasConflict =
-    nameConflict || addressConflict || companyConflict || vatConflict
-
-  let name,
-    address,
-    companyName,
-    vatNumber,
-    collectionMethod,
-    billingInfoForPaymentMethod
-
-  if (!hasConflict) {
-    // No conflict: use the standard coalesce logic (billing info preferred)
-    name = billingName ?? accountName
-    address = billingAddress ?? accountAddress
-    companyName = billingCompany ?? accountCompany
-    vatNumber = billingVat ?? accountVat
-    collectionMethod = null
-    billingInfoForPaymentMethod = null
-  } else {
-    // Conflict detected: fetch the subscription to determine which source wins
-    logWarn(
-      'Conflict between billing info and account fields; fetching subscription collection method to resolve',
-      {
-        ...context,
-        nameConflict,
-        addressConflict,
-        companyConflict,
-        vatConflict,
-      }
-    )
-
-    const subscription = await fetchRecurlyActiveSubscription(
-      recurlyAccountCode,
-      context
-    )
-    collectionMethod = subscription?.collectionMethod || null
-
-    if (!collectionMethod) {
-      throw new Error(
-        'Conflict between billing info and account fields, but no live subscription found to determine collection method'
-      )
-    }
-
-    logDebug(
-      'Resolving billing info / account conflict using subscription collection method',
-      {
-        ...context,
-        collectionMethod,
-        nameConflict,
-        addressConflict,
-        companyConflict,
-        vatConflict,
-      },
-      { verboseOnly: true }
-    )
-
-    if (collectionMethod === 'automatic') {
-      // Web sales: use billing info for the Stripe customer record
-      name = billingName ?? accountName
-      address = billingAddress ?? accountAddress
-      companyName = billingCompany ?? accountCompany
-      vatNumber = billingVat ?? accountVat
-      billingInfoForPaymentMethod = null
-    } else if (collectionMethod === 'manual') {
-      // Manual billing: use account info for the Stripe customer record,
-      // and return billing info to be copied to the payment method's billing_details
-      name = accountName ?? billingName
-      address = accountAddress ?? billingAddress
-      companyName = accountCompany ?? billingCompany
-      vatNumber = accountVat ?? billingVat
-      billingInfoForPaymentMethod = account.billingInfo
-    } else {
-      throw new Error(
-        `Unknown collection method "${collectionMethod}" encountered while resolving billing info / account conflict`
-      )
-    }
-  }
-
-  // Normalise GB VAT numbers using the resolved address country
-  if (vatNumber && address?.country === 'GB') {
-    vatNumber = normalisedGBVATNumber(vatNumber)
-  }
-
-  return {
-    name,
-    address,
-    companyName,
-    vatNumber,
-    collectionMethod,
-    billingInfoForPaymentMethod,
-  }
-}
-
 /**
  * Process a single customer row from the input CSV.
  *
@@ -1485,7 +1225,23 @@ async function processCustomer(
       companyName,
       vatNumber,
       billingInfoForPaymentMethod,
-    } = await resolveCustomerIdentity(account, recurlyAccountCode, context)
+    } = await resolveCustomerIdentity(account, async () => {
+      logWarn(
+        'Conflict between billing info and account fields; fetching subscription collection method to resolve',
+        { ...context }
+      )
+      const subscription = await fetchRecurlyActiveSubscription(
+        recurlyAccountCode,
+        context
+      )
+      const cm = subscription?.collectionMethod || null
+      logDebug(
+        'Resolved collection method for conflict',
+        { ...context, collectionMethod: cm },
+        { verboseOnly: true }
+      )
+      return cm
+    })
 
     if (name === null && companyName === null) {
       // This should not happen since we're handling all the known cases in resolveCustomerIdentity but just in case

+ 165 - 21
services/web/scripts/stripe/finalize-stripe-subscription-migration.mjs

@@ -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)