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
|
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: 2–3 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: 2–3 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
|
||||||
|
|||||||
Reference in New Issue
Block a user