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
@@ -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 })
}