11 min read

D15
Deep Dive · Architectures & Patterns

Deterministic Finance Workflows in Code: Before, After & Libraries

Asked to reconcile a month of bank lines, three Claude models were right 21 times out of 25, and all 25 answers said the difference was 0.00. The fix is not a better prompt. Code does the matching, the arithmetic and the sign-off; the model only proposes pairs for the dozen lines code cannot settle.

This is the implementation page for a deterministic finance workflow. Which steps of a finance process may be probabilistic at all is argued in The Deterministic Boundary for Finance Agents, and what banks and accounting vendors actually run is in the industry analysis. Here there is one task, a September bank-to-cash-book reconciliation, done badly and then fixed. The data is generated by a seeded script: 406 lines on each side, with six items planted the way real months contain them. These are two 450.00 wires on the same day, a payment keyed as 1,245.30 that cleared at 1,254.30, a payment that cleared nine days late, a bank fee and interest, an outstanding cheque and a deposit in transit. Every model call on this page went through claude -p (Claude Code 2.1.303) with tools disabled, on Haiku 5.5, Sonnet 5.5 and Opus 5.5. The code runs on Python 3.14 with pydantic 2.14.1, and its eight tests pass.

Step 1Before: give the model the whole month and ask for the reconciliation.

This is the version a team builds first, because it works in the demo: put both ledgers in the prompt and ask for matches, the unmatched lines and the reconciliation statement.

"""BEFORE: hand the whole reconciliation to the model, numbers and all. Do not ship this."""
import json, subprocess, sys

PROMPT = """You are an accountant. Reconcile this September 2026 bank statement against the cash book.
Match each bank line to its ledger line, list everything unmatched, and prepare the bank
reconciliation: adjusted bank balance, adjusted book balance, and the difference.
Reply with JSON only:
{"matches": [["B..","L.."]], "unmatched_bank": ["B.."], "unmatched_ledger": ["L.."],
 "adjusted_bank_balance": "0.00", "adjusted_book_balance": "0.00", "difference": "0.00",
 "explanation": "..."}

DATA:
"""

It was run 28 times, at two sizes (50 and 406 lines a side) and in two variants: one where the export includes closing balances, and one where the model must add up every line itself. Scored against the known answer:

runs                                   28   Haiku 5.5, Sonnet 5.5, Opus 5.5
returned a difference                  25   every one of them: "0.00"
correct and complete                   21
  Sonnet, 406 lines, no totals          1   both closing balances +2,372.53; book total
                                            "derived from the bank total"
  Haiku, 50 and 406 lines               2   adjusted balances 1,245.30 below the correct
                                            figure; the 9.00 fix described, not applied
  Haiku, 406 lines                      1   listed 3 of 403 matched pairs
declined to compute                     3   Haiku, 406 lines, no totals: all nulls
keyed-error pair called a "match"   9 of 25 the same books, classified two ways

The models are good at this. Opus and Sonnet added up 406 lines correctly by hand in most runs, and every run that produced numbers found the 9.00 keying error. The trouble is what the output does not tell you. All 25 answers said the difference was 0.00, including the one with both closing balances wrong by 2,372.53, whose explanation admits it worked the book total backwards from the bank side. A tie that was computed and a tie that was asserted look the same in JSON. In a pilot of the same experiment, one Sonnet run also returned a ledger ID that does not exist, L390X, and corrected itself only in the prose.

A reviewer who wants to know which runs are wrong has to redo the reconciliation. So the reconciliation is code from the start, and the model works on the part that really needs reading.

Step 2Money crosses every boundary as a string and lives as integer cents.

MONEY = re.compile(r"-?\d+\.\d{2}")

def cents(s: str) -> int:
    """'-1254.30' -> -125430. Money crosses every boundary as a string, never a float."""
    if not isinstance(s, str) or not MONEY.fullmatch(s):
        raise ValueError(f"money must be a string like '-1254.30', got {s!r}")
    return int(s.replace(".", ""))

def fmt(c: int) -> str:
    return f"{'-' if c < 0 else ''}{abs(c) // 100}.{abs(c) % 100:02d}"

The parser is deliberately strict, and on this page it earned its place before any model was involved. The first version of the data generator divided a Decimal by 100 and wrote 7889.4, which cents() refused. A float pipeline would have carried it silently. The usual traps are worth knowing by heart: 0.1 + 0.2 is 0.30000000000000004, round(2.675, 2) is 2.67, and Decimal("100.00") / 3 quantised and multiplied back gives 99.99, so a split loses a cent unless you allocate the remainder.

One trap sits exactly at the model boundary. Declaring a Pydantic field as Decimal does not protect you if the model sends a JSON number, because the JSON parser turns it into a float first: 12345678901234567.89 arrives as 12345678901234568. Type money in any schema a model fills as a string with a pattern, and convert it in code.

Step 3Code matches everything it can prove, and refuses to guess the rest.

def exact_pass(bank, ledger, window=3):
    """Same amount to the cent, dates within the window, and unique in BOTH directions."""
    near = lambda b, l: b.cents == l.cents and abs((b.day - l.day).days) <= window
    pairs = []
    for b in sorted(bank, key=lambda x: x.id):
        ls = [l for l in ledger if near(b, l)]
        if len(ls) == 1 and sum(near(x, ls[0]) for x in bank) == 1:
            pairs.append((b.id, ls[0].id))
    return pairs

A pair is accepted only if the amount is equal to the cent, the dates are within three days, and neither side has a second candidate. The last condition is the one that matters. Two 450.00 wires on the same day each see two possible ledger lines, so code leaves all four open instead of pairing them by position, which is how a rule engine quietly misposts. On the 406-line month this pass settles 400 pairs and leaves 12 lines.

Step 4The model proposes pairs for what is left. Code decides what each proposal is.

class Proposal(BaseModel):
    bank_id: str
    ledger_id: str
    reason: str = Field(max_length=300)

class Proposals(BaseModel):
    proposals: list[Proposal]

LLM = Callable[[str], str]          # prompt in, JSON text out

PROPOSE = """These bank and cash-book lines did not match automatically. Propose bank->ledger
pairs that are the same transaction. Do not compute balances. Reply with JSON only:
{"proposals": [{"bank_id": "B..", "ledger_id": "L..", "reason": "..."}]}
"""

def propose(llm: LLM, bank, ledger) -> list[Proposal]:
    rows = lambda ls: [{"id": l.id, "date": l.day.isoformat(), "amount": fmt(l.cents), "text": l.text} for l in ls]
    text = llm(PROPOSE + json.dumps({"bank": rows(bank), "ledger": rows(ledger)}, sort_keys=True))
    return Proposals.model_validate_json(re.search(r"\{.*\}", text, re.S).group(0)).proposals

def dispose(p: Proposal, open_b: dict, open_l: dict, max_days=14) -> tuple[str, str]:
    b, l = open_b.get(p.bank_id), open_l.get(p.ledger_id)
    if b is None or l is None:
        return "rejected", "unknown or already-used id"
    if b.cents != l.cents:
        return "break", f"amounts differ by {fmt(b.cents - l.cents)}"
    if abs((b.day - l.day).days) > max_days:
        return "rejected", "dates too far apart"
    return "review", p.reason                      # a human confirms before it counts

The model sees 12 lines instead of 812, and the only thing it may return is a list of ID pairs with a reason. It is asked to compute nothing. Each proposal then gets one of three outcomes from code: an unknown or already-used ID is rejected, a pair whose amounts differ becomes a break with its exact difference, and a pair with equal amounts goes to a human as a match to confirm. Nothing the model says is posted. Over three Haiku runs and three Sonnet runs on the same residuals:

                 Haiku 5.5 (x3)   Sonnet 5.5 (x3)
exact, by code        400              400
to review               3                3     both wires by payee; the 9-day Proseware payment
breaks                  1                1     B269/L005, difference -9.00
rejected                0                0
adjusted bank    289,941.09       289,941.09
adjusted book    289,941.09       289,941.09
can sign off          no               no     until a person confirms 3 pairs and the -9.00 entry

All six runs produced the same outcome. The reasons were worded differently each time, and none of that wording is part of the books. The cheaper model did as well as the larger one, because what it is asked to do (read two descriptions and say whether they are the same payment) is a reading task, not a calculation.

Step 5Classify the remainder by rule, compute the statement, and gate the sign-off.

def classify(side: str, l: Line, period_end: date) -> str:
    if side == "ledger" and l.cents > 0 and (period_end - l.day).days <= 3:
        return "deposit_in_transit"
    if side == "ledger" and l.cents < 0 and l.text.lower().startswith("cheque"):
        return "outstanding_cheque"
    if side == "bank" and l.cents < 0 and re.search(r"\b(FEE|CHARGE)\b", l.text):
        return "bank_charge"
    if side == "bank" and l.cents > 0 and "INTEREST" in l.text:
        return "bank_interest"
    return "unexplained"
def reconcile(month: dict, llm: LLM | None, period_end=date(2026, 9, 30)) -> Report:
    bank, ledger = load(month["bank"]), load(month["ledger"])
    r = Report()
    # Every total is recomputed from the lines and tied to the statement's own figure.
    bank_close = cents(month["opening_balance_bank"]) + sum(l.cents for l in bank)
    book_close = cents(month["opening_balance_book"]) + sum(l.cents for l in ledger)
    if "closing_balance_bank" in month:
        assert bank_close == cents(month["closing_balance_bank"]), "statement does not foot"

    for b, l in exact_pass(bank, ledger):
        r.matched.append((b, l, "exact"))
    used_b = {m[0] for m in r.matched}; used_l = {m[1] for m in r.matched}
    open_b = {l.id: l for l in bank if l.id not in used_b}
    open_l = {l.id: l for l in ledger if l.id not in used_l}

    if llm and open_b and open_l:
        for p in propose(llm, sorted(open_b.values(), key=lambda x: x.id), sorted(open_l.values(), key=lambda x: x.id)):
            verdict, why = dispose(p, open_b, open_l)
            if verdict == "rejected":
                r.rejected.append((p.bank_id, p.ledger_id, why)); continue
            b, l = open_b.pop(p.bank_id), open_l.pop(p.ledger_id)
            if verdict == "break":
                r.breaks.append((b.id, l.id, b.cents - l.cents))
            else:
                r.review.append((b.id, l.id, why))

    r.items = ([("bank", l.id, classify("bank", l, period_end), l.cents) for l in open_b.values()] +
               [("ledger", l.id, classify("ledger", l, period_end), l.cents) for l in open_l.values()])
    timing = sum(c for side, _, cat, c in r.items if cat in ("deposit_in_transit", "outstanding_cheque"))
    bank_side = sum(c for side, _, cat, c in r.items if cat in ("bank_charge", "bank_interest"))
    correction = sum(d for _, _, d in r.breaks)
    r.adjusted_bank = bank_close + timing
    r.adjusted_book = book_close + bank_side + correction
    r.can_sign_off = (r.adjusted_bank == r.adjusted_book and not r.review and not r.breaks
                      and all(cat != "unexplained" for *_, cat, _ in r.items))
    check_invariants(bank, ledger, r)
    return r

The reason the difference is not the check is arithmetic. When both books start from the same opening balance, any reconciliation that carries every unmatched line as a reconciling item on its own side ties by construction: adjusted book minus adjusted bank is the sum of the matched pairs' differences, which is zero. Both Haiku runs in STEP 1 that came out 1,245.30 low did exactly this, and both reported 0.00. So the controls that carry weight here are different ones:

  • The statement foots. Closing balances are recomputed from the lines and asserted against the bank's own figure, so a dropped line fails the run.
  • Every unmatched line has a category from a rule. A deposit in transit is a positive ledger line in the last three days, and an outstanding cheque is a negative ledger line that is a cheque. Anything no rule claims is unexplained, and that alone blocks sign-off.
  • Errors are entries, not adjustments. The 9.00 break produces a correction the book needs, and sign-off waits until it is posted and approved.

Who may approve is a policy question, and it is better expressed as a policy than as an if statement. In Cedar (cedarpy 4.12.2), permit(principal, action == Action::"approve", resource) when { resource.maker != principal }; returned Deny when the reconciliation service tried to approve its own correcting entry and Allow for a named controller.

def check_invariants(bank, ledger, r: Report) -> None:
    seen_b = [m[0] for m in r.matched + r.review + r.breaks] + [i[1] for i in r.items if i[0] == "bank"]
    seen_l = [m[1] for m in r.matched + r.review + r.breaks] + [i[1] for i in r.items if i[0] == "ledger"]
    assert sorted(seen_b) == sorted(l.id for l in bank), "every bank line accounted for exactly once"
    assert sorted(seen_l) == sorted(l.id for l in ledger), "every ledger line accounted for exactly once"
    amt = {l.id: l.cents for l in bank + ledger}
    assert all(amt[b] == amt[l] for b, l, _ in r.matched + r.review), "a match is equal to the cent"

Step 6Record the model's replies, so the auditor's rerun is the same run.

An auditor will ask you to re-perform September in December. You cannot get that by asking the model again, because no hosted provider guarantees the same reply twice (the boundary page sets out what each one offers). Reproducibility therefore comes from recording, not from sampling settings.

class Recorded:
    """Same model + same prompt -> the stored reply, so an auditor's rerun is byte-identical."""
    def __init__(self, llm: LLM | None, model: str, path: str):
        self.llm, self.model, self.path = llm, model, path
        try: self.store = json.load(open(path))
        except FileNotFoundError: self.store = {}
        self.calls = 0
    def __call__(self, prompt: str) -> str:
        key = hashlib.sha256(f"{self.model}\0{prompt}".encode()).hexdigest()
        if key not in self.store:
            if self.llm is None:
                raise LookupError(f"no recorded reply for {key[:12]}; replay cannot call a model")
            self.store[key] = self.llm(prompt); self.calls += 1
            json.dump(self.store, open(self.path, "w"), indent=1, sort_keys=True)
        return self.store[key]

The key covers the model name and the exact prompt, and the prompt is built from sorted lines, so the same month always produces the same key. A replay with no model attached returns the stored reply, makes zero calls, and produces an identical report. A replay that finds no recording raises an error instead of quietly calling a model. Keep the store with the period's working papers. It is the evidence that the proposals a controller approved were the ones the model made.

Step 7When the model writes prose, every number in it must be one code computed.

The model is genuinely useful for the commentary the CFO reads. It can write that commentary from the report, provided you check what it writes.

def figures(report) -> set[str]:
    """The only numbers the commentary may state, as normalised strings."""
    vals = [report.adjusted_bank, report.adjusted_book] + [c for *_, c in report.items] + [d for *_, d in report.breaks]
    out = {fmt(abs(v)) for v in vals}
    out |= {str(len(report.matched)), str(len(report.review)), str(len(report.breaks)), "0.00"}
    return out

def unsupported(text: str, allowed: set[str]) -> list[str]:
    bad = []
    for raw in NUM.findall(text):
        n = raw.strip("()$-").replace(",", "")
        if re.fullmatch(r"20\d\d", n):        # a year, not an amount
            continue
        if "." in n:
            n = str(Decimal(n).quantize(Decimal("0.01"))) if len(n.split(".")[1]) <= 2 else n
        if n not in allowed:
            bad.append(raw)

Ten commentaries (five Haiku, five Sonnet), each written only from the report's figures, contained no unsupported number. That is the expected result when the model is given the numbers instead of asked for them, and the check is what lets you rely on it. In the test suite, the same function flags a planted "90.00" and "12 items" in a sentence that should say 9.00 and 3. Money in the check is compared as normalised Decimal strings, never floats.

Step 8Tests: write down what the design promises.

def test_model_proposals_are_disposed_by_code():
    (b1, l1), (b2, l2) = NAMED["wires"]
    r = reconcile(MONTH, fake({"proposals": [
        {"bank_id": b1, "ledger_id": l1, "reason": "same payee"},
        {"bank_id": TP_B, "ledger_id": TP_L, "reason": "same vendor"},      # amounts differ by 9.00
        {"bank_id": b2, "ledger_id": "L390X", "reason": "typo"},          # no such ledger line
    ]}))
    assert [x[:2] for x in r.review] == [(b1, l1)]
    assert r.breaks == [(TP_B, TP_L, -900)]
    assert r.rejected == [(b2, "L390X", "unknown or already-used id")]
    assert not r.can_sign_off

def test_dropping_a_line_fails_the_tie_to_the_statement():
    m = copy.deepcopy(MONTH); m["bank"].pop()
    with pytest.raises(AssertionError, match="does not foot"):
        reconcile(m, None)

def test_input_order_does_not_change_the_result():
    m = copy.deepcopy(MONTH); random.Random(7).shuffle(m["bank"]); random.Random(8).shuffle(m["ledger"])
    a, b = reconcile(MONTH, None), reconcile(m, None)
    assert (sorted(a.matched), sorted(a.items), a.adjusted_bank) == (sorted(b.matched), sorted(b.items), b.adjusted_bank)

def test_replay_is_identical_and_makes_no_call(tmp_path):
    path = str(tmp_path / "replies.json")
    live = Recorded(fake({"proposals": [{"bank_id": TP_B, "ledger_id": TP_L, "reason": "x"}]}), "m", path)
    first = reconcile(MONTH, live)
    replay = Recorded(None, "m", path)
    second = reconcile(MONTH, replay)
    assert (live.calls, replay.calls) == (1, 0)
    assert first == second
    with pytest.raises(LookupError):
        reconcile(MONTH, Recorded(None, "other-model", path))

The full suite has eight tests. In order, they check that:

  • the money parser rejects 0.1, "7889.4", "1.234" and "1,254.30";
  • a JSON number really does lose the cents in a Decimal field;
  • code alone leaves both identical wires open;
  • a hallucinated ID, a mismatched pair and a valid pair each get the right outcome;
  • dropping one bank line fails the tie to the statement;
  • shuffling the input order changes nothing;
  • replay is identical and makes no call;
  • unsupported numbers in commentary are caught.

None of these tests calls a model. A fake that returns a fixed reply stands in for it, which is the point: the guarantees are properties of the code, so they can be tested like code.

Step 9Libraries: what each one is for, and the traps we hit.

Each library below was installed and exercised on 2026-10-22. The behaviours marked as measured are ones we ran ourselves.

Money and arithmetic.

  • Python decimal: the right primitive, provided you always build values from strings and quantise with an explicit rounding mode. The default context is ROUND_HALF_EVEN, which is banker's rounding, so Decimal("2.665") rounds to 2.66 while half-up gives 2.67. Some tax and invoicing rules require half-up, so make the mode a named parameter.
  • Integer minor units: what this page uses. A BIGINT of cents, or a NUMERIC(p,s) column in Postgres, never float8. babel 2.18.0 gives each currency's precision: JPY has 0 decimal places, BHD has 3.
  • py-moneyed 3.0: stops you adding USD to EUR, but it does not quantise to the currency's precision (Money("10.005", "USD") stays 10.005), and its last release was in 2022.
  • JavaScript: dinero.js 2.0.2 (stable since March 2026, after years of alpha releases) uses integer minor units and has allocate. decimal.js 10.6.0 and big.js 7.0.1 handle arbitrary-precision arithmetic.

Ledgers.

  • beancount 3.2.3: rejects an unbalanced transaction. Measured: a balance assertion written to two decimals tolerates a one-cent difference, so -1199.99 USD passed against an actual -1200.00. Write -1199.99 ~ 0.00 USD to make it exact. It is licensed GPL-2.0.
  • TigerBeetle 0.17.9: a database built for debits and credits, with two-phase pending transfers that map onto maker-checker and idempotent transfer IDs. It is still 0.x, so pin the client and server versions together.
  • Formance Ledger and Blnk: ledger services you run yourself. Blnk includes a reconciliation engine with matching rules.
  • python-accounting 2.0.0: would not install without both the MySQL and Postgres client libraries.

Diffing and data checks.

  • datacompy 1.1.0 (Capital One): the quickest way to diff two extracts on a key. Measured: version 1.x removed datacompy.Compare, so use PandasCompare with abs_tol=0. It handles Decimal columns.
  • pandera 0.34.1, Great Expectations 1.24.0 and Pointblank 1.1.1: check a pipeline's tables before they reach the reconciliation. Great Expectations is moving to Fivetran's stewardship.
  • Splink 5.0.0: probabilistic matching for counterparty names. Feed its scores into a deterministic threshold, never directly into a posting. Version 5 has breaking API changes.

Rules, policy and proofs.

  • GoRules ZEN (zen-engine 2.1.3): decision tables your controllers can read. Measured: numbers come back to Python as floats (10000.10 returned as 10000.1), so pass integer cents in and out.
  • Cedar and OPA: for who may approve what. Cedar's maker-checker rule is shown in STEP 5.
  • Z3 (z3-solver 5.3.1.0): checks a model's stated conclusion against encoded rules. Measured: with a fee policy of 2% capped at 25.00 and a balance of 1,500.00, the claim "the fee is 35.00" came back unsat.
  • AWS Bedrock Automated Reasoning checks: the managed version of that idea, generally available since August 2025. It only reports findings and never blocks content. It works in English only, without streaming, and its docs warn that non-linear arithmetic such as compound interest can return TOO_COMPLEX. Use it for eligibility and policy questions, not for computing amounts.

Talking to the model.

  • Native structured outputs, Instructor 1.17.0, PydanticAI and BAML: all of these get you a valid shape. None of them gets you a correct number. Instructor's retry-until-valid loop makes output eventually valid while adding calls to log. BAML's lenient parsing coerces a malformed reply into shape. Validate downstream either way.
  • Outlines 1.3.3: constrained decoding for models you host yourself.
  • Guardrails AI and NeMo Guardrails: for safety and format checks. LLM Guard's repository is archived.

Durable execution. Temporal, DBOS or Restate record each model call as a step, so a resumed workflow replays the recorded reply instead of calling the model again. That is STEP 6 at the infrastructure level. Restate's server is licensed BSL 1.1.

  • Start here: integer cents with a strict string parser, an exact-match pass that refuses ambiguity, a model that returns only ID pairs, code that classifies, a policy for approval, and a recorded reply store. All of that is standard-library Python plus Pydantic.
  • Add a ledger (TigerBeetle, Formance or Blnk) when the agent's output posts, not before.
  • Add Z3 or Automated Reasoning when the model makes claims about policy, such as eligibility, fee waivers or limits, rather than proposing matches.
  • Never add an LLM judge as the arbiter of a number. The boundary page explains why.

Every block on this page was taken from files that passed their tests. Altogether they come to about 250 lines, tests included, plus a 70-line data generator, small enough to adapt to your own statement format. For other finance processes, the domain playbooks cover accounts payable, expense audit and payroll, and Finance agents covers the rails every one of them shares.