File size: 21,394 Bytes
0ebef7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
aa36591
 
 
 
 
 
 
 
0ebef7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
"""
converter.py β€” hardened "Python-first" bank-statement PDF -> Excel/CSV converter.

Free path, no LLM. Handles:
  - gridlined tables (pdfplumber lines)
  - borderless tables (word-position reconstruction)
  - scanned/image PDFs (OCR: PyMuPDF rasterize + Tesseract)
  - renamed headers (synonym map)            - single signed Amount column
  - US (1,234.56) and EU (1.234,56) numbers   - relabeled opening/closing balances
  - multiple accounts in one PDF (sectioning)
Reconciliation (opening + Ξ£credits - Ξ£debits == closing) gates every result.

Usage: python converter.py [statement.pdf]   (default ./sample_statement.pdf)
"""
from __future__ import annotations

import csv
import math
import re
import statistics
import sys
from dataclasses import dataclass, field
from pathlib import Path

import pdfplumber
from openpyxl import Workbook

# ---------------- number / date parsing ----------------

def parse_money(value) -> float | None:
    if value is None:
        return None
    s = str(value).strip()
    if not s or s in {"-", "β€”"}:
        return None
    up = s.upper()
    neg = ("(" in s and ")" in s) or "DR" in up or "-" in s   # '-' anywhere, e.g. "$-2,942.71"
    t = re.sub(r"[^0-9.,]", "", s)
    if not t:
        return None
    if "." in t and "," in t:                       # both separators present
        t = t.replace(",", "") if t.rfind(".") > t.rfind(",") else t.replace(".", "").replace(",", ".")
    elif "," in t:                                   # comma only
        t = t.replace(",", ".") if (t.count(",") == 1 and re.search(r",\d{2}$", t)) else t.replace(",", "")
    try:
        v = float(t)
    except ValueError:
        return None
    return -abs(v) if neg else v


_MONTHS = {m: i for i, m in enumerate(
    ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"], start=1)}


def norm_date(s: str) -> str:
    s = (s or "").strip()
    m = re.match(r"(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{2,4})", s)
    if m:
        d, mo, y = m.groups()
        y = "20" + y if len(y) == 2 else y
        return f"{int(y):04d}-{int(mo):02d}-{int(d):02d}"
    m = re.match(r"(\d{4})-(\d{2})-(\d{2})", s)
    if m:
        return s[:10]
    m = re.match(r"(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})", s)
    if m:
        d, mon, y = m.groups()
        mo = _MONTHS.get(mon[:3].lower())
        if mo:
            return f"{int(y):04d}-{mo:02d}-{int(d):02d}"
    return s


# ---------------- header synonyms ----------------

_CANON = {
    "date": ["transaction date", "txn date", "value date", "posting date", "trans date", "date"],
    "balance": ["running balance", "closing balance", "balance amount", "balance"],
    "debit": ["debit amount", "withdrawals", "withdrawal", "money out", "paid out", "debit", "dr"],
    "credit": ["credit amount", "deposits", "deposit", "money in", "paid in", "credit", "cr"],
    "amount": ["amount", "value"],
    "description": ["description", "details", "narrative", "particulars", "memo", "reference", "remarks", "transaction"],
}


def map_header(cell: str) -> str | None:
    c = (cell or "").strip().lower()
    if not c:
        return None
    # whole-word match so short tokens like "cr"/"dr" don't hit "des(cr)iption"/"a(dr)ess"
    for key in ("date", "balance", "debit", "credit", "amount", "description"):
        for syn in _CANON[key]:
            if re.search(r"\b" + re.escape(syn) + r"\b", c):
                return key
    return None


# ---------------- model ----------------

@dataclass
class Transaction:
    date: str
    description: str
    debit: float | None
    credit: float | None
    balance: float | None


@dataclass
class ExtractedStatement:
    account: str | None = None
    account_holder: str | None = None
    period: str | None = None
    opening_balance: float | None = None
    closing_balance: float | None = None
    transactions: list[Transaction] = field(default_factory=list)
    reconciles: bool | None = None
    reconciliation_detail: str = ""
    # structured reconciliation result (each check is True/False/None=absent)
    running_balance_ok: bool | None = None
    stated_summary_ok: bool | None = None
    # "verified" (all present checks pass) | "review" (checks disagree) |
    # "failed" (all present checks fail) | "unverified" (nothing to check)
    reconcile_status: str = "unverified"

    @property
    def empty(self) -> bool:
        return not (self.transactions or self.opening_balance is not None
                    or self.closing_balance is not None or self.account)


# ---------------- the converter ----------------

class BankStatementConverter:
    def __init__(self, pdf_path):
        self.pdf_path = Path(pdf_path)
        self.statements: list[ExtractedStatement] = []

    def extract(self) -> list[ExtractedStatement]:
        events = []
        with pdfplumber.open(self.pdf_path) as pdf:
            for i, page in enumerate(pdf.pages):
                base = i * 100000.0           # offset so global order == reading order
                text = page.extract_text() or ""
                page_ev = self._events_from_text_page(page) if text.strip() else self._events_from_ocr(i)
                events += [(top + base, pri, kind, val) for (top, pri, kind, val) in page_ev]
        events.sort(key=lambda e: (e[0], e[1]))   # by (top, priority)
        self._assemble(events)
        for st in self.statements:
            self._reconcile(st)
        return self.statements

    # ----- text page (gridlined or borderless) -----
    def _events_from_text_page(self, page):
        ev = []
        for ln in page.extract_text_lines():
            kind, val = self._classify(ln["text"])
            if kind:
                ev.append((ln["top"], 0, kind, val))
        tables = page.find_tables()
        if tables:
            for tb in tables:
                ev.append((tb.bbox[1], 1, "table", tb.extract()))
        else:  # borderless -> reconstruct from word positions
            rows = self._rows_from_words(page.extract_words(), float(page.width))
            if rows:
                ev.append((rows[0][1], 1, "table", rows[0][0]))
        return ev

    # ----- scanned page (OCR) -----
    def _events_from_ocr(self, page_index):
        # pytesseract has a Py3.14 stderr-decode bug -> call tesseract CLI (TSV) directly
        import csv as _csv
        import io
        import subprocess
        import fitz

        doc = fitz.open(self.pdf_path)
        try:
            # 200 DPI is enough for OCR; 300 rendered ~30MB/page and spiked RAM -> OOM on the host.
            pix = doc[page_index].get_pixmap(dpi=200)
            # write the temp image next to the PDF (tesseract/leptonica can't read /tmp here)
            png = self.pdf_path.parent / f".ocr_p{page_index}.png"
            png.write_bytes(pix.tobytes("png"))
        finally:
            doc.close()  # release the page/pixmap immediately so RAM doesn't grow page-by-page
        try:
            proc = subprocess.run(["tesseract", str(png), "stdout", "tsv"],
                                  stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
            tsv = proc.stdout.decode("utf-8", "ignore")   # don't decode tesseract's binary stderr
        except FileNotFoundError:
            return []
        finally:
            png.unlink(missing_ok=True)
        words = []
        for row in _csv.DictReader(io.StringIO(tsv), delimiter="\t", quoting=_csv.QUOTE_NONE):
            txt = (row.get("text") or "").strip()
            try:
                conf = float(row.get("conf", "-1"))
            except (TypeError, ValueError):
                conf = -1
            if txt and conf > 30:
                x, y = int(row["left"]), int(row["top"])
                w, h = int(row["width"]), int(row["height"])
                words.append({"text": txt, "x0": x, "x1": x + w, "top": y, "bottom": y + h})
        ev = []
        # markers from OCR text lines
        for line_text, top in self._lines_from_words(words):
            kind, val = self._classify(line_text)
            if kind:
                ev.append((top, 0, kind, val))
        rows = self._rows_from_words(words, float(pix.width))
        if rows:
            ev.append((rows[0][1], 1, "table", rows[0][0]))
        return ev

    # ----- marker classification -----
    @staticmethod
    def _classify(text):
        if ":" not in text:
            return None, None
        label, _, val = text.partition(":")
        L = label.strip().lower()
        val = val.strip()
        if "holder" in L:
            return "holder", val
        if "period" in L:
            return "period", val
        if any(k in L for k in ("opening balance", "balance brought forward", "brought forward",
                                "opening", "beginning balance", "b/f")):
            return "opening", val
        if any(k in L for k in ("closing balance", "balance carried forward", "carried forward",
                                "closing", "ending balance", "c/f")):
            return "closing", val
        if "account" in L:
            return "account", val
        return None, None

    # ----- word-position table reconstruction (borderless + OCR) -----
    def _lines_from_words(self, words):
        if not words:
            return []
        ws = sorted(words, key=lambda w: (w["top"], w["x0"]))
        heights = [w["bottom"] - w["top"] for w in ws if w["bottom"] > w["top"]]
        ytol = (statistics.median(heights) if heights else 8) * 0.7
        lines, cur, top = [], [ws[0]], ws[0]["top"]
        for w in ws[1:]:
            if abs(w["top"] - top) <= ytol:
                cur.append(w)
            else:
                lines.append(cur)
                cur, top = [w], w["top"]
        lines.append(cur)
        out = []
        for ln in lines:
            ln.sort(key=lambda w: w["x0"])
            out.append((" ".join(w["text"] for w in ln), ln[0]["top"]))
        return out

    def _rows_from_words(self, words, page_width):
        """Reconstruct a table from word positions. Columns are found from the
        vertical whitespace gaps in the table region (robust for right-aligned
        numbers). Returns [(rows, table_top)] or []."""
        if not words:
            return []
        ws = sorted(words, key=lambda w: (w["top"], w["x0"]))
        heights = [w["bottom"] - w["top"] for w in ws if w["bottom"] > w["top"]]
        ytol = (statistics.median(heights) if heights else 8) * 0.7
        lines, cur, top = [], [ws[0]], ws[0]["top"]
        for w in ws[1:]:
            if abs(w["top"] - top) <= ytol:
                cur.append(w)
            else:
                lines.append(sorted(cur, key=lambda w: w["x0"]))
                cur, top = [w], w["top"]
        lines.append(sorted(cur, key=lambda w: w["x0"]))

        hdr_idx = None
        for i, ln in enumerate(lines):
            t = " ".join(w["text"] for w in ln).lower()
            if "date" in t and any(k in t for k in
                                   ("balance", "amount", "credit", "debit", "deposit", "withdraw",
                                    "description", "details")):
                hdr_idx = i
                break
        if hdr_idx is None:
            return []

        region = lines[hdr_idx:]                       # header + data rows only
        # merge word x-intervals into column "bands" separated by whitespace gaps
        intervals = sorted((w["x0"], w["x1"]) for ln in region for w in ln)
        merge_tol = max(4.0, 0.006 * page_width)        # < inter-column gap, > intra-word spaces
        bands = []
        bx0, bx1 = intervals[0]
        for x0, x1 in intervals[1:]:
            if x0 - bx1 <= merge_tol:
                bx1 = max(bx1, x1)
            else:
                bands.append((bx0, bx1)); bx0, bx1 = x0, x1
        bands.append((bx0, bx1))
        ncol = len(bands)
        bounds = [-math.inf] + [(bands[j][1] + bands[j + 1][0]) / 2 for j in range(ncol - 1)] + [math.inf]

        def assign(line):
            row = [""] * ncol
            for w in line:
                cx = (w["x0"] + w["x1"]) / 2
                for j in range(ncol):
                    if bounds[j] <= cx < bounds[j + 1]:
                        row[j] = (row[j] + " " + w["text"]).strip()
                        break
            return row

        rows = [assign(region[0])]
        for ln in region[1:]:
            r = assign(ln)
            if any(r):
                rows.append(r)
        return [(rows, region[0][0]["top"])]

    @staticmethod
    def _norm_acct(s):
        return re.sub(r"\(cid:\d+\)", "", (s or "")).strip().lower()

    # ----- assemble sections into statements -----
    def _assemble(self, events):
        cur = None

        def fresh():
            return ExtractedStatement()

        for _top, _pri, kind, val in events:
            if kind == "account":
                same = (cur is not None and cur.account is not None
                        and self._norm_acct(cur.account) == self._norm_acct(val))
                # a repeated header for the SAME account is a page continuation, not a new statement
                if cur and not cur.empty and not same and (cur.transactions or cur.closing_balance is not None):
                    self.statements.append(cur); cur = fresh()
                if cur is None:
                    cur = fresh()
                if cur.account is None or not same:
                    cur.account = val
            elif kind == "holder":
                cur = cur or fresh(); cur.account_holder = val
            elif kind == "period":
                cur = cur or fresh(); cur.period = val
            elif kind == "opening":
                # only split when the previous statement actually closed; a repeated
                # "brought forward" mid-run is a page continuation, not a new statement
                if cur and cur.closing_balance is not None:
                    self.statements.append(cur); cur = fresh()
                cur = cur or fresh()
                if cur.opening_balance is None:
                    cur.opening_balance = parse_money(val)
            elif kind == "closing":
                cur = cur or fresh(); cur.closing_balance = parse_money(val)
            elif kind == "table":
                summ = self._summary_balances(val)     # Beginning/Ending balance table?
                if summ:
                    if cur and cur.transactions:        # new summary block => new statement
                        self.statements.append(cur); cur = fresh()
                    cur = cur or fresh()
                    cur.opening_balance, cur.closing_balance = summ
                else:
                    cur = cur or fresh()
                    cur.transactions += self._parse_rows(val)
        if cur and not cur.empty:
            self.statements.append(cur)

    @staticmethod
    def _summary_balances(table):
        """Detect a Beginning/Ending-balance summary table -> (opening, closing) or None."""
        if not table or len(table) < 2:
            return None
        header = [(c or "").strip().lower() for c in table[0]]
        if any("date" in h for h in header):
            return None
        o = next((i for i, h in enumerate(header) if "beginning balance" in h or "opening balance" in h), None)
        cl = next((i for i, h in enumerate(header) if "ending balance" in h or "closing balance" in h), None)
        if o is None or cl is None:
            return None
        vals = table[1]
        op = parse_money(vals[o]) if o < len(vals) else None
        clo = parse_money(vals[cl]) if cl < len(vals) else None
        return (op, clo) if op is not None and clo is not None else None

    def _parse_rows(self, table):
        if not table or len(table) < 2:
            return []
        header = [(c or "").strip() for c in table[0]]
        idx = {}
        for j, h in enumerate(header):
            key = map_header(h)
            if key and key not in idx:
                idx[key] = j
        if "date" not in idx or not ({"balance", "amount", "debit", "credit"} & idx.keys()):
            return []
        out = []
        for row in table[1:]:
            cells = [(c or "").strip() for c in row]
            if "date" not in idx or idx["date"] >= len(cells):
                continue
            raw_date = cells[idx["date"]]
            if not raw_date or not re.search(r"\d", raw_date):
                continue
            debit = credit = None
            if "debit" in idx or "credit" in idx:
                debit = parse_money(cells[idx["debit"]]) if "debit" in idx and idx["debit"] < len(cells) else None
                credit = parse_money(cells[idx["credit"]]) if "credit" in idx and idx["credit"] < len(cells) else None
            elif "amount" in idx and idx["amount"] < len(cells):
                amt = parse_money(cells[idx["amount"]])
                if amt is not None:
                    if amt < 0:
                        debit = -amt
                    else:
                        credit = amt
            balance = parse_money(cells[idx["balance"]]) if "balance" in idx and idx["balance"] < len(cells) else None
            out.append(Transaction(norm_date(raw_date),
                                   cells[idx["description"]] if "description" in idx and idx["description"] < len(cells) else "",
                                   debit, credit, balance))
        return out

    def _reconcile(self, st, tol=0.02):
        txns = st.transactions
        credits = sum(t.credit or 0.0 for t in txns)
        debits = sum(t.debit or 0.0 for t in txns)
        parts = []

        # (1) running-balance integrity β€” validates the balance column directly (label-free)
        integrity = None
        if txns and all(t.balance is not None for t in txns):
            derived_open = round(txns[0].balance - ((txns[0].credit or 0) - (txns[0].debit or 0)), 2)
            prev, bad = derived_open, 0
            for t in txns:
                if abs(round(prev + (t.credit or 0) - (t.debit or 0), 2) - t.balance) > tol:
                    bad += 1
                prev = t.balance
            integrity = (bad == 0)
            parts.append(f"running-balance {'βœ“' if integrity else f'βœ— ({bad} bad)'} "
                         f"(derived {derived_open:,.2f}β†’{txns[-1].balance:,.2f})")

        # (2) stated-summary match β€” opening + credits - debits == closing
        summary = None
        if st.opening_balance is not None and st.closing_balance is not None and txns:
            summary = abs((st.opening_balance + credits - debits) - st.closing_balance) <= tol
            parts.append(f"stated-summary {'βœ“' if summary else 'βœ—'} "
                         f"({st.opening_balance:,.2f}+{credits:,.2f}-{debits:,.2f} vs {st.closing_balance:,.2f})")

        st.running_balance_ok = integrity
        st.stated_summary_ok = summary
        present = [c for c in (integrity, summary) if c is not None]
        if not present:
            st.reconciles = None
            st.reconcile_status = "unverified"
            st.reconciliation_detail = "No balance column or stated totals to reconcile."
        else:
            # `reconciles` stays "either proof is enough" for backwards-compat;
            # `reconcile_status` is the precise signal the UI uses.
            st.reconciles = any(present)
            if all(present):
                st.reconcile_status = "verified"
            elif any(present):
                st.reconcile_status = "review"   # one check passes, another disagrees
            else:
                st.reconcile_status = "failed"
            st.reconciliation_detail = " | ".join(parts)

    # ----- export -----
    def to_excel(self, out_path):
        wb = Workbook(); wb.remove(wb.active)
        for n, st in enumerate(self.statements, 1):
            ws = wb.create_sheet(f"Account{n}")
            ws.append(["Date", "Description", "Debit", "Credit", "Balance"])
            for t in st.transactions:
                ws.append([t.date, t.description, t.debit, t.credit, t.balance])
        wb.save(out_path); return Path(out_path)

    def to_csv(self, out_path):
        with open(out_path, "w", newline="") as f:
            w = csv.writer(f)
            w.writerow(["Account", "Date", "Description", "Debit", "Credit", "Balance"])
            for st in self.statements:
                for t in st.transactions:
                    w.writerow([st.account or "", t.date, t.description, t.debit, t.credit, t.balance])
        return Path(out_path)


# ---------------- demo ----------------

def _print(statements):
    flag = {True: "βœ… PASS", False: "❌ FAIL", None: "⚠️  N/A"}
    print("=" * 60)
    print(f"STATEMENTS FOUND: {len(statements)}")
    for n, st in enumerate(statements, 1):
        print("-" * 60)
        print(f"[{n}] account={st.account}  holder={st.account_holder}")
        print(f"    open/close: {st.opening_balance} / {st.closing_balance}   txns: {len(st.transactions)}")
        print(f"    reconcile : {flag[st.reconciles]}  | {st.reconciliation_detail}")
    print("=" * 60)


if __name__ == "__main__":
    pdf = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).parent / "sample_statement.pdf"
    conv = BankStatementConverter(pdf)
    sts = conv.extract()
    _print(sts)
    conv.to_excel(pdf.with_suffix(".out.xlsx"))
    conv.to_csv(pdf.with_suffix(".out.csv"))