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