Revise multi-provider payment model
This commit is contained in:
+127
-58
@@ -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: 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
|
||||
|
||||
Reference in New Issue
Block a user