Skip to content

Cookbook: Bank statements

Bank statements are the worst case of “table that wraps across many pages with a different header on each page.” The recipe handles the stitching; this page is mostly about telling you which knobs to turn when your bank’s format goes off-script.

from scriva import Orchestrator, Worker
from scriva.prompts import Prompt
worker = (
Worker.openai("gpt-4o")
.prompt(Prompt.bank_statement)
.cache(".scriva_cache")
.score(method="rendering")
)
recipe = (
Orchestrator()
.orient()
.deskew()
.split.multi_page_table() # detects header / footer per page, stitches rows
.classify(blank=True, merged=True)
.recognize.by_kind({
"text": worker,
"number": Worker.number(),
"date": Worker.date(),
"blank": Worker.skip(),
})
.reconstruct.table(
columns=["posted_date", "description", "amount", "balance"],
reconcile="opening + Σ == closing", # annotates notes on mismatch
)
.export.json("chase.json")
)
result = recipe("chase-2026-05.pdf")
stmt = result.to_dict()["fields"]
print(stmt["account_number_last4"], stmt["statement_period_start"], stmt["statement_period_end"])
print(stmt["opening_balance"], "->", stmt["closing_balance"])
for tx in stmt["transactions"]:
print(tx["posted_date"], tx["amount"], tx["description"])

.split.multi_page_table() detects column splits per page and stitches rows by column alignment. .reconstruct.table(reconcile=...) checks opening_balance + Σ transactions == closing_balance and annotates stmt.notes on failure.

class BankStatement(BaseModel):
institution: str # "Chase", "Wells Fargo", "Mitsubishi UFJ", …
account_number_last4: str
account_type: Literal["checking", "savings", "credit", "loan", "investment"]
currency: str
statement_period_start: date
statement_period_end: date
opening_balance: Decimal
closing_balance: Decimal
transactions: list[Transaction]
fees: list[Fee] | None
notes: str | None
class Transaction(BaseModel):
posted_date: date
value_date: date | None
amount: Decimal # signed: positive = credit, negative = debit
running_balance: Decimal | None
description: str
category: str | None
counterparty: str | None
reference: str | None
page_index: int
row_index: int

category and counterparty are derived from description by the reconstruct step. To disable:

recipe = recipe.replace(
"reconstruct",
reconstruct.table(columns=[...], derive_categories=False),
)

The default stitcher matches columns by horizontal alignment and treats mid-row continuation glyphs (, ) as joins. For statements with unusual column orders (e.g. amount on the left), give it a hint:

recipe = recipe.replace(
"split",
split.multi_page_table(
column_order=["posted_date", "description", "amount", "balance"],
header_height_px=80, # skip at the top of each page
footer_height_px=60, # …and bottom
),
)

See Detectors › multi_page_table.

The default Worker uses a generic prompt. For banks with idiosyncratic layouts (e.g. Japanese postal-savings books), pin one:

from scriva.prompts import Prompt
Prompt.register(
"bank-jp-yucho",
"""通帳画像から取引行を抽出してください。
列: 年月日 / 摘要 / お支払金額 / お預り金額 / 差引残高""",
locale="ja",
)
recipe = recipe.replace(
"recognize",
Worker.openai("gpt-4o").prompt(Prompt.from_registered("bank-jp-yucho")),
)

The default behaviour annotates stmt.notes on a mismatch. To raise instead:

from decimal import Decimal
recipe = recipe.replace(
"reconstruct",
reconstruct.table(
columns=[...],
reconcile="opening + Σ == closing",
on_mismatch="raise",
tolerance=Decimal("0.01"),
),
)

on_mismatch="re-ocr" re-runs the low-confidence rows through a second-pass Worker (the same shape as Orchestrator › Confidence-driven re-OCR).

Credit-card statements with FX transactions print three fields per foreign charge: the local amount, the converted amount, and the exchange rate. The default reconstructs them as separate rows; to collapse them, add a small step:

from scriva import step
@step(phase="reconstruct", name="merge-fx-rows")
async def merge_fx_rows(ctx):
stmt = ctx.fields
merged = []
i = 0
while i < len(stmt.transactions):
tx = stmt.transactions[i]
if i + 2 < len(stmt.transactions) and "FX" in (tx.description or ""):
local, conv, rate = stmt.transactions[i:i+3]
tx = tx.copy(update={
"amount": conv.amount,
"description": f"{tx.description} (rate {rate.amount})",
})
i += 3
else:
i += 1
merged.append(tx)
stmt.transactions = merged
return ctx
recipe = recipe.insert_after("reconstruct", merge_fx_rows)

A 30-page statement with 10 transactions per page is a more expensive run than a 5-page invoice. Two levers:

# Cache aggressively — headers/footers repeat per page.
worker = worker.cache(Cache.layered(".scriva_cache"))
# Cap total cost per statement.
recipe = recipe.options(max_cost_usd=0.30)

The standard pattern: auto-accept high-confidence rows; queue low-confidence rows for human review. Inside the recipe:

recipe = recipe.review.hitl(
when=lambda r: (r.confidence or 0) < 0.85,
sidecar="review.json",
)

Or after the run:

result = recipe("chase.pdf")
auto, review = [], []
for tx_region in result.regions_with_role("transaction"):
(auto if (tx_region.recognition.confidence or 0) >= 0.85 else review).append(tx_region)
import scriva
scriva.batch(
sources=Path("statements/").glob("*.pdf"),
recipe=recipe,
max_concurrency=4,
resume_file=".scriva_statements.json",
on_error="collect",
max_cost_usd=20.0, # batch-wide cap
)

on_error="collect" returns the list of (source, exception) pairs that failed so you can re-process them with a different prompt or model.

Statements contain account numbers, addresses, full transaction histories — high-sensitivity data. Follow the redaction pattern and consider running an on-premise Worker (Worker.tesseract() or Worker.paddle() for typed-statement rows) for the parts you can.