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:
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")
|
||||
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