Last reviewed September 1, 2026

OCR gets the numbers wrong — how do I catch it before it hits accounting?

Direct answer

Arithmetic, not eyeballs. The numbers on a financial document are redundant by design — line items sum to a subtotal, subtotal plus tax equals the total, debits and credits reconcile to a closing balance — so the cheapest error detector is a handful of equality checks your code runs on every extraction before anything posts. Add range checks against history and a duplicate test, and the errors that survive are rare enough for a person to review by hand.

01

Which digits actually go wrong

Character recognition errors are not random. They cluster around glyphs that look alike at low resolution — 1 and 7, 0 and O, 5 and S, 8 and B, 6 and G — and around the marks that carry meaning without being digits at all.

Those marks are where the expensive mistakes live. A decimal point lost to a speck of dust turns 1,234.56 into 123456. A European document that writes 1.234,56 read with US conventions becomes 1.23. A negative rendered as (450.00) becomes positive if the parentheses are dropped. A currency symbol read as a digit prepends a stray character. None of these are exotic; all of them are caught by arithmetic in one line of code.

  • Glyph confusions: 1/7, 0/O, 5/S, 8/B, 2/Z, 6/G — worst on low-DPI and dot-matrix print.
  • Separators: 1,234.56 versus 1.234,56 versus 1 234,56.
  • Negatives: leading minus, trailing minus, parentheses, or a CR suffix.
  • Decimals lost to speckle, or invented by a stray mark.
  • Currency symbols merged into the number.
  • Digits split across a line break in a narrow column.
02

Six checks that catch most of it

Run these before anything reaches a system of record. Every one is deterministic — no model, no confidence score, no judgement — which means they behave identically on every document forever.

  • Line items sum to the subtotal, in integer cents, with a tolerance of about one cent per line for rounding.
  • Subtotal plus tax equals the total. If a tax rate is stated, check that too: tax should be within a cent or two of subtotal times the rate.
  • A running balance advances correctly: previous balance plus or minus the transaction equals the new balance, row after row.
  • Dates are real and in range — not in the future, not before the account existed, and consistent with the period stated on the document.
  • The document identifier is new: a unique constraint on (issuer, document_number) catches both duplicate sends and re-scans.
  • Magnitude is plausible: compare against this counterparty’s trailing median. A 10x jump is a decimal-point error until a human says otherwise.
03

Confidence is a signal, not a proof

Extraction APIs, including Dokyumi, return confidence values for fields. They are useful as a triage signal — a field the model scored 0.42 is worth looking at — but they are model-reported estimates rather than calibrated probabilities, the map may omit fields entirely, and a confident misread is entirely possible.

So use confidence to decide what a human looks at first, and use arithmetic to decide what is allowed to post. The two are complementary and neither replaces the other. In practice the arithmetic gate is the one that saves money, because a wrong total that balances is far rarer than a wrong total that does not.

04

Put the gate where the money stops

The check belongs in your code, immediately before the write to the ledger or the payment file — not in a spreadsheet somebody reviews later, and not only inside the vendor’s pipeline. That placement gives you two properties worth having: every rejection is logged with a reason, and no upstream change can quietly remove the control.

In the Dokyumi response, the pieces you branch on are status, validation.errors and validation.low_confidence_fields. A status of completed means type validation passed and no reported score fell below the schema threshold, which defaults to 0.8; review means you should inspect both arrays. Neither status is a statement about arithmetic — that is yours to run.

Decision table

Validation rules, with tolerances you can copy

Tolerances are the part teams get wrong: too tight and every legitimate rounding difference becomes an exception, too loose and real errors pass. These starting values work for ordinary commercial documents in a two-decimal currency.

CheckRuleSuggested toleranceTypical cause when it fails
Line sumsum(line_total) = subtotal1 cent per line itemA missed row, a duplicated row, or a merged cell
Tax mathsubtotal + tax = total2 centsA misread digit in any of the three values
Tax ratetax = subtotal x stated_rate2 centsWrong rate applied, or a misread subtotal
Row mathquantity x unit_price = line_total1 centDecimal point lost in unit price
Running balanceprev_balance +/- amount = balance0 centsA dropped or duplicated transaction row
Date sanityissue_date <= today and >= account_openexactMisread year digit, or day/month order confusion
CurrencyISO 4217 code from your allow-listexactSymbol misread, or an unexpected foreign document
Duplicateunique (issuer, document_number)exactRe-sent document, or a re-scan of the same paper
Magnitudeamount within 10x of the counterparty medianflag, do not blockDecimal-point error, or a genuinely large order

Compare money in integer cents, never in floating point. 0.1 + 0.2 is not 0.3 in IEEE 754, and a validation rule that trips on binary rounding will be switched off by whoever is on call that week.

Copy-pasteable artifact

The checks, implemented

Drop this next to your extraction call. It has no dependencies, takes the extracted object, and returns the reasons a document must not post. Reasons — not booleans — because the reason is what a reviewer needs.

guard.ts
type Line = { line_total: number; quantity?: number | null; unit_price?: number | null }
type Doc = {
  issuer: string
  document_number: string
  issue_date: string
  currency: string
  subtotal?: number | null
  tax_amount?: number | null
  total: number
  lines: Line[]
}

const cents = (n: number) => Math.round(n * 100)
const ALLOWED_CURRENCIES = new Set(['USD', 'CAD', 'EUR', 'GBP'])

export function reasonsToHold(doc: Doc, history: { median: number; seen: Set<string> }) {
  const reasons: string[] = []

  const lineSum = doc.lines.reduce((acc, l) => acc + cents(l.line_total), 0)
  if (doc.subtotal != null && Math.abs(lineSum - cents(doc.subtotal)) > doc.lines.length) {
    reasons.push('line items do not sum to the subtotal')
  }
  if (doc.subtotal != null && doc.tax_amount != null) {
    if (Math.abs(cents(doc.subtotal) + cents(doc.tax_amount) - cents(doc.total)) > 2) {
      reasons.push('subtotal plus tax does not equal the total')
    }
  }
  for (const [i, l] of doc.lines.entries()) {
    if (l.quantity != null && l.unit_price != null) {
      if (Math.abs(cents(l.quantity * l.unit_price) - cents(l.line_total)) > 1) {
        reasons.push('line ' + i + ': quantity x unit price does not equal the line total')
      }
    }
  }
  if (!ALLOWED_CURRENCIES.has(doc.currency)) reasons.push('unexpected currency: ' + doc.currency)
  if (Date.parse(doc.issue_date) > Date.now()) reasons.push('issue date is in the future')
  if (history.seen.has(doc.issuer + '|' + doc.document_number)) reasons.push('duplicate document number for this issuer')
  if (history.median > 0 && doc.total > history.median * 10) reasons.push('total is more than 10x this issuer median')

  return reasons
}

Log every hold with the document id and the reason. After a month you will know which rule earns its keep, which tolerance is too tight, and which supplier needs a conversation about their scanner.

Terminology bridge

These are business-rule validations, and the human step is four-eyes review

In accounting systems this family of checks is called business-rule validation or control totals; the practice of a second person confirming a payment is the four-eyes principle. The share of documents that clear every rule without a person is the straight-through processing rate — the number worth tracking weekly, because it tells you whether your rules are too loose or your inputs are getting worse.

  • business-rule validation
  • control totals
  • four-eyes principle
  • straight-through processing
  • human-in-the-loop

Follow-up questions

Should I block a document that fails a rule, or just flag it?+
Block anything that fails an equality check — a document whose arithmetic does not work should never post automatically. Flag, but do not block, the plausibility checks like magnitude, since a large legitimate invoice must still be payable.
Can I rely on the confidence score instead of writing these checks?+
No. Confidence is model-reported, may omit fields, and is not a calibrated probability. It is a good queue-ordering signal and a poor gate. Arithmetic is a good gate.
What tolerance should I use for currencies without decimal subunits?+
Work in the currency’s minor unit and set the tolerance in whole units. For a zero-decimal currency, that means a tolerance of 1, and no cent conversion at all — hard-coding two decimal places is a common source of false exceptions on international documents.

Evidence notes

Sources and limitations

Sources used

Limitations

  • Arithmetic catches internally inconsistent documents. A document that is wrong but self-consistent — the supplier billed the wrong rate correctly — needs contract or purchase-order matching instead.
  • The tolerances above are starting points for two-decimal commercial documents, not universal constants. Measure your own false-exception rate and adjust.
  • Dokyumi reports confidence per field where the model provides it and does not claim calibration. No accuracy percentage is stated on this page because none has been measured and published.

Published and last reviewed September 1, 2026. Product behavior can change; the linked API, pricing, and security pages are the controlling public references.

Run the gate against one real document

Extract, then run the guard above before you write anything to your ledger. The free plan includes 25 credits a month and 2 schemas, with no card required, which is enough to test this on your own documents before deciding anything.