@owlmeans/payment 0.1.18-rc.9 → 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.
- package/README.md +95 -57
- package/agent-meta/manifest.json +10 -3
- package/agent-meta/skills/entitlements/SKILL.md +159 -0
- package/agent-meta/skills/payment/SKILL.md +491 -28
- package/build/advertise.d.ts +2 -0
- package/build/advertise.d.ts.map +1 -0
- package/build/advertise.js +10 -0
- package/build/advertise.js.map +1 -0
- package/build/checkout/consts.d.ts +2 -0
- package/build/checkout/consts.d.ts.map +1 -0
- package/build/checkout/consts.js +2 -0
- package/build/checkout/consts.js.map +1 -0
- package/build/checkout/index.d.ts +6 -0
- package/build/checkout/index.d.ts.map +1 -0
- package/build/checkout/index.js +4 -0
- package/build/checkout/index.js.map +1 -0
- package/build/checkout/narrow/types.d.ts +25 -0
- package/build/checkout/narrow/types.d.ts.map +1 -0
- package/build/checkout/narrow/types.js +2 -0
- package/build/checkout/narrow/types.js.map +1 -0
- package/build/checkout/narrow.d.ts +8 -0
- package/build/checkout/narrow.d.ts.map +1 -0
- package/build/checkout/narrow.js +47 -0
- package/build/checkout/narrow.js.map +1 -0
- package/build/checkout/protocols.d.ts +8 -0
- package/build/checkout/protocols.d.ts.map +1 -0
- package/build/checkout/protocols.js +28 -0
- package/build/checkout/protocols.js.map +1 -0
- package/build/checkout/types.d.ts +29 -0
- package/build/checkout/types.d.ts.map +1 -0
- package/build/checkout/types.js +2 -0
- package/build/checkout/types.js.map +1 -0
- package/build/consts.d.ts +242 -25
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +377 -24
- package/build/consts.js.map +1 -1
- package/build/consts.local.d.ts +20 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +23 -0
- package/build/consts.local.js.map +1 -0
- package/build/consumer/cancel.d.ts +11 -0
- package/build/consumer/cancel.d.ts.map +1 -0
- package/build/consumer/cancel.js +33 -0
- package/build/consumer/cancel.js.map +1 -0
- package/build/consumer/consts.d.ts +20 -0
- package/build/consumer/consts.d.ts.map +1 -0
- package/build/consumer/consts.js +39 -0
- package/build/consumer/consts.js.map +1 -0
- package/build/consumer/consts.local.d.ts +7 -0
- package/build/consumer/consts.local.d.ts.map +1 -0
- package/build/consumer/consts.local.js +13 -0
- package/build/consumer/consts.local.js.map +1 -0
- package/build/consumer/copy/types.d.ts +56 -0
- package/build/consumer/copy/types.d.ts.map +1 -0
- package/build/consumer/copy/types.js +2 -0
- package/build/consumer/copy/types.js.map +1 -0
- package/build/consumer/copy.d.ts +22 -0
- package/build/consumer/copy.d.ts.map +1 -0
- package/build/consumer/copy.js +87 -0
- package/build/consumer/copy.js.map +1 -0
- package/build/consumer/deadline/types.d.ts +30 -0
- package/build/consumer/deadline/types.d.ts.map +1 -0
- package/build/consumer/deadline/types.js +2 -0
- package/build/consumer/deadline/types.js.map +1 -0
- package/build/consumer/deadline.d.ts +12 -0
- package/build/consumer/deadline.d.ts.map +1 -0
- package/build/consumer/deadline.js +44 -0
- package/build/consumer/deadline.js.map +1 -0
- package/build/consumer/fifo/types.d.ts +17 -0
- package/build/consumer/fifo/types.d.ts.map +1 -0
- package/build/consumer/fifo/types.js +2 -0
- package/build/consumer/fifo/types.js.map +1 -0
- package/build/consumer/fifo.d.ts +4 -0
- package/build/consumer/fifo.d.ts.map +1 -0
- package/build/consumer/fifo.js +34 -0
- package/build/consumer/fifo.js.map +1 -0
- package/build/consumer/index.d.ts +18 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +11 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/policy/types.d.ts +20 -0
- package/build/consumer/policy/types.d.ts.map +1 -0
- package/build/consumer/policy/types.js +2 -0
- package/build/consumer/policy/types.js.map +1 -0
- package/build/consumer/policy.d.ts +17 -0
- package/build/consumer/policy.d.ts.map +1 -0
- package/build/consumer/policy.js +91 -0
- package/build/consumer/policy.js.map +1 -0
- package/build/consumer/protocols.d.ts +20 -0
- package/build/consumer/protocols.d.ts.map +1 -0
- package/build/consumer/protocols.js +70 -0
- package/build/consumer/protocols.js.map +1 -0
- package/build/consumer/refund/types.d.ts +27 -0
- package/build/consumer/refund/types.d.ts.map +1 -0
- package/build/consumer/refund/types.js +2 -0
- package/build/consumer/refund/types.js.map +1 -0
- package/build/consumer/refund.d.ts +9 -0
- package/build/consumer/refund.d.ts.map +1 -0
- package/build/consumer/refund.js +112 -0
- package/build/consumer/refund.js.map +1 -0
- package/build/consumer/refusal.d.ts +10 -0
- package/build/consumer/refusal.d.ts.map +1 -0
- package/build/consumer/refusal.js +35 -0
- package/build/consumer/refusal.js.map +1 -0
- package/build/consumer/revive/types.d.ts +19 -0
- package/build/consumer/revive/types.d.ts.map +1 -0
- package/build/consumer/revive/types.js +2 -0
- package/build/consumer/revive/types.js.map +1 -0
- package/build/consumer/revive.d.ts +13 -0
- package/build/consumer/revive.d.ts.map +1 -0
- package/build/consumer/revive.js +43 -0
- package/build/consumer/revive.js.map +1 -0
- package/build/consumer/types.d.ts +196 -0
- package/build/consumer/types.d.ts.map +1 -0
- package/build/consumer/types.js +2 -0
- package/build/consumer/types.js.map +1 -0
- package/build/consumer/types.local.d.ts +11 -0
- package/build/consumer/types.local.d.ts.map +1 -0
- package/build/consumer/types.local.js +2 -0
- package/build/consumer/types.local.js.map +1 -0
- package/build/countries.d.ts +3 -0
- package/build/countries.d.ts.map +1 -0
- package/build/countries.js +4 -0
- package/build/countries.js.map +1 -0
- package/build/entitled.d.ts +14 -0
- package/build/entitled.d.ts.map +1 -0
- package/build/entitled.js +17 -0
- package/build/entitled.js.map +1 -0
- package/build/entitlement/types.d.ts +29 -0
- package/build/entitlement/types.d.ts.map +1 -0
- package/build/entitlement/types.js +2 -0
- package/build/entitlement/types.js.map +1 -0
- package/build/entitlement.d.ts +7 -55
- package/build/entitlement.d.ts.map +1 -1
- package/build/entitlement.js +55 -86
- package/build/entitlement.js.map +1 -1
- package/build/errors.d.ts +189 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +361 -0
- package/build/errors.js.map +1 -1
- package/build/estimate/types.d.ts +34 -0
- package/build/estimate/types.d.ts.map +1 -0
- package/build/estimate/types.js +2 -0
- package/build/estimate/types.js.map +1 -0
- package/build/estimate.d.ts +11 -0
- package/build/estimate.d.ts.map +1 -0
- package/build/estimate.js +76 -0
- package/build/estimate.js.map +1 -0
- package/build/i18n/consumer-rights/be.json +131 -0
- package/build/i18n/consumer-rights/de.json +131 -0
- package/build/i18n/consumer-rights/en.json +131 -0
- package/build/i18n/consumer-rights/es.json +131 -0
- package/build/i18n/consumer-rights/fr.json +131 -0
- package/build/i18n/consumer-rights/pl.json +131 -0
- package/build/i18n/consumer-rights/ru.json +131 -0
- package/build/i18n/consumer-rights/uk.json +131 -0
- package/build/i18n/errors/be.json +30 -0
- package/build/i18n/errors/de.json +30 -0
- package/build/i18n/errors/en.json +30 -0
- package/build/i18n/errors/es.json +30 -0
- package/build/i18n/errors/fr.json +30 -0
- package/build/i18n/errors/pl.json +30 -0
- package/build/i18n/errors/ru.json +30 -0
- package/build/i18n/errors/uk.json +30 -0
- package/build/i18n/fr.json +39 -0
- package/build/i18n.js +53 -8
- package/build/i18n.js.map +1 -1
- package/build/index.d.ts +18 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +11 -1
- package/build/index.js.map +1 -1
- package/build/limit/types.d.ts +44 -0
- package/build/limit/types.d.ts.map +1 -0
- package/build/limit/types.js +2 -0
- package/build/limit/types.js.map +1 -0
- package/build/limit.d.ts +23 -0
- package/build/limit.d.ts.map +1 -0
- package/build/limit.js +97 -0
- package/build/limit.js.map +1 -0
- package/build/model/checkout.d.ts +2 -3
- package/build/model/checkout.d.ts.map +1 -1
- package/build/model/checkout.js +12 -7
- package/build/model/checkout.js.map +1 -1
- package/build/model/consts.d.ts +15 -0
- package/build/model/consts.d.ts.map +1 -0
- package/build/model/consts.js +193 -0
- package/build/model/consts.js.map +1 -0
- package/build/model/consts.local.d.ts +35 -0
- package/build/model/consts.local.d.ts.map +1 -0
- package/build/model/consts.local.js +65 -0
- package/build/model/consts.local.js.map +1 -0
- package/build/model/consumer.d.ts +23 -0
- package/build/model/consumer.d.ts.map +1 -0
- package/build/model/consumer.js +272 -0
- package/build/model/consumer.js.map +1 -0
- package/build/model/estimate.d.ts +5 -0
- package/build/model/estimate.d.ts.map +1 -0
- package/build/model/estimate.js +69 -0
- package/build/model/estimate.js.map +1 -0
- package/build/model/index.d.ts +7 -2
- package/build/model/index.d.ts.map +1 -1
- package/build/model/index.js +7 -2
- package/build/model/index.js.map +1 -1
- package/build/model/limit.d.ts +7 -0
- package/build/model/limit.d.ts.map +1 -0
- package/build/model/limit.js +32 -0
- package/build/model/limit.js.map +1 -0
- package/build/model/plan.d.ts.map +1 -1
- package/build/model/plan.js +24 -11
- package/build/model/plan.js.map +1 -1
- package/build/model/portal.d.ts +4 -0
- package/build/model/portal.d.ts.map +1 -0
- package/build/model/portal.js +22 -0
- package/build/model/portal.js.map +1 -0
- package/build/model/pricing.d.ts +4 -0
- package/build/model/pricing.d.ts.map +1 -0
- package/build/model/pricing.js +31 -0
- package/build/model/pricing.js.map +1 -0
- package/build/model/view.d.ts +3 -0
- package/build/model/view.d.ts.map +1 -0
- package/build/model/view.js +15 -0
- package/build/model/view.js.map +1 -0
- package/build/pricing/types.d.ts +13 -0
- package/build/pricing/types.d.ts.map +1 -0
- package/build/pricing/types.js +2 -0
- package/build/pricing/types.js.map +1 -0
- package/build/pricing.d.ts +13 -0
- package/build/pricing.d.ts.map +1 -0
- package/build/pricing.js +74 -0
- package/build/pricing.js.map +1 -0
- package/build/promo/types.d.ts +14 -0
- package/build/promo/types.d.ts.map +1 -0
- package/build/promo/types.js +2 -0
- package/build/promo/types.js.map +1 -0
- package/build/promo.d.ts +7 -0
- package/build/promo.d.ts.map +1 -0
- package/build/promo.js +25 -0
- package/build/promo.js.map +1 -0
- package/build/regions/types.d.ts +32 -0
- package/build/regions/types.d.ts.map +1 -0
- package/build/regions/types.js +2 -0
- package/build/regions/types.js.map +1 -0
- package/build/regions.d.ts +15 -0
- package/build/regions.d.ts.map +1 -0
- package/build/regions.js +43 -0
- package/build/regions.js.map +1 -0
- package/build/service.d.ts.map +1 -1
- package/build/service.js +24 -9
- package/build/service.js.map +1 -1
- package/build/types.d.ts +619 -52
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +4 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/build/view/types.d.ts +33 -0
- package/build/view/types.d.ts.map +1 -0
- package/build/view/types.js +2 -0
- package/build/view/types.js.map +1 -0
- package/build/view.d.ts +13 -0
- package/build/view.d.ts.map +1 -0
- package/build/view.js +78 -0
- package/build/view.js.map +1 -0
- package/package.json +14 -12
- package/src/advertise.ts +13 -0
- package/src/checkout/consts.ts +1 -0
- package/src/checkout/index.ts +5 -0
- package/src/checkout/narrow/types.ts +27 -0
- package/src/checkout/narrow.ts +63 -0
- package/src/checkout/protocols.ts +39 -0
- package/src/checkout/types.ts +27 -0
- package/src/consts.local.ts +34 -0
- package/src/consts.ts +417 -24
- package/src/consumer/cancel.ts +40 -0
- package/src/consumer/consts.local.ts +16 -0
- package/src/consumer/consts.ts +46 -0
- package/src/consumer/copy/types.ts +54 -0
- package/src/consumer/copy.ts +121 -0
- package/src/consumer/deadline/types.ts +30 -0
- package/src/consumer/deadline.ts +61 -0
- package/src/consumer/fifo/types.ts +17 -0
- package/src/consumer/fifo.ts +42 -0
- package/src/consumer/index.ts +17 -0
- package/src/consumer/policy/types.ts +20 -0
- package/src/consumer/policy.ts +101 -0
- package/src/consumer/protocols.ts +116 -0
- package/src/consumer/refund/types.ts +27 -0
- package/src/consumer/refund.ts +136 -0
- package/src/consumer/refusal.ts +35 -0
- package/src/consumer/revive/types.ts +23 -0
- package/src/consumer/revive.ts +82 -0
- package/src/consumer/types.local.ts +10 -0
- package/src/consumer/types.ts +189 -0
- package/src/countries.ts +5 -0
- package/src/entitled.ts +20 -0
- package/src/entitlement/types.ts +29 -0
- package/src/entitlement.ts +67 -103
- package/src/errors.ts +445 -0
- package/src/estimate/types.ts +34 -0
- package/src/estimate.ts +90 -0
- package/src/i18n/consumer-rights/be.json +131 -0
- package/src/i18n/consumer-rights/de.json +131 -0
- package/src/i18n/consumer-rights/en.json +131 -0
- package/src/i18n/consumer-rights/es.json +131 -0
- package/src/i18n/consumer-rights/fr.json +131 -0
- package/src/i18n/consumer-rights/pl.json +131 -0
- package/src/i18n/consumer-rights/ru.json +131 -0
- package/src/i18n/consumer-rights/uk.json +131 -0
- package/src/i18n/errors/be.json +30 -0
- package/src/i18n/errors/de.json +30 -0
- package/src/i18n/errors/en.json +30 -0
- package/src/i18n/errors/es.json +30 -0
- package/src/i18n/errors/fr.json +30 -0
- package/src/i18n/errors/pl.json +30 -0
- package/src/i18n/errors/ru.json +30 -0
- package/src/i18n/errors/uk.json +30 -0
- package/src/i18n/fr.json +39 -0
- package/src/i18n.ts +58 -8
- package/src/index.ts +18 -2
- package/src/limit/types.ts +41 -0
- package/src/limit.ts +132 -0
- package/src/model/checkout.ts +12 -7
- package/src/model/consts.local.ts +83 -0
- package/src/model/consts.ts +206 -0
- package/src/model/consumer.ts +294 -0
- package/src/model/estimate.ts +73 -0
- package/src/model/index.ts +7 -2
- package/src/model/limit.ts +36 -0
- package/src/model/plan.ts +25 -12
- package/src/model/portal.ts +25 -0
- package/src/model/pricing.ts +34 -0
- package/src/model/view.ts +17 -0
- package/src/pricing/types.ts +13 -0
- package/src/pricing.ts +110 -0
- package/src/promo/types.ts +18 -0
- package/src/promo.ts +46 -0
- package/src/regions/types.ts +42 -0
- package/src/regions.ts +85 -0
- package/src/service.ts +32 -15
- package/src/types.local.ts +5 -0
- package/src/types.ts +669 -49
- package/src/view/types.ts +39 -0
- package/src/view.ts +119 -0
- package/tests/checkout-narrow.spec.ts +75 -0
- package/tests/consumer-copy.spec.ts +378 -0
- package/tests/consumer-deadline.spec.ts +85 -0
- package/tests/consumer-fifo.spec.ts +61 -0
- package/tests/consumer-protocols.spec.ts +103 -0
- package/tests/consumer-refund.spec.ts +134 -0
- package/tests/consumer-regions.spec.ts +140 -0
- package/tests/consumer-wire.spec.ts +170 -0
- package/tests/contract.spec.ts +183 -0
- package/tests/entitlement.spec.ts +38 -25
- package/tests/errors.spec.ts +168 -0
- package/tests/estimate.spec.ts +139 -0
- package/tests/limit.spec.ts +117 -0
- package/tests/pricing.spec.ts +30 -0
- package/tests/view.spec.ts +130 -0
- package/build/.gitkeep +0 -0
- package/src/model/subscription.ts +0 -63
- package/src/model/utils.ts +0 -32
- package/src/modules.ts +0 -43
- package/tests/entitlement.spec.d.ts +0 -2
- package/tests/entitlement.spec.d.ts.map +0 -1
- package/tests/entitlement.spec.js +0 -72
- package/tests/entitlement.spec.js.map +0 -1
|
@@ -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
|
+
})
|