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]
|
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 })
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user