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