import type { ServiceKind } from "@jorgecuadros/database"; import type { OcrPage, OcrWord } from "../ocr/ocr.provider"; /** * What one parsed statement page yields. `accountRef` is already normalised to * the form the migrated `PropertyService` columns hold, so the matcher compares * like with like and never has to know about provider-specific formatting. */ export interface ParsedStatement { /** * "CFE" | "CESPT" | "TELNOR" | "GAS TIJUANA" | "PREDIAL TIJUANA" | * "PREDIAL ROSARITO" | "PREDIAL ENSENADA" | "ZONA FEDERAL TIJUANA", or null * when no parser claimed the page. */ provider: string | null; serviceKind: ServiceKind | null; accountRef: string | null; /** Clave catastral, when printed — a second key to match on. */ cadastralKey: string | null; amount: number | null; dueDate: Date | null; period: string | null; /** * Independent corroboration of `accountRef`. CFE and Telnor both print a * payment barcode that repeats the account number (and the amount), so when * the barcode and the label agree the extraction is near-certainly right; * when they disagree, or only one is present, the page is worth a human * glance. Null when the layout has no second source. */ crossChecked: boolean | null; /** Human-readable trail of what was read, surfaced in the review queue. */ notes: string[]; } // --- shared helpers --------------------------------------------------------- /** * Tesseract confuses these glyphs inside numeric runs with some regularity — * a real clave catastral `KB078025` came back as `KBO78025`. Applied ONLY to * fields known to be digits, never to free text, where it would corrupt words. */ const DIGIT_CONFUSIONS: Record = { O: "0", o: "0", D: "0", I: "1", l: "1", "|": "1", S: "5", B: "8", }; export function toDigits(s: string | null | undefined): string { if (!s) return ""; return s .split("") .map((c) => DIGIT_CONFUSIONS[c] ?? c) .join("") .replace(/\D/g, ""); } /** * Parse a printed amount, treating `,` and `.` by position rather than by * assumption. A real Telnor bill OCR'd as "$ 649,00" — blindly stripping commas * as thousands separators turned $649.00 into $64,900, a hundredfold error that * would post silently. Two trailing digits after a single separator are always * cents here; a separator followed by three digits is a thousands group. */ function money(s: string | null | undefined): number | null { if (!s) return null; const cleaned = s.replace(/[\s$]/g, ""); // 1.234,56 or 1,234.56 — grouped thousands plus optional cents. let m = cleaned.match(/^(\d{1,3}(?:[.,]\d{3})+)([.,]\d{1,2})?$/); if (m) { const whole = m[1].replace(/[.,]/g, ""); const cents = m[2] ? m[2].slice(1) : ""; return Number(cents ? `${whole}.${cents.padEnd(2, "0")}` : whole); } // 649,00 / 649.00 — a single separator with exactly two digits after it. m = cleaned.match(/^(\d+)[.,](\d{2})$/); if (m) return Number(`${m[1]}.${m[2]}`); const n = Number(cleaned.replace(/[,.]/g, "")); return Number.isFinite(n) ? n : null; } function firstMatch(text: string, patterns: RegExp[]): string | null { for (const p of patterns) { const m = text.match(p); if (m?.[1]) return m[1].trim(); } return null; } /** Every capture of `pattern` across the page, in order. */ function allMatches(text: string, pattern: RegExp): string[] { const out: string[] = []; const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`); for (const m of text.matchAll(re)) { if (m[1]) out.push(m[1].trim()); } return out; } const MONTHS: Record = { ENE: 0, FEB: 1, MAR: 2, ABR: 3, MAY: 4, JUN: 5, JUL: 6, AGO: 7, SEP: 8, OCT: 9, NOV: 10, DIC: 11, }; /** Parses the three date shapes these statements actually print. */ export function parseDate(raw: string | null | undefined): Date | null { if (!raw) return null; const s = raw.trim().toUpperCase(); // 16/07/2026 let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); if (m) return utc(+m[3], +m[2] - 1, +m[1]); // 22-JUL-2026 / 22 JUN 26 / 31/ENE/2026 (Tijuana predial) m = s.match(/^(\d{1,2})[-\s/]([A-Z]{3})[A-Z]*[-\s/](\d{2,4})$/); if (m && MONTHS[m[2]] !== undefined) { const y = m[3].length === 2 ? 2000 + +m[3] : +m[3]; return utc(y, MONTHS[m[2]], +m[1]); } // 2026-07-22 (already normalised, e.g. decoded from a barcode) and the // 2026/08/08 the gas bill prints — same field order, different separator. m = s.match(/^(\d{4})[-/](\d{2})[-/](\d{2})$/); if (m) return utc(+m[1], +m[2] - 1, +m[3]); return null; } function utc(y: number, mo: number, d: number): Date | null { const dt = new Date(Date.UTC(y, mo, d)); return Number.isNaN(dt.getTime()) ? null : dt; } /** * Read the value printed *underneath* a column header. * * The CESPT "RECIBO" is a table: `No. DE CUENTA` is a header cell and its value * sits in the row below it, so no amount of label-adjacent regex on line text * can associate the two. This walks the word boxes instead — find the header * word, then take the nearest word below it whose horizontal centre falls * within the column. */ export function valueUnder( page: OcrPage, header: RegExp, opts: { maxDy?: number; tolerance?: number; match?: RegExp } = {}, ): string | null { const { maxDy = 300, tolerance = 200, match } = opts; const centre = (w: OcrWord) => ({ x: w.left + w.width / 2, y: w.top + w.height / 2, }); for (const h of page.words.filter((w) => header.test(w.text))) { const hc = centre(h); const below = page.words .filter((w) => { const c = centre(w); return c.y > hc.y && c.y <= hc.y + maxDy && Math.abs(c.x - hc.x) <= tolerance; }) .sort((a, b) => centre(a).y - centre(b).y); for (const w of below) { if (!match || match.test(w.text)) return w.text; } } return null; } // --- provider detection ----------------------------------------------------- /** * Brand wordmarks first, page structure only as a fallback — and the two passes * must not be interleaved. Scanned logos OCR badly (one CESPT header came back * as "E BAJA ES PAGO / EALIFORNIA", with neither "CESPT" nor "COMISIÓN ESTATAL" * readable), so the structural pass is what rescues those pages. But a Telnor * bill contains the words "Pagar antes de", which a CFE structural rule * evaluated first will happily claim — running all brand checks before any * structural check is what keeps that from happening. */ const BRAND: [string, RegExp][] = [ ["CFE", /comisi[oó]n federal de electricidad|CFE.?contigo|Suministrador de Servicios/i], ["CESPT", /CESPT|COMISI[OÓ]N ESTATAL DE SERVICIOS/i], ["TELNOR", /TELNOR|TELEFONOS DEL NOROESTE/i], ["GAS TIJUANA", /COMPA[ÑN][IÍ]?A\s*DE\s*GAS\s*DE\s*TIJUANA|bajagas/i], // Ahead of the predial rules on purpose. Tijuana's zona federal receipt is // issued by the same treasury and carries the same header — "Ayuntamiento de // Tijuana", the same address, the same `ATB-541201` RFC — so every predial // discriminator matches it too, and whichever rule is asked first wins the // page. What only the zona federal layout says is "Marítimo Terrestre", which // survived OCR on all eight sample pages even where the heading above it came // back as "Zona Ledera) Maritimo Terrestre" and the printed concession clave // was lost under a highlighter mark. ["ZONA FEDERAL TIJUANA", /ZOFEMAT|Mar[ií]timo\s*Terrestre|ocupaci[oó]n\s*de\s*Zona\s*Federal/i], // The municipal RFCs are the single most reliable discriminator on a predial // receipt: they are printed in a clean monospaced run on every layout, they // never change, and they say which of the three city treasuries issued the // page — which the wordmarks alone do not, since a Tijuana receipt also // carries "PLAYAS DE TIJUANA" and a Rosarito one "TIJUANA ENSENADA". ["PREDIAL TIJUANA", /AYUNTAMIENTO\s*DE\s*TIJUANA|ATB.?541201/i], ["PREDIAL ROSARITO", /AYUNTAMIENTO\s*MUNICIPAL\s*DE\s*PLAYAS\s*DE\s*ROSARITO|AMP.?981201|rosarito\.gob/i], ["PREDIAL ENSENADA", /MUNICIPIO\s*DE\s*ENSENADA|MEN.?540301/i], ]; /** * The predial rules come first because a Rosarito receipt prints "Clave * Catastral" as a boxed label — the very string the CESPT structural rule * looks for — so a page whose municipal header failed to OCR would otherwise * be claimed as a water bill and matched against the wrong column entirely. * "IMPUESTO PREDIAL" appears on all three municipal layouts and on none of the * utility ones, so it is the safe first question to ask. */ const LAYOUT: [string, RegExp][] = [ // Same reasoning as the brand pass, one rule earlier: the concept line // "Derechos de ocupación de Zona Federal Marítimo Terrestre" is printed on // the stub of every zona federal page and on no other layout, and it read // cleanly on 8 of 8 samples — including the two whose heading did not. ["ZONA FEDERAL TIJUANA", /Derechos\s*de\s*ocupaci[oó]n/i], ["PREDIAL TIJUANA", /IMPUESTO\s*PREDIAL[\s\S]*?(?:CERTIFICACION\s*DE\s*CAJA|PASEO\s*DEL\s*CENTENARIO|PAGA\s*TU\s*PREDIAL)/i], ["PREDIAL ENSENADA", /(?:IMPUESTO\s*PREDIAL[\s\S]*?TRANSPENINSULAR)|(?:IMPRESION\s*MAQUINA\s*REGISTRADORA)/i], ["PREDIAL ROSARITO", /IMPUESTO\s*PREDIAL/i], ["GAS TIJUANA", /Orden\s*de\s*Facturaci[oó]n|FACTOR\s*DE\s*PRESI[OÓ]N|GAS\s*LP/i], ["CFE", /NO\.?\s*DE\s*SERVICIO|L[IÍ]MITE\s*DE\s*PAGO|PERIODO\s*FACTURADO/i], ["CESPT", /SALDO\s+CORRIENTE|CLAVE\s*CATASTRAL|No\.?\s*DE\s*CUENTA/i], ["TELNOR", /Mes\s*de\s*Facturaci[oó]n|Pagar\s*antes\s*de/i], ]; export function detectProvider(text: string): string | null { for (const group of [BRAND, LAYOUT]) { for (const [name, pattern] of group) { if (pattern.test(text)) return name; } } return null; } // --- CFE (electric) --------------------------------------------------------- function parseCfe(page: OcrPage): ParsedStatement { const text = page.text; const notes: string[] = []; // The payment barcode line repeats the service number, the due date (YYMMDD) // and the amount in one fixed-width run, and reads far more reliably than the // label: on one sample the label came back as "0059603001917" (a digit too // many) while its barcode gave the correct "005960300191". So the barcode // wins, and the label becomes the cross-check rather than the source. const barcode = text.match(/\b01\s+([0-9OIlSBD]{12})\s+([0-9OIlSBD]{6})\s+([0-9OIlSBD]{9})\b/); const label = firstMatch(text, [/NO\.?\s*DE\s*SERVICIO\s*[:;.]?\s*([0-9OIlSBD]{10,14})/i]); let accountRef: string | null = null; let amount: number | null = null; let dueDate: Date | null = null; let crossChecked: boolean | null = null; if (barcode) { // Leading zeros are print padding: DATMEX.rpu holds the bare 10 digits. accountRef = toDigits(barcode[1]).replace(/^0+/, ""); amount = Number(toDigits(barcode[3])); const d = toDigits(barcode[2]); dueDate = parseDate(`20${d.slice(0, 2)}-${d.slice(2, 4)}-${d.slice(4, 6)}`); notes.push("importe y vencimiento leídos del código de barras"); if (label) { crossChecked = toDigits(label).replace(/^0+/, "") === accountRef; if (!crossChecked) { notes.push( `el número impreso (${toDigits(label).replace(/^0+/, "")}) no coincide con el código de barras`, ); } } } else if (label) { accountRef = toDigits(label).replace(/^0+/, ""); notes.push("sin código de barras legible; número tomado de la etiqueta"); } if (amount == null) { amount = money(firstMatch(text, [/TOTAL\s*A\s*PAGAR\s*[:;.]?\s*\$?\s*([\d,]+\.?\d*)/i])); } if (!dueDate) { dueDate = parseDate( firstMatch(text, [/L[IÍ]MITE\s*DE\s*PAGO\s*[:;.]?\s*(\d{1,2}\s+\w{3}\s+\d{2,4})/i]), ); } return { provider: "CFE", serviceKind: "ELECTRIC", accountRef: accountRef || null, cadastralKey: null, amount, dueDate, period: firstMatch(text, [ /PERIODO\s*FACTURADO\s*[:;.]?\s*(\d{1,2}\s+\w{3}\s+\d{2}\s*-\s*\d{1,2}\s+\w{3}\s+\d{2})/i, ]), crossChecked, notes, }; } // --- CESPT (water) ---------------------------------------------------------- /** * Two different layouts arrive under the same brand: * - the line-oriented "COMPROBANTE DE PAGO" (`Cuenta : 7604192`), and * - the tabular "RECIBO", where `No. DE CUENTA` is a column header. * Line patterns are tried first; anything they miss falls through to the * geometric read, which is what the tabular layout needs. */ function parseCespt(page: OcrPage): ParsedStatement { const text = page.text; const notes: string[] = []; let account = firstMatch(text, [/Cuenta\s*[:;.]?\s*([0-9OIlSBD]{5,9})/i]); if (!account) { account = valueUnder(page, /^CUENTA$/i, { match: /^[0-9OIlSBD]{5,9}$/ }); if (account) notes.push("número de cuenta leído de la columna del recibo"); } let clave = firstMatch(text, [/Cve\.?\s*Cat\.?\s*[:;.]?\s*([A-Z]{2}\s?[0-9OIlSBD]{6})/i]); if (!clave) { clave = valueUnder(page, /^CATASTRAL$/i, { match: /^[A-Z]{2}[0-9OIlSBD]{6}$/i }); if (clave) notes.push("clave catastral leída de la columna del recibo"); } let due = firstMatch(text, [/Fecha\s*Venc\s*[:;.]?\s*(\d{2}\/\d{2}\/\d{4})/i]); if (!due) due = valueUnder(page, /^VENCIMIENTO$/i, { match: /^\d{2}\/\d{2}\/\d{4}$/ }); const amount = money( firstMatch(text, [ /TOTAL\s*[:;.]?\s*\$?\s*([\d,]+\.\d{2})/i, /SALDO\s+CORRIENTE[^\n]*?([\d,]+\.\d{2})/i, ]), ); // Leading zeros are print padding here too: the RECIBO prints `0457341` for // what DATMEX.agua holds as `457341`. const accountRef = account ? toDigits(account).replace(/^0+/, "") : null; const cadastralKey = clave ? clave.replace(/\s/g, "").slice(0, 2).toUpperCase() + toDigits(clave.replace(/\s/g, "").slice(2)) : null; return { provider: "CESPT", serviceKind: "WATER", accountRef: accountRef || null, cadastralKey: cadastralKey || null, amount, dueDate: parseDate(due), period: null, crossChecked: null, notes, }; } // --- TELNOR (telephone) ----------------------------------------------------- function parseTelnor(page: OcrPage): ParsedStatement { const text = page.text; const notes: string[] = []; const label = firstMatch(text, [ /Tel[eé]fono\s*[:;.]?\s*([0-9OIlSBD]{3}\s?[0-9OIlSBD]{3}\s?[0-9OIlSBD]{4})/i, ]); // The payment stub prints phone (10 digits) + amount in cents (9) + a check // digit: `6646093444 000099900 7` for a $999.00 bill. Reading the amount as // 10 digits swallows the check digit and inflates the figure 100-fold. const barcode = text.match(/\b(\d{10})(\d{9})\d\b/); let accountRef: string | null = null; let crossChecked: boolean | null = null; // The bill prints the number with its 664 Tijuana LADA; DATMEX stores the // bare local 7 digits, so the LADA is dropped rather than the stored value // being padded — padding would guess at an area code for the 500+ existing // rows that never recorded one. if (label) accountRef = toDigits(label).slice(-7); if (barcode) { const fromBarcode = barcode[1].slice(-7); if (accountRef) { crossChecked = fromBarcode === accountRef; if (!crossChecked) notes.push("el teléfono impreso no coincide con el código de barras"); } else { accountRef = fromBarcode; notes.push("teléfono leído del código de barras"); } } let amount = money(firstMatch(text, [/Total\s*a\s*Pagar\s*[:;.]?\s*\$?\s*([\d,]+\.?\d{0,2})/i])); if (amount == null && barcode) { amount = Number(barcode[2]) / 100; notes.push("importe leído del código de barras"); } return { provider: "TELNOR", serviceKind: "TELEPHONE", accountRef: accountRef || null, cadastralKey: null, amount, dueDate: parseDate( firstMatch(text, [/Pagar\s*antes\s*de\s*[:;.]?\s*(\d{2}-\w{3}-\d{4})/i]), ), period: firstMatch(text, [/Mes\s*de\s*Facturaci[oó]n\s*[:;.]?\s*(\w+)/i]), crossChecked, notes, }; } // --- GAS (Compañía de Gas de Tijuana / bajagas) ------------------------------ /** * These arrive as born-digital CFDI PDFs rather than scans, so the text layer * (see `TesseractOcrProvider.textPages`) usually reads them exactly and the * patterns below only have to be tolerant enough for the scanned case. * * The account number is printed three times — supply address, fiscal data, and * the payment stub at the foot — which is a free cross-check: three readings * that agree are near-certainly right, and any disagreement means one of them * was misread and the page deserves a human glance. * * `Cuenta` is what the matcher compares, not `Contrato`. The migration * recovered gas references out of `PropertyService.notes` into `meterNumber` * and what sat there is the 9-digit account (`900003463`), printed here with a * leading zero as `0900003463`. */ function parseGas(page: OcrPage): ParsedStatement { const text = page.text; const notes: string[] = []; const seen = allMatches(text, /Cuenta\s*[:;.]?\s*([0-9OIlSBD]{6,12})/i).map((s) => toDigits(s).replace(/^0+/, ""), ); const distinct = [...new Set(seen.filter(Boolean))]; let accountRef: string | null = null; let crossChecked: boolean | null = null; if (distinct.length === 1) { accountRef = distinct[0]; if (seen.length > 1) crossChecked = true; } else if (distinct.length > 1) { // Majority wins — the stub and the two address blocks print the same // number, so a single divergent reading is the misread one. It still goes // to review: `crossChecked: false` is what keeps the batch from // auto-matching a number one of three readings disagreed with. const tally = new Map(); for (const s of seen) tally.set(s, (tally.get(s) ?? 0) + 1); accountRef = [...tally.entries()].sort((a, b) => b[1] - a[1])[0][0]; crossChecked = false; notes.push(`el número de cuenta se leyó de ${distinct.length} formas distintas (${distinct.join(", ")})`); } const amount = money( firstMatch(text, [ /TOTAL\s*A\s*PAGAR\s*[:;.]?\s*\$\s*([\d,]+\.\d{2})/i, /Total\s*a\s*pagar\s*[:;.]?\s*\$\s*([\d,]+\.\d{2})/i, ]), ); // `20260630-20260630` — the range the bill was cut for. Both ends are the // same reading date on every sample, so the period is reported as the ISO // month rather than a range no ledger row would ever be searched by. const facturado = firstMatch(text, [/PERIODO\s*FACTURADO\s*[:;.]?\s*(\d{8})\s*-\s*\d{8}/i]); const period = facturado ? `${facturado.slice(0, 4)}-${facturado.slice(4, 6)}` : null; return { provider: "GAS TIJUANA", serviceKind: "GAS", accountRef: accountRef || null, cadastralKey: null, amount, dueDate: parseDate( firstMatch(text, [/Fecha\s*de\s*Vencimiento\s*[:;.]?\s*(\d{4}\s*\/\s*\d{2}\s*\/\s*\d{2})/i])?.replace( /\s/g, "", ), ), period, crossChecked, notes, }; } // --- PREDIAL (municipal property tax) --------------------------------------- /** * Normalise a printed clave catastral to the eight-character form * `Property.cadastralKey` holds. The municipalities print it grouped * (`KP-128-106`, `MM-B01-041`); the stored value drops the separators * (`KP128106`, `MMB01041`). * * The shape is *not* two letters and six digits, which is the assumption that * has to be resisted here. Across the 932 distinct claves on file, characters * four through eight are digits without exception, but the third is a digit in * 917 of them and one of `A`, `B`, `H`, `T` in the other fifteen. Running the * whole tail through `toDigits` — which maps `B` to `8` — is what turned a real * `MMB01041` into a nonexistent `MM801041`, so only positions four onward get * that treatment and a letter in the third position is kept as printed. * * That leaves a genuine ambiguity at that one position: a `B` there might be a * misread `8`, and 34 stored claves do carry an `8` there against six with a * `B`. It is left as read rather than guessed, because a page that fails to * match lands in the review queue where a human fixes it in seconds, while a * page that matches the wrong property posts a charge to the wrong customer. * * The two-letter prefix is the other fragile part. Tesseract inserts a spurious * `I` into letter pairs with some regularity — a real `MM-200-010` came back as * `MIM-200-010` — so a run longer than two letters has its `I`/`L` dropped * first, which recovers exactly that case. Anything still not two letters is * truncated and flagged, because a wrong prefix silently matches the wrong * property or, more often, nothing at all. */ export function normalizeCadastralKey( raw: string, notes: string[], ): string | null { const m = raw.match(/^([A-Za-z|]{2,5})[-\s]?([A-Za-z0-9|]{3})[-\s]?([0-9OIlSBD]{3})$/); if (!m) return null; let letters = m[1].toUpperCase().replace(/[^A-Z]/g, ""); if (letters.length > 2) { const stripped = letters.replace(/[IL]/g, ""); if (stripped.length === 2) { letters = stripped; } else { letters = letters.slice(0, 2); notes.push(`la clave catastral se leyó como "${m[1]}"; se tomó "${letters}"`); } } if (letters.length !== 2) return null; const third = m[2][0].toUpperCase(); const tail = (/[A-Z]/.test(third) ? third : toDigits(third)) + toDigits(m[2].slice(1)) + toDigits(m[3]); return tail.length === 6 ? letters + tail : null; } /** The grouped clave as printed, anchored to its label when one survived OCR. */ const GROUPED_CLAVE = "[A-Z|]{2,5}-[A-Z0-9OIlSBD]{3}-[0-9OIlSBD]{3}"; function findCadastralKey(text: string, notes: string[]): string | null { const labelled = firstMatch(text, [ new RegExp(`Clave\\s*Catastral\\s*[^A-Z0-9]{0,8}(${GROUPED_CLAVE})`, "i"), new RegExp(`CLAVE\\s*[^A-Z0-9]{0,8}(${GROUPED_CLAVE})`, "i"), ]); if (labelled) return normalizeCadastralKey(labelled, notes); // Ensenada's label ("CLAVE") lands inside a table header that OCRs into // noise more often than not, so the bare grouped shape is accepted as a // fallback. It is distinctive enough — two letters and two three-character // groups joined by hyphens appears nowhere else on these pages. const bare = firstMatch(text, [new RegExp(`\\b(${GROUPED_CLAVE})\\b`)]); return bare ? normalizeCadastralKey(bare, notes) : null; } /** * Tijuana: a "CERTIFICACIÓN DE CAJA" whose payment barcode is one 32-digit run * of `account(8) + due date(DDMMYY) + amount(9) + folio(9)`, verified against * all five sample pages. Municipal totals are whole pesos (the receipt itself * carries a "Redondeo" line), so the barcode amount needs no decimal point. * * No clave catastral is printed anywhere on this layout — the 8-digit * municipal account is the only identifier, and it is not a number the legacy * database ever held. Until a reviewer confirms one, every Tijuana page lands * in review; confirming teaches the matcher (see `learnAccountRefs`) so the * same property matches itself next year. */ function parsePredialTijuana(page: OcrPage): ParsedStatement { const text = page.text; const notes: string[] = []; const barcode = text.match(/(? /TOTALES/i.test(l)) ?? ""; const figures = allMatches(totalsRow, /\$\s*(\d[\d,.\s]*\.\d{2})/); const amount = figures.length ? money(figures[figures.length - 1]) : null; if (amount == null) { notes.push("no se pudo leer el importe con certeza; capturarlo a mano"); } return { provider: "PREDIAL ENSENADA", serviceKind: "PROPERTY_TAX", accountRef: null, cadastralKey: findCadastralKey(text, notes), amount, // This layout prints no payment deadline at all — it is a receipt for a // payment already made at the municipal window. dueDate: null, period: firstMatch(text, [/A[ÑN]O\s*[\s\S]{0,60}?\b(20\d{2})\b/i]), crossChecked: null, notes, }; } // --- ZONA FEDERAL (ZOFEMAT, Tijuana) ---------------------------------------- /** * Normalise the concession clave the zona federal receipt is keyed by. * * It is printed grouped and loosely spaced — `12-T -012`, `14-A-119`, * `14-K -031` — and is a different shape from the cadastral key entirely: two * digits, one letter, three digits. The letter is kept as printed rather than * digitised, for the same reason `normalizeCadastralKey` keeps its third * character: `toDigits` maps `B` to `8` and `D` to `0`, and a real `14-D -014` * run through it becomes `140014`, which is not a clave at all. * * Stored without separators, because nothing on file holds this value yet (see * `parseZonaFederal`) so the canonical form is ours to pick, and a bare run * cannot be broken by the hyphen the scan renders as a dash, a minus or * nothing. */ export function normalizeZofematKey(raw: string): string | null { const m = raw.match(/^([0-9OIlSBD]{2})\s*-\s*([A-Za-z])\s*-?\s*([0-9OIlSBD]{3})$/); if (!m) return null; const zone = toDigits(m[1]); const lot = toDigits(m[3]); if (zone.length !== 2 || lot.length !== 3) return null; return `${zone}${m[2].toUpperCase()}${lot}`; } /** * The bimester the receipt prints as `2026-2 / 2026-2`, rendered in the * vocabulary the ledger already speaks. * * All 258 legacy FEDERAL ZONE transactions carry a period of `JAN/FEB`, * `MAR/APR`, `MAY/JUN` or `NOV/DEC`, and their payment dates confirm the * ordering — JAN/FEB was paid in March, MAR/APR in May, MAY/JUN in July, * NOV/DEC in January, i.e. always the month after the bimester closes. The * receipts agree: the two `2026-3` samples fall due 17/07/2026 with no * surcharge, which is bimester three, May and June. Writing `2026-3` instead * would leave the OCR-posted rows unsearchable alongside every hand-keyed one. */ const BIMESTERS = ["JAN/FEB", "MAR/APR", "MAY/JUN", "JUL/AUG", "SEP/OCT", "NOV/DEC"]; /** * Tijuana's "Zona Federal Marítimo Terrestre" — the federal maritime-zone * occupancy fee, billed by the municipality for beachfront lots. * * Nothing on file identifies these. `PropertyService.accountNumber` for * FEDERAL_ZONE holds DATMEX.zfed, which is not a reference at all but an * amount: its 77 values include `246.06`, `2369.09`, `22653.94` and a negative * `-1679`, and the concession claves these receipts are keyed by appear nowhere * in the database. So the clave goes to `meterNumber` (see `scopedRefField`), * every page starts cold, and the first confirm teaches the match — the same * arrangement Tijuana predial needed, for the same reason. * * The amount is taken from the SubTotal row rather than the "Total a pagar" * box, which is printed on a grey fill and OCR'd on only 1 of 8 sample pages * while the SubTotal row read on 8 of 8. The two differ by design: 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 — so the payable figure is the rounded subtotal, and where * the printed box did read, it agreed. */ function parseZonaFederal(page: OcrPage): ParsedStatement { const text = page.text; const notes: string[] = []; // Printed twice, once on the receipt and once on the stub below it, which is // a free second reading: on one sample the heading was struck through by the // office's own highlighter and only the stub survived. const claves = [ ...new Set( allMatches(text, /Clave\s*[:;.]?\s*([0-9OIlSBD]{2}\s*-\s*[A-Za-z]\s*-?\s*[0-9OIlSBD]{3})/i) .map(normalizeZofematKey) .filter((k): k is string => k != null), ), ]; const accountRef = claves[0] ?? null; let crossChecked: boolean | null = null; const subtotalRow = text.split("\n").find((l) => /SubTotal/i.test(l)) ?? ""; const figures = allMatches(subtotalRow, /(\d[\d,]*\.\d{2})/); // Impuesto, Actualización, Recargo, Multa, Importe — the payable one is last. const importe = figures.length ? money(figures[figures.length - 1]) : null; const rounded = importe != null ? Math.round(importe) : null; const printed = money( firstMatch(text, [/Total\s*a\s*pagar\s*[:;.]?\s*\$?\s*([\d,]+\.\d{2})/i]), ); if (printed != null && rounded != null) { crossChecked = Math.abs(printed - rounded) < 0.5; if (!crossChecked) { notes.push( `el total impreso (${printed}) no coincide con el subtotal redondeado (${rounded})`, ); } } else if (rounded != null) { notes.push("importe tomado del subtotal, redondeado al peso"); } else if (printed == null) { notes.push("no se pudo leer el importe con certeza; capturarlo a mano"); } // A clave read two different ways means one of the two readings is wrong and // there is no third to break the tie, so the page goes to a human even if the // money cross-checked. if (claves.length > 1) { crossChecked = false; notes.push(`la clave se leyó de ${claves.length} formas distintas (${claves.join(", ")})`); } if (!accountRef) notes.push("no se pudo leer la clave de la concesión"); const bimester = text.match(/\b(20\d{2})\s*-\s*([1-6])\s*\/\s*20\d{2}\s*-\s*[1-6]/); return { provider: "ZONA FEDERAL TIJUANA", serviceKind: "FEDERAL_ZONE", accountRef, cadastralKey: null, amount: printed ?? rounded, dueDate: parseDate( firstMatch(text, [/Vencimiento\s*[:;.]?\s*(\d{2}\/\d{2}\/\d{4})/i]), ), period: bimester ? BIMESTERS[+bimester[2] - 1] : null, crossChecked, notes, }; } const PARSERS: Record ParsedStatement> = { CFE: parseCfe, CESPT: parseCespt, TELNOR: parseTelnor, "GAS TIJUANA": parseGas, "PREDIAL TIJUANA": parsePredialTijuana, "PREDIAL ROSARITO": parsePredialRosarito, "PREDIAL ENSENADA": parsePredialEnsenada, "ZONA FEDERAL TIJUANA": parseZonaFederal, }; const EMPTY: ParsedStatement = { provider: null, serviceKind: null, accountRef: null, cadastralKey: null, amount: null, dueDate: null, period: null, crossChecked: null, notes: [], }; /** Detect the provider and run its parser. */ export function parseStatement(page: OcrPage): ParsedStatement { const provider = detectProvider(page.text); if (!provider) return { ...EMPTY, notes: ["no se reconoció el proveedor"] }; return PARSERS[provider](page); }