47db41a238
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.
158 lines
9.3 KiB
Markdown
158 lines
9.3 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`. Companion email plan:
|
||
`plans/keysat-smtp-emails.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, 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: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).
|