@owlmeans/server-payment 0.1.18-rc.2 → 0.1.18-rc.20

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 (160) hide show
  1. package/README.md +109 -25
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-payment/SKILL.md +329 -48
  4. package/build/actions/index.d.ts +1 -0
  5. package/build/actions/index.d.ts.map +1 -1
  6. package/build/actions/index.js +1 -0
  7. package/build/actions/index.js.map +1 -1
  8. package/build/actions/resync-subscriptions.d.ts +3 -0
  9. package/build/actions/resync-subscriptions.d.ts.map +1 -0
  10. package/build/actions/resync-subscriptions.js +7 -0
  11. package/build/actions/resync-subscriptions.js.map +1 -0
  12. package/build/actions/resync.d.ts +1 -0
  13. package/build/actions/resync.d.ts.map +1 -1
  14. package/build/actions/resync.js +7 -3
  15. package/build/actions/resync.js.map +1 -1
  16. package/build/actions/webhook.d.ts.map +1 -1
  17. package/build/actions/webhook.js +6 -3
  18. package/build/actions/webhook.js.map +1 -1
  19. package/build/config.d.ts +46 -5
  20. package/build/config.d.ts.map +1 -1
  21. package/build/config.js +140 -4
  22. package/build/config.js.map +1 -1
  23. package/build/consts.d.ts +48 -3
  24. package/build/consts.d.ts.map +1 -1
  25. package/build/consts.js +67 -5
  26. package/build/consts.js.map +1 -1
  27. package/build/entitlement.d.ts +10 -0
  28. package/build/entitlement.d.ts.map +1 -0
  29. package/build/entitlement.js +69 -0
  30. package/build/entitlement.js.map +1 -0
  31. package/build/entrypoints.d.ts +1 -1
  32. package/build/entrypoints.d.ts.map +1 -1
  33. package/build/entrypoints.js +2 -1
  34. package/build/entrypoints.js.map +1 -1
  35. package/build/gate.d.ts +32 -4
  36. package/build/gate.d.ts.map +1 -1
  37. package/build/gate.js +59 -19
  38. package/build/gate.js.map +1 -1
  39. package/build/index.d.ts +16 -3
  40. package/build/index.d.ts.map +1 -1
  41. package/build/index.js +13 -3
  42. package/build/index.js.map +1 -1
  43. package/build/limit.d.ts +13 -0
  44. package/build/limit.d.ts.map +1 -0
  45. package/build/limit.js +47 -0
  46. package/build/limit.js.map +1 -0
  47. package/build/model.d.ts +5 -1
  48. package/build/model.d.ts.map +1 -1
  49. package/build/model.js +85 -17
  50. package/build/model.js.map +1 -1
  51. package/build/observer.d.ts +5 -0
  52. package/build/observer.d.ts.map +1 -1
  53. package/build/observer.js +25 -12
  54. package/build/observer.js.map +1 -1
  55. package/build/plan.d.ts +22 -0
  56. package/build/plan.d.ts.map +1 -0
  57. package/build/plan.js +72 -0
  58. package/build/plan.js.map +1 -0
  59. package/build/plugins/estimate.d.ts +43 -0
  60. package/build/plugins/estimate.d.ts.map +1 -0
  61. package/build/plugins/estimate.js +235 -0
  62. package/build/plugins/estimate.js.map +1 -0
  63. package/build/plugins/events.d.ts +43 -10
  64. package/build/plugins/events.d.ts.map +1 -1
  65. package/build/plugins/events.js +487 -135
  66. package/build/plugins/events.js.map +1 -1
  67. package/build/plugins/fx.d.ts +26 -0
  68. package/build/plugins/fx.d.ts.map +1 -0
  69. package/build/plugins/fx.js +56 -0
  70. package/build/plugins/fx.js.map +1 -0
  71. package/build/plugins/portal.d.ts +37 -0
  72. package/build/plugins/portal.d.ts.map +1 -0
  73. package/build/plugins/portal.js +255 -0
  74. package/build/plugins/portal.js.map +1 -0
  75. package/build/plugins/refunds.d.ts +33 -0
  76. package/build/plugins/refunds.d.ts.map +1 -0
  77. package/build/plugins/refunds.js +80 -0
  78. package/build/plugins/refunds.js.map +1 -0
  79. package/build/plugins/stripe.d.ts +17 -4
  80. package/build/plugins/stripe.d.ts.map +1 -1
  81. package/build/plugins/stripe.js +147 -88
  82. package/build/plugins/stripe.js.map +1 -1
  83. package/build/plugins/webhook-manager.d.ts +46 -0
  84. package/build/plugins/webhook-manager.d.ts.map +1 -0
  85. package/build/plugins/webhook-manager.js +231 -0
  86. package/build/plugins/webhook-manager.js.map +1 -0
  87. package/build/reconcile.d.ts +15 -0
  88. package/build/reconcile.d.ts.map +1 -0
  89. package/build/reconcile.js +88 -0
  90. package/build/reconcile.js.map +1 -0
  91. package/build/resource.d.ts +5 -1
  92. package/build/resource.d.ts.map +1 -1
  93. package/build/resource.js +40 -6
  94. package/build/resource.js.map +1 -1
  95. package/build/service.d.ts +27 -3
  96. package/build/service.d.ts.map +1 -1
  97. package/build/service.js +123 -19
  98. package/build/service.js.map +1 -1
  99. package/build/subscription.d.ts +42 -0
  100. package/build/subscription.d.ts.map +1 -0
  101. package/build/subscription.js +175 -0
  102. package/build/subscription.js.map +1 -0
  103. package/build/sync.d.ts +21 -1
  104. package/build/sync.d.ts.map +1 -1
  105. package/build/sync.js +108 -24
  106. package/build/sync.js.map +1 -1
  107. package/build/types.d.ts +412 -36
  108. package/build/types.d.ts.map +1 -1
  109. package/build/usage.d.ts +63 -0
  110. package/build/usage.d.ts.map +1 -0
  111. package/build/usage.js +363 -0
  112. package/build/usage.js.map +1 -0
  113. package/build/utils.d.ts +31 -1
  114. package/build/utils.d.ts.map +1 -1
  115. package/build/utils.js +43 -7
  116. package/build/utils.js.map +1 -1
  117. package/package.json +16 -13
  118. package/src/actions/index.ts +1 -0
  119. package/src/actions/resync-subscriptions.ts +10 -0
  120. package/src/actions/resync.ts +6 -3
  121. package/src/actions/webhook.ts +5 -3
  122. package/src/config.ts +159 -7
  123. package/src/consts.ts +80 -6
  124. package/src/entitlement.ts +84 -0
  125. package/src/entrypoints.ts +2 -1
  126. package/src/gate.ts +88 -21
  127. package/src/index.ts +22 -3
  128. package/src/limit.ts +57 -0
  129. package/src/model.ts +95 -18
  130. package/src/observer.ts +31 -11
  131. package/src/plan.ts +89 -0
  132. package/src/plugins/estimate.ts +299 -0
  133. package/src/plugins/events.ts +552 -120
  134. package/src/plugins/fx.ts +93 -0
  135. package/src/plugins/portal.ts +296 -0
  136. package/src/plugins/refunds.ts +108 -0
  137. package/src/plugins/stripe.ts +157 -88
  138. package/src/plugins/webhook-manager.ts +270 -0
  139. package/src/reconcile.ts +103 -0
  140. package/src/resource.ts +69 -7
  141. package/src/service.ts +152 -18
  142. package/src/subscription.ts +224 -0
  143. package/src/sync.ts +135 -22
  144. package/src/types.ts +476 -31
  145. package/src/usage.ts +453 -0
  146. package/src/utils.ts +78 -10
  147. package/tests/checkout.spec.ts +184 -83
  148. package/tests/context.ts +91 -0
  149. package/tests/entitlement.spec.ts +103 -0
  150. package/tests/estimate.spec.ts +240 -0
  151. package/tests/events.spec.ts +356 -0
  152. package/tests/fake-stripe.ts +802 -0
  153. package/tests/gate.spec.ts +62 -72
  154. package/tests/limit-gate.spec.ts +68 -0
  155. package/tests/portal.spec.ts +200 -0
  156. package/tests/protocol.spec.ts +23 -6
  157. package/tests/sync.spec.ts +114 -0
  158. package/tests/usage.integration.spec.ts +101 -0
  159. package/tests/usage.spec.ts +171 -0
  160. package/tests/webhook-manager.spec.ts +152 -0
package/README.md CHANGED
@@ -1,42 +1,126 @@
1
1
  # @owlmeans/server-payment
2
2
 
3
- Public server-side payment primitives for OwlMeans applications. The package registers payment
4
- resources, the entitlement gate, protocol-bound webhook/resync entrypoints, and a Stripe gateway
5
- supporting amount-priced consumables, quantity-priced consumables, and subscriptions.
3
+ Server-side payments for OwlMeans applications: a Stripe gateway embedded in the backend
4
+ (amount and quantity checkout, subscriptions, the customer portal), a subscription store that
5
+ understands the whole Stripe lifecycle, a usage ledger for counted plan limits, the entitlement
6
+ service that resolves what an organization entity may do, and the two gate services that refuse a
7
+ request before its handler runs. The contracts — plans, limits, promos, the entitlement view, the
8
+ refusal errors — live in `@owlmeans/payment`.
6
9
 
7
- Applications declare products and plans during configuration, mount `paymentGateEntrypoints`, and
8
- register completion callbacks on the payment observer. Amount policies are validated at declaration
9
- time. Stripe Checkout is always authoritative: completion is idempotent by session id and an
10
- amount checkout is fulfilled only after its paid currency and subtotal match its signed-in metadata.
10
+ ## Declare the catalogue
11
11
 
12
12
  ```typescript
13
- import { CheckoutPricingMode, PlanDuration, ProductType } from '@owlmeans/payment'
13
+ import { CheckoutPricingMode, LimitKind, LimitWindow, PlanDuration, ProductType } from '@owlmeans/payment'
14
14
  import {
15
- appendPaymentGatewayService, declarePaymentPlan, declarePaymentProduct, observer,
15
+ declarePaymentPlan, declarePaymentProduct, portalBranding, stripeSecrets,
16
16
  } from '@owlmeans/server-payment'
17
17
 
18
- declarePaymentProduct(cfg, {
19
- sku: 'app-credits', type: ProductType.Consumable, services: ['app'], gateways: ['stripe'],
20
- name: 'Credits',
18
+ stripeSecrets(cfg, { api: '/secrets/stripe-key' }) // `webhook` is an optional override
19
+ portalBranding(cfg, { returnUrl: 'https://app.example.com/billing', headline: 'Example' })
20
+
21
+ declarePaymentProduct(cfg, { sku: 'app-plans', type: ProductType.Service, services: ['app'], name: 'Plans' })
22
+ declarePaymentPlan(cfg, {
23
+ productSku: 'app-plans', sku: 'free', duration: PlanDuration.Monthly, rank: 0, free: true, price: 0,
24
+ capabilities: [{ scope: 'feature', permissions: { basic: true } }],
25
+ limits: { seats: { kind: LimitKind.Occupancy, limit: 1 }, exports: { kind: LimitKind.Window, window: LimitWindow.Month, limit: 3 } },
21
26
  })
22
27
  declarePaymentPlan(cfg, {
23
- productSku: 'app-credits', sku: 'app-credit-unit', duration: PlanDuration.Consumable,
24
- price: 0.02, pricingMode: CheckoutPricingMode.Amount,
25
- amountPolicy: {
26
- currency: 'usd', minimumMinor: 500, maximumMinor: 50_000, defaultMinor: 1_000,
27
- presetsMinor: [1_000, 2_000, 5_000, 10_000], fixedMinor: 0, rateBps: 200,
28
- },
28
+ productSku: 'app-plans', sku: 'pro-monthly', duration: PlanDuration.Monthly, rank: 10, price: 20,
29
+ recurring: { interval: 'month' },
30
+ capabilities: [{ scope: 'feature', permissions: { basic: true, whitelabel: true } }],
31
+ limits: { seats: { kind: LimitKind.Occupancy, limit: 5 }, exports: { kind: LimitKind.Window, window: LimitWindow.Day, limit: 20 } },
29
32
  })
30
33
 
31
- appendPaymentGatewayService(context)
32
- observer(context).onTopUp(async completion => {
33
- if (completion.mode === 'amount') await credit(completion.entityId, completion.amountMinor)
34
+ declarePaymentProduct(cfg, { sku: 'app-credits', type: ProductType.Consumable, services: ['app'], name: 'Credits' })
35
+ declarePaymentPlan(cfg, {
36
+ productSku: 'app-credits', sku: 'app-credit-unit', duration: PlanDuration.Consumable, price: 0.02,
37
+ pricingMode: CheckoutPricingMode.Amount,
38
+ amountPolicy: { currency: 'usd', minimumMinor: 500, maximumMinor: 50_000, defaultMinor: 1_000,
39
+ presetsMinor: [1_000, 5_000], fixedMinor: 0, rateBps: 200 },
34
40
  })
35
41
  ```
36
42
 
37
- Mount `paymentGateEntrypoints` in the server entrypoint list. `gateway(context).createLink(...)`
38
- accepts the stable organization `entityId` only after the application has resolved it at its
39
- authenticated boundary; public protocol bodies use `entitySlug`.
43
+ `declarePaymentPlan` validates each declaration before recording it; `assertPlanDeclarations` checks
44
+ the catalogue as a whole (one free plan per rank, no two paid plans of a product at one rank) and runs
45
+ when the gateway initializes. A limit key may use a different kind on different plans — each kind keeps
46
+ its own counter window, so a lifetime count still sticks to the entity when it moves to a monthly plan.
47
+
48
+ ## Wire the services
49
+
50
+ ```typescript
51
+ import {
52
+ appendPaymentGatewayService, entitlements, gateway, observer, paymentGateEntrypoints,
53
+ } from '@owlmeans/server-payment'
54
+
55
+ appendPaymentGatewayService(context) // the process that owns Stripe
56
+ appendPaymentGatewayService(context, { manage: false }) // a process that only reads entitlements
57
+ export const serverBindings = [...paymentGateEntrypoints]
58
+
59
+ observer(context).onTopUp(async completion => { /* credit, idempotent by completion.externalId */ })
60
+ observer(context).onSubscription(async event => { /* idempotent by event.eventKey */ })
61
+ observer(context).onRefund(async event => { /* claw back, idempotent by event.eventKey */ })
62
+ observer(context).onDispute(async event => { /* … */ })
63
+ observer(context).onPaymentFailed(async event => { /* notify */ })
64
+ ```
65
+
66
+ A managed gateway brings Stripe to the declared state at boot — products and prices, one
67
+ customer-portal configuration, one webhook endpoint at this deployment's public URL (its signing
68
+ secret stored in `payment-webhook`) — and each step is fingerprinted, so an unchanged deployment
69
+ makes no Stripe call.
70
+
71
+ Several deployments of one service may share a Stripe account (typically every test-mode
72
+ deployment), each with its own database and URL — and a deployment's identity is its webhook URL,
73
+ even an undeliverable local one.
74
+
75
+ - **Webhook endpoint.** A deployment owns exactly the endpoint at its own URL: it deletes an
76
+ endpoint only when its own `payment-webhook` rows name it — the endpoint of a URL it has moved away
77
+ from — and never one it merely finds at another URL, whatever its metadata says. The endpoint of a
78
+ retired deployment is removed by hand in the Stripe dashboard; one deleted from outside comes back
79
+ on the next forced `resync`.
80
+ - **Portal configuration.** Each deployment has its own, tagged
81
+ `{ owlmeans: 'payment', service, deployment: <webhook URL> }`. It updates the configuration its
82
+ `portal:<service>` fingerprint row names, unless that one is tagged for another deployment, and
83
+ without a row adopts only a configuration carrying exactly its own tag. Stripe cannot delete a
84
+ portal configuration, so one a deployment can no longer identify stays in the account and a new
85
+ one is created.
86
+
87
+ ## Use it
88
+
89
+ ```typescript
90
+ // Checkout and the portal (in-process; resolve the stable entityId at your authenticated boundary)
91
+ const url = await gateway(ctx).createLink(ctx, { productSku: 'app-plans', planSku: 'pro-monthly', entityId, service: 'app', successUrl })
92
+ const portal = await gateway(ctx).portalLink(ctx, entityId, { flow: PortalFlow.Change, planSku: 'pro-monthly', returnUrl })
93
+ await gateway(ctx).grantInternalPlan(ctx, entityId, 'free') // when the organization is created
94
+
95
+ // What the entity may do
96
+ const view = await entitlements(ctx).entitlements(entityId) // the EntitlementView a UI renders
97
+ await entitlements(ctx).hasCapability(entityId, 'feature:whitelabel')
98
+
99
+ // Spend a counted allowance — key it by the record it pays for
100
+ await entitlements(ctx).consume({ entityId, limitKey: 'exports', eventKey: `export:${reportId}`, ref: reportId })
101
+ await entitlements(ctx).release({ entityId, limitKey: 'exports', eventKey: `export:${reportId}` })
102
+
103
+ // Periodic repair
104
+ await reconcileAll(ctx, { freePlanSku: 'free' })
105
+ await entitlements(ctx).reconcileOccupancy(entityId, 'seats', liveSeatCount)
106
+ ```
107
+
108
+ Routes declare what they need; the gates refuse before the handler:
109
+
110
+ ```typescript
111
+ protocol(route(...), contract(...), { gate: { alias: ENTITLEMENT_GATE, params: ['feature:whitelabel'] } })
112
+ protocol(route(...), contract(...), { gate: { alias: LIMIT_GATE, params: [formatLimitParam('seats')] } })
113
+ ```
114
+
115
+ `CapabilityRequired` and `LimitExhausted` extend `AuthForbidden`, so an HTTP boundary answers 403.
116
+ The limit gate never consumes; the handler does.
117
+
118
+ ## Routes
119
+
120
+ `paymentGateEntrypoints` binds the immutable `paymentGate` tree: the public Stripe webhook
121
+ (`POST /payment-gate/webhook/:paygate`, verified by signature over the raw body), and two
122
+ Ed25519-guarded repairs — `resync` (products, portal, webhook endpoint) and `resyncSubscriptions`
123
+ (re-reads every live Stripe subscription, answering `{ scanned, updated }`).
40
124
 
41
125
  <!-- owlmeans:agent-guidance:start -->
42
126
  ## Agent guidance
@@ -46,7 +130,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
46
130
  your project's skill store (`.agents/skills/`):
47
131
 
48
132
  ```sh
49
- npx @owlmeans/agent-skills@^0.1.18-rc.17
133
+ npx @owlmeans/agent-skills@^0.1.18-rc.36
50
134
  ```
51
135
 
52
136
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/server-payment",
4
- "version": "0.1.18-rc.2",
5
- "generatedAt": "2026-09-12T12:59:24.916Z",
4
+ "version": "0.1.18-rc.20",
5
+ "generatedAt": "2026-09-22T22:22:19.443Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -7,74 +7,355 @@ user-invocable: false
7
7
 
8
8
  # @owlmeans/server-payment
9
9
 
10
- Public MIT package in the common monorepo. It embeds Stripe into an application backend; the
11
- consumer owns products, credit conversion and entitlement side effects. It persists customers,
12
- subscriptions/fulfilled sessions and sync fingerprints in Mongo.
10
+ **Install:** `bun add @owlmeans/server-payment@^0.1.18-rc.20`
11
+
12
+ Public MIT package. It embeds Stripe into an application backend and owns everything between
13
+ Stripe and an entity's entitlements: the subscription store, one-time fulfillments, the usage
14
+ ledger of counted limits, plan resolution, the two gate services and Stripe's own configuration
15
+ (products, prices, the portal, the webhook endpoint). The application owns its catalogue, what a
16
+ purchase is worth to it (credits, provisioning) and its side effects. Contracts — plans, limits,
17
+ promos, the entitlement view, refusals — are `@owlmeans/payment`; the model across packages is
18
+ the `entitlements` skill.
13
19
 
14
20
  ## Wiring
15
21
 
16
22
  ```typescript
17
- stripeSecrets(cfg, { api: '/secrets/stripe-key', webhook: '/secrets/stripe-webhook' })
18
- declarePaymentProduct(cfg, { sku: 'credits', type: ProductType.Consumable,
19
- services: ['app'], name: 'Credits', taxCode: 'txcd_10103000' })
20
- declarePaymentPlan(cfg, { productSku: 'credits', sku: 'credits-unit',
21
- duration: PlanDuration.Consumable, price: 0.02,
22
- pricingMode: CheckoutPricingMode.Amount, amountPolicy })
23
-
24
- appendPaymentGatewayService(context)
25
- export const appEntrypoints = [...paymentGateEntrypoints]
26
- observer(context).onTopUp(async completion => { /* append an idempotent ledger event */ })
23
+ stripeSecrets(cfg, { api: '/secrets/stripe-key' }) // webhook secret: optional override
24
+ portalBranding(cfg, { returnUrl: 'https://app.example.com/billing', headline: 'Example' })
25
+ declarePaymentPricing(cfg, { // absent entirely: today's fixed behaviour, unchanged
26
+ tax: { automatic: true, behavior: TaxBehavior.Exclusive, collectTaxId: true, estimate: true },
27
+ currency: { adaptive: true, estimate: true },
28
+ stripe: {
29
+ settlementCurrency: 'eur', // optional: catalogue USD → settlement EUR
30
+ subscriptionPaymentMethodTypes: ['card', 'link', 'klarna'], // optional; absent = Stripe dynamic selection
31
+ },
32
+ })
33
+ declarePaymentProduct(cfg, { sku: 'app-plans', type: ProductType.Service, services: ['app'], name: 'Plans' })
34
+ declarePaymentPlan(cfg, { productSku: 'app-plans', sku: 'free', rank: 0, free: true, price: 0, … })
35
+ declarePaymentPlan(cfg, { productSku: 'app-plans', sku: 'pro-monthly', rank: 10, price: 20,
36
+ recurring: { interval: 'month' }, capabilities: […], limits: { seats: {…} } })
37
+
38
+ appendPaymentGatewayService(context) // the process that talks to Stripe
39
+ appendPaymentGatewayService(context, { manage: false }) // a worker that only reads entitlements
40
+ export const serverBindings = [...paymentGateEntrypoints]
41
+ observer(context).onSubscription(async event => { /* keyed by event.eventKey */ })
27
42
  ```
28
43
 
29
- `declarePaymentPlan` validates amount and quantity policies immediately. An amount plan requires
30
- `amountPolicy`; a quantity/legacy plan may declare `quantityPolicy` or the legacy min/default/max
31
- fields. `gateway(ctx).createLink(...)` takes the stable internal `entityId`; an authenticated public
32
- handler must resolve that id from its request entity before calling the in-process service.
44
+ - `appendPaymentGatewayService` registers seven resources, the catalogue service
45
+ (`PAYMENT_SERVICE`), the completion observer, the gateway (`GATEWAY_SERVICE`), the capability
46
+ gate (`ENTITLEMENT_GATE`), the limit gate (`LIMIT_GATE`) and the entitlement service
47
+ (`ENTITLEMENT_SERVICE`), each only when not registered already.
48
+ - **`manage: false`** registers the same surface with no Stripe client: no bootstrap at init, and
49
+ `createLink`, `portalLink`, `resyncSubscription`, `resyncAll` and the webhook route throw
50
+ `PaygateError('unmanaged')`. `grantInternalPlan` and the whole entitlement service work, because
51
+ they are Mongo only.
52
+ - Gateway methods take the stable `entityId`. A public handler resolves it from its request
53
+ entity first; protocol bodies carry `entitySlug`.
54
+ - A value in `stripeSecrets` / `portalBranding` that starts with `/` is read from that file at
55
+ boot, and a missing file fails the boot — so leave `webhook` out unless the file exists.
56
+
57
+ ## Declaring plans
58
+
59
+ - **`rank`** orders a product's plans; a higher rank is an upgrade. A safe integer `>= 0`; absent
60
+ reads as `0`.
61
+ - **The free plan is a plan**: `free: true`, `price: 0`, no `gateways`. It is never synchronized to
62
+ Stripe and never checked out; an entity without an entitling subscription resolves to it.
63
+ - `gateways` names the paygates a plan is sold through; absent inherits the product's.
64
+ - `declarePaymentPlan` refuses before recording: a bad rank or a priced/gatewayed free plan
65
+ (`PlanRankConflict`), a capability set under the reserved `limit` scope, or a limit that is
66
+ malformed — a window limit without its window, a lifetime/occupancy limit with one, a ceiling that
67
+ is not a safe integer, a promo whose `until` is not a `Date` (`LimitMisdeclared('<key>:<reason>')`).
68
+ - `assertPlanDeclarations` runs when the gateway initializes and fails the boot on two free plans
69
+ at one rank, or two paid non-consumable plans of one product at one rank.
70
+ - A limit key may use a different kind on different plans (lifetime on the free plan, a monthly window
71
+ on a paid one). The counter window is derived from the kind, so each kind counts separately and a
72
+ lifetime count stays with the entity through upgrades, downgrades and cancellations.
73
+
74
+ ## Records
75
+
76
+ None declares an ObjectId reference: `entityId` is an organization key and every other id is Stripe's.
77
+
78
+ | Collection | One row per | Indexes |
79
+ |---|---|---|
80
+ | `payment-paygate-customer` | Stripe customer (`deletedAt` once deleted) | `{paygate, externalId}` unique · `{paygate, entityId}` · `{paygate, profileId}` |
81
+ | `payment-subscription` | subscription: `sub_…`, `free:<entityId>`, `internal:<planSku>:<entityId>` | `{paygate, externalId}` unique · `{entityId, status, rank:-1}` · `{entityId, planSku}` · `{paygate, customerId}` · `{paygate, itemId}` sparse · `{paygate, status, updatedAt}` |
82
+ | `payment-fulfillment` | one-time checkout session | `{paygate, externalId}` unique · `{paygate, paymentIntentId}` sparse · `{paygate, chargeId}` sparse · `{entityId, createdAt:-1}` |
83
+ | `payment-webhook` | managed webhook endpoint (`secret` is `secure: true`) | `{paygate, service, url}` unique |
84
+ | `payment-usage` | usage event — the ledger | `{entityId, limitKey, eventKey}` unique · `{entityId, limitKey, window}` · `{entityId, limitKey, ref}` sparse · `{entityId, createdAt:-1}` |
85
+ | `payment-usage-counter` | (entity, limit, window) projection | `{entityId, limitKey, window}` unique |
86
+ | `payment-fingerprint` | synchronized product (`<productSku>`) or portal (`portal:<service>`) | `{sku}` unique |
87
+
88
+ Every stored property is declared in the record schema: the resource writes a property its schema
89
+ does not know as a string, and the collection validator rejects it.
90
+
91
+ ## Checkout and fulfillment
92
+
93
+ - `Amount`: one inline `price_data` item for the synchronized product, quantity 1, its `tax_behavior`
94
+ the declared `PricingPolicy.tax.behavior` (default `'exclusive'`), no promotion codes. With
95
+ `stripe.settlementCurrency`, the catalogue `amountMinor` and grossed-up `sourceChargeAmountMinor`
96
+ stay in `amountCurrency`, while a fresh Stripe FX reference rate produces `chargeAmountMinor` in
97
+ the settlement `currency`. Without it, source and charge amounts/currencies are identical. `Quantity`:
98
+ the reusable price under the plan lookup key, adjustable quantity. Subscription: `planSku` (else
99
+ the product's first recurring plan), quantity 1. One-time payment Sessions enable
100
+ `invoice_creation`; subscription payments produce their own invoices.
101
+ - `CreateLinkParams.locale` is a caller-validated Stripe locale. It sets Session `locale` and the
102
+ Stripe Customer's `preferred_locales` on create or update, so later subscription invoices use the
103
+ same supported language. `submitText` is trusted application copy placed in
104
+ `custom_text.submit.message` on subscription Checkout only; never pass caller-provided text.
105
+ - A plan the paygate does not sell — a free plan, another gateway's plan — is refused
106
+ (`ProductError`). `checkoutOptions(policy, promotions)` puts automatic tax, billing address
107
+ collection, tax-id collection and Adaptive Pricing (`adaptive_pricing`) on the session, each
108
+ independently, exactly as `PricingPolicy` declares them — an undeclared policy reproduces the
109
+ fixed pre-policy session (automatic tax + tax-id collection on, no Adaptive Pricing).
110
+ - `stripe.subscriptionPaymentMethodTypes` explicitly sets `payment_method_types` on subscription
111
+ Sessions only; absent keeps Stripe's dynamic selection. Declare only recurring-capable methods:
112
+ Stripe rejects single-use methods such as BLIK in `subscription` mode. Account availability,
113
+ customer country, currency and each method's own restrictions still apply.
114
+ - `checkout.session.completed` and `…async_payment_succeeded` fulfill only a `payment`-mode session
115
+ with `payment_status === 'paid'`; an amount session must match its metadata currency and subtotal.
116
+ Adaptive Pricing never disturbs this: Session/webhook amounts stay in the settlement/integration
117
+ currency whatever currency the buyer paid in. Fulfillment validates that subtotal separately
118
+ from the source amount and preserves both currency pairs in its row and observer event.
119
+ - A pending `payment-fulfillment` row (with `paymentIntentId` and `invoiceId`) is written before
120
+ observers run; `fulfilledAt` is stamped after they succeed. An observer throw escapes so Stripe
121
+ redelivers, and the observer must stay idempotent by `externalId` — a crash after its side effect
122
+ and before the stamp redelivers the same session.
123
+
124
+ ## Price sync and the tax estimate
125
+
126
+ - `syncStripeProducts` gives a matching, still-`unspecified` price the declared `tax.behavior` IN
127
+ PLACE (`prices.update`; Stripe forbids changing a price already `exclusive`/`inclusive`) and a
128
+ fresh one on creation; a price already carrying the OPPOSITE behavior is deactivated and replaced,
129
+ the same as any other catalogue mismatch. Before an in-place update it checks the account's own
130
+ tax-settings default (`stripe.tax.settings.retrieve`) and skips the update — logging why — when
131
+ that default would make the price behave the other way for existing renewals, unless
132
+ `declarePaymentPricing({ stripe: { migrateUnspecifiedPrices: true } })` opts in. The behavior is
133
+ part of the sync fingerprint, so declaring or changing it re-syncs every product exactly once.
134
+ - When `stripe.settlementCurrency` differs from a plan's catalogue currency, each product sync gets
135
+ an unlocked Stripe FX Quote and converts recurring Prices with `rate_details.reference_rate`,
136
+ rounding minor units up. The resolved amount and currency are fingerprinted: an unchanged rounded
137
+ amount makes no product/price calls, while a changed amount deactivates the old Price and creates
138
+ a replacement. Existing subscriptions keep their accepted Price and settlement amount.
139
+ - `GatewayService.estimatePrice(ctx, { entityId, productSku, planSku?, country? })` (`estimatePrice`
140
+ in `plugins/estimate.ts`) is a Stripe Tax calculation (plus, with `currency.estimate` and
141
+ `currency.adaptive` both on, an FX Quotes lookup) for one product/plan's reference amount, at a
142
+ billing country the request names or the entity's paygate customer's. **$0.05 per distinct**
143
+ (currency, country, amount, tax code, behavior, matching tax ids) **combination** — cached per
144
+ gateway-service instance (never module-level: several instances in one process, as in tests, must
145
+ never share hits) for 24h; a rate-limit or connection error is never cached. Its status
146
+ (`TaxEstimateStatus`) covers a resolved rate, EU/GB reverse charge (a saved tax id whose OWN
147
+ country matches the one being estimated), no tax, "compute at checkout" (an unsupported
148
+ jurisdiction, or an invalid-request error such as a US address with no postal code), and
149
+ "choose a country" (neither the request nor the customer names one — zero Stripe calls). The FX
150
+ Quotes call is a Stripe PREVIEW endpoint (`STRIPE_FX_QUOTES_API_VERSION`, `stripe.rawRequest`),
151
+ and its failure only drops the estimate's `local` field, never the tax half. With a distinct
152
+ settlement currency, the local estimate composes catalogue→settlement `reference_rate` with the
153
+ fee-inclusive local→settlement `exchange_rate`, matching the Checkout conversion chain.
33
154
 
34
- ## Checkout modes
155
+ ## The subscription store
35
156
 
36
- - `Amount`: validate `amountMinor`, compute `chargeAmountMinor`, create one inline
37
- `price_data` item for the synchronized Stripe Product, quantity 1, tax-exclusive, with no
38
- adjustable quantity and no promotion codes. No reusable Stripe Price is created; a superseded
39
- quantity Price under the plan lookup key is deactivated.
40
- - `Quantity`: load the reusable Stripe Price and create an adjustable item from the quantity
41
- policy. This remains supported so existing consumers and already-open Checkout sessions work.
42
- - Subscription: reusable recurring Stripe Price, quantity 1, with billing portal management.
157
+ - **The effective plan** is the highest-ranked row in `ENTITLING_STATUSES` (catalogue rank; the
158
+ newest on a tie), else the declared free plan, else `PlanRequired`. An internal row with a past
159
+ `periodEnd` no longer entitles; a row naming a plan the catalogue lost is skipped (logged once).
160
+ - `mapStatus`: `active`→Active, `trialing`→Trial, `past_due`→PastDue (entitled, flagged),
161
+ `unpaid`/`paused`→Suspended, `incomplete`→Created, `incomplete_expired`→Ended, `canceled`→Canceled.
162
+ `pause_collection` on an active or trialing subscription is Suspended with `pausedAt`.
163
+ - **State is written before observers, and classified against what observers were last told.**
164
+ `propagated` holds the last propagated `{planSku, rank, status, cancelAtPeriodEnd, pausedAt,
165
+ renewedInvoiceId}`; it and `lastEventId` are stamped only after every observer succeeded. An
166
+ observer that throws therefore sees the same change again when Stripe retries, while an observer
167
+ that reads entitlements already sees the new plan.
168
+ - Classification, first match: `created` (the first propagated state that entitles — an
169
+ `incomplete` subscription reports nothing yet) · `canceled` · `paused` · `resumed` (pause cleared,
170
+ or suspended → entitling) · `upgraded`/`downgraded` by rank on a plan change (equal rank reads
171
+ `upgraded`) · `cancel-scheduled` · `cancel-undone` · `renewed` (a paid cycle invoice, once per
172
+ invoice) · `past-due` · `suspended` · `trial-ending` · otherwise nothing is reported.
173
+ - A repeated delivery (`lastEventId`) is ignored, and a webhook payload older than the stored state
174
+ (`event.created` before `syncedAt`) is not applied. State Stripe is asked for (invoice events,
175
+ resync) is always fresh.
176
+ - `grantInternalPlan(ctx, entityId, planSku, { force?, periodEnd? })` upserts an internal row,
177
+ keeps its `createdAt` (what promos are grandfathered against), and reports `created` once. A
178
+ plan that is not free needs `force`.
179
+ - `resyncSubscription(ctx, { entityId | subscriptionId })` retrieves and applies each subscription
180
+ exactly as a webhook would; one Stripe no longer has is canceled. `resyncAll` covers every
181
+ non-terminal Stripe row. Both answer how many rows changed.
43
182
 
44
- Every session enables automatic tax, billing address and tax-id collection. Amount metadata carries
45
- pricing mode, net amount, adjusted pre-tax subtotal, currency, product, plan, service and owner.
183
+ ## The usage ledger
46
184
 
47
- ## Fulfillment
185
+ `payment-usage` events are the source of truth; `payment-usage-counter` is the projection that
186
+ admission reads.
48
187
 
49
- Handle both `checkout.session.completed` and `checkout.session.async_payment_succeeded`, but grant
50
- nothing until `payment_status === 'paid'`. Amount fulfillment compares Stripe currency and actual
51
- `amount_subtotal` with the server-created metadata. Completion is discriminated:
188
+ - **`consume({ entityId, limitKey, eventKey, amount?, ref?, reason? })`** — the counter is
189
+ incremented FIRST, by one conditional upsert that matches only while `used <= limit - amount`,
190
+ then the event is appended. No room ⇒ `LimitExhausted` with `used`, `limit`, `resetsAt`, and no
191
+ event. **Invariant: the counter may over-count, never over-admit.**
192
+ - **An event key is idempotent.** A key already written replays its outcome (`replayed: true`)
193
+ before admission is asked — at the ceiling too; a concurrent duplicate that loses the unique event
194
+ undoes its increment and replays the winner. A released key stays released.
195
+ - Key a consumption by the record it pays for (`eventKey: <purpose>:<recordId>`, `ref: recordId`).
196
+ `consumption(entityId, limitKey, eventKey)` answers whether that event still holds a unit;
197
+ `consumptionByRef(entityId, limitKey, ref)` answers the newest active event of a record — for a
198
+ unit acquired under a fresh key per cycle.
199
+ - **`release`** appends `release:<eventKey>` first, decrements only when that append was new (never
200
+ below zero), then stamps `releasedAt`. The default amount is the consumed amount.
201
+ - A lapsed promo makes the ceiling `0`; an undeclared key is `LimitUnknown`.
202
+ - **`reconcileCounters(entityId?)`** recomputes every counter from the ledger sum, deletes past
203
+ day/month counters with no events (never lifetime or occupancy), and creates the current window
204
+ counter of every window limit.
205
+ - **`reconcileOccupancy(entityId, key, actual)`** sets an occupancy counter to the live count and
206
+ keeps the ledger equal to it with one adjusting event per UTC day (`reconcile:<key>:<YYYY-MM-DD>`,
207
+ adjusted in place by a later run that day). `overSince` is set when over the limit and cleared
208
+ within it. It never stops anything — what happens to an entity over its limit is the
209
+ application's decision.
210
+ - `reconcileEntity(ctx, entityId, { freePlanSku?, resync? })` and `reconcileAll(ctx, { freePlanSku?,
211
+ resync?, entities? })` combine the optional Stripe resync, the free-plan backfill and the counter
212
+ repair; a failing entity is counted, never fatal.
52
213
 
53
- - `{ mode: 'amount', amountMinor, chargeAmountMinor, currency, ... }`
54
- - `{ mode: 'quantity', units, ... }`
214
+ ## Entitlements and gates
55
215
 
56
- The session id is `externalId`. A pending fulfillment record is created before calling observers
57
- and gets `fulfilledAt` only after they succeed; observer failures escape so Stripe retries. Consumer
58
- callbacks must still use `externalId` as their own append-only event key, covering a crash after the
59
- side effect and before `fulfilledAt`. A session without pricing-mode metadata is a legacy quantity
60
- session and remains fulfillable.
216
+ - `entitlements(ctx)` → `effectivePlan`, `entitlements` (the `EntitlementView`, with
217
+ `plan.subscribedAt` = the subscription's `createdAt`), `hasCapability`, `limitState`, the ledger
218
+ operations above. It takes no context argument and never calls Stripe.
219
+ - **Capability gate** (`makeCapabilityGate(alias = ENTITLEMENT_GATE, { productSkus?, resolveEntity?,
220
+ requirePermission? })`, also `makeEntitlementGate`): authentication and an entity are required
221
+ (`AuthForbidden`); it passes when the view grants ANY parameter. The plan is the authority — a
222
+ token permission set to `false` denies, a token grant alone never allows, and `requirePermission`
223
+ (IAM `hasPermission`) is off by default because platform tokens carry no permissions. An
224
+ unreadable store refuses. Refusal: `CapabilityRequired(params)`.
225
+ - **Limit gate** (`makeLimitGate(alias = LIMIT_GATE, { resolveEntity? })`): passes when ANY
226
+ `limit:<key>[>=n]` has `remaining >= n`; malformed and undeclared parameters are skipped, a store
227
+ error refuses. Refusal: `LimitExhausted` for the first declared key. **It never consumes.**
228
+ - The two gates are two aliases because an entrypoint's gates are collected per gate service.
229
+ `entitlementsOf(ctx, entityId, productSkus?)` returns the capability sets in force.
61
230
 
62
- Subscription observers receive `{ isNew, status, capabilities, limits, ... }`. One-time bundles go
63
- behind `isNew` and use an idempotent external event key. Status updates and entitlement provisioning
64
- must be safe to repeat.
231
+ ## Stripe self-management
232
+
233
+ Runs in `initialize()` of a managed gateway, after the context is ready; each step is independent
234
+ and logged when it fails.
235
+
236
+ - **Products and prices** of plans sold through Stripe, fingerprinted per product. An amount plan
237
+ owns no reusable price.
238
+ - **The portal configuration** (`ensurePortalConfiguration`): customer update (email, address, tax
239
+ id), invoice history, payment method update, cancellation at period end without proration, and
240
+ price switching between every product's active recurring prices with prorations — both
241
+ subscription features off when nothing recurring is sold. `portalBranding` supplies the business
242
+ profile and default return URL; fingerprint `portal:<service>` holds its id, and its hash covers
243
+ the catalogue (including the declared tax behavior — a price `sync.ts` replaces refreshes the
244
+ portal's `products[].prices` too), the branding and the deployment key, so an unchanged
245
+ declaration makes no call.
246
+ - **Each deployment owns its own portal configuration**, tagged
247
+ `{ owlmeans: 'payment', service, deployment: webhookUrlOf(ctx) }` (`STRIPE_DEPLOYMENT_KEY`) — the
248
+ webhook URL keys it even when undeliverable (local). The configuration the fingerprint row names
249
+ is retrieved and updated, unless its metadata tags it for another deployment or service, which is
250
+ never overwritten. Without a usable row, only an active configuration tagged with exactly this
251
+ service and deployment key is adopted (how a forced `resync`, which clears fingerprints, finds its
252
+ own again); a configuration carrying only the service label is not. Stripe cannot delete portal
253
+ configurations, so one a deployment can no longer identify stays behind and a new one is created.
254
+ - **`portalLink(ctx, entityId, { flow, planSku?, returnUrl })`**: a customer is required
255
+ (`PortalUnavailable('customer')`); `Manage` opens the home, `PaymentMethod` the payment form;
256
+ `Cancel`, `Update` and `Change` need an entitling Stripe subscription
257
+ (`PortalUnavailable('subscription')`), and `Change` confirms its stored item switching to
258
+ `planSku`'s price as exactly one item. Deep links return with `after_completion: redirect`.
259
+ `PortalUnavailable` declares 409, so a portal asked of an entity with nothing to manage answers
260
+ 409 Conflict, never 500.
261
+ - **The webhook endpoint** (`ensureWebhookEndpoint`): at `webhookUrlOf(ctx)` — this service's
262
+ public URL plus the webhook route — on the API version read back from the client
263
+ (`apiVersionOf`, the SDK default; never a literal), subscribed to `WEBHOOK_EVENTS`. A URL that is
264
+ not https on a public dotted host is skipped. An unchanged `{url, apiVersion, events}` hash makes
265
+ no call; `force` (the `resync` route) first verifies the stored endpoint exists and recreates one
266
+ deleted from outside; changed events update in place; a changed API version deletes and recreates
267
+ (the version is create-only); an endpoint already at this exact URL that no row names is replaced
268
+ (its secret is unknowable). The secret — returned only by create — is stored field-encrypted where
269
+ the database has a key.
270
+ - **A deployment is its webhook URL, and deletes only what it remembers.** Deployments of one
271
+ service may share a Stripe account, each with its own database and URL. A `payment-webhook` row of
272
+ the same paygate and service at another URL is a URL this deployment has left: after the current
273
+ endpoint is in place, the endpoint that row names is deleted (already gone is fine) and the row
274
+ removed. An endpoint at another URL that no row names belongs to another deployment and is never
275
+ deleted; the `{ owlmeans: 'payment', service }` metadata on created endpoints is an operator's
276
+ label, never deletion authority. A retired deployment's endpoint is removed by hand. The portal
277
+ configuration follows the same identity (above).
278
+ - **Signature verification** tries the configured `webhook` override, then the stored secret
279
+ (`stripeWebhookSecrets`); none configured is `WebhookSetupError('secret')`.
280
+ - **Do not bump the Stripe SDK major**: the pinned API version reads `current_period_*`,
281
+ `invoice.subscription` and `charge.invoice` at the top level, and they move in later versions.
282
+
283
+ ## Event dispatch
284
+
285
+ | Event | Persisted | Observer · event key |
286
+ |---|---|---|
287
+ | `customer.created` / `.updated` | customer upsert | — |
288
+ | `customer.deleted` | customer `deletedAt` | — |
289
+ | `checkout.session.completed` / `.async_payment_succeeded` | paid payment session ⇒ fulfillment, `fulfilledAt` after observers | `onTopUp` · `externalId` (session id) |
290
+ | `checkout.session.async_payment_failed` | fulfillment `failedAt` | `onPaymentFailed {kind:'checkout'}` · `payment-failed:<session>:0` |
291
+ | `checkout.session.expired` | unfulfilled fulfillment purged | — |
292
+ | `customer.subscription.created` / `.updated` / `.pending_update_applied` / `.pending_update_expired` / `.paused` / `.resumed` | subscription applied | `onSubscription` · classified |
293
+ | `customer.subscription.deleted` | applied as Canceled + `endedAt` | `canceled` |
294
+ | `customer.subscription.trial_will_end` | applied | `trial-ending` |
295
+ | `invoice.paid` | cycle invoice ⇒ subscription re-read, applied as a renewal; otherwise `latestInvoiceId` | `renewed` |
296
+ | `invoice.payment_failed` / `.payment_action_required` | subscription re-read and applied | classified (`past-due`), then `onPaymentFailed {kind:'invoice', attempt, nextAttemptAt, actionRequired}` · `payment-failed:<invoice>:<attempt>` |
297
+ | `invoice.upcoming` | nothing (enabled for the application's own use) | — |
298
+ | `invoice.marked_uncollectible` | subscription re-read and applied | classified (`suspended`) |
299
+ | `invoice.voided` | `latestInvoiceId` refreshed | — |
300
+ | `charge.refunded` (each refund of the charge), `refund.created` / `.updated` (succeeded only) | fulfillment `refundedMinor` / `refundedAt` | `onRefund` · `refund:<refund>` |
301
+ | `refund.failed` | — | — |
302
+ | `charge.dispute.created` / `.funds_withdrawn` / `.funds_reinstated` / `.closed` | target `disputedAt`, `disputeStatus` | `onDispute {phase}` · `dispute:<dispute>:<phase>` |
303
+
304
+ A refund or dispute resolves to its target by payment intent (fulfillment), by charge (stored
305
+ charge, the charge's payment intent, else its invoice), by invoice (the subscription whose latest
306
+ invoice it is, else the invoice's subscription). Nothing resolved ⇒ logged, no observer.
307
+
308
+ ## Observer API and idempotency keys
309
+
310
+ Callbacks run sequentially and are awaited; a throw escapes so Stripe redelivers. Every callback
311
+ must be idempotent by its key.
312
+
313
+ | Callback | Payload | Key |
314
+ |---|---|---|
315
+ | `onTopUp` | `TopUpCompletion` (`amount` / `quantity`) | `externalId` — the session id |
316
+ | `onSubscription` | `SubscriptionEvent {change, previous, current, active, eventKey, invoiceId?, externalEventId?}`; snapshots carry `rank`, `status`, period, `capabilities`, `limits` | `subscription:<id>:created:<createdAt ISO>` · `…:renewed:<invoice>` · `…:upgraded|downgraded:<planSku>` · `…:trial-ending:<trialEnd ISO>` · `…:<change>:<event id>` (`sync-<ISO>` from a resync) |
317
+ | `onRefund` | `RefundEvent {target, amountMinor, refundedTotalMinor, paidMinor?, partial, netAmountMinor?, chargeAmountMinor?, …}` | `refund:<refund>` |
318
+ | `onDispute` | `DisputeEvent {phase, status, amountMinor, …}` | `dispute:<dispute>:<phase>` |
319
+ | `onPaymentFailed` | `PaymentFailedEvent {kind, attempt?, nextAttemptAt?, actionRequired?, …}` | `payment-failed:<session|invoice>:<attempt>` |
320
+
321
+ A proportional claw-back of a top-up uses the net credited value against what was paid:
322
+ `netAmountMinor * refundedTotalMinor / paidMinor`.
65
323
 
66
324
  ## Protocols and security
67
325
 
68
- `paymentGate` is an immutable protocol tree. `paymentGateEntrypoints` binds its base, webhook, and
69
- resync declarations directly with `bind(protocol, handler)`; the webhook is public because Stripe
70
- signature verification requires the untouched raw body, while resync carries the ED25519 guard.
71
- Never put an application auth guard on the Stripe webhook.
326
+ `paymentGate` is an immutable protocol tree bound by `paymentGateEntrypoints` with
327
+ `bind(protocol, handler)`: `webhook` (`POST /payment-gate/webhook/:paygate`) is public because
328
+ Stripe signs the untouched raw body — never put an application guard on it; `resync` (products,
329
+ portal, webhook endpoint, fingerprints ignored) and `resyncSubscriptions` (`{ scanned, updated }`)
330
+ carry `GUARD_ED25519`, so another service of the deployment triggers them with its own key.
72
331
 
73
- `appendPaymentGatewayService` also registers `ENTITLEMENT_GATE`. Paid capabilities belong on shared
74
- route declarations through `entitled(...)`, not in handlers. The gate fails closed when the
75
- subscription store cannot be read.
332
+ ## Testing
333
+
334
+ Unit specs run a real server context — real catalogue, services and gates — over in-memory
335
+ resources and a fake Stripe that records every SDK call, so "no Stripe call" is an assertion. The
336
+ Mongo-gated spec proves admission under concurrency and the collection validators against a real
337
+ database.
76
338
 
77
339
  ## External docs
78
340
 
79
341
  - https://docs.stripe.com/api/checkout/sessions/create — Checkout accepts inline `price_data` with integer minor-unit `unit_amount`; automatic tax is enabled on the Session and amount items are tax-exclusive.
342
+ - https://docs.stripe.com/receipts — successful-payment emails are a Dashboard setting; subscriptions produce paid invoices automatically, one-time Checkout needs `invoice_creation.enabled`, and a Customer’s `preferred_locales` localizes supported Stripe templates.
80
343
  - https://docs.stripe.com/checkout/fulfillment — Fulfillment must be idempotent, check payment state and support delayed-payment success events rather than trusting completion alone.
344
+ - https://docs.stripe.com/api/webhook_endpoints/create — the signing `secret` is returned only by create; update accepts `enabled_events`, `disabled`, `url`, `description`, `metadata`; `api_version` is create-only.
345
+ - https://docs.stripe.com/api/events/types — the event names `WEBHOOK_EVENTS` subscribes to.
346
+ - https://docs.stripe.com/api/subscriptions/object — statuses `incomplete|incomplete_expired|trialing|active|past_due|canceled|unpaid|paused`; `pause_collection` pauses collection without changing the status; `paused` only after a trial without a payment method; on `2025-02-24.acacia` (stripe-node 17) `current_period_start/end` and `invoice.subscription` are top-level and move in later versions.
347
+ - https://docs.stripe.com/customer-management/portal-deep-links and https://docs.stripe.com/api/customer_portal/sessions/create — `flow_data.type` ∈ `payment_method_update|subscription_cancel|subscription_update|subscription_update_confirm`; `subscription_update_confirm.items` holds exactly one `{ id: <subscription item id>, price, quantity }`; `after_completion` is `redirect|hosted_confirmation|portal_homepage`; the configuration must enable `subscription_update` (with `products[{product, prices[]}]`) and `subscription_cancel`.
348
+ - https://docs.stripe.com/api/customer_portal/configurations/create — `features.{customer_update, invoice_history, payment_method_update, subscription_cancel{enabled, mode, proration_behavior}, subscription_update{enabled, default_allowed_updates, products, proration_behavior}}`, `business_profile`, `default_return_url`, `metadata`; updatable by id, retrievable and listable (`active`, paginated), and never deletable — a configuration can only be deactivated.
349
+ - https://docs.stripe.com/api/tax/calculations/create — `line_items[].tax_behavior` (default `exclusive`), `tax_code`; `customer_details.address_source` ∈ `billing|shipping`; `tax_ids[]` shifts liability (a valid id is never validated for correctness); the response's `tax_breakdown[].tax_rate_details.percentage_decimal` is a STRING (parse it exactly, never `Number(x) * 10_000`) and `.rate_type` ∈ `flat_amount|percentage` (a flat rate never scales with the amount).
350
+ - https://docs.stripe.com/tax/products-prices-tax-codes-tax-behavior — a price's `tax_behavior` can be set only from `unspecified`; once `exclusive`/`inclusive` it cannot change, and `inferred_by_currency` (an account tax-settings default) resolves to exclusive for USD/CAD, inclusive otherwise.
351
+ - https://docs.stripe.com/payments/currencies/localize-prices/adaptive-pricing — `adaptive_pricing.enabled` on a Checkout Session; requires the price currency to be a settlement currency; Session/PaymentIntent/webhook amounts stay in the integration currency, with `presentment_details.{presentment_amount, presentment_currency}` alongside them when the buyer paid differently.
352
+ - https://docs.stripe.com/billing/subscriptions/klarna — Checkout can save Klarna for recurring subscription charges when the account and buyer are eligible.
353
+ - https://docs.stripe.com/payments/blik — BLIK is single-use and does not support recurring payments.
354
+ - https://docs.stripe.com/api/fx_quotes/create — a **preview** endpoint (needs a preview `Stripe-Version`, called via `stripe.rawRequest`); `to_currency`/`from_currencies[]`; `lock_duration: 'none'` is free, `five_minutes|hour|day` add a fee (`rate_details.duration_premium`) baked into `exchange_rate`; settlement conversion uses `rate_details.reference_rate`, while local-presentment estimates use fee-inclusive `exchange_rate`.
355
+
356
+ ## Related
357
+
358
+ - [[entitlements]] — the model across packages
359
+ - [[payment]] — the contracts: grammars, window algebra, views, refusals
360
+ - [[web-payment]] — hooks and pieces over the entitlement view
361
+ - [[mongo-resource]] — raw collection access, duplicate-key detection, field locking
@@ -1,3 +1,4 @@
1
1
  export * from './webhook.js';
2
2
  export * from './resync.js';
3
+ export * from './resync-subscriptions.js';
3
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/actions/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/actions/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,2BAA2B,CAAA"}
@@ -1,3 +1,4 @@
1
1
  export * from './webhook.js';
2
2
  export * from './resync.js';
3
+ export * from './resync-subscriptions.js';
3
4
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/actions/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/actions/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,aAAa,CAAA;AAC3B,cAAc,2BAA2B,CAAA"}
@@ -0,0 +1,3 @@
1
+ /** Re-read every live paygate subscription — the manually or periodically triggered repair. */
2
+ export declare const resyncSubscriptions: import("@owlmeans/server-entrypoint").BoundEntrypointHandler<import("@owlmeans/entrypoint").EntrypointProtocol<{}, import("../consts.js").ResyncSubscriptionsResult>>;
3
+ //# sourceMappingURL=resync-subscriptions.d.ts.map