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).
This commit is contained in:
Keysat
2026-06-12 19:39:41 -05:00
parent 576213b0ce
commit f574f025a6
18 changed files with 449 additions and 88 deletions
+87
View File
@@ -0,0 +1,87 @@
---
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 (`kind``btcpay`|`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.rs``AppState::merchant_profile_for_product``resolve_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](licensing-tiers.md).