feat(policies): capture the full premium breakdown
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m19s
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m1s

The capture form only ever had prima neta, derecho de póliza and comisión.
The Access form it replaces has seven figures, and the four that were missing
are the ones that make a policy paid in installments add up.

Adds recargo, IVA, prima total and forma de pago to the policy header, the
same breakdown per installment, and a per-line-of-business IVA rate.

IVA and prima total are the only derived figures:

    base  = prima neta + recargo + derecho de póliza
    IVA   = round(base * tasa)
    total = base + IVA

The recargo is inside the taxable base. That is not a guess — policy 7006785
prints IVA 52.03 on 610.86 + 8.55 + 31.00, and leaving the recargo out gives
51.35, which matches nothing on the page. Both of its money rows are asserted
in premium.spec.ts. The recargo itself is never derived: the carrier quotes it,
so staff key it in, and the field is disabled on ANNUAL/SINGLE. Both derived
figures are stored rather than recomputed on read, and stay editable, because
the printed policy is the record of truth and a later rate change must not
silently restate what was issued.

The rate lives on PolicyType (seeded to 0.08, editable in Catálogos), which is
the legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables made configurable. The
rate applied is stamped on the policy so an old one reads back at its original
rate.

Per-installment, not two fixed slots on the header: a policy split into several
exhibiciones prices each payment separately — that is why the Access form drew
the money row twice — and a trimestral policy needs four, which the Access
layout could not hold.

Also fixes two losses in the ETL, which is how these went missing:

  - `forma_pago` was marked consumed by the coverage sweep and then never
    written to any column, so FORMA PAGO existed nowhere in the platform.
  - `recargo` and the whole second money row fell into `coveragesJson` as
    loose strings, mislabeled as coverage amounts.

transform_policies.py now writes all of it directly;
backfill_policy_premium_breakdown.py recovers it on a database that must not be
re-imported, and strips the migrated keys back out of coveragesJson. Both are
COALESCE-only, so a figure a human has corrected in the app wins.

IVA and TOTAL are NOT backfilled: they were unbound calculated controls on the
Access form, never columns, so there is nothing to recover and every migrated
policy reads null until it is edited.

The backfill warns on 5 annual policies that carry a non-zero recargo — a
contradiction that predates this change and is left for a human, not silently
corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 00:24:22 -07:00
co-authored by Claude Opus 5
parent 8c144fe8c4
commit 48e01ddd21
20 changed files with 1049 additions and 60 deletions
+4
View File
@@ -18,6 +18,10 @@ const TYPE: ChildConfig = {
fields: [
{ key: "name", label: "Nombre" },
{ key: "shortDescription", label: "Descripción" },
// The rate is stored as a fraction, not a percentage, and the label has to
// say so: 8 typed here would tax a $600 premium $4,800. The API rejects
// anything above 1 rather than trusting the label alone.
{ key: "taxRate", label: "IVA (0.08 = 8%)", type: "number", step: "0.0001" },
],
};
const ADJUSTER: ChildConfig = {
+15
View File
@@ -926,6 +926,15 @@ button {
color: var(--ink-soft);
margin-bottom: 0.4375rem;
}
/* Sub-label under an input: the computed figure behind an override field, or
why a field is disabled. Quiet enough not to compete with .field-label. */
.field-hint {
display: block;
font-size: 0.75rem;
line-height: 1.35;
color: var(--muted-2);
margin-top: 0.3125rem;
}
.input {
width: 100%;
font-family: inherit;
@@ -940,6 +949,12 @@ button {
.input::placeholder {
color: var(--muted-2);
}
.input:disabled,
.select:disabled {
background: var(--surface-2, var(--surface));
color: var(--muted-2);
cursor: not-allowed;
}
.input:focus {
outline: none;
border-color: var(--brand-600);
+45 -4
View File
@@ -26,7 +26,13 @@ import {
premiumHeadline,
SIN_NOMBRE,
} from "@/lib/labels";
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types";
import {
PAYMENT_FREQUENCY_LABELS,
type AdjusterRow,
type Installment,
type PolicyDetail,
} from "@/lib/types";
import { formatRate } from "@/lib/premium";
export default function PolizaDetailPage({
params,
@@ -186,6 +192,10 @@ function ChildrenEditor({
const INSTALLMENTS: ChildConfig = {
apiKind: "installments",
title: "Pagos",
// A policy paid in several exhibiciones prices each payment on its own, so
// the whole premium breakdown repeats per row — that is the two-row money
// block on the Access form. `amount` stays what was actually collected and
// is deliberately separate from `total`; they differ by rounding.
fields: [
{ key: "sequence", label: "Sec.", type: "number" },
{ key: "amount", label: "Monto", type: "number" },
@@ -195,6 +205,12 @@ function ChildrenEditor({
{ key: "paidDate", label: "Pagado", type: "date" },
{ key: "checkNumber", label: "Cheque" },
{ key: "isCash", label: "Efectivo", type: "checkbox" },
{ key: "netPremium", label: "Prima neta", type: "number" },
{ key: "surcharge", label: "Recargo", type: "number" },
{ key: "policyFee", label: "Derecho", type: "number" },
{ key: "tax", label: "IVA", type: "number" },
{ key: "total", label: "Prima total", type: "number" },
{ key: "commission", label: "Comisión", type: "number" },
],
};
const VEHICLES: ChildConfig = {
@@ -412,15 +428,40 @@ function CondicionesSection({ data }: { data: PolicyDetail }) {
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
}
/>
<KV
label="Forma de pago"
value={
data.paymentFrequency
? PAYMENT_FREQUENCY_LABELS[data.paymentFrequency]
: null
}
/>
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
{/* Only ever set on a policy paid in installments, so showing an
empty row on the other 98% would be noise. */}
{data.surcharge != null && Number(data.surcharge) !== 0 && (
<KV label="Recargo" value={formatMoney(data.surcharge, cur)} />
)}
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
{/* Access never stored IVA — it was a calculated control on the form
— so every migrated policy reads null here until it is edited. */}
{data.tax != null && (
<KV
label={
data.taxRate != null
? `IVA (${formatRate(Number(data.taxRate))})`
: "IVA"
}
value={formatMoney(data.tax, cur)}
/>
)}
{/* The legacy `total` is 0 or null on all but 2 of 2378 policies —
only show it when it actually carries a figure. */}
{data.total != null && Number(data.total) > 0 && (
<KV label="Total" value={formatMoney(data.total, cur)} />
<KV label="Prima total" value={formatMoney(data.total, cur)} />
)}
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
<KV
label="Liquidación"
value={
+4 -1
View File
@@ -9,6 +9,9 @@ export type FieldDef = {
type?: "text" | "number" | "date" | "checkbox" | "select";
options?: { value: string; label: string }[];
width?: number;
/** Numeric granularity. Defaults to money (0.01); a tax rate stored as a
* fraction needs finer, or the browser rejects 0.0825 as off-step. */
step?: string;
};
export type ChildConfig = {
@@ -158,7 +161,7 @@ export function ChildCollection({
<input
className="input"
type={f.type === "number" ? "number" : f.type === "date" ? "date" : "text"}
step={f.type === "number" ? "0.01" : undefined}
step={f.type === "number" ? f.step ?? "0.01" : undefined}
value={String(values[f.key] ?? "")}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
/>
+119 -6
View File
@@ -4,12 +4,22 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { CustomerPicker } from "@/components/CustomerPicker";
import { createPolicy, getLookups, updatePolicy } from "@/lib/api";
import type {
Currency,
LookupsResponse,
PolicyDetail,
PolicyInput,
import {
PAYMENT_FREQUENCY_LABELS,
type Currency,
type LookupsResponse,
type PaymentFrequency,
type PolicyDetail,
type PolicyInput,
} from "@/lib/types";
import {
computeTax,
computeTotal,
formatRate,
resolveTaxRate,
surchargeApplies,
taxableBase,
} from "@/lib/premium";
function toDateInput(v: string | null | undefined): string {
if (!v) return "";
@@ -36,9 +46,16 @@ type V = {
policyFrom: string;
policyTo: string;
netPremium: string;
surcharge: string;
policyFee: string;
brokerFee: string;
commission: string;
/** Blank means "use the computed figure". Only ever holds a value once the
* operator overrides it, so a later change to prima neta keeps flowing
* through instead of being frozen by a value the form itself wrote. */
tax: string;
total: string;
paymentFrequency: PaymentFrequency | "";
currency: Currency;
liquidated: boolean;
liquidationNumber: string;
@@ -58,9 +75,13 @@ function initial(p?: PolicyDetail): V {
policyFrom: toDateInput(p?.policyFrom),
policyTo: toDateInput(p?.policyTo),
netPremium: p?.netPremium != null ? String(p.netPremium) : "",
surcharge: p?.surcharge != null ? String(p.surcharge) : "",
policyFee: p?.policyFee != null ? String(p.policyFee) : "",
brokerFee: p?.brokerFee != null ? String(p.brokerFee) : "",
commission: p?.commission != null ? String(p.commission) : "",
tax: p?.tax != null ? String(p.tax) : "",
total: p?.total != null ? String(p.total) : "",
paymentFrequency: p?.paymentFrequency ?? "",
currency: (p?.currency as Currency) ?? "MXN",
liquidated: p?.liquidated ?? false,
liquidationNumber: p?.liquidationNumber ?? "",
@@ -101,6 +122,27 @@ export function PolicyForm({
setV((p) => ({ ...p, [k]: val }));
}
// IVA and Total are the only two figures the form derives. Everything else,
// the recargo included, is keyed by hand — the carrier quotes the financing
// charge, we do not compute it.
const selectedType = lookups?.types.find((t) => t.id === v.policyTypeId);
const taxRate = resolveTaxRate(policy?.taxRate, selectedType?.taxRate);
const parts = {
netPremium: v.netPremium,
// A recargo on an annual policy is a data-entry mistake, so it is dropped
// from the arithmetic as well as disabled in the UI. Otherwise switching
// ANNUAL after typing one would leave it silently inflating the IVA.
surcharge: surchargeApplies(v.paymentFrequency || null) ? v.surcharge : "",
policyFee: v.policyFee,
};
const computedTax = computeTax(parts, taxRate);
const computedTotal = computeTotal(parts, taxRate);
// Blank field = take the computed figure. A typed one wins, so staff can key
// the carrier's rounding verbatim when it disagrees with ours by a centavo.
const effectiveTax = v.tax.trim() === "" ? computedTax : Number(v.tax);
const effectiveTotal = v.total.trim() === "" ? computedTotal : Number(v.total);
const showSurcharge = surchargeApplies(v.paymentFrequency || null);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!customerId) {
@@ -118,9 +160,17 @@ export function PolicyForm({
policyFrom: s(v.policyFrom),
policyTo: s(v.policyTo),
netPremium: numOrUndef(v.netPremium),
surcharge: showSurcharge ? numOrUndef(v.surcharge) : undefined,
policyFee: numOrUndef(v.policyFee),
brokerFee: numOrUndef(v.brokerFee),
commission: numOrUndef(v.commission),
// The derived figures are persisted, not recomputed on read: the printed
// policy is the record of truth and a later rate change must not silently
// restate what was issued. `taxRate` rides along for the same reason.
tax: Number.isFinite(effectiveTax) ? effectiveTax : undefined,
taxRate,
total: Number.isFinite(effectiveTotal) ? effectiveTotal : undefined,
paymentFrequency: v.paymentFrequency || undefined,
currency: v.currency,
liquidated: v.liquidated,
liquidationNumber: s(v.liquidationNumber),
@@ -208,7 +258,7 @@ export function PolicyForm({
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia y prima</h2>
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia</h2>
<div className="form-grid">
<label className="field">
<span className="field-label">Emisión</span>
@@ -225,21 +275,84 @@ export function PolicyForm({
<input className="input" type="date" value={v.policyTo}
onChange={(e) => set("policyTo", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Forma de pago</span>
<select className="select" value={v.paymentFrequency}
onChange={(e) =>
set("paymentFrequency", e.target.value as PaymentFrequency | "")
}>
<option value=""></option>
{(
Object.keys(PAYMENT_FREQUENCY_LABELS) as PaymentFrequency[]
).map((f) => (
<option key={f} value={f}>{PAYMENT_FREQUENCY_LABELS[f]}</option>
))}
</select>
</label>
</div>
</div>
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
<h2 className="section-title" style={{ marginBottom: 4 }}>Primas</h2>
<p className="muted" style={{ fontSize: 12, marginBottom: 14 }}>
IVA y prima total se calculan solos sobre (prima neta + recargo +
derecho de póliza). Puede sobrescribirlos si la póliza impresa
redondea distinto.
</p>
<div className="form-grid">
<label className="field">
<span className="field-label">Prima neta</span>
<input className="input" type="number" step="0.01" value={v.netPremium}
onChange={(e) => set("netPremium", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Recargo</span>
<input className="input" type="number" step="0.01" value={v.surcharge}
disabled={!showSurcharge}
onChange={(e) => set("surcharge", e.target.value)} />
<span className="field-hint">
{showSurcharge
? "Lo cotiza la aseguradora — se captura a mano."
: "No aplica en pago anual ni de contado."}
</span>
</label>
<label className="field">
<span className="field-label">Derecho de póliza</span>
<input className="input" type="number" step="0.01" value={v.policyFee}
onChange={(e) => set("policyFee", e.target.value)} />
</label>
<label className="field">
<span className="field-label">IVA ({formatRate(taxRate)})</span>
<input className="input" type="number" step="0.01"
placeholder={computedTax.toFixed(2)} value={v.tax}
onChange={(e) => set("tax", e.target.value)} />
<span className="field-hint">
Calculado: {computedTax.toFixed(2)} sobre base{" "}
{taxableBase(parts).toFixed(2)}
{selectedType?.taxRate == null &&
policy?.taxRate == null &&
" · tasa por omisión, configúrela en Catálogos"}
</span>
</label>
<label className="field">
<span className="field-label">Prima total</span>
<input className="input" type="number" step="0.01"
placeholder={computedTotal.toFixed(2)} value={v.total}
onChange={(e) => set("total", e.target.value)} />
<span className="field-hint">
Calculado: {computedTotal.toFixed(2)}
</span>
</label>
<label className="field">
<span className="field-label">Comisión</span>
<input className="input" type="number" step="0.01" value={v.commission}
onChange={(e) => set("commission", e.target.value)} />
</label>
<label className="field">
<span className="field-label">Honorarios</span>
<input className="input" type="number" step="0.01" value={v.brokerFee}
onChange={(e) => set("brokerFee", e.target.value)} />
</label>
</div>
</div>
+79
View File
@@ -0,0 +1,79 @@
import type { PaymentFrequency } from "./types";
/**
* Client-side twin of apps/api/src/policies/premium.ts. Duplicated rather than
* shared because the API and the web app do not share a package today, and
* both need it: the form computes IVA and Total live as the operator types,
* the API stores what it is sent.
*
* base = prima neta + recargo + derecho de póliza
* IVA = round(base * tasa)
* Total = base + IVA
*
* The recargo is inside the taxable base — that is what reconciles the Access
* books (policy 7006785 prints IVA 52.03 on 610.86 + 8.55 + 31.00; leaving the
* recargo out gives 51.35, which matches nothing on the page).
*/
/** Applied when neither the policy nor its type carries a rate. The single
* row both legacy IMPUESTOS tables held. */
export const DEFAULT_TAX_RATE = 0.08;
/** Paying in more than one exhibición is what earns a recargo. A null
* frequency (every migrated policy) is treated as "unknown, allow it": the
* legacy recargo figures are real and hiding the field would hide them. */
export function surchargeApplies(
frequency: PaymentFrequency | null | undefined,
): boolean {
return frequency !== "ANNUAL" && frequency !== "SINGLE";
}
export function num(v: string | number | null | undefined): number {
if (v === null || v === undefined || v === "") return 0;
const n = typeof v === "number" ? v : Number(String(v).trim());
return Number.isFinite(n) ? n : 0;
}
/** Half-up to cents, matching how the printed policy rounds. */
export function round2(n: number): number {
return Math.round((n + Number.EPSILON) * 100) / 100;
}
export interface PremiumParts {
netPremium: string | number | null | undefined;
surcharge: string | number | null | undefined;
policyFee: string | number | null | undefined;
}
export function taxableBase(p: PremiumParts): number {
return round2(num(p.netPremium) + num(p.surcharge) + num(p.policyFee));
}
export function computeTax(p: PremiumParts, rate: number): number {
return round2(taxableBase(p) * rate);
}
export function computeTotal(p: PremiumParts, rate: number): number {
return round2(taxableBase(p) + computeTax(p, rate));
}
/** Rate ladder: what the policy was issued at, else its line of business, else
* the default. Keeps an old policy reading back at its original rate after
* somebody edits the catalog. */
export function resolveTaxRate(
policyRate: string | number | null | undefined,
policyTypeRate: string | number | null | undefined,
): number {
for (const candidate of [policyRate, policyTypeRate]) {
if (candidate === null || candidate === undefined || candidate === "") continue;
const n = Number(candidate);
if (Number.isFinite(n) && n >= 0) return n;
}
return DEFAULT_TAX_RATE;
}
/** 0.08 -> "8%". Rates are stored as fractions but read as percentages. */
export function formatRate(rate: number): string {
const pct = round2(rate * 100);
return `${pct}%`;
}
+47
View File
@@ -3,6 +3,24 @@
export type Currency = "USD" | "MXN";
/** How the premium is split into payments. Anything other than ANNUAL/SINGLE
* is what earns a recargo. Null on every migrated policy — the original ETL
* dropped Access's FORMA PAGO column entirely. */
export type PaymentFrequency =
| "ANNUAL"
| "SEMIANNUAL"
| "QUARTERLY"
| "MONTHLY"
| "SINGLE";
export const PAYMENT_FREQUENCY_LABELS: Record<PaymentFrequency, string> = {
ANNUAL: "Anual",
SEMIANNUAL: "Semestral",
QUARTERLY: "Trimestral",
MONTHLY: "Mensual",
SINGLE: "Contado",
};
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
export type Ability =
@@ -289,6 +307,16 @@ export interface Installment {
paidDate: string | null;
checkNumber: string | null;
isCash: boolean;
/** Per-payment premium breakdown — a policy paid in several exhibiciones
* prices each one separately. `amount` is what was actually collected and
* can differ from `total` by rounding; it is not derived from these. */
netPremium: string | null;
surcharge: string | null;
policyFee: string | null;
tax: string | null;
taxRate: string | null;
total: string | null;
commission: string | null;
}
export interface Vehicle {
@@ -398,10 +426,14 @@ export interface PolicyInput {
policyTo?: string;
coveragePeriodDays?: number;
netPremium?: number;
surcharge?: number;
policyFee?: number;
brokerFee?: number;
commission?: number;
tax?: number;
taxRate?: number;
total?: number;
paymentFrequency?: PaymentFrequency;
currency?: Currency;
observations?: string;
notes?: string;
@@ -419,6 +451,13 @@ export interface InstallmentInput {
paidDate?: string;
checkNumber?: string;
isCash?: boolean;
netPremium?: number;
surcharge?: number;
policyFee?: number;
tax?: number;
taxRate?: number;
total?: number;
commission?: number;
}
export interface VehicleInput {
make?: string;
@@ -469,6 +508,10 @@ export interface PolicyTypeRow {
id: string;
name: string;
shortDescription: string | null;
/** IVA fraction for this line of business, 0.08 = 8%. Null means "not
* configured" and the form falls back to DEFAULT_TAX_RATE — it does NOT
* mean the line is untaxed. Serialized as a decimal string by Prisma. */
taxRate: string | null;
_count?: { policies: number };
}
export interface AdjusterRow {
@@ -567,10 +610,14 @@ export interface PolicyDetail {
policyTo: string | null;
coveragePeriodDays: number | null;
netPremium: string | null;
surcharge: string | null;
policyFee: string | null;
brokerFee: string | null;
commission: string | null;
tax: string | null;
taxRate: string | null;
total: string | null;
paymentFrequency: PaymentFrequency | null;
currency: string | null;
observations: string | null;
notes: string | null;