migrate_recurly_customers_to_stripe.helpers.node.test.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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 have card info', () => {
  212. const paymentMethods = [{ id: 'pm_1', card: null }, { id: 'pm_2' }]
  213. assert.throws(
  214. () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123'),
  215. /Stripe customer cus_123 has no usable payment method/
  216. )
  217. })
  218. test('coalesceOrThrowPaymentMethod throws when all payment methods are expired', () => {
  219. const paymentMethods = [
  220. {
  221. id: 'pm_expired',
  222. card: { exp_month: 1, exp_year: 2020 },
  223. },
  224. ]
  225. assert.throws(
  226. () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123'),
  227. /Stripe customer cus_123 has no usable payment method/
  228. )
  229. })
  230. test('coalesceOrThrowPaymentMethod throws when multiple non-expired payment methods exist', () => {
  231. const paymentMethods = [
  232. {
  233. id: 'pm_1',
  234. card: { exp_month: 12, exp_year: 2030 },
  235. },
  236. {
  237. id: 'pm_2',
  238. card: { exp_month: 12, exp_year: 2031 },
  239. },
  240. ]
  241. assert.throws(
  242. () => coalesceOrThrowPaymentMethod(paymentMethods, 'cus_123'),
  243. /Stripe customer cus_123 has multiple usable payment methods/
  244. )
  245. })
  246. test('coalesceOrThrowPaymentMethod returns single non-expired payment method', () => {
  247. const paymentMethod = {
  248. id: 'pm_valid',
  249. card: { exp_month: 12, exp_year: 2030 },
  250. }
  251. const result = coalesceOrThrowPaymentMethod([paymentMethod], 'cus_123')
  252. assert.equal(result, paymentMethod)
  253. })
  254. test('coalesceOrThrowPaymentMethod filters out expired and returns valid method', () => {
  255. const expiredMethod = {
  256. id: 'pm_expired',
  257. card: { exp_month: 1, exp_year: 2020 },
  258. }
  259. const validMethod = {
  260. id: 'pm_valid',
  261. card: { exp_month: 12, exp_year: 2030 },
  262. }
  263. const result = coalesceOrThrowPaymentMethod(
  264. [expiredMethod, validMethod],
  265. 'cus_123'
  266. )
  267. assert.equal(result, validMethod)
  268. })
  269. test('coalesceOrThrowPaymentMethod keeps payment method expiring this month', () => {
  270. const now = new Date()
  271. const currentMonth = now.getMonth() + 1 // JavaScript months are 0-indexed, Stripe uses 1-indexed
  272. const currentYear = now.getFullYear()
  273. const paymentMethod = {
  274. id: 'pm_expiring_this_month',
  275. card: { exp_month: currentMonth, exp_year: currentYear },
  276. }
  277. const result = coalesceOrThrowPaymentMethod([paymentMethod], 'cus_123')
  278. assert.equal(result, paymentMethod)
  279. })
  280. test('coalesceOrThrowPaymentMethod filters out method without exp_month', () => {
  281. const invalidMethod = {
  282. id: 'pm_invalid',
  283. card: { exp_year: 2030 },
  284. }
  285. const validMethod = {
  286. id: 'pm_valid',
  287. card: { exp_month: 12, exp_year: 2030 },
  288. }
  289. const result = coalesceOrThrowPaymentMethod(
  290. [invalidMethod, validMethod],
  291. 'cus_123'
  292. )
  293. assert.equal(result, validMethod)
  294. })
  295. test('coalesceOrThrowPaymentMethod filters out method without exp_year', () => {
  296. const invalidMethod = {
  297. id: 'pm_invalid',
  298. card: { exp_month: 12 },
  299. }
  300. const validMethod = {
  301. id: 'pm_valid',
  302. card: { exp_month: 12, exp_year: 2030 },
  303. }
  304. const result = coalesceOrThrowPaymentMethod(
  305. [invalidMethod, validMethod],
  306. 'cus_123'
  307. )
  308. assert.equal(result, validMethod)
  309. })