migrate_recurly_customers_to_stripe.helpers.node.test.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361
  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. coalesceOrThrowPaymentMethod,
  18. } from './migrate_recurly_customers_to_stripe.helpers.mjs'
  19. test('coalesceOrEqualOrThrow returns primary when set', () => {
  20. assert.equal(coalesceOrEqualOrThrow('a', undefined, 'field'), 'a')
  21. })
  22. test('coalesceOrEqualOrThrow returns fallback when primary is unset', () => {
  23. assert.equal(coalesceOrEqualOrThrow(undefined, 'b', 'field'), 'b')
  24. })
  25. test('coalesceOrEqualOrThrow returns value when both are set and equal', () => {
  26. assert.equal(coalesceOrEqualOrThrow('same', 'same', 'field'), 'same')
  27. })
  28. test('coalesceOrEqualOrThrow throws when both are set but differ', () => {
  29. assert.throws(
  30. () => coalesceOrEqualOrThrow('a', 'b', 'field'),
  31. /Primary and fallback values are both set but differ/
  32. )
  33. })
  34. test('coalesceOrEqualOrThrowAddress returns null when neither is valid', () => {
  35. assert.equal(coalesceOrEqualOrThrowAddress({}, null), null)
  36. assert.equal(
  37. coalesceOrEqualOrThrowAddress(
  38. { address: { street1: '', postalCode: '', country: '' } },
  39. { address: { street1: '', postalCode: '', country: '' } }
  40. ),
  41. null
  42. )
  43. assert.equal(
  44. coalesceOrEqualOrThrowAddress(
  45. { address: { street1: ' ', postalCode: ' ', country: ' ' } },
  46. null
  47. ),
  48. null
  49. )
  50. })
  51. test('coalesceOrEqualOrThrowAddress returns account when billing invalid', () => {
  52. const account = {
  53. address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' },
  54. }
  55. const billingInfo = {
  56. address: { street1: '', postalCode: 'ABC', country: 'GB' },
  57. }
  58. assert.deepEqual(coalesceOrEqualOrThrowAddress(account, billingInfo), {
  59. line1: '1 Road',
  60. postal_code: 'ABC',
  61. country: 'GB',
  62. })
  63. })
  64. test('coalesceOrEqualOrThrowAddress returns billing when account invalid', () => {
  65. const account = {
  66. address: { street1: '', postalCode: 'ABC', country: 'GB' },
  67. }
  68. const billingInfo = {
  69. address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' },
  70. }
  71. assert.deepEqual(coalesceOrEqualOrThrowAddress(account, billingInfo), {
  72. line1: '1 Road',
  73. postal_code: 'ABC',
  74. country: 'GB',
  75. })
  76. })
  77. test('coalesceOrEqualOrThrowAddress returns billing when both valid+equal', () => {
  78. const addr = { street1: '1 Road', postalCode: 'ABC', country: 'GB' }
  79. assert.deepEqual(
  80. coalesceOrEqualOrThrowAddress({ address: { ...addr } }, { address: addr }),
  81. { line1: '1 Road', postal_code: 'ABC', country: 'GB' }
  82. )
  83. })
  84. test('coalesceOrEqualOrThrowAddress normalizes Recurly-style address fields', () => {
  85. const billingInfo = {
  86. address: {
  87. street1: 'as',
  88. street2: '',
  89. city: '',
  90. region: '',
  91. postalCode: '12312',
  92. country: 'AI',
  93. },
  94. }
  95. assert.deepEqual(coalesceOrEqualOrThrowAddress({}, billingInfo), {
  96. line1: 'as',
  97. postal_code: '12312',
  98. country: 'AI',
  99. })
  100. })
  101. test('coalesceOrEqualOrThrowAddress throws when both valid but differ', () => {
  102. const account = {
  103. address: { street1: '1 Road', postalCode: 'ABC', country: 'GB' },
  104. }
  105. const billingInfo = {
  106. address: { street1: '2 Road', postalCode: 'ABC', country: 'GB' },
  107. }
  108. assert.throws(
  109. () => coalesceOrEqualOrThrowAddress(account, billingInfo),
  110. /Billing address and account address differ/
  111. )
  112. })
  113. test('coalesceOrEqualOrThrowName returns billingInfo name when both sources match', () => {
  114. const account = { firstName: 'Alice', lastName: 'Billing' }
  115. const billingInfo = { firstName: 'Alice', lastName: 'Billing' }
  116. assert.equal(
  117. coalesceOrEqualOrThrowName(account, billingInfo),
  118. 'Alice Billing'
  119. )
  120. })
  121. test('coalesceOrEqualOrThrowName prefers billingInfo when billingInfo is full but account is not', () => {
  122. const account = { firstName: 'Alice', lastName: '' }
  123. const billingInfo = { firstName: 'Alice', lastName: 'Billing' }
  124. assert.equal(
  125. coalesceOrEqualOrThrowName(account, billingInfo),
  126. 'Alice Billing'
  127. )
  128. })
  129. test('coalesceOrEqualOrThrowName falls back to account when billingInfo missing last name', () => {
  130. const account = { firstName: 'Alice', lastName: 'Account' }
  131. const billingInfo = { firstName: 'Alice', lastName: '' }
  132. assert.equal(
  133. coalesceOrEqualOrThrowName(account, billingInfo),
  134. 'Alice Account'
  135. )
  136. })
  137. test('coalesceOrEqualOrThrowName returns null when both sources are empty', () => {
  138. assert.equal(coalesceOrEqualOrThrowName({}, null), null)
  139. assert.equal(
  140. coalesceOrEqualOrThrowName({ firstName: '', lastName: '' }, null),
  141. null
  142. )
  143. })
  144. test('coalesceOrEqualOrThrowName throws when both full names are present but differ', () => {
  145. const account = { firstName: 'Alice', lastName: 'Account' }
  146. const billingInfo = { firstName: 'Alice', lastName: 'Billing' }
  147. assert.throws(
  148. () => coalesceOrEqualOrThrowName(account, billingInfo),
  149. /Name differs between billingInfo and account/
  150. )
  151. })
  152. test('coalesceOrThrowVATNumber returns billingInfo VAT when set', () => {
  153. const account = { vatNumber: '' }
  154. const billingInfo = { vatNumber: 'BILL456' }
  155. assert.equal(coalesceOrThrowVATNumber(account, billingInfo), 'BILL456')
  156. })
  157. test('coalesceOrThrowVATNumber returns account VAT when billingInfo VAT unset', () => {
  158. const account = { vatNumber: 'ACCT123' }
  159. const billingInfo = { vatNumber: '' }
  160. assert.equal(coalesceOrThrowVATNumber(account, billingInfo), 'ACCT123')
  161. })
  162. test('coalesceOrThrowVATNumber returns null when neither is set', () => {
  163. assert.equal(coalesceOrThrowVATNumber({}, null), null)
  164. assert.equal(
  165. coalesceOrThrowVATNumber({ vatNumber: '' }, { vatNumber: '' }),
  166. null
  167. )
  168. })
  169. test('coalesceOrThrowVATNumber treats trimmed values as equal', () => {
  170. const account = { vatNumber: ' GB123 ' }
  171. const billingInfo = { vatNumber: 'GB123' }
  172. assert.equal(coalesceOrThrowVATNumber(account, billingInfo), 'GB123')
  173. })
  174. test('coalesceOrThrowVATNumber throws when both are set but differ', () => {
  175. const account = { vatNumber: 'GB123' }
  176. const billingInfo = { vatNumber: 'DE999' }
  177. assert.throws(
  178. () => coalesceOrThrowVATNumber(account, billingInfo),
  179. /Field vatNumber: Primary and fallback values are both set but differ/
  180. )
  181. })
  182. test('getCanadaTaxIdType returns ca_gst_hst for GST/HST format', () => {
  183. assert.equal(getCanadaTaxIdType('123456789RT0002', null), 'ca_gst_hst')
  184. assert.equal(getCanadaTaxIdType(' 123456789rt0002 ', null), 'ca_gst_hst')
  185. })
  186. test('getCanadaTaxIdType returns ca_qst for Quebec QST format', () => {
  187. assert.equal(getCanadaTaxIdType('1234567890TQ1234', null), 'ca_qst')
  188. assert.equal(getCanadaTaxIdType('1234567890tq1234', null), 'ca_qst')
  189. })
  190. test('getCanadaTaxIdType returns PST types for documented provincial formats', () => {
  191. assert.equal(getCanadaTaxIdType('PST-1234-5678', null), 'ca_pst_bc')
  192. assert.equal(getCanadaTaxIdType('123456-7', 'R3C 4T3'), 'ca_pst_mb')
  193. assert.equal(getCanadaTaxIdType('1234567', 'S7K 3J8'), 'ca_pst_sk')
  194. })
  195. test('getCanadaTaxIdType returns ca_bn for 9-digit BN format', () => {
  196. // assert.equal(getCanadaTaxIdType('123456789', null), 'ca_bn')
  197. assert.equal(getCanadaTaxIdType('123456789', null), null) // TODO: improve function get definitive ca_bn vs ca_gst_hst
  198. })
  199. test('getCanadaTaxIdType returns null when format is unknown/ambiguous', () => {
  200. assert.equal(getCanadaTaxIdType('', null), null)
  201. assert.equal(getCanadaTaxIdType(null, null), null)
  202. assert.equal(getCanadaTaxIdType('RT0002', null), null)
  203. assert.equal(getCanadaTaxIdType('PST12345678', null), null)
  204. })
  205. test('coalesceOrThrowPaymentMethod throws when payment methods array is empty', () => {
  206. assert.throws(
  207. () => coalesceOrThrowPaymentMethod([], 'cus_123', {}),
  208. /Stripe customer cus_123 has no usable payment method/
  209. )
  210. })
  211. test('coalesceOrThrowPaymentMethod throws when no payment methods match billing info', () => {
  212. const paymentMethods = [
  213. {
  214. id: 'pm_1',
  215. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  216. },
  217. ]
  218. const billingInfo = {
  219. paymentMethod: { lastFour: '5678', expMonth: 12, expYear: 2030 },
  220. }
  221. assert.throws(
  222. () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', billingInfo),
  223. /Stripe customer cus_123 has no usable payment method/
  224. )
  225. })
  226. test('coalesceOrThrowPaymentMethod returns matching payment method', () => {
  227. const paymentMethod = {
  228. id: 'pm_match',
  229. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  230. }
  231. const paymentMethods = [paymentMethod]
  232. const billingInfo = {
  233. paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
  234. }
  235. const result = coalesceOrThrowPaymentMethod(
  236. paymentMethods,
  237. 'cus_123',
  238. billingInfo
  239. )
  240. assert.equal(result, paymentMethod)
  241. })
  242. test('coalesceOrThrowPaymentMethod matches first of multiple matching methods', () => {
  243. const paymentMethod1 = {
  244. id: 'pm_1',
  245. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  246. }
  247. const paymentMethod2 = {
  248. id: 'pm_2',
  249. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  250. }
  251. const paymentMethods = [paymentMethod1, paymentMethod2]
  252. const billingInfo = {
  253. paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
  254. }
  255. const result = coalesceOrThrowPaymentMethod(
  256. paymentMethods,
  257. 'cus_123',
  258. billingInfo
  259. )
  260. assert.equal(result, paymentMethod1)
  261. })
  262. test('coalesceOrThrowPaymentMethod filters out non-matching methods', () => {
  263. const matchingMethod = {
  264. id: 'pm_match',
  265. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  266. }
  267. const nonMatchingMethod = {
  268. id: 'pm_no_match',
  269. card: { last4: '5678', exp_month: 12, exp_year: 2030 },
  270. }
  271. const paymentMethods = [nonMatchingMethod, matchingMethod]
  272. const billingInfo = {
  273. paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
  274. }
  275. const result = coalesceOrThrowPaymentMethod(
  276. paymentMethods,
  277. 'cus_123',
  278. billingInfo
  279. )
  280. assert.equal(result, matchingMethod)
  281. })
  282. test('coalesceOrThrowPaymentMethod handles null billingInfo', () => {
  283. const paymentMethods = [
  284. {
  285. id: 'pm_1',
  286. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  287. },
  288. ]
  289. assert.throws(
  290. () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', null),
  291. /Stripe customer cus_123 has no usable payment method/
  292. )
  293. })
  294. test('coalesceOrThrowPaymentMethod handles missing paymentMethod in billingInfo', () => {
  295. const paymentMethods = [
  296. {
  297. id: 'pm_1',
  298. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  299. },
  300. ]
  301. const billingInfo = {}
  302. assert.throws(
  303. () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123', billingInfo),
  304. /Stripe customer cus_123 has no usable payment method/
  305. )
  306. })
  307. test('coalesceOrThrowPaymentMethod handles missing card in payment method', () => {
  308. const paymentMethods = [
  309. { id: 'pm_1' }, // no card property
  310. {
  311. id: 'pm_2',
  312. card: { last4: '1234', exp_month: 12, exp_year: 2030 },
  313. },
  314. ]
  315. const billingInfo = {
  316. paymentMethod: { lastFour: '1234', expMonth: 12, expYear: 2030 },
  317. }
  318. const result = coalesceOrThrowPaymentMethod(
  319. paymentMethods,
  320. 'cus_123',
  321. billingInfo
  322. )
  323. assert.equal(result.id, 'pm_2')
  324. })