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, durab...
Installs into .claude/skills of the current project.
Are you the author of Server Payment?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/owlmeans-server-payment-common)
---
name: server-payment
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.
user-invocable: false
---
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
# @owlmeans/server-payment
**Install:** `bun add @owlmeans/server-payment@^0.1.18-rc.32`
Public MIT package. It embeds Stripe into an application backend and owns everything between
Stripe and an entity's entitlements: the subscription store, one-time fulfillments, the usage
ledger of counted limits, plan resolution, the two gate services, Stripe's own configuration
(products, prices, the portal, the webhook endpoint) and the EU consumer-rights records and
functions. The application owns its catalogue, what a purchase is worth to it (credits,
provisioning), its usage meter and its side effects. Contracts — plans, limits, promos, the
entitlement view, the consumer-rights views, calculators, copy and refusals — are
`@owlmeans/payment`; the model across packages is the `entitlements` skill. Field lists, the
service contract and the step-by-step algorithms are in `reference.md` in this skill folder.
## Wiring
```typescript
stripeSecrets(cfg, { api: '/secrets/stripe-key' }) // webhook secret: optional override
portalBranding(cfg, { returnUrl: 'https://app.example.com/billing', headline: 'Example' })
declarePaymentPricing(cfg, { // absent entirely: today's fixed behaviour, unchanged
tax: { automatic: true, behavior: TaxBehavior.Exclusive, collectTaxId: true, estimate: true },
currency: { adaptive: true, estimate: true },
stripe: { settlementCurrency: 'eur', subscriptionPaymentMethodTypes: ['card', 'link', 'klarna'],
subscriptionPaymentMethodTypesByCurrency: { usd: ['card', 'link'] },
lockCustomerEmail: true, lockCustomerCountry: true }, // optional: see "Customer locks"
})
declareConsumerRights(cfg, { // absent: no consumer-rights behaviour at all
textVersion: 'terms-2026-09', links: { en: { billingTerms, withdrawalInformation, withdrawalFunction, cancellation } },
currencies: { eu: 'eur', other: 'usd' },
mechanisms: { countryLock: true, checkoutTerms: true, performanceConsent: true, subscriptionStart: true,
withdrawal: true, automaticRefunds: true, cancellation: true, purchaseConfirmation: true },
trader: { name: 'Example', legalName: 'Example Ltd', address: '…', email: 'support@example.com' },
mail: { from: 'billing@example.com', bcc: ['archive@example.com'] }, // alias: default MAILER_SERVICE
consentContext: 'included', // optional: the `_included` consent copy variant
})
declarePaymentProduct(cfg, { sku: 'app-plans', type: ProductType.Service, services: ['app'], name: 'Plans' })
declarePaymentPlan(cfg, { productSku: 'app-plans', sku: 'free', rank: 0, free: true, price: 0, … })
declarePaymentPlan(cfg, { productSku: 'app-plans', sku: 'pro-monthly', rank: 10, price: 20,
recurring: { interval: 'month' }, currencyPrices: { usd: 20 },
withdrawal: { components: [{ key: 'services', basis: 'time', shareMinor: 1000 },
{ key: 'credits', basis: 'units', shareMinor: 1000 }] }, capabilities: […] })
appendPaymentGatewayService(context) // the process that talks to Stripe
appendPaymentGatewayService(context, { manage: false }) // a worker that only reads entitlements and asserts consent
// One deployment, several managed processes on one database: the same owner and webhookService everywhere
appendPaymentGatewayService(context, { owner: 'app-api', webhookService: 'app-hooks' }) // receives the webhook, bootstraps at boot
appendPaymentGatewayService(context, { owner: 'app-api', webhookService: 'app-hooks', bootstrap: false }) // checkout and portal only
appendConsumerRights(context, { manage, usage: myUsageMeter }) // before or after the gateway; idempotent
paymentAccessOf(context).consumerRights().useMailRenderer(myRenderer) // lazy service: works while wiring
paymentAccessOf(context).gateway().use(myCheckoutPlugin) // a tier / cap / hold plugin — once the context is initialized
export const serverBindings = [
...paymentGateEntrypoints, // the library's own, unpinned `paymentGate`
...consumerRightsEntrypoints(consumerProtocols, { guardMoney, throttle, subjectOf, planNameOf }),
...checkoutReadEntrypoints(checkoutProtocols),
]
// …or the gate re-declared by the application (its aliases, pinned to its service, the library's paths):
export const hookBindings = [
bind(own.base),
bind(own.webhook, paymentGateHandlers.webhook),
bind(own.resync, paymentGateHandlers.resync),
bind(own.resyncSubscriptions, paymentGateHandlers.resyncSubscriptions),
]
paymentAccessOf(context).observer().onSubscription(async event => { /* keyed by event.eventKey */ })
```
- `appendPaymentGatewayService` registers twelve resources, the catalogue service
(`PAYMENT_SERVICE`), the completion observer, the gateway (`GATEWAY_SERVICE`), the capability
gate (`ENTITLEMENT_GATE`), the limit gate (`LIMIT_GATE`), the entitlement service
(`ENTITLEMENT_SERVICE`) and the consumer-rights service (`CONSUMER_RIGHTS_SERVICE`), each only
when not registered already. `appendConsumerRights` does the consumer-rights part alone.
- **Registration order is free.** Whichever of `appendConsumerRights` and the gateway comes first,
`usage` and `stripe` are installed on the one service, and `manage` resolves as the application's
explicit value, else the gateway's, else managed. The consumer-rights resources keep the aliases
of the first registration.
- **The consumer-rights service is lazy** (like the completion observer): `paymentAccessOf(ctx).consumerRights()`,
`useMeter` and `useMailRenderer` work while the application is wired; the gateway's initialization
initializes it, so its boot checks still run at boot. The gateway is NOT lazy: `paymentAccessOf(ctx).gateway()` (and
`.use(plugin)`) needs the initialized context.
- **`manage: false`** registers the same surface with no Stripe client: no bootstrap at init, and
`createLink`, `portalLink`, `resyncSubscription`, `resyncAll`, the webhook route, `withdraw` and
`cancel` throw `PaygateError('unmanaged')`. `grantInternalPlan`, the entitlement service,
`amountPolicy`, `planPrices` and the consumer-rights reads, `recordConsent`,
`recordStartRequest` and `assertConsent` work, because they are Mongo only.
- **Three keys of a deployment's Stripe state**, each a `PaymentGatewayOptions` field, each read
back on the gateway (`paymentAccessOf(ctx).gateway().webhookService / owner / bootstrap`):
- `webhookService` (default `cfg.service`) — the `cfg.services` alias whose host and base form
`webhookOf(ctx).webhookUrlOf`, the portal's deployment key too. Name the process that RECEIVES the webhook
(the one serving `paymentGate`) in every process, so every bootstrap computes one URL.
- `owner` (default `cfg.service`) — the `payment-webhook` rows' `service`, the signing-secret
lookup, the endpoint's `owlmeans:<owner>` description and `service` metadata, the
`portal:<owner>` fingerprint and the portal's `service` metadata. One value in every process of
the database, so each finds and replaces the others' rows.
- `bootstrap` (default: `manage`) — run `stripeBootstrapOf(ctx).bootstrapStripe` at boot. Exactly one kind of process
keeps it (the receiver). A managed process with `bootstrap: false` still serves checkout, the
portal, estimates, the resyncs and the webhook route, and a FORCED bootstrap (`resync`, an
application's maintenance `stripeBootstrapOf(ctx).bootstrapStripe(stripe, { force: true })`) still runs there —
same URL, same rows, fingerprinted, so idempotent from any process.
- Refused at construction: `bootstrap: true` with `manage: false`, an empty `owner` or
`webhookService` (`PaygateError`). A managed, bootstrapping gateway fails its initialization when
`webhookService` is not declared in `cfg.services` (`WebhookSetupError('service:<alias>')`).
- `stripe` (a `StripeFactory`, default `paymentAccessOf(ctx).stripeClient`) is the gateway's own Stripe client —
`paymentAccessOf(ctx).gateway().stripe(ctx)`, used by its methods, its boot bootstrap and the `paymentGate` handlers.
The consumer-rights service takes its own (`appendConsumerRights({ stripe })`).
- Every process that registers the gateway runs the collection validators (`collMod`) of all
twelve records at init: a process of an older version narrows the validators again, so roll
the processes of one deployment out together.
- Gateway methods take the stable `entityId`. A public handler resolves it from its request
entity first; protocol bodies carry `entitySlug`.
- A value in `stripeSecrets` / `portalBranding` that starts with `/` is read from that file at
boot, and a missing file fails the boot — so leave `webhook` out unless the file exists.
## Declaring plans
- **`rank`** orders a product's plans; a higher rank is an upgrade. A safe integer `>= 0`; absent
reads as `0`.
- **The free plan is a plan**: `free: true`, `price: 0`, no `gateways`. It is never synchronized to
Stripe and never checked out; an entity without an entitling subscription resolves to it.
- `gateways` names the paygates a plan is sold through; absent inherits the product's.
- **`currencyPrices`** (`{ usd: 20 }`, major units, lowercase codes) are exact prices in further
currencies, synced as the reusable Price's `currency_options`; a declared price in the Price's
default currency replaces its converted amount. Only for recurring and quantity plans.
- **`withdrawal.components`** state the separately priced parts of a subscription for a withdrawal
(CJEU C-641/19): `{ key, basis: 'time' | 'units', shareMinor }`, the shares summing to
`round(price × 100)`. Absent: the whole price is one `time` component. They also pick the start
statement: a `units` part → the `_units` variant, otherwise the base (time-only) texts
(`consumerCopyHelper.startContextOf`, `@owlmeans/payment`).
- `declarePaymentPlan` refuses before recording: a bad rank or a priced/gatewayed free plan
(`PlanRankConflict`), a capability set under the reserved `limit` scope, a malformed limit
(`LimitMisdeclared('<key>:<reason>')`), a malformed or amount-mode `currencyPrices` or components
that do not add up (`ProductError('currency-prices:…' | 'withdrawal:<sku>:…')`).
- `makePlanDeclarationsModel(cfg).assertPlans()` runs when the gateway initializes and fails the boot on two free plans
at one rank, or two paid non-consumable plans of one product at one rank.
- A limit key may use a different kind on different plans; each kind counts separately and a
lifetime count stays with the entity through upgrades, downgrades and cancellations.
## Records
None declares an ObjectId reference: `entityId` is an organization key and every other id is Stripe's.
| Collection | One row per | Indexes |
|---|---|---|
| `payment-paygate-customer` | Stripe customer (`country`, `currency` from its webhooks; `deletedAt`) | `{paygate, externalId}` unique · `{paygate, entityId}` · `{paygate, profileId}` |
| `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}` |
| `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}` |
| `payment-webhook` | managed webhook endpoint of an owner (`service` = the gateway's `owner`; `secret` is `secure: true`) | `{paygate, service, url}` unique |
| `payment-usage` | usage event — the ledger | `{entityId, limitKey, eventKey}` unique · `{entityId, limitKey, window}` · `{entityId, limitKey, ref}` sparse · `{entityId, createdAt:-1}` |
| `payment-usage-counter` | (entity, limit, window) projection | `{entityId, limitKey, window}` unique |
| `payment-fingerprint` | synchronized product (`<productSku>`, with `prices[]`) or portal (`portal:<owner>`) | `{sku}` unique |
| `payment-billing-profile` | organization: the billing country fixed at the first purchase | `{entityId}` unique · `{paygate, customerId}` sparse |
| `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}` |
| `payment-consumer-consent` | append-only: a performance consent or a subscription start request | `{entityId, decidedAt:-1}` · `{kind, entityId, planSku, decidedAt:-1}` |
| `payment-consumer-declaration` | append-only: a withdrawal or cancellation declaration | `{kind, receivedAt:-1}` · `{entityId, receivedAt:-1}` sparse · `{purchaseId}` sparse |
| `payment-consumer-event` | append-only: one execution or audit step (mail, refund, lock …) | `{recordId, at}` · `{entityId, at:-1}` sparse · `{action, ok, at}` |
- Every stored property is declared in the record schema (`additionalProperties: false`): the
resource writes a property its schema does not know as a string, and the collection validator
rejects it. New fields on existing records are optional, so old rows stay valid.
- **A map is stored as an array** (`prices[].options: [{ currency, unitAmount }]`): the resource
coerces a currency-keyed map's values to strings.
- A compound sparse index still indexes a row that lacks only some of its keys — a unique index
that must skip absent values is single-field (`{sessionId}`).
- Conditional writes (`consentedAt`, `withdrawnAt`) are raw `$set`s guarded by the old value
(`paymentUtils.conditionalSet`), so a concurrent declaration of the same purchase loses; declarations,
consents and events are only ever created.
## Checkout
- `Amount`: one inline `price_data` item for the synchronized product, quantity 1, its
`tax_behavior` the declared `PricingPolicy.tax.behavior`, no promotion codes. `Quantity`: the
reusable price under the plan lookup key, adjustable quantity. Subscription: `planSku` (else the
product's first recurring plan), quantity 1. One-time Sessions enable `invoice_creation`.
- `CreateLinkParams.locale` is a caller-validated Stripe locale (Session `locale`, the Customer's
`preferred_locales`). `submitText` is trusted application copy — a string, or a function of
`CheckoutTextContext {language, currency, unitAmountMinor, interval, region, country}` — on
every mode; never caller-provided text.
- A plan the paygate does not sell is refused (`ProductError`). `checkoutOptions` puts automatic
tax, billing address collection, tax-id collection and Adaptive Pricing on the session exactly
as `PricingPolicy` declares them.
- Subscription methods come from `stripe.subscriptionPaymentMethodTypesByCurrency[chargeCurrency]`,
falling back to `subscriptionPaymentMethodTypes`; with neither, Stripe selects dynamically.
Currency keys and method names normalize to lowercase. Currency keys must have three letters;
method lists must be nonempty and unique. Choose account-supported methods for each currency:
a method the account cannot accept in that currency rejects the whole session.
- **Without a consumer-rights policy every session is what it always was**: the charge currency
is the settlement currency (FX from the catalogue), Adaptive Pricing as declared.
### Under a consumer-rights policy
- **The country.** A locked billing profile overrides `params.country`; another declared country is
`BillingCountryLocked` (409), and so is a saved customer address that left the locked country
(an operator relocks). An organization that already paid before its country was locked is
locked lazily from its Stripe customer's address (`source: 'customer'`). Before any lock, the
declared country is prefilled on a customer without an address.
- **The currency.** Charge currency = the profile's currency, else `consumerRegionHelper.chargeCurrencyOf(region)`
(`policy.currencies`). An amount checkout whose policy currency IS the charge currency is
charged exactly — no FX call; any other goes through the FX reference rate (rounded up). A
subscription or quantity session is forced to the charge currency (`currency`) only when its
synced Price carries it (default or option) — otherwise it is left to Stripe and warned.
`adaptive_pricing.enabled` is explicitly true only when the policy enables it and the charge
currency is the settlement currency; every other session sends false. Omitting the flag lets
Stripe inherit the Dashboard setting and can enable localization for an exact USD checkout.
- **The lock at Stripe.** A locked profile whose customer carries the address:
`customer_update.address: 'never'` and `billing_address_collection: 'auto'` (tax follows the
saved address, Checkout cannot move it); `name: 'auto'` stays. A locked customer without a
tax-locatable address (`stripeSessionHelper.isTaxLocatable`) keeps `'auto'`/`'required'`, or Stripe refuses the
session ("Automatic tax calculation in Checkout requires a valid address on the Customer").
- **Terms.** `mechanisms.checkoutTerms` puts `consent_collection.terms_of_service: 'required'` and
`custom_text.terms_of_service_acceptance` (the `checkout.terms-acceptance.in-scope | other` copy
with the billing language's links, ≤ 1200 characters) on every session.
- **A missing Dashboard terms URL never breaks a payment.** Stripe refuses the checkbox then
(`invalid_request_error`, param `consent_collection[terms_of_service]`, "You cannot collect consent
to your terms of service unless a URL is set in the Stripe Dashboard", matched by
`stripeSessionHelper.isMissingTermsUrl`): the session is created ONCE more without `consent_collection` and without the
terms text, under the same plugin admissions, metadata `termsCollected: 'false'`; a
`checkout-terms-fallback` event (`recordKind: 'checkout'`, `recordId` = the entity, `externalId`
= the session, `ok: false`, the Stripe message in `detail`) is appended per fallback and one
`warn` log per context tells the operator what to set. Any other refusal is not retried; a
failing retry releases the admissions and propagates its own error.
- **Texts.** An in-scope top-up without `submitText` says what it buys (`checkout.top-up`, with the
country's name); a subscription without `submitText` shows the renewal price in the charge
currency (`checkout.renewal.<interval>`), and `after_submit` links the cancellation page while
that mechanism is on. Every text is asserted ≤ 1200 characters.
- **Start requests.** A subscription needs a fresh start request bound to the organization, the
plan and the current text version (`assertStartRequest` → `SubscriptionStartRequired`, 428),
unless the organization is already locked outside the territories — a country picked before
checkout may differ from the address typed at Stripe.
- **Metadata** (session and `subscription_data`): `region`, `country`, `language`, `termsVersion`,
`copyVersion`, `termsCollected` (`'true'` when the checkbox is on the session, `'false'` when the
policy has it off or after the fallback), `ipCountry` (the `cf-ipcountry` the app passes),
`startRequestId`, `profileId`, `countryPinned` (`'true'` only under `lockCustomerCountry` when the
session kept the pinned address). The purchase's `termsAccepted` comes only from the completed
session's `consent.terms_of_service` — absent when nothing was collected.
### Customer locks (`declarePaymentPricing` → `stripe`)
Both are opt-in, backend-only, read per call from the Stripe pricing plugin config, and work with
or without a consumer-rights policy.
- **`lockCustomerEmail`** — every `createLink` must pass `CreateLinkParams.email` (the application's
verified e-mail of the buyer), else `PaygateError('customer-email')` before any Stripe call. It is
written to the Stripe customer (created with it, or updated — case-insensitively different — in
the same call as `preferred_locales`), never as `customer_email`: Checkout shows a customer's
valid e-mail read-only, and only while the customer has none asks for one and saves it. The
customer carries the LAST payer's e-mail (invoices and renewals go there). Without the lock
`email` is ignored.
- **`lockCustomerCountry`** — the known country (the locked profile's, else `params.country`) is
written to the customer as a country-only address (lines and postal code cleared) when the saved
one carries no or another country — over another country only before any lock (a locked
customer's other country is still `BillingCountryLocked`). When the saved address is
tax-locatable the session keeps it (`customer_update.address: 'never'`, collection `'auto'`):
no address form, tax on the pinned country; Stripe still shows the card form's "Country or
region" picker (a payment-method detail no parameter hides), which then moves neither tax nor the
saved address. Metadata `countryPinned: 'true'` makes the first completed purchase lock THAT
country (`lockFromSession`: `metadata.country` before `customer_details.address.country`).
- **Tax-locatable** (`stripeSessionHelper.isTaxLocatable`, from Stripe's customer-locations table): a country alone
everywhere except US (needs `postal_code`) and CA / IN (`postal_code` or `state`). A pinned US,
CA or IN customer without one is not kept: Checkout collects the address — country editable —
and the purchase locks what was typed, as without the option.
- **The portal** never offers `email` under `lockCustomerEmail`, nor `address` under
`lockCustomerCountry` (or `countryLock`); both flags are in its fingerprint.
## Checkout plugins
`paymentAccessOf(ctx).gateway().use(plugin)` seats a `CheckoutPlugin` per gateway instance (the `ExecutionService.use`
registry: a plugin whose `alias` is registered already replaces it). All hooks are optional:
| Hook | Called | Contract |
|---|---|---|
| `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 |
| `admit(ctx, CheckoutAttempt)` | before the session, every mode | throw (`CheckoutLimitExceeded`) to veto; may return `{ reservationId }` |
| `created(ctx, CheckoutCreated)` | after the session | gets `sessionId`, `url`, `expiresAt`, amounts, its own `reservationId`; a throw expires the session, releases every hold and propagates |
| `settled(ctx, CheckoutSettled)` | webhook: `paid`, `expired`, `failed`; and `failed` for a checkout that never became usable | errors are logged, never raised |
| `sessionTtlSeconds` | — | the smallest declared, clamped to 30 min – 24 h, becomes `expires_at`; none declared: Stripe's default |
- **One narrowing path.** `createLink` and `gateway.amountPolicy(ctx, entityId, productSku,
planSku?)` both call `checkoutPluginsOf(ctx).narrowAmountFor` → `amountNarrowingHelper.narrowAmountPolicy` (`@owlmeans/payment`); an amount
above the narrowed maximum, or any amount while `blocked`, is `CheckoutLimitExceeded` (409). A
control and a refusal cannot disagree. An amount above the plan's own maximum stays the base
policy's error.
- A veto, a Stripe refusal or a throwing `created` releases what the earlier plugins admitted
(`settled` with `outcome: 'failed'` and their `reservationId`).
- `gateway.planPrices(ctx, productSku)` answers `PlanPriceView[]` (a default entry per Price plus
one per currency option) from the synced fingerprint rows — no Stripe call.
## Price sync and the tax estimate
- `productSyncOf(ctx).syncStripeProducts` gives a matching, still-`unspecified` price the declared `tax.behavior` IN
PLACE and a fresh one on creation; a price carrying the OPPOSITE behavior is replaced. Before an
in-place update it checks the account's tax-settings default and skips — logging why — when that
would change existing renewals, unless `stripe.migrateUnspecifiedPrices` opts in.
- A recurring or quantity plan whose catalogue currency differs from `stripe.settlementCurrency` is
converted with an unlocked FX Quote (`reference_rate`, rounded up). Its **currency options**:
the declared `currencyPrices`, plus the catalogue currency (exact `round(price × 100)`) when it is
one of the policy's region currencies; each option carries the declared `tax_behavior`. Active
prices are listed with `expand: ['data.currency_options']`; a changed option replaces the Price
(deactivate + create with `transfer_lookup_key`) — options are never edited in place, so a
subscriber keeps the Price they accepted.
- The resolved amounts, currencies and options are fingerprinted; an unchanged fingerprint makes no
product/price call. Each sync stores the product's `prices[]` (`SyncedPrice`: price id, lookup
key, default currency and amount, options, tax behavior, interval, source amount) on the
fingerprint row — what `planPrices`, checkout currency forcing and the estimate read. A row from
before `prices[]` existed syncs once more.
- `estimatePrice` (`plugins/estimate.ts`) is a Stripe Tax calculation for one product/plan's
reference amount at a country — **$0.05 per distinct** (currency, country, amount, tax code,
behavior, matching tax ids) combination, cached per gateway-service instance for 24h; failures
are never cached. **A locked profile overrides the requested country** (`source: 'profile'`,
`locked: true`); the estimate carries `region`. Under a policy with region currencies a
recurring plan is estimated in the charge currency at its synced unit amount (default or
option); the `local` line (FX Quotes, a PREVIEW endpoint) only when the session's CHARGE currency
(the region's, or the locked profile's) is the settlement currency — the same test the session's
`adaptive_pricing` takes. An amount plan stays estimated in its policy currency but is charged in
the region's currency, so an EU top-up gets its local line through the policy → settlement →
local chain, and an exact-USD one none.
## The subscription store
- **The effective plan** is the highest-ranked row in `ENTITLING_STATUSES` (catalogue rank; the
newest on a tie), else the declared free plan, else `PlanRequired`.
- **A subscription row may override its plan.** `PaymentSubscriptionRecord.overrides`
(`SubscriptionOverrides`: `limits: { <key>: { limit } }`) is applied by `planHelper.overriddenPlan(plan, row)`
inside `resolveEffectivePlan` and `snapshotOf`, so ceilings, the entitlement view, both gates and
observers read one answer and nothing else needs to know. An override sets the ceiling of a key
the plan declares and drops that key's promo; kind and window stay the plan's (counters are keyed
by them); an undeclared key or a ceiling that is not a safe integer `>= 0` is ignored. An operator
writes it, no paygate does: every sync and re-grant spreads the previous row, so it survives. It
belongs to the row — it follows the subscription through a plan change, ends with it, and the
free-plan fallback (no row) has none. A new overridable parameter is a new optional key of
`SubscriptionOverrides`, declared in `PaymentSubscriptionSchema` and merged in `planHelper.overriddenPlan`.
- `mapStatus`: `active`→Active, `trialing`→Trial, `past_due`→PastDue, `unpaid`/`paused`→Suspended,
`incomplete`→Created, `incomplete_expired`→Ended, `canceled`→Canceled; paused collection is
Suspended with `pausedAt`.
- **State is written before observers, and classified against what observers were last told**
(`propagated`, stamped with `lastEventId` only after every observer succeeded).
`CommitOptions.beforePropagate(record, change)` runs between the write and the observers — the
Stripe path writes a subscription's purchase there on `created`, so the window exists before an
observer grants the bundle.
- Classification, first match: `created` · `canceled` · `paused` · `resumed` · `upgraded` /
`downgraded` · `cancel-scheduled` · `cancel-undone` · `renewed` (once per invoice) · `past-due` ·
`suspended` · `trial-ending`.
- A repeated delivery (`lastEventId`) is ignored; an older payload than the stored state is not
applied. `grantInternalPlan(ctx, entityId, planSku, { force?, periodEnd? })` upserts an internal
row; `resyncSubscription` / `resyncAll` re-read and apply as a webhook would.
## The usage ledger
`payment-usage` events are the source of truth; `payment-usage-counter` is the projection that
admission reads. `consume` increments the counter FIRST by one conditional upsert (`used <= limit
- amount`), then appends the event — **the counter may over-count, never over-admit**. An event
key is idempotent; `release` appends `release:<eventKey>` first. `reconcileCounters`,
`reconcileOccupancy`, `reconcileOf(ctx).reconcileEntity` and `.reconcileAll` repair it (details: the `entitlements`
skill).
## Entitlements and gates
- `paymentAccessOf(ctx).entitlements()` → `effectivePlan`, `entitlements` (the `EntitlementView`), `hasCapability`,
`limitState`, the ledger operations. Never calls Stripe.
- **Capability gate** (`ENTITLEMENT_GATE`) passes when the view grants ANY parameter; **limit gate**
(`LIMIT_GATE`) when any `limit:<key>[>=n]` has room — it never consumes. Both refuse with
`AuthForbidden` refusals (403) and fail closed on an unreadable store.
- The consumer-rights refusals (428/409) and `CheckoutLimitExceeded` are not entitlement refusals.
## Consumer rights
The EU right of withdrawal (with the Art. 11a withdrawal function), spend consent for prepaid
credits, subscription start requests, the cancellation function and a billing country fixed at the
first purchase. The service is `paymentAccessOf(ctx).consumerRights()` (`.consumerRightsOf()` is null-safe); its
full contract is in `reference.md`.
- **A purchase** is a paid one-time checkout, or a subscription's FIRST invoice — renewals,
internal grants and manual credits never are. Its row (`purchaseId` `stripe:<cs_…>` /
`stripe:<sub_…>`, `contractRef` `CR-YYMMDD-XXXXXX` over an unambiguous alphabet, retried on a
collision) is written **before anything is granted**: in `checkout.session.completed` before
`onTopUp`, in the first subscription commit before the `created` observers. A completed
subscription checkout then refines it with the buyer's own country, e-mail, totals and terms
acceptance. Window = in scope, before `deadline` (`withdrawalDeadlineHelper.withdrawalDeadlineOf`, policy margin),
neither withdrawn nor refunded; a full refund closes it (`refundedAt`).
- **In scope** when the buyer's own country or the organization's locked country is in the policy's
territories; an unknown country is protected by default. A business tax id does not exempt.
- **The lock**: the first completed purchase's `customer_details.address.country` (else the
declared one) locks the profile — first write wins through the unique index; a later different
country is a `lock-mismatch` event, never a relock. The request's `cf-ipcountry` is stored as
`ipCountry` beside it, and the `lock` event flags `ipMismatch`. The profile's currency is an
entitling Stripe subscription's currency when one exists (two subscriptions of one customer can
not differ), else the region's. Only `lock(entityId, country, 'manual', { force: true, by,
reason })` replaces a lock, audited as `relock`.
- **Unlock** (an operator, Mongo only — unmanaged works): `unlock(entityId, { by, reason })` deletes
the profile (only while it still holds the country read) and appends an `unlock` event carrying the
whole row; it answers the profile as it was, or `null`. After an unlock no lock is taken from the
paygate customer's saved address — neither at checkout nor by `reconcile` — so the next COMPLETED
purchase locks the country again from its own address.
- **Performance consent** (top-ups only — a start request covers a subscription's own invoice):
required while an open in-scope top-up window has none. `assertConsent` is one indexed query and
throws `PerformanceConsentRequired` (428, `pending`, latest `deadline`); an application calls it
only where credits will actually be spent. `consentView` carries the policy's `consentContext`
as `context`. `recordConsent` renders the statement itself (`consumerCopyHelper.consentStatementOf` with the
trader's `name` and the policy's `consentContext`, so the stored verbatim text and the consent
mail are the `_<context>` variant the dialog showed; without a context the base statement),
stores that `context` on the record, refuses a stale `textVersion` with a fresh 428, covers only
the open windows the body lists, stamps `consentedAt` conditionally, mails the confirmation, then
tells `onConsent`.
- **Start requests follow the plan's withdrawal arithmetic.** `startView(entityId, planSku)` names
`context = consumerCopyHelper.startContextOf(plan)` (`'units'` for a plan with a `units` component, absent for a
time-only one); `recordStartRequest(subject, body, origin, { plan })` renders the statement with
the SAME `consumerCopyHelper.startContextOf(plan)` and the plan's short name the application passes (default: its
localized title), stores it verbatim with its `context`, and the start mail repeats it with
`email.start.rule` of that variant (the record's `context`). Usable `startRequestTtlSeconds`
(3600); the purchase takes it as `servicesStartedAt`/`consentedAt`.
- **Withdrawal** (`withdraw(subject | null, body, origin)`, managed only): in-app by `purchaseId`,
public by contract reference or invoice number plus an e-mail of the purchase, its profile or
its paygate customer. The declaration and the conditional `withdrawnAt` are written BEFORE
Stripe; the receipt is mailed at once; then the paygate steps under
`withdrawal:<id>:<step>` keys; then `onWithdrawal`. The refund comes from the application's
`UsageMeter` through the `@owlmeans/payment` calculators (deduction `usedAfter + settled +
clawed`); no meter, or `automaticRefunds` off, is `review` (no paygate call). A late
declaration is `expired` — recorded and acknowledged, nothing executed. A repeated one answers
the original receipt. The public answer is always the bare `DeclarationReceipt`.
- **Cancellation** (`cancel(subject | null, body, origin)`): in-app the organization's entitling
Stripe subscription; public a contract reference plus e-mail, else the one organization whose
paygate customer has that e-mail. Ordinary → `cancel_at_period_end`, or `cancel_at` a later
boundary (`cancellationEffectiveAt`, no proration); already scheduled → `already-scheduled`;
**extraordinary → `review`, the paygate untouched** (an operator decides, the receipt says so).
- **Observers `onConsent` / `onWithdrawal` / `onCancellation` run AFTER the records and the paygate
steps; a throw is recorded (`observers` event) and retried by `reconcile()` — unlike the paygate
callbacks, whose throw makes Stripe redeliver.** A `WithdrawalEvent` with `status: 'refunded'`
carries `units.returned` — take exactly those back; `review` means an operator refunds later,
and that refund arrives as an ordinary `RefundEvent`.
- **`RefundEvent.withdrawalId`**: a refund this package made for a withdrawal carries
`metadata.withdrawalId`; an `onRefund` observer MUST skip its own claw-back for it, or the units
are taken back twice.
## Durable-medium mails
Sent through the optional mailer (`mail.alias`, default `MAILER_SERVICE`), text + HTML, in the
language the consumer was shown, from the `payment-consumer-rights` copy (`email.*`): purchase
confirmation (in-scope purchases with an e-mail; withdrawal information with the function's
address and the model form), consent confirmation, start confirmation, withdrawal receipt,
cancellation receipt. User values are HTML-escaped; a deadline is shown as its last included day.
The mails and the withdrawal information name the trader's `legalName, address, email`; the
statements its `name`. Every send, skip or failure is a `mail` event (step = kind); addresses on
`.test`/`.example`/`.invalid`/`.localhost` and every subdomain of them (`consumerFormatHelper.isReservedAddress`; case, a
display-name form and a trailing dot read through) are never sent (recorded as skipped); each `bcc`
address gets its own copy. **The purchase confirmation goes out once per purchase**: a delivery
sends it only after winning the conditional `confirmationMailAt` claim on the purchase row (a mail
event of it from before the claim counts too) — concurrent deliveries in several processes, a
redelivery and a subscription checkout refining its purchase mail nothing twice; a failed send is
retried by `reconcile` only. `useMailRenderer((kind, data, rendered) => message | null |
undefined)` replaces (`message`), suppresses (`null`, recorded as skipped) or keeps a mail. The
boot warns once when a mailing mechanism is on and the trader has no address or e-mail, or no
mailer is registered.
## Reconcile
`paymentAccessOf(ctx).consumerRights().reconcile({ since?, limit? })` — the application's nightly job: retries the
paygate steps of withdrawals (refund, credit note, subscription cancel) and scheduled
cancellations, failed mails and failed observers; backfills purchases (records only, no mail) from
completed sessions of the last 16 days without a row; locks organizations that paid before the
lock from their paygate customer (never one an operator unlocked). A retry uses a fresh idempotency key (`…:<attempt>`; Stripe
replays a stored failure for a day) and first adopts a refund or credit note an earlier attempt
made (found by `metadata.withdrawalId`). Five failures of a step leave it to an operator. At most
`limit` (50) items per step.
## Protocols, handlers and security
- `paymentGate` (bound by `paymentGateEntrypoints`): `webhook` is public because Stripe signs the
untouched raw body — never put an application guard on it; `resync` and `resyncSubscriptions`
carry `GUARD_ED25519`.
- **An application that declares the gate itself** — its own aliases, every route pinned to the
service that receives the webhook — binds its declarations to `paymentGateHandlers` (`webhook`,
`resync`, `resyncSubscriptions`) with `bind(own.webhook, paymentGateHandlers.webhook)` from
`@owlmeans/server-entrypoint` / `@owlmeans/server-app`, and never registers `paymentGate` or
`paymentGateEntrypoints`. A handler reads its context from the entrypoint it is bound to, so the
application's declaration is the one mounted and run. Its declarations keep the library's paths
(`webhookOf(ctx).webhookUrlOf` forms the Stripe URL from `paymentGate`'s), contracts (`PaygateParams`,
`ResyncResult`, `ResyncSubscriptionsResult` — `bind` refuses another shape at compile time) and
guards. Two ways to get it wrong: `bindAll(declarations, [paymentGateHandlers.webhook])` pairs by
object identity and leaves the application's declaration without a handler, and
`handlers().params(own.webhook, paymentGateHandlers.webhook)` answers every request with
`HandlerMisconfiguredError` (a handler bound to another protocol).
- `consumerRightsEntrypoints(protocols, { resolveEntity?, subjectOf?, guardMoney?, throttle?,
metaOf?, publicMinMs?, planNameOf?, serviceAlias? })` binds `makeConsumerRightsProtocols`' tree.
**Every hook gets the request's context as its LAST argument** — `resolveEntity(req, ctx)`,
`subjectOf(req, ctx)`, `guardMoney(req, action, ctx)`, `throttle(req, key, ctx)`,
`metaOf(req, ctx)`, `planNameOf(planSku, language, req, ctx)` — so an application reaches its
services (a throttle store) through it and keeps no module state. Account routes act for
`resolveEntity` (default `req.entity.id`, else `AuthForbidden`); consent, start, withdraw and
cancel pass `guardMoney` first (refuse API keys there). **A tree with a `public` subtree needs
`throttle` — a wiring error otherwise.** Public declarations are throttled with `{action, email,
ip}`, a filled `honeypot` gets a decoy receipt and nothing is recorded, and every answer takes at
least `publicMinMs` (1000 ms) so a match is not visible in the timing either.
- `checkoutReadEntrypoints(protocols, { resolveEntity?, gatewayAlias? })` binds
`makeCheckoutReadProtocols`: `amountPolicy` and `planPrices`; its `resolveEntity(req, ctx)` too.
- **`originHelper.requestOriginOf(req)`** is the evidence of every consumer act (the default `metaOf`): `ip` =
`cf-connecting-ip` → the LAST `x-forwarded-for` entry → `x-real-ip` → the socket; the raw
`x-forwarded-for`, `user-agent` (≤ 512), `cf-ipcountry`, `accept-language`.
## Stripe self-management
Runs in `initialize()` of a managed gateway with `bootstrap` (default), after the context is
ready, and on a forced `stripeBootstrapOf(ctx).bootstrapStripe` from any managed process; each step independent: products
and prices (above), the portal configuration, the webhook endpoint. A deployment's identity is its
webhook URL (`webhookOf(ctx).webhookUrlOf()`: the `webhookService` alias's host and base); its rows carry the
`owner`.
- **The portal configuration**: customer update (tax id; email unless `stripe.lockCustomerEmail`;
address unless `mechanisms.countryLock` or `stripe.lockCustomerCountry`), invoice history,
payment method update, cancellation at period end without proration, price switching between the
active recurring prices. Its fingerprint covers the catalogue (incl. `currencyPrices`), the lock
flags, the region currencies, the branding and the deployment key. Each deployment owns its own configuration, tagged `{ owlmeans: 'payment',
service: <owner>, deployment: webhookOf(ctx).webhookUrlOf() }`; one tagged for another deployment (a former
webhook URL included) is never touched — a moved URL creates a new configuration.
- `portalLink(ctx, entityId, { flow, planSku?, returnUrl })`: a customer is required
(`PortalUnavailable`, 409); `Cancel`/`Update`/`Change` need an entitling Stripe subscription.
- **The webhook endpoint** at `webhookOf(ctx).webhookUrlOf()`, subscribed to `WEBHOOK_EVENTS`, on the API version
read back from the client; only an https public host. A deployment deletes only the endpoints its
own rows name. A new URL (a moved host, a new `webhookService`) creates the endpoint there, then
deletes the endpoints of the owner's rows at other URLs and those rows — no migration step. The
secret (create-only) is stored field-encrypted where the database has a key; the receiver reads
the newest row of its `owner`.
- **Do not bump the Stripe SDK** (17.x, API `2025-02-24.acacia`): `current_period_*`,
`invoice.subscription`, `invoice.payment_intent`, `charge.invoice` are top-level there and move
later; credit notes link a refund by `refund`; `presentment_details` is untyped. Read them through
narrow accessors.
- **Dashboard prerequisites** (test and live): a terms-of-service URL in Settings → Public details
before `checkoutTerms` is on (without it every checkout falls back to no checkbox — payments go on,
but the acceptance is not collected: watch for `checkout-terms-fallback` events); Checkout's return/refund
policy off or pointing at the Billing Terms, never "no refunds"; business name, address and
support e-mail in Public details; the "successful payments" and "refunds" customer e-mails.
## Event dispatch
| Event | Persisted | Observer · event key |
|---|---|---|
| `customer.created` / `.updated` | customer upsert (`country`, `currency`) | — |
| `customer.deleted` | customer `deletedAt` | — |
| `checkout.session.completed` / `.async_payment_succeeded`, `payment` mode, paid | lock + purchase + confirmation mail, fulfillment (+ evidence), `fulfilledAt` after observers | `onTopUp` · session id; plugins `settled('paid')` |
| same, `subscription` mode | subscription applied if no webhook did (purchase in its first commit), purchase refined, lock, subscription evidence, confirmation mail | plugins `settled('paid')` |
| `checkout.session.async_payment_failed` | fulfillment `failedAt` | `onPaymentFailed {kind:'checkout'}` · `payment-failed:<session>:0`; `settled('failed')` |
| `checkout.session.expired` | unfulfilled fulfillment purged | plugins `settled('expired')` |
| `customer.subscription.created` / `.updated` / `.pending_update_*` / `.paused` / `.resumed` | subscription applied (+ `currency`) | `onSubscription` · classified |
| `customer.subscription.deleted` | applied as Canceled + `endedAt` | `canceled` |
| `customer.subscription.trial_will_end` | applied | `trial-ending` |
| `invoice.paid` | cycle invoice ⇒ re-read, applied as a renewal; otherwise `latestInvoiceId` | `renewed` |
| `invoice.payment_failed` / `.payment_action_required` | re-read and applied | classified, then `onPaymentFailed {kind:'invoice'}` · `payment-failed:<invoice>:<attempt>` |
| `invoice.upcoming` | nothing | — |
| `invoice.marked_uncollectible` / `invoice.voided` | re-read and applied / `latestInvoiceId` | classified / — |
| `charge.refunded`, `refund.created` / `.updated` (succeeded) | fulfillment `refundedMinor`/`refundedAt`; purchase `refundedMinor`, `refundedAt` on a whole refund | `onRefund` · `refund:<refund>` (`metadata`, `withdrawalId`) |
| `refund.failed` | — | — |
| `charge.dispute.*` | target `disputedAt`, `disputeStatus` | `onDispute {phase}` · `dispute:<dispute>:<phase>` |
A refund or dispute resolves to its target by payment intent (fulfillment), by charge, by invoice
(the subscription whose latest invoice it is, else the invoice's subscription); a subscription
refund touches the purchase only when it is of the purchase's own (first) invoice.
## Observer API and idempotency keys
| Callback | Payload | Key | A throw |
|---|---|---|---|
| `onTopUp` | `TopUpCompletion` | session id | Stripe redelivers |
| `onSubscription` | `SubscriptionEvent` | `subscription:<id>:<change>:…` | Stripe redelivers |
| `onRefund` | `RefundEvent` (+ `metadata`, `withdrawalId`) | `refund:<refund>` | Stripe redelivers |
| `onDispute` | `DisputeEvent` | `dispute:<dispute>:<phase>` | Stripe redelivers |
| `onPaymentFailed` | `PaymentFailedEvent` | `payment-failed:<session\|invoice>:<attempt>` | Stripe redelivers |
| `onConsent` | `ConsentEvent` (`kind`, `consentId`, `purchaseIds`, `planSku`) | `consent:<id>` | recorded; `reconcile` retries |
| `onWithdrawal` | `WithdrawalEvent` (`status`, `purchase: PurchaseRef`, `refund`, `units`, `subscriptionCanceled`) | `withdrawal:<id>` | recorded; `reconcile` retries |
| `onCancellation` | `CancellationEvent` (`matched`, `kind`, `status`, `effectiveAt`) | `cancellation:<id>` | recorded; `reconcile` retries |
Every callback must be idempotent by its key. A proportional claw-back of an ordinary top-up
refund uses `netAmountMinor * refundedTotalMinor / paidMinor`.
## Testing
Unit specs run a real server context — real catalogue, services and gates — over in-memory
resources (unique and sparse-unique indexes, raw conditional updates) and a fake Stripe that records
every SDK call and its request options (idempotency keys) and can fail a method's next calls
(`state.failures`: a message, a real SDK error such as `new Stripe.errors.StripeInvalidRequestError(…)`,
or a list of them, one per call), so "no Stripe call" is an assertion. `makeFakeContext` wires the
consumer-rights call before the gateway by default (`gatewayFirst`, `rights` and a pre-init `wire`
hook test the other orders); `gateway` passes the gateway's options (default `{ manage: false }`,
the fake always its Stripe client, so a managed one boot-bootstraps against it), `service` /
`host` / `base` / `services` shape `cfg.service` and `cfg.services`, and `seed` pre-fills stores
(a database shared between two processes). `gate-handlers.spec.ts` binds `paymentGateHandlers`
onto a re-declared, pinned gate and runs it in that process's context. The consumer-rights service there is
managed through the fake (`appendConsumerRights({ manage: true, stripe })`) with a console mailer
(`fake.mails`). The Mongo-gated specs prove admission under concurrency, the collection validators
of every record, the unique indexes, the concurrent first lock and the single winner of
concurrent withdrawals.
## External docs
- https://docs.stripe.com/api/checkout/sessions/create — `customer`: "If the Customer already has a valid email set, the email will be prefilled and not editable in Checkout" (else Checkout saves the typed one); `customer_email` only prefills a NEW customer and cannot be combined with `customer`. 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.
- https://docs.stripe.com/tax/customer-locations — minimal tax location: country alone except US (postal code), CA and IN (postal code or province); https://docs.stripe.com/tax/checkout/page — an existing customer with `customer_update.address: 'never'` is taxed on its saved address. Verified against test mode (2026-10): a pinned DE customer keeps its VAT when the card country is switched to US; a country-only US/CA customer is refused at session creation.
- 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.
- 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.
- https://docs.stripe.com/api/checkout/sessions/create?query=adaptive_pricing — `adaptive_pricing.enabled` defaults to the Dashboard setting when omitted; send an explicit boolean for every session.
- https://docs.stripe.com/payments/klarna — Klarna eligibility depends on currency, customer location and purchase use case; verify the account's supported currencies before explicitly requesting it.
- https://docs.stripe.com/invoicing/multi-currency-customers — a customer's subscriptions share one currency; one-time payments may differ.
- 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.
- 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.
- 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.
- https://docs.stripe.com/api/subscriptions/cancel and /update — `cancel(prorate, invoice_now, cancellation_details)`; `update(cancel_at_period_end | cancel_at, proration_behavior)`.
- 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.
- https://docs.stripe.com/checkout/fulfillment — fulfillment must be idempotent and support delayed-payment success events.
- https://docs.stripe.com/api/webhook_endpoints/create — the signing `secret` is returned only by create; `api_version` is create-only.
- https://docs.stripe.com/api/customer_portal/configurations/create — `features.customer_update.allowed_updates`, subscription cancel/update features; configurations are never deletable.
- https://docs.stripe.com/api/tax/calculations/create — `percentage_decimal` is a STRING; parse it exactly.
- https://docs.stripe.com/api/fx_quotes/create — a PREVIEW endpoint (`stripe.rawRequest`); `reference_rate` for settlement conversion.
## Related
- [[entitlements]] — the model across packages
- [[payment]] — the contracts: grammars, views, consumer-rights calculators, copy, refusals, factories
- [[web-payment]] — hooks and pieces, the consumer-rights dialogs and functions
- [[mailer]] — the mail transport the consumer-rights mails go through
- [[mongo-resource]] — raw collection access, duplicate-key detection, field locking