diff --git a/multi-provider-payment-model.md b/multi-provider-payment-model.md index 3f9f6a3..1ac214e 100644 --- a/multi-provider-payment-model.md +++ b/multi-provider-payment-model.md @@ -166,6 +166,24 @@ CREATE INDEX idx_payment_providers_profile ON payment_providers(merchant_profile 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); @@ -228,30 +246,35 @@ UPDATE products 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 = ( - -- 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 + 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) ) ); ``` -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. +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 @@ -271,9 +294,25 @@ all attach to that profile. - `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. +- 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`. 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: @@ -295,12 +334,15 @@ all attach to that profile. 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/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: @@ -373,13 +415,26 @@ later moves a product to a different merchant profile: - 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). + 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) @@ -397,8 +452,10 @@ later moves a product to a different merchant profile: - `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 +- `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 @@ -433,36 +490,48 @@ later moves a product to a different merchant profile: - 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. +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 -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: +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). -**Option A — single cut.** All schema + Rust + admin UI in one release. -Cleaner UX (no half-shipped intermediate states) but a heavier cut. -Estimate: 2–3 focused days. +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. -**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. +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 @@ -564,7 +633,7 @@ different product. ## How this composes with the SMTP / operator-alerts plan -See `/Users/macpro/.claude/plans/keysat-smtp-emails.md` (companion +See `./keysat-smtp-emails.md` (companion plan). - Per-profile SMTP override fields (in this migration) replace what