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

@@ -54,4 +54,19 @@ export class BillingPeriodsController {
tx.billingPeriod.findMany({ where: { tenantId }, orderBy: { periodStart: "desc" } }),
);
}
/** Platform admin olhando um tenant arbitrário (secao 168, "Billing >
* Fechamentos") — `GET /billing/periods` acima só serve o próprio
* tenant do JWT, que um platform admin não tem. */
@RequirePermission("billing.manage")
@Get("by-tenant/:tenantId")
async listByTenant(@CurrentUser() user: AccessTokenClaims, @Param("tenantId") tenantId: string) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver fechamentos de outro tenant");
}
const prisma = getPrismaClient();
return withTenantContext(prisma, tenantId, (tx) =>
tx.billingPeriod.findMany({ where: { tenantId }, orderBy: { periodStart: "desc" } }),
);
}
}

View File

@@ -1,6 +1,6 @@
import { Controller, Get, NotFoundException, Param, UseGuards } from "@nestjs/common";
import { Controller, ForbiddenException, Get, NotFoundException, Param, Query, UseGuards } from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import type { AccessTokenClaims } from "@b2bcall/auth";
import { isPlatformUser, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
@@ -29,10 +29,39 @@ export class BillingStatementsController {
);
}
/** Platform admin olhando um tenant arbitrário (secao 168, "Billing >
* Relatórios") — `GET /billing/statements` acima só serve o próprio
* tenant do JWT, que um platform admin não tem. */
@RequirePermission("billing.manage")
@Get("by-tenant/:tenantId")
async listByTenant(@CurrentUser() user: AccessTokenClaims, @Param("tenantId") tenantId: string) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver relatorios de outro tenant");
}
const prisma = getPrismaClient();
return withTenantContext(prisma, tenantId, (tx) =>
tx.billingStatement.findMany({
where: { tenantId },
include: { billingPeriod: true },
orderBy: { generatedAt: "desc" },
}),
);
}
/** `tenantId` na query só é aceito de quem tem role de plataforma (secao
* 31: nunca confiar em tenant vindo do client sem checar) — um tenant
* admin comum sempre olha só o próprio, do JWT, mesmo que tente mandar
* outro. */
@RequirePermission("billing.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const tenantId = user.tenantId!;
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string, @Query("tenantId") queryTenantId?: string) {
let tenantId = user.tenantId!;
if (queryTenantId && queryTenantId !== tenantId) {
if (!(await isPlatformUser(user.sub))) {
throw new ForbiddenException("So' um usuario com role de plataforma pode ver statement de outro tenant");
}
tenantId = queryTenantId;
}
const prisma = getPrismaClient();
const statement = await withTenantContext(prisma, tenantId, (tx) =>
tx.billingStatement.findFirst({