From 75e9f582b49de3a103aa977843fc41d217af0606 Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Tue, 18 Aug 2026 07:38:23 -0700 Subject: [PATCH] feat(policy-ocr): store the IVA A.N.A. already prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../policy-ocr/parsers/policy-parser.spec.ts | 17 +++++++++++- .../src/policy-ocr/parsers/policy-parser.ts | 23 +++++++++++++++- apps/api/src/policy-ocr/policy-ocr.dto.ts | 2 ++ apps/api/src/policy-ocr/policy-ocr.service.ts | 13 ++++++++++ apps/web/src/components/PolicyOcrReview.tsx | 26 ++++++++++++++++++- apps/web/src/lib/types.ts | 6 +++++ docs/BACKLOG.md | 17 ++++++------ docs/POLICY_OCR.md | 21 +++++++++++++++ .../migration.sql | 9 +++++++ packages/database/prisma/schema.prisma | 5 ++++ 10 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 packages/database/prisma/migrations/20260818140000_policy_ocr_extracted_tax/migration.sql diff --git a/apps/api/src/policy-ocr/parsers/policy-parser.spec.ts b/apps/api/src/policy-ocr/parsers/policy-parser.spec.ts index d6c22ea..cafb197 100644 --- a/apps/api/src/policy-ocr/parsers/policy-parser.spec.ts +++ b/apps/api/src/policy-ocr/parsers/policy-parser.spec.ts @@ -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", () => { diff --git a/apps/api/src/policy-ocr/parsers/policy-parser.ts b/apps/api/src/policy-ocr/parsers/policy-parser.ts index d0e024b..435d993 100644 --- a/apps/api/src/policy-ocr/parsers/policy-parser.ts +++ b/apps/api/src/policy-ocr/parsers/policy-parser.ts @@ -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, diff --git a/apps/api/src/policy-ocr/policy-ocr.dto.ts b/apps/api/src/policy-ocr/policy-ocr.dto.ts index 2c5f355..de5466e 100644 --- a/apps/api/src/policy-ocr/policy-ocr.dto.ts +++ b/apps/api/src/policy-ocr/policy-ocr.dto.ts @@ -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; diff --git a/apps/api/src/policy-ocr/policy-ocr.service.ts b/apps/api/src/policy-ocr/policy-ocr.service.ts index d57648b..ae55fcc 100644 --- a/apps/api/src/policy-ocr/policy-ocr.service.ts +++ b/apps/api/src/policy-ocr/policy-ocr.service.ts @@ -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 diff --git a/apps/web/src/components/PolicyOcrReview.tsx b/apps/web/src/components/PolicyOcrReview.tsx index 308c122..0b2c93f 100644 --- a/apps/web/src/components/PolicyOcrReview.tsx +++ b/apps/web/src/components/PolicyOcrReview.tsx @@ -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)} /> - + + set("policyFee", e.target.value)} + /> + + + set("tax", e.target.value)} + /> + +