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> {
|
||||
|
||||
Reference in New Issue
Block a user