--- 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. **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`. ## 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 **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).