feat(api): Quotes, Client Registration e esqueleto de Contratos

Fecha o núcleo comercial da Fase 2/3: motor de precificação de Ofertas
(feito diretamente, dado o risco de erro de arredondamento/regra de
negócio), Cadastro de Cliente e esqueleto de Contratos (via 2 subagentes
em paralelo, salvos manualmente após ambos baterem no limite de gasto
mensal da conta — trabalho não commitado nos worktrees foi revisado
arquivo a arquivo e copiado para main, com as peças faltantes
completadas diretamente).

- Quotes: pricing.ts porta as fórmulas do legado (eden.md §7/§8) —
  needsApproval, faixas de condição de pagamento, efeito legal da
  fidelidade condicionado a approval_status==='approved'. Fluxo
  criar→decidir aprovação→fechar negócio (vinculado a um cadastro de
  cliente, com guarda contra fechamento duplo).
- Client Registration: cadastro direto (eden.md §3), MVP autenticado-only
  (sem token público de autoatendimento, upload ou lookup CNPJ/CEP nesta
  fase — documentado em comentário).
- Contracts: máquina de estados do ADR-0006 imposta via SELECT...FOR
  UPDATE + tabela de transições, com signed_at/activated_at e histórico
  de status; 4 das 11 tabelas do ADR-0006 (as demais adiadas).

2 bugs próprios corrigidos antes do commit: UpdateQuoteInput usava
COALESCE para campos financeiros (undefined do JS apagaria valor em
update parcial) — campos tornados obrigatórios; User.reseller_id/
can_approve_discount faltavam em types.ts apesar das colunas já
existirem no banco desde migrations anteriores.

Verificado: pnpm turbo run typecheck build (28/28), pnpm test (15/15),
e todo o fluxo testado ao vivo via curl contra a stack Docker real,
incluindo cenários de erro (transição de status inválida, FK de produto
inexistente, fechamento duplo, 401 sem token) e trilha de auditoria
conferida no Postgres.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 12:25:49 -03:00
parent e8014b4135
commit 18838a9031
36 changed files with 3199 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
/**
* Commercial bounded context: quotes (ofertas) — the pricing/fidelity/
* approval core of the legacy system, eden.md module 2. Ported with exact
* fidelity to the formulas documented there (see apps/api/src/quotes/
* pricing.ts for the calculation engine itself).
*
* `client_registration_id` is added as a plain nullable UUID WITHOUT a
* foreign key — the Customer 360 module (client_registrations table) is
* being built in parallel by another agent and doesn't exist yet in this
* migration's context. The FK is added in a follow-up migration once both
* land (this mirrors the legacy's own history: client_registration_id was
* itself added to quotes in a later migration, 1785513008756).
*
* `users.can_approve_discount` is added here too (small, single-column
* change) since it's exclusively needed by the discount-approval rule
* this migration's quotes table exists to support — eden.md §1.1:
* `canApproveDiscount(user) = super_admin || (admin && can_approve_discount)`.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
ALTER TABLE users ADD COLUMN can_approve_discount BOOLEAN NOT NULL DEFAULT false;
COMMENT ON COLUMN users.can_approve_discount IS
'Business flag (not a role) — an admin can approve a quote''s discount/reduced-fidelity exception only if this is true; super_admin always can, regardless of this flag. eden.md §1.1, §3.3.';
CREATE SEQUENCE quotes_quote_number_seq START 1;
CREATE TABLE quotes (
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),
quote_number TEXT NOT NULL UNIQUE DEFAULT ('Q-' || to_char(now(), 'YYYY') || '-' || lpad(nextval('quotes_quote_number_seq')::text, 6, '0')),
reseller_id UUID NOT NULL REFERENCES resellers(id),
client_name TEXT NOT NULL,
client_company TEXT,
client_email TEXT,
client_phone TEXT NOT NULL,
contract_period INTEGER NOT NULL DEFAULT 0 CHECK (contract_period IN (0,12,24,36,48)),
fidelity_period INTEGER CHECK (fidelity_period IS NULL OR (fidelity_period >= 0 AND fidelity_period <= contract_period)),
items JSONB NOT NULL DEFAULT '[]'::jsonb,
implementation_fee NUMERIC(12,2) NOT NULL DEFAULT 0,
impl_geral NUMERIC(12,2) NOT NULL DEFAULT 0,
monthly_total NUMERIC(12,2) NOT NULL DEFAULT 0,
contract_total NUMERIC(12,2) NOT NULL DEFAULT 0,
proposed_monthly_total NUMERIC(12,2),
approval_status TEXT NOT NULL DEFAULT 'none' CHECK (approval_status IN ('none','pending','approved','rejected')),
approval_notes TEXT,
approved_by_id UUID REFERENCES users(id),
notes TEXT,
deal_status TEXT NOT NULL DEFAULT 'orcamento' CHECK (deal_status IN ('orcamento','fechado','perdido')),
impl_payment_condition TEXT NOT NULL DEFAULT 'À vista' CHECK (impl_payment_condition IN ('À vista','1+1','1+2')),
client_registration_id UUID
);
COMMENT ON TABLE quotes IS
'Ofertas comerciais — eden.md módulo 2. contract_period é a faixa de preço; fidelity_period é a permanência efetiva quando MENOR que a faixa, só produz efeito legal (vigência/multa) depois de approval_status=approved (eden.md §3.4). client_registration_id referencia client_registrations (Customer 360) sem FK por enquanto — ver comentário no topo desta migration.';
COMMENT ON COLUMN quotes.client_registration_id IS
'Setar este campo trava a oferta para edição (exceto super_admin) — eden.md §5.1. FK a ser adicionada em migration futura quando client_registrations existir.';
COMMENT ON COLUMN quotes.impl_payment_condition IS
'À vista / 1+1 / 1+2 — progressivo por faixa de valor de implantação, eden.md §7 (paymentConditions.js). Faixas validadas na aplicação, não aqui.';
CREATE TRIGGER trg_quotes_updated_at BEFORE UPDATE ON quotes
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_quotes_reseller ON quotes(reseller_id);
CREATE INDEX idx_quotes_deal_status ON quotes(deal_status);
CREATE INDEX idx_quotes_created_by ON quotes(created_by);
CREATE INDEX idx_quotes_client_registration ON quotes(client_registration_id);
ALTER SEQUENCE quotes_quote_number_seq OWNED BY NONE;
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS quotes;
DROP SEQUENCE IF EXISTS quotes_quote_number_seq;
ALTER TABLE users DROP COLUMN IF EXISTS can_approve_discount;
`);
};

View File

@@ -0,0 +1,128 @@
/**
* Customer 360 / Client Registration — eden.md §3 §1.1-1.2. Authenticated-
* only MVP (Master Prompt §6.5): deliberately out of scope for this round
* — the public unauthenticated token-based self-service form
* (`GET/POST /public-client-registration/:token`), file attachment
* upload/storage, CNPJ/CEP external lookup integration, the "official"
* `cnpj_address_*` Receita Federal address block, and the
* `*_externally_verified`/`*_verification_*` columns (only the format-
* check booleans `cpf_format_valid`/`cnpj_format_valid` are kept). Those
* all land in a later phase.
*
* `quote_id` is a plain nullable UUID WITHOUT a foreign key — the `quotes`
* table was built in a parallel migration; the FK linking the two is
* added in a follow-up migration once both are confirmed present (mirrors
* the legacy's own history: client_registration_id was itself added to
* quotes in a later migration, 1785513008756).
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
CREATE SEQUENCE client_registrations_client_code_seq START 1;
CREATE TABLE client_registrations (
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),
quote_id UUID,
reseller_id UUID REFERENCES resellers(id),
token TEXT NOT NULL UNIQUE,
token_created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
client_code INTEGER NOT NULL UNIQUE DEFAULT nextval('client_registrations_client_code_seq'),
is_portability BOOLEAN NOT NULL DEFAULT false,
person_type TEXT NOT NULL CHECK (person_type IN ('pf','pj')),
registration_status TEXT NOT NULL DEFAULT 'rascunho' CHECK (registration_status IN ('rascunho','pendente_validacao','ativo','bloqueado','inativo')),
submitted_at TIMESTAMPTZ,
activated_at TIMESTAMPTZ,
internal_notes TEXT,
address_zip TEXT,
address_street TEXT,
address_number TEXT,
address_complement TEXT,
address_neighborhood TEXT,
address_city TEXT,
address_state TEXT,
address_country TEXT NOT NULL DEFAULT 'Brasil',
contact_principal_name TEXT,
contact_principal_email TEXT,
contact_principal_phone TEXT,
contact_financial_name TEXT,
contact_financial_email TEXT,
contact_financial_phone TEXT,
contact_technical_name TEXT,
contact_technical_email TEXT,
contact_technical_phone TEXT,
pf_full_name TEXT,
pf_cpf TEXT,
pf_birth_date DATE,
pf_email TEXT,
pf_mobile_phone TEXT,
pf_alt_phone TEXT,
pf_social_name TEXT,
pf_id_document TEXT,
pf_id_issuer TEXT,
pj_cnpj TEXT,
pj_company_name TEXT,
pj_trade_name TEXT,
pj_state_registration TEXT,
pj_municipal_registration TEXT,
pj_opening_date DATE,
pj_legal_nature TEXT,
pj_cnpj_situation TEXT,
pj_main_activity TEXT,
pj_rep_full_name TEXT,
pj_rep_cpf TEXT,
pj_rep_role TEXT,
pj_rep_email TEXT,
pj_rep_mobile_phone TEXT,
pj_rep_authorization_ack BOOLEAN NOT NULL DEFAULT false,
cpf_format_valid BOOLEAN,
cnpj_format_valid BOOLEAN,
portability_numbers JSONB NOT NULL DEFAULT '[]'::jsonb,
portability_ranges JSONB NOT NULL DEFAULT '[]'::jsonb,
ixc_client_id TEXT,
ixc_contract_number TEXT
);
COMMENT ON TABLE client_registrations IS
'Customer 360 / cadastro de cliente PF/PJ — eden.md §3. MVP autenticado apenas (sem fluxo público por token, sem anexos, sem lookup externo de CNPJ/CEP) — ver comentário no topo desta migration.';
COMMENT ON COLUMN client_registrations.quote_id IS
'Referencia quotes(id) sem FK por enquanto — FK a ser adicionada em migration futura.';
COMMENT ON COLUMN client_registrations.pf_cpf IS
'Imutável fora do fluxo de submissão original — nunca exposto em rota de edição administrativa (eden.md §1.1.2). Mesma regra para pj_cnpj e pj_rep_cpf.';
CREATE TRIGGER trg_client_registrations_updated_at BEFORE UPDATE ON client_registrations
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_client_registrations_status ON client_registrations(registration_status);
CREATE INDEX idx_client_registrations_reseller ON client_registrations(reseller_id);
CREATE INDEX idx_client_registrations_created_by ON client_registrations(created_by);
CREATE TABLE client_registration_partners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
client_registration_id UUID NOT NULL REFERENCES client_registrations(id) ON DELETE CASCADE,
name TEXT NOT NULL,
document TEXT,
qualification TEXT,
source TEXT NOT NULL DEFAULT 'manual',
cpf TEXT,
will_sign BOOLEAN NOT NULL DEFAULT false,
signature_email TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE client_registration_partners IS
'Sócios/QSA — eden.md §1.2, §4. source default "manual" (não "brasilapi" como no legado) — sem integração de lookup de CNPJ nesta fase.';
CREATE INDEX idx_client_registration_partners_registration ON client_registration_partners(client_registration_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS client_registration_partners;
DROP TABLE IF EXISTS client_registrations;
DROP SEQUENCE IF EXISTS client_registrations_client_code_seq;
`);
};

View File

@@ -0,0 +1,168 @@
/**
* Commercial bounded context: `contracts` as a first-class aggregate
* (docs/adr/0006-contract-first-class.md, Master Prompt §6.6). In the
* legacy OrçaFácil there is no `contracts` table at all — a "contrato" is
* a query-time join of `client_registrations` (active) with the `quotes`
* that originated it (eden.md §1.4, §10). ADR-0006 lists 11 related
* tables; this migration builds only the 4 that don't depend on a module
* that doesn't exist yet in this worktree:
*
* - contracts (this migration)
* - contract_items (this migration)
* - contract_parties (this migration)
* - contract_status_history (this migration)
*
* Explicitly DEFERRED, not forgotten — each needs a module that hasn't
* landed yet:
* - contract_versions — snapshot-on-signature (ADR-0006 invariant
* nº4); needs the signature/versioning workflow design to land with
* it, not just a bare table.
* - contract_documents — needs the Documents/e-signature module
* (Master Prompt §6.7, eden.md §4 "Documentos e Assinatura").
* - contract_amendments — needs contract_versions first (an amendment
* is a new version).
* - contract_renewals — needs the "renewed" terminal state's
* successor contract to point somewhere; the state machine below
* already reserves 'renewed' as a target but models no transition
* into/out of it yet.
* - contract_assets — needs Inventory/Warehouse (Master Prompt §6.8).
* - contract_services — needs the recurring service/billing-cycle
* model, not just line items.
* - contract_billing_rules — needs the Billing module.
*
* `contracts.quote_id` and `contracts.client_registration_id` are
* deliberately nullable with NO foreign key: `quotes` (Commercial/Ofertas)
* and `client_registrations` (Customer 360) are being built in parallel by
* other agents and don't exist in this worktree yet. Wiring those FKs is
* for the merge/integration step, not this migration.
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
-- =====================================================================
-- contracts — the core aggregate
-- =====================================================================
CREATE SEQUENCE contracts_contract_number_seq START 1;
CREATE TABLE contracts (
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),
contract_number TEXT UNIQUE NOT NULL,
quote_id UUID,
client_registration_id UUID,
reseller_id UUID REFERENCES resellers(id),
company_id UUID REFERENCES companies(id),
status TEXT NOT NULL DEFAULT 'draft'
CHECK (status IN (
'draft', 'pending_signature', 'active', 'suspended',
'cancelled', 'terminated', 'expired', 'renewed'
)),
contract_period INTEGER NOT NULL DEFAULT 0 CHECK (contract_period IN (0, 12, 24, 36, 48)),
fidelity_period INTEGER CHECK (fidelity_period IS NULL OR (fidelity_period >= 0 AND fidelity_period <= contract_period)),
fidelity_approved BOOLEAN NOT NULL DEFAULT false,
monthly_value NUMERIC(12,2),
contract_total NUMERIC(12,2),
implementation_fee NUMERIC(12,2) NOT NULL DEFAULT 0,
signed_at TIMESTAMPTZ,
activated_at TIMESTAMPTZ,
notes TEXT
);
COMMENT ON TABLE contracts IS
'Contrato como agregado de primeira classe — docs/adr/0006-contract-first-class.md, Master Prompt §6.6. Substitui a junção calculada client_registrations+quotes do legado (eden.md §10.1). quote_id/client_registration_id são nullable, sem FK: quotes e client_registrations estão sendo construídos em paralelo por outros agentes e ainda não existem neste worktree — a FK é trabalho de integração, não desta migration. contract_number é gerado na camada de repositório (packages/database/src/repositories/contracts.ts) a partir de contracts_contract_number_seq, formato CTR-{ano}-{sequência de 6 dígitos}, mesmo padrão de products.product_code.';
COMMENT ON COLUMN contracts.contract_period IS
'Faixa de preço comercial contratada (qual price_N do produto foi usado) — DISTINTA de fidelity_period, a permanência efetivamente assinada. Nunca usar contract_period para calcular vigência/vencimento/multa quando houver uma fidelidade reduzida aprovada (ver comentário de fidelity_approved).';
COMMENT ON COLUMN contracts.fidelity_period IS
'Prazo de permanência efetivamente negociado, quando menor que contract_period. NULL = sem exceção (fidelidade = contract_period, caso padrão). Só tem efeito jurídico se fidelity_approved = true (ver fidelity_approved).';
COMMENT ON COLUMN contracts.fidelity_approved IS
'INVARIANTE CRÍTICA, herdada do legado e que NÃO desaparece com contrato virando agregado de primeira classe — ela apenas migra de quotes.approval_status = ''approved'' (eden.md §10.2, §10.4) para este campo. Regra exata do legado, citada literalmente (eden.md §10.4):
fidelidadeEfetiva = (quote.fidelity_period != null && quote.approval_status === ''approved'')
? quote.fidelity_period
: (quote.contract_period || 0)
Ou seja: fidelity_period só tem efeito legal (cálculo de vigência/vencimento/multa) quando fidelity_approved = true; caso contrário, trate a fidelidade como igual a contract_period. contract_period continua sendo usado apenas para (a) qual price_N foi aplicado e (b) contract_total (eden.md §8, §10.4) — NUNCA para vencimento/multa quando há uma exceção de fidelidade já aprovada. A função pura resolveEffectiveFidelity() em packages/database/src/repositories/contracts.ts implementa exatamente esta regra e deve ser reusada por qualquer cálculo futuro de vigência/vencimento/multa (billing, relatórios) — nunca reimplementada ad-hoc.';
COMMENT ON COLUMN contracts.activated_at IS
'Quando o status se tornou ''active'' pela primeira vez — nunca sobrescrito depois (semântica COALESCE(activated_at, now()) na camada de repositório, mesmo padrão do client_registrations.activated_at do legado, eden.md §10.2). Base para o cálculo de vigência/vencimento junto com a fidelidade resolvida (resolveEffectiveFidelity).';
COMMENT ON COLUMN contracts.signed_at IS
'Quando o contrato foi de fato assinado — distinto de created_at (quando o rascunho foi criado). Setado pela camada de repositório ao transicionar de pending_signature para active, se ainda não setado.';
CREATE TRIGGER trg_contracts_updated_at BEFORE UPDATE ON contracts
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_contracts_status ON contracts(status);
CREATE INDEX idx_contracts_reseller ON contracts(reseller_id);
ALTER SEQUENCE contracts_contract_number_seq OWNED BY contracts.contract_number;
-- =====================================================================
-- contract_items — line items, real/queryable (not a JSONB snapshot
-- like the legacy's quotes.items, eden.md §1.3)
-- =====================================================================
CREATE TABLE contract_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
product_id UUID REFERENCES products(id),
product_name TEXT NOT NULL,
quantity NUMERIC(12,2) NOT NULL DEFAULT 1,
unit_price NUMERIC(12,2) NOT NULL,
total NUMERIC(12,2) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE contract_items IS
'Itens do contrato — versão real/consultável do que seria o JSONB items de uma oferta uma vez vinculada (eden.md §1.3), por ADR-0006. product_id é nullable e product_name é sempre um snapshot: um contrato nunca pode mudar retroativamente porque um produto foi editado ou removido depois (invariante nº4 do Master Prompt §24, ADR-0006).';
CREATE INDEX idx_contract_items_contract ON contract_items(contract_id);
-- =====================================================================
-- contract_parties — legal parties, snapshotted at creation time
-- =====================================================================
CREATE TABLE contract_parties (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES contracts(id) ON DELETE CASCADE,
party_role TEXT NOT NULL CHECK (party_role IN ('contratada', 'contratante')),
person_type TEXT CHECK (person_type IS NULL OR person_type IN ('pf', 'pj')),
full_name TEXT NOT NULL,
document TEXT,
email TEXT,
phone TEXT,
role_title TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE contract_parties IS
'Partes do contrato (contratada = empresa emissora, contratante = cliente) — SEMPRE um snapshot tirado no momento da criação do contrato, nunca uma junção viva que poderia mudar se o registro de origem (company/client_registration) mudar depois. Mesma filosofia de snapshot de signature_signers no motor de assinatura eletrônica do legado (eden.md §4), mesmo o módulo de assinatura em si ainda não existindo neste worktree.';
COMMENT ON COLUMN contract_parties.document IS
'CPF/CNPJ, snapshot em texto — sem validação de dígito verificador no banco, igual companies.cnpj e resellers.document. Validado (quando presente) na camada de aplicação.';
CREATE INDEX idx_contract_parties_contract ON contract_parties(contract_id);
-- =====================================================================
-- contract_status_history — append-only UX-facing lifecycle log,
-- distinct from (and complementary to) the generic audit_log
-- =====================================================================
CREATE TABLE contract_status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES contracts(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 contract_status_history IS
'Log de transições de status do contrato — append-only por convenção da CAMADA DE APLICAÇÃO (nenhuma rota de UPDATE/DELETE é exposta), não por trigger de banco como audit_log. Diferença deliberada: audit_log é a trilha de auditoria legal/hash-chained e cobre TODA ação crítica do sistema (inclui um evento contract.status_changed também, via auditRepo.appendAuditEvent); esta tabela é a versão estruturada, específica deste agregado, pensada para a UI mostrar o histórico de vida do contrato sem precisar parsear metadata genérico do audit_log. As duas tabelas coexistem deliberadamente — não são redundantes.';
CREATE INDEX idx_contract_status_history_contract ON contract_status_history(contract_id);
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS contract_status_history;
DROP TABLE IF EXISTS contract_parties;
DROP TABLE IF EXISTS contract_items;
DROP TABLE IF EXISTS contracts;
DROP SEQUENCE IF EXISTS contracts_contract_number_seq;
`);
};