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

5.4 KiB
Raw 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.

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). 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.