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)} + /> + +