feat(policy-ocr): store the IVA A.N.A. already prints
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m16s
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m50s

The parser has been reading A.N.A.'s `TAX` cell since the ANA layout landed,
but `ParsedPolicy` had nowhere to put it, so the figure only ever reached a
review note and every OCR-confirmed policy was written with `tax` null — even
though the paper states it.

`TAX` now flows parser -> `extractedTax` -> `Policy.tax`, alongside the premium
fields beside it. GMX stays null: its certificate carries no premium at all, so
there is no tax on it either.

The other two cells stay out, for reasons worth keeping:

  - `LOCAL TAX` is a separate levy with no destination column, and summing it
    into `tax` would produce an IVA that no longer divides back to a rate —
    which is the whole reason to store the figure. It reads 0.00 on every
    policy seen so far; a non-zero one now raises a note saying the total will
    not reconcile, instead of quietly inflating the IVA.
  - `DISCOUNT` has no column and prints as a bare "-" when unused, which is
    what makes the row positional rather than "find six amounts".

`taxRate` is left null by confirm. A.N.A. prints the amount, not the rate, and
back-dividing it would mint a rate the document never stated; the capture form
resolves one from the line of business instead.

The review screen gains derecho de póliza next to the new IVA field. It was
already parsed and already written on confirm, but never shown — and an IVA
with no fee beside it leaves the reviewer unable to see why premium + fee + tax
equals the printed total.

The spec's assertion moved off the note and onto the field, plus a check that
the row reconciles: 298.61 + 30.00 at 8% is 26.29, totalling 354.90. That
agreement is what proves the positional mapping landed on the right cells
rather than merely on six numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 07:38:23 -07:00
co-authored by Claude Opus 5
parent 48e01ddd21
commit 75e9f582b4
10 changed files with 128 additions and 11 deletions
@@ -689,8 +689,23 @@ describe("parsePolicy / ANA automobile", () => {
// reading would shift every value one column left.
expect(p.netPremium).toBe(298.61);
expect(p.policyFee).toBe(30);
expect(p.tax).toBe(26.29);
expect(p.total).toBe(354.9);
expect(p.notes.join(" | ")).toMatch(/impuesto: 26\.29/);
});
it("reads a TAX that reconciles against the rest of the row", () => {
// 298.61 + 30.00 = 328.61, taxed at 8% -> 26.29, totalling 354.90. The
// whole row agreeing is what proves the positional mapping landed on the
// right cells rather than merely on six numbers.
const base = p.netPremium! + p.policyFee!;
expect(Math.round(base * 0.08 * 100) / 100).toBe(p.tax);
expect(Math.round((base + p.tax!) * 100) / 100).toBe(p.total);
});
it("does not fold LOCAL TAX into the IVA", () => {
// It prints 0.00 here, so nothing to fold — but the guard is that a
// non-zero one would surface as a note instead of inflating `tax`.
expect(p.notes.join(" | ")).not.toMatch(/impuesto local/);
});
it("reads the vehicle by token role, not by column", () => {
@@ -36,6 +36,17 @@ export interface ParsedPolicy {
netPremium: number | null;
policyFee: number | null;
brokerFee: number | null;
/**
* IVA, off A.N.A.'s `TAX` cell. Null on GMX — its certificate carries no
* premium at all, so there is no tax on it to read either.
*
* The adjacent `LOCAL TAX` cell is deliberately NOT folded in here. It is a
* separate levy with no column of its own, and summing the two would report
* an IVA figure that no longer divides back to a rate — the whole point of
* storing it. It prints 0.00 on every policy seen so far and is surfaced as
* a note when it is not.
*/
tax: number | null;
total: number | null;
/** "CONTADO" / "MENSUAL" / … — premium-payment cadence text. */
premiumPayment: string | null;
@@ -319,6 +330,7 @@ function emptyParsedPolicy(provider: string): ParsedPolicy {
netPremium: null,
policyFee: null,
brokerFee: null,
tax: null,
total: null,
premiumPayment: null,
coverages: [],
@@ -1178,6 +1190,7 @@ interface AnaHeader {
coveragePeriodDays: number | null;
netPremium: number | null;
policyFee: number | null;
tax: number | null;
total: number | null;
}
@@ -1253,8 +1266,13 @@ function parseAnaHeader(lines: string[], notes: string[]): AnaHeader {
// ----- money row ---------------------------------------------------------
const row = anaMoneyRow(lines);
if (!row) notes.push("no se pudo leer el renglón de primas");
if (row?.tax) notes.push(`impuesto: ${row.tax.toFixed(2)}`);
if (row?.discount) notes.push(`descuento: ${row.discount.toFixed(2)}`);
// LOCAL TAX has no destination column and prints 0.00 on every A.N.A. policy
// seen so far. A non-zero one means the total will not reconcile against the
// stored IVA, so say so rather than folding it in and hiding the difference.
if (row?.localTax) {
notes.push(`impuesto local ${row.localTax.toFixed(2)} no capturado`);
}
return {
policyNumber,
@@ -1266,6 +1284,7 @@ function parseAnaHeader(lines: string[], notes: string[]): AnaHeader {
coveragePeriodDays,
netPremium: row?.netPremium ?? null,
policyFee: row?.policyFee ?? null,
tax: row?.tax ?? null,
total: row?.total ?? null,
};
}
@@ -1455,6 +1474,7 @@ function parseAnaAutomobile(lines: string[]): ParsedPolicy {
currency: anaCurrency(text, notes),
netPremium: header.netPremium,
policyFee: header.policyFee,
tax: header.tax,
total: header.total,
premiumPayment: paymentDeadline,
coverages,
@@ -1854,6 +1874,7 @@ function parseAnaDriverPolicy(lines: string[]): ParsedPolicy {
currency: anaCurrency(text, notes),
netPremium: header.netPremium,
policyFee: header.policyFee,
tax: header.tax,
total: header.total,
coverages,
coveragePeriodDays: header.coveragePeriodDays,
@@ -43,6 +43,7 @@ export class ConfirmPolicyDocumentDto {
@IsOptional() @IsNumber() netPremium?: number;
@IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() brokerFee?: number;
@IsOptional() @IsNumber() tax?: number;
@IsOptional() @IsNumber() total?: number;
@IsOptional() @IsString() premiumPayment?: string;
/** Printed term in days. Omitted leaves the parsed value (or the schema's
@@ -79,6 +80,7 @@ export class ReviewPolicyDocumentDto {
@IsOptional() @IsNumber() netPremium?: number;
@IsOptional() @IsNumber() policyFee?: number;
@IsOptional() @IsNumber() brokerFee?: number;
@IsOptional() @IsNumber() tax?: number;
@IsOptional() @IsNumber() total?: number;
@IsOptional() @IsString() premiumPayment?: string;
@IsOptional() @IsInt() @Min(1) @Max(3660) coveragePeriodDays?: number;
@@ -192,6 +192,8 @@ export class PolicyOcrService {
parsed.policyFee != null ? new Prisma.Decimal(parsed.policyFee) : null,
extractedBrokerFee:
parsed.brokerFee != null ? new Prisma.Decimal(parsed.brokerFee) : null,
extractedTax:
parsed.tax != null ? new Prisma.Decimal(parsed.tax) : null,
extractedTotal:
parsed.total != null ? new Prisma.Decimal(parsed.total) : null,
extractedCoveragesJson: parsed.coverages.length
@@ -365,6 +367,7 @@ export class PolicyOcrService {
dto.policyFee != null ? new Prisma.Decimal(dto.policyFee) : undefined,
extractedBrokerFee:
dto.brokerFee != null ? new Prisma.Decimal(dto.brokerFee) : undefined,
extractedTax: dto.tax != null ? new Prisma.Decimal(dto.tax) : undefined,
extractedTotal:
dto.total != null ? new Prisma.Decimal(dto.total) : undefined,
extractedCoveragesJson: dto.coveragesJson
@@ -799,6 +802,7 @@ function buildPolicyUpdateFromDoc(
extractedNetPremium: Prisma.Decimal | null;
extractedPolicyFee: Prisma.Decimal | null;
extractedBrokerFee: Prisma.Decimal | null;
extractedTax: Prisma.Decimal | null;
extractedTotal: Prisma.Decimal | null;
extractedCoveragesJson: Prisma.JsonValue | null;
extractedPremiumPayment: string | null;
@@ -845,6 +849,10 @@ function buildPolicyUpdateFromDoc(
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
// `taxRate` is deliberately left alone. A.N.A. prints the IVA amount, not
// the rate, and back-dividing it would mint a rate the document never
// stated — the policy form resolves one from the line of business instead.
tax: numOrUndef(item.tax, doc.extractedTax),
total: numOrUndef(item.total, doc.extractedTotal),
// coveragesJson / observations: freeform, keep the GMX data when present.
coveragesJson:
@@ -886,6 +894,7 @@ function buildPolicyCreateFromDoc(
extractedNetPremium: Prisma.Decimal | null;
extractedPolicyFee: Prisma.Decimal | null;
extractedBrokerFee: Prisma.Decimal | null;
extractedTax: Prisma.Decimal | null;
extractedTotal: Prisma.Decimal | null;
extractedCoveragesJson: Prisma.JsonValue | null;
extractedPremiumPayment: string | null;
@@ -936,6 +945,10 @@ function buildPolicyCreateFromDoc(
netPremium: numOrUndef(item.netPremium, doc.extractedNetPremium),
policyFee: numOrUndef(item.policyFee, doc.extractedPolicyFee),
brokerFee: numOrUndef(item.brokerFee, doc.extractedBrokerFee),
// `taxRate` is deliberately left alone. A.N.A. prints the IVA amount, not
// the rate, and back-dividing it would mint a rate the document never
// stated — the policy form resolves one from the line of business instead.
tax: numOrUndef(item.tax, doc.extractedTax),
total: numOrUndef(item.total, doc.extractedTotal),
coveragesJson:
item.coveragesJson !== undefined
+25 -1
View File
@@ -276,6 +276,8 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
policyDate: doc.extractedPolicyDate?.slice(0, 10) ?? "",
currency: doc.extractedCurrency ?? "USD",
netPremium: doc.extractedNetPremium ?? "",
policyFee: doc.extractedPolicyFee ?? "",
tax: doc.extractedTax ?? "",
total: doc.extractedTotal ?? "",
premiumPayment: doc.extractedPremiumPayment ?? "",
coveragePeriodDays: doc.extractedCoveragePeriodDays?.toString() ?? "",
@@ -314,6 +316,8 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
policyDate: v.policyDate || undefined,
currency,
netPremium: numOrUndef(v.netPremium),
policyFee: numOrUndef(v.policyFee),
tax: numOrUndef(v.tax),
total: numOrUndef(v.total),
premiumPayment: trimOrUndef(v.premiumPayment),
coveragePeriodDays: numOrUndef(v.coveragePeriodDays),
@@ -336,6 +340,8 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
policyDate: reviewInput.policyDate,
currency: (currency as "MXN" | "USD" | "EUR" | undefined) ?? undefined,
netPremium: reviewInput.netPremium,
policyFee: reviewInput.policyFee,
tax: reviewInput.tax,
total: reviewInput.total,
premiumPayment: reviewInput.premiumPayment,
coveragePeriodDays: reviewInput.coveragePeriodDays,
@@ -501,7 +507,25 @@ function DocumentRow({ doc, customerIndex, canReview, onSave, onReject }: Docume
onChange={(e) => set("netPremium", e.target.value)}
/>
</Field>
<Field label="Total">
<Field label="Derecho de póliza">
<input
className="input"
type="number"
step="0.01"
value={v.policyFee}
onChange={(e) => set("policyFee", e.target.value)}
/>
</Field>
<Field label="IVA">
<input
className="input"
type="number"
step="0.01"
value={v.tax}
onChange={(e) => set("tax", e.target.value)}
/>
</Field>
<Field label="Prima total">
<input
className="input"
type="number"
+6
View File
@@ -1555,6 +1555,10 @@ export interface PolicyOcrDocument {
extractedNetPremium: string | null;
extractedPolicyFee: string | null;
extractedBrokerFee: string | null;
/** IVA off A.N.A.'s `TAX` cell. Null on GMX — its certificate carries no
* premium, so no tax either. `LOCAL TAX` is not folded in; a non-zero one
* shows up in `matchNote`. */
extractedTax: string | null;
extractedTotal: string | null;
extractedCoveragesJson: PolicyOcrCoverage[] | null;
extractedPremiumPayment: string | null;
@@ -1589,6 +1593,7 @@ export interface PolicyOcrReviewInput {
netPremium?: number;
policyFee?: number;
brokerFee?: number;
tax?: number;
total?: number;
premiumPayment?: string;
coveragePeriodDays?: number;
@@ -1615,6 +1620,7 @@ export interface PolicyOcrConfirmDocument {
netPremium?: number;
policyFee?: number;
brokerFee?: number;
tax?: number;
total?: number;
premiumPayment?: string;
coveragePeriodDays?: number;
+9 -8
View File
@@ -184,14 +184,15 @@ Each of these is a known, deliberate stopping point rather than a bug.
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.
- **`LOCAL TAX` de A.N.A. no se captura.** El IVA (`TAX`) sí — se guarda desde
2026-08-18 — pero `LOCAL TAX` es un gravamen distinto sin columna destino y
**no** se suma al IVA: sumarlo daría una cifra que ya no divide de vuelta a
una tasa. Imprime 0.00 en todas las pólizas vistas hasta hoy; una distinta
de cero levanta la nota *"impuesto local N no capturado"* y significa que
`total` no cuadra contra `netPremium + policyFee + tax`.
- **`Policy.taxRate` queda en null por la ruta OCR.** A.N.A. imprime el monto
del IVA, no la tasa, y despejarla a la inversa inventaría una tasa que el
documento nunca declaró. El formulario resuelve una desde el ramo.
- **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
+21
View File
@@ -365,6 +365,27 @@ A.N.A.'s faces do print one — the `DISCOUNT / PREMIUM / POLICY FEE / TAX /
LOCAL TAX / TOTAL` row is on the same page — so an ANA document reaches the
review queue with `netPremium` populated and `postPremium` already ticked.
Four of those six cells are stored: `PREMIUM``netPremium`, `POLICY FEE`
`policyFee`, `TAX``tax` (`extractedTax` on the document, `Policy.tax` on
confirm), `TOTAL``total`. `DISCOUNT` and `LOCAL TAX` are reported as notes
instead:
- **`DISCOUNT`** has no column, and it prints as a bare `-` when unused, which
is what makes the row positional rather than "find six amounts".
- **`LOCAL TAX`** is a separate levy and is deliberately **not** summed into
`tax`. Folding it in would produce an IVA figure that no longer divides back
to a rate, which is the reason to store it at all. It reads 0.00 on every
A.N.A. policy seen so far; a non-zero one raises
*"impuesto local N no capturado"* and means `total` will not reconcile
against `netPremium + policyFee + tax`.
`Policy.taxRate` is left null by confirm. A.N.A. prints the IVA **amount**, not
the rate, and back-dividing one would mint a rate the document never stated —
the capture form resolves it from the line of business instead
(`PolicyType.taxRate`, see `apps/api/src/policies/premium.ts`). The figures do
agree: 298.61 + 30.00 taxed at 8% is 26.29, totalling 354.90, asserted in
`policy-parser.spec.ts`.
Deductible and loss participation are stored as **strings** (`"5%"`, `"20%"`,
`"USD 1,000"`) — they are printed as a mix of percentages, currency amounts
and free text, and normalising them would lose the distinction.
@@ -0,0 +1,9 @@
-- A.N.A. prints IVA on the policy face and the parser already read it, but
-- `ParsedPolicy` had no field for it, so the figure only ever reached a review
-- note and the confirmed Policy was written with `tax` null. This gives it a
-- column, matching the premium fields beside it.
--
-- GMX stays null: its certificate carries no premium at all, so there is no
-- tax on it to read either.
ALTER TABLE `policy_ocr_documents`
ADD COLUMN `extractedTax` DECIMAL(12, 2) NULL;
+5
View File
@@ -503,6 +503,11 @@ model PolicyOcrDocument {
extractedNetPremium Decimal? @db.Decimal(12, 2)
extractedPolicyFee Decimal? @db.Decimal(12, 2)
extractedBrokerFee Decimal? @db.Decimal(12, 2)
/// IVA off A.N.A.'s `TAX` cell. Null on GMX, whose certificate carries no
/// premium at all. The adjacent `LOCAL TAX` is a separate levy with no
/// column of its own and is NOT summed in — it would make the figure stop
/// dividing back to a rate; the parser reports a non-zero one as a note.
extractedTax 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