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).
6.7 KiB
paths
| paths | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
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) → providerwhen 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_*:
- explicit
merchant_profile_rail_preferencesentry wins; - else the single provider serving the rail;
- 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.
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:openBtcpayConnectModalinweb/index.html. - Discover BTCPay via the StartOS SDK, not hardcoded hostnames:
sdk.serviceInterface.getAll(effects, { packageId: 'btcpayserver' })— pattern atstartos/main.ts:156–175. - Zaprite is optional, gated by the
zaprite_paymentsentitlement; recurring auto-charge works viacharge_order_with_profile.
Migrations & gotchas
- Migrations 0020–0022 are one-way. 0020 ports the singleton
btcpay_config/zaprite_configintopayment_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_COLSwhile joining a table with a collidingidis the trap that shipped theget_merchant_profile_for_productbug; prefer a subquery or qualify columns.tests/api.rs::merchant_profile_provider_resolution_queries_round_tripexercises the resolution surface — keep it green when touching these queries. - FK enforcement is not guaranteed at runtime:
PRAGMA foreign_keys = ONin 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_profilesentitlement gates profile count (Creator = 1, Pro/Patron = unlimited). See licensing-tiers.