103 lines
5.4 KiB
Markdown
103 lines
5.4 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). **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: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).
|