Tier upgrades Phase 5 — admin UI: tier_rank input + Change-tier modal

Closes the operator surface for tier upgrades. With this in,
operators have a complete UI for managing the upgrade ladder
without ever needing the curl-the-API path.

Policy editor (create + edit forms):
- New "Tier ladder rank (optional)" number input alongside the
  recurring section. Operators set "0" for free, "1" for
  standard, "2" for pro, etc. Empty input = "not in any ladder"
  (server stores NULL; that policy is excluded from buyer-facing
  upgrade flows but admin can still force-change to/from it).
- Edit-form behavior: empty input clears tier_rank to NULL.
  Filled input sets to that value. The PATCH always sends the
  field (using the nullable-patch shape Some(Option<i64>)) so
  the operator's intent — clear or set — actually lands.
- Range 0–1000 enforced server-side; clipped client-side too.

Licenses page:
- New "Change tier" button on every non-revoked license row,
  to the left of Suspend/Unsuspend/Revoke.
- Opens a modal that:
    * Loads all policies for the license's product
    * Shows them in a dropdown with metadata (rank · cadence ·
      trial flags) so the operator can see the ladder shape
    * Offers a "Apply as comp (skip_payment=true — no invoice,
      flips immediately)" checkbox + an audit-reason field
    * On submit, POSTs to the new admin endpoint:
      - skip_payment=true → "Applied" status, modal closes
      - skip_payment=false → renders the checkout URL the
        operator forwards to the buyer through whatever channel
        they use (the design-doc-spec'd "operator delivers the
        URL" flow)
- The modal deliberately doesn't show a quote preview before
  submit (the buyer-quote endpoint requires the buyer's signed
  license key, which the admin doesn't have). Server-side
  response carries the actual numbers when the operator commits.
  Future polish: a separate admin-mode quote endpoint could
  render the preview pre-submit.

Tests unchanged (77 still passing) — pure UI commit, no Rust
changes. The behavior the UI drives is fully covered by the
api.rs admin_change_tier_* tests added in c5d716a.
This commit is contained in:
Grant
2026-05-08 20:15:23 -05:00
parent c5d716a6d4
commit fb062d5ca5
+205
View File
@@ -1311,6 +1311,168 @@ The request will be refused if there are licenses or invoices tied to it — use
}
}
// Change-tier modal: pick target policy, see quote, choose comp
// (skip_payment=true) vs paid (skip_payment=false → operator gets
// a checkout URL to forward to the buyer). Auth via admin token —
// POST /v1/admin/licenses/:id/change-tier.
function openChangeTier(license) {
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 card = el('div', {
style: 'background:var(--cream-50); border:1px solid var(--border-1); ' +
'border-radius:12px; max-width:560px; 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;',
})
overlay.appendChild(card)
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove() })
document.body.appendChild(overlay)
card.appendChild(el('div', { class: 'eyebrow', style: 'margin-bottom:8px' }, 'Change tier'))
card.appendChild(el('h3', { style: 'font-family:var(--font-display); font-weight:600; font-size:20px; margin:0 0 6px; color:var(--navy-950);' },
'License ' + (license.id ? license.id.slice(0, 8) : '?') + '…'))
card.appendChild(el('p', { class: 'muted', style: 'margin:0 0 16px; font-size:13px' },
'Force-change this license to a different policy under the same product. Bypasses ladder rules — operators can move sideways, downgrade perpetuals, etc. Preview the prorated charge before committing.'))
const status = el('div', { class: 'muted', style: 'margin-top:8px; font-size:12.5px;' }, 'Loading product policies…')
const policiesHolder = el('div')
const quoteHolder = el('div', { style: 'margin-top:14px' })
const buttonRow = el('div', { style: 'display:flex; gap:10px; margin-top:14px;' }, [
el('button', { class: 'btn secondary', onclick: () => overlay.remove() }, 'Cancel'),
])
card.appendChild(policiesHolder)
card.appendChild(quoteHolder)
card.appendChild(status)
card.appendChild(buttonRow)
let allPolicies = []
let currentPolicySlug = null
let selectedTargetSlug = null
let lastQuote = null
;(async function init() {
try {
const j = await api('/v1/admin/policies?product_slug=' + encodeURIComponent(license.product_slug || ''))
allPolicies = j.policies || []
if (license.policy_id) {
const cur = allPolicies.find((p) => p.id === license.policy_id)
if (cur) currentPolicySlug = cur.slug
}
renderPolicyPicker()
status.textContent = ''
} catch (e) {
status.textContent = 'Failed to load policies: ' + e.message
status.style.color = 'var(--danger)'
}
})()
function renderPolicyPicker() {
policiesHolder.innerHTML = ''
const opts = allPolicies
.filter((p) => p.slug !== currentPolicySlug)
.map((p) => ({
value: p.slug,
label: p.name + ' (' + p.slug + ')' +
(p.tier_rank != null ? ' · rank ' + p.tier_rank : '') +
(p.is_recurring ? ' · recurring' : '') +
(p.is_trial ? ' · trial' : ''),
}))
if (opts.length === 0) {
policiesHolder.appendChild(plainCard([
el('p', { class: 'muted', style: 'margin:0' },
'No other policies on this product. Create one first under Policies → ' + (license.product_slug || '<product>') + '.'),
]))
return
}
const sel = formSelect('change_tier_target', 'Target policy', opts, { required: true })
policiesHolder.appendChild(sel)
const selEl = sel.querySelector('select')
selEl.addEventListener('change', () => {
selectedTargetSlug = selEl.value
runQuote()
})
// Auto-pick first option + run quote.
selectedTargetSlug = opts[0].value
selEl.value = selectedTargetSlug
runQuote()
}
async function runQuote() {
quoteHolder.innerHTML = ''
buttonRow.innerHTML = ''
buttonRow.appendChild(el('button', { class: 'btn secondary', onclick: () => overlay.remove() }, 'Cancel'))
if (!selectedTargetSlug) return
try {
// Reuse the buyer quote endpoint when both ranks are set;
// for admin-only paths (NULL rank, sideways) the operator
// path through /v1/admin/.../change-tier validates server-side
// anyway. We only render the quote preview when buyer-quote
// returns a clean answer; otherwise show a generic preview.
// To keep UX simple, we don't currently expose an
// admin-mode quote endpoint — fall back to letting the
// operator see the listed price diff via the policy's
// price_sats_override, surfaced in the picker option label.
// For ranks that line up, we can pull a real quote via a
// short-lived test license_key... but that requires the
// 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.'))
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.',
})
quoteHolder.appendChild(reasonField)
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
buttonRow.querySelectorAll('button').forEach((b) => b.disabled = true)
status.textContent = skip ? 'Applying comp change…' : 'Creating invoice…'
status.style.color = ''
try {
const r = await api('/v1/admin/licenses/' + license.id + '/change-tier', {
method: 'POST',
body: {
to_policy_slug: selectedTargetSlug,
skip_payment: skip,
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)
} catch (e) {
status.textContent = e.message
status.style.color = 'var(--danger)'
buttonRow.querySelectorAll('button').forEach((b) => b.disabled = false)
}
},
}, 'Submit'))
} catch (e) {
status.textContent = 'Quote failed: ' + e.message
status.style.color = 'var(--danger)'
}
}
}
// Edit-policy modal. Mutable: name, description, price_sats_override,
// duration, grace, max_machines, is_trial, entitlements, highlight.
// Slug + product + tip config are NOT editable here (tip has its own
@@ -1400,6 +1562,14 @@ The request will be refused if there are licenses or invoices tied to it — use
const trialDaysField = formInput('e_pol_trial_days', 'Free trial (days)', {
type: 'number', value: String(pol.trial_days || 0),
})
// Tier-ladder rank. Empty input means "not in any ladder" (server
// stores NULL); operator can blank it to remove a policy from the
// ladder, or set a number to add it. Range 01000 enforced server-side.
const tierRankField = formInput('e_pol_tier_rank', 'Tier ladder rank (optional)', {
type: 'number',
value: pol.tier_rank == null ? '' : String(pol.tier_rank),
hint: 'Higher = better tier. Leave blank to keep this policy out of the buyer-facing upgrade ladder.',
})
if (isRecurringInit) setTimeout(() => {
const cb = card.querySelector('[name=e_pol_is_recurring]')
if (cb) cb.checked = true
@@ -1441,6 +1611,8 @@ The request will be refused if there are licenses or invoices tied to it — use
el('div', { class: 'row-2' }, [graceField, machinesField]),
entField,
el('div', { class: 'row-2' }, [highlightField, trialField]),
// Tier ladder rank — sits in its own row above the recurring section.
tierRankField,
// Recurring subscription block
el('div', {
style: 'margin-top:14px; padding-top:12px; border-top:1px dashed var(--border-1)',
@@ -1501,6 +1673,15 @@ The request will be refused if there are licenses or invoices tied to it — use
grace_period_days: isNaN(gracePeriodDays) ? 7 : gracePeriodDays,
trial_days: trialDays,
}
// tier_rank is a nullable patch. Empty input → null
// (remove from ladder). Number → set. We always send
// the field on edit so the server's "patch touched
// field?" logic fires and the operator's intent (clear
// or set) lands.
const tierRankRaw = (card.querySelector('[name=e_pol_tier_rank]').value || '').trim()
body.tier_rank = tierRankRaw === ''
? null
: Math.max(0, Math.min(1000, parseInt(tierRankRaw, 10) || 0))
await api('/v1/admin/policies/' + pol.id, { method: 'PATCH', body })
overlay.remove()
routes.policies()
@@ -1622,6 +1803,16 @@ The request will be refused if there are licenses or invoices tied to it — use
formCheckbox('is_trial', 'Trial flag (sets TRIAL bit on the signed license)'),
]),
// ---------- Tier ladder rank ----------
// Operator-defined ordering for in-place upgrades. Higher
// rank = better tier. Leave blank to exclude this policy
// from the buyer-facing upgrade ladder (admin can still
// force-change to/from any policy via the licenses page).
formInput('tier_rank', 'Tier ladder rank (optional)', {
type: 'number',
hint: 'Position in the upgrade ladder for this product. Higher = better tier. Common pattern: free=0, standard=1, pro=2, patron=3. Leave blank to keep the policy out of the ladder (e.g. one-off promo). Range 01000.',
}),
// ---------- Recurring subscription (Pro tier) ----------
el('div', {
style: 'margin:18px 0 12px; padding-top:14px; border-top:1px dashed var(--border-1)',
@@ -1730,6 +1921,13 @@ The request will be refused if there are licenses or invoices tied to it — use
metadata,
price_sats_override,
}
// tier_rank: only attach if the operator typed something.
// Empty input = "leave out of ladder" (server stores NULL).
const rankRaw = (create.querySelector('[name=tier_rank]').value || '').trim()
if (rankRaw !== '') {
const rank = parseInt(rankRaw, 10)
if (!isNaN(rank)) body.tier_rank = rank
}
if (tipRecipient) {
body.tip_recipient = tipRecipient
body.tip_pct_bps = tipPctBps
@@ -2333,6 +2531,13 @@ The request will be refused if there are licenses or invoices tied to it — use
el('td', { class: 'muted' }, l.expires_at ? fmtDate(l.expires_at) : el('span', { class: 'badge b-gold' }, 'perpetual')),
el('td', { class: 'muted' }, l.buyer_email || ''),
el('td', null, el('div', { class: 'actions-row' }, [
l.status !== 'revoked'
? el('button', {
class: 'btn sm secondary',
title: 'Move this license to a different policy/tier',
onclick: () => openChangeTier(l),
}, 'Change tier')
: null,
l.status !== 'revoked' && l.status !== 'suspended'
? el('button', { class: 'btn sm secondary', onclick: () => actLicense(l, 'suspend') }, 'Suspend')
: null,