feat(billing): rating engine, fechamento de periodo, dashboard platform (fase 22)

Fecha a orquestracao de Billing (agente.md secao 120-139) sobre o schema/
RatingEngine puro ja existentes: escritores do ledger UsageEvent
(CALL_SECONDS no CDR, ACTIVE_DAY via sweep diario), closeBillingPeriod/
reopenBillingPeriod (fechamento imutavel com audit trail), e os
controllers de price books/rate decks/plan versions/subscriptions/
periods/statements. Corrige 2 bugs reais de RLS achados no teste ponta a
ponta (reopen sem tenant context, subscriptions sem withTenantContext) e
adiciona teste unitario do RatingEngine (17 casos).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EWHKmcVJtstQFErbZ1AanY
This commit is contained in:
2026-08-29 00:28:35 -03:00
parent d4e2513764
commit b27cfaab02
35 changed files with 4705 additions and 34 deletions

View File

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

View File

@@ -42,11 +42,18 @@ model Tenant {
telephonyDomain String? @map("telephony_domain")
planId String @map("plan_id") @db.Uuid
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")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
plan Plan @relation(fields: [planId], references: [id])
priceBook PriceBook? @relation(fields: [priceBookId], references: [id])
rateDeck RateDeck? @relation(fields: [rateDeckId], references: [id])
memberships TenantMembership[]
userRoles UserRole[]
extensions Extension[]
@@ -82,6 +89,12 @@ model Tenant {
aipromptVersions AIPromptVersion[]
callTranscriptSegments CallTranscriptSegment[]
qualityScorecardItems QualityScorecardItem[]
subscriptions TenantSubscription[]
usageEvents UsageEvent[]
ratedUsageItems RatedUsageItem[]
billingPeriods BillingPeriod[]
billingStatements BillingStatement[]
billingStatementItems BillingStatementItem[]
@@map("tenants")
}
@@ -119,7 +132,8 @@ model Plan {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenants Tenant[]
tenants Tenant[]
planVersions PlanVersion[]
@@map("plans")
}
@@ -1030,6 +1044,15 @@ model Call {
durationSeconds Int? @map("duration_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")
dispositionId String? @map("disposition_id") @db.Uuid
@@ -1051,6 +1074,8 @@ model Call {
callAIAnalyses CallAIAnalysis[]
qualityEvaluations QualityEvaluation[]
aiusageRecords AIUsageRecord[]
usageEvents UsageEvent[]
ratedUsageItems RatedUsageItem[]
@@index([tenantId, createdAt])
@@index([tenantId, queueId])
@@ -1550,8 +1575,9 @@ enum AIUsageType {
// "AI usage metering" (secao 124) — ledger imutável (secao 233: "immutable
// usage ledger > reconstruir billing de forma improvisada"), só INSERT
// pelo código da aplicação, nunca UPDATE/DELETE. Alimenta a fase Billing
// (Rating Engine), ainda não construída.
// pelo código da aplicação, nunca UPDATE/DELETE. Consumido pelo
// 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 {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
@@ -1565,11 +1591,386 @@ model AIUsageRecord {
occurredAt DateTime @default(now()) @map("occurred_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
call Call? @relation(fields: [callId], references: [id])
provider AIProvider? @relation(fields: [providerId], references: [id])
tenant Tenant @relation(fields: [tenantId], references: [id])
call Call? @relation(fields: [callId], references: [id])
provider AIProvider? @relation(fields: [providerId], references: [id])
ratedUsageItems RatedUsageItem[]
@@index([tenantId, occurredAt])
@@index([tenantId, type])
@@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")
}