feat(recibos): OCR capture for zona federal (ZOFEMAT Tijuana)

Adds the ZONA FEDERAL TIJUANA parser to the statement intake, measured
against 8 pages of real "Zona Federal Marítimo Terrestre" receipts — the
federal maritime-zone occupancy fee the municipality bills on beachfront
lots. Provider read on 8/8, amount on 8/8 (each verified against the
paper), concession clave on 6/8, period on 8/8, deadline on 2/8.

Four things the corpus forced:

- Tijuana bills predial and zona federal from the same treasury: same
  header, same Paseo del Centenario address, same ATB-541201 RFC. Every
  predial discriminator matches a zona federal page too, so whichever
  rule is asked first wins it. The only words exclusive to this layout
  are "Marítimo Terrestre", so its brand rule is asked ahead of all
  three predial ones — and its structural rule, anchored on the stub's
  "Derechos de ocupación", ahead of theirs.

- FEDERAL_ZONE.accountNumber is an amount, not a reference. It holds
  DATMEX.zfed, whose 77 values include 246.06, 2369.09, 22653.94 and a
  negative -1679, while the concession claves these receipts are keyed
  by appear nowhere in the database. Matching on that column could never
  hit — and because every row already has a value, the `[field]: null`
  guards on learnAccountRefs and on the review blank-service fill would
  never fire either, so every page would return to the queue every
  bimester forever. The clave moves to meterNumber, joining gas and
  Tijuana predial, and the first confirm teaches the match.

- The payable figure is not the printed subtotal. The municipality
  rounds to whole pesos and prints the difference on its own "Ajuste Ley
  Hacienda Mpal" line (-$0.05 against a 591.05 subtotal, $0.21 against
  2,872.79). The "Total a pagar" box carrying the rounded figure sits on
  a grey fill and OCR'd on 1 of 8 pages; the SubTotal row read on 8 of
  8. So the amount is the rounded subtotal, cross-checked against the
  printed box wherever it survives — where it did, it agreed.

- The clave is 2 digits, a letter and 3 digits (12-T -012), not the
  cadastral shape, and the letter is kept as printed: toDigits maps D to
  0, which turns a real 14-D -014 into 140014. It is printed twice,
  which rescued a page whose heading was struck through by the office's
  own highlighter — the failure mode behind both missing claves.

Deriving the deadline from the bimester is deliberately not attempted:
it is the 17th of the month after the bimester closes on a current bill,
but four of these eight are late (a $1,000 Multa) and print a
recalculated date, so a derived date would be wrong on exactly the pages
a human most wants to see.

Re-ran the earlier corpora (25 pages: predial Tijuana/Rosarito/Ensenada,
CFE, CESPT, Telnor) through detection to confirm the new rules steal
nothing — all 25 still read as their original provider, including the
five Tijuana predial pages that share the RFC.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-01 14:07:21 -07:00
co-authored by Claude Opus 5
parent d6501f1d74
commit 5bce0e4c94
5 changed files with 281 additions and 8 deletions
@@ -2,6 +2,7 @@ import type { OcrPage } from "../ocr/ocr.provider";
import {
detectProvider,
normalizeCadastralKey,
normalizeZofematKey,
parseStatement,
} from "./statement-parser";
@@ -192,3 +193,78 @@ describe("parseGas", () => {
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 <pYl Av. Independencia y Esq. Paseo del CentenaxiaiiArlhnto de Tijuana, B.C.\n" +
"Teléfono: 9737000 R.F.C. ATB-541201-BK2 0070000146 12:54 PM\n" +
"Zona Federal Marítimo Terrestre\n" +
`${clave} Nombre: DENNIS JOHN SEIN Concesión:\n` +
"Periodo Construcción Tasa Ornato Tasa Impuesto Actualiza. Recargo Multa Importe\n" +
"2026-2 / 2026-2 316.40 35.00 0.00 12.11 1,845.66 0.00 27.13 1,000.00 2,872.79\n" +
"SubTotal 1,845.66 0.00 27.13 1,000.00 2,872.79\n" +
"Concepto: Derechos de ocupación de Zona Federal Marítimo Terrestre\n" +
body,
);
it("is not claimed by the predial parser that shares its RFC and header", () => {
// 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");
});
});