feat(billing): Stripe-backed billing engine (dark-launched)

Add a lazy/guarded Stripe client (boots without keys), Invoice/Payout schemas, per-currency Price.stripePriceIds, and a BillingService deriving partner/platform summaries, invoices and a partner-cut payout ledger. Partner and operator billing controllers plus a signature-verified Stripe webhook (Fastify raw body). Frontend: partner and operator billing pages and the operator tenant billing/audit tabs on real data. Gated behind new_billing_engine and BILLING_STRIPE_ENABLED; live money paths stay off until keys are set.
This commit is contained in:
Ronni Baslund
2026-05-30 08:03:23 +02:00
parent 6370e392cc
commit 0e1d2fb0d1
23 changed files with 1064 additions and 143 deletions
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'
import { AuthentikClient } from './authentik.client.js'
import { OcisClient } from './ocis.client.js'
import { StalwartClient } from './stalwart.client.js'
import { StripeClient } from './stripe.client.js'
@Module({
providers: [AuthentikClient, StalwartClient, OcisClient],
exports: [AuthentikClient, StalwartClient, OcisClient],
providers: [AuthentikClient, StalwartClient, OcisClient, StripeClient],
exports: [AuthentikClient, StalwartClient, OcisClient, StripeClient],
})
export class IntegrationsModule {}
@@ -0,0 +1,87 @@
import { Injectable, Logger } from '@nestjs/common'
import { ConfigService } from '@nestjs/config'
import Stripe from 'stripe'
// Thin wrapper around the Stripe SDK. Dark-launch friendly: the SDK is only
// constructed lazily on first use, so the app boots fine in dev with no Stripe
// keys. `enabled` gates the live money-moving paths (subscription create on
// tenant create, webhook processing). When disabled, billing reads run on
// DERIVED data (Subscription + Price + marginPct) instead of live Stripe.
@Injectable()
export class StripeClient {
private readonly logger = new Logger(StripeClient.name)
private readonly secretKey?: string
private readonly webhookSecret?: string
private _stripe?: Stripe
readonly enabled: boolean
constructor(config: ConfigService) {
this.secretKey = config.get<string>('STRIPE_SECRET_KEY')
this.webhookSecret = config.get<string>('STRIPE_WEBHOOK_SECRET')
this.enabled = config.get<string>('BILLING_STRIPE_ENABLED') === 'true' && !!this.secretKey
if (!this.enabled) {
this.logger.log(
'Stripe billing disabled (BILLING_STRIPE_ENABLED != true or no key) — billing uses derived data.',
)
}
}
get hasWebhookSecret(): boolean {
return !!this.webhookSecret
}
// Lazy SDK accessor — throws only if actually used while unconfigured.
private get stripe(): Stripe {
if (!this.secretKey) {
throw new Error('Stripe is not configured (STRIPE_SECRET_KEY missing)')
}
if (!this._stripe) this._stripe = new Stripe(this.secretKey)
return this._stripe
}
async createCustomer(input: {
name: string
email?: string
metadata?: Record<string, string>
}): Promise<string> {
const c = await this.stripe.customers.create({
name: input.name,
email: input.email,
metadata: input.metadata,
})
return c.id
}
async createSubscription(input: {
customerId: string
priceId: string
quantity: number
}): Promise<{ id: string; currentPeriodEnd?: number }> {
const s = await this.stripe.subscriptions.create({
customer: input.customerId,
items: [{ price: input.priceId, quantity: input.quantity }],
})
return { id: s.id, currentPeriodEnd: (s as { current_period_end?: number }).current_period_end }
}
async updateSubscriptionQuantity(subscriptionId: string, quantity: number): 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, quantity }] })
}
async cancelSubscription(subscriptionId: string): Promise<void> {
await this.stripe.subscriptions.cancel(subscriptionId)
}
async listInvoices(customerId: string, limit = 50): Promise<Stripe.Invoice[]> {
const res = await this.stripe.invoices.list({ customer: customerId, limit })
return res.data
}
constructWebhookEvent(rawBody: Buffer | string, signature: string): Stripe.Event {
if (!this.webhookSecret) throw new Error('Stripe webhook secret not configured')
return this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret)
}
}