@owlmeans/payment 0.1.18-rc.27 → 0.1.18-rc.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +98 -52
  2. package/agent-meta/manifest.json +9 -2
  3. package/agent-meta/skills/entitlements/SKILL.md +150 -0
  4. package/agent-meta/skills/payment/SKILL.md +148 -83
  5. package/build/consts.d.ts +34 -2
  6. package/build/consts.d.ts.map +1 -1
  7. package/build/consts.js +51 -2
  8. package/build/consts.js.map +1 -1
  9. package/build/entitlement.d.ts +16 -0
  10. package/build/entitlement.d.ts.map +1 -1
  11. package/build/entitlement.js +21 -1
  12. package/build/entitlement.js.map +1 -1
  13. package/build/errors.d.ts +75 -0
  14. package/build/errors.d.ts.map +1 -1
  15. package/build/errors.js +133 -0
  16. package/build/errors.js.map +1 -1
  17. package/build/i18n/errors/be.json +21 -0
  18. package/build/i18n/errors/de.json +21 -0
  19. package/build/i18n/errors/en.json +21 -0
  20. package/build/i18n/errors/es.json +21 -0
  21. package/build/i18n/errors/pl.json +21 -0
  22. package/build/i18n/errors/ru.json +21 -0
  23. package/build/i18n/errors/uk.json +21 -0
  24. package/build/i18n.js +18 -0
  25. package/build/i18n.js.map +1 -1
  26. package/build/index.d.ts +3 -1
  27. package/build/index.d.ts.map +1 -1
  28. package/build/index.js +3 -1
  29. package/build/index.js.map +1 -1
  30. package/build/limit.d.ts +50 -0
  31. package/build/limit.d.ts.map +1 -0
  32. package/build/limit.js +101 -0
  33. package/build/limit.js.map +1 -0
  34. package/build/model/checkout.js +1 -1
  35. package/build/model/checkout.js.map +1 -1
  36. package/build/model/index.d.ts +3 -2
  37. package/build/model/index.d.ts.map +1 -1
  38. package/build/model/index.js +3 -2
  39. package/build/model/index.js.map +1 -1
  40. package/build/model/limit.d.ts +7 -0
  41. package/build/model/limit.d.ts.map +1 -0
  42. package/build/model/limit.js +32 -0
  43. package/build/model/limit.js.map +1 -0
  44. package/build/model/plan.d.ts.map +1 -1
  45. package/build/model/plan.js +10 -11
  46. package/build/model/plan.js.map +1 -1
  47. package/build/model/portal.d.ts +4 -0
  48. package/build/model/portal.d.ts.map +1 -0
  49. package/build/model/portal.js +22 -0
  50. package/build/model/portal.js.map +1 -0
  51. package/build/model/view.d.ts +8 -0
  52. package/build/model/view.d.ts.map +1 -0
  53. package/build/model/view.js +86 -0
  54. package/build/model/view.js.map +1 -0
  55. package/build/promo.d.ts +11 -0
  56. package/build/promo.d.ts.map +1 -0
  57. package/build/promo.js +26 -0
  58. package/build/promo.js.map +1 -0
  59. package/build/types.d.ts +115 -57
  60. package/build/types.d.ts.map +1 -1
  61. package/build/view.d.ts +30 -0
  62. package/build/view.d.ts.map +1 -0
  63. package/build/view.js +91 -0
  64. package/build/view.js.map +1 -0
  65. package/package.json +11 -11
  66. package/src/consts.ts +57 -2
  67. package/src/entitlement.ts +23 -1
  68. package/src/errors.ts +171 -0
  69. package/src/i18n/errors/be.json +21 -0
  70. package/src/i18n/errors/de.json +21 -0
  71. package/src/i18n/errors/en.json +21 -0
  72. package/src/i18n/errors/es.json +21 -0
  73. package/src/i18n/errors/pl.json +21 -0
  74. package/src/i18n/errors/ru.json +21 -0
  75. package/src/i18n/errors/uk.json +21 -0
  76. package/src/i18n.ts +20 -0
  77. package/src/index.ts +3 -1
  78. package/src/limit.ts +130 -0
  79. package/src/model/checkout.ts +1 -1
  80. package/src/model/index.ts +3 -2
  81. package/src/model/limit.ts +36 -0
  82. package/src/model/plan.ts +11 -12
  83. package/src/model/portal.ts +25 -0
  84. package/src/model/view.ts +96 -0
  85. package/src/promo.ts +41 -0
  86. package/src/types.ts +123 -54
  87. package/src/view.ts +120 -0
  88. package/tests/contract.spec.ts +115 -0
  89. package/tests/entitlement.spec.ts +17 -2
  90. package/tests/errors.spec.ts +96 -0
  91. package/tests/limit.spec.ts +106 -0
  92. package/tests/view.spec.ts +130 -0
  93. package/build/entrypoints.d.ts +0 -25
  94. package/build/entrypoints.d.ts.map +0 -1
  95. package/build/entrypoints.js +0 -43
  96. package/build/entrypoints.js.map +0 -1
  97. package/build/model/subscription.d.ts +0 -9
  98. package/build/model/subscription.d.ts.map +0 -1
  99. package/build/model/subscription.js +0 -54
  100. package/build/model/subscription.js.map +0 -1
  101. package/build/model/utils.d.ts +0 -5
  102. package/build/model/utils.d.ts.map +0 -1
  103. package/build/model/utils.js +0 -28
  104. package/build/model/utils.js.map +0 -1
  105. package/src/entrypoints.ts +0 -57
  106. package/src/model/subscription.ts +0 -60
  107. package/src/model/utils.ts +0 -32
  108. package/tests/entitlement.spec.d.ts +0 -2
  109. package/tests/entitlement.spec.d.ts.map +0 -1
  110. package/tests/entitlement.spec.js +0 -72
  111. package/tests/entitlement.spec.js.map +0 -1
  112. package/tests/protocol.spec.ts +0 -51
package/README.md CHANGED
@@ -1,79 +1,125 @@
1
1
  # @owlmeans/payment
2
2
 
3
- Payment contracts and service abstractions for product catalogs, subscriptions, and amount- or
4
- quantity-priced checkout.
5
-
6
- ## Overview
7
-
8
- - `makePaymentService(alias?)` — creates a payment service for context registration
9
- - `appendPaymentService(context, alias?)` — registers the payment service in the context
10
- - `PaymentService` — interface for products, plans, subscriptions, and checkout session creation
11
- - `paymentApi` — immutable protocol declarations for subscription propagation and checkout
12
- - `CheckoutPricingMode` plus amount/quantity checkout policy schemas and validators
13
- - `chargeAmountMinor()` — integer-minor-unit processing adjustment calculation
3
+ Payment contracts shared by a server and a browser: the product catalogue, amount- or
4
+ quantity-priced checkout, and the entitlement model — plans that grant capabilities and counted
5
+ limits, promos, and the entitlement view both a gate and a UI read. It talks to no paygate; a
6
+ server integration (`@owlmeans/server-payment`) implements against these contracts.
14
7
 
15
8
  ## Installation
16
9
 
17
10
  ```bash
18
- bun add @owlmeans/payment@^0.1.18-rc.24
11
+ bun add @owlmeans/payment@^0.1.18-rc.29
19
12
  ```
20
13
 
14
+ ## Concepts
15
+
16
+ - **Plan** — a catalogue record with a `rank` (higher is an upgrade) and optionally `free: true`
17
+ (price 0, no paygate): every entity holds some plan, even without paying.
18
+ - **Capability** — a granted permission, written `[scope:]permission[>=n]`, asserted under
19
+ `ENTITLEMENT_GATE`.
20
+ - **Limit** — a counted allowance, written `limit:<key>[>=n]`, asserted under `LIMIT_GATE`. Three
21
+ kinds: `window` (calendar UTC day/month), `lifetime` (never renews), `occupancy` (a held count).
22
+ - **Promo** — `{ until, grandfather? }` on a capability set or a limit: in force before `until`,
23
+ or for a subscription created before it when grandfathered.
24
+ - **Entitlement view** — the effective plan, every capability and every limit with its usage, at
25
+ one instant; built by pure helpers the server and the browser share.
26
+
21
27
  ## Usage
22
28
 
23
- Create a checkout session:
29
+ Declare a plan:
24
30
 
25
31
  ```typescript
26
- import { paymentApi } from '@owlmeans/payment'
27
-
28
- const result = await ctx.entrypoint(
29
- paymentApi.service.checkout.session.external.create,
30
- ).call({
31
- body: {
32
- productSku: 'vib-tokens',
33
- entitySlug,
34
- service: VIB_ALIAS,
35
- amountMinor: 1_000,
36
- successUrl: helper.makeUrl(service)
37
- }
38
- })
39
- window.location.assign(result.url)
32
+ import { LimitKind, LimitWindow, PlanDuration, PlanStatus } from '@owlmeans/payment'
33
+ import type { ProductPlan } from '@owlmeans/payment'
34
+
35
+ const proMonthly: ProductPlan = {
36
+ productSku: 'pro', sku: 'pro-monthly', status: PlanStatus.Active, duration: PlanDuration.Monthly,
37
+ price: 20, title: 'Pro', rank: 10, gateways: ['stripe'],
38
+ capabilities: [
39
+ { scope: 'feature', permissions: { whitelabel: true } },
40
+ { scope: 'feature', permissions: { beta: true }, promo: { until: new Date('2027-01-01'), grandfather: true } },
41
+ ],
42
+ limits: {
43
+ seats: { kind: LimitKind.Occupancy, limit: 5 },
44
+ exports: { kind: LimitKind.Window, window: LimitWindow.Month, limit: 100, unit: 'file' },
45
+ },
46
+ }
40
47
  ```
41
48
 
42
- ## API
49
+ Gate a route on a capability or on a limit:
43
50
 
44
- ### `makePaymentService(alias?): PaymentService`
51
+ ```typescript
52
+ import { ENTITLEMENT_GATE, LIMIT_GATE, entitled, formatLimitParam } from '@owlmeans/payment'
45
53
 
46
- Creates the payment service.
54
+ protocol(route(WHITELABEL, '/whitelabel', backend(BASE, RouteMethod.POST)), contract(typed()),
55
+ entitled('feature:whitelabel'))
56
+ protocol(route(INVITE, '/invite', backend(BASE, RouteMethod.POST)), contract(typed()),
57
+ { gate: { alias: LIMIT_GATE, params: [formatLimitParam('seats')] } })
58
+ ```
47
59
 
48
- ### `PaymentService`
60
+ Build and read an entitlement view:
49
61
 
50
- - `product(sku): Promise<Product>` — get a product by SKU
51
- - `products(): Promise<Product[]>` — list all products
52
- - `plans(productSku, duration): Promise<ProductPlan[]>` — list plans for a product
53
- - `plan(planSku): Promise<ProductPlan>` — get a plan by SKU
54
- - `shallowAuthentication(token): Promise<string>` — shallow auth for payment flows
55
- - `localize(lng, entity): Promise<Localization | null>` — get localized payment content
62
+ ```typescript
63
+ import {
64
+ capabilityOf, entitlementViewOf, hasLimitRoom, limitOf, reviveEntitlementView,
65
+ } from '@owlmeans/payment'
66
+
67
+ const view = entitlementViewOf(plan, planView, usage) // server
68
+ const fromWire = reviveEntitlementView(await response.json()) // browser: ISO strings → Dates
69
+ capabilityOf(fromWire, 'feature:whitelabel')
70
+ hasLimitRoom(limitOf(fromWire, 'seats'))
71
+ ```
72
+
73
+ Refuse and recover the fields on the other side of a service hop:
56
74
 
57
- ### Types
75
+ ```typescript
76
+ import { LimitExhausted } from '@owlmeans/payment'
77
+ import { ResilientError } from '@owlmeans/error'
58
78
 
59
- - `CreateCheckoutBody` — `{ productSku, entitySlug, service, amountMinor?, successUrl?, cancelUrl? }`
60
- - `CreateCheckoutResponse` — `{ url: string }`
61
- - `AmountCheckoutPolicy` — integer minor-unit bounds, presets, currency, fixed and basis-point adjustments
62
- - `QuantityCheckoutPolicy` — integer quantity bounds and default
63
- - `SubscriptionPropagateBody` — propagation wire shape with `entitySlug`, never the stored `entityId`
64
- - `PlanSubscription` — `{ sku, entityId, createdAt, status, capabilities? }`
79
+ throw new LimitExhausted({ key: 'seats', used: 5, limit: 5 })
80
+ // …after marshal/unmarshal:
81
+ const error = ResilientError.ensure(caught)
82
+ if (error instanceof LimitExhausted) { error.limitKey; error.used; error.limit; error.resetsAt }
83
+ ```
65
84
 
66
- ### Enums
85
+ ## API
67
86
 
68
- - `PlanDuration` — `Monthly`, `Yearly`, `Consumable`, etc.
69
- - `SubscriptionStatus` — `Active`, `Consumable`, `Canceled`, etc.
70
- - `ProductType` — product category enum
71
- - `PaymentEntityType` — entity classification enum
72
- - `CheckoutPricingMode` — `Amount` or `Quantity`
87
+ - Catalogue: `makePaymentService(alias?)`, `appendPaymentService(ctx, alias?)`, `PaymentService`
88
+ (`product`, `products`, `plans`, `plan`, `allPlans`, `localize`, `shallowAuthentication`),
89
+ `l10nToId`, record types/prefixes.
90
+ - Checkout: `CheckoutPricingMode`, `AmountCheckoutPolicy`/`QuantityCheckoutPolicy` (+ schemas),
91
+ `assertAmountCheckoutPolicy`, `assertQuantityCheckoutPolicy`, `assertCheckoutAmount`,
92
+ `chargeAmountMinor`, `CreateCheckoutBody` (`planSku`) / `CreateCheckoutResponse` (+ schemas),
93
+ `PortalFlow`, `PortalLinkBody` / `PortalLinkResponse` (+ schemas).
94
+ - Entitlement grammar: `ENTITLEMENT_GATE`, `LIMIT_GATE`, `CAPABILITY_FEATURE_SCOPE`,
95
+ `CAPABILITY_LIMIT_SCOPE`, `entitled`, `parseEntitlementParam`, `formatEntitlementParam`,
96
+ `hasEntitlement`, `entitlementList`, `parseLimitParam`, `formatLimitParam`.
97
+ - Statuses: `SubscriptionStatus` (incl. `PastDue`), `ENTITLING_STATUSES`, `TERMINAL_STATUSES`,
98
+ `INTERNAL_PAYGATE`.
99
+ - Limits and promos: `LimitKind`, `LimitWindow`, `LimitDeclaration`, `PlanCapability`,
100
+ `PromoDeclaration` (+ schemas), `windowKeyOf`, `windowBoundsOf`, `LIFETIME_WINDOW`,
101
+ `OCCUPANCY_WINDOW`, `promoActive`, `promoViewOf`.
102
+ - Views: `EntitlementView`, `EntitlementPlanView`, `CapabilityView`, `LimitView`, `PromoView`,
103
+ `LimitUsage` (+ wire schemas), `capabilityViewsOf`, `limitViewsOf`, `entitlementViewOf`,
104
+ `reviveEntitlementView`, `capabilityOf`, `limitOf`, `hasLimitRoom`.
105
+ - Errors: `EntitlementRefusal` → `CapabilityRequired`, `LimitExhausted` (all `AuthForbidden`);
106
+ `PaymentError` family incl. `LimitUnknown`, `LimitMisdeclared`, `PlanRequired`,
107
+ `PlanRankConflict`, `WebhookSetupError`, `PortalUnavailable` (declares `httpStatus = 409`, so an
108
+ HTTP boundary answers 409; the other faults answer 500). Messages are registered under
109
+ `errors.<type>` in seven languages.
110
+
111
+ ## Common pitfalls
112
+
113
+ - A limit parameter is never a capability: `hasEntitlement(sets, 'limit:x')` is always `false`.
114
+ - A view's dates are ISO strings on the wire — revive before calling date methods.
115
+ - A promo ends AT `until`; grandfathering needs the subscription's creation date on the plan view.
116
+ - A refusal's fields survive a hop only through its message; catch the class, not the text.
73
117
 
74
118
  ## Related Packages
75
119
 
76
- - [`@owlmeans/client-payment`](../client-payment) — client-side wrapper for browser contexts
120
+ - `@owlmeans/server-payment` — Stripe gateway, subscription store, usage ledger and the two gates
121
+ - `@owlmeans/client-payment` — browser-side catalogue service
122
+ - `@owlmeans/web-payment` — React hooks and pieces over the entitlement view
77
123
 
78
124
  <!-- owlmeans:agent-guidance:start -->
79
125
  ## Agent guidance
@@ -83,7 +129,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
83
129
  your project's skill store (`.agents/skills/`):
84
130
 
85
131
  ```sh
86
- npx @owlmeans/agent-skills@^0.1.18-rc.26
132
+ npx @owlmeans/agent-skills@^0.1.18-rc.28
87
133
  ```
88
134
 
89
135
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,10 +1,17 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/payment",
4
- "version": "0.1.18-rc.27",
5
- "generatedAt": "2026-09-15T13:15:34.374Z",
4
+ "version": "0.1.18-rc.29",
5
+ "generatedAt": "2026-09-16T17:32:31.941Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
+ {
9
+ "kind": "skill",
10
+ "name": "entitlements",
11
+ "category": "multi-package",
12
+ "file": "skills/entitlements/SKILL.md",
13
+ "canonicalPath": ".agents/skills/entitlements/SKILL.md"
14
+ },
8
15
  {
9
16
  "kind": "skill",
10
17
  "name": "payment",
@@ -0,0 +1,150 @@
1
+ ---
2
+ name: entitlements
3
+ description: "The OwlMeans entitlement model across @owlmeans/payment, @owlmeans/server-payment and @owlmeans/web-payment — ranked plans with a real free plan, capabilities versus counted limits, the three limit kinds, promos and grandfathering, the admission-first usage ledger and its may-over-count-never-over-admit invariant, the entitlement view the server gate and the browser both read, the two gate services, and the refusal errors. Use when deciding what a plan grants, gating a route on a feature or a quota, consuming or releasing a limit, or rendering plan state in a UI."
4
+ user-invocable: false
5
+ ---
6
+ <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
7
+
8
+ # Entitlements — what a plan lets an entity do
9
+
10
+ An entity (the organization) always holds exactly one **effective plan**. The plan grants
11
+ **capabilities** (may it do X?) and **limits** (how many X may it do?). A route declares what it
12
+ needs; a gate refuses before the handler runs; the handler consumes. The browser renders the same
13
+ answer from the same pure functions, so a disabled button and a 403 cannot disagree.
14
+
15
+ ## Where each half lives
16
+
17
+ | Layer | Package | Owns |
18
+ |---|---|---|
19
+ | Contracts | `@owlmeans/payment` | Plan declarations, `LimitKind`/`LimitWindow`, promos, the param grammars, the view builders, the refusal errors, `ENTITLEMENT_GATE`/`LIMIT_GATE` |
20
+ | Server | `@owlmeans/server-payment` | The subscription store, plan resolution, the usage ledger and its counters, the two gate services, reconciliation, the paygate lifecycle |
21
+ | Browser | `@owlmeans/web-payment` | Hooks over a served entitlement view (`useEntitlementView`, `useCapability`, `useLimit`, `usePortal`) and presentational pieces |
22
+
23
+ ## Plans
24
+
25
+ - **Rank orders a product's plans.** A higher `rank` is an upgrade; a plan change is classified
26
+ as upgrade/downgrade by rank. No two paid plans of one product share a rank.
27
+ - **The free plan is a real plan** — `free: true`, `price: 0`, `gateways: []`, lowest rank. The
28
+ application grants it as an internal subscription row (`paygate: INTERNAL_PAYGATE`) when an
29
+ entity is created, and plan resolution falls back to it when no entitling row exists. With no
30
+ free plan declared and no entitling row, resolution throws `PlanRequired` — a setup fault, not a
31
+ refusal.
32
+ - **The effective plan** is the highest-ranked subscription in `ENTITLING_STATUSES`
33
+ (`Active`, `Trial`, `PastDue`). `PastDue` still entitles and is flagged; `Suspended` revokes
34
+ until resumed; terminal statuses fall back to the free plan.
35
+ - A plan names its keys, never a product's copy: every limit key an application gates on appears
36
+ on every plan (with `limit: 0` where not included), so "not included" is an answer, not a
37
+ missing row.
38
+
39
+ ## Capabilities versus limits
40
+
41
+ | | Capability | Limit |
42
+ |---|---|---|
43
+ | Declared as | `PlanCapability` (a permission set) | `LimitDeclaration` under a key in `plan.limits` |
44
+ | Asked as | `[scope:]permission[>=n]` | `limit:<key>[>=n]` |
45
+ | Gate alias | `ENTITLEMENT_GATE` | `LIMIT_GATE` |
46
+ | Answer | granted or not | room left in the current window |
47
+ | Changes when | the plan changes | something is consumed or released |
48
+
49
+ - `limit` is a reserved capability scope: no capability can answer a limit parameter.
50
+ - The two gates are different aliases because an entrypoint's gates are collected per gate
51
+ service; one alias would let one requirement hide the other.
52
+ - Several parameters on one gate are OR'd.
53
+
54
+ ## The three limit kinds
55
+
56
+ | Kind | Counter window | Renews |
57
+ |---|---|---|
58
+ | `window` (`day` / `month`) | `YYYY-MM-DD` / `YYYY-MM`, calendar UTC | at the UTC boundary; `resetsAt` is the exclusive start of the next window |
59
+ | `lifetime` | `lifetime` | never — the count belongs to the entity and survives plan changes, so an upgrade reads "1 of 4 used" |
60
+ | `occupancy` | `occupancy` | never — a held count, `+1` on acquire and `-1` on release, reconciled against what actually exists |
61
+
62
+ Windows are calendar UTC, never rolling and never local.
63
+
64
+ ## Promos and grandfathering
65
+
66
+ `promo: { until, grandfather? }` on a capability set or a limit is plan metadata resolved per
67
+ subscription:
68
+
69
+ - in force while `now < until` (`now === until` is over);
70
+ - with `grandfather`, also for a subscription created before `until`, for as long as it lasts;
71
+ - lapsed: the capability is listed `granted: false`, the limit reads `limit: 0`, and the promo
72
+ view says `active: false` — the UI says the promotion ended.
73
+
74
+ The server must put the subscription's creation date on the plan view (`subscribedAt`); without it
75
+ nothing is grandfathered.
76
+
77
+ ## The usage ledger — admission first
78
+
79
+ Limits are event-sourced with synchronous admission:
80
+
81
+ - **The ledger of usage events is the source of truth; the counter per (entity, key, window) is a
82
+ projection.**
83
+ - **Consume** increments the counter FIRST with an atomic conditional update (only while
84
+ `used <= limit - amount`), then appends the event keyed by an idempotency `eventKey`. No update
85
+ ⇒ `LimitExhausted`. A duplicate `eventKey` undoes the increment and replays the earlier outcome.
86
+ - **Release** appends first, then decrements (never below zero), and is idempotent.
87
+ - **Invariant: the counter may over-count, never over-admit.** A crash between the increment and
88
+ the event leaves a unit counted that nothing spent — the periodic reconciliation recomputes
89
+ counters from the ledger (and occupancy from reality). The opposite order would let two
90
+ concurrent requests both pass a read-then-write check.
91
+ - **Key a consumption by the record it pays for** (`eventKey = <purpose>:<recordId>`,
92
+ `ref = recordId`). A retry, a resumed run or a second attempt on the same record replays the same
93
+ event instead of spending a second unit, and "was a unit already spent on this record?" is a
94
+ ledger read rather than a marker written onto the record.
95
+ - Occupancy is reconciled against the live count; an entity over its ceiling is flagged
96
+ (`overSince`) and the application decides what to do after a grace period — a cron never
97
+ silently deletes customer work.
98
+
99
+ ## The gates — the gate refuses, the handler consumes
100
+
101
+ - The **capability gate** passes when the effective plan grants any parameter. The subscription
102
+ is the authority: a token's explicit `false` for a permission denies, a token's own grant never
103
+ allows, and requiring the token permission as well is an opt-in gate option. Store errors
104
+ refuse.
105
+ - The **limit gate** passes when any declared parameter has `remaining >= atLeast`. It **never
106
+ consumes** — checking room and spending it are different moments, and only the handler knows
107
+ whether the work actually started. Unparseable parameters are skipped; none left ⇒ refuse.
108
+ - The **handler consumes** with a record-keyed `eventKey` right before the paid work starts, and
109
+ releases (or relies on the key's replay) when the work is undone.
110
+ - Declare the requirement on the protocol, never inside a handler, so the route table states what
111
+ a feature costs.
112
+
113
+ ## The entitlement view
114
+
115
+ One read of an entity's position, built by `entitlementViewOf(plan, planView, usage, at)`:
116
+
117
+ - `plan` — sku, rank, free, status, paygate, period, trial, cancel-at-period-end, past-due,
118
+ fallback plan;
119
+ - `capabilities` — one row per granted permission, `param` exactly as the gate takes it;
120
+ - `limits` — one row per declared key with `limit`, `used`, `remaining` (floored at 0), and
121
+ `windowStart`/`resetsAt` for window limits;
122
+ - `at` — when it was computed.
123
+
124
+ The server serves it; the browser reads it with the same `capabilityOf` / `limitOf` /
125
+ `hasLimitRoom` the gate logic uses. On the wire every date is an ISO string — revive
126
+ (`reviveEntitlementView`) before using a date. In the browser `null` means "not known yet": render
127
+ paid controls disabled rather than enabled-then-refused, and an application with its own store
128
+ reads the view through the pure selectors rather than polling it a second time.
129
+
130
+ ## Refusals
131
+
132
+ | Error | Means | HTTP | Fields (rebuilt after a hop) |
133
+ |---|---|---|---|
134
+ | `CapabilityRequired` | none of the capability parameters is granted | 403 | `params` |
135
+ | `LimitExhausted` | no room in the current window | 403 | `limitKey`, `used`, `limit`, `resetsAt?` |
136
+ | `LimitUnknown` · `LimitMisdeclared` · `PlanRequired` · `PlanRankConflict` | configuration faults | 500 | — |
137
+
138
+ - Both refusals extend `AuthForbidden` through `EntitlementRefusal` — that is what turns them into
139
+ a 403 at the HTTP boundary.
140
+ - Only `type` and `message` cross a service hop; the refusals pack their fields into the message
141
+ and rebuild them on unmarshal. Catch the class after `ResilientError.ensure`.
142
+ - A UI phrases a refusal from `errors.<type>` and its fields (the reset date of an exhausted
143
+ window), never from the message text.
144
+
145
+ ## Related
146
+
147
+ - [[payment]] — the contracts in detail: grammars, window algebra, schemas
148
+ - [[server-payment]] — the Stripe lifecycle, the subscription store, the ledger and gates
149
+ - [[web-payment]] — the hooks and pieces
150
+ - [[error]] — marshalling and the error registry