import type { OcrPage } from "../ocr/ocr.provider"; import { detectProvider, normalizeCadastralKey, normalizeZofematKey, parseStatement, } from "./statement-parser"; /** * Every string in this file is a verbatim excerpt of what the OCR engine * actually returned for a real receipt — misreads, dropped spaces, mangled * accents and all. That is the point: these are the specific ways these five * layouts have been observed to fail, and the assertions pin down what the * parser is supposed to do about each one. Inventing clean input here would * test nothing, because clean input was never the problem. */ function page(text: string): OcrPage { return { text, words: [], confidence: 0.9 }; } describe("detectProvider", () => { it("reads a Rosarito predial receipt as predial, not as a water bill", () => { // "Clave Catastral" is also a CESPT structural marker, so a predial page // whose header OCR'd badly must still not be claimed by the CESPT rule. expect( detectProvider( "e | Clave Catastral. KP-128-105 IMPUESTO PREDIAL ea rita\n" + "TASA | VALOR FISCAL | BIMESTRES | INCISO. | IMPUESTO", ), ).toBe("PREDIAL ROSARITO"); }); it("keeps telling the three municipalities apart by their RFC", () => { expect(detectProvider("R.F.C. ATB-541201-KK2")).toBe("PREDIAL TIJUANA"); expect(detectProvider("R.F.C. AMP-981201-HJ4")).toBe("PREDIAL ROSARITO"); expect(detectProvider("MEN-540301-9J5")).toBe("PREDIAL ENSENADA"); }); it("does not let the CFE rule claim a gas bill over 'PERIODO FACTURADO'", () => { expect( detectProvider("Orden de Facturación: 000009801640\nPERIODO FACTURADO: 20260630-20260630"), ).toBe("GAS TIJUANA"); }); }); describe("normalizeCadastralKey", () => { it("keeps a letter in the third position instead of digitising it", () => { // `MMB01041` is a real key on file; mapping its B to 8 produced a key that // matches no property at all. expect(normalizeCadastralKey("MM-B01-041", [])).toBe("MMB01041"); }); it("repairs the spurious I tesseract inserts into the prefix", () => { expect(normalizeCadastralKey("MIM-200-010", [])).toBe("MM200010"); }); it("digitises confusable glyphs from position four onward", () => { expect(normalizeCadastralKey("KP-1O8-O45", [])).toBe("KP108045"); }); it("flags a prefix it had to truncate", () => { const notes: string[] = []; expect(normalizeCadastralKey("KPX-128-106", notes)).toBe("KP128106"); expect(notes).toHaveLength(1); }); }); describe("parsePredialTijuana", () => { const TIJUANA = page( "Hats | AYUNTAMIENTO DE TIJUANA, BC $2,613.00 23/01/2026\n" + "y) TELEFONO: 973-7000 R.F.C. ATB-541201-KK2\n" + "ER AÑO VALOR FISCAL TASA IMPUESTO |CONCEPTO IMPORTE\n" + "ED ca 2026 1,207,15778 246 2,969.61 1102 - IMPUESTO PREDIAL 2,969.61\n" + "55164964310126000002613000054192\n" + "se 0 O (54427 [a] | TOTALAPAGAR: 2,613.00\n" + "Dc 1097 : FECHA VENCE : 31/ENE/2026", ); it("splits the payment barcode into account, deadline and amount", () => { const p = parseStatement(TIJUANA); expect(p.provider).toBe("PREDIAL TIJUANA"); expect(p.serviceKind).toBe("PROPERTY_TAX"); expect(p.accountRef).toBe("55164964"); expect(p.amount).toBe(2613); expect(p.dueDate?.toISOString().slice(0, 10)).toBe("2026-01-31"); expect(p.period).toBe("2026"); }); it("reads the printed total even when the space in the label is lost", () => { // The real page OCR'd the label as "TOTALAPAGAR:", and it is that reading // that cross-checks the barcode's amount. expect(parseStatement(TIJUANA).crossChecked).toBe(true); }); it("refuses to trust a barcode the printed total contradicts", () => { const p = parseStatement( page( "R.F.C. ATB-541201-KK2\n" + "55164964310126000002613000054192\n" + "TOTAL A PAGAR: 9,613.00\nFECHA VENCE : 31/ENE/2026", ), ); expect(p.crossChecked).toBe(false); expect(p.notes.join(" ")).toContain("no coincide"); }); }); describe("parsePredialRosarito", () => { it("takes the rounded Total, not the Sub Total printed above it", () => { const p = parseStatement( page( "AYUNTAMIENTO MUNICIPAL DE PLAYAS DE ROSARITO, B.C.\n" + "Ce Clave Catastral: + JR-400-008 7 | IMPUESTO PREDIAL\n" + "SUPERFICIE: 228.31 ZONA 30025 “Redondeo IT049 -$0.39 Sub Total $5,409.39\n" + "¿XTEMPORANEO DESPUES DE: 31/01/2026 Elaboro: MGLG\n" + "Total | $5,409.00\n" + "| Periodo por Pagar: 2026/1 2026/6", ), ); expect(p.cadastralKey).toBe("JR400008"); expect(p.amount).toBe(5409); expect(p.dueDate?.toISOString().slice(0, 10)).toBe("2026-01-31"); expect(p.period).toBe("2026"); }); it("is not fooled by the unspaced 'SubTotal' spelling", () => { // This exact page read $9,624.85 off a receipt for $9,625.00 while the // lookbehind still assumed a space. const p = parseStatement( page( "AMP-981201-HJ4 IMPUESTO PREDIAL\n" + "SUPERFICIE. 367.62 ZONA:30151 | Redondco 17049 $0.15 SubTotal $9,624.85\n" + ": Total | $9,625.00", ), ); expect(p.amount).toBe(9625); }); }); describe("parsePredialEnsenada", () => { const totals = (tail: string) => page( "IMPRESION MAQUINA REGISTRADORA ez | MUNICIPIO DE ENSENADA\n" + "+7] DATOS. DEL.CAUSANTE alta A pe CLAVE MM-200-010 2 CUENTA\n" + `ES g € S| TOTALES 12,744.47 0.00 0.00 324.56 0.00 13,069.03 ${tail} |`, ); it("reads the paid total off the TOTALES row however the label OCR'd", () => { expect(parseStatement(totals("TOTA LA A $5,797.00")).amount).toBe(5797); expect(parseStatement(totals("orAL: M7 z] $14,414.00")).amount).toBe(14414); expect(parseStatement(totals("| TOTAL: = $6 246.00")).amount).toBe(6246); }); it("reports no amount rather than one whose $ was misread as an 8", () => { // `TOTAL: A 82,203.00` is a $2,203.00 receipt. Posting $82,203 would look // entirely ordinary in the ledger, so this page must go to review instead. const p = parseStatement(totals("TOTAL: A 82,203.00")); expect(p.amount).toBeNull(); expect(p.notes.join(" ")).toContain("capturarlo a mano"); }); it("never falls back to the assessed total on the same row", () => { expect(parseStatement(totals("yo: se TE= 58/4690]")).amount).toBeNull(); }); }); describe("parseGas", () => { const gas = (...cuentas: string[]) => page( "GTI4608032K2 COMPAÑIA DE GAS DE TIJUANA\n" + "Fecha de Vencimiento: 2026/08/08\n" + cuentas.map((c) => `Cuenta: ${c}`).join("\n") + "\nPERIODO FACTURADO: 20260630-20260630\nTOTAL A PAGAR: $275.82", ); it("strips the printed leading zero to the stored account number", () => { const p = parseStatement(gas("0900003463", "0900003463", "0900003463")); expect(p.serviceKind).toBe("GAS"); expect(p.accountRef).toBe("900003463"); expect(p.amount).toBe(275.82); expect(p.dueDate?.toISOString().slice(0, 10)).toBe("2026-08-08"); expect(p.period).toBe("2026-06"); expect(p.crossChecked).toBe(true); }); it("takes the majority reading but still sends a disagreement to review", () => { const p = parseStatement(gas("0900003463", "0900003463", "0900003468")); expect(p.accountRef).toBe("900003463"); expect(p.crossChecked).toBe(false); }); it("claims no cross-check from a single printing", () => { expect(parseStatement(gas("0900003463")).crossChecked).toBeNull(); }); }); describe("parseZonaFederal", () => { /** * The Tijuana zona federal receipt, trimmed to the rows the parser reads. * Verbatim from page 7 of the August 2026 batch, including the two ways the * heading OCR'd: the clave line is struck through by the office's own * highlighter, which is what cost two of eight pages their concession clave. */ const zf = (clave: string, body = "") => page( "ESIZ { // Tijuana bills predial and zona federal from the same treasury, so // "Ayuntamiento de Tijuana" and ATB-541201 identify neither on their own. expect(detectProvider("R.F.C. ATB-541201-BK2\nZona Federal Marítimo Terrestre")).toBe( "ZONA FEDERAL TIJUANA", ); expect(parseStatement(zf("Clave: 14-D -014")).serviceKind).toBe("FEDERAL_ZONE"); }); it("still recognises the layout when the heading itself did not survive OCR", () => { // Real: page 1 came back as "Zona Ledera) Maritimo Terrestre". expect( detectProvider("Zona Ledera) Maritimo Terrestre\nClave EJ -012% Nombre: STEFAN"), ).toBe("ZONA FEDERAL TIJUANA"); }); it("reads the clave through the loose spacing the receipt prints", () => { expect(parseStatement(zf("Clave: 14-D -014")).accountRef).toBe("14D014"); expect(parseStatement(zf("Clave: 14-A-119")).accountRef).toBe("14A119"); }); it("keeps the letter instead of digitising it", () => { // toDigits maps D to 0 and B to 8; a real 14-D -014 must not become 140014. expect(normalizeZofematKey("14-D -014")).toBe("14D014"); expect(normalizeZofematKey("12-B -013")).toBe("12B013"); }); it("takes the payable amount from the SubTotal row, rounded to whole pesos", () => { // The municipality rounds and prints the difference as "Ajuste Ley Hacienda // Mpal"; 2,872.79 is charged as $2,873.00. expect(parseStatement(zf("Clave: 14-D -014")).amount).toBe(2873); }); it("prefers the printed total and cross-checks it against the subtotal", () => { const p = parseStatement(zf("Clave: 14-D -014", "Total a pagar $2,873.00")); expect(p.amount).toBe(2873); expect(p.crossChecked).toBe(true); }); it("sends a printed total that contradicts the subtotal to review", () => { const p = parseStatement(zf("Clave: 14-D -014", "Total a pagar $2,973.00")); expect(p.crossChecked).toBe(false); }); it("translates the printed bimester into the ledger's own vocabulary", () => { expect(parseStatement(zf("Clave: 14-D -014")).period).toBe("MAR/APR"); }); it("leaves the clave blank rather than guessing when the marker ate it", () => { const p = parseStatement(zf("Clave EJ -012%")); expect(p.accountRef).toBeNull(); expect(p.notes.join(" ")).toContain("clave"); }); });