04e0dcd591
Lays the schema + types + resolution layer for the merchant-profile-aware
multi-provider model documented in plans/multi-provider-payment-model.md.
Does NOT yet migrate any existing call site — legacy `state.payment_provider()`
and the singleton config tables continue to work via deprecation shims so
the daemon keeps running unchanged on this checkpoint.
This commit is intentionally a WIP foundation, not a shippable release —
no version bump, no release notes, no admin UI, no call-site migration.
A follow-up cycle ports purchase / subscriptions / reconcile / upgrade /
tipping to the new resolution layer, rebuilds the BTCPay + Zaprite connect
flows around merchant_profile_id, refactors webhook URLs to
/v1/{kind}/webhook/{provider_id}, ships the Merchant Profiles admin UI
section, wires the tier-cap, and bumps to :52 with the one-way migration
release notes.
What landed:
migrations/0020_merchant_profiles.sql
Full schema + data port + DROP of the singleton tables. Creates
merchant_profiles, payment_providers (FK to profile, unique per
(profile, kind)), merchant_profile_rail_preferences (tie-breaker
when a profile has 2 providers serving the same rail). Adds
merchant_profile_id to products + (merchant_profile_id, payment_provider_id)
to subscriptions for the snapshot-on-create semantics. Ports
btcpay_config + zaprite_config + active_payment_provider setting
into the new tables, then drops them. Master operator post-migration
step: update the Zaprite webhook URL on the Zaprite dashboard to
the new /v1/zaprite/webhook/{provider-id} form (or click Reconnect
Zaprite in the new UI once it ships).
src/merchant_profiles.rs (new module)
MerchantProfile struct + NewMerchantProfile + MerchantProfileUpdate
input types. Business-logic CRUD helpers: create, get, get_default,
require_default, list, update, set_default, delete, for_product.
Delete refuses if products or active subs are attached or if it's
the default profile. Tier-cap check stubbed with a TODO for the
next chunk's tier.rs wire-up.
src/db/repo.rs (+469 lines)
Repo helpers: create/get_by_id/get_default/get_for_product/list/
update/set_default/delete for merchant_profiles + count helpers
for products/active_subscriptions per profile. PaymentProviderRow
struct + create/get/list_for_profile/list_all/delete. RailPreference
struct + list/set/clear helpers. update_merchant_profile builds a
dynamic SET clause so partial updates don't clobber fields the
caller didn't touch.
src/payment/mod.rs
Rail enum (Lightning / Onchain / Card) + ProviderKind::parse +
rails_for_kind static mapping. build_provider(row, public_base) ->
Arc<dyn PaymentProvider> factory that dispatches on kind to construct
a typed BtcpayProvider or ZapriteProvider from a payment_providers
row. PaymentProvider trait gains a default served_rails() impl
returning rails_for_kind(self.kind()).
Deprecation shims: SETTING_ACTIVE_PROVIDER constant +
read_active_provider_preference + write_active_provider_preference
stay callable so btcpay_authorize/zaprite_authorize/main.rs/the
thank-you page still build. read_active_provider_preference now
reads from the new payment_providers table (returns the kind of
the first provider attached to the default profile), falling back
to the legacy settings-table read pre-migration. write_* is a no-op.
Each shim has a #[deprecated] attribute so the build surfaces
exactly which call sites still need porting (lit up in the
follow-up cycle's TODO).
src/api/mod.rs (AppState)
New methods alongside the existing payment_provider() shim:
- payment_provider_by_id(id) — looks up a row, builds the provider
- merchant_profile_for_product(product_id) — resolves via products.merchant_profile_id, falls back to default
- resolve_provider_for_profile_rail(profile_id, rail) —
preference table -> single candidate -> deterministic earliest-
connected with WARN. Returns (row, Arc<dyn PaymentProvider>).
- resolve_provider_for_product_rail(product_id, rail) — convenience
wrapping the previous two.
src/lib.rs
Registers the new merchant_profiles module.
Build state: cargo check passes. Only warnings are the pre-existing
unused-import in recover.rs and the deprecation lint firing on the
five legacy call sites enumerated in the WIP plan.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
243 lines
12 KiB
SQL
243 lines
12 KiB
SQL
-- Multi-merchant-profile + multi-provider model.
|
|
--
|
|
-- Replaces the singleton btcpay_config + zaprite_config + SETTING_ACTIVE_PROVIDER
|
|
-- pattern with a generalized two-table model:
|
|
--
|
|
-- merchant_profiles — one row per business identity (brand, redirect,
|
|
-- optional SMTP override). Creator tier: 1 profile.
|
|
-- Pro/Patron: unlimited.
|
|
-- payment_providers — one row per configured BTCPay/Zaprite account,
|
|
-- attached to a merchant profile via FK. A profile
|
|
-- can have multiple providers (BTCPay for Bitcoin
|
|
-- AND Zaprite for card). Unique per (profile, kind).
|
|
--
|
|
-- Products and subscriptions both get a merchant_profile_id column;
|
|
-- subscriptions additionally snapshot the payment_provider_id at creation
|
|
-- so mid-cycle product edits don't redirect existing buyers to a different
|
|
-- merchant or payment account.
|
|
--
|
|
-- One-way migration: drops btcpay_config + zaprite_config + the
|
|
-- active_payment_provider setting after porting their data into the new
|
|
-- tables. The master operator (the only person running Keysat today) needs
|
|
-- one post-migration manual step: update the Zaprite webhook URL on the
|
|
-- Zaprite dashboard to the new `/v1/zaprite/webhook/{provider_id}` form,
|
|
-- or click "Reconnect Zaprite" in the new admin UI to have Keysat
|
|
-- re-register the webhook with the correct URL automatically.
|
|
|
|
PRAGMA foreign_keys = ON;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- merchant_profiles: business identity layer
|
|
-- ---------------------------------------------------------------------------
|
|
-- Each profile represents one "business" the operator is running on this
|
|
-- Keysat instance. Owns its own brand block, support contact, post-purchase
|
|
-- redirect URL, and optionally an SMTP override (paired with the
|
|
-- keysat-smtp-emails plan — the columns are added now so the SMTP work
|
|
-- layers on cleanly later without another schema migration).
|
|
--
|
|
-- Tier gating is enforced at the Rust layer (`merchant_profiles::create`
|
|
-- checks the operator's tier and refuses with AppError::TierCap if a
|
|
-- Creator already has one profile). No CHECK at the schema layer because
|
|
-- tier resolution requires reading the operator's signed license, not just
|
|
-- counting rows.
|
|
CREATE TABLE IF NOT EXISTS 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, e.g. '#1E3A5F'
|
|
post_purchase_redirect_url TEXT, -- NULL = Keysat's /thank-you
|
|
is_default INTEGER NOT NULL DEFAULT 0,
|
|
|
|
-- Per-profile SMTP override. NULL = inherit StartOS-level SMTP config.
|
|
-- See keysat-smtp-emails.md for the email-sending plan that consumes
|
|
-- these. Added in this migration so the SMTP plan doesn't need its
|
|
-- own migration to add per-profile branding fields.
|
|
smtp_host TEXT,
|
|
smtp_port INTEGER,
|
|
smtp_username TEXT,
|
|
smtp_password TEXT, -- TODO: encryption at rest
|
|
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))
|
|
);
|
|
|
|
-- Exactly one default profile. Partial unique index enforces this without
|
|
-- needing a trigger; updates to is_default must clear the previous default
|
|
-- in the same transaction (Rust layer handles this).
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_merchant_profiles_one_default
|
|
ON merchant_profiles(is_default) WHERE is_default = 1;
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- payment_providers: replaces btcpay_config + zaprite_config singletons
|
|
-- ---------------------------------------------------------------------------
|
|
-- One row per configured payment account. Multiple rows allowed per
|
|
-- profile, but at most one of each `kind` (no two BTCPay stores on the
|
|
-- same business — operators wanting that should split into two profiles).
|
|
CREATE TABLE IF NOT EXISTS 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, -- operator-set, e.g. "Recaps BTCPay"
|
|
api_key TEXT NOT NULL,
|
|
base_url TEXT NOT NULL,
|
|
webhook_id TEXT, -- provider-side webhook id, for delete on disconnect
|
|
webhook_secret TEXT, -- BTCPay HMAC secret; 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 IF NOT EXISTS idx_payment_providers_profile
|
|
ON payment_providers(merchant_profile_id);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_payment_providers_profile_kind
|
|
ON payment_providers(merchant_profile_id, kind);
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- merchant_profile_rail_preferences: tie-breaker for multi-provider profiles
|
|
-- ---------------------------------------------------------------------------
|
|
-- When a profile has 2 providers that BOTH serve the same payment rail
|
|
-- (e.g., both BTCPay and Zaprite can settle Lightning), the operator picks
|
|
-- which provider serves that rail for THIS profile here. Without an entry,
|
|
-- the routing layer picks the provider with the earliest connected_at
|
|
-- (deterministic but warns in the admin UI).
|
|
--
|
|
-- Rails-per-kind are inherent (BTCPay → Lightning + OnChain; Zaprite →
|
|
-- Card + Lightning + OnChain) — declared via the trait method
|
|
-- `served_rails()` in Rust, not stored per provider row. This table
|
|
-- is purely the ambiguity resolver.
|
|
CREATE TABLE IF NOT EXISTS 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'))
|
|
);
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- products: attach to a merchant profile
|
|
-- ---------------------------------------------------------------------------
|
|
-- Nullable during the data-port window (we set it in the UPDATE below).
|
|
-- After backfill the Rust create_product path requires it (enforced at
|
|
-- the application layer; can't add NOT NULL via ALTER on SQLite).
|
|
ALTER TABLE products
|
|
ADD COLUMN merchant_profile_id TEXT REFERENCES merchant_profiles(id);
|
|
CREATE INDEX IF NOT EXISTS idx_products_profile
|
|
ON products(merchant_profile_id);
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- subscriptions: snapshot profile + provider at creation
|
|
-- ---------------------------------------------------------------------------
|
|
-- The snapshot semantics matter: if an operator later edits a product to
|
|
-- attach a different profile / point at a different provider, existing
|
|
-- subscriptions keep renewing through their ORIGINAL profile + provider.
|
|
-- Re-routing an existing sub to a new merchant is a deliberate admin
|
|
-- action, never an automatic consequence of editing a product.
|
|
ALTER TABLE subscriptions
|
|
ADD COLUMN merchant_profile_id TEXT REFERENCES merchant_profiles(id);
|
|
ALTER TABLE subscriptions
|
|
ADD COLUMN payment_provider_id TEXT REFERENCES payment_providers(id);
|
|
CREATE INDEX IF NOT EXISTS idx_subs_profile
|
|
ON subscriptions(merchant_profile_id);
|
|
CREATE INDEX IF NOT EXISTS idx_subs_provider
|
|
ON subscriptions(payment_provider_id);
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- Data port: singletons → multi-row tables
|
|
-- ---------------------------------------------------------------------------
|
|
|
|
-- 1. Create the default merchant profile. Name = the operator_name setting
|
|
-- if present; else 'Keysat'. UUID-style id via SQLite's randomblob hex.
|
|
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')
|
|
WHERE NOT EXISTS (SELECT 1 FROM merchant_profiles WHERE is_default = 1);
|
|
|
|
-- 2. Port btcpay_config (if a row exists) into payment_providers, attached
|
|
-- to the default profile.
|
|
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. Port zaprite_config (if a row exists). Zaprite has no webhook_secret
|
|
-- or store_id; map both to NULL.
|
|
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. Backfill existing products to point at the default profile.
|
|
UPDATE products
|
|
SET merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
|
|
WHERE merchant_profile_id IS NULL;
|
|
|
|
-- 5. Backfill existing subscriptions. Pick the provider whose kind matches
|
|
-- SETTING_ACTIVE_PROVIDER if set; otherwise pick the earliest-connected
|
|
-- provider on the default profile (deterministic). Subs sitting on a
|
|
-- provider that no longer exists in payment_providers (extremely
|
|
-- unlikely — would require corrupted singleton data) are left NULL
|
|
-- and the operator's admin UI will flag them.
|
|
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 = 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)
|
|
)
|
|
)
|
|
WHERE merchant_profile_id IS NULL OR payment_provider_id IS NULL;
|
|
|
|
-- 6. Drop the singleton tables + the active-provider setting. Now the only
|
|
-- source of truth for payment configuration is payment_providers +
|
|
-- merchant_profiles.
|
|
DROP TABLE IF EXISTS btcpay_config;
|
|
DROP TABLE IF EXISTS zaprite_config;
|
|
DELETE FROM settings WHERE key = 'active_payment_provider';
|
|
|
|
-- Note: btcpay_authorize_state stays (it's the in-flight OAuth CSRF
|
|
-- token table from migration 0002; nothing to migrate, just continues
|
|
-- to scope per-attempt). Its `state_token` rows will now carry a
|
|
-- `merchant_profile_id` in their associated payload — see the
|
|
-- btcpay_authorize.rs changes that add this column in a future
|
|
-- micro-migration if needed (today the state token is opaque to the
|
|
-- DB and the profile id is round-tripped via the OAuth state param).
|