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