diff --git a/apps/api/src/policies/children.dto.ts b/apps/api/src/policies/children.dto.ts
index f9dd95b..41a98d4 100644
--- a/apps/api/src/policies/children.dto.ts
+++ b/apps/api/src/policies/children.dto.ts
@@ -12,6 +12,10 @@ import { Currency } from "@jorgecuadros/database";
// Each child DTO covers create; updates reuse the same shape with all fields
// optional via the corresponding Update class. Route supplies the policyId.
+// A policy split into several exhibiciones prices each payment on its own —
+// the Access form printed the whole money row once per pago — so the premium
+// breakdown repeats here. `amount` remains what was actually collected and is
+// never recomputed from the breakdown; the two differ by rounding in the books.
export class InstallmentDto {
@IsInt() sequence!: number;
@IsOptional() @IsNumber() amount?: number;
@@ -20,6 +24,13 @@ export class InstallmentDto {
@IsOptional() @IsString() paidDate?: string;
@IsOptional() @IsString() checkNumber?: string;
@IsOptional() @IsBoolean() isCash?: boolean;
+ @IsOptional() @IsNumber() netPremium?: number;
+ @IsOptional() @IsNumber() surcharge?: number;
+ @IsOptional() @IsNumber() policyFee?: number;
+ @IsOptional() @IsNumber() tax?: number;
+ @IsOptional() @IsNumber() taxRate?: number;
+ @IsOptional() @IsNumber() total?: number;
+ @IsOptional() @IsNumber() commission?: number;
}
export class UpdateInstallmentDto {
@IsOptional() @IsInt() sequence?: number;
@@ -29,6 +40,13 @@ export class UpdateInstallmentDto {
@IsOptional() @IsString() paidDate?: string;
@IsOptional() @IsString() checkNumber?: string;
@IsOptional() @IsBoolean() isCash?: boolean;
+ @IsOptional() @IsNumber() netPremium?: number;
+ @IsOptional() @IsNumber() surcharge?: number;
+ @IsOptional() @IsNumber() policyFee?: number;
+ @IsOptional() @IsNumber() tax?: number;
+ @IsOptional() @IsNumber() taxRate?: number;
+ @IsOptional() @IsNumber() total?: number;
+ @IsOptional() @IsNumber() commission?: number;
}
export class VehicleDto {
diff --git a/apps/api/src/policies/lookup.dto.ts b/apps/api/src/policies/lookup.dto.ts
index 02e6c31..9f23177 100644
--- a/apps/api/src/policies/lookup.dto.ts
+++ b/apps/api/src/policies/lookup.dto.ts
@@ -1,4 +1,4 @@
-import { IsOptional, IsString, MinLength } from "class-validator";
+import { IsNumber, IsOptional, IsString, Max, Min, MinLength } from "class-validator";
export class ProviderDto {
@IsString() @MinLength(1) name!: string;
@@ -7,13 +7,19 @@ export class UpdateProviderDto {
@IsOptional() @IsString() @MinLength(1) name?: string;
}
+// `taxRate` is the IVA fraction for this line of business (0.08 = 8%), the
+// legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables made editable. Bounded at
+// 1 because a rate is a fraction, not a percentage: 8 entered here would tax a
+// $600 premium $4,800, and the mistake is easy to make.
export class PolicyTypeDto {
@IsString() @MinLength(1) name!: string;
@IsOptional() @IsString() shortDescription?: string;
+ @IsOptional() @IsNumber() @Min(0) @Max(1) taxRate?: number;
}
export class UpdatePolicyTypeDto {
@IsOptional() @IsString() @MinLength(1) name?: string;
@IsOptional() @IsString() shortDescription?: string;
+ @IsOptional() @IsNumber() @Min(0) @Max(1) taxRate?: number;
}
export class AdjusterDto {
diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts
index 2103a88..1ba10ac 100644
--- a/apps/api/src/policies/policies.service.ts
+++ b/apps/api/src/policies/policies.service.ts
@@ -250,7 +250,16 @@ export class PoliciesService {
const [types, providers] = await this.prisma.$transaction([
this.prisma.policyType.findMany({
orderBy: { name: "asc" },
- select: { id: true, name: true, _count: { select: { policies: true } } },
+ select: {
+ id: true,
+ name: true,
+ shortDescription: true,
+ // The capture form computes IVA client-side as the operator types,
+ // so the rate has to travel with the type list it already loads —
+ // an extra round-trip per keystroke is not an option.
+ taxRate: true,
+ _count: { select: { policies: true } },
+ },
}),
this.prisma.insuranceProvider.findMany({
orderBy: { name: "asc" },
@@ -259,7 +268,13 @@ export class PoliciesService {
]);
return {
- types: types.map((t) => ({ id: t.id, name: t.name, count: t._count.policies })),
+ types: types.map((t) => ({
+ id: t.id,
+ name: t.name,
+ shortDescription: t.shortDescription,
+ taxRate: t.taxRate,
+ count: t._count.policies,
+ })),
providers: providers.map((p) => ({
id: p.id,
name: p.name,
@@ -397,6 +412,13 @@ export class PoliciesService {
paidDate: toDate(dto.paidDate) ?? undefined,
checkNumber: dto.checkNumber,
isCash: dto.isCash,
+ netPremium: dto.netPremium,
+ surcharge: dto.surcharge,
+ policyFee: dto.policyFee,
+ tax: dto.tax,
+ taxRate: dto.taxRate,
+ total: dto.total,
+ commission: dto.commission,
},
});
}
@@ -412,6 +434,13 @@ export class PoliciesService {
...(dto.paidDate !== undefined && { paidDate: toDate(dto.paidDate) }),
checkNumber: dto.checkNumber,
isCash: dto.isCash,
+ netPremium: dto.netPremium,
+ surcharge: dto.surcharge,
+ policyFee: dto.policyFee,
+ tax: dto.tax,
+ taxRate: dto.taxRate,
+ total: dto.total,
+ commission: dto.commission,
},
});
}
diff --git a/apps/api/src/policies/policy.dto.ts b/apps/api/src/policies/policy.dto.ts
index fe7cce1..6fa15e0 100644
--- a/apps/api/src/policies/policy.dto.ts
+++ b/apps/api/src/policies/policy.dto.ts
@@ -6,12 +6,16 @@ import {
IsString,
MinLength,
} from "class-validator";
-import { Currency } from "@jorgecuadros/database";
+import { Currency, PaymentFrequency } from "@jorgecuadros/database";
import { IsEnum } from "class-validator";
/** Editable policy-header fields. coveragesJson (freeform legacy blob) is not
* exposed for editing. Dates arrive as ISO strings and are coerced by the
- * service. `total` is legacy-dead data — the UI uses netPremium. */
+ * service. `total` is legacy-dead data on migrated rows — list and sort code
+ * still uses netPremium — but the capture form writes it going forward, along
+ * with `tax`, from the arithmetic in premium.ts. Both arrive as plain numbers
+ * rather than being recomputed server-side: the printed policy is the record
+ * of truth and staff must be able to key its rounding verbatim. */
export class CreatePolicyDto {
@IsString() @MinLength(1) policyNumber!: string;
@IsString() @MinLength(1) customerId!: string;
@@ -24,10 +28,14 @@ export class CreatePolicyDto {
@IsOptional() @IsString() policyTo?: string;
@IsOptional() @IsInt() coveragePeriodDays?: number;
@IsOptional() @IsNumber() netPremium?: number;
+ @IsOptional() @IsNumber() surcharge?: number;
@IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() brokerFee?: number;
@IsOptional() @IsNumber() commission?: number;
+ @IsOptional() @IsNumber() tax?: number;
+ @IsOptional() @IsNumber() taxRate?: number;
@IsOptional() @IsNumber() total?: number;
+ @IsOptional() @IsEnum(PaymentFrequency) paymentFrequency?: PaymentFrequency;
@IsOptional() @IsEnum(Currency) currency?: Currency;
@IsOptional() @IsString() observations?: string;
@IsOptional() @IsString() notes?: string;
@@ -48,10 +56,14 @@ export class UpdatePolicyDto {
@IsOptional() @IsString() policyTo?: string;
@IsOptional() @IsInt() coveragePeriodDays?: number;
@IsOptional() @IsNumber() netPremium?: number;
+ @IsOptional() @IsNumber() surcharge?: number;
@IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() brokerFee?: number;
@IsOptional() @IsNumber() commission?: number;
+ @IsOptional() @IsNumber() tax?: number;
+ @IsOptional() @IsNumber() taxRate?: number;
@IsOptional() @IsNumber() total?: number;
+ @IsOptional() @IsEnum(PaymentFrequency) paymentFrequency?: PaymentFrequency;
@IsOptional() @IsEnum(Currency) currency?: Currency;
@IsOptional() @IsString() observations?: string;
@IsOptional() @IsString() notes?: string;
diff --git a/apps/api/src/policies/premium.spec.ts b/apps/api/src/policies/premium.spec.ts
new file mode 100644
index 0000000..a20e282
--- /dev/null
+++ b/apps/api/src/policies/premium.spec.ts
@@ -0,0 +1,90 @@
+import {
+ DEFAULT_TAX_RATE,
+ computeTax,
+ computeTotal,
+ resolveTaxRate,
+ surchargeApplies,
+ taxableBase,
+} from "./premium";
+
+/**
+ * The reference case is policy 7006785 (MULT, semestral, GMX, two payments) as
+ * it stands in the Access books — the screen Jorge sent. Both of its money
+ * rows are asserted, because the second one is the case that proves the
+ * surcharge belongs in the taxable base and that a zero policy fee is a real
+ * value rather than a missing one.
+ */
+describe("premium arithmetic", () => {
+ it("matches the first payment of policy 7006785", () => {
+ const parts = { netPremium: 610.86, surcharge: 8.55, policyFee: 31.0 };
+ expect(taxableBase(parts)).toBe(650.41);
+ expect(computeTax(parts, 0.08)).toBe(52.03);
+ expect(computeTotal(parts, 0.08)).toBe(702.44);
+ });
+
+ it("matches the second payment of policy 7006785", () => {
+ const parts = { netPremium: 589.71, surcharge: 8.26, policyFee: 0 };
+ expect(computeTax(parts, 0.08)).toBe(47.84);
+ expect(computeTotal(parts, 0.08)).toBe(645.81);
+ });
+
+ it("excluding the surcharge does NOT reconcile", () => {
+ // Guards the one decision in this module that is easy to get wrong: the
+ // spoken-language version of the rule ("prima neta + derecho * 8%") gives
+ // 51.35, and the printed policy says 52.03.
+ const withoutSurcharge = { netPremium: 610.86, surcharge: 0, policyFee: 31.0 };
+ expect(computeTax(withoutSurcharge, 0.08)).not.toBe(52.03);
+ });
+
+ it("treats blank and null money as zero, not NaN", () => {
+ expect(taxableBase({ netPremium: "610.86", surcharge: null, policyFee: "" })).toBe(
+ 610.86,
+ );
+ expect(computeTax({ netPremium: undefined, surcharge: null, policyFee: null }, 0.08))
+ .toBe(0);
+ });
+
+ it("rounds half-up to cents", () => {
+ // 100.06 * 0.08 = 8.0048 -> 8.00; 100.13 * 0.08 = 8.0104 -> 8.01.
+ expect(computeTax({ netPremium: 100.06, surcharge: 0, policyFee: 0 }, 0.08)).toBe(8);
+ expect(computeTax({ netPremium: 100.13, surcharge: 0, policyFee: 0 }, 0.08)).toBe(8.01);
+ });
+
+ describe("surchargeApplies", () => {
+ it("is false for the two single-payment frequencies", () => {
+ expect(surchargeApplies("ANNUAL")).toBe(false);
+ expect(surchargeApplies("SINGLE")).toBe(false);
+ });
+ it("is true for every split frequency", () => {
+ expect(surchargeApplies("SEMIANNUAL")).toBe(true);
+ expect(surchargeApplies("QUARTERLY")).toBe(true);
+ expect(surchargeApplies("MONTHLY")).toBe(true);
+ });
+ it("allows it when the frequency is unknown", () => {
+ // Every migrated policy is null here — the original ETL dropped FORMA
+ // PAGO — and those rows DO carry recargo figures in the legacy data.
+ expect(surchargeApplies(null)).toBe(true);
+ expect(surchargeApplies(undefined)).toBe(true);
+ });
+ });
+
+ describe("resolveTaxRate", () => {
+ it("prefers the rate the policy was issued at", () => {
+ expect(resolveTaxRate(0.16, 0.08)).toBe(0.16);
+ });
+ it("falls back to the line of business", () => {
+ expect(resolveTaxRate(null, 0.08)).toBe(0.08);
+ });
+ it("falls back to the default when nothing is configured", () => {
+ expect(resolveTaxRate(null, null)).toBe(DEFAULT_TAX_RATE);
+ expect(resolveTaxRate(undefined, "")).toBe(DEFAULT_TAX_RATE);
+ });
+ it("accepts a zero rate as a real choice, not as absent", () => {
+ // An exempt line of business must read 0, not silently fall through to 8%.
+ expect(resolveTaxRate(null, 0)).toBe(0);
+ });
+ it("accepts Prisma's decimal strings", () => {
+ expect(resolveTaxRate(null, "0.0800")).toBe(0.08);
+ });
+ });
+});
diff --git a/apps/api/src/policies/premium.ts b/apps/api/src/policies/premium.ts
new file mode 100644
index 0000000..621e742
--- /dev/null
+++ b/apps/api/src/policies/premium.ts
@@ -0,0 +1,86 @@
+/**
+ * The premium arithmetic the Access capture form did in unbound calculated
+ * controls, moved somewhere it can be tested.
+ *
+ * Two figures are derived, everything else is keyed by hand:
+ *
+ * base = netPremium + surcharge + policyFee
+ * tax = round(base * rate)
+ * total = base + tax
+ *
+ * The surcharge IS part of the taxable base. That is not an assumption — it is
+ * the only reading that reconciles the books. Policy 7006785 (MULT, semestral,
+ * two payments) prints IVA 52.03 and 47.84 against net premiums 610.86 / 589.71,
+ * surcharges 8.55 / 8.26 and policy fees 31.00 / 0.00; excluding the surcharge
+ * gives 51.35, which matches nothing on the page.
+ *
+ * The surcharge itself is NEVER derived. It is the carrier's financing charge
+ * for paying in installments, quoted per policy, so staff key it in. It only
+ * ever appears on a policy that is not paid annually or in a single exhibición
+ * — `surchargeApplies` is what the UI uses to grey the field out.
+ */
+
+/** Used when neither the policy nor its type carries a rate. Matches the
+ * single row both legacy IMPUESTOS tables held (0.08 = 8%). */
+export const DEFAULT_TAX_RATE = 0.08;
+
+export type PaymentFrequencyValue =
+ | "ANNUAL"
+ | "SEMIANNUAL"
+ | "QUARTERLY"
+ | "MONTHLY"
+ | "SINGLE";
+
+/** Paying in more than one exhibición is what earns a surcharge. A null
+ * frequency (every migrated row — Access's FORMA PAGO was dropped by the
+ * original ETL) is treated as "unknown, allow it" rather than "annual":
+ * refusing to show a figure that is sitting in the legacy data would hide it. */
+export function surchargeApplies(
+ frequency: PaymentFrequencyValue | null | undefined,
+): boolean {
+ return frequency !== "ANNUAL" && frequency !== "SINGLE";
+}
+
+function num(v: unknown): number {
+ if (v === null || v === undefined || v === "") return 0;
+ const n = typeof v === "number" ? v : Number(v);
+ return Number.isFinite(n) ? n : 0;
+}
+
+/** Half-up to cents, the way the printed policy rounds. */
+export function round2(n: number): number {
+ return Math.round((n + Number.EPSILON) * 100) / 100;
+}
+
+export interface PremiumParts {
+ netPremium?: unknown;
+ surcharge?: unknown;
+ policyFee?: unknown;
+}
+
+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: the figure stored on the policy (so an old policy keeps the
+ * rate it was issued at even after the catalog changes), else the rate on its
+ * line of business, else the shipped default. */
+export function resolveTaxRate(
+ policyRate: unknown,
+ policyTypeRate: unknown,
+): 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;
+}
diff --git a/apps/web/src/app/catalogos/page.tsx b/apps/web/src/app/catalogos/page.tsx
index f00fada..cd15acd 100644
--- a/apps/web/src/app/catalogos/page.tsx
+++ b/apps/web/src/app/catalogos/page.tsx
@@ -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 = {
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 7cac4dc..dee7495 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -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);
diff --git a/apps/web/src/app/polizas/[id]/page.tsx b/apps/web/src/app/polizas/[id]/page.tsx
index 419e8b6..609646c 100644
--- a/apps/web/src/app/polizas/[id]/page.tsx
+++ b/apps/web/src/app/polizas/[id]/page.tsx
@@ -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
}
/>
+
+ {/* 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 && (
+
+ )}
-
-
+ {/* 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 && (
+
+ )}
{/* 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 && (
-
+
)}
+
+ setValues({ ...values, [f.key]: e.target.value })}
/>
diff --git a/apps/web/src/components/PolicyForm.tsx b/apps/web/src/components/PolicyForm.tsx
index 2880db8..f8f9386 100644
--- a/apps/web/src/components/PolicyForm.tsx
+++ b/apps/web/src/components/PolicyForm.tsx
@@ -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({
-
Vigencia y prima
+
Vigencia
+
+
+
+
+
+
Primas
+
+ IVA y prima total se calculan solos sobre (prima neta + recargo +
+ derecho de póliza). Puede sobrescribirlos si la póliza impresa
+ redondea distinto.
+
+
+
+
+
+
diff --git a/apps/web/src/lib/premium.ts b/apps/web/src/lib/premium.ts
new file mode 100644
index 0000000..3deb93a
--- /dev/null
+++ b/apps/web/src/lib/premium.ts
@@ -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}%`;
+}
diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts
index 76e3c43..83b317a 100644
--- a/apps/web/src/lib/types.ts
+++ b/apps/web/src/lib/types.ts
@@ -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 = {
+ 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;
diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md
index 0856c30..1202cee 100644
--- a/docs/BACKLOG.md
+++ b/docs/BACKLOG.md
@@ -179,6 +179,28 @@ Each of these is a known, deliberate stopping point rather than a bug.
be added.
- No `SKIPPED_NO_EMAIL` worklist (see 1.9).
+**Captura de pólizas — desglose de primas** (built 2026-08-18)
+- **IVA y prima total no existen en los datos legacy.** En Access eran
+ controles calculados sin campo, así que las 2,378 pólizas migradas leen
+ `tax` y `total` en null hasta que alguien las edite. No es recuperable: no
+ hay de dónde.
+- **El OCR de A.N.A. lee `TAX` y `LOCAL TAX` y los tira.** El parser ya extrae
+ la fila `DISCOUNT | PREMIUM | POLICY FEE | TAX | LOCAL TAX | TOTAL`
+ (`policy-parser.ts`), pero `ParsedPolicy` no tiene campo para el impuesto,
+ así que la ruta OCR sigue guardando `tax` en null aunque el papel lo
+ imprima. Cerrarlo son: campo en `ParsedPolicy`, columna
+ `extractedTax` en `policy_ocr_documents`, campo en la pantalla de revisión,
+ y escritura en confirm. `LOCAL TAX` no tiene columna destino y habría que
+ decidir si suma al IVA o va aparte.
+- **El recargo no se valida contra la forma de pago en datos migrados.** El
+ formulario lo deshabilita en ANUAL/CONTADO, pero
+ `backfill_policy_premium_breakdown.py` solo advierte cuando encuentra una
+ póliza anual con recargo; no la corrige.
+- **Las parcialidades 3 y 4 no llevan desglose.** Access solo dibujó la fila
+ de dinero dos veces, así que una póliza trimestral capturada hoy sí puede
+ llenar las cuatro a mano, pero no hay nada legacy que migrar a las dos
+ últimas.
+
**Policy OCR** — [`POLICY_OCR.md`](POLICY_OCR.md)
- **GMX and A.N.A. only.** The dispatcher is a `[provider, pattern]` table plus
a parser map, so a third carrier is one function and two entries — but no
diff --git a/docs/INSURANCE_FEATURES_SPEC.md b/docs/INSURANCE_FEATURES_SPEC.md
index f624973..324a73d 100644
--- a/docs/INSURANCE_FEATURES_SPEC.md
+++ b/docs/INSURANCE_FEATURES_SPEC.md
@@ -440,6 +440,12 @@ take a `policyType` select param. The workflow is *not* MULT-only: the legacy
Params: ramo (with an "todos" option), aseguradora, date range on `policyFrom`.
Columns: póliza, cliente, ramo, aseguradora, vigencia, prima neta, forma de pago.
+
+ℹ️ `forma de pago` became a real column on 2026-08-18 (`Policy.paymentFrequency`).
+It is **null on every policy migrated before that date** — the original ETL
+marked Access's `FORMA PAGO` consumed and then never wrote it anywhere — so the
+report must render null as "—" rather than assuming annual. Running
+`backfill_policy_premium_breakdown.py` recovers it from the staged Parquet.
Totals: count + prima neta sum per currency (**never collapse MXN and USD** —
same constraint as the billing module).
diff --git a/migration/backfill_policy_premium_breakdown.py b/migration/backfill_policy_premium_breakdown.py
new file mode 100644
index 0000000..0b7e643
--- /dev/null
+++ b/migration/backfill_policy_premium_breakdown.py
@@ -0,0 +1,249 @@
+"""
+Recovers the premium breakdown the original policy transform dropped.
+
+`transform_policies.py` modeled prima neta, derecho de póliza and comisión and
+nothing else, which lost two things from every migrated policy:
+
+ 1. RECARGO — the financing surcharge on a policy paid in more than one
+ exhibición, plus the whole second money row (`p_neta_2`, `recargo_2`,
+ `d_pol_2`, `com_2`) that a semestral policy carries because each payment
+ is priced separately. These were not deleted, they were swept into
+ `policies.coveragesJson` as loose strings alongside the real coverages —
+ unqueryable, and mislabeled as coverage amounts.
+
+ 2. FORMA PAGO — dropped outright. The column was marked "consumed" by the
+ transform's coverage sweep but never written to any column, so it exists
+ nowhere in the platform database. It is the field that decides whether a
+ recargo is legitimate on a row at all, so it cannot be inferred back from
+ the money.
+
+The transform has been fixed in the same commit, so a full `run_all.py` now
+produces all of this directly. This script exists for a database that must not
+be re-imported: it reads the same staged Parquet and patches in place.
+
+What it does NOT do: invent IVA or the printed TOTAL. Those were never columns
+in the home tables — they were unbound calculated controls on the Access form —
+so there is genuinely nothing to recover, and both stay null until a human
+edits the policy. The app computes them (apps/api/src/policies/premium.ts).
+
+Idempotent, and never overwrites a non-null value: a figure a human has since
+corrected in the app wins over the legacy one.
+
+ ./.venv/bin/python backfill_policy_premium_breakdown.py --env dev
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from decimal import Decimal, InvalidOperation
+from pathlib import Path
+
+import pandas as pd
+
+from dbenv import connect
+from sync import parse_mode
+
+STG = Path(__file__).parent / "output" / "stg_seguros"
+LEGACY_DB = "SEGUROS 16_be"
+NULL = "∅"
+
+# Legacy column -> what it is, per source table. Only the tables that actually
+# carry a breakdown appear; the auto tables have no recargo and no second row.
+HOME_TABLES = ("mult", "incendio", "m_empr")
+
+# Keys the transform used to dump into coveragesJson that are now real columns.
+# Stripped once migrated so the blob stops pretending they are coverages.
+MIGRATED_COVERAGE_KEYS = (
+ "recargo", "recargo_2", "p_neta_2", "d_pol_2", "com_2",
+)
+
+_FREQ = {
+ "ANNUAL": "ANNUAL",
+ "ANUAL": "ANNUAL",
+ "SEMESTRAL": "SEMIANNUAL",
+ "TRIMESTRAL": "QUARTERLY",
+ "MENSUAL": "MONTHLY",
+ "CONTADO": "SINGLE",
+}
+
+
+def s(v):
+ if v is None or pd.isna(v):
+ return None
+ v = str(v).strip()
+ return None if v in ("", NULL) else v
+
+
+def dec(v):
+ v = s(v)
+ if v is None:
+ return None
+ try:
+ return Decimal(v.replace(",", ""))
+ except (InvalidOperation, ValueError):
+ return None
+
+
+def freq(v):
+ return _FREQ.get((s(v) or "").upper())
+
+
+def load(name):
+ df = pd.read_parquet(STG / f"{name}.parquet").sort_values("_row_num").reset_index(drop=True)
+ for c in df.columns:
+ if c != "_row_num":
+ df[c] = df[c].astype("string").str.strip()
+ return df
+
+
+def main():
+ env, _sync_mode = parse_mode()
+
+ # Fails closed rather than reporting a clean run over nothing: an empty
+ # staging directory and a policy set with no recargo look identical from
+ # the database side, and "0 rows updated" would read as success.
+ if not STG.exists():
+ print(f"[policy-premium] staged Parquet missing at {STG} — run extract/load first.")
+ return 3
+
+ conn = connect(env)
+ c = conn.cursor()
+ print(f"[policy-premium] target env: {env}")
+
+ # Every policy that came from the insurance ETL, keyed by provenance. The
+ # id is needed to reach the installments, coveragesJson to strip the keys.
+ c.execute(
+ "SELECT legacySourceTable, legacyId, id, coveragesJson "
+ "FROM policies WHERE legacySourceDb = %s AND legacyId IS NOT NULL",
+ (LEGACY_DB,),
+ )
+ by_key = {(t, lid): (pid, cov) for t, lid, pid, cov in c.fetchall()}
+ print(f" {len(by_key)} migrated polic(ies) in the target database")
+
+ pol_updates = [] # (surcharge, paymentFrequency, coveragesJson, policyId)
+ inst_updates = [] # (netPremium, surcharge, policyFee, commission, policyId, seq)
+ seen_tables = 0
+
+ for table in HOME_TABLES + (
+ "tabla_autos", "tabla_autos_ampl", "tabla_autos_limit",
+ "tabla_autos_ampl_r", "tabla_autos_rc_r", "mca2", "licencias",
+ ):
+ path = STG / f"{table}.parquet"
+ if not path.exists():
+ continue
+ seen_tables += 1
+ df = load(table)
+ home = table in HOME_TABLES
+
+ for _, row in df.iterrows():
+ key = (table, str(int(row["_row_num"])))
+ hit = by_key.get(key)
+ if not hit:
+ continue
+ pid, cov_raw = hit
+
+ surcharge = dec(row.get("recargo")) if home else None
+ frequency = freq(row.get("forma_pago"))
+
+ # Strip the now-modeled keys out of the coverage blob. Rewritten
+ # only when something actually changes, so a policy whose blob a
+ # human has edited is left byte-identical.
+ cov_new = None
+ if cov_raw:
+ try:
+ cov = json.loads(cov_raw) if isinstance(cov_raw, str) else cov_raw
+ except (TypeError, ValueError):
+ cov = None
+ if isinstance(cov, dict):
+ kept = {k: v for k, v in cov.items() if k not in MIGRATED_COVERAGE_KEYS}
+ if len(kept) != len(cov):
+ cov_new = json.dumps(kept, ensure_ascii=False) if kept else None
+
+ if surcharge is not None or frequency is not None or cov_new is not None:
+ pol_updates.append((surcharge, frequency, cov_new, cov_new is not None, pid))
+
+ # Per-payment breakdown. Slot 1 is the unsuffixed money row, slot 2
+ # the _2 twin; the auto tables have a single slot and no recargo.
+ if home:
+ slots = [
+ (1, "p_neta", "recargo", "d_pol", "com"),
+ (2, "p_neta_2", "recargo_2", "d_pol_2", "com_2"),
+ ]
+ else:
+ pn = "prima1" if table == "mca2" else "prima_neta"
+ dp = "d_poliza1" if table == "mca2" else "d_poliza"
+ slots = [(1, pn, None, dp, None)]
+
+ for seq, pn, rc, dp, cm in slots:
+ vals = (
+ dec(row.get(pn)) if pn else None,
+ dec(row.get(rc)) if rc else None,
+ dec(row.get(dp)) if dp else None,
+ dec(row.get(cm)) if cm else None,
+ )
+ if all(v is None for v in vals):
+ continue
+ inst_updates.append((*vals, pid, seq))
+
+ if not seen_tables:
+ print(f"[policy-premium] no policy tables staged under {STG} — nothing to do.")
+ return 3
+
+ # COALESCE on every target: a column a human has already filled in the app
+ # keeps its value, the legacy figure only lands where there is a hole.
+ for surcharge, frequency, cov_new, rewrite_cov, pid in pol_updates:
+ c.execute(
+ "UPDATE policies SET "
+ " surcharge = COALESCE(surcharge, %s), "
+ " paymentFrequency = COALESCE(paymentFrequency, %s), "
+ " coveragesJson = IF(%s, %s, coveragesJson) "
+ "WHERE id = %s",
+ (surcharge, frequency, 1 if rewrite_cov else 0, cov_new, pid),
+ )
+
+ for netp, surch, fee, comm, pid, seq in inst_updates:
+ c.execute(
+ "UPDATE policy_payment_installments SET "
+ " netPremium = COALESCE(netPremium, %s), "
+ " surcharge = COALESCE(surcharge, %s), "
+ " policyFee = COALESCE(policyFee, %s), "
+ " commission = COALESCE(commission, %s) "
+ "WHERE policyId = %s AND sequence = %s",
+ (netp, surch, fee, comm, pid, seq),
+ )
+
+ conn.commit()
+ print(f" policies : {len(pol_updates)} row(s) touched")
+ print(f" installments: {len(inst_updates)} row(s) touched")
+
+ # --- validation ---------------------------------------------------------
+ c.execute("SELECT COUNT(*) FROM policies WHERE surcharge IS NOT NULL AND surcharge <> 0")
+ n_surch = c.fetchone()[0]
+ c.execute("SELECT COUNT(*) FROM policies WHERE paymentFrequency IS NOT NULL")
+ n_freq = c.fetchone()[0]
+ c.execute(
+ "SELECT COUNT(*) FROM policies "
+ "WHERE paymentFrequency IN ('ANNUAL','SINGLE') AND surcharge IS NOT NULL AND surcharge <> 0"
+ )
+ n_bad = c.fetchone()[0]
+ c.execute(
+ "SELECT COUNT(*) FROM policies WHERE coveragesJson IS NOT NULL "
+ "AND JSON_EXTRACT(coveragesJson, '$.recargo') IS NOT NULL"
+ )
+ n_left = c.fetchone()[0]
+
+ print(f" -> policies with a recargo : {n_surch}")
+ print(f" -> policies with a forma pago : {n_freq}")
+ print(f" -> recargo still in coverages : {n_left}")
+
+ # A surcharge on an annual policy contradicts the rule the capture form
+ # enforces, so it is worth surfacing rather than leaving for someone to
+ # find in a total. It is a warning, not a failure: the books are the books.
+ if n_bad:
+ print(f" !! {n_bad} annual/contado polic(ies) carry a non-zero recargo — review by hand")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main() or 0)
diff --git a/migration/run_all.py b/migration/run_all.py
index 80caf98..eebbf7d 100644
--- a/migration/run_all.py
+++ b/migration/run_all.py
@@ -59,6 +59,12 @@ STEPS = [
# touches.
"backfill_statement_match_fields.py",
"transform_policies.py",
+ # Premium breakdown (recargo, per-payment figures, forma de pago).
+ # transform_policies.py now writes these directly, so on a full rebuild
+ # this is a no-op that re-asserts them; on a database migrated before the
+ # breakdown existed it is what recovers them out of coveragesJson.
+ # Must follow transform_policies.py, which truncates the installments.
+ "backfill_policy_premium_breakdown.py",
"transform_transactions.py",
"prune_empty_customers.py",
# Seeds the Scotiabank chequera that every SCOTHIA movement is booked into;
@@ -78,6 +84,12 @@ SYNC_STEPS = [
# touches.
"backfill_statement_match_fields.py",
"transform_policies.py",
+ # Premium breakdown (recargo, per-payment figures, forma de pago).
+ # transform_policies.py now writes these directly, so on a full rebuild
+ # this is a no-op that re-asserts them; on a database migrated before the
+ # breakdown existed it is what recovers them out of coveragesJson.
+ # Must follow transform_policies.py, which truncates the installments.
+ "backfill_policy_premium_breakdown.py",
"transform_transactions.py",
# Manual-safe prune: drops legacy-owned empties that the customer upsert
# re-creates from Parquet, but leaves manually-added customers alone.
diff --git a/migration/transform_policies.py b/migration/transform_policies.py
index 0df4a65..4db3365 100644
--- a/migration/transform_policies.py
+++ b/migration/transform_policies.py
@@ -18,6 +18,17 @@ Design (validated against staged data):
policies are skipped and counted (required FK).
- Payment slots: c_1er_pago is the first amount, pago_subsec the recurring
amount for slots 2-4; efectivo is a cash flag, no_cheque the check ref.
+ - Premium breakdown: a policy paid in more than one exhibicion prices EACH
+ payment separately, which is why the home tables carry the whole money row
+ twice (p_neta/recargo/d_pol/com and their _2 twins). The unsuffixed set is
+ the policy header, the suffixed one belongs to payment 2, and both are
+ written per installment as well. This used to be lost: `recargo` and the
+ _2 columns fell into coveragesJson as loose strings and forma_pago was
+ marked consumed but never written anywhere at all.
+ - IVA and the printed TOTAL are NOT in Access for the home tables. They were
+ unbound calculated controls on the form, so there is nothing to migrate;
+ the app computes them (apps/api/src/policies/premium.ts) from
+ (p_neta + recargo + d_pol) * rate.
- Any source column not explicitly modeled (coverage amounts: edificio,
contenidos, robo, cristales, ...) is preserved verbatim in coveragesJson,
so nothing is lost in consolidation.
@@ -93,20 +104,55 @@ def truthy(v):
return (s(v) or "0").lower() in {"1", "-1", "true", "si", "sí", "yes", "x"}
+# Access FORMA PAGO -> PaymentFrequency. The whole staged corpus holds exactly
+# four spellings (ANNUAL 1851, SEMESTRAL 29, semestral 2, CONTADO 1); anything
+# else is left null rather than guessed, because the value decides whether a
+# recargo is legitimate on the row.
+_FREQ = {
+ "ANNUAL": "ANNUAL",
+ "ANUAL": "ANNUAL",
+ "SEMESTRAL": "SEMIANNUAL",
+ "TRIMESTRAL": "QUARTERLY",
+ "MENSUAL": "MONTHLY",
+ "CONTADO": "SINGLE",
+}
+
+
+def freq(v):
+ return _FREQ.get((s(v) or "").upper())
+
+
+def slot_dec(row, slot, key):
+ """One figure of a payment's premium breakdown, or None when the source
+ table has no such column. Deliberately not zero: a zero d_pol on the second
+ payment is a real figure in the books and must stay distinguishable from
+ 'this table never had that column'."""
+ col = slot.get(key)
+ return dec(row.get(col)) if col else None
+
+
# --- per-table config ------------------------------------------------------- #
# fields: policy column -> source column. installments: list of slot dicts.
# vehicles: 'trip_underscore' | 'single' | 'mca2' | None. drivers: 'mca2' |
# 'licencias' | None.
+# `pneta`/`recarg`/`dpol`/`com` on a slot are that payment's own share of the
+# premium. Only the first two payments have one in Access — the form only ever
+# drew the money row twice — so slots 3 and 4 carry none and keep just the
+# amount actually collected.
HOME_INST = [
- dict(seq=1, amt="c_1er_pago", cu="moned", d="fecha_pago", ck="no_cheque", cash="efectivo"),
- dict(seq=2, amt="pago_subsec", cu="moned_2", d="fecha_pago_2", ck="no_cheque_2", cash="efectivo_2"),
+ dict(seq=1, amt="c_1er_pago", cu="moned", d="fecha_pago", ck="no_cheque", cash="efectivo",
+ pneta="p_neta", recarg="recargo", dpol="d_pol", com="com"),
+ dict(seq=2, amt="pago_subsec", cu="moned_2", d="fecha_pago_2", ck="no_cheque_2", cash="efectivo_2",
+ pneta="p_neta_2", recarg="recargo_2", dpol="d_pol_2", com="com_2"),
dict(seq=3, amt="pago_subsec", cu="moned_3", d="fecha_pago_3", ck="no_cheque_3", cash="efectivo_3"),
dict(seq=4, amt="pago_subsec", cu="moned_4", d="fecha_pago_4", ck="no_cheque_4", cash="efectivo_4"),
]
HOME_FIELDS = dict(polno="no_poliza", agent="agent", comp="comp", desde="desde", hasta="hasta",
- forma="forma_pago", curcol="moned", pneta="p_neta", dpol="d_pol", com="com",
+ forma="forma_pago", curcol="moned", pneta="p_neta", recarg="recargo",
+ dpol="d_pol", com="com",
liquidada="liquidada", numliq="num_liquidacion", fliq="f_liquida1", renov="renovacion")
-AUTO_SINGLE_INST = [dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque", cash="efectivo")]
+AUTO_SINGLE_INST = [dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque", cash="efectivo",
+ pneta="prima_neta", dpol="d_poliza")]
CONFIGS = {
"incendio": dict(ptype="INCENDIO", idcol="num_id", fields={**HOME_FIELDS, "curcol": "moneda"},
@@ -157,7 +203,8 @@ CONFIGS = {
forma="forma_pago", curcol="moneda", pneta="prima_neta", dpol="d_poliza",
total="total", liquidada="liquidada", numliq="num_liquidacion",
fliq="f_liquida1", renov="renovacion"),
- inst=[dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque", cash="efectivo")],
+ inst=[dict(seq=1, amt="total", cu="moneda", d="fecha_pago", ck="no_cheque",
+ cash="efectivo", pneta="prima_neta", dpol="d_poliza")],
veh=None, drv="licencias"),
}
@@ -200,6 +247,10 @@ def main():
consumed = {cfg["idcol"], *F.values()}
for slot in cfg["inst"]:
consumed |= {slot["amt"], slot["cu"], slot["d"], slot["ck"], slot["cash"]}
+ # The per-payment premium columns are now modeled, so they must
+ # leave the coveragesJson sweep — otherwise every recargo would be
+ # written twice, once as a column and once as a fake coverage.
+ consumed |= {slot[k] for k in ("pneta", "recarg", "dpol", "com") if slot.get(k)}
for _, row in df.iterrows():
cid = cust.get(norm_id(row[cfg["idcol"]]))
@@ -237,9 +288,11 @@ def main():
dt(row.get(F.get("desde", ""))) if F.get("desde") else None,
dt(row.get(F.get("hasta", ""))) if F.get("hasta") else None,
dec(row.get(F.get("pneta", ""))) if F.get("pneta") else None,
+ dec(row.get(F.get("recarg", ""))) if F.get("recarg") else None,
dec(row.get(F.get("dpol", ""))) if F.get("dpol") else None,
dec(row.get(F.get("com", ""))) if F.get("com") else None,
dec(row.get(F.get("total", ""))) if F.get("total") else None,
+ freq(row.get(F.get("forma", ""))) if F.get("forma") else None,
cur(row.get(F.get("curcol", ""))) if F.get("curcol") else "MXN",
s(row.get("observaciones")),
json.dumps(cov, ensure_ascii=False) if cov else None,
@@ -255,9 +308,12 @@ def main():
pdate = dt(row.get(slot["d"]))
if amt is None and pdate is None:
continue
+ # Slot breakdown, where the source table has one.
insts.append((str(uuid.uuid4()), pid, slot["seq"], amt,
cur(row.get(slot["cu"])), pdate, s(row.get(slot["ck"])),
- 1 if truthy(row.get(slot["cash"])) else 0))
+ 1 if truthy(row.get(slot["cash"])) else 0,
+ slot_dec(row, slot, "pneta"), slot_dec(row, slot, "recarg"),
+ slot_dec(row, slot, "dpol"), slot_dec(row, slot, "com")))
# vehicles
def add_vehicle(make, model, body, engine, plate, year=None, state=None):
@@ -328,16 +384,19 @@ def main():
1 if truthy(r["concluido"]) else 0, s(r["resolucion"])))
pol_cols = ("id,policyNumber,customerId,policyTypeId,insuranceProviderId,agentName,policyDate,"
- "policyFrom,policyTo,netPremium,policyFee,commission,total,currency,observations,"
+ "policyFrom,policyTo,netPremium,surcharge,policyFee,commission,total,paymentFrequency,"
+ "currency,observations,"
"coveragesJson,liquidated,liquidationNumber,liquidationDate,legacySourceDb,"
"legacySourceTable,legacyId,updatedAt")
- ph = ",".join(["%s"] * 23)
+ ph = ",".join(["%s"] * 25)
pol_upsert = (
f"INSERT INTO policies ({pol_cols}) VALUES ({ph}) ON DUPLICATE KEY UPDATE "
"customerId=VALUES(customerId),policyNumber=VALUES(policyNumber),policyTypeId=VALUES(policyTypeId),"
"insuranceProviderId=VALUES(insuranceProviderId),agentName=VALUES(agentName),policyDate=VALUES(policyDate),"
- "policyFrom=VALUES(policyFrom),policyTo=VALUES(policyTo),netPremium=VALUES(netPremium),policyFee=VALUES(policyFee),"
- "commission=VALUES(commission),total=VALUES(total),currency=VALUES(currency),observations=VALUES(observations),"
+ "policyFrom=VALUES(policyFrom),policyTo=VALUES(policyTo),netPremium=VALUES(netPremium),"
+ "surcharge=VALUES(surcharge),policyFee=VALUES(policyFee),"
+ "commission=VALUES(commission),total=VALUES(total),paymentFrequency=VALUES(paymentFrequency),"
+ "currency=VALUES(currency),observations=VALUES(observations),"
"coveragesJson=VALUES(coveragesJson),liquidated=VALUES(liquidated),liquidationNumber=VALUES(liquidationNumber),"
"liquidationDate=VALUES(liquidationDate),updatedAt=VALUES(updatedAt),archivedAt=NULL")
@@ -399,8 +458,9 @@ def main():
c.executemany("INSERT INTO policy_payment_installments "
- "(id,policyId,sequence,amount,currency,paidDate,checkNumber,isCash) "
- "VALUES (%s,%s,%s,%s,%s,%s,%s,%s)", insts)
+ "(id,policyId,sequence,amount,currency,paidDate,checkNumber,isCash,"
+ "netPremium,surcharge,policyFee,commission) "
+ "VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", insts)
c.executemany("INSERT INTO vehicles (id,customerId,policyId,make,model,modelYear,bodyType,"
"engineNumber,licensePlate,stateCode,legacySourceTable,legacyId) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)", vehicles)
diff --git a/packages/database/prisma/migrations/20260818120000_policy_premium_breakdown/migration.sql b/packages/database/prisma/migrations/20260818120000_policy_premium_breakdown/migration.sql
new file mode 100644
index 0000000..df8b198
--- /dev/null
+++ b/packages/database/prisma/migrations/20260818120000_policy_premium_breakdown/migration.sql
@@ -0,0 +1,37 @@
+-- Premium breakdown the Access capture form had and this schema did not:
+-- RECARGO, IVA and PRIMA TOTAL on the policy header, the same six figures per
+-- payment on the installments, and the FORMA PAGO that decides whether a
+-- surcharge applies at all.
+--
+-- IVA and TOTAL were never columns in Access — they were unbound calculated
+-- controls on the form — so there is nothing to backfill for them here and
+-- every migrated row stays null until somebody edits the policy. RECARGO and
+-- the per-installment figures DO exist in the legacy data; they are currently
+-- stranded inside `policies.coveragesJson` (the migration swept every column
+-- it did not model into that blob) and are recovered by
+-- `migration/backfill_policy_premium_breakdown.py`, not by this migration.
+
+ALTER TABLE `policy_types`
+ ADD COLUMN `taxRate` DECIMAL(6, 4) NULL;
+
+ALTER TABLE `policies`
+ ADD COLUMN `surcharge` DECIMAL(12, 2) NULL,
+ ADD COLUMN `tax` DECIMAL(12, 2) NULL,
+ ADD COLUMN `taxRate` DECIMAL(6, 4) NULL,
+ ADD COLUMN `paymentFrequency` ENUM('ANNUAL', 'SEMIANNUAL', 'QUARTERLY', 'MONTHLY', 'SINGLE') NULL;
+
+ALTER TABLE `policy_payment_installments`
+ ADD COLUMN `netPremium` DECIMAL(12, 2) NULL,
+ ADD COLUMN `surcharge` DECIMAL(12, 2) NULL,
+ ADD COLUMN `policyFee` DECIMAL(12, 2) NULL,
+ ADD COLUMN `tax` DECIMAL(12, 2) NULL,
+ ADD COLUMN `taxRate` DECIMAL(6, 4) NULL,
+ ADD COLUMN `total` DECIMAL(12, 2) NULL,
+ ADD COLUMN `commission` DECIMAL(12, 2) NULL;
+
+-- Seed the rate the books actually use. The legacy IMPUESTOS and
+-- IMPUESTOS_AUTOS tables each held exactly one row, both 0.0800, covering the
+-- home and auto lines respectively; applying it to every existing type
+-- reproduces current behaviour rather than changing it. Types created later
+-- start null and fall back to the API default.
+UPDATE `policy_types` SET `taxRate` = 0.0800 WHERE `taxRate` IS NULL;
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index 3a3f1e8..ebda282 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -158,10 +158,31 @@ model InsuranceProvider {
@@map("insurance_providers")
}
+/// How the premium is split into payments. Drives whether a surcharge
+/// applies at all: the legacy books only ever charge `recargo` on a policy
+/// paid in more than one exhibición, never on an annual one. Values come from
+/// the Access `FORMA PAGO` column (ANNUAL / SEMESTRAL / CONTADO) plus the
+/// quarterly option Jorge sells today but never recorded in Access.
+enum PaymentFrequency {
+ ANNUAL
+ SEMIANNUAL
+ QUARTERLY
+ MONTHLY
+ /// Legacy "CONTADO" — the whole premium in one payment, no schedule.
+ SINGLE
+}
+
model PolicyType {
id String @id @default(uuid())
name String @unique
shortDescription String?
+ /// IVA rate charged on this line of business, as a fraction (0.08 = 8%).
+ /// Replaces the legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables, which
+ /// held exactly one rate each — per line of business, editable without a
+ /// deploy, because the rate is a tax rule and tax rules change. Null falls
+ /// back to DEFAULT_TAX_RATE in the API rather than to "no tax", so a type
+ /// nobody has configured still computes the same 8% the books use today.
+ taxRate Decimal? @db.Decimal(6, 4)
policies Policy[]
@@map("policy_types")
@@ -185,10 +206,32 @@ model Policy {
policyTo DateTime?
coveragePeriodDays Int? @default(365)
netPremium Decimal? @db.Decimal(12, 2)
+ /// "Recargo" — the financing surcharge for paying in installments. Entered
+ /// by hand, never derived: it is quoted by the carrier, not computed here.
+ /// Only ever set when `paymentFrequency` is not ANNUAL/SINGLE, and it IS
+ /// part of the taxable base (verified against the Access books: policy
+ /// 7006785 only reconciles as (610.86 + 8.55 + 31.00) * 0.08 = 52.03).
+ surcharge Decimal? @db.Decimal(12, 2)
policyFee Decimal? @db.Decimal(12, 2)
brokerFee Decimal? @db.Decimal(12, 2)
commission Decimal? @db.Decimal(12, 2)
+ /// IVA. Access never stored this — it was an unbound calculated control on
+ /// the form — so every legacy row starts null and is filled going forward.
+ /// Stored rather than computed on read because the printed policy is the
+ /// record of truth and its rounding must survive a later rate change.
+ tax Decimal? @db.Decimal(12, 2)
+ /// The rate actually applied when `tax` was written, as a fraction. Kept on
+ /// the row so a policy issued at 8% still reads back as 8% after somebody
+ /// edits the PolicyType to a new rate.
+ taxRate Decimal? @db.Decimal(6, 4)
+ /// Prima total = netPremium + surcharge + policyFee + tax. Populated by the
+ /// capture form from now on. NOTE the legacy rows: `total` is 0 or null on
+ /// all but 2 of 2378 migrated policies, so list/sort code must keep using
+ /// netPremium as the headline (see policies.service.ts).
total Decimal? @db.Decimal(12, 2)
+ /// ANNUAL on all but 31 legacy rows — and the migration used to drop the
+ /// column entirely, so every pre-2026 policy reads null here.
+ paymentFrequency PaymentFrequency?
currency Currency @default(MXN)
observations String? @db.Text
notes String? @db.Text
@@ -267,6 +310,23 @@ model PolicyPaymentInstallment {
checkNumber String?
isCash Boolean @default(false)
+ // Per-payment premium breakdown. A policy paid in more than one exhibición
+ // prices EACH payment separately — its own net premium, its own surcharge,
+ // its own IVA — which is why the Access form printed the whole money row
+ // twice (P NETA / RECARGO / D POL / IVA / TOTAL / COM, once per pago) and
+ // why these cannot live on the policy header alone. `amount` stays the
+ // authoritative figure actually collected: it is what the cheque was
+ // written for and it drifts from `total` by a peso or two in the books
+ // (policy 7006785: amount 702.73 vs total 702.44), so it is deliberately
+ // NOT recomputed from this breakdown.
+ netPremium Decimal? @db.Decimal(12, 2)
+ surcharge Decimal? @db.Decimal(12, 2)
+ policyFee Decimal? @db.Decimal(12, 2)
+ tax Decimal? @db.Decimal(12, 2)
+ taxRate Decimal? @db.Decimal(6, 4)
+ total Decimal? @db.Decimal(12, 2)
+ commission Decimal? @db.Decimal(12, 2)
+
@@map("policy_payment_installments")
}
@@ -430,25 +490,25 @@ model PolicyOcrDocument {
provider String?
// Extracted header fields, all staff-editable in review.
- extractedPolicyNumber String?
- extractedInsuredName String?
- extractedAdditionalInsured String?
- extractedAgentName String?
- extractedLegalAddress String? @db.Text
- extractedZip String?
- extractedPolicyFrom DateTime?
- extractedPolicyTo DateTime?
- extractedPolicyDate DateTime?
- extractedCurrency String?
- extractedNetPremium Decimal? @db.Decimal(12, 2)
- extractedPolicyFee Decimal? @db.Decimal(12, 2)
- extractedBrokerFee Decimal? @db.Decimal(12, 2)
- extractedTotal Decimal? @db.Decimal(12, 2)
+ extractedPolicyNumber String?
+ extractedInsuredName String?
+ extractedAdditionalInsured String?
+ extractedAgentName String?
+ extractedLegalAddress String? @db.Text
+ extractedZip String?
+ extractedPolicyFrom DateTime?
+ extractedPolicyTo DateTime?
+ extractedPolicyDate DateTime?
+ extractedCurrency String?
+ extractedNetPremium Decimal? @db.Decimal(12, 2)
+ extractedPolicyFee Decimal? @db.Decimal(12, 2)
+ extractedBrokerFee Decimal? @db.Decimal(12, 2)
+ extractedTotal Decimal? @db.Decimal(12, 2)
/// Per-coverage rows from the GMX "Material damages" / "Additional risk"
/// tables and ANA's numbered risk sections — preserved verbatim so a
/// missing premium receipt still leaves the coverages auditable.
- extractedCoveragesJson Json?
- extractedPremiumPayment String?
+ extractedCoveragesJson Json?
+ extractedPremiumPayment String?
/// Printed term length. ANA sells 3- and 4-day tourist policies, so
/// leaving `Policy.coveragePeriodDays` at its 365 default would overstate
/// a weekend policy by a year.
@@ -456,26 +516,26 @@ model PolicyOcrDocument {
/// `ParsedVehicle[]` off ANA's ITEM/YEAR/MAKE/BODY/SERIAL/PLATES table.
/// Written to `Vehicle` rows on confirm; kept here so the review screen
/// shows what was read before anything is applied.
- extractedVehiclesJson Json?
+ extractedVehiclesJson Json?
/// `ParsedDriver[]` — the insured on ANA's automobile face, the numbered
/// POLICY HOLDER list on its driver's policy. Written to `InsuredDriver`
/// rows on confirm.
- extractedDriversJson Json?
+ extractedDriversJson Json?
/// The `PolicyType.name` the parser read the product as ("AUTO",
/// "LICENCIAS", "MULT"). A NAME, not an id — the parser never touches the
/// database, so confirm resolves it against `policy_types` and leaves
/// `Policy.policyTypeId` null if there is no such row.
- extractedPolicyTypeName String?
+ extractedPolicyTypeName String?
// Match by `Policy.policyNumber` → existing Policy / Customer.
- matchedPolicyId String?
- matchedPolicy Policy? @relation("PolicyOcrDocumentPolicy", fields: [matchedPolicyId], references: [id])
- matchedCustomerId String?
- matchedCustomer Customer? @relation("PolicyOcrDocumentCustomer", fields: [matchedCustomerId], references: [id])
+ matchedPolicyId String?
+ matchedPolicy Policy? @relation("PolicyOcrDocumentPolicy", fields: [matchedPolicyId], references: [id])
+ matchedCustomerId String?
+ matchedCustomer Customer? @relation("PolicyOcrDocumentCustomer", fields: [matchedCustomerId], references: [id])
/// All policies carrying the same number, with their customer. One is
/// normal; >1 means the policy number is shared across customers and a
/// human must pick.
- matchCandidates Json?
+ matchCandidates Json?
/// `CustomerNameSuggestion[]` — customers whose name matches the printed
/// insured name, ranked. A SUGGESTION, never a match: it is deliberately
/// kept out of `matchCandidates` so the review screen cannot mistake a
@@ -487,7 +547,7 @@ model PolicyOcrDocument {
/// a multi-section ANA policy runs past 191 characters routinely. Silently
/// truncating it drops the tail notes, which are the ones that say what
/// could NOT be read.
- matchNote String? @db.Text
+ matchNote String? @db.Text
reviewedById String?
reviewedBy User? @relation("PolicyOcrDocumentReviewer", fields: [reviewedById], references: [id])
@@ -1021,8 +1081,8 @@ enum EmailNotificationStatus {
/// we store it always, so a customer reply quoting an old email can be traced
/// to the exact letter that was sent.
model EmailNotificationLog {
- id String @id @default(uuid())
- sendDate DateTime @default(now())
+ id String @id @default(uuid())
+ sendDate DateTime @default(now())
notificationType EmailNotificationType
/// Per-type discriminator, null where the type has none:
/// ACCOUNT_STATUS → 0 = yellow ("DEBAJO DEL TIPO"), 1 = red ("EN ROJO")
@@ -1040,7 +1100,7 @@ model EmailNotificationLog {
/// resolve the owner through `Property.customerId`, so this stays set on
/// job 4 too. Null only on skipped rows where the lookup itself failed.
customerId String?
- customer Customer? @relation(fields: [customerId], references: [id])
+ customer Customer? @relation(fields: [customerId], references: [id])
customerName String
customerEmail String
/// Subject line of the email we attempted to send.
@@ -1048,16 +1108,16 @@ model EmailNotificationLog {
/// For PAYMENT_CONFIRMATION: the per-customer URL the PHP code built and
/// fetched (kept verbatim so the legacy format is reproducible). Null on
/// the other three jobs — the body is built inline.
- bodyRequestUrl String? @db.Text
+ bodyRequestUrl String? @db.Text
/// The HTML body that was sent (or that would have been sent, for SKIPPED
/// rows). Stored verbatim so audit/customer-service can read the exact
/// letter that went out without re-running the render.
- bodySnapshot String @db.Text
+ bodySnapshot String @db.Text
/// True when `debug` was passed — the recipient was overridden to the
/// admin address and no real customer received the mail. Kept here so a
/// "where did all these emails go" investigation finds the answer in one
/// place instead of "who ran what with what flags" archaeology.
- debug Boolean @default(false)
+ debug Boolean @default(false)
/// SES SendEmail MessageId, when we actually got one back. Null on
/// failures, skipped rows, and dev/mock transport.
providerMessageId String?
@@ -1065,7 +1125,7 @@ model EmailNotificationLog {
/// insert so a verbose SES bounce payload can't blow the column.
providerResponse String?
status EmailNotificationStatus
- error String? @db.Text
+ error String? @db.Text
@@index([sendDate])
@@index([notificationType, sendDate])