feat(portal): customer-admin surface on real data + Stripe billing + session resilience

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).
This commit is contained in:
Ronni Baslund
2026-05-31 00:19:34 +02:00
parent db26dafc64
commit 3288fde693
44 changed files with 1874 additions and 1237 deletions
@@ -1,4 +1,10 @@
import { Injectable, Logger } from '@nestjs/common'
import {
ForbiddenException,
Injectable,
Logger,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import type Stripe from 'stripe'
import { Model, Types } from 'mongoose'
@@ -186,6 +192,102 @@ export class BillingService {
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.