bankstatementparser 0.0.7__tar.gz → 0.0.8__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.
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/PKG-INFO +8 -5
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/README.md +4 -4
- bankstatementparser-0.0.8/bankstatementparser/api.py +185 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/enrichment/__init__.py +3 -0
- bankstatementparser-0.0.8/bankstatementparser/enrichment/account_mapper.py +150 -0
- bankstatementparser-0.0.8/bankstatementparser/export/__init__.py +25 -0
- bankstatementparser-0.0.8/bankstatementparser/export/ledger.py +171 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/__init__.py +5 -0
- bankstatementparser-0.0.8/bankstatementparser/hybrid/scanner.py +125 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/verification.py +51 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/pyproject.toml +6 -2
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/LICENSE +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/__init__.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/additional_parsers.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/bank_statement_parsers.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/base_parser.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/camt_parser.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/cli.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/enrichment/categorizer.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/exceptions.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/llm_extractor.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/ollama_direct.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/orchestrator.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/pdf_text.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/prompts.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/vision.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/input_validator.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/pain001_parser.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/parallel.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/record_types.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/transaction_deduplicator.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/transaction_models.py +0 -0
- {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/zip_security.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bankstatementparser
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.8
|
|
4
4
|
Summary: BankStatementParser is your essential tool for easy bank statement management. Designed with finance and treasury experts in mind, it offers a simple way to handle CAMT (ISO 20022) formats and more. Get quick, accurate insights from your financial data and spend less time on processing. It's the smart, hassle-free way to stay on top of your transactions.
|
|
5
5
|
License: Apache Software License
|
|
6
6
|
License-File: LICENSE
|
|
@@ -14,12 +14,14 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.12
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.13
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Provides-Extra: api
|
|
17
18
|
Provides-Extra: enrichment
|
|
18
19
|
Provides-Extra: hybrid
|
|
19
20
|
Provides-Extra: hybrid-plus
|
|
20
21
|
Provides-Extra: hybrid-vision
|
|
21
22
|
Provides-Extra: polars
|
|
22
23
|
Requires-Dist: defusedxml (>=0.7.1,<0.8.0)
|
|
24
|
+
Requires-Dist: fastapi (>=0.115.0) ; extra == "api"
|
|
23
25
|
Requires-Dist: litellm (>=1.83.0) ; extra == "hybrid" or extra == "hybrid-plus" or extra == "hybrid-vision" or extra == "enrichment"
|
|
24
26
|
Requires-Dist: lxml (>=4.9.3)
|
|
25
27
|
Requires-Dist: openpyxl (>=3.1.5,<4.0.0)
|
|
@@ -29,6 +31,7 @@ Requires-Dist: polars (>=1.32.0,<2.0.0) ; extra == "polars"
|
|
|
29
31
|
Requires-Dist: pydantic (>=2.11.0,<3.0.0)
|
|
30
32
|
Requires-Dist: pypdf (>=4.0.0) ; extra == "hybrid" or extra == "hybrid-plus" or extra == "hybrid-vision"
|
|
31
33
|
Requires-Dist: pypdfium2 (>=4.30.0) ; extra == "hybrid-vision"
|
|
34
|
+
Requires-Dist: uvicorn (>=0.34.0) ; extra == "api"
|
|
32
35
|
Project-URL: Homepage, https://bankstatementparser.com
|
|
33
36
|
Project-URL: Repository, https://github.com/sebastienrousseau/bankstatementparser
|
|
34
37
|
Description-Content-Type: text/markdown
|
|
@@ -39,7 +42,7 @@ Parse bank statements across **six structured formats** (CAMT, PAIN.001, CSV, OF
|
|
|
39
42
|
|
|
40
43
|
Built for finance teams, treasury analysts, and fintech developers who need reliable, auditable extraction across the full spectrum of bank statement formats — without sending data to external services unless they explicitly opt in.
|
|
41
44
|
|
|
42
|
-
[](https://pypi.org/project/bankstatementparser/)
|
|
43
46
|
[](https://pypi.org/project/bankstatementparser/)
|
|
44
47
|
[](https://codecov.io/github/sebastienrousseau/bankstatementparser?branch=main)
|
|
45
48
|
[](LICENSE)
|
|
@@ -92,7 +95,7 @@ Every extracted row carries an immutable `transaction_hash`, an audit-trail `sou
|
|
|
92
95
|
| **Secure ZIP** | `iter_secure_xml_entries()` rejects zip bombs, encrypted entries, and suspicious compression ratios |
|
|
93
96
|
| **In-memory parsing** | `from_string()` and `from_bytes()` parse XML without touching disk |
|
|
94
97
|
| **Export** | CSV, JSON, Excel (`.xlsx`), and optional Polars DataFrames |
|
|
95
|
-
| **100% coverage** |
|
|
98
|
+
| **100% coverage** | 718 tests, 100% branch coverage, property-based fuzzing with Hypothesis |
|
|
96
99
|
|
|
97
100
|
## Requirements
|
|
98
101
|
|
|
@@ -408,12 +411,12 @@ See [`docs/MAPPING.md`](docs/MAPPING.md) for a complete reference of ISO 20022 X
|
|
|
408
411
|
## Project Layout
|
|
409
412
|
|
|
410
413
|
```text
|
|
411
|
-
bankstatementparser/ Source code (
|
|
414
|
+
bankstatementparser/ Source code (29 modules: deterministic core + hybrid + enrichment subpackages, 100% branch coverage)
|
|
412
415
|
bankstatementparser/hybrid/ PDF pipeline: orchestrator, llm_extractor, vision, pdf_text, prompts, verification, ollama_direct
|
|
413
416
|
docs/compliance/ ISO 13485 validation, risk register, traceability matrix
|
|
414
417
|
examples/ 14 deterministic + 8 hybrid runnable example scripts
|
|
415
418
|
scripts/ SBOM generation, checksums, signature verification
|
|
416
|
-
tests/
|
|
419
|
+
tests/ 718 tests (unit, integration, property-based, security, hybrid mocks)
|
|
417
420
|
```
|
|
418
421
|
|
|
419
422
|
## Security
|
|
@@ -4,7 +4,7 @@ Parse bank statements across **six structured formats** (CAMT, PAIN.001, CSV, OF
|
|
|
4
4
|
|
|
5
5
|
Built for finance teams, treasury analysts, and fintech developers who need reliable, auditable extraction across the full spectrum of bank statement formats — without sending data to external services unless they explicitly opt in.
|
|
6
6
|
|
|
7
|
-
[](https://pypi.org/project/bankstatementparser/)
|
|
8
8
|
[](https://pypi.org/project/bankstatementparser/)
|
|
9
9
|
[](https://codecov.io/github/sebastienrousseau/bankstatementparser?branch=main)
|
|
10
10
|
[](LICENSE)
|
|
@@ -57,7 +57,7 @@ Every extracted row carries an immutable `transaction_hash`, an audit-trail `sou
|
|
|
57
57
|
| **Secure ZIP** | `iter_secure_xml_entries()` rejects zip bombs, encrypted entries, and suspicious compression ratios |
|
|
58
58
|
| **In-memory parsing** | `from_string()` and `from_bytes()` parse XML without touching disk |
|
|
59
59
|
| **Export** | CSV, JSON, Excel (`.xlsx`), and optional Polars DataFrames |
|
|
60
|
-
| **100% coverage** |
|
|
60
|
+
| **100% coverage** | 718 tests, 100% branch coverage, property-based fuzzing with Hypothesis |
|
|
61
61
|
|
|
62
62
|
## Requirements
|
|
63
63
|
|
|
@@ -373,12 +373,12 @@ See [`docs/MAPPING.md`](docs/MAPPING.md) for a complete reference of ISO 20022 X
|
|
|
373
373
|
## Project Layout
|
|
374
374
|
|
|
375
375
|
```text
|
|
376
|
-
bankstatementparser/ Source code (
|
|
376
|
+
bankstatementparser/ Source code (29 modules: deterministic core + hybrid + enrichment subpackages, 100% branch coverage)
|
|
377
377
|
bankstatementparser/hybrid/ PDF pipeline: orchestrator, llm_extractor, vision, pdf_text, prompts, verification, ollama_direct
|
|
378
378
|
docs/compliance/ ISO 13485 validation, risk register, traceability matrix
|
|
379
379
|
examples/ 14 deterministic + 8 hybrid runnable example scripts
|
|
380
380
|
scripts/ SBOM generation, checksums, signature verification
|
|
381
|
-
tests/
|
|
381
|
+
tests/ 718 tests (unit, integration, property-based, security, hybrid mocks)
|
|
382
382
|
```
|
|
383
383
|
|
|
384
384
|
## Security
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Bank Statement Parser. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Lightweight REST API for the hybrid pipeline.
|
|
17
|
+
|
|
18
|
+
Finance teams want to POST a file and get JSON back. This module
|
|
19
|
+
wraps :func:`smart_ingest` in a single-file FastAPI app that can be
|
|
20
|
+
run as a microservice:
|
|
21
|
+
|
|
22
|
+
pip install 'bankstatementparser[api]'
|
|
23
|
+
bankstatementparser-api # starts on :8000
|
|
24
|
+
bankstatementparser-api --port 9000 # custom port
|
|
25
|
+
|
|
26
|
+
Or import the app for ASGI deployment::
|
|
27
|
+
|
|
28
|
+
from bankstatementparser.api import create_app
|
|
29
|
+
|
|
30
|
+
app = create_app()
|
|
31
|
+
# uvicorn bankstatementparser.api:app --host 0.0.0.0
|
|
32
|
+
|
|
33
|
+
The API is **stateless** — each ``/ingest`` call processes the
|
|
34
|
+
uploaded file and returns the result as JSON. No database, no
|
|
35
|
+
sessions, no auth (add those in your reverse proxy or wrapper).
|
|
36
|
+
|
|
37
|
+
Gated behind the ``[api]`` install extra (fastapi + uvicorn).
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import logging
|
|
43
|
+
import tempfile
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
from typing import Any, Optional
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class APIError(RuntimeError):
|
|
51
|
+
"""Raised when the API module can't start."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def create_app(
|
|
55
|
+
*,
|
|
56
|
+
title: str = "Bank Statement Parser API",
|
|
57
|
+
version: str = "0.0.8",
|
|
58
|
+
) -> Any:
|
|
59
|
+
"""Create a FastAPI application wrapping :func:`smart_ingest`.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A FastAPI ``app`` instance. Raises :class:`APIError` if
|
|
63
|
+
FastAPI is not installed.
|
|
64
|
+
"""
|
|
65
|
+
try:
|
|
66
|
+
from fastapi import FastAPI, File, UploadFile
|
|
67
|
+
from fastapi.responses import JSONResponse
|
|
68
|
+
except ImportError as exc:
|
|
69
|
+
raise APIError(
|
|
70
|
+
"FastAPI is required for the REST API. "
|
|
71
|
+
"Install with: pip install 'bankstatementparser[api]'"
|
|
72
|
+
) from exc
|
|
73
|
+
|
|
74
|
+
from .hybrid import smart_ingest
|
|
75
|
+
|
|
76
|
+
app = FastAPI(title=title, version=version)
|
|
77
|
+
|
|
78
|
+
_file_field = File(...)
|
|
79
|
+
|
|
80
|
+
@app.post("/ingest") # type: ignore[untyped-decorator]
|
|
81
|
+
async def ingest( # pragma: no cover - async endpoint needs ASGI server
|
|
82
|
+
file: UploadFile = _file_field, # noqa: B008
|
|
83
|
+
) -> JSONResponse:
|
|
84
|
+
"""Upload a bank statement and get structured JSON back.
|
|
85
|
+
|
|
86
|
+
Accepts any format supported by :func:`smart_ingest`:
|
|
87
|
+
CAMT, PAIN.001, CSV, OFX, QFX, MT940, or PDF. The routing
|
|
88
|
+
is automatic — deterministic parsers run first, LLM
|
|
89
|
+
fallback for PDFs.
|
|
90
|
+
|
|
91
|
+
Returns the full :class:`IngestResult` as JSON including
|
|
92
|
+
transactions, verification status, and warnings.
|
|
93
|
+
"""
|
|
94
|
+
suffix = Path(file.filename or "upload").suffix or ".xml"
|
|
95
|
+
with tempfile.NamedTemporaryFile(
|
|
96
|
+
suffix=suffix, delete=False
|
|
97
|
+
) as tmp:
|
|
98
|
+
content = await file.read()
|
|
99
|
+
tmp.write(content)
|
|
100
|
+
tmp_path = tmp.name
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
result = smart_ingest(tmp_path)
|
|
104
|
+
return JSONResponse(
|
|
105
|
+
content=_result_to_dict(result),
|
|
106
|
+
status_code=200,
|
|
107
|
+
)
|
|
108
|
+
except Exception as exc:
|
|
109
|
+
logger.error("Ingest failed: %s", exc)
|
|
110
|
+
return JSONResponse(
|
|
111
|
+
content={"error": str(exc)},
|
|
112
|
+
status_code=422,
|
|
113
|
+
)
|
|
114
|
+
finally:
|
|
115
|
+
Path(tmp_path).unlink(missing_ok=True)
|
|
116
|
+
|
|
117
|
+
@app.get("/health") # type: ignore[untyped-decorator]
|
|
118
|
+
async def health() -> dict[str, str]:
|
|
119
|
+
"""Health check endpoint."""
|
|
120
|
+
return {"status": "ok", "version": version}
|
|
121
|
+
|
|
122
|
+
return app
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _result_to_dict(result: Any) -> dict[str, Any]:
|
|
126
|
+
"""Serialize an IngestResult to a JSON-safe dict."""
|
|
127
|
+
return {
|
|
128
|
+
"source_method": result.source_method,
|
|
129
|
+
"source_format": result.source_format,
|
|
130
|
+
"transaction_count": len(result.transactions),
|
|
131
|
+
"transactions": [
|
|
132
|
+
tx.model_dump(mode="json") for tx in result.transactions
|
|
133
|
+
],
|
|
134
|
+
"verification": _verification_dict(result.verification),
|
|
135
|
+
"warnings": list(result.warnings),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _verification_dict(v: Any) -> Optional[dict[str, Any]]:
|
|
140
|
+
if v is None:
|
|
141
|
+
return None
|
|
142
|
+
return {
|
|
143
|
+
"status": v.status.value,
|
|
144
|
+
"opening_balance": str(v.opening_balance)
|
|
145
|
+
if v.opening_balance is not None
|
|
146
|
+
else None,
|
|
147
|
+
"closing_balance": str(v.closing_balance)
|
|
148
|
+
if v.closing_balance is not None
|
|
149
|
+
else None,
|
|
150
|
+
"total_credits": str(v.total_credits),
|
|
151
|
+
"total_debits": str(v.total_debits),
|
|
152
|
+
"discrepancy": str(v.discrepancy)
|
|
153
|
+
if v.discrepancy is not None
|
|
154
|
+
else None,
|
|
155
|
+
"message": v.message,
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main() -> None:
|
|
160
|
+
"""Console-script entry point for ``bankstatementparser-api``."""
|
|
161
|
+
import argparse
|
|
162
|
+
|
|
163
|
+
parser = argparse.ArgumentParser(
|
|
164
|
+
description="Start the Bank Statement Parser REST API."
|
|
165
|
+
)
|
|
166
|
+
parser.add_argument(
|
|
167
|
+
"--host",
|
|
168
|
+
default="127.0.0.1",
|
|
169
|
+
help="Bind address (use 0.0.0.0 for container deployments)",
|
|
170
|
+
)
|
|
171
|
+
parser.add_argument(
|
|
172
|
+
"--port", type=int, default=8000, help="Port"
|
|
173
|
+
)
|
|
174
|
+
args = parser.parse_args()
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
import uvicorn
|
|
178
|
+
except ImportError as exc:
|
|
179
|
+
raise APIError(
|
|
180
|
+
"uvicorn is required to run the API server. "
|
|
181
|
+
"Install with: pip install 'bankstatementparser[api]'"
|
|
182
|
+
) from exc
|
|
183
|
+
|
|
184
|
+
app = create_app() # pragma: no cover - server entrypoint
|
|
185
|
+
uvicorn.run(app, host=args.host, port=args.port) # pragma: no cover
|
{bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/enrichment/__init__.py
RENAMED
|
@@ -46,6 +46,7 @@ deterministic fields.
|
|
|
46
46
|
|
|
47
47
|
from __future__ import annotations
|
|
48
48
|
|
|
49
|
+
from .account_mapper import AccountMapper, AccountRule
|
|
49
50
|
from .categorizer import (
|
|
50
51
|
DEFAULT_CATEGORY_SCHEMA,
|
|
51
52
|
Categorizer,
|
|
@@ -54,6 +55,8 @@ from .categorizer import (
|
|
|
54
55
|
)
|
|
55
56
|
|
|
56
57
|
__all__ = [
|
|
58
|
+
"AccountMapper",
|
|
59
|
+
"AccountRule",
|
|
57
60
|
"DEFAULT_CATEGORY_SCHEMA",
|
|
58
61
|
"Categorizer",
|
|
59
62
|
"CategorizerError",
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Bank Statement Parser. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Configurable account mapping for plaintext-accounting export.
|
|
17
|
+
|
|
18
|
+
Assigns a destination account to each transaction based on
|
|
19
|
+
description patterns. Designed to pair with the hledger/beancount
|
|
20
|
+
exporter — the categorizer (#44) answers "what category?" while the
|
|
21
|
+
account mapper answers "which ledger account?".
|
|
22
|
+
|
|
23
|
+
Usage::
|
|
24
|
+
|
|
25
|
+
from bankstatementparser.enrichment.account_mapper import (
|
|
26
|
+
AccountMapper,
|
|
27
|
+
AccountRule,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
rules = [
|
|
31
|
+
AccountRule(pattern="COFFEE|STARBUCKS", account="Expenses:Food:Coffee"),
|
|
32
|
+
AccountRule(pattern="SALARY", account="Income:Salary"),
|
|
33
|
+
AccountRule(pattern="RENT", account="Expenses:Housing:Rent"),
|
|
34
|
+
]
|
|
35
|
+
mapper = AccountMapper(rules=rules, default="Expenses:Uncategorized")
|
|
36
|
+
|
|
37
|
+
for tx in transactions:
|
|
38
|
+
account = mapper.map(tx)
|
|
39
|
+
print(tx.description, "->", account)
|
|
40
|
+
|
|
41
|
+
Rules are evaluated top-to-bottom; the first match wins. Patterns
|
|
42
|
+
are compiled as case-insensitive regexes against the raw
|
|
43
|
+
description (not the normalized one, so the user sees the patterns
|
|
44
|
+
they'd expect from their bank's actual wording).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
import json
|
|
50
|
+
import re
|
|
51
|
+
from collections.abc import Iterable
|
|
52
|
+
from dataclasses import dataclass, field
|
|
53
|
+
from pathlib import Path
|
|
54
|
+
from typing import Union
|
|
55
|
+
|
|
56
|
+
from ..transaction_models import Transaction
|
|
57
|
+
|
|
58
|
+
PathLike = Union[str, Path]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class AccountRule:
|
|
63
|
+
"""A single pattern → account mapping rule.
|
|
64
|
+
|
|
65
|
+
``pattern`` is a case-insensitive regex matched against the
|
|
66
|
+
transaction's raw ``description``. ``account`` is the ledger
|
|
67
|
+
account name to assign when the pattern matches.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
pattern: str
|
|
71
|
+
account: str
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
# Validate the regex at construction time so bad patterns
|
|
75
|
+
# fail loudly, not silently at map time.
|
|
76
|
+
try:
|
|
77
|
+
re.compile(self.pattern, re.IGNORECASE)
|
|
78
|
+
except re.error as exc:
|
|
79
|
+
raise ValueError(
|
|
80
|
+
f"Invalid regex in AccountRule: {self.pattern!r}: {exc}"
|
|
81
|
+
) from exc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class AccountMapper:
|
|
86
|
+
"""Map transactions to ledger accounts via configurable rules.
|
|
87
|
+
|
|
88
|
+
Rules are evaluated top-to-bottom; the first match wins. When
|
|
89
|
+
no rule matches, ``default`` is returned.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
rules: Ordered list of :class:`AccountRule` instances.
|
|
93
|
+
default: Fallback account for unmatched transactions.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
rules: list[AccountRule] = field(default_factory=list)
|
|
97
|
+
default: str = "Expenses:Uncategorized"
|
|
98
|
+
_compiled: list[tuple[re.Pattern[str], str]] = field(
|
|
99
|
+
init=False, default_factory=list, repr=False
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def __post_init__(self) -> None:
|
|
103
|
+
self._compiled = [
|
|
104
|
+
(re.compile(rule.pattern, re.IGNORECASE), rule.account)
|
|
105
|
+
for rule in self.rules
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
def map(self, transaction: Transaction) -> str:
|
|
109
|
+
"""Return the ledger account for a single transaction."""
|
|
110
|
+
desc = transaction.description or ""
|
|
111
|
+
for compiled_re, account in self._compiled:
|
|
112
|
+
if compiled_re.search(desc):
|
|
113
|
+
return account
|
|
114
|
+
return self.default
|
|
115
|
+
|
|
116
|
+
def map_batch(
|
|
117
|
+
self, transactions: Iterable[Transaction]
|
|
118
|
+
) -> list[str]:
|
|
119
|
+
"""Map every transaction in a batch. Returns one account per row."""
|
|
120
|
+
return [self.map(tx) for tx in transactions]
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_json(cls, path: PathLike) -> AccountMapper:
|
|
124
|
+
"""Load rules from a JSON file.
|
|
125
|
+
|
|
126
|
+
Expected format::
|
|
127
|
+
|
|
128
|
+
{
|
|
129
|
+
"default": "Expenses:Uncategorized",
|
|
130
|
+
"rules": [
|
|
131
|
+
{"pattern": "COFFEE|STARBUCKS", "account": "Expenses:Food:Coffee"},
|
|
132
|
+
{"pattern": "SALARY", "account": "Income:Salary"}
|
|
133
|
+
]
|
|
134
|
+
}
|
|
135
|
+
"""
|
|
136
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
137
|
+
if not isinstance(data, dict):
|
|
138
|
+
raise ValueError("Account mapping JSON must be an object")
|
|
139
|
+
rules = [
|
|
140
|
+
AccountRule(
|
|
141
|
+
pattern=str(r.get("pattern", "")),
|
|
142
|
+
account=str(r.get("account", "")),
|
|
143
|
+
)
|
|
144
|
+
for r in data.get("rules", [])
|
|
145
|
+
if isinstance(r, dict)
|
|
146
|
+
]
|
|
147
|
+
return cls(
|
|
148
|
+
rules=rules,
|
|
149
|
+
default=str(data.get("default", "Expenses:Uncategorized")),
|
|
150
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Bank Statement Parser. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Export helpers for plaintext accounting and downstream systems."""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from .ledger import to_beancount, to_hledger
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"to_beancount",
|
|
24
|
+
"to_hledger",
|
|
25
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Bank Statement Parser. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Export transactions to hledger and beancount journal formats.
|
|
17
|
+
|
|
18
|
+
These formats are the two most popular plaintext-accounting file
|
|
19
|
+
formats. Both represent transactions as human-readable text files
|
|
20
|
+
that are version-controlled, diffable, and auditable — a natural
|
|
21
|
+
fit for bank statement data.
|
|
22
|
+
|
|
23
|
+
Usage::
|
|
24
|
+
|
|
25
|
+
from bankstatementparser.export import to_hledger, to_beancount
|
|
26
|
+
|
|
27
|
+
journal = to_hledger(transactions, account="Assets:Bank:Checking")
|
|
28
|
+
Path("journal.ledger").write_text(journal)
|
|
29
|
+
|
|
30
|
+
journal = to_beancount(transactions, account="Assets:Bank:Checking")
|
|
31
|
+
Path("journal.beancount").write_text(journal)
|
|
32
|
+
|
|
33
|
+
No external dependencies — both functions produce plain strings.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
from collections.abc import Iterable
|
|
39
|
+
|
|
40
|
+
from ..transaction_models import Transaction
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def to_hledger(
|
|
44
|
+
transactions: Iterable[Transaction],
|
|
45
|
+
*,
|
|
46
|
+
account: str = "Assets:Bank:Checking",
|
|
47
|
+
contra_account: str = "Expenses:Uncategorized",
|
|
48
|
+
default_currency: str = "EUR",
|
|
49
|
+
) -> str:
|
|
50
|
+
"""Render transactions as an hledger-compatible journal.
|
|
51
|
+
|
|
52
|
+
Each transaction produces one journal entry with two postings:
|
|
53
|
+
one to ``account`` (the bank account) and one to
|
|
54
|
+
``contra_account`` (the expense/income bucket). The
|
|
55
|
+
``contra_account`` posting has no explicit amount — hledger
|
|
56
|
+
infers it as the negation.
|
|
57
|
+
|
|
58
|
+
If the transaction has a ``category`` field set (from the
|
|
59
|
+
enrichment module), it replaces ``contra_account`` for that
|
|
60
|
+
entry.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
transactions: Iterable of :class:`Transaction` objects.
|
|
64
|
+
account: The bank account name in hledger format.
|
|
65
|
+
contra_account: Default contra-account for uncategorized
|
|
66
|
+
transactions.
|
|
67
|
+
default_currency: Currency code when the transaction has
|
|
68
|
+
no currency set.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
A string containing the full journal, ready to write to a
|
|
72
|
+
``.ledger`` or ``.journal`` file.
|
|
73
|
+
"""
|
|
74
|
+
lines: list[str] = []
|
|
75
|
+
for tx in transactions:
|
|
76
|
+
date = (
|
|
77
|
+
tx.booking_date.isoformat()
|
|
78
|
+
if tx.booking_date is not None
|
|
79
|
+
else "1970-01-01"
|
|
80
|
+
)
|
|
81
|
+
desc = _escape_description(tx.description or "Unknown")
|
|
82
|
+
currency = tx.currency or default_currency
|
|
83
|
+
amount = format(tx.amount.normalize(), "f")
|
|
84
|
+
|
|
85
|
+
contra = _resolve_contra(tx, contra_account)
|
|
86
|
+
|
|
87
|
+
lines.append(f"{date} {desc}")
|
|
88
|
+
lines.append(f" {account} {currency} {amount}")
|
|
89
|
+
lines.append(f" {contra}")
|
|
90
|
+
lines.append("")
|
|
91
|
+
|
|
92
|
+
return "\n".join(lines)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def to_beancount(
|
|
96
|
+
transactions: Iterable[Transaction],
|
|
97
|
+
*,
|
|
98
|
+
account: str = "Assets:Bank:Checking",
|
|
99
|
+
contra_account: str = "Expenses:Uncategorized",
|
|
100
|
+
default_currency: str = "EUR",
|
|
101
|
+
) -> str:
|
|
102
|
+
"""Render transactions as a beancount-compatible journal.
|
|
103
|
+
|
|
104
|
+
Beancount uses a slightly different syntax from hledger:
|
|
105
|
+
|
|
106
|
+
* The directive is ``txn`` (not implicit).
|
|
107
|
+
* Amounts appear on both postings (beancount requires explicit
|
|
108
|
+
amounts unless using ``pad`` directives).
|
|
109
|
+
* The payee and narration are separate quoted fields.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
transactions: Iterable of :class:`Transaction` objects.
|
|
113
|
+
account: The bank account name in beancount format.
|
|
114
|
+
contra_account: Default contra-account.
|
|
115
|
+
default_currency: Currency code when the transaction has
|
|
116
|
+
no currency set.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A string containing the full journal.
|
|
120
|
+
"""
|
|
121
|
+
lines: list[str] = []
|
|
122
|
+
for tx in transactions:
|
|
123
|
+
date = (
|
|
124
|
+
tx.booking_date.isoformat()
|
|
125
|
+
if tx.booking_date is not None
|
|
126
|
+
else "1970-01-01"
|
|
127
|
+
)
|
|
128
|
+
payee = _escape_beancount_string(
|
|
129
|
+
tx.counterparty or ""
|
|
130
|
+
)
|
|
131
|
+
narration = _escape_beancount_string(
|
|
132
|
+
tx.description or "Unknown"
|
|
133
|
+
)
|
|
134
|
+
currency = tx.currency or default_currency
|
|
135
|
+
amount = format(tx.amount.normalize(), "f")
|
|
136
|
+
neg_amount = format((-tx.amount).normalize(), "f")
|
|
137
|
+
|
|
138
|
+
contra = _resolve_contra(tx, contra_account)
|
|
139
|
+
|
|
140
|
+
lines.append(f'{date} txn "{payee}" "{narration}"')
|
|
141
|
+
lines.append(f" {account} {amount} {currency}")
|
|
142
|
+
lines.append(f" {contra} {neg_amount} {currency}")
|
|
143
|
+
lines.append("")
|
|
144
|
+
|
|
145
|
+
return "\n".join(lines)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _resolve_contra(
|
|
149
|
+
tx: Transaction, default: str
|
|
150
|
+
) -> str:
|
|
151
|
+
"""Use the enrichment category as contra-account if available."""
|
|
152
|
+
category = tx.category
|
|
153
|
+
if category:
|
|
154
|
+
safe = category.replace(" ", ":").replace("/", ":")
|
|
155
|
+
return f"Expenses:{safe}"
|
|
156
|
+
return default
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _escape_description(value: str) -> str:
|
|
160
|
+
"""Sanitize a description for hledger journal format."""
|
|
161
|
+
return value.replace("\n", " ").replace("\r", "").strip()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _escape_beancount_string(value: str) -> str:
|
|
165
|
+
"""Escape a string for beancount's quoted fields."""
|
|
166
|
+
return (
|
|
167
|
+
value.replace("\\", "\\\\")
|
|
168
|
+
.replace('"', '\\"')
|
|
169
|
+
.replace("\n", " ")
|
|
170
|
+
.strip()
|
|
171
|
+
)
|
{bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/__init__.py
RENAMED
|
@@ -38,10 +38,12 @@ from .orchestrator import (
|
|
|
38
38
|
smart_ingest,
|
|
39
39
|
)
|
|
40
40
|
from .pdf_text import PDFExtractionError, extract_text
|
|
41
|
+
from .scanner import ScanResult, scan_and_ingest
|
|
41
42
|
from .verification import (
|
|
42
43
|
BalanceVerification,
|
|
43
44
|
VerificationStatus,
|
|
44
45
|
verify_balance,
|
|
46
|
+
verify_balance_multi_currency,
|
|
45
47
|
)
|
|
46
48
|
from .vision import VisionExtractor, VisionExtractorError
|
|
47
49
|
|
|
@@ -53,12 +55,15 @@ __all__ = [
|
|
|
53
55
|
"LLMExtractorError",
|
|
54
56
|
"OllamaDirectError",
|
|
55
57
|
"PDFExtractionError",
|
|
58
|
+
"ScanResult",
|
|
56
59
|
"VerificationStatus",
|
|
57
60
|
"VisionExtractor",
|
|
58
61
|
"VisionExtractorError",
|
|
59
62
|
"extract_text",
|
|
60
63
|
"is_ollama_model",
|
|
61
64
|
"ollama_direct_completion",
|
|
65
|
+
"scan_and_ingest",
|
|
62
66
|
"smart_ingest",
|
|
67
|
+
"verify_balance_multi_currency",
|
|
63
68
|
"verify_balance",
|
|
64
69
|
]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Copyright (C) 2023-2026 Bank Statement Parser. All rights reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
12
|
+
# implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Bulk directory scanner for the hybrid pipeline.
|
|
17
|
+
|
|
18
|
+
Scans an organized folder tree, runs :func:`smart_ingest` on every
|
|
19
|
+
matching file, and deduplicates across the entire batch via
|
|
20
|
+
:meth:`Deduplicator.dedupe_by_hash`. Designed for the common
|
|
21
|
+
treasury pattern of ``statements/2026/04/*.pdf``.
|
|
22
|
+
|
|
23
|
+
Usage::
|
|
24
|
+
|
|
25
|
+
from bankstatementparser.hybrid.scanner import scan_and_ingest
|
|
26
|
+
|
|
27
|
+
batch = scan_and_ingest("statements/", pattern="**/*.pdf")
|
|
28
|
+
print(f"{len(batch.results)} files, {batch.total_unique} unique tx")
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import logging
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Optional, Union
|
|
37
|
+
|
|
38
|
+
from ..transaction_deduplicator import Deduplicator
|
|
39
|
+
from ..transaction_models import Transaction
|
|
40
|
+
from .orchestrator import IngestResult, smart_ingest
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
PathLike = Union[str, Path]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class ScanResult:
|
|
49
|
+
"""Output of :func:`scan_and_ingest`."""
|
|
50
|
+
|
|
51
|
+
results: tuple[IngestResult, ...]
|
|
52
|
+
unique_transactions: tuple[Transaction, ...]
|
|
53
|
+
skipped_hashes: tuple[str, ...]
|
|
54
|
+
file_count: int
|
|
55
|
+
total_unique: int
|
|
56
|
+
total_skipped: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def scan_and_ingest(
|
|
60
|
+
directory: PathLike,
|
|
61
|
+
*,
|
|
62
|
+
pattern: str = "**/*",
|
|
63
|
+
seen_hashes: Optional[set[str]] = None,
|
|
64
|
+
extensions: Optional[set[str]] = None,
|
|
65
|
+
) -> ScanResult:
|
|
66
|
+
"""Scan a directory and run :func:`smart_ingest` on every match.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
directory: Root directory to scan.
|
|
70
|
+
pattern: Glob pattern relative to ``directory``. Defaults
|
|
71
|
+
to ``**/*`` (recursive, all files).
|
|
72
|
+
seen_hashes: Optional set of already-ingested hashes for
|
|
73
|
+
cross-batch dedup. Mutated in-place so callers can
|
|
74
|
+
persist state.
|
|
75
|
+
extensions: Allowed file extensions (lowercase, with dot).
|
|
76
|
+
Defaults to ``{".xml", ".csv", ".ofx", ".qfx",
|
|
77
|
+
".mt940", ".sta", ".pdf"}``.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A :class:`ScanResult` with all per-file results, the
|
|
81
|
+
deduplicated transaction list, and the skipped-hash list.
|
|
82
|
+
"""
|
|
83
|
+
root = Path(directory)
|
|
84
|
+
if not root.is_dir():
|
|
85
|
+
raise FileNotFoundError(f"Not a directory: {root}")
|
|
86
|
+
|
|
87
|
+
if extensions is None:
|
|
88
|
+
extensions = {
|
|
89
|
+
".xml", ".csv", ".ofx", ".qfx",
|
|
90
|
+
".mt940", ".sta", ".pdf",
|
|
91
|
+
}
|
|
92
|
+
if seen_hashes is None:
|
|
93
|
+
seen_hashes = set()
|
|
94
|
+
|
|
95
|
+
files = sorted(
|
|
96
|
+
p
|
|
97
|
+
for p in root.glob(pattern)
|
|
98
|
+
if p.is_file() and p.suffix.lower() in extensions
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
all_results: list[IngestResult] = []
|
|
102
|
+
all_transactions: list[Transaction] = []
|
|
103
|
+
|
|
104
|
+
for file_path in files:
|
|
105
|
+
logger.info("Ingesting %s", file_path)
|
|
106
|
+
try:
|
|
107
|
+
result = smart_ingest(file_path)
|
|
108
|
+
all_results.append(result)
|
|
109
|
+
all_transactions.extend(result.transactions)
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
logger.warning("Failed to ingest %s: %s", file_path, exc)
|
|
112
|
+
|
|
113
|
+
dedup = Deduplicator()
|
|
114
|
+
unique, skipped = dedup.dedupe_by_hash(
|
|
115
|
+
all_transactions, seen_hashes=seen_hashes
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return ScanResult(
|
|
119
|
+
results=tuple(all_results),
|
|
120
|
+
unique_transactions=tuple(unique),
|
|
121
|
+
skipped_hashes=tuple(skipped),
|
|
122
|
+
file_count=len(files),
|
|
123
|
+
total_unique=len(unique),
|
|
124
|
+
total_skipped=len(skipped),
|
|
125
|
+
)
|
{bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/verification.py
RENAMED
|
@@ -22,6 +22,7 @@ the single most important integrity check in the hybrid pipeline.
|
|
|
22
22
|
|
|
23
23
|
from __future__ import annotations
|
|
24
24
|
|
|
25
|
+
from collections import defaultdict
|
|
25
26
|
from collections.abc import Iterable
|
|
26
27
|
from dataclasses import dataclass
|
|
27
28
|
from decimal import Decimal
|
|
@@ -131,3 +132,53 @@ def verify_balance(
|
|
|
131
132
|
f"actual {actual_delta} (off by {discrepancy})"
|
|
132
133
|
),
|
|
133
134
|
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def verify_balance_multi_currency(
|
|
138
|
+
transactions: Iterable[Transaction],
|
|
139
|
+
*,
|
|
140
|
+
balances: Optional[dict[str, tuple[Decimal, Decimal]]] = None,
|
|
141
|
+
tolerance: Decimal = Decimal("0.01"),
|
|
142
|
+
) -> dict[str, BalanceVerification]:
|
|
143
|
+
"""Run the Golden Rule **per currency**.
|
|
144
|
+
|
|
145
|
+
Multi-currency statements (common in international business
|
|
146
|
+
banking) mix transactions in different currencies. The single-
|
|
147
|
+
currency :func:`verify_balance` would always report
|
|
148
|
+
``DISCREPANCY`` because it sums GBP and EUR amounts together.
|
|
149
|
+
|
|
150
|
+
This function groups transactions by ``Transaction.currency``
|
|
151
|
+
and runs an independent Golden Rule check for each group.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
transactions: Iterable of normalized transactions.
|
|
155
|
+
balances: Optional ``{currency: (opening, closing)}`` dict.
|
|
156
|
+
When ``None``, verification runs with ``FAILED`` status
|
|
157
|
+
for every currency (no balances provided). When a
|
|
158
|
+
currency appears in the transactions but not in this
|
|
159
|
+
dict, its verification is also ``FAILED``.
|
|
160
|
+
tolerance: Per-currency tolerance. Defaults to one cent.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
A dict mapping each currency code (uppercase) to its
|
|
164
|
+
:class:`BalanceVerification`. Transactions with
|
|
165
|
+
``currency=None`` are grouped under the key ``"UNKNOWN"``.
|
|
166
|
+
"""
|
|
167
|
+
balances = balances or {}
|
|
168
|
+
groups: dict[str, list[Transaction]] = defaultdict(list)
|
|
169
|
+
for tx in transactions:
|
|
170
|
+
key = (tx.currency or "UNKNOWN").upper()
|
|
171
|
+
groups[key].append(tx)
|
|
172
|
+
|
|
173
|
+
results: dict[str, BalanceVerification] = {}
|
|
174
|
+
for currency, txs in sorted(groups.items()):
|
|
175
|
+
pair = balances.get(currency)
|
|
176
|
+
opening = pair[0] if pair else None
|
|
177
|
+
closing = pair[1] if pair else None
|
|
178
|
+
results[currency] = verify_balance(
|
|
179
|
+
txs,
|
|
180
|
+
opening_balance=opening,
|
|
181
|
+
closing_balance=closing,
|
|
182
|
+
tolerance=tolerance,
|
|
183
|
+
)
|
|
184
|
+
return results
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "bankstatementparser"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.8"
|
|
4
4
|
description = "BankStatementParser is your essential tool for easy bank statement management. Designed with finance and treasury experts in mind, it offers a simple way to handle CAMT (ISO 20022) formats and more. Get quick, accurate insights from your financial data and spend less time on processing. It's the smart, hassle-free way to stay on top of your transactions."
|
|
5
5
|
authors = ["Sebastien Rousseau <sebastian.rousseau@gmail.com>"]
|
|
6
6
|
license = "Apache Software License"
|
|
@@ -23,6 +23,8 @@ litellm = {version = ">=1.83.0", optional = true}
|
|
|
23
23
|
pypdf = {version = ">=4.0.0", optional = true}
|
|
24
24
|
pdfplumber = {version = ">=0.11.0", optional = true}
|
|
25
25
|
pypdfium2 = {version = ">=4.30.0", optional = true}
|
|
26
|
+
fastapi = {version = ">=0.115.0", optional = true}
|
|
27
|
+
uvicorn = {version = ">=0.34.0", optional = true}
|
|
26
28
|
|
|
27
29
|
[tool.poetry.group.dev.dependencies]
|
|
28
30
|
pytest = "^8.0.0"
|
|
@@ -39,9 +41,11 @@ hybrid = ["litellm", "pypdf"]
|
|
|
39
41
|
hybrid-plus = ["litellm", "pypdf", "pdfplumber"]
|
|
40
42
|
hybrid-vision = ["litellm", "pypdf", "pypdfium2"]
|
|
41
43
|
enrichment = ["litellm"]
|
|
44
|
+
api = ["fastapi", "uvicorn"]
|
|
42
45
|
|
|
43
46
|
[tool.poetry.scripts]
|
|
44
47
|
bankstatementparser = "bankstatementparser.cli:main"
|
|
48
|
+
bankstatementparser-api = "bankstatementparser.api:main"
|
|
45
49
|
|
|
46
50
|
[build-system]
|
|
47
51
|
requires = ["poetry-core"]
|
|
@@ -108,7 +112,7 @@ module = ["polars", "polars.*"]
|
|
|
108
112
|
ignore_missing_imports = true
|
|
109
113
|
|
|
110
114
|
[[tool.mypy.overrides]]
|
|
111
|
-
module = ["litellm", "litellm.*", "pypdf", "pypdf.*", "pdfplumber", "pdfplumber.*", "pypdfium2", "pypdfium2.*", "httpx", "httpx.*"]
|
|
115
|
+
module = ["litellm", "litellm.*", "pypdf", "pypdf.*", "pdfplumber", "pdfplumber.*", "pypdfium2", "pypdfium2.*", "httpx", "httpx.*", "fastapi", "fastapi.*", "uvicorn", "uvicorn.*"]
|
|
112
116
|
ignore_missing_imports = true
|
|
113
117
|
|
|
114
118
|
[[tool.mypy.overrides]]
|
|
File without changes
|
|
File without changes
|
{bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/additional_parsers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/llm_extractor.py
RENAMED
|
File without changes
|
{bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/ollama_direct.py
RENAMED
|
File without changes
|
{bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/orchestrator.py
RENAMED
|
File without changes
|
{bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/pdf_text.py
RENAMED
|
File without changes
|
{bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/prompts.py
RENAMED
|
File without changes
|
{bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/vision.py
RENAMED
|
File without changes
|
{bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/input_validator.py
RENAMED
|
File without changes
|
{bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/pain001_parser.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/transaction_models.py
RENAMED
|
File without changes
|
|
File without changes
|