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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: statementproof
3
- Version: 0.1.1
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.1.1"
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.1.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.1.1
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.
@@ -6,6 +6,7 @@ statementproof/__init__.py
6
6
  statementproof/__main__.py
7
7
  statementproof/cli.py
8
8
  statementproof/extract.py
9
+ statementproof/record.py
9
10
  statementproof/validate.py
10
11
  statementproof.egg-info/PKG-INFO
11
12
  statementproof.egg-info/SOURCES.txt
File without changes
File without changes
File without changes