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