Files
Verso/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs
T
Andrew Rumble 26b9d029f3 Merge pull request #33708 from overleaf/jlm-stripe-stage5-fixes
Stripe migration improvements from stage 5

GitOrigin-RevId: 897941bf1a51f8f1639489fcd3f542af671b7ac7
2026-05-28 08:07:04 +00:00

1149 lines
34 KiB
JavaScript

/* eslint-disable @overleaf/require-script-runner */
import test from 'node:test'
import assert from 'node:assert/strict'
/*
* This test can be run from the services/web directory with:
*
* node --test scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs
*
* It can be deleted once the Recurly to Stripe migration is complete.
*/
import {
coalesceOrEqualOrThrow,
coalesceOrThrowVATNumber,
extractNameFromAccount,
getCanadaTaxIdType,
getAustraliaTaxIdType,
getBrazilTaxIdType,
getBulgariaTaxIdType,
getCroatiaTaxIdType,
getGermanyTaxIdType,
getHungaryTaxIdType,
getJapanTaxIdType,
getLiechtensteinTaxIdType,
getMalaysiaTaxIdType,
getNorwayTaxIdType,
getPolandTaxIdType,
getRomaniaTaxIdType,
getRussiaTaxIdType,
getSingaporeTaxIdType,
getSloveniaTaxIdType,
getSpainTaxIdType,
getSwitzerlandTaxIdType,
getUkTaxIdType,
getUzbekistanTaxIdType,
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', () => {
const account = {
firstName: ' Alice ',
lastName: ' Example ',
}
assert.equal(extractNameFromAccount(account), 'Alice Example')
})
test('extractNameFromAccount falls back to firstName when lastName is missing', () => {
const account = {
firstName: 'Alice Example',
lastName: '',
}
assert.equal(extractNameFromAccount(account), 'Alice Example')
})
test('coalesceOrEqualOrThrow returns primary when set', () => {
assert.equal(coalesceOrEqualOrThrow('a', undefined, 'field'), 'a')
})
test('coalesceOrEqualOrThrow returns fallback when primary is unset', () => {
assert.equal(coalesceOrEqualOrThrow(undefined, 'b', 'field'), 'b')
})
test('coalesceOrEqualOrThrow returns value when both are set and equal', () => {
assert.equal(coalesceOrEqualOrThrow('same', 'same', 'field'), 'same')
})
test('coalesceOrEqualOrThrow throws when both are set but differ', () => {
assert.throws(
() => coalesceOrEqualOrThrow('a', 'b', 'field'),
/Primary and fallback values are both set but differ/
)
})
test('coalesceOrThrowVATNumber returns billingInfo VAT when set', () => {
const account = {
vatNumber: '',
billingInfo: { vatNumber: 'BILL456' },
}
assert.equal(coalesceOrThrowVATNumber(account), 'BILL456')
})
test('coalesceOrThrowVATNumber returns account VAT when billingInfo VAT unset', () => {
const account = {
vatNumber: 'ACCT123',
billingInfo: { vatNumber: '' },
}
assert.equal(coalesceOrThrowVATNumber(account), 'ACCT123')
})
test('coalesceOrThrowVATNumber returns null when neither is set', () => {
assert.equal(coalesceOrThrowVATNumber({}), null)
assert.equal(
coalesceOrThrowVATNumber({ vatNumber: '', billingInfo: { vatNumber: '' } }),
null
)
})
test('coalesceOrThrowVATNumber treats trimmed values as equal', () => {
const account = {
vatNumber: ' GB123 ',
billingInfo: { vatNumber: 'GB123' },
}
assert.equal(coalesceOrThrowVATNumber(account), 'GB123')
})
test('coalesceOrThrowVATNumber throws when both are set but differ', () => {
const account = {
vatNumber: 'GB123',
billingInfo: { vatNumber: 'DE999' },
}
assert.throws(
() => coalesceOrThrowVATNumber(account),
/Field vatNumber: Primary and fallback values are both set but differ/
)
})
test('getCanadaTaxIdType returns ca_gst_hst for GST/HST format', () => {
assert.equal(getCanadaTaxIdType('123456789RT0002', null), 'ca_gst_hst')
assert.equal(getCanadaTaxIdType(' 123456789rt0002 ', null), 'ca_gst_hst')
})
test('getCanadaTaxIdType returns ca_qst for Quebec QST format', () => {
assert.equal(getCanadaTaxIdType('1234567890TQ1234', null), 'ca_qst')
assert.equal(getCanadaTaxIdType('1234567890tq1234', null), 'ca_qst')
})
test('getCanadaTaxIdType returns PST types for documented provincial formats', () => {
assert.equal(getCanadaTaxIdType('PST-1234-5678', null), 'ca_pst_bc')
assert.equal(getCanadaTaxIdType('123456-7', 'R3C 4T3'), 'ca_pst_mb')
assert.equal(getCanadaTaxIdType('1234567', 'S7K 3J8'), 'ca_pst_sk')
})
test('getCanadaTaxIdType returns ca_bn for 9-digit BN format', () => {
// assert.equal(getCanadaTaxIdType('123456789', null), 'ca_bn')
assert.equal(getCanadaTaxIdType('123456789', null), null) // TODO: improve function get definitive ca_bn vs ca_gst_hst
})
test('getCanadaTaxIdType returns null when format is unknown/ambiguous', () => {
assert.equal(getCanadaTaxIdType('', null), null)
assert.equal(getCanadaTaxIdType(null, null), null)
assert.equal(getCanadaTaxIdType('RT0002', null), null)
assert.equal(getCanadaTaxIdType('PST12345678', null), null)
})
test('getAustraliaTaxIdType distinguishes ABN vs ARN', () => {
assert.equal(getAustraliaTaxIdType('12345678912'), 'au_abn')
assert.equal(getAustraliaTaxIdType('123456789123'), 'au_arn')
})
test('getBrazilTaxIdType distinguishes CNPJ vs CPF', () => {
assert.equal(getBrazilTaxIdType('01.234.456/5432-10'), 'br_cnpj')
assert.equal(getBrazilTaxIdType('123.456.789-87'), 'br_cpf')
})
test('getBulgariaTaxIdType distinguishes EU VAT vs UIC', () => {
assert.equal(getBulgariaTaxIdType('BG0123456789'), 'eu_vat')
assert.equal(getBulgariaTaxIdType('123456789'), 'bg_uic')
})
test('getCroatiaTaxIdType distinguishes EU VAT vs OIB', () => {
assert.equal(getCroatiaTaxIdType('HR12345678912'), 'eu_vat')
assert.equal(getCroatiaTaxIdType('12345678901'), 'hr_oib')
})
test('getGermanyTaxIdType distinguishes EU VAT vs Steuer Nummer', () => {
assert.equal(getGermanyTaxIdType('DE123456789'), 'eu_vat')
assert.equal(getGermanyTaxIdType('1234567890'), 'de_stn')
})
test('getHungaryTaxIdType distinguishes EU VAT vs HU tax number', () => {
assert.equal(getHungaryTaxIdType('HU12345678'), 'eu_vat')
assert.equal(getHungaryTaxIdType('12345678-1-23'), 'hu_tin')
})
test('getJapanTaxIdType distinguishes TRN, CN, RN', () => {
assert.equal(getJapanTaxIdType('T1234567891234'), 'jp_trn')
assert.equal(getJapanTaxIdType('1234567891234'), 'jp_cn')
assert.equal(getJapanTaxIdType('12345'), 'jp_rn')
})
test('getLiechtensteinTaxIdType distinguishes UID vs VAT', () => {
assert.equal(getLiechtensteinTaxIdType('CHE123456789'), 'li_uid')
assert.equal(getLiechtensteinTaxIdType('12345'), 'li_vat')
})
test('getMalaysiaTaxIdType distinguishes FRP, ITN, SST', () => {
assert.equal(getMalaysiaTaxIdType('12345678'), 'my_frp')
assert.equal(getMalaysiaTaxIdType('C 1234567890'), 'my_itn')
assert.equal(getMalaysiaTaxIdType('A12-3456-78912345'), 'my_sst')
})
test('getNorwayTaxIdType distinguishes VAT vs VOEC', () => {
assert.equal(getNorwayTaxIdType('123456789MVA'), 'no_vat')
assert.equal(getNorwayTaxIdType('1234567'), 'no_voec')
})
test('getPolandTaxIdType distinguishes EU VAT vs NIP', () => {
assert.equal(getPolandTaxIdType('PL1234567890'), 'eu_vat')
assert.equal(getPolandTaxIdType('1234567890'), 'pl_nip')
})
test('getRomaniaTaxIdType distinguishes EU VAT vs TIN', () => {
assert.equal(getRomaniaTaxIdType('RO1234567891'), 'eu_vat')
assert.equal(getRomaniaTaxIdType('1234567890123'), 'ro_tin')
})
test('getRussiaTaxIdType distinguishes INN vs KPP', () => {
assert.equal(getRussiaTaxIdType('1234567891'), 'ru_inn')
assert.equal(getRussiaTaxIdType('123456789'), 'ru_kpp')
})
test('getSingaporeTaxIdType distinguishes GST vs UEN', () => {
assert.equal(getSingaporeTaxIdType('M12345678X'), 'sg_gst')
assert.equal(getSingaporeTaxIdType('123456789F'), 'sg_uen')
})
test('getSloveniaTaxIdType distinguishes EU VAT vs TIN', () => {
assert.equal(getSloveniaTaxIdType('SI12345678'), 'eu_vat')
assert.equal(getSloveniaTaxIdType('12345678'), 'si_tin')
})
test('getSpainTaxIdType distinguishes EU VAT vs CIF', () => {
assert.equal(getSpainTaxIdType('ESA1234567Z'), 'eu_vat')
assert.equal(getSpainTaxIdType('A12345678'), 'es_cif')
})
test('getSwitzerlandTaxIdType distinguishes UID vs VAT', () => {
assert.equal(getSwitzerlandTaxIdType('CHE-123.456.789 HR'), 'ch_uid')
assert.equal(getSwitzerlandTaxIdType('CHE-123.456.789 MWST'), 'ch_vat')
})
test('getUkTaxIdType distinguishes GB VAT vs EU VAT (NI)', () => {
assert.equal(getUkTaxIdType('GB123456789'), 'gb_vat')
assert.equal(getUkTaxIdType('XI123456789'), 'eu_vat')
})
test('getUzbekistanTaxIdType distinguishes TIN vs VAT', () => {
assert.equal(getUzbekistanTaxIdType('123456789'), 'uz_tin')
assert.equal(getUzbekistanTaxIdType('123456789012'), 'uz_vat')
})
test('getTaxIdType handles EU OSS VAT and EU VAT defaults', () => {
assert.equal(getTaxIdType('EU', 'EU123456789').type, 'eu_oss_vat')
assert.equal(getTaxIdType('AT', 'ATU12345678').type, 'eu_vat')
})
test('getTaxIdType includes failure reason', () => {
const missingCountry = getTaxIdType(null, '12345', null)
assert.equal(missingCountry.type, null)
assert.equal(missingCountry.reason, 'missing country')
const invalidEuOss = getTaxIdType('EU', 'INVALID', null)
assert.equal(invalidEuOss.type, null)
assert.equal(invalidEuOss.reason, 'invalid EU OSS VAT number')
})
test('getTaxIdType pre-validation is opt-in', () => {
// Default behavior: no country-format pre-validation
const noPreValidation = getTaxIdType('US', 'NOT_A_VALID_EIN', null)
assert.equal(noPreValidation.type, 'us_ein')
assert.equal(noPreValidation.reason, null)
// Optional behavior: enable country-format pre-validation
const withPreValidation = getTaxIdType('US', 'NOT_A_VALID_EIN', null, true)
assert.equal(withPreValidation.type, null)
assert.equal(withPreValidation.reason, 'invalid tax ID format for country US')
})
test('getTaxIdType pre-validates EU VAT-only countries when enabled', () => {
const invalidWithPreValidation = getTaxIdType('AT', '12345678', null, true)
assert.equal(invalidWithPreValidation.type, null)
assert.equal(
invalidWithPreValidation.reason,
'invalid tax ID format for country AT'
)
const validWithPreValidation = getTaxIdType('AT', 'ATU12345678', null, true)
assert.equal(validWithPreValidation.type, 'eu_vat')
assert.equal(validWithPreValidation.reason, null)
})
test('coalesceOrThrowPaymentMethod throws when payment methods array is empty', () => {
assert.throws(
() => coalesceOrThrowPaymentMethod([], 'cus_123', {}),
/Stripe customer cus_123 has no usable payment method/
)
})
test('coalesceOrThrowPaymentMethod throws when no payment methods match billing info', () => {
const paymentMethods = [
{
id: 'pm_1',
card: { last4: '1234', exp_month: 12, exp_year: 2030 },
},
]
const billingInfo = {
paymentMethod: { lastFour: '5678', expMonth: 12, expYear: 2030 },
}
assert.throws(
() => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', billingInfo),
/Stripe customer cus_123 has no usable payment method/
)
})
test('coalesceOrThrowPaymentMethod returns matching payment method', () => {
const paymentMethod = {
id: 'pm_match',
type: 'card',
card: { last4: '1234', exp_month: 12, exp_year: 2030 },
}
const paymentMethods = [paymentMethod]
const billingInfo = {
paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
}
const result = coalesceOrThrowPaymentMethod(
paymentMethods,
'cus_123',
billingInfo
)
assert.equal(result, paymentMethod)
})
test('coalesceOrThrowPaymentMethod matches first of multiple matching methods', () => {
const paymentMethod1 = {
id: 'pm_1',
type: 'card',
card: { last4: '1234', exp_month: 12, exp_year: 2030 },
}
const paymentMethod2 = {
id: 'pm_2',
type: 'card',
card: { last4: '1234', exp_month: 12, exp_year: 2030 },
}
const paymentMethods = [paymentMethod1, paymentMethod2]
const billingInfo = {
paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
}
const result = coalesceOrThrowPaymentMethod(
paymentMethods,
'cus_123',
billingInfo
)
assert.equal(result, paymentMethod1)
})
test('coalesceOrThrowPaymentMethod filters out non-matching methods', () => {
const matchingMethod = {
id: 'pm_match',
type: 'card',
card: { last4: '1234', exp_month: 12, exp_year: 2030 },
}
const nonMatchingMethod = {
id: 'pm_no_match',
type: 'card',
card: { last4: '5678', exp_month: 12, exp_year: 2030 },
}
const paymentMethods = [nonMatchingMethod, matchingMethod]
const billingInfo = {
paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
}
const result = coalesceOrThrowPaymentMethod(
paymentMethods,
'cus_123',
billingInfo
)
assert.equal(result, matchingMethod)
})
test('coalesceOrThrowPaymentMethod handles null billingInfo', () => {
const paymentMethods = [
{
id: 'pm_1',
card: { last4: '1234', exp_month: 12, exp_year: 2030 },
},
]
assert.throws(
() => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', null),
/Stripe customer cus_123 has no usable payment method/
)
})
test('coalesceOrThrowPaymentMethod handles missing paymentMethod in billingInfo', () => {
const paymentMethods = [
{
id: 'pm_1',
card: { last4: '1234', exp_month: 12, exp_year: 2030 },
},
]
const billingInfo = {}
assert.throws(
() => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', billingInfo),
/Stripe customer cus_123 has no usable payment method/
)
})
test('coalesceOrThrowPaymentMethod handles missing card in payment method', () => {
const paymentMethods = [
{ id: 'pm_1' }, // no card property
{
id: 'pm_2',
type: 'card',
card: { last4: '1234', exp_month: 12, exp_year: 2030 },
},
]
const billingInfo = {
paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
}
const result = coalesceOrThrowPaymentMethod(
paymentMethods,
'cus_123',
billingInfo
)
assert.equal(result.id, 'pm_2')
})
test('normalisedGBVATNumber strips spaces and punctuation', () => {
assert.equal(normalisedGBVATNumber('123 456 789'), 'GB123456789')
assert.equal(normalisedGBVATNumber('123-456-789'), 'GB123456789')
assert.equal(normalisedGBVATNumber('123.456.789'), 'GB123456789')
assert.equal(normalisedGBVATNumber(' 123 456 789 '), 'GB123456789')
})
test('normalisedGBVATNumber prepends GB if not present', () => {
assert.equal(normalisedGBVATNumber('123456789'), 'GB123456789')
assert.equal(normalisedGBVATNumber('123456789012'), 'GB123456789012')
})
test('normalisedGBVATNumber does not prepend GB if already present', () => {
assert.equal(normalisedGBVATNumber('GB123456789'), 'GB123456789')
assert.equal(normalisedGBVATNumber('GB123456789012'), 'GB123456789012')
assert.equal(normalisedGBVATNumber('gb123456789'), 'GB123456789')
})
test('normalisedGBVATNumber removes trailing GB', () => {
assert.equal(normalisedGBVATNumber('123456789GB'), 'GB123456789')
assert.equal(normalisedGBVATNumber('GB123456789GB'), 'GB123456789')
})
test('normalisedGBVATNumber returns original if invalid after normalization', () => {
// Invalid length (8 digits instead of 9 or 12)
const invalid = '12345678'
assert.equal(normalisedGBVATNumber(invalid), invalid)
// Invalid length (10 digits instead of 9 or 12)
const invalid2 = '1234567890'
assert.equal(normalisedGBVATNumber(invalid2), invalid2)
// Invalid format with letters
const invalid3 = 'ABC123456'
assert.equal(normalisedGBVATNumber(invalid3), invalid3)
})
test('normalisedGBVATNumber handles null and undefined', () => {
assert.equal(normalisedGBVATNumber(null), null)
assert.equal(normalisedGBVATNumber(undefined), undefined)
assert.equal(normalisedGBVATNumber(''), '')
})
test('normalisedGBVATNumber handles valid 9-digit GB VAT numbers', () => {
assert.equal(normalisedGBVATNumber('123456789'), 'GB123456789')
assert.equal(normalisedGBVATNumber('GB123456789'), 'GB123456789')
assert.equal(normalisedGBVATNumber(' GB 123 456 789 '), 'GB123456789')
})
test('normalisedGBVATNumber handles valid 12-digit GB VAT numbers', () => {
assert.equal(normalisedGBVATNumber('123456789012'), 'GB123456789012')
assert.equal(normalisedGBVATNumber('GB123456789012'), 'GB123456789012')
assert.equal(normalisedGBVATNumber('GB 123 456 789 012'), 'GB123456789012')
})
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')
})
test('compareAccountFields reports bothPaypal diff when Recurly billingInfo.updatedAt is newer', async () => {
const recurlyUpdatedAt = new Date('2025-06-01T12:00:00Z')
const stripeCreatedEpoch = new Date('2025-05-01T12:00:00Z').getTime() / 1000
const account = {
email: 'user@example.com',
customFields: [],
billingInfo: {
updatedAt: recurlyUpdatedAt,
paymentMethod: { object: 'paypal_billing_agreement' },
},
address: { country: 'GB' },
}
const stripeCustomer = {
email: 'user@example.com',
metadata: {
recurlyAccountCode: '',
userId: 'user123',
taxInfoPending: '',
},
tax_exempt: 'none',
invoice_settings: {
default_payment_method: {
type: 'paypal',
created: stripeCreatedEpoch,
},
},
}
const diffs = await compareAccountFields({
account,
stripeCustomer,
overleafUserId: 'user123',
fetchCollectionMethod: async () => null,
stripePaymentMethods: [],
stripeServiceName: 'stripe-uk',
})
assert.ok(diffs.default_payment_method)
assert.equal(diffs.default_payment_method.bothPaypal, true)
assert.equal(diffs.default_payment_method.recurly.type, 'paypal')
assert.equal(
diffs.default_payment_method.recurly.updatedAt,
recurlyUpdatedAt.toISOString()
)
assert.equal(diffs.default_payment_method.stripe.type, 'paypal')
assert.equal(
diffs.default_payment_method.stripe.created,
new Date(stripeCreatedEpoch * 1000).toISOString()
)
})
test('compareAccountFields reports no diff when both are PayPal and Recurly is not newer', async () => {
const recurlyUpdatedAt = new Date('2025-04-01T12:00:00Z')
const stripeCreatedEpoch = new Date('2025-05-01T12:00:00Z').getTime() / 1000
const account = {
email: 'user@example.com',
customFields: [],
billingInfo: {
updatedAt: recurlyUpdatedAt,
paymentMethod: { object: 'paypal_billing_agreement' },
},
address: { country: 'GB' },
}
const stripeCustomer = {
email: 'user@example.com',
metadata: {
recurlyAccountCode: '',
userId: 'user123',
taxInfoPending: '',
},
tax_exempt: 'none',
invoice_settings: {
default_payment_method: {
type: 'paypal',
created: stripeCreatedEpoch,
},
},
}
const diffs = await compareAccountFields({
account,
stripeCustomer,
overleafUserId: 'user123',
fetchCollectionMethod: async () => null,
stripePaymentMethods: [],
stripeServiceName: 'stripe-uk',
})
assert.equal(diffs.default_payment_method, undefined)
})