Stripe migration improvements from stage 5 GitOrigin-RevId: 897941bf1a51f8f1639489fcd3f542af671b7ac7
1149 lines
34 KiB
JavaScript
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)
|
|
})
|