3288fde693
Access & navigation
- Gate partner-mode strictly to partner staff so admins/end-users never inherit
leftover partner-view state; purge stale session entry on hydrate.
- Role-driven admin entry: useMe.isTenantAdmin, Admin/Personal tiles in the app
launcher, and an /admin route guard in the global middleware (fail closed).
- Drop the duplicate user identity block from the sidebar footer.
Admin pages on real data
- New tenant-scoped, membership-gated endpoints: GET /tenants/:slug/{audit,users,
invoices}; useTenant composable resolves the active workspace + subscription.
- Dashboard: real seats, spend (cycle-normalized + minor-units), plan, renewal,
and recent audit; unbacked sections removed.
- Users & groups: real members; Groups/Invitations/Service accounts shown as
honest "coming soon".
- Subscription & invoices: real plan hero, invoice history, and billing details.
Stripe payment method (Elements + SetupIntent)
- StripeClient: publishable key + getDefaultCard/createSetupIntent/setDefaultCard.
- CustomerBillingController + BillingService methods (ensure-customer on demand).
- Portal: PaymentMethodModal, useStripeJs (CDN load), proxies; hidePostalCode.
Editable billing details & whitelabel branding
- PATCH /tenants/:slug/billing-info (narrow: company/VAT/country/email).
- TenantBranding schema/service + GET/PUT /tenants/:slug/branding: real product
name, accent colour, and per-tenant email-template overrides.
- Branding preview + sidebar workspace mark wired to real name/plan/seats/colour
with YIQ auto-contrast (readableOn util).
Session resilience
- Request offline_access so Authentik issues a refresh token (automaticRefresh).
- Silent refresh + single retry on 401 for writes (useApiFetch, incl. partner
pages) and reads (useMe.fetchMe) — no redirect, no lost input.
- Modal backdrop closes only on press+release on the backdrop (no more
drag-select-to-close).
376 lines
15 KiB
TypeScript
376 lines
15 KiB
TypeScript
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<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()
|
|
}
|
|
|
|
// ── 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<PayoutDocument[]> {
|
|
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<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)
|
|
}
|
|
|
|
// 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<InvoiceDocument[]> {
|
|
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<string> {
|
|
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<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()
|
|
}
|
|
}
|