Files
keysat-root/docs/guides/payments.md
T
Keysat 6d4efc8a33 Record scoped-keys + settle-tripwire work; document boundary and TODOs
Update Current state for the two P1 fixes done this session (source-only,
awaiting :55). Document the advisory settle-amount tripwire in payments.md. Add
Open TODOs: split audit:read into its own scope tier, and build the admin API-keys
management panel (both deferred to later sessions).
2026-06-13 00:10:53 -05:00

121 lines
6.7 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.
**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`.
## 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 **not** guaranteed at runtime: `PRAGMA foreign_keys = ON` in a
migration is connection-scoped. Confirm the pool sets it per-connection before
relying on cascade/constraint behavior (e.g. deleting a profile with products).
- 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).