feat(policies): capture the full premium breakdown
The capture form only ever had prima neta, derecho de póliza and comisión.
The Access form it replaces has seven figures, and the four that were missing
are the ones that make a policy paid in installments add up.
Adds recargo, IVA, prima total and forma de pago to the policy header, the
same breakdown per installment, and a per-line-of-business IVA rate.
IVA and prima total are the only derived figures:
base = prima neta + recargo + derecho de póliza
IVA = round(base * tasa)
total = base + IVA
The recargo is inside the taxable base. That is not a guess — policy 7006785
prints IVA 52.03 on 610.86 + 8.55 + 31.00, and leaving the recargo out gives
51.35, which matches nothing on the page. Both of its money rows are asserted
in premium.spec.ts. The recargo itself is never derived: the carrier quotes it,
so staff key it in, and the field is disabled on ANNUAL/SINGLE. Both derived
figures are stored rather than recomputed on read, and stay editable, because
the printed policy is the record of truth and a later rate change must not
silently restate what was issued.
The rate lives on PolicyType (seeded to 0.08, editable in Catálogos), which is
the legacy one-row IMPUESTOS / IMPUESTOS_AUTOS tables made configurable. The
rate applied is stamped on the policy so an old one reads back at its original
rate.
Per-installment, not two fixed slots on the header: a policy split into several
exhibiciones prices each payment separately — that is why the Access form drew
the money row twice — and a trimestral policy needs four, which the Access
layout could not hold.
Also fixes two losses in the ETL, which is how these went missing:
- `forma_pago` was marked consumed by the coverage sweep and then never
written to any column, so FORMA PAGO existed nowhere in the platform.
- `recargo` and the whole second money row fell into `coveragesJson` as
loose strings, mislabeled as coverage amounts.
transform_policies.py now writes all of it directly;
backfill_policy_premium_breakdown.py recovers it on a database that must not be
re-imported, and strips the migrated keys back out of coveragesJson. Both are
COALESCE-only, so a figure a human has corrected in the app wins.
IVA and TOTAL are NOT backfilled: they were unbound calculated controls on the
Access form, never columns, so there is nothing to recover and every migrated
policy reads null until it is edited.
The backfill warns on 5 annual policies that carry a non-zero recargo — a
contradiction that predates this change and is left for a human, not silently
corrected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -26,7 +26,13 @@ import {
|
||||
premiumHeadline,
|
||||
SIN_NOMBRE,
|
||||
} from "@/lib/labels";
|
||||
import type { AdjusterRow, Installment, PolicyDetail } from "@/lib/types";
|
||||
import {
|
||||
PAYMENT_FREQUENCY_LABELS,
|
||||
type AdjusterRow,
|
||||
type Installment,
|
||||
type PolicyDetail,
|
||||
} from "@/lib/types";
|
||||
import { formatRate } from "@/lib/premium";
|
||||
|
||||
export default function PolizaDetailPage({
|
||||
params,
|
||||
@@ -186,6 +192,10 @@ function ChildrenEditor({
|
||||
const INSTALLMENTS: ChildConfig = {
|
||||
apiKind: "installments",
|
||||
title: "Pagos",
|
||||
// A policy paid in several exhibiciones prices each payment on its own, so
|
||||
// the whole premium breakdown repeats per row — that is the two-row money
|
||||
// block on the Access form. `amount` stays what was actually collected and
|
||||
// is deliberately separate from `total`; they differ by rounding.
|
||||
fields: [
|
||||
{ key: "sequence", label: "Sec.", type: "number" },
|
||||
{ key: "amount", label: "Monto", type: "number" },
|
||||
@@ -195,6 +205,12 @@ function ChildrenEditor({
|
||||
{ key: "paidDate", label: "Pagado", type: "date" },
|
||||
{ key: "checkNumber", label: "Cheque" },
|
||||
{ key: "isCash", label: "Efectivo", type: "checkbox" },
|
||||
{ key: "netPremium", label: "Prima neta", type: "number" },
|
||||
{ key: "surcharge", label: "Recargo", type: "number" },
|
||||
{ key: "policyFee", label: "Derecho", type: "number" },
|
||||
{ key: "tax", label: "IVA", type: "number" },
|
||||
{ key: "total", label: "Prima total", type: "number" },
|
||||
{ key: "commission", label: "Comisión", type: "number" },
|
||||
],
|
||||
};
|
||||
const VEHICLES: ChildConfig = {
|
||||
@@ -412,15 +428,40 @@ function CondicionesSection({ data }: { data: PolicyDetail }) {
|
||||
data.coveragePeriodDays ? `${data.coveragePeriodDays} días` : null
|
||||
}
|
||||
/>
|
||||
<KV
|
||||
label="Forma de pago"
|
||||
value={
|
||||
data.paymentFrequency
|
||||
? PAYMENT_FREQUENCY_LABELS[data.paymentFrequency]
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<KV label="Prima neta" value={formatMoney(data.netPremium, cur)} />
|
||||
{/* Only ever set on a policy paid in installments, so showing an
|
||||
empty row on the other 98% would be noise. */}
|
||||
{data.surcharge != null && Number(data.surcharge) !== 0 && (
|
||||
<KV label="Recargo" value={formatMoney(data.surcharge, cur)} />
|
||||
)}
|
||||
<KV label="Derecho de póliza" value={formatMoney(data.policyFee, cur)} />
|
||||
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
||||
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
||||
{/* Access never stored IVA — it was a calculated control on the form
|
||||
— so every migrated policy reads null here until it is edited. */}
|
||||
{data.tax != null && (
|
||||
<KV
|
||||
label={
|
||||
data.taxRate != null
|
||||
? `IVA (${formatRate(Number(data.taxRate))})`
|
||||
: "IVA"
|
||||
}
|
||||
value={formatMoney(data.tax, cur)}
|
||||
/>
|
||||
)}
|
||||
{/* The legacy `total` is 0 or null on all but 2 of 2378 policies —
|
||||
only show it when it actually carries a figure. */}
|
||||
{data.total != null && Number(data.total) > 0 && (
|
||||
<KV label="Total" value={formatMoney(data.total, cur)} />
|
||||
<KV label="Prima total" value={formatMoney(data.total, cur)} />
|
||||
)}
|
||||
<KV label="Comisión" value={formatMoney(data.commission, cur)} />
|
||||
<KV label="Honorarios" value={formatMoney(data.brokerFee, cur)} />
|
||||
<KV
|
||||
label="Liquidación"
|
||||
value={
|
||||
|
||||
@@ -9,6 +9,9 @@ export type FieldDef = {
|
||||
type?: "text" | "number" | "date" | "checkbox" | "select";
|
||||
options?: { value: string; label: string }[];
|
||||
width?: number;
|
||||
/** Numeric granularity. Defaults to money (0.01); a tax rate stored as a
|
||||
* fraction needs finer, or the browser rejects 0.0825 as off-step. */
|
||||
step?: string;
|
||||
};
|
||||
|
||||
export type ChildConfig = {
|
||||
@@ -158,7 +161,7 @@ export function ChildCollection({
|
||||
<input
|
||||
className="input"
|
||||
type={f.type === "number" ? "number" : f.type === "date" ? "date" : "text"}
|
||||
step={f.type === "number" ? "0.01" : undefined}
|
||||
step={f.type === "number" ? f.step ?? "0.01" : undefined}
|
||||
value={String(values[f.key] ?? "")}
|
||||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||||
/>
|
||||
|
||||
@@ -4,12 +4,22 @@ import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CustomerPicker } from "@/components/CustomerPicker";
|
||||
import { createPolicy, getLookups, updatePolicy } from "@/lib/api";
|
||||
import type {
|
||||
Currency,
|
||||
LookupsResponse,
|
||||
PolicyDetail,
|
||||
PolicyInput,
|
||||
import {
|
||||
PAYMENT_FREQUENCY_LABELS,
|
||||
type Currency,
|
||||
type LookupsResponse,
|
||||
type PaymentFrequency,
|
||||
type PolicyDetail,
|
||||
type PolicyInput,
|
||||
} from "@/lib/types";
|
||||
import {
|
||||
computeTax,
|
||||
computeTotal,
|
||||
formatRate,
|
||||
resolveTaxRate,
|
||||
surchargeApplies,
|
||||
taxableBase,
|
||||
} from "@/lib/premium";
|
||||
|
||||
function toDateInput(v: string | null | undefined): string {
|
||||
if (!v) return "";
|
||||
@@ -36,9 +46,16 @@ type V = {
|
||||
policyFrom: string;
|
||||
policyTo: string;
|
||||
netPremium: string;
|
||||
surcharge: string;
|
||||
policyFee: string;
|
||||
brokerFee: string;
|
||||
commission: string;
|
||||
/** Blank means "use the computed figure". Only ever holds a value once the
|
||||
* operator overrides it, so a later change to prima neta keeps flowing
|
||||
* through instead of being frozen by a value the form itself wrote. */
|
||||
tax: string;
|
||||
total: string;
|
||||
paymentFrequency: PaymentFrequency | "";
|
||||
currency: Currency;
|
||||
liquidated: boolean;
|
||||
liquidationNumber: string;
|
||||
@@ -58,9 +75,13 @@ function initial(p?: PolicyDetail): V {
|
||||
policyFrom: toDateInput(p?.policyFrom),
|
||||
policyTo: toDateInput(p?.policyTo),
|
||||
netPremium: p?.netPremium != null ? String(p.netPremium) : "",
|
||||
surcharge: p?.surcharge != null ? String(p.surcharge) : "",
|
||||
policyFee: p?.policyFee != null ? String(p.policyFee) : "",
|
||||
brokerFee: p?.brokerFee != null ? String(p.brokerFee) : "",
|
||||
commission: p?.commission != null ? String(p.commission) : "",
|
||||
tax: p?.tax != null ? String(p.tax) : "",
|
||||
total: p?.total != null ? String(p.total) : "",
|
||||
paymentFrequency: p?.paymentFrequency ?? "",
|
||||
currency: (p?.currency as Currency) ?? "MXN",
|
||||
liquidated: p?.liquidated ?? false,
|
||||
liquidationNumber: p?.liquidationNumber ?? "",
|
||||
@@ -101,6 +122,27 @@ export function PolicyForm({
|
||||
setV((p) => ({ ...p, [k]: val }));
|
||||
}
|
||||
|
||||
// IVA and Total are the only two figures the form derives. Everything else,
|
||||
// the recargo included, is keyed by hand — the carrier quotes the financing
|
||||
// charge, we do not compute it.
|
||||
const selectedType = lookups?.types.find((t) => t.id === v.policyTypeId);
|
||||
const taxRate = resolveTaxRate(policy?.taxRate, selectedType?.taxRate);
|
||||
const parts = {
|
||||
netPremium: v.netPremium,
|
||||
// A recargo on an annual policy is a data-entry mistake, so it is dropped
|
||||
// from the arithmetic as well as disabled in the UI. Otherwise switching
|
||||
// ANNUAL after typing one would leave it silently inflating the IVA.
|
||||
surcharge: surchargeApplies(v.paymentFrequency || null) ? v.surcharge : "",
|
||||
policyFee: v.policyFee,
|
||||
};
|
||||
const computedTax = computeTax(parts, taxRate);
|
||||
const computedTotal = computeTotal(parts, taxRate);
|
||||
// Blank field = take the computed figure. A typed one wins, so staff can key
|
||||
// the carrier's rounding verbatim when it disagrees with ours by a centavo.
|
||||
const effectiveTax = v.tax.trim() === "" ? computedTax : Number(v.tax);
|
||||
const effectiveTotal = v.total.trim() === "" ? computedTotal : Number(v.total);
|
||||
const showSurcharge = surchargeApplies(v.paymentFrequency || null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!customerId) {
|
||||
@@ -118,9 +160,17 @@ export function PolicyForm({
|
||||
policyFrom: s(v.policyFrom),
|
||||
policyTo: s(v.policyTo),
|
||||
netPremium: numOrUndef(v.netPremium),
|
||||
surcharge: showSurcharge ? numOrUndef(v.surcharge) : undefined,
|
||||
policyFee: numOrUndef(v.policyFee),
|
||||
brokerFee: numOrUndef(v.brokerFee),
|
||||
commission: numOrUndef(v.commission),
|
||||
// The derived figures are persisted, not recomputed on read: the printed
|
||||
// policy is the record of truth and a later rate change must not silently
|
||||
// restate what was issued. `taxRate` rides along for the same reason.
|
||||
tax: Number.isFinite(effectiveTax) ? effectiveTax : undefined,
|
||||
taxRate,
|
||||
total: Number.isFinite(effectiveTotal) ? effectiveTotal : undefined,
|
||||
paymentFrequency: v.paymentFrequency || undefined,
|
||||
currency: v.currency,
|
||||
liquidated: v.liquidated,
|
||||
liquidationNumber: s(v.liquidationNumber),
|
||||
@@ -208,7 +258,7 @@ export function PolicyForm({
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia y prima</h2>
|
||||
<h2 className="section-title" style={{ marginBottom: 14 }}>Vigencia</h2>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Emisión</span>
|
||||
@@ -225,21 +275,84 @@ export function PolicyForm({
|
||||
<input className="input" type="date" value={v.policyTo}
|
||||
onChange={(e) => set("policyTo", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Forma de pago</span>
|
||||
<select className="select" value={v.paymentFrequency}
|
||||
onChange={(e) =>
|
||||
set("paymentFrequency", e.target.value as PaymentFrequency | "")
|
||||
}>
|
||||
<option value="">—</option>
|
||||
{(
|
||||
Object.keys(PAYMENT_FREQUENCY_LABELS) as PaymentFrequency[]
|
||||
).map((f) => (
|
||||
<option key={f} value={f}>{PAYMENT_FREQUENCY_LABELS[f]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 20, marginBottom: 16 }}>
|
||||
<h2 className="section-title" style={{ marginBottom: 4 }}>Primas</h2>
|
||||
<p className="muted" style={{ fontSize: 12, marginBottom: 14 }}>
|
||||
IVA y prima total se calculan solos sobre (prima neta + recargo +
|
||||
derecho de póliza). Puede sobrescribirlos si la póliza impresa
|
||||
redondea distinto.
|
||||
</p>
|
||||
<div className="form-grid">
|
||||
<label className="field">
|
||||
<span className="field-label">Prima neta</span>
|
||||
<input className="input" type="number" step="0.01" value={v.netPremium}
|
||||
onChange={(e) => set("netPremium", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Recargo</span>
|
||||
<input className="input" type="number" step="0.01" value={v.surcharge}
|
||||
disabled={!showSurcharge}
|
||||
onChange={(e) => set("surcharge", e.target.value)} />
|
||||
<span className="field-hint">
|
||||
{showSurcharge
|
||||
? "Lo cotiza la aseguradora — se captura a mano."
|
||||
: "No aplica en pago anual ni de contado."}
|
||||
</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Derecho de póliza</span>
|
||||
<input className="input" type="number" step="0.01" value={v.policyFee}
|
||||
onChange={(e) => set("policyFee", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">IVA ({formatRate(taxRate)})</span>
|
||||
<input className="input" type="number" step="0.01"
|
||||
placeholder={computedTax.toFixed(2)} value={v.tax}
|
||||
onChange={(e) => set("tax", e.target.value)} />
|
||||
<span className="field-hint">
|
||||
Calculado: {computedTax.toFixed(2)} sobre base{" "}
|
||||
{taxableBase(parts).toFixed(2)}
|
||||
{selectedType?.taxRate == null &&
|
||||
policy?.taxRate == null &&
|
||||
" · tasa por omisión, configúrela en Catálogos"}
|
||||
</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Prima total</span>
|
||||
<input className="input" type="number" step="0.01"
|
||||
placeholder={computedTotal.toFixed(2)} value={v.total}
|
||||
onChange={(e) => set("total", e.target.value)} />
|
||||
<span className="field-hint">
|
||||
Calculado: {computedTotal.toFixed(2)}
|
||||
</span>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Comisión</span>
|
||||
<input className="input" type="number" step="0.01" value={v.commission}
|
||||
onChange={(e) => set("commission", e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span className="field-label">Honorarios</span>
|
||||
<input className="input" type="number" step="0.01" value={v.brokerFee}
|
||||
onChange={(e) => set("brokerFee", e.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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}%`;
|
||||
}
|
||||
@@ -3,6 +3,24 @@
|
||||
|
||||
export type Currency = "USD" | "MXN";
|
||||
|
||||
/** How the premium is split into payments. Anything other than ANNUAL/SINGLE
|
||||
* is what earns a recargo. Null on every migrated policy — the original ETL
|
||||
* dropped Access's FORMA PAGO column entirely. */
|
||||
export type PaymentFrequency =
|
||||
| "ANNUAL"
|
||||
| "SEMIANNUAL"
|
||||
| "QUARTERLY"
|
||||
| "MONTHLY"
|
||||
| "SINGLE";
|
||||
|
||||
export const PAYMENT_FREQUENCY_LABELS: Record<PaymentFrequency, string> = {
|
||||
ANNUAL: "Anual",
|
||||
SEMIANNUAL: "Semestral",
|
||||
QUARTERLY: "Trimestral",
|
||||
MONTHLY: "Mensual",
|
||||
SINGLE: "Contado",
|
||||
};
|
||||
|
||||
export type Role = "ADMIN" | "MANAGER" | "STAFF" | "VIEWER";
|
||||
|
||||
export type Ability =
|
||||
@@ -289,6 +307,16 @@ export interface Installment {
|
||||
paidDate: string | null;
|
||||
checkNumber: string | null;
|
||||
isCash: boolean;
|
||||
/** Per-payment premium breakdown — a policy paid in several exhibiciones
|
||||
* prices each one separately. `amount` is what was actually collected and
|
||||
* can differ from `total` by rounding; it is not derived from these. */
|
||||
netPremium: string | null;
|
||||
surcharge: string | null;
|
||||
policyFee: string | null;
|
||||
tax: string | null;
|
||||
taxRate: string | null;
|
||||
total: string | null;
|
||||
commission: string | null;
|
||||
}
|
||||
|
||||
export interface Vehicle {
|
||||
@@ -398,10 +426,14 @@ export interface PolicyInput {
|
||||
policyTo?: string;
|
||||
coveragePeriodDays?: number;
|
||||
netPremium?: number;
|
||||
surcharge?: number;
|
||||
policyFee?: number;
|
||||
brokerFee?: number;
|
||||
commission?: number;
|
||||
tax?: number;
|
||||
taxRate?: number;
|
||||
total?: number;
|
||||
paymentFrequency?: PaymentFrequency;
|
||||
currency?: Currency;
|
||||
observations?: string;
|
||||
notes?: string;
|
||||
@@ -419,6 +451,13 @@ export interface InstallmentInput {
|
||||
paidDate?: string;
|
||||
checkNumber?: string;
|
||||
isCash?: boolean;
|
||||
netPremium?: number;
|
||||
surcharge?: number;
|
||||
policyFee?: number;
|
||||
tax?: number;
|
||||
taxRate?: number;
|
||||
total?: number;
|
||||
commission?: number;
|
||||
}
|
||||
export interface VehicleInput {
|
||||
make?: string;
|
||||
@@ -469,6 +508,10 @@ export interface PolicyTypeRow {
|
||||
id: string;
|
||||
name: string;
|
||||
shortDescription: string | null;
|
||||
/** IVA fraction for this line of business, 0.08 = 8%. Null means "not
|
||||
* configured" and the form falls back to DEFAULT_TAX_RATE — it does NOT
|
||||
* mean the line is untaxed. Serialized as a decimal string by Prisma. */
|
||||
taxRate: string | null;
|
||||
_count?: { policies: number };
|
||||
}
|
||||
export interface AdjusterRow {
|
||||
@@ -567,10 +610,14 @@ export interface PolicyDetail {
|
||||
policyTo: string | null;
|
||||
coveragePeriodDays: number | null;
|
||||
netPremium: string | null;
|
||||
surcharge: string | null;
|
||||
policyFee: string | null;
|
||||
brokerFee: string | null;
|
||||
commission: string | null;
|
||||
tax: string | null;
|
||||
taxRate: string | null;
|
||||
total: string | null;
|
||||
paymentFrequency: PaymentFrequency | null;
|
||||
currency: string | null;
|
||||
observations: string | null;
|
||||
notes: string | null;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
+37
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user