feat: portal redesign, pricing catalog, partner-staff invites
- portal: new admin/ and partner/ surfaces with full component library (AppLauncher, Avatar, Badge, Card, Modal, Tabs, etc.), composables, layouts, partner-routing middleware, and supporting server APIs - pricing: Price schema/module with operator CRUD, pricing.vue catalog UI, Subscription extended with cycle/currency/perSeatAmount/seats snapshots for stable MRR aggregation - partner staff: User.partnerId, invite-partner-user DTO and flow, /partners/:slug/users endpoints, InvitePartnerUserModal, shared dezky-partner-staff Authentik group - /me: partner-aware endpoint returning user + partner context so portal can route between end-user and partner-admin surfaces - tenant: seats field for portfolio displays and future MRR calculations - operator: pricing page, signed-out page, useMe/useToast composables, ToastStack
This commit is contained in:
@@ -6,7 +6,9 @@ import { AuthModule } from './auth/auth.module.js'
|
||||
import { FlagsModule } from './flags/flags.module.js'
|
||||
import { HealthModule } from './health/health.module.js'
|
||||
import { IngestModule } from './ingest/ingest.module.js'
|
||||
import { MeModule } from './me/me.module.js'
|
||||
import { PartnersModule } from './partners/partners.module.js'
|
||||
import { PricesModule } from './prices/prices.module.js'
|
||||
import { SeedModule } from './seed/seed.module.js'
|
||||
import { SubscriptionsModule } from './subscriptions/subscriptions.module.js'
|
||||
import { TenantsModule } from './tenants/tenants.module.js'
|
||||
@@ -24,7 +26,9 @@ import { UsersModule } from './users/users.module.js'
|
||||
TenantsModule,
|
||||
PartnersModule,
|
||||
UsersModule,
|
||||
MeModule,
|
||||
SubscriptionsModule,
|
||||
PricesModule,
|
||||
FlagsModule,
|
||||
IngestModule,
|
||||
SeedModule,
|
||||
|
||||
@@ -190,6 +190,27 @@ export class AuditService {
|
||||
return this.model.find(q).sort({ at: -1, _id: -1 }).limit(limit).exec()
|
||||
}
|
||||
|
||||
// Partner-scoped activity feed. Returns events where partnerSlug matches
|
||||
// OR tenantSlug is one of the partner's tenants. Distinct shape from
|
||||
// list() because the OR-across-many-slugs query doesn't fit the
|
||||
// single-tenantSlug filter the operator UI uses.
|
||||
async listForPartner(opts: {
|
||||
partnerSlug: string
|
||||
tenantSlugs: string[]
|
||||
limit?: number
|
||||
before?: Date
|
||||
}): Promise<AuditEventDocument[]> {
|
||||
const q: FilterQuery<AuditEventDocument> = {
|
||||
$or: [
|
||||
{ partnerSlug: opts.partnerSlug },
|
||||
...(opts.tenantSlugs.length > 0 ? [{ tenantSlug: { $in: opts.tenantSlugs } }] : []),
|
||||
],
|
||||
}
|
||||
if (opts.before) q.at = { $lt: opts.before }
|
||||
const limit = clamp(opts.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT)
|
||||
return this.model.find(q).sort({ at: -1, _id: -1 }).limit(limit).exec()
|
||||
}
|
||||
|
||||
// ── Chain integrity helpers ────────────────────────────────────────────
|
||||
|
||||
// Atomic monotonic counter. findOneAndUpdate with $inc and upsert returns
|
||||
|
||||
@@ -35,6 +35,12 @@ export class AuthentikClient {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`Authentik ${init.method ?? 'GET'} ${path} → ${res.status}: ${body.slice(0, 200)}`)
|
||||
}
|
||||
// 204 No Content (and other empty-body successes) crash res.json().
|
||||
// Endpoints like /core/groups/:id/add_user/ return 204; callers with a
|
||||
// void return type don't care about the payload, so hand back undefined.
|
||||
if (res.status === 204 || res.headers.get('content-length') === '0') {
|
||||
return undefined as T
|
||||
}
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AuthModule } from '../auth/auth.module.js'
|
||||
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
|
||||
import { TenantsModule } from '../tenants/tenants.module.js'
|
||||
import { UsersModule } from '../users/users.module.js'
|
||||
import { PartnerMeController } from './partner-me.controller.js'
|
||||
|
||||
// Self-service portal surface. Composes UsersService (partner-scoped reads,
|
||||
// invitePartnerUser, inviteTenantAdmin, partnerMrr, partnerActivity) and
|
||||
// TenantsService (create) behind clean /me/partner/* URLs. No service of
|
||||
// its own — the controllers are thin façades.
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
TenantsModule,
|
||||
MongooseModule.forFeature([{ name: Partner.name, schema: PartnerSchema }]),
|
||||
],
|
||||
controllers: [PartnerMeController],
|
||||
})
|
||||
export class MeModule {}
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import type { Model } from 'mongoose'
|
||||
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 { Partner, PartnerDocument } from '../schemas/partner.schema.js'
|
||||
import { CreateTenantDto } from '../tenants/dto/create-tenant.dto.js'
|
||||
import { TenantsService } from '../tenants/tenants.service.js'
|
||||
import { UsersService } from '../users/users.service.js'
|
||||
|
||||
// Self-service endpoints for the partner portal. Everything here scopes to
|
||||
// the caller's resolved User.partnerId — no slug in any URL, no operator
|
||||
// guard. A portal-aud JWT is sufficient; partner-staff is enforced per
|
||||
// handler via the actor.partnerId check.
|
||||
//
|
||||
// Identity endpoints (GET /users/me, etc.) intentionally stay on
|
||||
// UsersController — those are about "who am I", whereas everything here is
|
||||
// about "what does my partner own".
|
||||
@Controller('me/partner')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerMeController {
|
||||
constructor(
|
||||
private readonly users: UsersService,
|
||||
private readonly tenants: TenantsService,
|
||||
private readonly actor: ActorService,
|
||||
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
|
||||
) {}
|
||||
|
||||
// The OTHER people at the caller's partner organization. Distinct from
|
||||
// GET /partners/:slug/users (operator only): scoped via the actor's
|
||||
// User.partnerId so a portal-aud JWT works.
|
||||
@Get('users')
|
||||
async listUsers(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
if (!actor.partnerId) {
|
||||
throw new ForbiddenException('Not a partner-staff user')
|
||||
}
|
||||
return this.users.listPartnerUsers(actor.partnerId)
|
||||
}
|
||||
|
||||
// Tenants (customers) attached to the partner.
|
||||
@Get('tenants')
|
||||
async listTenants(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
if (!actor.partnerId) {
|
||||
throw new ForbiddenException('Not a partner-staff user')
|
||||
}
|
||||
return this.users.listPartnerTenants(actor.partnerId)
|
||||
}
|
||||
|
||||
// Self-service tenant create. Counterpart to operator POST /tenants.
|
||||
// Forces partnerId from actor.partnerId so a partner can never create
|
||||
// a tenant under a different partner, even if their payload says so.
|
||||
// If adminName + adminEmail are present, fires inviteTenantAdmin after
|
||||
// tenant provisioning. Admin failures don't roll back the tenant — the
|
||||
// response includes an `adminInvite` field with credentials or an error.
|
||||
@Post('tenants')
|
||||
async createTenant(
|
||||
@Body() dto: CreateTenantDto,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Req() req: Parameters<typeof clientIp>[0],
|
||||
) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
if (!actor.partnerId) {
|
||||
throw new ForbiddenException('Not a partner-staff user')
|
||||
}
|
||||
const auditActor = {
|
||||
userId: String(actor._id),
|
||||
email: actor.email,
|
||||
ip: clientIp(req),
|
||||
}
|
||||
const safeDto = { ...dto, partnerId: actor.partnerId } as CreateTenantDto & {
|
||||
partnerId: typeof actor.partnerId
|
||||
}
|
||||
const tenant = await this.tenants.create(safeDto, auditActor)
|
||||
|
||||
let adminInvite:
|
||||
| { subject: string; userId: string; attached?: boolean; link?: string; tempPassword?: string }
|
||||
| { error: string }
|
||||
| undefined
|
||||
if (dto.adminName && dto.adminEmail) {
|
||||
try {
|
||||
adminInvite = await this.users.inviteTenantAdmin(
|
||||
{ _id: tenant._id, slug: tenant.slug, authentikGroupId: tenant.authentikGroupId },
|
||||
{ name: dto.adminName, email: dto.adminEmail },
|
||||
auditActor,
|
||||
)
|
||||
} catch (err) {
|
||||
adminInvite = { error: err instanceof Error ? err.message : String(err) }
|
||||
}
|
||||
}
|
||||
|
||||
return { tenant, adminInvite }
|
||||
}
|
||||
|
||||
// Monthly Recurring Revenue across the partner's customers — grouped by
|
||||
// currency since subs can be billed in DKK / EUR / USD independently.
|
||||
@Get('mrr')
|
||||
async mrr(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
if (!actor.partnerId) {
|
||||
throw new ForbiddenException('Not a partner-staff user')
|
||||
}
|
||||
return this.users.partnerMrr(actor.partnerId)
|
||||
}
|
||||
|
||||
// Recent audit events across the partner's portfolio. Used by the
|
||||
// dashboard's Activity card and the /partner/audit page. Pagination via
|
||||
// ?before=<iso> + ?limit=N.
|
||||
@Get('activity')
|
||||
async activity(
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('before') before?: string,
|
||||
) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
if (!actor.partnerId) {
|
||||
throw new ForbiddenException('Not a partner-staff user')
|
||||
}
|
||||
const partner = await this.partnerModel.findById(actor.partnerId, { slug: 1 }).exec()
|
||||
if (!partner) {
|
||||
throw new ForbiddenException('Partner record missing')
|
||||
}
|
||||
return this.users.partnerActivity(actor.partnerId, partner.slug, {
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
before: before ? new Date(before) : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ 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 { InvitePartnerUserDto } from '../users/dto/invite-partner-user.dto.js'
|
||||
import { UsersService } from '../users/users.service.js'
|
||||
import { CreatePartnerDto } from './dto/create-partner.dto.js'
|
||||
import { UpdatePartnerDto } from './dto/update-partner.dto.js'
|
||||
import { PartnersService } from './partners.service.js'
|
||||
@@ -42,6 +44,7 @@ function auditActor(
|
||||
export class PartnersController {
|
||||
constructor(
|
||||
private readonly partners: PartnersService,
|
||||
private readonly users: UsersService,
|
||||
private readonly actorService: ActorService,
|
||||
) {}
|
||||
|
||||
@@ -93,4 +96,31 @@ export class PartnersController {
|
||||
const user = await this.actorService.resolve(jwt)
|
||||
await this.partners.terminate(slug, auditActor(user, req))
|
||||
}
|
||||
|
||||
// Partner-staff team listing. Returns the User docs whose partnerId matches
|
||||
// this partner. The /partners/:slug page's Team section calls this on load.
|
||||
@Get(':slug/users')
|
||||
async listUsers(@Param('slug') slug: string) {
|
||||
const partner = await this.partners.findOneBySlug(slug)
|
||||
return this.users.listPartnerUsers(partner._id)
|
||||
}
|
||||
|
||||
// Invite a new partner-staff user. Resolves slug → partner, delegates to
|
||||
// UsersService.invitePartnerUser which handles Authentik user creation,
|
||||
// group assignment, local User pre-create, and audit recording.
|
||||
@Post(':slug/users')
|
||||
async inviteUser(
|
||||
@Param('slug') slug: string,
|
||||
@Body() dto: InvitePartnerUserDto,
|
||||
@CurrentUser() jwt: AuthentikJwtPayload,
|
||||
@Req() req: Parameters<typeof clientIp>[0],
|
||||
) {
|
||||
const actor = await this.actorService.resolve(jwt)
|
||||
const partner = await this.partners.findOneBySlug(slug)
|
||||
return this.users.invitePartnerUser(
|
||||
dto,
|
||||
{ _id: partner._id, slug: partner.slug },
|
||||
auditActor(actor, req),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { Module, forwardRef } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AuditModule } from '../audit/audit.module.js'
|
||||
import { AuthModule } from '../auth/auth.module.js'
|
||||
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
|
||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||
import { UsersModule } from '../users/users.module.js'
|
||||
import { PartnersController } from './partners.controller.js'
|
||||
import { PartnersService } from './partners.service.js'
|
||||
|
||||
@@ -15,6 +16,10 @@ import { PartnersService } from './partners.service.js'
|
||||
]),
|
||||
AuthModule,
|
||||
AuditModule,
|
||||
// forwardRef defensively — if UsersModule ever grows a dependency back
|
||||
// on PartnersModule (e.g. partner-portal scope guards that need
|
||||
// PartnersService.findOneBySlug), we avoid the circular-dep refactor.
|
||||
forwardRef(() => UsersModule),
|
||||
],
|
||||
controllers: [PartnersController],
|
||||
providers: [PartnersService],
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Type } from 'class-transformer'
|
||||
import { IsBoolean, IsEnum, IsInt, IsOptional, Min, ValidateNested } from 'class-validator'
|
||||
|
||||
// Per-currency amount sub-object. Each currency is optional — a plan that
|
||||
// isn't sold in EUR just leaves EUR undefined. Stored in minor units.
|
||||
class AmountsDto {
|
||||
@IsOptional() @IsInt() @Min(0) DKK?: number
|
||||
@IsOptional() @IsInt() @Min(0) EUR?: number
|
||||
@IsOptional() @IsInt() @Min(0) USD?: number
|
||||
}
|
||||
|
||||
export class CreatePriceDto {
|
||||
@IsEnum(['mvp', 'pro', 'enterprise'])
|
||||
plan!: 'mvp' | 'pro' | 'enterprise'
|
||||
|
||||
@IsEnum(['monthly', 'quarterly', 'yearly'])
|
||||
cycle!: 'monthly' | 'quarterly' | 'yearly'
|
||||
|
||||
@ValidateNested() @Type(() => AmountsDto)
|
||||
amounts!: AmountsDto
|
||||
|
||||
@IsOptional() @IsBoolean()
|
||||
active?: boolean
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Type } from 'class-transformer'
|
||||
import { IsBoolean, IsInt, IsOptional, Min, ValidateNested } from 'class-validator'
|
||||
|
||||
class AmountsDto {
|
||||
@IsOptional() @IsInt() @Min(0) DKK?: number
|
||||
@IsOptional() @IsInt() @Min(0) EUR?: number
|
||||
@IsOptional() @IsInt() @Min(0) USD?: number
|
||||
}
|
||||
|
||||
// All fields optional — PATCH a single currency, the active flag, or both.
|
||||
// plan + cycle are the row identity and are not editable here (deactivate
|
||||
// and create a new row if those need to change, which the partial unique
|
||||
// index enforces).
|
||||
export class UpdatePriceDto {
|
||||
@IsOptional() @ValidateNested() @Type(() => AmountsDto)
|
||||
amounts?: AmountsDto
|
||||
|
||||
@IsOptional() @IsBoolean()
|
||||
active?: boolean
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard.js'
|
||||
import { OperatorGuard } from '../auth/operator.guard.js'
|
||||
import { CreatePriceDto } from './dto/create-price.dto.js'
|
||||
import { UpdatePriceDto } from './dto/update-price.dto.js'
|
||||
import { PricesService } from './prices.service.js'
|
||||
|
||||
// All endpoints require an authenticated caller. Read is open to any
|
||||
// authenticated JWT (partners + portal users see prices for display);
|
||||
// write operations layer the OperatorGuard on top.
|
||||
@Controller('prices')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PricesController {
|
||||
constructor(private readonly prices: PricesService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('includeInactive') includeInactive?: string) {
|
||||
return this.prices.findAll(includeInactive === 'true')
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(OperatorGuard)
|
||||
create(@Body() dto: CreatePriceDto) {
|
||||
return this.prices.create(dto)
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(OperatorGuard)
|
||||
update(@Param('id') id: string, @Body() dto: UpdatePriceDto) {
|
||||
return this.prices.update(id, dto)
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(OperatorGuard)
|
||||
deactivate(@Param('id') id: string) {
|
||||
return this.prices.deactivate(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AuthModule } from '../auth/auth.module.js'
|
||||
import { Price, PriceSchema } from '../schemas/price.schema.js'
|
||||
import { PricesController } from './prices.controller.js'
|
||||
import { PricesService } from './prices.service.js'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MongooseModule.forFeature([{ name: Price.name, schema: PriceSchema }]),
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [PricesController],
|
||||
providers: [PricesService],
|
||||
exports: [PricesService],
|
||||
})
|
||||
export class PricesModule {}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { Price, PriceDocument, type PriceCycle, type PriceCurrency, type PricePlan } from '../schemas/price.schema.js'
|
||||
import type { CreatePriceDto } from './dto/create-price.dto.js'
|
||||
import type { UpdatePriceDto } from './dto/update-price.dto.js'
|
||||
|
||||
@Injectable()
|
||||
export class PricesService {
|
||||
private readonly logger = new Logger(PricesService.name)
|
||||
constructor(@InjectModel(Price.name) private readonly priceModel: Model<PriceDocument>) {}
|
||||
|
||||
async findAll(includeInactive = false): Promise<PriceDocument[]> {
|
||||
const filter = includeInactive ? {} : { active: true }
|
||||
return this.priceModel.find(filter).sort({ plan: 1, cycle: 1 }).exec()
|
||||
}
|
||||
|
||||
// Lookup used by tenant provisioning: which active row covers this
|
||||
// (plan, cycle)? Single row per pair now — currency selection happens at
|
||||
// amount-read time, not row-lookup time. Returns null when the row is
|
||||
// missing entirely (e.g. Enterprise that the operator hasn't priced).
|
||||
async findActive(plan: PricePlan, cycle: PriceCycle): Promise<PriceDocument | null> {
|
||||
return this.priceModel.findOne({ plan, cycle, active: true }).exec()
|
||||
}
|
||||
|
||||
// Extract a currency's amount from a Price row, returning undefined when
|
||||
// that currency isn't priced. Centralizes the lookup so callers don't
|
||||
// reach into the amounts object directly.
|
||||
amountFor(price: PriceDocument, currency: PriceCurrency): number | undefined {
|
||||
return price.amounts[currency]
|
||||
}
|
||||
|
||||
async create(dto: CreatePriceDto): Promise<PriceDocument> {
|
||||
try {
|
||||
return await this.priceModel.create({ ...dto, active: dto.active ?? true })
|
||||
} catch (err: unknown) {
|
||||
const e = err as { code?: number; message?: string }
|
||||
if (e.code === 11000) {
|
||||
throw new ConflictException(
|
||||
`Active price for ${dto.plan}/${dto.cycle} already exists — deactivate or PATCH it.`,
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdatePriceDto): Promise<PriceDocument> {
|
||||
// Treat amounts as a partial merge so PATCH { amounts: { EUR: 700 } }
|
||||
// updates only EUR without clobbering DKK and USD.
|
||||
const $set: Record<string, unknown> = {}
|
||||
if (dto.amounts) {
|
||||
if (dto.amounts.DKK !== undefined) $set['amounts.DKK'] = dto.amounts.DKK
|
||||
if (dto.amounts.EUR !== undefined) $set['amounts.EUR'] = dto.amounts.EUR
|
||||
if (dto.amounts.USD !== undefined) $set['amounts.USD'] = dto.amounts.USD
|
||||
}
|
||||
if (dto.active !== undefined) $set.active = dto.active
|
||||
|
||||
const price = await this.priceModel
|
||||
.findByIdAndUpdate(id, { $set }, { new: true, runValidators: true })
|
||||
.exec()
|
||||
if (!price) throw new NotFoundException(`Price ${id} not found`)
|
||||
return price
|
||||
}
|
||||
|
||||
async deactivate(id: string): Promise<PriceDocument> {
|
||||
return this.update(id, { active: false })
|
||||
}
|
||||
|
||||
// Bootstrap. Runs in two phases:
|
||||
// 1. Migrate any legacy rows (have top-level `currency` + `perSeatAmount`)
|
||||
// into the new per-row currency-map shape. Preserves operator edits:
|
||||
// if Starter/Monthly was edited to 48 DKK before, the new row keeps
|
||||
// DKK=4800 and only fills in EUR/USD defaults.
|
||||
// 2. Insert any missing default rows (Starter + Business × 3 cycles)
|
||||
// with sensible DKK/EUR/USD round numbers.
|
||||
// Both phases are idempotent — safe to run on every boot.
|
||||
async ensureDefaults(): Promise<void> {
|
||||
await this.migrateLegacy()
|
||||
|
||||
type Defaults = {
|
||||
plan: PricePlan
|
||||
cycle: PriceCycle
|
||||
amounts: { DKK: number; EUR: number; USD: number }
|
||||
}
|
||||
const defaults: Defaults[] = [
|
||||
// Round numbers in each currency, not FX-derived. Operator can edit.
|
||||
{ plan: 'mvp', cycle: 'monthly', amounts: { DKK: 4900, EUR: 700, USD: 700 } },
|
||||
{ plan: 'mvp', cycle: 'quarterly', amounts: { DKK: 13200, EUR: 1900, USD: 1900 } },
|
||||
{ plan: 'mvp', cycle: 'yearly', amounts: { DKK: 48800, EUR: 7000, USD: 7000 } },
|
||||
{ plan: 'pro', cycle: 'monthly', amounts: { DKK: 12900, EUR: 1800, USD: 1800 } },
|
||||
{ plan: 'pro', cycle: 'quarterly', amounts: { DKK: 34800, EUR: 4900, USD: 4900 } },
|
||||
{ plan: 'pro', cycle: 'yearly', amounts: { DKK: 128800, EUR: 17800, USD: 17800 } },
|
||||
]
|
||||
for (const row of defaults) {
|
||||
const exists = await this.priceModel.findOne({ plan: row.plan, cycle: row.cycle, active: true })
|
||||
if (!exists) {
|
||||
await this.priceModel.create({ ...row, active: true }).catch(() => {
|
||||
// Race / unique-index hit — another boot inserted it; ignore.
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migration: legacy docs had { currency, perSeatAmount } and one row per
|
||||
// (plan, cycle, currency). Collapse them: pick the active row per
|
||||
// (plan, cycle), move perSeatAmount under amounts[currency], and drop
|
||||
// the legacy fields. Inactive duplicates get deleted (their identity has
|
||||
// been folded into the surviving row).
|
||||
private async migrateLegacy(): Promise<void> {
|
||||
const legacy = await this.priceModel
|
||||
.find({ currency: { $exists: true }, perSeatAmount: { $exists: true } } as Record<string, unknown>)
|
||||
.lean()
|
||||
.exec()
|
||||
if (legacy.length === 0) return
|
||||
|
||||
this.logger.log(`Migrating ${legacy.length} legacy Price row(s) to per-row currency map`)
|
||||
|
||||
// Group legacy rows by (plan, cycle). Keep the survivor with the most
|
||||
// currencies represented (or just any active one) and merge amounts in.
|
||||
const groups = new Map<string, typeof legacy>()
|
||||
for (const row of legacy) {
|
||||
const key = `${row.plan}|${row.cycle}`
|
||||
const list = groups.get(key) ?? []
|
||||
list.push(row)
|
||||
groups.set(key, list)
|
||||
}
|
||||
|
||||
for (const [key, rows] of groups) {
|
||||
// Choose survivor: prefer an active row, then the lowest _id (stable).
|
||||
const survivor = rows.find((r) => (r as { active?: boolean }).active) ?? rows[0]
|
||||
const merged: { DKK?: number; EUR?: number; USD?: number } = {}
|
||||
for (const r of rows) {
|
||||
const c = (r as { currency?: PriceCurrency }).currency
|
||||
const v = (r as { perSeatAmount?: number }).perSeatAmount
|
||||
if (c && typeof v === 'number') merged[c] = v
|
||||
}
|
||||
|
||||
await this.priceModel.updateOne(
|
||||
{ _id: survivor._id },
|
||||
{
|
||||
$set: { amounts: merged, active: true },
|
||||
$unset: { currency: '', perSeatAmount: '' },
|
||||
},
|
||||
)
|
||||
|
||||
// Delete the non-survivor rows (their amounts are now in the merged map).
|
||||
const losers = rows.filter((r) => String(r._id) !== String(survivor._id)).map((r) => r._id)
|
||||
if (losers.length > 0) {
|
||||
await this.priceModel.deleteMany({ _id: { $in: losers } })
|
||||
}
|
||||
this.logger.log(`Migrated ${key}: amounts=${JSON.stringify(merged)} (dropped ${losers.length} duplicate row(s))`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'
|
||||
import { HydratedDocument } from 'mongoose'
|
||||
|
||||
export type PriceDocument = HydratedDocument<Price>
|
||||
|
||||
export type PriceCycle = 'monthly' | 'quarterly' | 'yearly'
|
||||
export type PriceCurrency = 'DKK' | 'EUR' | 'USD'
|
||||
export type PricePlan = 'mvp' | 'pro' | 'enterprise'
|
||||
|
||||
// One row per (plan, cycle). Each row holds an independent per-currency
|
||||
// amount map so operators can set clean round numbers in every currency
|
||||
// (49 DKK / 7 EUR / 7 USD) instead of FX-derived fractions. Currency without
|
||||
// a value means "we don't sell this plan/cycle in that currency"; the wizard
|
||||
// rejects provisioning if a chosen currency isn't priced.
|
||||
//
|
||||
// All amounts in MINOR units (øre / cents / cents). 4900 = 49.00, regardless
|
||||
// of currency.
|
||||
@Schema({ collection: 'prices', timestamps: true })
|
||||
export class Price {
|
||||
@Prop({ enum: ['mvp', 'pro', 'enterprise'], required: true, index: true })
|
||||
plan!: PricePlan
|
||||
|
||||
@Prop({ enum: ['monthly', 'quarterly', 'yearly'], required: true, index: true })
|
||||
cycle!: PriceCycle
|
||||
|
||||
@Prop({
|
||||
type: {
|
||||
DKK: { type: Number, min: 0 },
|
||||
EUR: { type: Number, min: 0 },
|
||||
USD: { type: Number, min: 0 },
|
||||
},
|
||||
required: true,
|
||||
default: () => ({}),
|
||||
})
|
||||
amounts!: {
|
||||
DKK?: number
|
||||
EUR?: number
|
||||
USD?: number
|
||||
}
|
||||
|
||||
// Soft-active flag. When the operator changes a row's amounts we mutate
|
||||
// the row in place; deactivation is for cases like end-of-life'd plans
|
||||
// where we want to preserve subs' priceId references without making the
|
||||
// row attach to NEW provisioning runs.
|
||||
@Prop({ type: Boolean, default: true, index: true })
|
||||
active!: boolean
|
||||
}
|
||||
|
||||
export const PriceSchema = SchemaFactory.createForClass(Price)
|
||||
// One active row per (plan, cycle). Partial filter keeps deactivated history
|
||||
// rows from blocking the index.
|
||||
PriceSchema.index(
|
||||
{ plan: 1, cycle: 1 },
|
||||
{ unique: true, partialFilterExpression: { active: true } },
|
||||
)
|
||||
@@ -19,6 +19,36 @@ export class Subscription {
|
||||
@Prop({ enum: ['mvp', 'pro', 'enterprise'], default: 'mvp' })
|
||||
plan!: 'mvp' | 'pro' | 'enterprise'
|
||||
|
||||
// Billing cycle. Carried alongside plan so MRR aggregation doesn't have
|
||||
// to walk to the Price doc just to normalise to monthly.
|
||||
@Prop({ enum: ['monthly', 'quarterly', 'yearly'], default: 'monthly' })
|
||||
cycle!: 'monthly' | 'quarterly' | 'yearly'
|
||||
|
||||
// Currency the customer is billed in. Snapshotted from the wizard so a
|
||||
// later catalog edit that adds/removes currencies doesn't change what
|
||||
// this customer pays. MRR aggregation groups by this field.
|
||||
@Prop({ enum: ['DKK', 'EUR', 'USD'], default: 'DKK', index: true })
|
||||
currency!: 'DKK' | 'EUR' | 'USD'
|
||||
|
||||
// The catalog row this sub was provisioned against. Sparse — Enterprise
|
||||
// (custom pricing) and pre-billing-pipeline tenants leave this empty,
|
||||
// which MRR aggregation treats as "0 / custom".
|
||||
@Prop({ type: Types.ObjectId, ref: 'Price', index: true, sparse: true })
|
||||
priceId?: Types.ObjectId
|
||||
|
||||
// Snapshot of the per-seat amount in `currency` at provision time. Lets
|
||||
// MRR aggregation compute without re-reading the Price doc, and keeps
|
||||
// the historical bill stable when the operator edits the catalog later.
|
||||
@Prop({ type: Number, min: 0, default: 0 })
|
||||
perSeatAmount!: number
|
||||
|
||||
// Snapshot of the seat count at provision time. Updated when the
|
||||
// operator/partner changes seats — we don't recompute from Tenant.seats
|
||||
// on each MRR aggregation so a mid-cycle seat change is visible
|
||||
// immediately without waiting for the next renewal.
|
||||
@Prop({ type: Number, min: 0, default: 0 })
|
||||
seats!: number
|
||||
|
||||
@Prop({
|
||||
enum: ['trialing', 'active', 'past_due', 'canceled', 'incomplete', 'incomplete_expired'],
|
||||
default: 'trialing',
|
||||
|
||||
@@ -25,6 +25,14 @@ export class Tenant {
|
||||
@Prop({ enum: ['mvp', 'pro', 'enterprise'], default: 'mvp' })
|
||||
plan!: TenantPlan
|
||||
|
||||
// Initial seat count from provisioning. Used for portfolio displays and
|
||||
// (later) MRR calculations. The "used" count comes from User.tenantIds —
|
||||
// not stored here to avoid a denormalized field that drifts on every
|
||||
// user-add/remove. Default 0 so older docs without this field render
|
||||
// as "0 / N" without throwing.
|
||||
@Prop({ type: Number, min: 0, default: 0 })
|
||||
seats!: number
|
||||
|
||||
// Custom domains attached to this tenant. First entry is the primary host.
|
||||
@Prop({ type: [String], default: [] })
|
||||
domains!: string[]
|
||||
|
||||
@@ -15,6 +15,15 @@ export class User {
|
||||
@Prop({ type: [Types.ObjectId], ref: 'Tenant', default: [], index: true })
|
||||
tenantIds!: Types.ObjectId[]
|
||||
|
||||
// Partner this user works FOR (staff/admin at a partner organization).
|
||||
// Optional — most users (tenant members + platform admins) leave this null.
|
||||
// When set, /partners/:slug/users lists this user and future partner-portal
|
||||
// scope guards can verify the JWT-resolved user belongs to the requested
|
||||
// partner. Per-partner role is implicit in being on the list for v1;
|
||||
// refine to per-partner role enum later if partner-portal RBAC needs it.
|
||||
@Prop({ type: Types.ObjectId, ref: 'Partner', index: true })
|
||||
partnerId?: Types.ObjectId
|
||||
|
||||
@Prop({ required: true, lowercase: true, trim: true, index: true })
|
||||
email!: string
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
||||
import { PricesModule } from '../prices/prices.module.js'
|
||||
import { Flag, FlagSchema } from '../schemas/flag.schema.js'
|
||||
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
|
||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||
@@ -10,6 +11,7 @@ import { SeedService } from './seed.service.js'
|
||||
@Module({
|
||||
imports: [
|
||||
IntegrationsModule,
|
||||
PricesModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: Tenant.name, schema: TenantSchema },
|
||||
{ name: User.name, schema: UserSchema },
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model } from 'mongoose'
|
||||
import { AuthentikClient } from '../integrations/authentik.client.js'
|
||||
import { PricesService } from '../prices/prices.service.js'
|
||||
import { Flag, FlagDocument, type FlagState } from '../schemas/flag.schema.js'
|
||||
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
|
||||
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
||||
@@ -62,6 +63,7 @@ export class SeedService implements OnApplicationBootstrap {
|
||||
@InjectModel(Flag.name) private readonly flagModel: Model<FlagDocument>,
|
||||
private readonly authentik: AuthentikClient,
|
||||
private readonly config: ConfigService,
|
||||
private readonly prices: PricesService,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
@@ -105,6 +107,11 @@ export class SeedService implements OnApplicationBootstrap {
|
||||
|
||||
// No user seeded here — UsersController.me() upserts akadmin on first call.
|
||||
|
||||
// Pricing catalog defaults. Idempotent — operator edits via /pricing
|
||||
// (which marks old rows inactive + inserts new ones) are preserved.
|
||||
await this.prices.ensureDefaults()
|
||||
this.logger.log('Price catalog defaults ensured')
|
||||
|
||||
// Feature flags. Seeded via $setOnInsert so an operator who later edits a
|
||||
// flag's state through the UI doesn't get their change reverted on the
|
||||
// next bootstrap.
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Type } from 'class-transformer'
|
||||
import {
|
||||
IsArray,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator'
|
||||
@@ -30,6 +34,33 @@ export class CreateTenantDto {
|
||||
@IsOptional() @IsEnum(['mvp', 'pro', 'enterprise'])
|
||||
plan?: 'mvp' | 'pro' | 'enterprise'
|
||||
|
||||
// Billing cycle for the auto-created Subscription. Defaults to monthly
|
||||
// when omitted. Drives which Price catalog row attaches to the sub.
|
||||
@IsOptional() @IsEnum(['monthly', 'quarterly', 'yearly'])
|
||||
cycle?: 'monthly' | 'quarterly' | 'yearly'
|
||||
|
||||
// Currency this customer is billed in. Picked by the partner in the
|
||||
// wizard. The matching Price row must have an amount set for this
|
||||
// currency or the tenant is created without a Subscription (operator
|
||||
// fixes up later).
|
||||
@IsOptional() @IsEnum(['DKK', 'EUR', 'USD'])
|
||||
currency?: 'DKK' | 'EUR' | 'USD'
|
||||
|
||||
// First-admin invite. Optional — when present, the wizard's "First admin"
|
||||
// step has been filled in and the platform creates an Authentik user +
|
||||
// local User doc tied to this tenant as part of provisioning. Failures
|
||||
// here don't roll back the tenant — the operator can re-invite later.
|
||||
@IsOptional() @IsString() @MinLength(1) @MaxLength(200)
|
||||
adminName?: string
|
||||
|
||||
@IsOptional() @IsEmail() @MaxLength(254)
|
||||
adminEmail?: string
|
||||
|
||||
// Initial seat count. Upper bound is generous — partners can provision
|
||||
// bulk-license tenants and the platform doesn't impose a hard cap here.
|
||||
@IsOptional() @IsInt() @Min(0) @Max(10000)
|
||||
seats?: number
|
||||
|
||||
@IsOptional() @IsArray() @IsString({ each: true })
|
||||
domains?: string[]
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { IsArray, IsEnum, IsMongoId, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsMongoId,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator'
|
||||
|
||||
export class UpdateTenantDto {
|
||||
@IsOptional() @IsString() @MinLength(2) @MaxLength(120)
|
||||
@@ -10,6 +21,9 @@ export class UpdateTenantDto {
|
||||
@IsOptional() @IsEnum(['mvp', 'pro', 'enterprise'])
|
||||
plan?: 'mvp' | 'pro' | 'enterprise'
|
||||
|
||||
@IsOptional() @IsInt() @Min(0) @Max(10000)
|
||||
seats?: number
|
||||
|
||||
@IsOptional() @IsArray() @IsString({ each: true })
|
||||
domains?: string[]
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AuditModule } from '../audit/audit.module.js'
|
||||
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 { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||
import { ProvisioningService } from './provisioning.service.js'
|
||||
@@ -14,10 +16,15 @@ import { TenantsService } from './tenants.service.js'
|
||||
MongooseModule.forFeature([
|
||||
{ name: Tenant.name, schema: TenantSchema },
|
||||
{ name: User.name, schema: UserSchema },
|
||||
// Subscription model accessed directly by TenantsService so a freshly
|
||||
// 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 },
|
||||
]),
|
||||
AuthModule,
|
||||
AuditModule,
|
||||
IntegrationsModule,
|
||||
PricesModule,
|
||||
],
|
||||
controllers: [TenantsController],
|
||||
providers: [TenantsService, ProvisioningService],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||
import { PricesService } from '../prices/prices.service.js'
|
||||
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
|
||||
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
||||
import { User, UserDocument } from '../schemas/user.schema.js'
|
||||
import type { CreateTenantDto } from './dto/create-tenant.dto.js'
|
||||
@@ -10,11 +12,15 @@ import { ProvisioningService } from './provisioning.service.js'
|
||||
|
||||
@Injectable()
|
||||
export class TenantsService {
|
||||
private readonly logger = new Logger(TenantsService.name)
|
||||
|
||||
constructor(
|
||||
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
|
||||
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
||||
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
|
||||
private readonly provisioning: ProvisioningService,
|
||||
private readonly audit: AuditService,
|
||||
private readonly prices: PricesService,
|
||||
) {}
|
||||
|
||||
async listUsersForTenant(slug: string): Promise<UserDocument[]> {
|
||||
@@ -36,10 +42,40 @@ export class TenantsService {
|
||||
resourceId: String(tenant._id),
|
||||
resourceName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
metadata: { plan: tenant.plan, domains: tenant.domains },
|
||||
metadata: { plan: tenant.plan, domains: tenant.domains, seats: tenant.seats },
|
||||
},
|
||||
actor,
|
||||
)
|
||||
|
||||
// Spin up the matching Subscription. Looks up the active Price row for
|
||||
// (plan, cycle) — single row regardless of currency now — then reads
|
||||
// amounts[currency] for the snapshot. If the chosen currency isn't
|
||||
// priced, we still create the sub but with perSeatAmount=0 so MRR
|
||||
// reports it as "Custom" rather than a misleading non-zero value.
|
||||
try {
|
||||
const plan = tenant.plan ?? 'mvp'
|
||||
const cycle = dto.cycle ?? 'monthly'
|
||||
const currency = dto.currency ?? 'DKK'
|
||||
const price = await this.prices.findActive(plan, cycle)
|
||||
const perSeatAmount = price ? (this.prices.amountFor(price, currency) ?? 0) : 0
|
||||
await this.subModel.create({
|
||||
tenantId: tenant._id,
|
||||
plan,
|
||||
cycle,
|
||||
currency,
|
||||
priceId: price?._id,
|
||||
perSeatAmount,
|
||||
seats: tenant.seats ?? 0,
|
||||
status: 'active',
|
||||
})
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Subscription auto-create failed for tenant ${tenant.slug}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
} — tenant created, MRR will read as 0 until the sub is added.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Provision external resources best-effort. Errors are recorded on the doc;
|
||||
// the caller can re-POST or call /tenants/:slug/reconcile to retry.
|
||||
return this.provisioning.reconcile(tenant)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator'
|
||||
|
||||
// Operator-only: invite someone at a partner organization. Mirrors
|
||||
// InviteOperatorDto in shape — body is just identity. The partner is
|
||||
// resolved from the URL slug (POST /partners/:slug/users) so it never
|
||||
// appears in the body. The created user is added to the
|
||||
// dezky-partner-staff Authentik group and has User.partnerId set to the
|
||||
// partner's _id; future partner-portal scope guards use that to allow
|
||||
// access only to the partner's own data.
|
||||
export class InvitePartnerUserDto {
|
||||
@IsString() @MinLength(2) @MaxLength(120)
|
||||
name!: string
|
||||
|
||||
@IsEmail() @MaxLength(254)
|
||||
email!: string
|
||||
}
|
||||
@@ -45,9 +45,11 @@ export class UsersController {
|
||||
|
||||
// The signed-in user's own profile — bootstraps the user record on first call,
|
||||
// and syncs name/email/tenants/platformAdmin from the JWT on every subsequent call.
|
||||
// Adds a `partner` field when User.partnerId is set so the portal can decide
|
||||
// whether to render the partner-admin surface or the end-user surface.
|
||||
@Get('me')
|
||||
async me(@CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
return this.users.upsertFromAuthentik({
|
||||
return this.users.meWithPartner({
|
||||
subject: jwt.sub,
|
||||
email: jwt.email ?? jwt.preferred_username ?? jwt.sub,
|
||||
name: jwt.name ?? jwt.preferred_username ?? jwt.email ?? jwt.sub,
|
||||
@@ -56,6 +58,9 @@ export class UsersController {
|
||||
})
|
||||
}
|
||||
|
||||
// Partner-scoped endpoints live in PartnerMeController under /me/partner.
|
||||
// Identity endpoints (above) stay here.
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreateUserDto, @CurrentUser() jwt: AuthentikJwtPayload) {
|
||||
const actor = await this.actor.resolve(jwt)
|
||||
|
||||
@@ -3,6 +3,9 @@ import { MongooseModule } from '@nestjs/mongoose'
|
||||
import { AuditModule } from '../audit/audit.module.js'
|
||||
import { AuthModule } from '../auth/auth.module.js'
|
||||
import { IntegrationsModule } from '../integrations/integrations.module.js'
|
||||
import { Partner, PartnerSchema } from '../schemas/partner.schema.js'
|
||||
import { Price, PriceSchema } from '../schemas/price.schema.js'
|
||||
import { Subscription, SubscriptionSchema } from '../schemas/subscription.schema.js'
|
||||
import { Tenant, TenantSchema } from '../schemas/tenant.schema.js'
|
||||
import { User, UserSchema } from '../schemas/user.schema.js'
|
||||
import { TenantsModule } from '../tenants/tenants.module.js'
|
||||
@@ -14,6 +17,16 @@ import { UsersService } from './users.service.js'
|
||||
MongooseModule.forFeature([
|
||||
{ name: User.name, schema: UserSchema },
|
||||
{ name: Tenant.name, schema: TenantSchema },
|
||||
// Partner model registered directly (instead of importing PartnersModule)
|
||||
// to avoid the circular import — PartnersModule already imports
|
||||
// UsersModule for the invitePartnerUser delegation.
|
||||
{ name: Partner.name, schema: PartnerSchema },
|
||||
// Subscription + Price are read by partnerMrr() — MRR aggregation
|
||||
// runs entirely in this service rather than chaining into
|
||||
// SubscriptionsModule, since we want one direct query path that's
|
||||
// easy to extend (prorating, multi-currency) later.
|
||||
{ name: Subscription.name, schema: SubscriptionSchema },
|
||||
{ name: Price.name, schema: PriceSchema },
|
||||
]),
|
||||
AuthModule,
|
||||
AuditModule,
|
||||
|
||||
@@ -2,14 +2,26 @@ import { ConflictException, Injectable, Logger, NotFoundException } from '@nestj
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { InjectModel } from '@nestjs/mongoose'
|
||||
import { Model, Types } from 'mongoose'
|
||||
import type { AuditEventDocument } from '../schemas/audit-event.schema.js'
|
||||
import { AuditService, type AuditActor } from '../audit/audit.service.js'
|
||||
import { AuthentikClient } from '../integrations/authentik.client.js'
|
||||
import { Partner, PartnerDocument } from '../schemas/partner.schema.js'
|
||||
import { Price, PriceDocument } from '../schemas/price.schema.js'
|
||||
import { Subscription, SubscriptionDocument } from '../schemas/subscription.schema.js'
|
||||
import { Tenant, TenantDocument } from '../schemas/tenant.schema.js'
|
||||
import { User, UserDocument } from '../schemas/user.schema.js'
|
||||
import type { CreateUserDto } from './dto/create-user.dto.js'
|
||||
import type { InviteOperatorDto } from './dto/invite-operator.dto.js'
|
||||
import type { InvitePartnerUserDto } from './dto/invite-partner-user.dto.js'
|
||||
import type { UpdateUserDto } from './dto/update-user.dto.js'
|
||||
|
||||
// Authentik group every partner-staff invite gets added to. We use ONE
|
||||
// group across all partners (instead of `dezky-partner-{slug}` per partner)
|
||||
// because partner scoping is enforced server-side via User.partnerId — the
|
||||
// group claim just marks "this user is partner staff, route them to the
|
||||
// partner portal." Simpler than reconciling groups on partner rename.
|
||||
const PARTNER_STAFF_GROUP = 'dezky-partner-staff'
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
private readonly logger = new Logger(UsersService.name)
|
||||
@@ -18,10 +30,16 @@ export class UsersService {
|
||||
// created once during Authentik bootstrap and never moves; no need to look
|
||||
// it up every invite.
|
||||
private platformAdminGroupId: string | null = null
|
||||
// Same caching for the partner-staff group. Created lazily on first
|
||||
// invitePartnerUser call via ensureGroup (idempotent).
|
||||
private partnerStaffGroupId: string | null = null
|
||||
|
||||
constructor(
|
||||
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
|
||||
@InjectModel(Tenant.name) private readonly tenantModel: Model<TenantDocument>,
|
||||
@InjectModel(Partner.name) private readonly partnerModel: Model<PartnerDocument>,
|
||||
@InjectModel(Subscription.name) private readonly subModel: Model<SubscriptionDocument>,
|
||||
@InjectModel(Price.name) private readonly priceModel: Model<PriceDocument>,
|
||||
private readonly audit: AuditService,
|
||||
private readonly authentik: AuthentikClient,
|
||||
config: ConfigService,
|
||||
@@ -90,6 +108,41 @@ export class UsersService {
|
||||
// Called on every authenticated request from /users/me. The JWT's groups claim
|
||||
// is treated as a hint for first-time membership sync — the DB is the source of
|
||||
// truth for all subsequent authorization decisions.
|
||||
// What /users/me returns — the user doc plus the partner object when
|
||||
// User.partnerId is set. Frontends use the presence of `partner` to decide
|
||||
// whether to render the partner-admin surface vs. the end-user surface.
|
||||
async meWithPartner(payload: {
|
||||
subject: string
|
||||
email: string
|
||||
name: string
|
||||
tenantSlugs: string[]
|
||||
platformAdmin: boolean
|
||||
}): Promise<UserDocument & { partner?: { _id: string; slug: string; name: string; status: string } }> {
|
||||
const user = await this.upsertFromAuthentik(payload)
|
||||
if (!user.partnerId) {
|
||||
return user as UserDocument & { partner?: never }
|
||||
}
|
||||
const partner = await this.partnerModel
|
||||
.findById(user.partnerId, { slug: 1, name: 1, status: 1 })
|
||||
.exec()
|
||||
if (!partner) {
|
||||
// Partner was deleted out from under the user. Don't fail the whole
|
||||
// /me call — just omit the partner field; the frontend will treat
|
||||
// them as a regular end-user.
|
||||
return user as UserDocument & { partner?: never }
|
||||
}
|
||||
const userObj = user.toObject() as UserDocument & {
|
||||
partner?: { _id: string; slug: string; name: string; status: string }
|
||||
}
|
||||
userObj.partner = {
|
||||
_id: String(partner._id),
|
||||
slug: partner.slug,
|
||||
name: partner.name,
|
||||
status: partner.status,
|
||||
}
|
||||
return userObj
|
||||
}
|
||||
|
||||
async upsertFromAuthentik(payload: {
|
||||
subject: string
|
||||
email: string
|
||||
@@ -217,6 +270,391 @@ export class UsersService {
|
||||
return { subject: created.uid, userId: String(created.pk), link, tempPassword }
|
||||
}
|
||||
|
||||
// Invite a user that works at a partner organization. Same shape as
|
||||
// inviteOperator but adds the dezky-partner-staff group, sets
|
||||
// User.partnerId, and records a partner-scoped audit event. The caller
|
||||
// (PartnersController) already resolved the slug → partner, so we receive
|
||||
// the partnerId directly.
|
||||
async invitePartnerUser(
|
||||
dto: InvitePartnerUserDto,
|
||||
partner: { _id: Types.ObjectId; slug: string },
|
||||
actor?: AuditActor,
|
||||
): Promise<{
|
||||
subject: string
|
||||
userId: string
|
||||
// True if we attached an existing Authentik user instead of creating one.
|
||||
// When attached, link/tempPassword are omitted (the user already has a
|
||||
// password) and the UI shows a simpler success view.
|
||||
attached?: boolean
|
||||
link?: string
|
||||
tempPassword?: string
|
||||
}> {
|
||||
const groupPk = await this.resolvePartnerStaffGroupId()
|
||||
|
||||
// ── Attach path: user already exists in Authentik ────────────────────
|
||||
// Common when:
|
||||
// - Operator promotes an existing platform admin to also work for a
|
||||
// partner (e.g. internal staff cross-referenced as a partner contact).
|
||||
// - User was created via a different invite path (operator team) and
|
||||
// should now also be visible under a partner.
|
||||
// Refuse only if their local User doc already points at a DIFFERENT
|
||||
// partner — silently moving them would erase the prior relationship.
|
||||
const existing = await this.authentik.findUserByEmail(dto.email)
|
||||
if (existing) {
|
||||
const localUser = await this.userModel.findOne({ authentikSubjectId: existing.uid }).exec()
|
||||
if (
|
||||
localUser?.partnerId &&
|
||||
String(localUser.partnerId) !== String(partner._id)
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`User ${dto.email} already belongs to partner ${String(localUser.partnerId)} — detach them from that partner first.`,
|
||||
)
|
||||
}
|
||||
|
||||
await this.authentik.addUserToGroup(existing.pk, groupPk)
|
||||
|
||||
// Upsert local User. Existing docs get partnerId set; missing docs
|
||||
// (Authentik-only users like akadmin pre-/users/me) are created so the
|
||||
// partner team list shows them immediately instead of waiting for first
|
||||
// login to materialize the doc.
|
||||
await this.userModel
|
||||
.findOneAndUpdate(
|
||||
{ authentikSubjectId: existing.uid },
|
||||
{
|
||||
$set: {
|
||||
email: existing.email,
|
||||
// Don't clobber the local name if we have one (e.g. they
|
||||
// already logged in and set it from the JWT); only seed on insert.
|
||||
partnerId: partner._id,
|
||||
},
|
||||
$setOnInsert: {
|
||||
name: existing.name || dto.name,
|
||||
role: 'member',
|
||||
active: true,
|
||||
tenantIds: [],
|
||||
platformAdmin: false,
|
||||
},
|
||||
},
|
||||
{ upsert: true, new: true, runValidators: true },
|
||||
)
|
||||
.exec()
|
||||
|
||||
void this.audit.record(
|
||||
{
|
||||
action: 'partner.user_attached',
|
||||
resourceType: 'user',
|
||||
resourceId: existing.uid,
|
||||
resourceName: existing.email,
|
||||
partnerSlug: partner.slug,
|
||||
metadata: { name: existing.name || dto.name, role: 'partner-staff' },
|
||||
},
|
||||
actor,
|
||||
)
|
||||
|
||||
return { subject: existing.uid, userId: String(existing.pk), attached: true }
|
||||
}
|
||||
|
||||
// ── Create path: brand-new user in Authentik ─────────────────────────
|
||||
const created = await this.authentik.createUser({
|
||||
username: dto.email,
|
||||
email: dto.email,
|
||||
name: dto.name,
|
||||
groupPks: [groupPk],
|
||||
attributes: {
|
||||
partnerSlug: partner.slug,
|
||||
invitedBy: actor?.email,
|
||||
invitedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
// Pre-create the local User doc with partnerId so the partner team list
|
||||
// reflects the invite immediately. On first login, /users/me upserts
|
||||
// and reconciles email/name/lastLoginAt from the JWT (partnerId is
|
||||
// preserved — see upsertFromAuthentik).
|
||||
await this.userModel
|
||||
.findOneAndUpdate(
|
||||
{ authentikSubjectId: created.uid },
|
||||
{
|
||||
$set: {
|
||||
email: dto.email,
|
||||
name: dto.name,
|
||||
partnerId: partner._id,
|
||||
},
|
||||
$setOnInsert: { role: 'member', active: true, tenantIds: [], platformAdmin: false },
|
||||
},
|
||||
{ upsert: true, new: true, runValidators: true },
|
||||
)
|
||||
.exec()
|
||||
|
||||
let link: string | undefined
|
||||
let tempPassword: string | undefined
|
||||
link = await this.authentik.recoveryLink(created.pk)
|
||||
if (!link) {
|
||||
tempPassword = generateTempPassword()
|
||||
await this.authentik.setInitialPassword(created.pk, tempPassword)
|
||||
await this.authentik.markPasswordExpired(created.pk)
|
||||
}
|
||||
|
||||
void this.audit.record(
|
||||
{
|
||||
action: 'partner.user_invited',
|
||||
resourceType: 'user',
|
||||
resourceId: created.uid,
|
||||
resourceName: dto.email,
|
||||
partnerSlug: partner.slug,
|
||||
metadata: {
|
||||
role: 'partner-staff',
|
||||
name: dto.name,
|
||||
handoff: link ? 'recovery-link' : 'temp-password',
|
||||
},
|
||||
},
|
||||
actor,
|
||||
)
|
||||
|
||||
return { subject: created.uid, userId: String(created.pk), link, tempPassword }
|
||||
}
|
||||
|
||||
// List users belonging to a partner. Called by the operator partner-detail
|
||||
// page's Team section.
|
||||
async listPartnerUsers(partnerId: Types.ObjectId): Promise<UserDocument[]> {
|
||||
return this.userModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
|
||||
}
|
||||
|
||||
// List tenants attached to a partner. Used by the partner-portal's
|
||||
// /partner/customers page (via /users/me/partner/tenants) and could be
|
||||
// reused for operator surfaces that want partner-scoped tenant queries.
|
||||
// Each tenant carries a userCount (admins + members) so the seat "used"
|
||||
// column can render N/M without a second round-trip from the client.
|
||||
async listPartnerTenants(
|
||||
partnerId: Types.ObjectId,
|
||||
): Promise<Array<TenantDocument & { userCount: number; newUserCount30d: number }>> {
|
||||
const tenants = await this.tenantModel.find({ partnerId }).sort({ createdAt: -1 }).exec()
|
||||
if (tenants.length === 0) return []
|
||||
const since30d = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
// Single aggregation across all of the partner's tenants. We compute
|
||||
// both the total active user count and the subset created in the last
|
||||
// 30 days using $cond — one pass over the collection regardless of
|
||||
// how many tenants the partner has.
|
||||
const counts = await this.userModel.aggregate<{
|
||||
_id: Types.ObjectId
|
||||
n: number
|
||||
new30d: number
|
||||
}>([
|
||||
{ $match: { tenantIds: { $in: tenants.map((t) => t._id) }, active: true } },
|
||||
{ $unwind: '$tenantIds' },
|
||||
{ $match: { tenantIds: { $in: tenants.map((t) => t._id) } } },
|
||||
{
|
||||
$group: {
|
||||
_id: '$tenantIds',
|
||||
n: { $sum: 1 },
|
||||
new30d: { $sum: { $cond: [{ $gte: ['$createdAt', since30d] }, 1, 0] } },
|
||||
},
|
||||
},
|
||||
])
|
||||
const countMap = new Map(counts.map((c) => [String(c._id), c]))
|
||||
return tenants.map((t) => {
|
||||
const obj = t.toObject() as TenantDocument & {
|
||||
userCount: number
|
||||
newUserCount30d: number
|
||||
}
|
||||
const c = countMap.get(String(t._id))
|
||||
obj.userCount = c?.n ?? 0
|
||||
obj.newUserCount30d = c?.new30d ?? 0
|
||||
return obj
|
||||
})
|
||||
}
|
||||
|
||||
// Create (or attach) the first admin user for a freshly-provisioned
|
||||
// tenant. Same shape as invitePartnerUser but adds the user to the
|
||||
// tenant's Authentik group (created during provisioning) instead of
|
||||
// dezky-partner-staff, sets User.tenantIds to include this tenant, and
|
||||
// audits with tenantSlug.
|
||||
//
|
||||
// Failures are surfaced to the caller (TenantsController) rather than
|
||||
// swallowed — the wizard wants to show "admin invite failed: ..." in the
|
||||
// done state so the operator can retry rather than silently shipping a
|
||||
// tenant with no admin.
|
||||
async inviteTenantAdmin(
|
||||
tenant: { _id: Types.ObjectId; slug: string; authentikGroupId?: string },
|
||||
dto: { name: string; email: string },
|
||||
actor?: AuditActor,
|
||||
): Promise<{
|
||||
subject: string
|
||||
userId: string
|
||||
attached?: boolean
|
||||
link?: string
|
||||
tempPassword?: string
|
||||
}> {
|
||||
if (!tenant.authentikGroupId) {
|
||||
throw new Error(
|
||||
`Tenant ${tenant.slug} has no authentikGroupId — provisioning didn't complete. Retry /tenants/${tenant.slug}/reconcile and re-invite.`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Attach existing Authentik user ─────────────────────────────────
|
||||
const existing = await this.authentik.findUserByEmail(dto.email)
|
||||
if (existing) {
|
||||
await this.authentik.addUserToGroup(existing.pk, tenant.authentikGroupId)
|
||||
await this.userModel
|
||||
.findOneAndUpdate(
|
||||
{ authentikSubjectId: existing.uid },
|
||||
{
|
||||
$set: { email: existing.email },
|
||||
$setOnInsert: {
|
||||
name: existing.name || dto.name,
|
||||
role: 'admin',
|
||||
active: true,
|
||||
platformAdmin: false,
|
||||
},
|
||||
$addToSet: { tenantIds: tenant._id },
|
||||
},
|
||||
{ upsert: true, new: true, runValidators: true },
|
||||
)
|
||||
.exec()
|
||||
void this.audit.record(
|
||||
{
|
||||
action: 'tenant.admin_attached',
|
||||
resourceType: 'user',
|
||||
resourceId: existing.uid,
|
||||
resourceName: existing.email,
|
||||
tenantSlug: tenant.slug,
|
||||
metadata: { name: existing.name || dto.name, role: 'admin' },
|
||||
},
|
||||
actor,
|
||||
)
|
||||
return { subject: existing.uid, userId: String(existing.pk), attached: true }
|
||||
}
|
||||
|
||||
// ── Create new Authentik user ──────────────────────────────────────
|
||||
const created = await this.authentik.createUser({
|
||||
username: dto.email,
|
||||
email: dto.email,
|
||||
name: dto.name,
|
||||
groupPks: [tenant.authentikGroupId],
|
||||
attributes: {
|
||||
tenantSlug: tenant.slug,
|
||||
invitedBy: actor?.email,
|
||||
invitedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
await this.userModel
|
||||
.findOneAndUpdate(
|
||||
{ authentikSubjectId: created.uid },
|
||||
{
|
||||
$set: { email: dto.email, name: dto.name },
|
||||
$setOnInsert: { role: 'admin', active: true, platformAdmin: false },
|
||||
$addToSet: { tenantIds: tenant._id },
|
||||
},
|
||||
{ upsert: true, new: true, runValidators: true },
|
||||
)
|
||||
.exec()
|
||||
|
||||
let link: string | undefined
|
||||
let tempPassword: string | undefined
|
||||
link = await this.authentik.recoveryLink(created.pk)
|
||||
if (!link) {
|
||||
tempPassword = generateTempPassword()
|
||||
await this.authentik.setInitialPassword(created.pk, tempPassword)
|
||||
await this.authentik.markPasswordExpired(created.pk)
|
||||
}
|
||||
|
||||
void this.audit.record(
|
||||
{
|
||||
action: 'tenant.admin_invited',
|
||||
resourceType: 'user',
|
||||
resourceId: created.uid,
|
||||
resourceName: dto.email,
|
||||
tenantSlug: tenant.slug,
|
||||
metadata: {
|
||||
name: dto.name,
|
||||
role: 'admin',
|
||||
handoff: link ? 'recovery-link' : 'temp-password',
|
||||
},
|
||||
},
|
||||
actor,
|
||||
)
|
||||
|
||||
return { subject: created.uid, userId: String(created.pk), link, tempPassword }
|
||||
}
|
||||
|
||||
// Recent audit events scoped to a partner — events whose partnerSlug
|
||||
// matches OR whose tenantSlug belongs to one of the partner's tenants.
|
||||
// Used by the partner dashboard's Activity card.
|
||||
async partnerActivity(
|
||||
partnerId: Types.ObjectId,
|
||||
partnerSlug: string,
|
||||
opts: { limit?: number; before?: Date } = {},
|
||||
): Promise<AuditEventDocument[]> {
|
||||
const tenants = await this.tenantModel.find({ partnerId }, { slug: 1 }).exec()
|
||||
return this.audit.listForPartner({
|
||||
partnerSlug,
|
||||
tenantSlugs: tenants.map((t) => t.slug),
|
||||
limit: opts.limit,
|
||||
before: opts.before,
|
||||
})
|
||||
}
|
||||
|
||||
// MRR aggregation for a partner, grouped by currency. Each subscription
|
||||
// contributes to the bucket for its currency — no FX conversion, since
|
||||
// the partner gets paid in whatever currency the customer was billed in.
|
||||
//
|
||||
// Per-seat amount is read from Subscription.perSeatAmount (the snapshot
|
||||
// taken at provision time) instead of the live Price doc, so historical
|
||||
// MRR is stable even if the operator edits the catalog later.
|
||||
async partnerMrr(partnerId: Types.ObjectId): Promise<{
|
||||
totals: Array<{ currency: 'DKK' | 'EUR' | 'USD'; monthlyMinor: number }>
|
||||
breakdown: Array<{
|
||||
tenantId: string
|
||||
tenantSlug: string
|
||||
tenantName: string
|
||||
plan: 'mvp' | 'pro' | 'enterprise'
|
||||
cycle: 'monthly' | 'quarterly' | 'yearly'
|
||||
currency: 'DKK' | 'EUR' | 'USD'
|
||||
seats: number
|
||||
monthlyMinor: number
|
||||
custom: boolean // true when the sub has no priced amount (Enterprise / pre-catalog)
|
||||
}>
|
||||
}> {
|
||||
const tenants = await this.tenantModel.find({ partnerId }).exec()
|
||||
if (tenants.length === 0) {
|
||||
return { totals: [], breakdown: [] }
|
||||
}
|
||||
const tenantIds = tenants.map((t) => t._id)
|
||||
const subs = await this.subModel.find({ tenantId: { $in: tenantIds }, status: 'active' }).exec()
|
||||
const tenantById = new Map(tenants.map((t) => [String(t._id), t]))
|
||||
|
||||
const breakdown = subs.map((s) => {
|
||||
const tenant = tenantById.get(String(s.tenantId))!
|
||||
const monthlyMinor = normalizeToMonthly(s.perSeatAmount * s.seats, s.cycle)
|
||||
return {
|
||||
tenantId: String(tenant._id),
|
||||
tenantSlug: tenant.slug,
|
||||
tenantName: tenant.name,
|
||||
plan: s.plan,
|
||||
cycle: s.cycle,
|
||||
currency: s.currency,
|
||||
seats: s.seats,
|
||||
monthlyMinor,
|
||||
custom: s.perSeatAmount === 0,
|
||||
}
|
||||
})
|
||||
|
||||
// Group by currency. Use a Map to preserve insertion-on-first-seen
|
||||
// ordering — but emit totals in a stable order regardless: DKK, EUR, USD.
|
||||
const byCurrency = new Map<string, number>()
|
||||
for (const row of breakdown) {
|
||||
byCurrency.set(row.currency, (byCurrency.get(row.currency) ?? 0) + row.monthlyMinor)
|
||||
}
|
||||
const ORDER: Array<'DKK' | 'EUR' | 'USD'> = ['DKK', 'EUR', 'USD']
|
||||
const totals = ORDER.filter((c) => byCurrency.has(c)).map((c) => ({
|
||||
currency: c,
|
||||
monthlyMinor: byCurrency.get(c)!,
|
||||
}))
|
||||
|
||||
return { totals, breakdown }
|
||||
}
|
||||
|
||||
// Resolve + cache the dezky-platform-admins group ID. The group is created
|
||||
// by Authentik bootstrap so it's reliably present; ensureGroup is
|
||||
// idempotent so the worst case is a no-op extra API call on cold start.
|
||||
@@ -228,6 +666,18 @@ export class UsersService {
|
||||
this.platformAdminGroupId = group.pk
|
||||
return group.pk
|
||||
}
|
||||
|
||||
// Same caching pattern for the partner-staff group. Created lazily the
|
||||
// first time a partner invite runs — by then Authentik is past bootstrap
|
||||
// and ensureGroup will either find or create it.
|
||||
private async resolvePartnerStaffGroupId(): Promise<string> {
|
||||
if (this.partnerStaffGroupId) return this.partnerStaffGroupId
|
||||
const group = await this.authentik.ensureGroup(PARTNER_STAFF_GROUP, {
|
||||
role: 'partner-staff',
|
||||
})
|
||||
this.partnerStaffGroupId = group.pk
|
||||
return group.pk
|
||||
}
|
||||
}
|
||||
|
||||
// Generates a 16-character random password with mixed character classes.
|
||||
@@ -257,3 +707,13 @@ function generateTempPassword(): string {
|
||||
}
|
||||
return out.join('')
|
||||
}
|
||||
|
||||
// Convert a per-cycle subscription total (in minor units) to its monthly
|
||||
// equivalent. Used by MRR aggregation. Integer math throughout — final
|
||||
// rounding happens once at the partner-total level so per-row drift can't
|
||||
// accumulate visibly.
|
||||
function normalizeToMonthly(perCycleMinor: number, cycle: 'monthly' | 'quarterly' | 'yearly'): number {
|
||||
if (cycle === 'monthly') return perCycleMinor
|
||||
if (cycle === 'quarterly') return Math.round(perCycleMinor / 3)
|
||||
return Math.round(perCycleMinor / 12)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user