feat(api,ui): MFA/TOTP, Inventory, Support, HR-Timeclock, Fiscal, Billing e Design System

Leva grande, pedida como "pelo menos mais 20 etapas, use subagentes":
fecha a pendência de MFA/TOTP da Fase 1 (feito diretamente, autenticação é
sensível demais para delegar) e lança 5 subagentes em paralelo em 2 ondas
(eden-inventory, eden-support, eden-hr-timeclock; depois eden-finance,
eden-fiscal, eden-frontend), cada um em worktree isolado, com integração
seletiva manual (barrel, app.module.ts, migrations renomeadas/aplicadas,
build/typecheck/teste ao vivo antes de cada merge).

## Backend

- MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `crypto/field-encryption.service.ts`):
  login em duas etapas quando `mfa_enabled=true`, `FieldEncryptionService`
  genérico (AES-256-GCM versionado, ADR-0005) reaproveitado por outros
  segredos reversíveis (ex. credencial de equipamento Control iD).
- Inventory: warehouses, stock_movements (ledger append-only), serialized_assets,
  asset_assignments, stock_reservations — invariante "serial/patrimônio/MAC
  nunca duplicado entre ativos ativos" reforçada por índices únicos parciais
  no banco (Master Prompt §15.3).
- Support/Service Desk: tickets com máquina de estados, SLA com pausa/retomada
  em `waiting_customer`/`waiting_third_party` (funções puras testáveis).
- HR-Timeclock (fundação): dispositivos Control iD (senha cifrada), import de
  AFD com parser + CRC-16/KERMIT, `afd_records` imutável sem exceção nem
  para super_admin (sem válvula de escape, diferente de audit_log).
- Fiscal: NCM/CFOP/municípios/naturezas de operação (eden.md módulo 5, MVP de
  5 das ~19 tabelas), product_fiscal_profiles com invariante de único
  profile ativo por billing_component.
- Billing: subscription→billing_run→invoice idempotente (regenerar em draft
  substitui, nunca duplica) e imutável após consolidar (ADR-0008: nunca
  confunde faturamento/documento fiscal/recebimento).
- FKs cruzadas fechadas: quotes/client_registrations/contracts.

## Frontend — primeira leva

- `packages/ui`: Design System real extraído do tema DreamsERP comprado —
  achado que mudou o escopo: o tema já vem em React+Tailwind (não só
  Angular), tokens visuais reais portados (cores, Geist como fonte real,
  divergindo da doc antiga que citava Nunito/Poppins).
- `apps/core-web`: login real (com suporte a MFA), sessão, shell autenticado
  via `AppShell` — substitui a tela estática "Bootstrap da Fase 1".

## Bugs reais encontrados e corrigidos (todos via teste ao vivo, não leitura)

1. **Segurança**: o token de desafio MFA era aceito como access token normal
   pelo `JwtAuthGuard` (mesma assinatura, nenhuma checagem da claim
   `purpose`) — bypass total do segundo fator. Explorado e fechado ao vivo
   contra o container real; teste de regressão em `test/mfa.spec.ts`.
2. Crase solta dentro de comentário SQL na migration de hr-timeclock quebrava
   o template literal JS (`SyntaxError` ao rodar a migration).
3. CORS ausente por completo na API.
4. `turbo.json` não declarava `VITE_API_BASE_URL` como input de build — o
   Turborepo cacheava o bundle do frontend ignorando mudança na env var.
5. `compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`, que colidia com a
   variável de mesmo nome do `.env` local — o default nunca era aplicado.
6. Não existia `.dockerignore` — todo build copiava node_modules/.git/
   worktrees de subagentes (665MB) para dentro do contexto Docker.

Decisão do operador incorporada: a API nunca tem domínio público próprio
(só IP interno) — os 3 frontends chamam por caminho relativo `/api/v1`,
proxy externo roteia same-origin; CORS só é relevante para dev local.

Verificado: `pnpm turbo run typecheck build` (28/28), `pnpm --filter
@eden/api test` (30/30, cresceu de 15), todo o lote testado ao vivo via
curl contra os containers reais reconstruídos, incluindo os 6 bugs acima
já fechados e confirmados, e a trilha de auditoria conferida no Postgres.

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 17:25:31 -03:00
parent 18838a9031
commit eff515b70d
105 changed files with 8875 additions and 21 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
.git
.gitignore
node_modules
**/node_modules
**/dist
**/.turbo
.claude/worktrees
.tmp
*.log
.env
.env.local
.env.*.local
tema_do_Eden.zip

View File

@@ -21,6 +21,29 @@ EDEN_CORE_PORT=3001
EDEN_PARCEIROS_PORT=3002
EDEN_ASSINANTE_PORT=3003
# --- Frontend: URL base da API consumida pelo browser (Vite, build-time) ---
# Lida via import.meta.env.VITE_API_BASE_URL. Dev local (`pnpm dev`, fora de
# Docker): a API roda em localhost:8080, origem DIFERENTE da porta do Vite —
# precisa de URL absoluta + CORS (ver EDEN_CORS_ORIGINS abaixo). Copiar para
# o .env de cada app (apps/core-web/.env etc.) — Vite não lê o .env da raiz.
VITE_API_BASE_URL=http://localhost:8080/api/v1
# Build de produção via Docker (compose.yaml `VITE_API_BASE_URL` de build
# arg, default /api/v1): a API NÃO tem domínio público próprio (decisão do
# operador — só acessível por IP interno). O browser chama a API pelo MESMO
# domínio do frontend, caminho relativo /api/v1 — o proxy reverso na frente
# de cada um dos 3 domínios públicos precisa rotear internamente esse path
# para o container eden-api (rede interna, nunca exposto publicamente).
# Nesse fluxo (mesma origem) o navegador não faz preflight de CORS.
# --- CORS (origens de browser permitidas a chamar a API cross-origin) ---
# Só é relevante para dev local (core-web em localhost:3001+, API em
# localhost:8080 — origens diferentes). Nunca usar "*" — a API emite bearer
# token (ver packages/config/src/env-schema.ts). Os 3 domínios públicos NÃO
# entram aqui: eles chamam a API por caminho relativo via proxy (acima),
# nunca cross-origin diretamente na API.
EDEN_CORS_ORIGINS=http://localhost:3001,http://localhost:3002,http://localhost:3003
# --- Autenticação / sessão (ADR-0003) ---
# Gerar com: openssl rand -hex 48
JWT_SIGNING_SECRET=

View File

@@ -25,6 +25,7 @@
"class-validator": "^0.14.1",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"otplib": "^13.5.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},

View File

@@ -14,6 +14,11 @@ import { OpportunitiesModule } from "./opportunities/opportunities.module.js";
import { QuotesModule } from "./quotes/quotes.module.js";
import { ClientRegistrationsModule } from "./client-registrations/client-registrations.module.js";
import { ContractsModule } from "./contracts/contracts.module.js";
import { InventoryModule } from "./inventory/inventory.module.js";
import { SupportModule } from "./support/support.module.js";
import { HrTimeclockModule } from "./hr-timeclock/hr-timeclock.module.js";
import { FiscalModule } from "./fiscal/fiscal.module.js";
import { BillingModule } from "./billing/billing.module.js";
@Module({
imports: [
@@ -31,6 +36,11 @@ import { ContractsModule } from "./contracts/contracts.module.js";
QuotesModule,
ClientRegistrationsModule,
ContractsModule,
InventoryModule,
SupportModule,
HrTimeclockModule,
FiscalModule,
BillingModule,
],
controllers: [HealthController],
})

View File

@@ -2,8 +2,11 @@ import { Body, Controller, Get, HttpCode, Post, Req, UseGuards } from "@nestjs/c
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import type { Request } from "express";
import { AuthService } from "./auth.service.js";
import { MfaService } from "./mfa.service.js";
import { LoginDto } from "./dto/login.dto.js";
import { RefreshDto } from "./dto/refresh.dto.js";
import { MfaChallengeDto } from "./dto/mfa-challenge.dto.js";
import { MfaCodeDto } from "./dto/mfa-code.dto.js";
import { JwtAuthGuard } from "./jwt-auth.guard.js";
import { CurrentUser } from "./decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "./jwt-auth.guard.js";
@@ -18,7 +21,10 @@ function requestContext(req: Request) {
@ApiTags("auth")
@Controller("v1/auth")
export class AuthController {
constructor(private readonly auth: AuthService) {}
constructor(
private readonly auth: AuthService,
private readonly mfaService: MfaService,
) {}
@Post("login")
@HttpCode(200)
@@ -26,6 +32,35 @@ export class AuthController {
return this.auth.login(dto.email, dto.password, dto.applicationKey, requestContext(req));
}
@Post("mfa/challenge")
@HttpCode(200)
async mfaChallenge(@Body() dto: MfaChallengeDto, @Req() req: Request) {
return this.auth.completeMfaLogin(dto.mfaChallenge, dto.code, requestContext(req));
}
@Post("mfa/enroll")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async mfaEnroll(@CurrentUser() user: EdenAuthenticatedUser) {
return this.mfaService.startEnrollment(user.id, user.email);
}
@Post("mfa/confirm")
@HttpCode(204)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async mfaConfirm(@Body() dto: MfaCodeDto, @CurrentUser() user: EdenAuthenticatedUser) {
await this.mfaService.confirmEnrollment(user.id, dto.code);
}
@Post("mfa/disable")
@HttpCode(204)
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async mfaDisable(@Body() dto: MfaCodeDto, @CurrentUser() user: EdenAuthenticatedUser) {
await this.mfaService.disable(user.id, dto.code);
}
@Post("refresh")
@HttpCode(200)
async refresh(@Body() dto: RefreshDto, @Req() req: Request) {

View File

@@ -3,13 +3,24 @@ import { AuthController } from "./auth.controller.js";
import { AuthService } from "./auth.service.js";
import { PasswordService } from "./password.service.js";
import { TokenService } from "./token.service.js";
import { MfaService } from "./mfa.service.js";
import { JwtAuthGuard } from "./jwt-auth.guard.js";
import { PermissionGuard } from "./permission.guard.js";
import { SuperAdminGuard } from "./super-admin.guard.js";
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
@Module({
controllers: [AuthController],
providers: [AuthService, PasswordService, TokenService, JwtAuthGuard, PermissionGuard, SuperAdminGuard],
providers: [
AuthService,
PasswordService,
TokenService,
MfaService,
FieldEncryptionService,
JwtAuthGuard,
PermissionGuard,
SuperAdminGuard,
],
exports: [PasswordService, TokenService, JwtAuthGuard, PermissionGuard, SuperAdminGuard],
})
export class AuthModule {}

View File

@@ -2,6 +2,7 @@ import { ForbiddenException, Injectable, UnauthorizedException } from "@nestjs/c
import { usersRepo, sessionsRepo, auditRepo, type User } from "@eden/database";
import { PasswordService } from "./password.service.js";
import { TokenService } from "./token.service.js";
import { MfaService } from "./mfa.service.js";
import { resolvePermissions } from "./permissions.js";
export interface RequestContext {
@@ -16,6 +17,14 @@ export interface AuthResult {
user: PublicUser;
}
export interface MfaRequiredResult {
mfaRequired: true;
mfaChallenge: string;
expiresIn: number;
}
export type LoginResult = AuthResult | MfaRequiredResult;
export interface PublicUser {
id: string;
email: string;
@@ -34,6 +43,7 @@ export class AuthService {
constructor(
private readonly passwords: PasswordService,
private readonly tokens: TokenService,
private readonly mfa: MfaService,
) {}
async login(
@@ -41,7 +51,7 @@ export class AuthService {
password: string,
applicationKey: "core" | "reseller" | "subscriber",
ctx: RequestContext,
): Promise<AuthResult> {
): Promise<LoginResult> {
const user = await usersRepo.findUserByEmail(email);
// Same generic error whether the account doesn't exist or the password
@@ -65,6 +75,20 @@ export class AuthService {
throw new ForbiddenException("Usuário sem acesso a esta aplicação");
}
if (user.mfa_enabled) {
const challenge = this.tokens.signMfaChallenge({ sub: user.id, app: applicationKey });
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.mfa_challenge_issued",
resourceType: "user",
resourceId: user.id,
metadata: { applicationKey },
ipAddress: ctx.ipAddress,
});
return { mfaRequired: true, mfaChallenge: challenge.token, expiresIn: challenge.expiresIn };
}
const result = await this.issueTokens(user, applicationKey, ctx);
await auditRepo.appendAuditEvent({
@@ -80,6 +104,47 @@ export class AuthService {
return result;
}
/** Second step of login when the account has MFA enabled — exchanges a password-verified challenge + TOTP code for real tokens. */
async completeMfaLogin(challengeToken: string, code: string, ctx: RequestContext): Promise<AuthResult> {
let payload;
try {
payload = this.tokens.verifyMfaChallenge(challengeToken);
} catch {
throw new UnauthorizedException("Desafio MFA inválido ou expirado");
}
const user = await usersRepo.findUserById(payload.sub);
if (!user || !user.is_active || !user.mfa_enabled || !user.mfa_secret_encrypted) {
throw new UnauthorizedException("Desafio MFA inválido ou expirado");
}
if (!(await this.mfa.verifyCode(user.mfa_secret_encrypted, code))) {
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.mfa_challenge_failed",
resourceType: "user",
resourceId: user.id,
ipAddress: ctx.ipAddress,
});
throw new UnauthorizedException("Código inválido");
}
const result = await this.issueTokens(user, payload.app, ctx);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.login",
resourceType: "user",
resourceId: user.id,
metadata: { applicationKey: payload.app, viaMfa: true },
ipAddress: ctx.ipAddress,
});
return result;
}
async refresh(refreshTokenPlaintext: string, ctx: RequestContext): Promise<AuthResult> {
const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext);
const active = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash);

View File

@@ -0,0 +1,11 @@
import { IsString, Length, MinLength } from "class-validator";
export class MfaChallengeDto {
@IsString()
@MinLength(1)
mfaChallenge!: string;
@IsString()
@Length(6, 6)
code!: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, Length } from "class-validator";
export class MfaCodeDto {
@IsString()
@Length(6, 6)
code!: string;
}

View File

@@ -0,0 +1,49 @@
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
import { generateSecret, generateURI, verify } from "otplib";
import { usersRepo } from "@eden/database";
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
const ISSUER = "EDEN";
@Injectable()
export class MfaService {
constructor(private readonly fieldEncryption: FieldEncryptionService) {}
/** Stages a new TOTP secret (not yet active — see confirmEnrollment). */
async startEnrollment(userId: string, email: string): Promise<{ secret: string; otpauthUrl: string }> {
const secret = generateSecret();
await usersRepo.setMfaSecret(userId, this.fieldEncryption.encrypt(secret));
const otpauthUrl = generateURI({ issuer: ISSUER, label: email, secret });
return { secret, otpauthUrl };
}
/** Verifies the first code against the staged secret and flips mfa_enabled on. */
async confirmEnrollment(userId: string, code: string): Promise<void> {
const user = await usersRepo.findUserById(userId);
if (!user?.mfa_secret_encrypted) {
throw new BadRequestException("Nenhum cadastro de MFA pendente para este usuário");
}
if (!(await this.verifyCode(user.mfa_secret_encrypted, code))) {
throw new BadRequestException("Código inválido");
}
await usersRepo.confirmMfaEnrollment(userId);
}
/** Requires a currently-valid code — proves the factor, not just an authenticated session. */
async disable(userId: string, code: string): Promise<void> {
const user = await usersRepo.findUserById(userId);
if (!user?.mfa_enabled || !user.mfa_secret_encrypted) {
throw new BadRequestException("MFA não está habilitado para este usuário");
}
if (!(await this.verifyCode(user.mfa_secret_encrypted, code))) {
throw new UnauthorizedException("Código inválido");
}
await usersRepo.disableMfa(userId);
}
async verifyCode(encryptedSecret: string, code: string): Promise<boolean> {
const secret = this.fieldEncryption.decrypt(encryptedSecret);
const result = await verify({ token: code, secret });
return result.valid;
}
}

View File

@@ -11,6 +11,14 @@ export interface AccessTokenPayload {
const ACCESS_TOKEN_TTL_SECONDS = 15 * 60; // 15 min — ADR-0003
const REFRESH_TOKEN_TTL_DAYS = 30;
const MFA_CHALLENGE_TTL_SECONDS = 2 * 60;
const MFA_CHALLENGE_PURPOSE = "mfa_challenge" as const;
export interface MfaChallengePayload {
sub: string;
app: "core" | "reseller" | "subscriber";
purpose: typeof MFA_CHALLENGE_PURPOSE;
}
@Injectable()
export class TokenService {
@@ -26,7 +34,18 @@ export class TokenService {
}
verifyAccessToken(token: string): AccessTokenPayload {
return jwt.verify(token, this.secret) as AccessTokenPayload;
const payload = jwt.verify(token, this.secret) as AccessTokenPayload & { purpose?: unknown };
// Both token kinds are signed with the same secret, so signature
// verification alone doesn't distinguish them — a real access token
// must never carry a `purpose` claim (only single-purpose tokens like
// the MFA challenge do). Without this check, a password-verified MFA
// challenge token would be indistinguishable from a full access token
// and grant complete API access without ever checking the second
// factor — this was caught live (a real bug, not hypothetical).
if (payload.purpose !== undefined) {
throw new Error("Token não é um access token válido");
}
return payload;
}
/** Opaque, high-entropy refresh token — never a JWT (ADR-0003). Returns the
@@ -42,4 +61,27 @@ export class TokenService {
hashRefreshToken(plaintext: string): string {
return createHash("sha256").update(plaintext).digest("hex");
}
/**
* Short-lived, single-purpose token issued after password verification
* when the account has MFA enabled — proves "password already checked"
* without granting API access. The `purpose` claim is what keeps this
* out of JwtAuthGuard (see verifyAccessToken's explicit rejection of any
* `purpose` claim) — without it this token would be a fully valid
* access token, defeating MFA entirely.
*/
signMfaChallenge(payload: Omit<MfaChallengePayload, "purpose">): { token: string; expiresIn: number } {
const token = jwt.sign({ ...payload, purpose: MFA_CHALLENGE_PURPOSE }, this.secret, {
expiresIn: MFA_CHALLENGE_TTL_SECONDS,
});
return { token, expiresIn: MFA_CHALLENGE_TTL_SECONDS };
}
verifyMfaChallenge(token: string): MfaChallengePayload {
const payload = jwt.verify(token, this.secret) as Partial<MfaChallengePayload>;
if (payload.purpose !== MFA_CHALLENGE_PURPOSE || !payload.sub || !payload.app) {
throw new Error("Token de desafio MFA inválido");
}
return payload as MfaChallengePayload;
}
}

View File

@@ -0,0 +1,104 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { BillingService } from "./billing.service.js";
import { CreateBillingSubscriptionDto } from "./dto/create-billing-subscription.dto.js";
import { CreateBillingRunDto } from "./dto/create-billing-run.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import type {
BillingRunStatus,
BillingSubscriptionStatus,
InvoiceStatus,
} from "@eden/database";
/**
* Billing — Master Prompt §6.10, ADR-0008. Recurring subscription ->
* billing_run -> invoice MVP. Explicitly NOT Finance/AR-AP (títulos,
* boletos, dunning) or Fiscal (documento fiscal) — see ADR-0008. See
* billing.service.ts for the authorization rules (why run
* creation/consolidation requires 'all' scope).
*/
@ApiTags("billing")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/billing")
export class BillingController {
constructor(private readonly billing: BillingService) {}
@Get("subscriptions")
@RequirePermission("billing", "view")
listSubscriptions(
@CurrentUser() user: EdenAuthenticatedUser,
@Query("status") status?: BillingSubscriptionStatus,
) {
return this.billing.listSubscriptions(user, status);
}
@Post("subscriptions")
@RequirePermission("billing", "edit")
createSubscription(
@Body() dto: CreateBillingSubscriptionDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.billing.createSubscription(dto, user);
}
@Patch("subscriptions/:id/cancel")
@RequirePermission("billing", "edit")
cancelSubscription(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.billing.cancelSubscription(id, user);
}
@Post("runs")
@RequirePermission("billing", "edit")
createRun(@Body() dto: CreateBillingRunDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.billing.createOrRegenerateRun(dto, user);
}
@Get("runs")
@RequirePermission("billing", "view")
listRuns(
@CurrentUser() user: EdenAuthenticatedUser,
@Query("referenceYear") referenceYear?: string,
@Query("referenceMonth") referenceMonth?: string,
@Query("status") status?: BillingRunStatus,
) {
return this.billing.listRuns(user, {
referenceYear: referenceYear != null ? Number(referenceYear) : undefined,
referenceMonth: referenceMonth != null ? Number(referenceMonth) : undefined,
status,
});
}
@Get("runs/:id")
@RequirePermission("billing", "view")
getRun(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.billing.getRun(id, user);
}
@Post("runs/:id/consolidate")
@RequirePermission("billing", "edit")
consolidateRun(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.billing.consolidateRun(id, user);
}
@Get("invoices")
@RequirePermission("billing", "view")
listInvoices(
@CurrentUser() user: EdenAuthenticatedUser,
@Query("status") status?: InvoiceStatus,
@Query("referenceYear") referenceYear?: string,
@Query("referenceMonth") referenceMonth?: string,
@Query("resellerId") resellerId?: string,
) {
return this.billing.listInvoices(user, {
status,
referenceYear: referenceYear != null ? Number(referenceYear) : undefined,
referenceMonth: referenceMonth != null ? Number(referenceMonth) : undefined,
resellerId,
});
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { BillingController } from "./billing.controller.js";
import { BillingService } from "./billing.service.js";
@Module({
imports: [AuthModule],
controllers: [BillingController],
providers: [BillingService],
exports: [BillingService],
})
export class BillingModule {}

View File

@@ -0,0 +1,262 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import {
auditRepo,
billingRepo,
type BillingRun,
type BillingRunWithInvoices,
type BillingSubscription,
type Invoice,
} from "@eden/database";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { maxGrantedScope } from "../auth/permissions.js";
import type { CreateBillingSubscriptionDto } from "./dto/create-billing-subscription.dto.js";
import type { CreateBillingRunDto } from "./dto/create-billing-run.dto.js";
const {
BillingSubscriptionNotFoundError,
BillingRunNotFoundError,
BillingRunConsolidatedError,
BillingPeriodAlreadyConsolidatedError,
} = billingRepo;
const RESOURCE = "billing";
/**
* Billing (Master Prompt §6.10, ADR-0008) — recurring subscription ->
* billing_run -> invoice flow. Strictly distinct from Finance/AR-AP
* (títulos/boletos/cobrança) and Fiscal (documento fiscal) — neither is
* touched here.
*
* Authorization: creating/regenerating and consolidating a billing_run
* always requires 'all' scope for 'billing'/'edit' — a run spans every
* reseller with a matching billing_day, so only central back-office
* operators (not a single reseller) can trigger a billing close. Reading
* and per-subscription/invoice mutations respect the actor's scope
* ('all' vs 'reseller'), same pattern as contracts.service.ts.
*/
@Injectable()
export class BillingService {
// -------------------------------------------------------------------
// billing_subscriptions
// -------------------------------------------------------------------
async listSubscriptions(
user: EdenAuthenticatedUser,
status?: BillingSubscription["status"],
): Promise<BillingSubscription[]> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
if (scope === "all") return billingRepo.listBillingSubscriptions({ scope: "all", status });
return billingRepo.listBillingSubscriptions({
scope: "reseller",
resellerId: user.resellerId,
status,
});
}
async createSubscription(
dto: CreateBillingSubscriptionDto,
user: EdenAuthenticatedUser,
): Promise<BillingSubscription> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "edit");
if (!scope) throw new ForbiddenException(`Sem permissão para 'edit' em '${RESOURCE}'`);
// Never trust a client-supplied resellerId to place a subscription
// under an arbitrary reseller (CLAUDE.md: no authz decision trusts a
// client-supplied reseller_id). A 'reseller'-scoped actor always gets
// their own session-resolved reseller; only 'all' scope may target an
// explicit resellerId from the body.
let resellerId: string | null;
if (scope === "all") {
resellerId = dto.resellerId ?? null;
} else {
resellerId = user.resellerId;
if (!resellerId) {
throw new ForbiddenException("Usuário não está vinculado a nenhuma revenda");
}
}
const subscription = await billingRepo.createBillingSubscription({
contractId: dto.contractId,
resellerId,
monthlyValue: dto.monthlyValue,
billingDay: dto.billingDay,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "billing_subscription.created",
resourceType: "billing_subscription",
resourceId: subscription.id,
metadata: { contractId: dto.contractId, billingDay: dto.billingDay },
});
return subscription;
}
async cancelSubscription(id: string, user: EdenAuthenticatedUser): Promise<BillingSubscription> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "edit");
if (!scope) throw new ForbiddenException(`Sem permissão para 'edit' em '${RESOURCE}'`);
const existing = await billingRepo.getBillingSubscriptionById(id);
if (!existing) throw new NotFoundException("Subscription não encontrada");
if (scope !== "all" && existing.reseller_id !== user.resellerId) {
throw new NotFoundException("Subscription não encontrada");
}
const cancelled = await billingRepo.cancelBillingSubscription(id, user.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "billing_subscription.cancelled",
resourceType: "billing_subscription",
resourceId: id,
metadata: {},
});
return cancelled;
}
// -------------------------------------------------------------------
// billing_runs / invoices
// -------------------------------------------------------------------
/**
* Creates a new draft run, or — if a draft already exists for the same
* (referenceYear, referenceMonth, billingDay) — regenerates its
* invoices in place (idempotent: never duplicates). 409s if a run for
* that exact period is already consolidated (controlled re-billing is
* deferred, see PENDING_billing.cjs header).
*/
async createOrRegenerateRun(
dto: CreateBillingRunDto,
user: EdenAuthenticatedUser,
): Promise<BillingRunWithInvoices> {
this.requireAllScope(user, "edit");
try {
const run = await billingRepo.generateOrRegenerateBillingRun({
referenceYear: dto.referenceYear,
referenceMonth: dto.referenceMonth,
billingDay: dto.billingDay,
triggeredBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "billing_run.generated",
resourceType: "billing_run",
resourceId: run.id,
metadata: {
referenceYear: dto.referenceYear,
referenceMonth: dto.referenceMonth,
billingDay: dto.billingDay,
invoiceCount: run.invoices.length,
totalAmount: run.totalAmount,
},
});
return run;
} catch (err) {
if (err instanceof BillingPeriodAlreadyConsolidatedError) {
throw new ConflictException(err.message);
}
throw err;
}
}
async listRuns(
user: EdenAuthenticatedUser,
filter: { referenceYear?: number; referenceMonth?: number; status?: BillingRun["status"] },
): Promise<BillingRun[]> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
return billingRepo.listBillingRuns(filter);
}
async getRun(id: string, user: EdenAuthenticatedUser): Promise<BillingRunWithInvoices> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
const run = await billingRepo.getBillingRunById(id);
if (!run) throw new NotFoundException("Billing run não encontrado");
if (scope !== "all") {
// Reseller-scoped actors only ever see their own reseller's slice
// of a run's invoices — never another reseller's amounts.
run.invoices = run.invoices.filter((inv: Invoice) => inv.reseller_id === user.resellerId);
}
return run;
}
async consolidateRun(id: string, user: EdenAuthenticatedUser): Promise<BillingRunWithInvoices> {
this.requireAllScope(user, "edit");
try {
const run = await billingRepo.consolidateBillingRun(id, user.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "billing_run.consolidated",
resourceType: "billing_run",
resourceId: id,
metadata: { invoiceCount: run.invoices.length, totalAmount: run.totalAmount },
});
return run;
} catch (err) {
if (err instanceof BillingRunNotFoundError) throw new NotFoundException("Billing run não encontrado");
if (err instanceof BillingRunConsolidatedError) {
throw new ConflictException("Billing run já está consolidado");
}
throw err;
}
}
async listInvoices(
user: EdenAuthenticatedUser,
filter: {
status?: Invoice["status"];
referenceYear?: number;
referenceMonth?: number;
/** Only honored when the actor holds 'all' scope — see below. */
resellerId?: string;
},
): Promise<Invoice[]> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
if (!scope) throw new ForbiddenException(`Sem permissão para 'view' em '${RESOURCE}'`);
const { resellerId: requestedResellerId, ...rest } = filter;
if (scope === "all") {
// A client-supplied resellerId is only ever honored once the
// actor's own server-resolved scope already grants full ('all')
// visibility — it narrows an already-unrestricted view, it never
// grants access beyond what the session allows.
return billingRepo.listInvoices(
requestedResellerId
? { scope: "reseller", resellerId: requestedResellerId, ...rest }
: { scope: "all", ...rest },
);
}
return billingRepo.listInvoices({ scope: "reseller", resellerId: user.resellerId, ...rest });
}
private requireAllScope(user: EdenAuthenticatedUser, action: "view" | "edit"): void {
const scope = maxGrantedScope(user.permissions, RESOURCE, action);
if (scope !== "all") {
throw new ForbiddenException(
`Fechamento de billing exige escopo 'all' para '${RESOURCE}:${action}'`,
);
}
}
}

View File

@@ -0,0 +1,18 @@
import { IsInt, Max, Min } from "class-validator";
export class CreateBillingRunDto {
@IsInt()
@Min(2000)
@Max(2100)
referenceYear!: number;
@IsInt()
@Min(1)
@Max(12)
referenceMonth!: number;
@IsInt()
@Min(1)
@Max(28)
billingDay!: number;
}

View File

@@ -0,0 +1,30 @@
import { IsInt, IsNumber, IsOptional, IsUUID, Max, Min } from "class-validator";
/**
* `monthlyValue` is accepted directly in the body (never recalculated
* from a contract) — this worktree/leva has no guaranteed access to the
* real `contracts` module to look the value up server-side, so the
* caller (typically a Contracts-side workflow) supplies the snapshot
* explicitly. `resellerId` is only actually trusted from the client when
* the caller holds 'all' scope; for 'reseller' scope it is always
* overridden server-side with the actor's own reseller (see
* billing.service.ts#create) — no authorization decision ever trusts a
* client-supplied resellerId (CLAUDE.md).
*/
export class CreateBillingSubscriptionDto {
@IsUUID()
contractId!: string;
@IsOptional()
@IsUUID()
resellerId?: string;
@IsNumber()
@Min(0)
monthlyValue!: number;
@IsInt()
@Min(1)
@Max(28)
billingDay!: number;
}

View File

@@ -0,0 +1,61 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
import { Inject, Injectable } from "@nestjs/common";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
const KEY_VERSION = "v1";
const IV_LENGTH_BYTES = 12; // 96 bits — AES-GCM recommended nonce size
/**
* Generic reversible field encryption (ADR-0005) — AES-256-GCM, random IV
* per operation, authenticated (tampering makes decrypt() throw). Stored
* format mirrors the legacy's `iv:tag:ciphertext` scheme with an added key
* version prefix so a future root-key rotation can be applied incrementally
* instead of re-encrypting everything at once: `v1:iv:tag:ciphertext` (all
* three payload segments base64).
*
* Root key comes from EDEN_FIELD_ENCRYPTION_KEY (env-only, never in the
* database) and is hashed to exactly 32 bytes via SHA-256 so any
* sufficiently-long passphrase works as AES-256 key material.
*/
@Injectable()
export class FieldEncryptionService {
private readonly key: Buffer | null;
constructor(@Inject(EDEN_ENV) env: EdenEnv) {
this.key = env.EDEN_FIELD_ENCRYPTION_KEY
? createHash("sha256").update(env.EDEN_FIELD_ENCRYPTION_KEY).digest()
: null;
}
encrypt(plaintext: string): string {
const key = this.requireKey();
const iv = randomBytes(IV_LENGTH_BYTES);
const cipher = createCipheriv("aes-256-gcm", key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
return [KEY_VERSION, iv.toString("base64"), tag.toString("base64"), ciphertext.toString("base64")].join(":");
}
decrypt(stored: string): string {
const key = this.requireKey();
const [version, ivB64, tagB64, ciphertextB64] = stored.split(":");
if (version !== KEY_VERSION || !ivB64 || !tagB64 || !ciphertextB64) {
throw new Error("Formato de campo cifrado inválido ou versão de chave desconhecida");
}
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivB64, "base64"));
decipher.setAuthTag(Buffer.from(tagB64, "base64"));
const plaintext = Buffer.concat([
decipher.update(Buffer.from(ciphertextB64, "base64")),
decipher.final(),
]);
return plaintext.toString("utf8");
}
private requireKey(): Buffer {
if (!this.key) {
throw new Error("EDEN_FIELD_ENCRYPTION_KEY não configurada — obrigatória para criptografia de campo reversível");
}
return this.key;
}
}

View File

@@ -0,0 +1,21 @@
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
/**
* direction/scope are free-text, no CHECK/enum — exactly as the legacy
* (eden.md §4): the CFOP code's 1st digit encodes direction+scope in
* practice, but neither this DTO nor the schema derives it automatically.
*/
export class CreateCfopDto {
@IsString() code!: string;
@IsString() description!: string;
@IsOptional() @IsString() direction?: string;
@IsOptional() @IsString() scope?: string;
@IsOptional() @IsDateString() validFrom?: string;
@IsOptional() @IsDateString() validUntil?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() sourceVersion?: string;
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,12 @@
import { IsBoolean, IsOptional, IsString, Length } from "class-validator";
/** fiscal_municipality has NO valid_from/valid_until/metadata — eden.md §2.3 ("não faz sentido um município expirar"). */
export class CreateMunicipalityDto {
@IsString() code!: string;
@IsString() name!: string;
@IsString() @Length(2, 2) state!: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() sourceVersion?: string;
}

View File

@@ -0,0 +1,14 @@
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
export class CreateNcmDto {
@IsString() code!: string;
@IsString() description!: string;
@IsOptional() @IsDateString() validFrom?: string;
@IsOptional() @IsDateString() validUntil?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() sourceVersion?: string;
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,23 @@
import { IsDateString, IsIn, IsOptional, IsUUID } from "class-validator";
/**
* Creates a new ACTIVE profile for a (productId, billingComponent) pair.
* If one is already active, the service inactivates it and creates this
* one in the same transaction (eden.md §2.5's central invariant) — never a
* destructive update of the old profile.
*/
export class CreateProductFiscalProfileDto {
@IsIn(["RECURRING", "IMPLEMENTATION"])
billingComponent!: "RECURRING" | "IMPLEMENTATION";
@IsOptional()
@IsIn(["NFCOM", "NFSE", "NFE", "NONE"])
documentType?: "NFCOM" | "NFSE" | "NFE" | "NONE";
@IsOptional() @IsUUID() ncmId?: string;
@IsOptional() @IsUUID() cfopId?: string;
@IsOptional() @IsUUID() municipalityId?: string;
@IsOptional() @IsUUID() operationNatureId?: string;
@IsOptional() @IsDateString() validFrom?: string;
@IsOptional() @IsDateString() validUntil?: string;
}

View File

@@ -0,0 +1,15 @@
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
export class UpdateCfopDto {
@IsOptional() @IsString() code?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsString() direction?: string;
@IsOptional() @IsString() scope?: string;
@IsOptional() @IsDateString() validFrom?: string;
@IsOptional() @IsDateString() validUntil?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() sourceVersion?: string;
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,10 @@
import { IsBoolean, IsOptional, IsString, Length } from "class-validator";
export class UpdateMunicipalityDto {
@IsOptional() @IsString() code?: string;
@IsOptional() @IsString() name?: string;
@IsOptional() @IsString() @Length(2, 2) state?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() sourceVersion?: string;
}

View File

@@ -0,0 +1,14 @@
import { IsBoolean, IsDateString, IsObject, IsOptional, IsString } from "class-validator";
/** All fields optional — PATCH is a partial update, including flipping `active` alone. */
export class UpdateNcmDto {
@IsOptional() @IsString() code?: string;
@IsOptional() @IsString() description?: string;
@IsOptional() @IsDateString() validFrom?: string;
@IsOptional() @IsDateString() validUntil?: string;
@IsOptional() @IsBoolean() active?: boolean;
@IsOptional() @IsString() source?: string;
@IsOptional() @IsString() sourceVersion?: string;
@IsOptional() @IsDateString() sourceUpdatedAt?: string;
@IsOptional() @IsObject() metadata?: Record<string, unknown>;
}

View File

@@ -0,0 +1,142 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { FiscalService } from "./fiscal.service.js";
import { CreateNcmDto } from "./dto/create-ncm.dto.js";
import { UpdateNcmDto } from "./dto/update-ncm.dto.js";
import { CreateCfopDto } from "./dto/create-cfop.dto.js";
import { UpdateCfopDto } from "./dto/update-cfop.dto.js";
import { CreateMunicipalityDto } from "./dto/create-municipality.dto.js";
import { UpdateMunicipalityDto } from "./dto/update-municipality.dto.js";
import { CreateProductFiscalProfileDto } from "./dto/create-product-fiscal-profile.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Fiscal catalogs + product fiscal profiles (eden.md módulo 5). Uses the
* same `fiscal` permission feature as the legacy, with `view`/`edit`
* levels (eden.md §8). No bulk/mass operations in this MVP — writes always
* touch a single catalog row, never a whole table.
*/
@ApiTags("fiscal")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/fiscal")
export class FiscalController {
constructor(private readonly fiscal: FiscalService) {}
// -- fiscal_ncm ---------------------------------------------------------
@Get("ncm")
@RequirePermission("fiscal", "view")
listNcm(@Query("q") q?: string, @Query("active") active?: string) {
return this.fiscal.listNcm(q, parseActive(active));
}
@Get("ncm/:id")
@RequirePermission("fiscal", "view")
getNcm(@Param("id") id: string) {
return this.fiscal.getNcm(id);
}
@Post("ncm")
@RequirePermission("fiscal", "edit")
createNcm(@Body() dto: CreateNcmDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.fiscal.createNcm(dto, user);
}
@Patch("ncm/:id")
@RequirePermission("fiscal", "edit")
updateNcm(@Param("id") id: string, @Body() dto: UpdateNcmDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.fiscal.updateNcm(id, dto, user);
}
// -- fiscal_cfop ----------------------------------------------------------
@Get("cfop")
@RequirePermission("fiscal", "view")
listCfop(@Query("q") q?: string, @Query("active") active?: string) {
return this.fiscal.listCfop(q, parseActive(active));
}
@Get("cfop/:id")
@RequirePermission("fiscal", "view")
getCfop(@Param("id") id: string) {
return this.fiscal.getCfop(id);
}
@Post("cfop")
@RequirePermission("fiscal", "edit")
createCfop(@Body() dto: CreateCfopDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.fiscal.createCfop(dto, user);
}
@Patch("cfop/:id")
@RequirePermission("fiscal", "edit")
updateCfop(@Param("id") id: string, @Body() dto: UpdateCfopDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.fiscal.updateCfop(id, dto, user);
}
// -- fiscal_municipality --------------------------------------------------
@Get("municipalities")
@RequirePermission("fiscal", "view")
listMunicipalities(@Query("q") q?: string, @Query("active") active?: string) {
return this.fiscal.listMunicipalities(q, parseActive(active));
}
@Get("municipalities/:id")
@RequirePermission("fiscal", "view")
getMunicipality(@Param("id") id: string) {
return this.fiscal.getMunicipality(id);
}
@Post("municipalities")
@RequirePermission("fiscal", "edit")
createMunicipality(@Body() dto: CreateMunicipalityDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.fiscal.createMunicipality(dto, user);
}
@Patch("municipalities/:id")
@RequirePermission("fiscal", "edit")
updateMunicipality(
@Param("id") id: string,
@Body() dto: UpdateMunicipalityDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.fiscal.updateMunicipality(id, dto, user);
}
// -- fiscal_operation_nature — read-only in this MVP -----------------------
@Get("operation-natures")
@RequirePermission("fiscal", "view")
listOperationNatures() {
return this.fiscal.listOperationNatures();
}
// -- product_fiscal_profiles ----------------------------------------------
@Get("products/:productId/profiles")
@RequirePermission("fiscal", "view")
listProfiles(@Param("productId") productId: string) {
return this.fiscal.listProfiles(productId);
}
@Post("products/:productId/profiles")
@RequirePermission("fiscal", "edit")
createProfile(
@Param("productId") productId: string,
@Body() dto: CreateProductFiscalProfileDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.fiscal.createProfile(productId, dto, user);
}
}
function parseActive(value?: string): boolean | undefined {
if (value === undefined) return undefined;
return value === "true";
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { FiscalController } from "./fiscal.controller.js";
import { FiscalService } from "./fiscal.service.js";
@Module({
imports: [AuthModule],
controllers: [FiscalController],
providers: [FiscalService],
exports: [FiscalService],
})
export class FiscalModule {}

View File

@@ -0,0 +1,219 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import {
auditRepo,
fiscalRepo,
productsRepo,
type FiscalCfop,
type FiscalMunicipality,
type FiscalNcm,
type FiscalOperationNature,
type ProductFiscalProfile,
} from "@eden/database";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import type { CreateNcmDto } from "./dto/create-ncm.dto.js";
import type { UpdateNcmDto } from "./dto/update-ncm.dto.js";
import type { CreateCfopDto } from "./dto/create-cfop.dto.js";
import type { UpdateCfopDto } from "./dto/update-cfop.dto.js";
import type { CreateMunicipalityDto } from "./dto/create-municipality.dto.js";
import type { UpdateMunicipalityDto } from "./dto/update-municipality.dto.js";
import type { CreateProductFiscalProfileDto } from "./dto/create-product-fiscal-profile.dto.js";
/**
* Fiscal catalogs (eden.md módulo 5) — see PENDING_fiscal.cjs migration
* header for the MVP scope. Read (`fiscal:view`) is open to any
* authenticated user holding that grant; write (`fiscal:edit`) creates or
* PATCHes a single catalog row — no bulk/mass operations in this MVP.
*/
@Injectable()
export class FiscalService {
// ---------------------------------------------------------------------
// fiscal_ncm
// ---------------------------------------------------------------------
async listNcm(q?: string, active?: boolean): Promise<FiscalNcm[]> {
return fiscalRepo.listNcm({ q, active });
}
async getNcm(id: string): Promise<FiscalNcm> {
const row = await fiscalRepo.getNcmById(id);
if (!row) throw new NotFoundException("NCM não encontrado");
return row;
}
async createNcm(dto: CreateNcmDto, user: EdenAuthenticatedUser): Promise<FiscalNcm> {
const row = await fiscalRepo.createNcm(dto);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.ncm.created",
resourceType: "fiscal_ncm",
resourceId: row.id,
metadata: { code: row.code },
});
return row;
}
async updateNcm(id: string, dto: UpdateNcmDto, user: EdenAuthenticatedUser): Promise<FiscalNcm> {
const row = await fiscalRepo.updateNcm(id, dto);
if (!row) throw new NotFoundException("NCM não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.ncm.updated",
resourceType: "fiscal_ncm",
resourceId: row.id,
metadata: { ...dto },
});
return row;
}
// ---------------------------------------------------------------------
// fiscal_cfop
// ---------------------------------------------------------------------
async listCfop(q?: string, active?: boolean): Promise<FiscalCfop[]> {
return fiscalRepo.listCfop({ q, active });
}
async getCfop(id: string): Promise<FiscalCfop> {
const row = await fiscalRepo.getCfopById(id);
if (!row) throw new NotFoundException("CFOP não encontrado");
return row;
}
async createCfop(dto: CreateCfopDto, user: EdenAuthenticatedUser): Promise<FiscalCfop> {
const row = await fiscalRepo.createCfop(dto);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.cfop.created",
resourceType: "fiscal_cfop",
resourceId: row.id,
metadata: { code: row.code },
});
return row;
}
async updateCfop(id: string, dto: UpdateCfopDto, user: EdenAuthenticatedUser): Promise<FiscalCfop> {
const row = await fiscalRepo.updateCfop(id, dto);
if (!row) throw new NotFoundException("CFOP não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.cfop.updated",
resourceType: "fiscal_cfop",
resourceId: row.id,
metadata: { ...dto },
});
return row;
}
// ---------------------------------------------------------------------
// fiscal_municipality
// ---------------------------------------------------------------------
async listMunicipalities(q?: string, active?: boolean): Promise<FiscalMunicipality[]> {
return fiscalRepo.listMunicipalities({ q, active });
}
async getMunicipality(id: string): Promise<FiscalMunicipality> {
const row = await fiscalRepo.getMunicipalityById(id);
if (!row) throw new NotFoundException("Município não encontrado");
return row;
}
async createMunicipality(
dto: CreateMunicipalityDto,
user: EdenAuthenticatedUser,
): Promise<FiscalMunicipality> {
const row = await fiscalRepo.createMunicipality(dto);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.municipality.created",
resourceType: "fiscal_municipality",
resourceId: row.id,
metadata: { code: row.code },
});
return row;
}
async updateMunicipality(
id: string,
dto: UpdateMunicipalityDto,
user: EdenAuthenticatedUser,
): Promise<FiscalMunicipality> {
const row = await fiscalRepo.updateMunicipality(id, dto);
if (!row) throw new NotFoundException("Município não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.municipality.updated",
resourceType: "fiscal_municipality",
resourceId: row.id,
metadata: { ...dto },
});
return row;
}
// ---------------------------------------------------------------------
// fiscal_operation_nature — read-only in this MVP (fixed business enum,
// seeded by the migration)
// ---------------------------------------------------------------------
async listOperationNatures(): Promise<FiscalOperationNature[]> {
return fiscalRepo.listOperationNatures();
}
// ---------------------------------------------------------------------
// product_fiscal_profiles
// ---------------------------------------------------------------------
async listProfiles(productId: string): Promise<ProductFiscalProfile[]> {
await this.assertProductExists(productId);
return fiscalRepo.listProductFiscalProfiles(productId);
}
/**
* Creates a new ACTIVE profile for (productId, billingComponent). If one
* is already active, the repository layer inactivates it and creates the
* new one in a single transaction — never a destructive update of the
* old profile (eden.md §2.5).
*/
async createProfile(
productId: string,
dto: CreateProductFiscalProfileDto,
user: EdenAuthenticatedUser,
): Promise<ProductFiscalProfile> {
await this.assertProductExists(productId);
const created = await fiscalRepo.createActiveProductFiscalProfile({
productId,
billingComponent: dto.billingComponent,
documentType: dto.documentType,
ncmId: dto.ncmId ?? null,
cfopId: dto.cfopId ?? null,
municipalityId: dto.municipalityId ?? null,
operationNatureId: dto.operationNatureId ?? null,
validFrom: dto.validFrom ?? null,
validUntil: dto.validUntil ?? null,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "fiscal.product_profile.created",
resourceType: "product_fiscal_profile",
resourceId: created.id,
metadata: { productId, billingComponent: dto.billingComponent, documentType: created.document_type },
});
return created;
}
private async assertProductExists(productId: string): Promise<void> {
const product = await productsRepo.getProductById(productId);
if (!product) throw new NotFoundException("Produto não encontrado");
}
}

View File

@@ -0,0 +1,35 @@
import { Body, Controller, Get, Param, Post, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { AfdImportsService } from "./afd-imports.service.js";
import { CreateAfdImportDto } from "./dto/create-afd-import.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* eden.md módulo 6 §4.4/§4.5 — AFD import. See afd-imports.service.ts and
* CreateAfdImportDto for this leva's transport simplification (JSON
* string body, not multipart) and its documented encoding caveat.
*/
@ApiTags("hr-timeclock-afd-imports")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
@Controller("v1/hr-timeclock/afd-imports")
export class AfdImportsController {
constructor(private readonly afdImports: AfdImportsService) {}
@Post()
@RequirePermission("afd-import", "edit")
import(@Body() dto: CreateAfdImportDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.afdImports.import(dto, user);
}
@Get(":id")
@RequirePermission("afd-import", "view")
get(@Param("id") id: string) {
return this.afdImports.getById(id);
}
}

View File

@@ -0,0 +1,202 @@
import { createHash } from "node:crypto";
import { ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import {
auditRepo,
hrTimeclockRepo,
type AfdImportBatch,
type AfdImportBatchStatus,
type TimeclockEmployee,
} from "@eden/database";
import { parseAfdFile, normalizeAfdCpf, type AfdParseSummary } from "./afd-parser.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import type { CreateAfdImportDto } from "./dto/create-afd-import.dto.js";
export interface ImportAfdFileResult {
alreadyImported: boolean;
batch: AfdImportBatch;
summary?: AfdParseSummary;
}
function isUniqueViolation(err: unknown): boolean {
return typeof err === "object" && err !== null && (err as { code?: unknown }).code === "23505";
}
@Injectable()
export class AfdImportsService {
/**
* The whole AFD import orchestration: whole-file idempotency (SHA-256),
* pure parsing (afd-parser.ts — never touches the DB), best-effort
* employee resolution by CPF (never guesses — ambiguous/unmatched stays
* null), then the transactional write (hrTimeclockRepo.importAfdBatch,
* which does the per-device locking + per-record idempotency).
*
* ENCODING CAVEAT (see afd-parser.ts's header): `dto.fileContent` is
* expected to already be decoded as ISO-8859-1 by the caller — this
* leva's transport is a JSON string, not a raw multipart upload.
*/
async import(dto: CreateAfdImportDto, user: EdenAuthenticatedUser): Promise<ImportAfdFileResult> {
const device = await hrTimeclockRepo.getTimeclockDeviceById(dto.deviceId);
if (!device) throw new NotFoundException("Equipamento de ponto não encontrado");
// Whole-file idempotency layer (eden.md módulo 6 §12) — hash is over
// the content AS RECEIVED by this endpoint (see migration comment on
// afd_import_batches.file_hash for why this isn't necessarily
// byte-identical to a raw .afd file's own hash in this leva's
// string-transport simplification).
const fileHash = createHash("sha256").update(dto.fileContent, "utf8").digest("hex");
const fileSize = Buffer.byteLength(dto.fileContent, "utf8");
const existing = await hrTimeclockRepo.findAfdImportBatchByFileHash(fileHash);
if (existing) {
throw new ConflictException({
message: "Este arquivo já foi importado anteriormente (idempotência por SHA-256)",
alreadyImported: true,
importId: existing.id,
status: existing.status,
});
}
const parseResult = parseAfdFile(dto.fileContent);
const employeeIdByCpf = await this.resolveEmployeeIds(device.company_id, parseResult.records.map((r) => r.employeeCpfRaw));
const byStatus = parseResult.summary.byValidationStatus;
const validCount = byStatus.VALID ?? 0;
const warningCount = byStatus.WARNING ?? 0;
const invalidCount = byStatus.INVALID ?? 0;
const unsupportedCount = byStatus.UNSUPPORTED_LAYOUT ?? 0;
const parseFailureCount = parseResult.failures.length;
// Batch-level status: INVALID when nothing usable came out of the
// file at all; WARNING when the file is usable but has some
// invalid/unsupported/failed lines; VALID only when everything parsed
// clean. Never silently treated as fully VALID when problems exist.
const status: AfdImportBatchStatus =
validCount === 0 ? "INVALID" : invalidCount + unsupportedCount + parseFailureCount > 0 ? "WARNING" : "VALID";
let result;
try {
result = await hrTimeclockRepo.importAfdBatch({
batch: {
deviceId: dto.deviceId,
companyId: device.company_id,
fileHash,
fileSize,
recordCount: parseResult.summary.parsedCount,
validCount,
warningCount,
invalidCount,
unsupportedCount,
parseFailureCount,
status,
firstNsr: parseResult.summary.firstNsr,
lastNsr: parseResult.summary.lastNsr,
summary: parseResult.summary as unknown as Record<string, unknown>,
importedBy: user.id,
},
records: parseResult.records.map((r) => ({
nsr: r.nsr,
recordType: r.recordType,
eventDatetime: r.eventDatetime,
eventDatetimeRaw: r.eventDatetimeRaw,
employeeCpfRaw: r.employeeCpfRaw,
employeeId: employeeIdByCpf.get(r.employeeCpfRaw ?? "") ?? null,
rawLine: r.rawLine,
crcReceived: r.crcReceived,
crcCalculated: r.crcCalculated,
crcValid: r.crcValid,
parsedData: r.parsedData,
validationStatus: r.validationStatus,
validationNotes: r.validationNotes,
})),
});
} catch (err) {
if (err instanceof hrTimeclockRepo.TimeclockDeviceNotFoundError) {
throw new NotFoundException("Equipamento de ponto não encontrado");
}
if (isUniqueViolation(err)) {
// Race: another request imported the same file_hash between our
// check above and this INSERT. Never duplicate — report the
// existing batch exactly like the up-front check would have.
const raced = await hrTimeclockRepo.findAfdImportBatchByFileHash(fileHash);
if (raced) {
throw new ConflictException({
message: "Este arquivo já foi importado anteriormente (idempotência por SHA-256)",
alreadyImported: true,
importId: raced.id,
status: raced.status,
});
}
}
throw err;
}
// Import itself is a mutation — audited like any other, per the task
// brief's explicit instruction ("obrigatório em toda mutação incluindo
// o próprio import").
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "afd_import.imported",
resourceType: "afd_import_batch",
resourceId: result.batch.id,
metadata: {
deviceId: dto.deviceId,
fileHash,
recordCount: parseResult.summary.parsedCount,
insertedCount: result.insertedCount,
status,
},
});
return { alreadyImported: false, batch: result.batch, summary: parseResult.summary };
}
async getById(id: string): Promise<AfdImportBatch> {
const batch = await hrTimeclockRepo.getAfdImportBatchById(id);
if (!batch) throw new NotFoundException("Importação de AFD não encontrada");
return batch;
}
/**
* Best-effort CPF -> timeclock_employees.id resolution. NEVER guesses:
* a CPF matched in more than one company when the device has no
* company (or vice-versa, ambiguous within the same company — can't
* actually happen given the (company_id, cpf) UNIQUE constraint, but
* the code doesn't rely on that) is left unresolved (null) rather than
* picking arbitrarily. Returns a map keyed by the RAW (unnormalized)
* cpf field, since that's what afd_records.employee_cpf_raw stores and
* what callers look records up by.
*/
private async resolveEmployeeIds(
deviceCompanyId: string | null,
rawCpfs: (string | null)[],
): Promise<Map<string, string | null>> {
const normalizedByRaw = new Map<string, string>();
for (const raw of rawCpfs) {
if (!raw) continue;
const normalized = normalizeAfdCpf(raw);
if (normalized && normalized.length === 11) normalizedByRaw.set(raw, normalized);
}
const distinctNormalized = [...new Set(normalizedByRaw.values())];
const candidates = distinctNormalized.length > 0
? await hrTimeclockRepo.findTimeclockEmployeesByCpfs(distinctNormalized)
: [];
const candidatesByCpf = new Map<string, TimeclockEmployee[]>();
for (const employee of candidates) {
const list = candidatesByCpf.get(employee.cpf) ?? [];
list.push(employee);
candidatesByCpf.set(employee.cpf, list);
}
const result = new Map<string, string | null>();
for (const [raw, normalized] of normalizedByRaw) {
const matches = candidatesByCpf.get(normalized) ?? [];
const scoped = deviceCompanyId ? matches.filter((m) => m.company_id === deviceCompanyId) : matches;
result.set(raw, scoped.length === 1 ? scoped[0]!.id : null);
}
return result;
}
}

View File

@@ -0,0 +1,495 @@
/**
* AFD (Arquivo Fonte de Dados — Portaria MTP 671/2021, leiaute versão 004)
* parser/validator. Ported faithfully from the legacy OrçaFácil
* `parser.js`/`crc16.js` — see `eden.md` módulo 6 §4 (linhas ~3660-3771).
*
* Pure functions only — no I/O, no DB access. This module intentionally
* knows nothing about how the caller obtained the file content or what it
* will do with the parsed records (persist, preview, whatever).
*
* SCOPE OF THIS LEVA: parsing + CRC/structural validation only. Nothing
* here computes apuração (eden.md §5), fechamento de período (§6), ajustes
* (§7), banco de horas (§9) or relatórios (§10) — those are a future leva.
*
* ── Encoding note (real limitation of this leva's transport, documented
* per instruction) ──────────────────────────────────────────────────────
* The real AFD file is ISO-8859-1 (latin1), never UTF-8. This leva's HTTP
* endpoint accepts the file content as a plain JSON string (multipart
* upload is deferred — see afd-imports.controller.ts), which means the
* CALLER is responsible for decoding the original file bytes as latin1
* into a JS string *before* putting it in the JSON body. If the caller
* instead decodes as UTF-8 (or lets some HTTP client guess), any byte
* ≥ 0x80 (accented characters in type-2 `razao_social`/`local` fields —
* type-3 punch records, the ones that matter for attendance, are plain
* ASCII/digits and unaffected) will be corrupted and CRC validation for
* that line will legitimately fail (which is the CORRECT behavior for
* corrupted input — the parser never silently "fixes" a CRC mismatch).
* This module reconstructs bytes for CRC purposes via
* `Buffer.from(str, "latin1")`, which round-trips correctly only if the
* string was decoded as latin1 to begin with.
*
* ── Field positions ─────────────────────────────────────────────────────
* eden.md documents positions as 1-indexed, inclusive ranges (e.g.
* "011-034" = characters 11 through 34, 24 chars wide). Converted here to
* JS's 0-indexed, end-exclusive `slice(a-1, b)`. Every record starts with
* a 9-digit NSR (positions 001-009) + 1-digit record type (position 010),
* i.e. `slice(0, 9)` / `slice(9, 10)` — so every type-specific field list
* below picks up at `slice(10, ...)`, matching "011" in the source doc.
*
* Principle carried over verbatim from the legacy: only fields with an
* exact, documented position are decoded. Everything else — undocumented
* trailer bytes, unsupported record types, truncated lines — is captured
* as `raw_tail` inside `parsedData`, never guessed/invented.
*/
// ─────────────────────────────────────────────────────────────────────────
// CRC-16/KERMIT
// ─────────────────────────────────────────────────────────────────────────
/**
* CRC-16/KERMIT: polynomial 0x1021 reflected (used in the bit-reversed
* form 0x8408), init 0x0000, no final XOR. Applies to AFD record types
* 2/3/4 only — type 7 uses its own hash chain (never recomputed here) and
* types 1/5/6/9 have no CRC field at all.
*
* Known test vector (verified in afd-parser.spec.ts and via an ad-hoc
* script before this was wired into the import path): CRC-16/KERMIT of
* the ASCII string "123456789" is 0x2189.
*/
export function crc16Kermit(data: string | Buffer): number {
const buffer = typeof data === "string" ? Buffer.from(data, "latin1") : data;
let crc = 0x0000;
for (let i = 0; i < buffer.length; i++) {
crc ^= buffer[i]!;
for (let bit = 0; bit < 8; bit++) {
if (crc & 0x0001) {
crc = (crc >>> 1) ^ 0x8408;
} else {
crc = crc >>> 1;
}
}
}
return crc & 0xffff;
}
/** Zero-padded 4-hex-digit representation, uppercase, matching AFD's own CRC field format. */
export function crc16Hex(data: string | Buffer): string {
return crc16Kermit(data).toString(16).padStart(4, "0").toUpperCase();
}
// ─────────────────────────────────────────────────────────────────────────
// CPF normalization
// ─────────────────────────────────────────────────────────────────────────
/**
* The CPF field in AFD types 3/7 is 12 characters wide in the documented
* position, but a real CPF is 11 digits — eden.md §4.3: take the LAST 11
* numeric digits of the raw field, never assume exact formatting. If
* fewer than 11 digits are present (corrupt/short data), returns exactly
* what was found — never left-pads with invented zeros, since that would
* fabricate digits that were never actually in the file.
*/
export function normalizeAfdCpf(raw: string | null | undefined): string | null {
if (!raw) return null;
const digits = raw.replace(/\D/g, "");
if (digits.length === 0) return null;
return digits.length >= 11 ? digits.slice(-11) : digits;
}
// ─────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────
export type AfdCrcValidity = "VALID" | "INVALID" | "NOT_APPLICABLE" | "NOT_VALIDATED";
export type AfdValidationStatus = "VALID" | "WARNING" | "INVALID" | "UNSUPPORTED_LAYOUT";
export interface ParsedAfdRecord {
lineNumber: number;
nsr: number | null;
nsrRaw: string;
recordType: string;
eventDatetime: Date | null;
eventDatetimeRaw: string | null;
employeeCpfRaw: string | null;
rawLine: string;
crcReceived: string | null;
crcCalculated: string | null;
crcValid: AfdCrcValidity;
/** Type-specific decoded fields (only those with a documented position) + `raw_tail` for the rest. Never guessed. */
parsedData: Record<string, unknown>;
validationStatus: AfdValidationStatus;
validationNotes: string | null;
}
export interface AfdParseFailure {
lineNumber: number;
rawLine: string;
error: string;
}
export interface AfdParseSummary {
totalLines: number;
parsedCount: number;
failureCount: number;
byRecordType: Record<string, number>;
byValidationStatus: Record<string, number>;
/** Includes the sentinel trailer NSR (999999999); records whose NSR could not be parsed as a number are excluded. */
firstNsr: number | null;
lastNsr: number | null;
}
export interface AfdParseResult {
records: ParsedAfdRecord[];
failures: AfdParseFailure[];
summary: AfdParseSummary;
}
// ─────────────────────────────────────────────────────────────────────────
// Datetime parsing — AAAA-MM-ddThh:mm:00ZZZZZ (24 chars), e.g. "2026-08-06T08:30:00-0300"
// ─────────────────────────────────────────────────────────────────────────
const AFD_DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})([+-]\d{4})$/;
/**
* Parses the fixed-width AFD datetime field. The UTC offset is always
* taken verbatim from the text (never assumed/injected from the server's
* own timezone) — in practice this is always -0300 (America/Sao_Paulo,
* no DST in Brazil since 2019), but this function doesn't hardcode that;
* it trusts whatever offset the equipment actually wrote, exactly per the
* "never invent" principle. Returns null (never throws) if the 24-char
* field doesn't match the documented format — the caller still keeps the
* raw text in `eventDatetimeRaw` regardless.
*/
function parseAfdDatetime(raw: string): Date | null {
const match = AFD_DATETIME_RE.exec(raw.trim());
if (!match) return null;
const [, y, mo, d, h, mi, s, offset] = match;
const offsetWithColon = `${offset!.slice(0, 3)}:${offset!.slice(3)}`;
const iso = `${y}-${mo}-${d}T${h}:${mi}:${s}${offsetWithColon}`;
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? null : date;
}
// ─────────────────────────────────────────────────────────────────────────
// Per-line parsing
// ─────────────────────────────────────────────────────────────────────────
const HEADER_LENGTH = 10; // 9-digit NSR + 1-digit record type
function parseNsr(nsrRaw: string): number | null {
// Never default to 0/autoincrement — only a well-formed 9-digit numeric
// field becomes a number; anything else stays null (eden.md módulo 6 §12).
return /^\d{9}$/.test(nsrRaw) ? Number(nsrRaw) : null;
}
interface CrcCheck {
crcReceived: string | null;
crcCalculated: string | null;
crcValid: AfdCrcValidity;
note: string | null;
}
/** CRC is computed over the line bytes up to (not including) the CRC field itself, per eden.md §4.2. */
function checkCrc(line: string, dataEnd: number, crcFieldStart: number, crcFieldEnd: number): CrcCheck {
if (line.length < crcFieldEnd) {
return { crcReceived: null, crcCalculated: null, crcValid: "INVALID", note: "linha mais curta que o esperado para conter o campo de CRC" };
}
const crcReceived = line.slice(crcFieldStart, crcFieldEnd).trim();
const crcCalculated = crc16Hex(line.slice(0, dataEnd));
const valid = crcReceived.toUpperCase() === crcCalculated;
return {
crcReceived,
crcCalculated,
crcValid: valid ? "VALID" : "INVALID",
note: valid ? null : `CRC divergente: recebido=${crcReceived || "(vazio)"} calculado=${crcCalculated}`,
};
}
/**
* Parses a single AFD line. Never throws under normal malformed input —
* a line that doesn't fit any documented shape still produces a record
* (validationStatus INVALID/UNSUPPORTED_LAYOUT, nsr possibly null,
* rawLine always preserved) rather than being silently dropped, per
* eden.md's "preservar mesmo se inválido" invariant. `parseAfdFile` wraps
* this in a try/catch as a second line of defense for truly unexpected
* exceptions (defensive programming, not the normal path).
*/
export function parseAfdLine(rawLine: string, lineNumber: number): ParsedAfdRecord {
const notes: string[] = [];
if (rawLine.length < HEADER_LENGTH) {
return {
lineNumber,
nsr: null,
nsrRaw: rawLine,
recordType: "",
eventDatetime: null,
eventDatetimeRaw: null,
employeeCpfRaw: null,
rawLine,
crcReceived: null,
crcCalculated: null,
crcValid: "NOT_VALIDATED",
parsedData: { raw_tail: rawLine },
validationStatus: "INVALID",
validationNotes: "linha menor que o cabeçalho mínimo (NSR de 9 dígitos + 1 dígito de tipo, 10 caracteres)",
};
}
const nsrRaw = rawLine.slice(0, 9);
const recordType = rawLine.slice(9, 10);
const nsr = parseNsr(nsrRaw);
if (nsr === null) {
notes.push("NSR ausente ou não numérico — preservado como null, nunca substituído por 0/autoincrement");
}
let eventDatetimeRaw: string | null = null;
let employeeCpfRaw: string | null = null;
let parsedData: Record<string, unknown> = {};
let crcValid: AfdCrcValidity = "NOT_VALIDATED";
let crcReceived: string | null = null;
let crcCalculated: string | null = null;
let validationStatus: AfdValidationStatus = "VALID";
switch (recordType) {
case "1": {
// Cabeçalho do arquivo — só o NSR fixo (000000000) + tipo são documentados.
parsedData = { raw_tail: rawLine.slice(HEADER_LENGTH) };
if (nsrRaw !== "000000000") notes.push("NSR do cabeçalho difere do valor fixo esperado (000000000)");
break;
}
case "2": {
// Inclusão/alteração do empregador no REP
const dh = rawLine.slice(10, 34);
const cpfResp = rawLine.slice(34, 48);
const tipoIdent = rawLine.slice(48, 49);
const cnpjEmpregador = rawLine.slice(49, 63);
const cnoCaepf = rawLine.slice(63, 77);
const razaoSocial = rawLine.slice(77, 227);
const local = rawLine.slice(227, 327);
eventDatetimeRaw = dh;
parsedData = { dh, cpf_resp: cpfResp, tipo_ident: tipoIdent, cnpj_empregador: cnpjEmpregador, cno_caepf: cnoCaepf, razao_social: razaoSocial, local };
if (rawLine.length < 331) {
validationStatus = "INVALID";
notes.push("linha mais curta que o esperado para o tipo 2 (esperado >= 331 caracteres)");
crcValid = "INVALID";
} else {
const crc = checkCrc(rawLine, 327, 327, 331);
crcReceived = crc.crcReceived;
crcCalculated = crc.crcCalculated;
crcValid = crc.crcValid;
if (crc.note) { notes.push(crc.note); validationStatus = "INVALID"; }
}
break;
}
case "3": {
// Marcação REP-C/REP-A — a principal (batida de ponto convencional)
const dh = rawLine.slice(10, 34);
const cpf = rawLine.slice(34, 46);
eventDatetimeRaw = dh;
employeeCpfRaw = cpf;
parsedData = { dh, cpf };
if (rawLine.length < 50) {
validationStatus = "INVALID";
notes.push("linha mais curta que o esperado para o tipo 3 (esperado 50 caracteres)");
crcValid = "INVALID";
} else {
const crc = checkCrc(rawLine, 46, 46, 50);
crcReceived = crc.crcReceived;
crcCalculated = crc.crcCalculated;
crcValid = crc.crcValid;
if (crc.note) { notes.push(crc.note); validationStatus = "INVALID"; }
}
break;
}
case "4": {
// Ajuste do relógio do REP
const dhAntes = rawLine.slice(10, 34);
const dhAjustada = rawLine.slice(34, 58);
const cpfResp = rawLine.slice(58, 69);
eventDatetimeRaw = dhAjustada;
// cpf_resp here is the CPF of whoever adjusted the clock, not
// necessarily a timeclock_employees row — stored in the same
// generic employee_cpf_raw column; resolution against
// timeclock_employees simply won't match if they aren't one
// (harmless — never fabricates a match).
employeeCpfRaw = cpfResp;
parsedData = { dh_antes: dhAntes, dh_ajustada: dhAjustada, cpf_resp: cpfResp };
if (rawLine.length < 73) {
validationStatus = "INVALID";
notes.push("linha mais curta que o esperado para o tipo 4 (esperado 73 caracteres)");
crcValid = "INVALID";
} else {
const crc = checkCrc(rawLine, 69, 69, 73);
crcReceived = crc.crcReceived;
crcCalculated = crc.crcCalculated;
crcValid = crc.crcValid;
if (crc.note) { notes.push(crc.note); validationStatus = "INVALID"; }
}
break;
}
case "5": {
// Inclusão/alteração/exclusão de empregado no REP — só dh + operação documentados
const dh = rawLine.slice(10, 34);
const operacao = rawLine.slice(34, 35);
eventDatetimeRaw = dh;
parsedData = { dh, operacao, raw_tail: rawLine.slice(35) };
if (!["I", "A", "E"].includes(operacao)) {
notes.push(`código de operação não documentado: '${operacao}'`);
validationStatus = "WARNING";
}
break;
}
case "6": {
// Evento sensível do REP — só dh documentado
const dh = rawLine.slice(10, 34);
eventDatetimeRaw = dh;
parsedData = { dh, raw_tail: rawLine.slice(34) };
break;
}
case "7": {
// Marcação REP-P — usa hash próprio de encadeamento, NUNCA recalculado aqui
const dhMarcacao = rawLine.slice(10, 34);
const cpf = rawLine.slice(34, 46);
const dhGravacao = rawLine.slice(46, 70);
const coletor = rawLine.slice(70, 72);
const onlineOffline = rawLine.slice(72, 73);
const hash = rawLine.slice(73, 137);
eventDatetimeRaw = dhMarcacao;
employeeCpfRaw = cpf;
parsedData = { dh_marcacao: dhMarcacao, cpf, dh_gravacao: dhGravacao, coletor, online_offline: onlineOffline, hash };
crcValid = "NOT_APPLICABLE";
if (rawLine.length < 137) {
validationStatus = "INVALID";
notes.push("linha mais curta que o esperado para o tipo 7 (esperado 137 caracteres, hash de 64 posições incompleto)");
}
break;
}
case "9": {
// Trailer — só o NSR fixo (999999999) é documentado; contagens internas não têm posição documentada.
parsedData = { raw_tail: rawLine.slice(HEADER_LENGTH), trailer_cross_check: "not_implemented_no_documented_positions" };
if (nsrRaw !== "999999999") notes.push("NSR do trailer difere do valor fixo esperado (999999999)");
break;
}
default: {
parsedData = { raw_tail: rawLine.slice(HEADER_LENGTH) };
validationStatus = "UNSUPPORTED_LAYOUT";
notes.push(`tipo de registro não reconhecido: '${recordType}'`);
break;
}
}
const eventDatetime = eventDatetimeRaw ? parseAfdDatetime(eventDatetimeRaw) : null;
if (eventDatetimeRaw && eventDatetime === null) {
notes.push(`campo de data/hora fora do formato esperado (AAAA-MM-ddThh:mm:00ZZZZZ): '${eventDatetimeRaw}'`);
if (validationStatus === "VALID") validationStatus = "WARNING";
}
if (nsr === null && validationStatus === "VALID") {
validationStatus = "INVALID";
}
return {
lineNumber,
nsr,
nsrRaw,
recordType,
eventDatetime,
eventDatetimeRaw,
employeeCpfRaw,
rawLine,
crcReceived,
crcCalculated,
crcValid,
parsedData,
validationStatus,
validationNotes: notes.length > 0 ? notes.join("; ") : null,
};
}
// ─────────────────────────────────────────────────────────────────────────
// File-level parsing
// ─────────────────────────────────────────────────────────────────────────
/**
* Splits AFD file content into lines (CRLF per spec; also tolerates bare
* LF defensively) and parses each one. Never throws for a bad line and
* never stops processing because of one — see module doc for the
* "records vs. failures" partial-error policy:
*
* - A line that is merely malformed AFD data (wrong type, bad CRC, too
* short, unsupported record type, etc.) still produces a `records`
* entry with an INVALID/WARNING/UNSUPPORTED_LAYOUT status — it is
* NEVER dropped, because raw_line is legally-relevant raw data that
* must always be preserved (eden.md módulo 6 §12).
* - A line that causes an actual unexpected exception in `parseAfdLine`
* (a defensive safety net, not the normal path) still produces a
* fallback INVALID record so nothing is silently lost, AND is also
* recorded in `failures` for import-summary visibility.
*
* Fully blank lines (an artifact of a trailing CRLF at EOF, or stray
* blank lines in a hand-edited file) are skipped without generating a
* record — they carry no data to preserve.
*/
export function parseAfdFile(content: string): AfdParseResult {
const rawLines = content.split(/\r\n|\n\r|\n/);
const records: ParsedAfdRecord[] = [];
const failures: AfdParseFailure[] = [];
let lineNumber = 0;
for (const line of rawLines) {
lineNumber += 1;
if (line.length === 0) continue; // trailing EOF newline / stray blank line — no data to preserve
try {
records.push(parseAfdLine(line, lineNumber));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
failures.push({ lineNumber, rawLine: line, error: message });
records.push({
lineNumber,
nsr: null,
nsrRaw: line.slice(0, 9),
recordType: line.slice(9, 10) || "",
eventDatetime: null,
eventDatetimeRaw: null,
employeeCpfRaw: null,
rawLine: line,
crcReceived: null,
crcCalculated: null,
crcValid: "NOT_VALIDATED",
parsedData: { raw_tail: line },
validationStatus: "INVALID",
validationNotes: `Erro inesperado ao parsear linha: ${message}`,
});
}
}
const byRecordType: Record<string, number> = {};
const byValidationStatus: Record<string, number> = {};
let firstNsr: number | null = null;
let lastNsr: number | null = null;
for (const record of records) {
byRecordType[record.recordType] = (byRecordType[record.recordType] ?? 0) + 1;
byValidationStatus[record.validationStatus] = (byValidationStatus[record.validationStatus] ?? 0) + 1;
if (record.nsr !== null) {
if (firstNsr === null) firstNsr = record.nsr;
lastNsr = record.nsr;
}
}
return {
records,
failures,
summary: {
totalLines: lineNumber,
parsedCount: records.length,
failureCount: failures.length,
byRecordType,
byValidationStatus,
firstNsr,
lastNsr,
},
};
}

View File

@@ -0,0 +1,37 @@
import { Controller, Get, Param, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { AfdRecordsService } from "./afd-records.service.js";
import { ListAfdRecordsQueryDto } from "./dto/list-afd-records-query.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
/**
* READ-ONLY — eden.md módulo 6 §12: afd_records (dado bruto de ponto) é
* imutável. This controller intentionally has no POST/PATCH/PUT/DELETE
* handler, at any permission level — the only way a row is ever created
* is AfdImportsController's import endpoint (via
* hrTimeclockRepo.importAfdBatch's INSERT), and the database itself
* additionally rejects UPDATE/DELETE via a trigger with no bypass (see
* the migration) — so this is defense in depth, not the only guard.
*/
@ApiTags("hr-timeclock-afd-records")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
@Controller("v1/hr-timeclock/afd-records")
export class AfdRecordsController {
constructor(private readonly afdRecords: AfdRecordsService) {}
@Get()
@RequirePermission("afd-import", "view")
list(@Query() query: ListAfdRecordsQueryDto) {
return this.afdRecords.list(query);
}
@Get(":id")
@RequirePermission("afd-import", "view")
get(@Param("id") id: string) {
return this.afdRecords.get(id);
}
}

View File

@@ -0,0 +1,45 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { hrTimeclockRepo, type AfdRecord } from "@eden/database";
import type { ListAfdRecordsQueryDto } from "./dto/list-afd-records-query.dto.js";
/**
* Read-only, matching eden.md §4.5 ("nunca há rota de escrita para esta
* tabela"). There is deliberately no create/update/delete method here —
* see hr-timeclock/afd-records.controller.ts, which exposes GET only.
*/
@Injectable()
export class AfdRecordsService {
async list(query: ListAfdRecordsQueryDto): Promise<{ records: AfdRecord[]; total: number; limit: number; offset: number }> {
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
// dateTo is inclusive at the day level per eden.md §4.5 ("date_to usa
// limite exclusivo do dia seguinte para incluir o dia inteiro") — the
// query DTO takes a plain date/datetime string; if it looks like a
// bare date (no time component), advance to the start of the next
// day so the whole end day is included.
let dateTo: Date | undefined;
if (query.dateTo) {
const parsed = new Date(query.dateTo);
dateTo = /^\d{4}-\d{2}-\d{2}$/.test(query.dateTo) ? new Date(parsed.getTime() + 24 * 60 * 60 * 1000) : parsed;
}
const { records, total } = await hrTimeclockRepo.listAfdRecords({
deviceId: query.deviceId,
employeeId: query.employeeId,
recordType: query.recordType,
dateFrom: query.dateFrom ? new Date(query.dateFrom) : undefined,
dateTo,
limit,
offset,
});
return { records, total, limit, offset };
}
async get(id: string): Promise<AfdRecord> {
const record = await hrTimeclockRepo.getAfdRecordById(id);
if (!record) throw new NotFoundException("Registro de AFD não encontrado");
return record;
}
}

View File

@@ -0,0 +1,20 @@
import { IsString, IsUUID, MinLength } from "class-validator";
/**
* SIMPLIFICATION OF THIS LEVA: the AFD file is accepted as a plain string
* in the JSON body, not a real multipart upload — see
* afd-imports.controller.ts / afd-parser.ts header comments for why (and
* the encoding caveat this implies). Real multipart upload (matching
* eden.md §4.5's `POST /afd-imports/import`) is future work.
*/
export class CreateAfdImportDto {
@IsUUID() deviceId!: string;
/**
* The AFD file's full text content, decoded by the CALLER as
* ISO-8859-1 (latin1) — see afd-parser.ts's encoding note. Sending
* UTF-8-decoded content here will cause CRC validation to legitimately
* fail for any line with a byte ≥ 0x80.
*/
@IsString() @MinLength(1) fileContent!: string;
}

View File

@@ -0,0 +1,20 @@
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min, MinLength } from "class-validator";
export class CreateTimeclockDeviceDto {
@IsOptional() @IsUUID() companyId?: string;
@IsString() @MinLength(1) @MaxLength(200) name!: string;
@IsOptional() @IsString() @MaxLength(2000) description?: string;
@IsOptional() @IsString() @MaxLength(200) branch?: string;
@IsString() @MinLength(1) @MaxLength(255) host!: string;
@IsOptional() @IsInt() @Min(1) @Max(65535) port?: number;
@IsOptional() @IsBoolean() useHttps?: boolean;
@IsString() @MinLength(1) @MaxLength(200) username!: string;
/** Plaintext in — encrypted server-side via FieldEncryptionService before storage, never persisted or logged as-is. */
@IsString() @MinLength(1) @MaxLength(500) password!: string;
@IsOptional() @IsString() @MaxLength(100) timezone?: string;
}

View File

@@ -0,0 +1,18 @@
import { Type } from "class-transformer";
import { IsDateString, IsIn, IsInt, IsOptional, IsUUID, Max, Min } from "class-validator";
const AFD_RECORD_TYPES = ["1", "2", "3", "4", "5", "6", "7", "9"] as const;
export class ListAfdRecordsQueryDto {
@IsOptional() @IsUUID() deviceId?: string;
@IsOptional() @IsUUID() employeeId?: string;
@IsOptional() @IsIn(AFD_RECORD_TYPES) recordType?: string;
/** Inclusive lower bound (ISO date/datetime string). */
@IsOptional() @IsDateString() dateFrom?: string;
/** Inclusive upper bound at the DAY level — the service applies the next-day-exclusive convention (eden.md §4.5). */
@IsOptional() @IsDateString() dateTo?: string;
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) limit?: number;
@IsOptional() @Type(() => Number) @IsInt() @Min(0) offset?: number;
}

View File

@@ -0,0 +1,18 @@
import { IsBoolean, IsInt, IsOptional, IsString, Max, MaxLength, Min, MinLength } from "class-validator";
export class UpdateTimeclockDeviceDto {
@IsOptional() @IsString() @MinLength(1) @MaxLength(200) name?: string;
@IsOptional() @IsString() @MaxLength(2000) description?: string;
@IsOptional() @IsString() @MaxLength(200) branch?: string;
@IsOptional() @IsString() @MinLength(1) @MaxLength(255) host?: string;
@IsOptional() @IsInt() @Min(1) @Max(65535) port?: number;
@IsOptional() @IsBoolean() useHttps?: boolean;
@IsOptional() @IsString() @MinLength(1) @MaxLength(200) username?: string;
/** Only re-encrypted/stored when present — omit to leave the current credential untouched. */
@IsOptional() @IsString() @MinLength(1) @MaxLength(500) password?: string;
@IsOptional() @IsBoolean() active?: boolean;
}

View File

@@ -0,0 +1,45 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "../config/config.module.js";
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { TokenService } from "../auth/token.service.js";
import { TimeclockDevicesController } from "./timeclock-devices.controller.js";
import { TimeclockDevicesService } from "./timeclock-devices.service.js";
import { AfdImportsController } from "./afd-imports.controller.js";
import { AfdImportsService } from "./afd-imports.service.js";
import { AfdRecordsController } from "./afd-records.controller.js";
import { AfdRecordsService } from "./afd-records.service.js";
/**
* HR/Timeclock (eden.md módulo 6) — foundation leva. See
* afd-parser.ts and packages/database/migrations/PENDING_hr-timeclock.cjs
* for the full scope/deferral notes.
*
* INTEGRATION NOTE for whoever merges this: `ConfigModule`,
* `FieldEncryptionService`, `JwtAuthGuard`, `PermissionGuard` and
* `TokenService` are provided directly here (rather than imported from a
* shared `AuthModule`) because this worktree was isolated at a bootstrap
* commit with no Identity/Auth module yet — see apps/api/src/auth/*'s
* header comments. Once the real Identity/Auth module lands with its own
* `AuthModule` (exporting these same classes) and a global `ConfigModule`
* wired into `AppModule`, this module's `providers` list should shrink to
* just the HR-specific services and `imports: [AuthModule]` should
* replace the local guard/token/config providers below — no controller or
* service code needs to change, since they all import from the same
* `../auth/...`/`../crypto/...` paths either way.
*/
@Module({
imports: [ConfigModule],
controllers: [TimeclockDevicesController, AfdImportsController, AfdRecordsController],
providers: [
FieldEncryptionService,
TokenService,
JwtAuthGuard,
PermissionGuard,
TimeclockDevicesService,
AfdImportsService,
AfdRecordsService,
],
})
export class HrTimeclockModule {}

View File

@@ -0,0 +1,57 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { TimeclockDevicesService } from "./timeclock-devices.service.js";
import { CreateTimeclockDeviceDto } from "./dto/create-timeclock-device.dto.js";
import { UpdateTimeclockDeviceDto } from "./dto/update-timeclock-device.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* eden.md módulo 6 §1.1/§2 — Control iD REP iDClass device registry.
* password_encrypted is never returned in plaintext by any route here —
* see TimeclockDevicesService's toView (hasCredential flag only).
*
* NOTE: `@UsePipes(ValidationPipe)` is applied locally (rather than
* relying on a global pipe in main.ts) because this bootstrap-stage
* worktree's main.ts doesn't wire one up yet — see this task's final
* summary. Harmless/redundant once the real global pipe lands.
*/
@ApiTags("hr-timeclock-devices")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
@Controller("v1/hr-timeclock/devices")
export class TimeclockDevicesController {
constructor(private readonly devices: TimeclockDevicesService) {}
@Get()
@RequirePermission("timeclock-devices", "view")
list(@Query("includeInactive") includeInactive?: string) {
return this.devices.list(includeInactive === "true");
}
@Get(":id")
@RequirePermission("timeclock-devices", "view")
get(@Param("id") id: string) {
return this.devices.get(id);
}
@Post()
@RequirePermission("timeclock-devices", "edit")
create(@Body() dto: CreateTimeclockDeviceDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.devices.create(dto, user);
}
@Patch(":id")
@RequirePermission("timeclock-devices", "edit")
update(
@Param("id") id: string,
@Body() dto: UpdateTimeclockDeviceDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.devices.update(id, dto, user);
}
}

View File

@@ -0,0 +1,95 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { auditRepo, hrTimeclockRepo, type TimeclockDevice } from "@eden/database";
import { FieldEncryptionService } from "../crypto/field-encryption.service.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import type { CreateTimeclockDeviceDto } from "./dto/create-timeclock-device.dto.js";
import type { UpdateTimeclockDeviceDto } from "./dto/update-timeclock-device.dto.js";
/** Never expose password_encrypted — GET responses only ever carry hasCredential. */
export type TimeclockDeviceView = Omit<TimeclockDevice, "password_encrypted"> & { hasCredential: boolean };
function toView(device: TimeclockDevice): TimeclockDeviceView {
const { password_encrypted, ...rest } = device;
return { ...rest, hasCredential: password_encrypted.length > 0 };
}
@Injectable()
export class TimeclockDevicesService {
constructor(private readonly fieldEncryption: FieldEncryptionService) {}
async list(includeInactive: boolean): Promise<TimeclockDeviceView[]> {
const devices = await hrTimeclockRepo.listTimeclockDevices(includeInactive);
return devices.map(toView);
}
async get(id: string): Promise<TimeclockDeviceView> {
const device = await hrTimeclockRepo.getTimeclockDeviceById(id);
if (!device) throw new NotFoundException("Equipamento de ponto não encontrado");
return toView(device);
}
async create(dto: CreateTimeclockDeviceDto, user: EdenAuthenticatedUser): Promise<TimeclockDeviceView> {
const passwordEncrypted = this.fieldEncryption.encrypt(dto.password);
const device = await hrTimeclockRepo.createTimeclockDevice({
companyId: dto.companyId ?? null,
name: dto.name,
description: dto.description ?? null,
branch: dto.branch ?? null,
host: dto.host,
port: dto.port,
useHttps: dto.useHttps,
username: dto.username,
passwordEncrypted,
timezone: dto.timezone,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "timeclock_device.created",
resourceType: "timeclock_device",
resourceId: device.id,
metadata: { name: device.name, host: device.host },
});
return toView(device);
}
async update(
id: string,
dto: UpdateTimeclockDeviceDto,
user: EdenAuthenticatedUser,
): Promise<TimeclockDeviceView> {
await this.get(id); // 404 before attempting the update
const passwordEncrypted = dto.password ? this.fieldEncryption.encrypt(dto.password) : undefined;
const device = await hrTimeclockRepo.updateTimeclockDevice(id, {
name: dto.name,
description: dto.description,
branch: dto.branch,
host: dto.host,
port: dto.port,
useHttps: dto.useHttps,
username: dto.username,
passwordEncrypted,
active: dto.active,
updatedBy: user.id,
});
if (!device) throw new NotFoundException("Equipamento de ponto não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "timeclock_device.updated",
resourceType: "timeclock_device",
resourceId: id,
// Never log the password itself, even encrypted — just whether it changed.
metadata: { fields: Object.keys(dto).filter((f) => f !== "password"), credentialChanged: passwordEncrypted !== undefined },
});
return toView(device);
}
}

View File

@@ -0,0 +1,21 @@
import { IsDateString, IsOptional, IsString, IsUUID, MinLength } from "class-validator";
/**
* At least one of serial/patrimonyTag is required — enforced in the
* service (not here, class-validator doesn't do cross-field rules
* cleanly) so the 400 message can name both fields together. Mirrors the
* DB-level chk_serialized_assets_has_identifier constraint.
*/
export class CreateAssetDto {
@IsUUID()
productId!: string;
@IsUUID()
warehouseId!: string;
@IsOptional() @IsString() @MinLength(1) serial?: string;
@IsOptional() @IsString() @MinLength(1) patrimonyTag?: string;
@IsOptional() @IsString() @MinLength(1) macAddress?: string;
@IsOptional() @IsDateString() warrantyExpiresAt?: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,22 @@
import { IsNumber, IsOptional, IsString, IsUUID, Min } from "class-validator";
/**
* Generic reservation — product+quantity, no serial selected yet. The
* first step of the Master Prompt §6.8 flow (reserve → later pick the
* specific serialized unit → install).
*/
export class CreateReservationDto {
@IsUUID()
warehouseId!: string;
@IsUUID()
productId!: string;
@IsNumber()
@Min(0.001)
quantity!: number;
@IsOptional() @IsString() referenceType?: string;
@IsOptional() @IsUUID() referenceId?: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,16 @@
import { IsBoolean, IsOptional, IsString, IsUUID, MinLength } from "class-validator";
export class CreateWarehouseDto {
@IsUUID()
companyId!: string;
@IsString()
@MinLength(1)
name!: string;
@IsString()
@MinLength(1)
code!: string;
@IsOptional() @IsBoolean() isActive?: boolean;
}

View File

@@ -0,0 +1,11 @@
import { IsIn, IsOptional, IsString, IsUUID } from "class-validator";
export class InstallAssetDto {
@IsUUID()
contractId!: string;
@IsIn(["installed", "comodato"])
assignmentType!: "installed" | "comodato";
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsOptional, IsString, IsUUID } from "class-validator";
export class ReserveAssetDto {
@IsOptional() @IsString() referenceType?: string;
@IsOptional() @IsUUID() referenceId?: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,9 @@
import { IsOptional, IsString, IsUUID } from "class-validator";
export class ReturnAssetDto {
/** Warehouse receiving the physical unit back — see serialized_assets.warehouse_id comment in the migration for why this can't default to "the warehouse it left from" (that location was cleared on install). */
@IsUUID()
warehouseId!: string;
@IsOptional() @IsString() notes?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsBoolean, IsOptional, IsString, MinLength } from "class-validator";
export class UpdateWarehouseDto {
@IsOptional() @IsString() @MinLength(1) name?: string;
@IsOptional() @IsString() @MinLength(1) code?: string;
@IsOptional() @IsBoolean() isActive?: boolean;
}

View File

@@ -0,0 +1,120 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { InventoryService } from "./inventory.service.js";
import { CreateAssetDto } from "./dto/create-asset.dto.js";
import { ReserveAssetDto } from "./dto/reserve-asset.dto.js";
import { InstallAssetDto } from "./dto/install-asset.dto.js";
import { ReturnAssetDto } from "./dto/return-asset.dto.js";
import { CreateReservationDto } from "./dto/create-reservation.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Serialized assets, the stock ledger, and reservations — Master Prompt
* §6.8, ADR-0007. Full cycle modeled here: reserve (generic or a specific
* serial) → install/comodato → return. Every mutation appends a
* stock_movement (ledger, never an editable balance column — see
* PENDING_inventory.cjs's header comment) and an audit_log event.
*/
@ApiTags("inventory")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/inventory")
export class InventoryController {
constructor(private readonly inventory: InventoryService) {}
@Get("assets")
@RequirePermission("inventory", "view")
listAssets(
@Query("status") status?: string,
@Query("warehouseId") warehouseId?: string,
@Query("productId") productId?: string,
) {
return this.inventory.listAssets(status, warehouseId, productId);
}
@Get("assets/:id")
@RequirePermission("inventory", "view")
getAsset(@Param("id") id: string) {
return this.inventory.getAsset(id);
}
@Post("assets")
@RequirePermission("inventory", "edit")
createAsset(@Body() dto: CreateAssetDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.inventory.createAsset(dto, user);
}
/**
* Reserves this SPECIFIC serialized asset (quantity is always 1 —
* the serial has already been picked). For the generic product+quantity
* reservation described in the Master Prompt as the step BEFORE a
* serial is picked, see POST /v1/inventory/reservations instead.
*/
@Post("assets/:id/reserve")
@RequirePermission("inventory", "edit")
reserveAsset(
@Param("id") id: string,
@Body() dto: ReserveAssetDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.inventory.reserveAsset(id, dto, user);
}
@Post("assets/:id/install")
@RequirePermission("inventory", "edit")
installAsset(
@Param("id") id: string,
@Body() dto: InstallAssetDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.inventory.installAsset(id, dto, user);
}
@Post("assets/:id/return")
@RequirePermission("inventory", "edit")
returnAsset(
@Param("id") id: string,
@Body() dto: ReturnAssetDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.inventory.returnAsset(id, dto, user);
}
@Get("movements")
@RequirePermission("inventory", "view")
listMovements(
@Query("warehouseId") warehouseId?: string,
@Query("productId") productId?: string,
@Query("referenceType") referenceType?: string,
@Query("referenceId") referenceId?: string,
) {
return this.inventory.listMovements(warehouseId, productId, referenceType, referenceId);
}
/** Generic quantity reservation (product+warehouse, no serial picked yet) — the first step of the Master Prompt §6.8 flow. */
@Get("reservations")
@RequirePermission("inventory", "view")
listReservations(
@Query("status") status?: string,
@Query("warehouseId") warehouseId?: string,
@Query("productId") productId?: string,
) {
return this.inventory.listReservations(status, warehouseId, productId);
}
@Post("reservations")
@RequirePermission("inventory", "edit")
createReservation(@Body() dto: CreateReservationDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.inventory.createReservation(dto, user);
}
@Post("reservations/:id/release")
@RequirePermission("inventory", "edit")
releaseReservation(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.inventory.releaseReservation(id, user);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { WarehousesController } from "./warehouses.controller.js";
import { WarehousesService } from "./warehouses.service.js";
import { InventoryController } from "./inventory.controller.js";
import { InventoryService } from "./inventory.service.js";
/** Warehouses/Assets/Stock — Master Prompt §6.8, ADR-0007. Not yet imported by app.module.ts (integration step left to the merge, see .claude/agents/eden-inventory.md). */
@Module({
imports: [AuthModule],
controllers: [WarehousesController, InventoryController],
providers: [WarehousesService, InventoryService],
exports: [WarehousesService, InventoryService],
})
export class InventoryModule {}

View File

@@ -0,0 +1,268 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import {
auditRepo,
inventoryRepo,
productsRepo,
type SerializedAsset,
type StockMovement,
type StockReservation,
} from "@eden/database";
import type { CreateAssetDto } from "./dto/create-asset.dto.js";
import type { ReserveAssetDto } from "./dto/reserve-asset.dto.js";
import type { InstallAssetDto } from "./dto/install-asset.dto.js";
import type { ReturnAssetDto } from "./dto/return-asset.dto.js";
import type { CreateReservationDto } from "./dto/create-reservation.dto.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
const {
AssetNotFoundError,
InvalidAssetTransitionError,
NoOpenAssignmentError,
ReservationNotFoundError,
InvalidReservationTransitionError,
} = inventoryRepo;
@Injectable()
export class InventoryService {
// ---------------------------------------------------------------------
// serialized_assets
// ---------------------------------------------------------------------
listAssets(status?: string, warehouseId?: string, productId?: string): Promise<SerializedAsset[]> {
return inventoryRepo.listSerializedAssets({
status: status as SerializedAsset["status"] | undefined,
warehouseId,
productId,
});
}
async getAsset(id: string): Promise<SerializedAsset> {
const asset = await inventoryRepo.getSerializedAssetById(id);
if (!asset) throw new NotFoundException("Ativo não encontrado");
return asset;
}
async createAsset(dto: CreateAssetDto, user: EdenAuthenticatedUser): Promise<SerializedAsset> {
if (!dto.serial && !dto.patrimonyTag) {
throw new BadRequestException("Informe ao menos serial ou patrimonyTag");
}
const product = await productsRepo.getProductById(dto.productId);
if (!product) throw new BadRequestException(`Produto ${dto.productId} não encontrado`);
const warehouse = await inventoryRepo.getWarehouseById(dto.warehouseId);
if (!warehouse) throw new BadRequestException(`Armazém ${dto.warehouseId} não encontrado`);
let result: Awaited<ReturnType<typeof inventoryRepo.createAssetWithPurchaseMovement>>;
try {
result = await inventoryRepo.createAssetWithPurchaseMovement({
productId: dto.productId,
warehouseId: dto.warehouseId,
serial: dto.serial ?? null,
patrimonyTag: dto.patrimonyTag ?? null,
macAddress: dto.macAddress ?? null,
warrantyExpiresAt: dto.warrantyExpiresAt ?? null,
notes: dto.notes ?? null,
createdBy: user.id,
});
} catch (err) {
// 23505 = unique_violation — the DB-enforced "serial/patrimony/MAC
// never active twice" invariant (see migration).
if (isUniqueViolation(err)) {
throw new ConflictException(
"Serial, patrimônio ou MAC já está em uso por outro ativo ativo",
);
}
throw err;
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "inventory.asset_created",
resourceType: "serialized_asset",
resourceId: result.asset.id,
metadata: { productId: dto.productId, warehouseId: dto.warehouseId, serial: dto.serial ?? null },
});
return result.asset;
}
async reserveAsset(id: string, dto: ReserveAssetDto, user: EdenAuthenticatedUser): Promise<SerializedAsset> {
try {
const result = await inventoryRepo.reserveAsset(id, {
referenceType: dto.referenceType ?? null,
referenceId: dto.referenceId ?? null,
notes: dto.notes ?? null,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "inventory.asset_reserved",
resourceType: "serialized_asset",
resourceId: id,
metadata: { reservationId: result.reservation.id },
});
return result.asset;
} catch (err) {
if (err instanceof AssetNotFoundError) throw new NotFoundException("Ativo não encontrado");
if (err instanceof InvalidAssetTransitionError) {
throw new ConflictException(
`Ativo com status '${err.fromStatus}' não pode ser reservado (esperado: in_stock)`,
);
}
throw err;
}
}
async installAsset(id: string, dto: InstallAssetDto, user: EdenAuthenticatedUser): Promise<SerializedAsset> {
try {
const result = await inventoryRepo.installAsset(id, {
contractId: dto.contractId,
assignmentType: dto.assignmentType,
notes: dto.notes ?? null,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "inventory.asset_installed",
resourceType: "serialized_asset",
resourceId: id,
metadata: { contractId: dto.contractId, assignmentType: dto.assignmentType },
});
return result.asset;
} catch (err) {
if (err instanceof AssetNotFoundError) throw new NotFoundException("Ativo não encontrado");
if (err instanceof InvalidAssetTransitionError) {
throw new ConflictException(
`Ativo com status '${err.fromStatus}' não pode ser instalado/comodato (esperado: in_stock ou reserved)`,
);
}
throw err;
}
}
async returnAsset(id: string, dto: ReturnAssetDto, user: EdenAuthenticatedUser): Promise<SerializedAsset> {
const warehouse = await inventoryRepo.getWarehouseById(dto.warehouseId);
if (!warehouse) throw new BadRequestException(`Armazém ${dto.warehouseId} não encontrado`);
try {
const result = await inventoryRepo.returnAsset(id, {
warehouseId: dto.warehouseId,
notes: dto.notes ?? null,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "inventory.asset_returned",
resourceType: "serialized_asset",
resourceId: id,
metadata: { warehouseId: dto.warehouseId },
});
return result.asset;
} catch (err) {
if (err instanceof AssetNotFoundError) throw new NotFoundException("Ativo não encontrado");
if (err instanceof InvalidAssetTransitionError) {
throw new ConflictException(
`Ativo com status '${err.fromStatus}' não pode ser devolvido (esperado: installed ou comodato)`,
);
}
if (err instanceof NoOpenAssignmentError) {
throw new ConflictException("Ativo não tem vínculo aberto para encerrar");
}
throw err;
}
}
// ---------------------------------------------------------------------
// stock_movements
// ---------------------------------------------------------------------
listMovements(
warehouseId?: string,
productId?: string,
referenceType?: string,
referenceId?: string,
): Promise<StockMovement[]> {
return inventoryRepo.listStockMovements({ warehouseId, productId, referenceType, referenceId });
}
// ---------------------------------------------------------------------
// stock_reservations
// ---------------------------------------------------------------------
listReservations(status?: string, warehouseId?: string, productId?: string): Promise<StockReservation[]> {
return inventoryRepo.listStockReservations({
status: status as StockReservation["status"] | undefined,
warehouseId,
productId,
});
}
async createReservation(dto: CreateReservationDto, user: EdenAuthenticatedUser): Promise<StockReservation> {
const product = await productsRepo.getProductById(dto.productId);
if (!product) throw new BadRequestException(`Produto ${dto.productId} não encontrado`);
const warehouse = await inventoryRepo.getWarehouseById(dto.warehouseId);
if (!warehouse) throw new BadRequestException(`Armazém ${dto.warehouseId} não encontrado`);
const result = await inventoryRepo.createGenericReservation({
warehouseId: dto.warehouseId,
productId: dto.productId,
quantity: dto.quantity,
referenceType: dto.referenceType ?? null,
referenceId: dto.referenceId ?? null,
notes: dto.notes ?? null,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "inventory.reservation_created",
resourceType: "stock_reservation",
resourceId: result.reservation.id,
metadata: { productId: dto.productId, warehouseId: dto.warehouseId, quantity: dto.quantity },
});
return result.reservation;
}
async releaseReservation(id: string, user: EdenAuthenticatedUser): Promise<StockReservation> {
try {
const result = await inventoryRepo.releaseReservation(id, user.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "inventory.reservation_released",
resourceType: "stock_reservation",
resourceId: id,
metadata: {},
});
return result.reservation;
} catch (err) {
if (err instanceof ReservationNotFoundError) throw new NotFoundException("Reserva não encontrada");
if (err instanceof InvalidReservationTransitionError) {
throw new ConflictException(
`Reserva com status '${err.fromStatus}' não pode ser liberada (esperado: active)`,
);
}
throw err;
}
}
}
function isUniqueViolation(err: unknown): boolean {
return typeof err === "object" && err !== null && (err as { code?: string }).code === "23505";
}

View File

@@ -0,0 +1,54 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { WarehousesService } from "./warehouses.service.js";
import { CreateWarehouseDto } from "./dto/create-warehouse.dto.js";
import { UpdateWarehouseDto } from "./dto/update-warehouse.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Warehouses/Assets/Stock — Master Prompt §6.8, ADR-0007. Read is gated by
* inventory:view / write by inventory:edit for everything in this module
* (warehouses included) — no reseller-style scope filtering here, mirrors
* the simpler products/companies pattern rather than contracts' scoped
* one: warehouses/stock are an internal operational concern, not
* reseller-partitioned data.
*/
@ApiTags("inventory")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/warehouses")
export class WarehousesController {
constructor(private readonly warehouses: WarehousesService) {}
@Get()
@RequirePermission("inventory", "view")
list(@Query("includeInactive") includeInactive?: string) {
return this.warehouses.list(includeInactive === "true");
}
@Get(":id")
@RequirePermission("inventory", "view")
get(@Param("id") id: string) {
return this.warehouses.get(id);
}
@Post()
@RequirePermission("inventory", "edit")
create(@Body() dto: CreateWarehouseDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.warehouses.create(dto, user);
}
@Patch(":id")
@RequirePermission("inventory", "edit")
update(
@Param("id") id: string,
@Body() dto: UpdateWarehouseDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.warehouses.update(id, dto, user);
}
}

View File

@@ -0,0 +1,64 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { auditRepo, companiesRepo, inventoryRepo, type Warehouse } from "@eden/database";
import type { CreateWarehouseDto } from "./dto/create-warehouse.dto.js";
import type { UpdateWarehouseDto } from "./dto/update-warehouse.dto.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
@Injectable()
export class WarehousesService {
list(includeInactive: boolean): Promise<Warehouse[]> {
return inventoryRepo.listWarehouses(includeInactive);
}
async get(id: string): Promise<Warehouse> {
const warehouse = await inventoryRepo.getWarehouseById(id);
if (!warehouse) throw new NotFoundException("Armazém não encontrado");
return warehouse;
}
async create(dto: CreateWarehouseDto, user: EdenAuthenticatedUser): Promise<Warehouse> {
const company = await companiesRepo.getCompanyById(dto.companyId);
if (!company) throw new NotFoundException("Empresa (legal entity) não encontrada");
const warehouse = await inventoryRepo.createWarehouse({
companyId: dto.companyId,
name: dto.name,
code: dto.code,
isActive: dto.isActive,
createdBy: user.id,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "warehouse.created",
resourceType: "warehouse",
resourceId: warehouse.id,
metadata: { code: warehouse.code, companyId: warehouse.company_id },
});
return warehouse;
}
async update(id: string, dto: UpdateWarehouseDto, user: EdenAuthenticatedUser): Promise<Warehouse> {
await this.get(id); // 404 before attempting the update
const warehouse = await inventoryRepo.updateWarehouse(id, {
name: dto.name,
code: dto.code,
isActive: dto.isActive,
});
if (!warehouse) throw new NotFoundException("Armazém não encontrado");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "warehouse.updated",
resourceType: "warehouse",
resourceId: warehouse.id,
metadata: { name: dto.name, code: dto.code, isActive: dto.isActive },
});
return warehouse;
}
}

View File

@@ -2,6 +2,7 @@ import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { loadEnv } from "@eden/config";
import { AppModule } from "./app.module";
async function bootstrap() {
@@ -9,6 +10,17 @@ async function bootstrap() {
app.setGlobalPrefix("api", { exclude: ["health/live", "health/ready"] });
// The 3 frontends (core-web/reseller-web/subscriber-web) each run on a
// distinct origin/port (ADR-0002) and call this API cross-origin — never
// a wildcard, since requests carry bearer tokens (see env-schema.ts).
const corsOrigins = (loadEnv().EDEN_CORS_ORIGINS ?? "")
.split(",")
.map((origin) => origin.trim())
.filter(Boolean);
if (corsOrigins.length > 0) {
app.enableCors({ origin: corsOrigins, credentials: true });
}
// Server-side validation is the authority — never trust that a client
// already validated (Master Prompt §12). Strips unknown fields instead
// of accepting them silently (mass-assignment protection).

View File

@@ -0,0 +1,10 @@
import { IsBoolean, IsOptional, IsString, MinLength } from "class-validator";
export class CreateTicketCommentDto {
@IsString() @MinLength(1) body!: string;
// Defaults to false (public comment) — is_internal=true is never
// visible to the subscriber/reseller portals (enforced in
// support.service.ts::get, not just by UI convention).
@IsOptional() @IsBoolean() isInternal?: boolean;
}

View File

@@ -0,0 +1,24 @@
import { IsIn, IsOptional, IsString, IsUUID, MinLength } from "class-validator";
export class CreateTicketDto {
@IsString() @MinLength(3) title!: string;
@IsString() @MinLength(3) description!: string;
@IsString() category!: string;
@IsOptional() @IsString() subcategory?: string;
@IsOptional()
@IsIn(["low", "medium", "high", "critical"])
priority?: "low" | "medium" | "high" | "critical";
// resellerId is only honored for an actor with 'all' scope on
// support-tickets:edit (core staff) — a reseller-scoped actor always
// gets their own resellerId forced server-side in support.service.ts,
// never trusting this field for authorization (CLAUDE.md: never trust
// a client-supplied reseller_id).
@IsOptional() @IsUUID() resellerId?: string;
@IsOptional() @IsUUID() clientRegistrationId?: string;
@IsOptional() @IsUUID() contractId?: string;
@IsOptional() @IsUUID() ownerUserId?: string;
}

View File

@@ -0,0 +1,31 @@
import { IsIn, IsOptional, IsString } from "class-validator";
export class UpdateTicketStatusDto {
@IsIn([
"new",
"triage",
"in_progress",
"waiting_customer",
"waiting_third_party",
"resolved",
"closed",
"cancelled",
])
toStatus!:
| "new"
| "triage"
| "in_progress"
| "waiting_customer"
| "waiting_third_party"
| "resolved"
| "closed"
| "cancelled";
@IsOptional() @IsString() reason?: string;
// Only persisted by the service layer on a transition into 'closed' —
// see support.service.ts::updateStatus and the root_cause column
// comment in PENDING_support.cjs.
@IsOptional() @IsString() rootCause?: string;
@IsOptional() @IsString() resolutionNotes?: string;
}

View File

@@ -0,0 +1,76 @@
/**
* Support/Service Desk SLA engine — Master Prompt §6.13. Pure, DB-free
* functions (mirrors apps/api/src/quotes/pricing.ts: business logic lives
* here, not scattered across the repository/service layers, and is
* trivially unit-testable without a database).
*
* SLA policy for this MVP is deliberately a single flat default
* (first response in 4h, resolution in 24h) applied to every ticket
* regardless of category/priority. A configurable, per-category SLA
* policy (and a calendar-aware "business hours only" due-date engine,
* instead of naive wall-clock offsets) is explicitly DEFERRED — see the
* migration header comment in PENDING_support.cjs.
*/
/** The subset of a sla_timers row this module needs — duck-typed so callers can pass the full DB row. */
export interface SlaTimerPauseState {
paused_since: Date | null;
accumulated_pause_seconds: number;
}
export interface DefaultSlaDueDates {
firstResponseDueAt: Date;
resolutionDueAt: Date;
}
const DEFAULT_FIRST_RESPONSE_HOURS = 4;
const DEFAULT_RESOLUTION_HOURS = 24;
/**
* The example SLA policy this MVP ships with: first_response_due_at =
* now + 4h, resolution_due_at = now + 24h, for every ticket regardless of
* priority/category. Called once at ticket creation
* (support.service.ts::create) — never recomputed afterwards.
*/
export function computeDefaultSlaDueDates(now: Date): DefaultSlaDueDates {
return {
firstResponseDueAt: new Date(now.getTime() + DEFAULT_FIRST_RESPONSE_HOURS * 3_600_000),
resolutionDueAt: new Date(now.getTime() + DEFAULT_RESOLUTION_HOURS * 3_600_000),
};
}
/**
* Total time (in seconds) the timer has spent paused, up to `now` —
* accumulated_pause_seconds from past pause/resume cycles, PLUS the
* duration of the current pause if the timer is still paused right now
* (paused_since is non-null). This is exactly the amount that must be
* subtracted from wall-clock elapsed time to get the SLA-relevant
* "elapsed" time — a ticket sitting in waiting_customer does not burn
* SLA time by default (eden-support persona spec).
*
* Never negative even if `now` is (pathologically) earlier than
* paused_since — clamped at 0 for that slice.
*/
export function computeElapsedSeconds(timer: SlaTimerPauseState, now: Date): number {
let pausedSeconds = timer.accumulated_pause_seconds;
if (timer.paused_since) {
const currentPauseSeconds = Math.floor((now.getTime() - timer.paused_since.getTime()) / 1000);
pausedSeconds += Math.max(0, currentPauseSeconds);
}
return pausedSeconds;
}
/**
* Whether the given SLA due date has been breached as of `now`, once the
* paused time (computeElapsedSeconds) is excluded from the elapsed
* wall-clock time. Works for either due date (first_response_due_at or
* resolution_due_at) — pass whichever is being checked.
*
* adjustedNow = now - totalPausedSeconds
* breached = adjustedNow > dueAt
*/
export function isSlaBreached(timer: SlaTimerPauseState, dueAt: Date, now: Date): boolean {
const pausedSeconds = computeElapsedSeconds(timer, now);
const adjustedNowMs = now.getTime() - pausedSeconds * 1000;
return adjustedNowMs > dueAt.getTime();
}

View File

@@ -0,0 +1,64 @@
import { Body, Controller, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { SupportService } from "./support.service.js";
import { CreateTicketDto } from "./dto/create-ticket.dto.js";
import { UpdateTicketStatusDto } from "./dto/update-ticket-status.dto.js";
import { CreateTicketCommentDto } from "./dto/create-ticket-comment.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Support/Service Desk — Master Prompt §6.13. Greenfield MVP: tickets,
* status machine + SLA timers (see apps/api/src/support/sla.ts),
* public/internal comments. DEFERRED (see PENDING_support.cjs header):
* real S3 attachments, CSAT, dedicated work-order entity, routing
* queues, configurable attendance calendar.
*/
@ApiTags("support")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/support/tickets")
export class SupportController {
constructor(private readonly support: SupportService) {}
@Get()
@RequirePermission("support-tickets", "view")
list(@CurrentUser() user: EdenAuthenticatedUser) {
return this.support.list(user);
}
@Get(":id")
@RequirePermission("support-tickets", "view")
get(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.support.get(id, user);
}
@Post()
@RequirePermission("support-tickets", "edit")
create(@Body() dto: CreateTicketDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.support.create(dto, user);
}
@Patch(":id/status")
@RequirePermission("support-tickets", "edit")
updateStatus(
@Param("id") id: string,
@Body() dto: UpdateTicketStatusDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.support.updateStatus(id, dto, user);
}
@Post(":id/comments")
@RequirePermission("support-tickets", "edit")
addComment(
@Param("id") id: string,
@Body() dto: CreateTicketCommentDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.support.addComment(id, dto, user);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { SupportController } from "./support.controller.js";
import { SupportService } from "./support.service.js";
@Module({
imports: [AuthModule],
controllers: [SupportController],
providers: [SupportService],
exports: [SupportService],
})
export class SupportModule {}

View File

@@ -0,0 +1,176 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import {
auditRepo,
supportTicketsRepo,
type SupportTicket,
type TicketComment,
type TicketWithDetails,
} from "@eden/database";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { maxGrantedScope } from "../auth/permissions.js";
import { computeDefaultSlaDueDates, isSlaBreached } from "./sla.js";
import type { CreateTicketDto } from "./dto/create-ticket.dto.js";
import type { UpdateTicketStatusDto } from "./dto/update-ticket-status.dto.js";
import type { CreateTicketCommentDto } from "./dto/create-ticket-comment.dto.js";
const { TicketNotFoundError, InvalidTicketTransitionError } = supportTicketsRepo;
const RESOURCE = "support-tickets";
@Injectable()
export class SupportService {
async list(user: EdenAuthenticatedUser): Promise<SupportTicket[]> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
if (!scope) throw new ForbiddenException("Sem permissão para 'view' em 'support-tickets'");
if (scope === "all") return supportTicketsRepo.listTickets({ scope: "all" });
return supportTicketsRepo.listTickets({ scope: "reseller", resellerId: user.resellerId });
}
async get(id: string, user: EdenAuthenticatedUser): Promise<TicketWithDetails> {
const scope = maxGrantedScope(user.permissions, RESOURCE, "view");
const ticket = await supportTicketsRepo.getTicketById(id);
if (!ticket || !scope) throw new NotFoundException("Ticket não encontrado");
if (scope !== "all" && ticket.reseller_id !== user.resellerId) {
throw new NotFoundException("Ticket não encontrado");
}
// is_internal comments are never visible outside 'all' scope (core
// staff) — enforced here, in the query layer, not left to the UI.
// Reinforces the eden-support persona invariant: "comentário interno
// nunca visível ao cliente/revenda". A dedicated 'own' scope for the
// subscriber portal isn't wired up in this MVP (mirrors contracts.ts,
// which also only distinguishes all/reseller) — when it lands, it
// must fall into this same filtered branch.
if (scope !== "all") {
return { ...ticket, comments: ticket.comments.filter((c) => !c.is_internal) };
}
return ticket;
}
async create(dto: CreateTicketDto, user: EdenAuthenticatedUser): Promise<TicketWithDetails> {
const editScope = maxGrantedScope(user.permissions, RESOURCE, "edit");
if (!editScope) throw new ForbiddenException("Sem permissão para 'edit' em 'support-tickets'");
// Never trust a client-supplied reseller_id for a reseller-scoped
// actor (CLAUDE.md: authorization never trusts a client field) — it's
// always forced to the caller's own reseller. Only an 'all'-scope
// actor (core staff) may open a ticket on behalf of an arbitrary
// reseller.
const resellerId = editScope === "all" ? (dto.resellerId ?? null) : user.resellerId;
const now = new Date();
const { firstResponseDueAt, resolutionDueAt } = computeDefaultSlaDueDates(now);
const ticket = await supportTicketsRepo.createTicket({
title: dto.title,
description: dto.description,
category: dto.category,
subcategory: dto.subcategory ?? null,
priority: dto.priority ?? "medium",
resellerId,
clientRegistrationId: dto.clientRegistrationId ?? null,
contractId: dto.contractId ?? null,
ownerUserId: dto.ownerUserId ?? null,
openedByUserId: user.id,
createdBy: user.id,
firstResponseDueAt,
resolutionDueAt,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "support_ticket.created",
resourceType: "support_ticket",
resourceId: ticket.id,
metadata: { protocolNumber: ticket.protocol_number, priority: ticket.priority, category: ticket.category },
});
return ticket;
}
async updateStatus(
id: string,
dto: UpdateTicketStatusDto,
user: EdenAuthenticatedUser,
): Promise<SupportTicket> {
await this.get(id, user); // scope check — 404 before attempting the transition
try {
const { ticket, slaTimer } = await supportTicketsRepo.updateTicketStatus(
id,
dto.toStatus,
dto.reason ?? null,
user.id,
{ rootCause: dto.rootCause ?? null, resolutionNotes: dto.resolutionNotes ?? null },
);
// Only reachable when entering 'resolved' from 'in_progress' (see
// TICKET_STATUS_TRANSITIONS), so the timer returned here is
// guaranteed unpaused — safe to evaluate the breach directly
// against resolution_due_at.
if (dto.toStatus === "resolved") {
const now = new Date();
const met = !isSlaBreached(slaTimer, slaTimer.resolution_due_at, now);
await supportTicketsRepo.recordResolutionMet(id, met);
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "support_ticket.status_changed",
resourceType: "support_ticket",
resourceId: id,
metadata: { toStatus: dto.toStatus, reason: dto.reason ?? null },
});
return ticket;
} catch (err) {
if (err instanceof TicketNotFoundError) throw new NotFoundException("Ticket não encontrado");
if (err instanceof InvalidTicketTransitionError) {
throw new ConflictException(err.message);
}
throw err;
}
}
async addComment(
id: string,
dto: CreateTicketCommentDto,
user: EdenAuthenticatedUser,
): Promise<TicketComment> {
const ticket = await this.get(id, user);
const isInternal = dto.isInternal ?? false;
const comment = await supportTicketsRepo.addTicketComment(id, {
authorUserId: user.id,
body: dto.body,
isInternal,
});
// First public response starts/settles first_response_at + the SLA
// first_response_met flag — only once, never recomputed afterwards
// (see recordFirstResponse's idempotent WHERE ... IS NULL guards).
if (!isInternal && ticket.first_response_at === null && ticket.slaTimer) {
const now = new Date();
const met = !isSlaBreached(ticket.slaTimer, ticket.slaTimer.first_response_due_at, now);
await supportTicketsRepo.recordFirstResponse(id, met, now);
}
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "support_ticket.comment_added",
resourceType: "support_ticket",
resourceId: id,
metadata: { isInternal },
});
return comment;
}
}

View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { crc16Hex, crc16Kermit, normalizeAfdCpf, parseAfdFile, parseAfdLine } from "../src/hr-timeclock/afd-parser.js";
/**
* Pure-function unit tests for the AFD parser — no DB, no Nest app, unlike
* every other spec in this directory (which are DB-backed integration
* tests per vitest.config.ts's own doc comment). Placed here anyway
* because `include: ["test/**\/*.spec.ts"]` is the only pattern vitest
* picks up for @eden/api; these tests just happen not to need Postgres.
*/
describe("crc16Kermit", () => {
it("matches the known CRC-16/KERMIT test vector for ASCII '123456789'", () => {
expect(crc16Kermit("123456789")).toBe(0x2189);
expect(crc16Hex("123456789")).toBe("2189");
});
});
describe("normalizeAfdCpf", () => {
it("takes the last 11 digits of a wider raw field", () => {
expect(normalizeAfdCpf("01234567890X")).toBe("01234567890");
expect(normalizeAfdCpf(" 123.456.789-01 ")).toBe("12345678901");
});
it("never invents padding for a short field", () => {
expect(normalizeAfdCpf("123")).toBe("123");
expect(normalizeAfdCpf(null)).toBeNull();
expect(normalizeAfdCpf("")).toBeNull();
});
});
function buildType3Line(nsr: string, dh: string, cpf: string): string {
const dataPart = `${nsr}3${dh}${cpf}`;
return dataPart + crc16Hex(dataPart);
}
describe("parseAfdLine — type 3 (marcação REP-C/REP-A)", () => {
it("parses a well-formed, CRC-valid punch line", () => {
const line = buildType3Line("000000001", "2026-08-06T08:30:00-0300", "01234567890X");
const record = parseAfdLine(line, 1);
expect(record.nsr).toBe(1);
expect(record.recordType).toBe("3");
expect(record.crcValid).toBe("VALID");
expect(record.validationStatus).toBe("VALID");
expect(record.employeeCpfRaw).toBe("01234567890X");
expect(record.eventDatetime?.toISOString()).toBe(new Date("2026-08-06T08:30:00-03:00").toISOString());
});
it("flags a tampered CRC as INVALID but still preserves the raw line", () => {
const line = buildType3Line("000000002", "2026-08-06T08:30:00-0300", "01234567890X");
const tampered = line.slice(0, -1) + (line.at(-1) === "0" ? "1" : "0");
const record = parseAfdLine(tampered, 2);
expect(record.crcValid).toBe("INVALID");
expect(record.validationStatus).toBe("INVALID");
expect(record.rawLine).toBe(tampered);
expect(record.validationNotes).toMatch(/CRC divergente/);
});
it("never invents an NSR — a non-numeric NSR field stays null", () => {
const line = buildType3Line("ABCDEFGHI", "2026-08-06T08:30:00-0300", "01234567890X");
const record = parseAfdLine(line, 3);
expect(record.nsr).toBeNull();
expect(record.nsrRaw).toBe("ABCDEFGHI");
expect(record.validationStatus).toBe("INVALID");
});
});
describe("parseAfdLine — type 7 (marcação REP-P)", () => {
it("never recomputes the type-7 hash chain — crcValid is always NOT_APPLICABLE", () => {
const dh = "2026-08-06T08:30:00-0300";
const line = "0000000037" + dh + "01234567890X" + dh + "01" + "O" + "a".repeat(64);
const record = parseAfdLine(line, 1);
expect(record.recordType).toBe("7");
expect(record.crcValid).toBe("NOT_APPLICABLE");
expect(record.crcCalculated).toBeNull();
});
});
describe("parseAfdLine — unsupported/malformed", () => {
it("captures an unrecognized record type as UNSUPPORTED_LAYOUT, never dropped", () => {
const record = parseAfdLine("000000005X" + "resto-nao-documentado", 5);
expect(record.validationStatus).toBe("UNSUPPORTED_LAYOUT");
expect(record.parsedData.raw_tail).toBe("resto-nao-documentado");
});
it("preserves a too-short line instead of throwing", () => {
const record = parseAfdLine("12345", 6);
expect(record.validationStatus).toBe("INVALID");
expect(record.rawLine).toBe("12345");
expect(record.nsr).toBeNull();
});
});
describe("parseAfdFile — partial-error policy", () => {
it("keeps parsing all lines even when some are malformed, never dropping raw data", () => {
const good = buildType3Line("000000001", "2026-08-06T08:30:00-0300", "01234567890X");
const bad = "short";
const content = [good, bad].join("\r\n");
const result = parseAfdFile(content);
expect(result.records).toHaveLength(2);
expect(result.failures).toHaveLength(0); // "bad" is malformed-but-handled, not an unexpected exception
expect(result.summary.byValidationStatus.VALID).toBe(1);
expect(result.summary.byValidationStatus.INVALID).toBe(1);
expect(result.summary.firstNsr).toBe(1);
expect(result.summary.lastNsr).toBe(1);
});
it("skips blank trailing lines without generating a phantom record", () => {
const good = buildType3Line("000000001", "2026-08-06T08:30:00-0300", "01234567890X");
const result = parseAfdFile(good + "\r\n");
expect(result.records).toHaveLength(1);
});
});

148
apps/api/test/mfa.spec.ts Normal file
View File

@@ -0,0 +1,148 @@
import { INestApplication } from "@nestjs/common";
import { generate } from "otplib";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
agent,
bearer,
closeTestApp,
createTestApp,
loginSuperAdmin,
qaEmail,
type AuthedTokens,
} from "./setup.js";
/**
* Protects the MFA/TOTP invariants added in apps/api/src/auth/mfa.service.ts
* and the login-flow branch in auth.service.ts. Boots the real app against
* the real Postgres (same pattern as auth.spec.ts) — codes are generated
* for real via `otplib.generate` against the plaintext secret returned by
* the real enroll endpoint, never faked.
*/
describe("auth — MFA/TOTP (login step-up)", () => {
let app: INestApplication;
let admin: AuthedTokens;
let fixtureEmail: string;
let fixtureUserId: string;
let fixturePassword: string;
let fixtureAccessToken: string;
let secret: string;
beforeAll(async () => {
app = await createTestApp();
admin = await loginSuperAdmin(app);
fixtureEmail = qaEmail("mfa");
const invite = await agent(app)
.post("/api/v1/users/invite")
.set(...bearer(admin.accessToken))
.send({ email: fixtureEmail, roleKey: "user", applications: ["core"] })
.expect(201);
fixtureUserId = invite.body.user.id;
fixturePassword = invite.body.tempPassword;
const login = await agent(app)
.post("/api/v1/auth/login")
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
.expect(200);
fixtureAccessToken = login.body.accessToken as string;
});
afterAll(async () => {
await agent(app)
.patch(`/api/v1/users/${fixtureUserId}`)
.set(...bearer(admin.accessToken))
.send({ isActive: false })
.expect(200);
await closeTestApp(app);
});
it("login before enrollment returns tokens directly (no mfaRequired)", async () => {
const res = await agent(app)
.post("/api/v1/auth/login")
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
.expect(200);
expect(res.body.accessToken).toBeTypeOf("string");
expect(res.body.mfaRequired).toBeUndefined();
});
it("confirming enrollment with a wrong code is rejected and does not enable MFA", async () => {
const enroll = await agent(app)
.post("/api/v1/auth/mfa/enroll")
.set(...bearer(fixtureAccessToken))
.expect(201);
secret = enroll.body.secret as string;
expect(enroll.body.otpauthUrl).toContain("otpauth://totp/");
await agent(app)
.post("/api/v1/auth/mfa/confirm")
.set(...bearer(fixtureAccessToken))
.send({ code: "000000" })
.expect(400);
// MFA must still be off — login without a code still returns tokens directly.
const res = await agent(app)
.post("/api/v1/auth/login")
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
.expect(200);
expect(res.body.mfaRequired).toBeUndefined();
});
it("confirming enrollment with the real current code enables MFA and step-up login works end to end", async () => {
await agent(app)
.post("/api/v1/auth/mfa/confirm")
.set(...bearer(fixtureAccessToken))
.send({ code: await generate({ secret }) })
.expect(204);
// Password alone is no longer enough — login returns a challenge, not tokens.
const challenge = await agent(app)
.post("/api/v1/auth/login")
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
.expect(200);
expect(challenge.body.mfaRequired).toBe(true);
expect(challenge.body.accessToken).toBeUndefined();
const mfaChallenge = challenge.body.mfaChallenge as string;
// Regression: the challenge token must NEVER work as a bearer access
// token — it's signed with the same secret as a real access token, so
// without an explicit `purpose` check it would grant full API access
// without ever verifying the second factor (caught live against the
// real running stack, not a hypothetical).
await agent(app).get("/api/v1/auth/me").set(...bearer(mfaChallenge)).expect(401);
// Wrong TOTP code is rejected.
await agent(app)
.post("/api/v1/auth/mfa/challenge")
.send({ mfaChallenge, code: "000000" })
.expect(401);
// Correct TOTP code completes login with real tokens.
const done = await agent(app)
.post("/api/v1/auth/mfa/challenge")
.send({ mfaChallenge, code: await generate({ secret }) })
.expect(200);
expect(done.body.accessToken).toBeTypeOf("string");
expect(done.body.refreshToken).toBeTypeOf("string");
});
it("disabling MFA requires a valid current code, then login goes back to returning tokens directly", async () => {
await agent(app)
.post("/api/v1/auth/mfa/disable")
.set(...bearer(fixtureAccessToken))
.send({ code: "000000" })
.expect(401);
await agent(app)
.post("/api/v1/auth/mfa/disable")
.set(...bearer(fixtureAccessToken))
.send({ code: await generate({ secret }) })
.expect(204);
const res = await agent(app)
.post("/api/v1/auth/login")
.send({ email: fixtureEmail, password: fixturePassword, applicationKey: "core" })
.expect(200);
expect(res.body.mfaRequired).toBeUndefined();
expect(res.body.accessToken).toBeTypeOf("string");
});
});

View File

@@ -0,0 +1,4 @@
# Copiar para `.env` neste diretório (apps/core-web/.env) para desenvolvimento
# local com `pnpm --filter @eden/core-web dev` — Vite só lê .env do root do
# próprio app, não do root do monorepo (ver .env.example na raiz do repo).
VITE_API_BASE_URL=http://localhost:8080/api/v1

View File

@@ -11,8 +11,10 @@
"test": "echo 'no tests yet'"
},
"dependencies": {
"@eden/ui": "workspace:*",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.1"
},
"devDependencies": {
"@types/react": "^18.3.18",

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

View File

@@ -1,12 +1,23 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { AuthProvider } from "./lib/auth/AuthContext.js";
import { AuthenticatedLayout } from "./routes/AuthenticatedLayout.js";
import { DashboardPage } from "./routes/DashboardPage.js";
import { LoginPage } from "./routes/LoginPage.js";
import { ProtectedRoute } from "./routes/ProtectedRoute.js";
export function App() {
return (
<main className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100">
<div className="text-center">
<h1 className="text-2xl font-semibold">EDEN Core</h1>
<p className="mt-2 text-slate-400">
Bootstrap da Fase 1 — Design System ainda não portado do tema DreamsERP.
</p>
</div>
</main>
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<AuthenticatedLayout />}>
<Route path="/" element={<DashboardPage />} />
</Route>
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,67 @@
/**
* Client for `apps/api` `/v1/auth/*` (see apps/api/src/auth/auth.controller.ts
* and auth.service.ts for the source of truth — shapes below are mirrored,
* not invented, and were smoke-tested against the real running API).
*/
import { apiRequest } from "./http.js";
export interface PublicUser {
id: string;
email: string;
fullName: string | null;
roleKey: string;
}
export interface AuthResult {
accessToken: string;
expiresIn: number;
refreshToken: string;
user: PublicUser;
}
export interface MfaRequiredResult {
mfaRequired: true;
mfaChallenge: string;
expiresIn: number;
}
export type LoginResult = AuthResult | MfaRequiredResult;
export function isMfaRequired(result: LoginResult): result is MfaRequiredResult {
return "mfaRequired" in result && result.mfaRequired === true;
}
export type PermissionGrant = { resource: string; action: string; scope: string };
export interface MeResult {
user: PublicUser;
permissions: "ALL" | PermissionGrant[];
applications: Array<"core" | "reseller" | "subscriber">;
}
const APPLICATION_KEY = "core" as const;
export function login(email: string, password: string): Promise<LoginResult> {
return apiRequest<LoginResult>("/auth/login", {
method: "POST",
body: JSON.stringify({ email, password, applicationKey: APPLICATION_KEY }),
});
}
export function completeMfaLogin(mfaChallenge: string, code: string): Promise<AuthResult> {
return apiRequest<AuthResult>("/auth/mfa/challenge", {
method: "POST",
body: JSON.stringify({ mfaChallenge, code }),
});
}
export function fetchMe(accessToken: string): Promise<MeResult> {
return apiRequest<MeResult>("/auth/me", { method: "GET" }, { accessToken });
}
export async function logout(refreshToken: string): Promise<void> {
await apiRequest<void>("/auth/logout", {
method: "POST",
body: JSON.stringify({ refreshToken }),
});
}

View File

@@ -0,0 +1,73 @@
/**
* Minimal fetch wrapper for the EDEN API. Base URL comes from
* `VITE_API_BASE_URL` (set in apps/core-web/.env — see .env.example),
* falling back to the documented local dev default so `pnpm dev` works
* out of the box against `docker compose up eden-postgres` + a locally
* run `apps/api`.
*/
const DEFAULT_BASE_URL = "http://localhost:8080/api/v1";
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL as string | undefined) ?? DEFAULT_BASE_URL;
export class ApiError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
interface ApiErrorBody {
message?: string | string[];
error?: string;
statusCode?: number;
}
function extractErrorMessage(body: unknown, fallback: string): string {
const parsed = body as ApiErrorBody | null;
if (parsed && typeof parsed === "object") {
if (Array.isArray(parsed.message)) return parsed.message.join(" ");
if (typeof parsed.message === "string") return parsed.message;
if (typeof parsed.error === "string") return parsed.error;
}
return fallback;
}
export interface RequestOptions {
accessToken?: string;
}
export async function apiRequest<TResponse>(
path: string,
init?: RequestInit,
options?: RequestOptions,
): Promise<TResponse> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(options?.accessToken ? { Authorization: `Bearer ${options.accessToken}` } : {}),
...init?.headers,
},
});
} catch {
throw new ApiError(0, "Não foi possível conectar à API. Verifique sua conexão e tente novamente.");
}
if (response.status === 204) {
return undefined as TResponse;
}
const text = await response.text();
const body = text ? JSON.parse(text) : undefined;
if (!response.ok) {
throw new ApiError(response.status, extractErrorMessage(body, "Ocorreu um erro inesperado."));
}
return body as TResponse;
}

View File

@@ -0,0 +1,109 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { ReactNode } from "react";
import {
completeMfaLogin,
fetchMe,
isMfaRequired,
login as apiLogin,
logout as apiLogout,
} from "../api/auth.js";
import type { PublicUser } from "../api/auth.js";
import { clearSession, readSession, writeSession } from "./session.js";
export type AuthStatus = "loading" | "authenticated" | "anonymous";
/** Result of `login()` when the account has MFA enabled — caller must render a code field and call `submitMfa`. */
export interface MfaPending {
mfaRequired: true;
mfaChallenge: string;
}
interface AuthContextValue {
status: AuthStatus;
user: PublicUser | null;
accessToken: string | null;
/** Resolves to `{ mfaRequired: true, mfaChallenge }` when a TOTP code is needed next, otherwise logs in fully. */
login: (email: string, password: string) => Promise<MfaPending | { mfaRequired: false }>;
submitMfa: (mfaChallenge: string, code: string) => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<AuthStatus>("loading");
const [user, setUser] = useState<PublicUser | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState<string | null>(null);
useEffect(() => {
const stored = readSession();
if (!stored) {
setStatus("anonymous");
return;
}
// Validate the persisted token against the API rather than trusting
// localStorage blindly — it may have expired since the last visit.
// No refresh-token rotation in this leva: an expired access token here
// just sends the user back to /login.
fetchMe(stored.accessToken)
.then((me) => {
setUser(me.user);
setAccessToken(stored.accessToken);
setRefreshToken(stored.refreshToken);
setStatus("authenticated");
})
.catch(() => {
clearSession();
setStatus("anonymous");
});
}, []);
const login = useCallback(async (email: string, password: string) => {
const result = await apiLogin(email, password);
if (isMfaRequired(result)) {
return { mfaRequired: true as const, mfaChallenge: result.mfaChallenge };
}
writeSession({ accessToken: result.accessToken, refreshToken: result.refreshToken, user: result.user });
setUser(result.user);
setAccessToken(result.accessToken);
setRefreshToken(result.refreshToken);
setStatus("authenticated");
return { mfaRequired: false as const };
}, []);
const submitMfa = useCallback(async (mfaChallenge: string, code: string) => {
const result = await completeMfaLogin(mfaChallenge, code);
writeSession({ accessToken: result.accessToken, refreshToken: result.refreshToken, user: result.user });
setUser(result.user);
setAccessToken(result.accessToken);
setRefreshToken(result.refreshToken);
setStatus("authenticated");
}, []);
const logout = useCallback(async () => {
if (refreshToken) {
// Best-effort — even if the API call fails (e.g. session already
// expired server-side), the local session must still be cleared.
await apiLogout(refreshToken).catch(() => undefined);
}
clearSession();
setUser(null);
setAccessToken(null);
setRefreshToken(null);
setStatus("anonymous");
}, [refreshToken]);
const value = useMemo<AuthContextValue>(
() => ({ status, user, accessToken, login, submitMfa, logout }),
[status, user, accessToken, login, submitMfa, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within an AuthProvider");
return ctx;
}

View File

@@ -0,0 +1,34 @@
import type { PublicUser } from "../api/auth.js";
const STORAGE_KEY = "eden.core.session";
export interface StoredSession {
accessToken: string;
refreshToken: string;
user: PublicUser;
}
/**
* Basic session persistence (localStorage) so a page refresh doesn't log
* the user out. Deliberately NOT rotating the refresh token automatically
* on expiry in this leva — see apps/core-web/src/lib/auth/AuthContext.tsx.
*/
export function readSession(): StoredSession | null {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as StoredSession;
if (!parsed.accessToken || !parsed.refreshToken || !parsed.user) return null;
return parsed;
} catch {
return null;
}
}
export function writeSession(session: StoredSession): void {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
}
export function clearSession(): void {
window.localStorage.removeItem(STORAGE_KEY);
}

View File

@@ -0,0 +1,35 @@
import { useNavigate } from "react-router-dom";
import { Link, Outlet } from "react-router-dom";
import { AppShell } from "@eden/ui";
import type { AppShellNavItem } from "@eden/ui";
import { useAuth } from "../lib/auth/AuthContext.js";
const NAV_ITEMS: AppShellNavItem[] = [{ key: "dashboard", label: "Painel", href: "/", active: true }];
export function AuthenticatedLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
async function handleLogout() {
await logout();
navigate("/login", { replace: true });
}
return (
<AppShell
appName="EDEN Core"
logoSrc="/eden-logo-horizontal.png"
navItems={NAV_ITEMS}
userName={user?.fullName ?? user?.email}
userRole={user?.roleKey}
onLogout={handleLogout}
LinkComponent={({ href, className, children }) => (
<Link to={href} className={className}>
{children}
</Link>
)}
>
<Outlet />
</AppShell>
);
}

View File

@@ -0,0 +1,23 @@
import { Card, PageHeader } from "@eden/ui";
import { useAuth } from "../lib/auth/AuthContext.js";
/**
* Placeholder landing page — out of scope for this leva to build real
* business screens (Quotes/Contracts/etc.). This just proves the
* authenticated shell renders with real session data end to end.
*/
export function DashboardPage() {
const { user } = useAuth();
return (
<div>
<PageHeader title={`Bem-vindo, ${user?.fullName ?? user?.email ?? ""}`} description="EDEN Core — Fase 1" />
<Card>
<p className="text-sm text-default">
O shell autenticado está funcionando. As telas de negócio (orçamentos, contratos, etc.) chegam nas
próximas levas.
</p>
</Card>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { Navigate, useLocation, useNavigate } from "react-router-dom";
import { Alert, Button, Card, Input } from "@eden/ui";
import { ApiError } from "../lib/api/http.js";
import { useAuth } from "../lib/auth/AuthContext.js";
export function LoginPage() {
const { status, login, submitMfa } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [mfaChallenge, setMfaChallenge] = useState<string | null>(null);
const [mfaCode, setMfaCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
if (status === "authenticated") {
const redirectTo = (location.state as { from?: string } | null)?.from ?? "/";
return <Navigate to={redirectTo} replace />;
}
async function handleCredentialsSubmit(event: FormEvent) {
event.preventDefault();
setError(null);
setSubmitting(true);
try {
const result = await login(email, password);
if (result.mfaRequired) {
setMfaChallenge(result.mfaChallenge);
} else {
navigate("/", { replace: true });
}
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível entrar. Tente novamente.");
} finally {
setSubmitting(false);
}
}
async function handleMfaSubmit(event: FormEvent) {
event.preventDefault();
if (!mfaChallenge) return;
setError(null);
setSubmitting(true);
try {
await submitMfa(mfaChallenge, mfaCode);
navigate("/", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Código inválido. Tente novamente.");
} finally {
setSubmitting(false);
}
}
return (
<main className="flex min-h-screen items-center justify-center bg-light p-4">
<Card className="w-full max-w-md">
<div className="mb-6 text-center">
<img src="/eden-logo-horizontal.png" alt="EDEN" className="mx-auto mb-4 h-10" />
<h1 className="!text-xl font-bold text-title">
{mfaChallenge ? "Verificação em duas etapas" : "Bem-vindo de volta"}
</h1>
<p className="mt-1 text-sm text-default">
{mfaChallenge ? "Informe o código do seu aplicativo autenticador." : "Entre com sua conta EDEN Core."}
</p>
</div>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
{mfaChallenge ? (
<form className="space-y-4" onSubmit={handleMfaSubmit}>
<Input
label="Código de verificação"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="000000"
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value)}
required
autoFocus
/>
<Button type="submit" className="w-full" loading={submitting}>
Confirmar
</Button>
<button
type="button"
className="w-full text-center text-xs text-default hover:text-primary"
onClick={() => {
setMfaChallenge(null);
setMfaCode("");
setError(null);
}}
>
Voltar
</button>
</form>
) : (
<form className="space-y-4" onSubmit={handleCredentialsSubmit}>
<Input
label="E-mail"
type="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoFocus
/>
<Input
label="Senha"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Button type="submit" className="w-full" loading={submitting}>
Entrar
</Button>
</form>
)}
</Card>
</main>
);
}

View File

@@ -0,0 +1,24 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "../lib/auth/AuthContext.js";
export function ProtectedRoute() {
const { status } = useAuth();
const location = useLocation();
if (status === "loading") {
return (
<div className="flex min-h-screen items-center justify-center bg-light">
<span
aria-label="Carregando"
className="size-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
/>
</div>
);
}
if (status === "anonymous") {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return <Outlet />;
}

9
apps/core-web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -1,6 +1,16 @@
import edenPreset from "@eden/ui/tailwind-preset";
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
presets: [edenPreset],
content: [
"./index.html",
"./src/**/*.{ts,tsx}",
// Design-system components live in packages/ui and ship as source (not
// pre-built CSS) — Tailwind's JIT scanner needs to see them too, or
// classes only used inside @eden/ui get purged from core-web's build.
"../../packages/ui/src/**/*.{ts,tsx}",
],
theme: {
extend: {},
},

View File

@@ -50,6 +50,7 @@ services:
EDEN_FIELD_ENCRYPTION_KEY: ${EDEN_FIELD_ENCRYPTION_KEY}
EDEN_SUPERADMIN_EMAIL: ${EDEN_SUPERADMIN_EMAIL}
EDEN_SUPERADMIN_PASSWORD: ${EDEN_SUPERADMIN_PASSWORD}
EDEN_CORS_ORIGINS: ${EDEN_CORS_ORIGINS}
SMTP_HOST: ${SMTP_HOST}
SMTP_PORT: ${SMTP_PORT}
SMTP_USER: ${SMTP_USER}
@@ -95,6 +96,12 @@ services:
dockerfile: infra/docker/Dockerfile.web
args:
APP_NAME: core-web
# Sempre relativo aqui (não ${VITE_API_BASE_URL} do .env — esse é
# para `pnpm dev` local fora do Docker, valor diferente e não deve
# vazar para este build arg). A API nunca tem domínio público
# próprio; o proxy externo roteia este path para eden-api na rede
# interna — ver comentário em .env.example.
VITE_API_BASE_URL: /api/v1
container_name: eden-core
restart: unless-stopped
ports:
@@ -115,6 +122,12 @@ services:
dockerfile: infra/docker/Dockerfile.web
args:
APP_NAME: reseller-web
# Sempre relativo aqui (não ${VITE_API_BASE_URL} do .env — esse é
# para `pnpm dev` local fora do Docker, valor diferente e não deve
# vazar para este build arg). A API nunca tem domínio público
# próprio; o proxy externo roteia este path para eden-api na rede
# interna — ver comentário em .env.example.
VITE_API_BASE_URL: /api/v1
container_name: eden-parceiros
restart: unless-stopped
ports:
@@ -135,6 +148,12 @@ services:
dockerfile: infra/docker/Dockerfile.web
args:
APP_NAME: subscriber-web
# Sempre relativo aqui (não ${VITE_API_BASE_URL} do .env — esse é
# para `pnpm dev` local fora do Docker, valor diferente e não deve
# vazar para este build arg). A API nunca tem domínio público
# próprio; o proxy externo roteia este path para eden-api na rede
# interna — ver comentário em .env.example.
VITE_API_BASE_URL: /api/v1
container_name: eden-assinante
restart: unless-stopped
ports:

View File

@@ -115,7 +115,7 @@ Vitest + supertest + `@nestjs/testing` (bootstrap real do Nest, sem mock) contra
6 subagentes no total (4 + 1 QA + Users/Roles feito diretamente), rodando em `git worktree` isolados, revisados e mesclados manualmente um a um (nunca merge automático sem verificação). **4 bugs reais** encontrados só porque cada módulo foi testado contra a stack viva antes do commit — nenhum deles teria aparecido só com typecheck/build, que todos os agentes já reportavam verde. Padrão validado: delegar implementação para agentes especializados funciona bem para módulos CRUD bem especificados, mas a integração final (merge, wiring, teste ao vivo, revisão) precisa ficar com quem tem visão do sistema inteiro.
### Pendências da Fase 1 (não iniciadas)
- MFA/TOTP (schema já existe — `users.mfa_*` — endpoint ainda não implementado).
- ~~MFA/TOTP~~ — implementado na leva seguinte (ver Fase 4+ abaixo).
- Extração do Design System a partir do tema DreamsERP (`packages/ui` ainda é placeholder).
- Wiring dos adapters S3/SMTP em algum módulo consumidor real (hoje só existem como biblioteca em `packages/integrations`, nada os chama ainda).
- Nota de dado de teste: a empresa "Handix Telecom LTDA" e a revenda "Handix (revenda própria)" criadas durante os testes usam dados de exemplo, não os dados reais da Handix — substituir/remover antes de qualquer ambiente que não seja dev local.
@@ -150,6 +150,121 @@ Wiring final feito manualmente: barrel (`packages/database/src/index.ts`) e `app
2. `packages/database/src/repositories/types.ts` — a interface `User` estava sem `reseller_id`/`can_approve_discount`, mesmo essas colunas já existindo no banco desde as migrations de Resellers/Quotes (ninguém tinha atualizado o tipo compartilhado). Descoberto ao ligar o uso desses campos em Quotes; corrigido no `types.ts` e propagado para `EdenAuthenticatedUser` em `jwt-auth.guard.ts`.
### Pendências deste lote
- FKs cruzadas entre as 3 tabelas ainda não existem (ficaram como UUID nullable simples, documentado em comentário nas migrations): `quotes.client_registration_id → client_registrations(id)`, `client_registrations.quote_id → quotes(id)`, `contracts.quote_id → quotes(id)`, `contracts.client_registration_id → client_registrations(id)`.
- ~~FKs cruzadas entre as 3 tabelas~~ — fechadas na leva seguinte (ver abaixo).
- Fluxo de autoatendimento público (token) de Cadastro de Cliente, upload de anexo, integração CNPJ/CEP — todos fora do escopo desta MVP.
- 7 das 11 tabelas do ADR-0006 de Contratos (aditivos, faturas, documentos/e-signature, etc.) ainda não criadas.
---
## Fase 4/9 — MFA/TOTP, Estoque (Inventory), Suporte (Service Desk) e fundação de Ponto Eletrônico (HR/Timeclock)
Data: 2026-09-03.
Pedido do usuário: "pode seguir na criacao do app ao menos mais 20 etapas use subagentes". Estratégia: fechei a pendência de MFA/TOTP da Fase 1 diretamente (autenticação é sensível demais para delegar) e lancei 3 subagentes em paralelo (`eden-inventory`, `eden-support`, `eden-hr-timeclock`) em worktrees isolados, seguindo o mesmo padrão validado nas levas anteriores. Além das FKs cruzadas de Quotes/Contracts/ClientRegistrations, deixadas pendentes na leva anterior.
### FKs cruzadas (pendência da leva anterior) — fechada
Migration `1788450538883_cross-reference-fks.cjs`: `quotes.client_registration_id → client_registrations(id)`, `client_registrations.quote_id → quotes(id)`, `contracts.quote_id → quotes(id)`, `contracts.client_registration_id → client_registrations(id)`, todas `ON DELETE RESTRICT` (nenhum desses agregados tem rota DELETE hoje; perder o vínculo silenciosamente numa exclusão não relacionada seria pior que bloquear a exclusão). Verificado zero órfãos antes de aplicar.
### MFA/TOTP (`apps/api/src/auth/mfa.service.ts`, `field-encryption.service.ts`) — implementado diretamente
- `FieldEncryptionService` (`apps/api/src/crypto/`): criptografia reversível genérica AES-256-GCM versionada (ADR-0005), formato `v1:iv:tag:ciphertext` base64, chave de `EDEN_FIELD_ENCRYPTION_KEY` (hasheada para 32 bytes via SHA-256). Primeira implementação real desse padrão no projeto — vai ser reaproveitada por outros segredos reversíveis (ex.: credencial de equipamento Control iD, feito na mesma leva).
- Fluxo de login em duas etapas quando `mfa_enabled=true`: `POST /auth/login` retorna `{mfaRequired: true, mfaChallenge}` (JWT de propósito único, 2 min, claim `purpose` distintivo) em vez de tokens; `POST /auth/mfa/challenge` troca o desafio + código TOTP pelos tokens reais. `POST /auth/mfa/enroll` (gera secret + `otpauthUrl`, não habilita ainda) → `POST /auth/mfa/confirm` (primeiro código correto habilita) → `POST /auth/mfa/disable` (exige código válido, não só sessão autenticada).
- Biblioteca `otplib` v13 (API funcional `generateSecret`/`generateURI`/`verify`, não mais o antigo `authenticator` singleton de v12).
**Bug de segurança real encontrado e corrigido em teste ao vivo (não hipotético — explorado de verdade contra a stack rodando):** o token de desafio MFA (`mfaChallenge`, emitido logo após a senha, antes do código TOTP) era aceito pelo `JwtAuthGuard` como um access token comum — `verifyAccessToken()` só validava assinatura/expiração, nunca checava a claim `purpose`. Resultado: um atacante que só tivesse a senha (sem o segundo fator) conseguia acesso total à API por 2 minutos usando o token de desafio como Bearer normal — MFA completamente contornável. Corrigido em `token.service.ts::verifyAccessToken` rejeitando qualquer token com claim `purpose` presente. Teste de regressão adicionado em `test/mfa.spec.ts` (`GET /auth/me` com o challenge token como Bearer → 401). Explorado e depois fechado ao vivo via curl contra o container real antes de seguir adiante.
### Estoque / Inventory (`apps/api/src/inventory/` + migration `inventory`) — subagente `eden-inventory`
5 tabelas (de 15 possíveis no Master Prompt §6.8, mesmo approach de MVP-coerente do módulo Contracts): `warehouses`, `stock_movements` (ledger append-only, saldo sempre derivado via SUM, nunca coluna editável — reforçado por trigger que bloqueia UPDATE/DELETE sem exceção), `serialized_assets`, `asset_assignments`, `stock_reservations`. Invariante "mesmo serial/patrimônio/MAC nunca duas vezes ativo" (Master Prompt §15.3) reforçada por **3 índices únicos parciais no banco**, não só validação de aplicação. Fluxo completo reserva→instalação(comodato)→devolução implementado com `SELECT...FOR UPDATE` + transação, erros tipados mapeados para 404/409.
Subagente entregou trabalho de excelente qualidade na primeira tentativa — nenhum bug encontrado na revisão nem no teste ao vivo.
### Suporte / Service Desk (`apps/api/src/support/` + migration `support`) — subagente `eden-support`
4 tabelas: `support_tickets` (protocolo humano `TCK-2026-000001`, mesmo padrão de `quote_number`/`contract_number`), `ticket_comments` (com `is_internal`, nunca exposto fora do escopo `all`), `ticket_status_history`, `sla_timers` (1:1). Máquina de estados `new→triage→in_progress⇄{waiting_customer,waiting_third_party}→resolved→closed` (+`cancelled`), mesmo padrão de `CONTRACT_STATUS_TRANSITIONS`. Motor de SLA implementado como funções puras testáveis (`apps/api/src/support/sla.ts`) — pausa/despausa atomicamente em SQL na transição de status, cálculo de violação subtrai corretamente o tempo pausado (incluindo a pausa corrente). `root_cause`/`resolution_notes` só persistidos na transição para `closed`, não `resolved` (decisão documentada, confirmada em teste ao vivo).
Também de excelente qualidade — nenhum bug encontrado.
### Fundação de Ponto Eletrônico / HR-Timeclock (`apps/api/src/hr-timeclock/` + migration `hr-timeclock`) — subagente `eden-hr-timeclock`
Escopo desta leva deliberadamente limitado a fundação (modelo de dados + Control iD device + parser/import de AFD com CRC-16/KERMIT) — apuração, fechamento de período, ajustes, banco de horas e relatórios (eden.md §5-10) explicitamente adiados. 4 tabelas: `timeclock_devices` (senha do equipamento cifrada via `FieldEncryptionService`, nunca retornada em nenhum GET — só `hasCredential:boolean`), `timeclock_employees` (cadastro separado de `users`, decisão documentada: o Control iD identifica por CPF, e `users` não tem CPF), `afd_import_batches`, `afd_records`. `afd_records` é **imutável sem exceção, nem para super_admin** — trigger que bloqueia UPDATE/DELETE sem nenhuma válvula de escape (diferente de `audit_log`, que tem bypass controlado via `SET LOCAL`). Idempotência em duas camadas: hash SHA-256 do arquivo inteiro (retorna `alreadyImported:true` em reimportação) e `UNIQUE(device_id, nsr)` por registro. NSR ausente/não-numérico nunca vira 0 — fica `null`, preservando o dado bruto mesmo inválido.
Parser CRC-16/KERMIT (poly 0x1021 refletido/0x8408, init 0x0000, sem XOR final) verificado contra o vetor de teste conhecido ("123456789" → 0x2189) — confirmado tanto no script ad-hoc do agente quanto na suíte permanente `test/afd-parser.spec.ts` (11 testes).
**1 bug real encontrado e corrigido antes da integração:** a migration `PENDING_hr-timeclock.cjs` tinha uma crase (`` ` ``) solta dentro de um comentário SQL (`-- separate from \`users\``) — como a migration inteira é um template literal JS (`` pgm.sql(`...`) ``), essa crase fechava o literal prematuramente e quebrava o resto do SQL em código JS inválido, causando `SyntaxError: missing ) after argument list` ao tentar rodar `pnpm db:migrate`. Corrigido trocando a crase por aspas simples no comentário. Encontrado e corrigido antes de qualquer teste ao vivo, na primeira tentativa de aplicar a migration.
### Padrão de salvamento seletivo ao integrar (novo, deste lote)
Diferente da leva anterior (onde os 2 agentes falharam por limite de gasto e o trabalho foi resgatado de worktrees não commitados), desta vez os 3 agentes **completaram normalmente**, mas todos os 3 worktrees foram criados a partir do commit único de bootstrap (antes de Auth/Companies/Products/etc. existirem nesse checkout isolado) — sem acesso ao código real de `apps/api/src/auth/`, `packages/database/src/repositories/{audit,users,roles,types}.ts` etc. Cada agente relatou isso com transparência e, para conseguir cumprir a etapa de verificação (`pnpm typecheck` limpo), recriou stubs/placeholders type-compatíveis desses arquivos dentro do próprio worktree — e dois deles (`eden-support`, `eden-hr-timeclock`) deixaram esses placeholders (`audit.ts`, `users.ts`, `roles.ts`, `types.ts`, config, guards de auth) como arquivos genuínos no diff final, não revertidos, por acreditarem (corretamente, do ponto de vista deles, isolados no worktree) que essa infraestrutura ainda não existia.
Na integração, copiei seletivamente **só os arquivos de domínio real** de cada agente (migration, repositório específico do módulo, camada de API do módulo) e **descartei os placeholders duplicados de Auth/Config/Crypto/audit/users/roles**, mantendo as versões reais já existentes em `main`. Wiring do barrel (`packages/database/src/index.ts`) e `app.module.ts` feito manualmente para os 3 módulos, como nas levas anteriores.
### Verificação
`pnpm turbo run typecheck build` verde em cada integração incremental; suíte de testes cresceu de 15 para **30 testes, todos verdes** (`auth.spec.ts`, `mfa.spec.ts` novo, `afd-parser.spec.ts` novo com 11 testes, `role-weight.spec.ts`, `reseller-assignment.spec.ts`, `companies.spec.ts`, `mass-assignment.spec.ts`). Container `eden-api` reconstruído e todo o lote testado ao vivo via curl contra a stack real:
- MFA: enroll→confirm com código TOTP real gerado via `otplib`→login exige desafio→código errado rejeitado→código certo completa login→disable exige código válido; **exploração do bug de segurança repetida após o fix, confirmando 401**.
- Inventory: armazém→entrada de ativo→serial duplicado rejeitado (409, não 500)→reserva→instalação (comodato)→instalação duplicada rejeitada (409)→devolução→ledger conferido linha a linha (`purchase_in`, `reservation`, `comodato_out`, `return_in`).
- Support: ticket criado com SLA (+4h/+24h)→transições válidas→comentário público marca `first_response_met`→`waiting_customer` pausa o timer→retorno a `in_progress` acumula o tempo pausado→`resolved`→`closed` (root_cause só persiste aqui)→transição inválida rejeitada (409).
- HR-Timeclock: device criado com senha cifrada (confirmado no Postgres, formato `v1:iv:tag:ciphertext`, nunca no GET)→import de arquivo AFD válido (2 linhas, CRC correto)→reimport do mesmo arquivo retorna `alreadyImported:true` sem duplicar→`UPDATE` direto em `afd_records` via SQL bloqueado pelo trigger mesmo fora da API.
- Trilha de auditoria (`audit_log`) conferida no Postgres com a sequência completa de todos os eventos do lote.
### Pendências deste lote
- Inventory: `warehouse_locations`, `stock_items` (cache de saldo), `stock_lots`, `inventory_counts`, `transfers`/`receipts`/`issues`/`returns` formais, `rma`, `asset_maintenance` — deferidos, tipos de movimento já modelados no vocabulário do ledger para não exigir nova migration.
- Support: anexos reais (S3), CSAT, OS dedicada, filas com roteamento automático, calendário de atendimento configurável, SLA por categoria — hoje é uma política única fixa (4h/24h).
- HR-Timeclock: apuração, fechamento de período, ajustes manuais, banco de horas, relatórios, sync ao vivo com o Control iD, upload multipart real (hoje aceita o conteúdo como string JSON no body).
- MFA: sem rate-limiting/bloqueio de tentativas no `mfa/challenge` (mesma lacuna que já existe no login por senha hoje — nenhum endpoint de auth tem rate limit ainda, ficou registrado como débito técnico geral, não específico do MFA).
---
## Fase 5/6 + Design System — Billing, Fiscal e primeira tela real (login + shell autenticado)
Data: 2026-09-03.
Continuação do mesmo pedido do usuário ("pelo menos mais 20 etapas, use subagentes"). 3 subagentes em paralelo (`eden-finance`, `eden-fiscal`, `eden-frontend`), mesmo padrão de worktree isolado + integração seletiva manual.
### Billing / Faturamento recorrente (`apps/api/src/billing/` + migration `billing`) — subagente `eden-finance`
MVP de 4 tabelas (ADR-0008 lista 9; billing_accounts/billing_cycles/charge_components/usage_charges/invoice_adjustments/billing_run_logs deferidos, documentado): `billing_subscriptions` (derivada de contrato, `monthly_value` é snapshot — nunca recalculado automaticamente), `billing_runs`, `invoices`, `invoice_items`. Fluxo `subscription→billing_run→invoice` completo e idempotente: rodar o mesmo run duas vezes em `draft` substitui as invoices (não duplica) — reforçado por índice único parcial `WHERE status='draft'` (no máximo um run em rascunho por período); depois de `consolidate`, run e invoices ficam imutáveis pela aplicação (409 em qualquer tentativa de gerar/alterar). ADR-0008 respeitado à risca: Billing só calcula o que é devido, nunca toca em AR/AP/boleto/cobrança (Finance) nem em documento fiscal (Fiscal) — bounded contexts continuam sendo desenvolvidos separadamente.
### Fiscal (`apps/api/src/fiscal/` + migration `fiscal`) — subagente `eden-fiscal`
eden.md módulo 5 tem ~19 tabelas de catálogo + importadores automáticos (Siscomex/IBGE) + importador manual de arquivo — MVP portou 5 tabelas fielmente ao shape do legado: `fiscal_ncm`/`fiscal_cfop` (catálogo simples), `fiscal_municipality` (shape distinto, sem `valid_from`/`valid_until`), `fiscal_operation_nature` (seed fixo com as 11 naturezas do legado), `product_fiscal_profiles` (liga produto à classificação fiscal). Invariante do eden.md §2.5 implementada de verdade: **um produto tem no máximo um profile ativo por `billing_component`** — índice único parcial + camada de aplicação que inativa o profile anterior antes de criar o novo (nunca update destrutivo, histórico preservado). Todo o importador automático/manual e as ~14 outras tabelas de catálogo ficam deferidos (documentado com a seção exata do eden.md para cada item).
### Design System + login real (`packages/ui`, `apps/core-web`) — subagente `eden-frontend`
Primeira leva de frontend do projeto. Achado que mudou o escopo para melhor: o `tema_do_Eden.zip` (comprado) não é só Angular — tem uma versão pronta em **React 18 + Vite + Tailwind** dentro (`react.zip`, 42.5MB), a mesma stack que o EDEN já usa. Em vez de portar de Angular, o agente extraiu os tokens visuais reais (cores, tipografia, sombras, border-radius, larguras de layout) desse React/Tailwind — confirmando inclusive uma divergência da nossa própria documentação: `docs/project-inventory.md` citava fonte Nunito/Poppins, a fonte real do tema é **Geist**.
- `packages/ui` deixou de ser placeholder: preset Tailwind exportável (`@eden/ui/tailwind-preset`) com os tokens reais extraídos, mais componentes React reais (não stub) — `Button`, `Input`, `Card`, `Badge`, `Alert`, `PageHeader`, `AppShell` (sidebar+topbar+conteúdo).
- `apps/core-web` (EDEN Core — prioridade, os outros 2 apps ficam para leva futura): trocou a tela estática "Bootstrap da Fase 1" por um fluxo real com `react-router-dom` — página de login chamando de verdade `POST /api/v1/auth/login` (com suporte ao fluxo `mfaRequired`/`POST /mfa/challenge` da leva de MFA), sessão em `localStorage`, rota protegida, e um shell autenticado (`AppShell` real, nome do usuário via `GET /api/v1/auth/me`, logout).
**Achado real fora do escopo do agente, corrigido por mim em seguida**: a API não tinha CORS configurado (`app.enableCors` ausente em `main.ts`) — um browser real rodando `core-web` num domínio/porta diferente da API teria toda chamada de login bloqueada no preflight, mesmo com tudo implementado corretamente nos dois lados. Corrigido: novo `EDEN_CORS_ORIGINS` (env, lista de origens separadas por vírgula, nunca wildcard — a API emite bearer token) em `packages/config/src/env-schema.ts`, `app.enableCors({origin, credentials:true})` em `main.ts`, propagado em `compose.yaml`/`.env`/`.env.example` já com os 3 domínios públicos reais (`dev-edencore`/`dev-parceirohandix`/`dev-portalassinate.falehandix.com.br`) mais as portas de dev locais. Testado ao vivo: preflight de `https://dev-edencore.falehandix.com.br` aceito com os headers corretos; origem não autorizada (`evil.example.com`) corretamente sem `Access-Control-Allow-Origin` na resposta; login completo simulando o `Origin` real de browser, 200 com o header de CORS certo.
**Pendência de infra identificada, não resolvida (decisão do operador, não técnica)**: os 3 domínios públicos hoje só apontam para os containers de frontend (3001/3002/3003) — não há um domínio público equivalente para a API. `VITE_API_BASE_URL` do build de produção dos 3 apps ainda aponta para `localhost:8080` (só funciona em dev local); falta decidir e implementar como o browser em produção alcança a API (domínio próprio para a API? proxy reverso roteando um path tipo `/api` para o container `eden-api` no mesmo domínio do frontend?) antes do login funcionar nos domínios públicos de verdade.
### Verificação
`pnpm turbo run typecheck build` (28/28) e `pnpm --filter @eden/api test` (30/30, sem regressão) verdes após cada integração. Containers `eden-api`/`eden-core` reconstruídos; testado ao vivo via curl:
- Fiscal: naturezas de operação semeadas (11), NCM criado, **invariante de profile único ativo por componente confirmada** (segundo profile RECURRING criado → primeiro automaticamente `active:false`).
- Billing: subscription criada → billing run gerado (invoice de R$499,90, vencimento no `billing_day`) → regeneração do mesmo período reaproveita o mesmo run (idempotente, sem duplicar invoice) → consolidação → segunda consolidação rejeitada (409) → regeneração de período já consolidado rejeitada (409).
- CORS: preflight aceito do domínio público real, rejeitado de origem não cadastrada, login completo com `Origin` de browser real.
- `core-web`: build de produção servido pelo container real não contém mais o texto "Bootstrap da Fase 1" — contém o formulário de login real (`E-mail`/`Senha`/`Entrar`).
- Trilha de auditoria conferida no Postgres com a sequência completa do lote.
### Pendências deste lote
- Billing: `billing_accounts`, `billing_cycles` como tabela própria, consumo/telefonia (`charge_components`/`usage_charges`), `invoice_adjustments`, pró-rata — tudo Finance/AR-AP (títulos, boletos, dunning, conciliação) fica para quando esse bounded context for iniciado, por decisão da ADR-0008.
- Fiscal: as ~14 tabelas de catálogo restantes, `fiscal_catalog_sync`, importador automático (Siscomex/IBGE) e manual (CSV/XLSX/JSON com proteção de inativação em massa), `fiscal_rules` (schema pronto mas sem consumo mesmo no legado).
- Frontend: `reseller-web`/`subscriber-web` ainda no bootstrap estático; telas de negócio (Quotes/Contracts/Inventory/etc.) no `core-web`; rotação automática de refresh token; fluxo de ENROLL de MFA na UI (só login com MFA já habilitado está implementado).
### Decisão do operador sobre topologia de API pública + 3 bugs de infra encontrados e corrigidos na sequência
Usuário decidiu: a API **nunca** tem domínio público próprio, só IP/nome interno — o browser sempre chama a API pelo mesmo domínio do frontend (caminho relativo), e o proxy externo (gerenciado pelo operador, fora deste repo) roteia esse caminho internamente para `eden-api` (por nome de serviço Docker, `/etc/hosts` ou IP interno — detalhe de infra do operador, não deste código).
Implementado: `infra/docker/Dockerfile.web` agora aceita `ARG VITE_API_BASE_URL` (default `/api/v1`, Vite bake em build-time); `compose.yaml` passa esse build arg fixo (`/api/v1`) para os 3 serviços web; `EDEN_CORS_ORIGINS` simplificado para só as portas de dev local (o fluxo de produção é same-origin via proxy, não precisa de CORS).
**3 bugs reais de infraestrutura encontrados e corrigidos nessa verificação (nenhum era o objetivo original, todos achados testando o resultado de ponta a ponta, não só lendo o código):**
1. **`turbo.json` não declarava `VITE_API_BASE_URL` como input da task `build`** — o Turborepo cacheava o build do Vite sem saber que essa env var afeta o output, então reaproveitava um bundle antigo (com a URL absoluta de dev) mesmo depois de mudar a env var. Corrigido adicionando `"env": ["VITE_*"]` à task `build` em `turbo.json`.
2. **`compose.yaml` usava `${VITE_API_BASE_URL:-/api/v1}`** para o build arg do Docker — parecia razoável, mas colidia com a variável de mesmo nome no `.env` (usada para `pnpm dev` local, valor absoluto `http://localhost:8080/api/v1`) que o `docker compose` carrega automaticamente; o "default" nunca era usado porque a variável já estava setada. Corrigido fixando o valor diretamente (`/api/v1`, sem interpolação de env) nos 3 serviços web, já que essa é uma decisão arquitetural fixa, não uma configuração por ambiente.
3. **Não existia `.dockerignore`** no projeto — todo `COPY . .` do `Dockerfile.web`/`Dockerfile.node` estava copiando `node_modules/` (315MB), `.git/` (5.8MB) e `.claude/worktrees/` (345MB, os worktrees de subagentes desta mesma sessão) para dentro do contexto de build a cada vez. Além do desperdício óbvio, isso é parte de por que o bug nº1 passou despercebido no primeiro teste: caches `.turbo/` de dentro de cada pacote também eram copiados para dentro da imagem. Corrigido criando `.dockerignore` (exclui `node_modules`, `.git`, `dist`, `.turbo`, `.claude/worktrees`, `.tmp`, `.env*`, o zip do tema).
Cada correção foi testada isoladamente e a última confirmada abrindo o JS de produção servido pelo container real (`docker compose build --no-cache`) e conferindo que o literal `"/api/v1"` (não mais `"http://localhost:8080/api/v1"`) está no bundle.

View File

@@ -8,6 +8,15 @@ WORKDIR /repo
COPY . .
RUN pnpm install --frozen-lockfile
ARG APP_NAME
# Vite bakes VITE_-prefixed vars into the bundle at build time (no runtime
# env in a static nginx-served SPA) — see docs/adr/0002 + compose.yaml's
# build.args for each of the 3 frontends. Default is same-origin relative
# path: the API is never exposed on its own public domain (operator
# decision), so in production the reverse proxy in front of each public
# frontend domain must forward this path (e.g. /api/*) to eden-api on the
# internal network — see the comment on EDEN_CORS_ORIGINS in .env.example.
ARG VITE_API_BASE_URL=/api/v1
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN pnpm turbo run build --filter=@eden/${APP_NAME}...
FROM nginx:1.27-alpine AS runtime

View File

@@ -36,6 +36,19 @@ export const envSchema = z.object({
S3_BUCKET: z.string().optional(),
PUBLIC_BASE_URL: z.string().optional(),
/**
* Comma-separated list of allowed browser origins for CORS. The API has
* no public domain of its own (operator decision) — production traffic
* reaches it same-origin through each frontend's reverse proxy, so this
* only matters for local dev, where the Vite dev server (localhost:3001+)
* and the API (localhost:8080) are different origins. Optional — when
* unset, CORS stays effectively disabled (no browser origin is allowed
* cross-origin). Never a wildcard: the API issues bearer tokens, and a
* wildcard origin combined with credentialed requests is a known
* CSRF-adjacent misconfiguration.
*/
EDEN_CORS_ORIGINS: z.string().optional(),
});
export type EdenEnv = z.infer<typeof envSchema>;

View File

@@ -0,0 +1,55 @@
/**
* Adds the cross-reference foreign keys between Quotes, Client
* Registrations and Contracts that were deliberately left as plain
* nullable UUIDs when each table was created in parallel (see the header
* comments in 1788445280742_quotes.cjs, 1788447739229_client-registrations.cjs
* and 1788447802216_contracts.cjs). All three tables now exist on `main`,
* so this closes that gap.
*
* ON DELETE RESTRICT everywhere: none of these aggregates has a DELETE
* route today (quotes/client_registrations/contracts are only ever
* status-transitioned, never removed), and a business document silently
* losing its link to another business document on an unrelated delete
* would be a worse failure mode than the delete itself being blocked.
*/
exports.up = (pgm) => {
pgm.addConstraint("quotes", "fk_quotes_client_registration", {
foreignKeys: {
columns: "client_registration_id",
references: "client_registrations(id)",
onDelete: "RESTRICT",
},
});
pgm.addConstraint("client_registrations", "fk_client_registrations_quote", {
foreignKeys: {
columns: "quote_id",
references: "quotes(id)",
onDelete: "RESTRICT",
},
});
pgm.addConstraint("contracts", "fk_contracts_quote", {
foreignKeys: {
columns: "quote_id",
references: "quotes(id)",
onDelete: "RESTRICT",
},
});
pgm.addConstraint("contracts", "fk_contracts_client_registration", {
foreignKeys: {
columns: "client_registration_id",
references: "client_registrations(id)",
onDelete: "RESTRICT",
},
});
};
exports.down = (pgm) => {
pgm.dropConstraint("contracts", "fk_contracts_client_registration");
pgm.dropConstraint("contracts", "fk_contracts_quote");
pgm.dropConstraint("client_registrations", "fk_client_registrations_quote");
pgm.dropConstraint("quotes", "fk_quotes_client_registration");
};

View File

@@ -0,0 +1,264 @@
/**
* Inventory/Warehouse/Assets bounded context — Master Prompt §6.8, ADR-0007.
* Greenfield: the legacy OrçaFácil has no real stock module, only the
* requisições cited in the Master Prompt, so there is no eden.md section
* to port faithfully here — this schema is designed from the Master
* Prompt §6.8 requirements directly.
*
* Master Prompt §6.8 lists 15 possible entities. This migration builds
* the 5 that form one coherent, end-to-end-testable slice (reserve →
* select serial → install/comodato → return), mirroring the
* 4-of-11-tables MVP approach taken by the Contracts module
* (docs/adr/0006, see 1788447802216_contracts.cjs's header comment):
*
* - warehouses (this migration)
* - stock_movements (this migration)
* - serialized_assets (this migration)
* - asset_assignments (this migration)
* - stock_reservations (this migration)
*
* Explicitly DEFERRED, not forgotten — each needs a module/workflow that
* hasn't landed yet:
* - warehouse_locations — bin/shelf-level location inside a warehouse;
* needs a UX for put-away/picking before a bare table is useful.
* - stock_items — a per-(warehouse,product) balance cache row;
* deliberately NOT built — this MVP's invariant is that on-hand
* balance is ALWAYS derived from stock_movements (SUM by sign), never
* a stored/editable column (see stock_movements' comment). A
* materialized cache can be added later purely as a performance
* optimization, refreshed FROM the ledger, never the other way round.
* - stock_lots — batch/lot tracking (expiry, supplier batch);
* no product in the current catalog needs it yet.
* - inventory_counts — physical count / cycle-count workflow with
* its own reconciliation UX; a count's variance would itself need to
* post `adjustment_in`/`adjustment_out` movements once built.
* - transfers — a formal multi-step inter-warehouse transfer
* (request → in-transit → receive) with its own approval workflow;
* `transfer_out`/`transfer_in` movement types are already modeled in
* the ledger vocabulary below so this can be layered on top later
* without a ledger migration.
* - receipts / issues — formal goods-receipt-note / issue-note
* documents (e.g. tied to a purchase order); `purchase_in` already
* covers the ledger side for this MVP without the PO workflow.
* - returns — a formal customer-return workflow distinct
* from the simple `/assets/:id/return` endpoint this MVP ships
* (which only closes the assignment and posts `return_in`); needed
* later for e.g. partial/quantity-based non-serialized returns.
* - rma — formal vendor RMA case tracking (tracking
* number, vendor, resolution); `rma_out` is already modeled in the
* ledger vocabulary below so the case-tracking table can be added
* later without a ledger migration.
* - asset_maintenance — service/repair history for a serialized
* asset; needs its own workflow design, not just a bare log table.
*
* No FK to `contracts` yet: `serialized_assets.current_contract_id` and
* `asset_assignments.contract_id` are deliberately nullable/plain UUID
* with NO REFERENCES clause — same adopted pattern as Quotes/Contracts/
* ClientRegistrations' cross-module FKs while modules land in parallel
* worktrees. Wiring those FKs is integration work for the merge step.
*
* ON DELETE: no table in this migration ever CASCADEs a delete from
* warehouses/products/serialized_assets — an asset or warehouse with
* movement/assignment history is never hard-deleted, only inactivated
* (warehouses.is_active) or status-transitioned (serialized_assets.status
* = 'written_off'). This is a deliberate invariant per the Inventory
* agent's mandate, not an oversight.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
-- =====================================================================
-- warehouses — armazém/depósito físico, pertence a uma legal entity
-- =====================================================================
CREATE TABLE warehouses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
company_id UUID NOT NULL REFERENCES companies(id),
name TEXT NOT NULL,
code TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
UNIQUE (code)
);
COMMENT ON TABLE warehouses IS
'Armazém/depósito físico — Master Prompt §6.8. Pertence a uma company (legal entity, companies.id). code é único globalmente (não só por company) para servir como identificador humano curto e inequívoco em toda a UI/relatórios. Nunca deletado fisicamente — um armazém desativado é is_active=false, preservando o histórico de stock_movements/serialized_assets que apontam para ele (ver comentário de topo da migration).';
CREATE TRIGGER trg_warehouses_updated_at BEFORE UPDATE ON warehouses
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_warehouses_company ON warehouses(company_id);
-- =====================================================================
-- stock_movements — ledger append-only. Saldo de estoque é SEMPRE
-- derivado daqui (SUM com sinal implícito pelo tipo), nunca uma coluna
-- de saldo editável em nenhuma outra tabela.
-- =====================================================================
CREATE TABLE stock_movements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
warehouse_id UUID NOT NULL REFERENCES warehouses(id),
product_id UUID NOT NULL REFERENCES products(id),
movement_type TEXT NOT NULL CHECK (movement_type IN (
'purchase_in', 'adjustment_in', 'adjustment_out',
'transfer_out', 'transfer_in',
'reservation', 'reservation_release',
'sale_out', 'install_out', 'comodato_out',
'return_in', 'rma_out', 'write_off'
)),
quantity NUMERIC(14,3) NOT NULL CHECK (quantity > 0),
reference_type TEXT,
reference_id UUID,
notes TEXT
);
COMMENT ON TABLE stock_movements IS
'Ledger append-only de estoque — Master Prompt §6.8. quantity é SEMPRE positivo; o sinal/direção é implícito pelo movement_type (ver COMMENT de movement_type). Uma correção de um movimento passado é sempre um NOVO movimento de estorno (ex.: um purchase_in lançado errado é corrigido com um adjustment_out equivalente), nunca um UPDATE/DELETE da linha original — daí os triggers trg_stock_movements_no_update/no_delete abaixo, mesmo padrão de audit_log (ver baseline-schema). reference_type/reference_id são polimórficos e opcionais (sem FK, por natureza) — ex.: reference_type=''contract''/reference_id=<contracts.id> num install_out, ou reference_type=''serialized_asset''/reference_id=<serialized_assets.id> numa reservation de um ativo específico.';
COMMENT ON COLUMN stock_movements.movement_type IS
'Direção implícita por tipo (usada para calcular o saldo on-hand de um warehouse+product via SUM):
ENTRADA (+ saldo on-hand): purchase_in, adjustment_in, transfer_in, return_in
SAÍDA (- saldo on-hand): adjustment_out, transfer_out, sale_out, install_out, comodato_out, rma_out, write_off
NEUTRO para saldo on-hand, afeta apenas "disponível para prometer"
(on_hand - reservas ativas), não o saldo físico: reservation, reservation_release
Saldo on-hand de um (warehouse_id, product_id) = SUM(quantity) das entradas − SUM(quantity) das saídas, sempre calculado em tempo de consulta a partir desta tabela — nunca cacheado numa coluna editável (ver header da migration, item stock_items deferido).';
CREATE OR REPLACE FUNCTION prevent_stock_movement_mutation() RETURNS TRIGGER AS $$
BEGIN
IF current_setting('eden.allow_stock_movement_mutation', true) IS DISTINCT FROM 'true' THEN
RAISE EXCEPTION 'stock_movements é append-only — correções são novos movimentos de estorno, nunca UPDATE/DELETE (ver Master Prompt §6.8 / .claude/agents/eden-inventory.md)';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_stock_movements_no_update BEFORE UPDATE ON stock_movements
FOR EACH ROW EXECUTE FUNCTION prevent_stock_movement_mutation();
CREATE TRIGGER trg_stock_movements_no_delete BEFORE DELETE ON stock_movements
FOR EACH ROW EXECUTE FUNCTION prevent_stock_movement_mutation();
CREATE INDEX idx_stock_movements_warehouse_product ON stock_movements(warehouse_id, product_id);
CREATE INDEX idx_stock_movements_reference ON stock_movements(reference_type, reference_id);
CREATE INDEX idx_stock_movements_created_at ON stock_movements(created_at);
-- =====================================================================
-- serialized_assets — um ativo físico rastreado individualmente
-- =====================================================================
CREATE TABLE serialized_assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id),
product_id UUID NOT NULL REFERENCES products(id),
serial TEXT,
patrimony_tag TEXT,
mac_address TEXT,
warehouse_id UUID REFERENCES warehouses(id),
status TEXT NOT NULL DEFAULT 'in_stock' CHECK (status IN (
'in_stock', 'reserved', 'installed', 'comodato', 'returned', 'written_off'
)),
current_contract_id UUID,
warranty_expires_at TIMESTAMPTZ,
CONSTRAINT chk_serialized_assets_has_identifier CHECK (serial IS NOT NULL OR patrimony_tag IS NOT NULL)
);
COMMENT ON TABLE serialized_assets IS
'Ativo físico rastreado individualmente (serial/patrimônio/MAC) — Master Prompt §6.8. Ao menos um identificador (serial OU patrimony_tag) é obrigatório; produtos que só rastreiam patrimônio (ex.: mobiliário) podem não ter serial de fabricante. warehouse_id é a localização atual e é NULL quando o ativo está instalado/comodato no cliente (status IN (''installed'',''comodato'')) — current_contract_id aponta pra onde ele está nesse caso. current_contract_id NÃO tem FK para contracts ainda (contracts está sendo construído em paralelo por outro agente neste worktree) — mesmo padrão de FK cruzada adiada de Quotes/Contracts/ClientRegistrations; a FK real é trabalho de integração no merge. Nunca deletado fisicamente: um ativo dado de baixa vira status=''written_off'' (ver header da migration).';
COMMENT ON COLUMN serialized_assets.mac_address IS
'MVP: uma única coluna de MAC primário (nullable), não um array nem uma tabela filha. Decisão documentada: o índice único parcial que garante "MAC nunca duplicado entre ativos simultaneamente ativos" (ver abaixo) precisa colocalizar mac_address com status na MESMA tabela — um índice parcial não pode referenciar uma condição de uma tabela filha via JOIN. Se um produto real precisar rastrear múltiplos MACs (ex.: CPE dual-band com MAC de LAN e de WLAN), promover para uma tabela filha então, com a invariante reforçada por trigger em vez de índice parcial — deferido conscientemente, não esquecido.';
COMMENT ON COLUMN serialized_assets.status IS
'in_stock/reserved/installed/comodato contam como "ativo" para a invariante de não-duplicação de identificador (ver índices únicos parciais abaixo). returned/written_off NÃO contam — liberam o serial/patrimônio/MAC para reuso por uma linha nova, ex.: uma correção de cadastro. Nota: o endpoint POST /assets/:id/return desta MVP leva o ativo direto de volta a ''in_stock'' (mesma linha) — o status ''returned'' fica reservado para um fluxo futuro de RMA/inspeção pós-devolução (ainda não implementado, ver header da migration) em que o ativo chega mas ainda não foi conferido/restocado.';
COMMENT ON COLUMN serialized_assets.current_contract_id IS
'Sem FK para contracts.id ainda — deliberado, ver COMMENT da tabela. Preenchido em /assets/:id/install, limpo em /assets/:id/return.';
CREATE TRIGGER trg_serialized_assets_updated_at BEFORE UPDATE ON serialized_assets
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- Invariante obrigatória (Master Prompt §15.3 / §6.8: "nunca reutilizar
-- o mesmo serial/MAC/patrimônio simultaneamente em dois ativos
-- ativos") — reforçada em DB, não só em validação de aplicação.
CREATE UNIQUE INDEX idx_serialized_assets_serial_active ON serialized_assets(serial)
WHERE serial IS NOT NULL AND status IN ('in_stock', 'reserved', 'installed', 'comodato');
CREATE UNIQUE INDEX idx_serialized_assets_patrimony_active ON serialized_assets(patrimony_tag)
WHERE patrimony_tag IS NOT NULL AND status IN ('in_stock', 'reserved', 'installed', 'comodato');
CREATE UNIQUE INDEX idx_serialized_assets_mac_active ON serialized_assets(mac_address)
WHERE mac_address IS NOT NULL AND status IN ('in_stock', 'reserved', 'installed', 'comodato');
CREATE INDEX idx_serialized_assets_product ON serialized_assets(product_id);
CREATE INDEX idx_serialized_assets_warehouse ON serialized_assets(warehouse_id);
CREATE INDEX idx_serialized_assets_status ON serialized_assets(status);
CREATE INDEX idx_serialized_assets_contract ON serialized_assets(current_contract_id);
-- =====================================================================
-- asset_assignments — histórico de vínculo de um serialized_asset a um
-- contrato/cliente ao longo do tempo
-- =====================================================================
CREATE TABLE asset_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
asset_id UUID NOT NULL REFERENCES serialized_assets(id),
contract_id UUID NOT NULL,
assignment_type TEXT NOT NULL CHECK (assignment_type IN ('installed', 'comodato')),
assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
unassigned_at TIMESTAMPTZ
);
COMMENT ON TABLE asset_assignments IS
'Histórico de vínculo de um ativo serializado a um contrato/cliente — Master Prompt §6.8. contract_id sem FK ainda (mesmo motivo/padrão de serialized_assets.current_contract_id). Nunca UPDATE de um assignment fechado (unassigned_at já preenchido): encerrar um vínculo é preencher unassigned_at UMA vez (camada de repositório só permite esse UPDATE específico, guardado pelo índice único parcial abaixo que impede reabrir/duplicar um vínculo aberto); um novo vínculo é sempre uma linha nova.';
-- Reforça em DB que um ativo nunca tem mais de um vínculo ABERTO
-- simultâneo (unassigned_at IS NULL) — mesma invariante de
-- "não alocado duas vezes" do Master Prompt §15.3, vista pelo lado do
-- histórico de vínculos em vez do status do ativo.
CREATE UNIQUE INDEX idx_asset_assignments_open ON asset_assignments(asset_id)
WHERE unassigned_at IS NULL;
CREATE INDEX idx_asset_assignments_asset ON asset_assignments(asset_id);
CREATE INDEX idx_asset_assignments_contract ON asset_assignments(contract_id);
-- =====================================================================
-- stock_reservations — reserva de quantidade de um produto num
-- warehouse, com ou sem um serial específico já selecionado
-- =====================================================================
CREATE TABLE stock_reservations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
warehouse_id UUID NOT NULL REFERENCES warehouses(id),
product_id UUID NOT NULL REFERENCES products(id),
quantity NUMERIC(14,3) NOT NULL CHECK (quantity > 0),
reference_type TEXT,
reference_id UUID,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'released', 'fulfilled')),
notes TEXT
);
COMMENT ON TABLE stock_reservations IS
'Reserva de quantidade de um produto num warehouse, ANTES (ou no lugar) da seleção de um serial específico — Master Prompt §6.8, primeiro passo do fluxo reservar→selecionar serial→instalar. Duas formas de uso, ambas suportadas por esta mesma tabela (sem coluna asset_id — decisão documentada):
1. Reserva GENÉRICA (produto+quantidade, ainda sem serial escolhido): reference_type/reference_id apontam, por exemplo, para a quote_id ou contract_id que originou a necessidade. Caso descrito no Master Prompt como o primeiro passo do fluxo.
2. Reserva de um ATIVO ESPECÍFICO (POST /assets/:id/reserve): quantity=1, reference_type=''serialized_asset'', reference_id=<serialized_assets.id> — rastreável ao ativo exato sem precisar de uma coluna dedicada.
status=''fulfilled'' quando a reserva efetivamente vira uma saída (ex.: o ativo reservado é instalado); ''released'' quando cancelada sem uso. Sem FK em reference_id (polimórfico).';
CREATE TRIGGER trg_stock_reservations_updated_at BEFORE UPDATE ON stock_reservations
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_stock_reservations_warehouse_product ON stock_reservations(warehouse_id, product_id);
CREATE INDEX idx_stock_reservations_status ON stock_reservations(status);
CREATE INDEX idx_stock_reservations_reference ON stock_reservations(reference_type, reference_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS stock_reservations;
DROP TABLE IF EXISTS asset_assignments;
DROP TABLE IF EXISTS serialized_assets;
DROP TABLE IF EXISTS stock_movements;
DROP FUNCTION IF EXISTS prevent_stock_movement_mutation();
DROP TABLE IF EXISTS warehouses;
`);
};

View File

@@ -0,0 +1,164 @@
/**
* Support/Service Desk bounded context (Master Prompt §6.13). Greenfield —
* no legacy OrçaFácil module to port, unlike most other EDEN modules.
*
* This MVP builds the 4 tables needed for a working ticket lifecycle with
* real SLA timers:
* - support_tickets (this migration)
* - ticket_comments (this migration)
* - ticket_status_history (this migration)
* - sla_timers (this migration, 1:1 with support_tickets)
*
* Explicitly DEFERRED, not forgotten:
* - Real attachments (S3) — ticket_comments/support_tickets have no
* attachment table yet; needs the Documents/S3 integration adapter
* (packages/integrations) to land first, same reasoning as
* contract_documents in the Contracts migration.
* - CSAT (customer satisfaction survey after closing) — needs a survey
* delivery mechanism (email/portal) that doesn't exist yet.
* - Dedicated work order (OS) entity distinct from the ticket itself —
* this MVP treats the ticket as also carrying the OS-equivalent
* fields (root_cause/resolution_notes); a first-class `work_orders`
* table with its own scheduling/dispatch workflow is future work.
* - Queue model with automatic routing rules — there is no `queues`
* table yet; tickets are assigned via owner_user_id only (manual).
* - Configurable attendance calendar (business hours/holidays) for SLA
* due-date math — `sla_timers` due dates are computed with naive
* wall-clock offsets (see apps/api/src/support/sla.ts), not a
* calendar-aware engine. Category-specific SLA policy is also
* deferred — this MVP uses one flat default policy for every ticket
* regardless of category/priority (see sla.ts comment).
*
* `support_tickets.client_registration_id` and `.contract_id` are
* deliberately nullable with NO foreign key, same adopted pattern as
* `contracts.client_registration_id`/`contracts.quote_id`: those modules
* are being built in parallel by other agents. Wiring the FKs is
* integration-step work, not this migration's.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
-- =====================================================================
-- support_tickets — the core aggregate
-- =====================================================================
CREATE SEQUENCE support_tickets_protocol_number_seq START 1;
CREATE TABLE support_tickets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id),
protocol_number TEXT NOT NULL UNIQUE
DEFAULT ('TCK-' || to_char(now(), 'YYYY') || '-' || lpad(nextval('support_tickets_protocol_number_seq')::text, 6, '0')),
title TEXT NOT NULL,
description TEXT NOT NULL,
category TEXT NOT NULL,
subcategory TEXT,
priority TEXT NOT NULL DEFAULT 'medium'
CHECK (priority IN ('low', 'medium', 'high', 'critical')),
status TEXT NOT NULL DEFAULT 'new'
CHECK (status IN (
'new', 'triage', 'in_progress', 'waiting_customer',
'waiting_third_party', 'resolved', 'closed', 'cancelled'
)),
reseller_id UUID REFERENCES resellers(id),
client_registration_id UUID,
contract_id UUID,
owner_user_id UUID REFERENCES users(id),
opened_by_user_id UUID NOT NULL REFERENCES users(id),
root_cause TEXT,
resolution_notes TEXT,
first_response_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ
);
COMMENT ON TABLE support_tickets IS
'Ticket de atendimento (Service Desk, Master Prompt §6.13) — módulo greenfield, sem equivalente no legado OrçaFácil. protocol_number é gerado inline via support_tickets_protocol_number_seq + to_char, mesmo padrão de quotes.quote_number (ver migration 1788445280742_quotes.cjs), formato TCK-{ano}-{sequência de 6 dígitos}. client_registration_id/contract_id são nullable SEM FK — client_registrations e contracts existem em outras migrations mas a FK cruzada é trabalho de integração, não desta migration (mesmo padrão adotado por contracts.client_registration_id/contracts.quote_id).';
COMMENT ON COLUMN support_tickets.root_cause IS
'Causa raiz — só tem sentido preenchido no fechamento do ticket (transição para ''closed''), mas não há CHECK de banco forçando isso: a camada de aplicação (support.service.ts) só aceita esse campo no payload da transição para ''closed''.';
COMMENT ON COLUMN support_tickets.first_response_at IS
'Quando a primeira resposta pública (não-interna) ao ticket ocorreu. Setado uma única vez (nunca sobrescrito) pela camada de aplicação ao adicionar o primeiro ticket_comments.is_internal=false — ver apps/api/src/support/support.service.ts e sla_timers.first_response_met.';
COMMENT ON COLUMN support_tickets.resolved_at IS
'Quando o status se tornou ''resolved'' pela primeira vez — nunca sobrescrito depois (semântica COALESCE, mesmo padrão de contracts.activated_at).';
COMMENT ON COLUMN support_tickets.closed_at IS
'Quando o status se tornou ''closed'' pela primeira vez — nunca sobrescrito depois (semântica COALESCE, mesmo padrão de contracts.activated_at).';
CREATE TRIGGER trg_support_tickets_updated_at BEFORE UPDATE ON support_tickets
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_support_tickets_status ON support_tickets(status);
CREATE INDEX idx_support_tickets_reseller ON support_tickets(reseller_id);
CREATE INDEX idx_support_tickets_owner ON support_tickets(owner_user_id);
CREATE INDEX idx_support_tickets_client_registration ON support_tickets(client_registration_id);
CREATE INDEX idx_support_tickets_contract ON support_tickets(contract_id);
ALTER SEQUENCE support_tickets_protocol_number_seq OWNED BY support_tickets.protocol_number;
-- =====================================================================
-- ticket_comments — public/internal comment thread
-- =====================================================================
CREATE TABLE ticket_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
author_user_id UUID NOT NULL REFERENCES users(id),
body TEXT NOT NULL,
is_internal BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE ticket_comments IS
'Thread de comentários do ticket. is_internal=true nunca pode ser exposto ao portal do assinante ou da revenda — reforçado na camada de query/serviço (apps/api/src/support/support.service.ts), não apenas por convenção de UI, mesma disciplina de isolamento cross-tenant do módulo comercial (Master Prompt, eden-support persona).';
CREATE INDEX idx_ticket_comments_ticket ON ticket_comments(ticket_id);
-- =====================================================================
-- ticket_status_history — append-only UX-facing lifecycle log,
-- distinct from (and complementary to) the generic audit_log
-- =====================================================================
CREATE TABLE ticket_status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE,
from_status TEXT,
to_status TEXT NOT NULL,
reason TEXT,
changed_by UUID REFERENCES users(id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE ticket_status_history IS
'Log de transições de status do ticket — append-only por convenção da CAMADA DE APLICAÇÃO (nenhuma rota de UPDATE/DELETE é exposta), mesmo padrão e mesma justificativa de coexistência com audit_log de contract_status_history (ver migration 1788447802216_contracts.cjs).';
CREATE INDEX idx_ticket_status_history_ticket ON ticket_status_history(ticket_id);
-- =====================================================================
-- sla_timers — 1:1 with support_tickets (ticket_id is both PK and FK)
-- =====================================================================
CREATE TABLE sla_timers (
ticket_id UUID PRIMARY KEY REFERENCES support_tickets(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
first_response_due_at TIMESTAMPTZ NOT NULL,
resolution_due_at TIMESTAMPTZ NOT NULL,
first_response_met BOOLEAN,
resolution_met BOOLEAN,
paused_since TIMESTAMPTZ,
accumulated_pause_seconds INTEGER NOT NULL DEFAULT 0
);
COMMENT ON TABLE sla_timers IS
'Timer de SLA, uma linha por ticket (ticket_id é PK e FK ao mesmo tempo — força o 1:1). Regra de pausa: ao entrar em waiting_customer/waiting_third_party, paused_since = now(); ao voltar para in_progress, accumulated_pause_seconds soma o intervalo pausado e paused_since volta a NULL — implementado atomicamente em packages/database/src/repositories/support-tickets.ts::updateTicketStatus (mesmo padrão SELECT...FOR UPDATE + withTransaction de contracts.ts::updateContractStatus). O cálculo de violação de SLA (subtraindo o tempo pausado, incluindo a pausa corrente se ainda pausado) é feito pelas funções puras computeElapsedSeconds/isSlaBreached em apps/api/src/support/sla.ts — nunca reimplementado ad-hoc. first_response_met/resolution_met só são preenchidos no momento em que o evento correspondente (primeira resposta pública / entrada em resolved) de fato ocorre — permanecem NULL até lá, não são recalculados retroativamente.';
COMMENT ON COLUMN sla_timers.paused_since IS
'Quando não-nulo, o timer está pausado desde este instante — todo o tempo entre paused_since e now() ainda NÃO está refletido em accumulated_pause_seconds e deve ser somado no cálculo de "tempo decorrido" (ver computeElapsedSeconds em sla.ts).';
CREATE TRIGGER trg_sla_timers_updated_at BEFORE UPDATE ON sla_timers
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS sla_timers;
DROP TABLE IF EXISTS ticket_status_history;
DROP TABLE IF EXISTS ticket_comments;
DROP TABLE IF EXISTS support_tickets;
DROP SEQUENCE IF EXISTS support_tickets_protocol_number_seq;
`);
};

View File

@@ -0,0 +1,245 @@
/**
* HR/Timeclock (Ponto Eletrônico) — eden.md módulo 6, foundation leva.
* Master Prompt §6.14. Ported faithfully from the legacy OrçaFácil
* (`eden.md` módulo 6 §1 for the data model, §2 for the Control iD
* integration shape, §4 for the AFD format) — this is dense legal/
* compliance logic already validated in production, not redesigned here.
*
* SCOPE OF THIS LEVA (deliberately narrow — see the task brief):
* - timeclock_devices — Control iD REP iDClass device registry
* (data model only; no live sync yet)
* - timeclock_employees — separate cadastro from `users` (see note
* below on why)
* - afd_import_batches — one row per AFD file import (manual upload)
* - afd_records — one row per AFD file LINE, IMMUTABLE
*
* Explicitly DEFERRED to a future leva, not forgotten (eden.md §5-§10):
* - timeclock_employee_devices — per-device sync/biometry status link
* table (eden.md §1.3); needs the live-sync workflow to be meaningful.
* - timeclock_work_schedules / timeclock_work_schedule_days (§1.6-1.7)
* - timeclock_holidays (§1.8)
* - timeclock_punch_adjustments (§1.9) — administrative treatments
* - timeclock_daily_calculations (§1.10) — apuração engine output
* - timeclock_time_bank_entries (§1.11) — banco de horas ledger
* - timeclock_period_closures (§1.12) — fechamento de período
* - time_audit_logs (§1.13) — this leva uses the shared `audit_log`
* hash-chain (via auditRepo.appendAuditEvent) instead; the module-
* specific structured log is deferred with the workflows that need it
* (approvals, reopenings) to render nicely.
*
* ── employees vs. users (explicit decision, per task brief) ─────────────
* eden.md §1.2 is unambiguous: `timeclock_employees` is a cadastro
* SEPARATE from `users` (with an optional `user_id` link), because (a) a
* punch-clock employee often has no ERP login at all, and (b) the
* Control iD device identifies a person BY CPF (eden.md §1.2's "Importante
* — identidade no relógio": "no modo 671 da API Control iD, o CPF é o
* próprio identificador do usuário no equipamento"), and `users` has no
* CPF column. Reusing `users` would mean inventing a CPF column on a
* table owned by Identity/Auth, and forcing every punch-clock employee to
* have an ERP login they may not need. So: `timeclock_employees` it is,
* ported faithfully.
*
* ── naming deviation from eden.md, per task brief ────────────────────────
* The task brief names the import-batch table `afd_import_batches`
* (this migration) rather than eden.md's `afd_imports`. Kept as briefed.
* Also simplified vs. eden.md's richer version: no S3 storage of the
* original file bytes (the file is never persisted outside afd_records'
* raw_line reconstruction — see afd-imports.controller.ts (CreateAfdImportDto) for why: this
* leva's import endpoint accepts the file as a JSON string, not a
* multipart upload, so there's no original file artifact worth archiving
* separately from the parsed lines themselves; a real multipart upload +
* S3 archival, matching eden.md's `file_s3_key`, is future work), and no
* `source` distinction (`manual_upload` only — `live_sync` is deferred
* along with the Control iD live-communication layer, eden.md §2-3).
*
* ── crc_valid / validation_status as TEXT enums, not a plain boolean ────
* The task brief's own summary suggested a boolean `crc_valid`, but
* eden.md §1.5/§4.2 documents a 4-value enum (VALID/INVALID/
* NOT_APPLICABLE/NOT_VALIDATED) because "no CRC field for this record
* type" (types 1/5/6/9) and "CRC field present but not checked" are
* different, useful facts from a real CRC mismatch (type 7's own hash
* chain) — collapsing that into a boolean would lose information the
* legacy explicitly modeled. Followed eden.md here per this task's own
* instruction to prefer the full read over the summary when they diverge.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
-- =====================================================================
-- timeclock_devices — Control iD REP iDClass equipment registry
-- =====================================================================
CREATE TABLE timeclock_devices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id),
company_id UUID,
name TEXT NOT NULL,
description TEXT,
branch TEXT,
host TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 443 CHECK (port > 0 AND port <= 65535),
use_https BOOLEAN NOT NULL DEFAULT true,
username TEXT NOT NULL,
password_encrypted TEXT NOT NULL,
serial_number TEXT,
model TEXT,
firmware_version TEXT,
timezone TEXT NOT NULL DEFAULT 'America/Sao_Paulo',
status TEXT NOT NULL DEFAULT 'UNKNOWN' CHECK (status IN ('UNKNOWN', 'ONLINE', 'OFFLINE', 'ERROR')),
last_seen_at TIMESTAMPTZ,
last_sync_at TIMESTAMPTZ,
last_error TEXT,
last_afd_nsr BIGINT,
active BOOLEAN NOT NULL DEFAULT true
);
COMMENT ON TABLE timeclock_devices IS
'Relógios de ponto Control iD REP iDClass — eden.md módulo 6 §1.1/§2. company_id nullable = equipamento "sem empresa" (compartilhado). DELIBERADAMENTE sem FK para companies: essa tabela (Organization bounded context) não existe neste worktree isolado no momento em que esta migration foi escrita — mesma situação de contracts.cjs (contracts.quote_id/client_registration_id) neste monorepo. A FK é trabalho de integração/merge, não desta migration.';
COMMENT ON COLUMN timeclock_devices.password_encrypted IS
'AES-256-GCM reversível via FieldEncryptionService (apps/api/src/crypto/field-encryption.service.ts, formato v1:iv:tag:ciphertext) — NUNCA hash, porque a aplicação precisa autenticar no equipamento sob demanda a cada operação (eden.md §2.2/§2.3). NUNCA retornada em texto puro por nenhuma rota GET — a API expõe apenas um flag hasCredential.';
COMMENT ON COLUMN timeclock_devices.timezone IS
'Metadado informativo do cadastro do equipamento. A interpretação de negócio das datas/horas do AFD SEMPRE usa o offset literal embutido no próprio campo de data/hora do arquivo (fixo -03:00 na prática, América/São_Paulo sem horário de verão desde 2019) — esta coluna não é consultada pelo parser (afd-parser.ts), que nunca assume o timezone do servidor nem de nenhuma configuração.';
COMMENT ON COLUMN timeclock_devices.last_afd_nsr IS
'Cursor de sincronização incremental do AFD (eden.md §3) — reservado para a leva de sync ao vivo; não é escrito por esta leva (só import manual de arquivo completo).';
CREATE TRIGGER trg_timeclock_devices_updated_at BEFORE UPDATE ON timeclock_devices
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_timeclock_devices_company ON timeclock_devices(company_id);
-- =====================================================================
-- timeclock_employees — separate from 'users' (see migration header)
-- =====================================================================
CREATE TABLE timeclock_employees (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id),
company_id UUID,
user_id UUID REFERENCES users(id),
full_name TEXT NOT NULL,
cpf TEXT NOT NULL,
registration TEXT,
active BOOLEAN NOT NULL DEFAULT true,
UNIQUE (company_id, cpf)
);
COMMENT ON TABLE timeclock_employees IS
'Cadastro de funcionários do módulo de ponto, SEPARADO de users (eden.md §1.2) — vínculo opcional via user_id. CPF (11 dígitos, sem máscara) é o identificador do usuário no equipamento Control iD no modo 671 — não existe um "controlid_user_id" separado. CPF único por empresa (company_id, cpf), não globalmente. work_schedule_id (jornada vinculada) é DEFERRADO para a leva de apuração — não existe ainda tabela timeclock_work_schedules.';
COMMENT ON COLUMN timeclock_employees.cpf IS
'11 dígitos, sem máscara. Validação de dígito verificador é responsabilidade da camada de aplicação (mesma convenção de contract_parties.document/companies.cnpj neste monorepo) — sem CHECK de formato no banco nesta leva (nenhuma rota de CRUD de employees foi implementada ainda; a coluna existe para afd_records.employee_id ter o que referenciar e para a resolução de CPF do parser).';
CREATE TRIGGER trg_timeclock_employees_updated_at BEFORE UPDATE ON timeclock_employees
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_timeclock_employees_company ON timeclock_employees(company_id);
CREATE INDEX idx_timeclock_employees_user ON timeclock_employees(user_id);
-- =====================================================================
-- afd_import_batches — one row per AFD file import (manual upload only
-- this leva; live_sync source is deferred with the Control iD live
-- communication layer, eden.md §2-3)
-- =====================================================================
CREATE TABLE afd_import_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
device_id UUID NOT NULL REFERENCES timeclock_devices(id),
company_id UUID,
source TEXT NOT NULL DEFAULT 'manual_upload' CHECK (source = 'manual_upload'),
file_hash TEXT NOT NULL UNIQUE,
file_size INTEGER NOT NULL,
record_count INTEGER NOT NULL DEFAULT 0,
inserted_count INTEGER NOT NULL DEFAULT 0,
valid_count INTEGER NOT NULL DEFAULT 0,
warning_count INTEGER NOT NULL DEFAULT 0,
invalid_count INTEGER NOT NULL DEFAULT 0,
unsupported_count INTEGER NOT NULL DEFAULT 0,
parse_failure_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL CHECK (status IN ('VALID', 'WARNING', 'INVALID')),
first_nsr BIGINT,
last_nsr BIGINT,
summary JSONB NOT NULL DEFAULT '{}'::jsonb,
imported_by UUID REFERENCES users(id),
imported_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE afd_import_batches IS
'Metadados de cada importação de arquivo AFD (eden.md §1.4, nomeada afd_import_batches por instrução desta leva em vez de afd_imports do legado). file_hash é a idempotência de arquivo inteiro (SHA-256) — UNIQUE garante que reimportar o mesmo conteúdo retorna already_imported em vez de duplicar. Simplificação desta leva: nenhum S3/armazenamento do arquivo original bruto (o transporte é um JSON string, não multipart — ver afd-imports.controller.ts (CreateAfdImportDto)); o dado legal é preservado integralmente em afd_records.raw_line, uma linha por registro.';
COMMENT ON COLUMN afd_import_batches.file_hash IS
'SHA-256 do CONTEÚDO recebido pela API (a string JSON do body, decodificada como texto) — não necessariamente idêntico byte-a-byte ao SHA-256 de um arquivo .afd bruto em disco, já que o transporte desta leva é string JSON, não upload multipart de bytes crus. Suficiente para o propósito de idempotência desta leva; ver nota de simplificação no cabeçalho da migration.';
COMMENT ON COLUMN afd_import_batches.record_count IS
'Total de linhas parseadas do arquivo (inclui INVALID/UNSUPPORTED_LAYOUT — nunca descartadas). inserted_count pode ser menor que record_count quando ON CONFLICT (device_id, nsr) DO NOTHING pulou registros já existentes de uma importação anterior (idempotência por registro, eden.md §1.5).';
CREATE INDEX idx_afd_import_batches_device ON afd_import_batches(device_id);
-- =====================================================================
-- afd_records — one row per AFD file LINE. IMMUTABLE: no application
-- route may ever UPDATE/DELETE this table, at any privilege level,
-- not even super_admin (eden.md módulo 6 §12) — enforced here again at
-- the DB level via trigger, same pattern as audit_log's
-- prevent_audit_log_mutation, but WITHOUT that trigger's escape-hatch
-- GUC (audit_log allows a controlled internal bypass; afd_records
-- deliberately does not, per this leva's explicit non-negotiable
-- instruction: "em nenhum nível de privilégio, nem para super_admin").
-- =====================================================================
CREATE TABLE afd_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
afd_import_batch_id UUID NOT NULL REFERENCES afd_import_batches(id),
device_id UUID NOT NULL REFERENCES timeclock_devices(id),
company_id UUID,
nsr BIGINT,
record_type TEXT NOT NULL,
event_datetime TIMESTAMPTZ,
event_datetime_raw TEXT,
employee_cpf_raw TEXT,
employee_id UUID REFERENCES timeclock_employees(id),
raw_line TEXT NOT NULL,
crc_received TEXT,
crc_calculated TEXT,
crc_valid TEXT NOT NULL DEFAULT 'NOT_APPLICABLE' CHECK (crc_valid IN ('VALID', 'INVALID', 'NOT_APPLICABLE', 'NOT_VALIDATED')),
parsed_data JSONB NOT NULL DEFAULT '{}'::jsonb,
validation_status TEXT NOT NULL CHECK (validation_status IN ('VALID', 'WARNING', 'INVALID', 'UNSUPPORTED_LAYOUT')),
validation_notes TEXT,
UNIQUE (device_id, nsr)
);
COMMENT ON TABLE afd_records IS
'Dado bruto de ponto, eden.md módulo 6 §1.5/§12 — IMUTÁVEL por design. Nenhuma rota de UPDATE/DELETE existe na camada de aplicação (apps/api/src/hr-timeclock/afd-records.controller.ts expõe apenas GET) e o banco reforça isso via trigger (trg_afd_records_no_update/no_delete abaixo) SEM nenhuma válvula de escape — nem para super_admin, diferente de audit_log. Preservado mesmo quando inválido: nsr é nullable de propósito (NSR ausente/não-numérico nunca vira 0/autoincrement), e todo registro tem raw_line completa, mesmo os classificados INVALID/UNSUPPORTED_LAYOUT.';
COMMENT ON COLUMN afd_records.nsr IS
'Número Sequencial de Registro. NULLABLE DE PROPÓSITO — NSR ausente/não-numérico é inválido mas precisa ser preservado, nunca substituído por 0/autoincrement (eden.md §12). UNIQUE (device_id, nsr): múltiplas linhas com nsr NULL do mesmo device são permitidas (semântica padrão de NULL em índice único do Postgres) — a idempotência por registro só se aplica a NSRs realmente numéricos.';
COMMENT ON COLUMN afd_records.crc_valid IS
'VALID/INVALID = CRC-16/KERMIT verificado (tipos 2/3/4). NOT_APPLICABLE = tipo 7, que usa cadeia de hash própria NUNCA recalculada pelo EDEN. NOT_VALIDATED = tipos 1/5/6/9, sem campo de CRC. Enum de 4 valores, não boolean — ver nota no cabeçalho da migration sobre por que.';
COMMENT ON COLUMN afd_records.parsed_data IS
'Campos decodificados específicos do tipo de registro, com posição EXATA documentada em eden.md §4.1 — qualquer trecho sem posição documentada vai para a chave "raw_tail" dentro deste JSONB, nunca é inventado/adivinhado (ver apps/api/src/hr-timeclock/afd-parser.ts).';
COMMENT ON COLUMN afd_records.employee_cpf_raw IS
'CPF exatamente como veio no campo do AFD (12 caracteres de largura documentada para tipos 3/7, 11 para o cpf_resp do tipo 4) — sempre string, nunca number, nunca perde zero à esquerda. employee_id é resolvido a partir daqui via normalizeAfdCpf (últimos 11 dígitos numéricos), escopado por company_id quando o device tem uma; NULL quando órfão (sem match) — nunca um match é inventado/forçado.';
CREATE INDEX idx_afd_records_employee ON afd_records(employee_id);
CREATE INDEX idx_afd_records_company ON afd_records(company_id);
CREATE INDEX idx_afd_records_type ON afd_records(record_type);
CREATE INDEX idx_afd_records_event_datetime ON afd_records(event_datetime);
CREATE INDEX idx_afd_records_batch ON afd_records(afd_import_batch_id);
CREATE OR REPLACE FUNCTION prevent_afd_records_mutation() RETURNS TRIGGER AS $$
BEGIN
RAISE EXCEPTION 'afd_records é imutável (dado bruto de ponto, eden.md módulo 6 §12) — UPDATE/DELETE bloqueados, sem exceção, mesmo para super_admin';
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_afd_records_no_update BEFORE UPDATE ON afd_records
FOR EACH ROW EXECUTE FUNCTION prevent_afd_records_mutation();
CREATE TRIGGER trg_afd_records_no_delete BEFORE DELETE ON afd_records
FOR EACH ROW EXECUTE FUNCTION prevent_afd_records_mutation();
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TRIGGER IF EXISTS trg_afd_records_no_delete ON afd_records;
DROP TRIGGER IF EXISTS trg_afd_records_no_update ON afd_records;
DROP FUNCTION IF EXISTS prevent_afd_records_mutation();
DROP TABLE IF EXISTS afd_records;
DROP TABLE IF EXISTS afd_import_batches;
DROP TABLE IF EXISTS timeclock_employees;
DROP TABLE IF EXISTS timeclock_devices;
`);
};

View File

@@ -0,0 +1,231 @@
/**
* Fiscal bounded context (Master Prompt §6.11, ADR-0011, eden.md módulo 5
* "Módulo Fiscal (NCM, CFOP, Municípios)"). The legacy OrçaFácil has a
* mature ~19-table catalog layer + automatic (Siscomex/IBGE fetch) and
* manual (CSV/XLSX/JSON upload) importers + a sync log (`fiscal_catalog_sync`)
* + `product_fiscal_profiles` linking `products` to fiscal classification.
*
* This MVP builds a coherent, faithfully-shaped SUBSET, end-to-end:
* - fiscal_ncm (this migration) — "simple catalog" shape, eden.md §2.2
* - fiscal_cfop (this migration) — simple catalog + direction/scope, eden.md §2.3/§4
* - fiscal_municipality (this migration) — distinct shape, eden.md §2.3
* - fiscal_operation_nature (this migration) — internal enum-like catalog, eden.md §2.3, seeded
* - product_fiscal_profiles (this migration) — eden.md §2.5, reduced to the
* 4 catalogs actually created here (ncm/cfop/municipality/operation_nature)
*
* Explicitly DEFERRED, not forgotten:
* - The other ~9 "simple catalog" tables (eden.md §2.2): fiscal_product_origin,
* fiscal_icms_cst, fiscal_icms_csosn, fiscal_pis_cst, fiscal_cofins_cst,
* fiscal_ipi_cst, fiscal_lc116_service, fiscal_nbs, fiscal_ibs_cbs_cst,
* fiscal_ibs_cbs_credit_class — same shape as fiscal_ncm/fiscal_cfop, can
* be added later without a redesign.
* - The "extended catalog" tables (eden.md §2.3): fiscal_cest, fiscal_cbenef,
* fiscal_nfcom_cclass, fiscal_nfse_trib_nacional, fiscal_nfse_trib_municipal,
* fiscal_ibs_cbs_classification, fiscal_ibs_cbs_standard_rate.
* - `fiscal_catalog_sync` (append-only sync log, eden.md §2.4) and the
* automatic importers (NCM/Siscomex, Municipality/IBGE — eden.md §3/§5)
* + the generic manual file importer with dry-run and the mass-
* inactivation safety threshold (eden.md §7) — substantial, isolated
* work (network fetch adapters, checksum/threshold logic, file parsing),
* deferred to its own leva.
* - `fiscal_rules` (eden.md §2.6) — schema-ready-but-unconsumed even in the
* legacy itself ("estrutura pronta, sem UI/consumo ainda"); not recreated
* here, stays future work until the fiscal resolution engine is built.
* - `products.ixc_product_code` / `products.product_code` (eden.md §6) —
* belong to the Products migration, not this one.
* - product_fiscal_profiles columns for catalogs not yet created here:
* nfcom_cclass_id, nfse_trib_nacional_id, nfse_trib_municipal_id,
* lc116_service_id, nbs_id, cest_id, product_origin_id, ibs_cbs_cst_id,
* ibs_cbs_classification_id, ibs_cbs_credit_class_id (eden.md §2.5) —
* added when their respective catalogs land.
*
* `product_fiscal_profiles.product_id` is deliberately nullable-FK-free:
* NO `REFERENCES products(id)`, same cross-module "FK cruzada adiada"
* pattern used by `contracts.quote_id`/`contracts.client_registration_id`
* (see 1788447802216_contracts.cjs header) and
* `support_tickets.client_registration_id`/`.contract_id`
* (see 1788451374900_support.cjs header) — this worktree may not always
* carry every other module's migration in the same merge order; wiring the
* FK is integration-step work at merge time, not this migration's.
*
* Naming convention: all fiscal tables use `created_at`/`updated_at`
* (not `created_date`/`updated_date`) — this is already EDEN's own
* repo-wide convention (see every other migration in this directory), so
* there is no deviation to reconcile here. eden.md §2.1 calls this "a
* deliberate deviation" only relative to the *legacy* OrçaFácil schema,
* not relative to EDEN.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
-- =====================================================================
-- fiscal_ncm — Nomenclatura Comum do Mercosul. "Simple catalog" shape,
-- eden.md §2.2. Source: Receita Federal / Siscomex (import deferred).
-- =====================================================================
CREATE TABLE fiscal_ncm (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL,
description TEXT NOT NULL,
valid_from DATE,
valid_until DATE,
active BOOLEAN NOT NULL DEFAULT true,
source TEXT,
source_version TEXT,
source_updated_at TIMESTAMPTZ,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE fiscal_ncm IS
'Nomenclatura Comum do Mercosul (eden.md §2.2, §3). "Catálogo simples" — mesmo shape reutilizável para as ~9 outras tabelas de catálogo simples ainda não portadas (fiscal_product_origin, ICMS CST/CSOSN, PIS/COFINS/IPI CST, LC116, NBS, IBS-CBS CST/credit_class). Importação automática (Siscomex, checksum, trava de volume mínimo, upsert com inactivateMissing=true) é trabalho DEFERIDO — ver cabeçalho da migration.';
CREATE UNIQUE INDEX idx_fiscal_ncm_code ON fiscal_ncm(code);
CREATE INDEX idx_fiscal_ncm_active ON fiscal_ncm(active);
CREATE TRIGGER trg_fiscal_ncm_updated_at BEFORE UPDATE ON fiscal_ncm
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =====================================================================
-- fiscal_cfop — Código Fiscal de Operações e Prestações. "Simple
-- catalog" shape + direction/scope (eden.md §2.3, §4). Fonte MANUAL
-- (sem endpoint estruturado confiável testado no legado).
-- =====================================================================
CREATE TABLE fiscal_cfop (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL,
description TEXT NOT NULL,
direction TEXT,
scope TEXT,
valid_from DATE,
valid_until DATE,
active BOOLEAN NOT NULL DEFAULT true,
source TEXT,
source_version TEXT,
source_updated_at TIMESTAMPTZ,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE fiscal_cfop IS
'Código Fiscal de Operações e Prestações (eden.md §2.3, §4). "Catálogo simples" + direction/scope. Fonte declarada "Portal Nacional NF-e" mas tipo MANUAL no legado (sem endpoint estruturado confiável testado) — cadastro por upload de arquivo ou tela, ambos DEFERIDOS nesta leva (ver cabeçalho da migration).';
COMMENT ON COLUMN fiscal_cfop.direction IS
'Direção da operação (entrada/saída) — TEXT livre, sem CHECK/enum, exatamente como o legado (eden.md §4). O 1º dígito do código CFOP codifica direção+âmbito na prática brasileira, mas isso NUNCA é derivado automaticamente do código no legado nem aqui — direction/scope são só campos preenchidos manualmente/via importação.';
COMMENT ON COLUMN fiscal_cfop.scope IS
'Âmbito da operação (estadual/interestadual/exterior) — TEXT livre, sem CHECK/enum, mesma razão de direction (eden.md §4).';
CREATE UNIQUE INDEX idx_fiscal_cfop_code ON fiscal_cfop(code);
CREATE INDEX idx_fiscal_cfop_active ON fiscal_cfop(active);
CREATE TRIGGER trg_fiscal_cfop_updated_at BEFORE UPDATE ON fiscal_cfop
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =====================================================================
-- fiscal_municipality — shape DIFERENTE do catálogo simples: sem
-- valid_from/valid_until/metadata ("não faz sentido um município
-- expirar", eden.md §2.3). Fonte: IBGE (import deferido).
-- =====================================================================
CREATE TABLE fiscal_municipality (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL,
name TEXT NOT NULL,
state TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
source TEXT,
source_version TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE fiscal_municipality IS
'Municípios/IBGE (eden.md §2.3, §5) — base para ISS/NFS-e. Shape deliberadamente distinto do catálogo simples: sem valid_from/valid_until/metadata ("não faz sentido um município expirar"). code = código IBGE, state = UF. Importação automática (IBGE, checksum, trava de volume mínimo de 5000 linhas) é DEFERIDA — ver cabeçalho da migration.';
CREATE UNIQUE INDEX idx_fiscal_municipality_code ON fiscal_municipality(code);
CREATE INDEX idx_fiscal_municipality_state ON fiscal_municipality(state);
CREATE TRIGGER trg_fiscal_municipality_updated_at BEFORE UPDATE ON fiscal_municipality
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =====================================================================
-- fiscal_operation_nature — cadastro interno, sem fonte externa
-- (eden.md §2.3). Seed fixo via ON CONFLICT DO NOTHING.
-- =====================================================================
CREATE TABLE fiscal_operation_nature (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
description TEXT,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE fiscal_operation_nature IS
'Naturezas de operação (eden.md §2.3) — cadastro interno de negócio, não uma fonte externa sincronizável. Seed fixo abaixo, único endpoint da API para este catálogo é leitura (GET /v1/fiscal/operation-natures) — sem criação via API nesta leva.';
CREATE TRIGGER trg_fiscal_operation_nature_updated_at BEFORE UPDATE ON fiscal_operation_nature
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
INSERT INTO fiscal_operation_nature (code, name) VALUES
('VENDA', 'Venda'),
('REMESSA_LOCACAO', 'Remessa em locação'),
('RETORNO_LOCACAO', 'Retorno de locação'),
('REMESSA_MANUTENCAO', 'Remessa para manutenção'),
('RETORNO_MANUTENCAO', 'Retorno de manutenção'),
('STFC_RECORRENTE', 'STFC recorrente'),
('SVA_RECORRENTE', 'SVA recorrente'),
('PABX_RECORRENTE', 'PABX recorrente'),
('PABX_IMPLANTACAO', 'PABX implantação'),
('SOFTWARE_RECORRENTE', 'Software recorrente'),
('SOFTWARE_IMPLANTACAO', 'Software implantação')
ON CONFLICT (code) DO NOTHING;
-- =====================================================================
-- product_fiscal_profiles — liga products à classificação fiscal
-- (eden.md §2.5). Reduzida aos 4 catálogos criados nesta leva.
-- =====================================================================
CREATE TABLE product_fiscal_profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL,
billing_component TEXT NOT NULL CHECK (billing_component IN ('RECURRING', 'IMPLEMENTATION')),
document_type TEXT NOT NULL DEFAULT 'NONE' CHECK (document_type IN ('NFCOM', 'NFSE', 'NFE', 'NONE')),
ncm_id UUID REFERENCES fiscal_ncm(id),
cfop_id UUID REFERENCES fiscal_cfop(id),
municipality_id UUID REFERENCES fiscal_municipality(id),
operation_nature_id UUID REFERENCES fiscal_operation_nature(id),
valid_from DATE,
valid_until DATE,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id)
);
COMMENT ON TABLE product_fiscal_profiles IS
'Configuração fiscal de produto (eden.md §2.5) — deliberadamente separada de products, nunca duplica campos comerciais (price_*, impl_unit_price, metered, tariff_rates, has_ldi continuam só em products). product_id SEM FK (products pode não existir neste worktree isolado — ver cabeçalho da migration, padrão "FK cruzada adiada" de contracts.cjs/support.cjs). Colunas de referência a catálogos ainda não portados (nfcom_cclass_id, nfse_trib_nacional_id, nfse_trib_municipal_id, lc116_service_id, nbs_id, cest_id, product_origin_id, ibs_cbs_cst_id, ibs_cbs_classification_id, ibs_cbs_credit_class_id) ficam para quando os catálogos correspondentes existirem. Sem backfill automático nesta leva (eden.md §2.5 backfill dependia de products.service_type/impl_unit_price já populados em produção real) — profiles são criados sob demanda via API.';
COMMENT ON COLUMN product_fiscal_profiles.product_id IS
'Sem REFERENCES products(id) nesta leva — products pode não existir neste worktree isolado. FK cruzada é trabalho de integração no merge (mesmo padrão de contracts.quote_id/client_registration_id e support_tickets.client_registration_id/contract_id).';
-- Invariante central (eden.md §2.5): um produto tem no máximo um
-- profile ATIVO por billing_component. Nunca DELETE/UPDATE destrutivo
-- de um profile antigo para "trocar" a classificação — cria-se um novo
-- profile ativo e inativa-se o anterior (active=false), preservando
-- histórico.
CREATE UNIQUE INDEX idx_product_fiscal_profiles_active_component
ON product_fiscal_profiles(product_id, billing_component) WHERE active = true;
CREATE INDEX idx_product_fiscal_profiles_product ON product_fiscal_profiles(product_id);
CREATE TRIGGER trg_product_fiscal_profiles_updated_at BEFORE UPDATE ON product_fiscal_profiles
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS product_fiscal_profiles;
DROP TABLE IF EXISTS fiscal_operation_nature;
DROP TABLE IF EXISTS fiscal_municipality;
DROP TABLE IF EXISTS fiscal_cfop;
DROP TABLE IF EXISTS fiscal_ncm;
`);
};

View File

@@ -0,0 +1,185 @@
/**
* Billing bounded context (recurring billing engine) — Master Prompt
* §6.10, docs/adr/0008-billing-finance-fiscal-separation.md. ADR-0008 is
* explicit: Billing (this module) calculates *what is owed* and produces
* an internal invoice; it is strictly separate from Finance/AR-AP
* (collecting/paying — títulos, boletos, dunning, conciliação) and from
* Fiscal (documento fiscal). Neither of those is built here.
*
* ADR-0008 lists nine Billing tables (billing_accounts, billing_cycles,
* subscriptions/services, charge_components, usage_charges, invoices,
* invoice_items, invoice_adjustments, billing_runs). This migration
* builds the coherent MVP subset needed for a working, testable
* subscription -> billing_run -> invoice flow:
*
* - billing_subscriptions (this migration)
* - billing_runs (this migration)
* - invoices (this migration)
* - invoice_items (this migration)
*
* Explicitly DEFERRED, not forgotten:
* - billing_accounts — this MVP hangs subscriptions/invoices directly
* off `reseller_id`; a billing_account (grouping multiple subscriptions
* under one consolidated bill) is deferred until there's a real need
* to bill more than one subscription together.
* - billing_cycles as its own table — this MVP derives the cycle from
* the (billing_day, reference_year, reference_month) triple on the
* run/subscription rather than a calendar table of cycle instances.
* - charge_components / usage_charges — consumption/telephony billing;
* needs the usage-rating module (out of scope), so every subscription
* in this MVP bills a single flat `monthly_value` with no metered
* component.
* - invoice_adjustments / credit_debit_adjustments — the sanctioned way
* to correct a *consolidated* invoice (never a silent edit); deferred,
* so today a consolidated billing_run's invoices are simply immutable
* from the application layer (see billing.ts's assertRunIsMutable).
* - billing_run_logs — this MVP uses the shared `audit_log`
* (auditRepo.appendAuditEvent) for every billing_run/invoice mutation
* instead of a dedicated structured log table.
* - proration — every subscription always bills the full monthly_value;
* no day-proportional calculation for a subscription that started or
* was cancelled mid-cycle. `invoice_items.item_type` already reserves
* the 'proration' vocabulary value for when this lands, so it won't
* need a migration of its own.
*
* Deferred cross-context foreign keys (no REFERENCES, plain UUID, same
* pattern used by Quotes/Contracts/ClientRegistrations for tables owned by
* a module built in a different worktree — see contracts' migration
* header for precedent):
* - billing_subscriptions.contract_id — `contracts` (Commercial/§6.6) is
* not guaranteed to exist in every worktree this batch might land on.
* - billing_subscriptions.reseller_id / invoices.reseller_id —
* `resellers` is not present in THIS worktree's migration set either
* (bootstrap-only checkout). Both FKs are integration work for
* whoever merges this alongside the resellers/contracts migrations.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
-- =====================================================================
-- billing_subscriptions — a recurring billing subscription derived
-- from an active contract
-- =====================================================================
CREATE TABLE billing_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
updated_by UUID REFERENCES users(id),
contract_id UUID NOT NULL,
reseller_id UUID,
monthly_value NUMERIC(14,2) NOT NULL CHECK (monthly_value >= 0),
billing_day INTEGER NOT NULL CHECK (billing_day BETWEEN 1 AND 28),
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended', 'cancelled')),
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
cancelled_at TIMESTAMPTZ
);
COMMENT ON TABLE billing_subscriptions IS
'Assinatura de cobrança recorrente derivada de um contrato ativo (Master Prompt §6.10). contract_id e reseller_id são UUID simples sem REFERENCES — mesmo padrão de FK cruzada adiada de Quotes/Contracts/ClientRegistrations: contracts/resellers não existem garantidamente neste worktree (checkout de bootstrap). A FK real é trabalho de integração central, não desta migration.';
COMMENT ON COLUMN billing_subscriptions.monthly_value IS
'Snapshot do valor mensal no momento da criação da subscription — NUNCA recalculado automaticamente se o contrato de origem mudar depois (isso seria uma reversão silenciosa de fatura, proibida pelo Master Prompt §24 invariante nº4). Uma mudança de valor exige cancelar e recriar a subscription (ou, quando existir, um endpoint explícito de reajuste) — nesta leva não há reajuste, só criação/cancelamento.';
COMMENT ON COLUMN billing_subscriptions.billing_day IS
'Dia do mês do ciclo de cobrança, limitado a 1-28 deliberadamente para nunca cair em um mês sem esse dia (evita a ambiguidade de dia 29-31 em fevereiro/meses de 30 dias) — não há regra de "último dia do mês" nesta leva.';
COMMENT ON COLUMN billing_subscriptions.cancelled_at IS
'Setado uma única vez, quando status transiciona para cancelled (camada de repositório) — nunca sobrescrito depois.';
CREATE INDEX idx_billing_subscriptions_reseller ON billing_subscriptions(reseller_id);
CREATE INDEX idx_billing_subscriptions_status_day ON billing_subscriptions(status, billing_day);
CREATE INDEX idx_billing_subscriptions_contract ON billing_subscriptions(contract_id);
CREATE TRIGGER trg_billing_subscriptions_updated_at BEFORE UPDATE ON billing_subscriptions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =====================================================================
-- billing_runs — one billing-close execution for a
-- (reference_year, reference_month, billing_day) period
-- =====================================================================
CREATE TABLE billing_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
reference_year INTEGER NOT NULL CHECK (reference_year BETWEEN 2000 AND 2100),
reference_month INTEGER NOT NULL CHECK (reference_month BETWEEN 1 AND 12),
billing_day INTEGER NOT NULL CHECK (billing_day BETWEEN 1 AND 28),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'consolidated', 'failed')),
consolidated_at TIMESTAMPTZ,
triggered_by UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE billing_runs IS
'Execução de fechamento de billing para um período + billing_day (Master Prompt §6.10). draft é reexecutável: rodar de novo com os mesmos (reference_year, reference_month, billing_day) sobre um run ainda draft SUBSTITUI (delete+reinsert, mesma transação) as invoices geradas em vez de duplicar — implementado em packages/database/src/repositories/billing.ts, generateOrRegenerateBillingRun(). consolidated é terminal e imutável pela aplicação (ver invoices.status e assertRunIsMutable). failed é reservado para quando esta leva ganhar um passo que possa falhar de forma parcial (ex. rating de consumo) — nada nesta leva efetivamente produz failed.';
COMMENT ON COLUMN billing_runs.triggered_by IS
'Ator (usuário) que disparou a criação/regeneração deste run — atualizado a cada regeneração em draft para refletir quem rodou por último.';
CREATE INDEX idx_billing_runs_period ON billing_runs(reference_year, reference_month, billing_day);
-- Idempotência: no máximo um run 'draft' por (ano, mês, billing_day) —
-- índice único parcial em vez de UNIQUE(reference_year, reference_month,
-- billing_day, status) puro, porque este último permitiria dois runs
-- 'consolidated' distintos para o mesmo período sem impedir a corrida
-- de dois 'draft' concorrentes ao mesmo tempo (o problema real que essa
-- invariante precisa resolver). A regra "não criar novo run para um
-- período já consolidado" (refaturamento controlado é deferido) é
-- reforçada na camada de aplicação, não aqui, porque múltiplos runs
-- 'consolidated' históricos para o mesmo período não são logicamente
-- impossíveis no schema (só indesejados pelo fluxo atual).
CREATE UNIQUE INDEX uq_billing_runs_one_draft_per_period
ON billing_runs(reference_year, reference_month, billing_day)
WHERE status = 'draft';
-- =====================================================================
-- invoices — internal invoice generated by a billing_run for one
-- billing_subscription
-- =====================================================================
CREATE TABLE invoices (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
billing_run_id UUID NOT NULL REFERENCES billing_runs(id) ON DELETE CASCADE,
billing_subscription_id UUID NOT NULL REFERENCES billing_subscriptions(id),
reseller_id UUID,
amount NUMERIC(14,2) NOT NULL CHECK (amount >= 0),
reference_year INTEGER NOT NULL,
reference_month INTEGER NOT NULL CHECK (reference_month BETWEEN 1 AND 12),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'consolidated', 'cancelled')),
due_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (billing_run_id, billing_subscription_id)
);
COMMENT ON TABLE invoices IS
'Fatura interna (Billing) gerada por um billing_run — DISTINTA de um título de AR/AP (Finance) e de um documento fiscal (Fiscal), ADR-0008. status espelha o status do run no momento da geração (draft -> pode ser apagada/regerada enquanto o run ainda é draft; consolidated -> imutável pela aplicação, ver invariante em billing.ts). UNIQUE(billing_run_id, billing_subscription_id) é uma segunda barreira de idempotência a nível de banco: o mesmo run nunca pode gerar duas invoices para a mesma subscription.';
COMMENT ON COLUMN invoices.reseller_id IS
'Denormalizado a partir de billing_subscriptions.reseller_id no momento da geração — evita join obrigatório para filtrar por revenda em listagens/relatórios; nunca é a fonte da verdade (a subscription é). Sem REFERENCES pelo mesmo motivo de billing_subscriptions.reseller_id (ver cabeçalho da migration).';
COMMENT ON COLUMN invoices.due_date IS
'Nesta leva, due_date = dia billing_day do mês de referência (reference_year/reference_month) — regra de vencimento mais sofisticada (ex. carência após o fechamento) é responsabilidade do módulo Finance/AR-AP (ADR-0008), fora de escopo aqui.';
CREATE INDEX idx_invoices_run ON invoices(billing_run_id);
CREATE INDEX idx_invoices_subscription ON invoices(billing_subscription_id);
CREATE INDEX idx_invoices_reseller_status ON invoices(reseller_id, status);
CREATE INDEX idx_invoices_period ON invoices(reference_year, reference_month);
-- =====================================================================
-- invoice_items — invoice line items (generic vocabulary; this MVP
-- only ever generates 'recurring' automatically)
-- =====================================================================
CREATE TABLE invoice_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
description TEXT NOT NULL,
amount NUMERIC(14,2) NOT NULL,
item_type TEXT NOT NULL
CHECK (item_type IN ('recurring', 'proration', 'implementation', 'adjustment')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE invoice_items IS
'Linhas da fatura, modeladas de forma genérica para não exigir migration nova quando proration/implementation/adjustment passarem a ser gerados automaticamente — nesta leva, a camada de aplicação só cria itens item_type=recurring (um por invoice, espelhando billing_subscriptions.monthly_value no momento da geração do run).';
CREATE INDEX idx_invoice_items_invoice ON invoice_items(invoice_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS invoice_items;
DROP TABLE IF EXISTS invoices;
DROP TABLE IF EXISTS billing_runs;
DROP TABLE IF EXISTS billing_subscriptions;
`);
};

View File

@@ -10,6 +10,11 @@ export * as quotesRepo from "./repositories/quotes.js";
export * as contractsRepo from "./repositories/contracts.js";
export * as clientRegistrationsRepo from "./repositories/client-registrations.js";
export * as clientRegistrationPartnersRepo from "./repositories/client-registration-partners.js";
export * as inventoryRepo from "./repositories/inventory.js";
export * as supportTicketsRepo from "./repositories/support-tickets.js";
export * as hrTimeclockRepo from "./repositories/hr-timeclock.js";
export * as fiscalRepo from "./repositories/fiscal.js";
export * as billingRepo from "./repositories/billing.js";
export {
listLeads,
getLead,
@@ -74,3 +79,87 @@ export type {
CreatePartnerInput,
UpdatePartnerInput,
} from "./repositories/client-registration-partners.js";
export type {
Warehouse,
CreateWarehouseInput,
UpdateWarehouseInput,
StockMovement,
StockMovementType,
ListStockMovementsFilter,
CreateStockMovementInput,
SerializedAsset,
SerializedAssetStatus,
ListSerializedAssetsFilter,
CreateAssetInput,
ReserveAssetInput,
InstallAssetInput,
ReturnAssetInput,
AssetAssignment,
StockReservation,
StockReservationStatus,
ListStockReservationsFilter,
CreateGenericReservationInput,
} from "./repositories/inventory.js";
export type {
TicketStatus,
TicketPriority,
SupportTicket,
TicketComment,
TicketStatusHistoryEntry,
SlaTimer,
TicketWithDetails,
ListTicketsFilter,
CreateTicketInput,
CreateTicketCommentInput,
UpdateTicketStatusExtra,
} from "./repositories/support-tickets.js";
export type {
TimeclockDeviceStatus,
TimeclockDevice,
CreateTimeclockDeviceInput,
UpdateTimeclockDeviceInput,
TimeclockEmployee,
AfdImportBatchStatus,
AfdCrcValidity,
AfdValidationStatus,
AfdImportBatch,
CreateAfdImportBatchInput,
AfdRecord,
AfdRecordInsertInput,
ImportAfdBatchInput,
ImportAfdBatchResult,
ListAfdRecordsFilter,
} from "./repositories/hr-timeclock.js";
export type {
FiscalNcm,
CreateSimpleCatalogInput,
UpdateSimpleCatalogInput,
ListCatalogFilter,
FiscalCfop,
CreateCfopInput,
UpdateCfopInput,
FiscalMunicipality,
CreateMunicipalityInput,
UpdateMunicipalityInput,
FiscalOperationNature,
FiscalBillingComponent,
FiscalDocumentType,
ProductFiscalProfile,
CreateProductFiscalProfileInput,
} from "./repositories/fiscal.js";
export type {
BillingSubscriptionStatus,
BillingSubscription,
BillingRunStatus,
BillingRun,
InvoiceStatus,
Invoice,
InvoiceItemType,
InvoiceItem,
BillingRunWithInvoices,
CreateBillingSubscriptionInput,
ListBillingSubscriptionsFilter,
GenerateBillingRunInput,
ListBillingRunsFilter,
ListInvoicesFilter,
} from "./repositories/billing.js";

View File

@@ -0,0 +1,471 @@
import { query, withTransaction } from "../pool.js";
/**
* Billing bounded context (Master Prompt §6.10, ADR-0008) — recurring
* subscription -> billing_run -> invoice flow. See PENDING_billing.cjs's
* header comment for the full list of tables this MVP builds vs. defers
* (billing_accounts, billing_cycles, charge_components/usage_charges,
* invoice_adjustments, billing_run_logs, proration — all deferred).
*
* Mirrors contracts.ts's conventions: snake_case rows, withTransaction for
* multi-row writes that must commit/rollback together, typed domain
* errors mapped to HTTP status by the service layer (never here).
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type BillingSubscriptionStatus = "active" | "suspended" | "cancelled";
export interface BillingSubscription {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
contract_id: string;
reseller_id: string | null;
monthly_value: string;
billing_day: number;
status: BillingSubscriptionStatus;
started_at: Date;
cancelled_at: Date | null;
}
export type BillingRunStatus = "draft" | "consolidated" | "failed";
export interface BillingRun {
id: string;
reference_year: number;
reference_month: number;
billing_day: number;
status: BillingRunStatus;
consolidated_at: Date | null;
triggered_by: string | null;
created_at: Date;
}
export type InvoiceStatus = "draft" | "consolidated" | "cancelled";
export interface Invoice {
id: string;
billing_run_id: string;
billing_subscription_id: string;
reseller_id: string | null;
amount: string;
reference_year: number;
reference_month: number;
status: InvoiceStatus;
due_date: string | null;
created_at: Date;
}
export type InvoiceItemType = "recurring" | "proration" | "implementation" | "adjustment";
export interface InvoiceItem {
id: string;
invoice_id: string;
description: string;
amount: string;
item_type: InvoiceItemType;
created_at: Date;
}
export interface BillingRunWithInvoices extends BillingRun {
invoices: Invoice[];
totalAmount: string;
}
// ---------------------------------------------------------------------------
// Errors — mapped to HTTP status by the service layer, never here
// ---------------------------------------------------------------------------
export class BillingSubscriptionNotFoundError extends Error {
constructor(public readonly subscriptionId: string) {
super(`Billing subscription ${subscriptionId} not found`);
this.name = "BillingSubscriptionNotFoundError";
}
}
export class BillingRunNotFoundError extends Error {
constructor(public readonly billingRunId: string) {
super(`Billing run ${billingRunId} not found`);
this.name = "BillingRunNotFoundError";
}
}
/**
* Thrown when an operation tries to write (regenerate invoices, alter
* items) against a billing_run that is already 'consolidated' — the
* central invariant of this module. The service layer maps this to 409.
*/
export class BillingRunConsolidatedError extends Error {
constructor(public readonly billingRunId: string) {
super(`Billing run ${billingRunId} is already consolidated and cannot be modified`);
this.name = "BillingRunConsolidatedError";
}
}
/**
* Thrown when creating/regenerating a run for a (year, month, billing_day)
* that already has a CONSOLIDATED run. Controlled re-billing (a new run
* deliberately superseding a consolidated one) is explicitly deferred
* (see migration header) — until it exists, this is a hard stop rather
* than a silent double-bill.
*/
export class BillingPeriodAlreadyConsolidatedError extends Error {
constructor(
public readonly referenceYear: number,
public readonly referenceMonth: number,
public readonly billingDay: number,
) {
super(
`Billing run for ${referenceYear}-${referenceMonth}/day ${billingDay} is already consolidated`,
);
this.name = "BillingPeriodAlreadyConsolidatedError";
}
}
// ---------------------------------------------------------------------------
// billing_subscriptions
// ---------------------------------------------------------------------------
export interface CreateBillingSubscriptionInput {
contractId: string;
resellerId: string | null;
monthlyValue: number;
billingDay: number;
createdBy: string | null;
}
export async function createBillingSubscription(
input: CreateBillingSubscriptionInput,
): Promise<BillingSubscription> {
const { rows } = await query<BillingSubscription>(
`INSERT INTO billing_subscriptions
(contract_id, reseller_id, monthly_value, billing_day, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $5)
RETURNING *`,
[input.contractId, input.resellerId, input.monthlyValue, input.billingDay, input.createdBy],
);
const row = rows[0];
if (!row) throw new Error("failed to create billing subscription");
return row;
}
export async function getBillingSubscriptionById(id: string): Promise<BillingSubscription | null> {
const { rows } = await query<BillingSubscription>(
"SELECT * FROM billing_subscriptions WHERE id = $1",
[id],
);
return rows[0] ?? null;
}
export interface ListBillingSubscriptionsFilter {
scope: "all" | "reseller";
/** Required (and enforced by the caller) when scope === 'reseller'. */
resellerId?: string | null;
status?: BillingSubscriptionStatus;
}
export async function listBillingSubscriptions(
filter: ListBillingSubscriptionsFilter,
): Promise<BillingSubscription[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.scope === "reseller") {
// Deliberately never falls back to unfiltered: an actor with
// reseller-only scope and no resellerId sees nothing, not everything.
params.push(filter.resellerId ?? null);
conditions.push(`reseller_id = $${params.length}`);
}
if (filter.status) {
params.push(filter.status);
conditions.push(`status = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<BillingSubscription>(
`SELECT * FROM billing_subscriptions ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}
/**
* Cancels a subscription — idempotent: cancelling an already-cancelled
* subscription is a no-op that returns the existing row rather than
* erroring, mirroring the "safe to retry" philosophy applied to billing
* runs elsewhere in this module. cancelled_at is set only the first time.
*/
export async function cancelBillingSubscription(
id: string,
actorId: string | null,
): Promise<BillingSubscription> {
const { rows } = await query<BillingSubscription>(
`UPDATE billing_subscriptions SET
status = 'cancelled',
cancelled_at = COALESCE(cancelled_at, now()),
updated_by = $2
WHERE id = $1
RETURNING *`,
[id, actorId],
);
const row = rows[0];
if (!row) throw new BillingSubscriptionNotFoundError(id);
return row;
}
// ---------------------------------------------------------------------------
// billing_runs / invoices / invoice_items
// ---------------------------------------------------------------------------
function dueDateFor(referenceYear: number, referenceMonth: number, billingDay: number): string {
const mm = String(referenceMonth).padStart(2, "0");
const dd = String(billingDay).padStart(2, "0");
return `${referenceYear}-${mm}-${dd}`;
}
export interface GenerateBillingRunInput {
referenceYear: number;
referenceMonth: number;
billingDay: number;
triggeredBy: string | null;
}
/**
* Idempotent billing close. Behavior for (referenceYear, referenceMonth,
* billingDay):
* - if a CONSOLIDATED run already exists for this exact period ->
* throws BillingPeriodAlreadyConsolidatedError (controlled re-billing
* is deferred, see migration header — this is a hard stop, not a
* silent double-bill).
* - if a DRAFT run already exists -> reused in place: its invoices (and
* their items) are deleted and regenerated from the current set of
* active subscriptions, all inside one transaction. Running this
* twice with the same parameters therefore never duplicates invoices
* — the second run replaces the first draft's invoices 1:1.
* - otherwise -> a new 'draft' run is created.
*
* One invoice per active billing_subscription whose billing_day matches,
* each carrying a single 'recurring' invoice_item for the full
* monthly_value (no proration in this MVP — see migration header).
*/
export async function generateOrRegenerateBillingRun(
input: GenerateBillingRunInput,
): Promise<BillingRunWithInvoices> {
return withTransaction(async (client) => {
const { rows: existingRows } = await client.query<BillingRun>(
`SELECT * FROM billing_runs
WHERE reference_year = $1 AND reference_month = $2 AND billing_day = $3
AND status IN ('draft', 'consolidated')
FOR UPDATE`,
[input.referenceYear, input.referenceMonth, input.billingDay],
);
const consolidated = existingRows.find((r) => r.status === "consolidated");
if (consolidated) {
throw new BillingPeriodAlreadyConsolidatedError(
input.referenceYear,
input.referenceMonth,
input.billingDay,
);
}
const existingDraft = existingRows.find((r) => r.status === "draft");
let run: BillingRun;
if (existingDraft) {
// Regeneration: wipe this draft's invoices (items cascade) and
// refresh triggered_by to reflect who ran it last.
await client.query(
`DELETE FROM invoices WHERE billing_run_id = $1`,
[existingDraft.id],
);
const { rows: updatedRows } = await client.query<BillingRun>(
`UPDATE billing_runs SET triggered_by = $2 WHERE id = $1 RETURNING *`,
[existingDraft.id, input.triggeredBy],
);
run = updatedRows[0]!;
} else {
const { rows: createdRows } = await client.query<BillingRun>(
`INSERT INTO billing_runs (reference_year, reference_month, billing_day, triggered_by)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[input.referenceYear, input.referenceMonth, input.billingDay, input.triggeredBy],
);
run = createdRows[0]!;
}
const { rows: subscriptions } = await client.query<BillingSubscription>(
`SELECT * FROM billing_subscriptions WHERE status = 'active' AND billing_day = $1`,
[input.billingDay],
);
const dueDate = dueDateFor(input.referenceYear, input.referenceMonth, input.billingDay);
const invoices: Invoice[] = [];
for (const subscription of subscriptions) {
const { rows: invoiceRows } = await client.query<Invoice>(
`INSERT INTO invoices
(billing_run_id, billing_subscription_id, reseller_id, amount, reference_year, reference_month, due_date)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
run.id,
subscription.id,
subscription.reseller_id,
subscription.monthly_value,
input.referenceYear,
input.referenceMonth,
dueDate,
],
);
const invoice = invoiceRows[0]!;
await client.query(
`INSERT INTO invoice_items (invoice_id, description, amount, item_type)
VALUES ($1, $2, $3, 'recurring')`,
[invoice.id, "Mensalidade recorrente", subscription.monthly_value],
);
invoices.push(invoice);
}
return { ...run, invoices, totalAmount: sumAmounts(invoices) };
});
}
export interface ListBillingRunsFilter {
referenceYear?: number;
referenceMonth?: number;
status?: BillingRunStatus;
}
export async function listBillingRuns(filter: ListBillingRunsFilter = {}): Promise<BillingRun[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.referenceYear != null) {
params.push(filter.referenceYear);
conditions.push(`reference_year = $${params.length}`);
}
if (filter.referenceMonth != null) {
params.push(filter.referenceMonth);
conditions.push(`reference_month = $${params.length}`);
}
if (filter.status) {
params.push(filter.status);
conditions.push(`status = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<BillingRun>(
`SELECT * FROM billing_runs ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}
function sumAmounts(invoices: Invoice[]): string {
const total = invoices.reduce((sum, inv) => sum + Number(inv.amount), 0);
return total.toFixed(2);
}
export async function getBillingRunById(id: string): Promise<BillingRunWithInvoices | null> {
const { rows } = await query<BillingRun>("SELECT * FROM billing_runs WHERE id = $1", [id]);
const run = rows[0];
if (!run) return null;
const { rows: invoices } = await query<Invoice>(
"SELECT * FROM invoices WHERE billing_run_id = $1 ORDER BY created_at ASC",
[id],
);
return { ...run, invoices, totalAmount: sumAmounts(invoices) };
}
export async function listInvoiceItems(invoiceId: string): Promise<InvoiceItem[]> {
const { rows } = await query<InvoiceItem>(
"SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY created_at ASC",
[invoiceId],
);
return rows;
}
/**
* Consolidates a billing_run: flips the run and every invoice it owns to
* 'consolidated' and stamps consolidated_at, all in one transaction.
* 409s (via BillingRunConsolidatedError) if already consolidated —
* consolidation is a one-way door in this MVP (no un-consolidate).
*/
export async function consolidateBillingRun(
id: string,
_actorId: string | null,
): Promise<BillingRunWithInvoices> {
return withTransaction(async (client) => {
const { rows } = await client.query<BillingRun>(
"SELECT * FROM billing_runs WHERE id = $1 FOR UPDATE",
[id],
);
const run = rows[0];
if (!run) throw new BillingRunNotFoundError(id);
if (run.status === "consolidated") throw new BillingRunConsolidatedError(id);
const { rows: updatedRows } = await client.query<BillingRun>(
`UPDATE billing_runs SET status = 'consolidated', consolidated_at = now() WHERE id = $1 RETURNING *`,
[id],
);
const updated = updatedRows[0]!;
await client.query(
`UPDATE invoices SET status = 'consolidated' WHERE billing_run_id = $1`,
[id],
);
const { rows: invoices } = await client.query<Invoice>(
"SELECT * FROM invoices WHERE billing_run_id = $1 ORDER BY created_at ASC",
[id],
);
return { ...updated, invoices, totalAmount: sumAmounts(invoices) };
});
}
export interface ListInvoicesFilter {
scope: "all" | "reseller";
/** Required (and enforced by the caller) when scope === 'reseller'. */
resellerId?: string | null;
status?: InvoiceStatus;
referenceYear?: number;
referenceMonth?: number;
}
export async function listInvoices(filter: ListInvoicesFilter): Promise<Invoice[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.scope === "reseller") {
params.push(filter.resellerId ?? null);
conditions.push(`reseller_id = $${params.length}`);
}
if (filter.status) {
params.push(filter.status);
conditions.push(`status = $${params.length}`);
}
if (filter.referenceYear != null) {
params.push(filter.referenceYear);
conditions.push(`reference_year = $${params.length}`);
}
if (filter.referenceMonth != null) {
params.push(filter.referenceMonth);
conditions.push(`reference_month = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<Invoice>(
`SELECT * FROM invoices ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}

View File

@@ -0,0 +1,484 @@
import { query, withTransaction } from "../pool.js";
/**
* Fiscal bounded context (Master Prompt §6.11, ADR-0011, eden.md módulo 5).
* See PENDING_fiscal.cjs migration header for full scope/deferral notes.
* This MVP covers 3 "catalog" tables (fiscal_ncm, fiscal_cfop,
* fiscal_municipality), the read-only fiscal_operation_nature seed, and
* product_fiscal_profiles — the piece that actually links a commercial
* product to its fiscal classification.
*/
// ===========================================================================
// fiscal_ncm — "simple catalog" shape (eden.md §2.2)
// ===========================================================================
export interface FiscalNcm {
id: string;
code: string;
description: string;
valid_from: string | null;
valid_until: string | null;
active: boolean;
source: string | null;
source_version: string | null;
source_updated_at: Date | null;
metadata: Record<string, unknown> | null;
created_at: Date;
updated_at: Date;
}
export interface CreateSimpleCatalogInput {
code: string;
description: string;
validFrom?: string | null;
validUntil?: string | null;
active?: boolean;
source?: string | null;
sourceVersion?: string | null;
sourceUpdatedAt?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface UpdateSimpleCatalogInput {
code?: string;
description?: string;
validFrom?: string | null;
validUntil?: string | null;
active?: boolean;
source?: string | null;
sourceVersion?: string | null;
sourceUpdatedAt?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface ListCatalogFilter {
q?: string;
active?: boolean;
}
export async function listNcm(filter: ListCatalogFilter = {}): Promise<FiscalNcm[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.q) {
params.push(`%${filter.q}%`);
conditions.push(`(code ILIKE $${params.length} OR description ILIKE $${params.length})`);
}
if (filter.active !== undefined) {
params.push(filter.active);
conditions.push(`active = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<FiscalNcm>(
`SELECT * FROM fiscal_ncm ${where} ORDER BY code ASC NULLS LAST`,
params,
);
return rows;
}
export async function getNcmById(id: string): Promise<FiscalNcm | null> {
const { rows } = await query<FiscalNcm>("SELECT * FROM fiscal_ncm WHERE id = $1", [id]);
return rows[0] ?? null;
}
export async function createNcm(input: CreateSimpleCatalogInput): Promise<FiscalNcm> {
const { rows } = await query<FiscalNcm>(
`INSERT INTO fiscal_ncm (code, description, valid_from, valid_until, active, source, source_version, source_updated_at, metadata)
VALUES ($1,$2,$3,$4,COALESCE($5,true),$6,$7,$8,$9)
RETURNING *`,
[
input.code,
input.description,
input.validFrom ?? null,
input.validUntil ?? null,
input.active ?? null,
input.source ?? null,
input.sourceVersion ?? null,
input.sourceUpdatedAt ?? null,
input.metadata ? JSON.stringify(input.metadata) : null,
],
);
const row = rows[0];
if (!row) throw new Error("failed to create fiscal_ncm row");
return row;
}
export async function updateNcm(id: string, input: UpdateSimpleCatalogInput): Promise<FiscalNcm | null> {
const { rows } = await query<FiscalNcm>(
`UPDATE fiscal_ncm SET
code = COALESCE($2, code),
description = COALESCE($3, description),
valid_from = COALESCE($4, valid_from),
valid_until = COALESCE($5, valid_until),
active = COALESCE($6, active),
source = COALESCE($7, source),
source_version = COALESCE($8, source_version),
source_updated_at = COALESCE($9, source_updated_at),
metadata = COALESCE($10, metadata)
WHERE id = $1
RETURNING *`,
[
id,
input.code ?? null,
input.description ?? null,
input.validFrom ?? null,
input.validUntil ?? null,
input.active ?? null,
input.source ?? null,
input.sourceVersion ?? null,
input.sourceUpdatedAt ?? null,
input.metadata ? JSON.stringify(input.metadata) : null,
],
);
return rows[0] ?? null;
}
// ===========================================================================
// fiscal_cfop — "simple catalog" shape + direction/scope (eden.md §2.3/§4)
// ===========================================================================
export interface FiscalCfop {
id: string;
code: string;
description: string;
direction: string | null;
scope: string | null;
valid_from: string | null;
valid_until: string | null;
active: boolean;
source: string | null;
source_version: string | null;
source_updated_at: Date | null;
metadata: Record<string, unknown> | null;
created_at: Date;
updated_at: Date;
}
export interface CreateCfopInput extends CreateSimpleCatalogInput {
direction?: string | null;
scope?: string | null;
}
export interface UpdateCfopInput extends UpdateSimpleCatalogInput {
direction?: string | null;
scope?: string | null;
}
export async function listCfop(filter: ListCatalogFilter = {}): Promise<FiscalCfop[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.q) {
params.push(`%${filter.q}%`);
conditions.push(`(code ILIKE $${params.length} OR description ILIKE $${params.length})`);
}
if (filter.active !== undefined) {
params.push(filter.active);
conditions.push(`active = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<FiscalCfop>(
`SELECT * FROM fiscal_cfop ${where} ORDER BY code ASC NULLS LAST`,
params,
);
return rows;
}
export async function getCfopById(id: string): Promise<FiscalCfop | null> {
const { rows } = await query<FiscalCfop>("SELECT * FROM fiscal_cfop WHERE id = $1", [id]);
return rows[0] ?? null;
}
export async function createCfop(input: CreateCfopInput): Promise<FiscalCfop> {
const { rows } = await query<FiscalCfop>(
`INSERT INTO fiscal_cfop (code, description, direction, scope, valid_from, valid_until, active, source, source_version, source_updated_at, metadata)
VALUES ($1,$2,$3,$4,$5,$6,COALESCE($7,true),$8,$9,$10,$11)
RETURNING *`,
[
input.code,
input.description,
input.direction ?? null,
input.scope ?? null,
input.validFrom ?? null,
input.validUntil ?? null,
input.active ?? null,
input.source ?? null,
input.sourceVersion ?? null,
input.sourceUpdatedAt ?? null,
input.metadata ? JSON.stringify(input.metadata) : null,
],
);
const row = rows[0];
if (!row) throw new Error("failed to create fiscal_cfop row");
return row;
}
export async function updateCfop(id: string, input: UpdateCfopInput): Promise<FiscalCfop | null> {
const { rows } = await query<FiscalCfop>(
`UPDATE fiscal_cfop SET
code = COALESCE($2, code),
description = COALESCE($3, description),
direction = COALESCE($4, direction),
scope = COALESCE($5, scope),
valid_from = COALESCE($6, valid_from),
valid_until = COALESCE($7, valid_until),
active = COALESCE($8, active),
source = COALESCE($9, source),
source_version = COALESCE($10, source_version),
source_updated_at = COALESCE($11, source_updated_at),
metadata = COALESCE($12, metadata)
WHERE id = $1
RETURNING *`,
[
id,
input.code ?? null,
input.description ?? null,
input.direction ?? null,
input.scope ?? null,
input.validFrom ?? null,
input.validUntil ?? null,
input.active ?? null,
input.source ?? null,
input.sourceVersion ?? null,
input.sourceUpdatedAt ?? null,
input.metadata ? JSON.stringify(input.metadata) : null,
],
);
return rows[0] ?? null;
}
// ===========================================================================
// fiscal_municipality — distinct shape, no valid_from/valid_until/metadata
// (eden.md §2.3)
// ===========================================================================
export interface FiscalMunicipality {
id: string;
code: string;
name: string;
state: string;
active: boolean;
source: string | null;
source_version: string | null;
created_at: Date;
updated_at: Date;
}
export interface CreateMunicipalityInput {
code: string;
name: string;
state: string;
active?: boolean;
source?: string | null;
sourceVersion?: string | null;
}
export interface UpdateMunicipalityInput {
code?: string;
name?: string;
state?: string;
active?: boolean;
source?: string | null;
sourceVersion?: string | null;
}
export async function listMunicipalities(filter: ListCatalogFilter = {}): Promise<FiscalMunicipality[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.q) {
params.push(`%${filter.q}%`);
conditions.push(`(code ILIKE $${params.length} OR name ILIKE $${params.length})`);
}
if (filter.active !== undefined) {
params.push(filter.active);
conditions.push(`active = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<FiscalMunicipality>(
`SELECT * FROM fiscal_municipality ${where} ORDER BY code ASC NULLS LAST`,
params,
);
return rows;
}
export async function getMunicipalityById(id: string): Promise<FiscalMunicipality | null> {
const { rows } = await query<FiscalMunicipality>("SELECT * FROM fiscal_municipality WHERE id = $1", [id]);
return rows[0] ?? null;
}
export async function createMunicipality(input: CreateMunicipalityInput): Promise<FiscalMunicipality> {
const { rows } = await query<FiscalMunicipality>(
`INSERT INTO fiscal_municipality (code, name, state, active, source, source_version)
VALUES ($1,$2,$3,COALESCE($4,true),$5,$6)
RETURNING *`,
[
input.code,
input.name,
input.state,
input.active ?? null,
input.source ?? null,
input.sourceVersion ?? null,
],
);
const row = rows[0];
if (!row) throw new Error("failed to create fiscal_municipality row");
return row;
}
export async function updateMunicipality(
id: string,
input: UpdateMunicipalityInput,
): Promise<FiscalMunicipality | null> {
const { rows } = await query<FiscalMunicipality>(
`UPDATE fiscal_municipality SET
code = COALESCE($2, code),
name = COALESCE($3, name),
state = COALESCE($4, state),
active = COALESCE($5, active),
source = COALESCE($6, source),
source_version = COALESCE($7, source_version)
WHERE id = $1
RETURNING *`,
[
id,
input.code ?? null,
input.name ?? null,
input.state ?? null,
input.active ?? null,
input.source ?? null,
input.sourceVersion ?? null,
],
);
return rows[0] ?? null;
}
// ===========================================================================
// fiscal_operation_nature — internal enum-like catalog, read-only via API
// (eden.md §2.3) — seeded by the migration, no create/update in this MVP.
// ===========================================================================
export interface FiscalOperationNature {
id: string;
code: string;
name: string;
description: string | null;
active: boolean;
created_at: Date;
updated_at: Date;
}
export async function listOperationNatures(activeOnly = false): Promise<FiscalOperationNature[]> {
const { rows } = await query<FiscalOperationNature>(
activeOnly
? "SELECT * FROM fiscal_operation_nature WHERE active = true ORDER BY code ASC"
: "SELECT * FROM fiscal_operation_nature ORDER BY code ASC",
);
return rows;
}
// ===========================================================================
// product_fiscal_profiles — links products to fiscal classification
// (eden.md §2.5). Reduced to the 4 catalogs created in this MVP.
// ===========================================================================
export type FiscalBillingComponent = "RECURRING" | "IMPLEMENTATION";
export type FiscalDocumentType = "NFCOM" | "NFSE" | "NFE" | "NONE";
export interface ProductFiscalProfile {
id: string;
product_id: string;
billing_component: FiscalBillingComponent;
document_type: FiscalDocumentType;
ncm_id: string | null;
cfop_id: string | null;
municipality_id: string | null;
operation_nature_id: string | null;
valid_from: string | null;
valid_until: string | null;
active: boolean;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
}
export async function listProductFiscalProfiles(productId: string): Promise<ProductFiscalProfile[]> {
const { rows } = await query<ProductFiscalProfile>(
`SELECT * FROM product_fiscal_profiles
WHERE product_id = $1
ORDER BY billing_component ASC, created_at ASC`,
[productId],
);
return rows;
}
export async function getActiveProductFiscalProfile(
productId: string,
billingComponent: FiscalBillingComponent,
): Promise<ProductFiscalProfile | null> {
const { rows } = await query<ProductFiscalProfile>(
`SELECT * FROM product_fiscal_profiles
WHERE product_id = $1 AND billing_component = $2 AND active = true`,
[productId, billingComponent],
);
return rows[0] ?? null;
}
export interface CreateProductFiscalProfileInput {
productId: string;
billingComponent: FiscalBillingComponent;
documentType?: FiscalDocumentType;
ncmId?: string | null;
cfopId?: string | null;
municipalityId?: string | null;
operationNatureId?: string | null;
validFrom?: string | null;
validUntil?: string | null;
createdBy?: string | null;
}
/**
* Creates a new ACTIVE profile for (productId, billingComponent), and — in
* the same transaction — inactivates whichever profile was previously
* active for that same pair (never a destructive UPDATE/DELETE of the old
* one, per eden.md §2.5's central invariant: at most one active profile per
* product per billing_component, enforced again at the DB level by
* idx_product_fiscal_profiles_active_component). If two concurrent callers
* raced here without the transaction ordering below, the partial unique
* index is the actual backstop — it would reject the second INSERT outright.
*/
export async function createActiveProductFiscalProfile(
input: CreateProductFiscalProfileInput,
): Promise<ProductFiscalProfile> {
return withTransaction(async (client) => {
await client.query(
`UPDATE product_fiscal_profiles
SET active = false, updated_by = $3
WHERE product_id = $1 AND billing_component = $2 AND active = true`,
[input.productId, input.billingComponent, input.createdBy ?? null],
);
const { rows } = await client.query<ProductFiscalProfile>(
`INSERT INTO product_fiscal_profiles (
product_id, billing_component, document_type, ncm_id, cfop_id,
municipality_id, operation_nature_id, valid_from, valid_until,
created_by, updated_by
) VALUES ($1,$2,COALESCE($3,'NONE'),$4,$5,$6,$7,$8,$9,$10,$10)
RETURNING *`,
[
input.productId,
input.billingComponent,
input.documentType ?? null,
input.ncmId ?? null,
input.cfopId ?? null,
input.municipalityId ?? null,
input.operationNatureId ?? null,
input.validFrom ?? null,
input.validUntil ?? null,
input.createdBy ?? null,
],
);
const row = rows[0];
if (!row) throw new Error("failed to create product_fiscal_profiles row");
return row;
});
}

View File

@@ -0,0 +1,468 @@
import { query, withTransaction } from "../pool.js";
import type { PoolClient } from "pg";
/**
* HR/Timeclock (eden.md módulo 6) — foundation leva. See
* packages/database/migrations/PENDING_hr-timeclock.cjs for the full data
* model rationale (employees-vs-users decision, deferred tables, naming
* deviations from eden.md). This module deliberately does NOT expose any
* UPDATE/DELETE for afd_records — see importAfdBatch below and the
* afd-records.controller.ts (read-only) that consumes listAfdRecords.
*/
// ─────────────────────────────────────────────────────────────────────────
// timeclock_devices
// ─────────────────────────────────────────────────────────────────────────
export type TimeclockDeviceStatus = "UNKNOWN" | "ONLINE" | "OFFLINE" | "ERROR";
export interface TimeclockDevice {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
company_id: string | null;
name: string;
description: string | null;
branch: string | null;
host: string;
port: number;
use_https: boolean;
username: string;
password_encrypted: string;
serial_number: string | null;
model: string | null;
firmware_version: string | null;
timezone: string;
status: TimeclockDeviceStatus;
last_seen_at: Date | null;
last_sync_at: Date | null;
last_error: string | null;
last_afd_nsr: string | null; // BIGINT comes back as string from `pg`
active: boolean;
}
export interface CreateTimeclockDeviceInput {
companyId?: string | null;
name: string;
description?: string | null;
branch?: string | null;
host: string;
port?: number;
useHttps?: boolean;
username: string;
/** Already encrypted (v1:iv:tag:ciphertext) by the caller via FieldEncryptionService — this repo never sees plaintext. */
passwordEncrypted: string;
timezone?: string;
createdBy?: string | null;
}
export async function listTimeclockDevices(includeInactive = false): Promise<TimeclockDevice[]> {
const { rows } = await query<TimeclockDevice>(
includeInactive
? "SELECT * FROM timeclock_devices ORDER BY created_at DESC"
: "SELECT * FROM timeclock_devices WHERE active = true ORDER BY created_at DESC",
);
return rows;
}
export async function getTimeclockDeviceById(id: string): Promise<TimeclockDevice | null> {
const { rows } = await query<TimeclockDevice>("SELECT * FROM timeclock_devices WHERE id = $1", [id]);
return rows[0] ?? null;
}
export async function createTimeclockDevice(input: CreateTimeclockDeviceInput): Promise<TimeclockDevice> {
const { rows } = await query<TimeclockDevice>(
`INSERT INTO timeclock_devices (
company_id, name, description, branch, host, port, use_https, username,
password_encrypted, timezone, created_by
) VALUES ($1,$2,$3,$4,$5,COALESCE($6,443),COALESCE($7,true),$8,$9,COALESCE($10,'America/Sao_Paulo'),$11)
RETURNING *`,
[
input.companyId ?? null,
input.name,
input.description ?? null,
input.branch ?? null,
input.host,
input.port ?? null,
input.useHttps ?? null,
input.username,
input.passwordEncrypted,
input.timezone ?? null,
input.createdBy ?? null,
],
);
const device = rows[0];
if (!device) throw new Error("failed to create timeclock device");
return device;
}
export interface UpdateTimeclockDeviceInput {
name?: string;
description?: string | null;
branch?: string | null;
host?: string;
port?: number;
useHttps?: boolean;
username?: string;
/** Only re-encrypted/set when the caller actually supplied a new password — omit to leave unchanged. */
passwordEncrypted?: string;
active?: boolean;
updatedBy?: string | null;
}
export async function updateTimeclockDevice(
id: string,
input: UpdateTimeclockDeviceInput,
): Promise<TimeclockDevice | null> {
const { rows } = await query<TimeclockDevice>(
`UPDATE timeclock_devices SET
name = COALESCE($2, name),
description = COALESCE($3, description),
branch = COALESCE($4, branch),
host = COALESCE($5, host),
port = COALESCE($6, port),
use_https = COALESCE($7, use_https),
username = COALESCE($8, username),
password_encrypted = COALESCE($9, password_encrypted),
active = COALESCE($10, active),
updated_by = COALESCE($11, updated_by)
WHERE id = $1
RETURNING *`,
[
id,
input.name ?? null,
input.description ?? null,
input.branch ?? null,
input.host ?? null,
input.port ?? null,
input.useHttps ?? null,
input.username ?? null,
input.passwordEncrypted ?? null,
input.active ?? null,
input.updatedBy ?? null,
],
);
return rows[0] ?? null;
}
// ─────────────────────────────────────────────────────────────────────────
// timeclock_employees — no CRUD in this leva (see migration header); only
// the CPF-resolution lookup that AFD import needs is implemented here.
// ─────────────────────────────────────────────────────────────────────────
export interface TimeclockEmployee {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
company_id: string | null;
user_id: string | null;
full_name: string;
cpf: string;
registration: string | null;
active: boolean;
}
/**
* Looks up employees by normalized CPF (11 digits) for AFD import
* resolution — never invents a match. Returns every row found; the
* caller (afd-imports.service.ts) is responsible for the "same CPF
* matched in more than one company and the device has no company" case,
* where it must leave employee_id unresolved rather than guess.
*/
export async function findTimeclockEmployeesByCpfs(cpfs: string[]): Promise<TimeclockEmployee[]> {
if (cpfs.length === 0) return [];
const { rows } = await query<TimeclockEmployee>(
"SELECT * FROM timeclock_employees WHERE cpf = ANY($1)",
[cpfs],
);
return rows;
}
// ─────────────────────────────────────────────────────────────────────────
// afd_import_batches / afd_records
// ─────────────────────────────────────────────────────────────────────────
export type AfdImportBatchStatus = "VALID" | "WARNING" | "INVALID";
export type AfdCrcValidity = "VALID" | "INVALID" | "NOT_APPLICABLE" | "NOT_VALIDATED";
export type AfdValidationStatus = "VALID" | "WARNING" | "INVALID" | "UNSUPPORTED_LAYOUT";
export interface AfdImportBatch {
id: string;
device_id: string;
company_id: string | null;
source: "manual_upload";
file_hash: string;
file_size: number;
record_count: number;
inserted_count: number;
valid_count: number;
warning_count: number;
invalid_count: number;
unsupported_count: number;
parse_failure_count: number;
status: AfdImportBatchStatus;
first_nsr: string | null;
last_nsr: string | null;
summary: Record<string, unknown>;
imported_by: string | null;
imported_at: Date;
}
export interface CreateAfdImportBatchInput {
deviceId: string;
companyId: string | null;
fileHash: string;
fileSize: number;
recordCount: number;
validCount: number;
warningCount: number;
invalidCount: number;
unsupportedCount: number;
parseFailureCount: number;
status: AfdImportBatchStatus;
firstNsr: number | null;
lastNsr: number | null;
summary: Record<string, unknown>;
importedBy: string | null;
}
export interface AfdRecord {
id: string;
created_at: Date;
afd_import_batch_id: string;
device_id: string;
company_id: string | null;
nsr: string | null; // BIGINT -> string
record_type: string;
event_datetime: Date | null;
event_datetime_raw: string | null;
employee_cpf_raw: string | null;
employee_id: string | null;
raw_line: string;
crc_received: string | null;
crc_calculated: string | null;
crc_valid: AfdCrcValidity;
parsed_data: Record<string, unknown>;
validation_status: AfdValidationStatus;
validation_notes: string | null;
}
export interface AfdRecordInsertInput {
nsr: number | null;
recordType: string;
eventDatetime: Date | null;
eventDatetimeRaw: string | null;
employeeCpfRaw: string | null;
employeeId: string | null;
rawLine: string;
crcReceived: string | null;
crcCalculated: string | null;
crcValid: AfdCrcValidity;
parsedData: Record<string, unknown>;
validationStatus: AfdValidationStatus;
validationNotes: string | null;
}
export class TimeclockDeviceNotFoundError extends Error {
constructor(public readonly deviceId: string) {
super(`Timeclock device ${deviceId} not found`);
this.name = "TimeclockDeviceNotFoundError";
}
}
export async function findAfdImportBatchByFileHash(fileHash: string): Promise<AfdImportBatch | null> {
const { rows } = await query<AfdImportBatch>("SELECT * FROM afd_import_batches WHERE file_hash = $1", [fileHash]);
return rows[0] ?? null;
}
export async function getAfdImportBatchById(id: string): Promise<AfdImportBatch | null> {
const { rows } = await query<AfdImportBatch>("SELECT * FROM afd_import_batches WHERE id = $1", [id]);
return rows[0] ?? null;
}
export interface ImportAfdBatchInput {
batch: CreateAfdImportBatchInput;
records: AfdRecordInsertInput[];
}
export interface ImportAfdBatchResult {
batch: AfdImportBatch;
insertedCount: number;
}
/**
* The whole AFD import write path, in one transaction:
*
* 1. `SELECT ... FOR UPDATE` locks the device row — this is what
* guarantees "never two concurrent imports for the same device"
* (eden.md módulo 6 §12's sequential-execution invariant for anything
* device-adjacent): a second concurrent import for the same device_id
* blocks here until the first one commits or rolls back.
* 2. Inserts the afd_import_batches row (needs to exist first — afd_records
* has a NOT NULL FK to it).
* 3. Inserts each afd_records row one at a time (matches this codebase's
* established multi-row-child pattern, see contracts.ts's item/party
* loops) with `ON CONFLICT (device_id, nsr) DO NOTHING` — the
* per-record idempotency layer (eden.md §12): a record whose
* (device_id, nsr) already exists from an earlier, different import is
* silently skipped, never duplicated, and never overwritten (afd_records
* has no UPDATE route at all — see the migration's trigger).
* 4. Updates the batch's `inserted_count` with how many rows actually
* landed (can be less than `records.length` because of step 3's
* ON CONFLICT skips).
*
* Whole-file idempotency (SHA-256 of the received content) is checked by
* the caller (afd-imports.service.ts) BEFORE calling this, via
* findAfdImportBatchByFileHash — this function does not re-check it,
* though the UNIQUE constraint on afd_import_batches.file_hash is a
* database-level backstop against a race between the two.
*/
export async function importAfdBatch(input: ImportAfdBatchInput): Promise<ImportAfdBatchResult> {
return withTransaction(async (client: PoolClient) => {
const { rows: deviceRows } = await client.query<{ id: string }>(
"SELECT id FROM timeclock_devices WHERE id = $1 FOR UPDATE",
[input.batch.deviceId],
);
if (!deviceRows[0]) {
throw new TimeclockDeviceNotFoundError(input.batch.deviceId);
}
const { rows: batchRows } = await client.query<AfdImportBatch>(
`INSERT INTO afd_import_batches (
device_id, company_id, file_hash, file_size, record_count, inserted_count,
valid_count, warning_count, invalid_count, unsupported_count, parse_failure_count,
status, first_nsr, last_nsr, summary, imported_by
) VALUES ($1,$2,$3,$4,$5,0,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
RETURNING *`,
[
input.batch.deviceId,
input.batch.companyId,
input.batch.fileHash,
input.batch.fileSize,
input.batch.recordCount,
input.batch.validCount,
input.batch.warningCount,
input.batch.invalidCount,
input.batch.unsupportedCount,
input.batch.parseFailureCount,
input.batch.status,
input.batch.firstNsr,
input.batch.lastNsr,
JSON.stringify(input.batch.summary),
input.batch.importedBy,
],
);
const batch = batchRows[0];
if (!batch) throw new Error("failed to create afd_import_batch");
let insertedCount = 0;
for (const record of input.records) {
const { rowCount } = await client.query(
`INSERT INTO afd_records (
afd_import_batch_id, device_id, company_id, nsr, record_type,
event_datetime, event_datetime_raw, employee_cpf_raw, employee_id, raw_line,
crc_received, crc_calculated, crc_valid, parsed_data, validation_status, validation_notes
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
ON CONFLICT (device_id, nsr) DO NOTHING`,
[
batch.id,
input.batch.deviceId,
input.batch.companyId,
record.nsr,
record.recordType,
record.eventDatetime,
record.eventDatetimeRaw,
record.employeeCpfRaw,
record.employeeId,
record.rawLine,
record.crcReceived,
record.crcCalculated,
record.crcValid,
JSON.stringify(record.parsedData),
record.validationStatus,
record.validationNotes,
],
);
insertedCount += rowCount ?? 0;
}
const { rows: updatedBatchRows } = await client.query<AfdImportBatch>(
"UPDATE afd_import_batches SET inserted_count = $2 WHERE id = $1 RETURNING *",
[batch.id, insertedCount],
);
const updatedBatch = updatedBatchRows[0] ?? batch;
return { batch: updatedBatch, insertedCount };
});
}
export interface ListAfdRecordsFilter {
deviceId?: string;
employeeId?: string;
recordType?: string;
/** Inclusive lower bound on event_datetime. */
dateFrom?: Date;
/** Exclusive upper bound on event_datetime (caller passes "day after dateTo" to include the whole end day). */
dateTo?: Date;
limit?: number;
offset?: number;
}
const DEFAULT_PAGE_SIZE = 50;
/**
* Read-only, paginated. There is deliberately no equivalent write
* function for arbitrary fields — afd_records rows are only ever created
* via importAfdBatch's INSERT, never updated.
*/
export async function listAfdRecords(filter: ListAfdRecordsFilter): Promise<{ records: AfdRecord[]; total: number }> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.deviceId) {
params.push(filter.deviceId);
conditions.push(`device_id = $${params.length}`);
}
if (filter.employeeId) {
params.push(filter.employeeId);
conditions.push(`employee_id = $${params.length}`);
}
if (filter.recordType) {
params.push(filter.recordType);
conditions.push(`record_type = $${params.length}`);
}
if (filter.dateFrom) {
params.push(filter.dateFrom);
conditions.push(`event_datetime >= $${params.length}`);
}
if (filter.dateTo) {
params.push(filter.dateTo);
conditions.push(`event_datetime < $${params.length}`);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const limit = filter.limit ?? DEFAULT_PAGE_SIZE;
const offset = filter.offset ?? 0;
const { rows: countRows } = await query<{ count: string }>(
`SELECT count(*) FROM afd_records ${where}`,
params,
);
const total = Number(countRows[0]?.count ?? 0);
const { rows } = await query<AfdRecord>(
`SELECT * FROM afd_records ${where} ORDER BY event_datetime ASC NULLS LAST, created_at ASC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
[...params, limit, offset],
);
return { records: rows, total };
}
export async function getAfdRecordById(id: string): Promise<AfdRecord | null> {
const { rows } = await query<AfdRecord>("SELECT * FROM afd_records WHERE id = $1", [id]);
return rows[0] ?? null;
}

View File

@@ -0,0 +1,747 @@
import { query, withTransaction } from "../pool.js";
import type { PoolClient } from "pg";
/**
* Inventory/Warehouse/Assets bounded context — Master Prompt §6.8,
* ADR-0007. See the migration's header comment (PENDING_inventory.cjs)
* for the full list of possible entities and which 5 this MVP builds vs.
* explicitly deferred ones.
*
* Mirrors the conventions of companies.ts/products.ts (COALESCE-based
* partial writes, snake_case rows) and contracts.ts (withTransaction for
* multi-row writes that must commit/rollback together, custom error
* classes mapped to 404/409 by the service layer).
*
* NOT registered in the barrel (packages/database/src/index.ts) yet —
* that's deliberate integration work left to the merge step (see
* .claude/agents/eden-inventory.md task boundaries).
*/
// ===========================================================================
// warehouses
// ===========================================================================
export interface Warehouse {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
company_id: string;
name: string;
code: string;
is_active: boolean;
}
export async function listWarehouses(includeInactive = false): Promise<Warehouse[]> {
const { rows } = await query<Warehouse>(
includeInactive
? "SELECT * FROM warehouses ORDER BY created_at ASC"
: "SELECT * FROM warehouses WHERE is_active = true ORDER BY created_at ASC",
);
return rows;
}
export async function getWarehouseById(id: string): Promise<Warehouse | null> {
const { rows } = await query<Warehouse>("SELECT * FROM warehouses WHERE id = $1", [id]);
return rows[0] ?? null;
}
export interface CreateWarehouseInput {
companyId: string;
name: string;
code: string;
isActive?: boolean;
createdBy?: string | null;
}
export async function createWarehouse(input: CreateWarehouseInput): Promise<Warehouse> {
const { rows } = await query<Warehouse>(
`INSERT INTO warehouses (company_id, name, code, is_active, created_by)
VALUES ($1,$2,$3,COALESCE($4,true),$5)
RETURNING *`,
[input.companyId, input.name, input.code, input.isActive ?? null, input.createdBy ?? null],
);
const warehouse = rows[0];
if (!warehouse) throw new Error("failed to create warehouse");
return warehouse;
}
export interface UpdateWarehouseInput {
name?: string;
code?: string;
isActive?: boolean;
}
export async function updateWarehouse(id: string, input: UpdateWarehouseInput): Promise<Warehouse | null> {
const { rows } = await query<Warehouse>(
`UPDATE warehouses SET
name = COALESCE($2, name),
code = COALESCE($3, code),
is_active = COALESCE($4, is_active)
WHERE id = $1
RETURNING *`,
[id, input.name ?? null, input.code ?? null, input.isActive ?? null],
);
return rows[0] ?? null;
}
// ===========================================================================
// stock_movements — append-only ledger
// ===========================================================================
export type StockMovementType =
| "purchase_in"
| "adjustment_in"
| "adjustment_out"
| "transfer_out"
| "transfer_in"
| "reservation"
| "reservation_release"
| "sale_out"
| "install_out"
| "comodato_out"
| "return_in"
| "rma_out"
| "write_off";
export interface StockMovement {
id: string;
created_at: Date;
created_by: string | null;
warehouse_id: string;
product_id: string;
movement_type: StockMovementType;
quantity: string;
reference_type: string | null;
reference_id: string | null;
notes: string | null;
}
export interface ListStockMovementsFilter {
warehouseId?: string;
productId?: string;
referenceType?: string;
referenceId?: string;
}
export async function listStockMovements(filter: ListStockMovementsFilter = {}): Promise<StockMovement[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.warehouseId) {
params.push(filter.warehouseId);
conditions.push(`warehouse_id = $${params.length}`);
}
if (filter.productId) {
params.push(filter.productId);
conditions.push(`product_id = $${params.length}`);
}
if (filter.referenceType) {
params.push(filter.referenceType);
conditions.push(`reference_type = $${params.length}`);
}
if (filter.referenceId) {
params.push(filter.referenceId);
conditions.push(`reference_id = $${params.length}`);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<StockMovement>(
`SELECT * FROM stock_movements ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}
export interface CreateStockMovementInput {
warehouseId: string;
productId: string;
movementType: StockMovementType;
quantity: number;
referenceType?: string | null;
referenceId?: string | null;
notes?: string | null;
createdBy?: string | null;
}
/**
* Appends one movement. No update/edit function exists on purpose — a
* correction is always a new call to this function with the reversing
* type (also enforced at the DB level by trg_stock_movements_no_update/
* no_delete). Accepts an optional `client` so callers that need this
* insert inside a larger transaction (asset entry/install/return) can
* pass the transaction's client instead of grabbing a new pool
* connection — used by future endpoints (adjustment/transfer/write_off)
* that aren't wired up yet in this MVP; the asset lifecycle functions
* below (createAssetWithPurchaseMovement, reserveAsset, installAsset,
* returnAsset) inline their own movement INSERTs instead of calling this,
* since they already hold the transaction's client for the surrounding
* asset row lock.
*/
export async function recordStockMovement(
input: CreateStockMovementInput,
client?: PoolClient,
): Promise<StockMovement> {
const sql = `INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING *`;
const params = [
input.warehouseId,
input.productId,
input.movementType,
input.quantity,
input.referenceType ?? null,
input.referenceId ?? null,
input.notes ?? null,
input.createdBy ?? null,
];
const { rows } = client
? await client.query<StockMovement>(sql, params)
: await query<StockMovement>(sql, params);
const row = rows[0];
if (!row) throw new Error("failed to record stock movement");
return row;
}
// ===========================================================================
// serialized_assets
// ===========================================================================
export type SerializedAssetStatus =
| "in_stock"
| "reserved"
| "installed"
| "comodato"
| "returned"
| "written_off";
export interface SerializedAsset {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
product_id: string;
serial: string | null;
patrimony_tag: string | null;
mac_address: string | null;
warehouse_id: string | null;
status: SerializedAssetStatus;
current_contract_id: string | null;
warranty_expires_at: Date | null;
}
export interface ListSerializedAssetsFilter {
status?: SerializedAssetStatus;
warehouseId?: string;
productId?: string;
}
export async function listSerializedAssets(filter: ListSerializedAssetsFilter = {}): Promise<SerializedAsset[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.status) {
params.push(filter.status);
conditions.push(`status = $${params.length}`);
}
if (filter.warehouseId) {
params.push(filter.warehouseId);
conditions.push(`warehouse_id = $${params.length}`);
}
if (filter.productId) {
params.push(filter.productId);
conditions.push(`product_id = $${params.length}`);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<SerializedAsset>(
`SELECT * FROM serialized_assets ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}
export async function getSerializedAssetById(id: string): Promise<SerializedAsset | null> {
const { rows } = await query<SerializedAsset>("SELECT * FROM serialized_assets WHERE id = $1", [id]);
return rows[0] ?? null;
}
async function getSerializedAssetByIdForUpdate(client: PoolClient, id: string): Promise<SerializedAsset | null> {
const { rows } = await client.query<SerializedAsset>(
"SELECT * FROM serialized_assets WHERE id = $1 FOR UPDATE",
[id],
);
return rows[0] ?? null;
}
export class AssetNotFoundError extends Error {
constructor(public readonly assetId: string) {
super(`Serialized asset ${assetId} not found`);
this.name = "AssetNotFoundError";
}
}
export class InvalidAssetTransitionError extends Error {
constructor(
public readonly fromStatus: SerializedAssetStatus,
public readonly action: string,
) {
super(`Asset in status '${fromStatus}' cannot be ${action}`);
this.name = "InvalidAssetTransitionError";
}
}
export interface CreateAssetInput {
productId: string;
warehouseId: string;
serial?: string | null;
patrimonyTag?: string | null;
macAddress?: string | null;
warrantyExpiresAt?: Date | string | null;
notes?: string | null;
createdBy?: string | null;
}
/**
* Entrada de um ativo novo — cria a linha em serialized_assets (status
* in_stock) e o stock_movement purchase_in correspondente numa única
* transação. Se o serial/patrimônio/MAC já estiver "ativo" em outro
* ativo, a constraint de banco (índice único parcial) rejeita o INSERT —
* o erro do Postgres (23505) é deixado subir; a camada de serviço decide
* como traduzir para 409.
*/
export async function createAssetWithPurchaseMovement(input: CreateAssetInput): Promise<{
asset: SerializedAsset;
movement: StockMovement;
}> {
return withTransaction(async (client) => {
const { rows: assetRows } = await client.query<SerializedAsset>(
`INSERT INTO serialized_assets (
product_id, serial, patrimony_tag, mac_address, warehouse_id, status,
warranty_expires_at, created_by, updated_by
) VALUES ($1,$2,$3,$4,$5,'in_stock',$6,$7,$7)
RETURNING *`,
[
input.productId,
input.serial ?? null,
input.patrimonyTag ?? null,
input.macAddress ?? null,
input.warehouseId,
input.warrantyExpiresAt ?? null,
input.createdBy ?? null,
],
);
const asset = assetRows[0];
if (!asset) throw new Error("failed to create serialized asset");
const { rows: movementRows } = await client.query<StockMovement>(
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,'purchase_in',1,'serialized_asset',$3,$4,$5)
RETURNING *`,
[input.warehouseId, input.productId, asset.id, input.notes ?? null, input.createdBy ?? null],
);
const movement = movementRows[0];
if (!movement) throw new Error("failed to record purchase_in movement");
return { asset, movement };
});
}
export interface ReserveAssetInput {
referenceType?: string | null;
referenceId?: string | null;
notes?: string | null;
createdBy?: string | null;
}
/**
* Reserva um ativo específico já em estoque: cria a stock_reservation
* (quantity=1, reference_type='serialized_asset', reference_id=asset.id)
* e move o ativo para status='reserved'. Só permitido a partir de
* 'in_stock' — throws InvalidAssetTransitionError caso contrário.
*/
export async function reserveAsset(
assetId: string,
input: ReserveAssetInput,
): Promise<{ asset: SerializedAsset; reservation: StockReservation; movement: StockMovement }> {
return withTransaction(async (client) => {
const asset = await getSerializedAssetByIdForUpdate(client, assetId);
if (!asset) throw new AssetNotFoundError(assetId);
if (asset.status !== "in_stock") throw new InvalidAssetTransitionError(asset.status, "reserved");
if (!asset.warehouse_id) throw new Error(`asset ${assetId} has status in_stock but no warehouse_id`);
const { rows: updatedRows } = await client.query<SerializedAsset>(
`UPDATE serialized_assets SET status = 'reserved', updated_by = $2 WHERE id = $1 RETURNING *`,
[assetId, input.createdBy ?? null],
);
const updated = updatedRows[0];
if (!updated) throw new Error(`asset ${assetId} disappeared mid-transaction`);
const { rows: reservationRows } = await client.query<StockReservation>(
`INSERT INTO stock_reservations (warehouse_id, product_id, quantity, reference_type, reference_id, status, notes, created_by)
VALUES ($1,$2,1,'serialized_asset',$3,'active',$4,$5)
RETURNING *`,
[asset.warehouse_id, asset.product_id, assetId, input.notes ?? null, input.createdBy ?? null],
);
const reservation = reservationRows[0];
if (!reservation) throw new Error("failed to create reservation");
const { rows: movementRows } = await client.query<StockMovement>(
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,'reservation',1,'serialized_asset',$3,$4,$5)
RETURNING *`,
[asset.warehouse_id, asset.product_id, assetId, input.notes ?? null, input.createdBy ?? null],
);
const movement = movementRows[0];
if (!movement) throw new Error("failed to record reservation movement");
return { asset: updated, reservation, movement };
});
}
export interface InstallAssetInput {
contractId: string;
assignmentType: "installed" | "comodato";
notes?: string | null;
createdBy?: string | null;
}
/**
* Vincula um ativo (em 'in_stock' ou 'reserved') a um contrato: cria
* asset_assignments, move o ativo para status='installed'/'comodato'
* (limpando warehouse_id — a localização agora é "no cliente"), grava o
* stock_movement install_out/comodato_out, e — se havia uma reserva
* ATIVA para este ativo específico — marca-a 'fulfilled'. Tudo numa
* transação. Só permitido a partir de in_stock/reserved.
*/
export async function installAsset(
assetId: string,
input: InstallAssetInput,
): Promise<{ asset: SerializedAsset; assignment: AssetAssignment; movement: StockMovement }> {
return withTransaction(async (client) => {
const asset = await getSerializedAssetByIdForUpdate(client, assetId);
if (!asset) throw new AssetNotFoundError(assetId);
if (asset.status !== "in_stock" && asset.status !== "reserved") {
throw new InvalidAssetTransitionError(asset.status, input.assignmentType);
}
if (!asset.warehouse_id) throw new Error(`asset ${assetId} has no warehouse_id before install`);
const originWarehouseId = asset.warehouse_id;
const movementType = input.assignmentType === "comodato" ? "comodato_out" : "install_out";
const { rows: updatedRows } = await client.query<SerializedAsset>(
`UPDATE serialized_assets
SET status = $2, warehouse_id = NULL, current_contract_id = $3, updated_by = $4
WHERE id = $1
RETURNING *`,
[assetId, input.assignmentType, input.contractId, input.createdBy ?? null],
);
const updated = updatedRows[0];
if (!updated) throw new Error(`asset ${assetId} disappeared mid-transaction`);
const { rows: assignmentRows } = await client.query<AssetAssignment>(
`INSERT INTO asset_assignments (asset_id, contract_id, assignment_type, created_by)
VALUES ($1,$2,$3,$4)
RETURNING *`,
[assetId, input.contractId, input.assignmentType, input.createdBy ?? null],
);
const assignment = assignmentRows[0];
if (!assignment) throw new Error("failed to create asset assignment");
const { rows: movementRows } = await client.query<StockMovement>(
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,$3,1,'contract',$4,$5,$6)
RETURNING *`,
[originWarehouseId, asset.product_id, movementType, input.contractId, input.notes ?? null, input.createdBy ?? null],
);
const movement = movementRows[0];
if (!movement) throw new Error("failed to record install/comodato movement");
// If there was an active asset-specific reservation, close it as fulfilled.
await client.query(
`UPDATE stock_reservations
SET status = 'fulfilled'
WHERE reference_type = 'serialized_asset' AND reference_id = $1 AND status = 'active'`,
[assetId],
);
return { asset: updated, assignment, movement };
});
}
export interface ReturnAssetInput {
/** Warehouse receiving the physical unit back — not necessarily the one it left from (see migration comment on serialized_assets.warehouse_id). */
warehouseId: string;
notes?: string | null;
createdBy?: string | null;
}
export class NoOpenAssignmentError extends Error {
constructor(public readonly assetId: string) {
super(`Asset ${assetId} has no open assignment to close`);
this.name = "NoOpenAssignmentError";
}
}
/**
* Fecha o assignment aberto (unassigned_at = now()), grava o
* stock_movement return_in no warehouse de destino, e volta o ativo para
* status='in_stock' com current_contract_id limpo. Só permitido a partir
* de installed/comodato.
*/
export async function returnAsset(
assetId: string,
input: ReturnAssetInput,
): Promise<{ asset: SerializedAsset; movement: StockMovement }> {
return withTransaction(async (client) => {
const asset = await getSerializedAssetByIdForUpdate(client, assetId);
if (!asset) throw new AssetNotFoundError(assetId);
if (asset.status !== "installed" && asset.status !== "comodato") {
throw new InvalidAssetTransitionError(asset.status, "returned");
}
const { rows: assignmentRows } = await client.query<AssetAssignment>(
`UPDATE asset_assignments SET unassigned_at = now()
WHERE asset_id = $1 AND unassigned_at IS NULL
RETURNING *`,
[assetId],
);
if (!assignmentRows[0]) throw new NoOpenAssignmentError(assetId);
const { rows: updatedRows } = await client.query<SerializedAsset>(
`UPDATE serialized_assets
SET status = 'in_stock', warehouse_id = $2, current_contract_id = NULL, updated_by = $3
WHERE id = $1
RETURNING *`,
[assetId, input.warehouseId, input.createdBy ?? null],
);
const updated = updatedRows[0];
if (!updated) throw new Error(`asset ${assetId} disappeared mid-transaction`);
const { rows: movementRows } = await client.query<StockMovement>(
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,'return_in',1,'contract',$3,$4,$5)
RETURNING *`,
[input.warehouseId, asset.product_id, asset.current_contract_id, input.notes ?? null, input.createdBy ?? null],
);
const movement = movementRows[0];
if (!movement) throw new Error("failed to record return_in movement");
return { asset: updated, movement };
});
}
// ===========================================================================
// asset_assignments
// ===========================================================================
export interface AssetAssignment {
id: string;
created_at: Date;
created_by: string | null;
asset_id: string;
contract_id: string;
assignment_type: "installed" | "comodato";
assigned_at: Date;
unassigned_at: Date | null;
}
export async function listAssetAssignments(assetId: string): Promise<AssetAssignment[]> {
const { rows } = await query<AssetAssignment>(
"SELECT * FROM asset_assignments WHERE asset_id = $1 ORDER BY assigned_at DESC",
[assetId],
);
return rows;
}
// ===========================================================================
// stock_reservations
// ===========================================================================
export type StockReservationStatus = "active" | "released" | "fulfilled";
export interface StockReservation {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
warehouse_id: string;
product_id: string;
quantity: string;
reference_type: string | null;
reference_id: string | null;
status: StockReservationStatus;
notes: string | null;
}
export interface ListStockReservationsFilter {
status?: StockReservationStatus;
warehouseId?: string;
productId?: string;
}
export async function listStockReservations(
filter: ListStockReservationsFilter = {},
): Promise<StockReservation[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.status) {
params.push(filter.status);
conditions.push(`status = $${params.length}`);
}
if (filter.warehouseId) {
params.push(filter.warehouseId);
conditions.push(`warehouse_id = $${params.length}`);
}
if (filter.productId) {
params.push(filter.productId);
conditions.push(`product_id = $${params.length}`);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<StockReservation>(
`SELECT * FROM stock_reservations ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}
export async function getStockReservationById(id: string): Promise<StockReservation | null> {
const { rows } = await query<StockReservation>("SELECT * FROM stock_reservations WHERE id = $1", [id]);
return rows[0] ?? null;
}
export class ReservationNotFoundError extends Error {
constructor(public readonly reservationId: string) {
super(`Stock reservation ${reservationId} not found`);
this.name = "ReservationNotFoundError";
}
}
export class InvalidReservationTransitionError extends Error {
constructor(
public readonly fromStatus: StockReservationStatus,
public readonly action: string,
) {
super(`Reservation in status '${fromStatus}' cannot be ${action}`);
this.name = "InvalidReservationTransitionError";
}
}
export interface CreateGenericReservationInput {
warehouseId: string;
productId: string;
quantity: number;
referenceType?: string | null;
referenceId?: string | null;
notes?: string | null;
createdBy?: string | null;
}
/**
* Reserva GENÉRICA de quantidade (produto+warehouse), sem serial
* selecionado ainda — o primeiro passo do fluxo descrito no Master
* Prompt §6.8 antes de "posteriormente selecionar unidade serializada
* específica". Distinta de reserveAsset() (que reserva um serial já
* escolhido).
*/
export async function createGenericReservation(
input: CreateGenericReservationInput,
): Promise<{ reservation: StockReservation; movement: StockMovement }> {
return withTransaction(async (client) => {
const { rows: reservationRows } = await client.query<StockReservation>(
`INSERT INTO stock_reservations (warehouse_id, product_id, quantity, reference_type, reference_id, status, notes, created_by)
VALUES ($1,$2,$3,$4,$5,'active',$6,$7)
RETURNING *`,
[
input.warehouseId,
input.productId,
input.quantity,
input.referenceType ?? null,
input.referenceId ?? null,
input.notes ?? null,
input.createdBy ?? null,
],
);
const reservation = reservationRows[0];
if (!reservation) throw new Error("failed to create reservation");
const { rows: movementRows } = await client.query<StockMovement>(
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,'reservation',$3,$4,$5,$6,$7)
RETURNING *`,
[
input.warehouseId,
input.productId,
input.quantity,
input.referenceType ?? null,
input.referenceId ?? null,
input.notes ?? null,
input.createdBy ?? null,
],
);
const movement = movementRows[0];
if (!movement) throw new Error("failed to record reservation movement");
return { reservation, movement };
});
}
/**
* Cancela uma reserva ATIVA (status -> released) e grava o
* reservation_release correspondente. Se a reserva era de um ativo
* específico (reference_type='serialized_asset'), também devolve esse
* ativo para 'in_stock' — desfazendo o que reserveAsset() fez.
*/
export async function releaseReservation(
reservationId: string,
actorId: string | null,
): Promise<{ reservation: StockReservation; movement: StockMovement }> {
return withTransaction(async (client) => {
const { rows: currentRows } = await client.query<StockReservation>(
"SELECT * FROM stock_reservations WHERE id = $1 FOR UPDATE",
[reservationId],
);
const current = currentRows[0];
if (!current) throw new ReservationNotFoundError(reservationId);
if (current.status !== "active") {
throw new InvalidReservationTransitionError(current.status, "released");
}
const { rows: updatedRows } = await client.query<StockReservation>(
`UPDATE stock_reservations SET status = 'released' WHERE id = $1 RETURNING *`,
[reservationId],
);
const updated = updatedRows[0];
if (!updated) throw new Error(`reservation ${reservationId} disappeared mid-transaction`);
const { rows: movementRows } = await client.query<StockMovement>(
`INSERT INTO stock_movements (warehouse_id, product_id, movement_type, quantity, reference_type, reference_id, notes, created_by)
VALUES ($1,$2,'reservation_release',$3,$4,$5,$6,$7)
RETURNING *`,
[
current.warehouse_id,
current.product_id,
current.quantity,
current.reference_type,
current.reference_id,
null,
actorId,
],
);
const movement = movementRows[0];
if (!movement) throw new Error("failed to record reservation_release movement");
if (current.reference_type === "serialized_asset" && current.reference_id) {
await client.query(
`UPDATE serialized_assets SET status = 'in_stock', updated_by = $2
WHERE id = $1 AND status = 'reserved'`,
[current.reference_id, actorId],
);
}
return { reservation: updated, movement };
});
}

View File

@@ -0,0 +1,423 @@
import { query, withTransaction } from "../pool.js";
/**
* Support/Service Desk bounded context (Master Prompt §6.13). Greenfield —
* no legacy module to port. See the migration's header comment
* (PENDING_support.cjs) for the full list of tables this MVP builds vs.
* explicitly deferred ones (real S3 attachments, CSAT, dedicated work
* order entity, routing queues, configurable attendance calendar).
*
* Mirrors contracts.ts's conventions closely: withTransaction + SELECT
* ... FOR UPDATE for the status-machine transition, an explicit
* transitions table, a NotFound/InvalidTransition error pair the service
* layer maps to 404/409, and a *WithDetails aggregate for the "get one"
* endpoint.
*
* SLA breach/elapsed-time math (computeElapsedSeconds/isSlaBreached) is
* deliberately NOT implemented in this file — it's pure, DB-independent
* business logic that lives in apps/api/src/support/sla.ts (this package
* must not depend on apps/api). This repository only performs the SQL
* side effect of pausing/resuming the timer (using the DB's own `now()`,
* to avoid app/DB clock skew for that specific bookkeeping) and persists
* whatever met/unmet flags the service layer has already computed.
*/
export type TicketStatus =
| "new"
| "triage"
| "in_progress"
| "waiting_customer"
| "waiting_third_party"
| "resolved"
| "closed"
| "cancelled";
export type TicketPriority = "low" | "medium" | "high" | "critical";
export interface SupportTicket {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
updated_by: string | null;
protocol_number: string;
title: string;
description: string;
category: string;
subcategory: string | null;
priority: TicketPriority;
status: TicketStatus;
reseller_id: string | null;
client_registration_id: string | null;
contract_id: string | null;
owner_user_id: string | null;
opened_by_user_id: string;
root_cause: string | null;
resolution_notes: string | null;
first_response_at: Date | null;
resolved_at: Date | null;
closed_at: Date | null;
}
export interface TicketComment {
id: string;
ticket_id: string;
author_user_id: string;
body: string;
is_internal: boolean;
created_at: Date;
}
export interface TicketStatusHistoryEntry {
id: string;
ticket_id: string;
from_status: TicketStatus | null;
to_status: TicketStatus;
reason: string | null;
changed_by: string | null;
changed_at: Date;
}
export interface SlaTimer {
ticket_id: string;
created_at: Date;
updated_at: Date;
first_response_due_at: Date;
resolution_due_at: Date;
first_response_met: boolean | null;
resolution_met: boolean | null;
paused_since: Date | null;
accumulated_pause_seconds: number;
}
export interface TicketWithDetails extends SupportTicket {
comments: TicketComment[];
statusHistory: TicketStatusHistoryEntry[];
slaTimer: SlaTimer | null;
}
/**
* MVP lifecycle (eden-support persona spec): new -> triage -> in_progress
* <-> {waiting_customer, waiting_third_party} -> resolved -> closed, plus
* cancelled reachable from any non-terminal state. closed/cancelled are
* terminal (no outgoing transitions modeled).
*/
export const TICKET_STATUS_TRANSITIONS: Record<TicketStatus, TicketStatus[]> = {
new: ["triage", "cancelled"],
triage: ["in_progress", "cancelled"],
in_progress: ["waiting_customer", "waiting_third_party", "resolved", "cancelled"],
waiting_customer: ["in_progress", "cancelled"],
waiting_third_party: ["in_progress", "cancelled"],
resolved: ["closed", "cancelled"],
closed: [],
cancelled: [],
};
export class TicketNotFoundError extends Error {
constructor(public readonly ticketId: string) {
super(`Support ticket ${ticketId} not found`);
this.name = "TicketNotFoundError";
}
}
export class InvalidTicketTransitionError extends Error {
constructor(
public readonly fromStatus: TicketStatus,
public readonly toStatus: TicketStatus,
) {
super(`Invalid ticket status transition: '${fromStatus}' -> '${toStatus}'`);
this.name = "InvalidTicketTransitionError";
}
}
export interface ListTicketsFilter {
scope: "all" | "reseller";
/** Required (and enforced) when scope === 'reseller'. Ignored when scope === 'all'. */
resellerId?: string | null;
}
export async function listTickets(filter: ListTicketsFilter): Promise<SupportTicket[]> {
if (filter.scope === "reseller") {
// Deliberately never falls back to an unfiltered query — deny-by-default,
// same discipline as contracts.ts::listContracts.
const { rows } = await query<SupportTicket>(
"SELECT * FROM support_tickets WHERE reseller_id = $1 ORDER BY created_at DESC",
[filter.resellerId ?? null],
);
return rows;
}
const { rows } = await query<SupportTicket>("SELECT * FROM support_tickets ORDER BY created_at DESC");
return rows;
}
async function listTicketComments(ticketId: string): Promise<TicketComment[]> {
const { rows } = await query<TicketComment>(
"SELECT * FROM ticket_comments WHERE ticket_id = $1 ORDER BY created_at ASC",
[ticketId],
);
return rows;
}
async function listTicketStatusHistory(ticketId: string): Promise<TicketStatusHistoryEntry[]> {
const { rows } = await query<TicketStatusHistoryEntry>(
"SELECT * FROM ticket_status_history WHERE ticket_id = $1 ORDER BY changed_at ASC",
[ticketId],
);
return rows;
}
async function getSlaTimer(ticketId: string): Promise<SlaTimer | null> {
const { rows } = await query<SlaTimer>("SELECT * FROM sla_timers WHERE ticket_id = $1", [ticketId]);
return rows[0] ?? null;
}
export async function getTicketById(id: string): Promise<TicketWithDetails | null> {
const { rows } = await query<SupportTicket>("SELECT * FROM support_tickets WHERE id = $1", [id]);
const ticket = rows[0];
if (!ticket) return null;
const [comments, statusHistory, slaTimer] = await Promise.all([
listTicketComments(id),
listTicketStatusHistory(id),
getSlaTimer(id),
]);
return { ...ticket, comments, statusHistory, slaTimer };
}
export interface CreateTicketInput {
title: string;
description: string;
category: string;
subcategory?: string | null;
priority: TicketPriority;
resellerId?: string | null;
clientRegistrationId?: string | null;
contractId?: string | null;
ownerUserId?: string | null;
openedByUserId: string;
createdBy?: string | null;
/** Server-computed by the caller (apps/api/src/support/sla.ts) — never accepted raw from a client. */
firstResponseDueAt: Date;
/** Server-computed by the caller (apps/api/src/support/sla.ts) — never accepted raw from a client. */
resolutionDueAt: Date;
}
/**
* Creates the ticket plus its 1:1 sla_timers row in a single transaction
* (all-or-nothing) — mirrors contracts.ts::createContract. Status always
* starts 'new'; protocol_number is allocated by the column DEFAULT
* (support_tickets_protocol_number_seq), same pattern as quotes.quote_number.
*/
export async function createTicket(input: CreateTicketInput): Promise<TicketWithDetails> {
return withTransaction(async (client) => {
const { rows } = await client.query<SupportTicket>(
`INSERT INTO support_tickets (
title, description, category, subcategory, priority,
reseller_id, client_registration_id, contract_id,
owner_user_id, opened_by_user_id, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING *`,
[
input.title,
input.description,
input.category,
input.subcategory ?? null,
input.priority,
input.resellerId ?? null,
input.clientRegistrationId ?? null,
input.contractId ?? null,
input.ownerUserId ?? null,
input.openedByUserId,
input.createdBy ?? null,
],
);
const ticket = rows[0];
if (!ticket) throw new Error("failed to create support ticket");
const { rows: timerRows } = await client.query<SlaTimer>(
`INSERT INTO sla_timers (ticket_id, first_response_due_at, resolution_due_at)
VALUES ($1,$2,$3)
RETURNING *`,
[ticket.id, input.firstResponseDueAt, input.resolutionDueAt],
);
const slaTimer = timerRows[0];
if (!slaTimer) throw new Error("failed to create sla timer");
return { ...ticket, comments: [], statusHistory: [], slaTimer };
});
}
export interface CreateTicketCommentInput {
authorUserId: string;
body: string;
isInternal: boolean;
}
export async function addTicketComment(
ticketId: string,
input: CreateTicketCommentInput,
): Promise<TicketComment> {
const { rows } = await query<TicketComment>(
`INSERT INTO ticket_comments (ticket_id, author_user_id, body, is_internal)
VALUES ($1,$2,$3,$4)
RETURNING *`,
[ticketId, input.authorUserId, input.body, input.isInternal],
);
const row = rows[0];
if (!row) throw new Error("failed to add ticket comment");
return row;
}
/**
* Once the first public (non-internal) comment is added on a ticket whose
* first_response_at is still NULL, the service layer computes whether
* that response met the SLA (apps/api/src/support/sla.ts::isSlaBreached)
* and calls this to persist it. Idempotent by construction (WHERE ... IS
* NULL on both tables) — a second call after first_response_at is already
* set is a silent no-op, never overwrites.
*/
export async function recordFirstResponse(
ticketId: string,
met: boolean,
respondedAt: Date,
): Promise<void> {
await query(
`UPDATE support_tickets SET first_response_at = $2 WHERE id = $1 AND first_response_at IS NULL`,
[ticketId, respondedAt],
);
await query(
`UPDATE sla_timers SET first_response_met = $2, updated_at = now()
WHERE ticket_id = $1 AND first_response_met IS NULL`,
[ticketId, met],
);
}
/**
* Called by the service layer right after a transition into 'resolved',
* once it has computed the met/unmet flag from the (already-returned,
* necessarily-unpaused — see updateTicketStatus below) sla timer via
* sla.ts::isSlaBreached.
*/
export async function recordResolutionMet(ticketId: string, met: boolean): Promise<void> {
await query(`UPDATE sla_timers SET resolution_met = $2, updated_at = now() WHERE ticket_id = $1`, [
ticketId,
met,
]);
}
export interface UpdateTicketStatusExtra {
rootCause?: string | null;
resolutionNotes?: string | null;
}
/**
* Validates the transition against TICKET_STATUS_TRANSITIONS and, only if
* valid, atomically: updates support_tickets.status, sets resolved_at/
* closed_at via COALESCE the first time the ticket enters that state
* (never overwritten afterwards, same semantics as contracts.activated_at),
* accepts root_cause/resolution_notes only on the transition into
* 'closed', appends a ticket_status_history row, AND pauses/resumes the
* SLA timer:
* - entering waiting_customer/waiting_third_party -> paused_since = now()
* (COALESCE — a no-op if somehow already paused)
* - waiting_customer/waiting_third_party -> in_progress -> the elapsed
* pause is folded into accumulated_pause_seconds and paused_since is
* cleared
* All using the DB's own now() for this specific bookkeeping (single
* statement, no app/DB clock skew risk). Returns the updated ticket AND
* the post-transition sla timer row so the service layer can compute
* first_response_met/resolution_met with the pure functions in
* apps/api/src/support/sla.ts. Throws TicketNotFoundError /
* InvalidTicketTransitionError instead of silently no-oping — the service
* layer maps these to 404/409.
*/
export async function updateTicketStatus(
id: string,
toStatus: TicketStatus,
reason: string | null,
actorId: string | null,
extra?: UpdateTicketStatusExtra,
): Promise<{ ticket: SupportTicket; slaTimer: SlaTimer }> {
return withTransaction(async (client) => {
const { rows } = await client.query<{ status: TicketStatus }>(
"SELECT status FROM support_tickets WHERE id = $1 FOR UPDATE",
[id],
);
const current = rows[0];
if (!current) {
throw new TicketNotFoundError(id);
}
const fromStatus = current.status;
const allowed = TICKET_STATUS_TRANSITIONS[fromStatus] ?? [];
if (!allowed.includes(toStatus)) {
throw new InvalidTicketTransitionError(fromStatus, toStatus);
}
const enteringWaiting = toStatus === "waiting_customer" || toStatus === "waiting_third_party";
const resumingFromWaiting =
(fromStatus === "waiting_customer" || fromStatus === "waiting_third_party") && toStatus === "in_progress";
const enteringResolved = toStatus === "resolved";
const enteringClosed = toStatus === "closed";
// Lock the timer row too (1:1, always exists once the ticket does) and
// apply the pause/resume side effect. All SET expressions in a single
// UPDATE see the row's OLD values, so referencing paused_since while
// also overwriting it in the same statement is safe standard SQL.
await client.query(
`UPDATE sla_timers SET
accumulated_pause_seconds = CASE
WHEN $3 AND paused_since IS NOT NULL
THEN accumulated_pause_seconds + GREATEST(0, EXTRACT(EPOCH FROM (now() - paused_since))::int)
ELSE accumulated_pause_seconds
END,
paused_since = CASE
WHEN $2 THEN COALESCE(paused_since, now())
WHEN $3 THEN NULL
ELSE paused_since
END,
updated_at = now()
WHERE ticket_id = $1`,
[id, enteringWaiting, resumingFromWaiting],
);
const { rows: updatedRows } = await client.query<SupportTicket>(
`UPDATE support_tickets SET
status = $2,
resolved_at = CASE WHEN $3 THEN COALESCE(resolved_at, now()) ELSE resolved_at END,
closed_at = CASE WHEN $4 THEN COALESCE(closed_at, now()) ELSE closed_at END,
root_cause = CASE WHEN $4 THEN COALESCE($5, root_cause) ELSE root_cause END,
resolution_notes = CASE WHEN $4 THEN COALESCE($6, resolution_notes) ELSE resolution_notes END,
updated_by = $7
WHERE id = $1
RETURNING *`,
[
id,
toStatus,
enteringResolved,
enteringClosed,
extra?.rootCause ?? null,
extra?.resolutionNotes ?? null,
actorId ?? null,
],
);
const ticket = updatedRows[0];
if (!ticket) throw new Error(`support ticket ${id} disappeared mid-transaction`);
await client.query(
`INSERT INTO ticket_status_history (ticket_id, from_status, to_status, reason, changed_by)
VALUES ($1,$2,$3,$4,$5)`,
[id, fromStatus, toStatus, reason ?? null, actorId ?? null],
);
const { rows: timerRows } = await client.query<SlaTimer>(
"SELECT * FROM sla_timers WHERE ticket_id = $1",
[id],
);
const slaTimer = timerRows[0];
if (!slaTimer) throw new Error(`sla timer for ticket ${id} missing`);
return { ticket, slaTimer };
});
}

View File

@@ -142,3 +142,25 @@ export async function assignUserToReseller(
]);
return (rowCount ?? 0) > 0;
}
/**
* Stages a TOTP secret without enabling MFA yet — `mfa_enabled` stays
* false until `confirmMfaEnrollment` verifies a code against it. Login
* only checks `mfa_enabled`, so an unconfirmed enrollment is inert.
*/
export async function setMfaSecret(userId: string, encryptedSecret: string): Promise<void> {
await query("UPDATE users SET mfa_secret_encrypted = $2, mfa_enabled = false WHERE id = $1", [
userId,
encryptedSecret,
]);
}
export async function confirmMfaEnrollment(userId: string): Promise<void> {
await query("UPDATE users SET mfa_enabled = true WHERE id = $1", [userId]);
}
export async function disableMfa(userId: string): Promise<void> {
await query("UPDATE users SET mfa_enabled = false, mfa_secret_encrypted = null WHERE id = $1", [
userId,
]);
}

View File

@@ -1,10 +1,20 @@
{
"name": "@eden/ui",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./tailwind-preset": {
"types": "./dist/tailwind-preset.d.ts",
"default": "./dist/tailwind-preset.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
@@ -15,6 +25,7 @@
},
"devDependencies": {
"@types/react": "^18.3.18",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
}
}

View File

@@ -0,0 +1,29 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "../utils/cn.js";
export type AlertVariant = "info" | "success" | "warning" | "error";
const variantClasses: Record<AlertVariant, string> = {
info: "bg-info-transparent border-info-200 text-info-900",
success: "bg-success-transparent border-success-200 text-success-900",
warning: "bg-warning-transparent border-warning-200 text-warning-900",
error: "bg-danger-transparent border-danger-200 text-danger-900",
};
export interface AlertProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
variant?: AlertVariant;
title?: ReactNode;
}
export function Alert({ variant = "info", title, className, children, role, ...rest }: AlertProps) {
return (
<div
role={role ?? (variant === "error" ? "alert" : "status")}
className={cn("rounded-md border px-4 py-3 text-sm", variantClasses[variant], className)}
{...rest}
>
{title ? <p className="mb-0.5 font-semibold">{title}</p> : null}
{children ? <div>{children}</div> : null}
</div>
);
}

View File

@@ -0,0 +1,120 @@
import type { ComponentType, ReactNode } from "react";
import { cn } from "../utils/cn.js";
export interface AppShellNavItem {
key: string;
label: string;
href: string;
icon?: ReactNode;
active?: boolean;
}
interface NavLinkRenderProps {
href: string;
className?: string;
children: ReactNode;
}
export interface AppShellProps {
/** e.g. "EDEN Core" — shown next to the logo and used as the img alt text. */
appName: string;
logoSrc?: string;
navItems: AppShellNavItem[];
userName?: string;
userRole?: string;
onLogout?: () => void;
children: ReactNode;
/**
* Router-agnostic navigation: packages/ui doesn't depend on react-router.
* Defaults to a plain `<a>` (full page reload); pass e.g.
* `({ href, className, children }) => <Link to={href} className={className}>{children}</Link>`
* from the consuming app to get client-side routing.
*/
LinkComponent?: ComponentType<NavLinkRenderProps>;
}
function DefaultLink({ href, className, children }: NavLinkRenderProps) {
return (
<a href={href} className={className}>
{children}
</a>
);
}
/**
* Visually inspired by the DreamsERP theme's sidebar/topbar
* (react/src/components/common-sidebar, common-header) — sidebar with
* logo + dark active nav item, white topbar with a bordered bottom edge —
* without importing any of the theme's Redux/routing code.
*/
export function AppShell({
appName,
logoSrc,
navItems,
userName,
userRole,
onLogout,
children,
LinkComponent = DefaultLink,
}: AppShellProps) {
return (
<div className="flex min-h-screen bg-light">
<aside className="hidden w-sidebar shrink-0 flex-col border-r border-border-color bg-white lg:flex">
<div className="flex h-topbar items-center gap-2 border-b border-border-color px-4">
{logoSrc ? <img src={logoSrc} alt={appName} className="h-7 w-auto" /> : null}
<span className="text-sm font-bold text-title">{appName}</span>
</div>
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto p-3" aria-label="Navegação principal">
{navItems.map((item) => (
<LinkComponent
key={item.key}
href={item.href}
className={cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150",
item.active
? "bg-dark text-white"
: "text-gray-900 hover:bg-light-700 hover:text-primary",
)}
>
{item.icon}
<span>{item.label}</span>
</LinkComponent>
))}
</nav>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-topbar items-center justify-between gap-3 border-b border-border-color bg-white px-4">
<div className="flex items-center gap-2 lg:hidden">
{logoSrc ? <img src={logoSrc} alt={appName} className="h-6 w-auto" /> : null}
<span className="text-sm font-bold text-title">{appName}</span>
</div>
<div className="ms-auto flex items-center gap-3">
{userName ? (
<div className="flex items-center gap-2 rounded-md border border-border-color px-2 py-1">
<span className="flex size-7 items-center justify-center rounded-full bg-dark text-xs font-semibold text-white">
{userName.charAt(0).toUpperCase()}
</span>
<div className="hidden text-left leading-tight sm:block">
<p className="text-xs font-semibold text-title">{userName}</p>
{userRole ? <p className="text-[11px] text-default">{userRole}</p> : null}
</div>
</div>
) : null}
{onLogout ? (
<button
type="button"
onClick={onLogout}
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-default hover:bg-light hover:text-danger"
>
Sair
</button>
) : null}
</div>
</header>
<main className="flex-1 p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import type { HTMLAttributes } from "react";
import { cn } from "../utils/cn.js";
export type BadgeVariant = "primary" | "secondary" | "success" | "warning" | "danger" | "info" | "neutral";
const variantClasses: Record<BadgeVariant, string> = {
primary: "bg-primary-transparent text-primary-900",
secondary: "bg-secondary-transparent text-secondary-900",
success: "bg-success-transparent text-success-900",
warning: "bg-warning-transparent text-warning-900",
danger: "bg-danger-transparent text-danger-900",
info: "bg-info-transparent text-info-900",
neutral: "bg-light text-gray-900",
};
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: BadgeVariant;
}
export function Badge({ variant = "neutral", className, children, ...rest }: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-semibold",
variantClasses[variant],
className,
)}
{...rest}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,54 @@
import { forwardRef } from "react";
import type { ButtonHTMLAttributes } from "react";
import { cn } from "../utils/cn.js";
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "sm" | "md";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
/** Shows an inline spinner and disables the button — for in-flight submits (e.g. login). */
loading?: boolean;
}
const variantClasses: Record<ButtonVariant, string> = {
// Theme source (login.tsx) uses a dark CTA that hovers to the primary-hover
// shade, not a flat primary fill — kept here as-is.
primary: "bg-dark text-white hover:bg-primary-hover",
secondary: "bg-white text-title border border-border-color hover:bg-light",
danger: "bg-danger text-white hover:bg-danger-hover",
ghost: "bg-transparent text-default hover:bg-light",
};
const sizeClasses: Record<ButtonSize, string> = {
sm: "text-xs px-3 py-1.5",
md: "text-sm px-4 py-2.5",
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = "primary", size = "md", loading = false, disabled, className, children, ...rest },
ref,
) {
return (
<button
ref={ref}
disabled={disabled || loading}
className={cn(
"inline-flex items-center justify-center gap-2 rounded-md font-semibold cursor-pointer transition-colors duration-200 ease-in-out disabled:cursor-not-allowed disabled:opacity-60",
variantClasses[variant],
sizeClasses[size],
className,
)}
{...rest}
>
{loading ? (
<span
aria-hidden="true"
className="size-4 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
) : null}
{children}
</button>
);
});

View File

@@ -0,0 +1,22 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "../utils/cn.js";
export interface CardProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
title?: ReactNode;
actions?: ReactNode;
}
/** Theme source (auth-pages/login.tsx): `bg-white border border-border-color rounded-md shadow-sm`. */
export function Card({ title, actions, className, children, ...rest }: CardProps) {
return (
<div className={cn("rounded-md border border-border-color bg-white shadow-sm", className)} {...rest}>
{title || actions ? (
<div className="flex items-center justify-between gap-3 border-b border-border-color px-5 py-4">
{title ? <h3 className="!text-base font-semibold text-title">{title}</h3> : <span />}
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
) : null}
<div className="p-5">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import { forwardRef, useId } from "react";
import type { InputHTMLAttributes } from "react";
import { cn } from "../utils/cn.js";
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
/** Validation/server error message — shown below the field, field border turns danger. */
error?: string;
/** Helper text shown below the field when there's no error. */
hint?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ label, error, hint, id, className, ...rest },
ref,
) {
const generatedId = useId();
const inputId = id ?? generatedId;
return (
<div className="flex flex-col gap-1">
{label ? (
<label htmlFor={inputId} className="text-sm font-semibold text-gray-900">
{label}
</label>
) : null}
<input
ref={ref}
id={inputId}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${inputId}-error` : hint ? `${inputId}-hint` : undefined}
className={cn(
"w-full rounded-md border bg-white px-3 py-2.5 text-sm text-title placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary/30",
error ? "border-danger" : "border-border-color",
className,
)}
{...rest}
/>
{error ? (
<p id={`${inputId}-error`} className="text-xs text-danger">
{error}
</p>
) : hint ? (
<p id={`${inputId}-hint`} className="text-xs text-default">
{hint}
</p>
) : null}
</div>
);
});

View File

@@ -0,0 +1,20 @@
import type { HTMLAttributes, ReactNode } from "react";
import { cn } from "../utils/cn.js";
export interface PageHeaderProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
title: ReactNode;
description?: ReactNode;
actions?: ReactNode;
}
export function PageHeader({ title, description, actions, className, ...rest }: PageHeaderProps) {
return (
<div className={cn("flex flex-wrap items-start justify-between gap-3 pb-4", className)} {...rest}>
<div>
<h1 className="!text-xl font-bold text-title">{title}</h1>
{description ? <p className="mt-1 text-sm text-default">{description}</p> : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More