@owlmeans/payment 0.1.18-rc.8 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (364) hide show
  1. package/README.md +95 -57
  2. package/agent-meta/manifest.json +10 -3
  3. package/agent-meta/skills/entitlements/SKILL.md +159 -0
  4. package/agent-meta/skills/payment/SKILL.md +491 -28
  5. package/build/advertise.d.ts +2 -0
  6. package/build/advertise.d.ts.map +1 -0
  7. package/build/advertise.js +10 -0
  8. package/build/advertise.js.map +1 -0
  9. package/build/checkout/consts.d.ts +2 -0
  10. package/build/checkout/consts.d.ts.map +1 -0
  11. package/build/checkout/consts.js +2 -0
  12. package/build/checkout/consts.js.map +1 -0
  13. package/build/checkout/index.d.ts +6 -0
  14. package/build/checkout/index.d.ts.map +1 -0
  15. package/build/checkout/index.js +4 -0
  16. package/build/checkout/index.js.map +1 -0
  17. package/build/checkout/narrow/types.d.ts +25 -0
  18. package/build/checkout/narrow/types.d.ts.map +1 -0
  19. package/build/checkout/narrow/types.js +2 -0
  20. package/build/checkout/narrow/types.js.map +1 -0
  21. package/build/checkout/narrow.d.ts +8 -0
  22. package/build/checkout/narrow.d.ts.map +1 -0
  23. package/build/checkout/narrow.js +47 -0
  24. package/build/checkout/narrow.js.map +1 -0
  25. package/build/checkout/protocols.d.ts +8 -0
  26. package/build/checkout/protocols.d.ts.map +1 -0
  27. package/build/checkout/protocols.js +28 -0
  28. package/build/checkout/protocols.js.map +1 -0
  29. package/build/checkout/types.d.ts +29 -0
  30. package/build/checkout/types.d.ts.map +1 -0
  31. package/build/checkout/types.js +2 -0
  32. package/build/checkout/types.js.map +1 -0
  33. package/build/consts.d.ts +242 -25
  34. package/build/consts.d.ts.map +1 -1
  35. package/build/consts.js +377 -24
  36. package/build/consts.js.map +1 -1
  37. package/build/consts.local.d.ts +20 -0
  38. package/build/consts.local.d.ts.map +1 -0
  39. package/build/consts.local.js +23 -0
  40. package/build/consts.local.js.map +1 -0
  41. package/build/consumer/cancel.d.ts +11 -0
  42. package/build/consumer/cancel.d.ts.map +1 -0
  43. package/build/consumer/cancel.js +33 -0
  44. package/build/consumer/cancel.js.map +1 -0
  45. package/build/consumer/consts.d.ts +20 -0
  46. package/build/consumer/consts.d.ts.map +1 -0
  47. package/build/consumer/consts.js +39 -0
  48. package/build/consumer/consts.js.map +1 -0
  49. package/build/consumer/consts.local.d.ts +7 -0
  50. package/build/consumer/consts.local.d.ts.map +1 -0
  51. package/build/consumer/consts.local.js +13 -0
  52. package/build/consumer/consts.local.js.map +1 -0
  53. package/build/consumer/copy/types.d.ts +56 -0
  54. package/build/consumer/copy/types.d.ts.map +1 -0
  55. package/build/consumer/copy/types.js +2 -0
  56. package/build/consumer/copy/types.js.map +1 -0
  57. package/build/consumer/copy.d.ts +22 -0
  58. package/build/consumer/copy.d.ts.map +1 -0
  59. package/build/consumer/copy.js +87 -0
  60. package/build/consumer/copy.js.map +1 -0
  61. package/build/consumer/deadline/types.d.ts +30 -0
  62. package/build/consumer/deadline/types.d.ts.map +1 -0
  63. package/build/consumer/deadline/types.js +2 -0
  64. package/build/consumer/deadline/types.js.map +1 -0
  65. package/build/consumer/deadline.d.ts +12 -0
  66. package/build/consumer/deadline.d.ts.map +1 -0
  67. package/build/consumer/deadline.js +44 -0
  68. package/build/consumer/deadline.js.map +1 -0
  69. package/build/consumer/fifo/types.d.ts +17 -0
  70. package/build/consumer/fifo/types.d.ts.map +1 -0
  71. package/build/consumer/fifo/types.js +2 -0
  72. package/build/consumer/fifo/types.js.map +1 -0
  73. package/build/consumer/fifo.d.ts +4 -0
  74. package/build/consumer/fifo.d.ts.map +1 -0
  75. package/build/consumer/fifo.js +34 -0
  76. package/build/consumer/fifo.js.map +1 -0
  77. package/build/consumer/index.d.ts +18 -0
  78. package/build/consumer/index.d.ts.map +1 -0
  79. package/build/consumer/index.js +11 -0
  80. package/build/consumer/index.js.map +1 -0
  81. package/build/consumer/policy/types.d.ts +20 -0
  82. package/build/consumer/policy/types.d.ts.map +1 -0
  83. package/build/consumer/policy/types.js +2 -0
  84. package/build/consumer/policy/types.js.map +1 -0
  85. package/build/consumer/policy.d.ts +17 -0
  86. package/build/consumer/policy.d.ts.map +1 -0
  87. package/build/consumer/policy.js +91 -0
  88. package/build/consumer/policy.js.map +1 -0
  89. package/build/consumer/protocols.d.ts +20 -0
  90. package/build/consumer/protocols.d.ts.map +1 -0
  91. package/build/consumer/protocols.js +70 -0
  92. package/build/consumer/protocols.js.map +1 -0
  93. package/build/consumer/refund/types.d.ts +27 -0
  94. package/build/consumer/refund/types.d.ts.map +1 -0
  95. package/build/consumer/refund/types.js +2 -0
  96. package/build/consumer/refund/types.js.map +1 -0
  97. package/build/consumer/refund.d.ts +9 -0
  98. package/build/consumer/refund.d.ts.map +1 -0
  99. package/build/consumer/refund.js +112 -0
  100. package/build/consumer/refund.js.map +1 -0
  101. package/build/consumer/refusal.d.ts +10 -0
  102. package/build/consumer/refusal.d.ts.map +1 -0
  103. package/build/consumer/refusal.js +35 -0
  104. package/build/consumer/refusal.js.map +1 -0
  105. package/build/consumer/revive/types.d.ts +19 -0
  106. package/build/consumer/revive/types.d.ts.map +1 -0
  107. package/build/consumer/revive/types.js +2 -0
  108. package/build/consumer/revive/types.js.map +1 -0
  109. package/build/consumer/revive.d.ts +13 -0
  110. package/build/consumer/revive.d.ts.map +1 -0
  111. package/build/consumer/revive.js +43 -0
  112. package/build/consumer/revive.js.map +1 -0
  113. package/build/consumer/types.d.ts +196 -0
  114. package/build/consumer/types.d.ts.map +1 -0
  115. package/build/consumer/types.js +2 -0
  116. package/build/consumer/types.js.map +1 -0
  117. package/build/consumer/types.local.d.ts +11 -0
  118. package/build/consumer/types.local.d.ts.map +1 -0
  119. package/build/consumer/types.local.js +2 -0
  120. package/build/consumer/types.local.js.map +1 -0
  121. package/build/countries.d.ts +3 -0
  122. package/build/countries.d.ts.map +1 -0
  123. package/build/countries.js +4 -0
  124. package/build/countries.js.map +1 -0
  125. package/build/entitled.d.ts +14 -0
  126. package/build/entitled.d.ts.map +1 -0
  127. package/build/entitled.js +17 -0
  128. package/build/entitled.js.map +1 -0
  129. package/build/entitlement/types.d.ts +29 -0
  130. package/build/entitlement/types.d.ts.map +1 -0
  131. package/build/entitlement/types.js +2 -0
  132. package/build/entitlement/types.js.map +1 -0
  133. package/build/entitlement.d.ts +12 -0
  134. package/build/entitlement.d.ts.map +1 -0
  135. package/build/entitlement.js +64 -0
  136. package/build/entitlement.js.map +1 -0
  137. package/build/errors.d.ts +189 -0
  138. package/build/errors.d.ts.map +1 -1
  139. package/build/errors.js +361 -0
  140. package/build/errors.js.map +1 -1
  141. package/build/estimate/types.d.ts +34 -0
  142. package/build/estimate/types.d.ts.map +1 -0
  143. package/build/estimate/types.js +2 -0
  144. package/build/estimate/types.js.map +1 -0
  145. package/build/estimate.d.ts +11 -0
  146. package/build/estimate.d.ts.map +1 -0
  147. package/build/estimate.js +76 -0
  148. package/build/estimate.js.map +1 -0
  149. package/build/i18n/consumer-rights/be.json +131 -0
  150. package/build/i18n/consumer-rights/de.json +131 -0
  151. package/build/i18n/consumer-rights/en.json +131 -0
  152. package/build/i18n/consumer-rights/es.json +131 -0
  153. package/build/i18n/consumer-rights/fr.json +131 -0
  154. package/build/i18n/consumer-rights/pl.json +131 -0
  155. package/build/i18n/consumer-rights/ru.json +131 -0
  156. package/build/i18n/consumer-rights/uk.json +131 -0
  157. package/build/i18n/errors/be.json +30 -0
  158. package/build/i18n/errors/de.json +30 -0
  159. package/build/i18n/errors/en.json +30 -0
  160. package/build/i18n/errors/es.json +30 -0
  161. package/build/i18n/errors/fr.json +30 -0
  162. package/build/i18n/errors/pl.json +30 -0
  163. package/build/i18n/errors/ru.json +30 -0
  164. package/build/i18n/errors/uk.json +30 -0
  165. package/build/i18n/fr.json +39 -0
  166. package/build/i18n.js +53 -8
  167. package/build/i18n.js.map +1 -1
  168. package/build/index.d.ts +19 -1
  169. package/build/index.d.ts.map +1 -1
  170. package/build/index.js +12 -1
  171. package/build/index.js.map +1 -1
  172. package/build/limit/types.d.ts +44 -0
  173. package/build/limit/types.d.ts.map +1 -0
  174. package/build/limit/types.js +2 -0
  175. package/build/limit/types.js.map +1 -0
  176. package/build/limit.d.ts +23 -0
  177. package/build/limit.d.ts.map +1 -0
  178. package/build/limit.js +97 -0
  179. package/build/limit.js.map +1 -0
  180. package/build/model/checkout.d.ts +2 -3
  181. package/build/model/checkout.d.ts.map +1 -1
  182. package/build/model/checkout.js +12 -7
  183. package/build/model/checkout.js.map +1 -1
  184. package/build/model/consts.d.ts +15 -0
  185. package/build/model/consts.d.ts.map +1 -0
  186. package/build/model/consts.js +193 -0
  187. package/build/model/consts.js.map +1 -0
  188. package/build/model/consts.local.d.ts +35 -0
  189. package/build/model/consts.local.d.ts.map +1 -0
  190. package/build/model/consts.local.js +65 -0
  191. package/build/model/consts.local.js.map +1 -0
  192. package/build/model/consumer.d.ts +23 -0
  193. package/build/model/consumer.d.ts.map +1 -0
  194. package/build/model/consumer.js +272 -0
  195. package/build/model/consumer.js.map +1 -0
  196. package/build/model/estimate.d.ts +5 -0
  197. package/build/model/estimate.d.ts.map +1 -0
  198. package/build/model/estimate.js +69 -0
  199. package/build/model/estimate.js.map +1 -0
  200. package/build/model/index.d.ts +7 -2
  201. package/build/model/index.d.ts.map +1 -1
  202. package/build/model/index.js +7 -2
  203. package/build/model/index.js.map +1 -1
  204. package/build/model/limit.d.ts +7 -0
  205. package/build/model/limit.d.ts.map +1 -0
  206. package/build/model/limit.js +32 -0
  207. package/build/model/limit.js.map +1 -0
  208. package/build/model/plan.d.ts.map +1 -1
  209. package/build/model/plan.js +24 -11
  210. package/build/model/plan.js.map +1 -1
  211. package/build/model/portal.d.ts +4 -0
  212. package/build/model/portal.d.ts.map +1 -0
  213. package/build/model/portal.js +22 -0
  214. package/build/model/portal.js.map +1 -0
  215. package/build/model/pricing.d.ts +4 -0
  216. package/build/model/pricing.d.ts.map +1 -0
  217. package/build/model/pricing.js +31 -0
  218. package/build/model/pricing.js.map +1 -0
  219. package/build/model/view.d.ts +3 -0
  220. package/build/model/view.d.ts.map +1 -0
  221. package/build/model/view.js +15 -0
  222. package/build/model/view.js.map +1 -0
  223. package/build/pricing/types.d.ts +13 -0
  224. package/build/pricing/types.d.ts.map +1 -0
  225. package/build/pricing/types.js +2 -0
  226. package/build/pricing/types.js.map +1 -0
  227. package/build/pricing.d.ts +13 -0
  228. package/build/pricing.d.ts.map +1 -0
  229. package/build/pricing.js +74 -0
  230. package/build/pricing.js.map +1 -0
  231. package/build/promo/types.d.ts +14 -0
  232. package/build/promo/types.d.ts.map +1 -0
  233. package/build/promo/types.js +2 -0
  234. package/build/promo/types.js.map +1 -0
  235. package/build/promo.d.ts +7 -0
  236. package/build/promo.d.ts.map +1 -0
  237. package/build/promo.js +25 -0
  238. package/build/promo.js.map +1 -0
  239. package/build/regions/types.d.ts +32 -0
  240. package/build/regions/types.d.ts.map +1 -0
  241. package/build/regions/types.js +2 -0
  242. package/build/regions/types.js.map +1 -0
  243. package/build/regions.d.ts +15 -0
  244. package/build/regions.d.ts.map +1 -0
  245. package/build/regions.js +43 -0
  246. package/build/regions.js.map +1 -0
  247. package/build/service.d.ts.map +1 -1
  248. package/build/service.js +24 -9
  249. package/build/service.js.map +1 -1
  250. package/build/types.d.ts +619 -52
  251. package/build/types.d.ts.map +1 -1
  252. package/build/types.local.d.ts +4 -0
  253. package/build/types.local.d.ts.map +1 -0
  254. package/build/types.local.js +2 -0
  255. package/build/types.local.js.map +1 -0
  256. package/build/view/types.d.ts +33 -0
  257. package/build/view/types.d.ts.map +1 -0
  258. package/build/view/types.js +2 -0
  259. package/build/view/types.js.map +1 -0
  260. package/build/view.d.ts +13 -0
  261. package/build/view.d.ts.map +1 -0
  262. package/build/view.js +78 -0
  263. package/build/view.js.map +1 -0
  264. package/package.json +14 -12
  265. package/src/advertise.ts +13 -0
  266. package/src/checkout/consts.ts +1 -0
  267. package/src/checkout/index.ts +5 -0
  268. package/src/checkout/narrow/types.ts +27 -0
  269. package/src/checkout/narrow.ts +63 -0
  270. package/src/checkout/protocols.ts +39 -0
  271. package/src/checkout/types.ts +27 -0
  272. package/src/consts.local.ts +34 -0
  273. package/src/consts.ts +417 -24
  274. package/src/consumer/cancel.ts +40 -0
  275. package/src/consumer/consts.local.ts +16 -0
  276. package/src/consumer/consts.ts +46 -0
  277. package/src/consumer/copy/types.ts +54 -0
  278. package/src/consumer/copy.ts +121 -0
  279. package/src/consumer/deadline/types.ts +30 -0
  280. package/src/consumer/deadline.ts +61 -0
  281. package/src/consumer/fifo/types.ts +17 -0
  282. package/src/consumer/fifo.ts +42 -0
  283. package/src/consumer/index.ts +17 -0
  284. package/src/consumer/policy/types.ts +20 -0
  285. package/src/consumer/policy.ts +101 -0
  286. package/src/consumer/protocols.ts +116 -0
  287. package/src/consumer/refund/types.ts +27 -0
  288. package/src/consumer/refund.ts +136 -0
  289. package/src/consumer/refusal.ts +35 -0
  290. package/src/consumer/revive/types.ts +23 -0
  291. package/src/consumer/revive.ts +82 -0
  292. package/src/consumer/types.local.ts +10 -0
  293. package/src/consumer/types.ts +189 -0
  294. package/src/countries.ts +5 -0
  295. package/src/entitled.ts +20 -0
  296. package/src/entitlement/types.ts +29 -0
  297. package/src/entitlement.ts +86 -0
  298. package/src/errors.ts +445 -0
  299. package/src/estimate/types.ts +34 -0
  300. package/src/estimate.ts +90 -0
  301. package/src/i18n/consumer-rights/be.json +131 -0
  302. package/src/i18n/consumer-rights/de.json +131 -0
  303. package/src/i18n/consumer-rights/en.json +131 -0
  304. package/src/i18n/consumer-rights/es.json +131 -0
  305. package/src/i18n/consumer-rights/fr.json +131 -0
  306. package/src/i18n/consumer-rights/pl.json +131 -0
  307. package/src/i18n/consumer-rights/ru.json +131 -0
  308. package/src/i18n/consumer-rights/uk.json +131 -0
  309. package/src/i18n/errors/be.json +30 -0
  310. package/src/i18n/errors/de.json +30 -0
  311. package/src/i18n/errors/en.json +30 -0
  312. package/src/i18n/errors/es.json +30 -0
  313. package/src/i18n/errors/fr.json +30 -0
  314. package/src/i18n/errors/pl.json +30 -0
  315. package/src/i18n/errors/ru.json +30 -0
  316. package/src/i18n/errors/uk.json +30 -0
  317. package/src/i18n/fr.json +39 -0
  318. package/src/i18n.ts +58 -8
  319. package/src/index.ts +19 -2
  320. package/src/limit/types.ts +41 -0
  321. package/src/limit.ts +132 -0
  322. package/src/model/checkout.ts +12 -7
  323. package/src/model/consts.local.ts +83 -0
  324. package/src/model/consts.ts +206 -0
  325. package/src/model/consumer.ts +294 -0
  326. package/src/model/estimate.ts +73 -0
  327. package/src/model/index.ts +7 -2
  328. package/src/model/limit.ts +36 -0
  329. package/src/model/plan.ts +25 -12
  330. package/src/model/portal.ts +25 -0
  331. package/src/model/pricing.ts +34 -0
  332. package/src/model/view.ts +17 -0
  333. package/src/pricing/types.ts +13 -0
  334. package/src/pricing.ts +110 -0
  335. package/src/promo/types.ts +18 -0
  336. package/src/promo.ts +46 -0
  337. package/src/regions/types.ts +42 -0
  338. package/src/regions.ts +85 -0
  339. package/src/service.ts +32 -15
  340. package/src/types.local.ts +5 -0
  341. package/src/types.ts +669 -49
  342. package/src/view/types.ts +39 -0
  343. package/src/view.ts +119 -0
  344. package/tests/checkout-narrow.spec.ts +75 -0
  345. package/tests/consumer-copy.spec.ts +378 -0
  346. package/tests/consumer-deadline.spec.ts +85 -0
  347. package/tests/consumer-fifo.spec.ts +61 -0
  348. package/tests/consumer-protocols.spec.ts +103 -0
  349. package/tests/consumer-refund.spec.ts +134 -0
  350. package/tests/consumer-regions.spec.ts +140 -0
  351. package/tests/consumer-wire.spec.ts +170 -0
  352. package/tests/contract.spec.ts +183 -0
  353. package/tests/entitlement.spec.ts +103 -0
  354. package/tests/errors.spec.ts +168 -0
  355. package/tests/estimate.spec.ts +139 -0
  356. package/tests/limit.spec.ts +117 -0
  357. package/tests/pricing.spec.ts +30 -0
  358. package/tests/tsconfig.json +11 -0
  359. package/tests/view.spec.ts +130 -0
  360. package/tsconfig.json +6 -1
  361. package/build/.gitkeep +0 -0
  362. package/src/model/subscription.ts +0 -63
  363. package/src/model/utils.ts +0 -32
  364. package/src/modules.ts +0 -43
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ cancellationEffectiveAt, ConsumerRightsError, DEFAULT_CONSUMER_RIGHTS, withdrawalDeadlineHelper,
4
+ } from '../src/index.js'
5
+
6
+ const at = (iso: string): Date => new Date(iso)
7
+
8
+ describe('withdrawalDeadlineOf', () => {
9
+ test('the pinned examples: 14 days, weekend to Monday, five margin days, exclusive end of day', () => {
10
+ // Wed 2026-09-23 + 14 = Wed 10-07, + 5 margin = Mon 10-12 → ends at the start of 10-13.
11
+ expect(withdrawalDeadlineHelper.withdrawalDeadlineOf(at('2026-09-23T10:15:00Z'))).toEqual(at('2026-10-13T00:00:00.000Z'))
12
+ // Sat 2026-09-26 + 14 = Sat 10-10 → Mon 10-12, + 5 = Sat 10-17 → ends at the start of 10-18.
13
+ expect(withdrawalDeadlineHelper.withdrawalDeadlineOf(at('2026-09-26T08:00:00Z'))).toEqual(at('2026-10-18T00:00:00.000Z'))
14
+ // Sun 2026-12-20 + 14 = Sun 2027-01-03 → Mon 01-04, + 5 = Sat 01-09 → ends at the start of 01-10.
15
+ expect(withdrawalDeadlineHelper.withdrawalDeadlineOf(at('2026-12-20T23:59:59Z'))).toEqual(at('2027-01-10T00:00:00.000Z'))
16
+ })
17
+
18
+ test('a deadline is shown as its last included day', () => {
19
+ expect(withdrawalDeadlineHelper.lastWithdrawalDayOf(at('2026-10-13T00:00:00.000Z'))).toEqual(at('2026-10-12T00:00:00.000Z'))
20
+ expect(withdrawalDeadlineHelper.lastWithdrawalDayOf(at('2027-01-10T00:00:00.000Z'))).toEqual(at('2027-01-09T00:00:00.000Z'))
21
+ expect(withdrawalDeadlineHelper.lastWithdrawalDayOf(at('2026-10-13T00:00:00.001Z'))).toEqual(at('2026-10-13T00:00:00.000Z'))
22
+ })
23
+
24
+ test('the purchase day is a UTC day, whatever the hour', () => {
25
+ expect(withdrawalDeadlineHelper.withdrawalDeadlineOf(at('2026-09-23T00:00:00Z'))).toEqual(withdrawalDeadlineHelper.withdrawalDeadlineOf(at('2026-09-23T23:59:59.999Z')))
26
+ })
27
+
28
+ test('a leap day counts', () => {
29
+ // Tue 2028-02-15 + 14 = Tue 02-29, + 1 = 03-01 → ends at the start of 03-02.
30
+ expect(withdrawalDeadlineHelper.withdrawalDeadlineOf(at('2028-02-15T12:00:00Z'), { marginDays: 1 })).toEqual(at('2028-03-02T00:00:00.000Z'))
31
+ })
32
+
33
+ test('margin 0 and no rollover', () => {
34
+ expect(withdrawalDeadlineHelper.withdrawalDeadlineOf(at('2026-09-23T12:00:00Z'), { marginDays: 0 })).toEqual(at('2026-10-08T00:00:00.000Z'))
35
+ expect(withdrawalDeadlineHelper.withdrawalDeadlineOf(at('2026-09-26T12:00:00Z'), { marginDays: 0, weekendRollover: false }))
36
+ .toEqual(at('2026-10-11T00:00:00.000Z'))
37
+ expect(withdrawalDeadlineHelper.withdrawalDeadlineOf(at('2026-09-26T12:00:00Z'), { marginDays: 0 })).toEqual(at('2026-10-13T00:00:00.000Z'))
38
+ })
39
+
40
+ test('reads a policy directly', () => {
41
+ // Wed 2026-09-23 + 30 = Fri 10-23, + 5 = Wed 10-28 → ends at the start of 10-29.
42
+ expect(withdrawalDeadlineHelper.withdrawalDeadlineOf(at('2026-09-23T10:00:00Z'), { ...DEFAULT_CONSUMER_RIGHTS, withdrawalDays: 30 }))
43
+ .toEqual(at('2026-10-29T00:00:00.000Z'))
44
+ })
45
+
46
+ test('refuses an invalid date', () => {
47
+ expect(() => withdrawalDeadlineHelper.withdrawalDeadlineOf(new Date('nope'))).toThrow(ConsumerRightsError)
48
+ })
49
+ })
50
+
51
+ describe('withdrawalOpen', () => {
52
+ const deadline = at('2026-10-09T00:00:00.000Z')
53
+
54
+ test('open strictly before the deadline, never after a withdrawal or a refund', () => {
55
+ expect(withdrawalDeadlineHelper.withdrawalOpen({ deadline }, at('2026-10-08T23:59:59.999Z'))).toBe(true)
56
+ expect(withdrawalDeadlineHelper.withdrawalOpen({ deadline }, deadline)).toBe(false)
57
+ expect(withdrawalDeadlineHelper.withdrawalOpen(deadline, at('2026-10-01T00:00:00Z'))).toBe(true)
58
+ expect(withdrawalDeadlineHelper.withdrawalOpen({ deadline, withdrawnAt: at('2026-10-01T00:00:00Z') }, at('2026-10-02T00:00:00Z'))).toBe(false)
59
+ expect(withdrawalDeadlineHelper.withdrawalOpen({ deadline, refundedAt: at('2026-10-01T00:00:00Z') }, at('2026-10-02T00:00:00Z'))).toBe(false)
60
+ expect(withdrawalDeadlineHelper.withdrawalOpen({}, at('2026-10-02T00:00:00Z'))).toBe(false)
61
+ expect(withdrawalDeadlineHelper.withdrawalOpen(null)).toBe(false)
62
+ })
63
+ })
64
+
65
+ describe('cancellationEffectiveAt', () => {
66
+ const periodEnd = at('2026-10-31T10:00:00.000Z')
67
+
68
+ test('no date, or one inside the current period, ends at the period end', () => {
69
+ expect(cancellationEffectiveAt(periodEnd, 'month')).toEqual(periodEnd)
70
+ expect(cancellationEffectiveAt(periodEnd, 'month', at('2026-10-15T00:00:00Z'))).toEqual(periodEnd)
71
+ expect(cancellationEffectiveAt(periodEnd, 'month', periodEnd)).toEqual(periodEnd)
72
+ })
73
+
74
+ test('a later date ends at the first boundary on or after it, the 31st clamped per month', () => {
75
+ expect(cancellationEffectiveAt(periodEnd, 'month', at('2026-11-15T00:00:00Z'))).toEqual(at('2026-11-30T10:00:00.000Z'))
76
+ expect(cancellationEffectiveAt(periodEnd, 'month', at('2026-12-01T00:00:00Z'))).toEqual(at('2026-12-31T10:00:00.000Z'))
77
+ expect(cancellationEffectiveAt(periodEnd, 'month', at('2027-02-10T00:00:00Z'))).toEqual(at('2027-02-28T10:00:00.000Z'))
78
+ expect(cancellationEffectiveAt(periodEnd, 'month', at('2027-03-01T00:00:00Z'))).toEqual(at('2027-03-31T10:00:00.000Z'))
79
+ expect(cancellationEffectiveAt(periodEnd, 'year', at('2027-01-01T00:00:00Z'))).toEqual(at('2027-10-31T10:00:00.000Z'))
80
+ })
81
+
82
+ test('refuses an invalid date', () => {
83
+ expect(() => cancellationEffectiveAt(periodEnd, 'month', new Date('x'))).toThrow(ConsumerRightsError)
84
+ })
85
+ })
@@ -0,0 +1,61 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { fifoHelper } from '../src/index.js'
3
+
4
+ const t = (hour: number): Date => new Date(Date.UTC(2026, 8, 23, hour))
5
+
6
+ describe('allocateFifo', () => {
7
+ test('a spend takes the oldest lot first and spills into the next', () => {
8
+ const { lots, unallocated } = fifoHelper.allocateFifo(
9
+ [{ id: 'b', units: 100, at: t(3) }, { id: 'a', units: 100, at: t(1) }],
10
+ [{ units: 80, at: t(4) }, { units: 50, at: t(2) }],
11
+ )
12
+ expect(lots.map(lot => lot.id)).toEqual(['a', 'b'])
13
+ expect(lots[0]).toMatchObject({ granted: 100, used: 100, slices: [{ at: t(2), units: 50 }, { at: t(4), units: 50 }] })
14
+ expect(lots[1]).toMatchObject({ granted: 100, used: 30, slices: [{ at: t(4), units: 30 }] })
15
+ expect(unallocated).toBe(0)
16
+ })
17
+
18
+ test('a lot granted after a spend never pays for it', () => {
19
+ const { lots, unallocated } = fifoHelper.allocateFifo([{ id: 'late', units: 100, at: t(5) }], [{ units: 40, at: t(4) }])
20
+ expect(lots[0].used).toBe(0)
21
+ expect(unallocated).toBe(40)
22
+ })
23
+
24
+ test('a spend at the grant instant takes from the lot', () => {
25
+ expect(fifoHelper.allocateFifo([{ id: 'a', units: 10, at: t(1) }], [{ units: 4, at: t(1) }]).lots[0].used).toBe(4)
26
+ })
27
+
28
+ test('overflow beyond every lot is unallocated', () => {
29
+ const { lots, unallocated } = fifoHelper.allocateFifo(
30
+ [{ id: 'a', units: 100, at: t(1) }, { id: 'b', units: 100, at: t(2) }],
31
+ [{ units: 250, at: t(3) }],
32
+ )
33
+ expect(lots.map(lot => lot.used)).toEqual([100, 100])
34
+ expect(unallocated).toBe(50)
35
+ })
36
+
37
+ test('leaves its inputs untouched', () => {
38
+ const lots = [{ id: 'b', units: 1, at: t(2) }, { id: 'a', units: 1, at: t(1) }]
39
+ const spends = [{ units: 1, at: t(3) }]
40
+ fifoHelper.allocateFifo(lots, spends)
41
+ expect(lots.map(lot => lot.id)).toEqual(['b', 'a'])
42
+ })
43
+ })
44
+
45
+ describe('unitsUsedAfter', () => {
46
+ const { lots } = fifoHelper.allocateFifo(
47
+ [{ id: 'a', units: 100, at: t(1) }],
48
+ [{ units: 10, at: t(2) }, { units: 20, at: t(3) }, { units: 30, at: t(4) }],
49
+ )
50
+
51
+ test('counts only the slices strictly after the consent', () => {
52
+ expect(fifoHelper.unitsUsedAfter(lots[0], t(1))).toBe(60)
53
+ expect(fifoHelper.unitsUsedAfter(lots[0], t(3))).toBe(30)
54
+ expect(fifoHelper.unitsUsedAfter(lots[0], t(4))).toBe(0)
55
+ })
56
+
57
+ test('without a consent nothing is deductible', () => {
58
+ expect(fifoHelper.unitsUsedAfter(lots[0], null)).toBe(0)
59
+ expect(fifoHelper.unitsUsedAfter(lots[0], undefined)).toBe(0)
60
+ })
61
+ })
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { AppType } from '@owlmeans/context'
3
+ import { contract, gatesOf, protocol, protocols } from '@owlmeans/entrypoint'
4
+ import type { EntrypointProtocolDeclaration } from '@owlmeans/entrypoint'
5
+ import { backend, route, RouteMethod } from '@owlmeans/route'
6
+ import { makeCheckoutReadProtocols, makeConsumerRightsProtocols } from '../src/index.js'
7
+
8
+ const accountBase = protocol(route('spec:account:base', '/account', backend()), contract(), {
9
+ guards: 'spec-guard',
10
+ gate: { alias: 'spec-gate', params: 'spec-account-{entity}' },
11
+ })
12
+
13
+ const shape = (declaration: EntrypointProtocolDeclaration) => ({
14
+ alias: declaration.alias,
15
+ path: declaration.route.route.path,
16
+ method: declaration.route.route.method,
17
+ parent: declaration.route.route.parent,
18
+ })
19
+
20
+ describe('makeConsumerRightsProtocols', () => {
21
+ const tree = makeConsumerRightsProtocols({ prefix: 'spec:consumer', parent: accountBase })
22
+
23
+ test('the account subtree hangs under the guarded parent', () => {
24
+ expect(shape(tree.base)).toMatchObject({ alias: 'spec:consumer:base', path: '/consumer-rights', parent: accountBase.alias })
25
+ expect(tree.base.guards).toEqual([])
26
+ expect(gatesOf(tree.giveConsent, { accountBase, ...tree })).toEqual([{ alias: 'spec-gate', params: ['spec-account-{entity}'] }])
27
+ expect([
28
+ shape(tree.profile), shape(tree.purchases), shape(tree.consent), shape(tree.giveConsent), shape(tree.start),
29
+ shape(tree.requestStart), shape(tree.withdrawals), shape(tree.withdraw), shape(tree.cancel),
30
+ ]).toEqual([
31
+ { alias: 'spec:consumer:profile', path: '/profile', method: RouteMethod.GET, parent: 'spec:consumer:base' },
32
+ { alias: 'spec:consumer:purchases', path: '/purchases', method: RouteMethod.GET, parent: 'spec:consumer:base' },
33
+ { alias: 'spec:consumer:consent', path: '/consent', method: RouteMethod.GET, parent: 'spec:consumer:base' },
34
+ { alias: 'spec:consumer:consent:give', path: '/consent', method: RouteMethod.POST, parent: 'spec:consumer:base' },
35
+ { alias: 'spec:consumer:start', path: '/start', method: RouteMethod.GET, parent: 'spec:consumer:base' },
36
+ { alias: 'spec:consumer:start:request', path: '/start', method: RouteMethod.POST, parent: 'spec:consumer:base' },
37
+ { alias: 'spec:consumer:withdrawals', path: '/withdrawal', method: RouteMethod.GET, parent: 'spec:consumer:base' },
38
+ { alias: 'spec:consumer:withdraw', path: '/withdrawal', method: RouteMethod.POST, parent: 'spec:consumer:base' },
39
+ { alias: 'spec:consumer:cancel', path: '/cancellation', method: RouteMethod.POST, parent: 'spec:consumer:base' },
40
+ ])
41
+ })
42
+
43
+ test('without public options there is no public key at all, so the tree flattens cleanly', () => {
44
+ expect('public' in tree).toBe(false)
45
+ expect(protocols(tree)).toHaveLength(10)
46
+ expect(Object.values(tree).every(value => value != null)).toBe(true)
47
+ })
48
+
49
+ test('declares the request and response schemas', () => {
50
+ expect(tree.start.contract?.requestSchemas.query).toMatchObject({ required: ['planSku'] })
51
+ expect(tree.giveConsent.contract?.requestSchemas.body).toMatchObject({ required: ['purchaseIds', 'textVersion', 'language', 'acknowledged'] })
52
+ expect(tree.withdraw.contract?.responseSchemas?.default).toMatchObject({ required: expect.arrayContaining(['status']) })
53
+ })
54
+
55
+ test('the public subtree has no guard and no gate, and its receipts disclose nothing', () => {
56
+ const open = makeConsumerRightsProtocols({ prefix: 'spec:consumer', parent: accountBase, public: {} })
57
+ expect(shape(open.public.base)).toMatchObject({ alias: 'spec:consumer:public:base', path: '/public/consumer-rights' })
58
+ expect(open.public.base.route.route.parent).toBeUndefined()
59
+ expect([open.public.policy, open.public.withdraw, open.public.cancel].map(shape)).toEqual([
60
+ { alias: 'spec:consumer:public:policy', path: '/policy', method: RouteMethod.GET, parent: 'spec:consumer:public:base' },
61
+ { alias: 'spec:consumer:public:withdraw', path: '/withdrawal', method: RouteMethod.POST, parent: 'spec:consumer:public:base' },
62
+ { alias: 'spec:consumer:public:cancel', path: '/cancellation', method: RouteMethod.POST, parent: 'spec:consumer:public:base' },
63
+ ])
64
+ for (const declaration of protocols(open.public)) {
65
+ expect(declaration.guards).toEqual([])
66
+ expect(gatesOf(declaration, open.public)).toEqual([])
67
+ }
68
+ expect(open.public.withdraw.contract?.responseSchemas?.default?.properties).not.toHaveProperty('status')
69
+ expect('screens' in open.public).toBe(false)
70
+ expect(protocols(open)).toHaveLength(14)
71
+ })
72
+
73
+ test('the public screens are sticky frontend routes', () => {
74
+ const open = makeConsumerRightsProtocols({
75
+ prefix: 'spec:consumer', parent: accountBase, public: { path: '/legal-api', screens: { cancellation: '/cancel' } },
76
+ })
77
+ expect(open.public.base.route.route.path).toBe('/legal-api')
78
+ expect(open.public.screens.cancellation).toMatchObject({ alias: 'spec:consumer:public:screen:cancellation', sticky: true })
79
+ expect(open.public.screens.cancellation.route.route).toMatchObject({ path: '/cancel', type: AppType.Frontend })
80
+ expect(open.public.screens.withdrawal.route.route).toMatchObject({ path: '/legal/withdraw', type: AppType.Frontend })
81
+ expect(protocols(open)).toHaveLength(16)
82
+ })
83
+
84
+ test('a tree with neither a parent nor guards is a declaration error; guards alone mount it', () => {
85
+ expect(() => makeConsumerRightsProtocols({ prefix: 'spec:bare' })).toThrow(SyntaxError)
86
+ const guarded = makeConsumerRightsProtocols({ prefix: 'spec:bare', guards: 'spec-guard', path: '/rights' })
87
+ expect(guarded.base.guards).toEqual(['spec-guard'])
88
+ expect(guarded.base.route.route).toMatchObject({ path: '/rights', type: AppType.Backend })
89
+ })
90
+ })
91
+
92
+ describe('makeCheckoutReadProtocols', () => {
93
+ test('amount policy and plan prices under the guarded parent, with query schemas', () => {
94
+ const tree = makeCheckoutReadProtocols({ prefix: 'spec:checkout', parent: accountBase })
95
+ expect([tree.base, tree.amountPolicy, tree.planPrices].map(shape)).toEqual([
96
+ { alias: 'spec:checkout:base', path: '/checkout', method: undefined, parent: accountBase.alias },
97
+ { alias: 'spec:checkout:amount-policy', path: '/amount-policy', method: RouteMethod.GET, parent: 'spec:checkout:base' },
98
+ { alias: 'spec:checkout:plan-prices', path: '/plan-prices', method: RouteMethod.GET, parent: 'spec:checkout:base' },
99
+ ])
100
+ expect(tree.amountPolicy.contract?.requestSchemas.query).toMatchObject({ required: ['productSku'] })
101
+ expect(() => makeCheckoutReadProtocols({ prefix: 'spec:checkout' })).toThrow(SyntaxError)
102
+ })
103
+ })
@@ -0,0 +1,134 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { ConsumerRightsError, withdrawalRefundHelper } from '../src/index.js'
3
+ import type { PlanWithdrawalComponent } from '../src/index.js'
4
+
5
+ const DAY = 86_400_000
6
+ const periodStart = new Date('2026-09-01T00:00:00.000Z')
7
+ const periodEnd = new Date(periodStart.getTime() + 30 * DAY)
8
+
9
+ const components: PlanWithdrawalComponent[] = [
10
+ { key: 'services', basis: 'time', shareMinor: 1000 },
11
+ { key: 'credits', basis: 'units', shareMinor: 1000 },
12
+ ]
13
+
14
+ describe('splitByShares', () => {
15
+ test('parts are floors of the exact share, the rest to the largest remainders, earlier first on a tie', () => {
16
+ expect(withdrawalRefundHelper.splitByShares(2000, [1000, 1000])).toEqual([1000, 1000])
17
+ expect(withdrawalRefundHelper.splitByShares(1001, [1, 1])).toEqual([501, 500])
18
+ expect(withdrawalRefundHelper.splitByShares(10, [1, 1, 1])).toEqual([4, 3, 3])
19
+ expect(withdrawalRefundHelper.splitByShares(100, [1, 2])).toEqual([33, 67])
20
+ expect(withdrawalRefundHelper.splitByShares(100, [0, 0])).toEqual([50, 50])
21
+ expect(withdrawalRefundHelper.splitByShares(0, [3, 7])).toEqual([0, 0])
22
+ expect(withdrawalRefundHelper.splitByShares(5, [])).toEqual([])
23
+ })
24
+
25
+ test('always sums to the total', () => {
26
+ for (const [total, shares] of [[997, [3, 5, 7]], [12345, [1, 1, 1, 1, 1, 1, 1]], [1, [2, 2, 2]]] as Array<[number, number[]]>) {
27
+ expect(withdrawalRefundHelper.splitByShares(total, shares).reduce((a, b) => a + b, 0)).toBe(total)
28
+ }
29
+ })
30
+
31
+ test('refuses a negative or fractional amount', () => {
32
+ expect(() => withdrawalRefundHelper.splitByShares(-1, [1])).toThrow(ConsumerRightsError)
33
+ expect(() => withdrawalRefundHelper.splitByShares(1.5, [1])).toThrow(ConsumerRightsError)
34
+ })
35
+ })
36
+
37
+ describe('oneTimeWithdrawalRefund', () => {
38
+ test('the pinned example: 1256 paid, 125k of 500k used → 942', () => {
39
+ expect(withdrawalRefundHelper.oneTimeWithdrawalRefund({ paidMinor: 1256, unitsGranted: 500_000, unitsUsed: 125_000 }))
40
+ .toEqual({ refundMinor: 942, netRatio: 0.75, unitsReturned: 375_000 })
41
+ })
42
+
43
+ test('a refund rounds up, a fraction of a used unit rounds down', () => {
44
+ expect(withdrawalRefundHelper.oneTimeWithdrawalRefund({ paidMinor: 1000, unitsGranted: 3, unitsUsed: 1 }).refundMinor).toBe(667)
45
+ expect(withdrawalRefundHelper.oneTimeWithdrawalRefund({ paidMinor: 1000, unitsGranted: 3, unitsUsed: 1.9 }).refundMinor).toBe(667)
46
+ })
47
+
48
+ test('nothing used returns everything; everything used returns nothing', () => {
49
+ expect(withdrawalRefundHelper.oneTimeWithdrawalRefund({ paidMinor: 2550, unitsGranted: 2500, unitsUsed: 0 }).refundMinor).toBe(2550)
50
+ expect(withdrawalRefundHelper.oneTimeWithdrawalRefund({ paidMinor: 2550, unitsGranted: 2500, unitsUsed: 9_999 }))
51
+ .toEqual({ refundMinor: 0, netRatio: 0, unitsReturned: 0 })
52
+ expect(withdrawalRefundHelper.oneTimeWithdrawalRefund({ paidMinor: 2550, unitsGranted: 0, unitsUsed: 0 }).refundMinor).toBe(2550)
53
+ })
54
+
55
+ test('never refunds more than what is still unrefunded', () => {
56
+ expect(withdrawalRefundHelper.oneTimeWithdrawalRefund({ paidMinor: 1000, refundedMinor: 500, unitsGranted: 3, unitsUsed: 1 }).refundMinor).toBe(500)
57
+ expect(withdrawalRefundHelper.oneTimeWithdrawalRefund({ paidMinor: 1000, refundedMinor: 1200, unitsGranted: 3, unitsUsed: 0 }).refundMinor).toBe(0)
58
+ })
59
+ })
60
+
61
+ describe('subscriptionWithdrawalRefund (PE Digital)', () => {
62
+ test('the pinned example: services 3 of 30 days, credits 100k of 500k → net 1700, gross 2091', () => {
63
+ const result = withdrawalRefundHelper.subscriptionWithdrawalRefund({
64
+ paidMinor: 2460, netMinor: 2000, components, periodStart, periodEnd,
65
+ servicesRequestedAt: periodStart, withdrawnAt: new Date(periodStart.getTime() + 3 * DAY + 3_600_000),
66
+ units: { granted: 500_000, used: 100_000 },
67
+ })
68
+ expect(result).toMatchObject({
69
+ refundMinor: 2091, refundNetMinor: 1700, timeDeductionMinor: 100, unitsDeductionMinor: 200,
70
+ elapsedDays: 3, periodDays: 30, unitsUsed: 100_000, unitsGranted: 500_000,
71
+ })
72
+ expect(result.components.map(component => component.deductionMinor)).toEqual([100, 200])
73
+ })
74
+
75
+ test('no start request means no time deduction at all', () => {
76
+ const result = withdrawalRefundHelper.subscriptionWithdrawalRefund({
77
+ paidMinor: 2460, netMinor: 2000, components, periodStart, periodEnd,
78
+ withdrawnAt: new Date(periodStart.getTime() + 10 * DAY), units: { granted: 500_000, used: 100_000 },
79
+ })
80
+ expect(result.timeDeductionMinor).toBe(0)
81
+ expect(result.elapsedDays).toBe(0)
82
+ expect(result.refundNetMinor).toBe(1800)
83
+ expect(result.refundMinor).toBe(2214)
84
+ })
85
+
86
+ test('days count from the later of the period start and the start request, floored', () => {
87
+ const result = withdrawalRefundHelper.subscriptionWithdrawalRefund({
88
+ paidMinor: 2000, netMinor: 2000, components, periodStart, periodEnd,
89
+ servicesRequestedAt: new Date(periodStart.getTime() + 2 * DAY),
90
+ withdrawnAt: new Date(periodStart.getTime() + 3 * DAY - 1),
91
+ units: { granted: 500_000, used: 0 },
92
+ })
93
+ expect(result.elapsedDays).toBe(0)
94
+ expect(result.refundMinor).toBe(2000)
95
+ })
96
+
97
+ test('deductions round down, the gross refund rounds up', () => {
98
+ const result = withdrawalRefundHelper.subscriptionWithdrawalRefund({
99
+ paidMinor: 1230, netMinor: 1000, components: [{ key: 'services', basis: 'time', shareMinor: 1000 }],
100
+ periodStart, periodEnd, servicesRequestedAt: periodStart, withdrawnAt: new Date(periodStart.getTime() + DAY),
101
+ })
102
+ // floor(1000 / 30) = 33 → net 967 → ceil(1230 × 967 / 1000) = ceil(1189.41) = 1190
103
+ expect(result.timeDeductionMinor).toBe(33)
104
+ expect(result.refundMinor).toBe(1190)
105
+ })
106
+
107
+ test('without declared components the whole price is time; a unit component without units deducts nothing', () => {
108
+ const whole = withdrawalRefundHelper.subscriptionWithdrawalRefund({
109
+ paidMinor: 3000, netMinor: 3000, periodStart, periodEnd,
110
+ servicesRequestedAt: periodStart, withdrawnAt: new Date(periodStart.getTime() + 15 * DAY),
111
+ })
112
+ expect(whole.components).toEqual([{ key: 'price', basis: 'time', shareMinor: 3000, amountMinor: 3000, deductionMinor: 1500 }])
113
+ expect(whole.refundMinor).toBe(1500)
114
+
115
+ const noUnits = withdrawalRefundHelper.subscriptionWithdrawalRefund({
116
+ paidMinor: 2000, netMinor: 2000, components, periodStart, periodEnd, withdrawnAt: periodEnd,
117
+ })
118
+ expect(noUnits.unitsDeductionMinor).toBe(0)
119
+ expect(noUnits.refundMinor).toBe(2000)
120
+ })
121
+
122
+ test('shares that do not sum to the net split it by largest remainder', () => {
123
+ const result = withdrawalRefundHelper.subscriptionWithdrawalRefund({
124
+ paidMinor: 1001, netMinor: 1001, components, periodStart, periodEnd, withdrawnAt: periodStart,
125
+ })
126
+ expect(result.components.map(component => component.amountMinor)).toEqual([501, 500])
127
+ })
128
+
129
+ test('caps at what is still unrefunded', () => {
130
+ expect(withdrawalRefundHelper.subscriptionWithdrawalRefund({
131
+ paidMinor: 2460, refundedMinor: 2000, netMinor: 2000, components, periodStart, periodEnd, withdrawnAt: periodStart,
132
+ }).refundMinor).toBe(460)
133
+ })
134
+ })
@@ -0,0 +1,140 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import Ajv from 'ajv'
3
+ import {
4
+ CONSUMER_RIGHTS_TERRITORIES, ConsumerRegion, ConsumerRightsError, ConsumerRightsPolicySchema,
5
+ COUNTRY_LANGUAGES, DEFAULT_CONSUMER_RIGHTS, EEA_EXTRA, EU_CONSUMER_TERRITORIES, EU_COUNTRIES,
6
+ makeConsumerRightsPolicy, consumerRegionHelper, consumerRightsPolicyHelper,
7
+ } from '../src/index.js'
8
+ import type { ConsumerRightsPolicy } from '../src/index.js'
9
+
10
+ const ajv = new Ajv({ strict: false, validateFormats: false })
11
+
12
+ const links = {
13
+ en: {
14
+ billingTerms: 'https://example.com/en/billing-terms',
15
+ withdrawalInformation: 'https://example.com/en/withdrawal',
16
+ withdrawalFunction: 'https://app.example.com/legal/withdraw',
17
+ },
18
+ de: { billingTerms: 'https://example.com/de/abrechnungsbedingungen' },
19
+ }
20
+
21
+ const policy = (patch: Partial<ConsumerRightsPolicy> = {}): ConsumerRightsPolicy =>
22
+ makeConsumerRightsPolicy({ textVersion: '2026-09-23', links, ...patch })
23
+
24
+ describe('territories', () => {
25
+ test('the EU has 27 member states, Greece as GR', () => {
26
+ expect(EU_COUNTRIES).toHaveLength(27)
27
+ expect(new Set(EU_COUNTRIES).size).toBe(27)
28
+ expect(EU_COUNTRIES).toContain('GR')
29
+ expect(EU_COUNTRIES).not.toContain('EL')
30
+ expect(EU_COUNTRIES).not.toContain('GB')
31
+ })
32
+
33
+ test('consumer territories add Åland and the French outermost regions; the EEA adds IS, LI, NO', () => {
34
+ expect(EU_CONSUMER_TERRITORIES.filter(code => !EU_COUNTRIES.includes(code)).sort())
35
+ .toEqual(['AX', 'GF', 'GP', 'MF', 'MQ', 'RE', 'YT'])
36
+ expect(EEA_EXTRA).toEqual(['IS', 'LI', 'NO'])
37
+ expect(CONSUMER_RIGHTS_TERRITORIES).toHaveLength(27 + 7 + 3)
38
+ expect(DEFAULT_CONSUMER_RIGHTS.countries).toEqual([...CONSUMER_RIGHTS_TERRITORIES])
39
+ })
40
+
41
+ test('membership predicates are case-insensitive and exclude territories', () => {
42
+ expect(consumerRegionHelper.isEuCountry('pl')).toBe(true)
43
+ expect(consumerRegionHelper.isEuCountry('GF')).toBe(false)
44
+ expect(consumerRegionHelper.isEuCountry('NO')).toBe(false)
45
+ expect(consumerRegionHelper.isEeaCountry('NO')).toBe(true)
46
+ expect(consumerRegionHelper.isEeaCountry('CH')).toBe(false)
47
+ expect(consumerRegionHelper.isEuCountry(null)).toBe(false)
48
+ })
49
+ })
50
+
51
+ describe('region, scope and currency', () => {
52
+ test('a territory country is in the EU region; outside it is other; unknown is null', () => {
53
+ expect(consumerRegionHelper.regionOf('DE')).toBe(ConsumerRegion.Eu)
54
+ expect(consumerRegionHelper.regionOf('re')).toBe(ConsumerRegion.Eu)
55
+ expect(consumerRegionHelper.regionOf('NO')).toBe(ConsumerRegion.Eu)
56
+ expect(consumerRegionHelper.regionOf('US')).toBe(ConsumerRegion.Other)
57
+ expect(consumerRegionHelper.regionOf('GB')).toBe(ConsumerRegion.Other)
58
+ expect(consumerRegionHelper.regionOf(undefined)).toBeNull()
59
+ expect(consumerRegionHelper.regionOf(' ')).toBeNull()
60
+ expect(consumerRegionHelper.regionOf('US', { countries: ['US'] })).toBe(ConsumerRegion.Eu)
61
+ })
62
+
63
+ test('an unknown country is protected by default and can be ignored', () => {
64
+ expect(consumerRegionHelper.inScope(null, 'FR', policy())).toBe(true)
65
+ expect(consumerRegionHelper.inScope(null, 'US', policy())).toBe(false)
66
+ expect(consumerRegionHelper.inScope(null, null, policy())).toBe(true)
67
+ expect(consumerRegionHelper.inScope(null, null, policy({ unknownCountry: 'ignore' }))).toBe(false)
68
+ expect(consumerRegionHelper.inScope(ConsumerRegion.Other, null, policy())).toBe(false)
69
+ expect(consumerRegionHelper.inScope(null, null, null)).toBe(true)
70
+ })
71
+
72
+ test('the charge currency follows the region; an unknown region reads as EU', () => {
73
+ const declared = policy({ currencies: { eu: 'eur', other: 'usd' } })
74
+ expect(consumerRegionHelper.chargeCurrencyOf(ConsumerRegion.Eu, declared, 'usd')).toBe('eur')
75
+ expect(consumerRegionHelper.chargeCurrencyOf(ConsumerRegion.Other, declared, 'eur')).toBe('usd')
76
+ expect(consumerRegionHelper.chargeCurrencyOf(null, declared, 'usd')).toBe('eur')
77
+ expect(consumerRegionHelper.chargeCurrencyOf(ConsumerRegion.Other, policy(), 'USD')).toBe('usd')
78
+ expect(consumerRegionHelper.chargeCurrencyOf(ConsumerRegion.Eu, null, 'eur')).toBe('eur')
79
+ })
80
+
81
+ test('the legal language: policy map, then the unambiguous map, then the fallbacks', () => {
82
+ expect(COUNTRY_LANGUAGES).not.toHaveProperty('BE')
83
+ expect(COUNTRY_LANGUAGES).not.toHaveProperty('LU')
84
+ expect(COUNTRY_LANGUAGES).not.toHaveProperty('CH')
85
+ expect(consumerRegionHelper.billingLanguageOf('AT')).toBe('de')
86
+ expect(consumerRegionHelper.billingLanguageOf('gp')).toBe('fr')
87
+ expect(consumerRegionHelper.billingLanguageOf('BE', policy())).toBe('en')
88
+ expect(consumerRegionHelper.billingLanguageOf('BE', policy(), 'fr')).toBe('fr')
89
+ expect(consumerRegionHelper.billingLanguageOf('LU', policy({ languages: { LU: 'fr' } }))).toBe('fr')
90
+ expect(consumerRegionHelper.billingLanguageOf('PL', policy({ languages: { PL: 'en' } }))).toBe('en')
91
+ expect(consumerRegionHelper.billingLanguageOf(null, policy({ defaultLanguage: 'de', links: { ...links, de: links.de } }))).toBe('de')
92
+ })
93
+ })
94
+
95
+ describe('the policy declaration', () => {
96
+ test('fills the defaults and validates as a record', () => {
97
+ const declared = policy({ mechanisms: { withdrawal: true } } as Partial<ConsumerRightsPolicy>)
98
+ expect(declared).toMatchObject({
99
+ withdrawalDays: 14, unknownCountry: 'protect', defaultLanguage: 'en',
100
+ deadline: { weekendRollover: true, marginDays: 5 },
101
+ })
102
+ expect(declared.mechanisms.withdrawal).toBe(true)
103
+ expect(declared.mechanisms.checkoutTerms).toBe(false)
104
+ expect(ajv.validate(ConsumerRightsPolicySchema, declared)).toBe(true)
105
+ })
106
+
107
+ test('copies only its own fields — a server option never reaches the advertised record', () => {
108
+ const declared = makeConsumerRightsPolicy({
109
+ textVersion: 'v1', links, mail: { from: 'billing@example.com', bcc: ['archive@example.com'] },
110
+ } as Parameters<typeof makeConsumerRightsPolicy>[0])
111
+ expect(declared).not.toHaveProperty('mail')
112
+ expect(ajv.validate(ConsumerRightsPolicySchema, declared)).toBe(true)
113
+ })
114
+
115
+ test('rejects what the law or the wire cannot take', () => {
116
+ const cases: Array<[Partial<ConsumerRightsPolicy>, string]> = [
117
+ [{ textVersion: ' ' }, 'text-version'],
118
+ [{ withdrawalDays: 13 }, 'withdrawal-days'],
119
+ [{ deadline: { weekendRollover: true, marginDays: 8 } }, 'margin-days'],
120
+ [{ countries: ['Poland'] }, 'countries'],
121
+ [{ currencies: { eu: 'EURO' } }, 'currencies'],
122
+ [{ links: { de: links.de } }, 'links'],
123
+ [{ links: { en: { billingTerms: 'http://example.com/terms' } } }, 'links'],
124
+ [{ links: { ...links, de: { billingTerms: 'ftp://x' } } }, 'links:de'],
125
+ [{ links: { en: { billingTerms: links.en.billingTerms } }, mechanisms: { performanceConsent: true } as ConsumerRightsPolicy['mechanisms'] }, 'links:withdrawal-information'],
126
+ ]
127
+ for (const [patch, field] of cases) {
128
+ expect(() => policy(patch)).toThrow(`payment:consumer-rights:policy:${field}`)
129
+ expect(() => policy(patch)).toThrow(ConsumerRightsError)
130
+ }
131
+ })
132
+
133
+ test('links of a language fall back field by field to the default language', () => {
134
+ const declared = policy()
135
+ expect(consumerRightsPolicyHelper.linksOf(declared, 'de')).toEqual({ ...links.en, billingTerms: links.de.billingTerms })
136
+ expect(consumerRightsPolicyHelper.linksOf(declared, 'de-AT').billingTerms).toBe(links.de.billingTerms)
137
+ expect(consumerRightsPolicyHelper.linksOf(declared, 'fr')).toEqual(links.en)
138
+ expect(consumerRightsPolicyHelper.linksOf(declared)).toEqual(links.en)
139
+ })
140
+ })