| """Step C: deterministic validation -- the trust-winning feature. |
| |
| Running-balance reconciliation: for each consecutive pair of rows, |
| expected_balance[i] = balance[i-1] - debit[i] + credit[i] |
| If |expected - printed| > tolerance, both rows are flagged for review. |
| |
| Also validates: dates parse + are monotonically non-decreasing; each row has |
| exactly one of debit/credit; a printed totals row (if any) matches the sum. |
| |
| This converts "the AI might hallucinate" into "the math checks itself". |
| """ |
|
|
| TOLERANCE = 0.01 |
|
|
|
|
| def detect_direction(transactions): |
| """Detect the bank's balance sign convention from the first few rows. |
| |
| Standard convention: balance[i] = balance[i-1] - debit + credit. |
| Some statements print the opposite. We test both on the first usable rows |
| and return +1 (standard) or -1 (inverted). |
| """ |
| standard_err = 0.0 |
| inverted_err = 0.0 |
| checked = 0 |
| prev = None |
| for t in transactions: |
| bal = t.get("balance") |
| if bal is None: |
| prev = bal |
| continue |
| if prev is not None: |
| delta = (t.get("debit") or 0) - (t.get("credit") or 0) |
| standard_err += abs((prev - delta) - bal) |
| inverted_err += abs((prev + delta) - bal) |
| checked += 1 |
| prev = bal |
| if checked >= 3: |
| break |
| return -1 if inverted_err < standard_err else 1 |
|
|
|
|
| def reconcile(transactions): |
| """Reconcile running balances. Mutates each txn with a 'flags' list. |
| |
| Returns a dict: |
| {reconciled, total, direction, banner, all_flags} |
| where `total` counts rows that had a checkable printed balance. |
| """ |
| direction = detect_direction(transactions) |
| reconciled = 0 |
| checkable = 0 |
| prev_balance = None |
|
|
| for t in transactions: |
| t.setdefault("flags", []) |
|
|
| for i, t in enumerate(transactions): |
| bal = t.get("balance") |
| debit = t.get("debit") or 0 |
| credit = t.get("credit") or 0 |
|
|
| if prev_balance is not None and bal is not None: |
| checkable += 1 |
| if direction == 1: |
| expected = prev_balance - debit + credit |
| else: |
| expected = prev_balance + debit - credit |
| if abs(expected - bal) > TOLERANCE: |
| msg = f"balance mismatch (expected {expected:.2f}, printed {bal:.2f})" |
| _flag(transactions, i, "balance", msg) |
| _flag(transactions, i - 1, "balance", "adjacent to balance mismatch") |
| else: |
| reconciled += 1 |
| if bal is not None: |
| prev_balance = bal |
|
|
| |
| _structural_checks(transactions) |
|
|
| banner = _banner(reconciled, checkable) |
| all_flags = sum(1 for t in transactions if t.get("flags")) |
| return { |
| "reconciled": reconciled, |
| "total": checkable, |
| "direction": direction, |
| "banner": banner, |
| "flagged_rows": all_flags, |
| } |
|
|
|
|
| def _flag(transactions, idx, kind, msg): |
| if 0 <= idx < len(transactions): |
| flags = transactions[idx].setdefault("flags", []) |
| if msg not in flags: |
| flags.append(msg) |
|
|
|
|
| def _structural_checks(transactions): |
| prev_date = None |
| for i, t in enumerate(transactions): |
| debit = t.get("debit") |
| credit = t.get("credit") |
| has_debit = debit is not None and debit != 0 |
| has_credit = credit is not None and credit != 0 |
| if has_debit and has_credit: |
| _flag(transactions, i, "amount", "both debit and credit present") |
| if not has_debit and not has_credit: |
| _flag(transactions, i, "amount", "no debit or credit amount") |
|
|
| date = t.get("date") |
| if not date: |
| _flag(transactions, i, "date", "unparseable date") |
| elif prev_date and date < prev_date: |
| _flag(transactions, i, "date", f"date {date} precedes previous {prev_date}") |
| if date: |
| prev_date = date |
|
|
|
|
| def _banner(reconciled, total): |
| if total == 0: |
| return "ℹ️ No printed balances available to reconcile." |
| if reconciled == total: |
| return f"✅ {reconciled}/{total} rows reconciled against printed balances" |
| return (f"⚠️ {reconciled}/{total} rows reconciled — " |
| f"{total - reconciled} need review (flagged below)") |
|
|
|
|
| def flags_text(txn): |
| """Compact ⚠ string for the review table.""" |
| flags = txn.get("flags") or [] |
| return ("⚠ " + "; ".join(flags)) if flags else "" |
|
|
|
|
| def summary_stats(transactions): |
| """Totals for the summary chips.""" |
| total_debit = round(sum(t.get("debit") or 0 for t in transactions), 2) |
| total_credit = round(sum(t.get("credit") or 0 for t in transactions), 2) |
| return { |
| "count": len(transactions), |
| "total_debit": total_debit, |
| "total_credit": total_credit, |
| "net": round(total_credit - total_debit, 2), |
| } |
|
|