import type { OcrPage } from "../../statements/ocr/ocr.provider"; /** * What one parsed policy page yields. All fields are nullable because each * provider prints a different subset (GMX's certificate has no premium * breakdown, only insured amounts; GMX's receipt page would carry the * premium), and the matcher + the review queue both work better with * "field was read" vs "field was not" rather than guessing. */ export interface ParsedPolicy { /** "GMX" today; the dispatcher lives on `detectProvider`. */ provider: string; policyNumber: string | null; insuredName: string | null; additionalInsured: string | null; /** The "Broker" line on GMX — mapped onto `Policy.agentName`. */ agentName: string | null; legalAddress: string | null; zip: string | null; policyFrom: Date | null; policyTo: Date | null; /** Signature/issue date — `Policy.policyDate`. */ policyDate: Date | null; /** "MXN" | "USD" | …, derived from the printed currency word. */ currency: string | null; netPremium: number | null; policyFee: number | null; brokerFee: number | null; total: number | null; /** "CONTADO" / "MENSUAL" / … — premium-payment cadence text. */ premiumPayment: string | null; /** * GMX prints per-coverage rows in a table: Building / Contents / * Earthquake / … with insured amount, deductible, loss participation. * Preserved verbatim so a missing premium receipt still leaves the * coverages auditable on the Policy row. */ coverages: ParsedCoverage[]; /** Human-readable trail of what was read, surfaced in the review queue. */ notes: string[]; } export interface ParsedCoverage { /** "Building", "Contents", "Debris removal Building", "Earthquake…". */ risk: string; insuredAmount: number | null; deductible: string | null; lossParticipation: string | null; } // --- shared helpers --------------------------------------------------------- const DIGIT_CONFUSIONS: Record = { O: "0", o: "0", D: "0", I: "1", l: "1", "|": "1", S: "5", B: "8", }; /** * Tesseract confuses these glyphs inside numeric runs with some regularity. * Same map and same caveat as the statement parser: ONLY apply to fields * known to be digits, never to free text. */ 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. Same algorithm as the statement parser — kept here so the * policy module is self-contained, since importing from `../../statements` * would couple two unrelated domains through a helper. */ function money(s: string | null | undefined): number | null { if (!s) return null; const cleaned = s.replace(/[\s$]/g, ""); 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); } 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; } 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, }; /** * DD/MM/YYYY (GMX) and the dash-separated ISO variants. Two-digit years are * windowed: < 50 → 20YY, ≥ 50 → 19YY, matching what a 1950-2049 window * expects from a paper document. */ function parseDate(raw: string | null | undefined): Date | null { if (!raw) return null; const s = raw.trim(); let m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/); if (m) return utc(+m[3], +m[2] - 1, +m[1]); m = s.match(/^(\d{1,2})[-\s/]([A-Z]{3})[-\s/](\d{2,4})$/i); if (m && MONTHS[m[2].toUpperCase()] !== undefined) { const yr = +m[3]; const y = m[3].length === 2 ? (yr < 50 ? 2000 + yr : 1900 + yr) : yr; return utc(y, MONTHS[m[2].toUpperCase()], +m[1]); } m = s.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/); if (m) return utc(+m[1], +m[2] - 1, +m[3]); // "July 23, 2026" — the signature date on the GMX certificate. m = s.match(/^([A-Za-z]+)\s+(\d{1,2}),\s*(\d{4})$/); if (m) { const MONTH_NAMES: Record = { january: 0, february: 1, march: 2, april: 3, may: 4, june: 5, july: 6, august: 7, september: 8, october: 9, november: 10, december: 11, }; const mo = MONTH_NAMES[m[1].toLowerCase()]; if (mo !== undefined) return utc(+m[3], mo, +m[2]); } 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; } /** Map the printed currency word onto an ISO code. */ function currencyCode(raw: string | null | undefined): string | null { if (!raw) return null; const s = raw.trim().toUpperCase(); if (s.startsWith("PESO") || s === "MXN" || s.includes("NACIONAL")) return "MXN"; if (s.startsWith("DOLAR") || s === "USD" || s.includes("DOLLAR")) return "USD"; if (s === "EUR" || s.includes("EURO")) return "EUR"; return null; } // --- provider detection ----------------------------------------------------- /** * Brand first, layout as a fallback. Same ordering rule as the statement * parser: a brand wordmark is the cheapest, most reliable discriminator, and * a layout rule that runs first can wrongly claim a page that happens to * carry the same shape string (the statement parser's lesson with CFE vs * GAS on "PERIODO FACTURADO"). */ const BRAND: [string, RegExp][] = [ ["GMX", /\bGMX\b|Grupo\s*Mexicano\s*de\s*Seguros|gmx\.com\.mx|JUNTOS\s*EL\s*RIESGO\s*ES\s*MENOR/i], ]; const LAYOUT: [string, RegExp][] = [ ["GMX", /Multiple\s*Policy|IMPUESTO\s*PREDIAL[\s\S]{0,80}EN\s*FECHA|Material\s*damages\s*Section/i], ]; export function detectPolicyProvider(text: string): string | null { for (const group of [BRAND, LAYOUT]) { for (const [name, pattern] of group) { if (pattern.test(text)) return name; } } return null; } // --- parsers ---------------------------------------------------------------- const PARSERS: Record ParsedPolicy> = { GMX: parseGmx, }; const EMPTY_COVERAGE: ParsedCoverage = { risk: "", insuredAmount: null, deductible: null, lossParticipation: null, }; export function parsePolicy(page: OcrPage): ParsedPolicy { const provider = detectPolicyProvider(page.text); if (!provider) { return { provider: "", policyNumber: null, insuredName: null, additionalInsured: null, agentName: null, legalAddress: null, zip: null, policyFrom: null, policyTo: null, policyDate: null, currency: null, netPremium: null, policyFee: null, brokerFee: null, total: null, premiumPayment: null, coverages: [], notes: ["no se reconoció el proveedor"], }; } return PARSERS[provider](page); } // --- GMX -------------------------------------------------------------------- /** * GMX policy certificate layout (this is the translation PDF — the Spanish * version is the canonical source, but every GMX portal download is a * translation so the parser can rely on these English labels). * * Page 1 carries the contract header in a single boxed table: * Policy | Insured | Additional insured | Legal address | ZIP | Income Tax No. * Broker | Term | From | To | Currency | Premium payment * followed by an "Agreed clauses" block, the signature date, and the GMX * letterhead. * * Page 2 carries the per-coverage table (Risk / Insured Amount / Deductible / * Loss Participation) under "Material damages Section" and "ADDITIONAL RISK". * * Premium / total / fees are NOT on the certificate page — they live on * GMX's separate "recibo" PDF. The parser leaves them null and flags the * gap in `notes`; the matcher still proposes a Policy update from the * certificate alone, and the staff confirm step fills premium in by hand * or after a follow-up receipt upload. */ function parseGmx(page: OcrPage): ParsedPolicy { const text = page.text; const notes: string[] = []; // ----- header table (page 1) -------------------------------------------- // The Policy row repeats the number in a long run: // "Policy 007-037-07005947-0000-02 in accordance with the enclosed clauses…" // so taking the first token-shaped number is correct; the trailing prose // never looks like one. The dashes are part of the printed number — keep // them (don't run toDigits, which would flatten them). const policyNumber = firstMatch(text, [ /\bPolicy\s+([0-9OIlSBD]{3,4}[-\s][0-9OIlSBD]{3}[-\s][0-9OIlSBD]{8}[-\s][0-9OIlSBD]{4}[-\s][0-9OIlSBD]{2})/i, /\bPolicy\s+([0-9OIlSBD][0-9OIlSBD\s-]{9,30})/, ]); // "Insured JON ASHLEY STRABALA" — label, then 1+ whitespace, then the name. // Names can carry accents (ÁVILA) or apostrophes (O'NEILL); the label is // always upper-case English on this layout, so case is reliable. const insuredName = labelValue(text, /^Insured\s+([A-ZÁÉÍÓÚÑ'][A-ZÁÉÍÓÚÑ '\-.]+)$/m); const additionalInsured = labelValue(text, /^Additional\s+insured\s+([A-ZÁÉÍÓÚÑ '\-.]+)$/m); // Legal address is a single long line; the parser keeps it whole. const legalAddress = labelValue(text, /^Legal\s+address\s+(.+)$/m); const zip = labelValue(text, /^ZIP\s+(\d{4,6})\b/m); if (!zip && legalAddress) { // Last resort: zip often appears at the tail of the address run too // ("…C.P. 22550"). Cheap regex, no false-positive cost on this layout. const m = legalAddress.match(/\b(\d{5})\b/); if (m) notes.push(`ZIP leído de la dirección (${m[1]})`); } // Broker line on GMX: "(1176) Jorge Humberto Cuadros" — the number is the // agent code, the name is what lands on `Policy.agentName`. The parens // are optional: a future layout or scan drop them. const brokerRaw = labelValue(text, /^Broker\s+(?:\(\d+\)\s*)?(.+)$/m); const agentName = brokerRaw?.trim() ?? null; // Term: "12 months" — informational, not a free-standing date. Stored in // notes; the UI can derive `coveragePeriodDays` from From/To anyway. const term = firstMatch(text, [/^Term\s+(\d+\s+months?)$/m]); if (term) notes.push(`vigencia: ${term}`); const policyFrom = parseDate( labelValue(text, /^From\s+(\d{1,2}\/\d{1,2}\/\d{4})\b/m), ); const policyTo = parseDate( firstMatch(text, [/^To\s+(\d{1,2}\/\d{1,2}\/\d{4})\b/m]), ); // "at twelve hours (noon) Mexico City time." — kept in notes only. if (/twelve\s*hours|noon/i.test(text)) notes.push("vencimiento a las 12:00 hora del centro"); // The Currency / Premium payment cells sit next to each other on one // line; pull them with bounded matches so the trailing label of the // adjacent cell doesn't swallow the wrong value. const currency = currencyCode(labelValue(text, /^Currency\s+(\S+?)(?:\s+Premium\s+payment|$)/m)); const premiumPayment = labelValue(text, /Premium\s+payment\s+(\S+)$/m); // ----- signature date (page 1) ----------------------------------------- // Appears above the signature line on its own: "July 23, 2026". const dateMatch = text.match( /\b(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{1,2},\s*\d{4}\b/, ); const policyDate = dateMatch ? parseDate(dateMatch[0]) : null; if (!policyDate) notes.push("no se pudo leer la fecha de firma"); // ----- coverages table (page 2) ----------------------------------------- const coverages = parseGmxCoverages(text, notes); if (!policyNumber) notes.push("no se pudo leer el número de póliza"); if (!policyFrom || !policyTo) notes.push("no se pudo leer el período de vigencia"); // Premium fields are expected to be missing on the certificate page; flag // it explicitly so the reviewer knows to look for a separate receipt. if (!text.match(/Prima\s*neta|net\s*premium/i)) { notes.push("esta página no trae prima; revisar el recibo de GMX por separado"); } return { provider: "GMX", policyNumber: policyNumber ? policyNumber.replace(/\s+/g, "") : null, insuredName, additionalInsured, agentName, legalAddress, zip, policyFrom, policyTo, policyDate, currency, netPremium: null, policyFee: null, brokerFee: null, total: null, premiumPayment, coverages, notes, }; } /** * Read the value that follows a `LABEL` on the same line. Used by every * "Label Value" cell on the GMX header table — matches on the line * itself rather than across the page, so a label that also appears in body * text can't accidentally claim a different cell. */ function labelValue(text: string, pattern: RegExp): string | null { const m = text.match(pattern); if (!m?.[1]) return null; return m[1].replace(/\s+/g, " ").trim(); } /** * Walk the GMX per-coverage table on page 2. * * Real sample row (single-line representation of the table after pdftotext * flattens it; the real layout uses fixed columns): * "Building $350,000.00 Not applies Not applies" * * The four columns are: * Risk (left), Insured Amount ($ figure OR the word "Covered"), * Deductible (free text — "Not applies", "5%", "2% of the sum insured…"), * Loss Participation (same). * * "Covered" means the coverage is included with no dollar cap. We record * the word so the review queue surfaces it instead of inventing a number. * * Deductible / Loss Participation are kept as printed strings, not * converted to numbers — a "20%" loss participation is a different field * shape from a "$5,000" deductible and the JSON column lets the UI render * either verbatim. * * Multi-line cells (the "Earthquake" row's deductible wraps to three lines * because the column is narrow) are collapsed by joining consecutive * non-table-body lines onto the previous row's deductible cell before * applying the column regex. */ function parseGmxCoverages(text: string, notes: string[]): ParsedCoverage[] { const out: ParsedCoverage[] = []; // Stop at "VALUES ADDED" — the trailing prose section (homeowner // services, legal text) is not a coverage table. Re-enter at // "ADDITIONAL RISK" for the second coverage block on page 2. const segments = text.split(/VALUES\s*ADDED/i)[0].split(/ADDITIONAL\s*RISK/i); // `[ \t]` (not `\s`) inside a cell: the deductible/loss-participation // columns may wrap onto several lines in the raw `pdftotext` output, and // matching across newlines silently swallows the next row. const re = /^([A-Za-zÁÉÍÓÚÑ][A-Za-zÁÉÍÓÚÑ /\-.]+?)[ \t]+(\$[\d,.]+|Covered|Not[ \t]+applies)[ \t]+(\S+(?:[ \t]\S+){0,8})[ \t]+(\S+(?:[ \t]\S+){0,8})[ \t]*$/gim; let m: RegExpExecArray | null; for (const seg of segments) { re.lastIndex = 0; while ((m = re.exec(seg)) !== null) { const risk = m[1].trim(); const amountCell = m[2].trim(); const deductible = m[3].trim(); const lossParticipation = m[4].trim(); // Skip the "Risk / Insured Amount / Deductible / Loss Participation" // header row itself, which matches the same regex. if (/^Risk$/i.test(risk) && /Insured\s*Amount/i.test(amountCell)) continue; out.push({ risk, insuredAmount: amountCell === "Covered" || amountCell === "Not applies" ? null : money(amountCell), deductible, lossParticipation, }); } } if (out.length === 0) notes.push("no se encontraron coberturas en la tabla"); return out; }