v0.2.0:52 — multi-merchant-profile + multi-provider payment model
Final cut of the multi-merchant-profile work. Adds the Merchant Profiles
admin UI section (list/create/edit/delete profiles + per-profile Connect
BTCPay / Connect Zaprite), bumps the version, and writes the comprehensive
release notes flagging the one-way migration and the master-operator
post-migration manual step (update the Zaprite webhook URL to the new
path-keyed form, or click Disconnect + Reconnect in the new UI to have
Keysat re-register at the right URL automatically).
web/index.html
New sidebar nav entry + ROUTE_META + routes['merchant-profiles']:
- Lists every profile with: default badge, support email, brand
color preview, post-purchase redirect URL summary, attached
payment-providers table (kind / label / served rails / disconnect),
and Connect BTCPay / Connect Zaprite buttons for whichever kinds
aren't already attached.
- Set-default button on non-default profiles.
- Delete button on non-default profiles (the backend refuses if any
product or active subscription is still attached).
- Create modal: name, support URL, support email, post-purchase
redirect URL (with {invoice_id} substitution), brand color picker.
- Edit modal: same fields, populated from the profile row.
- Connect BTCPay opens the OAuth authorize URL in a new tab with the
merchant_profile_id baked into the CSRF state token (so the callback
knows which profile to attach the new provider row to).
- Connect Zaprite shows a small modal for the API key (+ optional
base_url for sandbox orgs); on success surfaces the new
provider-keyed webhook URL the operator pastes into Zaprite's
dashboard.
What this UI does NOT cover (deferred follow-ups, called out in the
release notes):
- Buy-page rail picker (defaults to first available rail today).
- Product-edit-page merchant-profile picker (new products always
attach to the default profile until the picker ships).
- Per-profile SMTP override form (the schema fields are in place,
consumed by the keysat-smtp-emails plan when it lands).
- Rail-preference editing UI (only matters when 2 providers on the
same profile both serve the same rail — settable today via
`PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail`).
startos/versions/v0.2.0.ts
Bumps to 0.2.0:52 with a comprehensive release note describing the
one-way migration, the post-migration manual Zaprite-webhook-URL step
for the master operator (you), the new tier-cap (unlimited_merchant_
profiles entitlement), and the four UI follow-ups deferred to later
releases.
Build: cargo check passes. Two warnings remaining — both expected:
- recover.rs unused-import (pre-existing, unrelated)
- SETTING_ACTIVE_PROVIDER inside the deprecated shim's own pre-
migration fallback branch
The shipped feature set:
- Migrations 0020 + 0021 + 0022 (one-way data port + invoice→provider
link + BTCPay-authorize-state profile column).
- Merchant profile + payment provider data model + repo helpers.
- Rail enum + served_rails() trait method + build_provider factory.
- AppState resolution layer (per-product, per-rail provider lookup
with explicit-preference → unique-candidate → deterministic-earliest-
connected fallback).
- Every backend call site (purchase, subscriptions, reconcile,
upgrade, tipping, capture, auto-charge, boot loader) ported.
- BTCPay + Zaprite connect/disconnect/status rewritten for the new
model (per-profile attachment + path-keyed webhook URLs).
- Webhook router with path-keyed deliveries + legacy back-compat.
- Thank-you page provider-kind copy reads the invoice's recorded
provider.
- Merchant profile CRUD + rail preference CRUD admin endpoints.
- Tier-cap wiring (enforce_merchant_profile_cap).
- Admin UI Merchant Profiles section (this commit).
- Comprehensive :52 release notes.
Master Keysat self-license note: the new `unlimited_merchant_profiles`
entitlement needs to be added to the Pro and Patron policies on the
master keysat.xyz admin UI for Pro/Patron customers to be able to
create multiple profiles. Pure data action, no code change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,8 @@ const RELEASE_NOTES = [
|
||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||
// append here.
|
||||
const ROUTINE_NOTES = [
|
||||
'0.2.0:52 — **Multi-merchant-profile + multi-provider payment model.** Drops the singleton-config-table assumption that one Keysat instance equals one business. Operators on Pro/Patron tier can now run multiple businesses from a single Keysat box: each business is a "merchant profile" with its own brand, post-purchase redirect URL, and a set of payment providers (BTCPay + Zaprite) that settle to that business\'s accounts. Products attach to a merchant profile; the buyer sees the profile\'s brand at checkout and the eventual rail-picker (UI follow-up) routes the buyer\'s payment-method choice to the right provider. **One-way DB migration** — migration 0020 creates `merchant_profiles` + `payment_providers` + `merchant_profile_rail_preferences`, ports the existing singleton `btcpay_config` / `zaprite_config` / `active_payment_provider` setting into the new tables (one auto-created default profile holding everything), then drops the old tables. Migrations 0021 + 0022 add `invoices.payment_provider_id` (so reconciler / tipping / capture know which provider settled each invoice) and a `merchant_profile_id` column on `btcpay_authorize_state` (so BTCPay\'s OAuth CSRF state can round-trip the operator\'s profile pick). **Subscriptions snapshot** both `merchant_profile_id` and `payment_provider_id` at creation, so editing a product\'s profile attachment never redirects existing buyers mid-cycle. **Webhook URLs** are now path-keyed: `/v1/{kind}/webhook/{provider-id}` — each profile\'s provider has its own isolated webhook receiver. Back-compat: the legacy `/v1/{kind}/webhook` URL still routes to the default profile\'s provider so any in-flight deliveries still settle. **Tier-gate**: Creator tier gets 1 profile (the auto-created default); Pro/Patron get unlimited via the new `unlimited_merchant_profiles` entitlement. **POST-MIGRATION MANUAL STEP for the master operator (you)**: after this version installs, your Zaprite webhook is still registered at `https://licensing.keysat.xyz/v1/zaprite/webhook` (the legacy URL). It keeps working via the back-compat fallback, but for proper per-provider isolation, either (a) open the Zaprite sandbox dashboard → Webhooks → edit the URL to include the new provider id shown in the Merchant Profiles UI, or (b) click Disconnect + Reconnect Zaprite in the new Merchant Profiles UI to have Keysat re-register a fresh webhook at the path-keyed URL. **WHAT THIS RELEASE DOES NOT YET INCLUDE** (UI follow-ups): the buy-page rail picker (today the buyer\'s checkout uses the first rail the profile\'s providers serve — fine for single-rail profiles), the product-edit-page merchant-profile picker (new products always go to the default profile until that UI ships), per-profile SMTP override form (the schema fields are in place for the keysat-smtp-emails plan), and rail-preference editing UI (only matters when 2 providers on the same profile both serve the same rail — operators can set them via `PUT /v1/admin/merchant-profiles/:id/rail-preferences/:rail` directly). **Entitlement note**: master Keysat\'s Pro and Patron policies need `unlimited_merchant_profiles` added to their entitlement JSON for Pro/Patron customers to actually be able to create multiple profiles — purely a data action on the master keysat.xyz admin UI, no code change.',
|
||||
'',
|
||||
'0.2.0:51 — **Zaprite `order.change` webhook is now actionable.** The `:50` probe-multiple-field-names fix surfaced that Zaprite\'s primary delivery shape isn\'t the convention-suggested `order.paid` / `order.complete` events — it\'s a single generic `order.change` event that just says "something about this order changed" and requires the receiver to look at `/data/status` to figure out what actually changed. Without handling this, every Zaprite webhook fell through to the Other arm ("non-actionable") and the polling reconciler (60-second tick) had to do all the work, adding ~45s of perceived latency before the buyer\'s thank-you page flipped from "waiting" to "issued". Fixed in `payment/zaprite/provider.rs::validate_webhook`: added an `order.change` match arm that branches on `/data/status` (`PAID`/`COMPLETE`/`OVERPAID` → InvoiceSettled, `EXPIRED` → InvoiceExpired, `INVALID`/`CANCELLED` → InvoiceInvalid, in-flight states like `PENDING`/`PROCESSING`/`UNDERPAID` → Other so we don\'t fire the settle hook on every transition toward PAID). End result: webhook-driven settles now flip subscriptions to `active` within seconds of Zaprite\'s callback — the reconciler stays as the safety net for actual missed deliveries.',
|
||||
'',
|
||||
'0.2.0:50 — **Zaprite webhook event-type extraction now probes multiple field names + warns + dumps payload on miss.** Sandbox testing of `:49` confirmed Zaprite\'s webhooks ARE being delivered, but every one was logged as "non-actionable webhook event event_type=" — empty event_type meant the receiver fell through to the Other arm, and only the polling reconciler (60-second tick) eventually picked up the settle. Root cause: `validate_webhook` only checked the top-level `event` field; Zaprite\'s docs don\'t enumerate webhook payload shapes, and their actual deliveries put the event name somewhere else. Fixed in `payment/zaprite/provider.rs::validate_webhook`: now probes four common top-level field names — `event`, `eventType`, `type`, `name` — first non-empty wins. Also widened the order-id probe to include `data.object.id` (the Stripe-style pattern). When NONE of the four event-name fields match, the handler now WARN-logs the (truncated to 2KB) raw payload so the actual field name can be added to the probe list. End result: webhook-driven settles should now flip subscriptions to `active` within seconds instead of waiting for the reconciler — improves perceived latency on the thank-you page and lets auto-charged renewals settle without polling lag.',
|
||||
@@ -537,7 +539,7 @@ const ROUTINE_NOTES = [
|
||||
].join('\n\n')
|
||||
|
||||
export const v0_2_0 = VersionInfo.of({
|
||||
version: '0.2.0:51',
|
||||
version: '0.2.0:52',
|
||||
releaseNotes: { en_US: ROUTINE_NOTES },
|
||||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||||
// SQLite-level migrations live separately under
|
||||
|
||||
Reference in New Issue
Block a user