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" | "ANA"; the dispatcher lives on `detectPolicyProvider`. */ provider: string; /** * The `PolicyType.name` this document's product corresponds to — "AUTO", * "LICENCIAS", "MULT". A **name**, not an id: the parser is a pure function * over text and must not reach for the database, so the confirm step * resolves it (and leaves the field null if no such row exists). * * Null when the layout cannot tell. Guessing is worse than null here — the * type drives which renewal report a policy appears on. */ policyTypeName: string | null; 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[]; /** * Printed term length in days. GMX prints "Term 12 months" and the office * keys the default 365; ANA's tourist book sells 3- and 4-day policies and * prints the count in its own `DAYS` cell, so leaving `Policy * .coveragePeriodDays` at its 365 default would overstate a weekend policy * by a year. */ coveragePeriodDays: number | null; /** * Vehicles listed on the policy face. Empty on the property lines (GMX * Hogar) and on ANA's driver's policy, which insures a person rather than * a car. */ vehicles: ParsedVehicle[]; /** * Named drivers. ANA's `DRIVER´S POLICY FOR AUTOMOBILE` prints up to five * slots and carries no vehicle at all — the drivers ARE the risk. */ drivers: ParsedDriver[]; /** 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; /** * The per-coverage premium, when the layout prints one. GMX never does; * ANA prints a `PREMIUM` column for the add-on sections (legal aid, * roadside assistance, catastrophic liability), where the printed figure * is what the coverage COST, not what it pays out. Recording it as * `insuredAmount` would read as a $40 sum insured on the review screen. */ premium?: number | null; } /** One row of ANA's `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` table. */ export interface ParsedVehicle { /** "VEHICLE" | "TRAILER" | "TOWING" — the printed item slot. */ item: string; modelYear: string | null; make: string | null; /** The printed BODY cell ("PACIFICA", "GENESIS SEDAN"). */ bodyType: string | null; vinNumber: string | null; licensePlate: string | null; } export interface ParsedDriver { fullName: string; licenseNumber: string | null; address: string | null; phone: string | null; email: 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; } function allMatches(text: string, pattern: RegExp): string[] { const re = new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`); const out: string[] = []; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { if (m[1]) out.push(m[1]); if (m.index === re.lastIndex) re.lastIndex++; } return out; } /** * The printed policy number, as a shape rather than as fixed widths. * * Both GMX document families print the same five dash-separated groups, but * NOT with the same widths: the English caratula reads * `007-037-07005947-0000-02` and the Spanish PVL especificación reads * `07-037-07006957-00000-01` — 2 digits in the first group and 5 in the * fourth. Pinning the widths (as the first version of this parser did) reads * one family and silently returns null on the other, which the matcher then * reports as "no se pudo leer el número de póliza" on a page where the number * is printed ten times. * * The OCR confusion class is allowed inside the groups but NOT normalised * here — `normalizePolicyNumber` does that, so the raw read stays available. */ const POLICY_NUMBER_SHAPE = /\b([0-9OIlSBD]{2,4}[-\s][0-9OIlSBD]{3}[-\s][0-9OIlSBD]{6,10}[-\s][0-9OIlSBD]{4,6}[-\s][0-9OIlSBD]{2})\b/; /** * Strip layout whitespace and fold the Tesseract digit confusions, keeping the * dashes: they are part of the number as the office keys it, and * `PolicyMatcherService` matches `Policy.policyNumber` exactly. */ function normalizePolicyNumber(raw: string | null | undefined): string | null { if (!raw) return null; const out = raw .replace(/\s+/g, "") .split("") .map((c) => (c === "-" ? c : (DIGIT_CONFUSIONS[c] ?? c))) .join(""); return out || 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], [ "ANA", /A\.?N\.?A\.?\s*COMPA[ÑN][IÍ]A\s*DE\s*SEGUROS|anaseguros\.com\.mx|auto-insurance-ana\.com|une@anaseguros/i, ], ]; const LAYOUT: [string, RegExp][] = [ [ "GMX", /Multiple\s*Policy|IMPUESTO\s*PREDIAL[\s\S]{0,80}EN\s*FECHA|Material\s*damages\s*Section|PVL\s*Hogar|ESPECIFICACI[ÓO]N\s*QUE\s*SE\s*ADHIERE/i, ], [ "ANA", /SPECIAL\s*POLICY\s*FOR\s*TOURISTS|DRIVER[´'`’]?S\s*POLICY\s*FOR\s*AUTOMOBILE|Tarjeta\s*de\s*Identificaci[óo]n\s*de\s*viajero/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, ANA: parseAna, }; /** Every field null / empty — the base each provider parser fills in. */ function emptyParsedPolicy(provider: string): ParsedPolicy { return { provider, policyTypeName: null, 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: [], coveragePeriodDays: null, vehicles: [], drivers: [], notes: [], }; } export function parsePolicy(page: OcrPage): ParsedPolicy { const provider = detectPolicyProvider(page.text); if (!provider) { return { ...emptyParsedPolicy(""), notes: ["no se reconoció el proveedor"] }; } return PARSERS[provider](page); } // --- GMX -------------------------------------------------------------------- /** * GMX ships (at least) two unrelated document families, and the office * downloads both from the same portal for the same policy: * * 1. **Caratula / translation** (`…_Traduccion.pdf`) — the English-labelled * certificate: a boxed header table (Policy / Insured / Broker / From / To * / Currency) and a four-column coverage table. * 2. **Especificación** (`…-CondicionesParticulares.pdf`) — the Spanish * "ESPECIFICACIÓN QUE SE ADHIERE Y FORMA PARTE INTEGRANTE DE LA PÓLIZA" * issued by GMX's Punto de Venta en Línea (PVL). Ten pages of prose with * NO header table, NO dates, NO broker, and the coverages expressed as * section headings above a `Límite Máximo de Responsabilidad:` label. * * They share only the brand and the policy number, so they get separate * parsers behind one provider. Both still return `provider: "GMX"` — the * matcher keys on the policy number alone and must not care which artifact * the office happened to upload. */ /** * Both GMX documents describe the same product: the caratula's own header * reads "Multiple Policy / Home" and the especificación is "PVL Hogar". MULT * is the legacy discriminator for that multi-line home policy, and the live * row carrying 769 of them. * * Not INCENDIO: that row is fire-only and no policy in the book has ever * used it. */ const GMX_POLICY_TYPE = "MULT"; function parseGmx(page: OcrPage): ParsedPolicy { return isEspecificacion(page.text) ? parseGmxEspecificacion(page) : parseGmxCaratula(page); } /** The PVL especificación announces itself in its own repeated page header. */ function isEspecificacion(text: string): boolean { return /ESPECIFICACI[ÓO]N\s+QUE\s+SE\s+ADHIERE|PVL\s+Hogar|Nombre\s+del\s+asegurado/i.test(text); } /** * 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 parseGmxCaratula(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, [ new RegExp(`\\bPolicy\\s+${POLICY_NUMBER_SHAPE.source}`, "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); let 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(?!.*\b\d{5}\b)/); if (m) { zip = m[1]; 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 { ...emptyParsedPolicy("GMX"), policyTypeName: GMX_POLICY_TYPE, policyNumber: normalizePolicyNumber(policyNumber), insuredName, additionalInsured, agentName, legalAddress, zip, policyFrom, policyTo, policyDate, currency, premiumPayment, coverages, // Derived from the dates, or left null so `Policy.coveragePeriodDays` // keeps its 365 default — which is the right answer for GMX, whose only // printed term is "12 months". Converting that term to a day count here // would just be a worse way of saying 365. coveragePeriodDays: daysBetween(policyFrom, policyTo), notes, }; } /** * Whole days from `from` to `to`, or null when either is missing. Both are * UTC midnights out of `parseDate`, so the division is exact — no DST hour to * round away. */ function daysBetween(from: Date | null, to: Date | null): number | null { if (!from || !to) return null; const days = Math.round((to.getTime() - from.getTime()) / 86_400_000); return days > 0 ? days : null; } /** * 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; } // --- GMX / PVL especificación ----------------------------------------------- /** * Page furniture on the PVL especificación: the two-line header that repeats * on all ten pages (title + policy number on its own line) and the footer. * Skipped when hunting for a coverage's heading, or the heading found would * be the page break rather than the risk. */ const ESPEC_FURNITURE = /^(ESPECIFICACI[ÓO]N\s+QUE\s+SE\s+ADHIERE|PVL\s+Hogar\s*-\s*GMX|P[áa]gina\s*:|La\s+presente\s+p[óo]liza\s+queda\s+sujeta)/i; /** * Labels that structure a coverage block rather than name one. A heading walk * that accepts these ends up calling every coverage "Deducible:". */ const ESPEC_STRUCTURAL = /^(L[íi]mites?\b|Deducibles?\b|Coaseguros?\b|Bienes\s+cubiertos|Riesgos\s+cubiertos|Riesgos\s+adicionales|Exclusiones?\b|Zona\s*:|Periodo\s+de\s+indemnizaci[óo]n|Nota\s+aclaratoria|CONDICIONES\s+ESPECIALES|IMPORTANTE\b|SECCI[ÓO]N\b|II+\.-|[●•]|\d+\.\s)/i; /** `$200,000.00 USD`, `$ 35,050.00 M.N.` — amount plus its printed currency. */ const ESPEC_AMOUNT = /\$\s*([\d,]+(?:\.\d{2})?)\s*(USD|MXN|M\.?\s?N\.?|D[LlÓó]{1,3}[Ss]?)?/; /** The label that introduces every insured amount on this layout. */ const ESPEC_LIMIT_LABEL = /^\s*L[íi]mite\s+(?:M[áa]ximo\s+)?(?:de\s+)?Responsabilidad\s*:?(.*)$/i; /** * GMX's PVL "especificación" layout — ten pages of Spanish prose that the * office receives as `…-CondicionesParticulares.pdf`. * * What this document DOES carry: the policy number (printed on every page * header), the insured's name, the risk location, and every coverage limit. * * What it does NOT carry, at all: policy dates, broker, premium, currency as * a labelled field, or a coverage table. There is nothing to read for those, * so they stay null and the notes say why — the caratula is the document that * has them, and the office downloads both for the same policy. * * Coverages are not tabular here. Each one is a section heading followed by * `Límite Máximo de Responsabilidad:` and an amount on the next line (or two * amounts under `Edificio` / `Contenidos` sub-labels), with the deductible * printed further down its own block. So the parser anchors on the limit * label and walks BACKWARDS for the heading, rather than trying to match a * row shape that does not exist. */ function parseGmxEspecificacion(page: OcrPage): ParsedPolicy { const text = page.text; const lines = text.split("\n"); const notes: string[] = []; // ----- policy number ----------------------------------------------------- // Printed on its own line under the title on all ten pages. Ten independent // readings of the same number is a free cross-check: on a scan, a group that // OCR'd two different ways means one reading is wrong and there is no third // to break the tie, so the row goes to a human (the same rule the zona // federal statement parser applies to its clave). const readings = [ ...new Set( allMatches(text, POLICY_NUMBER_SHAPE) .map(normalizePolicyNumber) .filter((n): n is string => n != null), ), ]; const policyNumber = readings[0] ?? null; if (!policyNumber) notes.push("no se pudo leer el número de póliza"); if (readings.length > 1) { notes.push( `el número de póliza se leyó de ${readings.length} formas distintas (${readings.join(", ")})`, ); } // ----- insured + risk location ------------------------------------------ // Values wrap onto the following lines within the same visual cell, and a // blank line always ends the cell, so the block reader joins until one. const insuredName = espectBlock(lines, /^\s*Nombre\s+del\s+asegurado\s+(.+)$/i); const additionalInsured = espectBlock(lines, /^\s*Asegurado\s+Adicional\s+(\S.*)$/i); const legalAddress = espectBlock(lines, /^\s*Ubicaci[óo]n\s+del\s+riesgo\s+(.+)$/i); // No ZIP field on this layout; it is the trailing run of the address // ("…BAJA CALIFORNIA 22713"). Take the LAST five-digit run — a street // number earlier in the line is never five digits, but anchoring on the // first match would still be the fragile choice. let zip: string | null = null; if (legalAddress) { const m = legalAddress.match(/(\d{5})(?!.*\d{5})/); if (m) { zip = m[1]; notes.push(`código postal leído de la ubicación del riesgo (${m[1]})`); } } // ----- underwriting context --------------------------------------------- // Not `ParsedPolicy` fields, but they are the difference between a coverage // a reviewer can price and one they cannot, so they ride along in notes. const personType = espectBlock(lines, /^\s*Tipo\s+Persona\s+Asegurada\s+(.+)$/i); if (personType) notes.push(`tipo de persona asegurada: ${personType}`); const building = espectBlock(lines, /^\s*Caracter[íi]sticas\s+del\s+Inmueble\s+(.+)$/i); if (building) notes.push(`características del inmueble: ${building}`); const water = firstMatch(text, [/^\s*(-?\s*\d+\s*mts\.?\s*cuerpo\s+agua\s+\S+)\s*$/im]); if (water) notes.push(`distancia a cuerpo de agua: ${water.replace(/\s+/g, " ")}`); const generalConditions = firstMatch(text, [/^\s*(W_\S+\.pdf)\s*$/im]); if (generalConditions) notes.push(`condiciones generales: ${generalConditions}`); // ----- coverages --------------------------------------------------------- const { coverages, currency } = parseEspecificacionCoverages(lines, notes); // These are absent by design on this document, not failures to read. Say so // explicitly, or the reviewer reads three empty fields as a broken parse. // // The renewal consequence is spelled out rather than left implied: a Policy // confirmed with a null `policyTo` never matches the renewals window query // (`renewals.service.ts`, `policyTo: { gte, lte }`), so it drops out of the // renewal notices silently and stays out. Nothing downstream errors. notes.push( "la especificación PVL no trae vigencia, agente ni prima; captúrelos a mano " + "(sin vigencia la póliza no entra en los avisos de renovación)", ); return { ...emptyParsedPolicy("GMX"), policyTypeName: GMX_POLICY_TYPE, policyNumber, insuredName, additionalInsured, legalAddress, zip, currency, coverages, notes, }; } /** * Read a `Label Value` cell whose value wraps onto the following lines. * * `pdftotext -layout` renders the especificación's header cells with the * value in a right-hand column, so a two-line risk location comes back as the * label line plus a continuation line indented to the same column. A blank * line always terminates the cell. */ function espectBlock(lines: string[], label: RegExp): string | null { for (let i = 0; i < lines.length; i++) { const m = lines[i].match(label); if (!m?.[1]) continue; const parts = [m[1]]; for (let j = i + 1; j < lines.length && lines[j].trim(); j++) { parts.push(lines[j].trim()); } const value = parts.join(" ").replace(/\s+/g, " ").trim(); return value || null; } return null; } /** * Walk the especificación's coverages by anchoring on the limit label. * * For each `Límite … Responsabilidad:` the amount is either on the same line * ("Bienes a la Intemperie"), on the next line (most), or split across two * lines under `Edificio` / `Contenidos` sub-labels ("Remoción de escombros"). * The risk name is the nearest heading ABOVE the label: a short line that is * itself preceded by a blank one. That last condition is what distinguishes a * heading from the short tail of a wrapped paragraph ("efectuados.", * "Y CADA PÉRDIDA."), which is otherwise indistinguishable by length alone * and would name a coverage after the last word of the prose above it. * * Returns the currency alongside, because on this layout there is no currency * field — it is only ever printed next to the amounts, and the sublimits in * the body prose are quoted in M.N. while the limits themselves are in USD. * Only the limit amounts vote. */ function parseEspecificacionCoverages( lines: string[], notes: string[], ): { coverages: ParsedCoverage[]; currency: string | null } { const coverages: ParsedCoverage[] = []; const currencyVotes: Record = {}; let lastHeading: string | null = null; const limitLines: number[] = []; lines.forEach((line, i) => { if (ESPEC_LIMIT_LABEL.test(line)) limitLines.push(i); }); limitLines.forEach((i, n) => { const nextLimit = limitLines[n + 1] ?? lines.length; const heading = espectHeadingAbove(lines, i); // "Sublímites:" / "Límite adicional." head a block that qualifies the // coverage above them rather than naming a new one, so they are read as // a sublimit OF the previous risk instead of as a standalone coverage. let risk = heading ?? "(sin nombre)"; if (/^Subl[íi]mites?\b|^L[íi]mite\s+adicional/i.test(risk) && lastHeading) { risk = `${lastHeading} — sublímite`; } else if (heading) { lastHeading = heading; } if (!heading) notes.push(`un límite no tiene encabezado identificable (línea ${i + 1})`); const deductible = espectDeductibleFor(lines, i, nextLimit); const found = espectAmountsFor(lines, i); if (found.length === 0) { notes.push(`no se pudo leer el monto de "${risk}"`); coverages.push({ risk, insuredAmount: null, deductible, lossParticipation: null }); return; } for (const a of found) { if (a.currency) currencyVotes[a.currency] = (currencyVotes[a.currency] ?? 0) + 1; coverages.push({ risk: a.sublabel ? `${risk} — ${a.sublabel}` : risk, insuredAmount: a.amount, deductible, lossParticipation: null, }); } }); // The per-section coverages that are stated inline rather than under a // limit label. On this policy they are the two that matter most on the // coast — earthquake (EXCLUIDO here) and hydrometeorological — and skipping // them would leave the biggest exposure off the parsed row entirely. coverages.push(...espectInlineSectionRisks(lines, currencyVotes, notes)); if (coverages.length === 0) notes.push("no se encontraron coberturas"); const currency = Object.entries(currencyVotes).sort((a, b) => b[1] - a[1])[0]?.[0] ?? null; if (currency) { notes.push(`moneda tomada de los límites impresos (${currency})`); } else { notes.push("no se pudo determinar la moneda de las sumas aseguradas"); } return { coverages, currency }; } /** * The nearest line above `at` that reads as a heading: short, not page * furniture, not a structural label, and itself preceded by a blank line. */ function espectHeadingAbove(lines: string[], at: number): string | null { for (let i = at - 1, walked = 0; i >= 0 && walked < 40; i--, walked++) { if (!isEspecHeading(lines, i)) continue; return lines[i].trim().replace(/\s*[:.]$/, ""); } return null; } /** * Does line `i` read as a coverage heading? * * Short, not page furniture, not a structural label — and preceded by a blank * line, which is the condition that actually does the work: a heading sits in * its own paragraph, and the short tail of a wrapped sentence ("efectuados.", * "Y CADA PÉRDIDA.") does not. Length alone cannot tell those apart. */ function isEspecHeading(lines: string[], i: number): boolean { const line = lines[i]?.trim() ?? ""; if (!line) return false; if (ESPEC_FURNITURE.test(line)) return false; // The repeated policy-number line in the page header. if (POLICY_NUMBER_SHAPE.test(line) && line.replace(/[\d\sOIlSBD-]/g, "") === "") return false; if (ESPEC_STRUCTURAL.test(line)) return false; if (line.length > 60) return false; // An amount on its own line is a value, never a heading — the sublimit // block prints one after a blank line, exactly where a heading would sit. if (/^\$?\s*[\d,]+(?:\.\d{2})?\s*(USD|MXN|M\.?\s?N\.?|DLLS?)?$/i.test(line)) return false; return i > 0 && !lines[i - 1].trim(); } /** * Amounts belonging to one limit label: on the label line itself, then on the * following lines up to the blank that closes the block. A short non-amount * line inside the block is a sub-label for the amount beneath it * (`Edificio` / `Contenidos`). */ function espectAmountsFor( lines: string[], at: number, ): { amount: number | null; currency: string | null; sublabel: string | null }[] { const out: { amount: number | null; currency: string | null; sublabel: string | null }[] = []; const push = (line: string, sublabel: string | null) => { const m = line.match(ESPEC_AMOUNT); if (!m) return false; out.push({ amount: money(m[1]), currency: espectCurrency(m[2]), sublabel }); return true; }; const tail = lines[at].match(ESPEC_LIMIT_LABEL)?.[1]?.trim() ?? ""; if (tail && push(tail, null)) return out; let sublabel: string | null = null; let seenContent = false; for (let i = at + 1; i < lines.length; i++) { const line = lines[i].trim(); // A blank line closes the block — but only once the block has started. // The label and its amount are not always adjacent: the sublimit block on // page 7 renders as "Límite de Responsabilidad:", a blank line, then // "$2,000.00 USD", and breaking on the first blank drops that amount. if (!line) { if (seenContent) break; if (i - at > 3) break; continue; } if (ESPEC_FURNITURE.test(line)) continue; if (push(line, sublabel)) { sublabel = null; seenContent = true; continue; } seenContent = true; // Not an amount: a sub-label if it is short enough to be one. if (line.length <= 40 && !ESPEC_STRUCTURAL.test(line)) sublabel = line.replace(/\s*:$/, ""); } return out; } /** * The deductible printed inside this coverage's own block, i.e. between its * limit and the next one. * * The value follows a bare `Deducible:` on the next line ("Sin deducible"). * The length guard is load-bearing: the document also has a page-level * `DEDUCIBLES:` heading followed by a paragraph explaining how deductibles * interact with the inflation clause, and without the guard that paragraph * becomes the deductible of whichever coverage precedes it. */ function espectDeductibleFor(lines: string[], from: number, to: number): string | null { for (let i = from + 1; i < to && i < from + 30; i++) { const line = lines[i].trim(); // A heading ends this coverage's block. Not every block that follows a // limit carries one of its own — the per-zone `Fenómenos // hidrometeorológicos` deductible table has no limit label to stop at, so // without this the coverage above it silently adopts its "1 POR CIENTO // SOBRE SUMA ASEGURADA". if (isEspecHeading(lines, i)) break; const inline = line.match(/^Deducibles?\s*:\s*(\S.*)$/i); if (inline) return inline[1].trim(); if (!/^Deducibles?\s*:?\s*$/i.test(line)) continue; for (let j = i + 1; j < to; j++) { const value = lines[j].trim(); if (!value) continue; // Too long to be a deductible: this is the page-level `DEDUCIBLES:` // heading and the paragraph explaining the inflation clause, not this // coverage's value. Keep looking rather than concluding "none". if (value.length <= 60) return value; break; } } return espectPercentDeductibleAbove(lines, from); } /** * Some coverages print their deductible and coinsurance as a sentence ABOVE * the limit instead of under a `Deducible:` label below it — "Bienes a la * Intemperie" reads * * 5 POR CIENTO SOBRE SUMA ASEGURADA, 20 PORCIENTO DE PARTICIPACIÓN A CARGO * DEL ASEGURADO DE TODA Y CADA PÉRDIDA. * * wrapped across two lines. Left unread it looks like a coverage with no * deductible at all, which is the one wrong answer worth avoiding here. * * Two bounds keep it from wandering into the body prose, and both are * load-bearing — without them the fire section's "…o hasta el 10% de la suma * asegurada de la sección de Edificio, lo que resulte menor" (a sublimit rule * for the building, several paragraphs up) becomes the deductible of * CONTENIDOS: * * - the walk stops at this coverage's own heading, so it can never read text * belonging to the coverage above it, and * - the line must OPEN with the percentage, which a prose sentence that * merely contains one does not. */ function espectPercentDeductibleAbove(lines: string[], limitAt: number): string | null { for (let i = limitAt - 1, walked = 0; i >= 0 && walked < 6; i--, walked++) { const line = lines[i].trim(); if (!line) continue; // The heading of this coverage: stop, everything above belongs to another. if (line.length <= 60 && !ESPEC_STRUCTURAL.test(line) && i > 0 && !lines[i - 1].trim()) { return null; } if (!/^\d+\s*(POR\s*CIENTO|PORCIENTO|%)/i.test(line)) continue; // Re-join the wrapped continuation lines beneath it. const parts = [line]; for (let j = i + 1; j < limitAt && lines[j].trim(); j++) parts.push(lines[j].trim()); return parts.join(" ").replace(/\s+/g, " "); } return null; } /** * Coverages the especificación states inline inside the fire section instead * of under a limit label: * * "2. Terremoto o erupción volcánica: Sección Edificio EXCLUIDO, Sección Contenidos EXCLUIDO" * "3. Fenómenos hidrometeorológicos: Sección Edificio $200,000.00 USD, Sección Contenidos $20,000.00 USD" * * An exclusion is recorded in the risk label rather than as a zero amount: * a coverage that is excluded and a coverage insured for $0 are the same * number and very different facts, and `ParsedCoverage` has no field for the * distinction. */ function espectInlineSectionRisks( lines: string[], currencyVotes: Record, notes: string[], ): ParsedCoverage[] { const out: ParsedCoverage[] = []; const cat = espectCatastrophicTerms(lines, notes); for (const raw of lines) { const m = raw.trim().match(/^\d+\.\s*([^:]{3,60}?)\s*:\s*(.*Secci[óo]n.+)$/i); if (!m) continue; const risk = m[1].trim(); for (const part of m[2].split(/,\s*(?=Secci[óo]n)/i)) { const section = part.match(/Secci[óo]n\s+(\S+)/i)?.[1] ?? null; const label = section ? `${risk} — Sección ${section}` : risk; // Deductible/coinsurance for these two live in their own block further // down the document, keyed by section rather than printed here. const terms = /hidrometeorol/i.test(risk) && section ? cat[section.toLowerCase()] : undefined; if (/EXCLUIDO|NO\s+CUBIERT/i.test(part)) { out.push({ risk: `${label}: EXCLUIDO`, insuredAmount: null, deductible: null, lossParticipation: null, }); continue; } const amount = part.match(ESPEC_AMOUNT); if (!amount) continue; const currency = espectCurrency(amount[2]); if (currency) currencyVotes[currency] = (currencyVotes[currency] ?? 0) + 1; out.push({ risk: label, insuredAmount: money(amount[1]), deductible: terms?.deductible ?? null, lossParticipation: terms?.coinsurance ?? null, }); } } return out; } /** * The `Fenómenos hidrometeorológicos` block, which states the deductible and * the coinsurance per section: * * Fenómenos hidrometeorológicos * Zona: A2 * Deducible * Edificio: 1 POR CIENTO SOBRE SUMA ASEGURADA * Coaseguro: * Zona 1: (INTERIOR) Participación a cargo del asegurado del 10% … * Zona 2: Participación a cargo del asegurado del 10% … * * The coinsurance is quoted per catastrophe zone, and the policy's own zone * ("A2") is not one of the labels used in that list — so it is only read when * every listed zone quotes the SAME percentage, which makes the mapping moot. * When they differ, the reviewer is told to read the zone table by hand * rather than being handed a percentage picked by guesswork. */ function espectCatastrophicTerms( lines: string[], notes: string[], ): Record { const out: Record = {}; const start = lines.findIndex((l) => /^\s*Fen[óo]menos\s+hidrometeorol[óo]gicos\s*$/i.test(l)); if (start < 0) return out; let end = lines.length; for (let i = start + 1; i < lines.length; i++) { if (/^\s*(I+\.-\s*)?SECCI[ÓO]N\b/i.test(lines[i])) { end = i; break; } } const block = lines.slice(start, end); const zone = firstMatch(block.join("\n"), [/^\s*Zona\s*:\s*(\S+)\s*$/im]); if (zone) notes.push(`zona catastrófica declarada: ${zone}`); const percents = [ ...new Set( allMatches( block.join("\n"), /Participaci[óo]n\s+a\s+cargo\s+del\s+asegurado\s+del\s+(\d+%)/i, ), ), ]; let coinsurance: string | null = null; if (percents.length === 1) { coinsurance = percents[0]; } else if (percents.length > 1) { notes.push( `el coaseguro de fenómenos hidrometeorológicos varía por zona (${percents.join(", ")}); revisarlo a mano`, ); } for (const line of block) { const m = line.trim().match(/^(Edificio|Contenidos)\s*:\s*(\S.+)$/i); if (!m) continue; out[m[1].toLowerCase()] = { deductible: m[2].trim(), coinsurance }; } return out; } /** `USD` / `M.N.` / `DLLS` as printed next to an amount → an ISO code. */ function espectCurrency(raw: string | null | undefined): string | null { if (!raw) return null; const s = raw.replace(/[.\s]/g, "").toUpperCase(); if (s === "USD" || s.startsWith("DL") || s.startsWith("DLL")) return "USD"; if (s === "MN" || s === "MXN") return "MXN"; return null; } // --- A.N.A. Seguros ---------------------------------------------------------- /** * A.N.A. Compañía de Seguros — the tourist auto book the Rosarito office * sells. Unlike GMX these are born-digital portal PDFs, so `pdftotext * -layout` gives exact glyphs and exact columns; the parser leans on column * geometry where the labels alone are ambiguous, which OCR'd scans would not * allow. (The pipeline still runs Tesseract when a PDF has no text layer; * the label-anchored paths below survive that, the column-geometry path on * the driver's policy degrades to "amount read, column unknown".) * * ANA ships two unrelated faces from the same portal: * * 1. **AUTOMOBILE** (`SPECIAL POLICY FOR TOURISTS`) — insures a car. Carries * the `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` table and nine * numbered risk sections in a single `LIMIT OF LIABILITY` column. * 2. **DRIVER´S POLICY FOR AUTOMOBILE** (the office calls it *licencia*) — * insures up to five named drivers whatever they happen to be driving. * NO vehicle table at all, and the risk table has two value columns * (`SUM INSURED` and `PREMIUM`) instead of one. * * The four AUTOMOBILE products the office sells (amplia / responsabilidad * civil, annual / by-the-day) are the SAME layout with different numbers — * "amplia" prints a non-zero vehicle value and COVERED on sections 1-2, * "resp. civil" prints 0.00 and EXCLUDED. That is data, not a layout, so * there is one AUTOMOBILE parser rather than four. */ function parseAna(page: OcrPage): ParsedPolicy { const lines = page.text.split("\n"); return isAnaDriverPolicy(page.text) ? parseAnaDriverPolicy(lines) : parseAnaAutomobile(lines); } /** The driver's policy announces itself in the title band above the No. cell. */ function isAnaDriverPolicy(text: string): boolean { return /DRIVER[´'`’]?S\s+POLICY\s+FOR\s+AUTOMOBILE/i.test(text); } /** * The header band both ANA faces share: policy number, issuing agent, the * three dates, the printed term in days, and the six-cell money row. * * Everything here is read off the FIRST copy in the merged text. Each ANA * PDF renders the same face two or three times — `ORIGINAL`, `AGENT COPY`, * a summary receipt, then three travel ID cards — and the pipeline * concatenates every page into one string before parsing. First-match is * therefore the right rule throughout, and the repeats are used only where * they buy a cross-check (the policy number, below). */ interface AnaHeader { policyNumber: string | null; agentName: string | null; agentCode: string | null; policyDate: Date | null; policyFrom: Date | null; policyTo: Date | null; coveragePeriodDays: number | null; netPremium: number | null; policyFee: number | null; total: number | null; } function parseAnaHeader(lines: string[], notes: string[]): AnaHeader { const text = lines.join("\n"); // ----- policy number ----------------------------------------------------- // Printed in the header cell (`No. 700489651`), again on the summary // receipt (`Policy No:`), and once per travel ID card in both languages. // Reading all of them is a free cross-check; disagreement means one read // is wrong and there is no tie-breaker, so the row goes to a human. // // The two-space floor after `No.` is load-bearing: the agent's own street // address on this form is "BENITO JUAREZ 25 No.50 INT 38", and a looser // pattern reads the house number as the policy number. const readings = [ ...new Set([ ...allMatches(text, /(?:^|\s)No\.\s{2,}(\d{6,12})(?=\s|$)/), ...allMatches(text, /Policy\s+No\s*:\s*(\d{6,12})/i), ...allMatches(text, /Policy\s+Number\s*:\s*(\d{6,12})/i), ...allMatches(text, /No\.\s*de\s*p[óo]liza\s*:\s*(\d{6,12})/i), ]), ]; const policyNumber = readings[0] ?? null; if (!policyNumber) notes.push("no se pudo leer el número de póliza"); if (readings.length > 1) { notes.push(`número de póliza leído de ${readings.length} formas (${readings.join(", ")})`); } // ----- issuing agent ----------------------------------------------------- const agentName = anaAgentName(lines); // The agent's clave sits in the cell under their address: on the same line // as the `TO` date on the automobile face, alone behind a stray "." on the // driver's policy. Both patterns are needed — the by-the-day products // print the agent's phone in that line's left cell ("(661) 612 12 55"), so // the clave is no longer the first thing on the line. // // What neither pattern can reach is the agent's own postal code // ("ROSARITO, BAJA CALIFORNIA 22710"), which is the misread worth // avoiding: it is five digits in the same header band. const agentCode = firstMatch(text, [ /\s{2,}(\d{4,6})\s{2,}TO\s+\d/, /^\s*[.\s]*(\d{4,6})\s*$/m, ]); if (agentCode) notes.push(`clave de agente: ${agentCode}`); // ----- dates ------------------------------------------------------------- // All three sit in DAY / MONTH / YEAR column cells, so they arrive as // three space-separated runs rather than a delimited date. DD MM YYYY is // confirmed by the travel card, which prints the same term twice — // "Desde : 07/08/2026" in Spanish next to "From : 08/07/2026" in English. const issued = text.match(/(? /ISSUED\s+BY\s*:/i.test(l)); if (start < 0) return null; for (let i = start + 1; i < lines.length && i < start + 6; i++) { const candidate = lines[i] .split(/DAY\s+MONTH\s+YEAR/i)[0] .replace(/\bDAYS?\b/gi, "") .replace(/\s+/g, " ") .trim(); // The "DAYS" header line collapses to empty; a date/clave cell to digits. if (!candidate || /^[\d\s.,:-]+$/.test(candidate)) continue; return candidate; } return null; } /** The bare number in the `DAYS` cell, between `ISSUED BY:` and the money row. */ function anaPrintedDays(lines: string[]): number | null { const start = lines.findIndex((l) => /ISSUED\s+BY\s*:/i.test(l)); if (start < 0) return null; for (let i = start + 1; i < lines.length && i < start + 14; i++) { if (/\bDISCOUNT\b/i.test(lines[i])) break; const m = lines[i].match(/^\s*(\d{1,3})\s*$/); if (m) return Number(m[1]); } return null; } /** * The six-cell money row under `DISCOUNT | PREMIUM | POLICY FEE | TAX | * LOCAL TAX | TOTAL`. * * Read positionally off the header, not by label: the cells carry nothing * but numbers, and an unused DISCOUNT prints as a bare "-" rather than 0.00, * so a "find the six amounts" approach would shift every value one column * left on a discounted policy. Requiring exactly six whitespace-separated * cells is the guard — a row that doesn't have them is reported as unread * instead of silently mis-mapped. */ function anaMoneyRow(lines: string[]): { discount: number | null; netPremium: number | null; policyFee: number | null; tax: number | null; localTax: number | null; total: number | null; } | null { const header = lines.findIndex((l) => /^DISCOUNT\s+PREMIUM\s+POLICY\s+FEE\s+TAX\s+LOCAL\s+TAX\s+TOTAL$/i.test( l.trim().replace(/\s+/g, " "), ), ); if (header < 0) return null; for (let i = header + 1; i < lines.length && i < header + 4; i++) { const cells = lines[i].trim().split(/\s+/); if (cells.length !== 6) continue; const cell = (c: string) => (/\d/.test(c) ? money(c) : null); return { discount: cell(cells[0]), netPremium: cell(cells[1]), policyFee: cell(cells[2]), tax: cell(cells[3]), localTax: cell(cells[4]), total: cell(cells[5]), }; } return null; } /** * ANA quotes its tourist book in dollars and prints the currency next to * every figure — `DLLS.` on the automobile face, `usd.` on the driver's * policy. Checked in that order because the MXN branch is deliberately * loose and would otherwise claim a page over an incidental "M.N.". */ function anaCurrency(text: string, notes: string[]): string | null { if (/\bDLLS?\.|\busd\.|\bUSD\b|AMERICAN\s+DOLLARS/i.test(text)) return "USD"; if (/\bMXN\b|\bPESOS\b|\bM\.\s?N\.\b/i.test(text)) return "MXN"; notes.push("no se pudo determinar la moneda"); return null; } /** * A `LABEL value` cell on ANA's insured block. The value ends where the * next cell's label begins — the block is three columns wide and * `pdftotext` renders all three on one line, so an unbounded read of * `ADDRESS` swallows "EMAIL PROLABSALE@AOL.COM". */ function anaLabelValue(lines: string[], label: RegExp): string | null { for (const line of lines) { const m = line.match(label); if (!m?.[1]) continue; const value = m[1] .replace(/\s{2,}(LICENSE|EMAIL|TELEPHONE|PAYMENT\s+DEADLINE)\b.*$/i, "") .replace(/\s+/g, " ") .trim(); if (value) return value; } return null; } // --- ANA / AUTOMOBILE -------------------------------------------------------- /** * ANA's `SPECIAL POLICY FOR TOURISTS` automobile face. * * Layout, top to bottom: the ANA letterhead and claim phone numbers, the * `No.` cell, the issuing-agent / dates / term header band, the six-cell * money row, the insured's contact block, the vehicle table, and the nine * numbered risk sections. */ function parseAnaAutomobile(lines: string[]): ParsedPolicy { const text = lines.join("\n"); const notes: string[] = []; const header = parseAnaHeader(lines, notes); // ----- insured contact block -------------------------------------------- const insuredName = anaLabelValue(lines, /^\s*INSURED\s{2,}(\S.*)$/); const street = anaLabelValue(lines, /^\s*ADDRESS\s{2,}(\S.*)$/); const cityState = anaLabelValue(lines, /^\s*CITY\s*&\s*STATE\s{2,}(\S.*)$/); const legalAddress = [street, cityState].filter(Boolean).join(", ") || null; // The ZIP sits in its own column between the city/state cell and the // TELEPHONE cell, so it comes back glued to the city value after the // whitespace collapse. Take the last five-digit run — a US street number // ("10308 DONNA AVE") is in the ADDRESS cell, not this one, but anchoring // on the first match would still be the fragile choice. const zip = cityState?.match(/(\d{5})(?!.*\d{5})/)?.[1] ?? null; const license = firstMatch(text, [/\bLICENSE\s{2,}([A-Z0-9]{4,20})\b/i]); const email = firstMatch(text, [/\bEMAIL\s{2,}(\S+@\S+)/i]); const phone = firstMatch(text, [/\bTELEPHONE\s+([\d()\s.-]{7,20})/i])?.trim() ?? null; // The insured on this face is also the driver, and their US license number // is the only identifier ANA prints. One InsuredDriver row keeps it out of // free text where nothing can query it. const drivers: ParsedDriver[] = insuredName ? [{ fullName: insuredName, licenseNumber: license, address: legalAddress, phone, email }] : []; if (!insuredName) notes.push("no se pudo leer el nombre del asegurado"); // ----- vehicle table ----------------------------------------------------- const vehicles = parseAnaVehicles(lines, notes); // ----- risk sections ----------------------------------------------------- const region = anaRiskRegion(lines); const coverages = parseAnaAutoCoverages(region, notes); if (vehicles.length > 1) { notes.push( "las sumas VEHICLE/TRAILER/TOWING son los valores declarados de cada " + "unidad, impresos a lo ancho de las secciones 1 y 2", ); } // The `PAYMENT DEADLINE` cell prints its value on the line below the // label, in the same column — "IMMEDIATE" for everything the office sells // through the portal. Mapped onto `premiumPayment`, which is the same // "when is it due" field GMX fills with "CONTADO". const paymentDeadline = /^\s*PAYMENT\s+DEADLINE\s*$/m.test(text) ? firstMatch(text, [/^\s*(IMMEDIATE|INMEDIATO)\s*$/im]) : null; return { ...emptyParsedPolicy("ANA"), // The face that insures a car. policyTypeName: "AUTO", policyNumber: header.policyNumber, insuredName, agentName: header.agentName, legalAddress, zip, policyFrom: header.policyFrom, policyTo: header.policyTo, policyDate: header.policyDate, currency: anaCurrency(text, notes), netPremium: header.netPremium, policyFee: header.policyFee, total: header.total, premiumPayment: paymentDeadline, coverages, coveragePeriodDays: header.coveragePeriodDays, vehicles, drivers, notes, }; } /** * The `ITEM / YEAR / MAKE / BODY / SERIAL No. / PLATES` table — three fixed * slots (VEHICLE, TRAILER, TOWING), of which the tourist policies the office * writes fill only the first. Unused slots print a "." per cell. * * Parsed by token role rather than by column offset, because the BODY cell * is the one that wraps: "PACIFICA" is one token and "GENESIS SEDAN" is two, * so a fixed token count reads the VIN out of the wrong slot on the second. * The VIN is the anchor — 17 characters is a shape nothing else in the row * has — and BODY is whatever sits between the make and it. */ function parseAnaVehicles(lines: string[], notes: string[]): ParsedVehicle[] { const header = lines.findIndex((l) => /^ITEM\s+YEAR\s+MAKE\s+BODY\s+SERIAL\s+No\.\s+PLATES$/i.test(l.trim().replace(/\s+/g, " ")), ); if (header < 0) return []; const out: ParsedVehicle[] = []; for (let i = header + 1; i < lines.length && i < header + 5; i++) { const m = lines[i].trim().match(/^(VEHICLE|TRAILER|TOWING)\b(.*)$/i); if (!m) break; const tokens = m[2].trim().split(/\s+/).filter((t) => t && t !== "."); if (tokens.length === 0) continue; const modelYear = /^\d{4}$/.test(tokens[0]) ? tokens.shift()! : null; const vinAt = tokens.findIndex((t) => /^[A-Z0-9]{17}$/i.test(t)); const make = tokens[0] ?? null; const bodyType = (vinAt > 0 ? tokens.slice(1, vinAt) : tokens.slice(1, -1)).join(" ") || null; const vinNumber = vinAt >= 0 ? tokens[vinAt] : null; // With no VIN to anchor on, the last token is the plate — unless it is // the only token, in which case it is the make and there is no plate. const plate = vinAt >= 0 ? tokens.slice(vinAt + 1).join(" ") : tokens.length > 1 ? tokens[tokens.length - 1] : ""; out.push({ item: m[1].toUpperCase(), modelYear, make, bodyType, vinNumber, licensePlate: plate || null, }); if (!vinNumber) notes.push(`sin número de serie en la unidad ${m[1].toUpperCase()}`); } return out; } /** * The risk table, bounded to the FIRST copy of the face. * * Every ANA PDF prints the whole page twice (ORIGINAL, then AGENT COPY) and * the pipeline hands the parser both concatenated. Without the end bound the * section walk runs straight into the second copy and every coverage is * emitted twice. */ function anaRiskRegion(lines: string[]): string[] { const start = lines.findIndex((l) => /SPECIFICATION\s+OF\s+RISKS/i.test(l)); if (start < 0) return []; for (let i = start + 1; i < lines.length; i++) { if ( /^\s*ISSUED\s+ONLINE\s*$/i.test(lines[i]) || /The\s+following\s+risks\s+are\s+excluded/i.test(lines[i]) || /HEREINAFTER\s+CALLED/i.test(lines[i]) ) { return lines.slice(start, i); } } return lines.slice(start); } type AnaSectionKind = "vehicleValue" | "limit" | "perPersonAccident" | "addon" | "elite"; /** * The printed risk sections, as a fixed table rather than a generic row * regex: this is a pre-printed insurer form whose nine sections never change * name, and each one puts its numbers in a different place. Section 3 has a * single limit; 4 and 5 split per-person / per-accident; 6, 7 and 8 print a * PREMIUM (what it cost) where the others print a limit (what it pays); 1 * and 2 carry the declared VEHICLE / TRAILER / TOWING values in a column * shared across both. * * The driver's policy reuses the same table minus the vehicle sections and * renames two — `LEGAL AID` without the `A.N.A.'s` prefix, and `AUTOMOBILE * ASSISTANCE` for what the automobile face calls `ROADSIDE ASSISTANCE`. * Same coverage, so it gets the same canonical name. */ const ANA_SECTIONS: { key: RegExp; name: string; kind: AnaSectionKind }[] = [ { key: /^MATERIAL\s+DAMAGE\b/i, name: "MATERIAL DAMAGE", kind: "vehicleValue" }, { key: /^TOTAL\s+THEFT\b/i, name: "TOTAL THEFT", kind: "vehicleValue" }, { key: /^LIABILITY\s+FOR\s+PROPERTY\s+DAMAGE\s+TO\s+THIRD\s+PARTIES\b/i, name: "LIABILITY FOR PROPERTY DAMAGE TO THIRD PARTIES", kind: "limit", }, { key: /^BODILY\s+INJURY\s+LIABILITY\b/i, name: "BODILY INJURY LIABILITY", kind: "perPersonAccident", }, { key: /^MEDICAL\s+EXPENSES\b/i, name: "MEDICAL EXPENSES", kind: "perPersonAccident" }, { key: /^CATASTROPHIC\s+LIABILITY\s+FOR\s+DEATH\s+OF\s+THIRD\b/i, name: "CATASTROPHIC LIABILITY FOR DEATH OF THIRD PARTIES", kind: "addon", }, { key: /^(?:A\.N\.A\.[´'`’]?s\s+)?LEGAL\s+AID\b/i, name: "LEGAL AID", kind: "addon" }, { key: /^(?:A\.N\.A\.[´'`’]?s\s+)?(?:ROADSIDE|AUTOMOBILE)\s+ASSISTANCE\b/i, name: "ROADSIDE ASSISTANCE", kind: "addon", }, { key: /^ELITE\s+OR\s+ELITE\s+PLUS\b/i, name: "ELITE / ELITE PLUS", kind: "elite" }, ]; interface AnaBlock { name: string; kind: AnaSectionKind; lines: string[]; } /** * Split the risk region into one block per section: from a section's label * line to the next section's. * * Blocks are keyed by where the labels actually appear, not by the order of * `ANA_SECTIONS` — the driver's policy prints CATASTROPHIC LIABILITY *above* * MEDICAL EXPENSES while the automobile face prints it below, and a walk * that assumed the declared order would hand one section the other's * numbers. * * The leading `\d+` strip is the section number, which prints in its own * narrow column and lands at the head of the line for the sections whose * label wraps (`9 PARTIAL THEFT (LIMIT …`). */ function anaSectionBlocks(region: string[]): AnaBlock[] { const hits: { at: number; name: string; kind: AnaSectionKind }[] = []; region.forEach((raw, i) => { const line = raw.trim().replace(/^\d{1,2}\s+/, ""); const hit = ANA_SECTIONS.find((s) => s.key.test(line)); if (hit) hits.push({ at: i, name: hit.name, kind: hit.kind }); }); return hits.map((h, n) => ({ name: h.name, kind: h.kind, lines: region.slice(h.at, hits[n + 1]?.at ?? region.length), })); } /** * Is the section covered or excluded? * * `COVERED/EXCLUDED` is the column *header* and prints on every section * regardless — it has to be removed before the check, or every section reads * as EXCLUDED. What remains is the single word printed in that column. */ function anaStatus(block: string[]): "COVERED" | "EXCLUDED" | null { const t = block.join("\n").replace(/COVERED\s*\/\s*EXCLUDED/gi, " "); if (/\bEXCLUDED\b/i.test(t)) return "EXCLUDED"; if (/\bCOVERED\b/i.test(t)) return "COVERED"; return null; } /** * The `DEDUCTIBLE:` sentence, which wraps across the rest of the section's * block with the right-hand value columns interleaved on the same lines. * Those columns are stripped per line before the join, or the deductible * comes back as "…ON AUTOS COVERED (SEDANS…) 0.00 DLLS.". */ function anaDeductibleText(block: string[]): string | null { const start = block.findIndex((l) => /DEDUCTIBLE\s*:/i.test(l)); if (start < 0) return null; const parts: string[] = []; for (let i = start; i < block.length; i++) { const stripped = block[i] .replace(/\s{2,}[\d,]+\.\d{2}\s*(?:DLLS?\.|usd\.)?\s*$/i, "") .replace(/\s{2,}(?:COVERED|EXCLUDED|VEHICLE|TRAILER|TOWING)\s*$/i, "") .replace(/^\s*\d{1,2}\s+/, "") .trim(); if (!stripped || /^(COVERED|EXCLUDED|VEHICLE|TRAILER|TOWING)$/i.test(stripped)) continue; parts.push(stripped); } const joined = parts.join(" ").replace(/^DEDUCTIBLE\s*:\s*/i, "").replace(/\s+/g, " ").trim(); return joined || null; } /** * Coverages off the automobile face's single `LIMIT OF LIABILITY` column. * * An excluded section is recorded in the risk label rather than as a zero * amount — same rule as the GMX especificación parser. "Excluded" and * "insured for $0.00" print the same number here (a responsabilidad-civil * policy shows 0.00 for material damage) and mean very different things, and * `ParsedCoverage` has no field for the distinction. */ function parseAnaAutoCoverages(region: string[], notes: string[]): ParsedCoverage[] { const out: ParsedCoverage[] = []; for (const block of anaSectionBlocks(region)) { const text = block.lines.join("\n"); const status = anaStatus(block.lines); const label = (sub?: string | null) => `${block.name}${sub ? ` — ${sub}` : ""}${status === "EXCLUDED" ? ": EXCLUDED" : ""}`; switch (block.kind) { case "vehicleValue": { const deductible = anaDeductibleText(block.lines); // The item keyword and its value are on different lines (the // keyword heads the column, the amount sits a line or two below), // so one pass over the whole block tracks whichever keyword was // seen most recently. Only amounts suffixed `DLLS.` count as // values — the "$500.00 ON AUTOS" inside the deductible sentence // is not one. const re = /\b(VEHICLE|TRAILER|TOWING)\b|([\d,]+\.\d{2})\s*DLLS?\./gi; let item: string | null = null; let found = false; let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { if (m[1]) { item = m[1].toUpperCase(); continue; } found = true; out.push({ risk: label(item), insuredAmount: money(m[2]), deductible, lossParticipation: null, premium: null, }); } if (!found) { out.push({ risk: label(), insuredAmount: null, deductible, lossParticipation: null, premium: null, }); notes.push(`sin suma asegurada en "${block.name}"`); } break; } case "limit": { const m = text.match(/([\d,]+\.\d{2})\s*DLLS?\./i); if (!m) notes.push(`sin límite en "${block.name}"`); out.push({ risk: label(), insuredAmount: m ? money(m[1]) : null, deductible: null, lossParticipation: null, premium: null, }); break; } case "perPersonAccident": { // The two limits are labelled by the tail of the wrapped column // header ("PER" on the label line, "PERSON"/"ACCIDENT" on the // value line), so the value-line keyword is the reliable anchor. const person = text.match(/\bPERSON\s+([\d,]+\.\d{2})/i); const accident = text.match(/\bACCIDENT\s+([\d,]+\.\d{2})/i); if (!person && !accident) notes.push(`sin límites en "${block.name}"`); if (person) { out.push({ risk: label("POR PERSONA"), insuredAmount: money(person[1]), deductible: null, lossParticipation: null, premium: null, }); } if (accident) { out.push({ risk: label("POR EVENTO"), insuredAmount: money(accident[1]), deductible: null, lossParticipation: null, premium: null, }); } break; } case "addon": { // These three print a PREMIUM, not a limit: the last amount in the // block is what the coverage cost. Recorded as `premium` so the // review screen never shows $40 as a sum insured. const amounts = [...text.matchAll(/([\d,]+\.\d{2})/g)]; out.push({ risk: label(), insuredAmount: null, deductible: null, lossParticipation: null, premium: amounts.length ? money(amounts[amounts.length - 1][1]) : null, }); break; } case "elite": { // Section 9 packs both its coverages into parenthesised prose: // "PARTIAL THEFT (LIMIT 0.00 DLLS.WITH DEDUCTIBLE: 0.00 DLLS. PER // EVENT)" — no space after "DLLS." in the source. One COVERED / // EXCLUDED applies to the whole section. let matched = false; for (const line of block.lines) { const m = line.match( /\b(PARTIAL\s+THEFT|VANDALISM)\s*\(\s*LIMIT\s*([\d,]+\.\d{2})\s*DLLS?\.\s*WITH\s+DEDUCTIBLE\s*:\s*([\d,]+\.\d{2})\s*DLLS?\.[^)]*\)(.*)$/i, ); if (!m) continue; matched = true; const trailing = m[4].match(/([\d,]+\.\d{2})/); out.push({ risk: label(m[1].toUpperCase().replace(/\s+/g, " ")), insuredAmount: money(m[2]), deductible: `${m[3]} DLLS. POR EVENTO`, lossParticipation: null, premium: trailing ? money(trailing[1]) : null, }); } if (!matched) notes.push(`no se pudo desglosar "${block.name}"`); break; } } } if (out.length === 0) notes.push("no se encontraron coberturas"); return out; } // --- ANA / DRIVER´S POLICY (licencia) ---------------------------------------- /** * ANA's `DRIVER´S POLICY FOR AUTOMOBILE` — the office's *licencia* product. * It insures up to five named drivers in whatever car they are driving, so * there is no vehicle table and the liability limits are the whole contract. * * The header band is the shared one (same `No.`, `ISSUED BY:` and money-row * cells as the automobile face, a line or two out of alignment); the insured * arrives as a numbered `POLICY HOLDER` list instead of a single INSURED * cell; and the risk table gains a second value column. */ function parseAnaDriverPolicy(lines: string[]): ParsedPolicy { const text = lines.join("\n"); const notes: string[] = []; const header = parseAnaHeader(lines, notes); const drivers = parseAnaDrivers(lines); if (drivers.length === 0) notes.push("no se pudo leer ningún conductor"); if (drivers.length > 1) notes.push(`${drivers.length} conductores nombrados`); // Driver 1 is the policy holder; the platform's single insured-name field // takes them and the rest ride on the InsuredDriver rows. const holder = drivers[0] ?? null; const legalAddress = holder?.address ?? null; const zip = legalAddress?.match(/(\d{5})(?!.*\d{5})/)?.[1] ?? null; const region = anaRiskRegion(lines); const coverages = parseAnaDriverCoverages(region, notes); // The excluded-risk sentence is the difference between this product and a // full automobile policy, so it is surfaced rather than dropped. const excluded = firstMatch(text, [ /The\s+following\s+risks\s+are\s+excluded\s+(.+?)\s*$/im, ]); if (excluded) notes.push(`riesgos excluidos: ${excluded.replace(/\s+/g, " ")}`); notes.push("póliza de conductor: no ampara un vehículo determinado"); return { ...emptyParsedPolicy("ANA"), // The office's own name for this product is *licencia*, and LICENCIAS is // the legacy Access table it was migrated from -- 306 policies. policyTypeName: "LICENCIAS", policyNumber: header.policyNumber, insuredName: holder?.fullName ?? null, agentName: header.agentName, legalAddress, zip, policyFrom: header.policyFrom, policyTo: header.policyTo, policyDate: header.policyDate, currency: anaCurrency(text, notes), netPremium: header.netPremium, policyFee: header.policyFee, total: header.total, coverages, coveragePeriodDays: header.coveragePeriodDays, drivers, notes, }; } /** * The `POLICY HOLDER` list: five numbered slots of NAME / ADDRESS / DRIVER * LICENSE, of which the unused ones print an empty value and a bare "NONE" * under the licence label. Slots without a name are dropped — a driver with * no name is not a driver. * * The phone rides at the tail of the name cell ("PAMELA DENISE WAGONER * Ph.3102001538") rather than in a column of its own. * * Bounded to the FIRST `POLICY HOLDER` list in the merged text. The driver's * policy renders its face three times (ORIGINAL, AGENT COPY, INSURED COPY) * and an unbounded walk returns the same person once per copy — which reads * as a three-driver policy, not as a parse bug, so nothing downstream would * have caught it. */ function parseAnaDrivers(lines: string[]): ParsedDriver[] { const listAt = lines.findIndex((l) => /^\s*POLICY\s+HOLDER\s*$/i.test(l)); if (listAt < 0) return []; const nextList = lines.findIndex( (l, i) => i > listAt && /^\s*POLICY\s+HOLDER\s*$/i.test(l), ); const scope = lines.slice(listAt, nextList < 0 ? lines.length : nextList); const email = firstMatch(lines.join("\n"), [/\bEMAIL\s{2,}(\S+@\S+)/i]); const out: ParsedDriver[] = []; const starts: number[] = []; scope.forEach((l, i) => { if (/^\s*\d\.\s*NAME\s*:/i.test(l)) starts.push(i); }); starts.forEach((at, n) => { const end = starts[n + 1] ?? Math.min(at + 6, scope.length); const raw = scope[at].match(/^\s*\d\.\s*NAME\s*:\s*(.*)$/i)?.[1]?.trim() ?? ""; if (!raw) return; const phoneAt = raw.match(/\s{2,}Ph\.?\s*([\d()\s.-]{7,})\s*$/i); const fullName = (phoneAt ? raw.slice(0, phoneAt.index) : raw).replace(/\s+/g, " ").trim(); if (!fullName) return; const block = scope.slice(at, end); const address = anaDriverField(block, /^\s*ADDRESS\s*:\s*(\S.*)$/i); const licenseNumber = anaDriverField(block, /^\s*DRIVER\s+LICENSE\s*:\s*(\S.*)$/i); out.push({ fullName, licenseNumber, address, phone: phoneAt?.[1]?.trim() ?? null, // ANA prints one contact email per policy, in the header cell above // the list — not per driver. Attaching it to every driver would // invent a fact, so only the holder gets it. email: n === 0 ? email : null, }); }); return out; } /** A `LABEL : value` cell inside one driver slot; "NONE" means empty. */ function anaDriverField(block: string[], label: RegExp): string | null { for (const line of block) { const m = line.match(label); if (!m?.[1]) continue; const value = m[1].replace(/\s+/g, " ").replace(/,\s*$/, "").trim(); if (!value || /^NONE$/i.test(value)) return null; return value; } return null; } /** * Coverages off the driver's policy, which prints `SUM INSURED` and * `PREMIUM` as two separate value columns. * * Both columns hold the same shape (`100,000.00 usd.` / `18.70 usd.`) and * neither carries a label on its own line, so the only thing that * distinguishes them is horizontal position. The split is taken from the * header's own column offsets rather than a hardcoded number, because the * offsets shift between ANA's products. * * When the header offsets can't be read — a scan OCR'd without column * fidelity — every amount is reported as a sum insured and the reviewer is * told the split failed, rather than half the premiums being silently filed * as coverage limits. */ function parseAnaDriverCoverages(region: string[], notes: string[]): ParsedCoverage[] { const head = region[0] ?? ""; const sumCol = head.search(/SUM\s+INSURED/i); const premiumCol = head.search(/PREMIUM/i); const split = sumCol >= 0 && premiumCol > sumCol ? Math.round((sumCol + premiumCol) / 2) : null; if (split == null) { notes.push("no se pudieron separar las columnas SUMA ASEGURADA / PRIMA"); } const out: ParsedCoverage[] = []; for (const block of anaSectionBlocks(region)) { const status = anaStatus(block.lines); const label = (sub?: string | null) => `${block.name}${sub ? ` — ${sub}` : ""}${status === "EXCLUDED" ? ": EXCLUDED" : ""}`; const sums: { amount: number | null; sub: string | null }[] = []; let premium: number | null = null; for (const line of block.lines) { for (const m of line.matchAll(/([\d,]+\.\d{2})/g)) { const at = m.index ?? 0; // "Per Person" / "Per Accident" trail their amount on this layout // (they precede it on the automobile face). const after = line.slice(at + m[0].length, at + m[0].length + 24); const sub = /Per\s+Person/i.test(after) ? "POR PERSONA" : /Per\s+Accident/i.test(after) ? "POR EVENTO" : null; if (split != null && at >= split) premium = money(m[1]); else sums.push({ amount: money(m[1]), sub }); } } if (sums.length === 0) { out.push({ risk: label(), insuredAmount: null, deductible: null, lossParticipation: null, premium, }); continue; } sums.forEach((s, i) => { out.push({ risk: label(s.sub), insuredAmount: s.amount, deductible: null, lossParticipation: null, // The premium covers the section, not each of its limits — putting // it on every row would double it in any total the UI computes. premium: i === 0 ? premium : null, }); }); } if (out.length === 0) notes.push("no se encontraron coberturas"); return out; }