@owlmeans/payment 0.1.18-rc.3 → 0.1.18-rc.31
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 +96 -58
- package/agent-meta/manifest.json +9 -2
- package/agent-meta/skills/entitlements/SKILL.md +150 -0
- package/agent-meta/skills/payment/SKILL.md +236 -28
- package/build/advertise.d.ts +2 -0
- package/build/advertise.d.ts.map +1 -0
- package/build/advertise.js +18 -0
- package/build/advertise.js.map +1 -0
- package/build/consts.d.ts +71 -25
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +104 -25
- package/build/consts.js.map +1 -1
- package/build/countries.d.ts +22 -0
- package/build/countries.d.ts.map +1 -0
- package/build/countries.js +66 -0
- package/build/countries.js.map +1 -0
- package/build/entitlement.d.ts +77 -0
- package/build/entitlement.d.ts.map +1 -0
- package/build/entitlement.js +118 -0
- package/build/entitlement.js.map +1 -0
- package/build/errors.d.ts +75 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +133 -0
- package/build/errors.js.map +1 -1
- package/build/estimate.d.ts +57 -0
- package/build/estimate.d.ts.map +1 -0
- package/build/estimate.js +93 -0
- package/build/estimate.js.map +1 -0
- package/build/i18n/errors/be.json +21 -0
- package/build/i18n/errors/de.json +21 -0
- package/build/i18n/errors/en.json +21 -0
- package/build/i18n/errors/es.json +21 -0
- package/build/i18n/errors/pl.json +21 -0
- package/build/i18n/errors/ru.json +21 -0
- package/build/i18n/errors/uk.json +21 -0
- package/build/i18n.js +18 -0
- package/build/i18n.js.map +1 -1
- package/build/index.d.ts +8 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +8 -1
- package/build/index.js.map +1 -1
- package/build/limit.d.ts +50 -0
- package/build/limit.d.ts.map +1 -0
- package/build/limit.js +101 -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 +9 -7
- package/build/model/checkout.js.map +1 -1
- package/build/model/estimate.d.ts +5 -0
- package/build/model/estimate.d.ts.map +1 -0
- package/build/model/estimate.js +91 -0
- package/build/model/estimate.js.map +1 -0
- package/build/model/index.d.ts +5 -2
- package/build/model/index.d.ts.map +1 -1
- package/build/model/index.js +5 -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 +14 -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 +8 -0
- package/build/model/view.d.ts.map +1 -0
- package/build/model/view.js +86 -0
- package/build/model/view.js.map +1 -0
- package/build/pricing.d.ts +7 -0
- package/build/pricing.d.ts.map +1 -0
- package/build/pricing.js +63 -0
- package/build/pricing.js.map +1 -0
- package/build/promo.d.ts +11 -0
- package/build/promo.d.ts.map +1 -0
- package/build/promo.js +26 -0
- package/build/promo.js.map +1 -0
- package/build/service.d.ts.map +1 -1
- package/build/service.js +10 -1
- package/build/service.js.map +1 -1
- package/build/types.d.ts +216 -54
- package/build/types.d.ts.map +1 -1
- package/build/view.d.ts +30 -0
- package/build/view.d.ts.map +1 -0
- package/build/view.js +91 -0
- package/build/view.js.map +1 -0
- package/package.json +13 -11
- package/src/advertise.ts +22 -0
- package/src/consts.ts +115 -26
- package/src/countries.ts +71 -0
- package/src/entitlement.ts +147 -0
- package/src/errors.ts +171 -0
- package/src/estimate.ts +115 -0
- package/src/i18n/errors/be.json +21 -0
- package/src/i18n/errors/de.json +21 -0
- package/src/i18n/errors/en.json +21 -0
- package/src/i18n/errors/es.json +21 -0
- package/src/i18n/errors/pl.json +21 -0
- package/src/i18n/errors/ru.json +21 -0
- package/src/i18n/errors/uk.json +21 -0
- package/src/i18n.ts +20 -0
- package/src/index.ts +8 -1
- package/src/limit.ts +130 -0
- package/src/model/checkout.ts +9 -7
- package/src/model/estimate.ts +97 -0
- package/src/model/index.ts +5 -2
- package/src/model/limit.ts +36 -0
- package/src/model/plan.ts +15 -12
- package/src/model/portal.ts +25 -0
- package/src/model/pricing.ts +34 -0
- package/src/model/view.ts +96 -0
- package/src/pricing.ts +87 -0
- package/src/promo.ts +41 -0
- package/src/service.ts +16 -2
- package/src/types.ts +233 -50
- package/src/view.ts +120 -0
- package/tests/contract.spec.ts +172 -0
- package/tests/entitlement.spec.ts +105 -0
- package/tests/errors.spec.ts +96 -0
- package/tests/estimate.spec.ts +121 -0
- package/tests/limit.spec.ts +106 -0
- package/tests/pricing.spec.ts +33 -0
- package/tests/tsconfig.json +11 -0
- package/tests/view.spec.ts +130 -0
- package/tsconfig.json +6 -1
- package/build/.gitkeep +0 -0
- package/build/model/subscription.d.ts +0 -9
- package/build/model/subscription.d.ts.map +0 -1
- package/build/model/subscription.js +0 -59
- package/build/model/subscription.js.map +0 -1
- package/build/model/utils.d.ts +0 -5
- package/build/model/utils.d.ts.map +0 -1
- package/build/model/utils.js +0 -28
- package/build/model/utils.js.map +0 -1
- package/build/modules.d.ts +0 -3
- package/build/modules.d.ts.map +0 -1
- package/build/modules.js +0 -18
- package/build/modules.js.map +0 -1
- package/src/model/subscription.ts +0 -63
- package/src/model/utils.ts +0 -32
- package/src/modules.ts +0 -43
package/README.md
CHANGED
|
@@ -1,87 +1,125 @@
|
|
|
1
1
|
# @owlmeans/payment
|
|
2
2
|
|
|
3
|
-
Payment
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
- `makePaymentService(alias?)` — creates a payment service for context registration
|
|
8
|
-
- `appendPaymentService(context, alias?)` — registers the payment service in the context
|
|
9
|
-
- `PaymentService` — interface for products, plans, subscriptions, and checkout session creation
|
|
10
|
-
- `CreateCheckoutBody` / `CreateCheckoutResponse` — request/response types for checkout
|
|
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.
|
|
11
7
|
|
|
12
8
|
## Installation
|
|
13
9
|
|
|
14
10
|
```bash
|
|
15
|
-
bun add @owlmeans/payment
|
|
11
|
+
bun add @owlmeans/payment@^0.1.18-rc.31
|
|
16
12
|
```
|
|
17
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
|
+
|
|
18
27
|
## Usage
|
|
19
28
|
|
|
20
|
-
|
|
29
|
+
Declare a plan:
|
|
21
30
|
|
|
22
31
|
```typescript
|
|
23
|
-
import
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
window.
|
|
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
|
+
}
|
|
36
47
|
```
|
|
37
48
|
|
|
38
|
-
|
|
49
|
+
Gate a route on a capability or on a limit:
|
|
39
50
|
|
|
40
51
|
```typescript
|
|
41
|
-
import {
|
|
42
|
-
|
|
43
|
-
const handler = handleBody<SubscriptionPropagateBody>(async (body, context) => {
|
|
44
|
-
if (body.status === SubscriptionStatus.Consumable) {
|
|
45
|
-
const tokens = body.capabilities?.find(
|
|
46
|
-
c => c.scope === PlanDuration.Consumable
|
|
47
|
-
)?.permissions.units ?? 0
|
|
48
|
-
await ctx.agentToken().topUpTokens(entityId, tokens * 1000)
|
|
49
|
-
}
|
|
50
|
-
})
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
## API
|
|
52
|
+
import { ENTITLEMENT_GATE, LIMIT_GATE, entitled, formatLimitParam } from '@owlmeans/payment'
|
|
54
53
|
|
|
55
|
-
|
|
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
|
+
```
|
|
56
59
|
|
|
57
|
-
|
|
60
|
+
Build and read an entitlement view:
|
|
58
61
|
|
|
59
|
-
|
|
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
|
+
```
|
|
60
72
|
|
|
61
|
-
|
|
62
|
-
- `products(): Promise<Product[]>` — list all products
|
|
63
|
-
- `plans(productSku, duration): Promise<ProductPlan[]>` — list plans for a product
|
|
64
|
-
- `plan(planSku): Promise<ProductPlan>` — get a plan by SKU
|
|
65
|
-
- `shallowAuthentication(token): Promise<string>` — shallow auth for payment flows
|
|
66
|
-
- `localize(lng, entity): Promise<Localization | null>` — get localized payment content
|
|
73
|
+
Refuse and recover the fields on the other side of a service hop:
|
|
67
74
|
|
|
68
|
-
|
|
75
|
+
```typescript
|
|
76
|
+
import { LimitExhausted } from '@owlmeans/payment'
|
|
77
|
+
import { ResilientError } from '@owlmeans/error'
|
|
69
78
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
```
|
|
74
84
|
|
|
75
|
-
|
|
85
|
+
## API
|
|
76
86
|
|
|
77
|
-
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
- `
|
|
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.
|
|
81
117
|
|
|
82
118
|
## Related Packages
|
|
83
119
|
|
|
84
|
-
-
|
|
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
|
|
85
123
|
|
|
86
124
|
<!-- owlmeans:agent-guidance:start -->
|
|
87
125
|
## Agent guidance
|
|
@@ -91,7 +129,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
91
129
|
your project's skill store (`.agents/skills/`):
|
|
92
130
|
|
|
93
131
|
```sh
|
|
94
|
-
npx @owlmeans/agent-skills
|
|
132
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.30
|
|
95
133
|
```
|
|
96
134
|
|
|
97
135
|
The embedded files are version-matched to this package release. Do not edit them
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/payment",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-
|
|
4
|
+
"version": "0.1.18-rc.31",
|
|
5
|
+
"generatedAt": "2026-09-19T13:42:56.261Z",
|
|
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
|