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 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: { async createSubscription(input: {
customerId: string customerId: string
priceId: string priceId: string
quantity: number 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({ const s = await this.stripe.subscriptions.create({
customer: input.customerId, customer: input.customerId,
items: [{ price: input.priceId, quantity: input.quantity }], 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> { async updateSubscriptionQuantity(subscriptionId: string, quantity: number): Promise<void> {
@@ -30,6 +30,19 @@ export class PricesService {
return price.amounts[currency] return price.amounts[currency]
} }
// Persist the Stripe Price id we created for (this row, currency) so the
// next tenant on the same plan/cycle/currency reuses it instead of creating
// a duplicate Stripe Price. Keyed write — never clobbers other currencies.
async setStripePriceId(
priceId: PriceDocument['_id'],
currency: PriceCurrency,
stripePriceId: string,
): Promise<void> {
await this.priceModel
.updateOne({ _id: priceId }, { $set: { [`stripePriceIds.${currency}`]: stripePriceId } })
.exec()
}
async create(dto: CreatePriceDto): Promise<PriceDocument> { async create(dto: CreatePriceDto): Promise<PriceDocument> {
try { try {
return await this.priceModel.create({ ...dto, active: dto.active ?? true }) return await this.priceModel.create({ ...dto, active: dto.active ?? true })
@@ -8,7 +8,9 @@ import {
import { InjectModel } from '@nestjs/mongoose' import { InjectModel } from '@nestjs/mongoose'
import { Model, Types } from 'mongoose' import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js' import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { StripeClient } from '../integrations/stripe.client.js'
import { PricesService } from '../prices/prices.service.js' import { PricesService } from '../prices/prices.service.js'
import type { PriceCurrency, PriceCycle, PriceDocument } from '../schemas/price.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js' import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js' import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js' import { User, UserDocument } from '../schemas/user.schema.js'
@@ -28,6 +30,7 @@ export class TenantsService {
private readonly provisioning: ProvisioningService, private readonly provisioning: ProvisioningService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly prices: PricesService, private readonly prices: PricesService,
private readonly stripe: StripeClient,
) {} ) {}
async listUsersForTenant(slug: string): Promise<UserDocument[]> { async listUsersForTenant(slug: string): Promise<UserDocument[]> {
@@ -65,7 +68,7 @@ export class TenantsService {
const currency = dto.currency ?? 'DKK' const currency = dto.currency ?? 'DKK'
const price = await this.prices.findActive(plan, cycle) const price = await this.prices.findActive(plan, cycle)
const perSeatAmount = price ? (this.prices.amountFor(price, currency) ?? 0) : 0 const perSeatAmount = price ? (this.prices.amountFor(price, currency) ?? 0) : 0
await this.subModel.create({ const sub = await this.subModel.create({
tenantId: tenant._id, tenantId: tenant._id,
plan, plan,
cycle, cycle,
@@ -75,6 +78,11 @@ export class TenantsService {
seats: tenant.seats ?? 0, seats: tenant.seats ?? 0,
status: 'active', status: 'active',
}) })
// Mirror the subscription into Stripe (best-effort, only when enabled).
// A Stripe failure must not fail tenant creation — the local sub still
// drives derived MRR; /tenants/:slug/reconcile can retry later.
await this.syncStripeOnCreate(tenant, sub, price, currency, perSeatAmount)
} catch (err) { } catch (err) {
this.logger.warn( this.logger.warn(
`Subscription auto-create failed for tenant ${tenant.slug}: ${ `Subscription auto-create failed for tenant ${tenant.slug}: ${
@@ -165,6 +173,8 @@ export class TenantsService {
if (dto.seats !== undefined) { if (dto.seats !== undefined) {
await this.subModel.updateOne({ tenantId: updated._id }, { $set: { seats: dto.seats } }).exec() await this.subModel.updateOne({ tenantId: updated._id }, { $set: { seats: dto.seats } }).exec()
// Mirror the seat count onto the live Stripe subscription quantity.
await this.syncStripeQuantity(updated._id, dto.seats)
} }
void this.audit.record( void this.audit.record(
@@ -239,6 +249,9 @@ export class TenantsService {
.findOneAndUpdate({ slug }, { status: 'deleted' }, { new: true }) .findOneAndUpdate({ slug }, { status: 'deleted' }, { new: true })
.exec() .exec()
if (!result) throw new NotFoundException(`Tenant "${slug}" not found`) if (!result) throw new NotFoundException(`Tenant "${slug}" not found`)
// Cancel the live Stripe subscription (best-effort) so a deleted tenant
// stops accruing charges.
await this.syncStripeCancel(result._id)
void this.audit.record( void this.audit.record(
{ {
action: 'tenant.deleted', action: 'tenant.deleted',
@@ -260,6 +273,8 @@ export class TenantsService {
.findOneAndUpdate({ slug }, { status }, { new: true }) .findOneAndUpdate({ slug }, { status }, { new: true })
.exec() .exec()
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`) if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
// Pause/resume the live Stripe subscription to match (best-effort).
await this.syncStripeStatus(tenant._id, status)
void this.audit.record( void this.audit.record(
{ {
action: status === 'suspended' ? 'tenant.suspended' : 'tenant.resumed', action: status === 'suspended' ? 'tenant.suspended' : 'tenant.resumed',
@@ -272,4 +287,135 @@ export class TenantsService {
) )
return tenant return tenant
} }
// ── Stripe lifecycle sync ──────────────────────────────────────────────────
// All helpers below are best-effort and gated on `stripe.enabled`. When Stripe
// is disabled (dev default) they are cheap no-ops, so the local Subscription
// doc remains the single source of truth for derived MRR.
// 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 }
}
// Resolve (creating + persisting once) the Stripe Price id for a catalog row
// in a given currency. Returns null when the row has no amount in that
// currency (e.g. Enterprise / unpriced) — caller then skips the subscription.
private async resolveStripePriceId(
price: PriceDocument,
currency: PriceCurrency,
perSeatAmount: number,
): Promise<string | null> {
if (perSeatAmount <= 0) return null
const existing = price.stripePriceIds?.[currency]
if (existing) return existing
const { interval, intervalCount } = this.stripeInterval(price.cycle)
const stripePriceId = await this.stripe.createPrice({
productName: `Dezky ${price.plan} (${price.cycle})`,
currency,
unitAmountMinor: perSeatAmount,
interval,
intervalCount,
metadata: { plan: price.plan, cycle: price.cycle, currency },
})
await this.prices.setStripePriceId(price._id, currency, stripePriceId)
return stripePriceId
}
// On tenant create: open a Stripe customer and, when the plan is priced and
// has at least one seat, a matching subscription. Persists the Stripe ids
// back onto the local Subscription doc so the webhook can correlate events.
private async syncStripeOnCreate(
tenant: TenantDocument,
sub: SubscriptionDocument,
price: PriceDocument | null,
currency: PriceCurrency,
perSeatAmount: number,
): Promise<void> {
if (!this.stripe.enabled) return
try {
const customerId = await this.stripe.createCustomer({
name: tenant.name,
metadata: { tenantId: String(tenant._id), slug: tenant.slug },
})
sub.stripeCustomerId = customerId
const seats = sub.seats ?? 0
const stripePriceId = price
? await this.resolveStripePriceId(price, currency, perSeatAmount)
: null
if (stripePriceId && seats >= 1) {
const created = await this.stripe.createSubscription({
customerId,
priceId: stripePriceId,
quantity: seats,
})
sub.stripeSubscriptionId = created.id
if (created.currentPeriodEnd) sub.currentPeriodEnd = new Date(created.currentPeriodEnd * 1000)
}
await sub.save()
this.logger.log(
`Stripe billing provisioned for ${tenant.slug}: customer=${customerId}${
sub.stripeSubscriptionId ? ` sub=${sub.stripeSubscriptionId}` : ' (no subscription — unpriced or 0 seats)'
}`,
)
} catch (err) {
this.logger.warn(
`Stripe provisioning failed for tenant ${tenant.slug}: ${
err instanceof Error ? err.message : String(err)
} — local subscription kept; reconcile to retry.`,
)
}
}
private async syncStripeQuantity(tenantId: Types.ObjectId, seats: number): Promise<void> {
if (!this.stripe.enabled || seats < 1) return
const sub = await this.subModel.findOne({ tenantId }, { stripeSubscriptionId: 1 }).exec()
if (!sub?.stripeSubscriptionId) return
try {
await this.stripe.updateSubscriptionQuantity(sub.stripeSubscriptionId, seats)
} catch (err) {
this.logger.warn(
`Stripe quantity update failed for tenant ${tenantId}: ${
err instanceof Error ? err.message : String(err)
}`,
)
}
}
private async syncStripeStatus(
tenantId: Types.ObjectId,
status: 'active' | 'suspended',
): Promise<void> {
if (!this.stripe.enabled) return
const sub = await this.subModel.findOne({ tenantId }, { stripeSubscriptionId: 1 }).exec()
if (!sub?.stripeSubscriptionId) return
try {
if (status === 'suspended') await this.stripe.pauseSubscription(sub.stripeSubscriptionId)
else await this.stripe.resumeSubscription(sub.stripeSubscriptionId)
} catch (err) {
this.logger.warn(
`Stripe pause/resume failed for tenant ${tenantId}: ${
err instanceof Error ? err.message : String(err)
}`,
)
}
}
private async syncStripeCancel(tenantId: Types.ObjectId): Promise<void> {
if (!this.stripe.enabled) return
const sub = await this.subModel.findOne({ tenantId }, { stripeSubscriptionId: 1 }).exec()
if (!sub?.stripeSubscriptionId) return
try {
await this.stripe.cancelSubscription(sub.stripeSubscriptionId)
} catch (err) {
this.logger.warn(
`Stripe cancel failed for tenant ${tenantId}: ${
err instanceof Error ? err.message : String(err)
}`,
)
}
}
} }