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:
Ronni Baslund
2026-05-30 16:13:15 +02:00
parent 0b269e7ea7
commit db26dafc64
6 changed files with 261 additions and 4 deletions
+108 -1
View File
@@ -68,6 +68,17 @@ function cancelEdit(id: string) {
delete drafts[id] 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) { async function saveEdit(row: PriceRow) {
const draft = drafts[row._id] const draft = drafts[row._id]
if (!draft) return if (!draft) return
@@ -81,6 +92,45 @@ async function saveEdit(row: PriceRow) {
} }
next[c] = parsed 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 saving.value = row._id
try { try {
await $fetch(`/api/prices/${row._id}`, { method: 'PATCH', body: { amounts: next } }) 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) { async function toggleActive(row: PriceRow) {
saving.value = row._id saving.value = row._id
try { try {
@@ -173,7 +235,7 @@ const sortedPrices = computed<PriceRow[]>(() =>
<PageHeader <PageHeader
eyebrow="Operator · operator.dezky.local" eyebrow="Operator · operator.dezky.local"
title="Pricing catalog" 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> <template #actions>
<label class="toggle"> <label class="toggle">
@@ -292,6 +354,37 @@ const sortedPrices = computed<PriceRow[]>(() =>
</div> </div>
</Card> </Card>
</div> </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> </div>
</template> </template>
@@ -345,4 +438,18 @@ tr.inactive { opacity: 0.55; }
outline: 0; outline: 0;
} }
.hint { font-size: 13px; color: var(--text-dim); margin: 0 0 12px; } .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> </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`)
})
@@ -114,6 +114,21 @@ export class StripeClient {
await this.stripe.subscriptions.update(subscriptionId, { items: [{ id: itemId, quantity }] }) await this.stripe.subscriptions.update(subscriptionId, { items: [{ id: itemId, quantity }] })
} }
// Repoint a subscription's line item at a different Price. Stripe Prices are
// immutable, so a catalog "price change" means minting a new Price and moving
// live subscriptions onto it. proration_behavior 'none' → no mid-cycle charge
// or credit; the new amount takes effect at the next renewal. Quantity is
// preserved (we only swap the price).
async updateSubscriptionPrice(subscriptionId: string, priceId: string): Promise<void> {
const sub = await this.stripe.subscriptions.retrieve(subscriptionId)
const itemId = sub.items.data[0]?.id
if (!itemId) throw new Error(`Stripe subscription ${subscriptionId} has no line items`)
await this.stripe.subscriptions.update(subscriptionId, {
items: [{ id: itemId, price: priceId }],
proration_behavior: 'none',
})
}
async cancelSubscription(subscriptionId: string): Promise<void> { async cancelSubscription(subscriptionId: string): Promise<void> {
await this.stripe.subscriptions.cancel(subscriptionId) await this.stripe.subscriptions.cancel(subscriptionId)
} }
@@ -18,6 +18,14 @@ export class PricesController {
return this.prices.findAll(includeInactive === 'true') return this.prices.findAll(includeInactive === 'true')
} }
// How many live customers a price edit on this row would touch, per currency.
// Operator-only — it exposes subscription counts. Drives the save warning.
@Get(':id/impact')
@UseGuards(OperatorGuard)
impact(@Param('id') id: string) {
return this.prices.impact(id)
}
@Post() @Post()
@UseGuards(OperatorGuard) @UseGuards(OperatorGuard)
create(@Body() dto: CreatePriceDto) { create(@Body() dto: CreatePriceDto) {
@@ -1,14 +1,22 @@
import { Module } from '@nestjs/common' import { Module } from '@nestjs/common'
import { MongooseModule } from '@nestjs/mongoose' import { MongooseModule } from '@nestjs/mongoose'
import { AuthModule } from '../auth/auth.module.js' import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { Price, PriceSchema } from '../schemas/price.schema.js' import { Price, PriceSchema } from '../schemas/price.schema.js'
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
import { PricesController } from './prices.controller.js' import { PricesController } from './prices.controller.js'
import { PricesService } from './prices.service.js' import { PricesService } from './prices.service.js'
@Module({ @Module({
imports: [ imports: [
MongooseModule.forFeature([{ name: Price.name, schema: PriceSchema }]), // Subscription model + Stripe client live here so a catalog amount edit can
// propagate to live subscriptions (mint new Stripe Price, repoint subs).
MongooseModule.forFeature([
{ name: Price.name, schema: PriceSchema },
{ name: Subscription.name, schema: SubscriptionSchema },
]),
AuthModule, AuthModule,
IntegrationsModule,
], ],
controllers: [PricesController], controllers: [PricesController],
providers: [PricesService], providers: [PricesService],
@@ -1,14 +1,26 @@
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common' import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose' import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose' import { Model, Types } from 'mongoose'
import { StripeClient } from '../integrations/stripe.client.js'
import { Price, PriceDocument, type PriceCycle, type PriceCurrency, type PricePlan } from '../schemas/price.schema.js' import { Price, PriceDocument, type PriceCycle, type PriceCurrency, type PricePlan } from '../schemas/price.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import type { CreatePriceDto } from './dto/create-price.dto.js' import type { CreatePriceDto } from './dto/create-price.dto.js'
import type { UpdatePriceDto } from './dto/update-price.dto.js' import type { UpdatePriceDto } from './dto/update-price.dto.js'
const CURRENCIES: PriceCurrency[] = ['DKK', 'EUR', 'USD']
// Subscriptions in these states still bill, so they feel a catalog price change
// at their next renewal. Canceled / incomplete subs are left untouched.
const LIVE_STATUSES = ['trialing', 'active', 'past_due']
@Injectable() @Injectable()
export class PricesService { export class PricesService {
private readonly logger = new Logger(PricesService.name) private readonly logger = new Logger(PricesService.name)
constructor(@InjectModel(Price.name) private readonly priceModel: Model<PriceDocument>) {} constructor(
@InjectModel(Price.name) private readonly priceModel: Model<PriceDocument>,
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
private readonly stripe: StripeClient,
) {}
async findAll(includeInactive = false): Promise<PriceDocument[]> { async findAll(includeInactive = false): Promise<PriceDocument[]> {
const filter = includeInactive ? {} : { active: true } const filter = includeInactive ? {} : { active: true }
@@ -57,7 +69,29 @@ export class PricesService {
} }
} }
// Count live subscriptions on a catalog row, per currency. Drives the
// operator's "N customers affected" warning shown before a price edit commits
// — a price change only touches subs billed in the currency that changed.
async impact(id: string): Promise<Record<PriceCurrency, number>> {
const out: Record<PriceCurrency, number> = { DKK: 0, EUR: 0, USD: 0 }
if (!Types.ObjectId.isValid(id)) return out
const priceId = new Types.ObjectId(id)
for (const currency of CURRENCIES) {
out[currency] = await this.subModel.countDocuments({
priceId,
currency,
status: { $in: LIVE_STATUSES },
})
}
return out
}
async update(id: string, dto: UpdatePriceDto): Promise<PriceDocument> { async update(id: string, dto: UpdatePriceDto): Promise<PriceDocument> {
// Snapshot the pre-edit amounts so we can tell which currencies actually
// changed value (and only re-price those).
const before = dto.amounts ? await this.priceModel.findById(id).exec() : null
if (dto.amounts && !before) throw new NotFoundException(`Price ${id} not found`)
// Treat amounts as a partial merge so PATCH { amounts: { EUR: 700 } } // Treat amounts as a partial merge so PATCH { amounts: { EUR: 700 } }
// updates only EUR without clobbering DKK and USD. // updates only EUR without clobbering DKK and USD.
const $set: Record<string, unknown> = {} const $set: Record<string, unknown> = {}
@@ -72,9 +106,88 @@ export class PricesService {
.findByIdAndUpdate(id, { $set }, { new: true, runValidators: true }) .findByIdAndUpdate(id, { $set }, { new: true, runValidators: true })
.exec() .exec()
if (!price) throw new NotFoundException(`Price ${id} not found`) if (!price) throw new NotFoundException(`Price ${id} not found`)
// Propagate amount edits to Stripe + live subscriptions.
if (before && dto.amounts) {
const changed = CURRENCIES.filter(
(c) => dto.amounts![c] !== undefined && dto.amounts![c] !== before.amounts?.[c],
)
if (changed.length > 0) await this.repriceCurrencies(price, changed)
}
return price return price
} }
// monthly/quarterly/yearly → Stripe recurring interval + count.
private stripeInterval(cycle: PriceCycle): { interval: 'month' | 'year'; intervalCount: number } {
if (cycle === 'yearly') return { interval: 'year', intervalCount: 1 }
if (cycle === 'quarterly') return { interval: 'month', intervalCount: 3 }
return { interval: 'month', intervalCount: 1 }
}
// Apply a catalog amount change to Stripe and to existing customers. Stripe
// Prices are immutable, so for each changed currency we mint a fresh Stripe
// Price at the new amount and repoint every live subscription on this
// (row, currency) onto it — effective at the next renewal, no proration.
// The refreshed stripePriceIds cache means future provisions also use the new
// Price. Stripe-disabled or unpriced currencies skip the API calls but still
// refresh the local per-seat snapshot so MRR reflects the go-forward rate.
// Per-subscription failures are logged, never fatal — the catalog stays saved.
private async repriceCurrencies(price: PriceDocument, currencies: PriceCurrency[]): Promise<void> {
for (const currency of currencies) {
const amount = price.amounts?.[currency] ?? 0
let newStripePriceId: string | null = null
if (this.stripe.enabled && amount > 0) {
try {
const { interval, intervalCount } = this.stripeInterval(price.cycle)
newStripePriceId = await this.stripe.createPrice({
productName: `Dezky ${price.plan} (${price.cycle})`,
currency,
unitAmountMinor: amount,
interval,
intervalCount,
metadata: { plan: price.plan, cycle: price.cycle, currency },
})
// Overwrite the cache so future provisions use the new Price too.
await this.setStripePriceId(price._id, currency, newStripePriceId)
} catch (err) {
this.logger.warn(
`Stripe Price mint failed for ${price.plan}/${price.cycle}/${currency}: ${
err instanceof Error ? err.message : String(err)
} — catalog saved; existing subs keep their old price. Reconcile to retry.`,
)
}
}
const subs = await this.subModel
.find({ priceId: price._id, currency, status: { $in: LIVE_STATUSES } })
.exec()
let moved = 0
for (const sub of subs) {
try {
if (newStripePriceId && sub.stripeSubscriptionId) {
await this.stripe.updateSubscriptionPrice(sub.stripeSubscriptionId, newStripePriceId)
}
sub.perSeatAmount = amount
await sub.save()
moved++
} catch (err) {
this.logger.warn(
`Re-price failed for subscription ${sub._id} (tenant ${sub.tenantId}): ${
err instanceof Error ? err.message : String(err)
}`,
)
}
}
if (subs.length > 0) {
this.logger.log(
`Re-priced ${moved}/${subs.length} live ${currency} subscription(s) on ` +
`${price.plan}/${price.cycle}${amount} (effective next billing cycle)`,
)
}
}
}
async deactivate(id: string): Promise<PriceDocument> { async deactivate(id: string): Promise<PriceDocument> {
return this.update(id, { active: false }) return this.update(id, { active: false })
} }