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:
65
TODO.md
65
TODO.md
@@ -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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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:*",
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
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";
|
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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
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 {}
|
||||||
@@ -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
163
docs/BILLING.md
Normal 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).
|
||||||
17
packages/billing/package.json
Normal file
17
packages/billing/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
143
packages/billing/src/__tests__/rating-engine.test.ts
Normal file
143
packages/billing/src/__tests__/rating-engine.test.ts
Normal 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();
|
||||||
11
packages/billing/src/index.ts
Normal file
11
packages/billing/src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type { RateDeckEntryLike, PriceBookItemLike, CallRatingResult } from "./types";
|
||||||
|
export {
|
||||||
|
longestPrefixMatch,
|
||||||
|
resolvePriceBookItem,
|
||||||
|
rateCallByDestination,
|
||||||
|
rateCallFlatFallback,
|
||||||
|
rateGenericUsage,
|
||||||
|
rateActiveDaysProrated,
|
||||||
|
rateTranscriptionSeconds,
|
||||||
|
rateRecordingBytes,
|
||||||
|
} from "./rating-engine";
|
||||||
129
packages/billing/src/rating-engine.ts
Normal file
129
packages/billing/src/rating-engine.ts
Normal 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;
|
||||||
|
}
|
||||||
34
packages/billing/src/types.ts
Normal file
34
packages/billing/src/types.ts
Normal 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;
|
||||||
|
}
|
||||||
8
packages/billing/tsconfig.json
Normal file
8
packages/billing/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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")
|
||||||
}
|
}
|
||||||
@@ -119,7 +132,8 @@ model Plan {
|
|||||||
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")
|
||||||
|
|
||||||
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
|
||||||
@@ -1565,11 +1591,386 @@ model AIUsageRecord {
|
|||||||
|
|
||||||
occurredAt DateTime @default(now()) @map("occurred_at")
|
occurredAt DateTime @default(now()) @map("occurred_at")
|
||||||
|
|
||||||
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
2243
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user