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:
161
apps/api/src/quotes/pricing.ts
Normal file
161
apps/api/src/quotes/pricing.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { QuoteItem } from "@eden/database";
|
||||
|
||||
/**
|
||||
* Quote pricing/fidelity/approval engine — ported verbatim from eden.md §8
|
||||
* (formulas) and §3–§4 (fidelity/discount business rules). These formulas
|
||||
* are exact, not approximate — do not "simplify" without an ADR. The
|
||||
* backend is always the source of truth (Master Prompt §6.4: "nunca
|
||||
* confiar no frontend para cálculo financeiro"); the frontend may mirror
|
||||
* this logic for UX, but every quote write recomputes from scratch here.
|
||||
*/
|
||||
|
||||
export interface PricingInput {
|
||||
items: QuoteItem[];
|
||||
contractPeriod: number;
|
||||
fidelityPeriod: number | null;
|
||||
implGeral: number;
|
||||
proposedMonthlyTotal: number | null;
|
||||
}
|
||||
|
||||
export interface PricingResult {
|
||||
monthlyTotal: number;
|
||||
implFromProducts: number;
|
||||
totalImpl: number;
|
||||
hasSpecialPrice: boolean;
|
||||
isDiscount: boolean;
|
||||
isMarkup: boolean;
|
||||
diffMonthly: number;
|
||||
effectiveMonthly: number;
|
||||
hasReducedFidelity: boolean;
|
||||
needsApproval: boolean;
|
||||
contractTotal: number;
|
||||
/** What to persist to quotes.fidelity_period — null unless it's genuinely a reduced-fidelity exception (eden.md §8 "só persiste se for de fato menor que a faixa"). */
|
||||
persistedFidelityPeriod: number | null;
|
||||
/** What to persist to quotes.proposed_monthly_total — null unless a special price is actually active. */
|
||||
persistedProposedMonthlyTotal: number | null;
|
||||
}
|
||||
|
||||
export function computeQuotePricing(input: PricingInput): PricingResult {
|
||||
const monthlyTotal = round2(input.items.reduce((sum, item) => sum + item.total, 0));
|
||||
const implFromProducts = round2(input.items.reduce((sum, item) => sum + item.implTotal, 0));
|
||||
const totalImpl = round2(implFromProducts + (input.implGeral || 0));
|
||||
|
||||
const proposedMonthly = input.proposedMonthlyTotal;
|
||||
const hasSpecialPrice =
|
||||
proposedMonthly != null && proposedMonthly > 0 && proposedMonthly !== monthlyTotal;
|
||||
const isDiscount = hasSpecialPrice && proposedMonthly! < monthlyTotal;
|
||||
const isMarkup = hasSpecialPrice && proposedMonthly! > monthlyTotal;
|
||||
const diffMonthly = hasSpecialPrice ? round2(monthlyTotal - proposedMonthly!) : 0;
|
||||
const effectiveMonthly = hasSpecialPrice ? proposedMonthly! : monthlyTotal;
|
||||
|
||||
const hasReducedFidelity =
|
||||
input.fidelityPeriod != null && input.fidelityPeriod < input.contractPeriod;
|
||||
|
||||
const needsApproval = isDiscount || hasReducedFidelity;
|
||||
|
||||
const contractTotal = round2(effectiveMonthly * (input.contractPeriod || 1) + totalImpl);
|
||||
|
||||
return {
|
||||
monthlyTotal,
|
||||
implFromProducts,
|
||||
totalImpl,
|
||||
hasSpecialPrice,
|
||||
isDiscount,
|
||||
isMarkup,
|
||||
diffMonthly,
|
||||
effectiveMonthly,
|
||||
hasReducedFidelity,
|
||||
needsApproval,
|
||||
contractTotal,
|
||||
persistedFidelityPeriod: hasReducedFidelity ? input.fidelityPeriod : null,
|
||||
persistedProposedMonthlyTotal: hasSpecialPrice ? proposedMonthly : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves what `approval_status` should be persisted, given the previous
|
||||
* status and whether the actor is submitting an approval decision.
|
||||
* Mirrors eden.md §8: `needsApproval ? (approvalStatus === 'none' ?
|
||||
* 'pending' : approvalStatus) : 'none'` — but generalized to accept an
|
||||
* explicit decision (approve/reject) from a privileged actor, which the
|
||||
* legacy handled as a separate code path (§3.3) that this folds in here
|
||||
* for a single source of truth.
|
||||
*/
|
||||
export function resolveApprovalStatus(
|
||||
needsApproval: boolean,
|
||||
previousStatus: "none" | "pending" | "approved" | "rejected",
|
||||
decision?: "approved" | "rejected",
|
||||
): "none" | "pending" | "approved" | "rejected" {
|
||||
if (!needsApproval) return "none";
|
||||
if (decision) return decision;
|
||||
return previousStatus === "none" ? "pending" : previousStatus;
|
||||
}
|
||||
|
||||
/** eden.md §3.3: super_admin always; admin only with the explicit per-user flag — never inferred from role alone. */
|
||||
export function canApproveDiscount(user: { roleKey: string; canApproveDiscount?: boolean }): boolean {
|
||||
if (user.roleKey === "super_admin") return true;
|
||||
return user.roleKey === "admin" && user.canApproveDiscount === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fidelity that actually has legal effect (vigência/vencimento/multa)
|
||||
* — eden.md §3.4/§10.4, repeated everywhere in the legacy as the single
|
||||
* most important rule to preserve: a pending/rejected exception NEVER
|
||||
* affects anything legal, only an approved one does.
|
||||
*/
|
||||
export function resolveEffectiveFidelity(
|
||||
contractPeriod: number,
|
||||
fidelityPeriod: number | null,
|
||||
approvalStatus: "none" | "pending" | "approved" | "rejected",
|
||||
): number {
|
||||
return fidelityPeriod != null && approvalStatus === "approved" ? fidelityPeriod : contractPeriod || 0;
|
||||
}
|
||||
|
||||
/** eden.md §7 (paymentConditions.js) — progressive by implementation value. */
|
||||
export const PAYMENT_CONDITIONS = ["À vista", "1+1", "1+2"] as const;
|
||||
|
||||
export function getAvailablePaymentConditions(implTotal: number): readonly string[] {
|
||||
const value = implTotal || 0;
|
||||
if (value <= 1500) return ["À vista"];
|
||||
if (value <= 3000) return ["À vista", "1+1"];
|
||||
return PAYMENT_CONDITIONS;
|
||||
}
|
||||
|
||||
export interface SavingsResult {
|
||||
totalDescontoMensal: number;
|
||||
totalEconomiaMensal: number;
|
||||
economiaTotalContrato: number;
|
||||
beneficioTotalFidelidade: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* eden.md §3.5/§4.4 — two DIFFERENT numbers that must never be confused:
|
||||
* `beneficioTotalFidelidade` (fidelity discount only, used as the legal
|
||||
* cap for the RGC art. 37 early-termination penalty) vs.
|
||||
* `totalEconomiaMensal`/`economiaTotalContrato` (fidelity + special
|
||||
* discount combined, shown as commercial "savings" info to the client).
|
||||
*/
|
||||
export function computeSavings(
|
||||
items: QuoteItem[],
|
||||
contractPeriod: number,
|
||||
effectiveFidelityPeriod: number,
|
||||
monthlyTotal: number,
|
||||
proposedMonthlyTotal: number | null,
|
||||
): SavingsResult {
|
||||
const totalDescontoMensal = round2(
|
||||
items.reduce((sum, item) => sum + ((item.price0 ?? 0) - item.unitPrice) * item.quantity, 0),
|
||||
);
|
||||
const hasSpecialPrice =
|
||||
proposedMonthlyTotal != null && proposedMonthlyTotal > 0 && proposedMonthlyTotal !== monthlyTotal;
|
||||
const specialDiscount = hasSpecialPrice ? round2(monthlyTotal - proposedMonthlyTotal!) : 0;
|
||||
const totalEconomiaMensal = round2(totalDescontoMensal + specialDiscount);
|
||||
const economiaTotalContrato =
|
||||
totalEconomiaMensal > 0 && contractPeriod > 0 ? round2(totalEconomiaMensal * contractPeriod) : 0;
|
||||
const beneficioTotalFidelidade = round2(totalDescontoMensal * effectiveFidelityPeriod);
|
||||
|
||||
return { totalDescontoMensal, totalEconomiaMensal, economiaTotalContrato, beneficioTotalFidelidade };
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
Reference in New Issue
Block a user