Spaces:
Sleeping
Sleeping
| """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 (debit XOR credit; date present). | |
| _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), | |
| } | |