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.
Files changed (33) hide show
  1. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/PKG-INFO +8 -5
  2. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/README.md +4 -4
  3. bankstatementparser-0.0.8/bankstatementparser/api.py +185 -0
  4. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/enrichment/__init__.py +3 -0
  5. bankstatementparser-0.0.8/bankstatementparser/enrichment/account_mapper.py +150 -0
  6. bankstatementparser-0.0.8/bankstatementparser/export/__init__.py +25 -0
  7. bankstatementparser-0.0.8/bankstatementparser/export/ledger.py +171 -0
  8. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/__init__.py +5 -0
  9. bankstatementparser-0.0.8/bankstatementparser/hybrid/scanner.py +125 -0
  10. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/verification.py +51 -0
  11. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/pyproject.toml +6 -2
  12. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/LICENSE +0 -0
  13. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/__init__.py +0 -0
  14. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/additional_parsers.py +0 -0
  15. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/bank_statement_parsers.py +0 -0
  16. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/base_parser.py +0 -0
  17. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/camt_parser.py +0 -0
  18. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/cli.py +0 -0
  19. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/enrichment/categorizer.py +0 -0
  20. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/exceptions.py +0 -0
  21. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/llm_extractor.py +0 -0
  22. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/ollama_direct.py +0 -0
  23. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/orchestrator.py +0 -0
  24. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/pdf_text.py +0 -0
  25. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/prompts.py +0 -0
  26. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/hybrid/vision.py +0 -0
  27. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/input_validator.py +0 -0
  28. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/pain001_parser.py +0 -0
  29. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/parallel.py +0 -0
  30. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/record_types.py +0 -0
  31. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/transaction_deduplicator.py +0 -0
  32. {bankstatementparser-0.0.7 → bankstatementparser-0.0.8}/bankstatementparser/transaction_models.py +0 -0
  33. {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.7
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
- [![PyPI](https://img.shields.io/pypi/pyversions/bankstatementparser.svg?style=for-the-badge&v=0.0.7)](https://pypi.org/project/bankstatementparser/)
45
+ [![PyPI](https://img.shields.io/pypi/pyversions/bankstatementparser.svg?style=for-the-badge&v=0.0.8)](https://pypi.org/project/bankstatementparser/)
43
46
  [![PyPI Downloads](https://img.shields.io/pypi/dm/bankstatementparser.svg?style=for-the-badge)](https://pypi.org/project/bankstatementparser/)
44
47
  [![Codecov](https://img.shields.io/codecov/c/github/sebastienrousseau/bankstatementparser?style=for-the-badge)](https://codecov.io/github/sebastienrousseau/bankstatementparser?branch=main)
45
48
  [![License](https://img.shields.io/github/license/sebastienrousseau/bankstatementparser?style=for-the-badge)](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** | 672 tests, 100% branch coverage, property-based fuzzing with Hypothesis |
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 (24 modules: deterministic core + hybrid + enrichment subpackages, 100% branch coverage)
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/ 672 tests (unit, integration, property-based, security, hybrid mocks)
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
- [![PyPI](https://img.shields.io/pypi/pyversions/bankstatementparser.svg?style=for-the-badge&v=0.0.7)](https://pypi.org/project/bankstatementparser/)
7
+ [![PyPI](https://img.shields.io/pypi/pyversions/bankstatementparser.svg?style=for-the-badge&v=0.0.8)](https://pypi.org/project/bankstatementparser/)
8
8
  [![PyPI Downloads](https://img.shields.io/pypi/dm/bankstatementparser.svg?style=for-the-badge)](https://pypi.org/project/bankstatementparser/)
9
9
  [![Codecov](https://img.shields.io/codecov/c/github/sebastienrousseau/bankstatementparser?style=for-the-badge)](https://codecov.io/github/sebastienrousseau/bankstatementparser?branch=main)
10
10
  [![License](https://img.shields.io/github/license/sebastienrousseau/bankstatementparser?style=for-the-badge)](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** | 672 tests, 100% branch coverage, property-based fuzzing with Hypothesis |
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 (24 modules: deterministic core + hybrid + enrichment subpackages, 100% branch coverage)
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/ 672 tests (unit, integration, property-based, security, hybrid mocks)
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
@@ -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
+ )
@@ -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
+ )
@@ -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.7"
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]]