---
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).
