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
@@ -0,0 +1,211 @@
import { Injectable, Logger } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import type Stripe from 'stripe'
import { Model, Types } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { StripeClient } from '../integrations/stripe.client.js'
import { Invoice, InvoiceDocument } from '../schemas/invoice.schema.js'
import { Payout, PayoutDocument } from '../schemas/payout.schema.js'
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { UsersService } from '../users/users.service.js'
type Currency = 'DKK' | 'EUR' | 'USD'
function toCurrency(c?: string): Currency {
const up = (c ?? 'DKK').toUpperCase()
return up === 'EUR' || up === 'USD' ? up : 'DKK'
}
// Billing reads run on DERIVED data (Subscription + Price + marginPct + the
// Invoice/Payout collections), so the billing pages show real numbers in dev
// even with no live Stripe. The webhook populates Invoices/Subscription status
// once Stripe is enabled.
@Injectable()
export class BillingService {
private readonly logger = new Logger(BillingService.name)
constructor(
@InjectModel(Invoice.name) private readonly invoiceModel: Model<InvoiceDocument>,
@InjectModel(Payout.name) private readonly payoutModel: Model<PayoutDocument>,
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
private readonly users: UsersService,
private readonly stripe: StripeClient,
private readonly audit: AuditService,
) {}
// ── Partner billing reads ────────────────────────────────────────────────
async partnerSummary(partnerId: Types.ObjectId): Promise<{
marginPct: number
mrr: Array<{ currency: Currency; monthlyMinor: number; partnerCutMinor: number; netMinor: number }>
customers: number
openInvoices: number
openAmountMinor: number
stripeLive: boolean
}> {
const [mrr, partner] = await Promise.all([
this.users.partnerMrr(partnerId),
this.partnerModel.findById(partnerId, { marginPct: 1 }).exec(),
])
const marginPct = partner?.marginPct ?? 0
const mrrRows = mrr.totals.map((t) => {
const partnerCutMinor = Math.round((t.monthlyMinor * marginPct) / 100)
return {
currency: t.currency,
monthlyMinor: t.monthlyMinor,
partnerCutMinor,
netMinor: t.monthlyMinor - partnerCutMinor,
}
})
const openInv = await this.invoiceModel
.find({ partnerId, status: { $in: ['open', 'past_due'] } })
.exec()
const openAmountMinor = openInv.reduce((s, i) => s + (i.amountDue - i.amountPaid), 0)
return {
marginPct,
mrr: mrrRows,
customers: mrr.breakdown.length,
openInvoices: openInv.length,
openAmountMinor,
stripeLive: this.stripe.enabled,
}
}
async partnerInvoices(partnerId: Types.ObjectId): Promise<InvoiceDocument[]> {
return this.invoiceModel.find({ partnerId }).sort({ createdAt: -1 }).limit(100).exec()
}
async partnerPayouts(partnerId: Types.ObjectId): Promise<PayoutDocument[]> {
return this.payoutModel.find({ partnerId }).sort({ periodMonth: -1 }).exec()
}
// ── Operator (platform-wide) billing reads ───────────────────────────────
async platformSummary(): Promise<{
invoicedMinor: number
paidMinor: number
outstandingMinor: number
openInvoices: number
pastDueInvoices: number
stripeLive: boolean
}> {
const invoices = await this.invoiceModel.find().exec()
let invoicedMinor = 0
let paidMinor = 0
let outstandingMinor = 0
let openInvoices = 0
let pastDueInvoices = 0
for (const i of invoices) {
invoicedMinor += i.amountDue
paidMinor += i.amountPaid
if (i.status === 'open' || i.status === 'past_due') {
outstandingMinor += i.amountDue - i.amountPaid
openInvoices++
if (i.status === 'past_due') pastDueInvoices++
}
}
return { invoicedMinor, paidMinor, outstandingMinor, openInvoices, pastDueInvoices, stripeLive: this.stripe.enabled }
}
async platformInvoices(): Promise<InvoiceDocument[]> {
return this.invoiceModel.find().sort({ createdAt: -1 }).limit(200).exec()
}
async tenantInvoices(tenantId: Types.ObjectId): Promise<InvoiceDocument[]> {
return this.invoiceModel.find({ tenantId }).sort({ createdAt: -1 }).exec()
}
async tenantInvoicesBySlug(slug: string): Promise<InvoiceDocument[]> {
const tenant = await this.tenantModel.findOne({ slug }, { _id: 1 }).exec()
if (!tenant) return []
return this.tenantInvoices(tenant._id)
}
// ── Stripe webhook ────────────────────────────────────────────────────────
// Idempotent upsert by stripeInvoiceId / subscription id. Only meaningful
// when Stripe is enabled; no-op acknowledgement otherwise.
async handleWebhookEvent(event: Stripe.Event, actor?: AuditActor): Promise<void> {
switch (event.type) {
case 'invoice.paid':
case 'invoice.payment_failed':
case 'invoice.finalized': {
await this.upsertInvoiceFromStripe(event.data.object as Stripe.Invoice, event.type)
break
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
await this.applySubscriptionEvent(event.data.object as Stripe.Subscription, event.type)
break
}
default:
// Ignore unhandled event types.
return
}
void this.audit.record(
{
action: `billing.${event.type.replace(/\./g, '_')}`,
resourceType: 'subscription',
resourceId: event.id,
source: 'platform-api',
externalId: event.id,
},
actor,
)
}
private async upsertInvoiceFromStripe(inv: Stripe.Invoice, eventType: string): Promise<void> {
const customerId = typeof inv.customer === 'string' ? inv.customer : inv.customer?.id
if (!customerId) return
const sub = await this.subModel.findOne({ stripeCustomerId: customerId }).exec()
if (!sub) {
this.logger.warn(`Stripe invoice ${inv.id} for unknown customer ${customerId} — skipping`)
return
}
const tenant = await this.tenantModel.findById(sub.tenantId, { partnerId: 1 }).exec()
const status: InvoiceDocument['status'] =
eventType === 'invoice.payment_failed'
? 'past_due'
: inv.status === 'paid'
? 'paid'
: ((inv.status as InvoiceDocument['status']) ?? 'open')
await this.invoiceModel
.findOneAndUpdate(
{ stripeInvoiceId: inv.id },
{
$set: {
tenantId: sub.tenantId,
partnerId: tenant?.partnerId,
subscriptionId: sub._id,
number: inv.number ?? undefined,
currency: toCurrency(inv.currency),
amountDue: inv.amount_due ?? 0,
amountPaid: inv.amount_paid ?? 0,
status,
periodStart: inv.period_start ? new Date(inv.period_start * 1000) : undefined,
periodEnd: inv.period_end ? new Date(inv.period_end * 1000) : undefined,
hostedInvoiceUrl: inv.hosted_invoice_url ?? undefined,
pdfUrl: inv.invoice_pdf ?? undefined,
},
},
{ upsert: true, new: true },
)
.exec()
}
private async applySubscriptionEvent(sub: Stripe.Subscription, eventType: string): Promise<void> {
const set: Record<string, unknown> = {}
if (eventType === 'customer.subscription.deleted') {
set.status = 'canceled'
set.canceledAt = new Date()
} else {
set.status = sub.status
}
const cpe = (sub as { current_period_end?: number }).current_period_end
if (cpe) set.currentPeriodEnd = new Date(cpe * 1000)
await this.subModel.updateOne({ stripeSubscriptionId: sub.id }, { $set: set }).exec()
}
}