Correcting OCR Errors in Financial Invoice Data
Optical character recognition on a scanned invoice rarely fails loudly; it substitutes a confident wrong digit, and correcting those errors before a charge reaches a tenant ledger is a distinct engineering discipline. This page is the repair stage of OCR for scanned CAM invoices, the image-based branch of the broader automated invoice parsing and data ingestion pipeline. It picks up where recognition ends: it takes the word boxes recovered by extracting scanned invoice tables with Tesseract OCR, scans each recovered amount for the character-level confusions that plague monetary fields, repairs what can be repaired deterministically, checks every figure against the invoice’s own arithmetic, and routes anything still doubtful to a person. The goal is narrow and unforgiving: a recovered charge is either correct to the cent or it is quarantined — a plausible-looking wrong number is the one outcome the reconciliation system must never emit.
Context & When to Use This Approach
Recognition and correction are two different jobs, and conflating them is why so many scanned-invoice pipelines leak wrong numbers. The recovery stage answers “what does this pixel region say?”; the correction stage answers “can I trust what it said, and if not, can I mechanically repair it or must a person look?” You need a dedicated correction stage the moment recovered figures start feeding money math rather than a search index. The concrete triggers in a common-area-maintenance feed are recognizable:
- A vendor sends the same scanned template every month — a snow-removal contractor, a fire-alarm inspection firm, a water utility — and the recognizer makes the same mistakes on it repeatedly, so a systematic repair pays for itself.
- Recovered charges flow downstream into GL code mapping for CAM expenses and, eventually, the pro-rata allocation algorithm that splits cost across tenants, so a single misread digit becomes a mis-billed reconciliation.
- The invoice carries its own checksums — line amounts that must foot to a printed subtotal — and you want to use that arithmetic to catch recognition errors that individually looked confident.
- You need every automatic repair to be explainable to an auditor: which characters were changed, why the change was safe, and which figures a human confirmed rather than the machine guessing.
If you are eyeballing a handful of scans by hand, none of this is worth building. The stage below is for volume, where a person cannot re-read every figure and the pipeline must decide, cell by cell, what it may fix silently and what it must escalate. It assumes recovery has already run: each amount cell arrives as recognized text plus a per-cell confidence score, and this stage never re-opens the image except to crop a region for the reviewer. Repairs here are confined to money and other strictly-typed numeric fields; a garbled word in a free-text description is left for the recognizer’s own confidence to flag, because there is no arithmetic to check it against.
Step-by-Step Implementation
The pattern is a short funnel. First widen the net — flag every character-level tell that an amount may be misread. Then attempt a deterministic repair into an exact value. Then subject that value to the invoice’s own arithmetic, which is a far stronger signal than any single recognizer score. Finally, fold recognizer confidence, the character flags, and the arithmetic result into one risk number that decides auto-correct versus review. Every monetary value is a decimal.Decimal from the instant a token is meant to be money — never float — so no binary rounding is ever mistaken for an OCR error, and a thousand summed line items still foot to the cent.
Step 1 — Detect suspicious tokens before touching them
Correction begins with suspicion, not repair. Scan each recovered amount for the confusions a print-oriented recognizer is known to make — a letter where a digit belongs (O/0, l/1/I, S/5, B/8), and the separator ambiguity between a thousands comma and a decimal point. Recording why a token is doubtful, as structured flags rather than a bare pass/fail, lets the later routing step weigh the evidence instead of discarding it.
from __future__ import annotations
from dataclasses import dataclass
# Print-recognizer confusions that flip a numeric field alphabetic or vice versa.
DIGIT_LOOKS_LIKE: dict[str, str] = {
"O": "0", "o": "0", "Q": "0", "D": "0",
"l": "1", "I": "1", "|": "1",
"S": "5", "s": "5",
"B": "8", "Z": "2", "g": "9",
}
@dataclass(frozen=True)
class Suspicion:
"""One structured reason a recovered amount cell may be mis-recognized."""
kind: str # "alpha_in_amount", "ambiguous_separator", or "substitution"
detail: str # human-readable note for the review queue
def scan_amount_token(token: str) -> list[Suspicion]:
"""Flag the character-level tells that a money cell was misread.
Runs before any repair, so the corrector knows *why* a token is doubtful,
not merely that it failed to parse. Returns an empty list for a clean token.
"""
flags: list[Suspicion] = []
letters = [ch for ch in token if ch.isalpha()]
if letters:
flags.append(Suspicion("alpha_in_amount", f"letters {''.join(letters)!r} in a money field"))
if "," in token and "." in token:
# Both separators present: grouping vs. decimal must be disambiguated.
flags.append(Suspicion("ambiguous_separator", "comma and period both present"))
for ch in token:
if ch in DIGIT_LOOKS_LIKE:
flags.append(Suspicion("substitution", f"{ch!r} is often misread for {DIGIT_LOOKS_LIKE[ch]!r}"))
return flags
Step 2 — Normalize the token and re-parse it to Decimal
With the tells recorded, attempt a deterministic repair. Map only the high-confidence letter-to-digit confusions — the ones that are almost never legitimately alphabetic inside an amount — then disambiguate the separators by position: whichever of comma or period appears last is the decimal point, and the other is grouping. Anything that still cannot be coerced into a money shape returns None, which is a deliberate refusal to guess rather than a silent zero.
from __future__ import annotations
import re
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
CENTS = Decimal("0.01")
_KEEP = re.compile(r"[^0-9.,\-]")
def repair_amount(token: str) -> Decimal | None:
"""Coerce a recovered amount into an exact Decimal, applying digit repairs.
High-confidence letter-to-digit confusions are mapped to their digit, the
thousands and decimal separators are disambiguated by position, and the
result is quantized to cents. Returns None when the token cannot be made to
look like money, so the caller routes it to review instead of inventing a value.
"""
text = token.replace("$", "").replace(" ", "").strip()
# Repair confusions only inside what is meant to be a numeric field.
repaired = "".join(DIGIT_LOOKS_LIKE.get(ch, ch) for ch in text)
repaired = _KEEP.sub("", repaired)
if not any(ch.isdigit() for ch in repaired):
return None
last_dot, last_comma = repaired.rfind("."), repaired.rfind(",")
if last_dot == -1 and last_comma == -1:
candidate = repaired
else:
decimal_sep = "." if last_dot > last_comma else ","
group_sep = "," if decimal_sep == "." else "."
candidate = repaired.replace(group_sep, "").replace(decimal_sep, ".")
try:
return Decimal(candidate).quantize(CENTS, rounding=ROUND_HALF_UP)
except InvalidOperation:
return None
Step 3 — Validate against the invoice’s own arithmetic
A recovered figure that parses cleanly can still be wrong, and the strongest evidence of that is arithmetic the invoice already contains. Two identities matter. Per line, the quantity times the unit rate must reconcile to the extended amount, ; across the page, the recovered line amounts must foot to the printed subtotal:
Footing is a page-level checksum that catches a misread digit even when every individual cell’s confidence looked acceptable, and per-line extension localizes the blame to a single row. Consider a monthly bill from Cardinal Mechanical for Beacon Hill Plaza: three service lines print at $1,204.50, $860.00, and $415.25 against a subtotal of $2,479.75. If the recognizer reads the first as $1.204,50 and the normalizer recovers $1,204.50, the page still foots; if it reads it as $7,204.50, the sum overshoots by exactly $6,000 and the row is pinpointed.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
TOLERANCE = Decimal("0.01")
@dataclass(frozen=True)
class LineItem:
"""One recovered invoice line: recognized text already parsed to Decimal."""
description: str
quantity: Decimal
unit_rate: Decimal
amount: Decimal # recovered extended amount
amount_confidence: int # recognizer score 0..100 for the amount cell
def line_extends(line: LineItem) -> bool:
"""True when quantity * unit_rate reconciles to the extended amount.
Per-row footing localizes the error: one failing line points at the exact
cell to re-examine instead of condemning the whole invoice.
"""
expected = (line.quantity * line.unit_rate).quantize(TOLERANCE)
return abs(expected - line.amount) <= TOLERANCE
def invoice_foots(lines: list[LineItem], printed_subtotal: Decimal) -> bool:
"""True when the recovered line amounts sum to the printed subtotal.
The invoice carries its own checksum. A scan that does not foot has a
misread digit somewhere, even if every cell's confidence looked fine.
"""
recovered_total = sum((li.amount for li in lines), Decimal("0"))
return abs(recovered_total - printed_subtotal) <= TOLERANCE
Step 4 — Score confidence and route the correction
The final step turns three streams of evidence into one decision. Recognizer confidence, the count of character-level suspicions, and the arithmetic result combine into a risk score in the interval :
Here is the recognizer’s own confidence, is the number of suspicions, and is $1$ when the arithmetic fails and $0$ when it holds. The arithmetic weight is set highest deliberately: a cell that fails to foot is untrustworthy no matter how confident the recognizer was, so arithmetic evidence must be able to override optimism. A repair is auto-accepted only when its risk sits below the amount threshold and a Decimal was actually recovered; everything else carries its reasons to a reviewer who sees the cropped source beside the value.
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum
# Weights tuned so a single arithmetic failure outweighs a merely low score.
W_CONFIDENCE = Decimal("0.4")
W_SUSPICION = Decimal("0.15")
W_ARITHMETIC = Decimal("0.6")
AMOUNT_RISK_THRESHOLD = Decimal("0.25")
class Route(str, Enum):
AUTO_CORRECTED = "auto_corrected"
REVIEW = "review"
@dataclass(frozen=True)
class CorrectionOutcome:
"""The verdict for one amount cell: what it became and how it was decided."""
route: Route
corrected_amount: Decimal | None
risk: Decimal
reasons: list[str]
def route_correction(line: LineItem, subtotal_ok: bool) -> CorrectionOutcome:
"""Score an amount cell, then auto-correct it or send it to a human.
Recognizer confidence, character-level suspicions, and the arithmetic
checks combine into a risk score. A repaired value is trusted only when
the score is below the amount threshold and a Decimal repair succeeded;
everything else routes to review with its accumulated reasons.
"""
raw_token = str(line.amount)
suspicions = scan_amount_token(raw_token)
corrected = repair_amount(raw_token)
conf_gap = Decimal(100 - line.amount_confidence) / Decimal(100)
suspicion_load = min(Decimal(len(suspicions)), Decimal(3)) / Decimal(3)
arithmetic_ok = line_extends(line) and subtotal_ok
arithmetic_fail = Decimal(0) if arithmetic_ok else Decimal(1)
risk = (W_CONFIDENCE * conf_gap
+ W_SUSPICION * suspicion_load
+ W_ARITHMETIC * arithmetic_fail)
reasons = [s.detail for s in suspicions]
if not arithmetic_ok:
reasons.append("line does not extend or invoice does not foot")
safe = risk < AMOUNT_RISK_THRESHOLD and corrected is not None
return CorrectionOutcome(
route=Route.AUTO_CORRECTED if safe else Route.REVIEW,
corrected_amount=corrected,
risk=risk,
reasons=reasons,
)
Gotchas & Known Limitations
The mistakes that actually corrupt a reconciliation are subtle, because a bad correction looks exactly like a good one until year-end. Treat this as a pre-flight checklist before trusting the stage against a new vendor:
Verification
The pure functions here — suspicion scanning, amount repair, footing, and routing — are deterministic, so they get exact-equality tests rather than the tolerance-bounded golden files that recognition itself requires. Assert that repair_amount("$1,204.50") and repair_amount("1.204,50") both return Decimal("1204.50"), that a substituted token like repair_amount("12O4.50") recovers Decimal("1204.50") because the O maps to 0, and that a hopeless token such as repair_amount("--") returns None instead of a fabricated figure. For the arithmetic layer, build a small LineItem set whose amounts sum to a known subtotal and assert invoice_foots is True, then perturb one amount by more than a cent and assert it flips to False. The routing tests are the ones that matter most: a high-confidence cell whose line foots must return Route.AUTO_CORRECTED with a risk below the threshold, while the same cell with its line broken must return Route.REVIEW even though nothing about the recognizer’s confidence changed — proving that arithmetic evidence overrides optimism exactly as the weights intend. Round the suite out with property-based tests that generate random line sets, compute the true subtotal in Decimal, and assert that a reconciling invoice never routes a clean cell to review while a subtotal perturbed past the tolerance always does. A cell that clears every gate here re-enters the automated invoice parsing and data ingestion pipeline as though it had been recognized cleanly, while everything flagged is held for a person — the same discipline the recovery step in extracting scanned invoice tables with Tesseract OCR applies one stage earlier.
Related
- OCR for scanned CAM invoices — the parent stage that classifies image-only pages and recovers word boxes this correction stage then repairs.
- Extracting scanned invoice tables with Tesseract OCR — the recovery step immediately upstream, turning recognized word boxes into the amount cells corrected here.
- Schema validation for parsed expense data — the next stage, which enforces types and value ranges on every corrected record.
- Building a CAM data validation layer — the orchestration that accepts a corrected record or quarantines it with a structured, machine-readable reason.