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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user