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

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

View File

@@ -1,7 +1,31 @@
/**
* EDEN Design System, derived from the purchased DreamsERP theme
* (tema_do_Eden.zip) — no components ported yet. See
* (tema_do_Eden.zip → dreamserp-v1.0.1/react.zip). See
* docs/project-inventory.md §2 and the eden-frontend agent
* (.claude/agents/eden-frontend.md) for the extraction plan.
* (.claude/agents/eden-frontend.md) for the extraction plan and
* `./tailwind-preset` for the extracted visual tokens.
*/
export const UI_PACKAGE_VERSION = "0.1.0";
export const UI_PACKAGE_VERSION = "0.2.0";
export { Button } from "./components/Button.js";
export type { ButtonProps, ButtonVariant, ButtonSize } from "./components/Button.js";
export { Input } from "./components/Input.js";
export type { InputProps } from "./components/Input.js";
export { Card } from "./components/Card.js";
export type { CardProps } from "./components/Card.js";
export { Badge } from "./components/Badge.js";
export type { BadgeProps, BadgeVariant } from "./components/Badge.js";
export { Alert } from "./components/Alert.js";
export type { AlertProps, AlertVariant } from "./components/Alert.js";
export { PageHeader } from "./components/PageHeader.js";
export type { PageHeaderProps } from "./components/PageHeader.js";
export { AppShell } from "./components/AppShell.js";
export type { AppShellProps, AppShellNavItem } from "./components/AppShell.js";
export { cn } from "./utils/cn.js";

View File

@@ -0,0 +1,231 @@
/**
* EDEN Tailwind preset — visual tokens extracted from the purchased
* DreamsERP theme (tema_do_Eden.zip → dreamserp-v1.0.1/react.zip →
* react/src/style/css/style.css, Tailwind v4 `@theme` block).
*
* The theme itself ships as Tailwind v4 (CSS-first `@theme`), but our apps
* are on Tailwind v3, so the same token values are re-expressed here as a
* plain JS/TS preset consumed via `presets: [require("@eden/ui/tailwind-preset")]`
* (or the ESM equivalent) in each app's own tailwind.config.js.
*
* Do not hand-edit color hexes without re-checking the source file — these
* are copied verbatim, not invented.
*/
import type { Config } from "tailwindcss";
type PresetTheme = Config["theme"];
const withHover = (base: Record<string, string>, hover: string) => ({
...base,
hover,
});
const primary = withHover(
{
DEFAULT: "#0F766E",
100: "#D7E5E3",
200: "#C3D8D5",
300: "#AFCCC8",
400: "#9BBFBA",
500: "#88B3AD",
600: "#74A6A0",
700: "#5F9A93",
800: "#4A8E87",
900: "#32827A",
transparent: "#EBF2F1",
},
"#0C6760",
);
const secondary = withHover(
{
DEFAULT: "#2563EB",
100: "#D6E4FF",
200: "#C2D7FE",
300: "#AEC9FD",
400: "#9ABCFB",
500: "#87AEF9",
600: "#74A0F7",
700: "#6092F5",
800: "#4D83F2",
900: "#3973EE",
transparent: "#EAF2FF",
},
"#1F56CF",
);
const success = withHover(
{
DEFAULT: "#059669",
100: "#DAECE2",
200: "#C7E2D4",
300: "#B4D9C6",
400: "#A1CFB8",
500: "#8EC6AB",
600: "#7BBC9D",
700: "#66B390",
800: "#50A983",
900: "#36A076",
transparent: "#ECF5F1",
},
"#04845C",
);
const warning = withHover(
{
DEFAULT: "#D97706",
100: "#FAE7D9",
200: "#F8DBC6",
300: "#F5CFB2",
400: "#F1C29F",
500: "#EEB68C",
600: "#EAAA78",
700: "#E69D64",
800: "#E2914D",
900: "#DE8434",
transparent: "#FDF3EC",
},
"#BF6805",
);
const danger = withHover(
{
DEFAULT: "#B91C1C",
100: "#F8D9D5",
200: "#F3C6C0",
300: "#EEB4AC",
400: "#EBA198",
500: "#E28E84",
600: "#DB7B70",
700: "#D3675C",
800: "#CB5248",
900: "#C23B33",
transparent: "#FCECEA",
},
"#A31717",
);
const info = withHover(
{
DEFAULT: "#0EA5E9",
100: "#DCEFFC",
200: "#CBE7FB",
300: "#B9DFF9",
400: "#A7D7F7",
500: "#94CFF5",
600: "#81C7F3",
700: "#6DBFF1",
800: "#57B6EE",
900: "#3CAEEC",
transparent: "#EEF7FE",
},
"#0B91CD",
);
const gray = withHover(
{
100: "#D1D4D8",
200: "#BBBFC5",
300: "#A5AAB3",
400: "#9096A1",
500: "#7B838F",
600: "#67707D",
700: "#545D6C",
800: "#414B5B",
900: "#2F3A4B",
transparent: "#E8E9EC",
},
"#192333",
);
const light = withHover(
{
DEFAULT: "#F8F9FA",
100: "#FAFBFD",
200: "#F6F8FB",
300: "#F3F4F6",
400: "#F3F6F9",
500: "#F1F4F7",
600: "#EEF2F6",
700: "#ECF1F6",
800: "#E9EFF3",
900: "#E7EDF2",
transparent: "#FDFDFE",
},
"#DBDCDE",
);
export const edenColors = {
primary,
secondary,
success,
warning,
danger,
info,
gray,
light,
dark: "#1E293B",
white: "#FFFFFF",
black: "#000000",
orange: { DEFAULT: "#E65100", transparent: "#FFF0EB" },
purple: { DEFAULT: "#6A1B9A", transparent: "#F1EBF7" },
pink: { DEFAULT: "#CC25B0", transparent: "#FDEDF8" },
indigo: { DEFAULT: "#303F9F", transparent: "#EAEDF7" },
cyan: { DEFAULT: "#0891B2", transparent: "#EBF3F6" },
teal: { DEFAULT: "#0D9488", transparent: "#EBF2F1" },
default: "#67707D",
title: "#2F3A4B",
"border-color": "#E8E9EC",
skeleton: "#E4E7EB",
} satisfies Record<string, unknown>;
/** Sidebar/topbar layout tokens (`--sidenav-width`, `--topbar-height`, …). */
export const edenLayout = {
sidebarWidth: "242px",
sidebarMiniWidth: "56px",
sidebarExpandMiniWidth: "80px",
topbarHeight: "56px",
};
const edenTheme: PresetTheme = {
extend: {
colors: edenColors,
fontFamily: {
// Theme source imports Google Fonts "Geist" as --font-family-primary.
// (Older EDEN docs referenced Nunito/Poppins for a prior theme
// revision — confirmed against the actual shipped tailwind/style.css
// that this is the current value; update here if the theme changes.)
sans: ["Geist", "ui-sans-serif", "system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"],
},
borderRadius: {
xs: "2px",
sm: "4px",
md: "8px",
xl: "12px",
},
boxShadow: {
DEFAULT: "0px 1px 4px 0px rgba(0, 0, 0, 0.05)",
sm: "0px 4px 4px 0px rgba(219, 219, 219, 0.25)",
lg: "0px 4px 30px 0px rgba(20, 20, 20, 0.08)",
primary: "0px 4px 10px 0px rgba(46, 55, 164, 0.1)",
},
spacing: {
sidebar: edenLayout.sidebarWidth,
"sidebar-mini": edenLayout.sidebarMiniWidth,
topbar: edenLayout.topbarHeight,
},
backgroundImage: {
"primary-gradient": "linear-gradient(90deg, #0F766E 0%, #059669 100%)",
"dark-gradient": "linear-gradient(90deg, #1E293B 0%, #0F172A 100%)",
},
},
};
// A preset intentionally omits `content` (each consuming app supplies its
// own) — Tailwind's `Config` type marks it required, so we type this as a
// partial config rather than a full `Config`.
const edenPreset: Partial<Config> = {
theme: edenTheme,
};
export default edenPreset;

View File

@@ -0,0 +1,4 @@
/** Joins class name fragments, skipping falsy values. No `clsx` dependency needed for this. */
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}