feat(platform): Billing > Fechamentos e Relatórios

GET /billing/periods e /billing/statements só serviam o próprio tenant do
JWT — sem uso pra um platform admin escolhendo um tenant arbitrário.
Adicionado GET .../by-tenant/:tenantId nos dois (mesmo padrão já usado em
Subscriptions), e GET /billing/statements/:id ganhou um ?tenantId=
opcional só aceito de quem tem role de plataforma.

Frontend: /platform/billing/fechamentos (fecha/reabre período por
tenant, seletor via querystring pra não duplicar rota) e /relatorios
(statements por tenant, detalhe com itens por categoria).

Bug real achado testando o fluxo: fechar um período de 01/08 a 31/08
mostrava "31 de jul." a "30 de ago." — meia-noite UTC de uma data-only
vira o dia anterior no timezone local do servidor. Corrigido com
formatDateUTC novo, usado só em fronteiras de calendário (não em
timestamps de verdade, que continuam com formatDate local).

Testado ponta a ponta contra a API real: período fechado, statement
gerado (R$ 0,00 honesto — Acme sem assinatura/price book ainda), detalhe
correto. Smoke test nas 19 telas do tenant + 8 telas platform, todas 200.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BFaBaBSQGhyXGEgtTYZGV8
This commit is contained in:
2026-08-29 20:31:12 -03:00
parent a23e68b011
commit c95c6805fb
18 changed files with 670 additions and 6 deletions

View File

@@ -37,3 +37,58 @@ export interface RateDeck {
updatedAt: string;
entries: RateDeckEntry[];
}
export interface BillingPeriod {
id: string;
tenantId: string;
periodStart: string;
periodEnd: string;
status: "OPEN" | "CALCULATING" | "READY" | "CLOSED" | "REOPENED";
closedAt: string | null;
reopenedAt: string | null;
createdAt: string;
}
export const BILLING_PERIOD_STATUS_LABELS: Record<string, string> = {
OPEN: "Aberto",
CALCULATING: "Calculando",
READY: "Pronto",
CLOSED: "Fechado",
REOPENED: "Reaberto",
};
export const BILLING_STATEMENT_CATEGORY_LABELS: Record<string, string> = {
PLAN_BASE: "Assinatura base",
EXTENSIONS: "Ramais",
AGENTS: "Agentes",
TRUNKS: "Troncos",
CALLS: "Chamadas",
MINUTES: "Minutos",
AI_TRANSCRIPTION: "IA — transcrição",
AI_ANALYSIS: "IA — análise",
AI_TOKENS: "IA — tokens",
STORAGE: "Armazenamento",
ADJUSTMENT: "Ajuste",
};
export interface BillingStatementItem {
id: string;
category: string;
description: string;
quantity: number | null;
unitPrice: number | null;
amount: number;
}
export interface BillingStatement {
id: string;
tenantId: string;
billingPeriodId: string;
currency: string;
subtotal: number;
adjustments: number;
total: number;
generatedAt: string;
billingPeriod: BillingPeriod;
items?: BillingStatementItem[];
}

View File

@@ -37,6 +37,15 @@ export function formatDate(iso: string): string {
return new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short", year: "numeric" }).format(new Date(iso));
}
/** Como `formatDate`, mas em UTC — pra fronteiras de calendário (início/fim
* de período de billing, por exemplo) que nunca deveriam deslizar um dia
* pra trás por causa do timezone local do servidor renderizando a página.
* Achado real: fechar um período de 01/08 a 31/08 mostrava "31 de jul." a
* "30 de ago." porque meia-noite UTC vira o dia anterior em UTC-3. */
export function formatDateUTC(iso: string): string {
return new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short", year: "numeric", timeZone: "UTC" }).format(new Date(iso));
}
export function formatDateTime(iso: string): string {
return new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",