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`)
|
||||
})
|
||||
@@ -114,6 +114,21 @@ export class StripeClient {
|
||||
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> {
|
||||
await this.stripe.subscriptions.cancel(subscriptionId)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,14 @@ export class PricesController {
|
||||
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()
|
||||
@UseGuards(OperatorGuard)
|
||||
create(@Body() dto: CreatePriceDto) {
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AuthModule } from '../auth/auth.module.js'
|
||||
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
||||
import { Price, PriceSchema } from '../schemas/price.schema.js'
|
||||
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
|
||||
import { PricesController } from './prices.controller.js'
|
||||
import { PricesService } from './prices.service.js'
|
||||
|
||||
@Module({
|
||||
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,
|
||||
IntegrationsModule,
|
||||
],
|
||||
controllers: [PricesController],
|
||||
providers: [PricesService],
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
|
||||
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 { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
|
||||
import type { CreatePriceDto } from './dto/create-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()
|
||||
export class PricesService {
|
||||
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[]> {
|
||||
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> {
|
||||
// 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 } }
|
||||
// updates only EUR without clobbering DKK and USD.
|
||||
const $set: Record<string, unknown> = {}
|
||||
@@ -72,9 +106,88 @@ export class PricesService {
|
||||
.findByIdAndUpdate(id, { $set }, { new: true, runValidators: true })
|
||||
.exec()
|
||||
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
|
||||
}
|
||||
|
||||
// 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> {
|
||||
return this.update(id, { active: false })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user