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:
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user