/* 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) })