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
@@ -10,6 +10,7 @@ import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { UsersModule } from '../users/users.module.js'
import { BillingService } from './billing.service.js'
import { CustomerBillingController } from './customer-billing.controller.js'
import { OperatorBillingController } from './operator-billing.controller.js'
import { PartnerBillingController } from './partner-billing.controller.js'
import { PayoutWorker } from './payout.worker.js'
@@ -29,7 +30,12 @@ import { StripeWebhookController } from './stripe-webhook.controller.js'
IntegrationsModule,
UsersModule,
],
controllers: [PartnerBillingController, OperatorBillingController, StripeWebhookController],
controllers: [
PartnerBillingController,
OperatorBillingController,
CustomerBillingController,
StripeWebhookController,
],
providers: [BillingService, PayoutWorker],
})
export class BillingModule {}
@@ -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.
@@ -0,0 +1,59 @@
import { BadRequestException, Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common'
import { ActorService } from '../auth/actor.service.js'
import { clientIp } from '../auth/client-ip.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'
// Customer-admin billing reads, scoped to a single tenant the caller belongs
// to. Lives in BillingModule (not TenantsModule) to avoid a module cycle:
// TenantsModule → BillingModule → UsersModule → TenantsModule. Only
// JwtAuthGuard here (any portal user); per-tenant membership is enforced in
// BillingService.tenantInvoicesForActor.
@Controller('tenants')
@UseGuards(JwtAuthGuard)
export class CustomerBillingController {
constructor(
private readonly billing: BillingService,
private readonly actor: ActorService,
) {}
@Get(':slug/invoices')
async invoices(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const user = await this.actor.resolve(jwt)
return this.billing.tenantInvoicesForActor(slug, user)
}
// The card currently on file (or null). Read-only.
@Get(':slug/payment-method')
async paymentMethod(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const user = await this.actor.resolve(jwt)
return this.billing.getPaymentMethod(slug, user)
}
// Start a card update — returns the SetupIntent client secret + publishable
// key for the portal to confirm with Stripe Elements.
@Post(':slug/payment-method/setup-intent')
async setupIntent(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const user = await this.actor.resolve(jwt)
return this.billing.createPaymentSetupIntent(slug, user)
}
// Finish the update: set the confirmed payment method as the default.
@Post(':slug/payment-method/default')
async setDefault(
@Param('slug') slug: string,
@Body('paymentMethodId') paymentMethodId: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
if (!paymentMethodId) throw new BadRequestException('paymentMethodId is required')
const user = await this.actor.resolve(jwt)
return this.billing.setDefaultPaymentMethod(slug, user, paymentMethodId, {
userId: String(user._id),
email: user.email,
ip: clientIp(req),
})
}
}
@@ -12,11 +12,15 @@ export class StripeClient {
private readonly logger = new Logger(StripeClient.name)
private readonly secretKey?: string
private readonly webhookSecret?: string
// Publishable key is safe to hand to the browser — it's how the portal mounts
// Stripe Elements. Exposed via getter so the billing endpoints can return it.
readonly publishableKey?: string
private _stripe?: Stripe
readonly enabled: boolean
constructor(config: ConfigService) {
this.secretKey = config.get<string>('STRIPE_SECRET_KEY')
this.publishableKey = config.get<string>('STRIPE_PUBLISHABLE_KEY')
this.webhookSecret = config.get<string>('STRIPE_WEBHOOK_SECRET')
this.enabled = config.get<string>('BILLING_STRIPE_ENABLED') === 'true' && !!this.secretKey
if (!this.enabled) {
@@ -138,6 +142,59 @@ export class StripeClient {
return res.data
}
// ── Payment methods (customer-admin "card on file" flow) ──────────────────
// The customer's default card, if any. Prefers the invoice-settings default;
// falls back to the first attached card. Returns null when none is on file.
async getDefaultCard(customerId: string): Promise<{
brand: string
last4: string
expMonth: number
expYear: number
} | null> {
const customer = await this.stripe.customers.retrieve(customerId, {
expand: ['invoice_settings.default_payment_method'],
})
if (customer.deleted) return null
let pm = customer.invoice_settings?.default_payment_method as Stripe.PaymentMethod | null
if (!pm || typeof pm === 'string') {
const list = await this.stripe.paymentMethods.list({ customer: customerId, type: 'card', limit: 1 })
pm = list.data[0] ?? null
}
if (!pm?.card) return null
return {
brand: pm.card.brand,
last4: pm.card.last4,
expMonth: pm.card.exp_month,
expYear: pm.card.exp_year,
}
}
// SetupIntent to collect a card off-session for future invoices. The portal
// confirms it client-side with Stripe Elements using the returned secret.
async createSetupIntent(customerId: string): Promise<string> {
const si = await this.stripe.setupIntents.create({
customer: customerId,
payment_method_types: ['card'],
usage: 'off_session',
})
if (!si.client_secret) throw new Error('SetupIntent has no client_secret')
return si.client_secret
}
// After a SetupIntent succeeds, make the new card the customer's default for
// invoices (and for the active subscription, if any). The PM is already
// attached to the customer by the SetupIntent.
async setDefaultCard(customerId: string, paymentMethodId: string, subscriptionId?: string): Promise<void> {
await this.stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
})
if (subscriptionId) {
await this.stripe.subscriptions.update(subscriptionId, { default_payment_method: paymentMethodId })
}
}
constructWebhookEvent(rawBody: Buffer | string, signature: string): Stripe.Event {
if (!this.webhookSecret) throw new Error('Stripe webhook secret not configured')
return this.stripe.webhooks.constructEvent(rawBody, signature, this.webhookSecret)
@@ -0,0 +1,36 @@
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
import { HydratedDocument, Types } from 'mongoose'
export type TenantBrandingDocument = HydratedDocument<TenantBranding>
// A single email-template override. `key` identifies the template
// ('invitation', 'reset', 'digest', 'trial'); subject/body are the customer's
// edited copy. Absence of a row for a key means "use the platform default",
// which lives in the portal (canonical defaults) — we only persist overrides.
@Schema({ _id: false })
export class EmailTemplateOverride {
@Prop({ required: true, trim: true })
key!: string
@Prop({ default: '' })
subject!: string
@Prop({ default: '' })
body!: string
}
const EmailTemplateOverrideSchema = SchemaFactory.createForClass(EmailTemplateOverride)
// Per-tenant whitelabel branding that doesn't fit on the Tenant doc itself.
// Name + brandColor live on Tenant (source of truth); this holds the email
// template overrides. Logos / custom-domain verification will join here once
// their pipelines exist. One doc per tenant, keyed by tenantId.
@Schema({ collection: 'tenant_branding', timestamps: true })
export class TenantBranding {
@Prop({ type: Types.ObjectId, ref: 'Tenant', required: true, unique: true, index: true })
tenantId!: Types.ObjectId
@Prop({ type: [EmailTemplateOverrideSchema], default: [] })
emailTemplates!: EmailTemplateOverride[]
}
export const TenantBrandingSchema = SchemaFactory.createForClass(TenantBranding)
@@ -0,0 +1,19 @@
import { IsOptional, IsString, MaxLength } from 'class-validator'
// Customer-editable company/tax fields for invoicing. Deliberately narrow —
// this is the only tenant mutation a customer admin can perform from the
// portal, so it must not carry plan/status/partner fields. contactEmail is a
// plain string (not @IsEmail) so it can be cleared to '' without a 400.
export class UpdateBillingInfoDto {
@IsOptional() @IsString() @MaxLength(160)
companyName?: string
@IsOptional() @IsString() @MaxLength(64)
vatId?: string
@IsOptional() @IsString() @MaxLength(80)
country?: string
@IsOptional() @IsString() @MaxLength(160)
contactEmail?: string
}
@@ -0,0 +1,36 @@
import { Type } from 'class-transformer'
import {
IsArray,
IsHexColor,
IsOptional,
IsString,
MaxLength,
MinLength,
ValidateNested,
} from 'class-validator'
export class EmailTemplateDto {
@IsString() @MaxLength(40)
key!: string
@IsString() @MaxLength(200)
subject!: string
@IsString() @MaxLength(20000)
body!: string
}
// Customer-editable whitelabel branding. Narrow on purpose: name + accent
// color (written to the Tenant doc) and email-template overrides (written to
// TenantBranding). No plan/status/partner — and no logo/domain yet (no
// backend). brandColor accepts '#rrggbb'.
export class UpdateTenantBrandingDto {
@IsOptional() @IsString() @MinLength(1) @MaxLength(120)
name?: string
@IsOptional() @IsHexColor()
brandColor?: string
@IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => EmailTemplateDto)
emailTemplates?: EmailTemplateDto[]
}
@@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common'
import { InjectModel } from '@nestjs/mongoose'
import { Model } from 'mongoose'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { TenantBranding, type TenantBrandingDocument } from '../schemas/tenant-branding.schema.js'
import { Tenant, type TenantDocument } from '../schemas/tenant.schema.js'
import type { UpdateTenantBrandingDto } from './dto/update-tenant-branding.dto.js'
export interface TenantBrandingView {
name: string
brandColor?: string
primaryDomain?: string
emailTemplates: Array<{ key: string; subject: string; body: string }>
}
// Customer whitelabel branding. Name + brandColor are read/written on the
// Tenant doc (source of truth); email-template overrides live in the
// TenantBranding doc. The portal page reads the combined view and saves the
// whole thing back in one PUT (same full-replace shape as partner branding).
@Injectable()
export class TenantBrandingService {
constructor(
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
@InjectModel(TenantBranding.name) private readonly brandingModel: Model<TenantBrandingDocument>,
private readonly audit: AuditService,
) {}
async get(tenant: TenantDocument): Promise<TenantBrandingView> {
const branding = await this.brandingModel.findOne({ tenantId: tenant._id }).exec()
return {
name: tenant.name,
brandColor: tenant.brandColor,
primaryDomain: tenant.domains?.[0],
emailTemplates: (branding?.emailTemplates ?? []).map((t) => ({
key: t.key,
subject: t.subject,
body: t.body,
})),
}
}
async put(
tenant: TenantDocument,
dto: UpdateTenantBrandingDto,
actor?: AuditActor,
): Promise<TenantBrandingView> {
// Name + accent live on the Tenant doc.
const set: Record<string, unknown> = {}
if (dto.name !== undefined) set.name = dto.name
if (dto.brandColor !== undefined) set.brandColor = dto.brandColor
if (Object.keys(set).length) {
await this.tenantModel.updateOne({ _id: tenant._id }, { $set: set }).exec()
}
// Email-template overrides are a full replace (the page edits the whole set).
if (dto.emailTemplates !== undefined) {
await this.brandingModel
.findOneAndUpdate(
{ tenantId: tenant._id },
{ $set: { emailTemplates: dto.emailTemplates } },
{ upsert: true, new: true, runValidators: true, setDefaultsOnInsert: true },
)
.exec()
}
void this.audit.record(
{
action: 'tenant.branding_updated',
resourceType: 'tenant',
resourceId: String(tenant._id),
resourceName: dto.name ?? tenant.name,
tenantSlug: tenant.slug,
metadata: {
fields: Object.keys(set),
templates: dto.emailTemplates?.length ?? 0,
},
},
actor,
)
const fresh = await this.tenantModel.findById(tenant._id).exec()
return this.get(fresh ?? tenant)
}
}
@@ -8,6 +8,8 @@ import {
Param,
Patch,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common'
@@ -17,9 +19,12 @@ import { CurrentUser } from '../auth/current-user.decorator.js'
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
import { OperatorGuard } from '../auth/operator.guard.js'
import type { AuthentikJwtPayload } from '../auth/jwt-payload.interface.js'
import type { AuditActor } from '../audit/audit.service.js'
import { AuditService, type AuditActor } from '../audit/audit.service.js'
import { CreateTenantDto } from './dto/create-tenant.dto.js'
import { UpdateBillingInfoDto } from './dto/update-billing-info.dto.js'
import { UpdateTenantBrandingDto } from './dto/update-tenant-branding.dto.js'
import { UpdateTenantDto } from './dto/update-tenant.dto.js'
import { TenantBrandingService } from './tenant-branding.service.js'
import { TenantsService } from './tenants.service.js'
// Build the audit actor from a resolved User doc + the originating request.
@@ -41,6 +46,8 @@ export class TenantsController {
constructor(
private readonly tenants: TenantsService,
private readonly actor: ActorService,
private readonly audit: AuditService,
private readonly branding: TenantBrandingService,
) {}
@Post()
@@ -79,6 +86,29 @@ export class TenantsController {
return this.tenants.listUsersForTenant(slug)
}
// Tenant-scoped audit slice for the customer-admin dashboard. Same membership
// gate as GET :slug — any member of the tenant can read their own workspace's
// activity. This is the portal-accessible counterpart to the operator-only
// GET /audit (which requires dezky-operator audience). Filtered strictly by
// tenantSlug so a caller only ever sees their own tenant's events.
@Get(':slug/audit')
async listAudit(
@Param('slug') slug: string,
@CurrentUser() jwt: AuthentikJwtPayload,
@Query('limit') limit?: string,
) {
const actor = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!actor.platformAdmin && !actor.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
const parsed = limit ? Number.parseInt(limit, 10) : undefined
return this.audit.list({
tenantSlug: slug,
limit: Number.isFinite(parsed) ? parsed : undefined,
})
}
@Patch(':slug')
async update(
@Param('slug') slug: string,
@@ -94,6 +124,51 @@ export class TenantsController {
return this.tenants.update(slug, dto, auditActor(user, req))
}
// Customer admins can fix their own company/tax details. Narrow on purpose —
// only billingInfo, never plan/status/partner (unlike the broad PATCH above).
// Membership-gated like the other tenant-scoped portal reads.
@Patch(':slug/billing-info')
async updateBillingInfo(
@Param('slug') slug: string,
@Body() dto: UpdateBillingInfoDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const user = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return this.tenants.updateBillingInfo(slug, dto, auditActor(user, req))
}
// Whitelabel branding (name + accent + email-template overrides). Narrow,
// membership-gated — never touches plan/status/partner.
@Get(':slug/branding')
async getBranding(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload) {
const user = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return this.branding.get(tenant)
}
@Put(':slug/branding')
async putBranding(
@Param('slug') slug: string,
@Body() dto: UpdateTenantBrandingDto,
@CurrentUser() jwt: AuthentikJwtPayload,
@Req() req: Parameters<typeof clientIp>[0],
) {
const user = await this.actor.resolve(jwt)
const tenant = await this.tenants.findOneBySlug(slug)
if (!user.platformAdmin && !user.tenantIds.some((id) => id.equals(tenant._id))) {
throw new ForbiddenException(`No access to tenant "${slug}"`)
}
return this.branding.put(tenant, dto, auditActor(user, req))
}
@Delete(':slug')
@HttpCode(204)
async remove(@Param('slug') slug: string, @CurrentUser() jwt: AuthentikJwtPayload, @Req() req: Parameters<typeof clientIp>[0]) {
@@ -5,9 +5,11 @@ import { AuthModule } from '../auth/auth.module.js'
import { IntegrationsModule } from '../integrations/integrations.module.js'
import { PricesModule } from '../prices/prices.module.js'
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
import { TenantBranding, TenantBrandingSchema } from '../schemas/tenant-branding.schema.js'
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
import { User, UserSchema } from '../schemas/user.schema.js'
import { ProvisioningService } from './provisioning.service.js'
import { TenantBrandingService } from './tenant-branding.service.js'
import { TenantsController } from './tenants.controller.js'
import { TenantsService } from './tenants.service.js'
@@ -20,6 +22,7 @@ import { TenantsService } from './tenants.service.js'
// provisioned tenant gets its Subscription doc in the same call. Price
// lookup goes through PricesService for the soft-active filter.
{ name: Subscription.name, schema: SubscriptionSchema },
{ name: TenantBranding.name, schema: TenantBrandingSchema },
]),
AuthModule,
AuditModule,
@@ -27,7 +30,7 @@ import { TenantsService } from './tenants.service.js'
PricesModule,
],
controllers: [TenantsController],
providers: [TenantsService, ProvisioningService],
providers: [TenantsService, ProvisioningService, TenantBrandingService],
exports: [TenantsService],
})
export class TenantsModule {}
@@ -16,6 +16,7 @@ import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
import { User, UserDocument } from '../schemas/user.schema.js'
import type { PartnerUpdateTenantDto } from '../me/dto/partner-update-tenant.dto.js'
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
import type { UpdateBillingInfoDto } from './dto/update-billing-info.dto.js'
import type { UpdateTenantDto } from './dto/update-tenant.dto.js'
import { ProvisioningService } from './provisioning.service.js'
@@ -244,6 +245,39 @@ export class TenantsService {
return tenant
}
// Narrow update of just the company/tax fields, for the customer-admin billing
// page. Uses dotted $set so only the provided keys change (and '' clears one)
// without disturbing the rest of billingInfo.
async updateBillingInfo(
slug: string,
dto: UpdateBillingInfoDto,
actor?: AuditActor,
): Promise<TenantDocument> {
const set: Record<string, unknown> = {}
if (dto.companyName !== undefined) set['billingInfo.companyName'] = dto.companyName
if (dto.vatId !== undefined) set['billingInfo.vatId'] = dto.vatId
if (dto.country !== undefined) set['billingInfo.country'] = dto.country
if (dto.contactEmail !== undefined) set['billingInfo.contactEmail'] = dto.contactEmail
const tenant = await this.tenantModel
.findOneAndUpdate({ slug }, { $set: set }, { new: true, runValidators: true })
.exec()
if (!tenant) throw new NotFoundException(`Tenant "${slug}" not found`)
void this.audit.record(
{
action: 'tenant.billing_info_updated',
resourceType: 'tenant',
resourceId: String(tenant._id),
resourceName: tenant.name,
tenantSlug: tenant.slug,
metadata: { fields: Object.keys(set) },
},
actor,
)
return tenant
}
async softDelete(slug: string, actor?: AuditActor): Promise<void> {
const result = await this.tenantModel
.findOneAndUpdate({ slug }, { status: 'deleted' }, { new: true })