File size: 4,899 Bytes
10ec275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
"""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),
    }