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).
4.6 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 — 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: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.