Просмотр исходного кода

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
Simon Gardner 5 месяцев назад
Родитель
Сommit
fcac73e27f

+ 296 - 137
services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.mjs

@@ -39,18 +39,22 @@ function normalizeName(firstName, lastName) {
  * - If both billingInfo and account have a complete (first+last) name and they differ, throw.
  * - If both billingInfo and account have a complete (first+last) name and they differ, throw.
  *
  *
  * @param {object} account - Recurly account object
  * @param {object} account - Recurly account object
- * @param {object|null} billingInfo - Recurly billing info object
  * @returns {string|null}
  * @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
   const billingName = billingHasFullName
-    ? normalizeName(billingInfo.firstName, billingInfo.lastName)
+    ? normalizeName(
+        account.billingInfo?.firstName,
+        account.billingInfo?.lastName
+      )
     : null
     : null
   const accountName = accountHasFullName
   const accountName = accountHasFullName
-    ? normalizeName(account?.firstName, account?.lastName)
+    ? normalizeName(account.firstName, account.lastName)
     : null
     : null
 
 
   if (billingHasFullName && accountHasFullName && billingName !== accountName) {
   if (billingHasFullName && accountHasFullName && billingName !== accountName) {
@@ -71,11 +75,10 @@ export function coalesceOrEqualOrThrowName(account, billingInfo) {
  * - If both are set but differ, throw.
  * - If both are set but differ, throw.
  *
  *
  * @param {object} account - Recurly account object
  * @param {object} account - Recurly account object
- * @param {object|null} billingInfo - Recurly billing info object
  * @returns {string|null}
  * @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
   const accountVat = account?.vatNumber?.trim() || null
   return coalesceOrEqualOrThrow(billingVat, accountVat, 'vatNumber')
   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.
  * Falls back to account address for manually-created accounts or legacy data.
  *
  *
  * @param {object} account - Recurly account object
  * @param {object} account - Recurly account object
- * @param {object|null} billingInfo - Recurly billing info object
  * @returns {import('stripe').Stripe.AddressParam|null}
  * @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 accountAddress = normalizeRecurlyAddressToStripe(account?.address)
 
 
   const isBillingAddressValid = !!billingAddress
   const isBillingAddressValid = !!billingAddress
@@ -581,8 +585,8 @@ export function getRussiaTaxIdType(taxIdValue) {
   const digits = digitsOnly(taxIdValue)
   const digits = digitsOnly(taxIdValue)
   if (!digits) return null
   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)
   // KPP: 9 digits (example: 123456789)
   if (/^\d{9}$/.test(digits)) return 'ru_kpp'
   if (/^\d{9}$/.test(digits)) return 'ru_kpp'
@@ -674,11 +678,11 @@ export function getSwitzerlandTaxIdType(taxIdValue) {
 
 
   const alnum = normalized.replace(/[^A-Z0-9]/g, '')
   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
   // 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
   return null
 }
 }
@@ -701,11 +705,46 @@ export function getUkTaxIdType(taxIdValue) {
   if (/^XI[A-Z0-9]+$/.test(normalized)) return 'eu_vat'
   if (/^XI[A-Z0-9]+$/.test(normalized)) return 'eu_vat'
 
 
   // GB VAT numbers use GB prefix
   // 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
   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).
  * Determine the Stripe tax ID type for Uzbekistan (TIN vs VAT).
  *
  *
@@ -729,6 +768,107 @@ export function getUzbekistanTaxIdType(taxIdValue) {
   return null
   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.
  * 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} country - ISO 3166-1 alpha-2
  * @param {string|undefined|null} taxIdValue
  * @param {string|undefined|null} taxIdValue
  * @param {string|undefined|null} postalCode - used for Canada province inference
  * @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
 // 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)
 // for example canada has multiple types (BN, QST, GST/HST) depending on province (inferred from postal code)
 export function getTaxIdType(country, taxIdValue, postalCode) {
 export function getTaxIdType(country, taxIdValue, postalCode) {
-  if (!country) return null
+  if (!country) return { type: null, reason: 'missing country' }
 
 
   const upperCountry = String(country).toUpperCase()
   const upperCountry = String(country).toUpperCase()
+  const normalizedTaxId = normalizeTaxIdCompact(taxIdValue)
+  if (!normalizedTaxId) return { type: null, reason: 'missing tax ID value' }
 
 
   if (upperCountry === 'EU') {
   if (upperCountry === 'EU') {
     // European One Stop Shop VAT number for non-Union scheme
     // European One Stop Shop VAT number for non-Union scheme
     const normalized = normalizeTaxIdCompact(taxIdValue)
     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
   // EU VAT
@@ -778,124 +922,139 @@ export function getTaxIdType(country, taxIdValue, postalCode) {
       'SE',
       'SE',
       'SK',
       'SK',
     ])
     ])
-    if (euVatOnlyCountries.has(upperCountry)) return 'eu_vat'
+    if (euVatOnlyCountries.has(upperCountry)) {
+      return { type: 'eu_vat', reason: null }
+    }
   }
   }
 
 
   // Multi-type countries
   // 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',
-  }
-
-  return countryTaxIdTypes[upperCountry] || null
+  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' }
+  }
+
+  const countryFormat = COUNTRY_TAX_ID_FORMATS[upperCountry]
+  if (countryFormat) {
+    return { type: countryFormat.taxIdType, reason: null }
+  }
+
+  return { type: null, reason: `unsupported country ${upperCountry}` }
 }
 }
 
 
 /**
 /**

+ 162 - 70
services/web/scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs

@@ -37,6 +37,7 @@ import {
   getUzbekistanTaxIdType,
   getUzbekistanTaxIdType,
   getTaxIdType,
   getTaxIdType,
   coalesceOrThrowPaymentMethod,
   coalesceOrThrowPaymentMethod,
+  normalisedGBVATNumber,
 } from './migrate_recurly_customers_to_stripe.helpers.mjs'
 } from './migrate_recurly_customers_to_stripe.helpers.mjs'
 
 
 test('coalesceOrEqualOrThrow returns primary when set', () => {
 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', () => {
 test('coalesceOrEqualOrThrowAddress returns null when neither is valid', () => {
-  assert.equal(coalesceOrEqualOrThrowAddress({}, null), null)
+  assert.equal(coalesceOrEqualOrThrowAddress({}), null)
   assert.equal(
   assert.equal(
-    coalesceOrEqualOrThrowAddress(
-      { address: { street1: '', postalCode: '', country: '' } },
-      { address: { street1: '', postalCode: '', country: '' } }
-    ),
+    coalesceOrEqualOrThrowAddress({
+      address: { street1: '', postalCode: '', country: '' },
+      billingInfo: { address: { street1: '', postalCode: '', country: '' } },
+    }),
     null
     null
   )
   )
 
 
   assert.equal(
   assert.equal(
-    coalesceOrEqualOrThrowAddress(
-      { address: { street1: '   ', postalCode: '  ', country: '  ' } },
-      null
-    ),
+    coalesceOrEqualOrThrowAddress({
+      address: { street1: '   ', postalCode: '  ', country: '  ' },
+    }),
     null
     null
   )
   )
 })
 })
@@ -80,11 +80,11 @@ test('coalesceOrEqualOrThrowAddress returns null when neither is valid', () => {
 test('coalesceOrEqualOrThrowAddress returns account when billing invalid', () => {
 test('coalesceOrEqualOrThrowAddress returns account when billing invalid', () => {
   const account = {
   const account = {
     address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' },
     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',
     line1: '1 Road',
     postal_code: 'ABC',
     postal_code: 'ABC',
     country: 'GB',
     country: 'GB',
@@ -94,11 +94,11 @@ test('coalesceOrEqualOrThrowAddress returns account when billing invalid', () =>
 test('coalesceOrEqualOrThrowAddress returns billing when account invalid', () => {
 test('coalesceOrEqualOrThrowAddress returns billing when account invalid', () => {
   const account = {
   const account = {
     address: { street1: '', postalCode: 'ABC', country: 'GB' },
     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',
     line1: '1 Road',
     postal_code: 'ABC',
     postal_code: 'ABC',
     country: 'GB',
     country: 'GB',
@@ -108,24 +108,29 @@ test('coalesceOrEqualOrThrowAddress returns billing when account invalid', () =>
 test('coalesceOrEqualOrThrowAddress returns billing when both valid+equal', () => {
 test('coalesceOrEqualOrThrowAddress returns billing when both valid+equal', () => {
   const addr = { street1: '1 Road', postalCode: 'ABC', country: 'GB' }
   const addr = { street1: '1 Road', postalCode: 'ABC', country: 'GB' }
   assert.deepEqual(
   assert.deepEqual(
-    coalesceOrEqualOrThrowAddress({ address: { ...addr } }, { address: addr }),
+    coalesceOrEqualOrThrowAddress({
+      address: { ...addr },
+      billingInfo: { address: addr },
+    }),
     { line1: '1 Road', postal_code: 'ABC', country: 'GB' }
     { line1: '1 Road', postal_code: 'ABC', country: 'GB' }
   )
   )
 })
 })
 
 
 test('coalesceOrEqualOrThrowAddress normalizes Recurly-style address fields', () => {
 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',
     line1: 'as',
     postal_code: '12312',
     postal_code: '12312',
     country: 'AI',
     country: 'AI',
@@ -135,91 +140,102 @@ test('coalesceOrEqualOrThrowAddress normalizes Recurly-style address fields', ()
 test('coalesceOrEqualOrThrowAddress throws when both valid but differ', () => {
 test('coalesceOrEqualOrThrowAddress throws when both valid but differ', () => {
   const account = {
   const account = {
     address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' },
     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(
   assert.throws(
-    () => coalesceOrEqualOrThrowAddress(account, billingInfo),
+    () => coalesceOrEqualOrThrowAddress(account),
     /Billing address and account address differ/
     /Billing address and account address differ/
   )
   )
 })
 })
 
 
 test('coalesceOrEqualOrThrowName returns billingInfo name when both sources match', () => {
 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', () => {
 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', () => {
 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', () => {
 test('coalesceOrEqualOrThrowName returns null when both sources are empty', () => {
-  assert.equal(coalesceOrEqualOrThrowName({}, null), null)
+  assert.equal(coalesceOrEqualOrThrowName({}), null)
   assert.equal(
   assert.equal(
-    coalesceOrEqualOrThrowName({ firstName: '', lastName: '' }, null),
+    coalesceOrEqualOrThrowName({ firstName: '', lastName: '' }),
     null
     null
   )
   )
 })
 })
 
 
 test('coalesceOrEqualOrThrowName throws when both full names are present but differ', () => {
 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(
   assert.throws(
-    () => coalesceOrEqualOrThrowName(account, billingInfo),
+    () => coalesceOrEqualOrThrowName(account),
     /Name differs between billingInfo and account/
     /Name differs between billingInfo and account/
   )
   )
 })
 })
 
 
 test('coalesceOrThrowVATNumber returns billingInfo VAT when set', () => {
 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', () => {
 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', () => {
 test('coalesceOrThrowVATNumber returns null when neither is set', () => {
-  assert.equal(coalesceOrThrowVATNumber({}, null), null)
+  assert.equal(coalesceOrThrowVATNumber({}), null)
   assert.equal(
   assert.equal(
-    coalesceOrThrowVATNumber({ vatNumber: '' }, { vatNumber: '' }),
+    coalesceOrThrowVATNumber({ vatNumber: '', billingInfo: { vatNumber: '' } }),
     null
     null
   )
   )
 })
 })
 
 
 test('coalesceOrThrowVATNumber treats trimmed values as equal', () => {
 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', () => {
 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(
   assert.throws(
-    () => coalesceOrThrowVATNumber(account, billingInfo),
+    () => coalesceOrThrowVATNumber(account),
     /Field vatNumber: Primary and fallback values are both set but differ/
     /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', () => {
 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', () => {
 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', () => {
 test('coalesceOrThrowPaymentMethod returns matching payment method', () => {
   const paymentMethod = {
   const paymentMethod = {
     id: 'pm_match',
     id: 'pm_match',
+    type: 'card',
     card: { last4: '1234', exp_month: 12, exp_year: 2030 },
     card: { last4: '1234', exp_month: 12, exp_year: 2030 },
   }
   }
   const paymentMethods = [paymentMethod]
   const paymentMethods = [paymentMethod]
@@ -397,10 +424,12 @@ test('coalesceOrThrowPaymentMethod returns matching payment method', () => {
 test('coalesceOrThrowPaymentMethod matches first of multiple matching methods', () => {
 test('coalesceOrThrowPaymentMethod matches first of multiple matching methods', () => {
   const paymentMethod1 = {
   const paymentMethod1 = {
     id: 'pm_1',
     id: 'pm_1',
+    type: 'card',
     card: { last4: '1234', exp_month: 12, exp_year: 2030 },
     card: { last4: '1234', exp_month: 12, exp_year: 2030 },
   }
   }
   const paymentMethod2 = {
   const paymentMethod2 = {
     id: 'pm_2',
     id: 'pm_2',
+    type: 'card',
     card: { last4: '1234', exp_month: 12, exp_year: 2030 },
     card: { last4: '1234', exp_month: 12, exp_year: 2030 },
   }
   }
   const paymentMethods = [paymentMethod1, paymentMethod2]
   const paymentMethods = [paymentMethod1, paymentMethod2]
@@ -418,10 +447,12 @@ test('coalesceOrThrowPaymentMethod matches first of multiple matching methods',
 test('coalesceOrThrowPaymentMethod filters out non-matching methods', () => {
 test('coalesceOrThrowPaymentMethod filters out non-matching methods', () => {
   const matchingMethod = {
   const matchingMethod = {
     id: 'pm_match',
     id: 'pm_match',
+    type: 'card',
     card: { last4: '1234', exp_month: 12, exp_year: 2030 },
     card: { last4: '1234', exp_month: 12, exp_year: 2030 },
   }
   }
   const nonMatchingMethod = {
   const nonMatchingMethod = {
     id: 'pm_no_match',
     id: 'pm_no_match',
+    type: 'card',
     card: { last4: '5678', exp_month: 12, exp_year: 2030 },
     card: { last4: '5678', exp_month: 12, exp_year: 2030 },
   }
   }
   const paymentMethods = [nonMatchingMethod, matchingMethod]
   const paymentMethods = [nonMatchingMethod, matchingMethod]
@@ -468,6 +499,7 @@ test('coalesceOrThrowPaymentMethod handles missing card in payment method', () =
     { id: 'pm_1' }, // no card property
     { id: 'pm_1' }, // no card property
     {
     {
       id: 'pm_2',
       id: 'pm_2',
+      type: 'card',
       card: { last4: '1234', exp_month: 12, exp_year: 2030 },
       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')
   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')
+})

+ 124 - 100
services/web/scripts/recurly/migrate_recurly_customers_to_stripe.mjs

@@ -97,8 +97,8 @@ import {
   coalesceOrThrowPaymentMethod,
   coalesceOrThrowPaymentMethod,
   coalesceOrThrowVATNumber,
   coalesceOrThrowVATNumber,
   getTaxIdType,
   getTaxIdType,
+  normalisedGBVATNumber,
   sanitizeAccount,
   sanitizeAccount,
-  sanitizeBillingInfo,
 } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
 } from '../helpers/migrate_recurly_customers_to_stripe.helpers.mjs'
 import {
 import {
   createRateLimitedApiWrappers,
   createRateLimitedApiWrappers,
@@ -520,32 +520,14 @@ let rateLimiters
  * Fetch Recurly account data for a given account code.
  * Fetch Recurly account data for a given account code.
  *
  *
  * @param {string} accountCode - The Recurly account code (Overleaf user ID)
  * @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) {
 async function fetchRecurlyData(accountCode, context) {
-  const account = await rateLimiters.requestWithRetries(
+  return await rateLimiters.requestWithRetries(
     'recurly',
     'recurly',
     () => recurlyClient.getAccount(`code-${accountCode}`),
     () => recurlyClient.getAccount(`code-${accountCode}`),
     context
     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.
  * Falls back to account company for manually-created accounts or legacy data.
  *
  *
  * @param {object} account - Recurly account object
  * @param {object} account - Recurly account object
- * @param {object|null} billingInfo - Recurly billing info object
  * @returns {string|null}
  * @returns {string|null}
  */
  */
-function extractCompanyName(account, billingInfo) {
+function extractCompanyName(account) {
   // Prefer billing info company (entered during checkout)
   // 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)
   // Fall back to account-level company (legacy or manually set)
   if (account.company) {
   if (account.company) {
@@ -903,6 +893,24 @@ function normalizeComparableString(value) {
   return String(value).trim()
   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) {
 function hasAnyAddressValue(address) {
   if (!address || typeof address !== 'object') return false
   if (!address || typeof address !== 'object') return false
   return Object.values(address).some(v => normalizeComparableString(v) !== '')
   return Object.values(address).some(v => normalizeComparableString(v) !== '')
@@ -1175,45 +1183,26 @@ async function processCustomer(
       { ...context, step: 'fetch_recurly' },
       { ...context, step: 'fetch_recurly' },
       { verboseOnly: true }
       { verboseOnly: true }
     )
     )
-    const { account, billingInfo } = await fetchRecurlyData(
-      recurlyAccountCode,
-      context
-    )
+    const account = await fetchRecurlyData(recurlyAccountCode, context)
 
 
     logDebug(
     logDebug(
       'Fetched Recurly account',
       'Fetched Recurly account',
       {
       {
         ...context,
         ...context,
         email: account.email,
         email: account.email,
-        hasBillingInfo: !!billingInfo,
+        hasBillingInfo: !!account.billingInfo,
         paymentMethod:
         paymentMethod:
-          billingInfo?.paymentMethod?.object === 'paypal_billing_agreement'
+          account.billingInfo?.paymentMethod?.object ===
+          'paypal_billing_agreement'
             ? 'paypal'
             ? 'paypal'
-            : billingInfo?.cardType || 'none',
+            : account.billingInfo?.cardType || 'none',
         account: sanitizeAccount(account),
         account: sanitizeAccount(account),
-        billingInfo: sanitizeBillingInfo(billingInfo),
       },
       },
       { verboseOnly: true }
       { 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) {
     if (account.exemptionCertificate) {
       logDebug(
       logDebug(
         'Found tax exemption certificate on Recurly account - aborting',
         'Found tax exemption certificate on Recurly account - aborting',
@@ -1249,7 +1238,7 @@ async function processCustomer(
     }
     }
 
 
     // Extract VAT number and country for tax ID creation
     // Extract VAT number and country for tax ID creation
-    const vatNumber = coalesceOrThrowVATNumber(account, billingInfo)
+    let vatNumber = coalesceOrThrowVATNumber(account)
     let taxIdType = null
     let taxIdType = null
     let country = null
     let country = null
     let createdTaxId = null
     let createdTaxId = null
@@ -1258,44 +1247,56 @@ async function processCustomer(
     // Determine VAT number tax ID type (if possible)
     // Determine VAT number tax ID type (if possible)
     if (vatNumber) {
     if (vatNumber) {
       // We need to extract address first to get the country
       // We need to extract address first to get the country
-      const tempAddress = coalesceOrEqualOrThrowAddress(account, billingInfo)
+      const tempAddress = coalesceOrEqualOrThrowAddress(account)
       country = tempAddress?.country
       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 (!country) {
         if (!forceInvalidTax) {
         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', {
         logWarn('VAT number present but no country in address', {
           ...context,
           ...context,
           vatNumber,
           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
         taxInfoPendingValue = vatNumber
       } else {
       } 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,
             ...context,
             vatNumber,
             vatNumber,
             country,
             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 }
       { 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(
     const paymentMethod = await getPaymentMethod(
       stripeClient,
       stripeClient,
       stripeCustomerId,
       stripeCustomerId,
-      billingInfo,
+      account.billingInfo,
       address,
       address,
       commit,
       commit,
       stripeContext
       stripeContext
@@ -1366,16 +1367,6 @@ async function processCustomer(
     //   customerParams.tax_exempt = 'exempt'
     //   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>} */
     /** @type {Record<string, string>} */
     const metadata = {}
     const metadata = {}
     if (account.createdAt) {
     if (account.createdAt) {
@@ -1411,6 +1402,31 @@ async function processCustomer(
       Object.assign(metadata, customFieldMetadata)
       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
     result.customFieldCounts = customFieldCounts
 
 
     /** @type {Stripe.CustomerUpdateParams} */
     /** @type {Stripe.CustomerUpdateParams} */
@@ -1423,6 +1439,14 @@ async function processCustomer(
       ...(paymentMethod
       ...(paymentMethod
         ? { invoice_settings: { default_payment_method: paymentMethod.id } }
         ? { 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
     // If Stripe already has any of the fields we're about to set, and the value is