import { ForbiddenException, Injectable, Logger, NotFoundException, ServiceUnavailableException, } 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' } // Current calendar month as YYYY-MM (UTC). The payout ledger keys on this. function currentPeriodMonth(): string { return new Date().toISOString().slice(0, 7) } // 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, @InjectModel(Payout.name) private readonly payoutModel: Model, @InjectModel(Subscription.name) private readonly subModel: Model, @InjectModel(Partner.name) private readonly partnerModel: Model, @InjectModel(Tenant.name) private readonly tenantModel: Model, 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 { return this.invoiceModel.find({ partnerId }).sort({ createdAt: -1 }).limit(100).exec() } async partnerPayouts(partnerId: Types.ObjectId): Promise { return this.payoutModel.find({ partnerId }).sort({ periodMonth: -1 }).exec() } // ── Payout ledger generation ────────────────────────────────────────────── // v1 is a COMPUTED ledger (not Stripe Connect): for the given month we snapshot // each partner's gross MRR per currency and their marginPct cut into Payout rows. // Idempotent — re-running the same period refreshes the (still-pending) amounts; // a row already marked `paid` is left untouched so a settled payout is never // rewritten. Disbursement stays out-of-band (operator marks rows paid). // // NOTE: MRR is a CURRENT snapshot, so regenerating a closed month would re-read // today's subscriptions. The worker only ever generates the current month, so a // past month freezes at its last snapshot once the month rolls over. async generatePayouts( periodMonth?: string, actor?: AuditActor, ): Promise<{ periodMonth: string; partners: number; rows: number }> { const period = periodMonth ?? currentPeriodMonth() const partners = await this.partnerModel.find({}, { marginPct: 1 }).exec() let rows = 0 for (const p of partners) { const marginPct = p.marginPct ?? 0 const mrr = await this.users.partnerMrr(p._id) for (const t of mrr.totals) { // Never mutate a settled payout. const existing = await this.payoutModel .findOne({ partnerId: p._id, periodMonth: period, currency: t.currency }, { status: 1 }) .exec() if (existing?.status === 'paid') continue const payoutMinor = Math.round((t.monthlyMinor * marginPct) / 100) await this.payoutModel .updateOne( { partnerId: p._id, periodMonth: period, currency: t.currency }, { $set: { grossMrrMinor: t.monthlyMinor, marginPct, payoutMinor }, $setOnInsert: { status: 'pending' }, }, { upsert: true }, ) .exec() rows++ } } void this.audit.record( { action: 'billing.payouts_generated', resourceType: 'subscription', resourceId: period, metadata: { periodMonth: period, partners: partners.length, rows }, }, actor, ) this.logger.log(`Generated payouts for ${period}: ${rows} row(s) across ${partners.length} partner(s)`) return { periodMonth: period, partners: partners.length, rows } } async platformPayouts(): Promise { return this.payoutModel.find().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 { return this.invoiceModel.find().sort({ createdAt: -1 }).limit(200).exec() } async tenantInvoices(tenantId: Types.ObjectId): Promise { return this.invoiceModel.find({ tenantId }).sort({ createdAt: -1 }).exec() } async tenantInvoicesBySlug(slug: string): Promise { const tenant = await this.tenantModel.findOne({ slug }, { _id: 1 }).exec() if (!tenant) return [] return this.tenantInvoices(tenant._id) } // Membership-gated variant for the customer-admin portal: only a member of // the tenant (or a platform admin) may read its invoices. Mirrors the // membership check used across the tenant-scoped portal endpoints. async tenantInvoicesForActor( slug: string, actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] }, ): Promise { const tenant = await this.tenantModel.findOne({ slug }, { _id: 1 }).exec() if (!tenant) return [] if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) { throw new ForbiddenException(`No access to tenant "${slug}"`) } return this.tenantInvoices(tenant._id) } // ── Customer payment method (card on file) ──────────────────────────────── // Load the tenant + its subscription, enforcing membership. Throws 404 if the // tenant is unknown, 403 if the caller isn't a member. private async resolveTenantSub( slug: string, actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] }, ): Promise<{ tenant: TenantDocument; sub: SubscriptionDocument | null }> { const tenant = await this.tenantModel.findOne({ slug }).exec() if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`) if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) { throw new ForbiddenException(`No access to tenant "${slug}"`) } const sub = await this.subModel.findOne({ tenantId: tenant._id }).exec() return { tenant, sub } } // Ensure the tenant's subscription has a Stripe customer, creating one on // demand (older tenants provisioned before Stripe was enabled lack it). private async ensureStripeCustomer(tenant: TenantDocument, sub: SubscriptionDocument | null): Promise { if (!this.stripe.enabled) throw new ServiceUnavailableException('Billing is not enabled') if (!sub) throw new NotFoundException(`No subscription for tenant "${tenant.slug}"`) if (sub.stripeCustomerId) return sub.stripeCustomerId const customerId = await this.stripe.createCustomer({ name: tenant.name, email: tenant.billingInfo?.contactEmail, metadata: { tenantId: String(tenant._id), slug: tenant.slug }, }) sub.stripeCustomerId = customerId await sub.save() return customerId } // Read-only: the card on file, or null. Doesn't create a customer just to read. async getPaymentMethod( slug: string, actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] }, ): Promise<{ brand: string; last4: string; expMonth: number; expYear: number } | null> { if (!this.stripe.enabled) return null const { sub } = await this.resolveTenantSub(slug, actor) if (!sub?.stripeCustomerId) return null return this.stripe.getDefaultCard(sub.stripeCustomerId) } // Begin a card-update: returns the SetupIntent secret + publishable key for // the portal to mount Stripe Elements. Creates a Stripe customer if missing. async createPaymentSetupIntent( slug: string, actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] }, ): Promise<{ clientSecret: string; publishableKey: string }> { const { tenant, sub } = await this.resolveTenantSub(slug, actor) const customerId = await this.ensureStripeCustomer(tenant, sub) const clientSecret = await this.stripe.createSetupIntent(customerId) return { clientSecret, publishableKey: this.stripe.publishableKey ?? '' } } // Finish a card-update: make the newly-attached PM the default and return the // resulting card so the UI can refresh without a second round-trip. async setDefaultPaymentMethod( slug: string, actor: { platformAdmin: boolean; tenantIds: Types.ObjectId[] }, paymentMethodId: string, auditActor?: AuditActor, ): Promise<{ brand: string; last4: string; expMonth: number; expYear: number } | null> { const { tenant, sub } = await this.resolveTenantSub(slug, actor) const customerId = await this.ensureStripeCustomer(tenant, sub) await this.stripe.setDefaultCard(customerId, paymentMethodId, sub?.stripeSubscriptionId) void this.audit.record( { action: 'billing.payment_method_updated', resourceType: 'subscription', resourceId: tenant.slug, resourceName: tenant.name, tenantSlug: tenant.slug, source: 'portal', }, auditActor, ) return this.stripe.getDefaultCard(customerId) } // ── 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 { 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 { 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 { const set: Record = {} 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() } }