migrate_recurly_customers_to_stripe.helpers.mjs 53 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621
  1. /* eslint-disable @overleaf/require-script-runner */
  2. /*
  3. *
  4. * This file can be deleted once the Recurly to Stripe migration is complete.
  5. */
  6. export function coalesceOrEqualOrThrow(a, b, fieldName) {
  7. const isSetA = !!a
  8. const isSetB = !!b
  9. if (isSetA && isSetB && a !== b) {
  10. throw new Error(
  11. `Field ${fieldName}: Primary and fallback values are both set but differ (${a} != ${b})`
  12. )
  13. }
  14. return isSetA ? a : b
  15. }
  16. export function normalizeName(firstName, lastName) {
  17. const first = (firstName || '').trim()
  18. const last = (lastName || '').trim()
  19. const full = `${first} ${last}`.trim()
  20. return full || null
  21. }
  22. /**
  23. * Extract customer name from billing info only (no fallback to account).
  24. *
  25. * @param {object} account - Recurly account object
  26. * @returns {string|null}
  27. */
  28. export function extractNameFromBillingInfo(account) {
  29. const billingHasFullName = !!(
  30. account.billingInfo?.firstName && account.billingInfo?.lastName
  31. )
  32. return billingHasFullName
  33. ? normalizeName(account.billingInfo.firstName, account.billingInfo.lastName)
  34. : null
  35. }
  36. /**
  37. * Extract customer name from account level only (no fallback to billing info).
  38. *
  39. * @param {object} account - Recurly account object
  40. * @returns {string|null}
  41. */
  42. export function extractNameFromAccount(account) {
  43. // some accounts have only a firstName field populated with the full name, so normalizeName falls back to just firstName if lastName is missing
  44. return normalizeName(account.firstName, account.lastName)
  45. }
  46. /**
  47. * Extract and coalesce VAT number from Recurly data.
  48. *
  49. * Coalesce/equality behavior:
  50. * - Prefer billingInfo.vatNumber when set.
  51. * - Fall back to account.vatNumber otherwise.
  52. * - If both are set but differ, throw.
  53. *
  54. * @param {object} account - Recurly account object
  55. * @returns {string|null}
  56. */
  57. export function coalesceOrThrowVATNumber(account) {
  58. const billingVat = account.billingInfo?.vatNumber?.trim() || null
  59. const accountVat = account?.vatNumber?.trim() || null
  60. return coalesceOrEqualOrThrow(billingVat, accountVat, 'vatNumber')
  61. }
  62. /**
  63. * Normalize a Recurly address into a Stripe AddressParam.
  64. *
  65. * Recurly address field names appear in multiple shapes depending on SDK/version
  66. * and serialization (e.g. street1/street2/postalCode vs line1/line2/postal_code).
  67. *
  68. * @param {any} address
  69. * @returns {import('stripe').Stripe.AddressParam|null}
  70. */
  71. export function normalizeRecurlyAddressToStripe(address) {
  72. if (!address) return null
  73. const line1 = (address.street1 || '').trim()
  74. // eslint-disable-next-line camelcase
  75. const postal_code = (address.postalCode || '').trim()
  76. const country = String(address.country || '')
  77. .trim()
  78. .toUpperCase()
  79. // Only send an address if it has enough data to be plausibly accepted/usable by Stripe.
  80. // For now we'll accept just bare minimum of a country code
  81. if (!/^[A-Z]{2}$/.test(country)) return null
  82. const line2 = (address.street2 || '').trim()
  83. const city = (address.city || '').trim()
  84. const state = (address.region || '').trim()
  85. // Intentionally include empty-string fields so Stripe clears any existing
  86. // stale values on the customer address when Recurly has blanks.
  87. return {
  88. line1,
  89. line2,
  90. city,
  91. state,
  92. // eslint-disable-next-line camelcase
  93. postal_code,
  94. country,
  95. }
  96. }
  97. /**
  98. * EU member state country codes for VAT purposes.
  99. */
  100. export const EU_VAT_COUNTRIES = [
  101. 'AT', // Austria
  102. 'BE', // Belgium
  103. 'BG', // Bulgaria
  104. 'CY', // Cyprus
  105. 'CZ', // Czech Republic
  106. 'DE', // Germany
  107. 'DK', // Denmark
  108. 'EE', // Estonia
  109. 'ES', // Spain
  110. 'FI', // Finland
  111. 'FR', // France
  112. 'GR', // Greece
  113. 'HR', // Croatia
  114. 'HU', // Hungary
  115. 'IE', // Ireland
  116. 'IT', // Italy
  117. 'LT', // Lithuania
  118. 'LU', // Luxembourg
  119. 'LV', // Latvia
  120. 'MT', // Malta
  121. 'NL', // Netherlands
  122. 'PL', // Poland
  123. 'PT', // Portugal
  124. 'RO', // Romania
  125. 'SE', // Sweden
  126. 'SI', // Slovenia
  127. 'SK', // Slovakia
  128. ]
  129. const EU_VAT_PREFIX_OVERRIDES = {
  130. GR: 'EL', // Greece uses EL prefix for VAT numbers
  131. }
  132. function normalizeTaxId(value) {
  133. if (!value) return ''
  134. return String(value).trim().toUpperCase()
  135. }
  136. function normalizeTaxIdCompact(value) {
  137. return normalizeTaxId(value).replace(/\s+/g, '')
  138. }
  139. function normalizeTaxIdAlnum(value) {
  140. // Keep alphanumerics and '&' (used by some MX RFC values), strip separators.
  141. return normalizeTaxId(value).replace(/[^A-Z0-9&]+/g, '')
  142. }
  143. function digitsOnly(value) {
  144. return normalizeTaxId(value).replace(/\D+/g, '')
  145. }
  146. function hasEuVatPrefix(country, taxIdValue) {
  147. const normalized = normalizeTaxIdCompact(taxIdValue)
  148. if (!normalized) return false
  149. const prefix = EU_VAT_PREFIX_OVERRIDES[country] || country
  150. return normalized.startsWith(prefix)
  151. }
  152. function caProvinceFromPostalCode(postalCode) {
  153. if (!postalCode) return null
  154. const m = String(postalCode)
  155. .trim()
  156. .toUpperCase()
  157. .match(/^([A-Z])/)
  158. if (!m) return null
  159. const c = m[1]
  160. if (c === 'A') return 'NL'
  161. if (c === 'B') return 'NS'
  162. if (c === 'C') return 'PE'
  163. if (c === 'E') return 'NB'
  164. if (c === 'G' || c === 'H' || c === 'J') return 'QC'
  165. if (c === 'K' || c === 'L' || c === 'M' || c === 'N' || c === 'P') return 'ON'
  166. if (c === 'R') return 'MB'
  167. if (c === 'S') return 'SK'
  168. if (c === 'T') return 'AB'
  169. if (c === 'V') return 'BC'
  170. if (c === 'Y') return 'YT'
  171. if (c === 'X') return 'NT_NU' // ambiguous without more info
  172. return null
  173. }
  174. /**
  175. * Determine the Stripe tax ID type for Canada based on the tax ID value format.
  176. *
  177. * Source reference:
  178. * - Stripe docs list supported types + example formats (Canada section)
  179. * https://docs.stripe.com/billing/customer/tax-ids
  180. *
  181. *
  182. * If the value doesn't clearly match one of Stripe's documented example formats,
  183. * return null so the caller can handle it manually.
  184. *
  185. * @param {string|undefined|null} taxIdValue
  186. * @returns {'ca_bn'|'ca_gst_hst'|'ca_pst_bc'|'ca_pst_mb'|'ca_pst_sk'|'ca_qst'|null}
  187. */
  188. export function getCanadaTaxIdType(taxIdValue, postalCode) {
  189. if (!taxIdValue) return null
  190. // Normalize for CRA/Stripe examples which may contain spaces (e.g. "123456789 RT 0001")
  191. const normalized = String(taxIdValue).trim().toUpperCase().replace(/\s+/g, '')
  192. if (!normalized) return null
  193. // GST/HST: CRA defines program account numbers as BN (9 digits) + program id (2 letters) + reference (4 digits).
  194. // CRA explicitly shows GST/HST program account number as: 123456789 RT 0001 (or without spaces).
  195. // Safe because "RT" in this exact position is the GST/HST program identifier.
  196. // source: https://www.canada.ca/en/revenue-agency/services/tax/businesses/topics/business-registration/business-number-program-account/need-program-accounts.htm
  197. if (/^\d{9}RT\d{4}$/.test(normalized)) return 'ca_gst_hst'
  198. // Québec QST: Stripe support doc gives exact structure and example 1234567891TQ0001,
  199. // and Revenu Québec states QST registration numbers include the letters "TQ".
  200. // Safe because "TQ" in this exact position is distinctive.
  201. // source: https://support.stripe.com/questions/quebec-sales-tax-information
  202. if (/^\d{10}TQ\d{4}$/.test(normalized)) return 'ca_qst'
  203. // British Columbia PST: BC Gov explicitly states PST number format is PST-1234-5678.
  204. // Safe because it has the "PST-" prefix + hyphen groups.
  205. // source: https://www2.gov.bc.ca/gov/content/taxes/sales-taxes/pst/register
  206. if (/^PST-\d{4}-\d{4}$/.test(normalized)) return 'ca_pst_bc'
  207. const prov = caProvinceFromPostalCode(postalCode)
  208. // Ambiguous numeric-only cases: require province inferred from postal code
  209. // Manitoba: allow dashed 123456-7 and undashed 1234567, but ONLY if prov==MB
  210. if (
  211. (/^\d{6}-\d$/.test(normalized) || /^\d{7}$/.test(normalized)) &&
  212. prov === 'MB'
  213. ) {
  214. return 'ca_pst_mb'
  215. }
  216. // Saskatchewan: 7 digits ONLY if prov==SK
  217. if (/^\d{7}$/.test(normalized) && prov === 'SK') {
  218. return 'ca_pst_sk'
  219. }
  220. // Canadian BN: CRA says the GST/HST program account number starts with a 9-digit BN, and some workflows ask for only those 9 digits.
  221. // Stripe has ca_bn, but a bare 9-digit value is ambiguous (BN-only vs “first 9 digits of GST/HST account” vs other).
  222. // -> Not safe to classify from format alone. Only classify as ca_bn if your *input field* is explicitly “BN”.
  223. // if (/^\d{9}$/.test(normalized)) return 'ca_bn'
  224. return null
  225. }
  226. /**
  227. * Determine the Stripe tax ID type for Australia based on the tax ID value.
  228. *
  229. * Source reference:
  230. * - Stripe docs list supported types + example formats (Australia section)
  231. * https://docs.stripe.com/billing/customer/tax-ids
  232. *
  233. * @param {string|undefined|null} taxIdValue
  234. * @returns {'au_abn'|'au_arn'|null}
  235. */
  236. export function getAustraliaTaxIdType(taxIdValue) {
  237. const digits = digitsOnly(taxIdValue)
  238. if (!digits) return null
  239. // ABN: 11 digits (example: 12345678912)
  240. if (/^\d{11}$/.test(digits)) return 'au_abn'
  241. // ARN: 12 digits (example: 123456789123)
  242. if (/^\d{12}$/.test(digits)) return 'au_arn'
  243. return null
  244. }
  245. /**
  246. * Determine the Stripe tax ID type for Brazil based on the tax ID value.
  247. *
  248. * Source reference:
  249. * - Stripe docs list supported types + example formats (Brazil section)
  250. * https://docs.stripe.com/billing/customer/tax-ids
  251. *
  252. * @param {string|undefined|null} taxIdValue
  253. * @returns {'br_cnpj'|'br_cpf'|null}
  254. */
  255. export function getBrazilTaxIdType(taxIdValue) {
  256. const digits = digitsOnly(taxIdValue)
  257. if (!digits) return null
  258. // CNPJ: 14 digits (example: 01.234.456/5432-10)
  259. if (/^\d{14}$/.test(digits)) return 'br_cnpj'
  260. // CPF: 11 digits (example: 123.456.789-87)
  261. if (/^\d{11}$/.test(digits)) return 'br_cpf'
  262. return null
  263. }
  264. /**
  265. * Determine the Stripe tax ID type for Bulgaria (EU VAT vs UIC).
  266. *
  267. * Source reference:
  268. * - Stripe docs list supported types + example formats (Bulgaria section)
  269. * https://docs.stripe.com/billing/customer/tax-ids
  270. *
  271. * @param {string|undefined|null} taxIdValue
  272. * @returns {'eu_vat'|'bg_uic'|null}
  273. */
  274. export function getBulgariaTaxIdType(taxIdValue) {
  275. if (!taxIdValue) return null
  276. // EU VAT: BG prefix (example: BG0123456789)
  277. if (hasEuVatPrefix('BG', taxIdValue)) return 'eu_vat'
  278. // UIC: 9 digits (example: 123456789)
  279. if (/^\d{9}$/.test(digitsOnly(taxIdValue))) return 'bg_uic'
  280. return null
  281. }
  282. /**
  283. * Determine the Stripe tax ID type for Croatia (EU VAT vs OIB).
  284. *
  285. * Source reference:
  286. * - Stripe docs list supported types + example formats (Croatia section)
  287. * https://docs.stripe.com/billing/customer/tax-ids
  288. *
  289. * @param {string|undefined|null} taxIdValue
  290. * @returns {'eu_vat'|'hr_oib'|null}
  291. */
  292. export function getCroatiaTaxIdType(taxIdValue) {
  293. if (!taxIdValue) return null
  294. // EU VAT: HR prefix (example: HR12345678912)
  295. if (hasEuVatPrefix('HR', taxIdValue)) return 'eu_vat'
  296. // OIB: 11 digits (example: 12345678901)
  297. if (/^\d{11}$/.test(digitsOnly(taxIdValue))) return 'hr_oib'
  298. return null
  299. }
  300. /**
  301. * Determine the Stripe tax ID type for Germany (EU VAT vs Steuer Nummer).
  302. *
  303. * Source reference:
  304. * - Stripe docs list supported types + example formats (Germany section)
  305. * https://docs.stripe.com/billing/customer/tax-ids
  306. *
  307. * @param {string|undefined|null} taxIdValue
  308. * @returns {'eu_vat'|'de_stn'|null}
  309. */
  310. export function getGermanyTaxIdType(taxIdValue) {
  311. if (!taxIdValue) return null
  312. // EU VAT: DE prefix (example: DE123456789)
  313. if (hasEuVatPrefix('DE', taxIdValue)) return 'eu_vat'
  314. // Steuer Nummer: 10 digits (example: 1234567890)
  315. if (/^\d{10}$/.test(digitsOnly(taxIdValue))) return 'de_stn'
  316. return null
  317. }
  318. /**
  319. * Determine the Stripe tax ID type for Hungary (EU VAT vs HU tax number).
  320. *
  321. * Source reference:
  322. * - Stripe docs list supported types + example formats (Hungary section)
  323. * https://docs.stripe.com/billing/customer/tax-ids
  324. *
  325. * @param {string|undefined|null} taxIdValue
  326. * @returns {'eu_vat'|'hu_tin'|null}
  327. */
  328. export function getHungaryTaxIdType(taxIdValue) {
  329. if (!taxIdValue) return null
  330. // EU VAT: HU prefix (example: HU12345678)
  331. if (hasEuVatPrefix('HU', taxIdValue)) return 'eu_vat'
  332. // HU tax number: 8-1-2 digits (example: 12345678-1-23)
  333. const normalized = normalizeTaxIdCompact(taxIdValue)
  334. if (/^\d{8}-\d-\d{2}$/.test(normalized)) return 'hu_tin'
  335. return null
  336. }
  337. /**
  338. * Determine the Stripe tax ID type for Japan.
  339. *
  340. * Source reference:
  341. * - Stripe docs list supported types + example formats (Japan section)
  342. * https://docs.stripe.com/billing/customer/tax-ids
  343. *
  344. * @param {string|undefined|null} taxIdValue
  345. * @returns {'jp_cn'|'jp_rn'|'jp_trn'|null}
  346. */
  347. export function getJapanTaxIdType(taxIdValue) {
  348. const normalized = normalizeTaxIdCompact(taxIdValue)
  349. if (!normalized) return null
  350. // Tax Registration Number: T + 13 digits (example: T1234567891234)
  351. if (/^T\d{13}$/.test(normalized)) return 'jp_trn'
  352. // Corporate Number: 13 digits (example: 1234567891234)
  353. if (/^\d{13}$/.test(normalized)) return 'jp_cn'
  354. // Registered Foreign Businesses: 5 digits (example: 12345)
  355. if (/^\d{5}$/.test(normalized)) return 'jp_rn'
  356. return null
  357. }
  358. /**
  359. * Determine the Stripe tax ID type for Liechtenstein (UID vs VAT).
  360. *
  361. * Source reference:
  362. * - Stripe docs list supported types + example formats (Liechtenstein section)
  363. * https://docs.stripe.com/billing/customer/tax-ids
  364. *
  365. * @param {string|undefined|null} taxIdValue
  366. * @returns {'li_uid'|'li_vat'|null}
  367. */
  368. export function getLiechtensteinTaxIdType(taxIdValue) {
  369. const normalized = normalizeTaxIdCompact(taxIdValue)
  370. if (!normalized) return null
  371. // UID: CHE + 9 digits (example: CHE123456789)
  372. if (/^CHE\d{9}$/.test(normalized)) return 'li_uid'
  373. // VAT: 5 digits (example: 12345)
  374. if (/^\d{5}$/.test(digitsOnly(normalized))) return 'li_vat'
  375. return null
  376. }
  377. /**
  378. * Determine the Stripe tax ID type for Malaysia (FRP, ITN, SST).
  379. *
  380. * Source reference:
  381. * - Stripe docs list supported types + example formats (Malaysia section)
  382. * https://docs.stripe.com/billing/customer/tax-ids
  383. *
  384. * @param {string|undefined|null} taxIdValue
  385. * @returns {'my_frp'|'my_itn'|'my_sst'|null}
  386. */
  387. export function getMalaysiaTaxIdType(taxIdValue) {
  388. const normalized = normalizeTaxIdCompact(taxIdValue)
  389. if (!normalized) return null
  390. // FRP: 8 digits (example: 12345678)
  391. if (/^\d{8}$/.test(digitsOnly(normalized))) return 'my_frp'
  392. // ITN: letter + 10 digits (example: C 1234567890)
  393. if (/^[A-Z]\d{10}$/.test(normalized.replace(/\s+/g, ''))) return 'my_itn'
  394. // SST: A12-3456-78912345
  395. if (/^[A-Z]\d{2}-\d{4}-\d{8}$/.test(normalized)) return 'my_sst'
  396. return null
  397. }
  398. /**
  399. * Determine the Stripe tax ID type for Norway (VAT vs VOEC).
  400. *
  401. * Source reference:
  402. * - Stripe docs list supported types + example formats (Norway section)
  403. * https://docs.stripe.com/billing/customer/tax-ids
  404. *
  405. * @param {string|undefined|null} taxIdValue
  406. * @returns {'no_vat'|'no_voec'|null}
  407. */
  408. export function getNorwayTaxIdType(taxIdValue) {
  409. const normalized = normalizeTaxIdCompact(taxIdValue)
  410. if (!normalized) return null
  411. // VAT: 9 digits + MVA (example: 123456789MVA)
  412. if (/^\d{9}MVA$/.test(normalized)) return 'no_vat'
  413. // VOEC: 7 digits (example: 1234567)
  414. if (/^\d{7}$/.test(digitsOnly(normalized))) return 'no_voec'
  415. return null
  416. }
  417. /**
  418. * Determine the Stripe tax ID type for Poland (EU VAT vs NIP).
  419. *
  420. * Source reference:
  421. * - Stripe docs list supported types + example formats (Poland section)
  422. * https://docs.stripe.com/billing/customer/tax-ids
  423. *
  424. * @param {string|undefined|null} taxIdValue
  425. * @returns {'eu_vat'|'pl_nip'|null}
  426. */
  427. export function getPolandTaxIdType(taxIdValue) {
  428. if (!taxIdValue) return null
  429. // EU VAT: PL prefix (example: PL1234567890)
  430. if (hasEuVatPrefix('PL', taxIdValue)) return 'eu_vat'
  431. // NIP: 10 digits (example: 1234567890)
  432. if (/^\d{10}$/.test(digitsOnly(taxIdValue))) return 'pl_nip'
  433. return null
  434. }
  435. /**
  436. * Determine the Stripe tax ID type for Romania (EU VAT vs TIN).
  437. *
  438. * Source reference:
  439. * - Stripe docs list supported types + example formats (Romania section)
  440. * https://docs.stripe.com/billing/customer/tax-ids
  441. *
  442. * @param {string|undefined|null} taxIdValue
  443. * @returns {'eu_vat'|'ro_tin'|null}
  444. */
  445. export function getRomaniaTaxIdType(taxIdValue) {
  446. if (!taxIdValue) return null
  447. // EU VAT: RO prefix (example: RO1234567891)
  448. if (hasEuVatPrefix('RO', taxIdValue)) return 'eu_vat'
  449. // TIN: 13 digits (example: 1234567890123)
  450. if (/^\d{13}$/.test(digitsOnly(taxIdValue))) return 'ro_tin'
  451. return null
  452. }
  453. /**
  454. * Determine the Stripe tax ID type for Russia (INN vs KPP).
  455. *
  456. * Source reference:
  457. * - Stripe docs list supported types + example formats (Russia section)
  458. * https://docs.stripe.com/billing/customer/tax-ids
  459. *
  460. * @param {string|undefined|null} taxIdValue
  461. * @returns {'ru_inn'|'ru_kpp'|null}
  462. */
  463. export function getRussiaTaxIdType(taxIdValue) {
  464. const digits = digitsOnly(taxIdValue)
  465. if (!digits) return null
  466. // INN: 10 or 12 digits (example: 1234567891)
  467. if (/^(\d{10}|\d{12})$/.test(digits)) return 'ru_inn'
  468. // KPP: 9 digits (example: 123456789)
  469. if (/^\d{9}$/.test(digits)) return 'ru_kpp'
  470. return null
  471. }
  472. /**
  473. * Determine the Stripe tax ID type for Singapore (GST vs UEN).
  474. *
  475. * Source reference:
  476. * - Stripe docs list supported types + example formats (Singapore section)
  477. * https://docs.stripe.com/billing/customer/tax-ids
  478. *
  479. * @param {string|undefined|null} taxIdValue
  480. * @returns {'sg_gst'|'sg_uen'|null}
  481. */
  482. export function getSingaporeTaxIdType(taxIdValue) {
  483. const normalized = normalizeTaxIdCompact(taxIdValue)
  484. if (!normalized) return null
  485. // GST: M + 8 digits + letter (example: M12345678X)
  486. if (/^M\d{8}[A-Z]$/.test(normalized)) return 'sg_gst'
  487. // UEN: 9 digits + letter (example: 123456789F)
  488. if (/^\d{9}[A-Z]$/.test(normalized)) return 'sg_uen'
  489. return null
  490. }
  491. /**
  492. * Determine the Stripe tax ID type for Slovenia (EU VAT vs TIN).
  493. *
  494. * Source reference:
  495. * - Stripe docs list supported types + example formats (Slovenia section)
  496. * https://docs.stripe.com/billing/customer/tax-ids
  497. *
  498. * @param {string|undefined|null} taxIdValue
  499. * @returns {'eu_vat'|'si_tin'|null}
  500. */
  501. export function getSloveniaTaxIdType(taxIdValue) {
  502. if (!taxIdValue) return null
  503. // EU VAT: SI prefix (example: SI12345678)
  504. if (hasEuVatPrefix('SI', taxIdValue)) return 'eu_vat'
  505. // TIN: 8 digits (example: 12345678)
  506. if (/^\d{8}$/.test(digitsOnly(taxIdValue))) return 'si_tin'
  507. return null
  508. }
  509. /**
  510. * Determine the Stripe tax ID type for Spain (EU VAT vs CIF/NIF).
  511. *
  512. * Source reference:
  513. * - Stripe docs list supported types + example formats (Spain section)
  514. * https://docs.stripe.com/billing/customer/tax-ids
  515. *
  516. * @param {string|undefined|null} taxIdValue
  517. * @returns {'eu_vat'|'es_cif'|null}
  518. */
  519. export function getSpainTaxIdType(taxIdValue) {
  520. const normalized = normalizeTaxIdCompact(taxIdValue)
  521. if (!normalized) return null
  522. // EU VAT: ES prefix (example: ESA1234567Z)
  523. if (hasEuVatPrefix('ES', normalized)) return 'eu_vat'
  524. // CIF/NIF: A12345678 (letter + 7 digits + alnum)
  525. if (/^[A-Z]\d{7}[A-Z0-9]$/.test(normalized)) return 'es_cif'
  526. return null
  527. }
  528. /**
  529. * Determine the Stripe tax ID type for Switzerland (UID vs VAT).
  530. *
  531. * Source reference:
  532. * - Stripe docs list supported types + example formats (Switzerland section)
  533. * https://docs.stripe.com/billing/customer/tax-ids
  534. *
  535. * @param {string|undefined|null} taxIdValue
  536. * @returns {'ch_uid'|'ch_vat'|null}
  537. */
  538. export function getSwitzerlandTaxIdType(taxIdValue) {
  539. const normalized = normalizeTaxIdCompact(taxIdValue)
  540. if (!normalized) return null
  541. const alnum = normalized.replace(/[^A-Z0-9]/g, '')
  542. // VAT: CHE-123.456.789 MWST
  543. if (/^CHE\d{9}(MWST|TVA|IVA)$/.test(alnum)) return 'ch_vat'
  544. // UID: CHE-123.456.789 HR
  545. if (/^CHE\d{9}(HR)?$/.test(alnum)) return 'ch_uid'
  546. return null
  547. }
  548. /**
  549. * Determine the Stripe tax ID type for the United Kingdom (GB VAT vs EU VAT for NI).
  550. *
  551. * Source reference:
  552. * - Stripe docs list supported types + example formats (UK section)
  553. * https://docs.stripe.com/billing/customer/tax-ids
  554. *
  555. * @param {string|undefined|null} taxIdValue
  556. * @returns {'gb_vat'|'eu_vat'|null}
  557. */
  558. export function getUkTaxIdType(taxIdValue) {
  559. const normalized = normalizeTaxIdCompact(taxIdValue)
  560. if (!normalized) return null
  561. // Northern Ireland VAT numbers use XI prefix
  562. if (/^XI[A-Z0-9]+$/.test(normalized)) return 'eu_vat'
  563. // GB VAT numbers use GB prefix
  564. if (/^GB(\d{9}|\d{12})$/.test(normalized)) return 'gb_vat'
  565. return null
  566. }
  567. /**
  568. * Normalize a GB VAT number to the standard format.
  569. *
  570. * @param {string|undefined|null} taxIdValue
  571. * @returns {string|undefined|null} - Normalized GB VAT number if valid, otherwise original value
  572. */
  573. export function normalisedGBVATNumber(taxIdValue) {
  574. if (!taxIdValue) return taxIdValue
  575. // Strip spaces and punctuation
  576. let normalized = String(taxIdValue)
  577. .trim()
  578. .toUpperCase()
  579. .replace(/[\s\-.]/g, '')
  580. // Prepend GB if not already there (but don't prepend if it starts with XI for Northern Ireland)
  581. if (!normalized.startsWith('GB') && !normalized.startsWith('XI')) {
  582. normalized = 'GB' + normalized
  583. }
  584. // Remove any trailing GB (but not if it's the only GB at the start)
  585. if (normalized.endsWith('GB') && normalized.length > 2) {
  586. normalized = normalized.slice(0, -2)
  587. }
  588. // Check if it's a valid GB VAT number
  589. const taxIdType = getUkTaxIdType(normalized)
  590. if (taxIdType === 'gb_vat') {
  591. return normalized
  592. }
  593. // Return original value if not valid
  594. return taxIdValue
  595. }
  596. /**
  597. * Determine the Stripe tax ID type for Uzbekistan (TIN vs VAT).
  598. *
  599. * Source reference:
  600. * - Stripe docs list supported types + example formats (Uzbekistan section)
  601. * https://docs.stripe.com/billing/customer/tax-ids
  602. *
  603. * @param {string|undefined|null} taxIdValue
  604. * @returns {'uz_tin'|'uz_vat'|null}
  605. */
  606. export function getUzbekistanTaxIdType(taxIdValue) {
  607. const digits = digitsOnly(taxIdValue)
  608. if (!digits) return null
  609. // VAT: 12 digits (example: 123456789012)
  610. if (/^\d{12}$/.test(digits)) return 'uz_vat'
  611. // TIN: 9 digits (example: 123456789)
  612. if (/^\d{9}$/.test(digits)) return 'uz_tin'
  613. return null
  614. }
  615. // we are now no longer pre-validating tax IDS based on country-specific formats before sending to Stripe.
  616. // however leaving the regexes and examples here for reference and potential future use
  617. const COUNTRY_TAX_ID_FORMATS = {
  618. // Africa
  619. AO: { taxIdType: 'ao_tin', regex: /^\d{10}$/ }, // Example: 5123456789
  620. BH: { taxIdType: 'bh_vat', regex: /^\d{15}$/ }, // Example: 123456789012345
  621. BF: { taxIdType: 'bf_ifu', regex: /^\d{8}[A-Z]$/ }, // Example: 12345678A
  622. BJ: { taxIdType: 'bj_ifu', regex: /^\d{13}$/ }, // Example: 1234567890123
  623. CM: { taxIdType: 'cm_niu', regex: /^[A-Z]\d{12}[A-Z]$/ }, // Example: M123456789000L
  624. CV: { taxIdType: 'cv_nif', regex: /^\d{9}$/ }, // Example: 213456789
  625. CD: { taxIdType: 'cd_nif', regex: /^[A-Z]\d{7}[A-Z]$/ }, // Example: A0123456M
  626. EG: { taxIdType: 'eg_tin', regex: /^\d{9}$/ }, // Example: 123456789
  627. ET: { taxIdType: 'et_tin', regex: /^\d{10}$/ }, // Example: 1234567890
  628. GN: { taxIdType: 'gn_nif', regex: /^\d{9}$/ }, // Example: 123456789
  629. KE: { taxIdType: 'ke_pin', regex: /^[A-Z]\d{9}[A-Z]$/ }, // Example: P000111111A
  630. MA: { taxIdType: 'ma_vat', regex: /^\d{8}$/ }, // Example: 12345678
  631. MR: { taxIdType: 'mr_nif', regex: /^\d{8}$/ }, // Example: 12345678
  632. NG: { taxIdType: 'ng_tin', regex: /^\d{8,14}$/ }, // Example: 12345678-0001
  633. SN: { taxIdType: 'sn_ninea', regex: /^\d{8}[A-Z]\d$/ }, // Example: 12345672A2
  634. TZ: { taxIdType: 'tz_vat', regex: /^\d{8}[A-Z]$/ }, // Example: 12345678A
  635. UG: { taxIdType: 'ug_tin', regex: /^\d{10}$/ }, // Example: 1014751879
  636. ZA: { taxIdType: 'za_vat', regex: /^\d{10}$/ }, // Example: 4123456789
  637. ZM: { taxIdType: 'zm_tin', regex: /^\d{10}$/ }, // Example: 1004751879
  638. ZW: { taxIdType: 'zw_tin', regex: /^\d{10}$/ }, // Example: 1234567890
  639. // Americas
  640. AR: { taxIdType: 'ar_cuit', regex: /^\d{11}$/ }, // Example: 12-3456789-01
  641. BO: { taxIdType: 'bo_tin', regex: /^\d{9}$/ }, // Example: 123456789
  642. BS: { taxIdType: 'bs_tin', regex: /^\d{9}$/ }, // Example: 123.456.789
  643. BB: { taxIdType: 'bb_tin', regex: /^\d{13}$/ }, // Example: 1123456789012
  644. CL: { taxIdType: 'cl_tin', regex: /^\d{7,8}[0-9Kk]$/ }, // Example: 12.345.678-K
  645. CO: { taxIdType: 'co_nit', regex: /^\d{10}$/ }, // Example: 123.456.789-0
  646. CR: { taxIdType: 'cr_tin', regex: /^\d{10}$/ }, // Example: 1-234-567890
  647. DO: { taxIdType: 'do_rcn', regex: /^\d{11}$/ }, // Example: 123-4567890-1
  648. EC: { taxIdType: 'ec_ruc', regex: /^\d{13}$/ }, // Example: 1234567890001
  649. // Accounts for Companies (12 chars). Individual entrepreneurs use 13 characters (4 letters at the start)
  650. // Note: & is also a valid character in some Mexican company names
  651. // and for companies, that first "alpha" section is derived directly from the legal name so may contain an ampersand.
  652. MX: { taxIdType: 'mx_rfc', regex: /^[A-Z&]{3,4}\d{6}[A-Z0-9]{3}$/ }, // Example: ABC010203AB9
  653. PE: { taxIdType: 'pe_ruc', regex: /^\d{11}$/ }, // Example: 12345678901
  654. SR: { taxIdType: 'sr_fin', regex: /^\d{10}$/ }, // Example: 1234567890
  655. SV: { taxIdType: 'sv_nit', regex: /^\d{14}$/ }, // Example: 1234-567890-123-4
  656. US: { taxIdType: 'us_ein', regex: /^\d{9}$/ }, // Example: 12-3456789
  657. UY: { taxIdType: 'uy_ruc', regex: /^\d{12}$/ }, // Example: 123456789012
  658. VE: { taxIdType: 've_rif', regex: /^[JGVGE]\d{9}$/ }, // Example: A-12345678-9
  659. // Asia-Pacific
  660. BD: { taxIdType: 'bd_bin', regex: /^\d{13}$/ }, // Example: 123456789-0123
  661. // the USCC (China) strictly excludes the letters I, O, S, V, and Z to prevent optical character recognition (OCR) errors.
  662. CN: { taxIdType: 'cn_tin', regex: /^[0-9A-HJ-NP-RTUW-Y]{18}$/ }, // Example: 12350000426600329N
  663. HK: { taxIdType: 'hk_br', regex: /^\d{8}$/ }, // Example: 12345678
  664. ID: { taxIdType: 'id_npwp', regex: /^\d{15}$/ }, // Example: 012.345.678.9-012.345
  665. IN: {
  666. taxIdType: 'in_gst',
  667. // The 13th character is usually a number but can be a letter. The 15th character (Check digit) can be a letter or a number.
  668. // 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'.
  669. regex: /^\d{2}[A-Z]{5}\d{4}[A-Z][A-Z0-9][Z][A-Z0-9]$/,
  670. }, // Example: 12ABCDE3456FGZH
  671. KH: { taxIdType: 'kh_tin', regex: /^\d{13}$/ }, // Example: 1001-123456789
  672. KR: { taxIdType: 'kr_brn', regex: /^\d{10}$/ }, // Example: 123-45-67890
  673. KZ: { taxIdType: 'kz_bin', regex: /^\d{12}$/ }, // Example: 123456789012
  674. KG: { taxIdType: 'kg_tin', regex: /^\d{14}$/ }, // Example: 12345678901234
  675. LA: { taxIdType: 'la_tin', regex: /^\d{12}$/ }, // Example: 123456789-000
  676. NZ: { taxIdType: 'nz_gst', regex: /^\d{9}$/ }, // Example: 123456789
  677. NP: { taxIdType: 'np_pan', regex: /^\d{9}$/ }, // Example: 123456789
  678. PH: { taxIdType: 'ph_tin', regex: /^\d{12}$/ }, // Example: 123456789012
  679. TH: { taxIdType: 'th_vat', regex: /^\d{13}$/ }, // Example: 1234567891234
  680. TW: { taxIdType: 'tw_vat', regex: /^\d{8}$/ }, // Example: 12345678
  681. VN: { taxIdType: 'vn_tin', regex: /^\d{10}$/ }, // Example: 1234567890
  682. // Europe (non-EU)
  683. AD: { taxIdType: 'ad_nrt', regex: /^[A-Z]\d{6}[A-Z]$/ }, // Example: A-123456-Z
  684. AL: { taxIdType: 'al_tin', regex: /^[A-Z]\d{8}[A-Z]$/ }, // Example: J12345678N
  685. AM: { taxIdType: 'am_tin', regex: /^\d{8}$/ }, // Example: 02538904
  686. AW: { taxIdType: 'aw_tin', regex: /^\d{8}$/ }, // Example: 12345678
  687. AZ: { taxIdType: 'az_tin', regex: /^\d{10}$/ }, // Example: 0123456789
  688. BA: { taxIdType: 'ba_tin', regex: /^\d{12}$/ }, // Example: 123456789012
  689. BY: { taxIdType: 'by_tin', regex: /^\d{9}$/ }, // Example: 123456789
  690. // CH: { taxIdType: 'ch_vat', regex: /^CHE\d{9}MWST$/ }, // Example: CHE-123.456.789 MWST
  691. GE: { taxIdType: 'ge_vat', regex: /^\d{9}$/ }, // Example: 123456789
  692. IS: { taxIdType: 'is_vat', regex: /^\d{6}$/ }, // Example: 123456
  693. LI: { taxIdType: 'li_uid', regex: /^CHE\d{9}$/ }, // Example: CHE123456789
  694. MD: { taxIdType: 'md_vat', regex: /^\d{7}$/ }, // Example: 1234567
  695. ME: { taxIdType: 'me_pib', regex: /^\d{8}$/ }, // Example: 12345678
  696. MK: { taxIdType: 'mk_vat', regex: /^MK\d{13}$/ }, // Example: MK1234567890123
  697. NO: { taxIdType: 'no_vat', regex: /^\d{9}MVA$/ }, // Example: 123456789MVA
  698. RS: { taxIdType: 'rs_pib', regex: /^\d{9}$/ }, // Example: 123456789
  699. RU: { taxIdType: 'ru_inn', regex: /^\d{10}$/ }, // Example: 1234567891
  700. TR: { taxIdType: 'tr_tin', regex: /^\d{10}$/ }, // Example: 0123456789
  701. UA: { taxIdType: 'ua_vat', regex: /^\d{9}$/ }, // Example: 123456789
  702. // Middle East
  703. AE: { taxIdType: 'ae_trn', regex: /^\d{15}$/ }, // Example: 123456789012345
  704. IL: { taxIdType: 'il_vat', regex: /^\d{9}$/ }, // Example: 000012345
  705. OM: { taxIdType: 'om_vat', regex: /^OM\d{10}$/ }, // Example: OM1234567890
  706. SA: { taxIdType: 'sa_vat', regex: /^\d{15}$/ }, // Example: 123456789012345
  707. // Other
  708. EU: { taxIdType: 'eu_oss_vat', regex: /^EU\d+$/ }, // Example: EU123456789
  709. }
  710. /**
  711. * Get the Stripe tax ID type for a given country + tax ID value.
  712. *
  713. * Note: for some countries (e.g. Canada) the type depends on the tax ID value format,
  714. * not just the country.
  715. *
  716. * @param {string|undefined|null} country - ISO 3166-1 alpha-2
  717. * @param {string|undefined|null} taxIdValue
  718. * @param {string|undefined|null} postalCode - used for Canada province inference
  719. * @param {boolean} [preValidateFormat=false] - Optional behavior flag to pre-validate format.
  720. * @returns {{type: string|null, reason: string|null}} - Stripe tax ID type + failure reason (when type is null)
  721. */
  722. // TODO: this function is naive - we need more than just country to determine tax ID type
  723. // for example canada has multiple types (BN, QST, GST/HST) depending on province (inferred from postal code)
  724. export function getTaxIdType(
  725. country,
  726. taxIdValue,
  727. postalCode,
  728. preValidateFormat = false
  729. ) {
  730. if (!country) return { type: null, reason: 'missing country' }
  731. const upperCountry = String(country).toUpperCase()
  732. const normalizedTaxId = normalizeTaxIdCompact(taxIdValue)
  733. if (!normalizedTaxId) return { type: null, reason: 'missing tax ID value' }
  734. if (upperCountry === 'EU') {
  735. // European One Stop Shop VAT number for non-Union scheme
  736. const normalized = normalizeTaxIdCompact(taxIdValue)
  737. return /^EU\d+$/.test(normalized)
  738. ? { type: 'eu_oss_vat', reason: null }
  739. : { type: null, reason: 'invalid EU OSS VAT number' }
  740. }
  741. // EU VAT
  742. if (EU_VAT_COUNTRIES.includes(upperCountry)) {
  743. // If this country has multiple types, we'll handle it below with a dedicated function.
  744. // Otherwise, EU VAT is the only supported type for that country.
  745. const euVatOnlyCountries = new Set([
  746. 'AT',
  747. 'BE',
  748. 'CY',
  749. 'CZ',
  750. 'DK',
  751. 'EE',
  752. 'FI',
  753. 'FR',
  754. 'GR',
  755. 'IE',
  756. 'IT',
  757. 'LT',
  758. 'LU',
  759. 'LV',
  760. 'MT',
  761. 'NL',
  762. 'PT',
  763. 'SE',
  764. 'SK',
  765. ])
  766. if (euVatOnlyCountries.has(upperCountry)) {
  767. if (preValidateFormat && !hasEuVatPrefix(upperCountry, taxIdValue)) {
  768. return {
  769. type: null,
  770. reason: `invalid tax ID format for country ${upperCountry}`,
  771. }
  772. }
  773. return { type: 'eu_vat', reason: null }
  774. }
  775. }
  776. // Multi-type countries
  777. if (upperCountry === 'CA') {
  778. const type = getCanadaTaxIdType(taxIdValue, postalCode)
  779. return type
  780. ? { type, reason: null }
  781. : { type: null, reason: 'unrecognized tax ID format for country CA' }
  782. }
  783. if (upperCountry === 'AU') {
  784. const type = getAustraliaTaxIdType(taxIdValue)
  785. return type
  786. ? { type, reason: null }
  787. : { type: null, reason: 'unrecognized tax ID format for country AU' }
  788. }
  789. if (upperCountry === 'BR') {
  790. const type = getBrazilTaxIdType(taxIdValue)
  791. return type
  792. ? { type, reason: null }
  793. : { type: null, reason: 'unrecognized tax ID format for country BR' }
  794. }
  795. if (upperCountry === 'BG') {
  796. const type = getBulgariaTaxIdType(taxIdValue)
  797. return type
  798. ? { type, reason: null }
  799. : { type: null, reason: 'unrecognized tax ID format for country BG' }
  800. }
  801. if (upperCountry === 'HR') {
  802. const type = getCroatiaTaxIdType(taxIdValue)
  803. return type
  804. ? { type, reason: null }
  805. : { type: null, reason: 'unrecognized tax ID format for country HR' }
  806. }
  807. if (upperCountry === 'DE') {
  808. const type = getGermanyTaxIdType(taxIdValue)
  809. return type
  810. ? { type, reason: null }
  811. : { type: null, reason: 'unrecognized tax ID format for country DE' }
  812. }
  813. if (upperCountry === 'HU') {
  814. const type = getHungaryTaxIdType(taxIdValue)
  815. return type
  816. ? { type, reason: null }
  817. : { type: null, reason: 'unrecognized tax ID format for country HU' }
  818. }
  819. if (upperCountry === 'JP') {
  820. const type = getJapanTaxIdType(taxIdValue)
  821. return type
  822. ? { type, reason: null }
  823. : { type: null, reason: 'unrecognized tax ID format for country JP' }
  824. }
  825. if (upperCountry === 'LI') {
  826. const type = getLiechtensteinTaxIdType(taxIdValue)
  827. return type
  828. ? { type, reason: null }
  829. : { type: null, reason: 'unrecognized tax ID format for country LI' }
  830. }
  831. if (upperCountry === 'MY') {
  832. const type = getMalaysiaTaxIdType(taxIdValue)
  833. return type
  834. ? { type, reason: null }
  835. : { type: null, reason: 'unrecognized tax ID format for country MY' }
  836. }
  837. if (upperCountry === 'NO') {
  838. const type = getNorwayTaxIdType(taxIdValue)
  839. return type
  840. ? { type, reason: null }
  841. : { type: null, reason: 'unrecognized tax ID format for country NO' }
  842. }
  843. if (upperCountry === 'PL') {
  844. const type = getPolandTaxIdType(taxIdValue)
  845. return type
  846. ? { type, reason: null }
  847. : { type: null, reason: 'unrecognized tax ID format for country PL' }
  848. }
  849. if (upperCountry === 'RO') {
  850. const type = getRomaniaTaxIdType(taxIdValue)
  851. return type
  852. ? { type, reason: null }
  853. : { type: null, reason: 'unrecognized tax ID format for country RO' }
  854. }
  855. if (upperCountry === 'RU') {
  856. const type = getRussiaTaxIdType(taxIdValue)
  857. return type
  858. ? { type, reason: null }
  859. : { type: null, reason: 'unrecognized tax ID format for country RU' }
  860. }
  861. if (upperCountry === 'SG') {
  862. const type = getSingaporeTaxIdType(taxIdValue)
  863. return type
  864. ? { type, reason: null }
  865. : { type: null, reason: 'unrecognized tax ID format for country SG' }
  866. }
  867. if (upperCountry === 'SI') {
  868. const type = getSloveniaTaxIdType(taxIdValue)
  869. return type
  870. ? { type, reason: null }
  871. : { type: null, reason: 'unrecognized tax ID format for country SI' }
  872. }
  873. if (upperCountry === 'ES') {
  874. const type = getSpainTaxIdType(taxIdValue)
  875. return type
  876. ? { type, reason: null }
  877. : { type: null, reason: 'unrecognized tax ID format for country ES' }
  878. }
  879. if (upperCountry === 'CH') {
  880. const type = getSwitzerlandTaxIdType(taxIdValue)
  881. return type
  882. ? { type, reason: null }
  883. : { type: null, reason: 'unrecognized tax ID format for country CH' }
  884. }
  885. if (upperCountry === 'GB') {
  886. const type = getUkTaxIdType(taxIdValue)
  887. return type
  888. ? { type, reason: null }
  889. : { type: null, reason: 'unrecognized tax ID format for country GB' }
  890. }
  891. if (upperCountry === 'UZ') {
  892. const type = getUzbekistanTaxIdType(taxIdValue)
  893. return type
  894. ? { type, reason: null }
  895. : { type: null, reason: 'unrecognized tax ID format for country UZ' }
  896. }
  897. const countryFormat = COUNTRY_TAX_ID_FORMATS[upperCountry]
  898. if (countryFormat) {
  899. if (preValidateFormat) {
  900. const normalized = normalizeTaxIdAlnum(taxIdValue)
  901. if (!countryFormat.regex.test(normalized)) {
  902. return {
  903. type: null,
  904. reason: `invalid tax ID format for country ${upperCountry}`,
  905. }
  906. }
  907. }
  908. return { type: countryFormat.taxIdType, reason: null }
  909. }
  910. return { type: null, reason: `unsupported country ${upperCountry}` }
  911. }
  912. /**
  913. * Remove paypal billing agreement id from account for logging purposes
  914. *
  915. * @param {object} account
  916. * @returns {object} sanitized account object
  917. */
  918. export function sanitizeAccount(account) {
  919. return {
  920. ...account,
  921. billingInfo: sanitizeBillingInfo(account.billingInfo),
  922. }
  923. }
  924. /**
  925. * Remove paypal billing agreement id from billing info for logging purposes
  926. *
  927. * @param {object} billingInfo
  928. * @returns {object} sanitized billing info object
  929. */
  930. export function sanitizeBillingInfo(billingInfo) {
  931. const sanitizedBillingInfo = structuredClone(billingInfo)
  932. if (
  933. sanitizedBillingInfo?.paymentMethod?.object === 'paypal_billing_agreement'
  934. ) {
  935. sanitizedBillingInfo.paymentMethod.billingAgreementId =
  936. 'REDACTED_FOR_LOGGING'
  937. }
  938. return sanitizedBillingInfo
  939. }
  940. /**
  941. *
  942. * @param {Stripe.PaymentMethod[]} paymentMethods
  943. * @param {string} stripeCustomerId
  944. * @param {object|null} billingInfo - Recurly billing info object
  945. * @returns {Stripe.PaymentMethod} valid payment method
  946. * @throws {Error} if no valid payment method found
  947. */
  948. export function coalesceOrThrowPaymentMethod(
  949. paymentMethods,
  950. stripeCustomerId,
  951. billingInfo
  952. ) {
  953. if (paymentMethods.length === 0) {
  954. throw new Error(
  955. `Stripe customer ${stripeCustomerId} has no usable payment method`
  956. )
  957. }
  958. const matchingPaymentMethods = paymentMethods.filter(method =>
  959. areStripeAndRecurlyCardDetailsEqual(method, billingInfo?.paymentMethod)
  960. )
  961. if (matchingPaymentMethods.length === 0) {
  962. throw new Error(
  963. `Stripe customer ${stripeCustomerId} has no usable payment method`
  964. )
  965. }
  966. return matchingPaymentMethods[0]
  967. }
  968. /**
  969. * Compare Stripe card details with Recurly card details to determine if they likely represent the same card.
  970. *
  971. * Checks last4 and expiry month/year.
  972. *
  973. * @param {Stripe.PaymentMethod} stripePaymentMethod
  974. * @param {object} recurlyPaymentMethod - Recurly billingInfo.paymentMethod object
  975. * @returns {boolean} true if details match, false otherwise
  976. */
  977. export function areStripeAndRecurlyCardDetailsEqual(
  978. stripePaymentMethod,
  979. recurlyPaymentMethod
  980. ) {
  981. if (
  982. !stripePaymentMethod ||
  983. stripePaymentMethod.type !== 'card' ||
  984. !stripePaymentMethod.card ||
  985. !recurlyPaymentMethod?.lastFour
  986. ) {
  987. return false
  988. }
  989. return (
  990. stripePaymentMethod.card.last4 === recurlyPaymentMethod.lastFour &&
  991. stripePaymentMethod.card.exp_month === recurlyPaymentMethod.expMonth &&
  992. stripePaymentMethod.card.exp_year === recurlyPaymentMethod.expYear
  993. )
  994. }
  995. // =============================================================================
  996. // ADDRESS / STRING COMPARISON HELPERS
  997. // =============================================================================
  998. export function normalizeComparableString(value) {
  999. if (value == null) return ''
  1000. return String(value).trim()
  1001. }
  1002. export function hasAnyAddressValue(address) {
  1003. if (!address || typeof address !== 'object') return false
  1004. return Object.values(address).some(v => normalizeComparableString(v) !== '')
  1005. }
  1006. export function ccEmailsToArray(ccEmails) {
  1007. if (ccEmails == null || ccEmails === undefined) return []
  1008. const normalisedEmails = String(ccEmails)
  1009. .split(/[\s,;]+/)
  1010. .filter(Boolean)
  1011. return [...new Set(normalisedEmails)]
  1012. }
  1013. export function normalizeTaxIdValue(taxId) {
  1014. const value = normalizeComparableString(taxId)
  1015. return value.replace(/[.\-_\s:/]/g, '').toLowerCase()
  1016. }
  1017. // =============================================================================
  1018. // CUSTOM FIELDS
  1019. // =============================================================================
  1020. export const RECURLY_CUSTOM_FIELD_NAMES = [
  1021. 'channel',
  1022. 'Industry',
  1023. 'ol_sales_person',
  1024. 'MigratedfromFreeAgent',
  1025. ]
  1026. /**
  1027. * Extract Recurly custom fields as Stripe metadata.
  1028. *
  1029. * Expects an account object with a `customFields` array (Recurly npm SDK format)
  1030. * where each element has `name` and `value` properties.
  1031. *
  1032. * @param {object} account - Recurly account object (npm SDK format)
  1033. * @returns {{ metadata: Record<string, string>, counts: Record<string, number> }}
  1034. */
  1035. export function extractRecurlyCustomFieldMetadata(account) {
  1036. const customFields = account?.customFields
  1037. if (!Array.isArray(customFields)) {
  1038. throw new Error(
  1039. 'Unexpected Recurly response: account.customFields is missing or not an array'
  1040. )
  1041. }
  1042. /** @type {Record<string, string>} */
  1043. const metadata = {}
  1044. const counts = {
  1045. channel: 0,
  1046. Industry: 0,
  1047. ol_sales_person: 0,
  1048. MigratedfromFreeAgent: 0,
  1049. noCustomFields: 0,
  1050. }
  1051. if (customFields.length === 0) {
  1052. counts.noCustomFields = 1
  1053. return { metadata, counts }
  1054. }
  1055. for (const field of customFields) {
  1056. const name = field?.name?.trim()
  1057. if (!RECURLY_CUSTOM_FIELD_NAMES.includes(name)) continue
  1058. const rawValue = field?.value
  1059. if (rawValue == null) continue
  1060. const value = String(rawValue).trim()
  1061. if (!value) continue
  1062. metadata[name] = value
  1063. counts[name] = 1
  1064. }
  1065. return { metadata, counts }
  1066. }
  1067. function normalizeComparableAddress(address) {
  1068. if (!address || typeof address !== 'object') return null
  1069. const norm = {
  1070. line1: normalizeComparableString(address.line1),
  1071. line2: normalizeComparableString(address.line2),
  1072. city: normalizeComparableString(address.city),
  1073. state: normalizeComparableString(address.state),
  1074. postal_code: normalizeComparableString(address.postal_code),
  1075. country: normalizeComparableString(address.country).toUpperCase(),
  1076. }
  1077. return hasAnyAddressValue(norm) ? norm : null
  1078. }
  1079. export function addressesEqual(a, b) {
  1080. const na = normalizeComparableAddress(a)
  1081. const nb = normalizeComparableAddress(b)
  1082. if (!na && !nb) return true
  1083. if (!na || !nb) return false
  1084. return (
  1085. na.line1 === nb.line1 &&
  1086. na.line2 === nb.line2 &&
  1087. na.city === nb.city &&
  1088. na.state === nb.state &&
  1089. na.postal_code === nb.postal_code &&
  1090. na.country === nb.country
  1091. )
  1092. }
  1093. // =============================================================================
  1094. // CUSTOMER IDENTITY RESOLUTION
  1095. // =============================================================================
  1096. /**
  1097. * Resolve customer name, address, company, and VAT number from Recurly account data.
  1098. *
  1099. * For most customers, billing info and account info agree (or only one source is set),
  1100. * and the standard coalesce logic applies (billing info preferred, account as fallback).
  1101. *
  1102. * When any field has conflicting values across both sources, `fetchCollectionMethod`
  1103. * is called to determine which source wins:
  1104. *
  1105. * - automatic (web sales): billing info is used for the Stripe customer record.
  1106. * - manual (manual billing): account info is used for the Stripe customer record,
  1107. * and billing info is returned separately to be copied to the payment method's
  1108. * billing_details.
  1109. *
  1110. * @param {object} account - Recurly account object
  1111. * @param {() => Promise<string|null>} fetchCollectionMethod - Async callback that returns the
  1112. * subscription's collection method ('automatic' or 'manual') when a conflict is detected.
  1113. * @returns {Promise<{
  1114. * name: string|null,
  1115. * address: import('stripe').Stripe.AddressParam|null,
  1116. * companyName: string|null,
  1117. * vatNumber: string|null,
  1118. * collectionMethod: string|null,
  1119. * billingInfoForPaymentMethod: object|null
  1120. * }>}
  1121. */
  1122. export async function resolveCustomerIdentity(account, fetchCollectionMethod) {
  1123. const billingName = extractNameFromBillingInfo(account)
  1124. const accountName = extractNameFromAccount(account)
  1125. const nameConflict =
  1126. billingName !== null && accountName !== null && billingName !== accountName
  1127. const billingAddress = normalizeRecurlyAddressToStripe(
  1128. account.billingInfo?.address
  1129. )
  1130. const accountAddress = normalizeRecurlyAddressToStripe(account?.address)
  1131. const addressConflict =
  1132. billingAddress !== null &&
  1133. accountAddress !== null &&
  1134. !addressesEqual(billingAddress, accountAddress)
  1135. const billingCompany = account.billingInfo?.company?.trim() || null
  1136. const accountCompany = account.company?.trim() || null
  1137. const companyConflict =
  1138. billingCompany !== null &&
  1139. accountCompany !== null &&
  1140. billingCompany !== accountCompany
  1141. const billingVat = account.billingInfo?.vatNumber?.trim() || null
  1142. const accountVat = account?.vatNumber?.trim() || null
  1143. const vatConflict =
  1144. billingVat !== null && accountVat !== null && billingVat !== accountVat
  1145. const hasConflict =
  1146. nameConflict || addressConflict || companyConflict || vatConflict
  1147. let name,
  1148. address,
  1149. companyName,
  1150. vatNumber,
  1151. collectionMethod,
  1152. billingInfoForPaymentMethod
  1153. if (!hasConflict) {
  1154. name = billingName ?? accountName
  1155. address = billingAddress ?? accountAddress
  1156. companyName = billingCompany ?? accountCompany
  1157. vatNumber = billingVat ?? accountVat
  1158. collectionMethod = null
  1159. billingInfoForPaymentMethod = null
  1160. } else {
  1161. collectionMethod = await fetchCollectionMethod()
  1162. if (!collectionMethod) {
  1163. throw new Error(
  1164. 'Conflict between billing info and account fields, but no subscription found to determine collection method'
  1165. )
  1166. }
  1167. if (collectionMethod === 'automatic') {
  1168. name = billingName ?? accountName
  1169. address = billingAddress ?? accountAddress
  1170. companyName = billingCompany ?? accountCompany
  1171. vatNumber = billingVat ?? accountVat
  1172. billingInfoForPaymentMethod = null
  1173. } else if (collectionMethod === 'manual') {
  1174. name = accountName ?? billingName
  1175. address = accountAddress ?? billingAddress
  1176. companyName = accountCompany ?? billingCompany
  1177. vatNumber = accountVat ?? billingVat
  1178. billingInfoForPaymentMethod = account.billingInfo
  1179. } else {
  1180. throw new Error(`Unexpected collectionMethod: ${collectionMethod}`)
  1181. }
  1182. }
  1183. if (vatNumber && address?.country === 'GB') {
  1184. vatNumber = normalisedGBVATNumber(vatNumber)
  1185. }
  1186. return {
  1187. name,
  1188. address,
  1189. companyName,
  1190. vatNumber,
  1191. collectionMethod,
  1192. billingInfoForPaymentMethod,
  1193. }
  1194. }
  1195. // =============================================================================
  1196. // ACCOUNT COMPARISON
  1197. // =============================================================================
  1198. /**
  1199. * Compare account-level fields between a Recurly account and a Stripe customer.
  1200. *
  1201. * This encapsulates the drift-detection logic shared by both the finalize script
  1202. * and the compare script.
  1203. *
  1204. * @param {object} options
  1205. * @param {object} options.account - Recurly account (npm SDK camelCase format)
  1206. * @param {object} options.stripeCustomer - Stripe customer (expanded with tax_ids, default_payment_method)
  1207. * @param {string} options.overleafUserId - Overleaf user ID (for metadata.userId comparison)
  1208. * @param {() => Promise<string|null>} options.fetchCollectionMethod - Callback to get subscription collection method
  1209. * @param {Array} options.stripePaymentMethods - Pre-fetched Stripe payment methods
  1210. * @param {string} options.stripeServiceName - 'stripe-us' or 'stripe-uk'
  1211. * @returns {Promise<Record<string, { recurly: any, stripe: any }>>} - Per-field diffs (empty = no drift)
  1212. */
  1213. export async function compareAccountFields({
  1214. account,
  1215. stripeCustomer,
  1216. overleafUserId,
  1217. fetchCollectionMethod,
  1218. stripePaymentMethods,
  1219. stripeServiceName,
  1220. }) {
  1221. const { name, address, companyName, vatNumber } =
  1222. await resolveCustomerIdentity(account, fetchCollectionMethod)
  1223. const diffs = {}
  1224. // Email
  1225. if (
  1226. normalizeComparableString(account.email) !==
  1227. normalizeComparableString(stripeCustomer.email)
  1228. ) {
  1229. diffs.email = {
  1230. recurly: account.email || null,
  1231. stripe: stripeCustomer.email || null,
  1232. }
  1233. }
  1234. // Name
  1235. if (
  1236. normalizeComparableString(name) !==
  1237. normalizeComparableString(stripeCustomer.name)
  1238. ) {
  1239. diffs.name = {
  1240. recurly: name || null,
  1241. stripe: stripeCustomer.name || null,
  1242. }
  1243. }
  1244. // Address
  1245. if (address) {
  1246. if (!addressesEqual(address, stripeCustomer.address)) {
  1247. diffs.address = {
  1248. recurly: address,
  1249. stripe: stripeCustomer.address || null,
  1250. }
  1251. }
  1252. }
  1253. // Business name (company)
  1254. if (companyName) {
  1255. if (
  1256. normalizeComparableString(companyName) !==
  1257. normalizeComparableString(stripeCustomer.business_name)
  1258. ) {
  1259. diffs.business_name = {
  1260. recurly: companyName,
  1261. stripe: stripeCustomer.business_name || null,
  1262. }
  1263. }
  1264. }
  1265. // Metadata
  1266. const expectedMetadata = {}
  1267. if (account.createdAt) {
  1268. expectedMetadata.recurlyCreatedAt = account.createdAt.toISOString()
  1269. }
  1270. expectedMetadata.recurlyAccountCode = ''
  1271. expectedMetadata.userId = overleafUserId
  1272. const { metadata: customFieldMetadata } =
  1273. extractRecurlyCustomFieldMetadata(account)
  1274. Object.assign(expectedMetadata, customFieldMetadata)
  1275. // Tax ID
  1276. let expectedTaxIdType = null
  1277. let expectedTaxIdValue = null
  1278. if (vatNumber) {
  1279. const taxIdTypeResult = getTaxIdType(
  1280. address?.country,
  1281. vatNumber,
  1282. address?.postal_code,
  1283. false
  1284. )
  1285. if (taxIdTypeResult.type && address?.country) {
  1286. expectedTaxIdType = taxIdTypeResult.type
  1287. expectedTaxIdValue = vatNumber
  1288. }
  1289. }
  1290. for (const [key, expectedValue] of Object.entries(expectedMetadata)) {
  1291. const stripeValue = stripeCustomer.metadata?.[key] ?? ''
  1292. if (
  1293. normalizeComparableString(expectedValue) !==
  1294. normalizeComparableString(stripeValue)
  1295. ) {
  1296. diffs[`metadata.${key}`] = {
  1297. recurly: expectedValue,
  1298. stripe: stripeValue || null,
  1299. }
  1300. }
  1301. }
  1302. // Tax exempt
  1303. const expectedTaxExempt = account.taxExempt ? 'exempt' : 'none'
  1304. if (expectedTaxExempt !== (stripeCustomer.tax_exempt || 'none')) {
  1305. diffs.tax_exempt = {
  1306. recurly: expectedTaxExempt,
  1307. stripe: stripeCustomer.tax_exempt || 'none',
  1308. }
  1309. }
  1310. // CC emails
  1311. const expectedCcEmails = ccEmailsToArray(account.ccEmails)
  1312. const stripeCcEmails = stripeCustomer.additional_emails?.cc || []
  1313. const recurlyCcSorted = [...expectedCcEmails].sort()
  1314. const stripeCcSorted = [...stripeCcEmails].sort()
  1315. if (JSON.stringify(recurlyCcSorted) !== JSON.stringify(stripeCcSorted)) {
  1316. diffs.cc_emails = {
  1317. recurly: expectedCcEmails,
  1318. stripe: stripeCcEmails,
  1319. }
  1320. }
  1321. // Tax ID
  1322. if (expectedTaxIdType && expectedTaxIdValue) {
  1323. const normalizedExpectedTaxIdValue = normalizeTaxIdValue(expectedTaxIdValue)
  1324. const stripePendingTaxId = stripeCustomer.metadata?.taxInfoPending
  1325. ? [
  1326. {
  1327. type: expectedTaxIdType,
  1328. value: stripeCustomer.metadata.taxInfoPending,
  1329. },
  1330. ]
  1331. : null
  1332. const stripeTaxIdsFromStripe = stripeCustomer.tax_ids?.data
  1333. const stripeTaxIds =
  1334. Array.isArray(stripeTaxIdsFromStripe) && stripeTaxIdsFromStripe.length > 0
  1335. ? stripeTaxIdsFromStripe
  1336. : stripePendingTaxId || []
  1337. const matchingTaxId = stripeTaxIds.find(
  1338. tid =>
  1339. tid.type === expectedTaxIdType &&
  1340. normalizedExpectedTaxIdValue &&
  1341. normalizeTaxIdValue(tid.value) === normalizedExpectedTaxIdValue
  1342. )
  1343. if (!matchingTaxId) {
  1344. diffs.tax_id = {
  1345. recurly: { type: expectedTaxIdType, value: expectedTaxIdValue },
  1346. stripe:
  1347. stripeTaxIds.length > 0
  1348. ? stripeTaxIds.map(t => ({ type: t.type, value: t.value }))
  1349. : null,
  1350. }
  1351. }
  1352. }
  1353. // Default payment method
  1354. const stripeDefaultPaymentMethod =
  1355. stripeCustomer.invoice_settings?.default_payment_method
  1356. const isPaypalBillingAgreement =
  1357. account.billingInfo?.paymentMethod?.object === 'paypal_billing_agreement'
  1358. let expectedPaymentMethod = null
  1359. if (
  1360. isPaypalBillingAgreement &&
  1361. !['US', 'CA'].includes(address?.country) &&
  1362. stripeServiceName !== 'stripe-us'
  1363. ) {
  1364. expectedPaymentMethod = { type: 'paypal' }
  1365. }
  1366. if (
  1367. !isPaypalBillingAgreement &&
  1368. stripePaymentMethods.length > 0 &&
  1369. account.billingInfo &&
  1370. account.billingInfo.paymentMethod
  1371. ) {
  1372. expectedPaymentMethod = account.billingInfo.paymentMethod
  1373. }
  1374. const stripePaymentMethodCreatedAt = stripeDefaultPaymentMethod?.created
  1375. const recurlyPaymentMethodUpdatedAt =
  1376. (account.billingInfo?.updatedAt?.getTime() || 0) / 1000
  1377. const recurlyPaymentMethodIsNewer =
  1378. stripePaymentMethodCreatedAt < recurlyPaymentMethodUpdatedAt
  1379. const paymentMethodIsMissing =
  1380. (!stripeDefaultPaymentMethod && expectedPaymentMethod) ||
  1381. (stripeDefaultPaymentMethod && !expectedPaymentMethod)
  1382. const bothArePaypal =
  1383. expectedPaymentMethod?.type === 'paypal' &&
  1384. stripeDefaultPaymentMethod?.type === 'paypal'
  1385. if (bothArePaypal) {
  1386. // Both are PayPal — the payment method itself matches, but flag if
  1387. // Recurly billing info was updated after the Stripe payment method was
  1388. // created.
  1389. if (recurlyPaymentMethodIsNewer) {
  1390. diffs.default_payment_method = {
  1391. recurly: {
  1392. type: 'paypal',
  1393. updatedAt: account.billingInfo?.updatedAt?.toISOString() || null,
  1394. },
  1395. stripe: {
  1396. type: 'paypal',
  1397. created: stripeDefaultPaymentMethod?.created
  1398. ? new Date(stripeDefaultPaymentMethod.created * 1000).toISOString()
  1399. : null,
  1400. },
  1401. bothPaypal: true,
  1402. }
  1403. }
  1404. } else if (
  1405. paymentMethodIsMissing ||
  1406. (!areStripeAndRecurlyCardDetailsEqual(
  1407. stripeDefaultPaymentMethod,
  1408. expectedPaymentMethod
  1409. ) &&
  1410. recurlyPaymentMethodIsNewer)
  1411. ) {
  1412. diffs.default_payment_method = {
  1413. recurly: {
  1414. type: expectedPaymentMethod?.type || 'card',
  1415. last4: expectedPaymentMethod?.lastFour || null,
  1416. expiry: expectedPaymentMethod?.lastFour
  1417. ? `${expectedPaymentMethod.expMonth}/${expectedPaymentMethod.expYear}`
  1418. : null,
  1419. updatedAt: account.billingInfo?.updatedAt?.toISOString() || null,
  1420. },
  1421. stripe: {
  1422. type: stripeDefaultPaymentMethod?.type || null,
  1423. last4: stripeDefaultPaymentMethod?.card
  1424. ? stripeDefaultPaymentMethod.card.last4
  1425. : null,
  1426. expiry: stripeDefaultPaymentMethod?.card
  1427. ? `${stripeDefaultPaymentMethod.card.exp_month}/${stripeDefaultPaymentMethod.card.exp_year}`
  1428. : null,
  1429. created: stripeDefaultPaymentMethod?.created
  1430. ? new Date(stripeDefaultPaymentMethod.created * 1000).toISOString()
  1431. : null,
  1432. },
  1433. }
  1434. }
  1435. return diffs
  1436. }