migrate_recurly_customers_to_stripe.helpers.node.test.mjs 31 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055
  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. coalesceOrThrowVATNumber,
  14. extractNameFromAccount,
  15. getCanadaTaxIdType,
  16. getAustraliaTaxIdType,
  17. getBrazilTaxIdType,
  18. getBulgariaTaxIdType,
  19. getCroatiaTaxIdType,
  20. getGermanyTaxIdType,
  21. getHungaryTaxIdType,
  22. getJapanTaxIdType,
  23. getLiechtensteinTaxIdType,
  24. getMalaysiaTaxIdType,
  25. getNorwayTaxIdType,
  26. getPolandTaxIdType,
  27. getRomaniaTaxIdType,
  28. getRussiaTaxIdType,
  29. getSingaporeTaxIdType,
  30. getSloveniaTaxIdType,
  31. getSpainTaxIdType,
  32. getSwitzerlandTaxIdType,
  33. getUkTaxIdType,
  34. getUzbekistanTaxIdType,
  35. getTaxIdType,
  36. coalesceOrThrowPaymentMethod,
  37. normalisedGBVATNumber,
  38. normalizeComparableString,
  39. hasAnyAddressValue,
  40. ccEmailsToArray,
  41. normalizeTaxIdValue,
  42. extractRecurlyCustomFieldMetadata,
  43. addressesEqual,
  44. resolveCustomerIdentity,
  45. compareAccountFields,
  46. areStripeAndRecurlyCardDetailsEqual,
  47. } from './migrate_recurly_customers_to_stripe.helpers.mjs'
  48. test('extractNameFromAccount returns normalized full name when first and last are present', () => {
  49. const account = {
  50. firstName: ' Alice ',
  51. lastName: ' Example ',
  52. }
  53. assert.equal(extractNameFromAccount(account), 'Alice Example')
  54. })
  55. test('extractNameFromAccount falls back to firstName when lastName is missing', () => {
  56. const account = {
  57. firstName: 'Alice Example',
  58. lastName: '',
  59. }
  60. assert.equal(extractNameFromAccount(account), 'Alice Example')
  61. })
  62. test('coalesceOrEqualOrThrow returns primary when set', () => {
  63. assert.equal(coalesceOrEqualOrThrow('a', undefined, 'field'), 'a')
  64. })
  65. test('coalesceOrEqualOrThrow returns fallback when primary is unset', () => {
  66. assert.equal(coalesceOrEqualOrThrow(undefined, 'b', 'field'), 'b')
  67. })
  68. test('coalesceOrEqualOrThrow returns value when both are set and equal', () => {
  69. assert.equal(coalesceOrEqualOrThrow('same', 'same', 'field'), 'same')
  70. })
  71. test('coalesceOrEqualOrThrow throws when both are set but differ', () => {
  72. assert.throws(
  73. () => coalesceOrEqualOrThrow('a', 'b', 'field'),
  74. /Primary and fallback values are both set but differ/
  75. )
  76. })
  77. test('coalesceOrThrowVATNumber returns billingInfo VAT when set', () => {
  78. const account = {
  79. vatNumber: '',
  80. billingInfo: { vatNumber: 'BILL456' },
  81. }
  82. assert.equal(coalesceOrThrowVATNumber(account), 'BILL456')
  83. })
  84. test('coalesceOrThrowVATNumber returns account VAT when billingInfo VAT unset', () => {
  85. const account = {
  86. vatNumber: 'ACCT123',
  87. billingInfo: { vatNumber: '' },
  88. }
  89. assert.equal(coalesceOrThrowVATNumber(account), 'ACCT123')
  90. })
  91. test('coalesceOrThrowVATNumber returns null when neither is set', () => {
  92. assert.equal(coalesceOrThrowVATNumber({}), null)
  93. assert.equal(
  94. coalesceOrThrowVATNumber({ vatNumber: '', billingInfo: { vatNumber: '' } }),
  95. null
  96. )
  97. })
  98. test('coalesceOrThrowVATNumber treats trimmed values as equal', () => {
  99. const account = {
  100. vatNumber: ' GB123 ',
  101. billingInfo: { vatNumber: 'GB123' },
  102. }
  103. assert.equal(coalesceOrThrowVATNumber(account), 'GB123')
  104. })
  105. test('coalesceOrThrowVATNumber throws when both are set but differ', () => {
  106. const account = {
  107. vatNumber: 'GB123',
  108. billingInfo: { vatNumber: 'DE999' },
  109. }
  110. assert.throws(
  111. () => coalesceOrThrowVATNumber(account),
  112. /Field vatNumber: Primary and fallback values are both set but differ/
  113. )
  114. })
  115. test('getCanadaTaxIdType returns ca_gst_hst for GST/HST format', () => {
  116. assert.equal(getCanadaTaxIdType('123456789RT0002', null), 'ca_gst_hst')
  117. assert.equal(getCanadaTaxIdType(' 123456789rt0002 ', null), 'ca_gst_hst')
  118. })
  119. test('getCanadaTaxIdType returns ca_qst for Quebec QST format', () => {
  120. assert.equal(getCanadaTaxIdType('1234567890TQ1234', null), 'ca_qst')
  121. assert.equal(getCanadaTaxIdType('1234567890tq1234', null), 'ca_qst')
  122. })
  123. test('getCanadaTaxIdType returns PST types for documented provincial formats', () => {
  124. assert.equal(getCanadaTaxIdType('PST-1234-5678', null), 'ca_pst_bc')
  125. assert.equal(getCanadaTaxIdType('123456-7', 'R3C 4T3'), 'ca_pst_mb')
  126. assert.equal(getCanadaTaxIdType('1234567', 'S7K 3J8'), 'ca_pst_sk')
  127. })
  128. test('getCanadaTaxIdType returns ca_bn for 9-digit BN format', () => {
  129. // assert.equal(getCanadaTaxIdType('123456789', null), 'ca_bn')
  130. assert.equal(getCanadaTaxIdType('123456789', null), null) // TODO: improve function get definitive ca_bn vs ca_gst_hst
  131. })
  132. test('getCanadaTaxIdType returns null when format is unknown/ambiguous', () => {
  133. assert.equal(getCanadaTaxIdType('', null), null)
  134. assert.equal(getCanadaTaxIdType(null, null), null)
  135. assert.equal(getCanadaTaxIdType('RT0002', null), null)
  136. assert.equal(getCanadaTaxIdType('PST12345678', null), null)
  137. })
  138. test('getAustraliaTaxIdType distinguishes ABN vs ARN', () => {
  139. assert.equal(getAustraliaTaxIdType('12345678912'), 'au_abn')
  140. assert.equal(getAustraliaTaxIdType('123456789123'), 'au_arn')
  141. })
  142. test('getBrazilTaxIdType distinguishes CNPJ vs CPF', () => {
  143. assert.equal(getBrazilTaxIdType('01.234.456/5432-10'), 'br_cnpj')
  144. assert.equal(getBrazilTaxIdType('123.456.789-87'), 'br_cpf')
  145. })
  146. test('getBulgariaTaxIdType distinguishes EU VAT vs UIC', () => {
  147. assert.equal(getBulgariaTaxIdType('BG0123456789'), 'eu_vat')
  148. assert.equal(getBulgariaTaxIdType('123456789'), 'bg_uic')
  149. })
  150. test('getCroatiaTaxIdType distinguishes EU VAT vs OIB', () => {
  151. assert.equal(getCroatiaTaxIdType('HR12345678912'), 'eu_vat')
  152. assert.equal(getCroatiaTaxIdType('12345678901'), 'hr_oib')
  153. })
  154. test('getGermanyTaxIdType distinguishes EU VAT vs Steuer Nummer', () => {
  155. assert.equal(getGermanyTaxIdType('DE123456789'), 'eu_vat')
  156. assert.equal(getGermanyTaxIdType('1234567890'), 'de_stn')
  157. })
  158. test('getHungaryTaxIdType distinguishes EU VAT vs HU tax number', () => {
  159. assert.equal(getHungaryTaxIdType('HU12345678'), 'eu_vat')
  160. assert.equal(getHungaryTaxIdType('12345678-1-23'), 'hu_tin')
  161. })
  162. test('getJapanTaxIdType distinguishes TRN, CN, RN', () => {
  163. assert.equal(getJapanTaxIdType('T1234567891234'), 'jp_trn')
  164. assert.equal(getJapanTaxIdType('1234567891234'), 'jp_cn')
  165. assert.equal(getJapanTaxIdType('12345'), 'jp_rn')
  166. })
  167. test('getLiechtensteinTaxIdType distinguishes UID vs VAT', () => {
  168. assert.equal(getLiechtensteinTaxIdType('CHE123456789'), 'li_uid')
  169. assert.equal(getLiechtensteinTaxIdType('12345'), 'li_vat')
  170. })
  171. test('getMalaysiaTaxIdType distinguishes FRP, ITN, SST', () => {
  172. assert.equal(getMalaysiaTaxIdType('12345678'), 'my_frp')
  173. assert.equal(getMalaysiaTaxIdType('C 1234567890'), 'my_itn')
  174. assert.equal(getMalaysiaTaxIdType('A12-3456-78912345'), 'my_sst')
  175. })
  176. test('getNorwayTaxIdType distinguishes VAT vs VOEC', () => {
  177. assert.equal(getNorwayTaxIdType('123456789MVA'), 'no_vat')
  178. assert.equal(getNorwayTaxIdType('1234567'), 'no_voec')
  179. })
  180. test('getPolandTaxIdType distinguishes EU VAT vs NIP', () => {
  181. assert.equal(getPolandTaxIdType('PL1234567890'), 'eu_vat')
  182. assert.equal(getPolandTaxIdType('1234567890'), 'pl_nip')
  183. })
  184. test('getRomaniaTaxIdType distinguishes EU VAT vs TIN', () => {
  185. assert.equal(getRomaniaTaxIdType('RO1234567891'), 'eu_vat')
  186. assert.equal(getRomaniaTaxIdType('1234567890123'), 'ro_tin')
  187. })
  188. test('getRussiaTaxIdType distinguishes INN vs KPP', () => {
  189. assert.equal(getRussiaTaxIdType('1234567891'), 'ru_inn')
  190. assert.equal(getRussiaTaxIdType('123456789'), 'ru_kpp')
  191. })
  192. test('getSingaporeTaxIdType distinguishes GST vs UEN', () => {
  193. assert.equal(getSingaporeTaxIdType('M12345678X'), 'sg_gst')
  194. assert.equal(getSingaporeTaxIdType('123456789F'), 'sg_uen')
  195. })
  196. test('getSloveniaTaxIdType distinguishes EU VAT vs TIN', () => {
  197. assert.equal(getSloveniaTaxIdType('SI12345678'), 'eu_vat')
  198. assert.equal(getSloveniaTaxIdType('12345678'), 'si_tin')
  199. })
  200. test('getSpainTaxIdType distinguishes EU VAT vs CIF', () => {
  201. assert.equal(getSpainTaxIdType('ESA1234567Z'), 'eu_vat')
  202. assert.equal(getSpainTaxIdType('A12345678'), 'es_cif')
  203. })
  204. test('getSwitzerlandTaxIdType distinguishes UID vs VAT', () => {
  205. assert.equal(getSwitzerlandTaxIdType('CHE-123.456.789 HR'), 'ch_uid')
  206. assert.equal(getSwitzerlandTaxIdType('CHE-123.456.789 MWST'), 'ch_vat')
  207. })
  208. test('getUkTaxIdType distinguishes GB VAT vs EU VAT (NI)', () => {
  209. assert.equal(getUkTaxIdType('GB123456789'), 'gb_vat')
  210. assert.equal(getUkTaxIdType('XI123456789'), 'eu_vat')
  211. })
  212. test('getUzbekistanTaxIdType distinguishes TIN vs VAT', () => {
  213. assert.equal(getUzbekistanTaxIdType('123456789'), 'uz_tin')
  214. assert.equal(getUzbekistanTaxIdType('123456789012'), 'uz_vat')
  215. })
  216. test('getTaxIdType handles EU OSS VAT and EU VAT defaults', () => {
  217. assert.equal(getTaxIdType('EU', 'EU123456789').type, 'eu_oss_vat')
  218. assert.equal(getTaxIdType('AT', 'ATU12345678').type, 'eu_vat')
  219. })
  220. test('getTaxIdType includes failure reason', () => {
  221. const missingCountry = getTaxIdType(null, '12345', null)
  222. assert.equal(missingCountry.type, null)
  223. assert.equal(missingCountry.reason, 'missing country')
  224. const invalidEuOss = getTaxIdType('EU', 'INVALID', null)
  225. assert.equal(invalidEuOss.type, null)
  226. assert.equal(invalidEuOss.reason, 'invalid EU OSS VAT number')
  227. })
  228. test('getTaxIdType pre-validation is opt-in', () => {
  229. // Default behavior: no country-format pre-validation
  230. const noPreValidation = getTaxIdType('US', 'NOT_A_VALID_EIN', null)
  231. assert.equal(noPreValidation.type, 'us_ein')
  232. assert.equal(noPreValidation.reason, null)
  233. // Optional behavior: enable country-format pre-validation
  234. const withPreValidation = getTaxIdType('US', 'NOT_A_VALID_EIN', null, true)
  235. assert.equal(withPreValidation.type, null)
  236. assert.equal(withPreValidation.reason, 'invalid tax ID format for country US')
  237. })
  238. test('getTaxIdType pre-validates EU VAT-only countries when enabled', () => {
  239. const invalidWithPreValidation = getTaxIdType('AT', '12345678', null, true)
  240. assert.equal(invalidWithPreValidation.type, null)
  241. assert.equal(
  242. invalidWithPreValidation.reason,
  243. 'invalid tax ID format for country AT'
  244. )
  245. const validWithPreValidation = getTaxIdType('AT', 'ATU12345678', null, true)
  246. assert.equal(validWithPreValidation.type, 'eu_vat')
  247. assert.equal(validWithPreValidation.reason, null)
  248. })
  249. test('coalesceOrThrowPaymentMethod throws when payment methods array is empty', () => {
  250. assert.throws(
  251. () => coalesceOrThrowPaymentMethod([], 'cus_123', {}),
  252. /Stripe customer cus_123 has no usable payment method/
  253. )
  254. })
  255. test('coalesceOrThrowPaymentMethod throws when no payment methods match billing info', () => {
  256. const paymentMethods = [
  257. {
  258. id: 'pm_1',
  259. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  260. },
  261. ]
  262. const billingInfo = {
  263. paymentMethod: { lastFour: '5678', expMonth: 12, expYear: 2030 },
  264. }
  265. assert.throws(
  266. () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', billingInfo),
  267. /Stripe customer cus_123 has no usable payment method/
  268. )
  269. })
  270. test('coalesceOrThrowPaymentMethod returns matching payment method', () => {
  271. const paymentMethod = {
  272. id: 'pm_match',
  273. type: 'card',
  274. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  275. }
  276. const paymentMethods = [paymentMethod]
  277. const billingInfo = {
  278. paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
  279. }
  280. const result = coalesceOrThrowPaymentMethod(
  281. paymentMethods,
  282. 'cus_123',
  283. billingInfo
  284. )
  285. assert.equal(result, paymentMethod)
  286. })
  287. test('coalesceOrThrowPaymentMethod matches first of multiple matching methods', () => {
  288. const paymentMethod1 = {
  289. id: 'pm_1',
  290. type: 'card',
  291. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  292. }
  293. const paymentMethod2 = {
  294. id: 'pm_2',
  295. type: 'card',
  296. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  297. }
  298. const paymentMethods = [paymentMethod1, paymentMethod2]
  299. const billingInfo = {
  300. paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
  301. }
  302. const result = coalesceOrThrowPaymentMethod(
  303. paymentMethods,
  304. 'cus_123',
  305. billingInfo
  306. )
  307. assert.equal(result, paymentMethod1)
  308. })
  309. test('coalesceOrThrowPaymentMethod filters out non-matching methods', () => {
  310. const matchingMethod = {
  311. id: 'pm_match',
  312. type: 'card',
  313. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  314. }
  315. const nonMatchingMethod = {
  316. id: 'pm_no_match',
  317. type: 'card',
  318. card: { last4: '5678', exp_month: 12, exp_year: 2030 },
  319. }
  320. const paymentMethods = [nonMatchingMethod, matchingMethod]
  321. const billingInfo = {
  322. paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
  323. }
  324. const result = coalesceOrThrowPaymentMethod(
  325. paymentMethods,
  326. 'cus_123',
  327. billingInfo
  328. )
  329. assert.equal(result, matchingMethod)
  330. })
  331. test('coalesceOrThrowPaymentMethod handles null billingInfo', () => {
  332. const paymentMethods = [
  333. {
  334. id: 'pm_1',
  335. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  336. },
  337. ]
  338. assert.throws(
  339. () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', null),
  340. /Stripe customer cus_123 has no usable payment method/
  341. )
  342. })
  343. test('coalesceOrThrowPaymentMethod handles missing paymentMethod in billingInfo', () => {
  344. const paymentMethods = [
  345. {
  346. id: 'pm_1',
  347. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  348. },
  349. ]
  350. const billingInfo = {}
  351. assert.throws(
  352. () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', billingInfo),
  353. /Stripe customer cus_123 has no usable payment method/
  354. )
  355. })
  356. test('coalesceOrThrowPaymentMethod handles missing card in payment method', () => {
  357. const paymentMethods = [
  358. { id: 'pm_1' }, // no card property
  359. {
  360. id: 'pm_2',
  361. type: 'card',
  362. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  363. },
  364. ]
  365. const billingInfo = {
  366. paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
  367. }
  368. const result = coalesceOrThrowPaymentMethod(
  369. paymentMethods,
  370. 'cus_123',
  371. billingInfo
  372. )
  373. assert.equal(result.id, 'pm_2')
  374. })
  375. test('normalisedGBVATNumber strips spaces and punctuation', () => {
  376. assert.equal(normalisedGBVATNumber('123 456 789'), 'GB123456789')
  377. assert.equal(normalisedGBVATNumber('123-456-789'), 'GB123456789')
  378. assert.equal(normalisedGBVATNumber('123.456.789'), 'GB123456789')
  379. assert.equal(normalisedGBVATNumber(' 123 456 789 '), 'GB123456789')
  380. })
  381. test('normalisedGBVATNumber prepends GB if not present', () => {
  382. assert.equal(normalisedGBVATNumber('123456789'), 'GB123456789')
  383. assert.equal(normalisedGBVATNumber('123456789012'), 'GB123456789012')
  384. })
  385. test('normalisedGBVATNumber does not prepend GB if already present', () => {
  386. assert.equal(normalisedGBVATNumber('GB123456789'), 'GB123456789')
  387. assert.equal(normalisedGBVATNumber('GB123456789012'), 'GB123456789012')
  388. assert.equal(normalisedGBVATNumber('gb123456789'), 'GB123456789')
  389. })
  390. test('normalisedGBVATNumber removes trailing GB', () => {
  391. assert.equal(normalisedGBVATNumber('123456789GB'), 'GB123456789')
  392. assert.equal(normalisedGBVATNumber('GB123456789GB'), 'GB123456789')
  393. })
  394. test('normalisedGBVATNumber returns original if invalid after normalization', () => {
  395. // Invalid length (8 digits instead of 9 or 12)
  396. const invalid = '12345678'
  397. assert.equal(normalisedGBVATNumber(invalid), invalid)
  398. // Invalid length (10 digits instead of 9 or 12)
  399. const invalid2 = '1234567890'
  400. assert.equal(normalisedGBVATNumber(invalid2), invalid2)
  401. // Invalid format with letters
  402. const invalid3 = 'ABC123456'
  403. assert.equal(normalisedGBVATNumber(invalid3), invalid3)
  404. })
  405. test('normalisedGBVATNumber handles null and undefined', () => {
  406. assert.equal(normalisedGBVATNumber(null), null)
  407. assert.equal(normalisedGBVATNumber(undefined), undefined)
  408. assert.equal(normalisedGBVATNumber(''), '')
  409. })
  410. test('normalisedGBVATNumber handles valid 9-digit GB VAT numbers', () => {
  411. assert.equal(normalisedGBVATNumber('123456789'), 'GB123456789')
  412. assert.equal(normalisedGBVATNumber('GB123456789'), 'GB123456789')
  413. assert.equal(normalisedGBVATNumber(' GB 123 456 789 '), 'GB123456789')
  414. })
  415. test('normalisedGBVATNumber handles valid 12-digit GB VAT numbers', () => {
  416. assert.equal(normalisedGBVATNumber('123456789012'), 'GB123456789012')
  417. assert.equal(normalisedGBVATNumber('GB123456789012'), 'GB123456789012')
  418. assert.equal(normalisedGBVATNumber('GB 123 456 789 012'), 'GB123456789012')
  419. })
  420. test('normalisedGBVATNumber preserves Northern Ireland XI numbers', () => {
  421. // XI numbers should not be modified to GB
  422. assert.equal(normalisedGBVATNumber('XI123456789'), 'XI123456789')
  423. })
  424. test('normalizeComparableString returns empty string for null/undefined', () => {
  425. assert.equal(normalizeComparableString(null), '')
  426. assert.equal(normalizeComparableString(undefined), '')
  427. })
  428. test('normalizeComparableString trims whitespace', () => {
  429. assert.equal(normalizeComparableString(' hello '), 'hello')
  430. assert.equal(normalizeComparableString(' '), '')
  431. })
  432. test('normalizeComparableString converts non-strings', () => {
  433. assert.equal(normalizeComparableString(42), '42')
  434. assert.equal(normalizeComparableString(0), '0')
  435. })
  436. test('hasAnyAddressValue returns false for null/undefined/non-object', () => {
  437. assert.equal(hasAnyAddressValue(null), false)
  438. assert.equal(hasAnyAddressValue(undefined), false)
  439. assert.equal(hasAnyAddressValue('string'), false)
  440. })
  441. test('hasAnyAddressValue returns false for all-empty address', () => {
  442. assert.equal(hasAnyAddressValue({ line1: '', line2: '', city: '' }), false)
  443. assert.equal(hasAnyAddressValue({ line1: ' ', city: null }), false)
  444. })
  445. test('hasAnyAddressValue returns true when any field is non-empty', () => {
  446. assert.equal(hasAnyAddressValue({ line1: '123 Main St' }), true)
  447. assert.equal(hasAnyAddressValue({ country: 'US' }), true)
  448. })
  449. test('ccEmailsToArray returns empty array for null/undefined', () => {
  450. assert.deepEqual(ccEmailsToArray(null), [])
  451. assert.deepEqual(ccEmailsToArray(undefined), [])
  452. })
  453. test('ccEmailsToArray splits on commas, semicolons, and whitespace', () => {
  454. assert.deepEqual(ccEmailsToArray('a@b.com,c@d.com'), ['a@b.com', 'c@d.com'])
  455. assert.deepEqual(ccEmailsToArray('a@b.com;c@d.com'), ['a@b.com', 'c@d.com'])
  456. assert.deepEqual(ccEmailsToArray('a@b.com c@d.com'), ['a@b.com', 'c@d.com'])
  457. })
  458. test('ccEmailsToArray deduplicates', () => {
  459. assert.deepEqual(ccEmailsToArray('a@b.com,a@b.com,c@d.com'), [
  460. 'a@b.com',
  461. 'c@d.com',
  462. ])
  463. })
  464. test('ccEmailsToArray handles empty string', () => {
  465. assert.deepEqual(ccEmailsToArray(''), [])
  466. })
  467. test('normalizeTaxIdValue strips punctuation and lowercases', () => {
  468. assert.equal(normalizeTaxIdValue('GB.123-456_789'), 'gb123456789')
  469. assert.equal(normalizeTaxIdValue(' DE:123/456 '), 'de123456')
  470. })
  471. test('normalizeTaxIdValue handles null/undefined', () => {
  472. assert.equal(normalizeTaxIdValue(null), '')
  473. assert.equal(normalizeTaxIdValue(undefined), '')
  474. })
  475. test('extractRecurlyCustomFieldMetadata extracts known fields', () => {
  476. const account = {
  477. customFields: [
  478. { name: 'channel', value: 'web' },
  479. { name: 'Industry', value: 'Education' },
  480. { name: 'unknown_field', value: 'ignored' },
  481. ],
  482. }
  483. const { metadata, counts } = extractRecurlyCustomFieldMetadata(account)
  484. assert.deepEqual(metadata, { channel: 'web', Industry: 'Education' })
  485. assert.equal(counts.channel, 1)
  486. assert.equal(counts.Industry, 1)
  487. assert.equal(counts.ol_sales_person, 0)
  488. })
  489. test('extractRecurlyCustomFieldMetadata returns empty for no custom fields', () => {
  490. const { metadata, counts } = extractRecurlyCustomFieldMetadata({
  491. customFields: [],
  492. })
  493. assert.deepEqual(metadata, {})
  494. assert.equal(counts.noCustomFields, 1)
  495. })
  496. test('extractRecurlyCustomFieldMetadata throws for missing customFields', () => {
  497. assert.throws(
  498. () => extractRecurlyCustomFieldMetadata({}),
  499. /account.customFields is missing or not an array/
  500. )
  501. })
  502. test('extractRecurlyCustomFieldMetadata skips null/empty values', () => {
  503. const account = {
  504. customFields: [
  505. { name: 'channel', value: null },
  506. { name: 'Industry', value: '' },
  507. { name: 'ol_sales_person', value: ' ' },
  508. ],
  509. }
  510. const { metadata } = extractRecurlyCustomFieldMetadata(account)
  511. assert.deepEqual(metadata, {})
  512. })
  513. test('addressesEqual returns true for two null addresses', () => {
  514. assert.equal(addressesEqual(null, null), true)
  515. })
  516. test('addressesEqual returns false when one is null', () => {
  517. assert.equal(
  518. addressesEqual({ line1: '123 Main St', country: 'US' }, null),
  519. false
  520. )
  521. assert.equal(
  522. addressesEqual(null, { line1: '123 Main St', country: 'US' }),
  523. false
  524. )
  525. })
  526. test('addressesEqual returns true for matching addresses', () => {
  527. const a = {
  528. line1: '123 Main St',
  529. line2: '',
  530. city: 'London',
  531. state: '',
  532. postal_code: 'SW1A 1AA',
  533. country: 'GB',
  534. }
  535. const b = {
  536. line1: '123 Main St',
  537. line2: '',
  538. city: 'London',
  539. state: '',
  540. postal_code: 'SW1A 1AA',
  541. country: 'gb',
  542. }
  543. assert.equal(addressesEqual(a, b), true)
  544. })
  545. test('addressesEqual returns false for differing addresses', () => {
  546. const a = {
  547. line1: '123 Main St',
  548. city: 'London',
  549. country: 'GB',
  550. }
  551. const b = {
  552. line1: '456 High St',
  553. city: 'London',
  554. country: 'GB',
  555. }
  556. assert.equal(addressesEqual(a, b), false)
  557. })
  558. test('addressesEqual normalizes whitespace', () => {
  559. const a = { line1: ' 123 Main St ', country: ' GB ' }
  560. const b = { line1: '123 Main St', country: 'GB' }
  561. assert.equal(addressesEqual(a, b), true)
  562. })
  563. test('areStripeAndRecurlyCardDetailsEqual returns false for null inputs', () => {
  564. assert.equal(areStripeAndRecurlyCardDetailsEqual(null, null), false)
  565. assert.equal(
  566. areStripeAndRecurlyCardDetailsEqual(null, { lastFour: '1234' }),
  567. false
  568. )
  569. assert.equal(
  570. areStripeAndRecurlyCardDetailsEqual(
  571. { type: 'card', card: { last4: '1234', exp_month: 12, exp_year: 2030 } },
  572. null
  573. ),
  574. false
  575. )
  576. })
  577. test('areStripeAndRecurlyCardDetailsEqual returns false for non-card type', () => {
  578. assert.equal(
  579. areStripeAndRecurlyCardDetailsEqual(
  580. { type: 'paypal' },
  581. { lastFour: '1234' }
  582. ),
  583. false
  584. )
  585. })
  586. test('areStripeAndRecurlyCardDetailsEqual returns true for matching cards', () => {
  587. assert.equal(
  588. areStripeAndRecurlyCardDetailsEqual(
  589. { type: 'card', card: { last4: '4242', exp_month: 6, exp_year: 2025 } },
  590. { lastFour: '4242', expMonth: 6, expYear: 2025 }
  591. ),
  592. true
  593. )
  594. })
  595. test('areStripeAndRecurlyCardDetailsEqual returns false for mismatched last4', () => {
  596. assert.equal(
  597. areStripeAndRecurlyCardDetailsEqual(
  598. { type: 'card', card: { last4: '4242', exp_month: 6, exp_year: 2025 } },
  599. { lastFour: '1111', expMonth: 6, expYear: 2025 }
  600. ),
  601. false
  602. )
  603. })
  604. test('resolveCustomerIdentity uses billing info when no conflict', async () => {
  605. const account = {
  606. billingInfo: {
  607. firstName: 'Bill',
  608. lastName: 'User',
  609. address: { street1: '123 Bill St', country: 'US' },
  610. company: 'Bill Corp',
  611. vatNumber: 'US123',
  612. },
  613. firstName: 'Account',
  614. lastName: 'User',
  615. address: { street1: '456 Acct St', country: 'US' },
  616. company: 'Acct Corp',
  617. vatNumber: 'US123',
  618. }
  619. // No conflict on vatNumber (same), but name differs -> conflict
  620. const result = await resolveCustomerIdentity(account, async () => 'automatic')
  621. assert.equal(result.name, 'Bill User')
  622. })
  623. test('resolveCustomerIdentity uses account info for manual collection', async () => {
  624. const account = {
  625. billingInfo: {
  626. firstName: 'Bill',
  627. lastName: 'User',
  628. address: { street1: '123 Bill St', country: 'US' },
  629. },
  630. firstName: 'Account',
  631. lastName: 'User',
  632. address: { street1: '456 Acct St', country: 'US' },
  633. }
  634. const result = await resolveCustomerIdentity(account, async () => 'manual')
  635. assert.equal(result.name, 'Account User')
  636. assert.notEqual(result.billingInfoForPaymentMethod, null)
  637. })
  638. test('resolveCustomerIdentity returns billing info preferred when no conflict', async () => {
  639. const account = {
  640. billingInfo: {
  641. firstName: 'Same',
  642. lastName: 'Name',
  643. vatNumber: 'GB123456789',
  644. },
  645. firstName: 'Same',
  646. lastName: 'Name',
  647. vatNumber: 'GB123456789',
  648. }
  649. const result = await resolveCustomerIdentity(account, async () => {
  650. throw new Error('should not be called')
  651. })
  652. assert.equal(result.name, 'Same Name')
  653. assert.equal(result.collectionMethod, null)
  654. })
  655. test('resolveCustomerIdentity does not call fetchCollectionMethod when no conflict', async () => {
  656. let called = false
  657. const account = {
  658. billingInfo: { firstName: 'Only', lastName: 'Billing' },
  659. }
  660. await resolveCustomerIdentity(account, async () => {
  661. called = true
  662. return 'automatic'
  663. })
  664. assert.equal(called, false)
  665. })
  666. test('resolveCustomerIdentity throws when conflict but no collection method', async () => {
  667. const account = {
  668. billingInfo: {
  669. firstName: 'Bill',
  670. lastName: 'Name',
  671. },
  672. firstName: 'Acct',
  673. lastName: 'Name',
  674. }
  675. await assert.rejects(
  676. () => resolveCustomerIdentity(account, async () => null),
  677. /no subscription found to determine collection method/
  678. )
  679. })
  680. test('resolveCustomerIdentity normalises GB VAT', async () => {
  681. const account = {
  682. billingInfo: {
  683. firstName: 'Test',
  684. lastName: 'User',
  685. address: { country: 'GB' },
  686. vatNumber: '123456789',
  687. },
  688. }
  689. const result = await resolveCustomerIdentity(account, async () => 'automatic')
  690. assert.equal(result.vatNumber, 'GB123456789')
  691. })
  692. test('compareAccountFields returns empty diffs when everything matches', async () => {
  693. const account = {
  694. email: 'user@example.com',
  695. firstName: 'Test',
  696. lastName: 'User',
  697. customFields: [],
  698. billingInfo: {
  699. firstName: 'Test',
  700. lastName: 'User',
  701. },
  702. }
  703. const stripeCustomer = {
  704. email: 'user@example.com',
  705. name: 'Test User',
  706. metadata: {
  707. recurlyAccountCode: '',
  708. userId: 'user123',
  709. taxInfoPending: '',
  710. },
  711. tax_exempt: 'none',
  712. }
  713. const diffs = await compareAccountFields({
  714. account,
  715. stripeCustomer,
  716. overleafUserId: 'user123',
  717. fetchCollectionMethod: async () => null,
  718. stripePaymentMethods: [],
  719. stripeServiceName: 'stripe-uk',
  720. })
  721. assert.deepEqual(diffs, {})
  722. })
  723. test('compareAccountFields detects email drift', async () => {
  724. const account = {
  725. email: 'new@example.com',
  726. customFields: [],
  727. billingInfo: {},
  728. }
  729. const stripeCustomer = {
  730. email: 'old@example.com',
  731. metadata: {
  732. recurlyAccountCode: '',
  733. userId: 'user123',
  734. taxInfoPending: '',
  735. },
  736. tax_exempt: 'none',
  737. }
  738. const diffs = await compareAccountFields({
  739. account,
  740. stripeCustomer,
  741. overleafUserId: 'user123',
  742. fetchCollectionMethod: async () => null,
  743. stripePaymentMethods: [],
  744. stripeServiceName: 'stripe-uk',
  745. })
  746. assert.ok(diffs.email)
  747. assert.equal(diffs.email.recurly, 'new@example.com')
  748. assert.equal(diffs.email.stripe, 'old@example.com')
  749. })
  750. test('compareAccountFields detects name drift', async () => {
  751. const account = {
  752. email: 'user@example.com',
  753. customFields: [],
  754. billingInfo: {
  755. firstName: 'New',
  756. lastName: 'Name',
  757. },
  758. }
  759. const stripeCustomer = {
  760. email: 'user@example.com',
  761. name: 'Old Name',
  762. metadata: {
  763. recurlyAccountCode: '',
  764. userId: 'user123',
  765. taxInfoPending: '',
  766. },
  767. tax_exempt: 'none',
  768. }
  769. const diffs = await compareAccountFields({
  770. account,
  771. stripeCustomer,
  772. overleafUserId: 'user123',
  773. fetchCollectionMethod: async () => null,
  774. stripePaymentMethods: [],
  775. stripeServiceName: 'stripe-uk',
  776. })
  777. assert.ok(diffs.name)
  778. assert.equal(diffs.name.recurly, 'New Name')
  779. assert.equal(diffs.name.stripe, 'Old Name')
  780. })
  781. test('compareAccountFields detects tax ID drift', async () => {
  782. const account = {
  783. email: 'user@example.com',
  784. customFields: [],
  785. billingInfo: {
  786. firstName: 'Test',
  787. lastName: 'User',
  788. address: { country: 'DE' },
  789. vatNumber: 'DE123456789',
  790. },
  791. }
  792. const stripeCustomer = {
  793. email: 'user@example.com',
  794. name: 'Test User',
  795. address: { country: 'DE' },
  796. metadata: {
  797. recurlyAccountCode: '',
  798. userId: 'user123',
  799. taxInfoPending: '',
  800. },
  801. tax_exempt: 'none',
  802. tax_ids: { data: [] },
  803. }
  804. const diffs = await compareAccountFields({
  805. account,
  806. stripeCustomer,
  807. overleafUserId: 'user123',
  808. fetchCollectionMethod: async () => null,
  809. stripePaymentMethods: [],
  810. stripeServiceName: 'stripe-uk',
  811. })
  812. assert.ok(diffs.tax_id)
  813. assert.equal(diffs.tax_id.recurly.type, 'eu_vat')
  814. assert.equal(diffs.tax_id.recurly.value, 'DE123456789')
  815. })
  816. test('compareAccountFields does not report tax ID diff when values match after normalization', async () => {
  817. const account = {
  818. email: 'user@example.com',
  819. customFields: [],
  820. billingInfo: {
  821. firstName: 'Test',
  822. lastName: 'User',
  823. address: { country: 'DE' },
  824. vatNumber: 'DE-123.456.789',
  825. },
  826. }
  827. const stripeCustomer = {
  828. email: 'user@example.com',
  829. name: 'Test User',
  830. address: { country: 'DE' },
  831. metadata: {
  832. recurlyAccountCode: '',
  833. userId: 'user123',
  834. taxInfoPending: '',
  835. },
  836. tax_exempt: 'none',
  837. tax_ids: {
  838. data: [{ type: 'eu_vat', value: 'DE123456789' }],
  839. },
  840. }
  841. const diffs = await compareAccountFields({
  842. account,
  843. stripeCustomer,
  844. overleafUserId: 'user123',
  845. fetchCollectionMethod: async () => null,
  846. stripePaymentMethods: [],
  847. stripeServiceName: 'stripe-uk',
  848. })
  849. assert.equal(diffs.tax_id, undefined)
  850. })
  851. test('compareAccountFields handles no payment methods on either side without crashing', async () => {
  852. const account = {
  853. email: 'user@example.com',
  854. customFields: [],
  855. billingInfo: {},
  856. }
  857. const stripeCustomer = {
  858. email: 'user@example.com',
  859. metadata: {
  860. recurlyAccountCode: '',
  861. userId: 'user123',
  862. taxInfoPending: '',
  863. },
  864. tax_exempt: 'none',
  865. invoice_settings: { default_payment_method: null },
  866. }
  867. // This should not throw (regression test for null payment method crash)
  868. const diffs = await compareAccountFields({
  869. account,
  870. stripeCustomer,
  871. overleafUserId: 'user123',
  872. fetchCollectionMethod: async () => null,
  873. stripePaymentMethods: [],
  874. stripeServiceName: 'stripe-uk',
  875. })
  876. assert.equal(diffs.default_payment_method, undefined)
  877. })
  878. test('compareAccountFields detects metadata drift', async () => {
  879. const account = {
  880. email: 'user@example.com',
  881. customFields: [{ name: 'channel', value: 'web' }],
  882. billingInfo: {},
  883. }
  884. const stripeCustomer = {
  885. email: 'user@example.com',
  886. metadata: {
  887. recurlyAccountCode: '',
  888. userId: 'user123',
  889. taxInfoPending: '',
  890. channel: 'api',
  891. },
  892. tax_exempt: 'none',
  893. }
  894. const diffs = await compareAccountFields({
  895. account,
  896. stripeCustomer,
  897. overleafUserId: 'user123',
  898. fetchCollectionMethod: async () => null,
  899. stripePaymentMethods: [],
  900. stripeServiceName: 'stripe-uk',
  901. })
  902. assert.ok(diffs['metadata.channel'])
  903. assert.equal(diffs['metadata.channel'].recurly, 'web')
  904. assert.equal(diffs['metadata.channel'].stripe, 'api')
  905. })
  906. test('compareAccountFields detects tax_exempt drift', async () => {
  907. const account = {
  908. email: 'user@example.com',
  909. customFields: [],
  910. billingInfo: {},
  911. taxExempt: true,
  912. }
  913. const stripeCustomer = {
  914. email: 'user@example.com',
  915. metadata: {
  916. recurlyAccountCode: '',
  917. userId: 'user123',
  918. taxInfoPending: '',
  919. },
  920. tax_exempt: 'none',
  921. }
  922. const diffs = await compareAccountFields({
  923. account,
  924. stripeCustomer,
  925. overleafUserId: 'user123',
  926. fetchCollectionMethod: async () => null,
  927. stripePaymentMethods: [],
  928. stripeServiceName: 'stripe-uk',
  929. })
  930. assert.ok(diffs.tax_exempt)
  931. assert.equal(diffs.tax_exempt.recurly, 'exempt')
  932. assert.equal(diffs.tax_exempt.stripe, 'none')
  933. })