From fcac73e27fbfacc69d38746c42248cbd09cbbf79 Mon Sep 17 00:00:00 2001 From: Simon Gardner Date: Wed, 25 Feb 2026 18:06:03 +0000 Subject: [PATCH] Improvements to Recurly -> Stripe customer upsert script (#31539) * migrate Recurly ccEmails to Stripe additional_emails.cc * improve tax error reporting * remove redundant call to recurly.getBillingInfo GitOrigin-RevId: fa26fd5312d2b7ac5734fc78118ede6e1cfa17c8 --- ...te_recurly_customers_to_stripe.helpers.mjs | 429 ++++++++++++------ ..._customers_to_stripe.helpers.node.test.mjs | 232 +++++++--- .../migrate_recurly_customers_to_stripe.mjs | 224 +++++---- 3 files changed, 580 insertions(+), 305 deletions(-) diff --git a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs index de9e71a359..0bd1e20204 100644 --- a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs +++ b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs @@ -39,18 +39,22 @@ function normalizeName(firstName, lastName) { * - If both billingInfo and account have a complete (first+last) name and they differ, throw. * * @param {object} account - Recurly account object - * @param {object|null} billingInfo - Recurly billing info object * @returns {string|null} */ -export function coalesceOrEqualOrThrowName(account, billingInfo) { - const billingHasFullName = !!(billingInfo?.firstName && billingInfo?.lastName) - const accountHasFullName = !!(account?.firstName && account?.lastName) +export function coalesceOrEqualOrThrowName(account) { + const billingHasFullName = !!( + account.billingInfo?.firstName && account.billingInfo?.lastName + ) + const accountHasFullName = !!(account.firstName && account.lastName) const billingName = billingHasFullName - ? normalizeName(billingInfo.firstName, billingInfo.lastName) + ? normalizeName( + account.billingInfo?.firstName, + account.billingInfo?.lastName + ) : null const accountName = accountHasFullName - ? normalizeName(account?.firstName, account?.lastName) + ? normalizeName(account.firstName, account.lastName) : null if (billingHasFullName && accountHasFullName && billingName !== accountName) { @@ -71,11 +75,10 @@ export function coalesceOrEqualOrThrowName(account, billingInfo) { * - If both are set but differ, throw. * * @param {object} account - Recurly account object - * @param {object|null} billingInfo - Recurly billing info object * @returns {string|null} */ -export function coalesceOrThrowVATNumber(account, billingInfo) { - const billingVat = billingInfo?.vatNumber?.trim() || null +export function coalesceOrThrowVATNumber(account) { + const billingVat = account.billingInfo?.vatNumber?.trim() || null const accountVat = account?.vatNumber?.trim() || null return coalesceOrEqualOrThrow(billingVat, accountVat, 'vatNumber') } @@ -126,11 +129,12 @@ export function normalizeRecurlyAddressToStripe(address) { * Falls back to account address for manually-created accounts or legacy data. * * @param {object} account - Recurly account object - * @param {object|null} billingInfo - Recurly billing info object * @returns {import('stripe').Stripe.AddressParam|null} */ -export function coalesceOrEqualOrThrowAddress(account, billingInfo) { - const billingAddress = normalizeRecurlyAddressToStripe(billingInfo?.address) +export function coalesceOrEqualOrThrowAddress(account) { + const billingAddress = normalizeRecurlyAddressToStripe( + account.billingInfo?.address + ) const accountAddress = normalizeRecurlyAddressToStripe(account?.address) const isBillingAddressValid = !!billingAddress @@ -581,8 +585,8 @@ export function getRussiaTaxIdType(taxIdValue) { const digits = digitsOnly(taxIdValue) if (!digits) return null - // INN: 10 digits (example: 1234567891) - if (/^\d{10}$/.test(digits)) return 'ru_inn' + // INN: 10 or 12 digits (example: 1234567891) + if (/^(\d{10}|\d{12})$/.test(digits)) return 'ru_inn' // KPP: 9 digits (example: 123456789) if (/^\d{9}$/.test(digits)) return 'ru_kpp' @@ -674,11 +678,11 @@ export function getSwitzerlandTaxIdType(taxIdValue) { const alnum = normalized.replace(/[^A-Z0-9]/g, '') - // UID: CHE-123.456.789 HR - if (/^CHE\d{9}HR$/.test(alnum)) return 'ch_uid' - // VAT: CHE-123.456.789 MWST - if (/^CHE\d{9}MWST$/.test(alnum)) return 'ch_vat' + if (/^CHE\d{9}(MWST|TVA|IVA)$/.test(alnum)) return 'ch_vat' + + // UID: CHE-123.456.789 HR + if (/^CHE\d{9}(HR)?$/.test(alnum)) return 'ch_uid' return null } @@ -701,11 +705,46 @@ export function getUkTaxIdType(taxIdValue) { if (/^XI[A-Z0-9]+$/.test(normalized)) return 'eu_vat' // GB VAT numbers use GB prefix - if (/^GB[A-Z0-9]+$/.test(normalized)) return 'gb_vat' + if (/^GB(\d{9}|\d{12})$/.test(normalized)) return 'gb_vat' return null } +/** + * Normalize a GB VAT number to the standard format. + * + * @param {string|undefined|null} taxIdValue + * @returns {string|undefined|null} - Normalized GB VAT number if valid, otherwise original value + */ +export function normalisedGBVATNumber(taxIdValue) { + if (!taxIdValue) return taxIdValue + + // Strip spaces and punctuation + let normalized = String(taxIdValue) + .trim() + .toUpperCase() + .replace(/[\s\-.]/g, '') + + // Prepend GB if not already there (but don't prepend if it starts with XI for Northern Ireland) + if (!normalized.startsWith('GB') && !normalized.startsWith('XI')) { + normalized = 'GB' + normalized + } + + // Remove any trailing GB (but not if it's the only GB at the start) + if (normalized.endsWith('GB') && normalized.length > 2) { + normalized = normalized.slice(0, -2) + } + + // Check if it's a valid GB VAT number + const taxIdType = getUkTaxIdType(normalized) + if (taxIdType === 'gb_vat') { + return normalized + } + + // Return original value if not valid + return taxIdValue +} + /** * Determine the Stripe tax ID type for Uzbekistan (TIN vs VAT). * @@ -729,6 +768,107 @@ export function getUzbekistanTaxIdType(taxIdValue) { return null } +// we are now no longer pre-validating tax IDS based on country-specific formats before sending to Stripe. +// however leaving the regexes and examples here for reference and potential future use +const COUNTRY_TAX_ID_FORMATS = { + // Africa + AO: { taxIdType: 'ao_tin', regex: /^\d{10}$/ }, // Example: 5123456789 + BH: { taxIdType: 'bh_vat', regex: /^\d{15}$/ }, // Example: 123456789012345 + BF: { taxIdType: 'bf_ifu', regex: /^\d{8}[A-Z]$/ }, // Example: 12345678A + BJ: { taxIdType: 'bj_ifu', regex: /^\d{13}$/ }, // Example: 1234567890123 + CM: { taxIdType: 'cm_niu', regex: /^[A-Z]\d{12}[A-Z]$/ }, // Example: M123456789000L + CV: { taxIdType: 'cv_nif', regex: /^\d{9}$/ }, // Example: 213456789 + CD: { taxIdType: 'cd_nif', regex: /^[A-Z]\d{7}[A-Z]$/ }, // Example: A0123456M + EG: { taxIdType: 'eg_tin', regex: /^\d{9}$/ }, // Example: 123456789 + ET: { taxIdType: 'et_tin', regex: /^\d{10}$/ }, // Example: 1234567890 + GN: { taxIdType: 'gn_nif', regex: /^\d{9}$/ }, // Example: 123456789 + KE: { taxIdType: 'ke_pin', regex: /^[A-Z]\d{9}[A-Z]$/ }, // Example: P000111111A + MA: { taxIdType: 'ma_vat', regex: /^\d{8}$/ }, // Example: 12345678 + MR: { taxIdType: 'mr_nif', regex: /^\d{8}$/ }, // Example: 12345678 + NG: { taxIdType: 'ng_tin', regex: /^\d{8,14}$/ }, // Example: 12345678-0001 + SN: { taxIdType: 'sn_ninea', regex: /^\d{8}[A-Z]\d$/ }, // Example: 12345672A2 + TZ: { taxIdType: 'tz_vat', regex: /^\d{8}[A-Z]$/ }, // Example: 12345678A + UG: { taxIdType: 'ug_tin', regex: /^\d{10}$/ }, // Example: 1014751879 + ZA: { taxIdType: 'za_vat', regex: /^\d{10}$/ }, // Example: 4123456789 + ZM: { taxIdType: 'zm_tin', regex: /^\d{10}$/ }, // Example: 1004751879 + ZW: { taxIdType: 'zw_tin', regex: /^\d{10}$/ }, // Example: 1234567890 + + // Americas + AR: { taxIdType: 'ar_cuit', regex: /^\d{11}$/ }, // Example: 12-3456789-01 + BO: { taxIdType: 'bo_tin', regex: /^\d{9}$/ }, // Example: 123456789 + BS: { taxIdType: 'bs_tin', regex: /^\d{9}$/ }, // Example: 123.456.789 + BB: { taxIdType: 'bb_tin', regex: /^\d{13}$/ }, // Example: 1123456789012 + CL: { taxIdType: 'cl_tin', regex: /^\d{7,8}[0-9Kk]$/ }, // Example: 12.345.678-K + CO: { taxIdType: 'co_nit', regex: /^\d{10}$/ }, // Example: 123.456.789-0 + CR: { taxIdType: 'cr_tin', regex: /^\d{10}$/ }, // Example: 1-234-567890 + DO: { taxIdType: 'do_rcn', regex: /^\d{11}$/ }, // Example: 123-4567890-1 + EC: { taxIdType: 'ec_ruc', regex: /^\d{13}$/ }, // Example: 1234567890001 + // Accounts for Companies (12 chars). Individual entrepreneurs use 13 characters (4 letters at the start) + // Note: & is also a valid character in some Mexican company names + // and for companies, that first "alpha" section is derived directly from the legal name so may contain an ampersand. + MX: { taxIdType: 'mx_rfc', regex: /^[A-Z&]{3,4}\d{6}[A-Z0-9]{3}$/ }, // Example: ABC010203AB9 + PE: { taxIdType: 'pe_ruc', regex: /^\d{11}$/ }, // Example: 12345678901 + SR: { taxIdType: 'sr_fin', regex: /^\d{10}$/ }, // Example: 1234567890 + SV: { taxIdType: 'sv_nit', regex: /^\d{14}$/ }, // Example: 1234-567890-123-4 + US: { taxIdType: 'us_ein', regex: /^\d{9}$/ }, // Example: 12-3456789 + UY: { taxIdType: 'uy_ruc', regex: /^\d{12}$/ }, // Example: 123456789012 + VE: { taxIdType: 've_rif', regex: /^[JGVGE]\d{9}$/ }, // Example: A-12345678-9 + + // Asia-Pacific + BD: { taxIdType: 'bd_bin', regex: /^\d{13}$/ }, // Example: 123456789-0123 + // the USCC (China) strictly excludes the letters I, O, S, V, and Z to prevent optical character recognition (OCR) errors. + CN: { taxIdType: 'cn_tin', regex: /^[0-9A-HJ-NP-RTUW-Y]{18}$/ }, // Example: 12350000426600329N + HK: { taxIdType: 'hk_br', regex: /^\d{8}$/ }, // Example: 12345678 + ID: { taxIdType: 'id_npwp', regex: /^\d{15}$/ }, // Example: 012.345.678.9-012.345 + IN: { + taxIdType: 'in_gst', + // The 13th character is usually a number but can be a letter. The 15th character (Check digit) can be a letter or a number. + // The 14th char is reserved by the Indian govt. One day it might be something other than 'Z' but for now it's always 'Z'. + regex: /^\d{2}[A-Z]{5}\d{4}[A-Z][A-Z0-9][Z][A-Z0-9]$/, + }, // Example: 12ABCDE3456FGZH + KH: { taxIdType: 'kh_tin', regex: /^\d{13}$/ }, // Example: 1001-123456789 + KR: { taxIdType: 'kr_brn', regex: /^\d{10}$/ }, // Example: 123-45-67890 + KZ: { taxIdType: 'kz_bin', regex: /^\d{12}$/ }, // Example: 123456789012 + KG: { taxIdType: 'kg_tin', regex: /^\d{14}$/ }, // Example: 12345678901234 + LA: { taxIdType: 'la_tin', regex: /^\d{12}$/ }, // Example: 123456789-000 + NZ: { taxIdType: 'nz_gst', regex: /^\d{9}$/ }, // Example: 123456789 + NP: { taxIdType: 'np_pan', regex: /^\d{9}$/ }, // Example: 123456789 + PH: { taxIdType: 'ph_tin', regex: /^\d{12}$/ }, // Example: 123456789012 + TH: { taxIdType: 'th_vat', regex: /^\d{13}$/ }, // Example: 1234567891234 + TW: { taxIdType: 'tw_vat', regex: /^\d{8}$/ }, // Example: 12345678 + VN: { taxIdType: 'vn_tin', regex: /^\d{10}$/ }, // Example: 1234567890 + + // Europe (non-EU) + AD: { taxIdType: 'ad_nrt', regex: /^[A-Z]\d{6}[A-Z]$/ }, // Example: A-123456-Z + AL: { taxIdType: 'al_tin', regex: /^[A-Z]\d{8}[A-Z]$/ }, // Example: J12345678N + AM: { taxIdType: 'am_tin', regex: /^\d{8}$/ }, // Example: 02538904 + AW: { taxIdType: 'aw_tin', regex: /^\d{8}$/ }, // Example: 12345678 + AZ: { taxIdType: 'az_tin', regex: /^\d{10}$/ }, // Example: 0123456789 + BA: { taxIdType: 'ba_tin', regex: /^\d{12}$/ }, // Example: 123456789012 + BY: { taxIdType: 'by_tin', regex: /^\d{9}$/ }, // Example: 123456789 + // CH: { taxIdType: 'ch_vat', regex: /^CHE\d{9}MWST$/ }, // Example: CHE-123.456.789 MWST + GE: { taxIdType: 'ge_vat', regex: /^\d{9}$/ }, // Example: 123456789 + IS: { taxIdType: 'is_vat', regex: /^\d{6}$/ }, // Example: 123456 + LI: { taxIdType: 'li_uid', regex: /^CHE\d{9}$/ }, // Example: CHE123456789 + MD: { taxIdType: 'md_vat', regex: /^\d{7}$/ }, // Example: 1234567 + ME: { taxIdType: 'me_pib', regex: /^\d{8}$/ }, // Example: 12345678 + MK: { taxIdType: 'mk_vat', regex: /^MK\d{13}$/ }, // Example: MK1234567890123 + NO: { taxIdType: 'no_vat', regex: /^\d{9}MVA$/ }, // Example: 123456789MVA + RS: { taxIdType: 'rs_pib', regex: /^\d{9}$/ }, // Example: 123456789 + RU: { taxIdType: 'ru_inn', regex: /^\d{10}$/ }, // Example: 1234567891 + TR: { taxIdType: 'tr_tin', regex: /^\d{10}$/ }, // Example: 0123456789 + UA: { taxIdType: 'ua_vat', regex: /^\d{9}$/ }, // Example: 123456789 + + // Middle East + AE: { taxIdType: 'ae_trn', regex: /^\d{15}$/ }, // Example: 123456789012345 + IL: { taxIdType: 'il_vat', regex: /^\d{9}$/ }, // Example: 000012345 + OM: { taxIdType: 'om_vat', regex: /^OM\d{10}$/ }, // Example: OM1234567890 + SA: { taxIdType: 'sa_vat', regex: /^\d{15}$/ }, // Example: 123456789012345 + + // Other + EU: { taxIdType: 'eu_oss_vat', regex: /^EU\d+$/ }, // Example: EU123456789 +} + /** * Get the Stripe tax ID type for a given country + tax ID value. * @@ -738,19 +878,23 @@ export function getUzbekistanTaxIdType(taxIdValue) { * @param {string|undefined|null} country - ISO 3166-1 alpha-2 * @param {string|undefined|null} taxIdValue * @param {string|undefined|null} postalCode - used for Canada province inference - * @returns {string|null} - Stripe tax ID type, or null if country/type unsupported + * @returns {{type: string|null, reason: string|null}} - Stripe tax ID type + failure reason (when type is null) */ // TODO: this function is naive - we need more than just country to determine tax ID type // for example canada has multiple types (BN, QST, GST/HST) depending on province (inferred from postal code) export function getTaxIdType(country, taxIdValue, postalCode) { - if (!country) return null + if (!country) return { type: null, reason: 'missing country' } const upperCountry = String(country).toUpperCase() + const normalizedTaxId = normalizeTaxIdCompact(taxIdValue) + if (!normalizedTaxId) return { type: null, reason: 'missing tax ID value' } if (upperCountry === 'EU') { // European One Stop Shop VAT number for non-Union scheme const normalized = normalizeTaxIdCompact(taxIdValue) - return /^EU\d+$/.test(normalized) ? 'eu_oss_vat' : null + return /^EU\d+$/.test(normalized) + ? { type: 'eu_oss_vat', reason: null } + : { type: null, reason: 'invalid EU OSS VAT number' } } // EU VAT @@ -778,124 +922,139 @@ export function getTaxIdType(country, taxIdValue, postalCode) { 'SE', 'SK', ]) - if (euVatOnlyCountries.has(upperCountry)) return 'eu_vat' + if (euVatOnlyCountries.has(upperCountry)) { + return { type: 'eu_vat', reason: null } + } } // Multi-type countries - if (upperCountry === 'CA') return getCanadaTaxIdType(taxIdValue, postalCode) - if (upperCountry === 'AU') return getAustraliaTaxIdType(taxIdValue) - if (upperCountry === 'BR') return getBrazilTaxIdType(taxIdValue) - if (upperCountry === 'BG') return getBulgariaTaxIdType(taxIdValue) - if (upperCountry === 'HR') return getCroatiaTaxIdType(taxIdValue) - if (upperCountry === 'DE') return getGermanyTaxIdType(taxIdValue) - if (upperCountry === 'HU') return getHungaryTaxIdType(taxIdValue) - if (upperCountry === 'JP') return getJapanTaxIdType(taxIdValue) - if (upperCountry === 'LI') return getLiechtensteinTaxIdType(taxIdValue) - if (upperCountry === 'MY') return getMalaysiaTaxIdType(taxIdValue) - if (upperCountry === 'NO') return getNorwayTaxIdType(taxIdValue) - if (upperCountry === 'PL') return getPolandTaxIdType(taxIdValue) - if (upperCountry === 'RO') return getRomaniaTaxIdType(taxIdValue) - if (upperCountry === 'RU') return getRussiaTaxIdType(taxIdValue) - if (upperCountry === 'SG') return getSingaporeTaxIdType(taxIdValue) - if (upperCountry === 'SI') return getSloveniaTaxIdType(taxIdValue) - if (upperCountry === 'ES') return getSpainTaxIdType(taxIdValue) - if (upperCountry === 'CH') return getSwitzerlandTaxIdType(taxIdValue) - if (upperCountry === 'GB') return getUkTaxIdType(taxIdValue) - if (upperCountry === 'UZ') return getUzbekistanTaxIdType(taxIdValue) - - // Country-specific tax IDs (all Stripe-supported types) - // See: https://docs.stripe.com/billing/customer/tax-ids - const countryTaxIdTypes = { - // Africa - AO: 'ao_tin', - BH: 'bh_vat', - BF: 'bf_ifu', - BJ: 'bj_ifu', - CM: 'cm_niu', - CV: 'cv_nif', - CD: 'cd_nif', - EG: 'eg_tin', - ET: 'et_tin', - GN: 'gn_nif', - KE: 'ke_pin', - MA: 'ma_vat', - MR: 'mr_nif', - NG: 'ng_tin', - SN: 'sn_ninea', - TZ: 'tz_vat', - UG: 'ug_tin', - ZA: 'za_vat', - ZM: 'zm_tin', - ZW: 'zw_tin', - - // Americas - AR: 'ar_cuit', - BO: 'bo_tin', - BS: 'bs_tin', - BB: 'bb_tin', - CL: 'cl_tin', - CO: 'co_nit', - CR: 'cr_tin', - DO: 'do_rcn', - EC: 'ec_ruc', - MX: 'mx_rfc', - PE: 'pe_ruc', - SR: 'sr_fin', - SV: 'sv_nit', - US: 'us_ein', - UY: 'uy_ruc', - VE: 've_rif', - - // Asia-Pacific - BD: 'bd_bin', - CN: 'cn_tin', - HK: 'hk_br', - ID: 'id_npwp', - IN: 'in_gst', - KH: 'kh_tin', - KR: 'kr_brn', - KZ: 'kz_bin', - KG: 'kg_tin', - LA: 'la_tin', - NZ: 'nz_gst', - NP: 'np_pan', - PH: 'ph_tin', - TH: 'th_vat', - TW: 'tw_vat', - VN: 'vn_tin', - - // Europe (non-EU) - AD: 'ad_nrt', - AL: 'al_tin', - AM: 'am_tin', - AW: 'aw_tin', - AZ: 'az_tin', - BA: 'ba_tin', - BY: 'by_tin', - CH: 'ch_vat', - GE: 'ge_vat', - IS: 'is_vat', - LI: 'li_uid', - MD: 'md_vat', - ME: 'me_pib', - MK: 'mk_vat', - NO: 'no_vat', - RS: 'rs_pib', - RU: 'ru_inn', - TR: 'tr_tin', - UA: 'ua_vat', - - // Middle East - AE: 'ae_trn', - IL: 'il_vat', - OM: 'om_vat', - SA: 'sa_vat', - - // Other - EU: 'eu_oss_vat', + if (upperCountry === 'CA') { + const type = getCanadaTaxIdType(taxIdValue, postalCode) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country CA' } + } + if (upperCountry === 'AU') { + const type = getAustraliaTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country AU' } + } + if (upperCountry === 'BR') { + const type = getBrazilTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country BR' } + } + if (upperCountry === 'BG') { + const type = getBulgariaTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country BG' } + } + if (upperCountry === 'HR') { + const type = getCroatiaTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country HR' } + } + if (upperCountry === 'DE') { + const type = getGermanyTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country DE' } + } + if (upperCountry === 'HU') { + const type = getHungaryTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country HU' } + } + if (upperCountry === 'JP') { + const type = getJapanTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country JP' } + } + if (upperCountry === 'LI') { + const type = getLiechtensteinTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country LI' } + } + if (upperCountry === 'MY') { + const type = getMalaysiaTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country MY' } + } + if (upperCountry === 'NO') { + const type = getNorwayTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country NO' } + } + if (upperCountry === 'PL') { + const type = getPolandTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country PL' } + } + if (upperCountry === 'RO') { + const type = getRomaniaTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country RO' } + } + if (upperCountry === 'RU') { + const type = getRussiaTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country RU' } + } + if (upperCountry === 'SG') { + const type = getSingaporeTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country SG' } + } + if (upperCountry === 'SI') { + const type = getSloveniaTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country SI' } + } + if (upperCountry === 'ES') { + const type = getSpainTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country ES' } + } + if (upperCountry === 'CH') { + const type = getSwitzerlandTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country CH' } + } + if (upperCountry === 'GB') { + const type = getUkTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country GB' } + } + if (upperCountry === 'UZ') { + const type = getUzbekistanTaxIdType(taxIdValue) + return type + ? { type, reason: null } + : { type: null, reason: 'unrecognized tax ID format for country UZ' } } - return countryTaxIdTypes[upperCountry] || null + const countryFormat = COUNTRY_TAX_ID_FORMATS[upperCountry] + if (countryFormat) { + return { type: countryFormat.taxIdType, reason: null } + } + + return { type: null, reason: `unsupported country ${upperCountry}` } } /** diff --git a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs index a3bc4e0872..0e07ac2dd0 100644 --- a/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs +++ b/services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs @@ -37,6 +37,7 @@ import { getUzbekistanTaxIdType, getTaxIdType, coalesceOrThrowPaymentMethod, + normalisedGBVATNumber, } from './migrate_recurly_customers_to_stripe.helpers.mjs' test('coalesceOrEqualOrThrow returns primary when set', () => { @@ -59,20 +60,19 @@ test('coalesceOrEqualOrThrow throws when both are set but differ', () => { }) test('coalesceOrEqualOrThrowAddress returns null when neither is valid', () => { - assert.equal(coalesceOrEqualOrThrowAddress({}, null), null) + assert.equal(coalesceOrEqualOrThrowAddress({}), null) assert.equal( - coalesceOrEqualOrThrowAddress( - { address: { street1: '', postalCode: '', country: '' } }, - { address: { street1: '', postalCode: '', country: '' } } - ), + coalesceOrEqualOrThrowAddress({ + address: { street1: '', postalCode: '', country: '' }, + billingInfo: { address: { street1: '', postalCode: '', country: '' } }, + }), null ) assert.equal( - coalesceOrEqualOrThrowAddress( - { address: { street1: ' ', postalCode: ' ', country: ' ' } }, - null - ), + coalesceOrEqualOrThrowAddress({ + address: { street1: ' ', postalCode: ' ', country: ' ' }, + }), null ) }) @@ -80,11 +80,11 @@ test('coalesceOrEqualOrThrowAddress returns null when neither is valid', () => { test('coalesceOrEqualOrThrowAddress returns account when billing invalid', () => { const account = { address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' }, + billingInfo: { + address: { street1: '', postalCode: 'ABC', country: 'GB' }, + }, } - const billingInfo = { - address: { street1: '', postalCode: 'ABC', country: 'GB' }, - } - assert.deepEqual(coalesceOrEqualOrThrowAddress(account, billingInfo), { + assert.deepEqual(coalesceOrEqualOrThrowAddress(account), { line1: '1 Road', postal_code: 'ABC', country: 'GB', @@ -94,11 +94,11 @@ test('coalesceOrEqualOrThrowAddress returns account when billing invalid', () => test('coalesceOrEqualOrThrowAddress returns billing when account invalid', () => { const account = { address: { street1: '', postalCode: 'ABC', country: 'GB' }, + billingInfo: { + address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' }, + }, } - const billingInfo = { - address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' }, - } - assert.deepEqual(coalesceOrEqualOrThrowAddress(account, billingInfo), { + assert.deepEqual(coalesceOrEqualOrThrowAddress(account), { line1: '1 Road', postal_code: 'ABC', country: 'GB', @@ -108,24 +108,29 @@ test('coalesceOrEqualOrThrowAddress returns billing when account invalid', () => test('coalesceOrEqualOrThrowAddress returns billing when both valid+equal', () => { const addr = { street1: '1 Road', postalCode: 'ABC', country: 'GB' } assert.deepEqual( - coalesceOrEqualOrThrowAddress({ address: { ...addr } }, { address: addr }), + coalesceOrEqualOrThrowAddress({ + address: { ...addr }, + billingInfo: { address: addr }, + }), { line1: '1 Road', postal_code: 'ABC', country: 'GB' } ) }) test('coalesceOrEqualOrThrowAddress normalizes Recurly-style address fields', () => { - const billingInfo = { - address: { - street1: 'as', - street2: '', - city: '', - region: '', - postalCode: '12312', - country: 'AI', + const account = { + billingInfo: { + address: { + street1: 'as', + street2: '', + city: '', + region: '', + postalCode: '12312', + country: 'AI', + }, }, } - assert.deepEqual(coalesceOrEqualOrThrowAddress({}, billingInfo), { + assert.deepEqual(coalesceOrEqualOrThrowAddress(account), { line1: 'as', postal_code: '12312', country: 'AI', @@ -135,91 +140,102 @@ test('coalesceOrEqualOrThrowAddress normalizes Recurly-style address fields', () test('coalesceOrEqualOrThrowAddress throws when both valid but differ', () => { const account = { address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' }, - } - const billingInfo = { - address: { street1: '2 Road', postalCode: 'ABC', country: 'GB' }, + billingInfo: { + address: { street1: '2 Road', postalCode: 'ABC', country: 'GB' }, + }, } assert.throws( - () => coalesceOrEqualOrThrowAddress(account, billingInfo), + () => coalesceOrEqualOrThrowAddress(account), /Billing address and account address differ/ ) }) test('coalesceOrEqualOrThrowName returns billingInfo name when both sources match', () => { - const account = { firstName: 'Alice', lastName: 'Billing' } - const billingInfo = { firstName: 'Alice', lastName: 'Billing' } - assert.equal( - coalesceOrEqualOrThrowName(account, billingInfo), - 'Alice Billing' - ) + const account = { + firstName: 'Alice', + lastName: 'Billing', + billingInfo: { firstName: 'Alice', lastName: 'Billing' }, + } + assert.equal(coalesceOrEqualOrThrowName(account), 'Alice Billing') }) test('coalesceOrEqualOrThrowName prefers billingInfo when billingInfo is full but account is not', () => { - const account = { firstName: 'Alice', lastName: '' } - const billingInfo = { firstName: 'Alice', lastName: 'Billing' } - assert.equal( - coalesceOrEqualOrThrowName(account, billingInfo), - 'Alice Billing' - ) + const account = { + firstName: 'Alice', + lastName: '', + billingInfo: { firstName: 'Alice', lastName: 'Billing' }, + } + assert.equal(coalesceOrEqualOrThrowName(account), 'Alice Billing') }) test('coalesceOrEqualOrThrowName falls back to account when billingInfo missing last name', () => { - const account = { firstName: 'Alice', lastName: 'Account' } - const billingInfo = { firstName: 'Alice', lastName: '' } - assert.equal( - coalesceOrEqualOrThrowName(account, billingInfo), - 'Alice Account' - ) + const account = { + firstName: 'Alice', + lastName: 'Account', + billingInfo: { firstName: 'Alice', lastName: '' }, + } + assert.equal(coalesceOrEqualOrThrowName(account), 'Alice Account') }) test('coalesceOrEqualOrThrowName returns null when both sources are empty', () => { - assert.equal(coalesceOrEqualOrThrowName({}, null), null) + assert.equal(coalesceOrEqualOrThrowName({}), null) assert.equal( - coalesceOrEqualOrThrowName({ firstName: '', lastName: '' }, null), + coalesceOrEqualOrThrowName({ firstName: '', lastName: '' }), null ) }) test('coalesceOrEqualOrThrowName throws when both full names are present but differ', () => { - const account = { firstName: 'Alice', lastName: 'Account' } - const billingInfo = { firstName: 'Alice', lastName: 'Billing' } + const account = { + firstName: 'Alice', + lastName: 'Account', + billingInfo: { firstName: 'Alice', lastName: 'Billing' }, + } assert.throws( - () => coalesceOrEqualOrThrowName(account, billingInfo), + () => coalesceOrEqualOrThrowName(account), /Name differs between billingInfo and account/ ) }) test('coalesceOrThrowVATNumber returns billingInfo VAT when set', () => { - const account = { vatNumber: '' } - const billingInfo = { vatNumber: 'BILL456' } - assert.equal(coalesceOrThrowVATNumber(account, billingInfo), 'BILL456') + const account = { + vatNumber: '', + billingInfo: { vatNumber: 'BILL456' }, + } + assert.equal(coalesceOrThrowVATNumber(account), 'BILL456') }) test('coalesceOrThrowVATNumber returns account VAT when billingInfo VAT unset', () => { - const account = { vatNumber: 'ACCT123' } - const billingInfo = { vatNumber: '' } - assert.equal(coalesceOrThrowVATNumber(account, billingInfo), 'ACCT123') + const account = { + vatNumber: 'ACCT123', + billingInfo: { vatNumber: '' }, + } + assert.equal(coalesceOrThrowVATNumber(account), 'ACCT123') }) test('coalesceOrThrowVATNumber returns null when neither is set', () => { - assert.equal(coalesceOrThrowVATNumber({}, null), null) + assert.equal(coalesceOrThrowVATNumber({}), null) assert.equal( - coalesceOrThrowVATNumber({ vatNumber: '' }, { vatNumber: '' }), + coalesceOrThrowVATNumber({ vatNumber: '', billingInfo: { vatNumber: '' } }), null ) }) test('coalesceOrThrowVATNumber treats trimmed values as equal', () => { - const account = { vatNumber: ' GB123 ' } - const billingInfo = { vatNumber: 'GB123' } - assert.equal(coalesceOrThrowVATNumber(account, billingInfo), 'GB123') + 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' } - const billingInfo = { vatNumber: 'DE999' } + const account = { + vatNumber: 'GB123', + billingInfo: { vatNumber: 'DE999' }, + } assert.throws( - () => coalesceOrThrowVATNumber(account, billingInfo), + () => coalesceOrThrowVATNumber(account), /Field vatNumber: Primary and fallback values are both set but differ/ ) }) @@ -350,8 +366,18 @@ test('getUzbekistanTaxIdType distinguishes TIN vs VAT', () => { }) test('getTaxIdType handles EU OSS VAT and EU VAT defaults', () => { - assert.equal(getTaxIdType('EU', 'EU123456789'), 'eu_oss_vat') - assert.equal(getTaxIdType('AT', 'ATU12345678'), 'eu_vat') + 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('coalesceOrThrowPaymentMethod throws when payment methods array is empty', () => { @@ -380,6 +406,7 @@ test('coalesceOrThrowPaymentMethod throws when no payment methods match billing 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] @@ -397,10 +424,12 @@ test('coalesceOrThrowPaymentMethod returns matching payment method', () => { 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] @@ -418,10 +447,12 @@ test('coalesceOrThrowPaymentMethod matches first of multiple matching methods', 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] @@ -468,6 +499,7 @@ test('coalesceOrThrowPaymentMethod handles missing card in payment method', () = { id: 'pm_1' }, // no card property { id: 'pm_2', + type: 'card', card: { last4: '1234', exp_month: 12, exp_year: 2030 }, }, ] @@ -481,3 +513,63 @@ test('coalesceOrThrowPaymentMethod handles missing card in payment method', () = ) 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') +}) diff --git a/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs b/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs index ae87b00bc9..2e76cfa4f5 100644 --- a/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs +++ b/services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs @@ -97,8 +97,8 @@ import { coalesceOrThrowPaymentMethod, coalesceOrThrowVATNumber, getTaxIdType, + normalisedGBVATNumber, sanitizeAccount, - sanitizeBillingInfo, } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs' import { createRateLimitedApiWrappers, @@ -520,32 +520,14 @@ let rateLimiters * Fetch Recurly account data for a given account code. * * @param {string} accountCode - The Recurly account code (Overleaf user ID) - * @returns {Promise<{account: object, billingInfo: object|null}>} + * @returns {Promise} */ async function fetchRecurlyData(accountCode, context) { - const account = await rateLimiters.requestWithRetries( + return await rateLimiters.requestWithRetries( 'recurly', () => recurlyClient.getAccount(`code-${accountCode}`), context ) - - let billingInfo = null - try { - billingInfo = await rateLimiters.requestWithRetries( - 'recurly', - () => recurlyClient.getBillingInfo(`code-${accountCode}`), - context - ) - } catch (error) { - // Billing info may not exist for manually billed customers - if (error instanceof recurly.errors.NotFoundError) { - // This is expected for some customers - } else { - throw error - } - } - - return { account, billingInfo } } /** @@ -865,15 +847,24 @@ async function replaceCustomerTaxIds( } } - return await rateLimiters.requestWithRetries( - stripeClient.serviceName, - () => - stripeClient.customers.createTaxId(stripeCustomerId, { - type: taxIdType, - value: vatNumber, - }), - { ...context, stripeApi: 'customers.createTaxId' } - ) + try { + return await rateLimiters.requestWithRetries( + stripeClient.serviceName, + () => + stripeClient.customers.createTaxId(stripeCustomerId, { + type: taxIdType, + value: vatNumber, + }), + { ...context, stripeApi: 'customers.createTaxId' } + ) + } catch (error) { + const parts = [ + `Failed to create Stripe tax ID (type=${taxIdType}, value=${vatNumber})`, + ] + if (error.code) parts.push(`code=${error.code}`) + if (error.message) parts.push(error.message) + throw new Error(parts.join(': ')) + } } /** @@ -883,13 +874,12 @@ async function replaceCustomerTaxIds( * Falls back to account company for manually-created accounts or legacy data. * * @param {object} account - Recurly account object - * @param {object|null} billingInfo - Recurly billing info object * @returns {string|null} */ -function extractCompanyName(account, billingInfo) { +function extractCompanyName(account) { // Prefer billing info company (entered during checkout) - if (billingInfo?.company) { - return billingInfo.company + if (account.billingInfo?.company) { + return account.billingInfo.company } // Fall back to account-level company (legacy or manually set) if (account.company) { @@ -903,6 +893,24 @@ function normalizeComparableString(value) { return String(value).trim() } +const STRIPE_METADATA_MAX_ALT_EMAILS = 5 + +function ccEmailsToArray(ccEmails) { + if (ccEmails == null || ccEmails === undefined) { + return [] + } + + // regex splits on commas, semicolons or whitespace and trims each email + // empty values are filtered out + const normalisedEmails = String(ccEmails) + .split(/[\s,;]+/) + .filter(Boolean) + + const deDupedEmails = [...new Set(normalisedEmails)] + + return deDupedEmails +} + function hasAnyAddressValue(address) { if (!address || typeof address !== 'object') return false return Object.values(address).some(v => normalizeComparableString(v) !== '') @@ -1175,45 +1183,26 @@ async function processCustomer( { ...context, step: 'fetch_recurly' }, { verboseOnly: true } ) - const { account, billingInfo } = await fetchRecurlyData( - recurlyAccountCode, - context - ) + const account = await fetchRecurlyData(recurlyAccountCode, context) logDebug( 'Fetched Recurly account', { ...context, email: account.email, - hasBillingInfo: !!billingInfo, + hasBillingInfo: !!account.billingInfo, paymentMethod: - billingInfo?.paymentMethod?.object === 'paypal_billing_agreement' + account.billingInfo?.paymentMethod?.object === + 'paypal_billing_agreement' ? 'paypal' - : billingInfo?.cardType || 'none', + : account.billingInfo?.cardType || 'none', account: sanitizeAccount(account), - billingInfo: sanitizeBillingInfo(billingInfo), }, { verboseOnly: true } ) - // TODO: Handle tax exemption + CC emails in later phases of the migration. - const hasCcEmails = - typeof account.ccEmails === 'string' - ? !!account.ccEmails.trim() - : !!account.ccEmails - if (hasCcEmails) { - logDebug( - 'Found CC emails on Recurly account - aborting', - { - ...context, - ccEmails: account.ccEmails, - }, - { verboseOnly: true } - ) - throw new Error( - 'Customer has ccEmails set in Recurly, but this migration does not yet handle CC invoice emails' - ) - } + // TODO: Handle tax exemption in later phases of the migration. + if (account.exemptionCertificate) { logDebug( 'Found tax exemption certificate on Recurly account - aborting', @@ -1249,7 +1238,7 @@ async function processCustomer( } // Extract VAT number and country for tax ID creation - const vatNumber = coalesceOrThrowVATNumber(account, billingInfo) + let vatNumber = coalesceOrThrowVATNumber(account) let taxIdType = null let country = null let createdTaxId = null @@ -1258,44 +1247,56 @@ async function processCustomer( // Determine VAT number tax ID type (if possible) if (vatNumber) { // We need to extract address first to get the country - const tempAddress = coalesceOrEqualOrThrowAddress(account, billingInfo) + const tempAddress = coalesceOrEqualOrThrowAddress(account) country = tempAddress?.country + if (country === 'GB') { + vatNumber = normalisedGBVATNumber(vatNumber) + } + const taxIdTypeResult = getTaxIdType( + country, + vatNumber, + tempAddress?.postal_code + ) + taxIdType = taxIdTypeResult.type + const taxIdTypeFailureReason = taxIdTypeResult.reason + if (!country) { if (!forceInvalidTax) { - throw new Error(`Unprocessable VAT number ${vatNumber} (no country)`) + throw new Error( + `Unprocessable VAT number ${vatNumber} (no country): ${taxIdTypeFailureReason}` + ) } logWarn('VAT number present but no country in address', { ...context, vatNumber, + reason: taxIdTypeFailureReason, + }) + taxInfoPendingValue = vatNumber + } else if (!taxIdType) { + if (!forceInvalidTax) { + throw new Error( + `Unprocessable VAT number ${vatNumber} (failed getTaxIdType): ${taxIdTypeFailureReason}` + ) + } + logWarn('Unable to determine tax id type for VAT number', { + ...context, + vatNumber, + country, + postalCode: tempAddress?.postal_code, + reason: taxIdTypeFailureReason, }) taxInfoPendingValue = vatNumber } else { - taxIdType = getTaxIdType(country, vatNumber, tempAddress?.postal_code) - if (!taxIdType) { - if (!forceInvalidTax) { - throw new Error( - `Unprocessable VAT number ${vatNumber} (failed getTaxIdType)` - ) - } - logWarn('Unable to determine tax id type for VAT number', { + logDebug( + 'Will create tax ID', + { ...context, vatNumber, country, - postalCode: tempAddress?.postal_code, - }) - taxInfoPendingValue = vatNumber - } else { - logDebug( - 'Will create tax ID', - { - ...context, - vatNumber, - country, - taxIdType, - }, - { verboseOnly: true } - ) - } + taxIdType, + }, + { verboseOnly: true } + ) } } @@ -1344,14 +1345,14 @@ async function processCustomer( { verboseOnly: true } ) - const name = coalesceOrEqualOrThrowName(account, billingInfo) - const address = coalesceOrEqualOrThrowAddress(account, billingInfo) - const companyName = extractCompanyName(account, billingInfo) + const name = coalesceOrEqualOrThrowName(account) + const address = coalesceOrEqualOrThrowAddress(account) + const companyName = extractCompanyName(account) const paymentMethod = await getPaymentMethod( stripeClient, stripeCustomerId, - billingInfo, + account.billingInfo, address, commit, stripeContext @@ -1366,16 +1367,6 @@ async function processCustomer( // customerParams.tax_exempt = 'exempt' // } - // TODO: Handle CC emails - // Recurly account.ccEmails field contains additional notification emails. - // Stripe doesn't have a direct equivalent. - // Options: - // 1. Store in metadata (limited to 500 chars per value) - // 2. Handle via application logic outside of Stripe - // if (account.ccEmails) { - // customerParams.metadata.ccEmails = account.ccEmails - // } - /** @type {Record} */ const metadata = {} if (account.createdAt) { @@ -1411,6 +1402,31 @@ async function processCustomer( Object.assign(metadata, customFieldMetadata) } + const ccEmailList = ccEmailsToArray(account.ccEmails) + if (ccEmailList.length > STRIPE_METADATA_MAX_ALT_EMAILS) { + // this limit is arbitrary just to catch any extreme outliers + throw new Error( + `Customer has ${ccEmailList.length} ccEmails; max supported is ${STRIPE_METADATA_MAX_ALT_EMAILS}` + ) + } + ccEmailList.forEach(email => { + if (email.length > 500) { + // The limit for account.email is 512 characters. + // assuming similar for additional_emails.cc but 500 is plenty + // as the longest ccEmails in Recurly is 179 + throw new Error( + `Recurly ${recurlyAccountCode}: ccEmail ${email} exceeds the maximum length of 500 characters` + ) + } + }) + + // if there are any ccEmails in Recurly or Stripe, + // then overwrite additional_emails.cc below with the Recurly value preserving any other fields + // that might exist in additional_emails in Stripe + const updateCCEmails = + ccEmailList.length > 0 || + existingCustomer.additional_emails?.cc?.length > 0 + result.customFieldCounts = customFieldCounts /** @type {Stripe.CustomerUpdateParams} */ @@ -1423,6 +1439,14 @@ async function processCustomer( ...(paymentMethod ? { invoice_settings: { default_payment_method: paymentMethod.id } } : {}), + ...(updateCCEmails + ? { + additional_emails: { + ...existingCustomer.additional_emails, + cc: ccEmailList, + }, + } + : {}), } // If Stripe already has any of the fields we're about to set, and the value is