db26dafc64
Editing a catalog amount now propagates beyond MongoDB. Stripe Prices are immutable, so each changed currency mints a fresh Stripe Price at the new amount, overwrites the cached stripePriceIds[currency] (which also fixes the stale-price bug for new subscriptions), and repoints every live subscription on that (row, currency) onto it with proration_behavior 'none' — the new amount takes effect at the customer's next billing cycle, no mid-cycle charge. The per-seat snapshot is refreshed so MRR reflects the go-forward rate. Before committing the edit, the operator sees a warning with the affected customer count, driven by a new GET /prices/:id/impact endpoint. Per-sub failures are logged, never fatal; Stripe-disabled rows still re-snapshot.
456 lines
16 KiB
Vue
456 lines
16 KiB
Vue
<script setup lang="ts">
|
|
// Pricing catalog editor. Operator-only. Each (plan, cycle) is a single row
|
|
// with three independent per-currency amounts (DKK / EUR / USD). Operator
|
|
// types clean round numbers in each currency — no FX derivation. Empty cells
|
|
// mean "we don't sell this plan/cycle in that currency."
|
|
//
|
|
// Backed by /prices in platform-api. Amounts are stored in MINOR units
|
|
// (4900 = 49.00); display uses major-unit strings with 2 decimals.
|
|
|
|
const CURRENCIES = ['DKK', 'EUR', 'USD'] as const
|
|
type Currency = (typeof CURRENCIES)[number]
|
|
|
|
interface PriceRow {
|
|
_id: string
|
|
plan: 'mvp' | 'pro' | 'enterprise'
|
|
cycle: 'monthly' | 'quarterly' | 'yearly'
|
|
amounts: Partial<Record<Currency, number>>
|
|
active: boolean
|
|
createdAt?: string
|
|
updatedAt?: string
|
|
}
|
|
|
|
const toast = useToast()
|
|
const showInactive = ref(false)
|
|
|
|
const { data: prices, refresh } = await useFetch<PriceRow[]>(
|
|
() => `/api/prices${showInactive.value ? '?includeInactive=true' : ''}`,
|
|
{ key: 'pricing-catalog', default: () => [], watch: [showInactive] },
|
|
)
|
|
|
|
const PLAN_LABEL: Record<PriceRow['plan'], string> = {
|
|
mvp: 'Starter',
|
|
pro: 'Business',
|
|
enterprise: 'Enterprise',
|
|
}
|
|
const CYCLE_LABEL: Record<PriceRow['cycle'], string> = {
|
|
monthly: 'Monthly',
|
|
quarterly: 'Quarterly',
|
|
yearly: 'Yearly',
|
|
}
|
|
|
|
function toMajor(minor?: number): string {
|
|
if (typeof minor !== 'number') return ''
|
|
return (minor / 100).toFixed(2)
|
|
}
|
|
function toMinor(major: string): number | undefined {
|
|
if (!major.trim()) return undefined // empty → unset that currency
|
|
const cleaned = major.replace(/\s+/g, '').replace(',', '.')
|
|
const n = Number(cleaned)
|
|
if (!Number.isFinite(n) || n < 0) return NaN
|
|
return Math.round(n * 100)
|
|
}
|
|
|
|
// Per-row draft. Keyed by row._id, holds one major-unit string per currency.
|
|
// undefined draft = not currently editing.
|
|
type Draft = Record<Currency, string>
|
|
const drafts = reactive<Record<string, Draft>>({})
|
|
const saving = ref<string | null>(null)
|
|
|
|
function startEdit(row: PriceRow) {
|
|
drafts[row._id] = {
|
|
DKK: toMajor(row.amounts.DKK),
|
|
EUR: toMajor(row.amounts.EUR),
|
|
USD: toMajor(row.amounts.USD),
|
|
}
|
|
}
|
|
function cancelEdit(id: string) {
|
|
delete drafts[id]
|
|
}
|
|
|
|
// A pending price change held back for operator confirmation, because it
|
|
// re-prices live customers. Resolved by confirmReprice() or dismissed.
|
|
interface PendingChange {
|
|
row: PriceRow
|
|
next: Partial<Record<Currency, number>>
|
|
changes: { currency: Currency; from?: number; to: number; affected: number }[]
|
|
totalAffected: number
|
|
}
|
|
const pending = ref<PendingChange | null>(null)
|
|
const confirmBusy = ref(false)
|
|
|
|
async function saveEdit(row: PriceRow) {
|
|
const draft = drafts[row._id]
|
|
if (!draft) return
|
|
const next: Partial<Record<Currency, number>> = {}
|
|
for (const c of CURRENCIES) {
|
|
const parsed = toMinor(draft[c])
|
|
if (parsed === undefined) continue // empty input → currency stays unset
|
|
if (Number.isNaN(parsed)) {
|
|
toast.warn(`Invalid ${c} amount`, 'Enter a number ≥ 0, or blank to leave unset')
|
|
return
|
|
}
|
|
next[c] = parsed
|
|
}
|
|
|
|
// Which currencies actually changed value? Only those re-price customers.
|
|
const changedCurrencies = CURRENCIES.filter((c) => next[c] !== undefined && next[c] !== row.amounts[c])
|
|
if (changedCurrencies.length === 0) {
|
|
cancelEdit(row._id) // nothing changed — just close the editor
|
|
return
|
|
}
|
|
|
|
// Ask the backend how many live customers each changed currency touches.
|
|
saving.value = row._id
|
|
let impact: Record<Currency, number> = { DKK: 0, EUR: 0, USD: 0 }
|
|
try {
|
|
impact = await $fetch<Record<Currency, number>>(`/api/prices/${row._id}/impact`)
|
|
} catch {
|
|
// Impact lookup failed — fall through with zeros; the save still works,
|
|
// we just can't show the affected-customer count.
|
|
} finally {
|
|
saving.value = null
|
|
}
|
|
|
|
const changes = changedCurrencies.map((c) => ({
|
|
currency: c,
|
|
from: row.amounts[c],
|
|
to: next[c]!,
|
|
affected: impact[c] ?? 0,
|
|
}))
|
|
const totalAffected = changes.reduce((sum, ch) => sum + ch.affected, 0)
|
|
|
|
// No live customers on the changed currencies → commit straight away.
|
|
if (totalAffected === 0) {
|
|
await commitSave(row, next)
|
|
return
|
|
}
|
|
pending.value = { row, next, changes, totalAffected }
|
|
}
|
|
|
|
// The actual PATCH. Backend mints new Stripe Prices for changed currencies and
|
|
// moves live subs onto them (effective next cycle).
|
|
async function commitSave(row: PriceRow, next: Partial<Record<Currency, number>>) {
|
|
saving.value = row._id
|
|
try {
|
|
await $fetch(`/api/prices/${row._id}`, { method: 'PATCH', body: { amounts: next } })
|
|
toast.ok('Prices updated', `${PLAN_LABEL[row.plan]} · ${CYCLE_LABEL[row.cycle]}`)
|
|
cancelEdit(row._id)
|
|
await refresh()
|
|
} catch (err) {
|
|
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
|
toast.warn('Update failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
|
|
} finally {
|
|
saving.value = null
|
|
}
|
|
}
|
|
|
|
async function confirmReprice() {
|
|
if (!pending.value) return
|
|
const { row, next } = pending.value
|
|
confirmBusy.value = true
|
|
try {
|
|
await commitSave(row, next)
|
|
} finally {
|
|
confirmBusy.value = false
|
|
pending.value = null
|
|
}
|
|
}
|
|
|
|
async function toggleActive(row: PriceRow) {
|
|
saving.value = row._id
|
|
try {
|
|
await $fetch(`/api/prices/${row._id}`, { method: 'PATCH', body: { active: !row.active } })
|
|
toast.ok(
|
|
row.active ? 'Price deactivated' : 'Price reactivated',
|
|
`${PLAN_LABEL[row.plan]} · ${CYCLE_LABEL[row.cycle]}`,
|
|
)
|
|
await refresh()
|
|
} catch (err) {
|
|
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
|
toast.warn('Toggle failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
|
|
} finally {
|
|
saving.value = null
|
|
}
|
|
}
|
|
|
|
// Add-row form. Used to insert an Enterprise row or replace a deactivated one.
|
|
const addForm = reactive<{
|
|
plan: PriceRow['plan']
|
|
cycle: PriceRow['cycle']
|
|
amounts: Record<Currency, string>
|
|
}>({
|
|
plan: 'enterprise',
|
|
cycle: 'monthly',
|
|
amounts: { DKK: '', EUR: '', USD: '' },
|
|
})
|
|
const adding = ref(false)
|
|
|
|
async function addRow() {
|
|
const amounts: Partial<Record<Currency, number>> = {}
|
|
for (const c of CURRENCIES) {
|
|
const parsed = toMinor(addForm.amounts[c])
|
|
if (parsed === undefined) continue
|
|
if (Number.isNaN(parsed)) {
|
|
toast.warn(`Invalid ${c} amount`, 'Enter a number ≥ 0')
|
|
return
|
|
}
|
|
amounts[c] = parsed
|
|
}
|
|
if (Object.keys(amounts).length === 0) {
|
|
toast.warn('No prices entered', 'Set at least one currency amount')
|
|
return
|
|
}
|
|
adding.value = true
|
|
try {
|
|
await $fetch('/api/prices', {
|
|
method: 'POST',
|
|
body: { plan: addForm.plan, cycle: addForm.cycle, amounts },
|
|
})
|
|
toast.ok('Row added', `${PLAN_LABEL[addForm.plan]} · ${CYCLE_LABEL[addForm.cycle]}`)
|
|
for (const c of CURRENCIES) addForm.amounts[c] = ''
|
|
await refresh()
|
|
} catch (err) {
|
|
const e = err as { data?: { data?: { message?: string }; message?: string } }
|
|
toast.warn('Add failed', e.data?.data?.message ?? e.data?.message ?? 'Try again')
|
|
} finally {
|
|
adding.value = false
|
|
}
|
|
}
|
|
|
|
// Sort: active first, then plan order (mvp/pro/enterprise), then cycle order.
|
|
const PLAN_ORDER: Record<PriceRow['plan'], number> = { mvp: 0, pro: 1, enterprise: 2 }
|
|
const CYCLE_ORDER: Record<PriceRow['cycle'], number> = { monthly: 0, quarterly: 1, yearly: 2 }
|
|
const sortedPrices = computed<PriceRow[]>(() =>
|
|
[...(prices.value ?? [])].sort((a, b) => {
|
|
if (a.active !== b.active) return a.active ? -1 : 1
|
|
if (a.plan !== b.plan) return PLAN_ORDER[a.plan] - PLAN_ORDER[b.plan]
|
|
return CYCLE_ORDER[a.cycle] - CYCLE_ORDER[b.cycle]
|
|
}),
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<PageHeader
|
|
eyebrow="Operator · operator.dezky.local"
|
|
title="Pricing catalog"
|
|
subtitle="One row per plan + cycle, with independent prices per currency. Editing an amount re-prices live customers on that currency at their next billing cycle (no mid-cycle charge) and applies to all new subscriptions."
|
|
>
|
|
<template #actions>
|
|
<label class="toggle">
|
|
<input v-model="showInactive" type="checkbox" />
|
|
Show inactive
|
|
</label>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<div class="stage">
|
|
<Card :pad="0">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Plan</th>
|
|
<th>Cycle</th>
|
|
<th v-for="c in CURRENCIES" :key="c" class="th-amount">{{ c }} / seat</th>
|
|
<th>Status</th>
|
|
<th class="th-actions">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="sortedPrices.length === 0" class="empty">
|
|
<td :colspan="4 + CURRENCIES.length">
|
|
<span class="empty-inner">No prices yet — add one below.</span>
|
|
</td>
|
|
</tr>
|
|
<tr v-for="row in sortedPrices" :key="row._id" :class="{ inactive: !row.active }">
|
|
<td>
|
|
<div class="cell-name">{{ PLAN_LABEL[row.plan] }}</div>
|
|
<Mono dim>{{ row.plan }}</Mono>
|
|
</td>
|
|
<td>{{ CYCLE_LABEL[row.cycle] }}</td>
|
|
<td v-for="c in CURRENCIES" :key="c" class="cell-amount">
|
|
<template v-if="drafts[row._id]">
|
|
<input
|
|
v-model="drafts[row._id]![c]"
|
|
type="text"
|
|
inputmode="decimal"
|
|
class="amount-input"
|
|
:placeholder="`— ${c}`"
|
|
:disabled="saving === row._id"
|
|
@keydown.enter="saveEdit(row)"
|
|
@keydown.escape="cancelEdit(row._id)"
|
|
/>
|
|
</template>
|
|
<template v-else>
|
|
<template v-if="row.amounts[c] !== undefined">
|
|
<span class="amount">{{ toMajor(row.amounts[c]) }}</span>
|
|
</template>
|
|
<Mono v-else dim>—</Mono>
|
|
</template>
|
|
</td>
|
|
<td>
|
|
<Badge :tone="row.active ? 'ok' : 'neutral'" dot>{{ row.active ? 'active' : 'inactive' }}</Badge>
|
|
</td>
|
|
<td class="cell-actions">
|
|
<div class="actions">
|
|
<template v-if="drafts[row._id]">
|
|
<UiButton size="sm" variant="primary" :disabled="saving === row._id" @click="saveEdit(row)">
|
|
{{ saving === row._id ? 'Saving…' : 'Save' }}
|
|
</UiButton>
|
|
<UiButton size="sm" variant="ghost" :disabled="saving === row._id" @click="cancelEdit(row._id)">
|
|
Cancel
|
|
</UiButton>
|
|
</template>
|
|
<template v-else>
|
|
<UiButton size="sm" variant="secondary" @click="startEdit(row)">Edit</UiButton>
|
|
<UiButton size="sm" variant="ghost" :disabled="saving === row._id" @click="toggleActive(row)">
|
|
{{ row.active ? 'Deactivate' : 'Reactivate' }}
|
|
</UiButton>
|
|
</template>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</Card>
|
|
|
|
<Card>
|
|
<h2>Add a row</h2>
|
|
<p class="hint">
|
|
Used for missing Enterprise rows or to replace a deactivated row.
|
|
Fill in only the currencies you want to offer; blank cells mean
|
|
"not sold in this currency."
|
|
</p>
|
|
<div class="add-form">
|
|
<label class="field">
|
|
<Eyebrow>Plan</Eyebrow>
|
|
<select v-model="addForm.plan">
|
|
<option value="mvp">Starter</option>
|
|
<option value="pro">Business</option>
|
|
<option value="enterprise">Enterprise</option>
|
|
</select>
|
|
</label>
|
|
<label class="field">
|
|
<Eyebrow>Cycle</Eyebrow>
|
|
<select v-model="addForm.cycle">
|
|
<option value="monthly">Monthly</option>
|
|
<option value="quarterly">Quarterly</option>
|
|
<option value="yearly">Yearly</option>
|
|
</select>
|
|
</label>
|
|
<label v-for="c in CURRENCIES" :key="c" class="field">
|
|
<Eyebrow>{{ c }} / seat</Eyebrow>
|
|
<input
|
|
v-model="addForm.amounts[c]"
|
|
type="text"
|
|
inputmode="decimal"
|
|
:placeholder="`e.g. ${c === 'DKK' ? '49.00' : '7.00'}`"
|
|
/>
|
|
</label>
|
|
<UiButton variant="primary" :disabled="adding" @click="addRow">
|
|
{{ adding ? 'Adding…' : 'Add row' }}
|
|
</UiButton>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
|
|
<ConfirmDialog
|
|
:open="pending !== null"
|
|
eyebrow="Pricing catalog"
|
|
:title="pending ? `Change ${PLAN_LABEL[pending.row.plan]} · ${CYCLE_LABEL[pending.row.cycle]} pricing?` : ''"
|
|
confirm-label="Apply change"
|
|
:busy="confirmBusy"
|
|
@close="pending = null"
|
|
@confirm="confirmReprice"
|
|
>
|
|
<template v-if="pending">
|
|
<p>
|
|
<strong>{{ pending.totalAffected }}</strong>
|
|
active {{ pending.totalAffected === 1 ? 'customer' : 'customers' }} will move to the
|
|
new price at their <strong>next billing cycle</strong>. The current period and past
|
|
invoices are unaffected — no mid-cycle charge or credit.
|
|
</p>
|
|
<ul class="reprice-list">
|
|
<li v-for="ch in pending.changes" :key="ch.currency">
|
|
<Mono>{{ ch.currency }}</Mono>
|
|
<span class="reprice-amounts">
|
|
{{ ch.from !== undefined ? toMajor(ch.from) : '—' }} →
|
|
<strong>{{ toMajor(ch.to) }}</strong>
|
|
</span>
|
|
<span class="reprice-count">
|
|
{{ ch.affected }} {{ ch.affected === 1 ? 'customer' : 'customers' }}
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
</template>
|
|
</ConfirmDialog>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.stage { padding: 24px 40px 64px; display: flex; flex-direction: column; gap: 18px; }
|
|
|
|
.toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
color: var(--text-mute);
|
|
}
|
|
|
|
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
|
|
th, td { padding: 12px 16px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
|
|
th { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-mute); font-weight: 500; }
|
|
.cell-name { font-size: 13px; font-weight: 500; }
|
|
.empty .empty-inner { display: block; padding: 24px 0; text-align: center; color: var(--text-mute); }
|
|
tr.inactive { opacity: 0.55; }
|
|
|
|
.th-amount, .cell-amount { text-align: right; width: 130px; white-space: nowrap; }
|
|
.amount { font-family: var(--font-mono); font-size: 13px; font-variant-numeric: tabular-nums; }
|
|
.amount-input {
|
|
width: 96px;
|
|
padding: 6px 8px;
|
|
text-align: right;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border-hi);
|
|
border-radius: 4px;
|
|
font-family: var(--font-mono);
|
|
font-size: 13px;
|
|
color: var(--text);
|
|
}
|
|
|
|
.th-actions, .cell-actions { text-align: right; width: 220px; }
|
|
.actions { display: inline-flex; gap: 6px; align-items: center; justify-content: flex-end; }
|
|
|
|
.add-form { display: grid; grid-template-columns: repeat(5, 1fr) auto; gap: 12px; align-items: end; }
|
|
.field { display: flex; flex-direction: column; gap: 6px; }
|
|
.field select, .field input {
|
|
height: 34px;
|
|
padding: 0 12px;
|
|
background: var(--input-bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
color: var(--text);
|
|
font-family: inherit;
|
|
font-size: 13px;
|
|
outline: 0;
|
|
}
|
|
.hint { font-size: 13px; color: var(--text-dim); margin: 0 0 12px; }
|
|
|
|
.reprice-list { list-style: none; margin: 14px 0 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
.reprice-list li {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 10px 12px;
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
font-size: 13px;
|
|
}
|
|
.reprice-amounts { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
|
.reprice-count { margin-left: auto; color: var(--text-mute); font-size: 12px; }
|
|
</style>
|