feat(billing): provision Stripe customer + subscription on tenant create

Wire the Stripe lifecycle into TenantsService (best-effort, gated on stripe.enabled): on create open a Stripe customer and, for a priced plan with seats >= 1, lazily create a Stripe Product+Price (persisted to Price.stripePriceIds[currency]) and a send_invoice subscription; mirror seat changes to the subscription quantity; pause/resume on suspend/resume; cancel on delete. A Stripe failure never blocks the tenant — the local Subscription stays the source of truth for derived MRR.
This commit is contained in:
Ronni Baslund
2026-05-30 08:29:34 +02:00
parent 22925599e7
commit 69197e11ae
3 changed files with 205 additions and 3 deletions
@@ -52,16 +52,59 @@ export class StripeClient {
return c.id
}
// Create (or reuse) a recurring Stripe Price for a catalog row. We pass
// product_data so Stripe creates the backing Product in the same call; the
// resulting price id is persisted on the Price doc (stripePriceIds[currency])
// so we only hit Stripe once per (plan, cycle, currency).
async createPrice(input: {
productName: string
currency: string // ISO, lower-cased by Stripe
unitAmountMinor: number
interval: 'month' | 'year'
intervalCount: number
metadata?: Record<string, string>
}): Promise<string> {
const price = await this.stripe.prices.create({
currency: input.currency.toLowerCase(),
unit_amount: input.unitAmountMinor,
recurring: { interval: input.interval, interval_count: input.intervalCount },
product_data: { name: input.productName },
metadata: input.metadata,
})
return price.id
}
async createSubscription(input: {
customerId: string
priceId: string
quantity: number
}): Promise<{ id: string; currentPeriodEnd?: number }> {
}): Promise<{ id: string; currentPeriodEnd?: number; status: string }> {
// send_invoice (not charge_automatically) so a test subscription produces
// a real open invoice without a saved payment method — that's what the
// billing pages and the webhook flow consume in dev/test.
const s = await this.stripe.subscriptions.create({
customer: input.customerId,
items: [{ price: input.priceId, quantity: input.quantity }],
collection_method: 'send_invoice',
days_until_due: 14,
})
return { id: s.id, currentPeriodEnd: (s as { current_period_end?: number }).current_period_end }
return {
id: s.id,
currentPeriodEnd: (s as { current_period_end?: number }).current_period_end,
status: s.status,
}
}
// Suspend billing without losing the subscription — collection is voided
// while paused, then resumed on unpause. Mirrors tenant suspend/resume.
async pauseSubscription(subscriptionId: string): Promise<void> {
await this.stripe.subscriptions.update(subscriptionId, {
pause_collection: { behavior: 'void' },
})
}
async resumeSubscription(subscriptionId: string): Promise<void> {
await this.stripe.subscriptions.update(subscriptionId, { pause_collection: null })
}
async updateSubscriptionQuantity(subscriptionId: string, quantity: number): Promise<void> {