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
This commit is contained in:
Simon Gardner
2026-02-25 18:06:03 +00:00
committed by Copybot
parent d8f9a643cd
commit fcac73e27f
3 changed files with 580 additions and 305 deletions

View File

@@ -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}` }
}
/**

View File

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

View File

@@ -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<Account>}
*/
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<string, string>} */
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