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

65
TODO.md
View File

@@ -589,8 +589,69 @@ docs/QUALITY_SCORECARDS.md
(mesma restrição de todo o módulo de IA); uma `QualityEvaluation` (mesma restrição de todo o módulo de IA); uma `QualityEvaluation`
completando de verdade (só via dead-letter, sem rede real) completando de verdade (só via dead-letter, sem rede real)
## PHASE 22+ver `agente.md` seções 120 em diante (Usage Metering ## PHASE 22 — Usage Metering / Billing (agente.md secao 120-139) — ver
completo, Billing, Frontend, Security, Tests) docs/BILLING.md
- [x] Migration `billing` (`PlanVersion`/`TenantSubscription`/`PriceBook`+
`PriceBookItem`/`RateDeck`+`RateDeckEntry`/`UsageEvent`/
`RatedUsageItem`/`BillingPeriod`/`BillingStatement`+
`BillingStatementItem`, RLS nas tenant-scoped) e `packages/billing`
(`RatingEngine` puro: longest-prefix match, rating por destino/
fallback plano, prorateio de dias ativos, tokens/transcrição de IA,
armazenamento) já existiam de uma sessão anterior interrompida —
só a orquestração (I/O) e os endpoints faltavam
- [x] `apps/api/src/billing/billing-engine.service.ts`:
`closeBillingPeriod`/`reopenBillingPeriod` — lê os 2 ledgers
imutáveis (`UsageEvent`+`AIUsageRecord`) ainda não tarifados
(`ratedUsageItems: { none: {} }`), grava 1 `RatedUsageItem` por
evento (nunca agrega antes de ratear), soma `PLAN_BASE` da
`TenantSubscription` ativa, agrega por categoria em
`BillingStatementItem`. Fechamento imutável (secao 137): `CLOSED`
de novo é 409, só reopen explícito (audit trail) permite recalcular
- [x] Escritores do ledger `UsageEvent`: `CALL_SECONDS` em
`apps/freeswitch-events/src/cdr.ts::finalizeCall` (mesma transação
do CDR); `EXTENSION_ACTIVE_DAY`/`AGENT_ACTIVE_DAY`/
`TRUNK_ACTIVE_DAY` em `apps/api/src/billing/active-day-sweep.ts`
(boot + hora em hora, idempotente por dia)
- [x] Controllers: `PriceBooksController`/`RateDecksController`/
`PlanVersionsController` (catálogo global, `pricing.manage` +
`isPlatformUser`), `SubscriptionsController`/
`BillingPeriodsController` (`billing.manage` + `isPlatformUser`,
`tenantId` explícito no body — ação de platform admin sobre um
tenant arbitrário), `BillingStatementsController` (`billing.view`,
sempre o próprio tenant do JWT)
- [x] **Bug real, achado no teste desta fase**: `reopenBillingPeriod` lia
o período sem tenant context — `billing_periods` tem FORCE RLS,
então a leitura nunca via a linha e "not found" virava 500 em vez
de 404. Corrigido exigindo `tenantId` explícito no reopen (igual ao
close) e lendo dentro de `withTenantContext`.
- [x] **Bug real, achado no teste desta fase**: `SubscriptionsController`
criava/lia `TenantSubscription` sem `withTenantContext` — RLS
rejeitava o create e o list sempre voltava vazio. Corrigido.
- [x] Teste unitário do `RatingEngine` (`packages/billing`, 17 casos,
`pnpm --filter @b2bcall/billing run test`) + testado ponta a ponta
contra a API real e Postgres real com RLS: período fechado com 8
`RatedUsageItem`s (chamada, 3 dias de ramal ativo, transcrição,
análise, tokens de entrada/saída) e total batendo exatamente com o
cálculo manual; fechar 2x = 409; reopen + reclose reusa os itens já
tarifados; reopen com tenant errado = 404 (RLS isolando de
verdade); tenant admin sem role de plataforma barrado (403) de
fechar mas lista as próprias statements normalmente;
`runActiveDaySweep` chamado 2x no mesmo dia sem duplicar
- [ ] **Lacuna real, conhecida**: `Call.calledNumber` ainda não é
populado pelo CDR (PHASE 17) — `CALL_SECONDS` sempre usa o fallback
plano (`CALL_MINUTE`), nunca o `RateDeck` por destino real
(`longestPrefixMatch`/`rateCallByDestination` só testados
isoladamente, não ponta a ponta)
- [ ] `RECORDING_BYTES` usa o storage atual no momento do fechamento como
proxy do período inteiro (sem histórico de tamanho por dia) —
decisão documentada em docs/BILLING.md, não uma média ponderada
- [ ] Reajuste de preço no meio de um período aberto (2 vigências
sobrepostas de `PriceBookItem`/`RateDeckEntry`) nunca exercitado
- [ ] Sem UI de platform admin pra criar tenant/price book/rate deck
ainda (fase Frontend) — testado só via API direto
## PHASE 23+ — ver `agente.md` seções 140 em diante (Frontend, Security,
Tests)
--- ---

View File

@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@b2bcall/ai": "workspace:*", "@b2bcall/ai": "workspace:*",
"@b2bcall/auth": "workspace:*", "@b2bcall/auth": "workspace:*",
"@b2bcall/billing": "workspace:*",
"@b2bcall/database": "workspace:*", "@b2bcall/database": "workspace:*",
"@b2bcall/entitlements": "workspace:*", "@b2bcall/entitlements": "workspace:*",
"@b2bcall/shared": "workspace:*", "@b2bcall/shared": "workspace:*",

View File

@@ -17,6 +17,8 @@ import { ReportsModule } from "./reports/reports.module";
import { RecordingsModule } from "./recordings/recordings.module"; import { RecordingsModule } from "./recordings/recordings.module";
import { AIModule } from "./ai/ai.module"; import { AIModule } from "./ai/ai.module";
import { QualityModule } from "./quality/quality.module"; import { QualityModule } from "./quality/quality.module";
import { PlatformModule } from "./platform/platform.module";
import { BillingModule } from "./billing/billing.module";
@Module({ @Module({
imports: [ imports: [
@@ -38,6 +40,8 @@ import { QualityModule } from "./quality/quality.module";
RecordingsModule, RecordingsModule,
AIModule, AIModule,
QualityModule, QualityModule,
PlatformModule,
BillingModule,
], ],
}) })
export class AppModule {} 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 type { FastifyRequest } from "fastify";
import { getPrismaClient } from "@b2bcall/database";
import { import {
changePassword, changePassword,
isPlatformUser,
listUserTenants, listUserTenants,
login, login,
logout, logout,
@@ -44,6 +46,18 @@ export class AuthController {
await logout(user.sessionId); 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) @UseGuards(JwtAuthGuard)
@Get("tenants") @Get("tenants")
async tenants(@CurrentUser() user: AccessTokenClaims) { 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"; import type { AuthenticatedRequest } from "./jwt-auth.guard";
/** /**
* Roda depois do JwtAuthGuard. Exige que a rota tenha um tenant selecionado * Roda depois do JwtAuthGuard. Exige que o usuário tenha a permission
* (agente.md secao 31: nunca confiar em tenant_id do frontend — aqui vem só * marcada via @RequirePermission() (secao 145/146), resolvida sempre a
* do JWT, nunca do body/query) e que o usuário tenha a permission marcada * partir do JWT (secao 31: nunca confiar em tenant_id do frontend).
* via @RequirePermission() (secao 145/146). *
* 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() @Injectable()
export class PermissionGuard implements CanActivate { export class PermissionGuard implements CanActivate {
@@ -29,13 +40,14 @@ export class PermissionGuard implements CanActivate {
if (!user) { if (!user) {
throw new ForbiddenException("Nao autenticado"); 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) { 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; return true;

View File

@@ -9,8 +9,10 @@ import rateLimit from "@fastify/rate-limit";
import { AppModule } from "./app.module"; import { AppModule } from "./app.module";
import { DomainExceptionFilter } from "./common/filters/domain-exception.filter"; import { DomainExceptionFilter } from "./common/filters/domain-exception.filter";
import { runRetentionSweep } from "./recordings/retention-sweep"; import { runRetentionSweep } from "./recordings/retention-sweep";
import { runActiveDaySweep } from "./billing/active-day-sweep";
const RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000; const RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
const ACTIVE_DAY_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
@@ -63,6 +65,13 @@ async function bootstrap() {
setInterval(() => { setInterval(() => {
runRetentionSweep().catch((err) => console.error("falha na varredura de retencao", err)); runRetentionSweep().catch((err) => console.error("falha na varredura de retencao", err));
}, RETENTION_SWEEP_INTERVAL_MS); }, 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(); 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 {}

View File

@@ -92,7 +92,7 @@ export async function persistCallEvent(normalized: NormalizedEvent): Promise<voi
}); });
if (normalized.type === "CALL_ENDED") { if (normalized.type === "CALL_ENDED") {
await finalizeCall(tx, callId); await finalizeCall(tx, callId, tenantId);
} }
}); });
} catch (err) { } catch (err) {
@@ -155,9 +155,10 @@ function buildPatch(normalized: NormalizedEvent): CallPatch {
/** Calcula os agregados em segundos (secao 155-156) uma vez que a chamada /** Calcula os agregados em segundos (secao 155-156) uma vez que a chamada
* terminou — nunca antes, pra não gravar valores parciais. */ * terminou — nunca antes, pra não gravar valores parciais. */
async function finalizeCall(tx: Prisma.TransactionClient, callId: string): Promise<void> { async function finalizeCall(tx: Prisma.TransactionClient, callId: string, tenantId: string): Promise<void> {
const call = await tx.call.findUniqueOrThrow({ where: { id: callId } }); const call = await tx.call.findUniqueOrThrow({ where: { id: callId } });
const talkTime = seconds(call.bridgeAt, call.endAt); const talkTime = seconds(call.bridgeAt, call.endAt);
const billableSeconds = talkTime ?? 0;
await tx.call.update({ await tx.call.update({
where: { id: callId }, where: { id: callId },
@@ -166,7 +167,27 @@ async function finalizeCall(tx: Prisma.TransactionClient, callId: string): Promi
waitTime: seconds(call.queueEnterAt, call.agentAnswerAt), waitTime: seconds(call.queueEnterAt, call.agentAnswerAt),
talkTime, talkTime,
durationSeconds: seconds(call.createdAt, call.endAt), durationSeconds: seconds(call.createdAt, call.endAt),
billableSeconds: talkTime ?? 0, billableSeconds,
}, },
}); });
// "Chamada faturável" (agente.md secao 133) gera o UsageEvent que o
// RatingEngine (packages/billing) consome no fechamento do período —
// nunca calcula o valor aqui, só registra o fato bruto (segundos
// faturáveis). Chamada sem talk time (nunca bridgeou) não gera evento —
// nada a cobrar.
if (billableSeconds > 0) {
await tx.usageEvent.create({
data: {
tenantId,
callId,
meter: "CALL_SECONDS",
quantity: billableSeconds,
unit: "seconds",
sourceType: "call",
sourceId: callId,
occurredAt: call.endAt ?? new Date(),
},
});
}
} }

163
docs/BILLING.md Normal file
View File

@@ -0,0 +1,163 @@
# Billing (agente.md secao 120-139)
"Criar billing desde o início. Não tratar cobrança como relatório
calculado posteriormente de maneira improvisada" (secao 125). Fecha a
PHASE 22 (Usage Metering + Billing) junto com a IA usage metering que já
vinha desde a PHASE 20/21 (`AIUsageRecord`).
## Modelo de dados
Catálogos GLOBAIS da plataforma (sem `tenant_id`, mesmo padrão de `Plan`),
gerenciados só por platform admin:
- `PriceBook`/`PriceBookItem` (secao 128) — preço unitário por
`PriceItemType`, versionado por `effectiveFrom`/`effectiveUntil`.
- `RateDeck`/`RateDeckEntry` (secao 129) — tarifa por prefixo de destino
(longest-prefix match), `pricePerMinute`/`billingIncrementSeconds`/
`minimumSeconds`/`connectionFee`.
- `PlanVersion` (secao 126) — só o preço base da assinatura é versionado;
os limites (`max_extensions` etc.) continuam em `Plan` direto, sem
versionamento próprio (mudam raramente nesta fase do produto —
simplificação conhecida).
`Tenant.priceBookId`/`rateDeckId` (null = usa o que tiver `isDefault=true`,
mesma convenção de `Queue.aiPrivacyLevel`) escolhem qual catálogo se
aplica a cada tenant.
Tenant-scoped, com RLS:
- `TenantSubscription` (secao 127) — qual `PlanVersion` o tenant assinou e
`billingCycleAnchor` (dia do mês, 1-28). Histórico: nunca UPDATE no
preço de uma assinatura ativa, sempre uma nova linha.
- `UsageEvent` (secao 131) — ledger imutável de uso bruto (só INSERT,
nunca UPDATE/DELETE, mesma disciplina de `AIUsageRecord` desde a PHASE
20). Meters: `CALL_SECONDS`, `EXTENSION_ACTIVE_DAY`, `AGENT_ACTIVE_DAY`,
`TRUNK_ACTIVE_DAY`. Os 4 meters de IA do enum (`AI_TRANSCRIPTION_SECONDS`
etc.) existem só pra bater com a especificação — quem escreve esse uso
na prática é `AIUsageRecord` (ledger próprio, criado antes da fase
Billing existir); o `RatingEngine` lê os dois ledgers, nunca duplica.
- `RatedUsageItem` — 1 linha por evento tarifado (`usageEventId` OU
`aiUsageRecordId`, nunca os dois), referenciando o `PriceBookItem`/
`RateDeckEntry` usado. Granularidade fina de propósito (secao 233:
"immutable usage ledger") — a agregação por categoria só acontece no
`BillingStatementItem`.
- `BillingPeriod` (secao 134, 137) — `OPEN → CALCULATING → CLOSED`.
Fechamento imutável: fechar de novo um `CLOSED` é 409, só
`POST /billing/periods/:id/reopen` (audit trail com motivo) volta pra
`REOPENED`, e só a partir daí um novo `close` roda de novo.
- `BillingStatement`/`BillingStatementItem` (secao 138-139) — o que o
tenant vê (`GET /billing/statements`), agregado por
`BillingStatementCategory`. Nunca chamado de "invoice"/"nota fiscal" na
UI (PRODUCT.md).
## Orquestração (`apps/api/src/billing/billing-engine.service.ts`)
`packages/billing` (`RatingEngine`) é matemática pura, sem I/O — recebe
linhas já buscadas do banco (`*Like` interfaces, não os tipos do Prisma) e
devolve valores calculados: `longestPrefixMatch`, `resolvePriceBookItem`
(vigência por `effectiveFrom`/`effectiveUntil`), `rateCallByDestination`,
`rateCallFlatFallback`, `rateGenericUsage`, `rateActiveDaysProrated`,
`rateTranscriptionSeconds`, `rateRecordingBytes`. Tem teste unitário
próprio dessas funções.
`closeBillingPeriod(tenantId, periodStart, periodEnd, userId)` é quem faz
I/O: resolve o `PriceBook` vigente do tenant (ou o default), busca
`UsageEvent`/`AIUsageRecord` do período ainda sem `RatedUsageItem`
(`ratedUsageItems: { none: {} }`), tarifa cada um com o `RatingEngine`,
grava os `RatedUsageItem`s, soma `PLAN_BASE` da `TenantSubscription`
ativa, agrega por categoria em `BillingStatementItem`, fecha o
`BillingPeriod`. Tudo dentro de um único `withTenantContext` (atômico).
`RECORDING_BYTES` não tem `UsageEvent` próprio (ver comentário no
schema) — usa `Recording.sizeBytes` somado NO MOMENTO do fechamento como
proxy do consumo do período inteiro (não faz média ponderada por dia
armazenado). Documentado aqui porque é a maior liberdade tomada na
implementação: correto o bastante pra fechar o período, mas superfatura
um tenant que reduziu MUITO o volume de gravações no meio do período e
subfatura o oposto.
## Escritores do ledger `UsageEvent`
- `CALL_SECONDS`: `apps/freeswitch-events/src/cdr.ts::finalizeCall`, junto
com o cálculo de `billableSeconds` (mesma transação do CDR) — só grava
se `billableSeconds > 0` (chamada que nunca bridgeou não gera evento).
- `EXTENSION_ACTIVE_DAY`/`AGENT_ACTIVE_DAY`/`TRUNK_ACTIVE_DAY`:
`apps/api/src/billing/active-day-sweep.ts::runActiveDaySweep`, boot +
de hora em hora (mesmo padrão de `runRetentionSweep`). 1 evento por
recurso ativo por dia — idempotente dentro do mesmo dia (checa
existência antes de inserir; sem constraint única no banco pra isso,
limitação conhecida documentada no próprio arquivo).
## **Lacuna real, conhecida**: `CALL_SECONDS` sempre usa o fallback plano
`Call.calledNumber` ainda não é populado pelo CDR (PHASE 17, TODO.md) —
não dá pra fazer o longest-prefix match do `RateDeck` (secao 129) por
destino real. Por isso `closeBillingPeriod` sempre chama
`rateCallFlatFallback` (contra `PriceBookItem` tipo `CALL_MINUTE`),
nunca `rateCallByDestination`/`longestPrefixMatch` contra um `RateDeck`.
`RateDeck`/`RateDeckEntry` ficam cadastráveis via API e testados
isoladamente (unit test do `RatingEngine`), mas não exercitados ponta a
ponta em `closeBillingPeriod` até essa lacuna do CDR fechar.
## Permissions
`pricing.manage` (PriceBook/RateDeck/PlanVersion) e `billing.manage`
(TenantSubscription, fechar/reabrir período) são ações de platform admin
sobre um tenant arbitrário — `tenantId` vem explícito no body (mesma
exceção já usada em GLOBAL de `AIProvider`/`AIPromptTemplate`), e
`isPlatformUser` é checado explicitamente na camada de serviço, nunca só
confiado na permission (o seed de RBAC dá `billing.manage`/`billing.view`
também pro `tenant_admin`, mas essas rotas continuam platform-only via
`isPlatformUser` — revisar se um dia existir uma ação de billing que o
próprio tenant deva poder fazer). `billing.view` é do tenant, sempre
escopado ao próprio JWT.
## O que foi testado de verdade
Ponta a ponta contra a API real (`apps/api` no host) e Postgres real com
RLS: tenant + `PriceBook` (9 items) + `PlanVersion` (basePrice=99) +
`TenantSubscription` criados; 3 `UsageEvent(EXTENSION_ACTIVE_DAY)`, 1
`UsageEvent(CALL_SECONDS, 185s)` e 4 `AIUsageRecord` (transcrição 42s,
1 análise, 1500 tokens de entrada, 600 de saída) semeados manualmente
(mesmo padrão de "transcrição semeada" já usado na PHASE 20/21, já que
gerar uma chamada real ponta a ponta não testa nada a mais do lado do
billing). `POST /billing/periods/close` fechou o período com 8
`RatedUsageItem`s e total `R$ 101,1043129032258` — conferido a mão
(0,40 chamada + 1,451612903225806 ramal + 99 plano + 0,05 transcrição +
0,20 análise + 0,0027 tokens) e batendo exatamente.
Confirmado também: fechar 2x o mesmo período dá 409; reabrir um período
`CLOSED` funciona e grava audit log; reabrir um período que não está
`CLOSED` dá 409; reabrir com `tenantId` de outro tenant dá 404 (RLS
isolando de verdade, não só a checagem de permission); fechar de novo
depois de reabrir reusa os `RatedUsageItem`s já existentes (não duplica)
e gera um 2º `BillingStatement` com o mesmo total; tenant admin (sem role
de plataforma) recebe 403 tentando fechar um período mas lista as
próprias `BillingStatement`s normalmente; `PriceBooksController`,
`RateDecksController`, `PlanVersionsController` (incremento de `version`
automático) e `SubscriptionsController` testados via HTTP com token real.
`runActiveDaySweep` chamado 2x seguidas no mesmo dia — confirmado não
duplicar o evento do dia (idempotência).
**Bug real, achado no teste desta fase**: `reopenBillingPeriod` lia
`prisma.billingPeriod.findUniqueOrThrow({ where: { id } })` SEM tenant
context pra descobrir o `tenantId` do período — mas `billing_periods` tem
FORCE ROW LEVEL SECURITY, então a leitura sem `app.current_tenant_id`
nunca via a linha, e "not found" (P2025) virava 500, não um 404 de
verdade. Corrigido exigindo `tenantId` explícito no body do reopen (igual
ao close) e lendo dentro de `withTenantContext`.
**Bug real, achado no teste desta fase**: `SubscriptionsController`
criava/lia `TenantSubscription` direto por `prisma.tenantSubscription`
sem `withTenantContext` — a tabela tem RLS, então o create batia direto
em "new row violates row-level security policy" (create) e o list sempre
voltava vazio (select). Corrigido envolvendo os dois em
`withTenantContext(prisma, tenantId, ...)`.
**Nunca exercitado**: `rateCallByDestination`/`longestPrefixMatch` contra
um `RateDeck` real dentro de `closeBillingPeriod` (ver lacuna do
`calledNumber` acima — testado só isoladamente como função pura);
`runActiveDaySweep` rodando via `setInterval` de verdade por várias horas
(só chamado diretamente na mesma execução do teste); reajuste de preço no
meio de um período já aberto (`PriceBookItem`/`RateDeckEntry` com 2
vigências sobrepostas).

View File

@@ -0,0 +1,17 @@
{
"name": "@b2bcall/billing",
"version": "0.0.1",
"private": true,
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit",
"test": "tsx src/__tests__/rating-engine.test.ts"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^22.20.1",
"tsx": "^4.23.12",
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,143 @@
/**
* Teste unitário do RatingEngine (agente.md secao 128-133) — matemática
* pura, sem I/O, sem banco. Roda com:
* pnpm --filter @b2bcall/billing run test
*/
import {
longestPrefixMatch,
resolvePriceBookItem,
rateCallByDestination,
rateCallFlatFallback,
rateGenericUsage,
rateActiveDaysProrated,
rateTranscriptionSeconds,
rateRecordingBytes,
} from "../rating-engine";
import type { RateDeckEntryLike, PriceBookItemLike } from "../types";
function assert(condition: boolean, message: string): void {
if (!condition) {
throw new Error(`FALHOU: ${message}`);
}
console.log(`OK: ${message}`);
}
function closeEnough(a: number, b: number, epsilon = 1e-9): boolean {
return Math.abs(a - b) < epsilon;
}
function entry(overrides: Partial<RateDeckEntryLike> = {}): RateDeckEntryLike {
return {
id: "entry-1",
prefix: "5511",
destinationName: "SP",
destinationType: "FIXED",
pricePerMinute: 0.1,
billingIncrementSeconds: 60,
minimumSeconds: 0,
connectionFee: 0,
validFrom: new Date("2020-01-01"),
validUntil: null,
...overrides,
};
}
function item(overrides: Partial<PriceBookItemLike> = {}): PriceBookItemLike {
return {
id: "item-1",
type: "CALL_MINUTE",
unitPrice: 0.1,
effectiveFrom: new Date("2020-01-01"),
effectiveUntil: null,
...overrides,
};
}
function main(): void {
// longestPrefixMatch: prefixo mais especifico vence.
{
const entries = [
entry({ id: "generic", prefix: "55" }),
entry({ id: "sp", prefix: "5511" }),
entry({ id: "sp-mobile", prefix: "551199" }),
];
const match = longestPrefixMatch(entries, "5511999998888", new Date("2025-01-01"));
assert(match?.id === "sp-mobile", "longestPrefixMatch escolhe o prefixo mais longo que bate");
}
// longestPrefixMatch: ignora entry fora de vigencia.
{
const entries = [
entry({ id: "expired", prefix: "5511", validFrom: new Date("2020-01-01"), validUntil: new Date("2024-01-01") }),
entry({ id: "current", prefix: "55", validFrom: new Date("2024-01-01"), validUntil: null }),
];
const match = longestPrefixMatch(entries, "5511999998888", new Date("2025-01-01"));
assert(match?.id === "current", "longestPrefixMatch ignora entry expirada mesmo com prefixo mais longo");
}
// longestPrefixMatch: sem match nenhum.
{
const match = longestPrefixMatch([entry({ prefix: "44" })], "5511999998888", new Date("2025-01-01"));
assert(match === null, "longestPrefixMatch retorna null sem nenhum prefixo batendo");
}
// resolvePriceBookItem: pega o effectiveFrom mais recente vigente.
{
const items = [
item({ id: "v1", unitPrice: 0.1, effectiveFrom: new Date("2020-01-01") }),
item({ id: "v2", unitPrice: 0.2, effectiveFrom: new Date("2024-01-01") }),
];
const resolved = resolvePriceBookItem(items, "CALL_MINUTE", new Date("2025-01-01"));
assert(resolved?.id === "v2" && resolved.unitPrice === 0.2, "resolvePriceBookItem pega o reajuste mais novo vigente");
}
// rateCallByDestination: piso + arredondamento pra cima + connection fee.
{
const result = rateCallByDestination(65, entry({ pricePerMinute: 0.5, billingIncrementSeconds: 60, minimumSeconds: 30, connectionFee: 0.1 }));
// 65s >= minimo 30, arredonda pra 120 (2 incrementos de 60), 2min * 0.5 + 0.1 = 1.1
assert(result.ratedMinutes === 2, "rateCallByDestination arredonda 65s pra 2 incrementos de 60s");
assert(closeEnough(result.ratedAmount, 1.1), "rateCallByDestination soma connection fee ao valor por minuto");
}
// rateCallByDestination: minimo aplicado quando a chamada e mais curta.
{
const result = rateCallByDestination(5, entry({ pricePerMinute: 0.6, billingIncrementSeconds: 30, minimumSeconds: 30, connectionFee: 0 }));
// piso de 30s mesmo com 5s reais, arredonda pra 30 (ja multiplo de 30)
assert(closeEnough(result.ratedMinutes, 0.5), "rateCallByDestination aplica o minimo mesmo pra chamada curtissima");
}
// rateCallFlatFallback: sempre arredonda pro minuto cheio, sem connection fee.
{
const result = rateCallFlatFallback(185, item({ unitPrice: 0.1 }));
assert(result.ratedMinutes === 4, "rateCallFlatFallback arredonda 185s pra 4 minutos cheios");
assert(closeEnough(result.ratedAmount, 0.4), "rateCallFlatFallback nao tem connection fee");
assert(result.matchedEntry === null, "rateCallFlatFallback nunca referencia um RateDeckEntry");
}
// rateGenericUsage: multiplicacao simples.
{
assert(closeEnough(rateGenericUsage(1500, 0.000001), 0.0015), "rateGenericUsage calcula tokens de entrada");
}
// rateActiveDaysProrated: recurso ativo o periodo inteiro paga o preco cheio.
{
assert(closeEnough(rateActiveDaysProrated(30, 15, 30), 15), "rateActiveDaysProrated: ativo o mes inteiro paga o preco cheio");
assert(closeEnough(rateActiveDaysProrated(15, 15, 30), 7.5), "rateActiveDaysProrated: ativo metade do periodo paga metade");
assert(rateActiveDaysProrated(10, 15, 0) === 0, "rateActiveDaysProrated: periodo de 0 dias nao divide por zero");
}
// rateTranscriptionSeconds: sempre arredonda pra cima.
{
assert(closeEnough(rateTranscriptionSeconds(42, 0.05), 0.05), "rateTranscriptionSeconds arredonda 42s pro minuto cheio");
assert(closeEnough(rateTranscriptionSeconds(61, 0.05), 0.1), "rateTranscriptionSeconds cobra 2 minutos por 61s");
}
// rateRecordingBytes: conversao simples bytes -> GB.
{
assert(closeEnough(rateRecordingBytes(2_000_000_000, 2), 4), "rateRecordingBytes converte 2GB corretamente");
}
console.log("\nTodos os testes do RatingEngine passaram.");
}
main();

View File

@@ -0,0 +1,11 @@
export type { RateDeckEntryLike, PriceBookItemLike, CallRatingResult } from "./types";
export {
longestPrefixMatch,
resolvePriceBookItem,
rateCallByDestination,
rateCallFlatFallback,
rateGenericUsage,
rateActiveDaysProrated,
rateTranscriptionSeconds,
rateRecordingBytes,
} from "./rating-engine";

View File

@@ -0,0 +1,129 @@
import type { RateDeckEntryLike, PriceBookItemLike, CallRatingResult } from "./types";
function isValidAt(validFrom: Date, validUntil: Date | null, at: Date): boolean {
return validFrom <= at && (validUntil === null || at < validUntil);
}
/**
* Longest prefix matching (agente.md secao 129) — entre as entries do rate
* deck válidas em `at`, retorna a de prefixo mais longo que `calledNumber`
* começa com. Empate em tamanho de prefixo: indefinido qual vence (não
* deveria acontecer com um rate deck bem configurado — dois prefixos
* idênticos vigentes ao mesmo tempo é erro de cadastro, não algo pro
* engine resolver silenciosamente).
*/
export function longestPrefixMatch(
entries: RateDeckEntryLike[],
calledNumber: string,
at: Date,
): RateDeckEntryLike | null {
let best: RateDeckEntryLike | null = null;
for (const entry of entries) {
if (!isValidAt(entry.validFrom, entry.validUntil, at)) continue;
if (!calledNumber.startsWith(entry.prefix)) continue;
if (!best || entry.prefix.length > best.prefix.length) best = entry;
}
return best;
}
/**
* Item de price book vigente em `at` pra um `type` (agente.md secao 128).
* Se mais de um item do mesmo tipo estiver vigente ao mesmo tempo (não
* deveria, mas não é validado na escrita), pega o de `effectiveFrom` mais
* recente — o reajuste mais novo vence.
*/
export function resolvePriceBookItem(
items: PriceBookItemLike[],
type: string,
at: Date,
): PriceBookItemLike | null {
let best: PriceBookItemLike | null = null;
for (const item of items) {
if (item.type !== type) continue;
if (!isValidAt(item.effectiveFrom, item.effectiveUntil, at)) continue;
if (!best || item.effectiveFrom > best.effectiveFrom) best = item;
}
return best;
}
/**
* Chamada faturável (agente.md secao 133): aplica minimum_seconds (piso),
* arredonda PRA CIMA pro próximo múltiplo de billing_increment_seconds
* (nunca arredonda pra baixo — telecom sempre cobra o incremento cheio
* iniciado), converte pra minutos fracionários e calcula o valor
* (minutos * preço/minuto + taxa de conexão fixa).
*/
export function rateCallByDestination(
billableSeconds: number,
entry: RateDeckEntryLike,
): CallRatingResult {
const flooredSeconds = Math.max(billableSeconds, entry.minimumSeconds);
const increment = entry.billingIncrementSeconds > 0 ? entry.billingIncrementSeconds : 1;
const roundedSeconds = Math.ceil(flooredSeconds / increment) * increment;
const ratedMinutes = roundedSeconds / 60;
const ratedAmount = ratedMinutes * entry.pricePerMinute + entry.connectionFee;
return {
matchedEntry: entry,
billingIncrementSeconds: entry.billingIncrementSeconds,
ratedMinutes,
destinationRate: entry.pricePerMinute,
ratedAmount,
};
}
/**
* Fallback quando nenhum prefixo do rate deck bate (secao 129 não define
* o que fazer nesse caso — decisão desta implementação: usa o
* PriceBookItem(type=CALL_MINUTE) como tarifa plana genérica, sem
* connection fee nem mínimo/incremento próprios — só arredonda pro
* minuto cheio pra cima, a granularidade mais grosseira e mais segura
* (nunca cobra a menos por falta de config).
*/
export function rateCallFlatFallback(billableSeconds: number, callMinuteItem: PriceBookItemLike): CallRatingResult {
const ratedMinutes = Math.ceil(billableSeconds / 60);
const ratedAmount = ratedMinutes * callMinuteItem.unitPrice;
return {
matchedEntry: null,
billingIncrementSeconds: 60,
ratedMinutes,
destinationRate: callMinuteItem.unitPrice,
ratedAmount,
};
}
/** Uso genérico já na mesma unidade do price book item (tokens de IA,
* AI_ANALYSIS_CALL por request, etc.) — sempre quantidade * preço
* unitário, nunca calculado ad hoc em outro lugar do código (agente.md
* secao 130: "Nunca calcular billing no frontend", e por extensão, nunca
* fora deste módulo). */
export function rateGenericUsage(quantity: number, unitPrice: number): number {
return quantity * unitPrice;
}
/** EXTENSION_ACTIVE_DAY/AGENT_ACTIVE_DAY/TRUNK_ACTIVE_DAY → preço mensal
* (EXTENSION_MONTH/AGENT_MONTH/TRUNK_MONTH) prorateado pelos dias do
* período de billing — um recurso ativo o período inteiro paga o preço
* cheio, ativo metade do período paga metade. */
export function rateActiveDaysProrated(activeDays: number, monthlyPrice: number, daysInPeriod: number): number {
if (daysInPeriod <= 0) return 0;
return activeDays * (monthlyPrice / daysInPeriod);
}
/** AI_TRANSCRIPTION_SECONDS → AI_TRANSCRIPTION_MINUTE: arredonda PRA CIMA
* pro minuto cheio (mesma convenção de `rateCallByDestination` — nunca
* cobra a menos por fração de minuto). */
export function rateTranscriptionSeconds(seconds: number, pricePerMinute: number): number {
return Math.ceil(seconds / 60) * pricePerMinute;
}
/** RECORDING_BYTES → RECORDING_GB_MONTH. Simplificação conhecida: usa os
* bytes armazenados no momento do fechamento do período como proxy do
* consumo do mês inteiro (não faz média ponderada por dia armazenado) —
* documentado em docs/BILLING.md, aceitável nesta fase por não haver
* ainda um histórico de tamanho por dia pra fazer a média de verdade. */
export function rateRecordingBytes(bytes: number, pricePerGbMonth: number): number {
const gigabytes = bytes / 1_000_000_000;
return gigabytes * pricePerGbMonth;
}

View File

@@ -0,0 +1,34 @@
/**
* Tipos "-Like" em vez de importar os tipos gerados do Prisma
* (`@b2bcall/database`) — o RatingEngine (agente.md secao 130) é
* matemática pura, sem I/O e sem depender do ORM. Quem chama (apps/api,
* um futuro worker de billing) passa as linhas já buscadas do banco.
*/
export interface RateDeckEntryLike {
id: string;
prefix: string;
destinationName: string;
destinationType: string;
pricePerMinute: number;
billingIncrementSeconds: number;
minimumSeconds: number;
connectionFee: number;
validFrom: Date;
validUntil: Date | null;
}
export interface PriceBookItemLike {
id: string;
type: string;
unitPrice: number;
effectiveFrom: Date;
effectiveUntil: Date | null;
}
export interface CallRatingResult {
matchedEntry: RateDeckEntryLike | null;
billingIncrementSeconds: number;
ratedMinutes: number;
destinationRate: number;
ratedAmount: number;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View File

@@ -0,0 +1,321 @@
-- CreateEnum
CREATE TYPE "tenant_subscription_status" AS ENUM ('TRIALING', 'ACTIVE', 'PAST_DUE', 'CANCELED');
-- CreateEnum
CREATE TYPE "price_item_type" AS ENUM ('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');
-- CreateEnum
CREATE TYPE "destination_type" AS ENUM ('FIXED', 'MOBILE', 'INTERNATIONAL');
-- CreateEnum
CREATE TYPE "usage_meter" AS ENUM ('CALL_COUNT', 'CALL_SECONDS', 'EXTENSION_ACTIVE_DAY', 'AGENT_ACTIVE_DAY', 'TRUNK_ACTIVE_DAY', 'RECORDING_BYTES', 'AI_TRANSCRIPTION_SECONDS', 'AI_ANALYSIS_REQUEST', 'AI_INPUT_TOKENS', 'AI_OUTPUT_TOKENS');
-- CreateEnum
CREATE TYPE "billing_period_status" AS ENUM ('OPEN', 'CALCULATING', 'READY', 'CLOSED', 'REOPENED');
-- CreateEnum
CREATE TYPE "billing_statement_category" AS ENUM ('PLAN_BASE', 'EXTENSIONS', 'AGENTS', 'TRUNKS', 'CALLS', 'MINUTES', 'AI_TRANSCRIPTION', 'AI_ANALYSIS', 'AI_TOKENS', 'STORAGE', 'ADJUSTMENT');
-- AlterTable
ALTER TABLE "calls" ADD COLUMN "billing_increment_seconds" INTEGER,
ADD COLUMN "destination_rate" DOUBLE PRECISION,
ADD COLUMN "rated_amount" DOUBLE PRECISION,
ADD COLUMN "rated_minutes" DOUBLE PRECISION;
-- AlterTable
ALTER TABLE "tenants" ADD COLUMN "price_book_id" UUID,
ADD COLUMN "rate_deck_id" UUID;
-- CreateTable
CREATE TABLE "plan_versions" (
"id" UUID NOT NULL,
"plan_id" UUID NOT NULL,
"version" INTEGER NOT NULL,
"base_price" DOUBLE PRECISION NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'BRL',
"effective_from" TIMESTAMP(3) NOT NULL,
"effective_until" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "plan_versions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tenant_subscriptions" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"plan_version_id" UUID NOT NULL,
"status" "tenant_subscription_status" NOT NULL DEFAULT 'ACTIVE',
"started_at" TIMESTAMP(3) NOT NULL,
"ends_at" TIMESTAMP(3),
"billing_cycle_anchor" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'BRL',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tenant_subscriptions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "price_books" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'BRL',
"is_default" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "price_books_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "price_book_items" (
"id" UUID NOT NULL,
"price_book_id" UUID NOT NULL,
"type" "price_item_type" NOT NULL,
"unit_price" DOUBLE PRECISION NOT NULL,
"effective_from" TIMESTAMP(3) NOT NULL,
"effective_until" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "price_book_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rate_decks" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "rate_decks_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rate_deck_entries" (
"id" UUID NOT NULL,
"rate_deck_id" UUID NOT NULL,
"prefix" TEXT NOT NULL,
"destination_name" TEXT NOT NULL,
"destination_type" "destination_type" NOT NULL,
"price_per_minute" DOUBLE PRECISION NOT NULL,
"billing_increment_seconds" INTEGER NOT NULL DEFAULT 60,
"minimum_seconds" INTEGER NOT NULL DEFAULT 0,
"connection_fee" DOUBLE PRECISION NOT NULL DEFAULT 0,
"valid_from" TIMESTAMP(3) NOT NULL,
"valid_until" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "rate_deck_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "usage_events" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"call_id" UUID,
"meter" "usage_meter" NOT NULL,
"quantity" DOUBLE PRECISION NOT NULL,
"unit" TEXT NOT NULL,
"source_type" TEXT NOT NULL,
"source_id" TEXT,
"occurred_at" TIMESTAMP(3) NOT NULL,
"metadata" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "usage_events_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rated_usage_items" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"usage_event_id" UUID,
"ai_usage_record_id" UUID,
"call_id" UUID,
"price_book_item_id" UUID,
"rate_deck_entry_id" UUID,
"quantity" DOUBLE PRECISION NOT NULL,
"unit_price" DOUBLE PRECISION NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'BRL',
"billing_period_id" UUID,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "rated_usage_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "billing_periods" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"period_start" TIMESTAMP(3) NOT NULL,
"period_end" TIMESTAMP(3) NOT NULL,
"status" "billing_period_status" NOT NULL DEFAULT 'OPEN',
"closed_at" TIMESTAMP(3),
"reopened_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "billing_periods_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "billing_statements" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"billing_period_id" UUID NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'BRL',
"subtotal" DOUBLE PRECISION NOT NULL,
"adjustments" DOUBLE PRECISION NOT NULL DEFAULT 0,
"total" DOUBLE PRECISION NOT NULL,
"generated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "billing_statements_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "billing_statement_items" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"billing_statement_id" UUID NOT NULL,
"category" "billing_statement_category" NOT NULL,
"description" TEXT NOT NULL,
"quantity" DOUBLE PRECISION,
"unit_price" DOUBLE PRECISION,
"amount" DOUBLE PRECISION NOT NULL,
CONSTRAINT "billing_statement_items_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "plan_versions_plan_id_version_key" ON "plan_versions"("plan_id", "version");
-- CreateIndex
CREATE INDEX "tenant_subscriptions_tenant_id_status_idx" ON "tenant_subscriptions"("tenant_id", "status");
-- CreateIndex
CREATE INDEX "price_book_items_price_book_id_type_effective_from_idx" ON "price_book_items"("price_book_id", "type", "effective_from");
-- CreateIndex
CREATE INDEX "rate_deck_entries_rate_deck_id_valid_from_idx" ON "rate_deck_entries"("rate_deck_id", "valid_from");
-- CreateIndex
CREATE INDEX "usage_events_tenant_id_occurred_at_idx" ON "usage_events"("tenant_id", "occurred_at");
-- CreateIndex
CREATE INDEX "usage_events_tenant_id_meter_idx" ON "usage_events"("tenant_id", "meter");
-- CreateIndex
CREATE INDEX "rated_usage_items_tenant_id_billing_period_id_idx" ON "rated_usage_items"("tenant_id", "billing_period_id");
-- CreateIndex
CREATE INDEX "billing_periods_tenant_id_status_idx" ON "billing_periods"("tenant_id", "status");
-- CreateIndex
CREATE UNIQUE INDEX "billing_periods_tenant_id_period_start_period_end_key" ON "billing_periods"("tenant_id", "period_start", "period_end");
-- CreateIndex
CREATE INDEX "billing_statements_tenant_id_billing_period_id_idx" ON "billing_statements"("tenant_id", "billing_period_id");
-- CreateIndex
CREATE INDEX "billing_statement_items_billing_statement_id_idx" ON "billing_statement_items"("billing_statement_id");
-- AddForeignKey
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_price_book_id_fkey" FOREIGN KEY ("price_book_id") REFERENCES "price_books"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_rate_deck_id_fkey" FOREIGN KEY ("rate_deck_id") REFERENCES "rate_decks"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "plan_versions" ADD CONSTRAINT "plan_versions_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "plans"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_subscriptions" ADD CONSTRAINT "tenant_subscriptions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tenant_subscriptions" ADD CONSTRAINT "tenant_subscriptions_plan_version_id_fkey" FOREIGN KEY ("plan_version_id") REFERENCES "plan_versions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "price_book_items" ADD CONSTRAINT "price_book_items_price_book_id_fkey" FOREIGN KEY ("price_book_id") REFERENCES "price_books"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rate_deck_entries" ADD CONSTRAINT "rate_deck_entries_rate_deck_id_fkey" FOREIGN KEY ("rate_deck_id") REFERENCES "rate_decks"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "usage_events" ADD CONSTRAINT "usage_events_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "usage_events" ADD CONSTRAINT "usage_events_call_id_fkey" FOREIGN KEY ("call_id") REFERENCES "calls"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rated_usage_items" ADD CONSTRAINT "rated_usage_items_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rated_usage_items" ADD CONSTRAINT "rated_usage_items_usage_event_id_fkey" FOREIGN KEY ("usage_event_id") REFERENCES "usage_events"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rated_usage_items" ADD CONSTRAINT "rated_usage_items_ai_usage_record_id_fkey" FOREIGN KEY ("ai_usage_record_id") REFERENCES "ai_usage_records"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rated_usage_items" ADD CONSTRAINT "rated_usage_items_call_id_fkey" FOREIGN KEY ("call_id") REFERENCES "calls"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rated_usage_items" ADD CONSTRAINT "rated_usage_items_price_book_item_id_fkey" FOREIGN KEY ("price_book_item_id") REFERENCES "price_book_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rated_usage_items" ADD CONSTRAINT "rated_usage_items_rate_deck_entry_id_fkey" FOREIGN KEY ("rate_deck_entry_id") REFERENCES "rate_deck_entries"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rated_usage_items" ADD CONSTRAINT "rated_usage_items_billing_period_id_fkey" FOREIGN KEY ("billing_period_id") REFERENCES "billing_periods"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "billing_periods" ADD CONSTRAINT "billing_periods_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "billing_statements" ADD CONSTRAINT "billing_statements_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "billing_statements" ADD CONSTRAINT "billing_statements_billing_period_id_fkey" FOREIGN KEY ("billing_period_id") REFERENCES "billing_periods"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "billing_statement_items" ADD CONSTRAINT "billing_statement_items_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "billing_statement_items" ADD CONSTRAINT "billing_statement_items_billing_statement_id_fkey" FOREIGN KEY ("billing_statement_id") REFERENCES "billing_statements"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- RLS (agente.md secao 233, docs/TENANT_ISOLATION.md) — mesmo padrao do
-- resto do sistema: FORCE ROW LEVEL SECURITY + policy unica baseada em
-- app.current_tenant_id. price_books/price_book_items/rate_decks/
-- rate_deck_entries/plan_versions NAO tem RLS (catalogos globais da
-- plataforma, sem tenant_id, mesmo padrao ja usado por "plans").
ALTER TABLE "tenant_subscriptions" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "tenant_subscriptions" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "tenant_subscriptions"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
ALTER TABLE "usage_events" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "usage_events" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "usage_events"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
ALTER TABLE "rated_usage_items" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "rated_usage_items" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "rated_usage_items"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
ALTER TABLE "billing_periods" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "billing_periods" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "billing_periods"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
ALTER TABLE "billing_statements" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "billing_statements" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "billing_statements"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);
ALTER TABLE "billing_statement_items" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "billing_statement_items" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "billing_statement_items"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);

View File

@@ -42,11 +42,18 @@ model Tenant {
telephonyDomain String? @map("telephony_domain") telephonyDomain String? @map("telephony_domain")
planId String @map("plan_id") @db.Uuid planId String @map("plan_id") @db.Uuid
aiPrivacyLevel AIPrivacyLevel @default(AI_OFF) @map("ai_privacy_level") aiPrivacyLevel AIPrivacyLevel @default(AI_OFF) @map("ai_privacy_level")
// null = usa o PriceBook/RateDeck com isDefault=true (agente.md secao
// 128-129) — mesma convenção de "campo null = default/sem override" já
// usada em Queue.aiPrivacyLevel.
priceBookId String? @map("price_book_id") @db.Uuid
rateDeckId String? @map("rate_deck_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
plan Plan @relation(fields: [planId], references: [id]) plan Plan @relation(fields: [planId], references: [id])
priceBook PriceBook? @relation(fields: [priceBookId], references: [id])
rateDeck RateDeck? @relation(fields: [rateDeckId], references: [id])
memberships TenantMembership[] memberships TenantMembership[]
userRoles UserRole[] userRoles UserRole[]
extensions Extension[] extensions Extension[]
@@ -82,6 +89,12 @@ model Tenant {
aipromptVersions AIPromptVersion[] aipromptVersions AIPromptVersion[]
callTranscriptSegments CallTranscriptSegment[] callTranscriptSegments CallTranscriptSegment[]
qualityScorecardItems QualityScorecardItem[] qualityScorecardItems QualityScorecardItem[]
subscriptions TenantSubscription[]
usageEvents UsageEvent[]
ratedUsageItems RatedUsageItem[]
billingPeriods BillingPeriod[]
billingStatements BillingStatement[]
billingStatementItems BillingStatementItem[]
@@map("tenants") @@map("tenants")
} }
@@ -120,6 +133,7 @@ model Plan {
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
tenants Tenant[] tenants Tenant[]
planVersions PlanVersion[]
@@map("plans") @@map("plans")
} }
@@ -1030,6 +1044,15 @@ model Call {
durationSeconds Int? @map("duration_seconds") durationSeconds Int? @map("duration_seconds")
billableSeconds Int? @map("billable_seconds") billableSeconds Int? @map("billable_seconds")
// "Chamada faturável" (agente.md secao 133) — preenchidos pelo
// RatingEngine (packages/billing) quando o UsageEvent CALL_SECONDS desta
// chamada é avaliado (nunca no momento do CDR — billableSeconds já
// existe desde a fase CDR, o resto só existe depois de rated).
billingIncrementSeconds Int? @map("billing_increment_seconds")
ratedMinutes Float? @map("rated_minutes")
destinationRate Float? @map("destination_rate")
ratedAmount Float? @map("rated_amount")
hangupCause String? @map("hangup_cause") hangupCause String? @map("hangup_cause")
dispositionId String? @map("disposition_id") @db.Uuid dispositionId String? @map("disposition_id") @db.Uuid
@@ -1051,6 +1074,8 @@ model Call {
callAIAnalyses CallAIAnalysis[] callAIAnalyses CallAIAnalysis[]
qualityEvaluations QualityEvaluation[] qualityEvaluations QualityEvaluation[]
aiusageRecords AIUsageRecord[] aiusageRecords AIUsageRecord[]
usageEvents UsageEvent[]
ratedUsageItems RatedUsageItem[]
@@index([tenantId, createdAt]) @@index([tenantId, createdAt])
@@index([tenantId, queueId]) @@index([tenantId, queueId])
@@ -1550,8 +1575,9 @@ enum AIUsageType {
// "AI usage metering" (secao 124) — ledger imutável (secao 233: "immutable // "AI usage metering" (secao 124) — ledger imutável (secao 233: "immutable
// usage ledger > reconstruir billing de forma improvisada"), só INSERT // usage ledger > reconstruir billing de forma improvisada"), só INSERT
// pelo código da aplicação, nunca UPDATE/DELETE. Alimenta a fase Billing // pelo código da aplicação, nunca UPDATE/DELETE. Consumido pelo
// (Rating Engine), ainda não construída. // RatingEngine (packages/billing) junto com UsageEvent — ver comentário
// acima de UsageEvent sobre por que são 2 tabelas em vez de 1.
model AIUsageRecord { model AIUsageRecord {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid tenantId String @map("tenant_id") @db.Uuid
@@ -1568,8 +1594,383 @@ model AIUsageRecord {
tenant Tenant @relation(fields: [tenantId], references: [id]) tenant Tenant @relation(fields: [tenantId], references: [id])
call Call? @relation(fields: [callId], references: [id]) call Call? @relation(fields: [callId], references: [id])
provider AIProvider? @relation(fields: [providerId], references: [id]) provider AIProvider? @relation(fields: [providerId], references: [id])
ratedUsageItems RatedUsageItem[]
@@index([tenantId, occurredAt]) @@index([tenantId, occurredAt])
@@index([tenantId, type]) @@index([tenantId, type])
@@map("ai_usage_records") @@map("ai_usage_records")
} }
// ============================================================
// BILLING (agente.md secao 125-139)
//
// "Criar billing desde o início. Não tratar cobrança como relatório
// calculado posteriormente de maneira improvisada" (secao 125).
//
// PriceBook/PriceBookItem/RateDeck/RateDeckEntry/PlanVersion são
// catálogos GLOBAIS da plataforma (sem tenant_id, mesmo padrão já usado
// por `Plan` — gerenciados só pelo platform admin, um Tenant escolhe qual
// usar via `Tenant.priceBookId`/`rateDeckId`, null = o que tiver
// `isDefault=true`). TenantSubscription/UsageEvent/RatedUsageItem/
// BillingPeriod/BillingStatement(Item) SÃO tenant-scoped, com RLS.
// ============================================================
// "plan_versions" (secao 126: "Preços e limites devem ser versionados").
// Versiona só o PREÇO base da assinatura por enquanto — os limites
// (max_extensions etc.) continuam em `Plan` direto, sem versionamento
// próprio (mudam raramente nesta fase do produto; documentado como
// simplificação conhecida em docs/BILLING.md).
model PlanVersion {
id String @id @default(uuid()) @db.Uuid
planId String @map("plan_id") @db.Uuid
version Int
basePrice Float @map("base_price")
currency String @default("BRL")
effectiveFrom DateTime @map("effective_from")
effectiveUntil DateTime? @map("effective_until")
createdAt DateTime @default(now()) @map("created_at")
plan Plan @relation(fields: [planId], references: [id])
subscriptions TenantSubscription[]
@@unique([planId, version])
@@map("plan_versions")
}
enum TenantSubscriptionStatus {
TRIALING
ACTIVE
PAST_DUE
CANCELED
@@map("tenant_subscription_status")
}
// "tenant_subscriptions" (secao 127).
model TenantSubscription {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
planVersionId String @map("plan_version_id") @db.Uuid
status TenantSubscriptionStatus @default(ACTIVE)
startedAt DateTime @map("started_at")
endsAt DateTime? @map("ends_at")
// Dia do mês (1-28, nunca 29-31 pra evitar mês sem esse dia) em que o
// período de billing do tenant fecha (secao 127).
billingCycleAnchor Int @map("billing_cycle_anchor")
currency String @default("BRL")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
planVersion PlanVersion @relation(fields: [planVersionId], references: [id])
@@index([tenantId, status])
@@map("tenant_subscriptions")
}
enum PriceItemType {
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
@@map("price_item_type")
}
// "price_books"/"price_book_items" (secao 128) — catálogo global,
// `isDefault` marca qual usar quando `Tenant.priceBookId` é null.
model PriceBook {
id String @id @default(uuid()) @db.Uuid
name String
currency String @default("BRL")
isDefault Boolean @default(false) @map("is_default")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
items PriceBookItem[]
tenants Tenant[]
@@map("price_books")
}
// Preço vigente por tipo — `validFrom`/`validUntil` permitem reajuste sem
// perder o preço histórico (o RatingEngine sempre busca o item vigente em
// `usage_event.occurred_at`, nunca "o preço de hoje" pra uso passado).
model PriceBookItem {
id String @id @default(uuid()) @db.Uuid
priceBookId String @map("price_book_id") @db.Uuid
type PriceItemType
unitPrice Float @map("unit_price")
effectiveFrom DateTime @map("effective_from")
effectiveUntil DateTime? @map("effective_until")
createdAt DateTime @default(now()) @map("created_at")
priceBook PriceBook @relation(fields: [priceBookId], references: [id])
ratedUsageItems RatedUsageItem[]
@@index([priceBookId, type, effectiveFrom])
@@map("price_book_items")
}
// "rate_decks"/"rate_deck_entries" (secao 129) — precificação por destino
// via longest prefix matching (packages/billing/src/rating-engine.ts),
// separado dos PriceBookItem(type=CALL_MINUTE) que servem só de fallback
// quando nenhum prefixo do rate deck bate com o número discado.
model RateDeck {
id String @id @default(uuid()) @db.Uuid
name String
isDefault Boolean @default(false) @map("is_default")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
entries RateDeckEntry[]
tenants Tenant[]
@@map("rate_decks")
}
enum DestinationType {
FIXED
MOBILE
INTERNATIONAL
@@map("destination_type")
}
model RateDeckEntry {
id String @id @default(uuid()) @db.Uuid
rateDeckId String @map("rate_deck_id") @db.Uuid
prefix String
destinationName String @map("destination_name")
destinationType DestinationType @map("destination_type")
pricePerMinute Float @map("price_per_minute")
billingIncrementSeconds Int @default(60) @map("billing_increment_seconds")
minimumSeconds Int @default(0) @map("minimum_seconds")
connectionFee Float @default(0) @map("connection_fee")
validFrom DateTime @map("valid_from")
validUntil DateTime? @map("valid_until")
createdAt DateTime @default(now()) @map("created_at")
rateDeck RateDeck @relation(fields: [rateDeckId], references: [id])
ratedUsageItems RatedUsageItem[]
// Longest prefix matching precisa varrer todas as entries vigentes do
// deck — sem índice em `prefix` sozinho (o match é por STARTS WITH, não
// igualdade), o RatingEngine já traz tudo pra memória por rateDeckId.
@@index([rateDeckId, validFrom])
@@map("rate_deck_entries")
}
enum UsageMeter {
CALL_COUNT
CALL_SECONDS
EXTENSION_ACTIVE_DAY
AGENT_ACTIVE_DAY
TRUNK_ACTIVE_DAY
RECORDING_BYTES
// Os 4 meters de IA abaixo completam a lista da secao 131, mas quem
// escreve esses eventos na prática é AIUsageRecord (ledger próprio,
// já existia desde a PHASE 20, antes da fase Billing) — o RatingEngine
// lê os dois ledgers, ver comentário em UsageEvent. Mantidos aqui só
// pra o enum bater com a especificação, não usados pra escrita.
AI_TRANSCRIPTION_SECONDS
AI_ANALYSIS_REQUEST
AI_INPUT_TOKENS
AI_OUTPUT_TOKENS
@@map("usage_meter")
}
// "usage_events" (secao 131) — ledger imutável, só INSERT pelo código da
// aplicação (mesma convenção de AIUsageRecord, secao 233: "immutable
// usage ledger"). Existem 2 ledgers (este + AIUsageRecord) em vez de 1
// porque AIUsageRecord já foi construído e testado ponta a ponta na fase
// de IA, ANTES da fase Billing existir — migrar aquele código pra esta
// tabela só pra unificar seria puro churn sem ganho funcional; o
// RatingEngine simplesmente lê dos dois. Documentado em docs/BILLING.md.
model UsageEvent {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
callId String? @map("call_id") @db.Uuid
meter UsageMeter
quantity Float
unit String
sourceType String @map("source_type")
sourceId String? @map("source_id")
occurredAt DateTime @map("occurred_at")
metadata Json?
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
call Call? @relation(fields: [callId], references: [id])
ratedUsageItems RatedUsageItem[]
@@index([tenantId, occurredAt])
@@index([tenantId, meter])
@@map("usage_events")
}
// "rated_usage_items" (secao 132) — resultado de aplicar o RatingEngine
// num UsageEvent OU AIUsageRecord (exatamente um dos dois, checado na
// camada de serviço — Postgres não tem um jeito limpo de expressar "XOR
// de FK nullable" sem trigger, e um trigger seria over-engineering pra
// isto). `pricingVersion` referencia o PriceBookItem/RateDeckEntry usado,
// pra auditoria de qual preço vigia quando foi calculado.
model RatedUsageItem {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
usageEventId String? @map("usage_event_id") @db.Uuid
aiUsageRecordId String? @map("ai_usage_record_id") @db.Uuid
callId String? @map("call_id") @db.Uuid
priceBookItemId String? @map("price_book_item_id") @db.Uuid
rateDeckEntryId String? @map("rate_deck_entry_id") @db.Uuid
quantity Float
unitPrice Float @map("unit_price")
amount Float
currency String @default("BRL")
billingPeriodId String? @map("billing_period_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
usageEvent UsageEvent? @relation(fields: [usageEventId], references: [id])
aiUsageRecord AIUsageRecord? @relation(fields: [aiUsageRecordId], references: [id])
call Call? @relation(fields: [callId], references: [id])
priceBookItem PriceBookItem? @relation(fields: [priceBookItemId], references: [id])
rateDeckEntry RateDeckEntry? @relation(fields: [rateDeckEntryId], references: [id])
billingPeriod BillingPeriod? @relation(fields: [billingPeriodId], references: [id])
@@index([tenantId, billingPeriodId])
@@map("rated_usage_items")
}
enum BillingPeriodStatus {
OPEN
CALCULATING
READY
CLOSED
REOPENED
@@map("billing_period_status")
}
// "billing_periods" (secao 134). Fechamento imutável (secao 137): depois
// de CLOSED, o service layer nunca recalcula silenciosamente — só via
// REOPEN explícito, com audit trail (recordAuditEvent, user+reason), que
// volta o status pra REOPENED (nunca direto pra OPEN, pra deixar visível
// no histórico que este período já foi fechado antes).
model BillingPeriod {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
periodStart DateTime @map("period_start")
periodEnd DateTime @map("period_end")
status BillingPeriodStatus @default(OPEN)
closedAt DateTime? @map("closed_at")
reopenedAt DateTime? @map("reopened_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
ratedUsageItems RatedUsageItem[]
statements BillingStatement[]
@@unique([tenantId, periodStart, periodEnd])
@@index([tenantId, status])
@@map("billing_periods")
}
enum BillingStatementCategory {
PLAN_BASE
EXTENSIONS
AGENTS
TRUNKS
CALLS
MINUTES
AI_TRANSCRIPTION
AI_ANALYSIS
AI_TOKENS
STORAGE
ADJUSTMENT
@@map("billing_statement_category")
}
// "billing_statements"/"billing_statement_items" (secao 135). Secao 136:
// NUNCA chamar isto de nota fiscal — só "Usage Statement"/"Billing
// Statement"/"Relatório de Consumo" (aplicado na nomenclatura da API e
// dos DTOs, não só em texto de UI que ainda não existe).
model BillingStatement {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
billingPeriodId String @map("billing_period_id") @db.Uuid
currency String @default("BRL")
subtotal Float
adjustments Float @default(0)
total Float
generatedAt DateTime @default(now()) @map("generated_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
billingPeriod BillingPeriod @relation(fields: [billingPeriodId], references: [id])
items BillingStatementItem[]
@@index([tenantId, billingPeriodId])
@@map("billing_statements")
}
model BillingStatementItem {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
billingStatementId String @map("billing_statement_id") @db.Uuid
category BillingStatementCategory
description String
quantity Float?
unitPrice Float? @map("unit_price")
amount Float
tenant Tenant @relation(fields: [tenantId], references: [id])
billingStatement BillingStatement @relation(fields: [billingStatementId], references: [id])
@@index([billingStatementId])
@@map("billing_statement_items")
}

2243
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,4 +6,5 @@ allowBuilds:
esbuild: false esbuild: false
msgpackr-extract: false msgpackr-extract: false
prisma: true prisma: true
sharp: false
workerd: false workerd: false