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
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
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 },
|
|
});
|
|
}
|