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

View File

@@ -6,6 +6,10 @@ export * as auditRepo from "./repositories/audit.js";
export * as companiesRepo from "./repositories/companies.js";
export * as productsRepo from "./repositories/products.js";
export * as resellersRepo from "./repositories/resellers.js";
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 {
listLeads,
getLead,
@@ -37,3 +41,36 @@ export type {
CreateOpportunityInput,
UpdateOpportunityInput,
} from "./repositories/opportunities.js";
export type {
Quote,
QuoteItem,
ListQuotesFilter,
CreateQuoteInput,
UpdateQuoteInput,
} from "./repositories/quotes.js";
export type {
Contract,
ContractItem,
ContractParty,
ContractStatus,
ContractStatusHistoryEntry,
ContractWithDetails,
ListContractsFilter,
CreateContractInput,
CreateContractItemInput,
CreateContractPartyInput,
} from "./repositories/contracts.js";
export type {
ClientRegistration,
RegistrationStatus,
PersonType,
PortabilityRange,
ListClientRegistrationsFilter,
CreateClientRegistrationInput,
UpdateClientRegistrationProfileInput,
} from "./repositories/client-registrations.js";
export type {
ClientRegistrationPartner,
CreatePartnerInput,
UpdatePartnerInput,
} from "./repositories/client-registration-partners.js";

View File

@@ -0,0 +1,97 @@
import { query } from "../pool.js";
/**
* `client_registration_partners` — sócios/QSA (eden.md §1.2, §4). In the
* legacy this table is populated from a public CNPJ lookup (cnpj.ws/
* BrasilAPI); that integration is out of scope for this MVP (see the
* migration comment — `source` defaults to `'manual'` here instead of the
* legacy's `'brasilapi'`, since nothing here comes from an external
* lookup). Only the backoffice-correction slice of the legacy flow
* (eden.md §2.5: correcting cpf/will_sign/signature_email after
* submission) is implemented — partners must be entered manually in this
* round.
*/
export interface ClientRegistrationPartner {
id: string;
client_registration_id: string;
name: string;
document: string | null;
qualification: string | null;
source: string;
cpf: string | null;
will_sign: boolean;
signature_email: string | null;
created_at: Date;
}
export async function listPartnersForRegistration(
registrationId: string,
): Promise<ClientRegistrationPartner[]> {
const { rows } = await query<ClientRegistrationPartner>(
"SELECT * FROM client_registration_partners WHERE client_registration_id = $1 ORDER BY created_at ASC",
[registrationId],
);
return rows;
}
export async function getPartnerById(id: string): Promise<ClientRegistrationPartner | null> {
const { rows } = await query<ClientRegistrationPartner>(
"SELECT * FROM client_registration_partners WHERE id = $1",
[id],
);
return rows[0] ?? null;
}
export interface CreatePartnerInput {
clientRegistrationId: string;
name: string;
document?: string | null;
qualification?: string | null;
cpf?: string | null;
willSign?: boolean;
signatureEmail?: string | null;
}
export async function createPartner(input: CreatePartnerInput): Promise<ClientRegistrationPartner> {
const { rows } = await query<ClientRegistrationPartner>(
`INSERT INTO client_registration_partners
(client_registration_id, name, document, qualification, cpf, will_sign, signature_email)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, false), $7)
RETURNING *`,
[
input.clientRegistrationId,
input.name,
input.document ?? null,
input.qualification ?? null,
input.cpf ?? null,
input.willSign ?? null,
input.signatureEmail ?? null,
],
);
const partner = rows[0];
if (!partner) throw new Error("failed to create client registration partner");
return partner;
}
/** eden.md §2.5: backoffice can correct only cpf/will_sign/signature_email post-submission. */
export interface UpdatePartnerInput {
cpf?: string | null;
willSign?: boolean;
signatureEmail?: string | null;
}
export async function updatePartner(
id: string,
input: UpdatePartnerInput,
): Promise<ClientRegistrationPartner | null> {
const { rows } = await query<ClientRegistrationPartner>(
`UPDATE client_registration_partners SET
cpf = COALESCE($2, cpf),
will_sign = COALESCE($3, will_sign),
signature_email = COALESCE($4, signature_email)
WHERE id = $1
RETURNING *`,
[id, input.cpf ?? null, input.willSign ?? null, input.signatureEmail ?? null],
);
return rows[0] ?? null;
}

View File

@@ -0,0 +1,341 @@
import { randomBytes } from "node:crypto";
import { query } from "../pool.js";
export type RegistrationStatus = "rascunho" | "pendente_validacao" | "ativo" | "bloqueado" | "inativo";
export type PersonType = "pf" | "pj";
export interface PortabilityRange {
first: string;
last: string;
}
/**
* Customer 360 / Client Registration — eden.md §3 §1.1. Authenticated-only
* MVP (Master Prompt §6.5, docs/adr/0004-permission-model.md): 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.
*/
export interface ClientRegistration {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
quote_id: string | null;
reseller_id: string | null;
token: string;
token_created_at: Date;
client_code: number;
is_portability: boolean;
person_type: PersonType;
registration_status: RegistrationStatus;
submitted_at: Date | null;
activated_at: Date | null;
internal_notes: string | null;
address_zip: string | null;
address_street: string | null;
address_number: string | null;
address_complement: string | null;
address_neighborhood: string | null;
address_city: string | null;
address_state: string | null;
address_country: string;
contact_principal_name: string | null;
contact_principal_email: string | null;
contact_principal_phone: string | null;
contact_financial_name: string | null;
contact_financial_email: string | null;
contact_financial_phone: string | null;
contact_technical_name: string | null;
contact_technical_email: string | null;
contact_technical_phone: string | null;
pf_full_name: string | null;
pf_cpf: string | null;
pf_birth_date: Date | null;
pf_email: string | null;
pf_mobile_phone: string | null;
pf_alt_phone: string | null;
pf_social_name: string | null;
pf_id_document: string | null;
pf_id_issuer: string | null;
pj_cnpj: string | null;
pj_company_name: string | null;
pj_trade_name: string | null;
pj_state_registration: string | null;
pj_municipal_registration: string | null;
pj_opening_date: Date | null;
pj_legal_nature: string | null;
pj_cnpj_situation: string | null;
pj_main_activity: string | null;
pj_rep_full_name: string | null;
pj_rep_cpf: string | null;
pj_rep_role: string | null;
pj_rep_email: string | null;
pj_rep_mobile_phone: string | null;
pj_rep_authorization_ack: boolean;
cpf_format_valid: boolean | null;
cnpj_format_valid: boolean | null;
portability_numbers: string[];
portability_ranges: PortabilityRange[];
ixc_client_id: string | null;
ixc_contract_number: string | null;
}
export interface ListClientRegistrationsFilter {
scope: "all" | "own" | "reseller";
actorId?: string;
resellerId?: string;
}
/**
* The *caller* (service layer) resolves which scope applies to the actor
* (own authorship vs. their reseller vs. unrestricted) from their granted
* permission scope (ADR-0004) — this function just applies the already-
* resolved filter. See eden.md §7.1 for the two-tier visibility this
* generalizes (`Clientes.jsx` full view by authorship/`clientes_todos`
* vs. `ClientesRevenda.jsx` directory scoped by reseller).
*/
export async function listClientRegistrations(
filter: ListClientRegistrationsFilter,
): Promise<ClientRegistration[]> {
if (filter.scope === "own") {
const { rows } = await query<ClientRegistration>(
"SELECT * FROM client_registrations WHERE created_by = $1 ORDER BY created_at DESC",
[filter.actorId ?? null],
);
return rows;
}
if (filter.scope === "reseller") {
const { rows } = await query<ClientRegistration>(
"SELECT * FROM client_registrations WHERE reseller_id = $1 ORDER BY created_at DESC",
[filter.resellerId ?? null],
);
return rows;
}
const { rows } = await query<ClientRegistration>(
"SELECT * FROM client_registrations ORDER BY created_at DESC",
);
return rows;
}
export async function getClientRegistrationById(id: string): Promise<ClientRegistration | null> {
const { rows } = await query<ClientRegistration>(
"SELECT * FROM client_registrations WHERE id = $1",
[id],
);
return rows[0] ?? null;
}
export interface CreateClientRegistrationInput {
personType: PersonType;
isPortability: boolean;
resellerId?: string | null;
createdBy?: string | null;
}
/**
* Cadastro "direto" (eden.md §2.6) — the only creation path in this MVP
* (no `quote_id`, since `quotes` isn't modeled in this worktree yet — see
* the module comment in the migration). The token is generated here,
* server-side, with a CSPRNG — never accepted from the client (eden.md §9:
* `crypto.randomBytes(24).toString('hex')`, mirrored from
* sessions.ts/token.service.ts's opaque-token pattern).
*/
export async function createClientRegistration(
input: CreateClientRegistrationInput,
): Promise<ClientRegistration> {
const token = randomBytes(24).toString("hex");
const { rows } = await query<ClientRegistration>(
`INSERT INTO client_registrations (person_type, is_portability, reseller_id, token, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[input.personType, input.isPortability, input.resellerId ?? null, token, input.createdBy ?? null],
);
const registration = rows[0];
if (!registration) throw new Error("failed to create client registration");
return registration;
}
/**
* Sole entry point for changing `registration_status` — no dedicated
* transition endpoint/legality guard in this MVP (eden.md §1.1.1: "não há
* endpoint dedicado de transição", legacy also just does a generic PATCH).
* Replicates the one real side effect the legacy has: `activated_at` is
* stamped the *first* time the status becomes `ativo` (`COALESCE`, never
* overwritten on a later reactivation).
*/
export async function updateClientRegistrationStatus(
id: string,
status: RegistrationStatus,
): Promise<ClientRegistration | null> {
const { rows } = await query<ClientRegistration>(
`UPDATE client_registrations SET
registration_status = $2,
activated_at = CASE WHEN $2 = 'ativo' THEN COALESCE(activated_at, now()) ELSE activated_at END
WHERE id = $1
RETURNING *`,
[id, status],
);
return rows[0] ?? null;
}
/**
* Fields administratively editable per eden.md §1.1.2 — and only those.
* `pf_cpf`, `pj_cnpj`, and `pj_rep_cpf` are deliberately absent from this
* interface entirely (not merely skipped at runtime) so the compiler
* enforces the "CPF/CNPJ never editable via administrative PATCH" rule.
* `registration_status` has its own function above; `quote_id`,
* `reseller_id`, `token`, `client_code`, `person_type`, `is_portability`,
* `submitted_at`, `created_by` aren't in the legacy's editable-fields list
* either.
*/
export interface UpdateClientRegistrationProfileInput {
internalNotes?: string | null;
ixcClientId?: string | null;
ixcContractNumber?: string | null;
addressZip?: string | null;
addressStreet?: string | null;
addressNumber?: string | null;
addressComplement?: string | null;
addressNeighborhood?: string | null;
addressCity?: string | null;
addressState?: string | null;
addressCountry?: string | null;
contactPrincipalName?: string | null;
contactPrincipalEmail?: string | null;
contactPrincipalPhone?: string | null;
contactFinancialName?: string | null;
contactFinancialEmail?: string | null;
contactFinancialPhone?: string | null;
contactTechnicalName?: string | null;
contactTechnicalEmail?: string | null;
contactTechnicalPhone?: string | null;
pfFullName?: string | null;
pfBirthDate?: string | null;
pfEmail?: string | null;
pfMobilePhone?: string | null;
pfAltPhone?: string | null;
pfSocialName?: string | null;
pfIdDocument?: string | null;
pfIdIssuer?: string | null;
pjCompanyName?: string | null;
pjTradeName?: string | null;
pjStateRegistration?: string | null;
pjMunicipalRegistration?: string | null;
pjOpeningDate?: string | null;
pjLegalNature?: string | null;
pjCnpjSituation?: string | null;
pjMainActivity?: string | null;
pjRepFullName?: string | null;
pjRepRole?: string | null;
pjRepEmail?: string | null;
pjRepMobilePhone?: string | null;
pjRepAuthorizationAck?: boolean;
portabilityNumbers?: string[];
portabilityRanges?: PortabilityRange[];
}
export async function updateClientRegistrationProfile(
id: string,
input: UpdateClientRegistrationProfileInput,
): Promise<ClientRegistration | null> {
const { rows } = await query<ClientRegistration>(
`UPDATE client_registrations SET
internal_notes = COALESCE($2, internal_notes),
ixc_client_id = COALESCE($3, ixc_client_id),
ixc_contract_number = COALESCE($4, ixc_contract_number),
address_zip = COALESCE($5, address_zip),
address_street = COALESCE($6, address_street),
address_number = COALESCE($7, address_number),
address_complement = COALESCE($8, address_complement),
address_neighborhood = COALESCE($9, address_neighborhood),
address_city = COALESCE($10, address_city),
address_state = COALESCE($11, address_state),
address_country = COALESCE($12, address_country),
contact_principal_name = COALESCE($13, contact_principal_name),
contact_principal_email = COALESCE($14, contact_principal_email),
contact_principal_phone = COALESCE($15, contact_principal_phone),
contact_financial_name = COALESCE($16, contact_financial_name),
contact_financial_email = COALESCE($17, contact_financial_email),
contact_financial_phone = COALESCE($18, contact_financial_phone),
contact_technical_name = COALESCE($19, contact_technical_name),
contact_technical_email = COALESCE($20, contact_technical_email),
contact_technical_phone = COALESCE($21, contact_technical_phone),
pf_full_name = COALESCE($22, pf_full_name),
pf_birth_date = COALESCE($23::date, pf_birth_date),
pf_email = COALESCE($24, pf_email),
pf_mobile_phone = COALESCE($25, pf_mobile_phone),
pf_alt_phone = COALESCE($26, pf_alt_phone),
pf_social_name = COALESCE($27, pf_social_name),
pf_id_document = COALESCE($28, pf_id_document),
pf_id_issuer = COALESCE($29, pf_id_issuer),
pj_company_name = COALESCE($30, pj_company_name),
pj_trade_name = COALESCE($31, pj_trade_name),
pj_state_registration = COALESCE($32, pj_state_registration),
pj_municipal_registration = COALESCE($33, pj_municipal_registration),
pj_opening_date = COALESCE($34::date, pj_opening_date),
pj_legal_nature = COALESCE($35, pj_legal_nature),
pj_cnpj_situation = COALESCE($36, pj_cnpj_situation),
pj_main_activity = COALESCE($37, pj_main_activity),
pj_rep_full_name = COALESCE($38, pj_rep_full_name),
pj_rep_role = COALESCE($39, pj_rep_role),
pj_rep_email = COALESCE($40, pj_rep_email),
pj_rep_mobile_phone = COALESCE($41, pj_rep_mobile_phone),
pj_rep_authorization_ack = COALESCE($42, pj_rep_authorization_ack),
portability_numbers = COALESCE($43::jsonb, portability_numbers),
portability_ranges = COALESCE($44::jsonb, portability_ranges)
WHERE id = $1
RETURNING *`,
[
id,
input.internalNotes ?? null,
input.ixcClientId ?? null,
input.ixcContractNumber ?? null,
input.addressZip ?? null,
input.addressStreet ?? null,
input.addressNumber ?? null,
input.addressComplement ?? null,
input.addressNeighborhood ?? null,
input.addressCity ?? null,
input.addressState ?? null,
input.addressCountry ?? null,
input.contactPrincipalName ?? null,
input.contactPrincipalEmail ?? null,
input.contactPrincipalPhone ?? null,
input.contactFinancialName ?? null,
input.contactFinancialEmail ?? null,
input.contactFinancialPhone ?? null,
input.contactTechnicalName ?? null,
input.contactTechnicalEmail ?? null,
input.contactTechnicalPhone ?? null,
input.pfFullName ?? null,
input.pfBirthDate ?? null,
input.pfEmail ?? null,
input.pfMobilePhone ?? null,
input.pfAltPhone ?? null,
input.pfSocialName ?? null,
input.pfIdDocument ?? null,
input.pfIdIssuer ?? null,
input.pjCompanyName ?? null,
input.pjTradeName ?? null,
input.pjStateRegistration ?? null,
input.pjMunicipalRegistration ?? null,
input.pjOpeningDate ?? null,
input.pjLegalNature ?? null,
input.pjCnpjSituation ?? null,
input.pjMainActivity ?? null,
input.pjRepFullName ?? null,
input.pjRepRole ?? null,
input.pjRepEmail ?? null,
input.pjRepMobilePhone ?? null,
input.pjRepAuthorizationAck ?? null,
input.portabilityNumbers ? JSON.stringify(input.portabilityNumbers) : null,
input.portabilityRanges ? JSON.stringify(input.portabilityRanges) : null,
],
);
return rows[0] ?? null;
}

View File

@@ -0,0 +1,437 @@
import { query, withTransaction } from "../pool.js";
/**
* Commercial bounded context — Contracts as a first-class aggregate
* (docs/adr/0006-contract-first-class.md, Master Prompt §6.6). See the
* migration's header comment for the full list of ADR-0006 tables this
* MVP builds (contracts, contract_items, contract_parties,
* contract_status_history) vs. explicitly deferred ones.
*
* Mirrors the conventions of companies.ts/resellers.ts (COALESCE-based
* partial writes, snake_case rows) and leads.ts (withTransaction for
* multi-row writes that must commit/rollback together).
*/
export type ContractStatus =
| "draft"
| "pending_signature"
| "active"
| "suspended"
| "cancelled"
| "terminated"
| "expired"
| "renewed";
export interface Contract {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
contract_number: string;
quote_id: string | null;
client_registration_id: string | null;
reseller_id: string | null;
company_id: string | null;
status: ContractStatus;
contract_period: number;
fidelity_period: number | null;
fidelity_approved: boolean;
monthly_value: string | null;
contract_total: string | null;
implementation_fee: string;
signed_at: Date | null;
activated_at: Date | null;
notes: string | null;
}
export interface ContractItem {
id: string;
contract_id: string;
product_id: string | null;
product_name: string;
quantity: string;
unit_price: string;
total: string;
sort_order: number;
created_at: Date;
}
export interface ContractParty {
id: string;
contract_id: string;
party_role: "contratada" | "contratante";
person_type: "pf" | "pj" | null;
full_name: string;
document: string | null;
email: string | null;
phone: string | null;
role_title: string | null;
created_at: Date;
}
export interface ContractStatusHistoryEntry {
id: string;
contract_id: string;
from_status: ContractStatus | null;
to_status: ContractStatus;
reason: string | null;
changed_by: string | null;
changed_at: Date;
}
export interface ContractWithDetails extends Contract {
items: ContractItem[];
parties: ContractParty[];
statusHistory: ContractStatusHistoryEntry[];
}
/**
* The eden.md §10.4 fidelity-resolution rule, ported verbatim from the
* legacy (where it read `quote.fidelity_period`/`quote.approval_status`)
* to the first-class contract's own columns. This rule doesn't disappear
* just because Contract is now a real table — it moves here:
*
* fidelidadeEfetiva = (fidelity_period != null && fidelity_approved)
* ? fidelity_period
* : (contract_period || 0)
*
* Pure, no DB access — reused by any future vigência/vencimento/multa
* calculation (billing, reports). Never reimplement this ad-hoc.
*/
export function resolveEffectiveFidelity(
contractPeriod: number,
fidelityPeriod: number | null,
fidelityApproved: boolean,
): number {
return fidelityApproved && fidelityPeriod != null ? fidelityPeriod : contractPeriod;
}
/**
* The MVP contract lifecycle state machine (docs/adr/0006, Master Prompt
* §6.6). `cancelled`/`terminated`/`expired`/`renewed` are terminal here —
* no `renewed -> ...` transition is modeled yet, that needs the
* not-yet-built contract_renewals table (see migration header comment).
*/
export const CONTRACT_STATUS_TRANSITIONS: Record<ContractStatus, ContractStatus[]> = {
draft: ["pending_signature", "cancelled"],
pending_signature: ["active", "cancelled"],
active: ["suspended", "cancelled", "terminated", "expired"],
suspended: ["active", "cancelled", "terminated"],
cancelled: [],
terminated: [],
expired: [],
renewed: [],
};
export class ContractNotFoundError extends Error {
constructor(public readonly contractId: string) {
super(`Contract ${contractId} not found`);
this.name = "ContractNotFoundError";
}
}
export class InvalidContractTransitionError extends Error {
constructor(
public readonly fromStatus: ContractStatus,
public readonly toStatus: ContractStatus,
) {
super(`Invalid contract status transition: '${fromStatus}' -> '${toStatus}'`);
this.name = "InvalidContractTransitionError";
}
}
export interface ListContractsFilter {
scope: "all" | "reseller";
/** Required (and enforced) when scope === 'reseller'. Ignored when scope === 'all'. */
resellerId?: string | null;
}
export async function listContracts(filter: ListContractsFilter): Promise<Contract[]> {
if (filter.scope === "reseller") {
// Deliberately never falls back to an unfiltered query: a caller with
// reseller-only scope and no resellerId (e.g. an actor not assigned to
// any reseller) sees nothing, not everything — deny-by-default.
const { rows } = await query<Contract>(
"SELECT * FROM contracts WHERE reseller_id = $1 ORDER BY created_at DESC",
[filter.resellerId ?? null],
);
return rows;
}
const { rows } = await query<Contract>("SELECT * FROM contracts ORDER BY created_at DESC");
return rows;
}
async function listContractItems(contractId: string): Promise<ContractItem[]> {
const { rows } = await query<ContractItem>(
"SELECT * FROM contract_items WHERE contract_id = $1 ORDER BY sort_order ASC, created_at ASC",
[contractId],
);
return rows;
}
async function listContractPartiesRows(contractId: string): Promise<ContractParty[]> {
const { rows } = await query<ContractParty>(
"SELECT * FROM contract_parties WHERE contract_id = $1 ORDER BY created_at ASC",
[contractId],
);
return rows;
}
export async function listContractStatusHistory(contractId: string): Promise<ContractStatusHistoryEntry[]> {
const { rows } = await query<ContractStatusHistoryEntry>(
"SELECT * FROM contract_status_history WHERE contract_id = $1 ORDER BY changed_at ASC",
[contractId],
);
return rows;
}
export async function getContractById(id: string): Promise<ContractWithDetails | null> {
const { rows } = await query<Contract>("SELECT * FROM contracts WHERE id = $1", [id]);
const contract = rows[0];
if (!contract) return null;
const [items, parties, statusHistory] = await Promise.all([
listContractItems(id),
listContractPartiesRows(id),
listContractStatusHistory(id),
]);
return { ...contract, items, parties, statusHistory };
}
export interface CreateContractItemInput {
productId?: string | null;
productName: string;
quantity: number;
unitPrice: number;
/** Never trust a client-supplied total — always server-recalculated by the caller (service layer) before reaching here. */
total: number;
sortOrder: number;
}
export interface CreateContractPartyInput {
partyRole: "contratada" | "contratante";
personType?: "pf" | "pj" | null;
fullName: string;
document?: string | null;
email?: string | null;
phone?: string | null;
roleTitle?: string | null;
}
export interface CreateContractInput {
quoteId?: string | null;
clientRegistrationId?: string | null;
resellerId?: string | null;
companyId?: string | null;
contractPeriod: number;
fidelityPeriod?: number | null;
fidelityApproved?: boolean;
/** Server-recalculated by the caller from `items` — never accepted raw from a client. */
monthlyValue: number;
/** Server-recalculated by the caller — never accepted raw from a client. */
contractTotal: number;
implementationFee?: number;
notes?: string | null;
createdBy?: string | null;
items: CreateContractItemInput[];
parties: CreateContractPartyInput[];
}
function formatContractNumber(seq: string | number): string {
const year = new Date().getFullYear();
return `CTR-${year}-${String(seq).padStart(6, "0")}`;
}
/**
* Creates the contract plus its items and parties in a single transaction
* (all-or-nothing) — mirrors leads.ts's convertLeadToOpportunity pattern.
* Status always starts 'draft'; contract_number is allocated here from
* contracts_contract_number_seq, same pattern as products.product_code.
*/
export async function createContract(input: CreateContractInput): Promise<ContractWithDetails> {
return withTransaction(async (client) => {
const { rows: seqRows } = await client.query<{ n: string }>(
"SELECT nextval('contracts_contract_number_seq') AS n",
);
const seq = seqRows[0]?.n;
if (seq === undefined) throw new Error("failed to allocate contract_number");
const contractNumber = formatContractNumber(seq);
const { rows } = await client.query<Contract>(
`INSERT INTO contracts (
contract_number, quote_id, client_registration_id, reseller_id, company_id,
contract_period, fidelity_period, fidelity_approved,
monthly_value, contract_total, implementation_fee, notes, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,COALESCE($8,false),$9,$10,COALESCE($11,0),$12,$13)
RETURNING *`,
[
contractNumber,
input.quoteId ?? null,
input.clientRegistrationId ?? null,
input.resellerId ?? null,
input.companyId ?? null,
input.contractPeriod,
input.fidelityPeriod ?? null,
input.fidelityApproved ?? null,
input.monthlyValue,
input.contractTotal,
input.implementationFee ?? null,
input.notes ?? null,
input.createdBy ?? null,
],
);
const contract = rows[0];
if (!contract) throw new Error("failed to create contract");
const items: ContractItem[] = [];
for (const item of input.items) {
const { rows: itemRows } = await client.query<ContractItem>(
`INSERT INTO contract_items (contract_id, product_id, product_name, quantity, unit_price, total, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING *`,
[
contract.id,
item.productId ?? null,
item.productName,
item.quantity,
item.unitPrice,
item.total,
item.sortOrder,
],
);
const row = itemRows[0];
if (!row) throw new Error("failed to create contract item");
items.push(row);
}
const parties: ContractParty[] = [];
for (const party of input.parties) {
const { rows: partyRows } = await client.query<ContractParty>(
`INSERT INTO contract_parties (contract_id, party_role, person_type, full_name, document, email, phone, role_title)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING *`,
[
contract.id,
party.partyRole,
party.personType ?? null,
party.fullName,
party.document ?? null,
party.email ?? null,
party.phone ?? null,
party.roleTitle ?? null,
],
);
const row = partyRows[0];
if (!row) throw new Error("failed to create contract party");
parties.push(row);
}
return { ...contract, items, parties, statusHistory: [] };
});
}
export async function addContractItem(
contractId: string,
input: CreateContractItemInput,
): Promise<ContractItem> {
const { rows } = await query<ContractItem>(
`INSERT INTO contract_items (contract_id, product_id, product_name, quantity, unit_price, total, sort_order)
VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING *`,
[
contractId,
input.productId ?? null,
input.productName,
input.quantity,
input.unitPrice,
input.total,
input.sortOrder,
],
);
const row = rows[0];
if (!row) throw new Error("failed to add contract item");
return row;
}
export async function addContractParty(
contractId: string,
input: CreateContractPartyInput,
): Promise<ContractParty> {
const { rows } = await query<ContractParty>(
`INSERT INTO contract_parties (contract_id, party_role, person_type, full_name, document, email, phone, role_title)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING *`,
[
contractId,
input.partyRole,
input.personType ?? null,
input.fullName,
input.document ?? null,
input.email ?? null,
input.phone ?? null,
input.roleTitle ?? null,
],
);
const row = rows[0];
if (!row) throw new Error("failed to add contract party");
return row;
}
/**
* Validates the transition against CONTRACT_STATUS_TRANSITIONS and, only
* if valid: updates contracts.status, sets activated_at via COALESCE the
* first time the contract enters 'active' (never overwritten afterwards —
* same semantics as the legacy's client_registrations.activated_at,
* eden.md §10.2), sets signed_at the first time it enters 'active' from
* 'pending_signature', and appends a contract_status_history row — all in
* one transaction. Throws ContractNotFoundError / InvalidContractTransitionError
* on failure instead of silently no-oping; the service layer maps these to
* 404/409.
*/
export async function updateContractStatus(
id: string,
toStatus: ContractStatus,
reason: string | null,
actorId: string | null,
): Promise<Contract> {
return withTransaction(async (client) => {
const { rows } = await client.query<{ status: ContractStatus; signed_at: Date | null }>(
"SELECT status, signed_at FROM contracts WHERE id = $1 FOR UPDATE",
[id],
);
const current = rows[0];
if (!current) {
throw new ContractNotFoundError(id);
}
const fromStatus = current.status;
const allowed = CONTRACT_STATUS_TRANSITIONS[fromStatus] ?? [];
if (!allowed.includes(toStatus)) {
throw new InvalidContractTransitionError(fromStatus, toStatus);
}
const enteringActive = toStatus === "active";
const settingSignedAt = enteringActive && fromStatus === "pending_signature" && current.signed_at === null;
const { rows: updatedRows } = await client.query<Contract>(
`UPDATE contracts SET
status = $2,
activated_at = CASE WHEN $3 THEN COALESCE(activated_at, now()) ELSE activated_at END,
signed_at = CASE WHEN $4 THEN now() ELSE signed_at END
WHERE id = $1
RETURNING *`,
[id, toStatus, enteringActive, settingSignedAt],
);
const updated = updatedRows[0];
if (!updated) throw new Error(`contract ${id} disappeared mid-transaction`);
await client.query(
`INSERT INTO contract_status_history (contract_id, from_status, to_status, reason, changed_by)
VALUES ($1,$2,$3,$4,$5)`,
[id, fromStatus, toStatus, reason ?? null, actorId ?? null],
);
return updated;
});
}

View File

@@ -0,0 +1,242 @@
import { query } from "../pool.js";
export interface QuoteItem {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
total: number;
implUnitPrice: number;
implTotal: number;
price0: number | null;
price12: number | null;
price24: number | null;
price36: number | null;
price48: number | null;
}
export interface Quote {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
quote_number: string;
reseller_id: string;
client_name: string;
client_company: string | null;
client_email: string | null;
client_phone: string;
contract_period: number;
fidelity_period: number | null;
items: QuoteItem[];
implementation_fee: string;
impl_geral: string;
monthly_total: string;
contract_total: string;
proposed_monthly_total: string | null;
approval_status: "none" | "pending" | "approved" | "rejected";
approval_notes: string | null;
approved_by_id: string | null;
notes: string | null;
deal_status: "orcamento" | "fechado" | "perdido";
impl_payment_condition: "À vista" | "1+1" | "1+2";
client_registration_id: string | null;
}
export interface ListQuotesFilter {
scope: "all" | "own" | "reseller";
actorId?: string;
resellerId?: string;
dealStatus?: string;
}
export async function listQuotes(filter: ListQuotesFilter): Promise<Quote[]> {
const conditions: string[] = [];
const params: unknown[] = [];
if (filter.scope === "own" && filter.actorId) {
params.push(filter.actorId);
conditions.push(`created_by = $${params.length}`);
} else if (filter.scope === "reseller" && filter.resellerId) {
params.push(filter.resellerId);
conditions.push(`reseller_id = $${params.length}`);
}
if (filter.dealStatus) {
params.push(filter.dealStatus);
conditions.push(`deal_status = $${params.length}`);
}
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
const { rows } = await query<Quote>(
`SELECT * FROM quotes ${where} ORDER BY created_at DESC`,
params,
);
return rows;
}
export async function getQuoteById(id: string): Promise<Quote | null> {
const { rows } = await query<Quote>("SELECT * FROM quotes WHERE id = $1", [id]);
return rows[0] ?? null;
}
export interface CreateQuoteInput {
resellerId: string;
clientName: string;
clientCompany?: string | null;
clientEmail?: string | null;
clientPhone: string;
contractPeriod: number;
fidelityPeriod?: number | null;
items: QuoteItem[];
implementationFee: number;
implGeral: number;
monthlyTotal: number;
contractTotal: number;
proposedMonthlyTotal?: number | null;
approvalStatus: "none" | "pending" | "approved" | "rejected";
implPaymentCondition: string;
notes?: string | null;
createdBy?: string | null;
}
export async function createQuote(input: CreateQuoteInput): Promise<Quote> {
const { rows } = await query<Quote>(
`INSERT INTO quotes (
reseller_id, client_name, client_company, client_email, client_phone,
contract_period, fidelity_period, items, implementation_fee, impl_geral,
monthly_total, contract_total, proposed_monthly_total, approval_status,
impl_payment_condition, notes, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
RETURNING *`,
[
input.resellerId,
input.clientName,
input.clientCompany ?? null,
input.clientEmail ?? null,
input.clientPhone,
input.contractPeriod,
input.fidelityPeriod ?? null,
JSON.stringify(input.items),
input.implementationFee,
input.implGeral,
input.monthlyTotal,
input.contractTotal,
input.proposedMonthlyTotal ?? null,
input.approvalStatus,
input.implPaymentCondition,
input.notes ?? null,
input.createdBy ?? null,
],
);
const quote = rows[0];
if (!quote) throw new Error("failed to create quote");
return quote;
}
/**
* Financial/commercial fields are ALL required here, never partially
* COALESCE'd — the service layer always recomputes the entire pricing
* picture (items/period/fidelity/approval) together on every edit that
* touches any of them (Master Prompt: "backend recalcula e valida antes
* de salvar"; eden.md §2: changing contract_period resets any pending
* fidelity exception). A prior version of this function tried to
* COALESCE fidelity_period/proposed_monthly_total/approved_by_id too,
* which silently wiped them to null on any update that didn't happen to
* re-pass them — fixed by making the caller always supply the real
* intended value (including explicit `null` to clear one).
*
* Only purely descriptive fields (contact info, free-text notes, payment
* condition) are optional/COALESCE'd.
*/
export interface UpdateQuoteInput {
clientName?: string;
clientCompany?: string | null;
clientEmail?: string | null;
clientPhone?: string;
contractPeriod: number;
fidelityPeriod: number | null;
items: QuoteItem[];
implementationFee: number;
implGeral: number;
monthlyTotal: number;
contractTotal: number;
proposedMonthlyTotal: number | null;
approvalStatus: "none" | "pending" | "approved" | "rejected";
approvedById: string | null;
approvalNotes?: string | null;
implPaymentCondition?: string;
notes?: string | null;
}
export async function updateQuote(id: string, input: UpdateQuoteInput): Promise<Quote | null> {
const { rows } = await query<Quote>(
`UPDATE quotes SET
client_name = COALESCE($2, client_name),
client_company = COALESCE($3, client_company),
client_email = COALESCE($4, client_email),
client_phone = COALESCE($5, client_phone),
contract_period = $6,
fidelity_period = $7,
items = $8,
implementation_fee = $9,
impl_geral = $10,
monthly_total = $11,
contract_total = $12,
proposed_monthly_total = $13,
approval_status = $14,
approved_by_id = $15,
approval_notes = COALESCE($16, approval_notes),
impl_payment_condition = COALESCE($17, impl_payment_condition),
notes = COALESCE($18, notes)
WHERE id = $1
RETURNING *`,
[
id,
input.clientName ?? null,
input.clientCompany ?? null,
input.clientEmail ?? null,
input.clientPhone ?? null,
input.contractPeriod,
input.fidelityPeriod,
JSON.stringify(input.items),
input.implementationFee,
input.implGeral,
input.monthlyTotal,
input.contractTotal,
input.proposedMonthlyTotal,
input.approvalStatus,
input.approvedById,
input.approvalNotes ?? null,
input.implPaymentCondition ?? null,
input.notes ?? null,
],
);
return rows[0] ?? null;
}
/** Super_admin-only shortcut to correct deal_status on a locked quote without touching anything else (eden.md §5.2). */
export async function setQuoteDealStatus(
id: string,
dealStatus: "orcamento" | "fechado" | "perdido",
): Promise<Quote | null> {
const { rows } = await query<Quote>(
"UPDATE quotes SET deal_status = $2 WHERE id = $1 RETURNING *",
[id, dealStatus],
);
return rows[0] ?? null;
}
/** Sets client_registration_id + deal_status='fechado' atomically — locks the quote (eden.md §5.1/§6). */
export async function closeQuoteDeal(
id: string,
clientRegistrationId: string,
): Promise<Quote | null> {
const { rows } = await query<Quote>(
`UPDATE quotes SET deal_status = 'fechado', client_registration_id = $2
WHERE id = $1 AND client_registration_id IS NULL
RETURNING *`,
[id, clientRegistrationId],
);
return rows[0] ?? null;
}

View File

@@ -31,6 +31,10 @@ export interface User {
reset_token_expires_at: Date | null;
mfa_enabled: boolean;
mfa_secret_encrypted: string | null;
/** Added by the resellers migration — nullable, scopes the user to a reseller (eden.md §7). */
reseller_id: string | null;
/** Added by the quotes migration — eden.md §1.1: admin can approve a quote's discount/reduced-fidelity exception only if this is true. */
can_approve_discount: boolean;
}
export interface Session {