@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.
- package/README.md +1 -1
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-payment/SKILL.md +402 -260
- package/build/config.d.ts +15 -1
- package/build/config.d.ts.map +1 -1
- package/build/config.js +99 -3
- package/build/config.js.map +1 -1
- package/build/consts.d.ts +27 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +27 -0
- package/build/consts.js.map +1 -1
- package/build/consumer/capture.d.ts +74 -0
- package/build/consumer/capture.d.ts.map +1 -0
- package/build/consumer/capture.js +291 -0
- package/build/consumer/capture.js.map +1 -0
- package/build/consumer/format.d.ts +27 -0
- package/build/consumer/format.d.ts.map +1 -0
- package/build/consumer/format.js +81 -0
- package/build/consumer/format.js.map +1 -0
- package/build/consumer/handlers.d.ts +28 -0
- package/build/consumer/handlers.d.ts.map +1 -0
- package/build/consumer/handlers.js +173 -0
- package/build/consumer/handlers.js.map +1 -0
- package/build/consumer/index.d.ts +7 -0
- package/build/consumer/index.d.ts.map +1 -0
- package/build/consumer/index.js +6 -0
- package/build/consumer/index.js.map +1 -0
- package/build/consumer/mail.d.ts +27 -0
- package/build/consumer/mail.d.ts.map +1 -0
- package/build/consumer/mail.js +314 -0
- package/build/consumer/mail.js.map +1 -0
- package/build/consumer/origin.d.ts +14 -0
- package/build/consumer/origin.d.ts.map +1 -0
- package/build/consumer/origin.js +47 -0
- package/build/consumer/origin.js.map +1 -0
- package/build/consumer/reconcile.d.ts +12 -0
- package/build/consumer/reconcile.d.ts.map +1 -0
- package/build/consumer/reconcile.js +317 -0
- package/build/consumer/reconcile.js.map +1 -0
- package/build/consumer/records.d.ts +78 -0
- package/build/consumer/records.d.ts.map +1 -0
- package/build/consumer/records.js +296 -0
- package/build/consumer/records.js.map +1 -0
- package/build/consumer/service.d.ts +51 -0
- package/build/consumer/service.d.ts.map +1 -0
- package/build/consumer/service.js +760 -0
- package/build/consumer/service.js.map +1 -0
- package/build/consumer/withdrawal.d.ts +57 -0
- package/build/consumer/withdrawal.d.ts.map +1 -0
- package/build/consumer/withdrawal.js +247 -0
- package/build/consumer/withdrawal.js.map +1 -0
- package/build/index.d.ts +4 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +4 -2
- package/build/index.js.map +1 -1
- package/build/model.d.ts +6 -1
- package/build/model.d.ts.map +1 -1
- package/build/model.js +134 -4
- package/build/model.js.map +1 -1
- package/build/observer.d.ts +4 -0
- package/build/observer.d.ts.map +1 -1
- package/build/observer.js +13 -0
- package/build/observer.js.map +1 -1
- package/build/plugins/checkout-plugins.d.ts +49 -0
- package/build/plugins/checkout-plugins.d.ts.map +1 -0
- package/build/plugins/checkout-plugins.js +124 -0
- package/build/plugins/checkout-plugins.js.map +1 -0
- package/build/plugins/estimate.d.ts.map +1 -1
- package/build/plugins/estimate.js +41 -8
- package/build/plugins/estimate.js.map +1 -1
- package/build/plugins/events.d.ts +2 -0
- package/build/plugins/events.d.ts.map +1 -1
- package/build/plugins/events.js +126 -9
- package/build/plugins/events.js.map +1 -1
- package/build/plugins/fx.d.ts +5 -0
- package/build/plugins/fx.d.ts.map +1 -1
- package/build/plugins/fx.js +25 -0
- package/build/plugins/fx.js.map +1 -1
- package/build/plugins/portal.d.ts.map +1 -1
- package/build/plugins/portal.js +11 -1
- package/build/plugins/portal.js.map +1 -1
- package/build/plugins/stripe.d.ts +21 -2
- package/build/plugins/stripe.d.ts.map +1 -1
- package/build/plugins/stripe.js +397 -61
- package/build/plugins/stripe.js.map +1 -1
- package/build/resource.d.ts +8 -1
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +59 -2
- package/build/resource.js.map +1 -1
- package/build/service.d.ts +2 -1
- package/build/service.d.ts.map +1 -1
- package/build/service.js +28 -5
- package/build/service.js.map +1 -1
- package/build/subscription.d.ts +6 -0
- package/build/subscription.d.ts.map +1 -1
- package/build/subscription.js +1 -0
- package/build/subscription.js.map +1 -1
- package/build/sync.d.ts +7 -0
- package/build/sync.d.ts.map +1 -1
- package/build/sync.js +108 -15
- package/build/sync.js.map +1 -1
- package/build/types.d.ts +707 -4
- package/build/types.d.ts.map +1 -1
- package/build/utils.d.ts +22 -1
- package/build/utils.d.ts.map +1 -1
- package/build/utils.js +27 -1
- package/build/utils.js.map +1 -1
- package/package.json +14 -13
- package/src/config.ts +111 -7
- package/src/consts.ts +34 -0
- package/src/consumer/capture.ts +362 -0
- package/src/consumer/format.ts +90 -0
- package/src/consumer/handlers.ts +211 -0
- package/src/consumer/index.ts +6 -0
- package/src/consumer/mail.ts +368 -0
- package/src/consumer/origin.ts +63 -0
- package/src/consumer/reconcile.ts +329 -0
- package/src/consumer/records.ts +374 -0
- package/src/consumer/service.ts +868 -0
- package/src/consumer/withdrawal.ts +302 -0
- package/src/index.ts +6 -4
- package/src/model.ts +148 -6
- package/src/observer.ts +15 -2
- package/src/plugins/checkout-plugins.ts +155 -0
- package/src/plugins/estimate.ts +49 -9
- package/src/plugins/events.ts +135 -11
- package/src/plugins/fx.ts +29 -0
- package/src/plugins/portal.ts +11 -1
- package/src/plugins/stripe.ts +476 -60
- package/src/resource.ts +87 -4
- package/src/service.ts +28 -6
- package/src/subscription.ts +7 -0
- package/src/sync.ts +124 -17
- package/src/types.ts +756 -6
- package/src/utils.ts +56 -7
- package/tests/checkout-consumer.spec.ts +348 -0
- package/tests/checkout-plugins.spec.ts +164 -0
- package/tests/consumer-events.spec.ts +218 -0
- package/tests/consumer-fixtures.ts +132 -0
- package/tests/consumer-ops.spec.ts +351 -0
- package/tests/consumer-rights.integration.spec.ts +150 -0
- package/tests/consumer-rights.spec.ts +501 -0
- package/tests/context.ts +20 -2
- 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
|
|
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.
|
|
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
|
|
15
|
-
(products, prices, the portal, the webhook endpoint)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
the
|
|
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
|
-
|
|
30
|
-
|
|
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' },
|
|
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
|
-
|
|
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
|
|
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`)
|
|
47
|
-
(`ENTITLEMENT_SERVICE`)
|
|
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
|
|
50
|
-
`PaygateError('unmanaged')`. `grantInternalPlan
|
|
51
|
-
|
|
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,
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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 (`
|
|
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
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
- `
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
`
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
`
|
|
142
|
-
|
|
143
|
-
(currency, country, amount, tax code,
|
|
144
|
-
|
|
145
|
-
never
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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`.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
`
|
|
165
|
-
|
|
166
|
-
observer
|
|
167
|
-
|
|
168
|
-
-
|
|
169
|
-
`
|
|
170
|
-
|
|
171
|
-
`
|
|
172
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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`,
|
|
217
|
-
`
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
234
|
-
and
|
|
235
|
-
|
|
236
|
-
- **
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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`
|
|
290
|
-
| `
|
|
291
|
-
| `checkout.session.
|
|
292
|
-
| `
|
|
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 ⇒
|
|
296
|
-
| `invoice.payment_failed` / `.payment_action_required` |
|
|
297
|
-
| `invoice.upcoming` | nothing
|
|
298
|
-
| `invoice.marked_uncollectible` |
|
|
299
|
-
| `
|
|
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
|
|
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
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
|
314
|
-
|
|
315
|
-
| `
|
|
316
|
-
| `
|
|
317
|
-
| `
|
|
318
|
-
| `
|
|
319
|
-
| `
|
|
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
|
-
|
|
327
|
-
|
|
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
|
|
336
|
-
|
|
337
|
-
|
|
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 —
|
|
342
|
-
- https://docs.stripe.com/
|
|
343
|
-
- https://docs.stripe.com/
|
|
344
|
-
- https://docs.stripe.com/
|
|
345
|
-
- https://docs.stripe.com/
|
|
346
|
-
- https://docs.stripe.com/
|
|
347
|
-
- https://docs.stripe.com/
|
|
348
|
-
- https://docs.stripe.com/api/
|
|
349
|
-
- https://docs.stripe.com/
|
|
350
|
-
- https://docs.stripe.com/
|
|
351
|
-
- https://docs.stripe.com/
|
|
352
|
-
- https://docs.stripe.com/
|
|
353
|
-
- https://docs.stripe.com/
|
|
354
|
-
- https://docs.stripe.com/api/fx_quotes/create — a
|
|
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,
|
|
360
|
-
- [[web-payment]] — hooks and pieces
|
|
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
|