655 lines
31 KiB
Markdown
655 lines
31 KiB
Markdown
# 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: 2–3 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. 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 `./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.
|