migrate_recurly_customers_to_stripe.helpers.node.test.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. /* eslint-disable @overleaf/require-script-runner */
  2. import test from 'node:test'
  3. import assert from 'node:assert/strict'
  4. /*
  5. * This test can be run from the services/web directory with:
  6. *
  7. * node --test scripts/helpers/migrate_recurly_customers_to_stripe.helpers.node.test.mjs
  8. *
  9. * It can be deleted once the Recurly to Stripe migration is complete.
  10. */
  11. import {
  12. coalesceOrEqualOrThrow,
  13. coalesceOrEqualOrThrowAddress,
  14. coalesceOrEqualOrThrowName,
  15. coalesceOrThrowVATNumber,
  16. getCanadaTaxIdType,
  17. getAustraliaTaxIdType,
  18. getBrazilTaxIdType,
  19. getBulgariaTaxIdType,
  20. getCroatiaTaxIdType,
  21. getGermanyTaxIdType,
  22. getHungaryTaxIdType,
  23. getJapanTaxIdType,
  24. getLiechtensteinTaxIdType,
  25. getMalaysiaTaxIdType,
  26. getNorwayTaxIdType,
  27. getPolandTaxIdType,
  28. getRomaniaTaxIdType,
  29. getRussiaTaxIdType,
  30. getSingaporeTaxIdType,
  31. getSloveniaTaxIdType,
  32. getSpainTaxIdType,
  33. getSwitzerlandTaxIdType,
  34. getUkTaxIdType,
  35. getUzbekistanTaxIdType,
  36. getTaxIdType,
  37. coalesceOrThrowPaymentMethod,
  38. } from './migrate_recurly_customers_to_stripe.helpers.mjs'
  39. test('coalesceOrEqualOrThrow returns primary when set', () => {
  40. assert.equal(coalesceOrEqualOrThrow('a', undefined, 'field'), 'a')
  41. })
  42. test('coalesceOrEqualOrThrow returns fallback when primary is unset', () => {
  43. assert.equal(coalesceOrEqualOrThrow(undefined, 'b', 'field'), 'b')
  44. })
  45. test('coalesceOrEqualOrThrow returns value when both are set and equal', () => {
  46. assert.equal(coalesceOrEqualOrThrow('same', 'same', 'field'), 'same')
  47. })
  48. test('coalesceOrEqualOrThrow throws when both are set but differ', () => {
  49. assert.throws(
  50. () => coalesceOrEqualOrThrow('a', 'b', 'field'),
  51. /Primary and fallback values are both set but differ/
  52. )
  53. })
  54. test('coalesceOrEqualOrThrowAddress returns null when neither is valid', () => {
  55. assert.equal(coalesceOrEqualOrThrowAddress({}, null), null)
  56. assert.equal(
  57. coalesceOrEqualOrThrowAddress(
  58. { address: { street1: '', postalCode: '', country: '' } },
  59. { address: { street1: '', postalCode: '', country: '' } }
  60. ),
  61. null
  62. )
  63. assert.equal(
  64. coalesceOrEqualOrThrowAddress(
  65. { address: { street1: ' ', postalCode: ' ', country: ' ' } },
  66. null
  67. ),
  68. null
  69. )
  70. })
  71. test('coalesceOrEqualOrThrowAddress returns account when billing invalid', () => {
  72. const account = {
  73. address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' },
  74. }
  75. const billingInfo = {
  76. address: { street1: '', postalCode: 'ABC', country: 'GB' },
  77. }
  78. assert.deepEqual(coalesceOrEqualOrThrowAddress(account, billingInfo), {
  79. line1: '1 Road',
  80. postal_code: 'ABC',
  81. country: 'GB',
  82. })
  83. })
  84. test('coalesceOrEqualOrThrowAddress returns billing when account invalid', () => {
  85. const account = {
  86. address: { street1: '', postalCode: 'ABC', country: 'GB' },
  87. }
  88. const billingInfo = {
  89. address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' },
  90. }
  91. assert.deepEqual(coalesceOrEqualOrThrowAddress(account, billingInfo), {
  92. line1: '1 Road',
  93. postal_code: 'ABC',
  94. country: 'GB',
  95. })
  96. })
  97. test('coalesceOrEqualOrThrowAddress returns billing when both valid+equal', () => {
  98. const addr = { street1: '1 Road', postalCode: 'ABC', country: 'GB' }
  99. assert.deepEqual(
  100. coalesceOrEqualOrThrowAddress({ address: { ...addr } }, { address: addr }),
  101. { line1: '1 Road', postal_code: 'ABC', country: 'GB' }
  102. )
  103. })
  104. test('coalesceOrEqualOrThrowAddress normalizes Recurly-style address fields', () => {
  105. const billingInfo = {
  106. address: {
  107. street1: 'as',
  108. street2: '',
  109. city: '',
  110. region: '',
  111. postalCode: '12312',
  112. country: 'AI',
  113. },
  114. }
  115. assert.deepEqual(coalesceOrEqualOrThrowAddress({}, billingInfo), {
  116. line1: 'as',
  117. postal_code: '12312',
  118. country: 'AI',
  119. })
  120. })
  121. test('coalesceOrEqualOrThrowAddress throws when both valid but differ', () => {
  122. const account = {
  123. address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' },
  124. }
  125. const billingInfo = {
  126. address: { street1: '2 Road', postalCode: 'ABC', country: 'GB' },
  127. }
  128. assert.throws(
  129. () => coalesceOrEqualOrThrowAddress(account, billingInfo),
  130. /Billing address and account address differ/
  131. )
  132. })
  133. test('coalesceOrEqualOrThrowName returns billingInfo name when both sources match', () => {
  134. const account = { firstName: 'Alice', lastName: 'Billing' }
  135. const billingInfo = { firstName: 'Alice', lastName: 'Billing' }
  136. assert.equal(
  137. coalesceOrEqualOrThrowName(account, billingInfo),
  138. 'Alice Billing'
  139. )
  140. })
  141. test('coalesceOrEqualOrThrowName prefers billingInfo when billingInfo is full but account is not', () => {
  142. const account = { firstName: 'Alice', lastName: '' }
  143. const billingInfo = { firstName: 'Alice', lastName: 'Billing' }
  144. assert.equal(
  145. coalesceOrEqualOrThrowName(account, billingInfo),
  146. 'Alice Billing'
  147. )
  148. })
  149. test('coalesceOrEqualOrThrowName falls back to account when billingInfo missing last name', () => {
  150. const account = { firstName: 'Alice', lastName: 'Account' }
  151. const billingInfo = { firstName: 'Alice', lastName: '' }
  152. assert.equal(
  153. coalesceOrEqualOrThrowName(account, billingInfo),
  154. 'Alice Account'
  155. )
  156. })
  157. test('coalesceOrEqualOrThrowName returns null when both sources are empty', () => {
  158. assert.equal(coalesceOrEqualOrThrowName({}, null), null)
  159. assert.equal(
  160. coalesceOrEqualOrThrowName({ firstName: '', lastName: '' }, null),
  161. null
  162. )
  163. })
  164. test('coalesceOrEqualOrThrowName throws when both full names are present but differ', () => {
  165. const account = { firstName: 'Alice', lastName: 'Account' }
  166. const billingInfo = { firstName: 'Alice', lastName: 'Billing' }
  167. assert.throws(
  168. () => coalesceOrEqualOrThrowName(account, billingInfo),
  169. /Name differs between billingInfo and account/
  170. )
  171. })
  172. test('coalesceOrThrowVATNumber returns billingInfo VAT when set', () => {
  173. const account = { vatNumber: '' }
  174. const billingInfo = { vatNumber: 'BILL456' }
  175. assert.equal(coalesceOrThrowVATNumber(account, billingInfo), 'BILL456')
  176. })
  177. test('coalesceOrThrowVATNumber returns account VAT when billingInfo VAT unset', () => {
  178. const account = { vatNumber: 'ACCT123' }
  179. const billingInfo = { vatNumber: '' }
  180. assert.equal(coalesceOrThrowVATNumber(account, billingInfo), 'ACCT123')
  181. })
  182. test('coalesceOrThrowVATNumber returns null when neither is set', () => {
  183. assert.equal(coalesceOrThrowVATNumber({}, null), null)
  184. assert.equal(
  185. coalesceOrThrowVATNumber({ vatNumber: '' }, { vatNumber: '' }),
  186. null
  187. )
  188. })
  189. test('coalesceOrThrowVATNumber treats trimmed values as equal', () => {
  190. const account = { vatNumber: ' GB123 ' }
  191. const billingInfo = { vatNumber: 'GB123' }
  192. assert.equal(coalesceOrThrowVATNumber(account, billingInfo), 'GB123')
  193. })
  194. test('coalesceOrThrowVATNumber throws when both are set but differ', () => {
  195. const account = { vatNumber: 'GB123' }
  196. const billingInfo = { vatNumber: 'DE999' }
  197. assert.throws(
  198. () => coalesceOrThrowVATNumber(account, billingInfo),
  199. /Field vatNumber: Primary and fallback values are both set but differ/
  200. )
  201. })
  202. test('getCanadaTaxIdType returns ca_gst_hst for GST/HST format', () => {
  203. assert.equal(getCanadaTaxIdType('123456789RT0002', null), 'ca_gst_hst')
  204. assert.equal(getCanadaTaxIdType(' 123456789rt0002 ', null), 'ca_gst_hst')
  205. })
  206. test('getCanadaTaxIdType returns ca_qst for Quebec QST format', () => {
  207. assert.equal(getCanadaTaxIdType('1234567890TQ1234', null), 'ca_qst')
  208. assert.equal(getCanadaTaxIdType('1234567890tq1234', null), 'ca_qst')
  209. })
  210. test('getCanadaTaxIdType returns PST types for documented provincial formats', () => {
  211. assert.equal(getCanadaTaxIdType('PST-1234-5678', null), 'ca_pst_bc')
  212. assert.equal(getCanadaTaxIdType('123456-7', 'R3C 4T3'), 'ca_pst_mb')
  213. assert.equal(getCanadaTaxIdType('1234567', 'S7K 3J8'), 'ca_pst_sk')
  214. })
  215. test('getCanadaTaxIdType returns ca_bn for 9-digit BN format', () => {
  216. // assert.equal(getCanadaTaxIdType('123456789', null), 'ca_bn')
  217. assert.equal(getCanadaTaxIdType('123456789', null), null) // TODO: improve function get definitive ca_bn vs ca_gst_hst
  218. })
  219. test('getCanadaTaxIdType returns null when format is unknown/ambiguous', () => {
  220. assert.equal(getCanadaTaxIdType('', null), null)
  221. assert.equal(getCanadaTaxIdType(null, null), null)
  222. assert.equal(getCanadaTaxIdType('RT0002', null), null)
  223. assert.equal(getCanadaTaxIdType('PST12345678', null), null)
  224. })
  225. test('getAustraliaTaxIdType distinguishes ABN vs ARN', () => {
  226. assert.equal(getAustraliaTaxIdType('12345678912'), 'au_abn')
  227. assert.equal(getAustraliaTaxIdType('123456789123'), 'au_arn')
  228. })
  229. test('getBrazilTaxIdType distinguishes CNPJ vs CPF', () => {
  230. assert.equal(getBrazilTaxIdType('01.234.456/5432-10'), 'br_cnpj')
  231. assert.equal(getBrazilTaxIdType('123.456.789-87'), 'br_cpf')
  232. })
  233. test('getBulgariaTaxIdType distinguishes EU VAT vs UIC', () => {
  234. assert.equal(getBulgariaTaxIdType('BG0123456789'), 'eu_vat')
  235. assert.equal(getBulgariaTaxIdType('123456789'), 'bg_uic')
  236. })
  237. test('getCroatiaTaxIdType distinguishes EU VAT vs OIB', () => {
  238. assert.equal(getCroatiaTaxIdType('HR12345678912'), 'eu_vat')
  239. assert.equal(getCroatiaTaxIdType('12345678901'), 'hr_oib')
  240. })
  241. test('getGermanyTaxIdType distinguishes EU VAT vs Steuer Nummer', () => {
  242. assert.equal(getGermanyTaxIdType('DE123456789'), 'eu_vat')
  243. assert.equal(getGermanyTaxIdType('1234567890'), 'de_stn')
  244. })
  245. test('getHungaryTaxIdType distinguishes EU VAT vs HU tax number', () => {
  246. assert.equal(getHungaryTaxIdType('HU12345678'), 'eu_vat')
  247. assert.equal(getHungaryTaxIdType('12345678-1-23'), 'hu_tin')
  248. })
  249. test('getJapanTaxIdType distinguishes TRN, CN, RN', () => {
  250. assert.equal(getJapanTaxIdType('T1234567891234'), 'jp_trn')
  251. assert.equal(getJapanTaxIdType('1234567891234'), 'jp_cn')
  252. assert.equal(getJapanTaxIdType('12345'), 'jp_rn')
  253. })
  254. test('getLiechtensteinTaxIdType distinguishes UID vs VAT', () => {
  255. assert.equal(getLiechtensteinTaxIdType('CHE123456789'), 'li_uid')
  256. assert.equal(getLiechtensteinTaxIdType('12345'), 'li_vat')
  257. })
  258. test('getMalaysiaTaxIdType distinguishes FRP, ITN, SST', () => {
  259. assert.equal(getMalaysiaTaxIdType('12345678'), 'my_frp')
  260. assert.equal(getMalaysiaTaxIdType('C 1234567890'), 'my_itn')
  261. assert.equal(getMalaysiaTaxIdType('A12-3456-78912345'), 'my_sst')
  262. })
  263. test('getNorwayTaxIdType distinguishes VAT vs VOEC', () => {
  264. assert.equal(getNorwayTaxIdType('123456789MVA'), 'no_vat')
  265. assert.equal(getNorwayTaxIdType('1234567'), 'no_voec')
  266. })
  267. test('getPolandTaxIdType distinguishes EU VAT vs NIP', () => {
  268. assert.equal(getPolandTaxIdType('PL1234567890'), 'eu_vat')
  269. assert.equal(getPolandTaxIdType('1234567890'), 'pl_nip')
  270. })
  271. test('getRomaniaTaxIdType distinguishes EU VAT vs TIN', () => {
  272. assert.equal(getRomaniaTaxIdType('RO1234567891'), 'eu_vat')
  273. assert.equal(getRomaniaTaxIdType('1234567890123'), 'ro_tin')
  274. })
  275. test('getRussiaTaxIdType distinguishes INN vs KPP', () => {
  276. assert.equal(getRussiaTaxIdType('1234567891'), 'ru_inn')
  277. assert.equal(getRussiaTaxIdType('123456789'), 'ru_kpp')
  278. })
  279. test('getSingaporeTaxIdType distinguishes GST vs UEN', () => {
  280. assert.equal(getSingaporeTaxIdType('M12345678X'), 'sg_gst')
  281. assert.equal(getSingaporeTaxIdType('123456789F'), 'sg_uen')
  282. })
  283. test('getSloveniaTaxIdType distinguishes EU VAT vs TIN', () => {
  284. assert.equal(getSloveniaTaxIdType('SI12345678'), 'eu_vat')
  285. assert.equal(getSloveniaTaxIdType('12345678'), 'si_tin')
  286. })
  287. test('getSpainTaxIdType distinguishes EU VAT vs CIF', () => {
  288. assert.equal(getSpainTaxIdType('ESA1234567Z'), 'eu_vat')
  289. assert.equal(getSpainTaxIdType('A12345678'), 'es_cif')
  290. })
  291. test('getSwitzerlandTaxIdType distinguishes UID vs VAT', () => {
  292. assert.equal(getSwitzerlandTaxIdType('CHE-123.456.789 HR'), 'ch_uid')
  293. assert.equal(getSwitzerlandTaxIdType('CHE-123.456.789 MWST'), 'ch_vat')
  294. })
  295. test('getUkTaxIdType distinguishes GB VAT vs EU VAT (NI)', () => {
  296. assert.equal(getUkTaxIdType('GB123456789'), 'gb_vat')
  297. assert.equal(getUkTaxIdType('XI123456789'), 'eu_vat')
  298. })
  299. test('getUzbekistanTaxIdType distinguishes TIN vs VAT', () => {
  300. assert.equal(getUzbekistanTaxIdType('123456789'), 'uz_tin')
  301. assert.equal(getUzbekistanTaxIdType('123456789012'), 'uz_vat')
  302. })
  303. test('getTaxIdType handles EU OSS VAT and EU VAT defaults', () => {
  304. assert.equal(getTaxIdType('EU', 'EU123456789'), 'eu_oss_vat')
  305. assert.equal(getTaxIdType('AT', 'ATU12345678'), 'eu_vat')
  306. })
  307. test('coalesceOrThrowPaymentMethod throws when payment methods array is empty', () => {
  308. assert.throws(
  309. () => coalesceOrThrowPaymentMethod([], 'cus_123', {}),
  310. /Stripe customer cus_123 has no usable payment method/
  311. )
  312. })
  313. test('coalesceOrThrowPaymentMethod throws when no payment methods match billing info', () => {
  314. const paymentMethods = [
  315. {
  316. id: 'pm_1',
  317. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  318. },
  319. ]
  320. const billingInfo = {
  321. paymentMethod: { lastFour: '5678', expMonth: 12, expYear: 2030 },
  322. }
  323. assert.throws(
  324. () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', billingInfo),
  325. /Stripe customer cus_123 has no usable payment method/
  326. )
  327. })
  328. test('coalesceOrThrowPaymentMethod returns matching payment method', () => {
  329. const paymentMethod = {
  330. id: 'pm_match',
  331. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  332. }
  333. const paymentMethods = [paymentMethod]
  334. const billingInfo = {
  335. paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
  336. }
  337. const result = coalesceOrThrowPaymentMethod(
  338. paymentMethods,
  339. 'cus_123',
  340. billingInfo
  341. )
  342. assert.equal(result, paymentMethod)
  343. })
  344. test('coalesceOrThrowPaymentMethod matches first of multiple matching methods', () => {
  345. const paymentMethod1 = {
  346. id: 'pm_1',
  347. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  348. }
  349. const paymentMethod2 = {
  350. id: 'pm_2',
  351. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  352. }
  353. const paymentMethods = [paymentMethod1, paymentMethod2]
  354. const billingInfo = {
  355. paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
  356. }
  357. const result = coalesceOrThrowPaymentMethod(
  358. paymentMethods,
  359. 'cus_123',
  360. billingInfo
  361. )
  362. assert.equal(result, paymentMethod1)
  363. })
  364. test('coalesceOrThrowPaymentMethod filters out non-matching methods', () => {
  365. const matchingMethod = {
  366. id: 'pm_match',
  367. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  368. }
  369. const nonMatchingMethod = {
  370. id: 'pm_no_match',
  371. card: { last4: '5678', exp_month: 12, exp_year: 2030 },
  372. }
  373. const paymentMethods = [nonMatchingMethod, matchingMethod]
  374. const billingInfo = {
  375. paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
  376. }
  377. const result = coalesceOrThrowPaymentMethod(
  378. paymentMethods,
  379. 'cus_123',
  380. billingInfo
  381. )
  382. assert.equal(result, matchingMethod)
  383. })
  384. test('coalesceOrThrowPaymentMethod handles null billingInfo', () => {
  385. const paymentMethods = [
  386. {
  387. id: 'pm_1',
  388. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  389. },
  390. ]
  391. assert.throws(
  392. () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', null),
  393. /Stripe customer cus_123 has no usable payment method/
  394. )
  395. })
  396. test('coalesceOrThrowPaymentMethod handles missing paymentMethod in billingInfo', () => {
  397. const paymentMethods = [
  398. {
  399. id: 'pm_1',
  400. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  401. },
  402. ]
  403. const billingInfo = {}
  404. assert.throws(
  405. () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', billingInfo),
  406. /Stripe customer cus_123 has no usable payment method/
  407. )
  408. })
  409. test('coalesceOrThrowPaymentMethod handles missing card in payment method', () => {
  410. const paymentMethods = [
  411. { id: 'pm_1' }, // no card property
  412. {
  413. id: 'pm_2',
  414. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  415. },
  416. ]
  417. const billingInfo = {
  418. paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
  419. }
  420. const result = coalesceOrThrowPaymentMethod(
  421. paymentMethods,
  422. 'cus_123',
  423. billingInfo
  424. )
  425. assert.equal(result.id, 'pm_2')
  426. })