Files
keysat-plans/multi-provider-payment-model.md
2026-06-12 17:58:26 -05:00

655 lines
31 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
```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);
-- When a merchant profile has two providers that BOTH serve the same
-- payment rail (e.g., Recaps profile has both BTCPay and Zaprite, both
-- of which can settle Lightning), the operator picks a preferred
-- provider per rail here. The buy-page picker uses these preferences to
-- route the buyer's pick to the right provider. Rails a provider serves
-- are inherent to the provider kind (BTCPay → Lightning, OnChain;
-- Zaprite → Card, Lightning, OnChain), so the schema doesn't store
-- per-provider "served_rails" — the trait method `served_rails()`
-- returns that. The preference table only kicks in when there's
-- ambiguity within a profile.
CREATE TABLE merchant_profile_rail_preferences (
merchant_profile_id TEXT NOT NULL REFERENCES merchant_profiles(id),
rail TEXT NOT NULL, -- 'lightning' | 'onchain' | 'card'
payment_provider_id TEXT NOT NULL REFERENCES payment_providers(id),
PRIMARY KEY (merchant_profile_id, rail),
CHECK (rail IN ('lightning', 'onchain', 'card'))
);
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 = (
-- Pick whichever provider was attached to the default profile.
-- If both BTCPay and Zaprite were configured pre-migration,
-- the active-at-cut one wins (most likely whatever the operator
-- was actively using). Operators with both providers configured
-- can re-route any sub via the admin UI after the cut.
SELECT id FROM payment_providers
WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
AND kind = COALESCE(
(SELECT value FROM settings WHERE key = 'active_payment_provider'),
(SELECT kind FROM payment_providers
WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
ORDER BY connected_at ASC LIMIT 1)
)
);
```
One-way migration: not reversible without a backup restore. Release
notes flag this for the master operator (you) since you're the only
person running Keysat today — no third-party operators to worry about.
**Post-migration manual step for the master operator**: the existing
Zaprite webhook on your sandbox/prod dashboard is registered at
`https://licensing.keysat.xyz/v1/zaprite/webhook` (no provider id).
After the migration runs, the URL becomes
`/v1/zaprite/webhook/{provider-id}`. Update the webhook URL on the
Zaprite side; or, simpler, the connect-flow re-registers a fresh
webhook with the new URL when the operator next clicks "Reconnect" in
the admin UI. No legacy-URL shim in the router — Keysat isn't in the
wild yet so there are no pre-existing webhooks to preserve.
## 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. **Rail = the buyer-facing payment method**
(`enum Rail { Lightning, OnChain, Card }`), not "rail = provider."
This is the right shape for buyer UX (Card / Lightning / Bitcoin are
familiar choices; "Pay via Zaprite" isn't).
- Each provider impl declares which rails it serves via a new trait
method `fn served_rails(&self) -> Vec<Rail>`. BTCPay returns
`[Lightning, OnChain]`; Zaprite returns `[Card, Lightning, OnChain]`.
Rails-per-kind are hardcoded — they don't vary per row.
- The buy page renders the UNION of rails across the profile's
attached providers as the payment-method picker.
- When the buyer picks a rail, the routing layer:
1. Looks up `merchant_profile_rail_preferences(profile_id, rail)`
returns a specific provider id if the operator set a preference.
2. If no preference is set AND only one provider on the profile
serves that rail → use it directly.
3. If no preference is set AND multiple providers serve that rail →
use the first one by `connected_at` order, AND log a warning
(the admin UI shows the operator a "Set rail preference for
this profile" prompt to disambiguate explicitly).
**`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`** — replace the existing
`/v1/{kind}/webhook` routes with `/v1/{kind}/webhook/{provider_id}`.
The `provider_id` path segment resolves to a specific
`payment_providers` row, the handler validates the payload against
that row's secret and updates the right invoice. No legacy-URL shim
— since Keysat isn't in the wild yet, there's no third-party-operator
webhook config to preserve. The master operator updates their own
Zaprite webhook URL post-migration (see the manual step note in the
migration body above).
**`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:
the picker renders the union of rails served by the profile's
attached providers (see "rail concept" in the Rust section). When
the profile exposes a single rail (e.g., a Bitcoin-only seller with
just BTCPay → only `Lightning` + `OnChain` available), the picker
shows those two. When the profile exposes one provider serving one
rail only, hide the picker entirely.
- After payment, redirect respects profile's
`post_purchase_redirect_url` if set; falls back to Keysat's default
thank-you page.
**Zero-providers fallback.** If a product's merchant profile has NO
payment providers connected, the buy page refuses to render the payment
flow and shows a clear "This product isn't available right now —
contact the seller." message instead. No tier card, no Pay button.
The product's `merchant_profile_id` field is still NOT NULL (so
referential integrity holds); the empty-providers state is purely a
runtime UX concern. Same message rendered for any rail mismatch
(e.g., product attached to a profile that only serves Card, but the
buyer's region doesn't support card payments — corner case for later).
## 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 to `:52` (we're at `:51` as of
the just-pushed commit `4cde540`), release notes flag the one-way
migration + Creator tier cap + the post-migration manual webhook
URL update on the Zaprite side
## 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. **Master-operator upgrade path** (the only existing install):
- Your StartOS box at `:51` upgrades to this version. Confirm
migrations 0020 + run successfully on your existing data:
one auto-created default profile named per your operator name,
your existing Zaprite provider attached to it, your existing
products + subscriptions all linked through.
- After the upgrade, update your Zaprite webhook URL on the
Zaprite dashboard to the new
`/v1/zaprite/webhook/{provider-id}` form (or click "Reconnect
Zaprite" in the new Merchant Profiles UI to have Keysat
re-register the webhook with the right URL automatically).
## Sequencing
Single cut (`:52`). Since no operators are running Keysat in the wild
yet, there's no "preserve existing UX during the transition" concern
that would justify splitting into two releases — that was the main
reason an earlier draft proposed a phased approach. With the only
upgrade path being the master operator's own install, shipping the
full data-model + Rust + admin UI together makes the migration story
cleaner: one bump, one set of release notes, one post-migration
checklist (update your own Zaprite webhook URL).
Estimate: 23 focused days. The biggest single chunk is the admin UI
work — the new Merchant Profiles page, the per-profile-scoped
provider connect flows, the product-page picker, the buy-page
payment-method picker. The Rust data-model + resolution layer is
straightforward; the migration body is a handful of SELECT-then-
INSERT statements; the route refactor is a small Axum diff.
Suggested execution order within the single cut:
1. Migration 0020 + repo helpers (compiles, no behavior change yet).
2. Build out merchant-profile + provider lookup + AppState accessors;
port the existing call sites to use the new resolution path.
3. Webhook URL refactor + connect-flow `merchant_profile_id` param.
4. Admin UI: Merchant Profiles page first (CRUD), then product-page
picker, then buy-page branding + payment-method picker.
5. Tier-gate the create-profile endpoint + bake
`unlimited_merchant_profiles` into master Keysat's Pro/Patron
policies.
6. Bump to `:52`, write release notes, end-to-end sandbox test (the
verification plan above).
## 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 `./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.