Gives receipt capture the same treatment policy OCR just got: a doc that records what is in the code, separate from the spec that records what was designed. RECEIPT_CAPTURE_SPEC.md §2 had accumulated three BUILT notes totalling ~120 lines of findings, which is the right place for the evidence but the wrong place to look up how the matcher picks a column. docs/STATEMENT_OCR.md covers the pipeline, the OCR seam and its text-layer-first rule, all eight parsers and the ordering constraints between them, the matcher's two governing rules and the scopedRefField table, confirm-through-BillingService, the learning write-back, and the API surface. Weight goes to the things that are load-bearing and invisible from the code shape: brand detection must run to completion before layout because Tijuana bills predial and zona federal off the same treasury header; scopedRefField is exported because three call sites must agree or a reference gets learned into a column nothing searches; FEDERAL_ZONE's accountNumber holds a peso amount, so it fails the null-guards as well as the lookup; a misread `$` is the dangerous failure, not a missing one. Also records that CFE/CESPT/Telnor have no unit suite — they predate the gas/predial extension and were only verified end to end. Cross-linked from the spec, POLICY_OCR.md, PLAN.md, README and RESUME.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
316 lines
16 KiB
Markdown
316 lines
16 KiB
Markdown
# Utility Statement OCR Capture (receipt capture)
|
||
|
||
Reads the stack of scanned utility bills the office pays every month, proposes
|
||
the customer and the amount for each page, and posts the confirmed pages to the
|
||
ledger as one batch against one check. Built 2026-08-01, live under `/recibos`.
|
||
|
||
This is the **as-built** record. The design and the reasoning behind it are
|
||
[`RECEIPT_CAPTURE_SPEC.md`](RECEIPT_CAPTURE_SPEC.md) §2, which also carries the
|
||
measured results and the four spec corrections the real scans forced. Read that
|
||
for *why*; read this for *what is there*.
|
||
|
||
## The job it replaces
|
||
|
||
Staff receive 300+ pages per month per service provider — CFE electricity,
|
||
CESPT water, Telnor phone, gas, municipal predial, federal zone — and key each
|
||
one into the ledger by hand, as a charge against the customer whose property
|
||
the bill belongs to. The whole stack is paid with one office check, so the
|
||
capture is naturally a batch.
|
||
|
||
Auto-capture is a **mode of** the existing capture screen, not a separate
|
||
feature: it is the same daily job with a scanner instead of a keyboard, and
|
||
both modes post through the same ledger path.
|
||
|
||
## What ships
|
||
|
||
| Piece | Path |
|
||
|---|---|
|
||
| API module | `apps/api/src/statements/` (service, controller, DTOs, matcher, parsers) |
|
||
| OCR seam | `apps/api/src/statements/ocr/` (interface + Tesseract), bound in `apps/api/src/ocr/ocr.module.ts` |
|
||
| Tables | `statement_batches`, `statement_documents` (`20260731235721_statement_ocr_intake`) |
|
||
| Web | `components/Captura.tsx` (tab shell), `StatementIntake.tsx` (upload + batch list), `/recibos/:id` (review queue) |
|
||
| Abilities | `statement:ingest`, `statement:review` — both **STAFF** |
|
||
|
||
STAFF is deliberate: the review step is what makes machine capture safe at that
|
||
tier, since nothing reaches the ledger unconfirmed.
|
||
|
||
## The screen
|
||
|
||
`Captura` is one screen with two ways in:
|
||
|
||
- `/estado-cuenta/lote` → **manual** tab (`ManualCheckCapture`, key each
|
||
receipt against one check by hand)
|
||
- `/recibos` → **automática (OCR)** tab (`StatementIntake`, upload the scans)
|
||
- `/recibos/:id` → the batch review queue, page image beside the extracted
|
||
fields
|
||
|
||
Both end in the same place — charges on customers' ledgers posted against one
|
||
check — so they are modes of one screen. Staff pick by what is on the desk that
|
||
morning. Either URL renders the same component, so old bookmarks land on the
|
||
right tab.
|
||
|
||
## Pipeline
|
||
|
||
```
|
||
upload PDFs (+ service kind) → store source → render pages → text layer? → parse → match → review → confirm → ledger
|
||
```
|
||
|
||
**A batch is one service kind.** The uploader labels it (ELECTRIC, WATER,
|
||
TELEPHONE, GAS, PROPERTY_TAX, FEDERAL_ZONE) and that label is enforced: if the
|
||
parser reads a page as a different provider, the page is rejected as
|
||
mis-sorted rather than matched. Posting a phone bill as a water charge is the
|
||
failure being prevented.
|
||
|
||
**Processing is not awaited.** 300 pages of OCR is minutes of CPU, far past any
|
||
HTTP timeout, so `POST /statements/batches` returns the batch id immediately and
|
||
the client polls. That is also what lets the review queue show partial progress.
|
||
|
||
**One page = one document.** Statements arrive **bundled, one customer per
|
||
page** — Telnor's own `Pág 3 de 6` is its internal pagination, not the office's
|
||
scan — so every rendered page becomes its own `StatementDocument` and the
|
||
parser runs per page. (The policy OCR feature inverts this; see "Sibling
|
||
feature" below.)
|
||
|
||
**One unreadable page must not abandon the other 299.** A page that throws
|
||
becomes a single `OCR_FAILED` row and the loop continues.
|
||
|
||
Both the source PDFs (`statement/{batchId}/source-N.pdf`) and every rendered
|
||
page image (`page-N.png`) are stored. The source is the artifact the office
|
||
received and the only way to re-run a corrected parser over the original; the
|
||
page image is what the reviewer looks at, because "what the parser read" is
|
||
only checkable against a picture of the paper.
|
||
|
||
## The OCR seam
|
||
|
||
`OcrProvider` (`ocr/ocr.provider.ts`) is the swap point. Four methods:
|
||
`available()`, `renderPages()`, `recognize()`, `textPages()`.
|
||
|
||
Everything above it works in terms of page text and word boxes, so the engine
|
||
is replaceable without touching the parsers, the matcher or the schema. The
|
||
shipped implementation is **self-hosted Tesseract**, and that choice is
|
||
evidence-based rather than assumed — see the spec's measured results. A managed
|
||
API (Textract, Document Intelligence, Document AI) fits behind the same
|
||
interface with no schema change; at 300+ pages/month/company it would carry
|
||
real recurring cost for accuracy that is not the bottleneck.
|
||
|
||
The binding lives in `apps/api/src/ocr/ocr.module.ts`, extracted out of
|
||
`StatementsModule` when [`POLICY_OCR.md`](POLICY_OCR.md) needed the same seam.
|
||
`StatementsModule` imports it and binds nothing itself, so the engine decision
|
||
is one line in one file for both features.
|
||
|
||
### Text layer first, OCR as the fallback
|
||
|
||
**Not every statement is a scan.** The gas company sends born-digital CFDI
|
||
invoices whose text layer is already exact and already positioned.
|
||
`textPages()` reads it (`pdftotext -bbox-layout`, same poppler package as
|
||
`pdftoppm`) and OCR runs only where there is none.
|
||
|
||
Rasterising a born-digital page and re-recognising it can only lose
|
||
information — one sample turned `MEDIDOR: VM01014426` into
|
||
`ar (LTR): 014420` — while costing about a minute of CPU for the privilege.
|
||
Positions come back in the same pixel space `recognize()` uses, so the parsers'
|
||
geometric helpers work unchanged on either source. When the text layer is used
|
||
the document's notes say so verbatim: *"texto leído del PDF original, sin
|
||
OCR"*.
|
||
|
||
### Word boxes, not just text
|
||
|
||
`OcrPage` carries `words[]` with pixel boxes because several real layouts are
|
||
**tables**: the CESPT "RECIBO" prints `No. DE CUENTA` as a column header with
|
||
the value in the row beneath it, which line-oriented text cannot associate.
|
||
Parsers fall back to geometry for exactly those fields.
|
||
|
||
## Parsers
|
||
|
||
Eight providers, dispatched by a `BRAND` table checked before a `LAYOUT` table:
|
||
|
||
| Provider | Service kind |
|
||
|---|---|
|
||
| `CFE` | ELECTRIC |
|
||
| `CESPT` | WATER |
|
||
| `TELNOR` | TELEPHONE |
|
||
| `GAS TIJUANA` | GAS |
|
||
| `PREDIAL TIJUANA` / `PREDIAL ROSARITO` / `PREDIAL ENSENADA` | PROPERTY_TAX |
|
||
| `ZONA FEDERAL TIJUANA` | FEDERAL_ZONE |
|
||
|
||
Three predial parsers rather than one because Tijuana, Rosarito and Ensenada
|
||
issue three completely different documents — same tax, nothing else in common.
|
||
|
||
Rules that are load-bearing and easy to break:
|
||
|
||
- **Brand before layout, and never interleaved.** Scanned logos read badly (a
|
||
CESPT header came back as `E BAJA ES PAGO / EALIFORNIA`), which is why the
|
||
layout fallback exists — but *every* brand rule runs first, because a Telnor
|
||
page contains words a CFE structural rule would otherwise claim.
|
||
- **Tijuana bills predial and zona federal from the same treasury.** Same
|
||
header, same address, same `ATB-541201` RFC, so every predial discriminator
|
||
matches a zona federal page too. The words only that layout prints are
|
||
`Marítimo Terrestre`, so its rule is asked ahead of all three predial ones.
|
||
**Order matters here in a way that is invisible from the code shape.**
|
||
- **Parse amounts by separator position.** A real Telnor bill OCR'd as
|
||
`$ 649,00`; stripping commas as thousands separators makes that $64,900.
|
||
- **A misread `$` is the dangerous failure, not a missing one.** An Ensenada
|
||
receipt for `$2,203.00` OCR'd as `82,203.00` — the sign read as an 8, which
|
||
would post a charge 37× too large and look entirely ordinary in the ledger.
|
||
Every predial amount therefore requires a literal `$`; a page that cannot
|
||
produce one reports no amount and goes to review.
|
||
- **Digit-confusion repair only on fields known to be digits** (`O→0`, `S→5`,
|
||
`B→8`, …), never on free text.
|
||
- **The clave catastral is not `[A-Z]{2}[0-9]{6}`.** Position three is a letter
|
||
in fifteen of the 932 stored claves (`MMB01041`, `CGH52121`). Digitising the
|
||
whole tail maps that `B` to an `8` and yields a key matching no property.
|
||
- **Barcodes beat printed labels.** Where a provider prints a payment barcode
|
||
it is preferred and the two are cross-checked; disagreement sets
|
||
`crossChecked: false` and forces review, because which of the two was
|
||
misread is a judgement call.
|
||
|
||
## Matching
|
||
|
||
`StatementMatcherService`. Two rules govern everything:
|
||
|
||
**Match on one scoped field, never fuzzily across all identifiers.** Each
|
||
service kind has exactly one column its statements print, and only that column
|
||
is consulted. A blanket search over accountNumber/meterNumber/route would let a
|
||
water account number collide with an unrelated phone number, and the mis-post
|
||
would look perfectly ordinary in the ledger.
|
||
|
||
**Never match on the customer name.** A CESPT receipt for account `5365218`
|
||
prints `ARNAIZ ROSAS ELSA AURORA`; the office's book, corroborated by the
|
||
clave, has `CATT, RANDY`. The name on a utility bill is the registrant, not the
|
||
current owner. Names are shown to the reviewer and are never an input.
|
||
|
||
### `scopedRefField` — which column each kind actually prints
|
||
|
||
| Kind | Column | Why |
|
||
|---|---|---|
|
||
| ELECTRIC, WATER, TELEPHONE, CABLE | `accountNumber` | the legacy column holds the printed number |
|
||
| GAS | `meterNumber` | the number lived in free-text notes; `accountNumber` was never populated |
|
||
| PROPERTY_TAX | `meterNumber` | `accountNumber` holds `DATMEX.predial`, an office file number that is neither unique nor printed anywhere |
|
||
| FEDERAL_ZONE | `meterNumber` | `accountNumber` holds `DATMEX.zfed`, which is a **peso amount**, not a reference |
|
||
|
||
`scopedRefField` is exported because three places must agree on the answer: the
|
||
lookup, the blank-service fill on review, and the write-back on confirm. When
|
||
they disagree a reference gets learned into a column nothing searches, and the
|
||
same page returns to the review queue every month forever.
|
||
|
||
The `FEDERAL_ZONE` case is the sharpest instance of a trap this codebase hits
|
||
repeatedly (see also `policies.total`): a legacy column whose *name* promises
|
||
an identifier and whose *contents* are something else. Three of its 77 values
|
||
carry cents and one is negative. Worse than never matching — because every row
|
||
already has a value, the `[field]: null` guards on learning and on the
|
||
blank-service fill would never fire either.
|
||
|
||
### The clave catastral is a rescue on some layouts and the primary key on others
|
||
|
||
CESPT bills print the clave as well as an account number, so it rescues a page
|
||
whose account number did not OCR — which happened on real samples. There it
|
||
stays a hint.
|
||
|
||
On Rosarito and Ensenada predial the receipt prints **nothing else**, so a
|
||
unique clave hit is a real match and auto-matches. Tijuana predial prints no
|
||
clave at all; its only identifier is an 8-digit municipal account carried in a
|
||
32-digit payment barcode (`account(8) + DDMMYY + amount(9) + folio(9)`) that
|
||
the legacy database never held, so those pages start cold and are taught by the
|
||
first confirm.
|
||
|
||
Multiple hits are always surfaced, never auto-picked — duplicate account
|
||
numbers do occur in the legacy data, and the office's own `DUPLICADOS` report
|
||
existed for a reason.
|
||
|
||
## Confirm: what gets written
|
||
|
||
`confirmBatch` posts through **`BillingService.createBatch`** — the same method
|
||
the manual Editor screen uses — rather than writing `Transaction` rows
|
||
directly, so OCR-sourced and hand-keyed receipts share one write path, one
|
||
validation path and one audit trail.
|
||
|
||
- `source: "OCR"` and a per-line `captureRef` of the document id feed the
|
||
duplicate-post guard, so a batch confirmed twice cannot double-charge.
|
||
- `items[i]` is positionally parallel to `lines[i]` (a documented seam
|
||
guarantee), so the created rows zip straight back onto the documents that
|
||
produced them via `postedTransactionId`.
|
||
- **The sign is applied here.** Charges are negative in this ledger; the parser
|
||
reads the printed positive figure, and `-Math.abs()` is applied at the single
|
||
point where a statement becomes a ledger row.
|
||
- A missing amount blocks the confirm with the offending page numbers, rather
|
||
than silently posting zero.
|
||
|
||
### Learning: the cold start is a one-time cost
|
||
|
||
After posting, `learnAccountRefs` writes each confirmed reference back onto the
|
||
`PropertyService` that matched — **only where the field was null**. Never
|
||
overwrites a number already on file, which would let one misread page rewrite
|
||
good reference data.
|
||
|
||
This is what turns gas (whose numbers the migration never populated) and
|
||
Tijuana predial (whose municipal account the legacy database never held) from a
|
||
permanent review queue into a one-time cost: next month's statement for the
|
||
same account matches on its own.
|
||
|
||
### Discarding
|
||
|
||
Refused once any page is `POSTED` — those pages already wrote ledger rows
|
||
against a check, and a "discarded" label on the batch would leave the charges
|
||
unexplained. Reject the remaining pages individually instead.
|
||
|
||
## API surface
|
||
|
||
| Method | Route | Ability |
|
||
|---|---|---|
|
||
| `GET` | `/statements/status` (is OCR + storage available) | authenticated |
|
||
| `GET` | `/statements/batches`, `/batches/:id`, `/batches/:id/documents` | authenticated |
|
||
| `GET` | `/statements/documents/:id/page` (streams the page image) | authenticated |
|
||
| `POST` | `/statements/batches` (upload + service kind) | `statement:ingest` |
|
||
| `PATCH` | `/statements/documents/:id` (correct a field or the match) | `statement:review` |
|
||
| `POST` | `/statements/documents/:id/reject` | `statement:review` |
|
||
| `POST` | `/statements/batches/:id/discard` | `statement:review` |
|
||
| `POST` | `/statements/batches/:id/confirm` | `statement:review` |
|
||
|
||
## Requirements
|
||
|
||
Object storage (`S3_ENDPOINT` + credentials) for the scans, and `tesseract-ocr`
|
||
/ `tesseract-ocr-data-spa` / `poppler-utils` in the API image. Both are checked
|
||
at upload rather than at the first write — a missing dependency should be a 400
|
||
on the request, not a `FAILED` batch minutes later. `GET /statements/status`
|
||
reports both and the upload card hides itself unless both hold.
|
||
|
||
## Tests
|
||
|
||
- `parsers/statement-parser.spec.ts` — `detectProvider`,
|
||
`normalizeCadastralKey`, and one suite per newer parser
|
||
(`parsePredialTijuana`, `parsePredialRosarito`, `parsePredialEnsenada`,
|
||
`parseGas`, `parseZonaFederal`). Every fixture is a **verbatim OCR excerpt
|
||
from a real receipt**, including the ones that bit: the `82,203.00` Ensenada
|
||
dollar sign, the three-letter clave, the CESPT logo garbage.
|
||
- `ocr/tesseract.provider.spec.ts` — `parseBboxLayout`, the
|
||
`pdftotext -bbox-layout` reader that produces the text-layer `OcrPage`
|
||
(positions included, which is what lets the geometric helpers work on
|
||
born-digital input).
|
||
|
||
Note the CFE / CESPT / Telnor parsers themselves have **no unit suite** — they
|
||
predate the gas/predial extension and were verified against the 46-page corpus
|
||
end to end rather than in isolation. Worth closing if they are touched.
|
||
|
||
## Not built
|
||
|
||
- **Handwritten folder numbers.** Staff pencil a customer number on each bill
|
||
(`9`, `405`); Tesseract read `405` as `205`. Handwriting is a review hint at
|
||
best and is deliberately not an input to matching.
|
||
- **Re-running a corrected parser over a stored batch.** The source PDFs are
|
||
kept precisely so this is possible, but nothing exposes it yet.
|
||
- **Providers beyond the eight above.** Adding one is a `BRAND` entry, an
|
||
optional `LAYOUT` entry, and a parser function.
|
||
|
||
## Sibling feature
|
||
|
||
[`POLICY_OCR.md`](POLICY_OCR.md) — the same pipeline reading carrier policy
|
||
PDFs into `Policy` rows, built out of this one. It reuses the seam, the
|
||
provider-detection ordering, the digit-confusion map and the
|
||
amount-by-separator rule.
|
||
|
||
**One assumption does not carry over.** Here a page *is* a document, because
|
||
statements arrive one customer per page. A policy PDF is one document across
|
||
several pages, so that feature concatenates the pages and parses once per file.
|
||
If you are porting a change between the two, that is the difference to check
|
||
first.
|