Files
keysat-root/docs/guides/payments.md
T
Keysat f574f025a6 Restructure AGENTS.md into scoped guides
Trim AGENTS.md to whole-repo, every-session facts (154 -> 110 lines) and move
subsystem guidance into docs/guides/*.md, each with paths: frontmatter and a
one-line index entry in AGENTS.md. Symlink each guide from .claude/rules/ so
Claude Code lazy-loads it by matching path; track those symlinks via a
.gitignore exception (.claude/settings.local.json stays ignored).
2026-06-12 19:39:41 -05:00

4.6 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 — there is no mock seam in this path, so integration tests can't drive it with MockPaymentProvider without one. The legacy state.payment arc is a compat shim and is NOT consulted by the new resolver.

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.