Files
keysat-plans/multi-provider-payment-model.md
T

27 KiB
Raw Blame History

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

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)

-- 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<MerchantProfile>, 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<dyn PaymentProvider> 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<dyn PaymentProvider>
  • Add a cache provider_cache: Cache<String, Arc<dyn PaymentProvider>> 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/<slug>)

  • 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: 23 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. 12 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 13 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.