Files
keysat-root/docs/guides/payments.md
T

103 lines
5.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.
**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). **Not yet
done**: a literal paid-amount/currency check (the trait exposes only a status
enum); trusting the provider's own `Settled` determination is the current
boundary — see the auditor's open P1.
## 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).