6d4efc8a33
Update Current state for the two P1 fixes done this session (source-only, awaiting :55). Document the advisory settle-amount tripwire in payments.md. Add Open TODOs: split audit:read into its own scope tier, and build the admin API-keys management panel (both deferred to later sessions).
121 lines
6.7 KiB
Markdown
121 lines
6.7 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.
|
||
|
||
**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).
|