@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,170 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import Ajv from 'ajv'
3
+ import {
4
+ BillingProfileViewSchema, CancellationBodySchema, CancellationReceiptSchema, ConsumerRegion,
5
+ ConsumerRightsError, ConsumerRightsPolicySchema, CreateCheckoutBodySchema,
6
+ makeConsumerRightsPolicy, PerformanceConsentBodySchema, PerformanceConsentViewSchema,
7
+ PlanPriceListSchema, PlanStatus, PlanDuration, PriceEstimateSchema, ProductPlanSchema,
8
+ PurchaseKind, SubscriptionStartViewSchema, TaxBehavior, TaxEstimateStatus, WithdrawalBodySchema,
9
+ WithdrawalCandidateListSchema, WithdrawalStatus, WithdrawalReceiptSchema, CancellationKind,
10
+ CancellationStatus, consumerReviveHelper,
11
+ } from '../src/index.js'
12
+ import type {
13
+ PerformanceConsentView, PurchaseView, SubscriptionStartView, WithdrawalCandidateList,
14
+ } from '../src/index.js'
15
+
16
+ const ajv = new Ajv({ strict: false, validateFormats: false })
17
+ const wire = <T>(value: T): unknown => JSON.parse(JSON.stringify(value))
18
+
19
+ const links = { billingTerms: 'https://example.com/terms', withdrawalInformation: 'https://example.com/withdrawal' }
20
+ const purchase: PurchaseView = {
21
+ purchaseId: 'stripe:cs_test_1', contractRef: 'CR-260923-ABC123', kind: PurchaseKind.TopUp,
22
+ purchasedAt: new Date('2026-09-23T10:00:00.000Z'), deadline: new Date('2026-10-09T00:00:00.000Z'),
23
+ productSku: 'credits-product', amountTotalMinor: 3075, currency: 'eur', withdrawable: true,
24
+ }
25
+
26
+ describe('consumer-rights wire shapes', () => {
27
+ test('a consent view crosses as ISO strings and revives', () => {
28
+ const view: PerformanceConsentView = {
29
+ required: true, region: ConsumerRegion.Eu, country: 'PL', language: 'pl', trader: 'Trader Ltd',
30
+ textVersion: '2026-09-23', copyVersion: '2026-09-23', links, purchases: [purchase],
31
+ deadline: purchase.deadline, at: new Date('2026-09-24T08:00:00.000Z'),
32
+ }
33
+ const sent = wire(view) as PerformanceConsentView
34
+ expect(ajv.validate(PerformanceConsentViewSchema, sent)).toBe(true)
35
+ expect(typeof sent.at).toBe('string')
36
+ const revived = consumerReviveHelper.reviveConsentView(sent)
37
+ expect(revived.at).toEqual(view.at)
38
+ expect(revived.purchases[0].purchasedAt).toEqual(purchase.purchasedAt)
39
+ expect(revived.purchases[0].deadline).toEqual(purchase.deadline)
40
+ expect(consumerReviveHelper.reviveConsentView(revived)).toEqual(revived)
41
+ })
42
+
43
+ test('a consent view carries the copy variant it is rendered with', () => {
44
+ const view: PerformanceConsentView = {
45
+ required: true, region: ConsumerRegion.Eu, country: 'DE', language: 'de', trader: 'Trader Ltd',
46
+ context: 'included', textVersion: '2026-09-23', copyVersion: '2026-09-23', links, purchases: [purchase],
47
+ at: new Date('2026-09-24T08:00:00.000Z'),
48
+ }
49
+ const sent = wire(view) as PerformanceConsentView
50
+ expect(ajv.validate(PerformanceConsentViewSchema, sent)).toBe(true)
51
+ expect(consumerReviveHelper.reviveConsentView(sent).context).toBe('included')
52
+ expect(ajv.validate(PerformanceConsentViewSchema, { ...sent, context: 'Included' })).toBe(false)
53
+ expect(ajv.validate(PerformanceConsentViewSchema, { ...sent, context: '' })).toBe(false)
54
+ })
55
+
56
+ test('a policy declares a consent context: carried, validated, a malformed one refused', () => {
57
+ const declared = makeConsumerRightsPolicy({ textVersion: 'v1', links: { en: links }, consentContext: 'included' })
58
+ expect(declared.consentContext).toBe('included')
59
+ expect(ajv.validate(ConsumerRightsPolicySchema, wire(declared))).toBe(true)
60
+ expect(ajv.validate(ConsumerRightsPolicySchema, { ...declared, consentContext: 'in cluded' })).toBe(false)
61
+ expect(makeConsumerRightsPolicy({ textVersion: 'v1', links: { en: links } })).not.toHaveProperty('consentContext')
62
+ for (const consentContext of ['Included', '_included', 'in cluded', '']) {
63
+ const declare = () => makeConsumerRightsPolicy({ textVersion: 'v1', links: { en: links }, consentContext })
64
+ expect(declare).toThrow(ConsumerRightsError)
65
+ expect(declare).toThrow('payment:consumer-rights:policy:consent-context')
66
+ }
67
+ })
68
+
69
+ test('a consent body must acknowledge', () => {
70
+ const body = { purchaseIds: ['stripe:cs_test_1'], textVersion: '2026-09-23', language: 'de', acknowledged: true }
71
+ expect(ajv.validate(PerformanceConsentBodySchema, body)).toBe(true)
72
+ expect(ajv.validate(PerformanceConsentBodySchema, { ...body, acknowledged: false })).toBe(false)
73
+ expect(ajv.validate(PerformanceConsentBodySchema, { ...body, uiLanguage: 'en' })).toBe(true)
74
+ expect(ajv.validate(PerformanceConsentBodySchema, { ...body, language: 'German' })).toBe(false)
75
+ })
76
+
77
+ test('a withdrawal or cancellation asks only for name, contract and e-mail, plus a honeypot', () => {
78
+ expect(ajv.validate(WithdrawalBodySchema, { contractRef: 'CR-260923-ABC123', name: 'Anna', email: 'anna@example.eu' })).toBe(true)
79
+ expect(ajv.validate(WithdrawalBodySchema, { name: 'Anna', email: 'anna@example.eu', honeypot: '' })).toBe(true)
80
+ expect(ajv.validate(WithdrawalBodySchema, { name: 'Anna', email: 'not-an-address' })).toBe(false)
81
+ expect(ajv.validate(WithdrawalBodySchema, { name: 'Anna', email: 'anna@example.eu', address: 'x' })).toBe(false)
82
+ const cancel = { kind: CancellationKind.Ordinary, name: 'Anna', effective: 'date', date: '2026-12-31', email: 'anna@example.eu' }
83
+ expect(ajv.validate(CancellationBodySchema, cancel)).toBe(true)
84
+ expect(ajv.validate(CancellationBodySchema, { ...cancel, date: '31.12.2026' })).toBe(false)
85
+ })
86
+
87
+ test('receipts and candidate lists revive their dates', () => {
88
+ const receipt = {
89
+ declarationId: 'decl-1', receivedAt: new Date('2026-09-25T09:30:00.000Z'), content: { name: 'Anna' }, mailed: true,
90
+ status: WithdrawalStatus.Refunded, refundMinor: 3075, currency: 'eur',
91
+ }
92
+ expect(ajv.validate(WithdrawalReceiptSchema, wire(receipt))).toBe(true)
93
+ expect(consumerReviveHelper.reviveReceipt(wire(receipt) as typeof receipt).receivedAt).toEqual(receipt.receivedAt)
94
+ const cancellation = { ...receipt, status: CancellationStatus.Scheduled, effectiveAt: new Date('2026-10-31T00:00:00.000Z') }
95
+ delete (cancellation as Partial<typeof cancellation>).refundMinor
96
+ delete (cancellation as Partial<typeof cancellation>).currency
97
+ expect(ajv.validate(CancellationReceiptSchema, wire(cancellation))).toBe(true)
98
+ expect(consumerReviveHelper.reviveReceipt(wire(cancellation) as typeof cancellation).effectiveAt).toEqual(cancellation.effectiveAt)
99
+
100
+ const list: WithdrawalCandidateList = {
101
+ candidates: [{
102
+ purchaseId: purchase.purchaseId, contractRef: purchase.contractRef, kind: purchase.kind,
103
+ purchasedAt: purchase.purchasedAt, deadline: purchase.deadline!, amountTotalMinor: 3075, currency: 'eur',
104
+ estimate: { refundMinor: 3075, currency: 'eur', timeDeductionMinor: 0, unitsDeductionMinor: 0 }, automatic: true,
105
+ }],
106
+ language: 'de', links,
107
+ }
108
+ expect(ajv.validate(WithdrawalCandidateListSchema, wire(list))).toBe(true)
109
+ expect(consumerReviveHelper.reviveWithdrawalList(wire(list) as WithdrawalCandidateList).candidates[0].deadline).toEqual(purchase.deadline!)
110
+ })
111
+
112
+ test('profile, start view and plan prices', () => {
113
+ expect(ajv.validate(BillingProfileViewSchema, {
114
+ country: null, region: null, currency: null, locked: false, language: 'en', inScope: true,
115
+ })).toBe(true)
116
+ expect(ajv.validate(SubscriptionStartViewSchema, {
117
+ required: true, planSku: 'pro-monthly', language: 'fr', trader: 'Trader Ltd', textVersion: 'v1',
118
+ copyVersion: 'v1', links, region: ConsumerRegion.Eu,
119
+ })).toBe(true)
120
+ expect(ajv.validate(PlanPriceListSchema, { prices: [
121
+ { planSku: 'pro-monthly', currency: 'eur', unitAmountMinor: 1709, default: true, taxBehavior: TaxBehavior.Exclusive, interval: 'month' },
122
+ { planSku: 'pro-monthly', currency: 'usd', unitAmountMinor: 2000, default: false },
123
+ ] })).toBe(true)
124
+ })
125
+
126
+ test('a start view carries the copy variant of its plan; a malformed one is refused', () => {
127
+ const view: SubscriptionStartView = {
128
+ required: true, planSku: 'pro-split', language: 'pl', trader: 'Trader Ltd', context: 'units', textVersion: 'v1',
129
+ copyVersion: 'v1', links, region: ConsumerRegion.Eu,
130
+ }
131
+ expect(ajv.validate(SubscriptionStartViewSchema, wire(view))).toBe(true)
132
+ expect((wire(view) as SubscriptionStartView).context).toBe('units')
133
+ for (const context of ['Units', '', '_units', 'un its']) {
134
+ expect([context, ajv.validate(SubscriptionStartViewSchema, { ...view, context })]).toEqual([context, false])
135
+ }
136
+ })
137
+ })
138
+
139
+ describe('extended catalogue and checkout shapes', () => {
140
+ test('a checkout body carries the declared country and the start request', () => {
141
+ expect(ajv.validate(CreateCheckoutBodySchema, {
142
+ productSku: 'pro-product', planSku: 'pro-monthly', entitySlug: 'acme', service: 'manager', country: 'PL',
143
+ startRequestId: 'start-1',
144
+ })).toBe(true)
145
+ expect(ajv.validate(CreateCheckoutBodySchema, {
146
+ productSku: 'pro-product', entitySlug: 'acme', service: 'manager', country: 'pl',
147
+ })).toBe(false)
148
+ })
149
+
150
+ test('an estimate may come from the locked profile', () => {
151
+ expect(ajv.validate(PriceEstimateSchema, {
152
+ country: 'PL', source: 'profile', locked: true, region: ConsumerRegion.Eu, currency: 'eur',
153
+ behavior: TaxBehavior.Exclusive,
154
+ tax: { status: TaxEstimateStatus.Taxed, subtotalMinor: 100, taxMinor: 23, totalMinor: 123, scalable: true, rates: [] },
155
+ })).toBe(true)
156
+ })
157
+
158
+ test('a plan declares its withdrawal components', () => {
159
+ const plan = {
160
+ productSku: 'pro-product', sku: 'pro-monthly', status: PlanStatus.Active, duration: PlanDuration.Monthly,
161
+ price: 20, title: 'Pro',
162
+ withdrawal: { components: [
163
+ { key: 'services', basis: 'time', shareMinor: 1000 }, { key: 'credits', basis: 'units', shareMinor: 1000 },
164
+ ] },
165
+ }
166
+ expect(ajv.validate(ProductPlanSchema, plan)).toBe(true)
167
+ expect(ajv.validate(ProductPlanSchema, { ...plan, withdrawal: { components: [{ key: 'x', basis: 'days', shareMinor: 1 }] } })).toBe(false)
168
+ expect(ajv.validate(ProductPlanSchema, { ...plan, withdrawal: { components: [] } })).toBe(false)
169
+ })
170
+ })
@@ -0,0 +1,183 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import Ajv from 'ajv'
3
+ import {
4
+ COUNTRY_CURRENCIES, CreateCheckoutBodySchema, DEFAULT_PRICING_POLICY, EntitlementViewSchema,
5
+ LimitKind, LimitWindow, PlanDuration, PlanStatus, PortalFlow, PortalLinkBodySchema,
6
+ PriceEstimateBodySchema, PriceEstimateSchema, PricingPolicySchema, ProductPlanSchema,
7
+ SubscriptionStatus, TaxBehavior, TaxEstimateStatus, TaxType, ENTITLING_STATUSES, TERMINAL_STATUSES,
8
+ entitlementViewHelper,
9
+ } from '../src/index.js'
10
+ import * as payment from '../src/index.js'
11
+ import type { PriceEstimate, ProductPlan } from '../src/index.js'
12
+
13
+ const ajv = new Ajv({ strict: false, validateFormats: false })
14
+
15
+ const plan: ProductPlan = {
16
+ productSku: 'pro-product', sku: 'pro-monthly', status: PlanStatus.Active,
17
+ duration: PlanDuration.Monthly, price: 20, title: 'Pro', rank: 10, gateways: ['stripe'],
18
+ capabilities: [{
19
+ scope: 'feature', permissions: { whitelabel: true },
20
+ promo: { until: new Date('2026-10-01T00:00:00.000Z'), grandfather: true },
21
+ }],
22
+ limits: {
23
+ seats: { kind: LimitKind.Occupancy, limit: 3 },
24
+ exports: { kind: LimitKind.Window, window: LimitWindow.Month, limit: 10, unit: 'file' },
25
+ },
26
+ }
27
+
28
+ describe('statuses', () => {
29
+ test('past-due still entitles; the terminal set does not overlap it', () => {
30
+ expect(ENTITLING_STATUSES).toEqual([SubscriptionStatus.Active, SubscriptionStatus.Trial, SubscriptionStatus.PastDue])
31
+ expect(TERMINAL_STATUSES.some(status => ENTITLING_STATUSES.includes(status))).toBe(false)
32
+ expect(Object.isFrozen(ENTITLING_STATUSES)).toBe(true)
33
+ })
34
+
35
+ test('a free tier is a plan and a one-time purchase a fulfillment, never a subscription status', () => {
36
+ expect(Object.values(SubscriptionStatus)).not.toContain('free')
37
+ expect(Object.values(SubscriptionStatus)).not.toContain('consumable')
38
+ })
39
+ })
40
+
41
+ describe('plan declarations', () => {
42
+ const validate = ajv.compile(ProductPlanSchema)
43
+
44
+ test('accept rank, free, gateways, promos and limit declarations', () => {
45
+ expect(validate(plan)).toBe(true)
46
+ expect(validate({ ...plan, free: true, price: 0, gateways: [], rank: 0 })).toBe(true)
47
+ })
48
+
49
+ test('status is a PLAN status, not a subscription status', () => {
50
+ expect(validate({ ...plan, status: PlanStatus.Hidden })).toBe(true)
51
+ expect(validate({ ...plan, status: SubscriptionStatus.Trial })).toBe(false)
52
+ })
53
+
54
+ test('reject an unknown limit kind', () => {
55
+ expect(validate({ ...plan, limits: { seats: { kind: 'forever', limit: 1 } } })).toBe(false)
56
+ })
57
+
58
+ test('reject paygate alias maps and the misspelled suspension date', () => {
59
+ expect(validate({ ...plan, suspendedAt: new Date() })).toBe(true)
60
+ expect(validate({ ...plan, payagateAliases: { stripe: 'price_1' } })).toBe(false)
61
+ expect(validate({ ...plan, supsendedAt: new Date() })).toBe(false)
62
+ })
63
+ })
64
+
65
+ describe('wire shapes', () => {
66
+ test('the entitlement view schema accepts a full view as the wire carries it', () => {
67
+ const view = entitlementViewHelper.entitlementViewOf(plan, {
68
+ sku: plan.sku, productSku: plan.productSku, title: plan.title, rank: 10, free: false,
69
+ status: SubscriptionStatus.PastDue, paygate: 'stripe', subscriptionId: 'sub_1', pastDue: true,
70
+ subscribedAt: new Date('2026-09-01T00:00:00.000Z'), periodEnd: new Date('2026-10-01T00:00:00.000Z'),
71
+ fallbackSku: 'free-plan',
72
+ }, [{ key: 'seats', window: 'occupancy', used: 1 }], new Date('2026-10-15T00:00:00.000Z'))
73
+ const validate = ajv.compile(EntitlementViewSchema)
74
+
75
+ expect(validate(JSON.parse(JSON.stringify(view)))).toBe(true)
76
+ expect(validate({ ...JSON.parse(JSON.stringify(view)), entityId: 'internal' })).toBe(false)
77
+ })
78
+
79
+ test('a portal link body names a flow', () => {
80
+ const validate = ajv.compile(PortalLinkBodySchema)
81
+ expect(validate({ flow: PortalFlow.Change, planSku: 'pro-monthly', returnUrl: 'https://app.test/billing' })).toBe(true)
82
+ expect(validate({ flow: 'refund' })).toBe(false)
83
+ expect(validate({ flow: PortalFlow.Manage, entityId: 'internal' })).toBe(false)
84
+ })
85
+
86
+ test('a checkout body takes a plan sku and never an entity id', () => {
87
+ const validate = ajv.compile(CreateCheckoutBodySchema)
88
+ const body = { productSku: 'pro-product', planSku: 'pro-monthly', entitySlug: 'acme', service: 'app' }
89
+ expect(validate(body)).toBe(true)
90
+ expect(validate({ ...body, entitySlug: undefined, entityId: 'internal' })).toBe(false)
91
+ expect(validate({ ...body, planSku: undefined, sku: 'pro-monthly' })).toBe(false)
92
+ })
93
+
94
+ test('an amount checkout body takes integer minor units only', () => {
95
+ const validate = ajv.compile(CreateCheckoutBodySchema)
96
+ const body = { productSku: 'credit-pack', entitySlug: 'acme', service: 'app' }
97
+ expect(validate({ ...body, amountMinor: 500 })).toBe(true)
98
+ expect(validate({ ...body, amountMinor: 500.5 })).toBe(false)
99
+ })
100
+ })
101
+
102
+ describe('country currencies', () => {
103
+ test('every key is an uppercase alpha-2 code and every value a lowercase alpha-3 currency', () => {
104
+ for (const [country, currency] of Object.entries(COUNTRY_CURRENCIES)) {
105
+ expect(country).toMatch(/^[A-Z]{2}$/)
106
+ expect(currency).toMatch(/^[a-z]{3}$/)
107
+ }
108
+ })
109
+
110
+ test('keys are unique and the reference points hold', () => {
111
+ const keys = Object.keys(COUNTRY_CURRENCIES)
112
+ expect(new Set(keys).size).toBe(keys.length)
113
+ expect(COUNTRY_CURRENCIES.PL).toBe('pln')
114
+ expect(COUNTRY_CURRENCIES.DE).toBe('eur')
115
+ expect(COUNTRY_CURRENCIES.US).toBe('usd')
116
+ })
117
+ })
118
+
119
+ describe('pricing policy and price estimate schemas', () => {
120
+ const validatePolicy = ajv.compile(PricingPolicySchema)
121
+ const validateBody = ajv.compile(PriceEstimateBodySchema)
122
+ const validateEstimate = ajv.compile(PriceEstimateSchema)
123
+
124
+ test('the default policy round-trips through its own schema', () => {
125
+ expect(validatePolicy(JSON.parse(JSON.stringify(DEFAULT_PRICING_POLICY)))).toBe(true)
126
+ })
127
+
128
+ test('the policy carries no Stripe-specific field (api version, migration switch)', () => {
129
+ for (const forbidden of ['fxApiVersion', 'migrateUnspecifiedPrices', 'stripe']) {
130
+ expect(validatePolicy({ ...DEFAULT_PRICING_POLICY, [forbidden]: 'x' })).toBe(false)
131
+ }
132
+ })
133
+
134
+ test('a price estimate body accepts an empty body, a plan and a country, never an entity id', () => {
135
+ expect(validateBody({})).toBe(true)
136
+ expect(validateBody({ planSku: 'vib-pro-monthly' })).toBe(true)
137
+ expect(validateBody({ country: 'PL' })).toBe(true)
138
+ expect(validateBody({ country: 'pl' })).toBe(false)
139
+ expect(validateBody({ country: 'POL' })).toBe(false)
140
+ expect(validateBody({ entityId: 'internal' })).toBe(false)
141
+ })
142
+
143
+ test('a price estimate survives a JSON round trip and rejects an extra property', () => {
144
+ const estimate: PriceEstimate = {
145
+ country: 'PL', source: 'request', currency: 'usd', behavior: TaxBehavior.Exclusive,
146
+ tax: {
147
+ status: TaxEstimateStatus.Taxed, subtotalMinor: 1_021, taxMinor: 235, totalMinor: 1_256,
148
+ scalable: true, rates: [{ type: TaxType.Vat, percentage: '23', ratePpm: 230_000, country: 'PL' }],
149
+ },
150
+ local: { currency: 'pln', exchangeRate: 0.25, fxFeeRate: 0.02 },
151
+ }
152
+ expect(validateEstimate(JSON.parse(JSON.stringify(estimate)))).toBe(true)
153
+ expect(validateEstimate({ ...JSON.parse(JSON.stringify(estimate)), extra: true })).toBe(false)
154
+ })
155
+ })
156
+
157
+ describe('package surface', () => {
158
+ test('declares no protocols: an application declares its own checkout and portal protocols', () => {
159
+ for (const name of ['paymentApi', 'entrypoints', 'serviceEntrypoints']) {
160
+ expect(payment).not.toHaveProperty(name)
161
+ }
162
+ })
163
+
164
+ test('carries no subscription record, propagation body or legacy limit schemas', () => {
165
+ for (const name of [
166
+ 'PlanSubscriptionSchema', 'SubscriptionPropagateBodySchema', 'SubscriptionPropogateBodySchema',
167
+ 'LimitConfigSchema', 'CapabilityUsageSchema',
168
+ ]) {
169
+ expect(payment).not.toHaveProperty(name)
170
+ }
171
+ })
172
+ })
173
+
174
+ describe('webhook signature refusal', () => {
175
+ test('a missing or unverifiable signature is a client error, still a PaygateError', async () => {
176
+ const { PaygateError, PaygateSignatureError } = await import('../src/errors.js')
177
+ const error = new PaygateSignatureError()
178
+ expect(error).toBeInstanceOf(PaygateError)
179
+ expect(error.message).toContain('signature')
180
+ expect((error.constructor as unknown as { httpStatus: number }).httpStatus).toBe(400)
181
+ expect((PaygateError as unknown as { httpStatus?: number }).httpStatus).toBeUndefined()
182
+ })
183
+ })
@@ -0,0 +1,103 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { entitlementParamHelper } from '../src/entitlement.js'
3
+ import { CAPABILITY_FEATURE_SCOPE, CAPABILITY_LIMIT_SCOPE, ENTITLEMENT_GATE, LIMIT_GATE } from '../src/consts.js'
4
+ import type { PermissionSet } from '@owlmeans/auth'
5
+
6
+ const pro: PermissionSet[] = [
7
+ { scope: 'renewable', permissions: { production: 1, credits: 5000 } },
8
+ { scope: CAPABILITY_FEATURE_SCOPE, permissions: { 'branding--whitelabel': true, 'domain--custom': true } },
9
+ ]
10
+
11
+ describe('the parameter grammar', () => {
12
+ test('a bare permission has no scope and no floor', () => {
13
+ expect(entitlementParamHelper.parseEntitlementParam('domain--custom')).toEqual({ permission: 'domain--custom' })
14
+ })
15
+
16
+ test('a scope is everything before the first colon', () => {
17
+ expect(entitlementParamHelper.parseEntitlementParam('feature:branding--whitelabel'))
18
+ .toEqual({ scope: 'feature', permission: 'branding--whitelabel' })
19
+ })
20
+
21
+ test('a floor is parsed as a number', () => {
22
+ expect(entitlementParamHelper.parseEntitlementParam('renewable:credits>=100'))
23
+ .toEqual({ scope: 'renewable', permission: 'credits', atLeast: 100 })
24
+ })
25
+
26
+ test('formatting round-trips', () => {
27
+ for (const param of ['domain--custom', 'feature:branding--whitelabel', 'renewable:credits>=100']) {
28
+ expect(entitlementParamHelper.formatEntitlementParam(entitlementParamHelper.parseEntitlementParam(param))).toBe(param)
29
+ }
30
+ })
31
+ })
32
+
33
+ describe('the predicate', () => {
34
+ test('a held feature passes', () => {
35
+ expect(entitlementParamHelper.hasEntitlement(pro, 'feature:branding--whitelabel')).toBe(true)
36
+ })
37
+
38
+ test('a feature nobody granted does not', () => {
39
+ expect(entitlementParamHelper.hasEntitlement(pro, 'feature:branding--nonexistent')).toBe(false)
40
+ })
41
+
42
+ test('a scope that does not match is not consulted', () => {
43
+ expect(entitlementParamHelper.hasEntitlement(pro, 'renewable:branding--whitelabel')).toBe(false)
44
+ // …and without a scope, any set may answer.
45
+ expect(entitlementParamHelper.hasEntitlement(pro, 'branding--whitelabel')).toBe(true)
46
+ })
47
+
48
+ test('a numeric floor compares', () => {
49
+ expect(entitlementParamHelper.hasEntitlement(pro, 'renewable:credits>=5000')).toBe(true)
50
+ expect(entitlementParamHelper.hasEntitlement(pro, 'renewable:credits>=5001')).toBe(false)
51
+ })
52
+
53
+ test('a boolean cannot satisfy a numeric floor', () => {
54
+ expect(entitlementParamHelper.hasEntitlement(pro, 'feature:branding--whitelabel>=1')).toBe(false)
55
+ })
56
+
57
+ test('an explicit false is not a grant', () => {
58
+ expect(entitlementParamHelper.hasEntitlement([{ scope: 'feature', permissions: { x: false } }], 'feature:x')).toBe(false)
59
+ expect(entitlementParamHelper.hasEntitlement([{ scope: 'feature', permissions: { x: null } }], 'feature:x')).toBe(false)
60
+ })
61
+
62
+ test('no capabilities at all is a refusal, never a pass', () => {
63
+ expect(entitlementParamHelper.hasEntitlement(undefined, 'feature:branding--whitelabel')).toBe(false)
64
+ expect(entitlementParamHelper.hasEntitlement([], 'feature:branding--whitelabel')).toBe(false)
65
+ })
66
+
67
+ test('a malformed parameter refuses rather than throwing', () => {
68
+ // A gate that crashed on a typo would take down the endpoint it guards.
69
+ expect(() => entitlementParamHelper.hasEntitlement(pro, '')).not.toThrow()
70
+ expect(entitlementParamHelper.hasEntitlement(pro, '')).toBe(false)
71
+ expect(entitlementParamHelper.hasEntitlement(pro, 'feature:')).toBe(false)
72
+ expect(entitlementParamHelper.hasEntitlement(pro, 'renewable:credits>=abc')).toBe(true)
73
+ })
74
+ })
75
+
76
+ describe('limits are not capabilities', () => {
77
+ test('a limit parameter never passes the capability predicate', () => {
78
+ // Even a set that (wrongly) carries the reserved scope cannot answer a limit requirement.
79
+ const misdeclared = [...pro, { scope: CAPABILITY_LIMIT_SCOPE, permissions: { seats: 5 } }]
80
+ expect(entitlementParamHelper.hasEntitlement(pro, 'limit:seats')).toBe(false)
81
+ expect(entitlementParamHelper.hasEntitlement(misdeclared, 'limit:seats')).toBe(false)
82
+ expect(entitlementParamHelper.hasEntitlement(misdeclared, 'limit:seats>=1')).toBe(false)
83
+ })
84
+
85
+ test('the two gates are distinct aliases', () => {
86
+ expect(ENTITLEMENT_GATE).toBe('entitlement-gate')
87
+ expect(LIMIT_GATE).toBe('limit-gate')
88
+ })
89
+ })
90
+
91
+ describe('listing', () => {
92
+ test('unions every granted capability, scoped', () => {
93
+ expect(entitlementParamHelper.entitlementList(pro).sort()).toEqual([
94
+ 'feature:branding--whitelabel', 'feature:domain--custom',
95
+ 'renewable:credits', 'renewable:production',
96
+ ])
97
+ })
98
+
99
+ test('skips what is not granted', () => {
100
+ expect(entitlementParamHelper.entitlementList([{ scope: 'feature', permissions: { a: true, b: false, c: null } }]))
101
+ .toEqual(['feature:a'])
102
+ })
103
+ })
@@ -0,0 +1,168 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { AuthForbidden } from '@owlmeans/auth'
3
+ import { ResilientError } from '@owlmeans/error'
4
+ import { SUPPORTED_LNGS } from '@owlmeans/i18n'
5
+ import * as payment from '../src/index.js'
6
+ import {
7
+ BillingCountryLocked, CancellationUnavailable, CapabilityRequired, CheckoutLimitExceeded, ConsentKind,
8
+ consentRefusalOf, ConsumerRightsError, ConsumerRightsRefusal, EntitlementRefusal, LimitExhausted, LimitMisdeclared,
9
+ LimitUnknown, PerformanceConsentRequired, PlanRequired, PortalUnavailable, SubscriptionStartRequired,
10
+ WebhookSetupError, WithdrawalUnavailable, WithdrawalUnavailableReason,
11
+ } from '../src/index.js'
12
+
13
+ /** The canonical set plus French, which this package ships and registers too. */
14
+ const LANGUAGES = [...SUPPORTED_LNGS, 'fr'] as const
15
+
16
+ const roundTrip = <T extends ResilientError>(error: T): ResilientError =>
17
+ ResilientError.ensure(ResilientError.marshal(error))
18
+
19
+ describe('refusals', () => {
20
+ test('are forbidden, so an HTTP boundary answers 403', () => {
21
+ expect(new CapabilityRequired('feature:whitelabel')).toBeInstanceOf(AuthForbidden)
22
+ expect(new LimitExhausted({ key: 'seats', used: 3, limit: 3 })).toBeInstanceOf(EntitlementRefusal)
23
+ expect(new LimitUnknown('seats')).not.toBeInstanceOf(AuthForbidden)
24
+ })
25
+
26
+ test('a capability refusal keeps its parameters across a marshal', () => {
27
+ const error = new CapabilityRequired(['feature:whitelabel', 'feature:custom-domain'])
28
+ expect(error.message).toContain('capability-required:feature:whitelabel|feature:custom-domain')
29
+
30
+ const back = roundTrip(error)
31
+ expect(back).toBeInstanceOf(CapabilityRequired)
32
+ expect(back.type).toBe(CapabilityRequired.typeName)
33
+ expect((back as CapabilityRequired).params).toEqual(['feature:whitelabel', 'feature:custom-domain'])
34
+ })
35
+
36
+ test('a limit refusal keeps its key, counts and reset across a marshal', () => {
37
+ const resetsAt = new Date('2026-10-01T00:00:00.000Z')
38
+ const error = new LimitExhausted({ key: 'seats', used: 3, limit: 3, resetsAt })
39
+ expect(error.message).toContain('limit-exhausted:seats:3/3:2026-10-01T00:00:00.000Z')
40
+
41
+ const back = roundTrip(error) as LimitExhausted
42
+ expect(back).toBeInstanceOf(LimitExhausted)
43
+ expect(back).toMatchObject({ limitKey: 'seats', used: 3, limit: 3, resetsAt })
44
+ })
45
+
46
+ test('a limit that never renews marshals without a reset', () => {
47
+ const back = roundTrip(new LimitExhausted({ key: 'imports', used: 1, limit: 1 })) as LimitExhausted
48
+ expect(back).toMatchObject({ limitKey: 'imports', used: 1, limit: 1 })
49
+ expect(back.resetsAt).toBeUndefined()
50
+ expect(roundTrip(new PortalUnavailable('customer'))).toBeInstanceOf(PortalUnavailable)
51
+ })
52
+ })
53
+
54
+ describe('declared HTTP status', () => {
55
+ const statusOf = (error: Error): unknown => (error.constructor as { httpStatus?: unknown }).httpStatus
56
+
57
+ test('a portal with nothing to act on is a conflict, and stays one across a marshal', () => {
58
+ expect(statusOf(new PortalUnavailable('subscription'))).toBe(409)
59
+ expect(statusOf(roundTrip(new PortalUnavailable('customer')))).toBe(409)
60
+ })
61
+
62
+ test('faults declare nothing, so an HTTP boundary answers 500', () => {
63
+ for (const error of [
64
+ new LimitUnknown('seats'), new LimitMisdeclared('seats'), new PlanRequired('x'), new WebhookSetupError('x'),
65
+ ]) {
66
+ expect(statusOf(error)).toBeUndefined()
67
+ }
68
+ })
69
+
70
+ test('refusals declare nothing either — they are AuthForbidden, which a boundary answers 403', () => {
71
+ expect(statusOf(new CapabilityRequired('feature:whitelabel'))).toBeUndefined()
72
+ expect(new LimitExhausted({ key: 'seats', used: 1, limit: 1 })).toBeInstanceOf(AuthForbidden)
73
+ })
74
+ })
75
+
76
+ const errorClasses = Object.values(payment as Record<string, unknown>).filter(
77
+ (value): value is { typeName: string } =>
78
+ typeof value === 'function' && value.prototype instanceof ResilientError,
79
+ )
80
+
81
+ describe('error messages', () => {
82
+ const load = async (lng: string): Promise<Record<string, string>> =>
83
+ (await import(`../src/i18n/errors/${lng}.json`, { with: { type: 'json' } })).default
84
+
85
+ test('every language carries the same non-empty keys', async () => {
86
+ const en = await load('en')
87
+ for (const lng of LANGUAGES) {
88
+ const messages = await load(lng)
89
+ expect(Object.keys(messages).sort()).toEqual(Object.keys(en).sort())
90
+ expect(Object.values(messages).every(message => message.trim() !== '')).toBe(true)
91
+ }
92
+ })
93
+
94
+ test('no message calls anything non-refundable', async () => {
95
+ for (const lng of LANGUAGES) {
96
+ const messages = Object.values(await load(lng)).join('\n')
97
+ expect(messages).not.toMatch(/non[-\s]?refundable|nicht\s+erstattungsf|non\s+rembours|bezzwrotn|no\s+reembolsable|невозвратн|неповоротн|незваротн/i)
98
+ }
99
+ })
100
+
101
+ test('every error type of the package has a message', async () => {
102
+ const en = await load('en')
103
+ expect(errorClasses.length).toBeGreaterThan(10)
104
+ for (const errorClass of errorClasses) {
105
+ expect(en).toHaveProperty([errorClass.typeName])
106
+ }
107
+ })
108
+ })
109
+
110
+ describe('consumer-rights refusals', () => {
111
+ const statusOf = (error: Error): unknown => (error.constructor as { httpStatus?: unknown }).httpStatus
112
+ const deadline = new Date('2026-10-09T00:00:00.000Z')
113
+ const resetsAt = new Date('2026-09-30T12:00:00.000Z')
114
+
115
+ test('declare 428 and 409 and are never forbidden; the fault declares nothing', () => {
116
+ expect(statusOf(new PerformanceConsentRequired({ pending: 1 }))).toBe(428)
117
+ expect(statusOf(new SubscriptionStartRequired('pro-monthly'))).toBe(428)
118
+ expect(statusOf(new BillingCountryLocked({ country: 'PL' }))).toBe(409)
119
+ expect(statusOf(new WithdrawalUnavailable(WithdrawalUnavailableReason.Expired))).toBe(409)
120
+ expect(statusOf(new CancellationUnavailable('no-subscription'))).toBe(409)
121
+ expect(statusOf(new CheckoutLimitExceeded({ reason: 'window', maximumMinor: 0, currency: 'usd' }))).toBe(409)
122
+ expect(statusOf(new ConsumerRightsError('policy:links'))).toBeUndefined()
123
+ for (const error of [
124
+ new PerformanceConsentRequired({ pending: 1 }), new SubscriptionStartRequired('x'), new BillingCountryLocked({ country: 'PL' }),
125
+ new WithdrawalUnavailable('expired'), new CancellationUnavailable('ended'),
126
+ new CheckoutLimitExceeded({ reason: 'x', maximumMinor: 1, currency: 'usd' }),
127
+ ]) {
128
+ expect(error).not.toBeInstanceOf(AuthForbidden)
129
+ }
130
+ expect(new PerformanceConsentRequired({ pending: 1 })).toBeInstanceOf(ConsumerRightsRefusal)
131
+ expect(new CheckoutLimitExceeded({ reason: 'x', maximumMinor: 1, currency: 'usd' })).not.toBeInstanceOf(ConsumerRightsError)
132
+ })
133
+
134
+ test('keep their fields across a marshal, with markers the wire can match', () => {
135
+ const consent = new PerformanceConsentRequired({ pending: 2, deadline })
136
+ expect(consent.message).toBe('payment:consumer-rights:performance-consent-required:2:2026-10-09T00:00:00.000Z')
137
+ const consentBack = roundTrip(consent) as PerformanceConsentRequired
138
+ expect(consentBack).toBeInstanceOf(PerformanceConsentRequired)
139
+ expect(consentBack).toMatchObject({ pending: 2, deadline })
140
+ expect(statusOf(consentBack)).toBe(428)
141
+ expect((roundTrip(new PerformanceConsentRequired({ pending: 1 })) as PerformanceConsentRequired).deadline).toBeUndefined()
142
+
143
+ const start = roundTrip(new SubscriptionStartRequired('pro:monthly/eu')) as SubscriptionStartRequired
144
+ expect(start.message).toBe('payment:consumer-rights:subscription-start-required:pro%3Amonthly%2Feu')
145
+ expect(start.planSku).toBe('pro:monthly/eu')
146
+
147
+ expect(roundTrip(new BillingCountryLocked({ country: 'PL', requested: 'DE' }))).toMatchObject({ country: 'PL', requested: 'DE' })
148
+ expect(roundTrip(new BillingCountryLocked({ country: 'PL' }))).toMatchObject({ country: 'PL', requested: undefined })
149
+ expect(roundTrip(new WithdrawalUnavailable('performed'))).toMatchObject({ reason: 'performed' })
150
+ expect(roundTrip(new CancellationUnavailable('no-subscription'))).toMatchObject({ reason: 'no-subscription' })
151
+
152
+ const limit = new CheckoutLimitExceeded({ reason: 'window:7d', maximumMinor: 2500, currency: 'USD', resetsAt })
153
+ expect(limit.message).toBe('payment:checkout-limit-exceeded:window%3A7d:2500:usd:2026-09-30T12:00:00.000Z')
154
+ expect(roundTrip(limit)).toMatchObject({ reason: 'window:7d', maximumMinor: 2500, currency: 'usd', resetsAt })
155
+ })
156
+
157
+ test('consentRefusalOf reads the class, a marshaled error, or a marker', () => {
158
+ expect(consentRefusalOf(new PerformanceConsentRequired({ pending: 1 }))).toBe(ConsentKind.Performance)
159
+ expect(consentRefusalOf(new SubscriptionStartRequired('pro-monthly'))).toBe(ConsentKind.SubscriptionStart)
160
+ expect(consentRefusalOf(ResilientError.marshal(new PerformanceConsentRequired({ pending: 3 })))).toBe(ConsentKind.Performance)
161
+ expect(consentRefusalOf({ type: PerformanceConsentRequired.typeName, message: 'x' })).toBe(ConsentKind.Performance)
162
+ expect(consentRefusalOf(new Error('upstream: subscription-start-required:pro'))).toBe(ConsentKind.SubscriptionStart)
163
+ expect(consentRefusalOf(new BillingCountryLocked({ country: 'PL' }))).toBeNull()
164
+ expect(consentRefusalOf(new Error('plain'))).toBeNull()
165
+ expect(consentRefusalOf(null)).toBeNull()
166
+ expect(consentRefusalOf('performance-consent-required')).toBeNull()
167
+ })
168
+ })