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.
- statementproof-0.1.0/LICENSE +21 -0
- statementproof-0.1.0/PKG-INFO +117 -0
- statementproof-0.1.0/README.md +50 -0
- statementproof-0.1.0/pyproject.toml +39 -0
- statementproof-0.1.0/setup.cfg +4 -0
- statementproof-0.1.0/statementproof/README.md +96 -0
- statementproof-0.1.0/statementproof/__init__.py +2 -0
- statementproof-0.1.0/statementproof/__main__.py +3 -0
- statementproof-0.1.0/statementproof/cli.py +188 -0
- statementproof-0.1.0/statementproof/extract.py +314 -0
- statementproof-0.1.0/statementproof/validate.py +202 -0
- statementproof-0.1.0/statementproof.egg-info/PKG-INFO +117 -0
- statementproof-0.1.0/statementproof.egg-info/SOURCES.txt +15 -0
- statementproof-0.1.0/statementproof.egg-info/dependency_links.txt +1 -0
- statementproof-0.1.0/statementproof.egg-info/entry_points.txt +2 -0
- statementproof-0.1.0/statementproof.egg-info/requires.txt +4 -0
- statementproof-0.1.0/statementproof.egg-info/top_level.txt +1 -0
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
statementproof
|