statementproof 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Carthorne
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: statementproof
3
+ Version: 0.1.0
4
+ Summary: Extract transactions from bank statement PDFs -- and prove the extraction is right, or say it isn't.
5
+ License: MIT
6
+ Keywords: bank-statement,pdf,csv,bookkeeping,accounting,reconciliation,extraction,converter,quickbooks,xero
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Financial and Insurance Industry
9
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
10
+ Classifier: Topic :: Text Processing :: Filters
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: pdfplumber>=0.10
18
+ Provides-Extra: dev
19
+ Requires-Dist: reportlab>=4.0; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # statementproof
23
+
24
+ Bank statement PDF → CSV extraction that **tells you when it got it wrong.**
25
+
26
+ Most converters hand you output with no way to know if it is correct. A bank
27
+ statement is one of the few documents carrying its own checksum — the balances.
28
+
29
+ ## What it does differently
30
+
31
+ **1. Geometry, not regex.** The most-cited converter failure is *"columns shift,
32
+ debit and credit values land in the wrong places."* That happens because
33
+ `extract_text()` flattens a 2-D layout and discards x-position — the only signal that
34
+ distinguishes a debit column from a credit column. This reads word coordinates and
35
+ clusters money columns by their right edges.
36
+
37
+ **2. Column roles from arithmetic, not headers.** Which column is the running balance
38
+ is decided by behaviour, not by header text — header text differs across banks and is
39
+ often missing on continuation pages.
40
+
41
+ **3. Sign from the balance chain.** If the balance went down, it was a debit,
42
+ regardless of whether a minus glyph survived extraction.
43
+
44
+ **4. It refuses to bluff.** With nothing to check against, output is `UNVERIFIED` —
45
+ never a silent pass.
46
+
47
+ ## Verdicts
48
+
49
+ | verdict | meaning |
50
+ |---|---|
51
+ | `VERIFIED` | transactions reproduce the statement's own balances |
52
+ | `FAILED` | they do not — **with the offending row named** |
53
+ | `UNVERIFIED` | statement carries no balances to check against |
54
+
55
+ ## What it CANNOT catch — read this
56
+
57
+ The check is **arithmetic consistency**, not correctness. It cannot see errors that do
58
+ not disturb the arithmetic:
59
+
60
+ - junk rows with a `0.00` amount (page headers picked up as transactions)
61
+ - wrong dates
62
+ - garbled or truncated descriptions
63
+ - two errors that cancel out
64
+
65
+ **"Provably arithmetically consistent" is the honest claim. "Provably correct" is not,
66
+ and is not made here.**
67
+
68
+ ## Free test mode — try it on your own statement
69
+
70
+ ```bash
71
+ python -m statementproof YOUR_STATEMENT.pdf # validate, print a report
72
+ python -m statementproof YOUR_STATEMENT.pdf --csv out.csv
73
+ python -m statementproof YOUR_STATEMENT.pdf --diagnostic # shareable layout report
74
+ ```
75
+
76
+ **It runs entirely on your machine. Nothing is uploaded, nothing is stored, no file is
77
+ written unless you name one with `--csv`.**
78
+
79
+ That is not a policy, it is a property of the code, and it is tested:
80
+ `tests/test_privacy.py` parses every module's AST and **fails the build if any
81
+ networking library is imported anywhere in the package.**
82
+
83
+ ### The `--diagnostic` flag, and why it exists
84
+
85
+ The single thing that would most improve this tool is a library of real statement
86
+ *layouts*. A layout can be described without describing anyone's money, so
87
+ `--diagnostic` prints exactly that: column positions, column density, date-token
88
+ **shapes** (`DD/DD`, not `10/02`), and where extraction broke.
89
+
90
+ It contains **no amounts, no balances, no descriptions, no dates, no account numbers,
91
+ no names, and not even the filename.** It prints to your screen so you can read the
92
+ whole thing before deciding whether to share it. The tool never sends it anywhere —
93
+ there is no code that could.
94
+
95
+ Those exclusions are asserted by tests against a known statement, not just intended.
96
+
97
+ ## Status
98
+
99
+ Early, and scoped to **text-layer PDFs only** — statements downloaded from a bank
100
+ portal. Scans and photographs are not supported; the tool detects them and says so
101
+ rather than producing garbage.
102
+
103
+ **7/7 layouts extracted exactly, 0 false assurances.** Six are synthetic; the seventh
104
+ is reproduced from a real bank's published specimen and is the useful one — it found
105
+ three bugs the synthetic set never could, including `MM/DD` dates with no year, which
106
+ alone produced **0/21 extracted** while the synthetic suite still reported 6/6.
107
+
108
+ ⚠️ **Passing a test suite written by the author of the code under test is worth very
109
+ little.** Six invented layouts passed while a real bank's date format extracted
110
+ nothing. **No real customer file has been processed yet** — which is what the free
111
+ test mode above is for.
112
+
113
+ python statementproof/tests/make_statements.py # build synthetic corpus
114
+ python statementproof/tests/make_real_derived.py # build real-bank-derived layout
115
+ python statementproof/tests/score.py # score extraction
116
+ python statementproof/tests/test_failure_modes.py # validator behaviour
117
+ python statementproof/tests/test_privacy.py # privacy promises
@@ -0,0 +1,50 @@
1
+ # CARTHORNE — AUTOPILOT
2
+ **State of the business in 20 lines. Read this first.**
3
+
4
+ **Day 2 · 2026-08-20**
5
+
6
+ | | |
7
+ |---|---|
8
+ | Capital | **$100 — untouched.** Hard ceiling, nothing more coming. |
9
+ | Burn | **$59.40/mo** → **$89.11/mo on 2026-08-31** (11 days) |
10
+ | **Runway** | **51 days now · 34 days after Aug 31** |
11
+ | Revenue | **$0** |
12
+ | Break-even | **$89/mo** = 5 sales/mo of a $19 one-off |
13
+ | Thesis | **NONE. This is the problem.** |
14
+
15
+ ## THE MACHINE WORKS. THERE IS NO BUSINESS IN IT.
16
+
17
+ **Built and verified:** security audit · metric definitions locked before data · spend tracker
18
+ reading real token counts · Telegram live (verified by traffic, not a config flag) · Stripe live ·
19
+ billing understood · asset inventory · 27 commits · DK queue at **0 blocking**.
20
+
21
+ **Not built:** a thesis, a product, an audience, a customer, a dollar.
22
+
23
+ ## THE MAP SO FAR — where NOT to look
24
+
25
+ | buyer | verdict | why |
26
+ |---|---|---|
27
+ | SMB owners | ❌ | need a sales call — QR's $0 proves it |
28
+ | Developers | ❌ | build it themselves, give it away free |
29
+ | Enterprise | ❌ | won't trust a solo shop; escrow + self-host demands |
30
+ | Regulated / financial | ❌ | §11 |
31
+ | **Individual non-developers** | **← all that remains** | |
32
+
33
+ **Candidate 1 (agent cost forensics): KILLED.** Demand real — a $6,000 overnight bill, Uber's
34
+ budget gone in 4 months. But six free tools exist including Anthropic's own. *Pain × free
35
+ alternatives = no business.*
36
+
37
+ ## THE HONEST RISK
38
+
39
+ **Two days in, the ratio of infrastructure-and-self-examination to revenue-seeking is roughly
40
+ 20:1.** Four ADRs, three ops docs, two corrections of my own errors — and one round of demand
41
+ research. Some of that was forced (Telegram was genuinely dead, the burn figure genuinely wrong),
42
+ but §12's warning about building machinery instead of a business is **currently the live risk,
43
+ not a hypothetical one.**
44
+
45
+ ## NEXT
46
+
47
+ Round 3 demand research: individual non-developers, supply-side checked **first**. Reddit and
48
+ Stack Overflow are blocked to our crawler — browser access is the unexplored path.
49
+
50
+ **Decision point: a candidate by 2026-08-27, or cut scope.**
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "statementproof"
7
+ version = "0.1.0"
8
+ description = "Extract transactions from bank statement PDFs -- and prove the extraction is right, or say it isn't."
9
+ readme = "statementproof/README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ keywords = [
13
+ "bank-statement", "pdf", "csv", "bookkeeping", "accounting",
14
+ "reconciliation", "extraction", "converter", "quickbooks", "xero",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Financial and Insurance Industry",
19
+ "Topic :: Office/Business :: Financial :: Accounting",
20
+ "Topic :: Text Processing :: Filters",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Programming Language :: Python :: 3",
23
+ "Operating System :: OS Independent",
24
+ ]
25
+ dependencies = [
26
+ "pdfplumber>=0.10",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ dev = ["reportlab>=4.0"]
31
+
32
+ [project.scripts]
33
+ statementproof = "statementproof.cli:main"
34
+
35
+ [tool.setuptools]
36
+ packages = ["statementproof"]
37
+
38
+ [tool.setuptools.package-data]
39
+ statementproof = ["README.md"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,96 @@
1
+ # statementproof
2
+
3
+ Bank statement PDF → CSV extraction that **tells you when it got it wrong.**
4
+
5
+ Most converters hand you output with no way to know if it is correct. A bank
6
+ statement is one of the few documents carrying its own checksum — the balances.
7
+
8
+ ## What it does differently
9
+
10
+ **1. Geometry, not regex.** The most-cited converter failure is *"columns shift,
11
+ debit and credit values land in the wrong places."* That happens because
12
+ `extract_text()` flattens a 2-D layout and discards x-position — the only signal that
13
+ distinguishes a debit column from a credit column. This reads word coordinates and
14
+ clusters money columns by their right edges.
15
+
16
+ **2. Column roles from arithmetic, not headers.** Which column is the running balance
17
+ is decided by behaviour, not by header text — header text differs across banks and is
18
+ often missing on continuation pages.
19
+
20
+ **3. Sign from the balance chain.** If the balance went down, it was a debit,
21
+ regardless of whether a minus glyph survived extraction.
22
+
23
+ **4. It refuses to bluff.** With nothing to check against, output is `UNVERIFIED` —
24
+ never a silent pass.
25
+
26
+ ## Verdicts
27
+
28
+ | verdict | meaning |
29
+ |---|---|
30
+ | `VERIFIED` | transactions reproduce the statement's own balances |
31
+ | `FAILED` | they do not — **with the offending row named** |
32
+ | `UNVERIFIED` | statement carries no balances to check against |
33
+
34
+ ## What it CANNOT catch — read this
35
+
36
+ The check is **arithmetic consistency**, not correctness. It cannot see errors that do
37
+ not disturb the arithmetic:
38
+
39
+ - junk rows with a `0.00` amount (page headers picked up as transactions)
40
+ - wrong dates
41
+ - garbled or truncated descriptions
42
+ - two errors that cancel out
43
+
44
+ **"Provably arithmetically consistent" is the honest claim. "Provably correct" is not,
45
+ and is not made here.**
46
+
47
+ ## Free test mode — try it on your own statement
48
+
49
+ ```bash
50
+ python -m statementproof YOUR_STATEMENT.pdf # validate, print a report
51
+ python -m statementproof YOUR_STATEMENT.pdf --csv out.csv
52
+ python -m statementproof YOUR_STATEMENT.pdf --diagnostic # shareable layout report
53
+ ```
54
+
55
+ **It runs entirely on your machine. Nothing is uploaded, nothing is stored, no file is
56
+ written unless you name one with `--csv`.**
57
+
58
+ That is not a policy, it is a property of the code, and it is tested:
59
+ `tests/test_privacy.py` parses every module's AST and **fails the build if any
60
+ networking library is imported anywhere in the package.**
61
+
62
+ ### The `--diagnostic` flag, and why it exists
63
+
64
+ The single thing that would most improve this tool is a library of real statement
65
+ *layouts*. A layout can be described without describing anyone's money, so
66
+ `--diagnostic` prints exactly that: column positions, column density, date-token
67
+ **shapes** (`DD/DD`, not `10/02`), and where extraction broke.
68
+
69
+ It contains **no amounts, no balances, no descriptions, no dates, no account numbers,
70
+ no names, and not even the filename.** It prints to your screen so you can read the
71
+ whole thing before deciding whether to share it. The tool never sends it anywhere —
72
+ there is no code that could.
73
+
74
+ Those exclusions are asserted by tests against a known statement, not just intended.
75
+
76
+ ## Status
77
+
78
+ Early, and scoped to **text-layer PDFs only** — statements downloaded from a bank
79
+ portal. Scans and photographs are not supported; the tool detects them and says so
80
+ rather than producing garbage.
81
+
82
+ **7/7 layouts extracted exactly, 0 false assurances.** Six are synthetic; the seventh
83
+ is reproduced from a real bank's published specimen and is the useful one — it found
84
+ three bugs the synthetic set never could, including `MM/DD` dates with no year, which
85
+ alone produced **0/21 extracted** while the synthetic suite still reported 6/6.
86
+
87
+ ⚠️ **Passing a test suite written by the author of the code under test is worth very
88
+ little.** Six invented layouts passed while a real bank's date format extracted
89
+ nothing. **No real customer file has been processed yet** — which is what the free
90
+ test mode above is for.
91
+
92
+ python statementproof/tests/make_statements.py # build synthetic corpus
93
+ python statementproof/tests/make_real_derived.py # build real-bank-derived layout
94
+ python statementproof/tests/score.py # score extraction
95
+ python statementproof/tests/test_failure_modes.py # validator behaviour
96
+ python statementproof/tests/test_privacy.py # privacy promises
@@ -0,0 +1,2 @@
1
+ """statementproof - verifiable bank statement extraction."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+ import sys
3
+ sys.exit(main())
@@ -0,0 +1,188 @@
1
+ """statementproof CLI -- validate a bank statement PDF locally.
2
+
3
+ Design constraint that shapes everything here: **nothing leaves this machine.**
4
+
5
+ This tool is asking people to point it at a bank statement. That document carries
6
+ their name, address, account number, employer, and every place they spent money last
7
+ month. "Nothing stored" cannot be a marketing line; it has to be a structural fact.
8
+ So:
9
+
10
+ - there is no network code in this package at all
11
+ - no file is written unless the user names one (--csv)
12
+ - the diagnostic (--diagnostic) is opt-in and contains NO financial content:
13
+ no amounts, no descriptions, no dates, no account numbers, no names -- only
14
+ layout geometry. It is printed to the screen so it can be read in full before
15
+ anyone chooses to share it.
16
+
17
+ The diagnostic exists because a corpus of real statement LAYOUTS is the thing that
18
+ makes this parser better, and layouts can be described without describing anyone's
19
+ money.
20
+
21
+ Usage:
22
+ python -m statementproof STATEMENT.pdf
23
+ python -m statementproof STATEMENT.pdf --csv transactions.csv
24
+ python -m statementproof STATEMENT.pdf --diagnostic
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import hashlib
31
+ import json
32
+ import os
33
+ import sys
34
+
35
+ from .extract import parse, find_balances, is_money, is_date_start, rows_from_page
36
+ from .validate import validate, fmt
37
+
38
+ BANNER = r"""
39
+ statementproof -- bank statement extraction that tells you when it is wrong
40
+ runs entirely on this machine | no network | nothing uploaded
41
+ """
42
+
43
+
44
+ def build_diagnostic(pdf_path, txns, diag, opening, closing, res):
45
+ """A shareable description of the LAYOUT, containing no financial data.
46
+
47
+ Deliberately excluded: amounts, balances, descriptions, dates, names, account
48
+ numbers, and the file name. Included: how many columns were found and where,
49
+ which date format matched, and where extraction broke. Nothing here identifies
50
+ a person or reveals a transaction.
51
+ """
52
+ import pdfplumber
53
+
54
+ date_shapes = set()
55
+ col_count_hist = {}
56
+ with pdfplumber.open(pdf_path) as pdf:
57
+ pages = len(pdf.pages)
58
+ for page in pdf.pages:
59
+ for r in rows_from_page(page):
60
+ toks = r.tokens
61
+ n = is_date_start(toks)
62
+ if n:
63
+ # Record the SHAPE of the date, not the date.
64
+ t0 = toks[0]
65
+ shape = "".join("D" if ch.isdigit() else
66
+ ("A" if ch.isalpha() else ch) for ch in t0)
67
+ date_shapes.add(shape)
68
+ k = sum(1 for w in r.words if is_money(w["text"]))
69
+ col_count_hist[k] = col_count_hist.get(k, 0) + 1
70
+
71
+ return {
72
+ "statementproof_version": __import__("statementproof").__version__,
73
+ "pdf": {
74
+ "pages": pages,
75
+ "has_text_layer": diag.get("rows", 0) > 0,
76
+ "text_rows_detected": diag.get("rows"),
77
+ },
78
+ "layout": {
79
+ "money_columns_found": len(diag.get("columns") or []),
80
+ "money_column_x_positions": diag.get("columns"),
81
+ "column_density": diag.get("density"),
82
+ "balance_column_index": diag.get("balance_col"),
83
+ "single_column_resolution": diag.get("single_column_resolved"),
84
+ "date_token_shapes": sorted(date_shapes),
85
+ "money_tokens_per_row_histogram":
86
+ {str(k): v for k, v in sorted(col_count_hist.items())},
87
+ },
88
+ "outcome": {
89
+ "transactions_extracted": len(txns),
90
+ "rows_skipped": len(diag.get("skipped") or []),
91
+ "skip_reasons": sorted({s["why"] for s in (diag.get("skipped") or [])}),
92
+ "opening_balance_found": opening is not None,
93
+ "closing_balance_found": closing is not None,
94
+ "verdict": res.verdict,
95
+ "checks_run": res.checks_run,
96
+ "problem_kinds": sorted({p.kind for p in res.problems}),
97
+ },
98
+ "contains_no_financial_data": True,
99
+ }
100
+
101
+
102
+ def main(argv=None):
103
+ ap = argparse.ArgumentParser(
104
+ prog="statementproof",
105
+ description="Validate bank statement PDF extraction. Runs locally; uploads nothing.")
106
+ ap.add_argument("pdf", help="path to a bank statement PDF")
107
+ ap.add_argument("--csv", metavar="OUT", help="write extracted transactions to CSV")
108
+ ap.add_argument("--diagnostic", action="store_true",
109
+ help="print a shareable layout report containing no financial data")
110
+ ap.add_argument("--quiet", action="store_true", help="verdict line only")
111
+ args = ap.parse_args(argv)
112
+
113
+ if not os.path.isfile(args.pdf):
114
+ print("error: no such file: %s" % args.pdf, file=sys.stderr)
115
+ return 2
116
+
117
+ if not args.quiet:
118
+ print(BANNER)
119
+
120
+ try:
121
+ opening, closing = find_balances(args.pdf)
122
+ txns, diag = parse(args.pdf, balance_hints=(opening, closing))
123
+ except Exception as e:
124
+ print("error: could not read PDF (%s: %s)" % (type(e).__name__, e), file=sys.stderr)
125
+ return 2
126
+
127
+ res = validate(txns, opening, closing)
128
+
129
+ if args.quiet:
130
+ print(res.verdict)
131
+ else:
132
+ if diag.get("rows", 0) == 0:
133
+ print(" This PDF has no text layer -- it is a scan or a set of images.")
134
+ print(" statementproof reads text-layer PDFs only. A statement downloaded")
135
+ print(" directly from your bank's website normally has one; a scanned or")
136
+ print(" photographed page does not. OCR support is not built yet.\n")
137
+
138
+ print(" opening balance : %s" % (fmt(opening) if opening is not None else "not found"))
139
+ print(" closing balance : %s" % (fmt(closing) if closing is not None else "not found"))
140
+ print(" transactions : %d extracted, %d row(s) skipped"
141
+ % (len(txns), len(diag.get("skipped") or [])))
142
+ print()
143
+ print(" " + res.report().replace("\n", "\n "))
144
+ print()
145
+
146
+ if res.verdict == "VERIFIED":
147
+ print(" The extracted transactions reproduce this statement's own balances.")
148
+ print(" That means the arithmetic is consistent. It does NOT mean every")
149
+ print(" description or date is correct -- errors that do not change the")
150
+ print(" totals cannot be detected this way.")
151
+ elif res.verdict == "FAILED":
152
+ print(" The extraction does NOT reconcile. The row(s) named above are where")
153
+ print(" the running balance stops following. Do not import this without")
154
+ print(" checking them against the PDF.")
155
+ elif diag.get("rows", 0) == 0:
156
+ print(" Nothing could be read from this file, so there is nothing to check.")
157
+ print(" This is the scan case described above, not a problem with the")
158
+ print(" statement itself.")
159
+ else:
160
+ print(" This statement does not carry enough balance information to check")
161
+ print(" the extraction against. The output may be correct -- but nothing")
162
+ print(" here proves it, so it is not claimed.")
163
+
164
+ if args.csv:
165
+ import csv as _csv
166
+ with open(args.csv, "w", newline="", encoding="utf-8") as f:
167
+ w = _csv.writer(f)
168
+ w.writerow(["row", "date", "description", "amount", "balance", "verdict"])
169
+ for t in txns:
170
+ w.writerow([t.row, t.date, t.description, fmt(t.amount_cents),
171
+ fmt(t.balance_cents) if t.balance_cents is not None else "",
172
+ res.verdict])
173
+ if not args.quiet:
174
+ print("\n wrote %d rows to %s" % (len(txns), args.csv))
175
+
176
+ if args.diagnostic:
177
+ d = build_diagnostic(args.pdf, txns, diag, opening, closing, res)
178
+ print("\n" + "=" * 68)
179
+ print(" LAYOUT DIAGNOSTIC -- contains no amounts, names, dates or account numbers.")
180
+ print(" Read it below. Share it only if you choose to; nothing is sent by this tool.")
181
+ print("=" * 68)
182
+ print(json.dumps(d, indent=2))
183
+
184
+ return 0 if res.verdict == "VERIFIED" else 1
185
+
186
+
187
+ if __name__ == "__main__":
188
+ sys.exit(main())
@@ -0,0 +1,314 @@
1
+ """PDF bank statement -> candidate transactions, using word geometry.
2
+
3
+ Why geometry instead of regex on extracted text:
4
+
5
+ The most-cited failure of generic converters is "columns shift, debit and credit
6
+ values land in the wrong places." That happens because `page.extract_text()` flattens
7
+ a 2-D layout into 1-D lines and throws away the only reliable signal a statement has
8
+ -- the x-position of each number. A debit column and a credit column are not
9
+ distinguishable by their digits; they are distinguishable by where they sit.
10
+
11
+ So this module never regexes a flattened line. It:
12
+
13
+ 1. pulls words with (x0, x1, top) from pdfplumber
14
+ 2. groups words into rows by vertical position
15
+ 3. clusters the x-positions of every numeric token across the whole document to
16
+ discover where the money columns actually are
17
+ 4. assigns each number to a column by position, then labels the columns by
18
+ behaviour (the column that changes monotonically with the others is the
19
+ running balance)
20
+
21
+ Step 4 is the part that matters: column ROLES are inferred from arithmetic, not from
22
+ header text, because header text is inconsistent across banks and often absent on
23
+ continuation pages.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ import statistics
30
+ from dataclasses import dataclass
31
+
32
+ from .validate import Txn, to_cents
33
+
34
+ # A money token: 1,234.56 / 1234.56 / (1,234.56) / 1234.56- / -1234.56 / .26
35
+ # The bare-decimal form (".26", no leading zero) is not hypothetical: the Impact Bank
36
+ # specimen statement prints its interest credit as ".26" on the transaction page and
37
+ # "0.26" on the detail page -- the same figure, two renderings, in one document.
38
+ # A synthetic corpus never produces that. Real documents do.
39
+ MONEY_RE = re.compile(
40
+ r"^\(?-?[$£€]?\d{1,3}(?:,\d{3})*(?:\.\d{2})?-?\)?$" # 1,234.56 / 1234
41
+ r"|^\(?-?[$£€]?\d+\.\d{2}-?\)?$" # 1234.56
42
+ r"|^\(?-?[$£€]?\.\d{2}-?\)?$" # .26 <- leading zero omitted
43
+ )
44
+ # Dates in the formats banks actually print.
45
+ DATE_RES = [
46
+ re.compile(r"^\d{1,2}[/-]\d{1,2}[/-]\d{2,4}$"), # 01/08/2026
47
+ re.compile(r"^\d{4}-\d{2}-\d{2}$"), # 2026-08-01
48
+ # MM/DD with NO year. Extremely common on US statements -- the Impact Bank
49
+ # specimen prints every transaction date this way. The invented corpus always
50
+ # wrote full dates, so this gap survived 6/6 "passing" layouts undetected.
51
+ re.compile(r"^\d{1,2}[/-]\d{1,2}$"),
52
+ re.compile(r"^\d{1,2}$"), # day-only, seen in statements that print the month once
53
+ ]
54
+ MONTHS = {m.lower() for m in
55
+ ("Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec January February March "
56
+ "April May June July August September October November December").split()}
57
+
58
+
59
+ def is_money(tok: str) -> bool:
60
+ t = tok.strip()
61
+ return bool(t) and bool(MONEY_RE.match(t)) and any(c.isdigit() for c in t)
62
+
63
+
64
+ def is_date_start(tokens: list[str]) -> int:
65
+ """Return how many leading tokens form a date, or 0."""
66
+ if not tokens:
67
+ return 0
68
+ t0 = tokens[0].strip().rstrip(",")
69
+ for r in DATE_RES[:3]:
70
+ if r.match(t0):
71
+ return 1
72
+ # "01 Aug 2026" / "1 August"
73
+ if t0.isdigit() and len(tokens) > 1 and tokens[1].strip().rstrip(",.").lower() in MONTHS:
74
+ return 3 if len(tokens) > 2 and tokens[2].strip().isdigit() else 2
75
+ # "Aug 01 2026"
76
+ if t0.lower().rstrip(".") in MONTHS and len(tokens) > 1 and tokens[1].strip().isdigit():
77
+ return 3 if len(tokens) > 2 and tokens[2].strip().isdigit() else 2
78
+ return 0
79
+
80
+
81
+ @dataclass
82
+ class Row:
83
+ top: float
84
+ words: list[dict]
85
+
86
+ @property
87
+ def tokens(self) -> list[str]:
88
+ return [w["text"] for w in self.words]
89
+
90
+ @property
91
+ def text(self) -> str:
92
+ return " ".join(self.tokens)
93
+
94
+
95
+ def rows_from_page(page, y_tol: float = 2.5) -> list[Row]:
96
+ """Group words into visual rows by their vertical position."""
97
+ words = page.extract_words(use_text_flow=False, keep_blank_chars=False)
98
+ if not words:
99
+ return []
100
+ words.sort(key=lambda w: (round(w["top"], 1), w["x0"]))
101
+ rows: list[Row] = []
102
+ for w in words:
103
+ if rows and abs(w["top"] - rows[-1].top) <= y_tol:
104
+ rows[-1].words.append(w)
105
+ else:
106
+ rows.append(Row(top=w["top"], words=[w]))
107
+ for r in rows:
108
+ r.words.sort(key=lambda w: w["x0"])
109
+ return rows
110
+
111
+
112
+ def money_columns(rows: list[Row], tol: float = 12.0) -> list[float]:
113
+ """Cluster the right-edges of numeric tokens to find money column positions.
114
+
115
+ Right edge, not left: money is right-aligned in every statement layout, so x1
116
+ is stable across values of different magnitude while x0 is not.
117
+ """
118
+ edges = [w["x1"] for r in rows for w in r.words if is_money(w["text"])]
119
+ if not edges:
120
+ return []
121
+ edges.sort()
122
+ clusters: list[list[float]] = [[edges[0]]]
123
+ for e in edges[1:]:
124
+ if e - clusters[-1][-1] <= tol:
125
+ clusters[-1].append(e)
126
+ else:
127
+ clusters.append([e])
128
+ # Keep columns that occur often enough to be real columns, not stray figures.
129
+ threshold = max(2, len(rows) // 20)
130
+ return [statistics.median(c) for c in clusters if len(c) >= threshold]
131
+
132
+
133
+ def assign_columns(row: Row, cols: list[float], tol: float = 14.0):
134
+ """Map each money token in the row to its column index."""
135
+ out: dict[int, str] = {}
136
+ for w in row.words:
137
+ if not is_money(w["text"]):
138
+ continue
139
+ best, bestd = None, tol
140
+ for i, c in enumerate(cols):
141
+ d = abs(w["x1"] - c)
142
+ if d < bestd:
143
+ best, bestd = i, d
144
+ if best is not None:
145
+ out[best] = w["text"]
146
+ return out
147
+
148
+
149
+ def parse(pdf_path: str, balance_hints: tuple[int | None, int | None] | None = None
150
+ ) -> tuple[list[Txn], dict]:
151
+ """Extract transactions. Returns (txns, diagnostics).
152
+
153
+ `balance_hints` is (opening_cents, closing_cents) when known. It is used only to
154
+ disambiguate single-money-column statements, where position alone cannot say
155
+ whether that column holds amounts or running balances.
156
+
157
+ Deliberately conservative: a row that cannot be confidently parsed is recorded
158
+ in diagnostics['skipped'] rather than guessed at. A wrong row that validates is
159
+ far more damaging than a missing row that shows up as a balance break.
160
+ """
161
+ import pdfplumber
162
+
163
+ diag = {"pages": 0, "rows": 0, "skipped": [], "columns": [], "balance_col": None}
164
+ all_rows: list[Row] = []
165
+ with pdfplumber.open(pdf_path) as pdf:
166
+ diag["pages"] = len(pdf.pages)
167
+ for page in pdf.pages:
168
+ all_rows.extend(rows_from_page(page))
169
+
170
+ diag["rows"] = len(all_rows)
171
+ cols = money_columns(all_rows)
172
+ diag["columns"] = [round(c, 1) for c in cols]
173
+ if not cols:
174
+ return [], diag
175
+
176
+ # Candidate transaction rows: start with a date and carry at least one number.
177
+ cands: list[tuple[Row, int, dict[int, str]]] = []
178
+ for r in all_rows:
179
+ n = is_date_start(r.tokens)
180
+ if not n:
181
+ continue
182
+ assigned = assign_columns(r, cols)
183
+ if assigned:
184
+ cands.append((r, n, assigned))
185
+
186
+ if not cands:
187
+ return [], diag
188
+
189
+ # Identify the running-balance column: the rightmost column present on nearly
190
+ # every transaction row. Amount columns are sparse (a row is either a debit or
191
+ # a credit); the balance column is dense.
192
+ density = {i: sum(1 for _, _, a in cands if i in a) / len(cands)
193
+ for i in range(len(cols))}
194
+ dense = [i for i, d in density.items() if d >= 0.9]
195
+ bal_col = max(dense) if dense else None
196
+
197
+ # Single-column statements are genuinely ambiguous: one dense money column is
198
+ # either "amount, no balance printed" or "balance, no amount printed", and
199
+ # position cannot tell them apart. Resolve it the same way this module resolves
200
+ # everything else -- by arithmetic. Test both readings against the statement's
201
+ # own opening/closing figures and keep whichever reconciles.
202
+ if len(cols) == 1 and bal_col == 0:
203
+ vals = []
204
+ for _, _, a in cands:
205
+ try:
206
+ vals.append(to_cents(a[0]))
207
+ except (ValueError, TypeError):
208
+ vals.append(None)
209
+ clean = [v for v in vals if v is not None]
210
+ op, cl = balance_hints if balance_hints else (None, None)
211
+ as_amounts = (op is not None and cl is not None
212
+ and op + sum(clean) == cl)
213
+ as_balances = bool(clean) and cl is not None and clean[-1] == cl
214
+ if as_amounts and not as_balances:
215
+ bal_col = None # the column is amounts
216
+ diag["single_column_resolved"] = "amounts (reconciles to closing)"
217
+ elif as_balances and not as_amounts:
218
+ diag["single_column_resolved"] = "balances (last value == closing)"
219
+ elif as_amounts and as_balances:
220
+ diag["single_column_resolved"] = "ambiguous - both readings reconcile"
221
+ else:
222
+ # Neither reconciles. Prefer amounts: a wrong balance silently poisons
223
+ # the chain check, whereas wrong amounts get caught by the aggregate.
224
+ bal_col = None
225
+ diag["single_column_resolved"] = "neither reconciles - assumed amounts"
226
+
227
+ diag["balance_col"] = bal_col
228
+ diag["density"] = {i: round(d, 2) for i, d in density.items()}
229
+
230
+ txns: list[Txn] = []
231
+ for idx, (r, ndate, assigned) in enumerate(cands, start=1):
232
+ toks = r.tokens
233
+ date = " ".join(toks[:ndate])
234
+ money_texts = {v for v in assigned.values()}
235
+ desc = " ".join(t for t in toks[ndate:] if t not in money_texts).strip()
236
+
237
+ bal = None
238
+ if bal_col is not None and bal_col in assigned:
239
+ try:
240
+ bal = to_cents(assigned[bal_col])
241
+ except (ValueError, TypeError):
242
+ bal = None
243
+
244
+ amt_cols = [i for i in assigned if i != bal_col]
245
+ amount = None
246
+ if len(amt_cols) == 1:
247
+ try:
248
+ amount = to_cents(assigned[amt_cols[0]])
249
+ except (ValueError, TypeError):
250
+ amount = None
251
+ elif len(amt_cols) > 1:
252
+ # Separate debit and credit columns: leftmost of the two is
253
+ # conventionally the debit. Sign is resolved below by the balance
254
+ # chain where possible, so do not guess here.
255
+ try:
256
+ vals = {i: to_cents(assigned[i]) for i in sorted(amt_cols)}
257
+ amount = vals[sorted(amt_cols)[0]]
258
+ except (ValueError, TypeError):
259
+ amount = None
260
+
261
+ if amount is None:
262
+ diag["skipped"].append({"row": idx, "text": r.text[:90],
263
+ "why": "no unambiguous amount column"})
264
+ continue
265
+
266
+ txns.append(Txn(row=idx, date=date, description=desc,
267
+ amount_cents=amount, balance_cents=bal,
268
+ source_line=r.text[:200]))
269
+
270
+ # Resolve sign from the balance chain: if bal[i] < bal[i-1] the amount is a
271
+ # debit regardless of how it was printed. This is the geometry payoff -- sign
272
+ # comes from arithmetic, not from a minus glyph that may not survive extraction.
273
+ if bal_col is not None:
274
+ opening_hint = balance_hints[0] if balance_hints else None
275
+ for i, t in enumerate(txns):
276
+ if t.balance_cents is None:
277
+ continue
278
+ # Seed row 0 from the opening balance. Without it the FIRST transaction
279
+ # has no predecessor, its sign cannot be derived from the chain, and a
280
+ # debit printed without a minus stays positive -- which is exactly the
281
+ # single error the Impact Bank layout produced (row 1, +4.23 vs -4.23).
282
+ prev = txns[i - 1].balance_cents if i > 0 else opening_hint
283
+ if prev is None:
284
+ continue
285
+ delta = t.balance_cents - prev
286
+ if delta != 0 and abs(abs(delta) - abs(t.amount_cents)) <= 1:
287
+ t.amount_cents = delta
288
+
289
+ return txns, diag
290
+
291
+
292
+ def find_balances(pdf_path: str) -> tuple[int | None, int | None]:
293
+ """Locate opening and closing balances from summary lines."""
294
+ import pdfplumber
295
+
296
+ opening = closing = None
297
+ open_pat = re.compile(r"(opening|previous|beginning|brought forward|balance b/f)", re.I)
298
+ close_pat = re.compile(r"(closing|ending|new balance|balance c/f|carried forward)", re.I)
299
+ with pdfplumber.open(pdf_path) as pdf:
300
+ for page in pdf.pages:
301
+ for r in rows_from_page(page):
302
+ text = r.text
303
+ monies = [w["text"] for w in r.words if is_money(w["text"])]
304
+ if not monies:
305
+ continue
306
+ try:
307
+ val = to_cents(monies[-1])
308
+ except (ValueError, TypeError):
309
+ continue
310
+ if opening is None and open_pat.search(text):
311
+ opening = val
312
+ if close_pat.search(text):
313
+ closing = val
314
+ return opening, closing
@@ -0,0 +1,202 @@
1
+ """Balance validation for extracted bank statement transactions.
2
+
3
+ The point of this module: most PDF-to-CSV converters give you output with no way to
4
+ know whether it is correct. A bank statement is one of the few documents that carries
5
+ its own checksum -- the balances. If the extracted rows do not reproduce the closing
6
+ balance from the opening balance, at least one row is wrong, and it can usually be
7
+ located exactly.
8
+
9
+ Two independent checks:
10
+
11
+ 1. AGGREGATE opening + sum(amounts) == closing
12
+ 2. CHAIN for every row with a running balance: bal[i] == bal[i-1] + amount[i]
13
+
14
+ The chain check is the useful one. Aggregate tells you *that* something is wrong;
15
+ chain tells you *which row*, which is what makes the failure actionable rather than
16
+ just discouraging.
17
+
18
+ Money is handled in integer minor units (cents) throughout. Floats are not used for
19
+ comparison anywhere -- 0.1 + 0.2 != 0.3 is not an acceptable source of false alarms
20
+ in an accounting tool.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass, field
26
+ from decimal import Decimal, InvalidOperation
27
+ from typing import Iterable, Sequence
28
+
29
+
30
+ def to_cents(value) -> int:
31
+ """Convert a money value to integer cents. Never uses float arithmetic."""
32
+ if isinstance(value, int):
33
+ return value * 100
34
+ if isinstance(value, Decimal):
35
+ return int((value * 100).to_integral_value(rounding="ROUND_HALF_UP"))
36
+ if isinstance(value, float):
37
+ # Round-trip through str so 12.34 does not become 12.339999999999999.
38
+ return int((Decimal(str(value)) * 100).to_integral_value(rounding="ROUND_HALF_UP"))
39
+ if isinstance(value, str):
40
+ s = value.strip()
41
+ if not s:
42
+ raise ValueError("empty money value")
43
+ neg = False
44
+ # Accounting negatives: (1,234.56) means -1234.56
45
+ if s.startswith("(") and s.endswith(")"):
46
+ neg, s = True, s[1:-1]
47
+ # Trailing sign: 1,234.56- (common in some bank exports)
48
+ if s.endswith("-"):
49
+ neg, s = True, s[:-1]
50
+ for ch in "$£€,  ":
51
+ s = s.replace(ch, "")
52
+ if s.startswith("-"):
53
+ neg, s = True, s[1:]
54
+ if not s:
55
+ raise ValueError("no digits in money value %r" % value)
56
+ try:
57
+ cents = int((Decimal(s) * 100).to_integral_value(rounding="ROUND_HALF_UP"))
58
+ except InvalidOperation as exc:
59
+ raise ValueError("unparseable money value %r" % value) from exc
60
+ return -cents if neg else cents
61
+ raise TypeError("unsupported money type: %r" % type(value))
62
+
63
+
64
+ def fmt(cents: int) -> str:
65
+ """Render integer cents as a signed decimal string."""
66
+ sign = "-" if cents < 0 else ""
67
+ c = abs(cents)
68
+ return "%s%d.%02d" % (sign, c // 100, c % 100)
69
+
70
+
71
+ @dataclass
72
+ class Txn:
73
+ """One extracted transaction. `balance` is the running balance if the statement
74
+ prints one; None when the format does not carry it."""
75
+ row: int
76
+ date: str
77
+ description: str
78
+ amount_cents: int
79
+ balance_cents: int | None = None
80
+ source_line: str = ""
81
+
82
+
83
+ @dataclass
84
+ class Problem:
85
+ kind: str # "chain_break" | "aggregate_mismatch" | "no_evidence"
86
+ row: int | None
87
+ message: str
88
+ expected_cents: int | None = None
89
+ found_cents: int | None = None
90
+
91
+
92
+ @dataclass
93
+ class Result:
94
+ ok: bool
95
+ checks_run: list[str] = field(default_factory=list)
96
+ problems: list[Problem] = field(default_factory=list)
97
+ txn_count: int = 0
98
+ computed_closing_cents: int | None = None
99
+
100
+ @property
101
+ def verdict(self) -> str:
102
+ if self.ok:
103
+ return "VERIFIED"
104
+ if any(p.kind == "no_evidence" for p in self.problems):
105
+ return "UNVERIFIED"
106
+ return "FAILED"
107
+
108
+ def report(self) -> str:
109
+ lines = ["%s (%d transactions, checks: %s)"
110
+ % (self.verdict, self.txn_count,
111
+ ", ".join(self.checks_run) or "none")]
112
+ for p in self.problems:
113
+ where = "row %d" % p.row if p.row is not None else "statement"
114
+ lines.append(" [%s] %s: %s" % (p.kind, where, p.message))
115
+ return "\n".join(lines)
116
+
117
+
118
+ def validate(
119
+ txns: Sequence[Txn],
120
+ opening_cents: int | None,
121
+ closing_cents: int | None,
122
+ ) -> Result:
123
+ """Validate extracted transactions against the statement's own balances.
124
+
125
+ Returns VERIFIED only when at least one check actually ran and passed. Absence
126
+ of evidence is reported as UNVERIFIED, never as success -- a converter that
127
+ says "looks fine" because it had nothing to check is the exact failure this
128
+ tool exists to prevent.
129
+ """
130
+ res = Result(ok=False, txn_count=len(txns))
131
+
132
+ if not txns:
133
+ res.problems.append(Problem("no_evidence", None, "no transactions extracted"))
134
+ return res
135
+
136
+ # --- CHAIN CHECK -------------------------------------------------------
137
+ # Locates the specific row that breaks the running balance.
138
+ with_balance = [t for t in txns if t.balance_cents is not None]
139
+ if len(with_balance) >= 2:
140
+ res.checks_run.append("chain")
141
+ prev = None
142
+ # Seed from the opening balance when we have it, so a broken FIRST row
143
+ # is still caught rather than silently becoming the baseline.
144
+ if opening_cents is not None and txns[0].balance_cents is not None:
145
+ prev = opening_cents
146
+ seq = txns
147
+ else:
148
+ prev = with_balance[0].balance_cents
149
+ seq = with_balance[1:]
150
+ for t in seq:
151
+ if t.balance_cents is None:
152
+ # Gap in the balance column: chain cannot span it. Reset and continue.
153
+ prev = None
154
+ continue
155
+ if prev is None:
156
+ prev = t.balance_cents
157
+ continue
158
+ expected = prev + t.amount_cents
159
+ if expected != t.balance_cents:
160
+ res.problems.append(Problem(
161
+ kind="chain_break",
162
+ row=t.row,
163
+ message=("running balance does not follow: %s %+s should give %s, "
164
+ "statement shows %s (off by %s)"
165
+ % (fmt(prev), fmt(t.amount_cents), fmt(expected),
166
+ fmt(t.balance_cents),
167
+ fmt(t.balance_cents - expected))),
168
+ expected_cents=expected,
169
+ found_cents=t.balance_cents,
170
+ ))
171
+ # Trust the statement's own figure going forward so one bad row
172
+ # produces one error rather than cascading through every later row.
173
+ prev = t.balance_cents
174
+
175
+ # --- AGGREGATE CHECK ---------------------------------------------------
176
+ total = sum(t.amount_cents for t in txns)
177
+ if opening_cents is not None and closing_cents is not None:
178
+ res.checks_run.append("aggregate")
179
+ computed = opening_cents + total
180
+ res.computed_closing_cents = computed
181
+ if computed != closing_cents:
182
+ res.problems.append(Problem(
183
+ kind="aggregate_mismatch",
184
+ row=None,
185
+ message=("opening %s plus %d transactions totalling %s gives %s, "
186
+ "but statement closing balance is %s (off by %s)"
187
+ % (fmt(opening_cents), len(txns), fmt(total),
188
+ fmt(computed), fmt(closing_cents),
189
+ fmt(closing_cents - computed))),
190
+ expected_cents=closing_cents,
191
+ found_cents=computed,
192
+ ))
193
+
194
+ if not res.checks_run:
195
+ res.problems.append(Problem(
196
+ "no_evidence", None,
197
+ "statement carries neither a running balance column nor both "
198
+ "opening and closing balances -- extraction cannot be verified"))
199
+ return res
200
+
201
+ res.ok = not res.problems
202
+ return res
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: statementproof
3
+ Version: 0.1.0
4
+ Summary: Extract transactions from bank statement PDFs -- and prove the extraction is right, or say it isn't.
5
+ License: MIT
6
+ Keywords: bank-statement,pdf,csv,bookkeeping,accounting,reconciliation,extraction,converter,quickbooks,xero
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Financial and Insurance Industry
9
+ Classifier: Topic :: Office/Business :: Financial :: Accounting
10
+ Classifier: Topic :: Text Processing :: Filters
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: pdfplumber>=0.10
18
+ Provides-Extra: dev
19
+ Requires-Dist: reportlab>=4.0; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # statementproof
23
+
24
+ Bank statement PDF → CSV extraction that **tells you when it got it wrong.**
25
+
26
+ Most converters hand you output with no way to know if it is correct. A bank
27
+ statement is one of the few documents carrying its own checksum — the balances.
28
+
29
+ ## What it does differently
30
+
31
+ **1. Geometry, not regex.** The most-cited converter failure is *"columns shift,
32
+ debit and credit values land in the wrong places."* That happens because
33
+ `extract_text()` flattens a 2-D layout and discards x-position — the only signal that
34
+ distinguishes a debit column from a credit column. This reads word coordinates and
35
+ clusters money columns by their right edges.
36
+
37
+ **2. Column roles from arithmetic, not headers.** Which column is the running balance
38
+ is decided by behaviour, not by header text — header text differs across banks and is
39
+ often missing on continuation pages.
40
+
41
+ **3. Sign from the balance chain.** If the balance went down, it was a debit,
42
+ regardless of whether a minus glyph survived extraction.
43
+
44
+ **4. It refuses to bluff.** With nothing to check against, output is `UNVERIFIED` —
45
+ never a silent pass.
46
+
47
+ ## Verdicts
48
+
49
+ | verdict | meaning |
50
+ |---|---|
51
+ | `VERIFIED` | transactions reproduce the statement's own balances |
52
+ | `FAILED` | they do not — **with the offending row named** |
53
+ | `UNVERIFIED` | statement carries no balances to check against |
54
+
55
+ ## What it CANNOT catch — read this
56
+
57
+ The check is **arithmetic consistency**, not correctness. It cannot see errors that do
58
+ not disturb the arithmetic:
59
+
60
+ - junk rows with a `0.00` amount (page headers picked up as transactions)
61
+ - wrong dates
62
+ - garbled or truncated descriptions
63
+ - two errors that cancel out
64
+
65
+ **"Provably arithmetically consistent" is the honest claim. "Provably correct" is not,
66
+ and is not made here.**
67
+
68
+ ## Free test mode — try it on your own statement
69
+
70
+ ```bash
71
+ python -m statementproof YOUR_STATEMENT.pdf # validate, print a report
72
+ python -m statementproof YOUR_STATEMENT.pdf --csv out.csv
73
+ python -m statementproof YOUR_STATEMENT.pdf --diagnostic # shareable layout report
74
+ ```
75
+
76
+ **It runs entirely on your machine. Nothing is uploaded, nothing is stored, no file is
77
+ written unless you name one with `--csv`.**
78
+
79
+ That is not a policy, it is a property of the code, and it is tested:
80
+ `tests/test_privacy.py` parses every module's AST and **fails the build if any
81
+ networking library is imported anywhere in the package.**
82
+
83
+ ### The `--diagnostic` flag, and why it exists
84
+
85
+ The single thing that would most improve this tool is a library of real statement
86
+ *layouts*. A layout can be described without describing anyone's money, so
87
+ `--diagnostic` prints exactly that: column positions, column density, date-token
88
+ **shapes** (`DD/DD`, not `10/02`), and where extraction broke.
89
+
90
+ It contains **no amounts, no balances, no descriptions, no dates, no account numbers,
91
+ no names, and not even the filename.** It prints to your screen so you can read the
92
+ whole thing before deciding whether to share it. The tool never sends it anywhere —
93
+ there is no code that could.
94
+
95
+ Those exclusions are asserted by tests against a known statement, not just intended.
96
+
97
+ ## Status
98
+
99
+ Early, and scoped to **text-layer PDFs only** — statements downloaded from a bank
100
+ portal. Scans and photographs are not supported; the tool detects them and says so
101
+ rather than producing garbage.
102
+
103
+ **7/7 layouts extracted exactly, 0 false assurances.** Six are synthetic; the seventh
104
+ is reproduced from a real bank's published specimen and is the useful one — it found
105
+ three bugs the synthetic set never could, including `MM/DD` dates with no year, which
106
+ alone produced **0/21 extracted** while the synthetic suite still reported 6/6.
107
+
108
+ ⚠️ **Passing a test suite written by the author of the code under test is worth very
109
+ little.** Six invented layouts passed while a real bank's date format extracted
110
+ nothing. **No real customer file has been processed yet** — which is what the free
111
+ test mode above is for.
112
+
113
+ python statementproof/tests/make_statements.py # build synthetic corpus
114
+ python statementproof/tests/make_real_derived.py # build real-bank-derived layout
115
+ python statementproof/tests/score.py # score extraction
116
+ python statementproof/tests/test_failure_modes.py # validator behaviour
117
+ python statementproof/tests/test_privacy.py # privacy promises
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ statementproof/README.md
5
+ statementproof/__init__.py
6
+ statementproof/__main__.py
7
+ statementproof/cli.py
8
+ statementproof/extract.py
9
+ statementproof/validate.py
10
+ statementproof.egg-info/PKG-INFO
11
+ statementproof.egg-info/SOURCES.txt
12
+ statementproof.egg-info/dependency_links.txt
13
+ statementproof.egg-info/entry_points.txt
14
+ statementproof.egg-info/requires.txt
15
+ statementproof.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ statementproof = statementproof.cli:main
@@ -0,0 +1,4 @@
1
+ pdfplumber>=0.10
2
+
3
+ [dev]
4
+ reportlab>=4.0
@@ -0,0 +1 @@
1
+ statementproof