From db26dafc646264ddca807390bf6603a0edae5e66 Mon Sep 17 00:00:00 2001 From: Ronni Baslund Date: Sat, 30 May 2026 16:13:15 +0200 Subject: [PATCH] feat(billing): sync catalog price edits to Stripe + re-price live customers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/operator/pages/pricing.vue | 109 +++++++++++++++- .../server/api/prices/[id]/impact.get.ts | 6 + .../src/integrations/stripe.client.ts | 15 +++ .../src/prices/prices.controller.ts | 8 ++ .../platform-api/src/prices/prices.module.ts | 10 +- .../platform-api/src/prices/prices.service.ts | 117 +++++++++++++++++- 6 files changed, 261 insertions(+), 4 deletions(-) create mode 100644 apps/operator/server/api/prices/[id]/impact.get.ts diff --git a/apps/operator/pages/pricing.vue b/apps/operator/pages/pricing.vue index 1c3cebf..ed00d52 100644 --- a/apps/operator/pages/pricing.vue +++ b/apps/operator/pages/pricing.vue @@ -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> + changes: { currency: Currency; from?: number; to: number; affected: number }[] + totalAffected: number +} +const pending = ref(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 = { DKK: 0, EUR: 0, USD: 0 } + try { + impact = await $fetch>(`/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>) { 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(() => @@ -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; } diff --git a/apps/operator/server/api/prices/[id]/impact.get.ts b/apps/operator/server/api/prices/[id]/impact.get.ts new file mode 100644 index 0000000..c57b494 --- /dev/null +++ b/apps/operator/server/api/prices/[id]/impact.get.ts @@ -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`) +}) diff --git a/services/platform-api/src/integrations/stripe.client.ts b/services/platform-api/src/integrations/stripe.client.ts index 462cb71..0b238ea 100644 --- a/services/platform-api/src/integrations/stripe.client.ts +++ b/services/platform-api/src/integrations/stripe.client.ts @@ -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 { + 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 { await this.stripe.subscriptions.cancel(subscriptionId) } diff --git a/services/platform-api/src/prices/prices.controller.ts b/services/platform-api/src/prices/prices.controller.ts index 922b905..cd0908e 100644 --- a/services/platform-api/src/prices/prices.controller.ts +++ b/services/platform-api/src/prices/prices.controller.ts @@ -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) { diff --git a/services/platform-api/src/prices/prices.module.ts b/services/platform-api/src/prices/prices.module.ts index 07baea9..c7c3e3a 100644 --- a/services/platform-api/src/prices/prices.module.ts +++ b/services/platform-api/src/prices/prices.module.ts @@ -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], diff --git a/services/platform-api/src/prices/prices.service.ts b/services/platform-api/src/prices/prices.service.ts index e28e6ab..4f732f4 100644 --- a/services/platform-api/src/prices/prices.service.ts +++ b/services/platform-api/src/prices/prices.service.ts @@ -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) {} + constructor( + @InjectModel(Price.name) private readonly priceModel: Model, + @InjectModel(Subscription.name) private readonly subModel: Model, + private readonly stripe: StripeClient, + ) {} async findAll(includeInactive = false): Promise { 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> { + const out: Record = { 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 { + // 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 = {} @@ -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 { + 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 { return this.update(id, { active: false }) }