Revise multi-provider payment model

This commit is contained in:
Keysat
2026-06-12 17:58:26 -05:00
parent 3aacbbe278
commit a39a33d919
+127 -58
View File
@@ -166,6 +166,24 @@ CREATE INDEX idx_payment_providers_profile ON payment_providers(merchant_profile
CREATE UNIQUE INDEX idx_payment_providers_profile_kind CREATE UNIQUE INDEX idx_payment_providers_profile_kind
ON payment_providers(merchant_profile_id, 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 ALTER TABLE products
ADD COLUMN merchant_profile_id TEXT ADD COLUMN merchant_profile_id TEXT
REFERENCES merchant_profiles(id); REFERENCES merchant_profiles(id);
@@ -228,30 +246,35 @@ UPDATE products
UPDATE subscriptions UPDATE subscriptions
SET merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1), SET merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1),
payment_provider_id = ( 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 SELECT id FROM payment_providers
WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1) WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
AND kind = ( AND kind = COALESCE(
-- Resolve the original provider for back-compat. Use whichever (SELECT value FROM settings WHERE key = 'active_payment_provider'),
-- provider the active-provider setting pointed at; fall back to (SELECT kind FROM payment_providers
-- BTCPay if both exist (preserves existing single-provider WHERE merchant_profile_id = (SELECT id FROM merchant_profiles WHERE is_default = 1)
-- operator behavior). ORDER BY connected_at ASC LIMIT 1)
CASE
WHEN EXISTS (SELECT 1 FROM payment_providers
WHERE merchant_profile_id =
(SELECT id FROM merchant_profiles WHERE is_default = 1)
AND kind = 'btcpay')
THEN 'btcpay'
ELSE 'zaprite'
END
) )
); );
``` ```
Additive-then-cutover; not reversible without a backup restore. Release One-way migration: not reversible without a backup restore. Release
notes call this out as a one-way migration (consistent with the v0.2.0:1+ notes flag this for the master operator (you) since you're the only
migration framing). Existing single-provider operators see no behavior person running Keysat today — no third-party operators to worry about.
change — their setup becomes "1 profile + 1 provider," products and subs
all attach to that profile. **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 ## Rust changes
@@ -271,9 +294,25 @@ all attach to that profile.
- `repo::list_providers_for_profile(pool, profile_id)`, - `repo::list_providers_for_profile(pool, profile_id)`,
`repo::get_provider_by_id`, etc. `repo::get_provider_by_id`, etc.
- Drop `SETTING_ACTIVE_PROVIDER` constant + `read/write_active_provider_preference`. - Drop `SETTING_ACTIVE_PROVIDER` constant + `read/write_active_provider_preference`.
- Add a "rail" concept: each provider impl declares which payment rails - Add a "rail" concept. **Rail = the buyer-facing payment method**
it offers (`enum Rail { Lightning, OnChain, Card, … }`). The buy page (`enum Rail { Lightning, OnChain, Card }`), not "rail = provider."
uses this to render the payment-method picker. 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)**: **`src/api/mod.rs` (AppState)**:
- Replace single `payment_provider` accessor with two: - Replace single `payment_provider` accessor with two:
@@ -295,12 +334,15 @@ all attach to that profile.
the renewal webhook payload. the renewal webhook payload.
- `src/tipping.rs`, `src/reconcile.rs` — same pattern. - `src/tipping.rs`, `src/reconcile.rs` — same pattern.
**`src/api/webhook.rs`** — router unchanged from the prior multi-provider **`src/api/webhook.rs`** — replace the existing
draft: `/v1/{kind}/webhook/{provider_id}` is still the path. The `/v1/{kind}/webhook` routes with `/v1/{kind}/webhook/{provider_id}`.
`provider_id` resolves to a profile transitively, and the webhook The `provider_id` path segment resolves to a specific
handler doesn't need to know about profiles (it just needs to validate `payment_providers` row, the handler validates the payload against
the payload against the right provider's secret and update the right that row's secret and updates the right invoice. No legacy-URL shim
invoice). — 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`** — **`src/api/btcpay_authorize.rs` and `src/api/zaprite_authorize.rs`** —
connect flows: 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 - Brand block at top renders the merchant profile's `name`, optionally
with `brand_color` accent, and "Sold by {name}" subtitle. with `brand_color` accent, and "Sold by {name}" subtitle.
- Existing tier-card UI unchanged except for the payment-method picker: - Existing tier-card UI unchanged except for the payment-method picker:
if the profile has 2+ providers, render a "Pay with" picker (Card / the picker renders the union of rails served by the profile's
Lightning / Bitcoin) before the final Pay button. If only 1 provider, attached providers (see "rail concept" in the Rust section). When
hide the picker (current behavior). 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 - After payment, redirect respects profile's
`post_purchase_redirect_url` if set; falls back to Keysat's default `post_purchase_redirect_url` if set; falls back to Keysat's default
thank-you page. 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) ## Files modified (estimated)
- `migrations/0020_merchant_profiles.sql` (new) - `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 - `src/tipping.rs`, `src/reconcile.rs` — pass profile context
- `web/index.html` — new Merchant Profiles section + product picker + - `web/index.html` — new Merchant Profiles section + product picker +
buy-page profile branding + payment-method picker buy-page profile branding + payment-method picker
- `startos/versions/v0.2.0.ts` — bump (TBD which `:NN`), release notes - `startos/versions/v0.2.0.ts` — bump to `:52` (we're at `:51` as of
flagging one-way migration + Creator tier cap 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 ## 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. - On a Creator-tier instance, attempt to create a second profile.
- Expect 402 + upgrade modal pointing at the master Keysat upgrade - Expect 402 + upgrade modal pointing at the master Keysat upgrade
URL. URL.
6. **Backward-compat smoke**: 6. **Master-operator upgrade path** (the only existing install):
- Existing operator upgrades from `:45` to this version. Confirm - Your StartOS box at `:51` upgrades to this version. Confirm
they see one profile auto-created with their existing operator migrations 0020 + run successfully on your existing data:
name + connected providers attached + all products + subs one auto-created default profile named per your operator name,
linked through. your existing Zaprite provider attached to it, your existing
- Webhook delivered to OLD `/v1/zaprite/webhook` URL (no provider products + subscriptions all linked through.
id) still settles via the migrated default provider. - 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 ## Sequencing
This is a bigger change than the original multi-provider draft. Feasible Single cut (`:52`). Since no operators are running Keysat in the wild
in one cycle but the admin UI surface is meaningful. Two reasonable yet, there's no "preserve existing UX during the transition" concern
options: 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. Estimate: 23 focused days. The biggest single chunk is the admin UI
Cleaner UX (no half-shipped intermediate states) but a heavier cut. work — the new Merchant Profiles page, the per-profile-scoped
Estimate: 23 focused days. 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.** Suggested execution order within the single cut:
- Cut 1: Schema migration + Rust resolution layer + buy-page resolution. 1. Migration 0020 + repo helpers (compiles, no behavior change yet).
Admin UI stays mostly unchanged (one auto-created profile, existing 2. Build out merchant-profile + provider lookup + AppState accessors;
payment providers page shows the default profile's providers). port the existing call sites to use the new resolution path.
Lower risk; existing operators see no UX change. 3. Webhook URL refactor + connect-flow `merchant_profile_id` param.
- Cut 2: Full admin UI surface (Merchant Profiles top-level page, 4. Admin UI: Merchant Profiles page first (CRUD), then product-page
per-profile connect flows, profile picker on product page). Layered picker, then buy-page branding + payment-method picker.
on after Cut 1 is stable. 5. Tier-gate the create-profile endpoint + bake
`unlimited_merchant_profiles` into master Keysat's Pro/Patron
I'd lean toward **B** — the data-model and resolution-layer changes policies.
deserve their own focused cycle, and the UI work benefits from real 6. Bump to `:52`, write release notes, end-to-end sandbox test (the
operator feedback on the new shape before we commit to the admin UX. verification plan above).
## Future considerations — still on the roadmap ## Future considerations — still on the roadmap
@@ -564,7 +633,7 @@ different product.
## How this composes with the SMTP / operator-alerts plan ## 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). plan).
- Per-profile SMTP override fields (in this migration) replace what - Per-profile SMTP override fields (in this migration) replace what