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 = { 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 = { 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 = { 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 = { 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(); 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 { 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 }, }); }