mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
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:
@@ -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}` }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user