v0.2.0:41 — Patron implies Pro; BTCPay Connect back to one-click authorize
Patron entitlement now expands to the full Pro surface (unlimited_products / _policies / _codes, recurring_billing, zaprite_payments) in tier::current(). Existing Patron customers get the implied entitlements without re-issuing. BTCPay Connect: replace the four-field paste form (Base URL + API key + Store id + Webhook secret) with the original one-click button that fetches an authorize URL from /v1/admin/btcpay/connect, opens it in a new tab, and polls /v1/admin/btcpay/status until the BTCPay callback finishes. Zaprite path unchanged.
This commit is contained in:
@@ -90,12 +90,36 @@ impl TierInfo {
|
|||||||
/// to be fixed.
|
/// to be fixed.
|
||||||
pub async fn current(state: &AppState) -> TierInfo {
|
pub async fn current(state: &AppState) -> TierInfo {
|
||||||
let tier = state.self_tier.read().await;
|
let tier = state.self_tier.read().await;
|
||||||
let entitlements = match &*tier {
|
let mut entitlements = match &*tier {
|
||||||
Tier::Licensed { entitlements, .. } => entitlements.clone(),
|
Tier::Licensed { entitlements, .. } => entitlements.clone(),
|
||||||
Tier::Unlicensed { .. } => Vec::new(),
|
Tier::Unlicensed { .. } => Vec::new(),
|
||||||
};
|
};
|
||||||
drop(tier);
|
drop(tier);
|
||||||
|
|
||||||
|
// Patron implies Pro by design (see module docstring: "Patron: same
|
||||||
|
// feature surface as Pro, plus a `patron` entitlement..."). Without
|
||||||
|
// this expansion, every downstream `tier.has(<pro-entitlement>)`
|
||||||
|
// check requires the Patron POLICY on the master Keysat to
|
||||||
|
// redundantly list every Pro entitlement. That's brittle: a single
|
||||||
|
// missing slug on the policy (e.g. operator forgets
|
||||||
|
// `zaprite_payments`) breaks Pro-equivalence for every Patron
|
||||||
|
// customer. Treating `patron` as a strict superset of Pro at the
|
||||||
|
// resolution layer means policy authors can list `patron` alone
|
||||||
|
// and have everything Pro grants flow through automatically.
|
||||||
|
if entitlements.iter().any(|e| e == "patron") {
|
||||||
|
for implied in [
|
||||||
|
"unlimited_products",
|
||||||
|
"unlimited_policies",
|
||||||
|
"unlimited_codes",
|
||||||
|
"recurring_billing",
|
||||||
|
"zaprite_payments",
|
||||||
|
] {
|
||||||
|
if !entitlements.iter().any(|e| e == implied) {
|
||||||
|
entitlements.push(implied.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let label: &'static str;
|
let label: &'static str;
|
||||||
let display_name: &'static str;
|
let display_name: &'static str;
|
||||||
if entitlements.iter().any(|e| e == "patron") {
|
if entitlements.iter().any(|e| e == "patron") {
|
||||||
|
|||||||
@@ -5794,7 +5794,13 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
} else {
|
} else {
|
||||||
buttons.push(el('button', {
|
buttons.push(el('button', {
|
||||||
class: 'btn sm primary',
|
class: 'btn sm primary',
|
||||||
onclick: () => openConnectModal(name, label, () => renderPaymentProvidersCard(host)),
|
onclick: () => {
|
||||||
|
if (name === 'btcpay') {
|
||||||
|
openBtcpayConnectModal(() => renderPaymentProvidersCard(host))
|
||||||
|
} else {
|
||||||
|
openConnectModal(name, label, () => renderPaymentProvidersCard(host))
|
||||||
|
}
|
||||||
|
},
|
||||||
}, 'Connect'))
|
}, 'Connect'))
|
||||||
}
|
}
|
||||||
return el('div', {
|
return el('div', {
|
||||||
@@ -5898,6 +5904,105 @@ hr.div { border:none; border-top:1px solid var(--border-1); margin:18px 0; }
|
|||||||
document.body.appendChild(overlay)
|
document.body.appendChild(overlay)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BTCPay one-click connect modal.
|
||||||
|
//
|
||||||
|
// Replaces the manual base-URL / API-key / store-id / webhook-secret
|
||||||
|
// paste flow. BTCPay's Greenfield API has a native consent endpoint
|
||||||
|
// (/api-keys/authorize) — the daemon's /v1/admin/btcpay/connect returns
|
||||||
|
// a pre-built authorize URL we open in a new tab. After the operator
|
||||||
|
// clicks Authorize in BTCPay, BTCPay redirects to our
|
||||||
|
// /v1/btcpay/authorize/callback, which auto-detects the store,
|
||||||
|
// registers the webhook, and persists everything. We poll
|
||||||
|
// /v1/admin/btcpay/status here every 2.5s so the modal closes itself
|
||||||
|
// the moment connection lands.
|
||||||
|
async function openBtcpayConnectModal(onSuccess) {
|
||||||
|
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; overflow-y:auto;',
|
||||||
|
})
|
||||||
|
const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px; min-height:18px' }, '')
|
||||||
|
let pollTimer = null
|
||||||
|
function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null } }
|
||||||
|
function close() { stopPolling(); overlay.remove() }
|
||||||
|
|
||||||
|
const openBtn = el('button', { class: 'btn primary' }, 'Open BTCPay to authorize')
|
||||||
|
const cancelBtn = el('button', { class: 'btn secondary' }, 'Cancel')
|
||||||
|
const urlBox = el('div', {
|
||||||
|
style: 'display:none; margin-top:12px; padding:10px 12px; background:var(--cream-100); ' +
|
||||||
|
'border:1px solid var(--border-1); border-radius:8px; font-family:var(--font-mono); ' +
|
||||||
|
'font-size:11.5px; word-break:break-all; color:var(--ink-700)',
|
||||||
|
}, '')
|
||||||
|
const urlCopy = el('button', {
|
||||||
|
class: 'btn sm secondary',
|
||||||
|
style: 'display:none; margin-top:6px',
|
||||||
|
}, 'Copy URL')
|
||||||
|
|
||||||
|
openBtn.addEventListener('click', async () => {
|
||||||
|
openBtn.disabled = true
|
||||||
|
status.textContent = 'Requesting authorize URL from BTCPay…'
|
||||||
|
let authorizeUrl
|
||||||
|
try {
|
||||||
|
const resp = await api('/v1/admin/btcpay/connect', { method: 'POST', body: {} })
|
||||||
|
authorizeUrl = resp.authorize_url
|
||||||
|
if (!authorizeUrl) throw new Error('No authorize_url in response')
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = e.message || 'Could not start authorize flow.'
|
||||||
|
openBtn.disabled = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.open(authorizeUrl, '_blank', 'noopener')
|
||||||
|
urlBox.textContent = authorizeUrl
|
||||||
|
urlBox.style.display = 'block'
|
||||||
|
urlCopy.style.display = 'inline-block'
|
||||||
|
urlCopy.onclick = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(authorizeUrl)
|
||||||
|
urlCopy.textContent = 'Copied'
|
||||||
|
setTimeout(() => { urlCopy.textContent = 'Copy URL' }, 1400)
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
openBtn.textContent = 'Re-open authorize page'
|
||||||
|
openBtn.onclick = () => window.open(authorizeUrl, '_blank', 'noopener')
|
||||||
|
openBtn.disabled = false
|
||||||
|
status.textContent = 'Approve in the new tab, then return here — this dialog will close automatically.'
|
||||||
|
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const s = await api('/v1/admin/btcpay/status')
|
||||||
|
if (s && s.connected) {
|
||||||
|
close()
|
||||||
|
onSuccess && onSuccess()
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Non-fatal — keep polling. BTCPay callback might still be
|
||||||
|
// mid-flight or the operator hasn't approved yet.
|
||||||
|
}
|
||||||
|
}, 2500)
|
||||||
|
})
|
||||||
|
cancelBtn.addEventListener('click', close)
|
||||||
|
|
||||||
|
const cardEl = el('div', {
|
||||||
|
style: 'background:var(--cream-50); border:1px solid var(--border-1); border-radius:12px; max-width:540px; width:100%; padding:24px; ' +
|
||||||
|
'box-shadow:0 0 0 1px var(--gold-500) inset, 0 16px 32px rgba(14,31,51,0.20); max-height:90vh; overflow-y:auto;',
|
||||||
|
}, [
|
||||||
|
el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'Connect'),
|
||||||
|
el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:18px; margin:0 0 6px; color:var(--navy-950);' },
|
||||||
|
'Connect BTCPay Server'),
|
||||||
|
el('p', { class: 'muted', style: 'margin:6px 0 12px; font-size:13px; line-height:1.5' },
|
||||||
|
'One-click connect via BTCPay’s native authorize flow. Click below to open a consent page in your BTCPay server; ' +
|
||||||
|
'after you click Authorize there, Keysat auto-detects the store, registers the webhook, and finishes setup. ' +
|
||||||
|
'You don’t need to copy an API key or store id anywhere.'),
|
||||||
|
openBtn,
|
||||||
|
status,
|
||||||
|
urlBox,
|
||||||
|
urlCopy,
|
||||||
|
el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [cancelBtn]),
|
||||||
|
])
|
||||||
|
overlay.appendChild(cardEl)
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close() })
|
||||||
|
document.body.appendChild(overlay)
|
||||||
|
}
|
||||||
|
|
||||||
async function renderApiKeysCard(host) {
|
async function renderApiKeysCard(host) {
|
||||||
host.innerHTML = ''
|
host.innerHTML = ''
|
||||||
let keys = []
|
let keys = []
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ const RELEASE_NOTES = [
|
|||||||
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
// in RELEASE_NOTES above (the milestone). Subsequent revisions
|
||||||
// append here.
|
// append here.
|
||||||
const ROUTINE_NOTES = [
|
const ROUTINE_NOTES = [
|
||||||
|
'0.2.0:41 — **Two fixes: Patron tier now implies the full Pro feature surface, and BTCPay Connect is back to one-click authorize.** Both came from operator-side bugs that the admin-UI redesign exposed.',
|
||||||
|
'',
|
||||||
|
'**Patron implies Pro at the resolution layer.** Previously, every `tier.has(<pro-entitlement>)` check required the Patron POLICY on the master Keysat to redundantly list every Pro entitlement (`unlimited_products`, `unlimited_policies`, `unlimited_codes`, `recurring_billing`, `zaprite_payments`) — if the operator forgot even one slug on the Patron policy, every Patron customer was silently locked out of that feature. The Zaprite gate caught this in the wild: a Patron license without `zaprite_payments` got an "Upgrade to Pro" CTA on the payment-providers page. Fixed at the right layer: `tier::current()` now expands `patron` into the full Pro entitlement set on read, so a Patron policy can list just `patron` and have everything Pro grants flow through automatically. Existing Patron customers get the implied entitlements without re-issuing a license. Recommended cleanup: also list the entitlements explicitly on the Patron policy itself so the buy-page tier card stays informative — but the gate behavior no longer depends on it.',
|
||||||
|
'',
|
||||||
|
'**BTCPay Connect: one-click authorize flow restored.** When BTCPay setup moved from a StartOS action (where it was a one-click `Connect BTCPay` that returned a URL the operator opened in their browser to authorize, with the API key / store id / webhook secret auto-detected by Keysat) to the admin UI’s Payment Providers card, it regressed to a four-field paste form asking for Base URL, API key, Store id, and Webhook secret. The daemon-side `/v1/admin/btcpay/connect` endpoint never changed — it still returns an `authorize_url` for BTCPay’s consent page — the form just stopped using it. Rewrote the BTCPay path in the modal: a "Connect BTCPay Server" dialog now has one primary button, "Open BTCPay to authorize". On click, it requests the authorize URL, opens it in a new tab, displays it as copyable text (for operators on a different device than their browser), and polls `/v1/admin/btcpay/status` every 2.5 seconds. The moment BTCPay’s callback lands and the store/webhook are persisted, the modal closes itself and the Payment Providers card re-renders showing BTCPay connected. Zaprite’s connect path is unchanged (Zaprite has no OAuth-style consent endpoint; an API key paste is still required).',
|
||||||
|
'',
|
||||||
|
'**Upgrade path.** Drop-in. No schema, no SDK change. Operators currently stuck on the four-field BTCPay form get the new one-click button automatically; the manual-fields path is removed for BTCPay since it never actually wrote those fields server-side anyway.',
|
||||||
|
'',
|
||||||
'0.2.0:40 — **Discount-code slot reaper plugs the abandoned-cart leak.** When a buyer clicked Pay with Bitcoin with a discount code applied, the daemon reserved a slot on that code (incrementing `used_count`) BEFORE creating the BTCPay invoice. This is the right pessimistic-lock behavior — prevents two buyers from racing for the last slot of a limited code — but it meant abandoned checkouts only freed the slot when BTCPay later fired `InvoiceExpired`. If that webhook never landed (network blip, daemon offline at the firing moment, misconfigured webhook URL), the slot leaked forever. New 5-minute background reaper closes both holes: scans `discount_redemptions` where status=\'pending\' and the linked invoice is either in a terminal failure state (\'expired\' / \'invalid\') OR has been sitting in \'pending\' for more than 30 minutes, and cancels each one — flipping the redemption to \'cancelled\' and decrementing the code\'s `used_count` so the slot is available again. 30-min threshold covers BTCPay\'s default 15-min invoice expiry plus webhook-delivery buffer. Lives alongside the existing hourly session reaper in `main.rs`. Internal-only; no API or schema change. Operator-visible only in the sense that limited-discount slots no longer drift over time.',
|
'0.2.0:40 — **Discount-code slot reaper plugs the abandoned-cart leak.** When a buyer clicked Pay with Bitcoin with a discount code applied, the daemon reserved a slot on that code (incrementing `used_count`) BEFORE creating the BTCPay invoice. This is the right pessimistic-lock behavior — prevents two buyers from racing for the last slot of a limited code — but it meant abandoned checkouts only freed the slot when BTCPay later fired `InvoiceExpired`. If that webhook never landed (network blip, daemon offline at the firing moment, misconfigured webhook URL), the slot leaked forever. New 5-minute background reaper closes both holes: scans `discount_redemptions` where status=\'pending\' and the linked invoice is either in a terminal failure state (\'expired\' / \'invalid\') OR has been sitting in \'pending\' for more than 30 minutes, and cancels each one — flipping the redemption to \'cancelled\' and decrementing the code\'s `used_count` so the slot is available again. 30-min threshold covers BTCPay\'s default 15-min invoice expiry plus webhook-delivery buffer. Lives alongside the existing hourly session reaper in `main.rs`. Internal-only; no API or schema change. Operator-visible only in the sense that limited-discount slots no longer drift over time.',
|
||||||
'',
|
'',
|
||||||
'0.2.0:39 — **Buy page now renders a tier card for single-public-policy products.** Previously the tier picker only rendered when a product had two or more public policies; single-public-policy products fell back to a bare price card + form, swallowing all the operator-configured entitlements, marketing bullets, and tier descriptions. Fixed: render a single centered tier card (new `.tiers-1` grid class, ~480px max-width) whenever there\'s at least one public policy. Operators who keep most tiers private and only expose one (e.g. "Pro" public, "Core" and "Max" admin-only) now see the same rich tier-card render that multi-tier products get. The price card below still renders unchanged as the buy-confirmation summary.',
|
'0.2.0:39 — **Buy page now renders a tier card for single-public-policy products.** Previously the tier picker only rendered when a product had two or more public policies; single-public-policy products fell back to a bare price card + form, swallowing all the operator-configured entitlements, marketing bullets, and tier descriptions. Fixed: render a single centered tier card (new `.tiers-1` grid class, ~480px max-width) whenever there\'s at least one public policy. Operators who keep most tiers private and only expose one (e.g. "Pro" public, "Core" and "Max" admin-only) now see the same rich tier-card render that multi-tier products get. The price card below still renders unchanged as the buy-confirmation summary.',
|
||||||
@@ -509,7 +517,7 @@ const ROUTINE_NOTES = [
|
|||||||
].join('\n\n')
|
].join('\n\n')
|
||||||
|
|
||||||
export const v0_2_0 = VersionInfo.of({
|
export const v0_2_0 = VersionInfo.of({
|
||||||
version: '0.2.0:40',
|
version: '0.2.0:41',
|
||||||
releaseNotes: { en_US: ROUTINE_NOTES },
|
releaseNotes: { en_US: ROUTINE_NOTES },
|
||||||
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
// No on-disk transformation needed — v0.2.0:0 is a label change.
|
||||||
// SQLite-level migrations live separately under
|
// SQLite-level migrations live separately under
|
||||||
|
|||||||
Reference in New Issue
Block a user