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:
@@ -17,6 +17,8 @@ import { ReportsModule } from "./reports/reports.module";
|
||||
import { RecordingsModule } from "./recordings/recordings.module";
|
||||
import { AIModule } from "./ai/ai.module";
|
||||
import { QualityModule } from "./quality/quality.module";
|
||||
import { PlatformModule } from "./platform/platform.module";
|
||||
import { BillingModule } from "./billing/billing.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -38,6 +40,8 @@ import { QualityModule } from "./quality/quality.module";
|
||||
RecordingsModule,
|
||||
AIModule,
|
||||
QualityModule,
|
||||
PlatformModule,
|
||||
BillingModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, NotFoundException, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import type { FastifyRequest } from "fastify";
|
||||
import { getPrismaClient } from "@b2bcall/database";
|
||||
import {
|
||||
changePassword,
|
||||
isPlatformUser,
|
||||
listUserTenants,
|
||||
login,
|
||||
logout,
|
||||
@@ -44,6 +46,18 @@ export class AuthController {
|
||||
await logout(user.sessionId);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("me")
|
||||
async me(@CurrentUser() user: AccessTokenClaims) {
|
||||
const prisma = getPrismaClient();
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.sub },
|
||||
select: { id: true, email: true, name: true },
|
||||
});
|
||||
if (!dbUser) throw new NotFoundException();
|
||||
return { ...dbUser, isPlatformUser: await isPlatformUser(user.sub) };
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("tenants")
|
||||
async tenants(@CurrentUser() user: AccessTokenClaims) {
|
||||
|
||||
74
apps/api/src/billing/active-day-sweep.ts
Normal file
74
apps/api/src/billing/active-day-sweep.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
330
apps/api/src/billing/billing-engine.service.ts
Normal file
330
apps/api/src/billing/billing-engine.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
57
apps/api/src/billing/billing-periods.controller.ts
Normal file
57
apps/api/src/billing/billing-periods.controller.ts
Normal 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" } }),
|
||||
);
|
||||
}
|
||||
}
|
||||
46
apps/api/src/billing/billing-statements.controller.ts
Normal file
46
apps/api/src/billing/billing-statements.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
apps/api/src/billing/billing.module.ts
Normal file
19
apps/api/src/billing/billing.module.ts
Normal 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 {}
|
||||
12
apps/api/src/billing/dto/close-period.dto.ts
Normal file
12
apps/api/src/billing/dto/close-period.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsDateString, IsUUID } from "class-validator";
|
||||
|
||||
export class ClosePeriodDto {
|
||||
@IsUUID()
|
||||
tenantId!: string;
|
||||
|
||||
@IsDateString()
|
||||
periodStart!: string;
|
||||
|
||||
@IsDateString()
|
||||
periodEnd!: string;
|
||||
}
|
||||
22
apps/api/src/billing/dto/create-plan-version.dto.ts
Normal file
22
apps/api/src/billing/dto/create-plan-version.dto.ts
Normal 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;
|
||||
}
|
||||
55
apps/api/src/billing/dto/create-price-book.dto.ts
Normal file
55
apps/api/src/billing/dto/create-price-book.dto.ts
Normal 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[];
|
||||
}
|
||||
60
apps/api/src/billing/dto/create-rate-deck.dto.ts
Normal file
60
apps/api/src/billing/dto/create-rate-deck.dto.ts
Normal 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[];
|
||||
}
|
||||
23
apps/api/src/billing/dto/create-subscription.dto.ts
Normal file
23
apps/api/src/billing/dto/create-subscription.dto.ts
Normal 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;
|
||||
}
|
||||
11
apps/api/src/billing/dto/reopen-period.dto.ts
Normal file
11
apps/api/src/billing/dto/reopen-period.dto.ts
Normal 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;
|
||||
}
|
||||
66
apps/api/src/billing/plan-versions.controller.ts
Normal file
66
apps/api/src/billing/plan-versions.controller.ts
Normal 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" } });
|
||||
}
|
||||
}
|
||||
78
apps/api/src/billing/price-books.controller.ts
Normal file
78
apps/api/src/billing/price-books.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
84
apps/api/src/billing/rate-decks.controller.ts
Normal file
84
apps/api/src/billing/rate-decks.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
75
apps/api/src/billing/subscriptions.controller.ts
Normal file
75
apps/api/src/billing/subscriptions.controller.ts
Normal 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" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,21 @@ import { PERMISSION_KEY } from "../decorators/require-permission.decorator";
|
||||
import type { AuthenticatedRequest } from "./jwt-auth.guard";
|
||||
|
||||
/**
|
||||
* Roda depois do JwtAuthGuard. Exige que a rota tenha um tenant selecionado
|
||||
* (agente.md secao 31: nunca confiar em tenant_id do frontend — aqui vem só
|
||||
* do JWT, nunca do body/query) e que o usuário tenha a permission marcada
|
||||
* via @RequirePermission() (secao 145/146).
|
||||
* Roda depois do JwtAuthGuard. Exige que o usuário tenha a permission
|
||||
* marcada via @RequirePermission() (secao 145/146), resolvida sempre a
|
||||
* partir do JWT (secao 31: nunca confiar em tenant_id do frontend).
|
||||
*
|
||||
* Um usuário PLATFORM puro (sem NENHUMA TenantMembership — ex.: o platform
|
||||
* super admin recém-criado) nunca tem um tenant pra selecionar em
|
||||
* `/auth/select-tenant` (`/auth/tenants` retorna vazio pra ele), então
|
||||
* `user.tenantId` legitimamente nunca vai existir no token dele. Bug real
|
||||
* encontrado testando `PlatformOverviewController` (agente.md secao 163):
|
||||
* antes desta correção, exigir `tenantId` incondicionalmente deixava
|
||||
* QUALQUER endpoint com @RequirePermission inacessível pra esse usuário,
|
||||
* mesmo os platform-only. Corrigido: sem tenantId, ainda tenta a permission
|
||||
* em escopo PLATFORM (`userHasPermission` com tenantId undefined já filtra
|
||||
* só roles com tenantId null); só barra de fato quem não tem a permission
|
||||
* em nenhum escopo.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
@@ -29,13 +40,14 @@ export class PermissionGuard implements CanActivate {
|
||||
if (!user) {
|
||||
throw new ForbiddenException("Nao autenticado");
|
||||
}
|
||||
if (!user.tenantId) {
|
||||
throw new ForbiddenException("Nenhum tenant selecionado (use /auth/select-tenant)");
|
||||
}
|
||||
|
||||
const allowed = await userHasPermission(user.sub, permissionKey, user.tenantId);
|
||||
const allowed = await userHasPermission(user.sub, permissionKey, user.tenantId ?? undefined);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(`Permissao necessaria: ${permissionKey}`);
|
||||
throw new ForbiddenException(
|
||||
user.tenantId
|
||||
? `Permissao necessaria: ${permissionKey}`
|
||||
: `Permissao necessaria: ${permissionKey} (nenhum tenant selecionado, checado so' em escopo PLATFORM — use /auth/select-tenant se a permissao for de tenant)`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -9,8 +9,10 @@ import rateLimit from "@fastify/rate-limit";
|
||||
import { AppModule } from "./app.module";
|
||||
import { DomainExceptionFilter } from "./common/filters/domain-exception.filter";
|
||||
import { runRetentionSweep } from "./recordings/retention-sweep";
|
||||
import { runActiveDaySweep } from "./billing/active-day-sweep";
|
||||
|
||||
const RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
||||
const ACTIVE_DAY_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
@@ -63,6 +65,13 @@ async function bootstrap() {
|
||||
setInterval(() => {
|
||||
runRetentionSweep().catch((err) => console.error("falha na varredura de retencao", err));
|
||||
}, RETENTION_SWEEP_INTERVAL_MS);
|
||||
|
||||
// Usage metering diario (agente.md secao 131: EXTENSION/AGENT/TRUNK
|
||||
// ACTIVE_DAY) — mesmo padrao da varredura de retencao acima.
|
||||
runActiveDaySweep().catch((err) => console.error("falha na varredura de uso diario (boot)", err));
|
||||
setInterval(() => {
|
||||
runActiveDaySweep().catch((err) => console.error("falha na varredura de uso diario", err));
|
||||
}, ACTIVE_DAY_SWEEP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
85
apps/api/src/platform/platform-overview.controller.ts
Normal file
85
apps/api/src/platform/platform-overview.controller.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/api/src/platform/platform.module.ts
Normal file
7
apps/api/src/platform/platform.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PlatformOverviewController } from "./platform-overview.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [PlatformOverviewController],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
Reference in New Issue
Block a user