@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,39 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CapabilityView, EntitlementPlanView, EntitlementView, LimitUsage, LimitView, ProductPlan,
|
|
3
|
+
} from '../types.js'
|
|
4
|
+
|
|
5
|
+
/** The entitlement view of a plan — its capability and limit rows — and its wire reviver. */
|
|
6
|
+
export interface EntitlementViewHelper {
|
|
7
|
+
/**
|
|
8
|
+
* One row per granted permission of a plan's capability sets.
|
|
9
|
+
*
|
|
10
|
+
* A `null`/`false` value is not a grant and is not listed. A set whose promo is no longer in force
|
|
11
|
+
* is still listed — `granted: false`, `promo.active: false` — so a UI can say the promotion ended.
|
|
12
|
+
* A set under the reserved `limit` scope is never a capability and is skipped.
|
|
13
|
+
*/
|
|
14
|
+
capabilityViewsOf: (plan: Pick<ProductPlan, 'capabilities'>, subscribedAt?: Date | null, at?: Date) => CapabilityView[]
|
|
15
|
+
/**
|
|
16
|
+
* One row per declared limit, in declaration order.
|
|
17
|
+
*
|
|
18
|
+
* `used` comes from the usage row of the limit's CURRENT window (none ⇒ `0`); a lapsed promo makes
|
|
19
|
+
* the limit `0`; `remaining` never goes below `0`; `windowStart`/`resetsAt` exist only for a
|
|
20
|
+
* window limit.
|
|
21
|
+
*/
|
|
22
|
+
limitViewsOf: (
|
|
23
|
+
plan: Pick<ProductPlan, 'limits'>, usage: LimitUsage[], subscribedAt?: Date | null, at?: Date,
|
|
24
|
+
) => LimitView[]
|
|
25
|
+
/**
|
|
26
|
+
* The full entitlement view of one effective plan.
|
|
27
|
+
*
|
|
28
|
+
* Promos are measured against `planView.subscribedAt` — leave it unset for an entity with no
|
|
29
|
+
* subscription row, and no promo is grandfathered.
|
|
30
|
+
*/
|
|
31
|
+
entitlementViewOf: (
|
|
32
|
+
plan: ProductPlan, planView: EntitlementPlanView, usage: LimitUsage[], at?: Date,
|
|
33
|
+
) => EntitlementView
|
|
34
|
+
/**
|
|
35
|
+
* An `EntitlementView` as the WIRE carries it — every date an ISO string — turned back into the
|
|
36
|
+
* typed shape with `Date` instances. Idempotent: a view that already holds dates is copied as is.
|
|
37
|
+
*/
|
|
38
|
+
reviveEntitlementView: (view: EntitlementView) => EntitlementView
|
|
39
|
+
}
|
package/src/view.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { LimitKind, CAPABILITY_LIMIT_SCOPE } from './consts.js'
|
|
2
|
+
import { entitlementParamHelper } from './entitlement.js'
|
|
3
|
+
import { planLimitHelper } from './limit.js'
|
|
4
|
+
import { promoHelper } from './promo.js'
|
|
5
|
+
import type {
|
|
6
|
+
CapabilityView, EntitlementPlanView, EntitlementView, LimitUsage, LimitView, ProductPlan,
|
|
7
|
+
} from './types.js'
|
|
8
|
+
import { LIMIT_DATES, PLAN_DATES } from './consts.local.js'
|
|
9
|
+
import type { Dated } from './types.local.js'
|
|
10
|
+
import type { EntitlementViewHelper } from './view/types.js'
|
|
11
|
+
|
|
12
|
+
export const createEntitlementViewHelper = (): EntitlementViewHelper => {
|
|
13
|
+
const capabilityViewsOf = (
|
|
14
|
+
plan: Pick<ProductPlan, 'capabilities'>,
|
|
15
|
+
subscribedAt?: Date | null,
|
|
16
|
+
at: Date = new Date(),
|
|
17
|
+
): CapabilityView[] => {
|
|
18
|
+
const views: CapabilityView[] = []
|
|
19
|
+
for (const set of plan.capabilities ?? []) {
|
|
20
|
+
if (set.scope === CAPABILITY_LIMIT_SCOPE) {
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
const granted = promoHelper.promoActive(set.promo, subscribedAt, at)
|
|
24
|
+
const promo = promoHelper.promoViewOf(set.promo, subscribedAt, at)
|
|
25
|
+
for (const [permission, value] of Object.entries(set.permissions ?? {})) {
|
|
26
|
+
if (value == null || value === false) {
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
views.push({
|
|
30
|
+
param: entitlementParamHelper.formatEntitlementParam({ scope: set.scope, permission }),
|
|
31
|
+
scope: set.scope, permission, value, granted,
|
|
32
|
+
...(promo != null ? { promo } : {}),
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return views
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const limitViewsOf = (
|
|
41
|
+
plan: Pick<ProductPlan, 'limits'>,
|
|
42
|
+
usage: LimitUsage[],
|
|
43
|
+
subscribedAt?: Date | null,
|
|
44
|
+
at: Date = new Date(),
|
|
45
|
+
): LimitView[] => Object.entries(plan.limits ?? {}).map(([key, declaration]) => {
|
|
46
|
+
const window = planLimitHelper.windowKeyOf(declaration.kind, declaration.window, at, subscribedAt ?? at)
|
|
47
|
+
const used = Math.max(0, usage.find(row => row.key === key && row.window === window)?.used ?? 0)
|
|
48
|
+
const limit = promoHelper.promoActive(declaration.promo, subscribedAt, at) ? declaration.limit : 0
|
|
49
|
+
const promo = promoHelper.promoViewOf(declaration.promo, subscribedAt, at)
|
|
50
|
+
const bounds = declaration.kind === LimitKind.Window && declaration.window != null
|
|
51
|
+
? planLimitHelper.windowBoundsOf(declaration.window, at, subscribedAt ?? at) : null
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
key, param: planLimitHelper.formatLimitParam(key), kind: declaration.kind,
|
|
55
|
+
...(declaration.kind === LimitKind.Window ? { window: declaration.window } : {}),
|
|
56
|
+
limit, used, remaining: Math.max(0, limit - used),
|
|
57
|
+
...(bounds != null ? { windowStart: bounds.start, resetsAt: bounds.resetsAt } : {}),
|
|
58
|
+
...(declaration.unit != null ? { unit: declaration.unit } : {}),
|
|
59
|
+
...(promo != null ? { promo } : {}),
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const entitlementViewOf = (
|
|
64
|
+
plan: ProductPlan,
|
|
65
|
+
planView: EntitlementPlanView,
|
|
66
|
+
usage: LimitUsage[],
|
|
67
|
+
at: Date = new Date(),
|
|
68
|
+
): EntitlementView => ({
|
|
69
|
+
plan: planView,
|
|
70
|
+
capabilities: capabilityViewsOf(plan, planView.subscribedAt, at),
|
|
71
|
+
limits: limitViewsOf(plan, usage, planView.subscribedAt, at),
|
|
72
|
+
at,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const revive = <T extends Dated>(value: T, fields: readonly string[]): T => {
|
|
76
|
+
const copy: Dated = { ...value }
|
|
77
|
+
for (const field of fields) {
|
|
78
|
+
const raw = copy[field]
|
|
79
|
+
if (raw != null && !(raw instanceof Date)) {
|
|
80
|
+
copy[field] = new Date(raw as string)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return copy as T
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const revivePromo = <T extends { promo?: unknown }>(row: T): T =>
|
|
88
|
+
row.promo == null ? row : { ...row, promo: revive(row.promo as Dated, ['until']) }
|
|
89
|
+
|
|
90
|
+
const reviveEntitlementView = (view: EntitlementView): EntitlementView => ({
|
|
91
|
+
plan: revive(view.plan as unknown as Dated, PLAN_DATES) as unknown as EntitlementPlanView,
|
|
92
|
+
capabilities: view.capabilities.map(revivePromo),
|
|
93
|
+
limits: view.limits.map(limit => revivePromo(revive(limit as unknown as Dated, LIMIT_DATES) as unknown as LimitView)),
|
|
94
|
+
at: view.at instanceof Date ? view.at : new Date(view.at),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return { capabilityViewsOf, limitViewsOf, entitlementViewOf, reviveEntitlementView }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const entitlementViewHelper = createEntitlementViewHelper()
|
|
101
|
+
|
|
102
|
+
/** @deprecated compat:factory-refactor — use `entitlementViewHelper.capabilityViewsOf(…)` */
|
|
103
|
+
export const capabilityViewsOf = (
|
|
104
|
+
plan: Pick<ProductPlan, 'capabilities'>, subscribedAt?: Date | null, at?: Date,
|
|
105
|
+
): CapabilityView[] => entitlementViewHelper.capabilityViewsOf(plan, subscribedAt, at)
|
|
106
|
+
|
|
107
|
+
/** @deprecated compat:factory-refactor — use `entitlementViewHelper.limitViewsOf(…)` */
|
|
108
|
+
export const limitViewsOf = (
|
|
109
|
+
plan: Pick<ProductPlan, 'limits'>, usage: LimitUsage[], subscribedAt?: Date | null, at?: Date,
|
|
110
|
+
): LimitView[] => entitlementViewHelper.limitViewsOf(plan, usage, subscribedAt, at)
|
|
111
|
+
|
|
112
|
+
/** @deprecated compat:factory-refactor — use `entitlementViewHelper.entitlementViewOf(…)` */
|
|
113
|
+
export const entitlementViewOf = (
|
|
114
|
+
plan: ProductPlan, planView: EntitlementPlanView, usage: LimitUsage[], at?: Date,
|
|
115
|
+
): EntitlementView => entitlementViewHelper.entitlementViewOf(plan, planView, usage, at)
|
|
116
|
+
|
|
117
|
+
/** @deprecated compat:factory-refactor — use `entitlementViewHelper.reviveEntitlementView(…)` */
|
|
118
|
+
export const reviveEntitlementView = (view: EntitlementView): EntitlementView =>
|
|
119
|
+
entitlementViewHelper.reviveEntitlementView(view)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import Ajv from 'ajv'
|
|
3
|
+
import {
|
|
4
|
+
AmountPolicyViewSchema, PaymentError, amountNarrowingHelper, checkoutPricingHelper,
|
|
5
|
+
consumerReviveHelper,
|
|
6
|
+
} from '../src/index.js'
|
|
7
|
+
import type { AmountCheckoutPolicy } from '../src/index.js'
|
|
8
|
+
|
|
9
|
+
const base: AmountCheckoutPolicy = {
|
|
10
|
+
currency: 'usd', minimumMinor: 500, maximumMinor: 50_000, defaultMinor: 2_000,
|
|
11
|
+
presetsMinor: [1_000, 2_000, 5_000, 10_000], fixedMinor: 0, rateBps: 200,
|
|
12
|
+
}
|
|
13
|
+
const resetsAt = new Date('2026-09-30T12:00:00.000Z')
|
|
14
|
+
|
|
15
|
+
describe('narrowAmountPolicy', () => {
|
|
16
|
+
test('no narrowing, or none below the base maximum, leaves the policy and no limit', () => {
|
|
17
|
+
expect(amountNarrowingHelper.narrowAmountPolicy(base, [])).toEqual({ policy: base, limit: null })
|
|
18
|
+
expect(amountNarrowingHelper.narrowAmountPolicy(base, [{ maximumMinor: 50_000, reason: 'tier' }, { maximumMinor: 90_000, reason: 'x' }]))
|
|
19
|
+
.toEqual({ policy: base, limit: null })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('the maximum is the smallest one; the reason, reset and remaining come from the narrowing that set it', () => {
|
|
23
|
+
const view = amountNarrowingHelper.narrowAmountPolicy(base, [
|
|
24
|
+
{ maximumMinor: 10_000, reason: 'window', resetsAt, remainingMinor: 10_000 },
|
|
25
|
+
{ maximumMinor: 2_500, reason: 'per-purchase' },
|
|
26
|
+
], { productSku: 'credits-product', planSku: 'credits-topup' })
|
|
27
|
+
expect(view.policy).toEqual({ ...base, maximumMinor: 2_500, presetsMinor: [1_000, 2_000] })
|
|
28
|
+
expect(view.limit).toEqual({
|
|
29
|
+
productSku: 'credits-product', planSku: 'credits-topup', currency: 'usd', minimumMinor: 500,
|
|
30
|
+
maximumMinor: 2_500, narrowed: true, blocked: false, reason: 'per-purchase',
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('the default is clamped under a narrowed maximum; the first narrowing wins a tie', () => {
|
|
35
|
+
const view = amountNarrowingHelper.narrowAmountPolicy(base, [
|
|
36
|
+
{ maximumMinor: 1_500, reason: 'window', resetsAt, remainingMinor: 1_500 },
|
|
37
|
+
{ maximumMinor: 1_500, reason: 'per-purchase' },
|
|
38
|
+
])
|
|
39
|
+
expect(view.policy.defaultMinor).toBe(1_500)
|
|
40
|
+
expect(view.policy.presetsMinor).toEqual([1_000])
|
|
41
|
+
expect(view.limit).toMatchObject({ reason: 'window', resetsAt, remainingMinor: 1_500 })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('blocked below the minimum — the policy stays valid, pinned to the minimum, and nothing is allowed', () => {
|
|
45
|
+
const view = amountNarrowingHelper.narrowAmountPolicy(base, [{ maximumMinor: 0, reason: 'window', resetsAt, remainingMinor: 0 }])
|
|
46
|
+
expect(view.limit).toMatchObject({ blocked: true, narrowed: true, maximumMinor: 0, minimumMinor: 500 })
|
|
47
|
+
expect(view.policy).toEqual({ ...base, maximumMinor: 500, defaultMinor: 500, presetsMinor: [] })
|
|
48
|
+
expect(() => checkoutPricingHelper.assertAmountCheckoutPolicy(view.policy)).not.toThrow()
|
|
49
|
+
expect(amountNarrowingHelper.amountAllowed(view, 500)).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('a negative or fractional narrowing reads as its floor at zero', () => {
|
|
53
|
+
expect(amountNarrowingHelper.narrowAmountPolicy(base, [{ maximumMinor: -5, reason: 'hold' }]).limit?.maximumMinor).toBe(0)
|
|
54
|
+
expect(amountNarrowingHelper.narrowAmountPolicy(base, [{ maximumMinor: 999.9, reason: 'x' }]).limit?.maximumMinor).toBe(999)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('amountAllowed follows the narrowed policy', () => {
|
|
58
|
+
const view = amountNarrowingHelper.narrowAmountPolicy(base, [{ maximumMinor: 2_500, reason: 'per-purchase' }])
|
|
59
|
+
expect(amountNarrowingHelper.amountAllowed(view, 2_500)).toBe(true)
|
|
60
|
+
expect(amountNarrowingHelper.amountAllowed(view, 2_501)).toBe(false)
|
|
61
|
+
expect(amountNarrowingHelper.amountAllowed(view, 499)).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('an invalid base policy is a fault', () => {
|
|
65
|
+
expect(() => amountNarrowingHelper.narrowAmountPolicy({ ...base, defaultMinor: 1 }, [])).toThrow(PaymentError)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('crosses the wire as ISO dates and revives', () => {
|
|
69
|
+
const view = amountNarrowingHelper.narrowAmountPolicy(base, [{ maximumMinor: 0, reason: 'window', resetsAt }])
|
|
70
|
+
const wire = JSON.parse(JSON.stringify(view))
|
|
71
|
+
expect(new Ajv({ strict: false, validateFormats: false }).validate(AmountPolicyViewSchema, wire)).toBe(true)
|
|
72
|
+
expect(consumerReviveHelper.reviveAmountPolicyView(wire).limit?.resetsAt).toEqual(resetsAt)
|
|
73
|
+
expect(consumerReviveHelper.reviveAmountPolicyView({ policy: base, limit: null }).limit).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { LIB_NAMESPACE, SUPPORTED_LNGS, i18nHelper } from '@owlmeans/i18n'
|
|
3
|
+
import {
|
|
4
|
+
ConsentKind, CONSUMER_RIGHTS_COPY_VERSION, CONSUMER_RIGHTS_RESOURCE, ConsumerRightsError,
|
|
5
|
+
consumerCopyHelper,
|
|
6
|
+
} from '../src/index.js'
|
|
7
|
+
import type { CopyTree } from '../src/index.js'
|
|
8
|
+
|
|
9
|
+
const LANGUAGES = [...SUPPORTED_LNGS, 'fr'] as const
|
|
10
|
+
|
|
11
|
+
const load = async (lng: string): Promise<CopyTree> =>
|
|
12
|
+
(await import(`../src/i18n/consumer-rights/${lng}.json`, { with: { type: 'json' } })).default
|
|
13
|
+
|
|
14
|
+
/** Every leaf as `path → text`. */
|
|
15
|
+
const leaves = (tree: CopyTree, prefix = ''): Map<string, string> => {
|
|
16
|
+
const result = new Map<string, string>()
|
|
17
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
18
|
+
const path = prefix === '' ? key : `${prefix}.${key}`
|
|
19
|
+
if (typeof value === 'string') {
|
|
20
|
+
result.set(path, value)
|
|
21
|
+
} else {
|
|
22
|
+
leaves(value, path).forEach((text, leaf) => result.set(leaf, text))
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return result
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Every branch as `path → sorted child keys`. */
|
|
30
|
+
const branches = (tree: CopyTree, prefix = '', result = new Map<string, string[]>()): Map<string, string[]> => {
|
|
31
|
+
result.set(prefix, Object.keys(tree).sort())
|
|
32
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
33
|
+
if (typeof value !== 'string') {
|
|
34
|
+
branches(value, prefix === '' ? key : `${prefix}.${key}`, result)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return result
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const STATUTORY: Record<string, [string, string, string, string]> = {
|
|
42
|
+
en: ['Withdraw from contract here', 'Confirm withdrawal', 'Cancel contracts here', 'Cancel now'],
|
|
43
|
+
de: ['Vertrag widerrufen', 'Widerruf bestätigen', 'Verträge hier kündigen', 'Jetzt kündigen'],
|
|
44
|
+
fr: ['Renoncer au contrat ici', 'Confirmer la rétractation', 'Résilier votre contrat', 'Notification de la résiliation'],
|
|
45
|
+
pl: ['Odstąp od umowy tutaj', 'Potwierdź odstąpienie od umowy', 'Wypowiedz umowę tutaj', 'Wypowiedz teraz'],
|
|
46
|
+
es: ['Desistir del contrato aquí', 'Confirmar el desistimiento', 'Cancelar contratos aquí', 'Cancelar ahora'],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const FORBIDDEN = [
|
|
50
|
+
/non[-\s]?refundable/i, /nicht\s+erstattungsf/i, /non\s+rembours/i, /bezzwrotn/i, /no\s+reembolsable/i,
|
|
51
|
+
/невозвратн/i, /неповоротн/i, /незваротн/i,
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
describe('the consumer-rights copy', () => {
|
|
55
|
+
test('ships all 8 languages with the same keys at every depth and no empty text', async () => {
|
|
56
|
+
const en = await load('en')
|
|
57
|
+
for (const lng of LANGUAGES) {
|
|
58
|
+
const copy = await load(lng)
|
|
59
|
+
expect([lng, [...branches(copy).entries()]]).toEqual([lng, [...branches(en).entries()]])
|
|
60
|
+
expect([...leaves(copy).values()].every(text => text.trim() !== '')).toBe(true)
|
|
61
|
+
}
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('every text carries exactly the placeholders of its English master', async () => {
|
|
65
|
+
const en = leaves(await load('en'))
|
|
66
|
+
for (const lng of LANGUAGES) {
|
|
67
|
+
for (const [path, text] of leaves(await load(lng))) {
|
|
68
|
+
expect([lng, path, consumerCopyHelper.placeholdersOf(text).sort()]).toEqual([lng, path, consumerCopyHelper.placeholdersOf(en.get(path)!).sort()])
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('pins the statutory labels exactly', () => {
|
|
74
|
+
for (const [lng, [withdraw, confirmWithdrawal, cancel, confirmCancel]] of Object.entries(STATUTORY)) {
|
|
75
|
+
expect(consumerCopyHelper.legalLabelsOf(lng)).toEqual({
|
|
76
|
+
withdrawal: { function: withdraw, confirm: confirmWithdrawal },
|
|
77
|
+
cancellation: { function: cancel, confirm: confirmCancel },
|
|
78
|
+
})
|
|
79
|
+
expect(consumerCopyHelper.consumerText(lng, 'links.withdrawal-function')).toBe(withdraw)
|
|
80
|
+
expect(consumerCopyHelper.consumerText(lng, 'links.cancellation')).toBe(cancel)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('carries the statutory verbs of the express request', () => {
|
|
85
|
+
const vars = { trader: 'Trader Ltd', plan: 'Pro' }
|
|
86
|
+
expect(consumerCopyHelper.consumerText('pl', 'performance-consent.request', vars)).toStartWith('Żądam i wyrażam wyraźną zgodę')
|
|
87
|
+
expect(consumerCopyHelper.consumerText('pl', 'performance-consent.acknowledgement', vars)).toStartWith('Przyjmuję do wiadomości')
|
|
88
|
+
expect(consumerCopyHelper.consumerText('pl', 'performance-consent.acknowledgement', vars)).toContain('utracę prawo odstąpienia od umowy')
|
|
89
|
+
expect(consumerCopyHelper.consumerText('de', 'performance-consent.request', vars)).toStartWith('Ich verlange ausdrücklich und stimme ausdrücklich zu')
|
|
90
|
+
expect(consumerCopyHelper.consumerText('de', 'performance-consent.acknowledgement', vars)).toMatch(/^Mir ist bekannt, dass mein Widerrufsrecht .* erlischt/)
|
|
91
|
+
expect(consumerCopyHelper.consumerText('fr', 'performance-consent.request', vars)).toStartWith('Je demande expressément et j’accepte expressément')
|
|
92
|
+
expect(consumerCopyHelper.consumerText('fr', 'performance-consent.acknowledgement', vars)).toStartWith('Je reconnais perdre mon droit de rétractation')
|
|
93
|
+
for (const lng of LANGUAGES) {
|
|
94
|
+
for (const context of [undefined, 'units']) {
|
|
95
|
+
expect([lng, context, consumerCopyHelper.consumerText(lng, 'subscription-start.request', vars, context).split(' ').slice(0, 3)])
|
|
96
|
+
.toEqual([lng, context, consumerCopyHelper.consumerText(lng, 'performance-consent.request', vars).split(' ').slice(0, 3)])
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
for (const context of [undefined, 'units']) {
|
|
100
|
+
expect(consumerCopyHelper.consumerText('pl', 'subscription-start.acknowledgement', vars, context)).toStartWith('Przyjmuję do wiadomości')
|
|
101
|
+
expect(consumerCopyHelper.consumerText('de', 'subscription-start.acknowledgement', vars, context)).toStartWith('Mir ist bekannt')
|
|
102
|
+
expect(consumerCopyHelper.consumerText('fr', 'subscription-start.acknowledgement', vars, context)).toStartWith('Je reconnais')
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('the English statements are the approved masters', () => {
|
|
107
|
+
expect(consumerCopyHelper.consentStatementOf('en', ConsentKind.Performance, { trader: 'OwlMeans' }).checkbox).toBe(
|
|
108
|
+
'I expressly request and agree that OwlMeans starts performing the paid service — the AI work and the digital'
|
|
109
|
+
+ ' content it produces — now, before the withdrawal period ends. I acknowledge that for the credits I use, my'
|
|
110
|
+
+ ' right of withdrawal expires once that work has been performed, and that if I withdraw, only the credits I'
|
|
111
|
+
+ ' have not used are reimbursed.',
|
|
112
|
+
)
|
|
113
|
+
expect(consumerCopyHelper.consentStatementOf('en', ConsentKind.SubscriptionStart, { trader: 'OwlMeans', plan: 'Pro' })).toEqual({
|
|
114
|
+
request: 'I expressly request and agree that OwlMeans starts the Pro services — including the AI work within the'
|
|
115
|
+
+ ' usage the plan includes — now, before the 14-day withdrawal period ends.',
|
|
116
|
+
acknowledgement: 'I acknowledge that if I withdraw, I pay for the services provided until then, pro rata by the'
|
|
117
|
+
+ ' days elapsed, and everything else is reimbursed.',
|
|
118
|
+
checkbox: 'I expressly request and agree that OwlMeans starts the Pro services — including the AI work within the'
|
|
119
|
+
+ ' usage the plan includes — now, before the 14-day withdrawal period ends. I acknowledge that if I withdraw, I'
|
|
120
|
+
+ ' pay for the services provided until then, pro rata by the days elapsed, and everything else is reimbursed.',
|
|
121
|
+
})
|
|
122
|
+
expect(consumerCopyHelper.consentStatementOf('en', ConsentKind.SubscriptionStart, { trader: 'OwlMeans', plan: 'Pro', context: 'units' }).checkbox).toBe(
|
|
123
|
+
'I expressly request and agree that OwlMeans starts the Pro platform services now and performs the AI work paid'
|
|
124
|
+
+ ' with the included credits whenever I start it, before the 14-day withdrawal period ends. I acknowledge that'
|
|
125
|
+
+ ' if I withdraw, I pay for the platform services provided until then, pro rata by time, and that my right of'
|
|
126
|
+
+ ' withdrawal expires for the included credits I have used; everything else is reimbursed.',
|
|
127
|
+
)
|
|
128
|
+
expect(consumerCopyHelper.consumerText('en', 'email.start.rule')).toBe(
|
|
129
|
+
'If you withdraw within the withdrawal period, you pay for the services provided until then, pro rata by the days'
|
|
130
|
+
+ ' elapsed; everything else is reimbursed.',
|
|
131
|
+
)
|
|
132
|
+
expect(consumerCopyHelper.consumerText('en', 'email.start.rule', {}, 'units')).toBe(
|
|
133
|
+
'If you withdraw within the withdrawal period, you pay for the platform services provided until then, pro rata by'
|
|
134
|
+
+ ' time, and your right of withdrawal expires for the included credits you have used; everything else is reimbursed.',
|
|
135
|
+
)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('the checkbox is the request followed by the acknowledgement, in every language', () => {
|
|
139
|
+
for (const lng of LANGUAGES) {
|
|
140
|
+
for (const [kind, context] of [
|
|
141
|
+
[ConsentKind.Performance, undefined], [ConsentKind.Performance, 'included'],
|
|
142
|
+
[ConsentKind.SubscriptionStart, undefined], [ConsentKind.SubscriptionStart, 'units'],
|
|
143
|
+
] as const) {
|
|
144
|
+
const statement = consumerCopyHelper.consentStatementOf(lng, kind, { trader: 'Trader Ltd', plan: 'Pro', context })
|
|
145
|
+
expect([lng, kind, context, statement.checkbox])
|
|
146
|
+
.toEqual([lng, kind, context, `${statement.request} ${statement.acknowledgement}`])
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('never calls anything non-refundable, in any language', async () => {
|
|
152
|
+
for (const lng of LANGUAGES) {
|
|
153
|
+
for (const [path, text] of leaves(await load(lng))) {
|
|
154
|
+
for (const pattern of FORBIDDEN) {
|
|
155
|
+
expect([lng, path, pattern.test(text)]).toEqual([lng, path, false])
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('the paygate texts stay within 1200 characters with long URLs and values', () => {
|
|
162
|
+
const url = `https://legal.example.com/${'very-long-path-segment/'.repeat(8)}billing-terms?lang=xx&version=2026-09-23`
|
|
163
|
+
const vars = {
|
|
164
|
+
billingTerms: url, withdrawalInformation: url, cancelUrl: url, price: '€1,234.56 plus VAT',
|
|
165
|
+
product: 'prepaid credits for the platform account', country: 'PL',
|
|
166
|
+
}
|
|
167
|
+
for (const lng of LANGUAGES) {
|
|
168
|
+
for (const path of [
|
|
169
|
+
'checkout.terms-acceptance.in-scope', 'checkout.terms-acceptance.other', 'checkout.renewal.month',
|
|
170
|
+
'checkout.renewal.year', 'checkout.renewal.after-submit', 'checkout.top-up',
|
|
171
|
+
]) {
|
|
172
|
+
expect([lng, path, consumerCopyHelper.consumerText(lng, path, vars).length <= 1200]).toEqual([lng, path, true])
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
test('the e-mail templates carry what a durable medium must', () => {
|
|
178
|
+
const en = leaves(consumerCopyHelper.consumerRightsCopy('en'))
|
|
179
|
+
expect(consumerCopyHelper.placeholdersOf(en.get('email.consent.body')!)).toEqual(['date', 'statement', 'purchases'])
|
|
180
|
+
expect(consumerCopyHelper.placeholdersOf(en.get('email.consent.purchase')!)).toEqual(['contractRef', 'date', 'amount', 'deadline'])
|
|
181
|
+
// `{{trader}}` is the trader's whole identity (legal name, address, e-mail — whatever is declared).
|
|
182
|
+
expect(consumerCopyHelper.placeholdersOf(en.get('email.purchase.withdrawal-information')!)).toEqual(['trader', 'withdrawalFunction'])
|
|
183
|
+
expect(consumerCopyHelper.placeholdersOf(en.get('email.purchase.model-form')!)).toEqual(['trader'])
|
|
184
|
+
expect(en.get('email.purchase.model-form')).toContain('Model withdrawal form')
|
|
185
|
+
// A review receipt promises the reimbursement itself, within the statutory 14 days.
|
|
186
|
+
expect(en.get('email.withdrawal.review')).toContain('not later than 14 days')
|
|
187
|
+
expect(consumerCopyHelper.consumerText('de', 'email.withdrawal.review')).toContain('unverzüglich und spätestens binnen vierzehn Tagen')
|
|
188
|
+
expect(consumerCopyHelper.consumerText('fr', 'email.withdrawal.review')).toContain('sans retard excessif')
|
|
189
|
+
expect(consumerCopyHelper.consumerText('pl', 'email.withdrawal.review')).toContain('niezwłocznie, a w każdym przypadku nie później niż 14 dni')
|
|
190
|
+
// A deadline is shown as its last included day.
|
|
191
|
+
expect(en.get('withdrawal.deadline')).toContain('until the end of {{deadline}}')
|
|
192
|
+
expect(consumerCopyHelper.placeholdersOf(en.get('email.withdrawal.body')!)).toEqual(['date', 'name', 'contract', 'email'])
|
|
193
|
+
expect(consumerCopyHelper.placeholdersOf(en.get('email.cancellation.effective')!)).toEqual(['effectiveAt'])
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
describe('the included-credits variant of the performance consent', () => {
|
|
198
|
+
const VARIANTS = ['title', 'intro', 'request', 'checkbox'] as const
|
|
199
|
+
|
|
200
|
+
test('is present in all 8 languages, with the placeholders of its base text', async () => {
|
|
201
|
+
for (const lng of LANGUAGES) {
|
|
202
|
+
const copy = leaves(await load(lng))
|
|
203
|
+
for (const key of VARIANTS) {
|
|
204
|
+
const variant = copy.get(`performance-consent.${key}_included`)
|
|
205
|
+
expect([lng, key, typeof variant === 'string' && variant.trim() !== '']).toEqual([lng, key, true])
|
|
206
|
+
expect([lng, key, consumerCopyHelper.placeholdersOf(variant!).sort()])
|
|
207
|
+
.toEqual([lng, key, consumerCopyHelper.placeholdersOf(copy.get(`performance-consent.${key}`)!).sort()])
|
|
208
|
+
expect([lng, key, variant === copy.get(`performance-consent.${key}`)]).toEqual([lng, key, false])
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test('its checkbox is its request followed by the base acknowledgement, in every language', () => {
|
|
214
|
+
for (const lng of LANGUAGES) {
|
|
215
|
+
const statement = consumerCopyHelper.consentStatementOf(lng, ConsentKind.Performance, { trader: 'Trader Ltd', context: 'included' })
|
|
216
|
+
const base = consumerCopyHelper.consentStatementOf(lng, ConsentKind.Performance, { trader: 'Trader Ltd' })
|
|
217
|
+
expect(statement.request).toBe(consumerCopyHelper.consumerText(lng, 'performance-consent.request_included', { trader: 'Trader Ltd' }))
|
|
218
|
+
expect(statement.acknowledgement).toBe(base.acknowledgement)
|
|
219
|
+
expect(statement.checkbox).toBe(`${statement.request} ${statement.acknowledgement}`)
|
|
220
|
+
expect(statement.request).not.toBe(base.request)
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('keeps the statutory opening of the base request in every language', () => {
|
|
225
|
+
const vars = { trader: 'Trader Ltd' }
|
|
226
|
+
for (const lng of LANGUAGES) {
|
|
227
|
+
expect(consumerCopyHelper.consumerText(lng, 'performance-consent.request', vars, 'included').split(' ').slice(0, 3))
|
|
228
|
+
.toEqual(consumerCopyHelper.consumerText(lng, 'performance-consent.request', vars).split(' ').slice(0, 3))
|
|
229
|
+
}
|
|
230
|
+
expect(consumerCopyHelper.consumerText('pl', 'performance-consent.request', vars, 'included')).toStartWith('Żądam i wyrażam wyraźną zgodę')
|
|
231
|
+
expect(consumerCopyHelper.consumerText('de', 'performance-consent.request', vars, 'included')).toStartWith('Ich verlange ausdrücklich und stimme ausdrücklich zu')
|
|
232
|
+
expect(consumerCopyHelper.consumerText('fr', 'performance-consent.request', vars, 'included')).toStartWith('Je demande expressément et j’accepte expressément')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('the English variant is the approved master', () => {
|
|
236
|
+
expect(consumerCopyHelper.consumerText('en', 'performance-consent.title', {}, 'included')).toBe('Use your topped-up credits now?')
|
|
237
|
+
expect(consumerCopyHelper.consumerText('en', 'performance-consent.intro', { count: 1, deadline: 'October 8, 2026' }, 'included')).toBe(
|
|
238
|
+
'Purchases still within the withdrawal period: 1. The last withdrawal period ends on October 8, 2026. The'
|
|
239
|
+
+ ' credit limits included in your plan are always used first; this applies to credits you topped up once those'
|
|
240
|
+
+ ' limits have run out.',
|
|
241
|
+
)
|
|
242
|
+
expect(consumerCopyHelper.consentStatementOf('en', ConsentKind.Performance, { trader: 'OwlMeans', context: 'included' }).checkbox).toBe(
|
|
243
|
+
'I expressly request and agree that OwlMeans starts performing the paid service — the AI work and the digital'
|
|
244
|
+
+ ' content it produces — now, before the withdrawal period ends, paid with my topped-up credits once the credit'
|
|
245
|
+
+ ' limits included in my plan, which are always used first, have run out. I acknowledge that for the credits I'
|
|
246
|
+
+ ' use, my right of withdrawal expires once that work has been performed, and that if I withdraw, only the'
|
|
247
|
+
+ ' credits I have not used are reimbursed.',
|
|
248
|
+
)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test('names the platform\'s credit vocabulary in every language: topped-up credits and credit limits', () => {
|
|
252
|
+
const TERMS: Record<string, [RegExp, RegExp]> = {
|
|
253
|
+
en: [/topped[- ]up/, /credit limits/i], pl: [/doładowan/, /limit(y|ów) kredytów/i],
|
|
254
|
+
ru: [/пополненн/, /лимиты кредитов/i], be: [/папоўнен/, /ліміты крэдытаў/i], uk: [/поповнен/, /ліміти кредитів/i],
|
|
255
|
+
es: [/recargad/, /límites de créditos/i], de: [/aufgeladenen Credits/, /Credit-Limits/],
|
|
256
|
+
fr: [/rechargés/, /limites de crédits/i],
|
|
257
|
+
}
|
|
258
|
+
for (const [lng, [toppedUp, limits]] of Object.entries(TERMS)) {
|
|
259
|
+
for (const key of ['intro', 'request', 'checkbox']) {
|
|
260
|
+
const variant = consumerCopyHelper.consumerText(lng, `performance-consent.${key}`, { trader: 'x', count: 1, deadline: 'd' }, 'included')
|
|
261
|
+
expect([lng, key, toppedUp.test(variant), limits.test(variant)]).toEqual([lng, key, true, true])
|
|
262
|
+
}
|
|
263
|
+
expect([lng, toppedUp.test(consumerCopyHelper.consumerText(lng, 'performance-consent.title', {}, 'included'))]).toEqual([lng, true])
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
test('falls back to the base text where no variant exists, and without a context', () => {
|
|
268
|
+
expect(consumerCopyHelper.consumerText('de', 'performance-consent.acknowledgement', {}, 'included'))
|
|
269
|
+
.toBe(consumerCopyHelper.consumerText('de', 'performance-consent.acknowledgement'))
|
|
270
|
+
expect(consumerCopyHelper.consumerText('en', 'performance-consent.confirm', {}, 'unknown-context')).toBe('Start now')
|
|
271
|
+
expect(consumerCopyHelper.consumerText('en', 'withdrawal.function', {}, '')).toBe('Withdraw from contract here')
|
|
272
|
+
expect(consumerCopyHelper.consentStatementOf('pl', ConsentKind.SubscriptionStart, { trader: 'x', plan: 'Pro', context: 'included' }))
|
|
273
|
+
.toEqual(consumerCopyHelper.consentStatementOf('pl', ConsentKind.SubscriptionStart, { trader: 'x', plan: 'Pro' }))
|
|
274
|
+
expect(consumerCopyHelper.consumerText('en', 'performance-consent.title')).toBe('Start using your credits now?')
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
test('a variant with a missing value names the variant', () => {
|
|
278
|
+
expect(() => consumerCopyHelper.consumerText('en', 'performance-consent.request', {}, 'included'))
|
|
279
|
+
.toThrow('copy:performance-consent.request_included:trader')
|
|
280
|
+
expect(() => consumerCopyHelper.consumerText('en', 'performance-consent.nope', {}, 'included')).toThrow('copy:performance-consent.nope')
|
|
281
|
+
})
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
describe('the subscription start statement — base (time only) and the units variant', () => {
|
|
285
|
+
const VARIANTS = ['subscription-start.request', 'subscription-start.acknowledgement', 'subscription-start.checkbox', 'email.start.rule'] as const
|
|
286
|
+
const SPLIT = { withdrawal: { components: [{ key: 'services', basis: 'time' as const, shareMinor: 1_000 }, { key: 'credits', basis: 'units' as const, shareMinor: 1_000 }] } }
|
|
287
|
+
const TIME_ONLY = { withdrawal: { components: [{ key: 'services', basis: 'time' as const, shareMinor: 2_000 }] } }
|
|
288
|
+
|
|
289
|
+
test('the units variant is present in all 8 languages, with the placeholders of its base text, and differs from it', async () => {
|
|
290
|
+
for (const lng of LANGUAGES) {
|
|
291
|
+
const copy = leaves(await load(lng))
|
|
292
|
+
for (const path of VARIANTS) {
|
|
293
|
+
const variant = copy.get(`${path}_units`)
|
|
294
|
+
expect([lng, path, typeof variant === 'string' && variant.trim() !== '']).toEqual([lng, path, true])
|
|
295
|
+
expect([lng, path, consumerCopyHelper.placeholdersOf(variant!).sort()]).toEqual([lng, path, consumerCopyHelper.placeholdersOf(copy.get(path)!).sort()])
|
|
296
|
+
expect([lng, path, variant === copy.get(path)]).toEqual([lng, path, false])
|
|
297
|
+
}
|
|
298
|
+
// Only the statement and the mail's rule depend on the arithmetic.
|
|
299
|
+
for (const key of ['title', 'intro', 'confirm', 'decline']) {
|
|
300
|
+
expect([lng, key, copy.has(`subscription-start.${key}_units`)]).toEqual([lng, key, false])
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test('the base names no included units and no expiring right; the variant names both', async () => {
|
|
306
|
+
const en = leaves(await load('en'))
|
|
307
|
+
for (const path of VARIANTS) {
|
|
308
|
+
expect([path, /credits|expires/.test(en.get(path)!)]).toEqual([path, false])
|
|
309
|
+
}
|
|
310
|
+
for (const path of ['subscription-start.acknowledgement', 'subscription-start.checkbox', 'email.start.rule']) {
|
|
311
|
+
expect([path, en.get(path)!.includes('pro rata by the days elapsed')]).toEqual([path, true])
|
|
312
|
+
}
|
|
313
|
+
expect(en.get('subscription-start.acknowledgement_units')).toContain('my right of withdrawal expires for the included credits')
|
|
314
|
+
expect(en.get('email.start.rule_units')).toContain('your right of withdrawal expires for the included credits')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
test('startContextOf: units exactly when a units component is declared', () => {
|
|
318
|
+
expect(consumerCopyHelper.startContextOf(SPLIT)).toBe('units')
|
|
319
|
+
expect(consumerCopyHelper.startContextOf({ withdrawal: { components: [SPLIT.withdrawal.components[1]] } })).toBe('units')
|
|
320
|
+
expect(consumerCopyHelper.startContextOf(TIME_ONLY)).toBeUndefined()
|
|
321
|
+
expect(consumerCopyHelper.startContextOf({})).toBeUndefined()
|
|
322
|
+
expect(consumerCopyHelper.startContextOf({ withdrawal: { components: [] } })).toBeUndefined()
|
|
323
|
+
expect(consumerCopyHelper.startContextOf(null)).toBeUndefined()
|
|
324
|
+
expect(consumerCopyHelper.startContextOf(undefined)).toBeUndefined()
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
test('a plan renders its own statement through startContextOf, in every language', () => {
|
|
328
|
+
for (const lng of LANGUAGES) {
|
|
329
|
+
const vars = { trader: 'Trader Ltd', plan: 'Pro' }
|
|
330
|
+
const split = consumerCopyHelper.consentStatementOf(lng, ConsentKind.SubscriptionStart, { ...vars, context: consumerCopyHelper.startContextOf(SPLIT) })
|
|
331
|
+
const timeOnly = consumerCopyHelper.consentStatementOf(lng, ConsentKind.SubscriptionStart, { ...vars, context: consumerCopyHelper.startContextOf(TIME_ONLY) })
|
|
332
|
+
expect(timeOnly).toEqual(consumerCopyHelper.consentStatementOf(lng, ConsentKind.SubscriptionStart, vars))
|
|
333
|
+
expect(split.checkbox).toBe(consumerCopyHelper.consumerText(lng, 'subscription-start.checkbox_units', vars))
|
|
334
|
+
expect(split.request).toBe(consumerCopyHelper.consumerText(lng, 'subscription-start.request_units', vars))
|
|
335
|
+
expect(split.acknowledgement).toBe(consumerCopyHelper.consumerText(lng, 'subscription-start.acknowledgement_units', vars))
|
|
336
|
+
expect([lng, split.checkbox === timeOnly.checkbox]).toEqual([lng, false])
|
|
337
|
+
expect(split.request).toContain('Pro')
|
|
338
|
+
expect(timeOnly.request).toContain('Pro')
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
test('the units context falls back to the base where no variant exists', () => {
|
|
343
|
+
const vars = { trader: 'Trader Ltd', plan: 'Pro' }
|
|
344
|
+
expect(consumerCopyHelper.consumerText('de', 'subscription-start.title', vars, 'units')).toBe(consumerCopyHelper.consumerText('de', 'subscription-start.title', vars))
|
|
345
|
+
expect(consumerCopyHelper.consumerText('fr', 'subscription-start.confirm', {}, 'units')).toBe('Continuer vers le paiement')
|
|
346
|
+
expect(consumerCopyHelper.consumerText('en', 'email.start.subject', { plan: 'Pro' }, 'units')).toBe(consumerCopyHelper.consumerText('en', 'email.start.subject', { plan: 'Pro' }))
|
|
347
|
+
expect(consumerCopyHelper.consentStatementOf('pl', ConsentKind.Performance, { trader: 'x', context: 'units' }))
|
|
348
|
+
.toEqual(consumerCopyHelper.consentStatementOf('pl', ConsentKind.Performance, { trader: 'x' }))
|
|
349
|
+
expect(() => consumerCopyHelper.consumerText('en', 'subscription-start.checkbox', { trader: 'x' }, 'units'))
|
|
350
|
+
.toThrow('copy:subscription-start.checkbox_units:plan')
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
describe('reading the copy', () => {
|
|
355
|
+
test('any language, a regional tag reads its base language, an unknown one reads English', () => {
|
|
356
|
+
expect(consumerCopyHelper.consumerText('de-DE', 'withdrawal.function')).toBe('Vertrag widerrufen')
|
|
357
|
+
expect(consumerCopyHelper.consumerRightsCopy('it')).toEqual(consumerCopyHelper.consumerRightsCopy('en'))
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('an application override wins key by key; the rest stays', () => {
|
|
361
|
+
i18nHelper.addI18nApp('es', CONSUMER_RIGHTS_RESOURCE, { spec: { probe: 'override' } }, { ns: LIB_NAMESPACE })
|
|
362
|
+
expect(consumerCopyHelper.consumerText('es', 'spec.probe')).toBe('override')
|
|
363
|
+
expect(consumerCopyHelper.consumerText('es', 'withdrawal.function')).toBe('Desistir del contrato aquí')
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('a missing text or value throws instead of sending a hole', () => {
|
|
367
|
+
expect(() => consumerCopyHelper.consumerText('en', 'withdrawal.nope')).toThrow(ConsumerRightsError)
|
|
368
|
+
expect(() => consumerCopyHelper.consumerText('en', 'withdrawal')).toThrow('copy:withdrawal')
|
|
369
|
+
expect(() => consumerCopyHelper.consumerText('en', 'withdrawal.received', { date: 'x' })).toThrow('copy:withdrawal.received:email')
|
|
370
|
+
expect(() => consumerCopyHelper.consentStatementOf('en', ConsentKind.SubscriptionStart, { trader: 'x' })).toThrow(ConsumerRightsError)
|
|
371
|
+
expect(consumerCopyHelper.consumerText('en', 'withdrawal.received', { date: '2026-09-23 10:00', email: 'a@b.eu' }))
|
|
372
|
+
.toBe('Your withdrawal was received on 2026-09-23 10:00 (UTC). An acknowledgement of receipt has been sent to a@b.eu.')
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test('the copy has a version', () => {
|
|
376
|
+
expect(CONSUMER_RIGHTS_COPY_VERSION).toMatch(/^\d{4}-\d{2}-\d{2}/)
|
|
377
|
+
})
|
|
378
|
+
})
|