statementproof 0.1.1__tar.gz → 0.2.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.1/statementproof.egg-info → statementproof-0.2.0}/PKG-INFO +58 -3
- {statementproof-0.1.1 → statementproof-0.2.0}/pyproject.toml +1 -1
- {statementproof-0.1.1 → statementproof-0.2.0}/statementproof/README.md +57 -2
- {statementproof-0.1.1 → statementproof-0.2.0}/statementproof/__init__.py +1 -1
- {statementproof-0.1.1 → statementproof-0.2.0}/statementproof/cli.py +80 -1
- statementproof-0.2.0/statementproof/record.py +275 -0
- {statementproof-0.1.1 → statementproof-0.2.0/statementproof.egg-info}/PKG-INFO +58 -3
- {statementproof-0.1.1 → statementproof-0.2.0}/statementproof.egg-info/SOURCES.txt +1 -0
- {statementproof-0.1.1 → statementproof-0.2.0}/LICENSE +0 -0
- {statementproof-0.1.1 → statementproof-0.2.0}/README.md +0 -0
- {statementproof-0.1.1 → statementproof-0.2.0}/setup.cfg +0 -0
- {statementproof-0.1.1 → statementproof-0.2.0}/statementproof/__main__.py +0 -0
- {statementproof-0.1.1 → statementproof-0.2.0}/statementproof/extract.py +0 -0
- {statementproof-0.1.1 → statementproof-0.2.0}/statementproof/validate.py +0 -0
- {statementproof-0.1.1 → statementproof-0.2.0}/statementproof.egg-info/dependency_links.txt +0 -0
- {statementproof-0.1.1 → statementproof-0.2.0}/statementproof.egg-info/entry_points.txt +0 -0
- {statementproof-0.1.1 → statementproof-0.2.0}/statementproof.egg-info/requires.txt +0 -0
- {statementproof-0.1.1 → statementproof-0.2.0}/statementproof.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: statementproof
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Extract transactions from bank statement PDFs -- and prove the extraction is right, or say it isn't.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/OrbitalKeyAi/statementproof
|
|
@@ -71,9 +71,31 @@ statementproof statement.pdf --csv transactions.csv
|
|
|
71
71
|
|
|
72
72
|
# layout report you can share — contains no financial data
|
|
73
73
|
statementproof statement.pdf --diagnostic
|
|
74
|
+
|
|
75
|
+
# a whole folder of statements, one summary
|
|
76
|
+
statementproof ./january-statements/
|
|
77
|
+
|
|
78
|
+
# write a reconciliation record you can file
|
|
79
|
+
statementproof ./january-statements/ --record january.html
|
|
74
80
|
```
|
|
75
81
|
|
|
76
|
-
Exit code is `0` when verified, `1` otherwise, so it drops into a script.
|
|
82
|
+
Exit code is `0` when verified, `1` otherwise, so it drops into a script. In batch mode a
|
|
83
|
+
file that could not be opened also exits non-zero — **unreadable is never counted as
|
|
84
|
+
passing.**
|
|
85
|
+
|
|
86
|
+
## The reconciliation record
|
|
87
|
+
|
|
88
|
+
`--record` writes a document recording *which exact file was checked, by whom, on what
|
|
89
|
+
date, and what the check found.* It opens in any browser and prints to PDF.
|
|
90
|
+
|
|
91
|
+
The load-bearing field is the **SHA-256 of the source PDF**. A record that names a file
|
|
92
|
+
proves nothing — filenames change and statements get re-exported. The hash binds the
|
|
93
|
+
record to one exact document, so if the PDF is later altered by a single byte, the record
|
|
94
|
+
visibly no longer describes the file it is attached to.
|
|
95
|
+
|
|
96
|
+
In batch mode the record also names every file that *could not* be read, in the printed
|
|
97
|
+
copy as well as the JSON. A record that quietly omits the statements it choked on would
|
|
98
|
+
assert a clean review of a folder it never finished reading.
|
|
77
99
|
|
|
78
100
|
## Three verdicts, and it will not bluff
|
|
79
101
|
|
|
@@ -154,14 +176,47 @@ python statementproof/tests/make_real_derived.py # build real-bank-derived la
|
|
|
154
176
|
python statementproof/tests/score.py # score extraction vs ground truth
|
|
155
177
|
python statementproof/tests/test_failure_modes.py # validator behaviour
|
|
156
178
|
python statementproof/tests/test_privacy.py # privacy promises
|
|
179
|
+
python statementproof/tests/test_record.py # reconciliation record guarantees
|
|
157
180
|
```
|
|
158
181
|
|
|
159
182
|
Current: **7/7 layouts extracted exactly · 9/9 failure-mode tests · 8/8 privacy checks ·
|
|
160
|
-
0 false assurances.**
|
|
183
|
+
13/13 record guarantees · 0 false assurances.**
|
|
161
184
|
|
|
162
185
|
"False assurance" — reporting `VERIFIED` on a bad extraction — is the number that
|
|
163
186
|
matters. A validator that green-lights an error is worse than no validator.
|
|
164
187
|
|
|
188
|
+
## Commercial licence
|
|
189
|
+
|
|
190
|
+
The tool is free and MIT, and every figure it produces is real whether you pay or not.
|
|
191
|
+
|
|
192
|
+
What a licence changes is one thing: **it puts your firm's name on the record.** Without
|
|
193
|
+
it the record is stamped `UNLICENSED` — the numbers are still correct, but no one is named
|
|
194
|
+
as having performed the review, and an unsigned document is not evidence that anyone did.
|
|
195
|
+
|
|
196
|
+
**[Buy a licence — $39, one-time, perpetual](https://buy.stripe.com/4gM8wRe6NdON50s2kjgA802)**
|
|
197
|
+
· unlimited statements · unlimited machines · no subscription
|
|
198
|
+
|
|
199
|
+
Then set it once per machine:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
setx STATEMENTPROOF_LICENCE "Your Firm Name" # Windows
|
|
203
|
+
export STATEMENTPROOF_LICENCE="Your Firm Name" # macOS / Linux
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
There is no key server, no activation call, and no check-in — the package still contains
|
|
207
|
+
no network code of any kind. The licence is an attestation, not a lock. Since the source
|
|
208
|
+
is public, any check could be deleted in seconds; pretending otherwise would be exactly
|
|
209
|
+
the kind of bluffing this tool exists to refuse. Editing the stamp out would produce a
|
|
210
|
+
record naming a licence holder who did not buy one, which is forging your own audit
|
|
211
|
+
evidence, and no payment system prevents that either.
|
|
212
|
+
|
|
213
|
+
## Related
|
|
214
|
+
|
|
215
|
+
**[sqliteproof](https://github.com/OrbitalKeyAi/sqliteproof)** — the same idea for SQLite
|
|
216
|
+
databases. `PRAGMA integrity_check` tells you a page number; sqliteproof tells you which
|
|
217
|
+
tables survived, how many rows were lost, and what is safe to export. Free, MIT, no
|
|
218
|
+
dependencies.
|
|
219
|
+
|
|
165
220
|
## License
|
|
166
221
|
|
|
167
222
|
MIT.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "statementproof"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Extract transactions from bank statement PDFs -- and prove the extraction is right, or say it isn't."
|
|
9
9
|
readme = "statementproof/README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -46,9 +46,31 @@ statementproof statement.pdf --csv transactions.csv
|
|
|
46
46
|
|
|
47
47
|
# layout report you can share — contains no financial data
|
|
48
48
|
statementproof statement.pdf --diagnostic
|
|
49
|
+
|
|
50
|
+
# a whole folder of statements, one summary
|
|
51
|
+
statementproof ./january-statements/
|
|
52
|
+
|
|
53
|
+
# write a reconciliation record you can file
|
|
54
|
+
statementproof ./january-statements/ --record january.html
|
|
49
55
|
```
|
|
50
56
|
|
|
51
|
-
Exit code is `0` when verified, `1` otherwise, so it drops into a script.
|
|
57
|
+
Exit code is `0` when verified, `1` otherwise, so it drops into a script. In batch mode a
|
|
58
|
+
file that could not be opened also exits non-zero — **unreadable is never counted as
|
|
59
|
+
passing.**
|
|
60
|
+
|
|
61
|
+
## The reconciliation record
|
|
62
|
+
|
|
63
|
+
`--record` writes a document recording *which exact file was checked, by whom, on what
|
|
64
|
+
date, and what the check found.* It opens in any browser and prints to PDF.
|
|
65
|
+
|
|
66
|
+
The load-bearing field is the **SHA-256 of the source PDF**. A record that names a file
|
|
67
|
+
proves nothing — filenames change and statements get re-exported. The hash binds the
|
|
68
|
+
record to one exact document, so if the PDF is later altered by a single byte, the record
|
|
69
|
+
visibly no longer describes the file it is attached to.
|
|
70
|
+
|
|
71
|
+
In batch mode the record also names every file that *could not* be read, in the printed
|
|
72
|
+
copy as well as the JSON. A record that quietly omits the statements it choked on would
|
|
73
|
+
assert a clean review of a folder it never finished reading.
|
|
52
74
|
|
|
53
75
|
## Three verdicts, and it will not bluff
|
|
54
76
|
|
|
@@ -129,14 +151,47 @@ python statementproof/tests/make_real_derived.py # build real-bank-derived la
|
|
|
129
151
|
python statementproof/tests/score.py # score extraction vs ground truth
|
|
130
152
|
python statementproof/tests/test_failure_modes.py # validator behaviour
|
|
131
153
|
python statementproof/tests/test_privacy.py # privacy promises
|
|
154
|
+
python statementproof/tests/test_record.py # reconciliation record guarantees
|
|
132
155
|
```
|
|
133
156
|
|
|
134
157
|
Current: **7/7 layouts extracted exactly · 9/9 failure-mode tests · 8/8 privacy checks ·
|
|
135
|
-
0 false assurances.**
|
|
158
|
+
13/13 record guarantees · 0 false assurances.**
|
|
136
159
|
|
|
137
160
|
"False assurance" — reporting `VERIFIED` on a bad extraction — is the number that
|
|
138
161
|
matters. A validator that green-lights an error is worse than no validator.
|
|
139
162
|
|
|
163
|
+
## Commercial licence
|
|
164
|
+
|
|
165
|
+
The tool is free and MIT, and every figure it produces is real whether you pay or not.
|
|
166
|
+
|
|
167
|
+
What a licence changes is one thing: **it puts your firm's name on the record.** Without
|
|
168
|
+
it the record is stamped `UNLICENSED` — the numbers are still correct, but no one is named
|
|
169
|
+
as having performed the review, and an unsigned document is not evidence that anyone did.
|
|
170
|
+
|
|
171
|
+
**[Buy a licence — $39, one-time, perpetual](https://buy.stripe.com/4gM8wRe6NdON50s2kjgA802)**
|
|
172
|
+
· unlimited statements · unlimited machines · no subscription
|
|
173
|
+
|
|
174
|
+
Then set it once per machine:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
setx STATEMENTPROOF_LICENCE "Your Firm Name" # Windows
|
|
178
|
+
export STATEMENTPROOF_LICENCE="Your Firm Name" # macOS / Linux
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
There is no key server, no activation call, and no check-in — the package still contains
|
|
182
|
+
no network code of any kind. The licence is an attestation, not a lock. Since the source
|
|
183
|
+
is public, any check could be deleted in seconds; pretending otherwise would be exactly
|
|
184
|
+
the kind of bluffing this tool exists to refuse. Editing the stamp out would produce a
|
|
185
|
+
record naming a licence holder who did not buy one, which is forging your own audit
|
|
186
|
+
evidence, and no payment system prevents that either.
|
|
187
|
+
|
|
188
|
+
## Related
|
|
189
|
+
|
|
190
|
+
**[sqliteproof](https://github.com/OrbitalKeyAi/sqliteproof)** — the same idea for SQLite
|
|
191
|
+
databases. `PRAGMA integrity_check` tells you a page number; sqliteproof tells you which
|
|
192
|
+
tables survived, how many rows were lost, and what is safe to export. Free, MIT, no
|
|
193
|
+
dependencies.
|
|
194
|
+
|
|
140
195
|
## License
|
|
141
196
|
|
|
142
197
|
MIT.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""statementproof - verifiable bank statement extraction."""
|
|
2
|
-
__version__ = "0.
|
|
2
|
+
__version__ = "0.2.0"
|
|
@@ -99,17 +99,85 @@ def build_diagnostic(pdf_path, txns, diag, opening, closing, res):
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
|
|
102
|
+
def run_batch(args):
|
|
103
|
+
"""Validate every PDF in a folder, then emit one record covering all of them.
|
|
104
|
+
|
|
105
|
+
A firm processes many clients' statements a month, and the free single-file path makes
|
|
106
|
+
that a hundred terminal invocations nobody keeps a trace of. What matters here is that
|
|
107
|
+
**a file that fails to open is reported, not skipped silently** -- a batch summary that
|
|
108
|
+
quietly omits the statements it choked on is precisely the false assurance this whole
|
|
109
|
+
tool exists to refuse.
|
|
110
|
+
"""
|
|
111
|
+
from . import record as _rec
|
|
112
|
+
|
|
113
|
+
pdfs = sorted(os.path.join(args.pdf, f) for f in os.listdir(args.pdf)
|
|
114
|
+
if f.lower().endswith(".pdf"))
|
|
115
|
+
if not pdfs:
|
|
116
|
+
print("error: no PDFs in %s" % args.pdf, file=sys.stderr)
|
|
117
|
+
return 2
|
|
118
|
+
|
|
119
|
+
if not args.quiet:
|
|
120
|
+
print(BANNER)
|
|
121
|
+
print(" %d statement(s) in %s\n" % (len(pdfs), args.pdf))
|
|
122
|
+
|
|
123
|
+
records, failed_to_read = [], []
|
|
124
|
+
for p in pdfs:
|
|
125
|
+
name = os.path.basename(p)
|
|
126
|
+
try:
|
|
127
|
+
opening, closing = find_balances(p)
|
|
128
|
+
txns, diag = parse(p, balance_hints=(opening, closing))
|
|
129
|
+
res = validate(txns, opening, closing)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
failed_to_read.append((name, "%s: %s" % (type(e).__name__, e)))
|
|
132
|
+
if not args.quiet:
|
|
133
|
+
print(" %-38s COULD NOT READ (%s)" % (name[:38], type(e).__name__))
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
records.append(_rec.build(p, txns, res, opening, closing, diag))
|
|
137
|
+
if not args.quiet:
|
|
138
|
+
print(" %-38s %-13s %3d txns" % (name[:38], res.verdict, len(txns)))
|
|
139
|
+
|
|
140
|
+
batch = _rec.build_batch(records)
|
|
141
|
+
if failed_to_read:
|
|
142
|
+
batch["summary"]["unreadable"] = [{"file": n, "error": e} for n, e in failed_to_read]
|
|
143
|
+
|
|
144
|
+
if not args.quiet:
|
|
145
|
+
print()
|
|
146
|
+
for verdict, n in batch["summary"]["by_verdict"].items():
|
|
147
|
+
print(" %-13s %d" % (verdict, n))
|
|
148
|
+
if failed_to_read:
|
|
149
|
+
print(" %-13s %d <-- not validated; they are not counted as passing"
|
|
150
|
+
% ("UNREADABLE", len(failed_to_read)))
|
|
151
|
+
|
|
152
|
+
if args.record:
|
|
153
|
+
_rec.write(batch, args.record)
|
|
154
|
+
if not args.quiet:
|
|
155
|
+
print("\n wrote batch reconciliation record to %s" % args.record)
|
|
156
|
+
if batch["licence"]["status"] != "licensed":
|
|
157
|
+
print(" (UNLICENSED -- set %s to name the reviewer.)" % _rec.LICENCE_ENV)
|
|
158
|
+
|
|
159
|
+
# Non-zero if anything failed or could not be read, so it drops into a firm's scripts.
|
|
160
|
+
bad = batch["summary"]["by_verdict"].get("FAILED", 0) + len(failed_to_read)
|
|
161
|
+
return 1 if bad else 0
|
|
162
|
+
|
|
163
|
+
|
|
102
164
|
def main(argv=None):
|
|
103
165
|
ap = argparse.ArgumentParser(
|
|
104
166
|
prog="statementproof",
|
|
105
167
|
description="Validate bank statement PDF extraction. Runs locally; uploads nothing.")
|
|
106
|
-
ap.add_argument("pdf", help="path to a bank statement PDF")
|
|
168
|
+
ap.add_argument("pdf", help="path to a bank statement PDF, or a folder of them")
|
|
107
169
|
ap.add_argument("--csv", metavar="OUT", help="write extracted transactions to CSV")
|
|
170
|
+
ap.add_argument("--record", metavar="OUT",
|
|
171
|
+
help="write a reconciliation record (.html or .json) -- the artifact "
|
|
172
|
+
"recording which file was checked, by whom, and what was found")
|
|
108
173
|
ap.add_argument("--diagnostic", action="store_true",
|
|
109
174
|
help="print a shareable layout report containing no financial data")
|
|
110
175
|
ap.add_argument("--quiet", action="store_true", help="verdict line only")
|
|
111
176
|
args = ap.parse_args(argv)
|
|
112
177
|
|
|
178
|
+
if os.path.isdir(args.pdf):
|
|
179
|
+
return run_batch(args)
|
|
180
|
+
|
|
113
181
|
if not os.path.isfile(args.pdf):
|
|
114
182
|
print("error: no such file: %s" % args.pdf, file=sys.stderr)
|
|
115
183
|
return 2
|
|
@@ -173,6 +241,17 @@ def main(argv=None):
|
|
|
173
241
|
if not args.quiet:
|
|
174
242
|
print("\n wrote %d rows to %s" % (len(txns), args.csv))
|
|
175
243
|
|
|
244
|
+
if args.record:
|
|
245
|
+
from . import record as _rec
|
|
246
|
+
r = _rec.build(args.pdf, txns, res, opening, closing, diag)
|
|
247
|
+
_rec.write(r, args.record)
|
|
248
|
+
if not args.quiet:
|
|
249
|
+
print("\n wrote reconciliation record to %s" % args.record)
|
|
250
|
+
if r["licence"]["status"] != "licensed":
|
|
251
|
+
print(" (UNLICENSED -- the figures are real, but no licence holder is named,")
|
|
252
|
+
print(" so it does not record who performed the review. Set %s.)"
|
|
253
|
+
% _rec.LICENCE_ENV)
|
|
254
|
+
|
|
176
255
|
if args.diagnostic:
|
|
177
256
|
d = build_diagnostic(args.pdf, txns, diag, opening, closing, res)
|
|
178
257
|
print("\n" + "=" * 68)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""The reconciliation record — the artifact a firm files as evidence of review.
|
|
2
|
+
|
|
3
|
+
Sentius, reviewing the pricing plan, identified which half of the paid tier is actually
|
|
4
|
+
the product:
|
|
5
|
+
|
|
6
|
+
"That last part is the actual product: not the batch processing, the artifact a
|
|
7
|
+
bookkeeper can staple to a file as proof they looked."
|
|
8
|
+
|
|
9
|
+
That is what this module produces. Batch mode is convenience; the record is the thing worth
|
|
10
|
+
money, because it answers a question the free tool cannot: *who checked this, against which
|
|
11
|
+
exact file, on what date, and what did the check find.*
|
|
12
|
+
|
|
13
|
+
## Why the source hash is the load-bearing field
|
|
14
|
+
|
|
15
|
+
A record that names a file proves nothing — filenames are trivially changed and a statement
|
|
16
|
+
can be re-exported. The SHA-256 of the source bytes binds the record to one exact document.
|
|
17
|
+
If the PDF is later altered by a single byte, the hash no longer matches and the record
|
|
18
|
+
visibly does not describe the file it is attached to. That is the difference between a
|
|
19
|
+
report and a record.
|
|
20
|
+
|
|
21
|
+
## Licensing, stated plainly rather than enforced by theatre
|
|
22
|
+
|
|
23
|
+
This package is MIT and its source is public, so any license check in it could be deleted
|
|
24
|
+
by the person it applies to in about four seconds. Building a cracked-in-a-minute key
|
|
25
|
+
validator would be pretending otherwise, and dishonest engineering in a tool whose entire
|
|
26
|
+
pitch is that it refuses to bluff.
|
|
27
|
+
|
|
28
|
+
So the licence is **not enforced, it is attested.** An unlicensed run produces a complete,
|
|
29
|
+
fully functional record stamped UNLICENSED. Every number in it is real. What it lacks is a
|
|
30
|
+
named licence holder — and a record with no one's name on it is not evidence that anyone
|
|
31
|
+
reviewed anything, which is the only reason a firm wants the artifact in the first place.
|
|
32
|
+
|
|
33
|
+
The mechanism and the value are therefore the same thing, which is why it needs no
|
|
34
|
+
enforcement: removing the stamp by editing the source would produce a record asserting a
|
|
35
|
+
licence holder who did not buy a licence, and that is the user forging their own audit
|
|
36
|
+
evidence. Nothing in a payment system prevents that either.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import datetime as dt
|
|
42
|
+
import hashlib
|
|
43
|
+
import html
|
|
44
|
+
import json
|
|
45
|
+
import os
|
|
46
|
+
|
|
47
|
+
from .validate import fmt
|
|
48
|
+
|
|
49
|
+
LICENCE_ENV = "STATEMENTPROOF_LICENCE"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def source_hash(path: str, chunk: int = 1 << 20) -> str:
|
|
53
|
+
h = hashlib.sha256()
|
|
54
|
+
with open(path, "rb") as f:
|
|
55
|
+
while True:
|
|
56
|
+
b = f.read(chunk)
|
|
57
|
+
if not b:
|
|
58
|
+
break
|
|
59
|
+
h.update(b)
|
|
60
|
+
return h.hexdigest()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def licence_holder() -> str | None:
|
|
64
|
+
"""The name recorded on the artifact, or None for an unlicensed run.
|
|
65
|
+
|
|
66
|
+
Read from the environment rather than a config file so it survives in CI and cron
|
|
67
|
+
without a file to lose, and so a firm can set it once per machine.
|
|
68
|
+
"""
|
|
69
|
+
v = (os.environ.get(LICENCE_ENV) or "").strip()
|
|
70
|
+
return v or None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def build(pdf_path: str, txns, res, opening, closing, diag=None, holder=None) -> dict:
|
|
74
|
+
"""One record for one statement. Pure data; rendering is separate."""
|
|
75
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
76
|
+
holder = holder if holder is not None else licence_holder()
|
|
77
|
+
skipped = list((diag or {}).get("skipped") or [])
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"record_type": "statementproof.reconciliation_record",
|
|
81
|
+
"record_version": 1,
|
|
82
|
+
"generated_utc": now.isoformat(timespec="seconds"),
|
|
83
|
+
"tool": {
|
|
84
|
+
"name": "statementproof",
|
|
85
|
+
"version": __import__("statementproof").__version__,
|
|
86
|
+
},
|
|
87
|
+
"licence": {
|
|
88
|
+
"holder": holder,
|
|
89
|
+
"status": "licensed" if holder else "UNLICENSED",
|
|
90
|
+
},
|
|
91
|
+
"source": {
|
|
92
|
+
"filename": os.path.basename(pdf_path),
|
|
93
|
+
"bytes": os.path.getsize(pdf_path),
|
|
94
|
+
# Binds this record to one exact document. See the module docstring.
|
|
95
|
+
"sha256": source_hash(pdf_path),
|
|
96
|
+
},
|
|
97
|
+
"statement": {
|
|
98
|
+
"opening_balance": fmt(opening) if opening is not None else None,
|
|
99
|
+
"closing_balance": fmt(closing) if closing is not None else None,
|
|
100
|
+
"transactions_extracted": len(txns),
|
|
101
|
+
"rows_skipped": len(skipped),
|
|
102
|
+
},
|
|
103
|
+
"verification": {
|
|
104
|
+
"verdict": res.verdict,
|
|
105
|
+
"checks_run": list(res.checks_run),
|
|
106
|
+
"problems": [
|
|
107
|
+
{"kind": p.kind, "row": getattr(p, "row", None), "detail": str(p)[:200]}
|
|
108
|
+
for p in res.problems
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
# Said in the record itself, not only in the docs, because a record that
|
|
112
|
+
# overstates what it proves is worse than no record.
|
|
113
|
+
"scope_of_assurance":
|
|
114
|
+
"A VERIFIED verdict means the extracted transactions reproduce this "
|
|
115
|
+
"statement's own opening and closing balances. It does not certify that "
|
|
116
|
+
"descriptions or dates are correct, and it does not detect errors that "
|
|
117
|
+
"leave the totals unchanged.",
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def build_batch(records: list) -> dict:
|
|
122
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
123
|
+
counts = {}
|
|
124
|
+
for r in records:
|
|
125
|
+
v = r["verification"]["verdict"]
|
|
126
|
+
counts[v] = counts.get(v, 0) + 1
|
|
127
|
+
holder = records[0]["licence"]["holder"] if records else licence_holder()
|
|
128
|
+
return {
|
|
129
|
+
"record_type": "statementproof.batch_reconciliation_record",
|
|
130
|
+
"record_version": 1,
|
|
131
|
+
"generated_utc": now.isoformat(timespec="seconds"),
|
|
132
|
+
"tool": {"name": "statementproof",
|
|
133
|
+
"version": __import__("statementproof").__version__},
|
|
134
|
+
"licence": {"holder": holder, "status": "licensed" if holder else "UNLICENSED"},
|
|
135
|
+
"summary": {
|
|
136
|
+
"statements": len(records),
|
|
137
|
+
"by_verdict": dict(sorted(counts.items())),
|
|
138
|
+
"transactions_total": sum(r["statement"]["transactions_extracted"] for r in records),
|
|
139
|
+
},
|
|
140
|
+
"statements": records,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# --------------------------------------------------------------------------- rendering
|
|
145
|
+
|
|
146
|
+
_CSS = """
|
|
147
|
+
:root{--ink:#1b1b1f;--mute:#5c5f66;--rule:#d8d6d1;--bg:#fff;
|
|
148
|
+
--ok:#1c6b3c;--bad:#9c2b21;--warn:#8a6114;--paper:#faf9f7}
|
|
149
|
+
*{box-sizing:border-box}
|
|
150
|
+
body{margin:0;background:var(--paper);color:var(--ink);
|
|
151
|
+
font:14px/1.55 "Iowan Old Style","Palatino Linotype",Georgia,serif}
|
|
152
|
+
.sheet{max-width:52rem;margin:2rem auto;background:var(--bg);padding:2.6rem 3rem;
|
|
153
|
+
border:1px solid var(--rule)}
|
|
154
|
+
h1{font-size:1.35rem;margin:0 0 .15rem;letter-spacing:-.01em}
|
|
155
|
+
.sub{color:var(--mute);font-size:.82rem;margin:0 0 1.6rem}
|
|
156
|
+
h2{font-size:.72rem;text-transform:uppercase;letter-spacing:.12em;color:var(--mute);
|
|
157
|
+
margin:1.9rem 0 .6rem;padding-bottom:.3rem;border-bottom:1px solid var(--rule)}
|
|
158
|
+
dl{display:grid;grid-template-columns:11rem 1fr;gap:.4rem 1.2rem;margin:0}
|
|
159
|
+
dt{color:var(--mute);font-size:.82rem}
|
|
160
|
+
dd{margin:0;font-variant-numeric:tabular-nums}
|
|
161
|
+
.hash{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-size:.74rem;
|
|
162
|
+
word-break:break-all;color:var(--mute)}
|
|
163
|
+
.verdict{display:inline-block;padding:.18rem .6rem;border:1px solid currentColor;
|
|
164
|
+
font-size:.76rem;letter-spacing:.09em;text-transform:uppercase}
|
|
165
|
+
.v-VERIFIED{color:var(--ok)} .v-FAILED{color:var(--bad)} .v-UNVERIFIABLE{color:var(--warn)}
|
|
166
|
+
table{width:100%;border-collapse:collapse;margin-top:.4rem;font-size:.86rem}
|
|
167
|
+
th{text-align:left;font-weight:600;font-size:.72rem;text-transform:uppercase;
|
|
168
|
+
letter-spacing:.08em;color:var(--mute);border-bottom:1px solid var(--rule);padding:.35rem .5rem}
|
|
169
|
+
td{padding:.35rem .5rem;border-bottom:1px solid #efedea;font-variant-numeric:tabular-nums}
|
|
170
|
+
.scope{margin-top:1.8rem;padding:.85rem 1rem;background:#f6f5f2;border-left:2px solid var(--rule);
|
|
171
|
+
font-size:.82rem;color:var(--mute)}
|
|
172
|
+
.unlicensed{margin:0 0 1.4rem;padding:.7rem 1rem;border:1px solid var(--bad);color:var(--bad);
|
|
173
|
+
font-size:.83rem}
|
|
174
|
+
@media print{body{background:#fff}.sheet{border:0;margin:0;padding:0;max-width:none}}
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _e(v) -> str:
|
|
179
|
+
return html.escape("" if v is None else str(v))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _one(rec: dict, heading: bool = True) -> str:
|
|
183
|
+
v = rec["verification"]["verdict"]
|
|
184
|
+
s, src, st = rec["licence"], rec["source"], rec["statement"]
|
|
185
|
+
L = []
|
|
186
|
+
if heading:
|
|
187
|
+
L.append("<h2>%s</h2>" % _e(src["filename"]))
|
|
188
|
+
L.append("<dl>")
|
|
189
|
+
L.append("<dt>Verdict</dt><dd><span class='verdict v-%s'>%s</span></dd>" % (_e(v), _e(v)))
|
|
190
|
+
L.append("<dt>Opening balance</dt><dd>%s</dd>" % _e(st["opening_balance"] or "not found"))
|
|
191
|
+
L.append("<dt>Closing balance</dt><dd>%s</dd>" % _e(st["closing_balance"] or "not found"))
|
|
192
|
+
L.append("<dt>Transactions</dt><dd>%d extracted, %d row(s) skipped</dd>"
|
|
193
|
+
% (st["transactions_extracted"], st["rows_skipped"]))
|
|
194
|
+
L.append("<dt>Checks run</dt><dd>%s</dd>"
|
|
195
|
+
% (_e(", ".join(rec["verification"]["checks_run"]) or "none")))
|
|
196
|
+
L.append("<dt>Source SHA-256</dt><dd class='hash'>%s</dd>" % _e(src["sha256"]))
|
|
197
|
+
L.append("</dl>")
|
|
198
|
+
|
|
199
|
+
probs = rec["verification"]["problems"]
|
|
200
|
+
if probs:
|
|
201
|
+
L.append("<table><tr><th>Row</th><th>Finding</th></tr>")
|
|
202
|
+
for p in probs[:40]:
|
|
203
|
+
L.append("<tr><td>%s</td><td>%s</td></tr>"
|
|
204
|
+
% (_e(p["row"] if p["row"] is not None else "-"), _e(p["detail"])))
|
|
205
|
+
L.append("</table>")
|
|
206
|
+
if len(probs) > 40:
|
|
207
|
+
L.append("<p class='sub'>… and %d more.</p>" % (len(probs) - 40))
|
|
208
|
+
return "\n".join(L)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def to_html(rec: dict) -> str:
|
|
212
|
+
"""A self-contained record. Prints to PDF from any browser; no dependency added."""
|
|
213
|
+
batch = rec["record_type"].endswith("batch_reconciliation_record")
|
|
214
|
+
lic = rec["licence"]
|
|
215
|
+
title = "Reconciliation Record"
|
|
216
|
+
|
|
217
|
+
L = ["<!doctype html><html lang='en'><head><meta charset='utf-8'>",
|
|
218
|
+
"<meta name='viewport' content='width=device-width,initial-scale=1'>",
|
|
219
|
+
"<title>%s — statementproof</title><style>%s</style></head><body><div class='sheet'>"
|
|
220
|
+
% (title, _CSS)]
|
|
221
|
+
L.append("<h1>%s</h1>" % title)
|
|
222
|
+
L.append("<p class='sub'>Generated %s UTC by statementproof %s</p>"
|
|
223
|
+
% (_e(rec["generated_utc"]), _e(rec["tool"]["version"])))
|
|
224
|
+
|
|
225
|
+
if lic["status"] != "licensed":
|
|
226
|
+
L.append("<p class='unlicensed'><strong>UNLICENSED.</strong> Every figure below is "
|
|
227
|
+
"real, but no licence holder is named — so this document does not record "
|
|
228
|
+
"who performed the review, and should not be filed as evidence that anyone "
|
|
229
|
+
"did.</p>")
|
|
230
|
+
else:
|
|
231
|
+
L.append("<dl><dt>Licence holder</dt><dd>%s</dd></dl>" % _e(lic["holder"]))
|
|
232
|
+
|
|
233
|
+
if batch:
|
|
234
|
+
s = rec["summary"]
|
|
235
|
+
L.append("<h2>Summary</h2><dl>")
|
|
236
|
+
L.append("<dt>Statements</dt><dd>%d</dd>" % s["statements"])
|
|
237
|
+
for k, n in s["by_verdict"].items():
|
|
238
|
+
L.append("<dt>%s</dt><dd>%d</dd>" % (_e(k), n))
|
|
239
|
+
L.append("<dt>Transactions</dt><dd>%d</dd>" % s["transactions_total"])
|
|
240
|
+
L.append("</dl>")
|
|
241
|
+
|
|
242
|
+
# Files that could not be opened are named in the printable record, not only in
|
|
243
|
+
# the JSON. A record that silently omits the statements it choked on is the exact
|
|
244
|
+
# false assurance this tool exists to refuse -- and the printed page is the copy
|
|
245
|
+
# that actually gets filed.
|
|
246
|
+
unread = s.get("unreadable") or []
|
|
247
|
+
if unread:
|
|
248
|
+
L.append("<h2>Not validated — %d file(s) could not be read</h2>" % len(unread))
|
|
249
|
+
L.append("<p class='sub'>These are not covered by any verdict above. They were "
|
|
250
|
+
"not checked, which is not the same as having passed.</p>")
|
|
251
|
+
L.append("<table><tr><th>File</th><th>Reason</th></tr>")
|
|
252
|
+
for u in unread:
|
|
253
|
+
L.append("<tr><td>%s</td><td>%s</td></tr>" % (_e(u["file"]), _e(u["error"])))
|
|
254
|
+
L.append("</table>")
|
|
255
|
+
|
|
256
|
+
for r in rec["statements"]:
|
|
257
|
+
L.append(_one(r))
|
|
258
|
+
scope = rec["statements"][0]["scope_of_assurance"] if rec["statements"] else ""
|
|
259
|
+
else:
|
|
260
|
+
L.append(_one(rec, heading=False))
|
|
261
|
+
scope = rec["scope_of_assurance"]
|
|
262
|
+
|
|
263
|
+
if scope:
|
|
264
|
+
L.append("<p class='scope'><strong>Scope of assurance.</strong> %s</p>" % _e(scope))
|
|
265
|
+
L.append("</div></body></html>")
|
|
266
|
+
return "\n".join(L)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def write(rec: dict, path: str) -> str:
|
|
270
|
+
"""Write .json or .html, chosen by the extension the caller asked for."""
|
|
271
|
+
ext = os.path.splitext(path)[1].lower()
|
|
272
|
+
data = to_html(rec) if ext in (".html", ".htm") else json.dumps(rec, indent=2)
|
|
273
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
274
|
+
f.write(data)
|
|
275
|
+
return path
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: statementproof
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Extract transactions from bank statement PDFs -- and prove the extraction is right, or say it isn't.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/OrbitalKeyAi/statementproof
|
|
@@ -71,9 +71,31 @@ statementproof statement.pdf --csv transactions.csv
|
|
|
71
71
|
|
|
72
72
|
# layout report you can share — contains no financial data
|
|
73
73
|
statementproof statement.pdf --diagnostic
|
|
74
|
+
|
|
75
|
+
# a whole folder of statements, one summary
|
|
76
|
+
statementproof ./january-statements/
|
|
77
|
+
|
|
78
|
+
# write a reconciliation record you can file
|
|
79
|
+
statementproof ./january-statements/ --record january.html
|
|
74
80
|
```
|
|
75
81
|
|
|
76
|
-
Exit code is `0` when verified, `1` otherwise, so it drops into a script.
|
|
82
|
+
Exit code is `0` when verified, `1` otherwise, so it drops into a script. In batch mode a
|
|
83
|
+
file that could not be opened also exits non-zero — **unreadable is never counted as
|
|
84
|
+
passing.**
|
|
85
|
+
|
|
86
|
+
## The reconciliation record
|
|
87
|
+
|
|
88
|
+
`--record` writes a document recording *which exact file was checked, by whom, on what
|
|
89
|
+
date, and what the check found.* It opens in any browser and prints to PDF.
|
|
90
|
+
|
|
91
|
+
The load-bearing field is the **SHA-256 of the source PDF**. A record that names a file
|
|
92
|
+
proves nothing — filenames change and statements get re-exported. The hash binds the
|
|
93
|
+
record to one exact document, so if the PDF is later altered by a single byte, the record
|
|
94
|
+
visibly no longer describes the file it is attached to.
|
|
95
|
+
|
|
96
|
+
In batch mode the record also names every file that *could not* be read, in the printed
|
|
97
|
+
copy as well as the JSON. A record that quietly omits the statements it choked on would
|
|
98
|
+
assert a clean review of a folder it never finished reading.
|
|
77
99
|
|
|
78
100
|
## Three verdicts, and it will not bluff
|
|
79
101
|
|
|
@@ -154,14 +176,47 @@ python statementproof/tests/make_real_derived.py # build real-bank-derived la
|
|
|
154
176
|
python statementproof/tests/score.py # score extraction vs ground truth
|
|
155
177
|
python statementproof/tests/test_failure_modes.py # validator behaviour
|
|
156
178
|
python statementproof/tests/test_privacy.py # privacy promises
|
|
179
|
+
python statementproof/tests/test_record.py # reconciliation record guarantees
|
|
157
180
|
```
|
|
158
181
|
|
|
159
182
|
Current: **7/7 layouts extracted exactly · 9/9 failure-mode tests · 8/8 privacy checks ·
|
|
160
|
-
0 false assurances.**
|
|
183
|
+
13/13 record guarantees · 0 false assurances.**
|
|
161
184
|
|
|
162
185
|
"False assurance" — reporting `VERIFIED` on a bad extraction — is the number that
|
|
163
186
|
matters. A validator that green-lights an error is worse than no validator.
|
|
164
187
|
|
|
188
|
+
## Commercial licence
|
|
189
|
+
|
|
190
|
+
The tool is free and MIT, and every figure it produces is real whether you pay or not.
|
|
191
|
+
|
|
192
|
+
What a licence changes is one thing: **it puts your firm's name on the record.** Without
|
|
193
|
+
it the record is stamped `UNLICENSED` — the numbers are still correct, but no one is named
|
|
194
|
+
as having performed the review, and an unsigned document is not evidence that anyone did.
|
|
195
|
+
|
|
196
|
+
**[Buy a licence — $39, one-time, perpetual](https://buy.stripe.com/4gM8wRe6NdON50s2kjgA802)**
|
|
197
|
+
· unlimited statements · unlimited machines · no subscription
|
|
198
|
+
|
|
199
|
+
Then set it once per machine:
|
|
200
|
+
|
|
201
|
+
```bash
|
|
202
|
+
setx STATEMENTPROOF_LICENCE "Your Firm Name" # Windows
|
|
203
|
+
export STATEMENTPROOF_LICENCE="Your Firm Name" # macOS / Linux
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
There is no key server, no activation call, and no check-in — the package still contains
|
|
207
|
+
no network code of any kind. The licence is an attestation, not a lock. Since the source
|
|
208
|
+
is public, any check could be deleted in seconds; pretending otherwise would be exactly
|
|
209
|
+
the kind of bluffing this tool exists to refuse. Editing the stamp out would produce a
|
|
210
|
+
record naming a licence holder who did not buy one, which is forging your own audit
|
|
211
|
+
evidence, and no payment system prevents that either.
|
|
212
|
+
|
|
213
|
+
## Related
|
|
214
|
+
|
|
215
|
+
**[sqliteproof](https://github.com/OrbitalKeyAi/sqliteproof)** — the same idea for SQLite
|
|
216
|
+
databases. `PRAGMA integrity_check` tells you a page number; sqliteproof tells you which
|
|
217
|
+
tables survived, how many rows were lost, and what is safe to export. Free, MIT, no
|
|
218
|
+
dependencies.
|
|
219
|
+
|
|
165
220
|
## License
|
|
166
221
|
|
|
167
222
|
MIT.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|