feat(billing): sync catalog price edits to Stripe + re-price live customers
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.
This commit is contained in:
@@ -68,6 +68,17 @@ 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
|
||||
@@ -81,6 +92,45 @@ async function saveEdit(row: PriceRow) {
|
||||
}
|
||||
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 } })
|
||||
@@ -95,6 +145,18 @@ async function saveEdit(row: PriceRow) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -173,7 +235,7 @@ const sortedPrices = computed<PriceRow[]>(() =>
|
||||
<PageHeader
|
||||
eyebrow="Operator · operator.dezky.local"
|
||||
title="Pricing catalog"
|
||||
subtitle="One row per plan + cycle, with independent prices per currency. Changes affect subscriptions provisioned from now on — existing customers keep the price snapshot taken at provisioning."
|
||||
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">
|
||||
@@ -292,6 +354,37 @@ const sortedPrices = computed<PriceRow[]>(() =>
|
||||
</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>
|
||||
|
||||
@@ -345,4 +438,18 @@ tr.inactive { opacity: 0.55; }
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { platformApi } from '~~/server/utils/platform-api'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const id = getRouterParam(event, 'id')
|
||||
return platformApi(event, `/prices/${id}/impact`)
|
||||
})
|
||||
Reference in New Issue
Block a user