feat(billing): rating engine, fechamento de periodo, dashboard platform (fase 22)

Fecha a orquestracao de Billing (agente.md secao 120-139) sobre o schema/
RatingEngine puro ja existentes: escritores do ledger UsageEvent
(CALL_SECONDS no CDR, ACTIVE_DAY via sweep diario), closeBillingPeriod/
reopenBillingPeriod (fechamento imutavel com audit trail), e os
controllers de price books/rate decks/plan versions/subscriptions/
periods/statements. Corrige 2 bugs reais de RLS achados no teste ponta a
ponta (reopen sem tenant context, subscriptions sem withTenantContext) e
adiciona teste unitario do RatingEngine (17 casos).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
This commit is contained in:
2026-08-29 00:28:35 -03:00
parent d4e2513764
commit b27cfaab02
35 changed files with 4705 additions and 34 deletions

View File

@@ -17,6 +17,8 @@ import { ReportsModule } from "./reports/reports.module";
import { RecordingsModule } from "./recordings/recordings.module";
import { AIModule } from "./ai/ai.module";
import { QualityModule } from "./quality/quality.module";
import { PlatformModule } from "./platform/platform.module";
import { BillingModule } from "./billing/billing.module";
@Module({
imports: [
@@ -38,6 +40,8 @@ import { QualityModule } from "./quality/quality.module";
RecordingsModule,
AIModule,
QualityModule,
PlatformModule,
BillingModule,
],
})
export class AppModule {}

View File

@@ -1,7 +1,9 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, UseGuards } from "@nestjs/common";
import { Body, Controller, Get, HttpCode, HttpStatus, NotFoundException, Post, Req, UseGuards } from "@nestjs/common";
import type { FastifyRequest } from "fastify";
import { getPrismaClient } from "@b2bcall/database";
import {
changePassword,
isPlatformUser,
listUserTenants,
login,
logout,
@@ -44,6 +46,18 @@ export class AuthController {
await logout(user.sessionId);
}
@UseGuards(JwtAuthGuard)
@Get("me")
async me(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const dbUser = await prisma.user.findUnique({
where: { id: user.sub },
select: { id: true, email: true, name: true },
});
if (!dbUser) throw new NotFoundException();
return { ...dbUser, isPlatformUser: await isPlatformUser(user.sub) };
}
@UseGuards(JwtAuthGuard)
@Get("tenants")
async tenants(@CurrentUser() user: AccessTokenClaims) {

View File

@@ -0,0 +1,74 @@
import { getPrismaClient, withTenantContext, type Prisma } from "@b2bcall/database";
import { createLogger } from "@b2bcall/shared";
const logger = createLogger("b2bcall-api");
type Meter = "EXTENSION_ACTIVE_DAY" | "AGENT_ACTIVE_DAY" | "TRUNK_ACTIVE_DAY";
async function recordDailyMeter(
tx: Prisma.TransactionClient,
tenantId: string,
meter: Meter,
sourceType: string,
rows: Array<{ id: string }>,
todayStart: Date,
todayEnd: Date,
): Promise<void> {
for (const row of rows) {
// Sem constraint unica no banco pra (tenantId, meter, sourceId, dia) —
// checagem explicita antes do insert. Corrida real possivel se a
// varredura rodar 2x em paralelo pro mesmo tenant (nao acontece hoje,
// um unico processo apps/api chama isso num setInterval sequencial),
// documentado como limitacao conhecida em docs/BILLING.md.
const exists = await tx.usageEvent.findFirst({
where: { tenantId, meter, sourceId: row.id, occurredAt: { gte: todayStart, lt: todayEnd } },
});
if (exists) continue;
await tx.usageEvent.create({
data: {
tenantId,
meter,
quantity: 1,
unit: "day",
sourceType,
sourceId: row.id,
occurredAt: todayStart,
},
});
}
}
/**
* "EXTENSION_ACTIVE_DAY"/"AGENT_ACTIVE_DAY"/"TRUNK_ACTIVE_DAY" (agente.md
* secao 131) — 1 `UsageEvent` por recurso ativo por dia, consumido pelo
* `BillingEngineService` no fechamento (`rateActiveDaysProrated`). Roda no
* boot + de hora em hora (mesmo padrão de `runRetentionSweep`) — idempotente
* dentro do mesmo dia (não duplica se já rodou hoje pra aquele recurso).
*/
export async function runActiveDaySweep(): Promise<void> {
const prisma = getPrismaClient();
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const todayEnd = new Date(todayStart.getTime() + 24 * 60 * 60 * 1000);
const tenants = await prisma.tenant.findMany({ where: { status: "ACTIVE" }, select: { id: true } });
for (const tenant of tenants) {
try {
await withTenantContext(prisma, tenant.id, async (tx) => {
const [extensions, agents, trunks] = await Promise.all([
tx.extension.findMany({ where: { tenantId: tenant.id, deletedAt: null }, select: { id: true } }),
tx.agent.findMany({ where: { tenantId: tenant.id, deletedAt: null }, select: { id: true } }),
tx.trunk.findMany({ where: { tenantId: tenant.id, deletedAt: null }, select: { id: true } }),
]);
await recordDailyMeter(tx, tenant.id, "EXTENSION_ACTIVE_DAY", "extension", extensions, todayStart, todayEnd);
await recordDailyMeter(tx, tenant.id, "AGENT_ACTIVE_DAY", "agent", agents, todayStart, todayEnd);
await recordDailyMeter(tx, tenant.id, "TRUNK_ACTIVE_DAY", "trunk", trunks, todayStart, todayEnd);
});
} catch (err) {
logger.error("falha na varredura de uso diario (billing)", { error: String(err), tenantId: tenant.id });
}
}
}

View File

@@ -0,0 +1,330 @@
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
import { getPrismaClient, withTenantContext, type Prisma, type PriceItemType } from "@b2bcall/database";
import {
resolvePriceBookItem,
rateCallFlatFallback,
rateGenericUsage,
rateActiveDaysProrated,
rateTranscriptionSeconds,
rateRecordingBytes,
type PriceBookItemLike,
} from "@b2bcall/billing";
import { recordAuditEvent } from "@b2bcall/auth";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
/** UsageMeter -> PriceItemType, pra métricas do tipo "N dias ativo" (agente.md
* secao 131) — preço mensal do item, prorateado por `rateActiveDaysProrated`. */
const ACTIVE_DAY_METER_TO_PRICE_TYPE: Record<string, PriceItemType> = {
EXTENSION_ACTIVE_DAY: "EXTENSION_MONTH",
AGENT_ACTIVE_DAY: "AGENT_MONTH",
TRUNK_ACTIVE_DAY: "TRUNK_MONTH",
};
/** AIUsageType -> PriceItemType (secao 124/131 — nomes não batem 1:1,
* "SECONDS"/"REQUEST"/plural de token na origem viram "MINUTE"/"CALL"/
* singular no catálogo de preço). */
const AI_USAGE_TYPE_TO_PRICE_TYPE: Record<string, PriceItemType> = {
AI_TRANSCRIPTION_SECONDS: "AI_TRANSCRIPTION_MINUTE",
AI_ANALYSIS_REQUEST: "AI_ANALYSIS_CALL",
AI_INPUT_TOKENS: "AI_INPUT_TOKEN",
AI_OUTPUT_TOKENS: "AI_OUTPUT_TOKEN",
};
/** PriceItemType -> BillingStatementCategory, pra agrupar `RatedUsageItem`s
* na linha do statement (agente.md secao 136). */
const PRICE_TYPE_TO_CATEGORY: Record<PriceItemType, string> = {
BASE_SUBSCRIPTION: "PLAN_BASE",
EXTENSION_MONTH: "EXTENSIONS",
AGENT_MONTH: "AGENTS",
TRUNK_MONTH: "TRUNKS",
CALL: "CALLS",
CALL_MINUTE: "MINUTES",
FIXED_MINUTE: "MINUTES",
MOBILE_MINUTE: "MINUTES",
INTERNATIONAL_MINUTE: "MINUTES",
AI_TRANSCRIPTION_MINUTE: "AI_TRANSCRIPTION",
AI_ANALYSIS_CALL: "AI_ANALYSIS",
AI_INPUT_TOKEN: "AI_TOKENS",
AI_OUTPUT_TOKEN: "AI_TOKENS",
RECORDING_GB_MONTH: "STORAGE",
};
const CATEGORY_LABEL: Record<string, string> = {
PLAN_BASE: "Assinatura do plano",
EXTENSIONS: "Ramais ativos",
AGENTS: "Agentes ativos",
TRUNKS: "Troncos ativos",
CALLS: "Chamadas",
MINUTES: "Minutos de chamada",
AI_TRANSCRIPTION: "Transcricao (IA)",
AI_ANALYSIS: "Analise de chamada (IA)",
AI_TOKENS: "Tokens (IA)",
STORAGE: "Armazenamento de gravacoes",
ADJUSTMENT: "Ajuste",
};
/**
* RatingEngine é matemática pura (packages/billing, sem I/O — ver
* comentário em rating-engine.ts); este service faz a orquestração real
* (agente.md secao 130-137): resolve catálogos vigentes, lê os 2 ledgers
* imutáveis (`UsageEvent`+`AIUsageRecord`), grava `RatedUsageItem` (1 por
* evento — nunca agrega antes de ratear, "immutable usage ledger" secao
* 233) e fecha em `BillingStatement`/`BillingStatementItem` (agregado por
* categoria, o que o tenant efetivamente vê).
*
* **Lacuna real, conhecida**: `Call.calledNumber` ainda não é populado
* pelo CDR (ver TODO.md PHASE 17) — não dá pra fazer o longest-prefix
* match do RateDeck (secao 129) por destino real. `CALL_SECONDS` sempre
* usa `rateCallFlatFallback` (PriceBookItem `CALL_MINUTE`) por enquanto;
* `RateDeck`/`longestPrefixMatch` ficam cadastráveis e testados
* isoladamente (packages/billing tem teste unitário), só não são
* exercitados ponta a ponta até essa lacuna fechar.
*/
export async function closeBillingPeriod(opts: {
tenantId: string;
periodStart: Date;
periodEnd: Date;
userId: string;
}): Promise<{ periodId: string; statementId: string; total: number }> {
const { tenantId, periodStart, periodEnd, userId } = opts;
if (periodStart >= periodEnd) {
throw new BadRequestException("periodStart deve ser anterior a periodEnd");
}
const daysInPeriod = (periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY;
const prisma = getPrismaClient();
const result = await withTenantContext(prisma, tenantId, async (tx) => {
const existing = await tx.billingPeriod.findUnique({
where: { tenantId_periodStart_periodEnd: { tenantId, periodStart, periodEnd } },
});
if (existing?.status === "CLOSED") {
throw new ConflictException("Periodo ja fechado (secao 137) — reabra explicitamente antes de recalcular");
}
if (existing?.status === "CALCULATING") {
throw new ConflictException("Fechamento ja em andamento para este periodo");
}
const period = existing
? await tx.billingPeriod.update({ where: { id: existing.id }, data: { status: "CALCULATING" } })
: await tx.billingPeriod.create({ data: { tenantId, periodStart, periodEnd, status: "CALCULATING" } });
const tenant = await tx.tenant.findUniqueOrThrow({ where: { id: tenantId } });
const priceBook = await tx.priceBook.findFirst({
where: tenant.priceBookId ? { id: tenant.priceBookId } : { isDefault: true },
include: { items: true },
});
if (!priceBook) {
throw new ConflictException("Nenhum PriceBook configurado (nem default) para tarifar este tenant");
}
const priceItems: PriceBookItemLike[] = priceBook.items;
const callMinuteItem = resolvePriceBookItem(priceItems, "CALL_MINUTE", periodEnd);
const [usageEvents, aiUsageRecords] = await Promise.all([
tx.usageEvent.findMany({
where: { tenantId, occurredAt: { gte: periodStart, lt: periodEnd }, ratedUsageItems: { none: {} } },
}),
tx.aIUsageRecord.findMany({
where: { tenantId, occurredAt: { gte: periodStart, lt: periodEnd }, ratedUsageItems: { none: {} } },
}),
]);
const ratedItemsData: Prisma.RatedUsageItemCreateManyInput[] = [];
for (const ev of usageEvents) {
if (ev.meter === "CALL_SECONDS") {
if (!callMinuteItem) continue; // sem preco/minuto configurado — nao cobra, nao inventa preco
const rated = rateCallFlatFallback(ev.quantity, callMinuteItem);
ratedItemsData.push({
tenantId,
usageEventId: ev.id,
callId: ev.callId,
priceBookItemId: callMinuteItem.id,
quantity: rated.ratedMinutes,
unitPrice: rated.destinationRate,
amount: rated.ratedAmount,
currency: priceBook.currency,
billingPeriodId: period.id,
});
continue;
}
const priceType = ACTIVE_DAY_METER_TO_PRICE_TYPE[ev.meter];
if (!priceType) continue; // RECORDING_BYTES e os meters de IA nao viram UsageEvent (ver cdr.ts / process-*.ts)
const item = resolvePriceBookItem(priceItems, priceType, periodEnd);
if (!item) continue;
const amount = rateActiveDaysProrated(ev.quantity, item.unitPrice, daysInPeriod);
ratedItemsData.push({
tenantId,
usageEventId: ev.id,
priceBookItemId: item.id,
quantity: ev.quantity,
unitPrice: daysInPeriod > 0 ? item.unitPrice / daysInPeriod : 0,
amount,
currency: priceBook.currency,
billingPeriodId: period.id,
});
}
for (const rec of aiUsageRecords) {
const priceType = AI_USAGE_TYPE_TO_PRICE_TYPE[rec.type];
const item = resolvePriceBookItem(priceItems, priceType, periodEnd);
if (!item) continue;
const amount =
rec.type === "AI_TRANSCRIPTION_SECONDS"
? rateTranscriptionSeconds(rec.quantity, item.unitPrice)
: rateGenericUsage(rec.quantity, item.unitPrice);
ratedItemsData.push({
tenantId,
aiUsageRecordId: rec.id,
priceBookItemId: item.id,
quantity: rec.quantity,
unitPrice: item.unitPrice,
amount,
currency: priceBook.currency,
billingPeriodId: period.id,
});
}
// RECORDING_BYTES (secao 131): sem ledger de eventos próprio (ver
// comentário em rating-engine.ts) — usa os bytes armazenados AGORA como
// proxy do consumo do período inteiro, decisão documentada em
// docs/BILLING.md.
const storageItem = resolvePriceBookItem(priceItems, "RECORDING_GB_MONTH", periodEnd);
if (storageItem) {
const recordingAgg = await tx.recording.aggregate({
where: { tenantId, status: "AVAILABLE" },
_sum: { sizeBytes: true },
});
const bytes = Number(recordingAgg._sum.sizeBytes ?? 0n);
if (bytes > 0) {
const amount = rateRecordingBytes(bytes, storageItem.unitPrice);
ratedItemsData.push({
tenantId,
priceBookItemId: storageItem.id,
quantity: bytes / 1_000_000_000,
unitPrice: storageItem.unitPrice,
amount,
currency: priceBook.currency,
billingPeriodId: period.id,
});
}
}
if (ratedItemsData.length > 0) {
await tx.ratedUsageItem.createMany({ data: ratedItemsData });
}
const ratedItems = await tx.ratedUsageItem.findMany({
where: { billingPeriodId: period.id },
include: { priceBookItem: true },
});
const categoryTotals = new Map<string, number>();
for (const item of ratedItems) {
const category = item.priceBookItem ? PRICE_TYPE_TO_CATEGORY[item.priceBookItem.type] : "ADJUSTMENT";
categoryTotals.set(category, (categoryTotals.get(category) ?? 0) + item.amount);
}
const subscription = await tx.tenantSubscription.findFirst({
where: { tenantId, status: { in: ["ACTIVE", "TRIALING"] } },
include: { planVersion: true },
orderBy: { startedAt: "desc" },
});
if (subscription && subscription.planVersion.basePrice > 0) {
categoryTotals.set("PLAN_BASE", (categoryTotals.get("PLAN_BASE") ?? 0) + subscription.planVersion.basePrice);
}
const subtotal = [...categoryTotals.values()].reduce((sum, v) => sum + v, 0);
const currency = subscription?.currency ?? priceBook.currency;
const statement = await tx.billingStatement.create({
data: {
tenantId,
billingPeriodId: period.id,
currency,
subtotal,
adjustments: 0,
total: subtotal,
items: {
create: [...categoryTotals.entries()].map(([category, amount]) => ({
tenantId,
category: category as never,
description: CATEGORY_LABEL[category] ?? category,
amount,
})),
},
},
});
await tx.billingPeriod.update({
where: { id: period.id },
data: { status: "CLOSED", closedAt: new Date() },
});
return { periodId: period.id, statementId: statement.id, total: statement.total, ratedCount: ratedItems.length };
});
await recordAuditEvent(prisma, {
action: "BILLING_PERIOD_CLOSE",
tenantId,
userId,
entityType: "billing_period",
entityId: result.periodId,
after: { total: result.total, ratedItemCount: result.ratedCount },
});
return result;
}
/**
* "Reabrir" um período fechado pra corrigir e recalcular (secao 137): nunca
* volta direto pra OPEN — vira REOPENED, com audit trail (user+motivo),
* deixando visível no histórico que esse período já foi fechado antes.
* `closeBillingPeriod` aceita rodar de novo em cima de um período
* REOPENED (só bloqueia CLOSED/CALCULATING).
*
* **Bug real, achado no teste desta fase**: a primeira versão lia o
* período com `prisma.billingPeriod.findUniqueOrThrow({ where: { id } })`
* SEM tenant context pra descobrir o `tenantId` — mas `billing_periods` tem
* FORCE ROW LEVEL SECURITY (agente.md secao 30), então a leitura sem
* `app.current_tenant_id` não vê a linha, e o "not found" virava 500 (P2025
* não mapeado, nunca um 404 de verdade). `tenantId` precisa vir explícito
* no request (mesma exceção já aplicada em `closeBillingPeriod`), nunca
* descoberto lendo a própria tabela protegida por RLS.
*/
export async function reopenBillingPeriod(opts: {
periodId: string;
tenantId: string;
userId: string;
reason: string;
}): Promise<void> {
const prisma = getPrismaClient();
const period = await withTenantContext(prisma, opts.tenantId, (tx) =>
tx.billingPeriod.findUnique({ where: { id: opts.periodId } }),
);
if (!period || period.tenantId !== opts.tenantId) {
throw new NotFoundException("Periodo de billing nao encontrado para este tenant");
}
if (period.status !== "CLOSED") {
throw new ConflictException("So' e' possivel reabrir um periodo CLOSED");
}
await withTenantContext(prisma, opts.tenantId, (tx) =>
tx.billingPeriod.update({
where: { id: period.id },
data: { status: "REOPENED", reopenedAt: new Date() },
}),
);
await recordAuditEvent(prisma, {
action: "BILLING_PERIOD_REOPEN",
tenantId: period.tenantId,
userId: opts.userId,
entityType: "billing_period",
entityId: period.id,
after: { reason: opts.reason },
});
}

View File

@@ -0,0 +1,57 @@
import { Body, Controller, ForbiddenException, Get, Param, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { ClosePeriodDto } from "./dto/close-period.dto";
import { ReopenPeriodDto } from "./dto/reopen-period.dto";
import { closeBillingPeriod, reopenBillingPeriod } from "./billing-engine.service";
/**
* "Fechamentos" (PRODUCT.md, menu Platform > Billing). Fechar/reabrir um
* período é uma ação de platform admin sobre um tenant arbitrário — por
* isso `tenantId` vem no body em vez de vir só do JWT (mesma exceção já
* aplicada a GLOBAL em AIProvider/AIPromptTemplate: `isPlatformUser`
* checado explicitamente na camada de serviço, nunca confiado só na
* permission). Ver histórico do fechamento em `GET /billing/periods` (o
* próprio tenant, escopo do seu JWT).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/periods")
export class BillingPeriodsController {
@RequirePermission("billing.manage")
@Post("close")
async close(@CurrentUser() user: AccessTokenClaims, @Body() dto: ClosePeriodDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode fechar um periodo de billing");
}
return closeBillingPeriod({
tenantId: dto.tenantId,
periodStart: new Date(dto.periodStart),
periodEnd: new Date(dto.periodEnd),
userId: user.sub,
});
}
@RequirePermission("billing.manage")
@Post(":id/reopen")
async reopen(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Body() dto: ReopenPeriodDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode reabrir um periodo de billing");
}
await reopenBillingPeriod({ periodId: id, tenantId: dto.tenantId, userId: user.sub, reason: dto.reason });
return { reopened: true };
}
@RequirePermission("billing.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const tenantId = user.tenantId!;
const prisma = getPrismaClient();
return withTenantContext(prisma, tenantId, (tx) =>
tx.billingPeriod.findMany({ where: { tenantId }, orderBy: { periodStart: "desc" } }),
);
}
}

View File

@@ -0,0 +1,46 @@
import { Controller, Get, NotFoundException, Param, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import type { AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* "Billing Statement" / "Relatorio de Consumo" (agente.md secao 138-139) —
* nunca chamado de "invoice"/"nota fiscal" na UI (PRODUCT.md, Operating
* Context). Sempre escopado ao próprio tenant do JWT (secao 31) — nunca um
* id de statement de outro tenant, RLS + WHERE tenantId garantem os dois.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/statements")
export class BillingStatementsController {
@RequirePermission("billing.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const tenantId = user.tenantId!;
const prisma = getPrismaClient();
return withTenantContext(prisma, tenantId, (tx) =>
tx.billingStatement.findMany({
where: { tenantId },
include: { billingPeriod: true },
orderBy: { generatedAt: "desc" },
}),
);
}
@RequirePermission("billing.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const tenantId = user.tenantId!;
const prisma = getPrismaClient();
const statement = await withTenantContext(prisma, tenantId, (tx) =>
tx.billingStatement.findFirst({
where: { id, tenantId },
include: { billingPeriod: true, items: true },
}),
);
if (!statement) throw new NotFoundException();
return statement;
}
}

View File

@@ -0,0 +1,19 @@
import { Module } from "@nestjs/common";
import { PriceBooksController } from "./price-books.controller";
import { RateDecksController } from "./rate-decks.controller";
import { PlanVersionsController } from "./plan-versions.controller";
import { SubscriptionsController } from "./subscriptions.controller";
import { BillingPeriodsController } from "./billing-periods.controller";
import { BillingStatementsController } from "./billing-statements.controller";
@Module({
controllers: [
PriceBooksController,
RateDecksController,
PlanVersionsController,
SubscriptionsController,
BillingPeriodsController,
BillingStatementsController,
],
})
export class BillingModule {}

View File

@@ -0,0 +1,12 @@
import { IsDateString, IsUUID } from "class-validator";
export class ClosePeriodDto {
@IsUUID()
tenantId!: string;
@IsDateString()
periodStart!: string;
@IsDateString()
periodEnd!: string;
}

View File

@@ -0,0 +1,22 @@
import { IsDateString, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from "class-validator";
export class CreatePlanVersionDto {
@IsUUID()
planId!: string;
@IsNumber()
@Min(0)
basePrice!: number;
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
@IsDateString()
effectiveFrom!: string;
@IsOptional()
@IsDateString()
effectiveUntil?: string;
}

View File

@@ -0,0 +1,55 @@
import { IsArray, IsBoolean, IsDateString, IsIn, IsNumber, IsOptional, IsString, MaxLength, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
const PRICE_ITEM_TYPES = [
"BASE_SUBSCRIPTION",
"EXTENSION_MONTH",
"AGENT_MONTH",
"TRUNK_MONTH",
"CALL",
"CALL_MINUTE",
"FIXED_MINUTE",
"MOBILE_MINUTE",
"INTERNATIONAL_MINUTE",
"AI_TRANSCRIPTION_MINUTE",
"AI_ANALYSIS_CALL",
"AI_INPUT_TOKEN",
"AI_OUTPUT_TOKEN",
"RECORDING_GB_MONTH",
] as const;
export class CreatePriceBookItemDto {
@IsIn(PRICE_ITEM_TYPES)
type!: (typeof PRICE_ITEM_TYPES)[number];
@IsNumber()
unitPrice!: number;
@IsDateString()
effectiveFrom!: string;
@IsOptional()
@IsDateString()
effectiveUntil?: string;
}
export class CreatePriceBookDto {
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreatePriceBookItemDto)
items?: CreatePriceBookItemDto[];
}

View File

@@ -0,0 +1,60 @@
import { IsArray, IsBoolean, IsDateString, IsIn, IsInt, IsNumber, IsOptional, IsString, Max, MaxLength, Min, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
const DESTINATION_TYPES = ["FIXED", "MOBILE", "INTERNATIONAL"] as const;
export class CreateRateDeckEntryDto {
@IsString()
@MaxLength(20)
prefix!: string;
@IsString()
@MaxLength(80)
destinationName!: string;
@IsIn(DESTINATION_TYPES)
destinationType!: (typeof DESTINATION_TYPES)[number];
@IsNumber()
pricePerMinute!: number;
@IsOptional()
@IsInt()
@Min(1)
@Max(3600)
billingIncrementSeconds?: number;
@IsOptional()
@IsInt()
@Min(0)
@Max(3600)
minimumSeconds?: number;
@IsOptional()
@IsNumber()
@Min(0)
connectionFee?: number;
@IsDateString()
validFrom!: string;
@IsOptional()
@IsDateString()
validUntil?: string;
}
export class CreateRateDeckDto {
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CreateRateDeckEntryDto)
entries?: CreateRateDeckEntryDto[];
}

View File

@@ -0,0 +1,23 @@
import { IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from "class-validator";
export class CreateSubscriptionDto {
@IsUUID()
tenantId!: string;
@IsUUID()
planVersionId!: string;
@IsOptional()
@IsDateString()
startedAt?: string;
@IsInt()
@Min(1)
@Max(28)
billingCycleAnchor!: number;
@IsOptional()
@IsString()
@MaxLength(3)
currency?: string;
}

View File

@@ -0,0 +1,11 @@
import { IsString, IsUUID, MaxLength, MinLength } from "class-validator";
export class ReopenPeriodDto {
@IsUUID()
tenantId!: string;
@IsString()
@MinLength(3)
@MaxLength(500)
reason!: string;
}

View File

@@ -0,0 +1,66 @@
import { Body, Controller, ForbiddenException, Get, Param, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreatePlanVersionDto } from "./dto/create-plan-version.dto";
/**
* "plan_versions" (agente.md secao 126: "Precos e limites devem ser
* versionados"). Versiona só o preço base — ver comentário no schema.
* `version` é sempre a proxima sequencial do plano (nunca escolhida pelo
* client, agente.md secao 233: nunca confiar em input do client pra
* invariante do sistema).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/plan-versions")
export class PlanVersionsController {
@RequirePermission("pricing.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreatePlanVersionDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode versionar planos");
}
const prisma = getPrismaClient();
const plan = await prisma.plan.findUnique({ where: { id: dto.planId } });
if (!plan) throw new ForbiddenException("Plano nao encontrado");
const lastVersion = await prisma.planVersion.findFirst({
where: { planId: dto.planId },
orderBy: { version: "desc" },
});
const nextVersion = (lastVersion?.version ?? 0) + 1;
const planVersion = await prisma.planVersion.create({
data: {
planId: dto.planId,
version: nextVersion,
basePrice: dto.basePrice,
currency: dto.currency ?? "BRL",
effectiveFrom: new Date(dto.effectiveFrom),
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : null,
},
});
await recordAuditEvent(prisma, {
action: "PLAN_VERSION_CREATE",
tenantId: null,
userId: user.sub,
entityType: "plan_version",
entityId: planVersion.id,
after: { planId: plan.id, version: planVersion.version, basePrice: planVersion.basePrice },
});
return planVersion;
}
@RequirePermission("pricing.manage")
@Get("by-plan/:planId")
async listByPlan(@Param("planId") planId: string) {
const prisma = getPrismaClient();
return prisma.planVersion.findMany({ where: { planId }, orderBy: { version: "desc" } });
}
}

View File

@@ -0,0 +1,78 @@
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreatePriceBookDto } from "./dto/create-price-book.dto";
async function assertPlatformUser(userId: string): Promise<void> {
if (!(await isPlatformUser(userId))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode gerenciar price books");
}
}
/**
* "price_books"/"price_book_items" (agente.md secao 128) — catálogo
* global da plataforma, sem tenant_id (mesmo padrão de `Plan`), gerenciado
* só por platform admin. Um `Tenant` escolhe qual usar via
* `Tenant.priceBookId` (null = o que tiver `isDefault=true`); a atribuição
* em si é uma ação de `tenants.manage`, fora do escopo deste controller.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/price-books")
export class PriceBooksController {
@RequirePermission("pricing.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreatePriceBookDto) {
await assertPlatformUser(user.sub);
const prisma = getPrismaClient();
const priceBook = await prisma.priceBook.create({
data: {
name: dto.name,
currency: dto.currency ?? "BRL",
isDefault: dto.isDefault ?? false,
items: dto.items
? {
create: dto.items.map((item) => ({
type: item.type,
unitPrice: item.unitPrice,
effectiveFrom: new Date(item.effectiveFrom),
effectiveUntil: item.effectiveUntil ? new Date(item.effectiveUntil) : null,
})),
}
: undefined,
},
include: { items: true },
});
await recordAuditEvent(prisma, {
action: "PRICE_BOOK_CREATE",
tenantId: null,
userId: user.sub,
entityType: "price_book",
entityId: priceBook.id,
after: { name: priceBook.name, isDefault: priceBook.isDefault },
});
return priceBook;
}
@RequirePermission("pricing.manage")
@Get()
async list() {
const prisma = getPrismaClient();
return prisma.priceBook.findMany({ include: { items: true }, orderBy: { name: "asc" } });
}
@RequirePermission("pricing.manage")
@Get(":id")
async get(@Param("id") id: string) {
const prisma = getPrismaClient();
const priceBook = await prisma.priceBook.findUnique({ where: { id }, include: { items: true } });
if (!priceBook) throw new NotFoundException();
return priceBook;
}
}

View File

@@ -0,0 +1,84 @@
import { Body, Controller, ForbiddenException, Get, NotFoundException, Param, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateRateDeckDto } from "./dto/create-rate-deck.dto";
async function assertPlatformUser(userId: string): Promise<void> {
if (!(await isPlatformUser(userId))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode gerenciar rate decks");
}
}
/**
* "rate_decks"/"rate_deck_entries" (agente.md secao 129) — catálogo global
* de tarifas por prefixo de destino, mesmo padrão de escopo de
* `PriceBooksController`. `RatingEngine.longestPrefixMatch`
* (packages/billing) consome `entries` pra tarifar `CALL_SECONDS` por
* destino — hoje o fallback plano é sempre usado (ver
* `BillingEngineService`, `Call.calledNumber` ainda não é populado pelo
* CDR), mas o cadastro fica disponível pra quando essa lacuna for fechada.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/rate-decks")
export class RateDecksController {
@RequirePermission("pricing.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateRateDeckDto) {
await assertPlatformUser(user.sub);
const prisma = getPrismaClient();
const rateDeck = await prisma.rateDeck.create({
data: {
name: dto.name,
isDefault: dto.isDefault ?? false,
entries: dto.entries
? {
create: dto.entries.map((entry) => ({
prefix: entry.prefix,
destinationName: entry.destinationName,
destinationType: entry.destinationType,
pricePerMinute: entry.pricePerMinute,
billingIncrementSeconds: entry.billingIncrementSeconds ?? 60,
minimumSeconds: entry.minimumSeconds ?? 0,
connectionFee: entry.connectionFee ?? 0,
validFrom: new Date(entry.validFrom),
validUntil: entry.validUntil ? new Date(entry.validUntil) : null,
})),
}
: undefined,
},
include: { entries: true },
});
await recordAuditEvent(prisma, {
action: "RATE_DECK_CREATE",
tenantId: null,
userId: user.sub,
entityType: "rate_deck",
entityId: rateDeck.id,
after: { name: rateDeck.name, isDefault: rateDeck.isDefault },
});
return rateDeck;
}
@RequirePermission("pricing.manage")
@Get()
async list() {
const prisma = getPrismaClient();
return prisma.rateDeck.findMany({ include: { entries: true }, orderBy: { name: "asc" } });
}
@RequirePermission("pricing.manage")
@Get(":id")
async get(@Param("id") id: string) {
const prisma = getPrismaClient();
const rateDeck = await prisma.rateDeck.findUnique({ where: { id }, include: { entries: true } });
if (!rateDeck) throw new NotFoundException();
return rateDeck;
}
}

View File

@@ -0,0 +1,75 @@
import { Body, Controller, ForbiddenException, Get, Param, Post, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { recordAuditEvent, isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { CreateSubscriptionDto } from "./dto/create-subscription.dto";
/**
* "tenant_subscriptions" (agente.md secao 127) — qual `PlanVersion` (preço
* vigente) um tenant assinou e em que dia do mês fecha o período de
* billing dele (`billingCycleAnchor`). Ação de platform admin (o tenant
* não escolhe o próprio preço); um tenant pode ter várias linhas ao longo
* do tempo (histórico de mudança de plano/preço), nunca UPDATE no preço de
* uma assinatura já ativa — sempre uma nova linha.
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("billing/subscriptions")
export class SubscriptionsController {
@RequirePermission("billing.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateSubscriptionDto) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode gerenciar assinaturas");
}
const prisma = getPrismaClient();
const planVersion = await prisma.planVersion.findUnique({ where: { id: dto.planVersionId } });
if (!planVersion) throw new ForbiddenException("PlanVersion nao encontrada");
// `tenant_subscriptions` tem RLS (tenant-scoped) mesmo essa sendo uma
// ação de platform admin sobre um tenant arbitrário — precisa do
// contexto igual a qualquer outra escrita tenant-scoped (secao 30).
const subscription = await withTenantContext(prisma, dto.tenantId, (tx) =>
tx.tenantSubscription.create({
data: {
tenantId: dto.tenantId,
planVersionId: dto.planVersionId,
status: "ACTIVE",
startedAt: dto.startedAt ? new Date(dto.startedAt) : new Date(),
billingCycleAnchor: dto.billingCycleAnchor,
currency: dto.currency ?? planVersion.currency,
},
}),
);
await recordAuditEvent(prisma, {
action: "TENANT_SUBSCRIPTION_CREATE",
tenantId: dto.tenantId,
userId: user.sub,
entityType: "tenant_subscription",
entityId: subscription.id,
after: { planVersionId: subscription.planVersionId, billingCycleAnchor: subscription.billingCycleAnchor },
});
return subscription;
}
@RequirePermission("billing.manage")
@Get("by-tenant/:tenantId")
async listByTenant(@CurrentUser() user: AccessTokenClaims, @Param("tenantId") tenantId: string) {
if (!(await isPlatformUser(user.sub)) && user.tenantId !== tenantId) {
throw new ForbiddenException("Sem acesso as assinaturas deste tenant");
}
const prisma = getPrismaClient();
return withTenantContext(prisma, tenantId, (tx) =>
tx.tenantSubscription.findMany({
where: { tenantId },
include: { planVersion: true },
orderBy: { startedAt: "desc" },
}),
);
}
}

View File

@@ -5,10 +5,21 @@ import { PERMISSION_KEY } from "../decorators/require-permission.decorator";
import type { AuthenticatedRequest } from "./jwt-auth.guard";
/**
* Roda depois do JwtAuthGuard. Exige que a rota tenha um tenant selecionado
* (agente.md secao 31: nunca confiar em tenant_id do frontend — aqui vem só
* do JWT, nunca do body/query) e que o usuário tenha a permission marcada
* via @RequirePermission() (secao 145/146).
* Roda depois do JwtAuthGuard. Exige que o usuário tenha a permission
* marcada via @RequirePermission() (secao 145/146), resolvida sempre a
* partir do JWT (secao 31: nunca confiar em tenant_id do frontend).
*
* Um usuário PLATFORM puro (sem NENHUMA TenantMembership — ex.: o platform
* super admin recém-criado) nunca tem um tenant pra selecionar em
* `/auth/select-tenant` (`/auth/tenants` retorna vazio pra ele), então
* `user.tenantId` legitimamente nunca vai existir no token dele. Bug real
* encontrado testando `PlatformOverviewController` (agente.md secao 163):
* antes desta correção, exigir `tenantId` incondicionalmente deixava
* QUALQUER endpoint com @RequirePermission inacessível pra esse usuário,
* mesmo os platform-only. Corrigido: sem tenantId, ainda tenta a permission
* em escopo PLATFORM (`userHasPermission` com tenantId undefined já filtra
* só roles com tenantId null); só barra de fato quem não tem a permission
* em nenhum escopo.
*/
@Injectable()
export class PermissionGuard implements CanActivate {
@@ -29,13 +40,14 @@ export class PermissionGuard implements CanActivate {
if (!user) {
throw new ForbiddenException("Nao autenticado");
}
if (!user.tenantId) {
throw new ForbiddenException("Nenhum tenant selecionado (use /auth/select-tenant)");
}
const allowed = await userHasPermission(user.sub, permissionKey, user.tenantId);
const allowed = await userHasPermission(user.sub, permissionKey, user.tenantId ?? undefined);
if (!allowed) {
throw new ForbiddenException(`Permissao necessaria: ${permissionKey}`);
throw new ForbiddenException(
user.tenantId
? `Permissao necessaria: ${permissionKey}`
: `Permissao necessaria: ${permissionKey} (nenhum tenant selecionado, checado so' em escopo PLATFORM — use /auth/select-tenant se a permissao for de tenant)`,
);
}
return true;

View File

@@ -9,8 +9,10 @@ import rateLimit from "@fastify/rate-limit";
import { AppModule } from "./app.module";
import { DomainExceptionFilter } from "./common/filters/domain-exception.filter";
import { runRetentionSweep } from "./recordings/retention-sweep";
import { runActiveDaySweep } from "./billing/active-day-sweep";
const RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
const ACTIVE_DAY_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@@ -63,6 +65,13 @@ async function bootstrap() {
setInterval(() => {
runRetentionSweep().catch((err) => console.error("falha na varredura de retencao", err));
}, RETENTION_SWEEP_INTERVAL_MS);
// Usage metering diario (agente.md secao 131: EXTENSION/AGENT/TRUNK
// ACTIVE_DAY) — mesmo padrao da varredura de retencao acima.
runActiveDaySweep().catch((err) => console.error("falha na varredura de uso diario (boot)", err));
setInterval(() => {
runActiveDaySweep().catch((err) => console.error("falha na varredura de uso diario", err));
}, ACTIVE_DAY_SWEEP_INTERVAL_MS);
}
bootstrap();

View File

@@ -0,0 +1,85 @@
import { Controller, ForbiddenException, Get, UseGuards } from "@nestjs/common";
import { getPrismaClient } from "@b2bcall/database";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
/**
* "Dashboard Platform" (agente.md secao 163) — só platform admin (mesmo
* padrão de isPlatformUser já usado pra escrita GLOBAL em AIProvider/
* AIPromptTemplate). Consulta direto (sem withTenantContext — precisa
* agregar TODOS os tenants, não faz sentido sob RLS de um tenant só).
*/
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("platform")
export class PlatformOverviewController {
@RequirePermission("tenants.view")
@Get("overview")
async overview(@CurrentUser() user: AccessTokenClaims) {
const isPlatform = await isPlatformUser(user.sub);
if (!isPlatform) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver o dashboard da plataforma");
}
const prisma = getPrismaClient();
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const monthStart = new Date(Date.UTC(new Date().getUTCFullYear(), new Date().getUTCMonth(), 1));
const [
tenantsActive,
tenantsTotal,
extensionsTotal,
agentsTotal,
callsCurrent,
callsToday,
cpsCapacity,
aiUsageThisMonth,
recordingBytesAgg,
] = await Promise.all([
prisma.tenant.count({ where: { status: "ACTIVE", deletedAt: null } }),
prisma.tenant.count({ where: { deletedAt: null } }),
prisma.extension.count({ where: { deletedAt: null } }),
prisma.agent.count({ where: { deletedAt: null } }),
prisma.call.count({ where: { endAt: null } }),
prisma.call.count({ where: { createdAt: { gte: todayStart } } }),
prisma.plan.aggregate({ _sum: { maxCps: true } }),
prisma.aIUsageRecord.groupBy({
by: ["type"],
where: { occurredAt: { gte: monthStart } },
_sum: { quantity: true },
}),
prisma.recording.aggregate({ _sum: { sizeBytes: true } }),
]);
return {
tenantsActive,
tenantsTotal,
extensionsTotal,
agentsTotal,
callsCurrent,
callsToday,
// Deployment desta lab tem 1 unico container FreeSWITCH — sem tabela
// de nodes ainda pra descobrir isso dinamicamente (nao existe
// clustering multi-node nesta fase).
freeswitchNodes: 1,
// Soma dos tetos de CPS configurados por plano em todos os tenants —
// capacidade OUTORGADA, nao consumo em tempo real (isso vive no
// token bucket do Redis do predictive-dialer, apps/api nao le de
// la ainda).
cpsCapacityConfigured: cpsCapacity._sum.maxCps ?? null,
aiUsageThisMonth: Object.fromEntries(
aiUsageThisMonth.map((row) => [row.type, row._sum.quantity ?? 0]),
),
recordingStorageBytes: Number(recordingBytesAgg._sum.sizeBytes ?? 0n),
// Precisa de BillingPeriod/BillingStatement fechados de verdade
// (fase Billing, em construcao) — nenhum periodo foi fechado ainda
// nesta lab, entao nao ha numero real pra mostrar. null e' honesto,
// nao 0.
monthlyConsumption: null as number | null,
estimatedRevenue: null as number | null,
};
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { PlatformOverviewController } from "./platform-overview.controller";
@Module({
controllers: [PlatformOverviewController],
})
export class PlatformModule {}