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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user