Files
Keysat 47db41a238 Handoff: ship 0.2.0:58 agent-payment-connect; document the connect gate
Current state rewritten to :58-shipped (both onboarding stages completed-clean,
validated separately); payments guide gains the scoped (agent) BTCPay connect
sandbox-gate section (two-gate fail-closed design, migration 0025, GET-callback
status gotcha, regtest validation facts); guide index flags it for the connect
gate + migrations 0024-0025.
2026-06-17 10:49:36 -05:00

9.3 KiB
Raw Permalink Blame History

paths
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 (kindbtcpay|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.rsAppState::merchant_profile_for_productresolve_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. 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 UNDERPAIDPending), 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.

Scoped (agent) BTCPay connect — the sandbox gate

Shipped :58 (spec: plans/agent-payment-connect-scope.md). Provider connect is master-only EXCEPT: a scoped key carrying the à-la-carte payment_providers:write scope (in NO role, granted per-key via extra_scopes) may connect a BTCPay provider iff the daemon is in sandbox mode AND the target store is non-mainnet. Disconnect, mainnet, and any production daemon stay master-only — a key that can repoint settlement is a fund-redirection key. Two gates, both fail-closed:

  • Outer (api_keys.rs::require_provider_connect): replaces require_admin on start_connect. Master → any. Scoped+payment_providers:write → only if Config.sandbox_mode (env KEYSAT_SANDBOX_MODE, boot-only, never API-settable).
  • Inner (btcpay_authorize.rs::finish_connect): the initiator (master vs scoped) is carried across the OAuth round-trip in btcpay_authorize_state (migration 0025: scoped_initiator + initiator_actor_hash). For a scoped initiator, the store network is resolved from its on-chain receive address (btcpay/network.rs::classify_address_network via client::fetch_onchain_network) and a mainnet/undetermined result is refused before any webhook/persist. Fail-closed: no on-chain wallet, unreachable BTCPay, non-JSON, or unknown prefix all → mainnet → deny. Scoped connects write an audit row.

Validated on a live regtest BTCPay (onboarding-harness/stage2/): the on-chain pmid is BTC-CHAIN (BTCPay 2.x; legacy BTC is normalized to it — don't hardcode), the wallet/address endpoint needs btcpay.store.canmodifystoresettings (which the daemon's OAuth already requests), and a regtest address is bcrt1…. Gotcha: both callback forms (GET + POST) must return the error's real HTTP status on a denied connect — the GET handler uses AppError::status_code() so a refusal is a 4xx, not a misleading 200.

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.