[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
@@ -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
}
@@ -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')
})