feat(recibos): OCR capture for gas butano and municipal predial
Adds four parsers to the statement intake — GAS TIJUANA plus one per municipality, because Tijuana, Rosarito and Ensenada issue three completely different predial documents — and a text-layer fast path for the born-digital invoices the gas company sends. Measured against a new corpus of 14 documents / 29 pages: provider read on 29/29, amount on 26/29, and 21/29 auto-matched against the dev database (22/29 identified). The eight review cases are all legitimate. Five things the corpus forced: - Not every statement is a scan. The gas invoices are born-digital CFDIs whose text layer is exact; rasterising them only loses information (one sample turned `MEDIDOR: VM01014426` into `ar (LTR): 014420`). The new `OcrProvider.textPages` reads the embedded layer via `pdftotext -bbox-layout` — same poppler package as `pdftoppm`, so no new dependency — and OCR stays the fallback for real scans. Poppler's own `<line>` grouping follows text flow rather than the page, so words are regrouped by vertical position; without that, a two-column header leaves every label separated from the value printed beside it. - The clave catastral is not two letters and six digits. Position three is a letter in 15 of the 932 stored claves, and digitising the whole tail mapped a real `MMB01041` to a nonexistent `MM801041`. - Tijuana predial prints no clave at all. Its only identifier is an 8-digit municipal account carried in a 32-digit payment barcode, which the legacy database never held, so it goes in `meterNumber` alongside gas — `accountNumber` holds `DATMEX.predial`, which is not a per-property key and must not be overwritten. Those pages start cold and are taught by the first confirm. - On Rosarito and Ensenada the clave is the primary key, not a fallback: those receipts print nothing else, so a unique hit auto-matches. On a utility bill that merely happens to print one it stays a review hint. - A misread `$` is the dangerous failure. An Ensenada receipt for $2,203.00 OCR'd as `82,203.00`, which would post a charge 37x too large and look ordinary in the ledger. Predial amounts now require a literal `$` and a page that cannot produce one goes to review. The scoped match field is now one exported function rather than three copies of `kind === "GAS" ? ... : ...`, since the lookup, the blank-service fill and the confirm write-back have to agree or a reference gets learned into a column nothing searches. First tests in this package: 23 specs over the parsers and the text-layer reader, every fixture a verbatim OCR excerpt from a real receipt. Adds the jest config they need and a build tsconfig so they stay out of dist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
import type { OcrPage } from "../ocr/ocr.provider";
|
||||
import {
|
||||
detectProvider,
|
||||
normalizeCadastralKey,
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user