Files
jorgecuadros-platform/apps/api/src/policy-ocr/parsers/policy-parser.ts
T
rmancinasandClaude Opus 5 022d1935ad
Build and Push Images / Build jorgecuadros-web (push) Successful in 2m0s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m14s
feat(policy-ocr): set policyTypeId and insuranceProviderId on confirm
The BACKLOG claimed this was blocked on incomplete `policy_types` rows.
Querying the dev database says otherwise: AUTO (1316 policies) and LICENCIAS
(306) are both live and healthy, so ANA's two faces were never blocked at
all. Three separate things had been conflated.

What the parser now emits is a NAME, not an id -- it is a pure function over
text and must not reach for the database:

  ANA AUTOMOBILE        -> AUTO
  ANA DRIVER'S POLICY   -> LICENCIAS
  GMX (both documents)  -> MULT

`resolveLookups()` turns that into a foreign key at confirm, and does the
same for the carrier off the parser's provider code. It resolves, never
creates: a missing `policy_types` row means a human deleted it, and silently
recreating it would undo that with no record. An explicit `policyTypeId` /
`insuranceProviderId` on the confirm payload always wins.

GMX is MULT rather than INCENDIO because the caratula's own header reads
"Multiple Policy / Home" and the especificación is "PVL Hogar" -- one product,
two artifacts. MULT is the live row carrying 769 of them; INCENDIO is fire-only
and no policy in the book has ever used it.

The parser's provider code is not the carrier's row name, so PROVIDER_ROW_NAME
maps ANA onto "ANA SEGUROS", which is where the office's 738 ANA policies
already are.

--- the actual defect underneath -----------------------------------------

`policies.policyTypeId`, `policies.insuranceProviderId` and
`claims.adjusterId` are all ON DELETE SET NULL, and the lookups screen deleted
unconditionally. So deleting a lookup row returned 200 and silently blanked
the field on every row referencing it -- no error, nothing in the UI. That is
how M_EMPR disappeared and left 5 policies with no ramo, found months later
only by querying.

All three deletes now refuse while the row is in use, naming it and the count
("El tipo de póliza «M_EMPR» está en uso por 5 póliza(s)"). The schema-level
`onDelete: Restrict` the spec once recommended is deliberately not used: a raw
FK error is not something the operator can act on.

`20260815160000_policy_type_repair` cleans up what already happened:

  - restores M_EMPR and re-points its 5 policies, scoped to
    `policyTypeId IS NULL AND legacySourceTable = 'm_empr'` so it can never
    claim a policy blanked for some other reason
  - merges the duplicate "ANA" carrier (1 policy) into "ANA SEGUROS" (738).
    OCR is about to start assigning the carrier automatically and two rows
    would keep splitting the book. Written as joins, not subqueries, so both
    statements are no-ops when either row is absent -- a subquery form would
    resolve to NULL and blank the carrier off every ANA policy.
  - does NOT restore INCENDIO. It is the other row the migration would have
    produced, but the legacy INCENDIO table has 1 row that never loaded, so
    the type has zero policies and restoring it would only put a dead option
    in the type picker.

Verified by running the repair against the real broken dev data inside a
transaction and rolling back: 5 orphans -> 0, ANA/ANA SEGUROS -> one row with
739, and a second run in the same transaction changes nothing. The DDL half
matches `prisma migrate diff` exactly.

186 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:28:26 -07:00

2002 lines
78 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, string> = {
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<string, number> = {
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<string, number> = {
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<string, (page: OcrPage) => 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<string, number> = {};
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<string, number>,
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<string, { deductible: string | null; coinsurance: string | null }> {
const out: Record<string, { deductible: string | null; coinsurance: string | null }> = {};
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(/(?<!\d)(\d{1,2})\s+(\d{1,2})\s+(\d{4})\s+FROM\b/i);
const from = text.match(/\bFROM\s+(\d{1,2})\s+(\d{1,2})\s+(\d{4})\b/i);
const to = text.match(/\bTO\s+(\d{1,2})\s+(\d{1,2})\s+(\d{4})\b/i);
const policyDate = issued ? utc(+issued[3], +issued[2] - 1, +issued[1]) : null;
const policyFrom = from ? utc(+from[3], +from[2] - 1, +from[1]) : null;
const policyTo = to ? utc(+to[3], +to[2] - 1, +to[1]) : null;
if (!policyFrom || !policyTo) notes.push("no se pudo leer la vigencia");
// ----- term in days ------------------------------------------------------
// ANA sells 3- and 4-day tourist policies alongside annual ones and prints
// the count in its own `DAYS` cell. Prefer the derived figure (two dates
// beat one number) but say so when the printed cell disagrees — that is
// either a misread date or a policy issued with a term the dates don't
// support, and both need a human.
const printedDays = anaPrintedDays(lines);
const derivedDays = daysBetween(policyFrom, policyTo);
const coveragePeriodDays = derivedDays ?? printedDays;
if (printedDays != null && derivedDays != null && printedDays !== derivedDays) {
notes.push(`vigencia impresa ${printedDays} días, calculada ${derivedDays}`);
}
// ----- money row ---------------------------------------------------------
const row = anaMoneyRow(lines);
if (!row) notes.push("no se pudo leer el renglón de primas");
if (row?.tax) notes.push(`impuesto: ${row.tax.toFixed(2)}`);
if (row?.discount) notes.push(`descuento: ${row.discount.toFixed(2)}`);
return {
policyNumber,
agentName,
agentCode,
policyDate,
policyFrom,
policyTo,
coveragePeriodDays,
netPremium: row?.netPremium ?? null,
policyFee: row?.policyFee ?? null,
total: row?.total ?? null,
};
}
/**
* The name under `ISSUED BY:`. On the automobile face it shares its line
* with the `DAY MONTH YEAR` column headers of the adjacent date cell; on the
* driver's policy it sits alone and the column headers are a line lower. So
* the walk strips the headers rather than assuming a fixed offset.
*/
function anaAgentName(lines: string[]): string | 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 + 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;
}