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:
@@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AuditModule } from '../audit/audit.module.js'
|
||||
import { AuthModule } from '../auth/auth.module.js'
|
||||
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
||||
import { Invoice, InvoiceSchema } from '../schemas/invoice.schema.js'
|
||||
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
|
||||
import { Payout, PayoutSchema } from '../schemas/payout.schema.js'
|
||||
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
|
||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||
import { UsersModule } from '../users/users.module.js'
|
||||
import { BillingService } from './billing.service.js'
|
||||
import { OperatorBillingController } from './operator-billing.controller.js'
|
||||
import { PartnerBillingController } from './partner-billing.controller.js'
|
||||
import { StripeWebhookController } from './stripe-webhook.controller.js'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([
|
||||
{ name: Invoice.name, schema: InvoiceSchema },
|
||||
{ name: Payout.name, schema: PayoutSchema },
|
||||
{ name: Subscription.name, schema: SubscriptionSchema },
|
||||
{ name: Partner.name, schema: PartnerSchema },
|
||||
{ name: Tenant.name, schema: TenantSchema },
|
||||
]),
|
||||
AuthModule,
|
||||
AuditModule,
|
||||
IntegrationsModule,
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [PartnerBillingController, OperatorBillingController, StripeWebhookController],
|
||||
providers: [BillingService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get, Param, UseGuards } from '@nestjs/common'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||
import { OperatorGuard } from '../auth/operator.guard.js'
|
||||
import { BillingService } from './billing.service.js'
|
||||
|
||||
// Platform-wide billing reads. Operator-only.
|
||||
@Controller('billing')
|
||||
@UseGuards(JwtAuthGuard, OperatorGuard)
|
||||
export class OperatorBillingController {
|
||||
constructor(private readonly billing: BillingService) {}
|
||||
|
||||
@Get('summary')
|
||||
summary() {
|
||||
return this.billing.platformSummary()
|
||||
}
|
||||
|
||||
@Get('invoices')
|
||||
invoices() {
|
||||
return this.billing.platformInvoices()
|
||||
}
|
||||
|
||||
@Get('tenants/:slug/invoices')
|
||||
tenantInvoices(@Param('slug') slug: string) {
|
||||
return this.billing.tenantInvoicesBySlug(slug)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Controller, ForbiddenException, Get, UseGuards } from '@nestjs/common'
|
||||
import { ActorService } from '../auth/actor.service.js'
|
||||
import { CurrentUser } from '../auth/current-user.decorator.js'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
|
||||
import { BillingService } from './billing.service.js'
|
||||
|
||||
// Partner-facing billing reads. Scoped to the caller's partnerId.
|
||||
@Controller('me/partner/billing')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerBillingController {
|
||||
constructor(
|
||||
private readonly billing: BillingService,
|
||||
private readonly actor: ActorService,
|
||||
) {}
|
||||
|
||||
@Get('summary')
|
||||
async summary(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
if (!actor.partnerId) throw new ForbiddenException('Not a partner-staff user')
|
||||
return this.billing.partnerSummary(actor.partnerId)
|
||||
}
|
||||
|
||||
@Get('invoices')
|
||||
async invoices(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
if (!actor.partnerId) throw new ForbiddenException('Not a partner-staff user')
|
||||
return this.billing.partnerInvoices(actor.partnerId)
|
||||
}
|
||||
|
||||
@Get('payouts')
|
||||
async payouts(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
if (!actor.partnerId) throw new ForbiddenException('Not a partner-staff user')
|
||||
return this.billing.partnerPayouts(actor.partnerId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Post,
|
||||
Req,
|
||||
type RawBodyRequest,
|
||||
} from '@nestjs/common'
|
||||
import { StripeClient } from '../integrations/stripe.client.js'
|
||||
import { BillingService } from './billing.service.js'
|
||||
|
||||
// Stripe webhook receiver. NOT guarded (Stripe calls it) — authenticity comes
|
||||
// from signature verification against the raw body. No-ops when Stripe is
|
||||
// disabled so the route is safe to expose in dev.
|
||||
@Controller('stripe')
|
||||
export class StripeWebhookController {
|
||||
constructor(
|
||||
private readonly stripe: StripeClient,
|
||||
private readonly billing: BillingService,
|
||||
) {}
|
||||
|
||||
@Post('webhook')
|
||||
@HttpCode(200)
|
||||
async webhook(
|
||||
// Only req.rawBody is needed; the signature comes via @Headers. Typed
|
||||
// minimally to avoid a direct 'fastify' type import (transitive dep).
|
||||
@Req() req: RawBodyRequest<Record<string, unknown>>,
|
||||
@Headers('stripe-signature') signature: string,
|
||||
) {
|
||||
if (!this.stripe.enabled || !this.stripe.hasWebhookSecret) {
|
||||
return { received: true, ignored: 'stripe disabled' }
|
||||
}
|
||||
if (!req.rawBody || !signature) {
|
||||
throw new BadRequestException('Missing raw body or stripe-signature header')
|
||||
}
|
||||
let event
|
||||
try {
|
||||
event = this.stripe.constructWebhookEvent(req.rawBody, signature)
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid Stripe signature')
|
||||
}
|
||||
await this.billing.handleWebhookEvent(event)
|
||||
return { received: true }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user