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
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<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:
@@ -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: 23 focused days.
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.
**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