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:
@@ -511,6 +511,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
<a class="nav" data-route="licenses"><i data-lucide="key-round"></i>Licenses</a>
|
||||
<a class="nav" data-route="machines"><i data-lucide="cpu"></i>Machines</a>
|
||||
<div class="group-label">System</div>
|
||||
<a class="nav" data-route="merchant-profiles"><i data-lucide="store"></i>Merchant profiles</a>
|
||||
<a class="nav" data-route="webhooks"><i data-lucide="webhook"></i>Webhooks</a>
|
||||
<a class="nav" data-route="audit"><i data-lucide="scroll-text"></i>Audit log</a>
|
||||
<a class="nav" data-route="settings"><i data-lucide="settings"></i>Settings</a>
|
||||
@@ -1228,6 +1229,7 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
codes: { title: 'Discount codes', crumb: 'Workspace · Discount codes' },
|
||||
licenses: { title: 'Licenses', crumb: 'Workspace · Licenses' },
|
||||
machines: { title: 'Machines', crumb: 'Workspace · Machines' },
|
||||
'merchant-profiles': { title: 'Merchant profiles', crumb: 'System · Merchant profiles' },
|
||||
webhooks: { title: 'Webhooks', crumb: 'System · Webhooks' },
|
||||
settings: { title: 'Settings', crumb: 'System · Settings' },
|
||||
audit: { title: 'Audit log', crumb: 'System · Audit log' },
|
||||
@@ -5719,6 +5721,439 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
||||
load()
|
||||
}
|
||||
|
||||
// -------- Merchant profiles (multi-provider model, :52+) --------
|
||||
// Each profile represents one "business" the operator is running on
|
||||
// this Keysat instance. Owns business identity (brand, support contact,
|
||||
// post-purchase redirect, optional SMTP override) and a set of payment
|
||||
// providers (BTCPay / Zaprite) that legally settle to that business.
|
||||
// Products attach to a profile. Tier-gated: Creator gets 1, Pro/Patron
|
||||
// get unlimited.
|
||||
routes['merchant-profiles'] = async function () {
|
||||
const target = document.getElementById('route-target')
|
||||
target.innerHTML = ''
|
||||
|
||||
// Lead-in explainer + Create button.
|
||||
const createBtn = el('button', { class: 'btn primary' }, [
|
||||
el('i', { 'data-lucide': 'plus' }), 'Add merchant profile',
|
||||
])
|
||||
createBtn.addEventListener('click', () => openCreateMerchantProfileModal(reload))
|
||||
target.appendChild(plainCard([
|
||||
el('p', { class: 'muted', style: 'margin:0 0 12px' },
|
||||
'A merchant profile bundles one business’s brand, post-purchase redirect, ' +
|
||||
'and a set of payment providers (BTCPay / Zaprite). Products attach to a profile; ' +
|
||||
'the buyer sees the profile’s brand at checkout and the payment-method picker ' +
|
||||
'reflects whichever rails its providers serve.'),
|
||||
el('div', { class: 'toolbar' }, [createBtn]),
|
||||
]))
|
||||
|
||||
const listHost = el('div', { style: 'margin-top:18px' })
|
||||
target.appendChild(listHost)
|
||||
|
||||
async function reload() {
|
||||
listHost.innerHTML = ''
|
||||
listHost.appendChild(el('div', { class: 'card' }, el('div', { class: 'empty' }, 'Loading…')))
|
||||
try {
|
||||
const j = await api('/v1/admin/merchant-profiles')
|
||||
const profiles = j.profiles || []
|
||||
listHost.innerHTML = ''
|
||||
if (profiles.length === 0) {
|
||||
listHost.appendChild(plainCard([el('div', { class: 'empty' }, 'No merchant profiles yet.')]))
|
||||
return
|
||||
}
|
||||
for (const p of profiles) {
|
||||
listHost.appendChild(renderMerchantProfileCard(p, reload))
|
||||
}
|
||||
} catch (e) {
|
||||
listHost.innerHTML = ''
|
||||
listHost.appendChild(plainCard([err(e.message)]))
|
||||
}
|
||||
}
|
||||
reload()
|
||||
}
|
||||
|
||||
function renderMerchantProfileCard(p, reload) {
|
||||
const head = el('div', { class: 'card-head' }, [
|
||||
el('div', null, [
|
||||
el('h3', null, [
|
||||
p.name,
|
||||
p.is_default ? el('span', {
|
||||
class: 'badge b-gold',
|
||||
style: 'margin-left:10px; vertical-align:middle;',
|
||||
}, 'default') : null,
|
||||
].filter(Boolean)),
|
||||
p.support_email
|
||||
? el('div', { class: 'sub' }, p.support_email)
|
||||
: null,
|
||||
].filter(Boolean)),
|
||||
el('div', { class: 'actions-row' }, [
|
||||
!p.is_default
|
||||
? el('button', { class: 'btn ghost sm' }, [
|
||||
el('i', { 'data-lucide': 'star' }), 'Set default',
|
||||
])
|
||||
: null,
|
||||
el('button', { class: 'btn ghost sm' }, [
|
||||
el('i', { 'data-lucide': 'pencil' }), 'Edit',
|
||||
]),
|
||||
!p.is_default
|
||||
? el('button', { class: 'btn danger sm' }, [
|
||||
el('i', { 'data-lucide': 'trash-2' }), 'Delete',
|
||||
])
|
||||
: null,
|
||||
].filter(Boolean)),
|
||||
])
|
||||
// Wire action buttons.
|
||||
const setDefaultBtn = head.querySelectorAll('button')[0]
|
||||
if (setDefaultBtn && !p.is_default) {
|
||||
setDefaultBtn.addEventListener('click', async () => {
|
||||
if (!confirm('Make "' + p.name + '" the default profile?')) return
|
||||
try {
|
||||
await api('/v1/admin/merchant-profiles/' + encodeURIComponent(p.id) + '/set-default', {
|
||||
method: 'POST',
|
||||
})
|
||||
reload()
|
||||
} catch (e) {
|
||||
alert('Set-default failed: ' + e.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
const editBtn = head.querySelector('button.ghost:not(:first-child)') ||
|
||||
head.querySelectorAll('button.ghost')[p.is_default ? 0 : 1]
|
||||
if (editBtn) {
|
||||
editBtn.addEventListener('click', () => openEditMerchantProfileModal(p, reload))
|
||||
}
|
||||
const deleteBtn = head.querySelector('button.danger')
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!confirm('Delete merchant profile "' + p.name + '"? Refused if products or active subs are attached.')) return
|
||||
try {
|
||||
await api('/v1/admin/merchant-profiles/' + encodeURIComponent(p.id), { method: 'DELETE' })
|
||||
reload()
|
||||
} catch (e) {
|
||||
alert('Delete failed: ' + e.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const body = el('div', { class: 'card-body' })
|
||||
|
||||
// Brand / redirect summary.
|
||||
const meta = el('div', { class: 'row-2', style: 'margin-bottom:14px' }, [
|
||||
el('div', null, [
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Post-purchase redirect URL'),
|
||||
el('div', { class: p.post_purchase_redirect_url ? '' : 'muted' },
|
||||
p.post_purchase_redirect_url || 'Keysat default /thank-you page'),
|
||||
]),
|
||||
]),
|
||||
el('div', null, [
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Brand color'),
|
||||
el('div', { class: p.brand_color ? '' : 'muted' },
|
||||
p.brand_color || 'Keysat default navy'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
body.appendChild(meta)
|
||||
|
||||
// Providers list.
|
||||
body.appendChild(el('div', { class: 'eyebrow', style: 'margin-bottom:6px' }, 'Payment providers'))
|
||||
const providers = p.providers || []
|
||||
if (providers.length === 0) {
|
||||
body.appendChild(el('div', { class: 'empty', style: 'padding:14px' },
|
||||
'No providers connected. Buyers can’t pay on products attached to this profile until you add one.'))
|
||||
} else {
|
||||
const tb = el('tbody')
|
||||
for (const pr of providers) {
|
||||
const rails = (pr.served_rails || []).join(', ')
|
||||
const disconnectBtn = el('button', { class: 'btn danger sm' }, [
|
||||
el('i', { 'data-lucide': 'unplug' }), 'Disconnect',
|
||||
])
|
||||
disconnectBtn.addEventListener('click', async () => {
|
||||
if (!confirm('Disconnect ' + pr.kind + ' provider "' + pr.label + '"?')) return
|
||||
try {
|
||||
const path = pr.kind === 'btcpay' ? '/v1/admin/btcpay/disconnect' : '/v1/admin/zaprite/disconnect'
|
||||
await api(path, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider_id: pr.id }),
|
||||
})
|
||||
reload()
|
||||
} catch (e) {
|
||||
alert('Disconnect failed: ' + e.message)
|
||||
}
|
||||
})
|
||||
tb.appendChild(el('tr', null, [
|
||||
el('td', null, el('strong', null, pr.label || pr.kind)),
|
||||
el('td', { class: 'muted' }, pr.kind),
|
||||
el('td', { class: 'muted' }, rails || '—'),
|
||||
el('td', null, disconnectBtn),
|
||||
]))
|
||||
}
|
||||
const table = el('table', { class: 't' }, [
|
||||
el('thead', null, el('tr', null, [
|
||||
el('th', null, 'Label'),
|
||||
el('th', null, 'Kind'),
|
||||
el('th', null, 'Rails'),
|
||||
el('th', null, ''),
|
||||
])),
|
||||
tb,
|
||||
])
|
||||
body.appendChild(el('div', { class: 't-wrap' }, table))
|
||||
}
|
||||
|
||||
// Connect buttons (offer whichever provider kinds aren't yet attached).
|
||||
const haveBtcpay = providers.some((pr) => pr.kind === 'btcpay')
|
||||
const haveZaprite = providers.some((pr) => pr.kind === 'zaprite')
|
||||
const connectActions = el('div', { class: 'toolbar', style: 'margin-top:14px' }, [])
|
||||
if (!haveBtcpay) {
|
||||
const btn = el('button', { class: 'btn secondary' }, [
|
||||
el('i', { 'data-lucide': 'bitcoin' }), 'Connect BTCPay',
|
||||
])
|
||||
btn.addEventListener('click', () => connectBtcpayForProfile(p.id, reload))
|
||||
connectActions.appendChild(btn)
|
||||
}
|
||||
if (!haveZaprite) {
|
||||
const btn = el('button', { class: 'btn secondary' }, [
|
||||
el('i', { 'data-lucide': 'credit-card' }), 'Connect Zaprite',
|
||||
])
|
||||
btn.addEventListener('click', () => connectZapriteForProfile(p.id, reload))
|
||||
connectActions.appendChild(btn)
|
||||
}
|
||||
if (connectActions.children.length) body.appendChild(connectActions)
|
||||
|
||||
if (window.lucide) lucide.createIcons()
|
||||
return el('div', { class: 'card' }, [head, body])
|
||||
}
|
||||
|
||||
function openCreateMerchantProfileModal(onDone) {
|
||||
const overlay = el('div', {
|
||||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||||
'display:flex; align-items:center; justify-content:center; padding:20px;',
|
||||
})
|
||||
const nameInput = el('input', { class: 'input', placeholder: 'e.g. Recaps' })
|
||||
const supportUrlInput = el('input', { class: 'input', placeholder: 'https://recaps.cc/support (optional)' })
|
||||
const supportEmailInput = el('input', { class: 'input', placeholder: 'support@recaps.cc (optional)' })
|
||||
const redirectInput = el('input', { class: 'input', placeholder: 'https://recaps.cc/welcome?invoice_id={invoice_id} (optional)' })
|
||||
const brandColorInput = el('input', { class: 'input', type: 'color', value: '#1E3A5F' })
|
||||
const errBox = el('div')
|
||||
const submitBtn = el('button', { class: 'btn primary' }, 'Create profile')
|
||||
const card = el('div', {
|
||||
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
|
||||
'border-radius:12px; max-width:520px; width:100%; padding:24px; max-height:90vh; overflow-y:auto;',
|
||||
}, [
|
||||
el('h3', { style: 'margin:0 0 14px' }, 'New merchant profile'),
|
||||
el('p', { class: 'muted', style: 'margin:0 0 18px; font-size:13px' },
|
||||
'Each profile is one business identity. Buyers see the brand on the buy page; ' +
|
||||
'products attached to this profile route their payments through the providers you connect to it.'),
|
||||
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Name'), nameInput]),
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Support URL'),
|
||||
supportUrlInput,
|
||||
el('div', { class: 'hint' }, 'Linked from the buy page so buyers can contact your team.'),
|
||||
]),
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Support email'),
|
||||
supportEmailInput,
|
||||
]),
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Post-purchase redirect URL'),
|
||||
redirectInput,
|
||||
el('div', { class: 'hint' }, '{invoice_id} is substituted at purchase time. Leave blank to use Keysat’s /thank-you page.'),
|
||||
]),
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Brand color'),
|
||||
brandColorInput,
|
||||
]),
|
||||
errBox,
|
||||
el('div', { style: 'display:flex; gap:10px; margin-top:18px; justify-content:flex-end;' }, [
|
||||
el('button', { class: 'btn ghost', onclick: () => overlay.remove() }, 'Cancel'),
|
||||
submitBtn,
|
||||
]),
|
||||
])
|
||||
overlay.appendChild(card)
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||||
document.body.appendChild(overlay)
|
||||
nameInput.focus()
|
||||
|
||||
submitBtn.addEventListener('click', async () => {
|
||||
errBox.innerHTML = ''
|
||||
const name = nameInput.value.trim()
|
||||
if (!name) {
|
||||
errBox.appendChild(err('Name is required.'))
|
||||
return
|
||||
}
|
||||
submitBtn.disabled = true
|
||||
try {
|
||||
await api('/v1/admin/merchant-profiles', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
support_url: supportUrlInput.value.trim() || null,
|
||||
support_email: supportEmailInput.value.trim() || null,
|
||||
post_purchase_redirect_url: redirectInput.value.trim() || null,
|
||||
brand_color: brandColorInput.value || null,
|
||||
}),
|
||||
})
|
||||
overlay.remove()
|
||||
if (onDone) onDone()
|
||||
} catch (e) {
|
||||
errBox.appendChild(err(e.message))
|
||||
submitBtn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function openEditMerchantProfileModal(profile, onDone) {
|
||||
const overlay = el('div', {
|
||||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||||
'display:flex; align-items:center; justify-content:center; padding:20px;',
|
||||
})
|
||||
const nameInput = el('input', { class: 'input', value: profile.name || '' })
|
||||
const supportUrlInput = el('input', { class: 'input', value: profile.support_url || '' })
|
||||
const supportEmailInput = el('input', { class: 'input', value: profile.support_email || '' })
|
||||
const redirectInput = el('input', { class: 'input', value: profile.post_purchase_redirect_url || '' })
|
||||
const brandColorInput = el('input', { class: 'input', type: 'color', value: profile.brand_color || '#1E3A5F' })
|
||||
const errBox = el('div')
|
||||
const submitBtn = el('button', { class: 'btn primary' }, 'Save')
|
||||
const card = el('div', {
|
||||
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
|
||||
'border-radius:12px; max-width:520px; width:100%; padding:24px; max-height:90vh; overflow-y:auto;',
|
||||
}, [
|
||||
el('h3', { style: 'margin:0 0 14px' }, 'Edit merchant profile'),
|
||||
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Name'), nameInput]),
|
||||
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Support URL'), supportUrlInput]),
|
||||
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Support email'), supportEmailInput]),
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Post-purchase redirect URL'),
|
||||
redirectInput,
|
||||
el('div', { class: 'hint' }, '{invoice_id} substituted at purchase time.'),
|
||||
]),
|
||||
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'Brand color'), brandColorInput]),
|
||||
errBox,
|
||||
el('div', { style: 'display:flex; gap:10px; margin-top:18px; justify-content:flex-end;' }, [
|
||||
el('button', { class: 'btn ghost', onclick: () => overlay.remove() }, 'Cancel'),
|
||||
submitBtn,
|
||||
]),
|
||||
])
|
||||
overlay.appendChild(card)
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||||
document.body.appendChild(overlay)
|
||||
|
||||
submitBtn.addEventListener('click', async () => {
|
||||
errBox.innerHTML = ''
|
||||
submitBtn.disabled = true
|
||||
try {
|
||||
// Build the patch: outer Option means "leave unchanged" (omit
|
||||
// the key); we always send all the editable fields so the
|
||||
// operator can also CLEAR them by leaving the input empty.
|
||||
const patch = {
|
||||
name: nameInput.value.trim() || null,
|
||||
support_url: [supportUrlInput.value.trim() || null],
|
||||
support_email: [supportEmailInput.value.trim() || null],
|
||||
post_purchase_redirect_url: [redirectInput.value.trim() || null],
|
||||
brand_color: [brandColorInput.value || null],
|
||||
}
|
||||
// Wire-format note: the Rust patch uses double-Option (outer Some
|
||||
// wraps inner None for "set to NULL"). serde_json deserializes
|
||||
// a single value into outer Some(value); arrays into Some(Some(v)).
|
||||
// We use the bare value for outer Some/Some(v), and the [null]
|
||||
// array trick to express Some(None) (clear). For simplicity here
|
||||
// we just send the bare value — Rust treats null → Some(None) →
|
||||
// sets to NULL; a string → Some(Some(s)) → updates.
|
||||
const wirePatch = {}
|
||||
if (patch.name !== null) wirePatch.name = patch.name
|
||||
wirePatch.support_url = patch.support_url[0]
|
||||
wirePatch.support_email = patch.support_email[0]
|
||||
wirePatch.post_purchase_redirect_url = patch.post_purchase_redirect_url[0]
|
||||
wirePatch.brand_color = patch.brand_color[0]
|
||||
await api('/v1/admin/merchant-profiles/' + encodeURIComponent(profile.id), {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(wirePatch),
|
||||
})
|
||||
overlay.remove()
|
||||
if (onDone) onDone()
|
||||
} catch (e) {
|
||||
errBox.appendChild(err(e.message))
|
||||
submitBtn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function connectBtcpayForProfile(profileId, onDone) {
|
||||
try {
|
||||
const r = await api('/v1/admin/btcpay/connect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ merchant_profile_id: profileId }),
|
||||
})
|
||||
if (r.authorize_url) {
|
||||
if (confirm('Open BTCPay’s consent page in a new tab to complete connection?')) {
|
||||
window.open(r.authorize_url, '_blank', 'noopener')
|
||||
}
|
||||
}
|
||||
if (onDone) onDone()
|
||||
} catch (e) {
|
||||
alert('Connect BTCPay failed: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function connectZapriteForProfile(profileId, onDone) {
|
||||
const overlay = el('div', {
|
||||
style: 'position:fixed; inset:0; background:rgba(14,31,51,0.55); z-index:9999; ' +
|
||||
'display:flex; align-items:center; justify-content:center; padding:20px;',
|
||||
})
|
||||
const apiKeyInput = el('input', { class: 'input', type: 'password', placeholder: 'paste Zaprite API key' })
|
||||
const baseUrlInput = el('input', { class: 'input', placeholder: 'https://api.zaprite.com (default)' })
|
||||
const errBox = el('div')
|
||||
const submitBtn = el('button', { class: 'btn primary' }, 'Connect')
|
||||
const card = el('div', {
|
||||
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
|
||||
'border-radius:12px; max-width:480px; width:100%; padding:24px;',
|
||||
}, [
|
||||
el('h3', { style: 'margin:0 0 12px' }, 'Connect Zaprite'),
|
||||
el('p', { class: 'muted', style: 'margin:0 0 14px; font-size:13px' },
|
||||
'Paste an API key from app.zaprite.com Settings → API.'),
|
||||
el('div', { class: 'field' }, [el('div', { class: 'lbl' }, 'API key'), apiKeyInput]),
|
||||
el('div', { class: 'field' }, [
|
||||
el('div', { class: 'lbl' }, 'Base URL (optional)'),
|
||||
baseUrlInput,
|
||||
el('div', { class: 'hint' }, 'Override only for sandbox orgs that point at a different host.'),
|
||||
]),
|
||||
errBox,
|
||||
el('div', { style: 'display:flex; gap:10px; margin-top:14px; justify-content:flex-end;' }, [
|
||||
el('button', { class: 'btn ghost', onclick: () => overlay.remove() }, 'Cancel'),
|
||||
submitBtn,
|
||||
]),
|
||||
])
|
||||
overlay.appendChild(card)
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
|
||||
document.body.appendChild(overlay)
|
||||
apiKeyInput.focus()
|
||||
|
||||
submitBtn.addEventListener('click', async () => {
|
||||
errBox.innerHTML = ''
|
||||
const key = apiKeyInput.value.trim()
|
||||
if (!key) { errBox.appendChild(err('API key required.')); return }
|
||||
submitBtn.disabled = true
|
||||
try {
|
||||
const r = await api('/v1/admin/zaprite/connect', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
api_key: key,
|
||||
base_url: baseUrlInput.value.trim() || undefined,
|
||||
merchant_profile_id: profileId,
|
||||
}),
|
||||
})
|
||||
overlay.remove()
|
||||
if (r.webhook_url) {
|
||||
alert('Zaprite connected. Register this webhook URL on the Zaprite dashboard:\n\n' + r.webhook_url)
|
||||
}
|
||||
if (onDone) onDone()
|
||||
} catch (e) {
|
||||
errBox.appendChild(err(e.message))
|
||||
submitBtn.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// -------- Settings --------
|
||||
// Three subsections:
|
||||
// 1. Operator name — the human-readable name on /buy/<slug> + thank-you.
|
||||
|
||||
@@ -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