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:
@@ -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> {
|
||||
|
||||
@@ -30,6 +30,19 @@ export class PricesService {
|
||||
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> {
|
||||
try {
|
||||
return await this.priceModel.create({ ...dto, active: dto.active ?? true })
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||
import { StripeClient } from '../integrations/stripe.client.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 { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
||||
import { User, UserDocument } from '../schemas/user.schema.js'
|
||||
@@ -28,6 +30,7 @@ export class TenantsService {
|
||||
private readonly provisioning: ProvisioningService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly prices: PricesService,
|
||||
private readonly stripe: StripeClient,
|
||||
) {}
|
||||
|
||||
async listUsersForTenant(slug: string): Promise<UserDocument[]> {
|
||||
@@ -65,7 +68,7 @@ export class TenantsService {
|
||||
const currency = dto.currency ?? 'DKK'
|
||||
const price = await this.prices.findActive(plan, cycle)
|
||||
const perSeatAmount = price ? (this.prices.amountFor(price, currency) ?? 0) : 0
|
||||
await this.subModel.create({
|
||||
const sub = await this.subModel.create({
|
||||
tenantId: tenant._id,
|
||||
plan,
|
||||
cycle,
|
||||
@@ -75,6 +78,11 @@ export class TenantsService {
|
||||
seats: tenant.seats ?? 0,
|
||||
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) {
|
||||
this.logger.warn(
|
||||
`Subscription auto-create failed for tenant ${tenant.slug}: ${
|
||||
@@ -165,6 +173,8 @@ export class TenantsService {
|
||||
|
||||
if (dto.seats !== undefined) {
|
||||
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(
|
||||
@@ -239,6 +249,9 @@ export class TenantsService {
|
||||
.findOneAndUpdate({ slug }, { status: 'deleted' }, { new: true })
|
||||
.exec()
|
||||
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(
|
||||
{
|
||||
action: 'tenant.deleted',
|
||||
@@ -260,6 +273,8 @@ export class TenantsService {
|
||||
.findOneAndUpdate({ slug }, { status }, { new: true })
|
||||
.exec()
|
||||
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(
|
||||
{
|
||||
action: status === 'suspended' ? 'tenant.suspended' : 'tenant.resumed',
|
||||
@@ -272,4 +287,135 @@ export class TenantsService {
|
||||
)
|
||||
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)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user