2f502dd4c1
payments.md: flag the smtp_* columns dormant and the companion email plan superseded. HOW_IT_WORKS.md predated recurring subscriptions and merchant profiles -- correct 'not a subscription service' (subscriptions ship on paid tiers), the single-tenant / one-box-one-business framing (merchant profiles let one instance host multiple businesses), and the 'short of an email' aside (Keysat never emails keys).
161 lines
9.5 KiB
Markdown
161 lines
9.5 KiB
Markdown
---
|
||
paths:
|
||
- "licensing-service-startos/licensing-service/src/payment/**"
|
||
- "licensing-service-startos/licensing-service/src/merchant_profiles.rs"
|
||
- "licensing-service-startos/licensing-service/src/btcpay/**"
|
||
- "licensing-service-startos/licensing-service/src/api/purchase.rs"
|
||
- "licensing-service-startos/licensing-service/src/api/btcpay_authorize.rs"
|
||
- "licensing-service-startos/licensing-service/src/api/zaprite_authorize.rs"
|
||
- "licensing-service-startos/licensing-service/src/api/payment_provider.rs"
|
||
- "licensing-service-startos/licensing-service/src/api/merchant_profiles.rs"
|
||
- "licensing-service-startos/licensing-service/src/subscriptions.rs"
|
||
- "licensing-service-startos/licensing-service/src/reconcile.rs"
|
||
- "licensing-service-startos/licensing-service/migrations/0020_merchant_profiles.sql"
|
||
- "licensing-service-startos/licensing-service/migrations/0021_invoice_provider_link.sql"
|
||
- "licensing-service-startos/licensing-service/migrations/0022_btcpay_state_profile.sql"
|
||
---
|
||
|
||
# Payments & the multi-provider / merchant-profile model
|
||
|
||
Full design spec: `plans/multi-provider-payment-model.md`. The companion email
|
||
plan `plans/keysat-smtp-emails.md` is **superseded** — Keysat does not send email;
|
||
operators receive events via webhooks and run their own email pipelines (see the
|
||
"Operability & alerts" item in `ROADMAP.md`).
|
||
|
||
## Model
|
||
|
||
One Keysat instance can host multiple businesses. The data model (migrations
|
||
0020–0022):
|
||
|
||
```
|
||
merchant_profiles (1) ──< (N) payment_providers
|
||
(1) ──< (N) products
|
||
(1) ──< (N) subscriptions [profile + provider snapshotted on create]
|
||
```
|
||
|
||
- **merchant_profiles** — business identity: name, branding, post-purchase
|
||
redirect. (The `smtp_*` columns from migration 0020 are **dormant** — never read
|
||
by any send path; the email plan was dropped.) Exactly one `is_default`,
|
||
auto-created at first boot from the operator-name setting.
|
||
- **payment_providers** — one row per configured BTCPay/Zaprite account, attached
|
||
to a profile (`kind` ∈ `btcpay`|`zaprite`).
|
||
- **merchant_profile_rail_preferences** — tie-breaker `(profile, rail) → provider`
|
||
when a profile has two providers serving the same rail.
|
||
|
||
Products attach to a profile via `repo::set_product_merchant_profile` (validates
|
||
the profile exists → 404, else UPDATE), called **post-write** from the create /
|
||
update handlers — same pattern as the entitlements catalog. The admin product form
|
||
renders the profile `<select>` only when >1 profile exists; a NULL
|
||
`merchant_profile_id` resolves to the default profile (`merchant_profiles::for_product`,
|
||
NOT the bare repo query, applies that fallback). On `UpdateProductReq` the field is a
|
||
double-Option: `Some(None)` clears back to default-resolution.
|
||
|
||
**Rails** are buyer-facing methods (`lightning`, `onchain`, `card`), declared per
|
||
provider kind via `rails_for_kind` (BTCPay → lightning+onchain; Zaprite →
|
||
card+lightning+onchain), NOT stored per row.
|
||
|
||
## Resolution (the production purchase path)
|
||
|
||
`purchase.rs` → `AppState::merchant_profile_for_product` → `resolve_provider_for_*`:
|
||
1. explicit `merchant_profile_rail_preferences` entry wins;
|
||
2. else the single provider serving the rail;
|
||
3. else earliest-`connected_at` (deterministic) + a warning.
|
||
|
||
`payment::build_provider(&row, ...)` constructs a **real** `BtcpayProvider` /
|
||
`ZapriteProvider` from the DB row. Tests swap in a mock via the always-compiled
|
||
`AppState::provider_override` seam (`None` in production), honored by
|
||
`AppState::provider_from_row` at every resolution site — see [testing](testing.md).
|
||
The legacy `state.payment` arc is a compat shim and is NOT consulted by the new
|
||
resolver.
|
||
|
||
## Webhook settle confirmation (anti-forgery)
|
||
|
||
Zaprite webhooks carry no signature, so the settle handler does **not** trust the
|
||
webhook body's claim. `api/webhook.rs::handle_inner` re-fetches
|
||
`provider.get_invoice_status` and requires `Settled` before persisting status or
|
||
taking ANY settle-derived action (license issuance, tier-change, subscription
|
||
renewal — the guard sits ahead of all of them). On a provider-API error it acks
|
||
`200` without issuing — the reconcile loop re-confirms and issues on its next tick
|
||
(fail-closed on issuance, and a 2xx avoids a provider retry-storm).
|
||
|
||
**Settle-amount tripwire (advisory, not a gate).** `get_invoice_status` returns a
|
||
`ProviderInvoiceSnapshot { status, amount }`; on a confirmed settle,
|
||
`audit_settle_amount` (shared by the webhook + reconcile issue paths) compares the
|
||
provider's reported sat amount against the invoice's `amount_sats` and, on
|
||
mismatch, logs a WARN + writes an `invoice.amount_mismatch` audit row — then
|
||
**issues anyway**. It is deliberately NOT a hard gate: a literal "amount actually
|
||
paid" check is redundant with the `Settled` requirement (both providers only
|
||
report `Settled` for a paid-in-full invoice — Zaprite maps `UNDERPAID` →
|
||
`Pending`), and a strict `paid >= owed` gate would false-reject operators running
|
||
a BTCPay payment tolerance (a deliberate config we must not second-guess). The
|
||
tripwire catches *drift* — settling a SAT invoice for an amount other than the
|
||
`amount_sats` we recorded. It applies ONLY to SAT-denominated settles: one-shot
|
||
purchases and SAT subscriptions always charge `Money::sats` (= `amount_sats`), but
|
||
fiat-priced subscription RENEWALS (`subscriptions.rs`) create the order in the
|
||
listed fiat currency, where `amount_sats` is not the charged amount — those report
|
||
a non-SAT currency and are **skipped** (no clean comparison basis; `Settled`
|
||
already covers them). `amount` is likewise `None`/skipped when the provider
|
||
response carries no parseable positive amount. Regression tests in `tests/api.rs`:
|
||
`settled_amount_mismatch_issues_license_but_audits`,
|
||
`settled_non_sat_settle_skips_amount_tripwire`.
|
||
|
||
## Provider connect
|
||
|
||
- **Use BTCPay's one-click authorize flow**, not hand-pasted keys. `POST
|
||
/v1/admin/btcpay/connect` → `{ authorize_url }`; operator opens it, BTCPay
|
||
callbacks `/v1/btcpay/authorize/callback`, daemon auto-detects store + registers
|
||
webhook. Reference: `openBtcpayConnectModal` in `web/index.html`.
|
||
- **Discover BTCPay via the StartOS SDK**, not hardcoded hostnames:
|
||
`sdk.serviceInterface.getAll(effects, { packageId: 'btcpayserver' })` —
|
||
pattern at `startos/main.ts:156–175`.
|
||
- Zaprite is optional, gated by the `zaprite_payments` entitlement; recurring
|
||
auto-charge works via `charge_order_with_profile`.
|
||
|
||
### Scoped (agent) BTCPay connect — the sandbox gate
|
||
|
||
Shipped `:58` (spec: `plans/agent-payment-connect-scope.md`). Provider connect is
|
||
master-only EXCEPT: a scoped key carrying the à-la-carte `payment_providers:write`
|
||
scope (in NO role, granted per-key via `extra_scopes`) may connect a BTCPay provider
|
||
**iff the daemon is in sandbox mode AND the target store is non-mainnet**. Disconnect,
|
||
mainnet, and any production daemon stay master-only — a key that can repoint settlement
|
||
is a fund-redirection key. Two gates, both fail-closed:
|
||
|
||
- **Outer (`api_keys.rs::require_provider_connect`)**: replaces `require_admin` on
|
||
`start_connect`. Master → any. Scoped+`payment_providers:write` → only if
|
||
`Config.sandbox_mode` (env `KEYSAT_SANDBOX_MODE`, boot-only, never API-settable).
|
||
- **Inner (`btcpay_authorize.rs::finish_connect`)**: the initiator (master vs scoped) is
|
||
carried across the OAuth round-trip in `btcpay_authorize_state` (migration 0025:
|
||
`scoped_initiator` + `initiator_actor_hash`). For a scoped initiator, the store network
|
||
is resolved from its on-chain receive address (`btcpay/network.rs::classify_address_network`
|
||
via `client::fetch_onchain_network`) and a mainnet/undetermined result is refused **before**
|
||
any webhook/persist. Fail-closed: no on-chain wallet, unreachable BTCPay, non-JSON, or
|
||
unknown prefix all → mainnet → deny. Scoped connects write an audit row.
|
||
|
||
Validated on a live regtest BTCPay (`onboarding-harness/stage2/`): the on-chain pmid is
|
||
`BTC-CHAIN` (BTCPay 2.x; legacy `BTC` is normalized to it — don't hardcode), the
|
||
`wallet/address` endpoint needs `btcpay.store.canmodifystoresettings` (which the daemon's
|
||
OAuth already requests), and a regtest address is `bcrt1…`. Gotcha: both callback forms
|
||
(GET + POST) must return the error's real HTTP status on a denied connect — the GET handler
|
||
uses `AppError::status_code()` so a refusal is a 4xx, not a misleading 200.
|
||
|
||
## Migrations & gotchas
|
||
|
||
- Migrations 0020–0022 are **one-way**. 0020 ports the singleton
|
||
`btcpay_config`/`zaprite_config` into `payment_providers` + creates the default
|
||
profile, then **drops** the old singleton tables.
|
||
- These repo queries are runtime-prepared — a bad/ambiguous column 500s only when
|
||
executed. A JOIN that selects the bare `MERCHANT_PROFILE_COLS` while joining a
|
||
table with a colliding `id` is the trap that shipped the
|
||
`get_merchant_profile_for_product` bug; prefer a subquery or qualify columns.
|
||
`tests/api.rs::merchant_profile_provider_resolution_queries_round_trip` exercises
|
||
the resolution surface — keep it green when touching these queries.
|
||
- FK enforcement is **ON**: the sqlx pool sets `.foreign_keys(true)` per
|
||
connection (`db/mod.rs:29`), so cascade/constraint behavior is safe to rely on
|
||
(e.g. deleting a profile with products). A bare `PRAGMA foreign_keys = ON` in a
|
||
migration would be connection-scoped and is not what guarantees this — the pool
|
||
option is.
|
||
- Known: after a `:52`+ install the master Zaprite webhook still points at the
|
||
legacy URL — works via back-compat, needs re-register for per-provider isolation.
|
||
- `unlimited_merchant_profiles` entitlement gates profile count (Creator = 1,
|
||
Pro/Patron = unlimited). See [licensing-tiers](licensing-tiers.md).
|