# Insurance Policy OCR Capture

Reads an insurance policy PDF the office downloads from a carrier portal,
proposes the `Policy` row it should become, and lets staff confirm. Built
2026-08-01 (`5e9cb12`), live under `/polizas/captura`.

## Why this exists — it was not planned

This feature is **not in any spec**. It came out of building the utility
statement OCR intake in [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md)
§2: once there was a working render → OCR → parse → match → review pipeline
for CFE/CESPT/Telnor receipts, it was obvious the same shape applies to the
*other* stack of paper this office keys in by hand every week — the carrier
policy PDFs behind every `Policy` row.

The two are the same job with a different document on the scanner. Keeping
that recognition cheap is the whole point of how it was built: the pipeline
was **reused, not copied**.

- `OcrModule` (`apps/api/src/ocr/ocr.module.ts`) was extracted out of
  `StatementsModule` in this same commit, purely so `PolicyOcrModule` can
  inject `OCR_PROVIDER` without dragging in the statement pipeline.
  `StatementsModule` now imports it and binds nothing itself. That extraction
  was **blocking**: without it the policy module could not resolve the
  provider at all.
- The engine stays Tesseract behind the same swappable seam, so a managed
  extraction API remains a one-line change in one file for both features.
- The intake screen is a *mode of* the existing policy-creation screen, the
  same way OCR receipt capture is a mode of Captura — not a new menu entry.

## What ships

| Piece | Path |
|---|---|
| API module | `apps/api/src/policy-ocr/` (service, controller, DTOs, matcher, parser) |
| Shared OCR seam | `apps/api/src/ocr/ocr.module.ts` |
| Tables | `policy_ocr_batches`, `policy_ocr_documents` (`20260801000000_policy_ocr_intake`) |
| Web | `components/PolicyCaptura.tsx` (tab shell), `PolicyOcrIntake.tsx` (upload), `PolicyOcrReview.tsx` (review queue) |
| Abilities | `policy:ingest`, `policy:ocr-review` — both **STAFF** |

Abilities are STAFF for the same reason statement OCR is: nothing reaches the
books unconfirmed, and the review step is what makes machine capture safe at
that tier.

## The screen

`PolicyCaptura` is one screen with two ways in, mirroring `Captura.tsx`:

- `/polizas/nuevo` → **manual** tab (`PolicyForm`, every field by hand)
- `/polizas/captura` → **automática** tab (`PolicyOcrIntake`, drop a PDF)
- `/polizas/captura/[id]` → the batch review queue

Both modes end at the same place — a `Policy` row on a customer's file — so
they are modes of one screen rather than two menu entries. Either URL renders
the same component, so the tab toggle works from either entry point and old
bookmarks land on the right tab.

## Pipeline

```
upload PDF → store source → render pages → text layer? → parse → match → review → confirm
```

1. **Store the source.** `policy-ocr/{batchId}/source-N.pdf`, before anything
   else touches it.
2. **Render + read.** Every page is rendered to
   `policy-ocr/{batchId}/page-M.png`. Text-layer wins when the PDF has one
   (cheap, exact); the rendered image is OCR'd only when it does not — the
   same precedence rule as the statement pipeline. Carrier-portal PDFs are
   usually born-digital, so most of the time no OCR runs at all.
3. **Parse.** Provider detected by brand signal first
   (`GMX`, `Grupo Mexicano de Seguros`, `gmx.com.mx`,
   `JUNTOS EL RIESGO ES MENOR`), layout patterns only as fallback — the same
   ordering rule the statement parser needed.
4. **Match.** Against `Policy.policyNumber`.
5. **Review + confirm.** Nothing is written to `Policy` until a human
   confirms.

### One PDF = one policy

This is the sharpest difference from statement OCR, and it inverts that
feature's core assumption.

Utility statements arrive **bundled, one customer per page** — so there, one
page is one document and the parser runs per page. A policy PDF is the
opposite: the GMX certificate is a 2-page document where page 1 carries the
contract header and page 2 carries the per-coverage table, and **both pages
describe the same policy**. So the pipeline concatenates every page's text
(`\n\n` between pages, which also keeps `ocrRawText` readable for debugging)
and runs the parser and the matcher exactly **once per file**.

Consequences worth knowing before touching this code:

- `PolicyOcrDocument.pageNumber` is repurposed as the **file ordinal within
  the batch** (1, 2, 3…), not a page index. The
  `(batchId, pageNumber)` unique constraint still holds, and one batch still
  carries many policies — one per uploaded file.
- Parser regexes are anchored across the whole concatenated text (`^From$`,
  `^Currency\s+…`), which is why the page-boundary blank line matters.
- `ocrConfidence` on the row is the **mean** across the file's pages.
- A file that fails to parse produces exactly one `OCR_FAILED` row — the right
  granularity, and the page PNGs stay on disk for a re-run after a parser fix.

### `storageKey` is the source PDF, not a page image

`PolicyOcrDocument.storageKey` points at `source-N.pdf`. The review screen
embeds that file directly, so the reviewer looks at the **exact artifact the
office received** and gets the browser's native PDF scrolling, zoom and text
selection for free. Rendered PNGs are still written for future re-OCR or an
image-based audit, but nothing points at them as the document's identity.

(The statement side does the opposite — there `storageKey` is the page image,
because a page *is* the document.)

## Matching: policy number only, never the insured name

`PolicyMatcherService` matches on `Policy.policyNumber` and nothing else.

The certificate's "Insured" line is the account's registrant, which drifts
from the customer the office actually holds the file under — the same finding
the statement matcher is built around (a CESPT receipt reading
`ARNAIZ ROSAS ELSA AURORA` for a customer this office holds as `CATT, RANDY`).
Names are shown to the reviewer as a sanity check and never feed matching.

| Rows on `policyNumber` | Result |
|---|---|
| exactly 1 | `MATCHED`, confident — the only unambiguous hit |
| 0 | new policy: review offers a customer picker, confirm **creates** the row |
| >1 | surfaced as candidates, human picks |

More than one hit is never auto-resolved. Duplicate policy numbers across
customers do occur (one group policy bound by two related parties), and
picking arbitrarily would silently book the wrong coverage against the wrong
person.

## What the parser reads, and the field it cannot

`ParsedPolicy` fields are all nullable on purpose: each carrier prints a
different subset, and the matcher and review queue both work better with
"field was read" vs "field was not" than with a guess.

Read from the GMX certificate: policy number, insured name, additional
insured, broker (→ `Policy.agentName`), legal address, ZIP, `policyFrom` /
`policyTo` / `policyDate`, currency, premium-payment cadence, and the full
per-coverage table (risk, insured amount, deductible, loss participation)
preserved verbatim.

> **The GMX certificate carries no premium.** Not "sometimes missing" — the
> document does not have the figure. It lives on GMX's **separate `recibo`
> PDF**. The parser leaves `netPremium` / `policyFee` / `brokerFee` / `total`
> null and pushes a note onto the row —
> *"esta página no trae prima; revisar el recibo de GMX por separado"* — so
> the reviewer sees why the field is empty rather than assuming a read
> failure.

This is also why confirm never overwrites an existing `Policy.netPremium`
with null: the certificate not carrying a premium is not evidence that the
premium is gone.

Deductible and loss participation are stored as **strings** (`"5%"`, `"20%"`,
`"USD 1,000"`) — they are printed as a mix of percentages, currency amounts
and free text, and normalising them would lose the distinction.

## Confirm: what actually gets written

Per confirmed document, in order:

1. **The `Policy` row** — updated if a policy was matched, created under the
   picked customer if not. Only non-null `extracted*` fields are written; null
   never overwrites existing data.
2. **A `PolicyDocument`** — the source PDF is streamed into the policy's
   storage namespace and attached, so the paperwork stays with the policy.
3. **Optionally a `Transaction`** — `INSURANCE` domain, negative amount
   (a charge), `captureSource: "OCR"`, `captureRef` = the document id.

The ledger write is **opt-in twice over**: staff must tick `postPremium`
*and* a premium must have parsed to a positive number. Without that gate the
premium-less certificate above would silently book a $0 charge on every
confirm.

`createdPolicyId` and `postedTransactionId` are unique columns on the
document row, so a double-confirm cannot re-apply — and a `POSTED` document
is refused outright.

Discarding a batch is refused once any page is `POSTED`: a partly-applied
batch has already written `Policy` (and possibly `Transaction`) rows, and
hiding the paperwork behind a "discarded" label would leave those rows
unexplained. Reject the remaining pages individually instead.

## API surface

| Method | Route | Ability |
|---|---|---|
| `GET` | `/policy-ocr/status` (is OCR + storage available) | authenticated |
| `GET` | `/policy-ocr/batches`, `/batches/:id`, `/batches/:id/documents` | authenticated |
| `GET` | `/policy-ocr/documents/:id/page` (streams the source PDF) | authenticated |
| `POST` | `/policy-ocr/batches` (upload) | `policy:ingest` |
| `PATCH` | `/policy-ocr/documents/:id` (edit the extracted fields) | `policy:ocr-review` |
| `POST` | `/policy-ocr/documents/:id/reject` | `policy:ocr-review` |
| `POST` | `/policy-ocr/batches/:id/discard` | `policy:ocr-review` |
| `POST` | `/policy-ocr/batches/:id/confirm` | `policy:ocr-review` |

## Requirements

Same as statement OCR: object storage (`S3_ENDPOINT` + credentials) for the
source PDFs and page images, and `tesseract-ocr` / `tesseract-ocr-data-spa` /
`poppler-utils` in the API image. `GET /policy-ocr/status` reports both; if
either is missing the feature reports itself unavailable and only this
feature is disabled.

## Tests

`apps/api/src/policy-ocr/parsers/policy-parser.spec.ts` — 8 cases, all
against verbatim text extracted from one real document,
`HC_Folio_000767_Traduccion.pdf`: provider detection from the wordmark and
from the footer URL, the header fields, every coverage row off the second
page, the deductible/loss-participation strings, the missing-premium note,
the broker line with the agent-number parens absent, and a page with no GMX
signal at all (which must yield no provider rather than a bad guess).

## Not built

- **Only GMX.** The dispatcher (`detectPolicyProvider`) is a table of
  `[provider, pattern]` pairs plus a `parsers` map, so adding ANA or Qualitas
  is a parser function and two entries — but no other carrier's layout has
  been seen yet, and guessing at one produces a parser nobody can verify.
- **The `recibo` PDF.** Reading the premium off GMX's separate receipt
  document, and pairing it to the certificate it belongs to, is the obvious
  next piece. It is what would let `postPremium` stop being a manual tick.
- **Renewals from OCR.** A re-issued policy arrives as a new certificate with
  the same number; confirm updates the existing row rather than versioning
  it. Nothing tracks "this is the 2027 issue of that policy".

## Related

- [`STATEMENT_OCR.md`](STATEMENT_OCR.md) — the utility statement pipeline this
  was lifted from, as built. [`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md)
  §2 is its design and the measured evidence behind it. Between them they are
  the origin of three rules the policy parser applies: detect the provider by brand before layout, only
  ever apply the Tesseract digit-confusion map (`O→0`, `S→5`, `B→8`, …) to
  fields known to be digits, and parse amounts by separator *position* rather
  than assuming `,` is thousands.

  Those last two are **duplicated on purpose**, not imported: the module is
  kept self-contained, since sharing a helper would couple two unrelated
  domains through it. If you fix a bug in one, check the other.
- [`INSURANCE_FEATURES_SPEC.md`](INSURANCE_FEATURES_SPEC.md) — the four
  insurance features that *were* planned. This is not one of them.
