@owlmeans/server-payment 0.1.18-rc.10 → 0.1.18-rc.12

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