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])