P1 — change-tier UX, Zaprite webhook copy, self-tier guard, Lightning copy
Bundle of bugfixes from the P1 testing batch. None individually
huge; together they close several "tested it, hit a sharp edge"
items.
1. Change-tier modal — kill the paid path from UI
The Apply-as-comp toggle is gone. Admin tier changes always
apply as comp now. The reasoning (per Grant's testing): admin
tier changes are operator-driven, payment has either already
happened off-rails or it's a comp; the "admin generates
invoice and forwards URL" flow is a tiny niche that just
produces orphan invoices when the modal gets dismissed.
Buyers who want to pay use the SDK's /v1/upgrade.
The API path is unchanged for back-compat with scripted
operators (skip_payment defaults to true here).
2. Change-tier modal — downgrade detection + warning banner
Detects target.tier_rank < current.tier_rank (or price-diff
when ranks aren't set), renders a yellow warning card listing
the entitlements the buyer is about to lose, and confirms via
browser dialog before submit. Operator sees what they're
doing.
3. Self-tier guard on admin change-tier
POST /v1/admin/licenses/<id>/change-tier rejects when <id>
is the daemon's own self_license. Avoids the recursion Grant
hit when trying to downgrade himself: the on-disk signed key
is the source-of-truth at boot, so the DB tier_change just
produces a half-applied state. Error message points at the
right paths (re-mint via master Keysat OR rename
/data/keysat-license.txt for testing). With the P0 self-tier
live-refresh in place the recursion is now fully resolved
anyway, but the guard is good belt-and-suspenders for
operator clarity.
4. Zaprite webhook — full URL in copy + persistent action
- The Connect Zaprite action now shows the EXACT
https://your-keysat-url/v1/zaprite/webhook URL to paste
into Zaprite's dashboard. Previous copy showed a
placeholder "<your Keysat public URL>/...", which Zaprite's
form rejects (it requires full https://). Daemon's
/v1/admin/zaprite/connect now returns webhook_url; the
action displays it.
- New "Show Zaprite webhook setup" StartOS Action — operators
who skipped the step on first connect, or who lost the
output, can run this any time and get the URL again.
- Full explainer of what webhooks unlock vs polling-only:
"without webhooks, Keysat polls /v1/orders every 60s, so
license issuance lags settle by up to a minute; with
webhooks, ~1s." Lives on /v1/admin/zaprite/status response
as `webhook_explainer` + in the action's display text.
5. Connect-while-connected short-circuit
POST /v1/admin/zaprite/connect now returns 409 Conflict with a
clear "already connected — disconnect first" message instead
of silently overwriting an existing config. (BTCPay's
start_connect already had this guard since the durable
provider switch work.)
6. Lightning vs on-chain copy on the wait page
/thank-you was hard-coded to "next block confirms" — wrong
for Lightning payments (instant) and confusing in the common
case where buyers paid via Lightning and saw a "waiting for
block confirmation" message. Updated to: "Lightning settles
in seconds; on-chain typically settles in 10-20 minutes (one
block confirmation)." Method-aware copy (parsed from the
provider's invoice payload) is a deeper fix but out of scope
here — this gets the operator-facing accuracy right today.
Test count unchanged; all 77 still passing.
This commit is contained in:
@@ -1420,11 +1420,46 @@ The request will be refused if there are licenses or invoices tied to it — use
|
||||
// buyer key, which the admin doesn't have. So: we skip
|
||||
// the quote preview in the admin UI and rely on the
|
||||
// server-side response after submit. Show a placeholder.
|
||||
quoteHolder.appendChild(el('p', { class: 'muted', style: 'margin:0; font-size:13px' },
|
||||
'The server will compute the prorated charge in the listed currency on submit. Toggle "Apply as comp" below to skip the invoice and move the license immediately at no charge.'))
|
||||
// Detect direction (upgrade vs downgrade) by comparing the
|
||||
// current and target policies' price_sats_override (or rank
|
||||
// when both ranked). Drives a "downgrade warning" banner so
|
||||
// the operator sees what entitlements the buyer is about to
|
||||
// lose. Cheap client-side compute; the server still
|
||||
// re-validates.
|
||||
const targetPol = allPolicies.find((p) => p.slug === selectedTargetSlug)
|
||||
const currentPol = allPolicies.find((p) => p.slug === currentPolicySlug)
|
||||
let isDowngrade = false
|
||||
if (targetPol && currentPol) {
|
||||
if (targetPol.tier_rank != null && currentPol.tier_rank != null) {
|
||||
isDowngrade = targetPol.tier_rank < currentPol.tier_rank
|
||||
} else {
|
||||
const a = currentPol.price_sats_override != null ? currentPol.price_sats_override : 0
|
||||
const b = targetPol.price_sats_override != null ? targetPol.price_sats_override : 0
|
||||
isDowngrade = b < a
|
||||
}
|
||||
}
|
||||
|
||||
if (isDowngrade && targetPol && currentPol) {
|
||||
// Build a list of entitlements the buyer is about to lose.
|
||||
const losing = (currentPol.entitlements || []).filter(
|
||||
(e) => !(targetPol.entitlements || []).includes(e),
|
||||
)
|
||||
const losingLine = losing.length > 0
|
||||
? 'Buyer will LOSE these entitlements: ' + losing.join(', ') + '.'
|
||||
: 'Buyer keeps the same entitlements.'
|
||||
quoteHolder.appendChild(plainCard([
|
||||
el('div', { style: 'color:#7a5814; font-weight:600; margin-bottom:6px' },
|
||||
'⚠ Downgrade'),
|
||||
el('p', { style: 'margin:0 0 8px; font-size:13px' },
|
||||
'You\'re moving this license from ' + currentPol.name + ' → ' + targetPol.name + '. ' +
|
||||
losingLine + ' The buyer will NOT be refunded automatically — handle any refund out of band.'),
|
||||
]))
|
||||
} else {
|
||||
quoteHolder.appendChild(el('p', { class: 'muted', style: 'margin:0; font-size:13px' },
|
||||
'Admin tier changes always apply as comp (no invoice, license flips immediately). ' +
|
||||
'For paid upgrades, point the buyer at /buy/<slug> or have their app drive the SDK\'s in-app purchase flow.'))
|
||||
}
|
||||
|
||||
const compToggle = formCheckbox('change_tier_skip_payment', 'Apply as comp (skip_payment=true — no invoice, license flips immediately)')
|
||||
quoteHolder.appendChild(compToggle)
|
||||
const reasonField = formInput('change_tier_reason', 'Audit reason (optional)', {
|
||||
hint: 'Free-form note. Stored on the tier_changes row + audit_log.',
|
||||
})
|
||||
@@ -1433,39 +1468,36 @@ The request will be refused if there are licenses or invoices tied to it — use
|
||||
buttonRow.appendChild(el('button', {
|
||||
class: 'btn primary',
|
||||
onclick: async () => {
|
||||
const skip = !!card.querySelector('[name=change_tier_skip_payment]').checked
|
||||
const reason = (card.querySelector('[name=change_tier_reason]').value || '').trim() || null
|
||||
// Confirm on downgrades — operator should explicitly OK
|
||||
// before the buyer loses entitlements.
|
||||
if (isDowngrade && !confirm('Confirm downgrade? Buyer loses entitlements without refund.')) {
|
||||
return
|
||||
}
|
||||
buttonRow.querySelectorAll('button').forEach((b) => b.disabled = true)
|
||||
status.textContent = skip ? 'Applying comp change…' : 'Creating invoice…'
|
||||
status.textContent = 'Applying…'
|
||||
status.style.color = ''
|
||||
try {
|
||||
// Always apply as comp from the admin UI. Paid admin
|
||||
// tier changes are admin-API-only (back-compat) — see
|
||||
// KEYSAT_INTEGRATION.md re: SDK-driven buyer upgrades.
|
||||
const r = await api('/v1/admin/licenses/' + license.id + '/change-tier', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
to_policy_slug: selectedTargetSlug,
|
||||
skip_payment: skip,
|
||||
skip_payment: true,
|
||||
reason,
|
||||
},
|
||||
})
|
||||
if (r.applied) {
|
||||
status.textContent = 'Applied — license is now on ' + r.to_policy_slug + '.'
|
||||
} else {
|
||||
const url = r.checkout_url || ''
|
||||
status.innerHTML = ''
|
||||
status.appendChild(el('div', null, 'Invoice created. Forward this URL to the buyer:'))
|
||||
const link = el('a', { href: url, target: '_blank', style: 'word-break:break-all' }, url)
|
||||
status.appendChild(link)
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (skip) overlay.remove()
|
||||
}, 800)
|
||||
status.textContent = 'Applied — license is now on ' + r.to_policy_slug + '.'
|
||||
setTimeout(() => overlay.remove(), 800)
|
||||
} catch (e) {
|
||||
status.textContent = e.message
|
||||
status.style.color = 'var(--danger)'
|
||||
buttonRow.querySelectorAll('button').forEach((b) => b.disabled = false)
|
||||
}
|
||||
},
|
||||
}, 'Submit'))
|
||||
}, 'Apply'))
|
||||
} catch (e) {
|
||||
status.textContent = 'Quote failed: ' + e.message
|
||||
status.style.color = 'var(--danger)'
|
||||
|
||||
Reference in New Issue
Block a user