Skip to content
Back to skills

Payment

ASecurity

How to use @owlmeans/payment — provider-agnostic payment contracts, immutable payment protocols, amount/quantity checkout policies, per-entity checkout narrowing and pricing, catalogue records, entitlement gates, and the EU consumer-rights contracts (billing regions, the policy record, withdrawal/cancellation views, refund calculators, 428/409 refusals, the legal copy, protocol factories). Auto-invoked when importing payment types or errors, declaring paid routes, creating checkout, or touchi...

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added September 22, 2026
devopsgoexpressgitapifrontendbackendperformance

Works with

  • terminal
  • cli
  • api

Security analysis

A100/100

Scanned October 6, 2026

npx -y skills add owlmeans/common --skill payment --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Payment?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Payment
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/owlmeans-payment-common/badge)](https://www.skillsdirectory.com/skills/owlmeans-payment-common)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: payment
description: "How to use @owlmeans/payment — provider-agnostic payment contracts, immutable payment protocols, amount/quantity checkout policies, per-entity checkout narrowing and pricing, catalogue records, entitlement gates, and the EU consumer-rights contracts (billing regions, the policy record, withdrawal/cancellation views, refund calculators, 428/409 refusals, the legal copy, protocol factories). Auto-invoked when importing payment types or errors, declaring paid routes, creating checkout, or touching consent, withdrawal or cancellation."
user-invocable: false
---
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->

# @owlmeans/payment

**Layer:** Core
**Install:** `"@owlmeans/payment": "^0.1.18-rc.49"` in `dependencies`

The contracts half of payments: the catalogue (products, plans, localizations), amount- and
quantity-priced checkout, the entitlement model — plan capabilities, counted limits, promos,
and the entitlement view the server gate and the browser both read — and the EU consumer-rights
contracts (billing regions, the policy record, withdrawal and cancellation views, the refund
calculators, the refusals, the legal copy, the protocol factories). It talks to no paygate: a
server-side integration (`@owlmeans/server-payment`) implements against these, and
`@owlmeans/client-payment` adapts the catalogue service for a browser. The model as a whole —
admission, the usage ledger, the two gate services — is the `entitlements` skill.

## Key exports

| Export | Description |
|---|---|
| `makePaymentService(alias?)` · `appendPaymentService(ctx, alias?)` | The catalogue reader, registered under `DEFAULT_ALIAS` / `PAYMENT_SERVICE` (`'payment'`). |
| `PaymentService` | `product(sku)` · `products()` · `plans(productSku, duration)` · `plan(planSku)` · `allPlans(productSku)` · `localize(lng, entity)` · `shallowAuthentication(token)` · `pricingPolicy()` · `consumerRightsPolicy()` (`null` when undeclared). |
| `Product` · `ProductPlan` · `Localization` (+ schemas) | Catalogue records. `ProductPlan` carries `rank`, `free`, `gateways`, `suspendedAt`, `capabilities: PlanCapability[]`, `limits: { [key]: LimitDeclaration }`, `withdrawal.components` (§ Consumer rights). |
| `PlanCapability` · `LimitDeclaration` · `PromoDeclaration` (+ schemas) | What a plan grants: a permission set, a counted allowance, a time box on either. |
| `SubscriptionStatus` · `ENTITLING_STATUSES` · `TERMINAL_STATUSES` · `INTERNAL_PAYGATE` | Lifecycle vocabulary. `Active`/`Trial`/`PastDue` entitle; `Canceled`/`Expired`/`Ended`/`Blocked` are terminal; `Suspended` is revoked until resumed. |
| `LimitKind` · `LimitWindow` · `PortalFlow` (+ schemas) | `window`/`lifetime`/`occupancy`; `day`/`month`; `manage`/`cancel`/`update`/`change`/`payment-method`. |
| `ENTITLEMENT_GATE` · `LIMIT_GATE` · `CAPABILITY_FEATURE_SCOPE` · `CAPABILITY_LIMIT_SCOPE` | The two gate aliases and the two reserved scopes (`feature` for flags, `limit` for limit parameters). |
| `entitled` · `entitlementParamHelper` (`parseEntitlementParam` · `formatEntitlementParam` · `hasEntitlement` · `entitlementList`) | The capability grammar and predicate. |
| `planLimitHelper` (`parseLimitParam` · `formatLimitParam` · `windowKeyOf` · `windowBoundsOf`) · `LIFETIME_WINDOW` · `OCCUPANCY_WINDOW` | The limit grammar and window algebra. |
| `promoHelper` (`promoActive` · `promoViewOf`) | Whether a promo is in force for one subscription at one instant. |
| `EntitlementView` · `EntitlementPlanView` · `CapabilityView` · `LimitView` · `PromoView` · `LimitUsage` (+ wire schemas) | The entitlement view and its rows. |
| `entitlementViewHelper` (`capabilityViewsOf` · `limitViewsOf` · `entitlementViewOf` · `reviveEntitlementView`) · `planLimitHelper` (`capabilityOf` · `limitOf` · `hasLimitRoom`) | Pure view builders and readers. |
| `CreateCheckoutBody` (`planSku`, `country`, `startRequestId`) · `CreateCheckoutResponse` · `PortalLinkBody` · `PortalLinkResponse` (+ schemas) | Wire shapes an application's own checkout and portal protocols use. |
| `CheckoutPricingMode` · `AmountCheckoutPolicy` / `QuantityCheckoutPolicy` (+ schemas) · `checkoutPricingHelper` (`assertAmountCheckoutPolicy` · `assertQuantityCheckoutPolicy` · `assertCheckoutAmount` · `chargeAmountMinor`) | Checkout pricing policies and their validation. |
| `PricingPolicy` (+ schema) · `DEFAULT_PRICING_POLICY` · `priceEstimateHelper.assertPricingPolicy` · `TaxBehavior` · `TaxEstimateStatus` · `TaxType` (+ schemas) | Declared tax/currency behaviour (see § Pricing policy and tax estimate). |
| `PriceEstimate` · `PriceEstimateBody` · `TaxEstimate` · `TaxRateEstimate` (+ schemas) · `priceEstimateHelper` (`estimateOf` · `ratePpmOf`) | The tax/local-currency estimate read model and its pure math. |
| `COUNTRY_CURRENCIES` · `currencyOfCountry` · `COUNTRY_CODES` · `CountrySchema` | ISO 3166-1 → ISO 4217 reference map for a country picker. |
| `EntitlementRefusal` · `CapabilityRequired` · `LimitExhausted` | Entitlement refusals — all `AuthForbidden` (403). |
| `PaymentError` · `PaygateError` · `UnknownPaygate` · `PaygateMappingError` · `WebhookSetupError` · `PortalUnavailable` · `ProductError` · `UnknownProduct` · `UnknownPlan` · `PlanRequired` · `PlanRankConflict` · `LimitUnknown` · `LimitMisdeclared` · `PaymentIdentificationError` · `SubscriptionError` · `UnknownSubscription` · `ConsumerRightsError` | Faults (500), except `PortalUnavailable` (409). Importing the package registers every type's message under `errors.<type>` in eight languages (the seven of `SUPPORTED_LNGS` plus `fr`). |
| `ConsumerRightsRefusal` · `PerformanceConsentRequired` · `SubscriptionStartRequired` (428) · `BillingCountryLocked` · `WithdrawalUnavailable` · `CancellationUnavailable` · `CheckoutLimitExceeded` (409) · `consentRefusalOf` | Consumer-rights and checkout-limit refusals — declared statuses, never `AuthForbidden` (§ Consumer rights). |
| `ConsumerRegion` · `PurchaseKind` · `ConsentKind` · `DeclarationKind` · `DeclarationChannel` · `CancellationKind` · `WithdrawalStatus` · `CancellationStatus` · `WithdrawalUnavailableReason` · `CancellationUnavailableReason` (+ schemas) | Consumer-rights vocabulary. |
| `EU_COUNTRIES` · `EU_CONSUMER_TERRITORIES` · `EEA_EXTRA` · `CONSUMER_RIGHTS_TERRITORIES` · `COUNTRY_LANGUAGES` · `consumerRegionHelper` (`isEuCountry` · `isEeaCountry` · `regionOf` · `inScope` · `chargeCurrencyOf` · `billingLanguageOf`) | Territories, region, scope, charge currency, legal language. |
| `ConsumerRightsPolicy` (+ `Links`, `Mechanisms`, schemas) · `DEFAULT_CONSUMER_RIGHTS` · `makeConsumerRightsPolicy` · `consumerRightsPolicyHelper` (`assertConsumerRightsPolicy` · `linksOf`) · `CONSUMER_RIGHTS_RECORD_TYPE`/`_ID` | The consumer-rights policy record. |
| `BillingProfileView` · `PurchaseView`/`List` · `PerformanceConsentView`/`Body`/`Response` · `SubscriptionStartView`/`Query`/`Body`/`Response` · `WithdrawalEstimate` · `WithdrawalCandidate`/`List` · `WithdrawalBody` · `DeclarationReceipt` · `WithdrawalReceipt` · `CancellationBody` · `CancellationReceipt` · `ConsumerRightsPublicView` (+ schemas) · `consumerReviveHelper.revive*` | Consumer-rights wire shapes (ISO dates) and their revivers. |
| `withdrawalDeadlineHelper` (`withdrawalDeadlineOf` · `lastWithdrawalDayOf` · `withdrawalOpen`) · `withdrawalRefundHelper` (`oneTimeWithdrawalRefund` · `subscriptionWithdrawalRefund` · `splitByShares`) · `fifoHelper` (`allocateFifo` · `unitsUsedAfter`) · `cancellationEffectiveAt` | Pure calculators. |
| `CONSUMER_RIGHTS_RESOURCE` · `CONSUMER_RIGHTS_COPY_VERSION` · `consumerCopyHelper` (`consumerRightsCopy` · `consumerText` · `consentStatementOf` · `startContextOf` · `legalLabelsOf` · `placeholdersOf`) | The legal copy and the start statement's variant. |
| `makeConsumerRightsProtocols` · `makeCheckoutReadProtocols` | Protocol factories. |
| `AmountNarrowing` · `amountNarrowingHelper` (`narrowAmountPolicy` · `amountAllowed`) · `CheckoutLimitView` · `AmountPolicyView`/`Query` · `PlanPriceView`/`List`/`PlanPricesQuery` (+ schemas) | Per-entity narrowing of an amount checkout, and synced plan prices. |

Subpath: `./utils` — the `Config` / `Context` aliases to type your own context against.

## The catalogue is config, not a table

`PaymentService` reads everything through the context's config resource, so a deployment ships its
catalogue the same way it ships any other configuration:

| Record | Id | Type |
|---|---|---|
| product | `product:<sku>` | `PRODUCT_RECORD_TYPE` |
| plan | `plan:<planSku>` | `PLAN_RECORD_TYPE` |
| localization | `l10nToId(l10n, true)` → `l10n:<entityType>:<sku>:<lng>` | `L10N_RECORD_TYPE` |

`product` and `plan` read the record with the config resource's `get`, which **throws
`UnknownRecordError`** (`@owlmeans/resource`) when the id is absent rather than returning null — so
that, not `UnknownProduct` / `UnknownPlan`, is what a checkout for a sku outside the catalogue
raises. `localize` is the opposite: it reads with `load`, tries the asked-for language, falls back
to the default one, and returns `null` when neither exists, because a missing translation is a
display problem and never a reason to fail a call.

## Declaring plans

- **Every plan of a product has a `rank`; a higher rank is an upgrade.** An absent rank reads as
  `0`. Plan changes are classified by rank, and the effective plan of an entity is its
  highest-ranked entitling subscription — two plans of one product at one rank make both
  questions unanswerable.
- **The free tier is a real plan**: `free: true`, `price: 0`, `gateways: []`, usually `rank: 0`.
  Every entity holds some plan, so a gate never has to special-case "no subscription".
- **`gateways`** names the paygates a plan is sold through; a free plan has none.
- **`status` is a `PlanStatus`** (active, hidden, archived …) — the catalogue state of the plan,
  never a subscription's lifecycle.
- Plan records use the record date convention (`DateSchema` from `@owlmeans/auth`, a `Date`
  object); wire shapes do not (below).

## Capabilities and limits are different things

A **capability** is granted: a permission in a `PlanCapability` set, asked for as

    [<scope>:]<permission>[>=<n>]

`feature:branding--whitelabel` · `renewable:credits>=100` · `production--standalone`

A **limit** is counted: a `LimitDeclaration` under a key in `plan.limits`, asked for as

    limit:<key>[>=<n>]

- **`limit` is a reserved scope.** A plan never declares a capability set under it,
  `entitlementParamHelper.hasEntitlement` answers `false` for any `limit:` parameter, and the view
  builders skip such a set. A limit requirement can therefore never be satisfied by a capability
  grant.
- **One grammar, two gate services.** Capability parameters go to `ENTITLEMENT_GATE`
  (`entitled(params)` is sugar for it), limit parameters to `LIMIT_GATE`
  (`{ gate: { alias: LIMIT_GATE, params: [planLimitHelper.formatLimitParam(key)] } }`). The aliases
  differ because the framework collects an entrypoint's gates per gate service — one alias would let
  one requirement hide the other.
- **`@` is deliberately not part of the grammar.** That is `@owlmeans/iam`'s resource-selector
  syntax; reusing it would make two different things look identical in a route declaration.
- **Keep flags, magnitudes and counts apart.** `CAPABILITY_FEATURE_SCOPE` carries booleans; a
  granted magnitude nothing spends (`renewable:credits>=100`) lives under its own scope; anything
  spent against a ceiling is a limit. Merged, the purchase that spends a quota takes the feature
  with it.
- **A floor is a numeric question.** `>=n` passes only for a number at least that big — a boolean
  flag, however true, does not answer it. For a limit, `>=n` asks for `n` units of room.
- **A malformed parameter answers `false` / `null`, it never throws.**
  `entitlementParamHelper.hasEntitlement` refuses, `planLimitHelper.parseLimitParam` returns `null`
  for a capability parameter, a bare key, an empty key or a floor that is not positive. A gate that
  crashed on a typo would take down the endpoint it guards.
- **Declare the requirement on the protocol, not in the handler.** The framework asserts gates
  before the handler is entered, so the route table states what a feature costs. Several
  parameters of one gate are OR'd. The gate services themselves live in the server integration.

## The three limit kinds

| Kind | Window key (`planLimitHelper.windowKeyOf`) | Renews | Typical use |
|---|---|---|---|
| `window` + `day` | `YYYY-MM-DD` | at UTC midnight | per-day actions |
| `window` + `month` | `YYYY-MM` | on the 1st, UTC | per-month actions |
| `lifetime` | `lifetime` | never — the count belongs to the entity and survives plan changes | "N ever" |
| `occupancy` | `occupancy` | never — held `+1` / released `-1`, reconciled against reality | "N at once" |

- Windows are **calendar UTC**, never rolling and never local: two servers in two zones must agree
  on which counter a unit lands in.
- `planLimitHelper.windowBoundsOf(window, at)` returns `start` inclusive and `resetsAt`
  **exclusive** — `resetsAt` is the first instant of the next window and already belongs to it.
- A `window` limit without a known `window` (or an unknown kind) throws `LimitMisdeclared`: a
  misdeclared plan is loud, not a silently unlimited one.
- `limit: 0` means "not included" — a declared key with no room, which a UI renders as such.

## Promos

`promo: { until: Date, grandfather?: boolean }` on a capability set or a limit.

- In force while `at < until`. **`at === until` is over.**
- With `grandfather: true`, also in force for a subscription created before `until`, forever.
- A lapsed promo makes a capability `granted: false` and a limit's `limit: 0` — the row stays in
  the view with `promo.active: false`, so a UI can say the promotion ended rather than pretend the
  feature never existed.
- `promoHelper.promoViewOf` → `{ until, grandfathered, active }` is what a UI inscribes: active and
  not grandfathered ⇒ "free until"; grandfathered ⇒ "kept for your plan"; inactive ⇒ "ended".
- An unparseable `until` is never in force.

## The entitlement view

`entitlementViewHelper.entitlementViewOf(plan, planView, usage, at?)` is the one read of what an
entity may do:

- `plan` — the `EntitlementPlanView` the caller resolved (sku, rank, free, status, paygate,
  period, trial, cancel-at-period-end, past-due, fallback). **Promos are measured against
  `planView.subscribedAt`** — set it to the subscription's creation date, or nothing is
  grandfathered.
- `capabilities` — its `capabilityViewsOf`: one row per non-null, non-false permission, `param`
  exactly as the capability gate takes it.
- `limits` — its `limitViewsOf`: one row per declared key; `used` from the `LimitUsage` row of the
  CURRENT window (none ⇒ `0`); `remaining = max(0, limit - used)`; `windowStart`/`resetsAt` only
  for a window limit.
- Read it with `planLimitHelper.capabilityOf(view, param)` (the same predicate as the gate, over
  granted rows), `planLimitHelper.limitOf(view, key)` and
  `planLimitHelper.hasLimitRoom(limitView, atLeast)`. The server gate and the browser call the same
  functions, so the UI's "disabled" and the server's refusal cannot disagree.
- **The wire carries dates as ISO strings.** The view schemas describe that
  (`{ type: 'string', format: 'date-time' }`): a response serializer writes a `Date` through it as
  ISO, while the record convention (`DateSchema`, an object) would serialize it as `{}`. A browser
  calls `entitlementViewHelper.reviveEntitlementView` before touching a date.

## Refusals and faults

- **A refusal extends `AuthForbidden`** (through `EntitlementRefusal`), so an HTTP boundary answers
  403.
  - `CapabilityRequired(params)` — message marker `capability-required:<a|b>`, field `params`.
  - `LimitExhausted({ key, used, limit, resetsAt? })` — marker
    `limit-exhausted:<key>:<used>/<limit>[:<ISO>]`, fields `limitKey`, `used`, `limit`,
    `resetsAt`.
- **Only `type` and `message` survive a marshal.** Both refusals pack their fields into the
  message and rebuild them in `finalizeUnmarshal()`; catch the class after
  `ResilientError.ensure`, never parse the text yourself.
- **Consumer-rights and checkout-limit refusals are not entitlement refusals**: they declare
  428/409 and never extend `AuthForbidden` (§ Consumer rights).
- **Faults are not refusals**: `LimitUnknown` (a key no plan declares), `LimitMisdeclared`,
  `PlanRequired` (no plan resolvable, not even a free one), `PlanRankConflict`,
  `WebhookSetupError` are `PaymentError`s — a configuration or setup problem, not the user's plan
  saying no — and declare no status, so an HTTP boundary answers 500.
- **`PortalUnavailable` is the entity's state, not a fault**: no paygate customer, no entitling
  subscription, no plan or item for the flow. It is a `PaymentError` declaring
  `static httpStatus = 409`, which `@owlmeans/server-api` answers as 409 Conflict. A new payment
  error that is the caller's condition declares its 4xx the same way; one that is a fault declares
  nothing.
- A UI phrases any of them from `errors.<type>`; the package registers those messages.

## Checkout

`CreateCheckoutBody` is the wire body of a checkout: `productSku`, `planSku` for a subscription,
`entitySlug` (the only organization value on a public body — a server resolves the stable
`entityId` before persisting anything), `service`, `amountMinor` for an amount checkout, return
URLs, `country` (the declared billing country; a locked profile overrides it) and
`startRequestId` (the subscription start request recorded just before). The package declares no
fixed checkout protocols: an application declares its own checkout and portal protocols over
`CreateCheckoutBodySchema` / `PortalLinkBodySchema`; `PortalLinkBody.flow` picks the portal flow
and `planSku` names the target of a `change`. The read side and the consumer-rights surface come
as factories (§ Protocol factories).

### Amount checkout

`amountMinor` is the net value credited to the customer, in integer currency minor units. The
policy bounds that value; fees and tax do not enlarge the credit. Gross up the pre-tax charge with:

```
ceil((amountMinor + fixedMinor) * 10_000 / (10_000 - rateBps))
```

Validate the whole policy when configuration is declared, then validate every selected amount at
the backend boundary. Minimum, default, maximum, preset entries, fixed adjustment and basis-point
rate are safe integers; presets are unique/in-range and the rate is below 100%. The maximum applies
to `amountMinor`, not the adjusted checkout subtotal or tax-inclusive total.

Quantity checkout remains supported. It uses its reusable unit price and quantity policy; do not
infer a pricing mode from the presence of `amountMinor`.

### Narrowing an amount checkout per entity

The plan's `amountPolicy` is the same for everyone; what one entity may buy NOW is narrower when a
checkout plugin (a spending tier, a rolling cap, a fraud hold) answers an `AmountNarrowing`
`{ maximumMinor, reason, resetsAt?, remainingMinor? }`.
`amountNarrowingHelper.narrowAmountPolicy(base, narrowings, { productSku?, planSku? })` is the one
computation the server's refusal and the dialog's control share, so they cannot disagree:

- the maximum is the smallest of the base maximum and every narrowing's (negative or fractional
  values floor at 0); `reason`/`resetsAt`/`remainingMinor` come from the narrowing that set it,
  the first on a tie;
- presets above the maximum are dropped, the default is clamped into `[minimum, maximum]`;
- `limit` is `null` when nothing lowered the base maximum — no note to show;
- **`limit.blocked`** (`limit.maximumMinor < base.minimumMinor`) means no amount may be bought now.
  The returned `policy` is then still a VALID policy
  (`checkoutPricingHelper.assertAmountCheckoutPolicy` accepts it), pinned to the minimum with no
  presets, so a validator never throws — but it is NOT an offer of the minimum: a UI disables the
  input and the confirm button on `blocked`, and a server refuses every amount with
  `CheckoutLimitExceeded`. `amountNarrowingHelper.amountAllowed(view, amount)` answers exactly that.

`CheckoutLimitExceeded` (409) packs `checkout-limit-exceeded:<encodeURIComponent(reason)>:
<maximumMinor>:<currency>[:<resetsAt ISO>]` and rebuilds `reason`, `maximumMinor`, `currency`,
`resetsAt`. It is a payment refusal, not an entitlement one.

## Pricing policy and tax estimate

`PricingPolicy` (a `PRICING_POLICY_RECORD_ID` singleton config record, `declarePaymentPricing` in
`@owlmeans/server-payment`) is what a checkout-owning application declares once for tax and
currency behaviour: `tax.automatic` (Stripe Tax on the session), `tax.behavior` (a synced price's
`TaxBehavior.Exclusive`/`Inclusive`, absent = leave it `unspecified`), `tax.collectTaxId`,
`tax.estimate` / `currency.estimate` (serve a tax/local-currency estimate endpoint — the estimate
requires `tax.automatic`, the currency one requires `currency.adaptive`), `currency.adaptive`
(Stripe Adaptive Pricing on the session). `PaymentService.pricingPolicy()` reads the declared record
or `DEFAULT_PRICING_POLICY` — the fixed behaviour every checkout had before this policy existed
(automatic tax and tax-id collection on, no forced behavior, no Adaptive Pricing, no estimate), so
an application that declares nothing sees no change.

`PriceEstimate` (built by `@owlmeans/server-payment`'s gateway, `estimatePrice`) is the read model:
`country`/`source` (`'request'`/`'customer'`, or `'profile'` with `locked: true` when the entity's
billing country is locked and overrides the request — a picker then shows it and does not change
it), `region`, `currency`, `behavior`, `tax: TaxEstimate` (`status`
one of `TaxEstimateStatus` — `taxed`/`reverse-charge`/`none`/`at-checkout`/`location-required` —
plus `subtotalMinor`/`taxMinor`/`totalMinor`, `scalable`, and `rates: TaxRateEstimate[]` with
`ratePpm` parsed by `priceEstimateHelper.ratePpmOf` from Stripe's `percentage_decimal`, never
`Number(x) * 10_000`), and an optional `local` (currency + `exchangeRate`, from Stripe's FX Quotes).
The pure helper `priceEstimateHelper.estimateOf(amountMinor, estimate)` re-derives tax and a total
for a DIFFERENT amount than the estimate's own reference one — exact only when `scalable` and the
behavior is `Exclusive`; both `taxMinor`/`totalMinor` come back `null` otherwise, meaning "computed
at checkout", never a wrong number. When the source estimate carries `local`, the result's own
`local` gives the subtotal, tax and total in THAT currency
(`subtotalAmount`/`taxAmount`/`totalAmount`, major units) alongside the integration-currency ones —
a UI shows one or the other, marking the local figures `≈`, never both side by side.
`COUNTRY_CURRENCIES` / `currencyOfCountry` / `COUNTRY_CODES` is a reference ISO 3166-1 → ISO 4217
map for a country picker and the local-currency lookup — not a Stripe list, and a country absent
from it still gets a tax estimate, just no local-currency line.

## Consumer rights (EU withdrawal, cancellation, country lock)

The contracts behind the right of withdrawal (CRD Art. 9–16, the Art. 11a withdrawal function),
the cancellation function (§ 312k BGB / L215-1-1) and a billing country fixed at the first
purchase. `@owlmeans/server-payment` records, enforces and mails; `@owlmeans/web-payment` renders.
Nothing here is product copy — the trader, plan and product names are placeholders.

### Territories, region, scope

- `EU_COUNTRIES` (27, Greece is `GR`), `EU_CONSUMER_TERRITORIES` (plus AX, GF, GP, MQ, RE, YT, MF —
  consumer law applies there even where EU VAT does not), `EEA_EXTRA` (IS, LI, NO) and their union
  `CONSUMER_RIGHTS_TERRITORIES`, the default `policy.countries`.
- `consumerRegionHelper.regionOf(country, policy?)`: `Eu` inside the policy's territories, `Other`
  outside, `null` for no country. `consumerRegionHelper.inScope(region, country, policy)`: a known
  country decides by the territories, else a known region, else `unknownCountry` — `'protect'` by
  default, so an unknown buyer is protected.
- `consumerRegionHelper.chargeCurrencyOf(region, policy, fallback)`: the policy's currency for the
  region; an unknown region reads as `Eu`. The display currency is the charge currency, everywhere.
- `consumerRegionHelper.billingLanguageOf(country, policy?, fallback?)`: `policy.languages`, then
  `COUNTRY_LANGUAGES` (unambiguous countries only — BE, LU, CH are absent), then `fallback`, then
  `policy.defaultLanguage`. Legal copy is shown in this language, with a toggle to the UI language.

### The policy record

`ConsumerRightsPolicy` is a singleton config record (`CONSUMER_RIGHTS_RECORD_ID`), declared by the
server integration through `makeConsumerRightsPolicy(def)` — `DEFAULT_CONSUMER_RIGHTS` filled in
(14 days, weekend rollover, margin 5 — a period ending on a public holiday runs to the next working
day and holiday clusters need up to five, no per-country calendar is kept —, every mechanism OFF,
`defaultLanguage: 'en'`, start requests
usable 3600 s) — and `consumerRightsPolicyHelper.assertConsumerRightsPolicy` (non-empty
`textVersion`, `^[A-Z]{2}$` countries, `withdrawalDays >= 14`, margin 0..7, lowercase currencies,
https links, the default language present, `withdrawalInformation` required while `withdrawal` or
`performanceConsent` is on, `consentContext` a lowercase key `^[a-z][a-z0-9-]*$`; throws
`ConsumerRightsError('policy:<field>')`). It carries only public links, territories, switches and
the consent context — mail options live in a backend-only plugin config — so it is ADVERTISED to the
browser like the pricing policy. **`consentContext`** names the copy variant of the performance
consent (`'included'`: the credit limits included in a plan are used first, so the consent covers
topped-up credits once those limits have run out): the server records and mails, and the dialog
shows, the `_<context>` texts (§ The legal copy); absent, the base texts.
`PaymentService.consumerRightsPolicy()` answers `null` when none is declared: no consumer-rights
behaviour at all. `consumerRightsPolicyHelper.linksOf(policy, lng)` merges a language's links field
by field over the default language's (`de-AT` reads `de`).

`ProductPlan.withdrawal.components: PlanWithdrawalComponent[]` (`{ key, basis: 'time' | 'units',
shareMinor }`) states the separately priced parts of a subscription (CJEU C-641/19 PE Digital:
without them the whole price is pro rata by time). The same declaration picks the subscription start
statement: a `units` part makes it the `_units` variant (`consumerCopyHelper.startContextOf`, § The
legal copy).

### Wire views

Every view schema carries dates as ISO strings (as `model/view.ts` does) and is revived with its
`consumerReviveHelper.revive*` member (`reviveConsentView`, `revivePurchase`/`List`,
`reviveBillingProfile`, `reviveConsentResponse`, `reviveStartResponse`, `reviveWithdrawalList`,
`reviveReceipt`, `reviveCheckoutLimit`, `reviveAmountPolicyView`) — idempotent. A nullable enum on
the wire lists `null` in its enum (ajv rejects `null` otherwise). The consent and start views carry
`trader`, the name the statement is rendered with, and `context`, the variant it is rendered with —
the consent view the policy's `consentContext`, the start view
`consumerCopyHelper.startContextOf(plan)` (`'units'`), each absent for the base — so the server's
record and the dialog's text are identical. `WithdrawalBody` and `CancellationBody` ask only for
name, contract and e-mail (plus the cancellation kind, reason and date) and accept an optional
`honeypot` a person never fills. A public declaration answers `DeclarationReceipt` — what was
declared and when, never whether a contract matched.

### Calculators — always in the consumer's favour

All amounts in BigInt; a deduction rounds DOWN, a refund rounds UP, a refund never exceeds what is
still unrefunded.

- `withdrawalDeadlineHelper.withdrawalDeadlineOf(purchasedAt, rule | policy)` → the EXCLUSIVE end:
  the purchase's UTC day + `days`, a Saturday/Sunday last day moved to Monday, + `marginDays`, start
  of the next UTC day. Wed 2026-09-23 → 2026-10-13T00:00Z; Sat 2026-09-26 → 2026-10-18T00:00Z; Sun
  2026-12-20 → 2027-01-10T00:00Z (margin 5). `withdrawalDeadlineHelper.withdrawalOpen(window, at)` —
  before the deadline, not withdrawn, not refunded. **A person is shown the last included day**,
  `withdrawalDeadlineHelper.lastWithdrawalDayOf(deadline)` (the UTC day of `deadline − 1 ms`),
  phrased "until the end of <date>" — never the exclusive instant.
- `withdrawalRefundHelper.oneTimeWithdrawalRefund({ paidMinor, refundedMinor?, unitsGranted, unitsUsed })`
  → `min(paid − refunded, ceil(paid × (granted − used) / granted))`; `unitsUsed` is the deduction —
  units used AFTER consent (none before it) plus debt settled from the lot plus units already
  clawed back (`@owlmeans/server-payment` passes the meter's `usedAfter + settled + clawed`);
  returns `unitsReturned`. 1256 paid, 125k of 500k used → 942.
- `withdrawalRefundHelper.subscriptionWithdrawalRefund(...)` splits `netMinor` by the components
  (its `splitByShares`, largest remainder); a `time` part loses
  `floor(c × elapsedDays / periodDays)` counted from `max(periodStart, servicesRequestedAt)` —
  NOTHING without a start request; a `units` part loses `floor(c × used / granted)`; gross =
  `ceil(paid × refundNet / net)`. Net 2000 / paid 2460, 3 of 30 days and 100k of 500k used → 2091.
- `fifoHelper.allocateFifo(lots, spends)` — a spend takes the oldest lot granted at or before it;
  overflow is `unallocated`. `fifoHelper.unitsUsedAfter(lot, consentedAt)` counts slices strictly
  after the consent, `0` without one.
- `cancellationEffectiveAt(periodEnd, 'month' | 'year', requested?)` — the period end, or the first
  boundary on or after a later requested date (end-of-month clamped from the anchor).

### Refusals

`ConsumerRightsRefusal` extends `ConsumerRightsError` (a `PaymentError`), NEVER `AuthForbidden`:
an HTTP boundary tests that family first, and a 403 would hide the status a client acts on. Each
refusal declares `static httpStatus`, packs its fields into the message and rebuilds them in
`finalizeUnmarshal()` (the marker is read after its LAST occurrence, so a registry rebuild of a
doubled prefix still parses):

| Class | Status | Marker (after `payment:consumer-rights:`) | Fields |
|---|---|---|---|
| `PerformanceConsentRequired` | 428 | `performance-consent-required:<pending>[:<deadline ISO>]` | `pending`, `deadline` |
| `SubscriptionStartRequired` | 428 | `subscription-start-required:<encodeURIComponent(planSku)>` | `planSku` (pass it raw) |
| `BillingCountryLocked` | 409 | `billing-country-locked:<country>[:<requested>]` | `country`, `requested` |
| `WithdrawalUnavailable` | 409 | `withdrawal-unavailable:<WithdrawalUnavailableReason>` | `reason` |
| `CancellationUnavailable` | 409 | `cancellation-unavailable:<CancellationUnavailableReason>` | `reason` |

`consentRefusalOf(e)` → `'performance' | 'subscription-start' | null`: the class after
`ResilientError.ensure`, else the marker or type name in `message`/`type`. A production body
carries only an incident id — a bare 428 is the caller's to read with `@owlmeans/api/status`.

### The legal copy

The i18n resource `payment-consumer-rights` (`CONSUMER_RIGHTS_RESOURCE`, library tier, `lib`
namespace), in en pl ru be uk es de fr, versioned by `CONSUMER_RIGHTS_COPY_VERSION` (bump it on ANY
change to a bundle). Branches: `performance-consent`, `subscription-start` (`title`, `intro`,
`request`, `acknowledgement`, `checkbox` = request + space + acknowledgement, `confirm`,
`decline`; the consent branch also `purchase`; the start branch's `request`/`acknowledgement`/
`checkbox` also as `_units`), `withdrawal`, `cancellation` (form labels and the
statutory buttons `function` / `confirm`), `links`, `checkout` (`terms-acceptance.{in-scope,
other}` markdown, `renewal.{month, year, after-submit}`, `price.{exclusive, inclusive, exclusive-tax}` (VAT wording for the territories, "applicable tax" for a buyer outside them), `top-up`,
`top-up-note`), `email.{common, consent, start (`rule` + `rule_units`), purchase, withdrawal, cancellation}` (the purchase
mail carries the CRD Annex I(A) withdrawal information with the function's address and the Annex
I(B) model form, per language from the national models; there `{{trader}}` is the trader's whole
identity — legal name, address, e-mail; a `review` withdrawal receipt promises the reimbursement
within the statutory 14 days; `email.cancellation.review` answers an extraordinary cancellation,
whose status is `CancellationStatus.Review`), `credit-note.memo`. In the consent and start
STATEMENTS `{{trader}}` is the trader's short name.

- Read it with `consumerCopyHelper.consumerRightsCopy(lng)` (any language, merged key by key over
  English, no i18next instance, never drains a bundle — `i18nHelper.resolveI18nResource`),
  `consumerCopyHelper.consumerText(lng, path, vars, context?)` (fills `{{name}}`; throws
  `ConsumerRightsError('copy:<path>[:<name>]')` on a missing text or value — a legal text never goes
  out with a hole), `consumerCopyHelper.consentStatementOf(lng, kind, { trader, plan?, context? })`
  (the exact statement the dialog shows, the server records and the mail repeats) and
  `consumerCopyHelper.legalLabelsOf(lng)`.
- **Context variants** (the i18next context suffix): with a `context`,
  `consumerCopyHelper.consumerText` reads `<path>_<context>` first and the base `path` only where no
  such variant exists (a missing value then names the variant: `copy:<path>_<context>:<name>`); an
  empty context is none. `consumerCopyHelper.consentStatementOf` passes its `context` to all three
  texts. Shipped variants, all eight languages, same placeholders as the base keys, each request
  keeping the base request's statutory opening verbatim; a new variant lands in every language at
  once, with its base key's placeholders:
  - `performance-consent.{title,intro,request,checkbox}_included` — the consuming platform's
    vocabulary: "topped-up credits" (pl doładowane, ru пополненные, be папоўненыя, uk поповнені, es
    recargados, de aufgeladene, fr rechargés) are spent once "the credit limits included in my plan"
    (a plan's non-monetary credits) have run out; `checkbox_included` = `request_included` + space +
    the BASE `acknowledgement` (generic "credits", so `acknowledgement` has no variant).
  - **The subscription start statement follows the plan's withdrawal arithmetic.** The BASE
    `subscription-start.{request,acknowledgement,checkbox}` and `email.start.rule` describe the
    common case — the plan withdrawn pro rata by the DAYS ELAPSED only ("starts the {{plan}}
    services — including the AI work within the usage the plan includes — now …; … I pay for the
    services provided until then, pro rata by the days elapsed, and everything else is
    reimbursed"); `…_units` (`checkbox_units` = `request_units` + space + `acknowledgement_units`)
    describe the split — services pro rata by time, the right of withdrawal expiring for the
    included units used. `consumerCopyHelper.startContextOf(plan)` is the ONE decision: `'units'`
    exactly when the plan's `withdrawal.components` has a `basis: 'units'` part, else `undefined`.
    Every renderer takes the context from it — the server's start view, the recorded statement and
    the start mail, and the dialog (from the view's `context`) — so the statement on screen is the
    one recorded. `title`, `intro`, `confirm`, `decline` have no `_units` variant. Generic statutory
    text (the purchase mail's withdrawal information) stays generic.
- Statutory labels are pinned by a test: en "Withdraw from contract here" / "Confirm withdrawal",
  "Cancel contracts here" / "Cancel now"; de "Vertrag widerrufen" / "Widerruf bestätigen",
  "Verträge hier kündigen" / "Jetzt kündigen"; fr "Renoncer au contrat ici" / "Confirmer la
  rétractation", "Résilier votre contrat" / "Notification de la résiliation"; pl "Odstąp od umowy
  tutaj" / "Potwierdź odstąpienie od umowy", "Wypowiedz umowę tutaj" / "Wypowiedz teraz"; es
  "Desistir del contrato aquí" / "Confirmar el desistimiento", "Cancelar contratos aquí" /
  "Cancelar ahora"; uk/ru/be are courtesy translations.
- The express request uses the statutory verbs (PL "Żądam i wyrażam wyraźną zgodę … Przyjmuję do
  wiadomości …", DE "Ich verlange ausdrücklich und stimme ausdrücklich zu … Mir ist bekannt …", FR
  "Je demande expressément et j’accepte expressément … Je reconnais perdre …"). Every start
  request — base and `_units`, every language — opens with the same three words as that language's
  performance request (pinned by the copy spec).
- Wording rule, enforced by a scan of every language: say "the right of withdrawal expires" and
  "only unused credits are reimbursed" — never non-refundable / nicht erstattungsfähig / non
  remboursable / bezzwrotny / no reembolsable / невозвратный / неповоротний / незваротны.
- Paygate texts (`checkout.*`) stay within 1200 characters after interpolating long URLs.
- An application overrides a text with `i18nHelper.addI18nApp(lng, CONSUMER_RIGHTS_RESOURCE, data,
  { ns: LIB_NAMESPACE })`; every placeholder of a text must match its English master.

### Protocol factories

`makeConsumerRightsProtocols({ prefix, parent | guards (+ gate), path = '/consumer-rights',
public?: false | { path = '/public/consumer-rights', parent?, screens?: { withdrawal?,
cancellation?, parent? } } })`, the `makeMarketingConsentProtocols` way:

- account subtree under the app's GUARDED parent (its guards and gate inherited): `base`,
  `profile` GET `/profile`, `purchases` GET `/purchases`, `consent` GET / `giveConsent` POST
  `/consent`, `start` GET `/start?planSku` / `requestStart` POST `/start`, `withdrawals` GET /
  `withdraw` POST `/withdrawal`, `cancel` POST `/cancellation`; aliases `<prefix>:<name>`
  (`consent:give`, `start:request`);
- `public` (only when declared): `base`, `policy` GET `/policy`, `withdraw` POST `/withdrawal`,
  `cancel` POST `/cancellation` — NO guard and NO gate (like `paymentGate.webhook`; a public
  `parent` must be unguarded), receipts typed `DeclarationReceipt`; `screens` declares BOTH sticky
  frontend routes (`/legal/withdraw`, `/legal/cancel` by default); aliases `<prefix>:public:<name>`;
- neither `parent` nor `guards` is a `SyntaxError`; absent parts are left OUT of the tree (never
  `undefined`), so `protocols(tree)` and `mapProtocols` walk it as is.

`makeCheckoutReadProtocols({ prefix, parent | guards })` → `base` (`/checkout`), `amountPolicy`
GET `/amount-policy?productSku&planSku` (`AmountPolicyView`) and `planPrices` GET
`/plan-prices?productSku` (`PlanPriceList`).

## `shallowAuthentication` identifies, it does not authorize

It reads the `profileId` out of an envelope token WITHOUT verifying the signature, and throws
`PaymentIdentificationError` when there is no token or no profile in it. Use it to correlate a
checkout return with a visitor; never as the basis of an access decision — that is a gate's job.

## Depends On

- `@owlmeans/entrypoint` · `@owlmeans/route` · `@owlmeans/config` · `@owlmeans/resource`
- `@owlmeans/auth` (`PermissionSet`, `AuthForbidden`) · `@owlmeans/basic-envelope` · `@owlmeans/context`
- `@owlmeans/error` · `@owlmeans/i18n` · `@owlmeans/api-config` · peer `ajv`

## Related

- [[entitlements]] — the model across packages: admission, the usage ledger, the gates
- [[server-payment]] — the Stripe gateway, subscription store, usage ledger and gate services
- [[web-payment]] — hooks and pieces over the entitlement view
- [[client-payment]] — the browser-side adaptation of the catalogue service
- [[iam]] — `PermissionSet` and the resource-selector syntax entitlements deliberately avoid

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…