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

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 (222) 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 +475 -52
  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 +60 -5
  20. package/build/config.d.ts.map +1 -1
  21. package/build/config.js +237 -5
  22. package/build/config.js.map +1 -1
  23. package/build/consts.d.ts +75 -3
  24. package/build/consts.d.ts.map +1 -1
  25. package/build/consts.js +94 -5
  26. package/build/consts.js.map +1 -1
  27. package/build/consumer/capture.d.ts +74 -0
  28. package/build/consumer/capture.d.ts.map +1 -0
  29. package/build/consumer/capture.js +291 -0
  30. package/build/consumer/capture.js.map +1 -0
  31. package/build/consumer/format.d.ts +27 -0
  32. package/build/consumer/format.d.ts.map +1 -0
  33. package/build/consumer/format.js +81 -0
  34. package/build/consumer/format.js.map +1 -0
  35. package/build/consumer/handlers.d.ts +28 -0
  36. package/build/consumer/handlers.d.ts.map +1 -0
  37. package/build/consumer/handlers.js +173 -0
  38. package/build/consumer/handlers.js.map +1 -0
  39. package/build/consumer/index.d.ts +7 -0
  40. package/build/consumer/index.d.ts.map +1 -0
  41. package/build/consumer/index.js +6 -0
  42. package/build/consumer/index.js.map +1 -0
  43. package/build/consumer/mail.d.ts +27 -0
  44. package/build/consumer/mail.d.ts.map +1 -0
  45. package/build/consumer/mail.js +314 -0
  46. package/build/consumer/mail.js.map +1 -0
  47. package/build/consumer/origin.d.ts +14 -0
  48. package/build/consumer/origin.d.ts.map +1 -0
  49. package/build/consumer/origin.js +47 -0
  50. package/build/consumer/origin.js.map +1 -0
  51. package/build/consumer/reconcile.d.ts +12 -0
  52. package/build/consumer/reconcile.d.ts.map +1 -0
  53. package/build/consumer/reconcile.js +317 -0
  54. package/build/consumer/reconcile.js.map +1 -0
  55. package/build/consumer/records.d.ts +78 -0
  56. package/build/consumer/records.d.ts.map +1 -0
  57. package/build/consumer/records.js +296 -0
  58. package/build/consumer/records.js.map +1 -0
  59. package/build/consumer/service.d.ts +51 -0
  60. package/build/consumer/service.d.ts.map +1 -0
  61. package/build/consumer/service.js +760 -0
  62. package/build/consumer/service.js.map +1 -0
  63. package/build/consumer/withdrawal.d.ts +57 -0
  64. package/build/consumer/withdrawal.d.ts.map +1 -0
  65. package/build/consumer/withdrawal.js +247 -0
  66. package/build/consumer/withdrawal.js.map +1 -0
  67. package/build/entitlement.d.ts +10 -0
  68. package/build/entitlement.d.ts.map +1 -0
  69. package/build/entitlement.js +69 -0
  70. package/build/entitlement.js.map +1 -0
  71. package/build/entrypoints.d.ts +1 -1
  72. package/build/entrypoints.d.ts.map +1 -1
  73. package/build/entrypoints.js +2 -1
  74. package/build/entrypoints.js.map +1 -1
  75. package/build/gate.d.ts +32 -4
  76. package/build/gate.d.ts.map +1 -1
  77. package/build/gate.js +59 -19
  78. package/build/gate.js.map +1 -1
  79. package/build/index.d.ts +18 -3
  80. package/build/index.d.ts.map +1 -1
  81. package/build/index.js +15 -3
  82. package/build/index.js.map +1 -1
  83. package/build/limit.d.ts +13 -0
  84. package/build/limit.d.ts.map +1 -0
  85. package/build/limit.js +47 -0
  86. package/build/limit.js.map +1 -0
  87. package/build/model.d.ts +10 -1
  88. package/build/model.d.ts.map +1 -1
  89. package/build/model.js +215 -17
  90. package/build/model.js.map +1 -1
  91. package/build/observer.d.ts +9 -0
  92. package/build/observer.d.ts.map +1 -1
  93. package/build/observer.js +38 -12
  94. package/build/observer.js.map +1 -1
  95. package/build/plan.d.ts +22 -0
  96. package/build/plan.d.ts.map +1 -0
  97. package/build/plan.js +72 -0
  98. package/build/plan.js.map +1 -0
  99. package/build/plugins/checkout-plugins.d.ts +49 -0
  100. package/build/plugins/checkout-plugins.d.ts.map +1 -0
  101. package/build/plugins/checkout-plugins.js +124 -0
  102. package/build/plugins/checkout-plugins.js.map +1 -0
  103. package/build/plugins/estimate.d.ts +43 -0
  104. package/build/plugins/estimate.d.ts.map +1 -0
  105. package/build/plugins/estimate.js +268 -0
  106. package/build/plugins/estimate.js.map +1 -0
  107. package/build/plugins/events.d.ts +45 -10
  108. package/build/plugins/events.d.ts.map +1 -1
  109. package/build/plugins/events.js +605 -136
  110. package/build/plugins/events.js.map +1 -1
  111. package/build/plugins/fx.d.ts +31 -0
  112. package/build/plugins/fx.d.ts.map +1 -0
  113. package/build/plugins/fx.js +81 -0
  114. package/build/plugins/fx.js.map +1 -0
  115. package/build/plugins/portal.d.ts +37 -0
  116. package/build/plugins/portal.d.ts.map +1 -0
  117. package/build/plugins/portal.js +265 -0
  118. package/build/plugins/portal.js.map +1 -0
  119. package/build/plugins/refunds.d.ts +33 -0
  120. package/build/plugins/refunds.d.ts.map +1 -0
  121. package/build/plugins/refunds.js +80 -0
  122. package/build/plugins/refunds.js.map +1 -0
  123. package/build/plugins/stripe.d.ts +36 -4
  124. package/build/plugins/stripe.d.ts.map +1 -1
  125. package/build/plugins/stripe.js +492 -97
  126. package/build/plugins/stripe.js.map +1 -1
  127. package/build/plugins/webhook-manager.d.ts +46 -0
  128. package/build/plugins/webhook-manager.d.ts.map +1 -0
  129. package/build/plugins/webhook-manager.js +231 -0
  130. package/build/plugins/webhook-manager.js.map +1 -0
  131. package/build/reconcile.d.ts +15 -0
  132. package/build/reconcile.d.ts.map +1 -0
  133. package/build/reconcile.js +88 -0
  134. package/build/reconcile.js.map +1 -0
  135. package/build/resource.d.ts +12 -1
  136. package/build/resource.d.ts.map +1 -1
  137. package/build/resource.js +97 -6
  138. package/build/resource.js.map +1 -1
  139. package/build/service.d.ts +28 -3
  140. package/build/service.d.ts.map +1 -1
  141. package/build/service.js +146 -19
  142. package/build/service.js.map +1 -1
  143. package/build/subscription.d.ts +48 -0
  144. package/build/subscription.d.ts.map +1 -0
  145. package/build/subscription.js +176 -0
  146. package/build/subscription.js.map +1 -0
  147. package/build/sync.d.ts +28 -1
  148. package/build/sync.d.ts.map +1 -1
  149. package/build/sync.js +208 -31
  150. package/build/sync.js.map +1 -1
  151. package/build/types.d.ts +1115 -36
  152. package/build/types.d.ts.map +1 -1
  153. package/build/usage.d.ts +63 -0
  154. package/build/usage.d.ts.map +1 -0
  155. package/build/usage.js +363 -0
  156. package/build/usage.js.map +1 -0
  157. package/build/utils.d.ts +52 -1
  158. package/build/utils.d.ts.map +1 -1
  159. package/build/utils.js +69 -7
  160. package/build/utils.js.map +1 -1
  161. package/package.json +17 -13
  162. package/src/actions/index.ts +1 -0
  163. package/src/actions/resync-subscriptions.ts +10 -0
  164. package/src/actions/resync.ts +6 -3
  165. package/src/actions/webhook.ts +5 -3
  166. package/src/config.ts +264 -8
  167. package/src/consts.ts +114 -6
  168. package/src/consumer/capture.ts +362 -0
  169. package/src/consumer/format.ts +90 -0
  170. package/src/consumer/handlers.ts +211 -0
  171. package/src/consumer/index.ts +6 -0
  172. package/src/consumer/mail.ts +368 -0
  173. package/src/consumer/origin.ts +63 -0
  174. package/src/consumer/reconcile.ts +329 -0
  175. package/src/consumer/records.ts +374 -0
  176. package/src/consumer/service.ts +868 -0
  177. package/src/consumer/withdrawal.ts +302 -0
  178. package/src/entitlement.ts +84 -0
  179. package/src/entrypoints.ts +2 -1
  180. package/src/gate.ts +88 -21
  181. package/src/index.ts +24 -3
  182. package/src/limit.ts +57 -0
  183. package/src/model.ts +237 -18
  184. package/src/observer.ts +44 -11
  185. package/src/plan.ts +89 -0
  186. package/src/plugins/checkout-plugins.ts +155 -0
  187. package/src/plugins/estimate.ts +339 -0
  188. package/src/plugins/events.ts +677 -121
  189. package/src/plugins/fx.ts +122 -0
  190. package/src/plugins/portal.ts +306 -0
  191. package/src/plugins/refunds.ts +108 -0
  192. package/src/plugins/stripe.ts +581 -96
  193. package/src/plugins/webhook-manager.ts +270 -0
  194. package/src/reconcile.ts +103 -0
  195. package/src/resource.ts +152 -7
  196. package/src/service.ts +174 -18
  197. package/src/subscription.ts +231 -0
  198. package/src/sync.ts +249 -29
  199. package/src/types.ts +1227 -32
  200. package/src/usage.ts +453 -0
  201. package/src/utils.ts +127 -10
  202. package/tests/checkout-consumer.spec.ts +348 -0
  203. package/tests/checkout-plugins.spec.ts +164 -0
  204. package/tests/checkout.spec.ts +184 -83
  205. package/tests/consumer-events.spec.ts +218 -0
  206. package/tests/consumer-fixtures.ts +132 -0
  207. package/tests/consumer-ops.spec.ts +351 -0
  208. package/tests/consumer-rights.integration.spec.ts +150 -0
  209. package/tests/consumer-rights.spec.ts +501 -0
  210. package/tests/context.ts +109 -0
  211. package/tests/entitlement.spec.ts +103 -0
  212. package/tests/estimate.spec.ts +240 -0
  213. package/tests/events.spec.ts +356 -0
  214. package/tests/fake-stripe.ts +972 -0
  215. package/tests/gate.spec.ts +62 -72
  216. package/tests/limit-gate.spec.ts +68 -0
  217. package/tests/portal.spec.ts +200 -0
  218. package/tests/protocol.spec.ts +23 -6
  219. package/tests/sync.spec.ts +114 -0
  220. package/tests/usage.integration.spec.ts +101 -0
  221. package/tests/usage.spec.ts +171 -0
  222. package/tests/webhook-manager.spec.ts +152 -0
@@ -1,80 +1,503 @@
1
1
  ---
2
2
  name: server-payment
3
- description: Public in-process Stripe gateway for OwlMeans backends — amount and quantity checkout, subscriptions, protocol-bound webhook routes, product sync, fulfillment observers and entitlement gates. Use when wiring @owlmeans/server-payment or changing Stripe payment behavior.
3
+ description: Public in-process Stripe gateway for OwlMeans backends — amount and quantity checkout, subscriptions, the checkout plugin seam (per-entity narrowing, admission, holds), protocol-bound webhook routes, product sync with per-currency prices, fulfillment observers, entitlement gates, and the EU consumer-rights service (country lock, purchases and withdrawal windows, spend consent, subscription start requests, the withdrawal and cancellation functions with automatic refunds and credit notes, durable-medium mails, reconcile). Use when wiring @owlmeans/server-payment or changing Stripe payment behavior.
4
4
  user-invocable: false
5
5
  ---
6
6
  <!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
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.21`
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, Stripe's own configuration
15
+ (products, prices, the portal, the webhook endpoint) and the EU consumer-rights records and
16
+ functions. The application owns its catalogue, what a purchase is worth to it (credits,
17
+ provisioning), its usage meter and its side effects. Contracts — plans, limits, promos, the
18
+ entitlement view, the consumer-rights views, calculators, copy and refusals — are
19
+ `@owlmeans/payment`; the model across packages is the `entitlements` skill. Field lists, the
20
+ service contract and the step-by-step algorithms are in `reference.md` in this skill folder.
13
21
 
14
22
  ## Wiring
15
23
 
16
24
  ```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 */ })
25
+ stripeSecrets(cfg, { api: '/secrets/stripe-key' }) // webhook secret: optional override
26
+ portalBranding(cfg, { returnUrl: 'https://app.example.com/billing', headline: 'Example' })
27
+ declarePaymentPricing(cfg, { // absent entirely: today's fixed behaviour, unchanged
28
+ tax: { automatic: true, behavior: TaxBehavior.Exclusive, collectTaxId: true, estimate: true },
29
+ currency: { adaptive: true, estimate: true },
30
+ stripe: { settlementCurrency: 'eur', subscriptionPaymentMethodTypes: ['card', 'link'] },
31
+ })
32
+ declareConsumerRights(cfg, { // absent: no consumer-rights behaviour at all
33
+ textVersion: 'terms-2026-09', links: { en: { billingTerms, withdrawalInformation, withdrawalFunction, cancellation } },
34
+ currencies: { eu: 'eur', other: 'usd' },
35
+ mechanisms: { countryLock: true, checkoutTerms: true, performanceConsent: true, subscriptionStart: true,
36
+ withdrawal: true, automaticRefunds: true, cancellation: true, purchaseConfirmation: true },
37
+ trader: { name: 'Example', legalName: 'Example Ltd', address: '…', email: 'support@example.com' },
38
+ mail: { from: 'billing@example.com', bcc: ['archive@example.com'] }, // alias: default MAILER_SERVICE
39
+ })
40
+ declarePaymentProduct(cfg, { sku: 'app-plans', type: ProductType.Service, services: ['app'], name: 'Plans' })
41
+ declarePaymentPlan(cfg, { productSku: 'app-plans', sku: 'free', rank: 0, free: true, price: 0, … })
42
+ declarePaymentPlan(cfg, { productSku: 'app-plans', sku: 'pro-monthly', rank: 10, price: 20,
43
+ recurring: { interval: 'month' }, currencyPrices: { usd: 20 },
44
+ withdrawal: { components: [{ key: 'services', basis: 'time', shareMinor: 1000 },
45
+ { key: 'credits', basis: 'units', shareMinor: 1000 }] }, capabilities: […] })
46
+
47
+ appendPaymentGatewayService(context) // the process that talks to Stripe
48
+ appendPaymentGatewayService(context, { manage: false }) // a worker that only reads entitlements and asserts consent
49
+ appendConsumerRights(context, { manage, usage: myUsageMeter }) // before or after the gateway; idempotent
50
+ consumerRights(context).useMailRenderer(myRenderer) // lazy service: works while wiring
51
+ gateway(context).use(myCheckoutPlugin) // a tier / cap / hold plugin — once the context is initialized
52
+ export const serverBindings = [
53
+ ...paymentGateEntrypoints,
54
+ ...consumerRightsEntrypoints(consumerProtocols, { guardMoney, throttle, subjectOf, planNameOf }),
55
+ ...checkoutReadEntrypoints(checkoutProtocols),
56
+ ]
57
+ observer(context).onSubscription(async event => { /* keyed by event.eventKey */ })
27
58
  ```
28
59
 
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.
60
+ - `appendPaymentGatewayService` registers twelve resources, the catalogue service
61
+ (`PAYMENT_SERVICE`), the completion observer, the gateway (`GATEWAY_SERVICE`), the capability
62
+ gate (`ENTITLEMENT_GATE`), the limit gate (`LIMIT_GATE`), the entitlement service
63
+ (`ENTITLEMENT_SERVICE`) and the consumer-rights service (`CONSUMER_RIGHTS_SERVICE`), each only
64
+ when not registered already. `appendConsumerRights` does the consumer-rights part alone.
65
+ - **Registration order is free.** Whichever of `appendConsumerRights` and the gateway comes first,
66
+ `usage` and `stripe` are installed on the one service, and `manage` resolves as the application's
67
+ explicit value, else the gateway's, else managed. The consumer-rights resources keep the aliases
68
+ of the first registration.
69
+ - **The consumer-rights service is lazy** (like the completion observer): `consumerRights(ctx)`,
70
+ `useMeter` and `useMailRenderer` work while the application is wired; the gateway's initialization
71
+ initializes it, so its boot checks still run at boot. The gateway is NOT lazy: `gateway(ctx)` (and
72
+ `.use(plugin)`) needs the initialized context.
73
+ - **`manage: false`** registers the same surface with no Stripe client: no bootstrap at init, and
74
+ `createLink`, `portalLink`, `resyncSubscription`, `resyncAll`, the webhook route, `withdraw` and
75
+ `cancel` throw `PaygateError('unmanaged')`. `grantInternalPlan`, the entitlement service,
76
+ `amountPolicy`, `planPrices` and the consumer-rights reads, `recordConsent`,
77
+ `recordStartRequest` and `assertConsent` work, because they are Mongo only.
78
+ - Every process that registers the gateway runs the collection validators (`collMod`) of all
79
+ twelve records at init: a process of an older version narrows the validators again, so roll
80
+ the processes of one deployment out together.
81
+ - Gateway methods take the stable `entityId`. A public handler resolves it from its request
82
+ entity first; protocol bodies carry `entitySlug`.
83
+ - A value in `stripeSecrets` / `portalBranding` that starts with `/` is read from that file at
84
+ boot, and a missing file fails the boot — so leave `webhook` out unless the file exists.
85
+
86
+ ## Declaring plans
87
+
88
+ - **`rank`** orders a product's plans; a higher rank is an upgrade. A safe integer `>= 0`; absent
89
+ reads as `0`.
90
+ - **The free plan is a plan**: `free: true`, `price: 0`, no `gateways`. It is never synchronized to
91
+ Stripe and never checked out; an entity without an entitling subscription resolves to it.
92
+ - `gateways` names the paygates a plan is sold through; absent inherits the product's.
93
+ - **`currencyPrices`** (`{ usd: 20 }`, major units, lowercase codes) are exact prices in further
94
+ currencies, synced as the reusable Price's `currency_options`; a declared price in the Price's
95
+ default currency replaces its converted amount. Only for recurring and quantity plans.
96
+ - **`withdrawal.components`** state the separately priced parts of a subscription for a withdrawal
97
+ (CJEU C-641/19): `{ key, basis: 'time' | 'units', shareMinor }`, the shares summing to
98
+ `round(price × 100)`. Absent: the whole price is one `time` component.
99
+ - `declarePaymentPlan` refuses before recording: a bad rank or a priced/gatewayed free plan
100
+ (`PlanRankConflict`), a capability set under the reserved `limit` scope, a malformed limit
101
+ (`LimitMisdeclared('<key>:<reason>')`), a malformed or amount-mode `currencyPrices` or components
102
+ that do not add up (`ProductError('currency-prices:…' | 'withdrawal:<sku>:…')`).
103
+ - `assertPlanDeclarations` runs when the gateway initializes and fails the boot on two free plans
104
+ at one rank, or two paid non-consumable plans of one product at one rank.
105
+ - A limit key may use a different kind on different plans; each kind counts separately and a
106
+ lifetime count stays with the entity through upgrades, downgrades and cancellations.
107
+
108
+ ## Records
109
+
110
+ None declares an ObjectId reference: `entityId` is an organization key and every other id is Stripe's.
111
+
112
+ | Collection | One row per | Indexes |
113
+ |---|---|---|
114
+ | `payment-paygate-customer` | Stripe customer (`country`, `currency` from its webhooks; `deletedAt`) | `{paygate, externalId}` unique · `{paygate, entityId}` · `{paygate, profileId}` |
115
+ | `payment-subscription` | subscription: `sub_…`, `free:<entityId>`, `internal:<planSku>:<entityId>` (+ `currency` and the checkout evidence) | `{paygate, externalId}` unique · `{entityId, status, rank:-1}` · `{entityId, planSku}` · `{paygate, customerId}` · `{paygate, itemId}` sparse · `{paygate, status, updatedAt}` |
116
+ | `payment-fulfillment` | one-time checkout session (+ evidence: country, e-mail, totals, terms, `purchaseId`) | `{paygate, externalId}` unique · `{paygate, paymentIntentId}` sparse · `{paygate, chargeId}` sparse · `{entityId, createdAt:-1}` |
117
+ | `payment-webhook` | managed webhook endpoint (`secret` is `secure: true`) | `{paygate, service, url}` unique |
118
+ | `payment-usage` | usage event — the ledger | `{entityId, limitKey, eventKey}` unique · `{entityId, limitKey, window}` · `{entityId, limitKey, ref}` sparse · `{entityId, createdAt:-1}` |
119
+ | `payment-usage-counter` | (entity, limit, window) projection | `{entityId, limitKey, window}` unique |
120
+ | `payment-fingerprint` | synchronized product (`<productSku>`, with `prices[]`) or portal (`portal:<service>`) | `{sku}` unique |
121
+ | `payment-billing-profile` | organization: the billing country fixed at the first purchase | `{entityId}` unique · `{paygate, customerId}` sparse |
122
+ | `payment-purchase` | purchase = contract + withdrawal window (a paid one-time checkout, a subscription's first invoice) | `{purchaseId}` unique · `{contractRef}` unique · `{entityId, deadline:-1}` · `{entityId, purchasedAt:-1}` · `{sessionId}` unique sparse · `{paygate, subscriptionId}` · `{invoiceId}` · `{invoiceNumber}` · `{paygate, paymentIntentId}` |
123
+ | `payment-consumer-consent` | append-only: a performance consent or a subscription start request | `{entityId, decidedAt:-1}` · `{kind, entityId, planSku, decidedAt:-1}` |
124
+ | `payment-consumer-declaration` | append-only: a withdrawal or cancellation declaration | `{kind, receivedAt:-1}` · `{entityId, receivedAt:-1}` sparse · `{purchaseId}` sparse |
125
+ | `payment-consumer-event` | append-only: one execution or audit step (mail, refund, lock …) | `{recordId, at}` · `{entityId, at:-1}` sparse · `{action, ok, at}` |
126
+
127
+ - Every stored property is declared in the record schema (`additionalProperties: false`): the
128
+ resource writes a property its schema does not know as a string, and the collection validator
129
+ rejects it. New fields on existing records are optional, so old rows stay valid.
130
+ - **A map is stored as an array** (`prices[].options: [{ currency, unitAmount }]`): the resource
131
+ coerces a currency-keyed map's values to strings.
132
+ - A compound sparse index still indexes a row that lacks only some of its keys — a unique index
133
+ that must skip absent values is single-field (`{sessionId}`).
134
+ - Conditional writes (`consentedAt`, `withdrawnAt`) are raw `$set`s guarded by the old value
135
+ (`conditionalSet`), so a concurrent declaration of the same purchase loses; declarations,
136
+ consents and events are only ever created.
137
+
138
+ ## Checkout
139
+
140
+ - `Amount`: one inline `price_data` item for the synchronized product, quantity 1, its
141
+ `tax_behavior` the declared `PricingPolicy.tax.behavior`, no promotion codes. `Quantity`: the
142
+ reusable price under the plan lookup key, adjustable quantity. Subscription: `planSku` (else the
143
+ product's first recurring plan), quantity 1. One-time Sessions enable `invoice_creation`.
144
+ - `CreateLinkParams.locale` is a caller-validated Stripe locale (Session `locale`, the Customer's
145
+ `preferred_locales`). `submitText` is trusted application copy — a string, or a function of
146
+ `CheckoutTextContext {language, currency, unitAmountMinor, interval, region, country}` — on
147
+ every mode; never caller-provided text.
148
+ - A plan the paygate does not sell is refused (`ProductError`). `checkoutOptions` puts automatic
149
+ tax, billing address collection, tax-id collection and Adaptive Pricing on the session exactly
150
+ as `PricingPolicy` declares them.
151
+ - **Without a consumer-rights policy every session is what it always was**: the charge currency
152
+ is the settlement currency (FX from the catalogue), Adaptive Pricing as declared.
153
+
154
+ ### Under a consumer-rights policy
155
+
156
+ - **The country.** A locked billing profile overrides `params.country`; another declared country is
157
+ `BillingCountryLocked` (409), and so is a saved customer address that left the locked country
158
+ (an operator relocks). An organization that already paid before its country was locked is
159
+ locked lazily from its Stripe customer's address (`source: 'customer'`). Before any lock, the
160
+ declared country is prefilled on a customer without an address.
161
+ - **The currency.** Charge currency = the profile's currency, else `chargeCurrencyOf(region)`
162
+ (`policy.currencies`). An amount checkout whose policy currency IS the charge currency is
163
+ charged exactly — no FX call; any other goes through the FX reference rate (rounded up). A
164
+ subscription or quantity session is forced to the charge currency (`currency`) only when its
165
+ synced Price carries it (default or option) — otherwise it is left to Stripe and warned.
166
+ `adaptive_pricing` only when the charge currency is the settlement currency.
167
+ - **The lock at Stripe.** A locked profile whose customer carries the address:
168
+ `customer_update.address: 'never'` and `billing_address_collection: 'auto'` (tax follows the
169
+ saved address, Checkout cannot move it); `name: 'auto'` stays. A locked customer without an
170
+ address keeps `'auto'`/`'required'`, or automatic tax would have no location.
171
+ - **Terms.** `mechanisms.checkoutTerms` puts `consent_collection.terms_of_service: 'required'` and
172
+ `custom_text.terms_of_service_acceptance` (the `checkout.terms-acceptance.in-scope | other` copy
173
+ with the billing language's links, ≤ 1200 characters) on every session.
174
+ - **A missing Dashboard terms URL never breaks a payment.** Stripe refuses the checkbox then
175
+ (`invalid_request_error`, param `consent_collection[terms_of_service]`, "You cannot collect consent
176
+ to your terms of service unless a URL is set in the Stripe Dashboard", matched by
177
+ `isMissingTermsUrl`): the session is created ONCE more without `consent_collection` and without the
178
+ terms text, under the same plugin admissions, metadata `termsCollected: 'false'`; a
179
+ `checkout-terms-fallback` event (`recordKind: 'checkout'`, `recordId` = the entity, `externalId`
180
+ = the session, `ok: false`, the Stripe message in `detail`) is appended per fallback and one
181
+ `console.warn` per context tells the operator what to set. Any other refusal is not retried; a
182
+ failing retry releases the admissions and propagates its own error.
183
+ - **Texts.** An in-scope top-up without `submitText` says what it buys (`checkout.top-up`, with the
184
+ country's name); a subscription without `submitText` shows the renewal price in the charge
185
+ currency (`checkout.renewal.<interval>`), and `after_submit` links the cancellation page while
186
+ that mechanism is on. Every text is asserted ≤ 1200 characters.
187
+ - **Start requests.** A subscription needs a fresh start request bound to the organization, the
188
+ plan and the current text version (`assertStartRequest` → `SubscriptionStartRequired`, 428),
189
+ unless the organization is already locked outside the territories — a country picked before
190
+ checkout may differ from the address typed at Stripe.
191
+ - **Metadata** (session and `subscription_data`): `region`, `country`, `language`, `termsVersion`,
192
+ `copyVersion`, `termsCollected` (`'true'` when the checkbox is on the session, `'false'` when the
193
+ policy has it off or after the fallback), `ipCountry` (the `cf-ipcountry` the app passes),
194
+ `startRequestId`, `profileId`. The purchase's `termsAccepted` comes only from the completed
195
+ session's `consent.terms_of_service` — absent when nothing was collected.
196
+
197
+ ## Checkout plugins
198
+
199
+ `gateway(ctx).use(plugin)` seats a `CheckoutPlugin` per gateway instance (the `ExecutionService.use`
200
+ registry: a plugin whose `alias` is registered already replaces it). All hooks are optional:
201
+
202
+ | Hook | Called | Contract |
203
+ |---|---|---|
204
+ | `narrow(ctx, {entityId, productSku, planSku?, base, at})` | every amount checkout and every `gateway.amountPolicy` | answers an `AmountNarrowing` or `null`; a throw fails the checkout closed |
205
+ | `admit(ctx, CheckoutAttempt)` | before the session, every mode | throw (`CheckoutLimitExceeded`) to veto; may return `{ reservationId }` |
206
+ | `created(ctx, CheckoutCreated)` | after the session | gets `sessionId`, `url`, `expiresAt`, amounts, its own `reservationId`; a throw expires the session, releases every hold and propagates |
207
+ | `settled(ctx, CheckoutSettled)` | webhook: `paid`, `expired`, `failed`; and `failed` for a checkout that never became usable | errors are logged, never raised |
208
+ | `sessionTtlSeconds` | — | the smallest declared, clamped to 30 min – 24 h, becomes `expires_at`; none declared: Stripe's default |
209
+
210
+ - **One narrowing path.** `createLink` and `gateway.amountPolicy(ctx, entityId, productSku,
211
+ planSku?)` both call `narrowAmountFor` → `narrowAmountPolicy` (`@owlmeans/payment`); an amount
212
+ above the narrowed maximum, or any amount while `blocked`, is `CheckoutLimitExceeded` (409). A
213
+ control and a refusal cannot disagree. An amount above the plan's own maximum stays the base
214
+ policy's error.
215
+ - A veto, a Stripe refusal or a throwing `created` releases what the earlier plugins admitted
216
+ (`settled` with `outcome: 'failed'` and their `reservationId`).
217
+ - `gateway.planPrices(ctx, productSku)` answers `PlanPriceView[]` (a default entry per Price plus
218
+ one per currency option) from the synced fingerprint rows — no Stripe call.
219
+
220
+ ## Price sync and the tax estimate
221
+
222
+ - `syncStripeProducts` gives a matching, still-`unspecified` price the declared `tax.behavior` IN
223
+ PLACE and a fresh one on creation; a price carrying the OPPOSITE behavior is replaced. Before an
224
+ in-place update it checks the account's tax-settings default and skips — logging why — when that
225
+ would change existing renewals, unless `stripe.migrateUnspecifiedPrices` opts in.
226
+ - A recurring or quantity plan whose catalogue currency differs from `stripe.settlementCurrency` is
227
+ converted with an unlocked FX Quote (`reference_rate`, rounded up). Its **currency options**:
228
+ the declared `currencyPrices`, plus the catalogue currency (exact `round(price × 100)`) when it is
229
+ one of the policy's region currencies; each option carries the declared `tax_behavior`. Active
230
+ prices are listed with `expand: ['data.currency_options']`; a changed option replaces the Price
231
+ (deactivate + create with `transfer_lookup_key`) — options are never edited in place, so a
232
+ subscriber keeps the Price they accepted.
233
+ - The resolved amounts, currencies and options are fingerprinted; an unchanged fingerprint makes no
234
+ product/price call. Each sync stores the product's `prices[]` (`SyncedPrice`: price id, lookup
235
+ key, default currency and amount, options, tax behavior, interval, source amount) on the
236
+ fingerprint row — what `planPrices`, checkout currency forcing and the estimate read. A row from
237
+ before `prices[]` existed syncs once more.
238
+ - `estimatePrice` (`plugins/estimate.ts`) is a Stripe Tax calculation for one product/plan's
239
+ reference amount at a country — **$0.05 per distinct** (currency, country, amount, tax code,
240
+ behavior, matching tax ids) combination, cached per gateway-service instance for 24h; failures
241
+ are never cached. **A locked profile overrides the requested country** (`source: 'profile'`,
242
+ `locked: true`); the estimate carries `region`. Under a policy with region currencies a
243
+ recurring plan is estimated in the charge currency at its synced unit amount (default or
244
+ option); the `local` line (FX Quotes, a PREVIEW endpoint) only when that currency is the
245
+ settlement currency.
33
246
 
34
- ## Checkout modes
247
+ ## The subscription store
35
248
 
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.
249
+ - **The effective plan** is the highest-ranked row in `ENTITLING_STATUSES` (catalogue rank; the
250
+ newest on a tie), else the declared free plan, else `PlanRequired`.
251
+ - `mapStatus`: `active`→Active, `trialing`→Trial, `past_due`→PastDue, `unpaid`/`paused`→Suspended,
252
+ `incomplete`→Created, `incomplete_expired`→Ended, `canceled`→Canceled; paused collection is
253
+ Suspended with `pausedAt`.
254
+ - **State is written before observers, and classified against what observers were last told**
255
+ (`propagated`, stamped with `lastEventId` only after every observer succeeded).
256
+ `CommitOptions.beforePropagate(record, change)` runs between the write and the observers — the
257
+ Stripe path writes a subscription's purchase there on `created`, so the window exists before an
258
+ observer grants the bundle.
259
+ - Classification, first match: `created` · `canceled` · `paused` · `resumed` · `upgraded` /
260
+ `downgraded` · `cancel-scheduled` · `cancel-undone` · `renewed` (once per invoice) · `past-due` ·
261
+ `suspended` · `trial-ending`.
262
+ - A repeated delivery (`lastEventId`) is ignored; an older payload than the stored state is not
263
+ applied. `grantInternalPlan(ctx, entityId, planSku, { force?, periodEnd? })` upserts an internal
264
+ row; `resyncSubscription` / `resyncAll` re-read and apply as a webhook would.
43
265
 
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.
266
+ ## The usage ledger
46
267
 
47
- ## Fulfillment
268
+ `payment-usage` events are the source of truth; `payment-usage-counter` is the projection that
269
+ admission reads. `consume` increments the counter FIRST by one conditional upsert (`used <= limit
270
+ - amount`), then appends the event — **the counter may over-count, never over-admit**. An event
271
+ key is idempotent; `release` appends `release:<eventKey>` first. `reconcileCounters`,
272
+ `reconcileOccupancy`, `reconcileEntity` and `reconcileAll` repair it (details: the `entitlements`
273
+ skill).
48
274
 
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:
275
+ ## Entitlements and gates
52
276
 
53
- - `{ mode: 'amount', amountMinor, chargeAmountMinor, currency, ... }`
54
- - `{ mode: 'quantity', units, ... }`
277
+ - `entitlements(ctx)` → `effectivePlan`, `entitlements` (the `EntitlementView`), `hasCapability`,
278
+ `limitState`, the ledger operations. Never calls Stripe.
279
+ - **Capability gate** (`ENTITLEMENT_GATE`) passes when the view grants ANY parameter; **limit gate**
280
+ (`LIMIT_GATE`) when any `limit:<key>[>=n]` has room — it never consumes. Both refuse with
281
+ `AuthForbidden` refusals (403) and fail closed on an unreadable store.
282
+ - The consumer-rights refusals (428/409) and `CheckoutLimitExceeded` are not entitlement refusals.
55
283
 
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.
284
+ ## Consumer rights
61
285
 
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.
286
+ The EU right of withdrawal (with the Art. 11a withdrawal function), spend consent for prepaid
287
+ credits, subscription start requests, the cancellation function and a billing country fixed at the
288
+ first purchase. The service is `consumerRights(ctx)` (`consumerRightsOf(ctx)` is null-safe); its
289
+ full contract is in `reference.md`.
65
290
 
66
- ## Protocols and security
291
+ - **A purchase** is a paid one-time checkout, or a subscription's FIRST invoice — renewals,
292
+ internal grants and manual credits never are. Its row (`purchaseId` `stripe:<cs_…>` /
293
+ `stripe:<sub_…>`, `contractRef` `CR-YYMMDD-XXXXXX` over an unambiguous alphabet, retried on a
294
+ collision) is written **before anything is granted**: in `checkout.session.completed` before
295
+ `onTopUp`, in the first subscription commit before the `created` observers. A completed
296
+ subscription checkout then refines it with the buyer's own country, e-mail, totals and terms
297
+ acceptance. Window = in scope, before `deadline` (`withdrawalDeadlineOf`, policy margin),
298
+ neither withdrawn nor refunded; a full refund closes it (`refundedAt`).
299
+ - **In scope** when the buyer's own country or the organization's locked country is in the policy's
300
+ territories; an unknown country is protected by default. A business tax id does not exempt.
301
+ - **The lock**: the first completed purchase's `customer_details.address.country` (else the
302
+ declared one) locks the profile — first write wins through the unique index; a later different
303
+ country is a `lock-mismatch` event, never a relock. The request's `cf-ipcountry` is stored as
304
+ `ipCountry` beside it, and the `lock` event flags `ipMismatch`. The profile's currency is an
305
+ entitling Stripe subscription's currency when one exists (two subscriptions of one customer can
306
+ not differ), else the region's. Only `lock(entityId, country, 'manual', { force: true, by,
307
+ reason })` replaces a lock, audited as `relock`.
308
+ - **Unlock** (an operator, Mongo only — unmanaged works): `unlock(entityId, { by, reason })` deletes
309
+ the profile (only while it still holds the country read) and appends an `unlock` event carrying the
310
+ whole row; it answers the profile as it was, or `null`. After an unlock no lock is taken from the
311
+ paygate customer's saved address — neither at checkout nor by `reconcile` — so the next COMPLETED
312
+ purchase locks the country again from its own address.
313
+ - **Performance consent** (top-ups only — a start request covers a subscription's own invoice):
314
+ required while an open in-scope top-up window has none. `assertConsent` is one indexed query and
315
+ throws `PerformanceConsentRequired` (428, `pending`, latest `deadline`); an application calls it
316
+ only where credits will actually be spent. `recordConsent` renders the statement itself
317
+ (`consentStatementOf` with the trader's `name`), refuses a stale `textVersion` with a fresh 428,
318
+ covers only the open windows the body lists, stamps `consentedAt` conditionally, mails the
319
+ confirmation, then tells `onConsent`.
320
+ - **Start requests**: `recordStartRequest(subject, body, origin, { plan })` records the statement
321
+ with the plan's short name the application passes (default: its localized title), usable
322
+ `startRequestTtlSeconds` (3600); the purchase takes it as `servicesStartedAt`/`consentedAt`.
323
+ - **Withdrawal** (`withdraw(subject | null, body, origin)`, managed only): in-app by `purchaseId`,
324
+ public by contract reference or invoice number plus an e-mail of the purchase, its profile or
325
+ its paygate customer. The declaration and the conditional `withdrawnAt` are written BEFORE
326
+ Stripe; the receipt is mailed at once; then the paygate steps under
327
+ `withdrawal:<id>:<step>` keys; then `onWithdrawal`. The refund comes from the application's
328
+ `UsageMeter` through the `@owlmeans/payment` calculators (deduction `usedAfter + settled +
329
+ clawed`); no meter, or `automaticRefunds` off, is `review` (no paygate call). A late
330
+ declaration is `expired` — recorded and acknowledged, nothing executed. A repeated one answers
331
+ the original receipt. The public answer is always the bare `DeclarationReceipt`.
332
+ - **Cancellation** (`cancel(subject | null, body, origin)`): in-app the organization's entitling
333
+ Stripe subscription; public a contract reference plus e-mail, else the one organization whose
334
+ paygate customer has that e-mail. Ordinary → `cancel_at_period_end`, or `cancel_at` a later
335
+ boundary (`cancellationEffectiveAt`, no proration); already scheduled → `already-scheduled`;
336
+ **extraordinary → `review`, the paygate untouched** (an operator decides, the receipt says so).
337
+ - **Observers `onConsent` / `onWithdrawal` / `onCancellation` run AFTER the records and the paygate
338
+ steps; a throw is recorded (`observers` event) and retried by `reconcile()` — unlike the paygate
339
+ callbacks, whose throw makes Stripe redeliver.** A `WithdrawalEvent` with `status: 'refunded'`
340
+ carries `units.returned` — take exactly those back; `review` means an operator refunds later,
341
+ and that refund arrives as an ordinary `RefundEvent`.
342
+ - **`RefundEvent.withdrawalId`**: a refund this package made for a withdrawal carries
343
+ `metadata.withdrawalId`; an `onRefund` observer MUST skip its own claw-back for it, or the units
344
+ are taken back twice.
67
345
 
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.
346
+ ## Durable-medium mails
72
347
 
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.
348
+ Sent through the optional mailer (`mail.alias`, default `MAILER_SERVICE`), text + HTML, in the
349
+ language the consumer was shown, from the `payment-consumer-rights` copy (`email.*`): purchase
350
+ confirmation (in-scope purchases with an e-mail; withdrawal information with the function's
351
+ address and the model form), consent confirmation, start confirmation, withdrawal receipt,
352
+ cancellation receipt. User values are HTML-escaped; a deadline is shown as its last included day.
353
+ The mails and the withdrawal information name the trader's `legalName, address, email`; the
354
+ statements its `name`. Every send, skip or failure is a `mail` event (step = kind); addresses on
355
+ `.test`/`.example`/`.invalid`/`.localhost` and every subdomain of them (`isReservedAddress`; case, a
356
+ display-name form and a trailing dot read through) are never sent (recorded as skipped); each `bcc`
357
+ address gets its own copy. **The purchase confirmation goes out once per purchase**: a delivery
358
+ sends it only after winning the conditional `confirmationMailAt` claim on the purchase row (a mail
359
+ event of it from before the claim counts too) — concurrent deliveries in several processes, a
360
+ redelivery and a subscription checkout refining its purchase mail nothing twice; a failed send is
361
+ retried by `reconcile` only. `useMailRenderer((kind, data, rendered) => message | null |
362
+ undefined)` replaces (`message`), suppresses (`null`, recorded as skipped) or keeps a mail. The
363
+ boot warns once when a mailing mechanism is on and the trader has no address or e-mail, or no
364
+ mailer is registered.
365
+
366
+ ## Reconcile
367
+
368
+ `consumerRights(ctx).reconcile({ since?, limit? })` — the application's nightly job: retries the
369
+ paygate steps of withdrawals (refund, credit note, subscription cancel) and scheduled
370
+ cancellations, failed mails and failed observers; backfills purchases (records only, no mail) from
371
+ completed sessions of the last 16 days without a row; locks organizations that paid before the
372
+ lock from their paygate customer (never one an operator unlocked). A retry uses a fresh idempotency key (`…:<attempt>`; Stripe
373
+ replays a stored failure for a day) and first adopts a refund or credit note an earlier attempt
374
+ made (found by `metadata.withdrawalId`). Five failures of a step leave it to an operator. At most
375
+ `limit` (50) items per step.
376
+
377
+ ## Protocols, handlers and security
378
+
379
+ - `paymentGate` (bound by `paymentGateEntrypoints`): `webhook` is public because Stripe signs the
380
+ untouched raw body — never put an application guard on it; `resync` and `resyncSubscriptions`
381
+ carry `GUARD_ED25519`.
382
+ - `consumerRightsEntrypoints(protocols, { resolveEntity?, subjectOf?, guardMoney?, throttle?,
383
+ metaOf?, publicMinMs?, planNameOf?, serviceAlias? })` binds `makeConsumerRightsProtocols`' tree.
384
+ **Every hook gets the request's context as its LAST argument** — `resolveEntity(req, ctx)`,
385
+ `subjectOf(req, ctx)`, `guardMoney(req, action, ctx)`, `throttle(req, key, ctx)`,
386
+ `metaOf(req, ctx)`, `planNameOf(planSku, language, req, ctx)` — so an application reaches its
387
+ services (a throttle store) through it and keeps no module state. Account routes act for
388
+ `resolveEntity` (default `req.entity.id`, else `AuthForbidden`); consent, start, withdraw and
389
+ cancel pass `guardMoney` first (refuse API keys there). **A tree with a `public` subtree needs
390
+ `throttle` — a wiring error otherwise.** Public declarations are throttled with `{action, email,
391
+ ip}`, a filled `honeypot` gets a decoy receipt and nothing is recorded, and every answer takes at
392
+ least `publicMinMs` (1000 ms) so a match is not visible in the timing either.
393
+ - `checkoutReadEntrypoints(protocols, { resolveEntity?, gatewayAlias? })` binds
394
+ `makeCheckoutReadProtocols`: `amountPolicy` and `planPrices`; its `resolveEntity(req, ctx)` too.
395
+ - **`requestOriginOf(req)`** is the evidence of every consumer act (the default `metaOf`): `ip` =
396
+ `cf-connecting-ip` → the LAST `x-forwarded-for` entry → `x-real-ip` → the socket; the raw
397
+ `x-forwarded-for`, `user-agent` (≤ 512), `cf-ipcountry`, `accept-language`.
398
+
399
+ ## Stripe self-management
400
+
401
+ Runs in `initialize()` of a managed gateway, after the context is ready; each step independent:
402
+ products and prices (above), the portal configuration, the webhook endpoint.
403
+
404
+ - **The portal configuration**: customer update (email, address, tax id — **without address under
405
+ `mechanisms.countryLock`**), invoice history, payment method update, cancellation at period end
406
+ without proration, price switching between the active recurring prices. Its fingerprint covers
407
+ the catalogue (incl. `currencyPrices`), the lock flag, the region currencies, the branding and
408
+ the deployment key. Each deployment owns its own configuration, tagged `{ owlmeans: 'payment',
409
+ service, deployment: webhookUrlOf(ctx) }`; one tagged for another deployment is never touched.
410
+ - `portalLink(ctx, entityId, { flow, planSku?, returnUrl })`: a customer is required
411
+ (`PortalUnavailable`, 409); `Cancel`/`Update`/`Change` need an entitling Stripe subscription.
412
+ - **The webhook endpoint** at `webhookUrlOf(ctx)`, subscribed to `WEBHOOK_EVENTS`, on the API version
413
+ read back from the client; only an https public host. A deployment deletes only the endpoints its
414
+ own rows name. The secret (create-only) is stored field-encrypted where the database has a key.
415
+ - **Do not bump the Stripe SDK** (17.x, API `2025-02-24.acacia`): `current_period_*`,
416
+ `invoice.subscription`, `invoice.payment_intent`, `charge.invoice` are top-level there and move
417
+ later; credit notes link a refund by `refund`; `presentment_details` is untyped. Read them through
418
+ narrow accessors.
419
+ - **Dashboard prerequisites** (test and live): a terms-of-service URL in Settings → Public details
420
+ before `checkoutTerms` is on (without it every checkout falls back to no checkbox — payments go on,
421
+ but the acceptance is not collected: watch for `checkout-terms-fallback` events); Checkout's return/refund
422
+ policy off or pointing at the Billing Terms, never "no refunds"; business name, address and
423
+ support e-mail in Public details; the "successful payments" and "refunds" customer e-mails.
424
+
425
+ ## Event dispatch
426
+
427
+ | Event | Persisted | Observer · event key |
428
+ |---|---|---|
429
+ | `customer.created` / `.updated` | customer upsert (`country`, `currency`) | — |
430
+ | `customer.deleted` | customer `deletedAt` | — |
431
+ | `checkout.session.completed` / `.async_payment_succeeded`, `payment` mode, paid | lock + purchase + confirmation mail, fulfillment (+ evidence), `fulfilledAt` after observers | `onTopUp` · session id; plugins `settled('paid')` |
432
+ | same, `subscription` mode | subscription applied if no webhook did (purchase in its first commit), purchase refined, lock, subscription evidence, confirmation mail | plugins `settled('paid')` |
433
+ | `checkout.session.async_payment_failed` | fulfillment `failedAt` | `onPaymentFailed {kind:'checkout'}` · `payment-failed:<session>:0`; `settled('failed')` |
434
+ | `checkout.session.expired` | unfulfilled fulfillment purged | plugins `settled('expired')` |
435
+ | `customer.subscription.created` / `.updated` / `.pending_update_*` / `.paused` / `.resumed` | subscription applied (+ `currency`) | `onSubscription` · classified |
436
+ | `customer.subscription.deleted` | applied as Canceled + `endedAt` | `canceled` |
437
+ | `customer.subscription.trial_will_end` | applied | `trial-ending` |
438
+ | `invoice.paid` | cycle invoice ⇒ re-read, applied as a renewal; otherwise `latestInvoiceId` | `renewed` |
439
+ | `invoice.payment_failed` / `.payment_action_required` | re-read and applied | classified, then `onPaymentFailed {kind:'invoice'}` · `payment-failed:<invoice>:<attempt>` |
440
+ | `invoice.upcoming` | nothing | — |
441
+ | `invoice.marked_uncollectible` / `invoice.voided` | re-read and applied / `latestInvoiceId` | classified / — |
442
+ | `charge.refunded`, `refund.created` / `.updated` (succeeded) | fulfillment `refundedMinor`/`refundedAt`; purchase `refundedMinor`, `refundedAt` on a whole refund | `onRefund` · `refund:<refund>` (`metadata`, `withdrawalId`) |
443
+ | `refund.failed` | — | — |
444
+ | `charge.dispute.*` | target `disputedAt`, `disputeStatus` | `onDispute {phase}` · `dispute:<dispute>:<phase>` |
445
+
446
+ A refund or dispute resolves to its target by payment intent (fulfillment), by charge, by invoice
447
+ (the subscription whose latest invoice it is, else the invoice's subscription); a subscription
448
+ refund touches the purchase only when it is of the purchase's own (first) invoice.
449
+
450
+ ## Observer API and idempotency keys
451
+
452
+ | Callback | Payload | Key | A throw |
453
+ |---|---|---|---|
454
+ | `onTopUp` | `TopUpCompletion` | session id | Stripe redelivers |
455
+ | `onSubscription` | `SubscriptionEvent` | `subscription:<id>:<change>:…` | Stripe redelivers |
456
+ | `onRefund` | `RefundEvent` (+ `metadata`, `withdrawalId`) | `refund:<refund>` | Stripe redelivers |
457
+ | `onDispute` | `DisputeEvent` | `dispute:<dispute>:<phase>` | Stripe redelivers |
458
+ | `onPaymentFailed` | `PaymentFailedEvent` | `payment-failed:<session\|invoice>:<attempt>` | Stripe redelivers |
459
+ | `onConsent` | `ConsentEvent` (`kind`, `consentId`, `purchaseIds`, `planSku`) | `consent:<id>` | recorded; `reconcile` retries |
460
+ | `onWithdrawal` | `WithdrawalEvent` (`status`, `purchase: PurchaseRef`, `refund`, `units`, `subscriptionCanceled`) | `withdrawal:<id>` | recorded; `reconcile` retries |
461
+ | `onCancellation` | `CancellationEvent` (`matched`, `kind`, `status`, `effectiveAt`) | `cancellation:<id>` | recorded; `reconcile` retries |
462
+
463
+ Every callback must be idempotent by its key. A proportional claw-back of an ordinary top-up
464
+ refund uses `netAmountMinor * refundedTotalMinor / paidMinor`.
465
+
466
+ ## Testing
467
+
468
+ Unit specs run a real server context — real catalogue, services and gates — over in-memory
469
+ resources (unique and sparse-unique indexes, raw conditional updates) and a fake Stripe that records
470
+ every SDK call and its request options (idempotency keys) and can fail a method's next calls
471
+ (`state.failures`: a message, a real SDK error such as `new Stripe.errors.StripeInvalidRequestError(…)`,
472
+ or a list of them, one per call), so "no Stripe call" is an assertion. `makeFakeContext` wires the
473
+ consumer-rights call before the gateway by default (`gatewayFirst`, `rights`, `gatewayManage` and a
474
+ pre-init `wire` hook test the other orders). The consumer-rights service there is
475
+ managed through the fake (`appendConsumerRights({ manage: true, stripe })`) with a console mailer
476
+ (`fake.mails`). The Mongo-gated specs prove admission under concurrency, the collection validators
477
+ of every record, the unique indexes, the concurrent first lock and the single winner of
478
+ concurrent withdrawals.
76
479
 
77
480
  ## External docs
78
481
 
79
- - 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.
80
- - https://docs.stripe.com/checkout/fulfillment — Fulfillment must be idempotent, check payment state and support delayed-payment success events rather than trusting completion alone.
482
+ - https://docs.stripe.com/api/checkout/sessions/create — inline `price_data`; `consent_collection.terms_of_service` needs a terms URL in the Dashboard (else `invalid_request_error` on param `consent_collection[terms_of_service]`); `custom_text.{submit, after_submit, terms_of_service_acceptance}` ≤ 1200 characters each; `currency` forces a Price's currency option; `expires_at` 30 min – 24 h.
483
+ - https://docs.stripe.com/payments/checkout/localize-prices/manual-currency-prices — `currency_options` on a Price, one reusable Price for several currencies; manual options override Adaptive Pricing for that currency.
484
+ - https://docs.stripe.com/payments/currencies/localize-prices/adaptive-pricing — Adaptive Pricing requires the price currency to be a settlement currency; webhook amounts stay in the integration currency.
485
+ - https://docs.stripe.com/invoicing/multi-currency-customers — a customer's subscriptions share one currency; one-time payments may differ.
486
+ - https://docs.stripe.com/invoicing/integration/programmatic-credit-notes — preview a credit note on an invoice line; link an existing refund with `refund`; custom lines are not allowed with automatic tax.
487
+ - https://docs.stripe.com/tax/reports — a refund or a credit note lowers reported tax; only the credit note is the corrective document of an issued invoice.
488
+ - https://docs.stripe.com/api/refunds/create — `payment_intent`, `amount`, `reason: requested_by_customer`, `metadata`; an idempotency key replays the stored answer (failures too) for 24 h.
489
+ - https://docs.stripe.com/api/subscriptions/cancel and /update — `cancel(prorate, invoice_now, cancellation_details)`; `update(cancel_at_period_end | cancel_at, proration_behavior)`.
490
+ - https://docs.stripe.com/payments/checkout/receipts — one-time Checkout needs `invoice_creation.enabled` for a post-payment invoice; a Customer's `preferred_locales` localizes Stripe mails.
491
+ - https://docs.stripe.com/checkout/fulfillment — fulfillment must be idempotent and support delayed-payment success events.
492
+ - https://docs.stripe.com/api/webhook_endpoints/create — the signing `secret` is returned only by create; `api_version` is create-only.
493
+ - https://docs.stripe.com/api/customer_portal/configurations/create — `features.customer_update.allowed_updates`, subscription cancel/update features; configurations are never deletable.
494
+ - https://docs.stripe.com/api/tax/calculations/create — `percentage_decimal` is a STRING; parse it exactly.
495
+ - https://docs.stripe.com/api/fx_quotes/create — a PREVIEW endpoint (`stripe.rawRequest`); `reference_rate` for settlement conversion.
496
+
497
+ ## Related
498
+
499
+ - [[entitlements]] — the model across packages
500
+ - [[payment]] — the contracts: grammars, views, consumer-rights calculators, copy, refusals, factories
501
+ - [[web-payment]] — hooks and pieces, the consumer-rights dialogs and functions
502
+ - [[mailer]] — the mail transport the consumer-rights mails go through
503
+ - [[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