# Keysat multi-merchant-profile + multi-provider model ## Context Today both `btcpay_config` and `zaprite_config` are singleton rows (`id INTEGER PRIMARY KEY CHECK (id = 1)`), and `SETTING_ACTIVE_PROVIDER` picks one of the two as the daemon's process-wide active provider. Every call to `state.payment_provider()` returns that one provider, regardless of which product is being sold. There's no concept of a "merchant" or "seller" identity anywhere — every product on a Keysat instance is implicitly being sold by the same legal entity, through the same payment account, with the same branding and the same post-purchase landing page. That model breaks the moment a software author wants to run ONE Keysat instance for multiple distinct businesses. Concrete example: one operator sells **Recaps** licenses (settled to a Recaps Zaprite org, branded as Recaps, buyers redirected to `recaps.cc`) AND **Keysat** licenses (settled to a Keysat Zaprite org, branded as Keysat, buyers redirected to `keysat.xyz`). Today's architecture forces both to share one merchant identity, one provider account, one branding set, one redirect URL. This plan introduces a **merchant profile** layer that owns business identity, branding, redirect, optional SMTP, AND a set of payment providers. Products attach to a merchant profile (not directly to a provider). The buyer sees the merchant profile's brand at checkout and picks a payment rail from the providers attached to that profile. Tier-gating: Creator (free) gets 1 merchant profile. Pro/Patron get N. ## Why this shape, not the simpler "per-product provider override" An earlier draft of this plan had products carry a nullable `payment_provider_id` override directly. That worked but conflated two concerns: **business identity** (who's selling this? what's the brand? where do buyers land?) and **payment routing** (which Stripe / Bitcoin account receives the money?). In practice an operator running two businesses wants every product of business A to share a brand AND a set of payment accounts AND a redirect; copying those fields onto every product of that business would be redundant and error-prone. Merchant profile cleanly separates the two: - **Profile** = the business identity (brand, redirect, support contact, optional SMTP), and the set of payment providers that legally settle TO that business. - **Provider** = the technical credential to a specific payment account (BTCPay store + API key, OR Zaprite org + API key). One provider per account; a profile can have many providers (e.g. BTCPay for Bitcoin AND Zaprite for card). - **Product** = what's being sold, attached to one profile. This also makes **buyer-currency routing** (previously a deferred future- consideration) fall out for free: the buy page shows which payment rails the product's profile supports, the buyer picks Bitcoin / Lightning / Card, we route through the right provider. ## Design overview ### Data model ``` merchant_profiles (1) ──────< (N) payment_providers (1) ──────< (N) products (1) ──────< (N) subscriptions [snapshot on create] ``` - Exactly one `merchant_profiles.is_default = 1` row. Auto-created on first boot after upgrade, populated from existing `SETTING_OPERATOR_NAME`. New operators get one default profile too. - Each `payment_provider` belongs to exactly ONE profile (`merchant_profile_id NOT NULL`). Providers move with the business. - Each product has `merchant_profile_id NOT NULL`. Default to the default profile. - Each subscription snapshots BOTH `merchant_profile_id` and `payment_provider_id` at creation, so mid-cycle changes to the product don't silently redirect existing subs to a different business or rail. ### Buy-page resolution at runtime 1. Buyer hits `/buy/`. 2. Server loads product → product's merchant profile → profile's attached providers. 3. Buy page renders the merchant profile's brand (name, color, support link), the product's price/tiers, and a payment-method picker exposing every rail the attached providers offer (Lightning, on-chain, card, etc.). 4. Buyer picks a rail. The picker resolves to one provider (e.g., "Card" → the Zaprite provider attached to this profile). 5. Server calls `state.payment_provider_for(profile_id, rail)` → `create_invoice` on that provider. 6. After settle, redirect goes to profile's `post_purchase_redirect_url` if set, else Keysat's default `/thank-you?invoice_id=…` page. ### Subscription renewal resolution Renewal worker reads `sub.payment_provider_id` and `sub.merchant_profile_id` from the subscription snapshot — never re- resolves from the product. This protects existing buyers from operator edits. ## Tier gating - **Creator (free)** — exactly 1 merchant profile. Auto-created on first boot; can be edited but not deleted, and the create-profile endpoint refuses with 402 + upgrade URL if a Creator already has one. - **Pro / Patron** — unlimited merchant profiles. Same cap-resolution pattern as the existing `unlimited_products` / `unlimited_policies` entitlements (`tier::current()` returns the cap; admin endpoint checks before insert; the existing tier-cap modal shows the upgrade CTA). New entitlement string: `unlimited_merchant_profiles`. Master Keysat's Pro and Patron policies need this added to their entitlement lists; the master operator's self-license then signs with it baked in. ## Schema (migration 0020) ```sql PRAGMA foreign_keys = ON; CREATE TABLE merchant_profiles ( id TEXT PRIMARY KEY, -- UUID v4 name TEXT NOT NULL, -- "Recaps", "Keysat" legal_name TEXT, -- optional, for receipts/tax support_url TEXT, support_email TEXT, brand_color TEXT, -- hex post_purchase_redirect_url TEXT, -- NULL = Keysat default /thank-you is_default INTEGER NOT NULL DEFAULT 0, -- Optional per-profile SMTP override. NULL means inherit the -- StartOS-level / Keysat-singleton SMTP config. Lets a Pro/Patron -- operator running 3 businesses send emails from 3 different -- domains/senders WITHOUT having to configure 3 separate StartOS -- accounts. Pairs with the keysat-smtp-emails plan. smtp_host TEXT, smtp_port INTEGER, smtp_username TEXT, smtp_password TEXT, -- encrypted at rest (TBD) smtp_from_address TEXT, smtp_from_name TEXT, smtp_use_starttls INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, CHECK (is_default IN (0, 1)), CHECK (smtp_use_starttls IN (0, 1)) ); CREATE UNIQUE INDEX idx_merchant_profiles_one_default ON merchant_profiles(is_default) WHERE is_default = 1; CREATE TABLE payment_providers ( id TEXT PRIMARY KEY, -- UUID v4 merchant_profile_id TEXT NOT NULL REFERENCES merchant_profiles(id), kind TEXT NOT NULL, -- 'btcpay' | 'zaprite' label TEXT NOT NULL, -- "Recaps BTCPay", operator-set api_key TEXT NOT NULL, base_url TEXT NOT NULL, webhook_id TEXT, webhook_secret TEXT, -- BTCPay HMAC; NULL for Zaprite store_id TEXT, -- BTCPay only connected_at TEXT NOT NULL, updated_at TEXT NOT NULL, CHECK (kind IN ('btcpay', 'zaprite')) ); CREATE INDEX idx_payment_providers_profile ON payment_providers(merchant_profile_id); -- Within a profile, at most one provider of each kind (avoid the -- two-BTCPay-providers-same-business confusion): CREATE UNIQUE INDEX idx_payment_providers_profile_kind ON payment_providers(merchant_profile_id, kind); ALTER TABLE products ADD COLUMN merchant_profile_id TEXT REFERENCES merchant_profiles(id); CREATE INDEX idx_products_profile ON products(merchant_profile_id); ALTER TABLE subscriptions ADD COLUMN merchant_profile_id TEXT REFERENCES merchant_profiles(id), ADD COLUMN payment_provider_id TEXT REFERENCES payment_providers(id); CREATE INDEX idx_subs_profile ON subscriptions(merchant_profile_id); CREATE INDEX idx_subs_provider ON subscriptions(payment_provider_id); ``` ### Migration body (data port) ```sql -- 1. Create the default merchant profile from existing operator settings. INSERT INTO merchant_profiles( id, name, support_url, support_email, brand_color, post_purchase_redirect_url, is_default, created_at, updated_at ) SELECT lower(hex(randomblob(16))), COALESCE((SELECT value FROM settings WHERE key='operator_name'), 'Keysat'), NULL, NULL, NULL, NULL, 1, datetime('now'), datetime('now'); -- 2. Migrate btcpay_config → payment_providers (if present), attached to default. INSERT INTO payment_providers( id, merchant_profile_id, kind, label, api_key, base_url, webhook_id, webhook_secret, store_id, connected_at, updated_at ) SELECT lower(hex(randomblob(16))), (SELECT id FROM merchant_profiles WHERE is_default = 1), 'btcpay', 'BTCPay (migrated)', api_key, base_url, webhook_id, webhook_secret, store_id, connected_at, connected_at FROM btcpay_config; -- 3. Same for zaprite_config. INSERT INTO payment_providers( id, merchant_profile_id, kind, label, api_key, base_url, webhook_id, webhook_secret, store_id, connected_at, updated_at ) SELECT lower(hex(randomblob(16))), (SELECT id FROM merchant_profiles WHERE is_default = 1), 'zaprite', 'Zaprite (migrated)', api_key, base_url, webhook_id, NULL, NULL, connected_at, connected_at FROM zaprite_config; -- 4. Drop the old singleton tables. DROP TABLE btcpay_config; DROP TABLE zaprite_config; DELETE FROM settings WHERE key = 'active_payment_provider'; -- 5. Backfill products + subscriptions to point at the migrated default. UPDATE products SET merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1); UPDATE subscriptions SET merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1), payment_provider_id = ( SELECT id FROM payment_providers WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1) AND kind = ( -- Resolve the original provider for back-compat. Use whichever -- provider the active-provider setting pointed at; fall back to -- BTCPay if both exist (preserves existing single-provider -- operator behavior). CASE WHEN EXISTS (SELECT 1 FROM payment_providers WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1) AND kind = 'btcpay') THEN 'btcpay' ELSE 'zaprite' END ) ); ``` Additive-then-cutover; not reversible without a backup restore. Release notes call this out as a one-way migration (consistent with the v0.2.0:1+ migration framing). Existing single-provider operators see no behavior change — their setup becomes "1 profile + 1 provider," products and subs all attach to that profile. ## Rust changes **`src/merchant_profiles.rs`** (new): - `pub struct MerchantProfile` mirroring a row. - `pub async fn list(pool) -> Vec`, `get_by_id`, `get_default`, `create`, `update`, `delete`. - Tier gate inside `create` — refuses with `AppError::TierCap` (existing type that triggers the 402 + upgrade modal) when Creator already has one profile. **`src/payment/mod.rs`**: - `pub struct PaymentProviderConfig` mirroring a row (now includes `merchant_profile_id`). - `pub fn build_provider(cfg: &PaymentProviderConfig) -> Arc` factory dispatching on `kind`. - `repo::list_providers_for_profile(pool, profile_id)`, `repo::get_provider_by_id`, etc. - Drop `SETTING_ACTIVE_PROVIDER` constant + `read/write_active_provider_preference`. - Add a "rail" concept: each provider impl declares which payment rails it offers (`enum Rail { Lightning, OnChain, Card, … }`). The buy page uses this to render the payment-method picker. **`src/api/mod.rs` (AppState)**: - Replace single `payment_provider` accessor with two: - `state.merchant_profile_for(product_id) -> MerchantProfile` - `state.payment_provider_by_id(provider_id) -> Arc` - Add a cache `provider_cache: Cache>` keyed by provider id; invalidated on connect/disconnect/edit. **Call sites that switch**: - `src/api/purchase.rs` — resolve product → profile, then accept a rail selection from the request body (or default to "first rail" if product has no override), then build the provider; pass `merchant_profile_id` AND `payment_provider_id` to subscription create. - `src/api/upgrade.rs` — derive from license → product → profile; pick the same rail the original sub used (snapshot). - `src/subscriptions.rs` — use `sub.payment_provider_id` snapshot (already planned in the prior multi-provider draft); additionally use `sub.merchant_profile_id` to load redirect URL / branding for the renewal webhook payload. - `src/tipping.rs`, `src/reconcile.rs` — same pattern. **`src/api/webhook.rs`** — router unchanged from the prior multi-provider draft: `/v1/{kind}/webhook/{provider_id}` is still the path. The `provider_id` resolves to a profile transitively, and the webhook handler doesn't need to know about profiles (it just needs to validate the payload against the right provider's secret and update the right invoice). **`src/api/btcpay_authorize.rs` and `src/api/zaprite_authorize.rs`** — connect flows: - Connect now takes a `merchant_profile_id` query param. Default = the default profile if not specified. - Refuses if a provider of the same kind already exists on that profile (the unique index would error; we want a clean 409 with a helpful message: "Recaps already has a Zaprite provider — disconnect it first or connect to a different profile"). - "Disconnect" deletes the provider row; if it was the last provider on the profile AND that profile has active products/subscriptions, the admin UI prompt requires picking a replacement before delete. - New endpoint `POST /v1/admin/merchant-profiles/{id}` CRUD endpoints for profile management. ## Subscription snapshot semantics `subscriptions.merchant_profile_id` AND `subscriptions.payment_provider_id` are both set on create and never changed by product edits. If the operator later moves a product to a different merchant profile: - New purchases on that product create subscriptions tied to the NEW profile + provider. - Existing subscriptions keep renewing through their ORIGINAL profile + provider. - Trade-off: an admin "re-route this subscription" action exists as a manual flow (see "Mid-cycle subscription migration" in future considerations) — never automatic. ## Admin UI changes (in `web/index.html`) ### Settings → Merchant Profiles (new top-level page) - List view: table of profiles with name, default badge, attached provider kinds (icons for BTCPay / Zaprite), product count, action menu (edit, set default, delete). - "Add Merchant Profile" button: tier-gated. On Creator with 1 profile, the button shows the tier-cap upgrade modal instead. - Profile edit page: form with all the merchant_profile fields. Section for "Payment providers" listing attached providers with disconnect/ reconnect; "Connect BTCPay" and "Connect Zaprite" buttons within the profile (which is how the connect flow's `merchant_profile_id` query param gets populated). Section for optional per-profile SMTP override (collapsed by default; the keysat-smtp-emails plan covers the form fields in detail). - Delete profile: refused if the profile has any active products or unsettled subscriptions; otherwise prompts confirm. ### Existing Payment Providers page (rename → drop) - The current "Payment Providers" section on the Settings page is removed; provider config now lives INSIDE each merchant profile's edit page. There's no top-level "list all providers across all profiles" view — providers are scoped to their profile. ### Product create/edit page - New "Settle through" picker: - "Merchant profile" dropdown — required, defaults to the default profile. Lists all profiles the operator has configured. - Below it, an info chip showing which payment rails buyers will be offered ("Card via Zaprite + Lightning via BTCPay") based on the chosen profile's attached providers. If the profile has no providers, the operator sees an error: "This merchant profile has no payment providers connected — buyers can't pay until you add one." ### Buy page (`/buy/`) - Brand block at top renders the merchant profile's `name`, optionally with `brand_color` accent, and "Sold by {name}" subtitle. - Existing tier-card UI unchanged except for the payment-method picker: if the profile has 2+ providers, render a "Pay with" picker (Card / Lightning / Bitcoin) before the final Pay button. If only 1 provider, hide the picker (current behavior). - After payment, redirect respects profile's `post_purchase_redirect_url` if set; falls back to Keysat's default thank-you page. ## Files modified (estimated) - `migrations/0020_merchant_profiles.sql` (new) - `src/merchant_profiles.rs` (new) - `src/payment/mod.rs` — provider factory + rail concept + profile-aware accessors - `src/payment/btcpay.rs`, `src/payment/zaprite/{client,config,provider}.rs` — constructors take row config; declare rails - `src/api/{purchase,upgrade,webhook,btcpay_authorize,zaprite_authorize, merchant_profiles}.rs` — call sites + new CRUD endpoints - `src/api/mod.rs` (AppState) — provider cache + profile accessors - `src/db/repo.rs` — profile + provider repo helpers - `src/subscriptions.rs` — snapshot profile_id + provider_id - `src/tier.rs` — wire `unlimited_merchant_profiles` entitlement - `src/tipping.rs`, `src/reconcile.rs` — pass profile context - `web/index.html` — new Merchant Profiles section + product picker + buy-page profile branding + payment-method picker - `startos/versions/v0.2.0.ts` — bump (TBD which `:NN`), release notes flagging one-way migration + Creator tier cap ## Verification 1. **Unit/integration tests**: profile CRUD with tier gating (Creator hits cap, Pro/Patron doesn't); subscription snapshot stickiness across product moves; provider-by-rail resolution. 2. **Migration test**: copy a production-shape DB (BTCPay singleton + one product + one active sub + active_provider setting), run migrations, confirm: one default profile exists named per operator, one BTCPay provider attached, product points to default profile, sub points to both default profile + the migrated BTCPay provider. 3. **Sandbox end-to-end**: - On a Pro-tier instance, create two profiles: "Recaps" and "Keysat". - Connect a Zaprite sandbox org to Recaps; connect a different Zaprite sandbox org to Keysat. - Create a Recaps product, attach to Recaps profile. Create a Keysat product, attach to Keysat profile. - Buy each. Confirm: - Recaps purchase shows "Sold by Recaps" branding on buy page, redirects to Recaps's `post_purchase_redirect_url`, settles to the Recaps Zaprite dashboard, webhook hits `/v1/zaprite/webhook/{recaps-provider-id}`. - Keysat purchase shows Keysat branding, redirects to Keysat thank-you, settles to Keysat Zaprite dashboard. 4. **Multi-rail per profile**: - On the Recaps profile, also connect BTCPay (separate provider). - Buy the Recaps product again. Buy page now shows "Pay with Card" and "Pay with Lightning" picker. - Pick card → Zaprite. Pick Lightning → BTCPay. Confirm each settles to the correct dashboard. 5. **Creator tier cap**: - On a Creator-tier instance, attempt to create a second profile. - Expect 402 + upgrade modal pointing at the master Keysat upgrade URL. 6. **Backward-compat smoke**: - Existing operator upgrades from `:45` to this version. Confirm they see one profile auto-created with their existing operator name + connected providers attached + all products + subs linked through. - Webhook delivered to OLD `/v1/zaprite/webhook` URL (no provider id) still settles via the migrated default provider. ## Sequencing This is a bigger change than the original multi-provider draft. Feasible in one cycle but the admin UI surface is meaningful. Two reasonable options: **Option A — single cut.** All schema + Rust + admin UI in one release. Cleaner UX (no half-shipped intermediate states) but a heavier cut. Estimate: 2–3 focused days. **Option B — two cuts.** - Cut 1: Schema migration + Rust resolution layer + buy-page resolution. Admin UI stays mostly unchanged (one auto-created profile, existing payment providers page shows the default profile's providers). Lower risk; existing operators see no UX change. - Cut 2: Full admin UI surface (Merchant Profiles top-level page, per-profile connect flows, profile picker on product page). Layered on after Cut 1 is stable. I'd lean toward **B** — the data-model and resolution-layer changes deserve their own focused cycle, and the UI work benefits from real operator feedback on the new shape before we commit to the admin UX. ## Future considerations — still on the roadmap The merchant-profile shape makes most of these straight extensions. Listed roughly in order of "smallest leap from this foundation." ### 1. Buyer-currency routing — natively supported **What this plan already does.** A profile with multiple providers already exposes a multi-rail picker on the buy page. Currency routing is the same shape: buyer picks USD → routes through the profile's card-capable provider (Zaprite); picks BTC → routes through Bitcoin-capable (BTCPay). No follow-up work needed beyond the multi-rail picker. **What's left for a polish pass.** Currency-aware tier card pricing (if a product is listed in USD but the buyer picks BTC, show the sat-equivalent live). Already partly there with the multi-currency work in v0.1.0:43+; the remaining piece is the buy-page rate display. ### 2. Per-policy provider — "free tier no payment, paid tiers via Stripe" **Value.** A product with `Free`, `Pro $5/mo` and `Patron 50000 sats/mo` policies could route the paid policies through different providers. Free tier has no provider at all (today it falls through to whichever is configured + a $0 invoice). **What changes from this plan's foundation.** Add a nullable `payment_provider_id` to `policies` (a SECOND override). Resolution order: policy override → product's profile → buyer's rail pick. **Complexity.** Mostly UI (the policy edit modal gets a "settle through" picker). Half a day on top of this foundation. Probably not needed for v1 — the per-profile-multi-provider model handles most cases. ### 3. Mid-cycle subscription migration — "move my subs to a new merchant" **Value.** Operator changes payment processors (e.g., switches a profile's Zaprite org) and wants existing recurring subs to re-attach to the new provider/profile. **What changes from this plan's foundation.** New admin endpoint `POST /v1/admin/subscriptions/{id}/migrate` that updates the sub's `merchant_profile_id` and/or `payment_provider_id`, drops the captured `zaprite_payment_profile_id` (which is scoped to the old org), and either prompts the buyer to re-save their card OR issues a one-time invoice on the new provider to capture a fresh profile on settle. **Complexity.** The data model is trivial — the hard part is the buyer-communication step. Recommended to defer until a real operator asks. Half a day for endpoint + UI. ### 4. Auto-failover within a merchant profile **Value.** A profile has BTCPay primary + Zaprite warm-standby. If BTCPay's webhook deliveries start failing or its API is unreachable, Keysat automatically routes the next purchase through Zaprite without manual intervention. **What changes.** New `payment_providers.fallback_provider_id` (nullable FK to another provider on the SAME profile). Purchase flow tries the primary; on `create_invoice` failure (network, 5xx, timeout), retries with the fallback. Health-check loop also pings each provider periodically and records last-known-good status for admin UI. **Complexity.** Genuine — failure detection is the hard part. 1–2 days for the naive version, more for proper circuit-breaker logic. Lower priority because most operators tolerate manual intervention for brief outages. ### 5. Multi-tenant Keysat boxes — "Keysat as a service" **Value.** Separate business shape: a SaaS provider runs ONE box that hosts licensing for many INDEPENDENT operators (each with their own merchant profiles, products, customers, branding, auth). Different product than the operator-owned licensing server Keysat is today. **What changes.** Almost everything — every table needs a `tenant_id` (or `operator_id`), auth becomes per-tenant, the admin UI becomes a tenant-scoped view, etc. **Recommendation.** Don't plan against this. If the SaaS shape ever becomes interesting, it's a fork or v2.0 product, not a layered-on feature. The merchant-profile schema we're building IS however a useful foundation IF that pivot happens, because per-tenant billing already maps to per-tenant set-of-merchant-profiles. --- The merchant-profile foundation we're shipping in this cycle is deliberately shaped to make 1–3 above straight extensions. 4 is a different kind of work (failure detection, not data model). 5 is a different product. ## How this composes with the SMTP / operator-alerts plan See `/Users/macpro/.claude/plans/keysat-smtp-emails.md` (companion plan). - Per-profile SMTP override fields (in this migration) replace what the SMTP plan originally placed on `products`. Email branding is business-level, not product-level, so it belongs on `merchant_profiles`. - The SMTP plan's "buyer transactional emails" opt-in becomes a per-profile flag (`merchant_profiles.keysat_sends_buyer_emails`) rather than per-product. Master Keysat's profile defaults it to on; Recaps profile defaults it off (Recaps handles its own buyer emails via webhooks). - The SMTP plan's operator alerts are unchanged — those go to the operator personally (StartOS-level email), independent of merchant profiles. Migration order: ship this plan first (merchant_profiles table exists), then the SMTP plan layers per-profile SMTP + email settings on top of the new table.