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

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;
`);
};