Files
keysat-root/docs/guides/payments.md
T
Keysat ce5edaed29 Record product→merchant-profile write path; refresh Current state
Document the now-functional product→profile write path in the payments
guide (set_product_merchant_profile, post-write pattern, picker gating,
double-Option clear). Mark the multi-profile GAP closed, drop the done
work-queue item, and note the discovered set_product_entitlements_catalog
rows_affected gap.
2026-06-15 21:40:25 -05:00

131 lines
7.4 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`.
## 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).