Files
keysat-root/docs/guides/payments.md
T
Keysat 47db41a238 Handoff: ship 0.2.0:58 agent-payment-connect; document the connect gate
Current state rewritten to :58-shipped (both onboarding stages completed-clean,
validated separately); payments guide gains the scoped (agent) BTCPay connect
sandbox-gate section (two-gate fail-closed design, migration 0025, GET-callback
status gotcha, regtest validation facts); guide index flags it for the connect
gate + migrations 0024-0025.
2026-06-17 10:49:36 -05:00

158 lines
9.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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`. Companion email plan:
`plans/keysat-smtp-emails.md`.
## Model
One Keysat instance can host multiple businesses. The data model (migrations
00200022):
```
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, optional per-profile SMTP override fields. 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:156175`.
- 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 00200022 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).