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:
@@ -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