bankstatementparser-loader-bai2 0.0.10__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.
@@ -0,0 +1,189 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work.
38
+
39
+ "Derivative Works" shall mean any work, whether in Source or Object
40
+ form, that is based on (or derived from) the Work and for which the
41
+ editorial revisions, annotations, elaborations, or other modifications
42
+ represent, as a whole, an original work of authorship. For the purposes
43
+ of this License, Derivative Works shall not include works that remain
44
+ separable from, or merely link (or bind by name) to the interfaces of,
45
+ the Work and Derivative Works thereof.
46
+
47
+ "Contribution" shall mean any work of authorship, including
48
+ the original version of the Work and any modifications or additions
49
+ to that Work or Derivative Works thereof, that is intentionally
50
+ submitted to the Licensor for inclusion in the Work by the copyright owner
51
+ or by an individual or Legal Entity authorized to submit on behalf of
52
+ the copyright owner. For the purposes of this definition, "submitted"
53
+ means any form of electronic, verbal, or written communication sent
54
+ to the Licensor or its representatives, including but not limited to
55
+ communication on electronic mailing lists, source code control systems,
56
+ and issue tracking systems that are managed by, or on behalf of, the
57
+ Licensor for the purpose of discussing and improving the Work, but
58
+ excluding communication that is conspicuously marked or otherwise
59
+ designated in writing by the copyright owner as "Not a Contribution."
60
+
61
+ "Contributor" shall mean Licensor and any individual or Legal Entity
62
+ on behalf of whom a Contribution has been received by the Licensor and
63
+ subsequently incorporated within the Work.
64
+
65
+ 2. Grant of Copyright License. Subject to the terms and conditions of
66
+ this License, each Contributor hereby grants to You a perpetual,
67
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
68
+ copyright license to reproduce, prepare Derivative Works of,
69
+ publicly display, publicly perform, sublicense, and distribute the
70
+ Work and such Derivative Works in Source or Object form.
71
+
72
+ 3. Grant of Patent License. Subject to the terms and conditions of
73
+ this License, each Contributor hereby grants to You a perpetual,
74
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
75
+ (except as stated in this section) patent license to make, have made,
76
+ use, offer to sell, sell, import, and otherwise transfer the Work,
77
+ where such license applies only to those patent claims licensable
78
+ by such Contributor that are necessarily infringed by their
79
+ Contribution(s) alone or by combination of their Contribution(s)
80
+ with the Work to which such Contribution(s) was submitted. If You
81
+ institute patent litigation against any entity (including a
82
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
83
+ or a Contribution incorporated within the Work constitutes direct
84
+ or contributory patent infringement, then any patent licenses
85
+ granted to You under this License for that Work shall terminate
86
+ as of the date such litigation is filed.
87
+
88
+ 4. Redistribution. You may reproduce and distribute copies of the
89
+ Work or Derivative Works thereof in any medium, with or without
90
+ modifications, and in Source or Object form, provided that You
91
+ meet the following conditions:
92
+
93
+ (a) You must give any other recipients of the Work or
94
+ Derivative Works a copy of this License; and
95
+
96
+ (b) You must cause any modified files to carry prominent notices
97
+ stating that You changed the files; and
98
+
99
+ (c) You must retain, in the Source form of any Derivative Works
100
+ that You distribute, all copyright, patent, trademark, and
101
+ attribution notices from the Source form of the Work,
102
+ excluding those notices that do not pertain to any part of
103
+ the Derivative Works; and
104
+
105
+ (d) If the Work includes a "NOTICE" text file as part of its
106
+ distribution, then any Derivative Works that You distribute must
107
+ include a readable copy of the attribution notices contained
108
+ within such NOTICE file, excluding any notices that do not
109
+ pertain to any part of the Derivative Works, in at least one
110
+ of the following places: within a NOTICE text file distributed
111
+ as part of the Derivative Works; within the Source form or
112
+ documentation, if provided along with the Derivative Works; or,
113
+ within a display generated by the Derivative Works, if and
114
+ wherever such third-party notices normally appear. The contents
115
+ of the NOTICE file are for informational purposes only and
116
+ do not modify the License. You may add Your own attribution
117
+ notices within Derivative Works that You distribute, alongside
118
+ or as an addendum to the NOTICE text from the Work, provided
119
+ that such additional attribution notices cannot be construed
120
+ as modifying the License.
121
+
122
+ You may add Your own copyright statement to Your modifications and
123
+ may provide additional or different license terms and conditions
124
+ for use, reproduction, or distribution of Your modifications, or
125
+ for any such Derivative Works as a whole, provided Your use,
126
+ reproduction, and distribution of the Work otherwise complies with
127
+ the conditions stated in this License.
128
+
129
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
130
+ any Contribution intentionally submitted for inclusion in the Work
131
+ by You to the Licensor shall be under the terms and conditions of
132
+ this License, without any additional terms or conditions.
133
+ Notwithstanding the above, nothing herein shall supersede or modify
134
+ the terms of any separate license agreement you may have executed
135
+ with Licensor regarding such Contributions.
136
+
137
+ 6. Trademarks. This License does not grant permission to use the trade
138
+ names, trademarks, service marks, or product names of the Licensor,
139
+ except as required for reasonable and customary use in describing the
140
+ origin of the Work and reproducing the content of the NOTICE file.
141
+
142
+ 7. Disclaimer of Warranty. Unless required by applicable law or
143
+ agreed to in writing, Licensor provides the Work (and each
144
+ Contributor provides its Contributions) on an "AS IS" BASIS,
145
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
146
+ implied, including, without limitation, any warranties or conditions
147
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
148
+ PARTICULAR PURPOSE. You are solely responsible for determining the
149
+ appropriateness of using or redistributing the Work and assume any
150
+ risks associated with Your exercise of permissions under this License.
151
+
152
+ 8. Limitation of Liability. In no event and under no legal theory,
153
+ whether in tort (including negligence), contract, or otherwise,
154
+ unless required by applicable law (such as deliberate and grossly
155
+ negligent acts) or agreed to in writing, shall any Contributor be
156
+ liable to You for damages, including any direct, indirect, special,
157
+ incidental, or consequential damages of any character arising as a
158
+ result of this License or out of the use or inability to use the
159
+ Work (including but not limited to damages for loss of goodwill,
160
+ work stoppage, computer failure or malfunction, or any and all
161
+ other commercial damages or losses), even if such Contributor
162
+ has been advised of the possibility of such damages.
163
+
164
+ 9. Accepting Warranty or Additional Liability. While redistributing
165
+ the Work or Derivative Works thereof, You may choose to offer,
166
+ and charge a fee for, acceptance of support, warranty, indemnity,
167
+ or other liability obligations and/or rights consistent with this
168
+ License. However, in accepting such obligations, You may act only
169
+ on Your own behalf and on Your sole responsibility, not on behalf
170
+ of any other Contributor, and only if You agree to indemnify,
171
+ defend, and hold each Contributor harmless for any liability
172
+ incurred by, or claims asserted against, such Contributor by reason
173
+ of your accepting any such warranty or additional liability.
174
+
175
+ END OF TERMS AND CONDITIONS
176
+
177
+ Copyright 2023-2026 Sebastien Rousseau
178
+
179
+ Licensed under the Apache License, Version 2.0 (the "License");
180
+ you may not use this file except in compliance with the License.
181
+ You may obtain a copy of the License at
182
+
183
+ http://www.apache.org/licenses/LICENSE-2.0
184
+
185
+ Unless required by applicable law or agreed to in writing, software
186
+ distributed under the License is distributed on an "AS IS" BASIS,
187
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
188
+ See the License for the specific language governing permissions and
189
+ limitations under the License.
@@ -0,0 +1,310 @@
1
+ Metadata-Version: 2.4
2
+ Name: bankstatementparser-loader-bai2
3
+ Version: 0.0.10
4
+ Summary: BAI2 (Bank Administration Institute v2) cash-management loader that parses BAI2 files into bankstatementparser Transaction objects.
5
+ License: Apache-2.0
6
+ License-File: LICENSE
7
+ Keywords: bai2,bank,statement,cash-management,transactions
8
+ Author: Sebastien Rousseau
9
+ Author-email: sebastian.rousseau@gmail.com
10
+ Requires-Python: >=3.10,<4.0
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Requires-Dist: bankstatementparser (>=0.0.9)
19
+ Project-URL: Homepage, https://bankstatementparser.com
20
+ Project-URL: Repository, https://github.com/sebastienrousseau/bankstatementparser-loader-bai2
21
+ Description-Content-Type: text/markdown
22
+
23
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
24
+
25
+ <p align="center">
26
+ <img
27
+ src="https://cloudcdn.pro/bankstatementparser/v1/logos/bankstatementparser.svg"
28
+ alt="bankstatementparser-loader-bai2 logo"
29
+ width="120"
30
+ height="120"
31
+ />
32
+ </p>
33
+
34
+ <h1 align="center">bankstatementparser-loader-bai2</h1>
35
+
36
+ <p align="center">
37
+ <b>A BAI2 (Bank Administration Institute, version 2) cash-management loader that parses BAI2 files into <code>bankstatementparser</code> <code>Transaction</code> objects.</b>
38
+ </p>
39
+
40
+ <p align="center">
41
+ <a href="https://pypi.org/project/bankstatementparser-loader-bai2/"><img src="https://img.shields.io/pypi/v/bankstatementparser-loader-bai2?style=for-the-badge" alt="PyPI version" /></a>
42
+ <a href="https://pypi.org/project/bankstatementparser-loader-bai2/"><img src="https://img.shields.io/pypi/pyversions/bankstatementparser-loader-bai2.svg?style=for-the-badge" alt="Python versions" /></a>
43
+ <a href="https://pypi.org/project/bankstatementparser-loader-bai2/"><img src="https://img.shields.io/pypi/dm/bankstatementparser-loader-bai2.svg?style=for-the-badge" alt="PyPI downloads" /></a>
44
+ <a href="https://github.com/sebastienrousseau/bankstatementparser-loader-bai2/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/sebastienrousseau/bankstatementparser-loader-bai2/ci.yml?branch=main&label=Tests&style=for-the-badge" alt="Tests" /></a>
45
+ <a href="#license"><img src="https://img.shields.io/pypi/l/bankstatementparser-loader-bai2?style=for-the-badge" alt="License" /></a>
46
+ </p>
47
+
48
+ ---
49
+
50
+ ## Contents
51
+
52
+ - [What is bankstatementparser-loader-bai2?](#what-is-bankstatementparser-loader-bai2) — the problem it solves
53
+ - [Install](#install) — PyPI, virtualenv
54
+ - [Quick start](#quick-start) — parse a file in three lines
55
+ - [Public API](#public-api) — `load_bai2`, `load_bai2_file`, `summarize_bai2`
56
+ - [Supported BAI2 subset](#supported-bai2-subset) — exactly which records are handled
57
+ - [Amount and sign convention](#amount-and-sign-convention) — how cents and debit/credit map
58
+ - [When not to use this loader](#when-not-to-use-this-loader) — honest boundaries
59
+ - [Development](#development) — gates, make targets
60
+ - [Security](#security) — input-handling posture
61
+ - [Contributing](#contributing) — how to get changes in
62
+ - [License](#license) — Apache-2.0
63
+
64
+ ---
65
+
66
+ ## What is bankstatementparser-loader-bai2?
67
+
68
+ **BAI2** (Bank Administration Institute, version 2) is the de-facto US
69
+ cash-management file format that banks ship for intraday and prior-day
70
+ balance and transaction reporting. The published
71
+ [`bankstatementparser`](https://pypi.org/project/bankstatementparser/)
72
+ library parses PDF and other statement formats but **does not support
73
+ BAI2**.
74
+
75
+ **bankstatementparser-loader-bai2** is a small, dependency-light companion
76
+ that fills that gap: give it a BAI2 payload and it returns a flat list of
77
+ [`bankstatementparser.transaction_models.Transaction`](https://pypi.org/project/bankstatementparser/)
78
+ objects (`source="bai2"`) that the rest of your deterministic pipeline
79
+ can consume unchanged.
80
+
81
+ | Concern | How this loader handles it |
82
+ | :--- | :--- |
83
+ | Record model | A documented, pragmatic subset of BAI2 (`01`/`02`/`03`/`16`/`88` plus ignored trailers) |
84
+ | Amounts | BAI2 minor-unit integers (cents) converted to `Decimal` (never `float`) |
85
+ | Debit / credit | Derived from the `16` type-code range, with the raw code preserved |
86
+ | Multiple accounts | All `16` records across every group / account are flattened into one list |
87
+ | Robustness | Tolerates CRLF, blank lines, and an optional trailing `/` per record |
88
+ | Errors | A clear `ValueError` if the file does not start with an `01` File Header |
89
+
90
+ ---
91
+
92
+ ## Install
93
+
94
+ | Channel | Command | Notes |
95
+ | :--- | :--- | :--- |
96
+ | PyPI | `pip install bankstatementparser-loader-bai2` | Pulls in `bankstatementparser >= 0.0.9` |
97
+ | Source | `git clone https://github.com/sebastienrousseau/bankstatementparser-loader-bai2 && cd bankstatementparser-loader-bai2 && poetry install` | For development |
98
+
99
+ Requires Python 3.10 or later. Works on macOS, Linux, and Windows.
100
+
101
+ <details>
102
+ <summary>Using an isolated virtual environment (recommended)</summary>
103
+
104
+ ```sh
105
+ python -m venv venv
106
+ source venv/bin/activate # macOS/Linux
107
+ venv\Scripts\activate # Windows
108
+ python -m pip install -U bankstatementparser-loader-bai2
109
+ ```
110
+
111
+ </details>
112
+
113
+ ---
114
+
115
+ ## Quick start
116
+
117
+ ```python
118
+ from bankstatementparser_loader_bai2 import load_bai2_file
119
+
120
+ transactions = load_bai2_file("statement.bai")
121
+ for txn in transactions:
122
+ print(txn.account_id, txn.currency, txn.amount, txn.description)
123
+ ```
124
+
125
+ Or parse an in-memory payload:
126
+
127
+ ```python
128
+ from bankstatementparser_loader_bai2 import load_bai2
129
+
130
+ payload = (
131
+ "01,SENDER,RECEIVER,260601,1200,FILE001,,,/\n"
132
+ "02,RCVR,ORIG,1,260601,1200,USD,/\n"
133
+ "03,0123456789,USD,010,150000,1,,/\n"
134
+ "16,165,150000,Z,BANKREF1,CUSTREF1,Incoming wire payment/\n"
135
+ "88,from ACME Corp invoice 42/\n"
136
+ "16,475,2500,Z,BANKREF2,,ATM withdrawal/\n"
137
+ "49,152500,2/\n"
138
+ "98,152500,1,4/\n"
139
+ "99,152500,1,6/\n"
140
+ )
141
+
142
+ for txn in load_bai2(payload):
143
+ print(txn.amount, txn.category, txn.description)
144
+ # 1500 bai2:165 Incoming wire payment from ACME Corp invoice 42
145
+ # -25 bai2:475 ATM withdrawal
146
+ ```
147
+
148
+ Runnable versions live in [`examples/`](examples/).
149
+
150
+ ---
151
+
152
+ ## Public API
153
+
154
+ ```python
155
+ from bankstatementparser_loader_bai2 import (
156
+ load_bai2,
157
+ load_bai2_file,
158
+ summarize_bai2,
159
+ Bai2Summary,
160
+ )
161
+ ```
162
+
163
+ | Function | Signature | Returns |
164
+ | :--- | :--- | :--- |
165
+ | `load_bai2` | `load_bai2(text: str)` | `list[Transaction]` |
166
+ | `load_bai2_file` | `load_bai2_file(path)` | `list[Transaction]` |
167
+ | `summarize_bai2` | `summarize_bai2(text: str)` | `Bai2Summary` |
168
+
169
+ `Bai2Summary` is a dataclass with the fields `file_id`, `group_count`,
170
+ `account_count`, `transaction_count`, and `currency`.
171
+
172
+ Each produced `Transaction` is populated as follows:
173
+
174
+ | `Transaction` field | Source |
175
+ | :--- | :--- |
176
+ | `account_id` | `03` Account Identifier — `accountNumber` |
177
+ | `currency` | `03` `currencyCode`, falling back to the `02` group currency |
178
+ | `amount` | `16` `amount` (cents / 100), signed per the convention below |
179
+ | `booking_date` | `02` Group Header as-of date, when present |
180
+ | `description` | `16` text plus any `88` continuations |
181
+ | `transaction_id` | `16` `bankRefNum`, falling back to `customerRefNum` |
182
+ | `reference` / `category` | The raw `16` type code (`category` as `bai2:<code>`) |
183
+ | `source` | Always `"bai2"` |
184
+
185
+ ---
186
+
187
+ ## Supported BAI2 subset
188
+
189
+ BAI2 records are comma-delimited fields ending with a `/` delimiter,
190
+ each beginning with a numeric type code. This loader implements a
191
+ **documented, pragmatic subset**:
192
+
193
+ | Record | Meaning | Handling |
194
+ | :--- | :--- | :--- |
195
+ | `01` | File Header | **Required first record.** `fileId` captured for the summary. |
196
+ | `02` | Group Header | Group `currency` and as-of date captured. |
197
+ | `03` | Account Identifier | `accountNumber` + optional `currencyCode` captured; account currency overrides group currency. |
198
+ | `16` | Transaction Detail | One transaction. |
199
+ | `88` | Continuation | Appended to the preceding `03`/`16` record's text. |
200
+ | `49` / `98` / `99` | Account / Group / File trailers | **Ignored** — control totals are not validated. |
201
+
202
+ Any other (or unknown) leading type code is ignored so that vendor
203
+ extensions do not abort the parse. Ignoring control-total trailers is a
204
+ deliberate, documented choice: the goal is faithful transaction
205
+ extraction, not file-level reconciliation.
206
+
207
+ ---
208
+
209
+ ## Amount and sign convention
210
+
211
+ BAI2 amounts are unsigned integers in the account currency's **minor
212
+ units** (cents), with no decimal point. They are converted to
213
+ `decimal.Decimal` by dividing by 100. An empty amount field is treated
214
+ as `0`.
215
+
216
+ Debit / credit direction is derived from the numeric range of the `16`
217
+ record's type code (this is the loader's chosen, documented convention):
218
+
219
+ | Type-code range | Direction | Sign |
220
+ | :--- | :--- | :--- |
221
+ | `100`–`399` | Credit | amount kept **positive** |
222
+ | `400`–`699` | Debit | amount made **negative** |
223
+ | anything else | unknown | amount kept **positive** |
224
+
225
+ The raw BAI2 type code is always preserved on the `Transaction` in both
226
+ `category` (as `bai2:<code>`) and `reference`, so no information is lost
227
+ — even for codes outside the two ranges above.
228
+
229
+ ---
230
+
231
+ ## When not to use this loader
232
+
233
+ - **You have ISO 20022 camt.053 or SWIFT MT940, not BAI2.** Those are
234
+ different formats with their own dedicated loaders.
235
+ - **You need control-total reconciliation.** This loader extracts
236
+ transactions and deliberately ignores the `49`/`98`/`99` trailers; if
237
+ you must validate file sums, do so before or after loading.
238
+ - **You need the full BAI2 specification.** This is a documented subset
239
+ focused on transaction extraction, not an exhaustive BAI2 parser.
240
+
241
+ ---
242
+
243
+ ## Development
244
+
245
+ This project uses [Poetry](https://python-poetry.org/) and
246
+ [mise](https://mise.jdx.dev/).
247
+
248
+ ```bash
249
+ git clone https://github.com/sebastienrousseau/bankstatementparser-loader-bai2.git
250
+ cd bankstatementparser-loader-bai2
251
+ poetry env use python3.12
252
+ poetry install
253
+ ```
254
+
255
+ A `Makefile` orchestrates the quality gates (kept in lockstep with CI):
256
+
257
+ | Target | What it runs |
258
+ | :--- | :--- |
259
+ | `make check` | All gates (REQUIRED before commit) |
260
+ | `make test` | `pytest --cov=bankstatementparser_loader_bai2 --cov-branch --cov-fail-under=100` |
261
+ | `make lint` | `ruff check` + `black --check` |
262
+ | `make type-check` | `mypy --strict` |
263
+ | `make doc-coverage` | `interrogate --fail-under=100` (docstring coverage) |
264
+
265
+ Current state (v0.0.10): **all tests passing, 100% line + branch
266
+ coverage** against a 100% enforced floor, `mypy --strict` clean,
267
+ interrogate 100%.
268
+
269
+ ---
270
+
271
+ ## Security
272
+
273
+ - **Read-only.** The loader only reads text / files you pass it; it
274
+ writes nothing.
275
+ - **No XML, no network, no code execution.** Parsing is a pure
276
+ string-to-dataclass transformation.
277
+ - **Decimal arithmetic** is used throughout, avoiding `float` rounding
278
+ surprises in financial amounts.
279
+ - **Dependencies** are pinned via `poetry.lock` and audited in CI.
280
+
281
+ To report a vulnerability, please use
282
+ [GitHub private vulnerability reporting](https://github.com/sebastienrousseau/bankstatementparser-loader-bai2/security)
283
+ rather than a public issue.
284
+
285
+ ---
286
+
287
+ ## Contributing
288
+
289
+ Contributions are welcome — see the
290
+ [contributing instructions](https://github.com/sebastienrousseau/bankstatementparser-loader-bai2/blob/main/CONTRIBUTING.md).
291
+ Thanks to all the
292
+ [contributors](https://github.com/sebastienrousseau/bankstatementparser-loader-bai2/graphs/contributors)
293
+ who have helped build `bankstatementparser-loader-bai2`.
294
+
295
+ ---
296
+
297
+ ## License
298
+
299
+ Licensed under the [Apache License, Version 2.0](https://opensource.org/license/apache-2-0/).
300
+ Any contribution submitted for inclusion shall be licensed as above,
301
+ without additional terms.
302
+
303
+ ---
304
+
305
+ <p align="center">
306
+ <a href="https://bankstatementparser.com">bankstatementparser.com</a> ·
307
+ <a href="https://pypi.org/project/bankstatementparser-loader-bai2/">PyPI</a> ·
308
+ <a href="https://github.com/sebastienrousseau/bankstatementparser-loader-bai2">GitHub</a>
309
+ </p>
310
+
@@ -0,0 +1,287 @@
1
+ <!-- SPDX-License-Identifier: Apache-2.0 -->
2
+
3
+ <p align="center">
4
+ <img
5
+ src="https://cloudcdn.pro/bankstatementparser/v1/logos/bankstatementparser.svg"
6
+ alt="bankstatementparser-loader-bai2 logo"
7
+ width="120"
8
+ height="120"
9
+ />
10
+ </p>
11
+
12
+ <h1 align="center">bankstatementparser-loader-bai2</h1>
13
+
14
+ <p align="center">
15
+ <b>A BAI2 (Bank Administration Institute, version 2) cash-management loader that parses BAI2 files into <code>bankstatementparser</code> <code>Transaction</code> objects.</b>
16
+ </p>
17
+
18
+ <p align="center">
19
+ <a href="https://pypi.org/project/bankstatementparser-loader-bai2/"><img src="https://img.shields.io/pypi/v/bankstatementparser-loader-bai2?style=for-the-badge" alt="PyPI version" /></a>
20
+ <a href="https://pypi.org/project/bankstatementparser-loader-bai2/"><img src="https://img.shields.io/pypi/pyversions/bankstatementparser-loader-bai2.svg?style=for-the-badge" alt="Python versions" /></a>
21
+ <a href="https://pypi.org/project/bankstatementparser-loader-bai2/"><img src="https://img.shields.io/pypi/dm/bankstatementparser-loader-bai2.svg?style=for-the-badge" alt="PyPI downloads" /></a>
22
+ <a href="https://github.com/sebastienrousseau/bankstatementparser-loader-bai2/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/sebastienrousseau/bankstatementparser-loader-bai2/ci.yml?branch=main&label=Tests&style=for-the-badge" alt="Tests" /></a>
23
+ <a href="#license"><img src="https://img.shields.io/pypi/l/bankstatementparser-loader-bai2?style=for-the-badge" alt="License" /></a>
24
+ </p>
25
+
26
+ ---
27
+
28
+ ## Contents
29
+
30
+ - [What is bankstatementparser-loader-bai2?](#what-is-bankstatementparser-loader-bai2) — the problem it solves
31
+ - [Install](#install) — PyPI, virtualenv
32
+ - [Quick start](#quick-start) — parse a file in three lines
33
+ - [Public API](#public-api) — `load_bai2`, `load_bai2_file`, `summarize_bai2`
34
+ - [Supported BAI2 subset](#supported-bai2-subset) — exactly which records are handled
35
+ - [Amount and sign convention](#amount-and-sign-convention) — how cents and debit/credit map
36
+ - [When not to use this loader](#when-not-to-use-this-loader) — honest boundaries
37
+ - [Development](#development) — gates, make targets
38
+ - [Security](#security) — input-handling posture
39
+ - [Contributing](#contributing) — how to get changes in
40
+ - [License](#license) — Apache-2.0
41
+
42
+ ---
43
+
44
+ ## What is bankstatementparser-loader-bai2?
45
+
46
+ **BAI2** (Bank Administration Institute, version 2) is the de-facto US
47
+ cash-management file format that banks ship for intraday and prior-day
48
+ balance and transaction reporting. The published
49
+ [`bankstatementparser`](https://pypi.org/project/bankstatementparser/)
50
+ library parses PDF and other statement formats but **does not support
51
+ BAI2**.
52
+
53
+ **bankstatementparser-loader-bai2** is a small, dependency-light companion
54
+ that fills that gap: give it a BAI2 payload and it returns a flat list of
55
+ [`bankstatementparser.transaction_models.Transaction`](https://pypi.org/project/bankstatementparser/)
56
+ objects (`source="bai2"`) that the rest of your deterministic pipeline
57
+ can consume unchanged.
58
+
59
+ | Concern | How this loader handles it |
60
+ | :--- | :--- |
61
+ | Record model | A documented, pragmatic subset of BAI2 (`01`/`02`/`03`/`16`/`88` plus ignored trailers) |
62
+ | Amounts | BAI2 minor-unit integers (cents) converted to `Decimal` (never `float`) |
63
+ | Debit / credit | Derived from the `16` type-code range, with the raw code preserved |
64
+ | Multiple accounts | All `16` records across every group / account are flattened into one list |
65
+ | Robustness | Tolerates CRLF, blank lines, and an optional trailing `/` per record |
66
+ | Errors | A clear `ValueError` if the file does not start with an `01` File Header |
67
+
68
+ ---
69
+
70
+ ## Install
71
+
72
+ | Channel | Command | Notes |
73
+ | :--- | :--- | :--- |
74
+ | PyPI | `pip install bankstatementparser-loader-bai2` | Pulls in `bankstatementparser >= 0.0.9` |
75
+ | Source | `git clone https://github.com/sebastienrousseau/bankstatementparser-loader-bai2 && cd bankstatementparser-loader-bai2 && poetry install` | For development |
76
+
77
+ Requires Python 3.10 or later. Works on macOS, Linux, and Windows.
78
+
79
+ <details>
80
+ <summary>Using an isolated virtual environment (recommended)</summary>
81
+
82
+ ```sh
83
+ python -m venv venv
84
+ source venv/bin/activate # macOS/Linux
85
+ venv\Scripts\activate # Windows
86
+ python -m pip install -U bankstatementparser-loader-bai2
87
+ ```
88
+
89
+ </details>
90
+
91
+ ---
92
+
93
+ ## Quick start
94
+
95
+ ```python
96
+ from bankstatementparser_loader_bai2 import load_bai2_file
97
+
98
+ transactions = load_bai2_file("statement.bai")
99
+ for txn in transactions:
100
+ print(txn.account_id, txn.currency, txn.amount, txn.description)
101
+ ```
102
+
103
+ Or parse an in-memory payload:
104
+
105
+ ```python
106
+ from bankstatementparser_loader_bai2 import load_bai2
107
+
108
+ payload = (
109
+ "01,SENDER,RECEIVER,260601,1200,FILE001,,,/\n"
110
+ "02,RCVR,ORIG,1,260601,1200,USD,/\n"
111
+ "03,0123456789,USD,010,150000,1,,/\n"
112
+ "16,165,150000,Z,BANKREF1,CUSTREF1,Incoming wire payment/\n"
113
+ "88,from ACME Corp invoice 42/\n"
114
+ "16,475,2500,Z,BANKREF2,,ATM withdrawal/\n"
115
+ "49,152500,2/\n"
116
+ "98,152500,1,4/\n"
117
+ "99,152500,1,6/\n"
118
+ )
119
+
120
+ for txn in load_bai2(payload):
121
+ print(txn.amount, txn.category, txn.description)
122
+ # 1500 bai2:165 Incoming wire payment from ACME Corp invoice 42
123
+ # -25 bai2:475 ATM withdrawal
124
+ ```
125
+
126
+ Runnable versions live in [`examples/`](examples/).
127
+
128
+ ---
129
+
130
+ ## Public API
131
+
132
+ ```python
133
+ from bankstatementparser_loader_bai2 import (
134
+ load_bai2,
135
+ load_bai2_file,
136
+ summarize_bai2,
137
+ Bai2Summary,
138
+ )
139
+ ```
140
+
141
+ | Function | Signature | Returns |
142
+ | :--- | :--- | :--- |
143
+ | `load_bai2` | `load_bai2(text: str)` | `list[Transaction]` |
144
+ | `load_bai2_file` | `load_bai2_file(path)` | `list[Transaction]` |
145
+ | `summarize_bai2` | `summarize_bai2(text: str)` | `Bai2Summary` |
146
+
147
+ `Bai2Summary` is a dataclass with the fields `file_id`, `group_count`,
148
+ `account_count`, `transaction_count`, and `currency`.
149
+
150
+ Each produced `Transaction` is populated as follows:
151
+
152
+ | `Transaction` field | Source |
153
+ | :--- | :--- |
154
+ | `account_id` | `03` Account Identifier — `accountNumber` |
155
+ | `currency` | `03` `currencyCode`, falling back to the `02` group currency |
156
+ | `amount` | `16` `amount` (cents / 100), signed per the convention below |
157
+ | `booking_date` | `02` Group Header as-of date, when present |
158
+ | `description` | `16` text plus any `88` continuations |
159
+ | `transaction_id` | `16` `bankRefNum`, falling back to `customerRefNum` |
160
+ | `reference` / `category` | The raw `16` type code (`category` as `bai2:<code>`) |
161
+ | `source` | Always `"bai2"` |
162
+
163
+ ---
164
+
165
+ ## Supported BAI2 subset
166
+
167
+ BAI2 records are comma-delimited fields ending with a `/` delimiter,
168
+ each beginning with a numeric type code. This loader implements a
169
+ **documented, pragmatic subset**:
170
+
171
+ | Record | Meaning | Handling |
172
+ | :--- | :--- | :--- |
173
+ | `01` | File Header | **Required first record.** `fileId` captured for the summary. |
174
+ | `02` | Group Header | Group `currency` and as-of date captured. |
175
+ | `03` | Account Identifier | `accountNumber` + optional `currencyCode` captured; account currency overrides group currency. |
176
+ | `16` | Transaction Detail | One transaction. |
177
+ | `88` | Continuation | Appended to the preceding `03`/`16` record's text. |
178
+ | `49` / `98` / `99` | Account / Group / File trailers | **Ignored** — control totals are not validated. |
179
+
180
+ Any other (or unknown) leading type code is ignored so that vendor
181
+ extensions do not abort the parse. Ignoring control-total trailers is a
182
+ deliberate, documented choice: the goal is faithful transaction
183
+ extraction, not file-level reconciliation.
184
+
185
+ ---
186
+
187
+ ## Amount and sign convention
188
+
189
+ BAI2 amounts are unsigned integers in the account currency's **minor
190
+ units** (cents), with no decimal point. They are converted to
191
+ `decimal.Decimal` by dividing by 100. An empty amount field is treated
192
+ as `0`.
193
+
194
+ Debit / credit direction is derived from the numeric range of the `16`
195
+ record's type code (this is the loader's chosen, documented convention):
196
+
197
+ | Type-code range | Direction | Sign |
198
+ | :--- | :--- | :--- |
199
+ | `100`–`399` | Credit | amount kept **positive** |
200
+ | `400`–`699` | Debit | amount made **negative** |
201
+ | anything else | unknown | amount kept **positive** |
202
+
203
+ The raw BAI2 type code is always preserved on the `Transaction` in both
204
+ `category` (as `bai2:<code>`) and `reference`, so no information is lost
205
+ — even for codes outside the two ranges above.
206
+
207
+ ---
208
+
209
+ ## When not to use this loader
210
+
211
+ - **You have ISO 20022 camt.053 or SWIFT MT940, not BAI2.** Those are
212
+ different formats with their own dedicated loaders.
213
+ - **You need control-total reconciliation.** This loader extracts
214
+ transactions and deliberately ignores the `49`/`98`/`99` trailers; if
215
+ you must validate file sums, do so before or after loading.
216
+ - **You need the full BAI2 specification.** This is a documented subset
217
+ focused on transaction extraction, not an exhaustive BAI2 parser.
218
+
219
+ ---
220
+
221
+ ## Development
222
+
223
+ This project uses [Poetry](https://python-poetry.org/) and
224
+ [mise](https://mise.jdx.dev/).
225
+
226
+ ```bash
227
+ git clone https://github.com/sebastienrousseau/bankstatementparser-loader-bai2.git
228
+ cd bankstatementparser-loader-bai2
229
+ poetry env use python3.12
230
+ poetry install
231
+ ```
232
+
233
+ A `Makefile` orchestrates the quality gates (kept in lockstep with CI):
234
+
235
+ | Target | What it runs |
236
+ | :--- | :--- |
237
+ | `make check` | All gates (REQUIRED before commit) |
238
+ | `make test` | `pytest --cov=bankstatementparser_loader_bai2 --cov-branch --cov-fail-under=100` |
239
+ | `make lint` | `ruff check` + `black --check` |
240
+ | `make type-check` | `mypy --strict` |
241
+ | `make doc-coverage` | `interrogate --fail-under=100` (docstring coverage) |
242
+
243
+ Current state (v0.0.10): **all tests passing, 100% line + branch
244
+ coverage** against a 100% enforced floor, `mypy --strict` clean,
245
+ interrogate 100%.
246
+
247
+ ---
248
+
249
+ ## Security
250
+
251
+ - **Read-only.** The loader only reads text / files you pass it; it
252
+ writes nothing.
253
+ - **No XML, no network, no code execution.** Parsing is a pure
254
+ string-to-dataclass transformation.
255
+ - **Decimal arithmetic** is used throughout, avoiding `float` rounding
256
+ surprises in financial amounts.
257
+ - **Dependencies** are pinned via `poetry.lock` and audited in CI.
258
+
259
+ To report a vulnerability, please use
260
+ [GitHub private vulnerability reporting](https://github.com/sebastienrousseau/bankstatementparser-loader-bai2/security)
261
+ rather than a public issue.
262
+
263
+ ---
264
+
265
+ ## Contributing
266
+
267
+ Contributions are welcome — see the
268
+ [contributing instructions](https://github.com/sebastienrousseau/bankstatementparser-loader-bai2/blob/main/CONTRIBUTING.md).
269
+ Thanks to all the
270
+ [contributors](https://github.com/sebastienrousseau/bankstatementparser-loader-bai2/graphs/contributors)
271
+ who have helped build `bankstatementparser-loader-bai2`.
272
+
273
+ ---
274
+
275
+ ## License
276
+
277
+ Licensed under the [Apache License, Version 2.0](https://opensource.org/license/apache-2-0/).
278
+ Any contribution submitted for inclusion shall be licensed as above,
279
+ without additional terms.
280
+
281
+ ---
282
+
283
+ <p align="center">
284
+ <a href="https://bankstatementparser.com">bankstatementparser.com</a> ·
285
+ <a href="https://pypi.org/project/bankstatementparser-loader-bai2/">PyPI</a> ·
286
+ <a href="https://github.com/sebastienrousseau/bankstatementparser-loader-bai2">GitHub</a>
287
+ </p>
@@ -0,0 +1,32 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2023-2026 Sebastien Rousseau. All rights reserved.
3
+
4
+ """BAI2 -> bankstatementparser ``Transaction`` loader.
5
+
6
+ BAI2 (Bank Administration Institute, version 2) is the US cash-management
7
+ file format banks ship for balance and transaction reporting. The
8
+ `bankstatementparser <https://pypi.org/project/bankstatementparser/>`_
9
+ library does not parse BAI2; this companion loader turns a BAI2 payload
10
+ into a flat list of
11
+ :class:`bankstatementparser.transaction_models.Transaction` objects.
12
+
13
+ See :mod:`bankstatementparser_loader_bai2.loader` for the documented
14
+ record subset, amount handling, and debit / credit sign convention.
15
+ """
16
+
17
+ from bankstatementparser_loader_bai2.loader import (
18
+ Bai2Summary,
19
+ load_bai2,
20
+ load_bai2_file,
21
+ summarize_bai2,
22
+ )
23
+
24
+ __version__ = "0.0.10"
25
+
26
+ __all__ = [
27
+ "load_bai2",
28
+ "load_bai2_file",
29
+ "summarize_bai2",
30
+ "Bai2Summary",
31
+ "__version__",
32
+ ]
@@ -0,0 +1,440 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (C) 2023-2026 Sebastien Rousseau. All rights reserved.
3
+
4
+ """BAI2 -> bankstatementparser ``Transaction`` loader.
5
+
6
+ `BAI2 <https://www.bai.org/>`_ (Bank Administration Institute, version 2)
7
+ is the de-facto US cash-management file format that banks ship for
8
+ intraday and prior-day balance reporting. The published
9
+ `bankstatementparser <https://pypi.org/project/bankstatementparser/>`_
10
+ library does **not** parse BAI2; this companion loader fills that gap by
11
+ turning a BAI2 payload into a flat list of
12
+ :class:`bankstatementparser.transaction_models.Transaction` objects that
13
+ downstream deterministic logic can consume.
14
+
15
+ Supported (pragmatic) subset
16
+ ----------------------------
17
+
18
+ BAI2 files are line-oriented. Each physical record is a sequence of
19
+ comma-delimited fields and ends with a ``/`` record delimiter. Every
20
+ record begins with a numeric *type code* identifying the record kind.
21
+ This loader implements the following records:
22
+
23
+ ``01`` File Header
24
+ ``01,senderId,receiverId,fileDate,fileTime,fileId,...`` -- the file
25
+ must start with this record. ``fileId`` is captured for the summary.
26
+
27
+ ``02`` Group Header
28
+ ``02,ultimateReceiver,originator,groupStatus,asOfDate,asOfTime,currency,...``
29
+ -- the group ``currency`` and ``asOfDate`` are captured. The as-of
30
+ date becomes each transaction's ``booking_date``; the group
31
+ currency is the fallback currency for accounts that omit one.
32
+
33
+ ``03`` Account Identifier
34
+ ``03,accountNumber,currencyCode,typeCode,amount,itemCount,fundsType,...``
35
+ -- ``accountNumber`` and the optional account ``currencyCode`` are
36
+ captured. The account currency, when present, overrides the group
37
+ currency for every transaction under this account.
38
+
39
+ ``16`` Transaction Detail
40
+ ``16,typeCode,amount,fundsType,bankRefNum,customerRefNum,text`` --
41
+ one transaction. ``amount`` is an integer in the account currency's
42
+ minor units (see "Amounts" below). ``text`` becomes the
43
+ description.
44
+
45
+ ``88`` Continuation
46
+ Continues the text of the immediately preceding ``03`` or ``16``
47
+ record; its content is appended to that record's description.
48
+
49
+ ``49`` Account Trailer, ``98`` Group Trailer, ``99`` File Trailer
50
+ Control-total records. This loader **ignores** them -- it does not
51
+ validate the control sums. Ignoring is a deliberate, documented
52
+ choice: the goal is faithful transaction extraction, not file-level
53
+ reconciliation.
54
+
55
+ Any other (or unknown) leading type code is ignored so that vendor
56
+ extensions do not abort the parse.
57
+
58
+ Amounts
59
+ -------
60
+
61
+ BAI2 amounts are unsigned integers expressed in the account currency's
62
+ **minor units** (e.g. cents), with no decimal point. They are converted
63
+ to :class:`decimal.Decimal` by dividing by 100 -- ``Decimal`` is used
64
+ throughout (never ``float``) to avoid binary rounding error. An empty
65
+ amount field is treated as ``0``.
66
+
67
+ Sign convention (debit / credit)
68
+ ---------------------------------
69
+
70
+ BAI2 transaction *type codes* encode the direction of funds. This loader
71
+ applies the following documented convention based on the numeric range
72
+ of the ``16`` record's type code:
73
+
74
+ * ``100``-``399`` -> **credit** (amount kept **positive**)
75
+ * ``400``-``699`` -> **debit** (amount made **negative**)
76
+ * anything else -> kept **positive**; the raw type code is preserved.
77
+
78
+ The raw BAI2 type code is always preserved on the resulting
79
+ ``Transaction`` in both the ``category`` field (as ``bai2:<code>``) and
80
+ the ``reference`` field, so no information is lost even for codes outside
81
+ the two ranges above.
82
+ """
83
+
84
+ from __future__ import annotations
85
+
86
+ from collections.abc import Iterator
87
+ from dataclasses import dataclass
88
+ from datetime import date, datetime
89
+ from decimal import Decimal
90
+ from pathlib import Path
91
+
92
+ from bankstatementparser.transaction_models import Transaction
93
+
94
+ __all__ = [
95
+ "load_bai2",
96
+ "load_bai2_file",
97
+ "summarize_bai2",
98
+ "Bai2Summary",
99
+ ]
100
+
101
+ # ─── Sign-convention boundaries ──────────────────────────────────────────────
102
+
103
+ # Inclusive lower/upper bounds for the credit and debit type-code ranges.
104
+ # Documented in the module docstring; kept here as the single source of
105
+ # truth so the loader and the tests agree.
106
+ _CREDIT_RANGE = range(100, 400) # 100-399 -> credit (positive)
107
+ _DEBIT_RANGE = range(400, 700) # 400-699 -> debit (negative)
108
+
109
+
110
+ # ─── Record tokeniser ────────────────────────────────────────────────────────
111
+
112
+
113
+ def _iter_records(text: str) -> Iterator[list[str]]:
114
+ """Yield each BAI2 record as a list of its comma-delimited fields.
115
+
116
+ Tolerates CRLF / LF line endings, blank lines, and an optional
117
+ trailing ``/`` record delimiter. The trailing ``/`` (and anything a
118
+ bank may append after it) is stripped before the fields are split.
119
+
120
+ Args:
121
+ text: The raw BAI2 payload.
122
+
123
+ Yields:
124
+ One ``list[str]`` of fields per non-empty record, in file order.
125
+ """
126
+ for raw_line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
127
+ line = raw_line.strip()
128
+ if not line:
129
+ continue
130
+ # A record ends with the '/' delimiter; drop it (and any
131
+ # trailing remainder) so the final field is clean.
132
+ if "/" in line:
133
+ line = line[: line.index("/")]
134
+ line = line.rstrip()
135
+ if not line:
136
+ continue
137
+ yield [field.strip() for field in line.split(",")]
138
+
139
+
140
+ # ─── Field helpers ───────────────────────────────────────────────────────────
141
+
142
+
143
+ def _field(fields: list[str], index: int) -> str:
144
+ """Return the field at ``index`` or an empty string if absent.
145
+
146
+ Args:
147
+ fields: The split fields of one record.
148
+ index: Zero-based field position.
149
+
150
+ Returns:
151
+ The trimmed field value, or ``""`` when the position is missing.
152
+ """
153
+ if 0 <= index < len(fields):
154
+ return fields[index]
155
+ return ""
156
+
157
+
158
+ def _amount_to_decimal(raw: str) -> Decimal:
159
+ """Convert a BAI2 minor-unit integer amount to a major-unit Decimal.
160
+
161
+ BAI2 amounts are unsigned integers in the currency's minor units
162
+ (cents) with no decimal point. An empty field is treated as ``0``.
163
+
164
+ Args:
165
+ raw: The raw amount field (e.g. ``"150000"`` for 1500.00).
166
+
167
+ Returns:
168
+ The amount as a :class:`decimal.Decimal` in major units.
169
+ """
170
+ text = raw.strip()
171
+ if not text:
172
+ return Decimal("0")
173
+ return Decimal(text) / Decimal(100)
174
+
175
+
176
+ def _signed_amount(type_code: str, magnitude: Decimal) -> Decimal:
177
+ """Apply the documented sign convention to a transaction magnitude.
178
+
179
+ Args:
180
+ type_code: The raw BAI2 ``16`` record type code.
181
+ magnitude: The non-negative amount in major units.
182
+
183
+ Returns:
184
+ The magnitude negated for debit type codes (``400``-``699``),
185
+ otherwise returned unchanged (credits and unknown codes stay
186
+ positive).
187
+ """
188
+ try:
189
+ code = int(type_code)
190
+ except ValueError:
191
+ return magnitude
192
+ if code in _DEBIT_RANGE:
193
+ return -magnitude
194
+ return magnitude
195
+
196
+
197
+ def _parse_bai2_date(raw: str) -> date | None:
198
+ """Parse a BAI2 ``YYMMDD`` date into a :class:`datetime.date`.
199
+
200
+ Years are interpreted with a sliding window matching industry
201
+ practice: ``00``-``79`` -> ``20YY``, ``80``-``99`` -> ``19YY``. An
202
+ empty or malformed value yields ``None`` rather than raising, so a
203
+ missing as-of date never aborts a parse.
204
+
205
+ Args:
206
+ raw: The raw 6-digit date field.
207
+
208
+ Returns:
209
+ The parsed date, or ``None`` when absent or unparseable.
210
+ """
211
+ text = raw.strip()
212
+ if len(text) != 6 or not text.isdigit():
213
+ return None
214
+ year = int(text[0:2])
215
+ century = 2000 if year < 80 else 1900
216
+ try:
217
+ return datetime.strptime(
218
+ f"{century + year:04d}{text[2:4]}{text[4:6]}", "%Y%m%d"
219
+ ).date()
220
+ except ValueError: # pragma: no cover - guarded by the isdigit check
221
+ return None
222
+
223
+
224
+ # ─── Working state ───────────────────────────────────────────────────────────
225
+
226
+
227
+ @dataclass
228
+ class _PendingTransaction:
229
+ """Mutable accumulator for one ``16`` record and its continuations."""
230
+
231
+ type_code: str
232
+ amount: Decimal
233
+ bank_ref: str
234
+ customer_ref: str
235
+ text_parts: list[str]
236
+ account_number: str | None
237
+ currency: str | None
238
+ booking_date: date | None
239
+ index: int
240
+
241
+ def to_transaction(self) -> Transaction:
242
+ """Materialise the accumulated state into a ``Transaction``.
243
+
244
+ Returns:
245
+ A frozen :class:`~bankstatementparser.transaction_models.Transaction`
246
+ with the BAI2 sign convention applied and the raw type code
247
+ preserved in both ``category`` and ``reference``.
248
+ """
249
+ description = " ".join(
250
+ part for part in self.text_parts if part
251
+ ).strip()
252
+ transaction_id = self.bank_ref or self.customer_ref or None
253
+ return Transaction(
254
+ account_id=self.account_number,
255
+ currency=self.currency,
256
+ amount=_signed_amount(self.type_code, self.amount),
257
+ booking_date=self.booking_date,
258
+ description=description or None,
259
+ reference=self.type_code or None,
260
+ transaction_id=transaction_id,
261
+ category=f"bai2:{self.type_code}" if self.type_code else None,
262
+ source="bai2",
263
+ source_index=self.index,
264
+ )
265
+
266
+
267
+ # ─── Summary model ───────────────────────────────────────────────────────────
268
+
269
+
270
+ @dataclass
271
+ class Bai2Summary:
272
+ """High-level counts and identifiers for a parsed BAI2 file.
273
+
274
+ Attributes:
275
+ file_id: The ``fileId`` field from the ``01`` File Header.
276
+ group_count: Number of ``02`` Group Header records.
277
+ account_count: Number of ``03`` Account Identifier records.
278
+ transaction_count: Number of ``16`` Transaction Detail records.
279
+ currency: The first currency seen (account currency preferred,
280
+ otherwise the group currency), or ``None`` if none was given.
281
+ """
282
+
283
+ file_id: str | None
284
+ group_count: int
285
+ account_count: int
286
+ transaction_count: int
287
+ currency: str | None
288
+
289
+
290
+ # ─── Public API ──────────────────────────────────────────────────────────────
291
+
292
+
293
+ def load_bai2(text: str) -> list[Transaction]:
294
+ """Parse a BAI2 payload into a flat list of ``Transaction`` objects.
295
+
296
+ Every ``16`` Transaction Detail record across all groups and
297
+ accounts becomes one transaction, carrying its account number and
298
+ currency. ``88`` continuation records extend the description of the
299
+ preceding ``03`` or ``16`` record.
300
+
301
+ Args:
302
+ text: The raw BAI2 payload. CRLF / LF endings, blank lines, and
303
+ an optional trailing ``/`` per record are all tolerated.
304
+
305
+ Returns:
306
+ The parsed transactions in file order. May be empty if the file
307
+ contains headers but no ``16`` records.
308
+
309
+ Raises:
310
+ ValueError: If the file does not start with an ``01`` File
311
+ Header record.
312
+ """
313
+ records = list(_iter_records(text))
314
+ if not records or _field(records[0], 0) != "01":
315
+ raise ValueError("BAI2 payload must start with an '01' File Header")
316
+
317
+ transactions: list[Transaction] = []
318
+ pending: _PendingTransaction | None = None
319
+ # Continuation target: 0 = none, 3 = last account note, 16 = pending tx.
320
+ continuation_target = 0
321
+ group_currency: str | None = None
322
+ group_as_of: date | None = None
323
+ account_number: str | None = None
324
+ account_currency: str | None = None
325
+
326
+ def _flush() -> None:
327
+ """Append any in-progress transaction to the output list."""
328
+ nonlocal pending
329
+ if pending is not None:
330
+ transactions.append(pending.to_transaction())
331
+ pending = None
332
+
333
+ for fields in records:
334
+ code = _field(fields, 0)
335
+ if code == "02":
336
+ _flush()
337
+ continuation_target = 0
338
+ group_as_of = _parse_bai2_date(_field(fields, 4))
339
+ group_currency = _field(fields, 6) or None
340
+ account_number = None
341
+ account_currency = None
342
+ elif code == "03":
343
+ _flush()
344
+ continuation_target = 3
345
+ account_number = _field(fields, 1) or None
346
+ account_currency = _field(fields, 2) or None
347
+ elif code == "16":
348
+ _flush()
349
+ continuation_target = 16
350
+ pending = _PendingTransaction(
351
+ type_code=_field(fields, 1),
352
+ amount=_amount_to_decimal(_field(fields, 2)),
353
+ bank_ref=_field(fields, 4),
354
+ customer_ref=_field(fields, 5),
355
+ text_parts=[_field(fields, 6)],
356
+ account_number=account_number,
357
+ currency=account_currency or group_currency,
358
+ booking_date=group_as_of,
359
+ index=len(transactions),
360
+ )
361
+ elif code == "88":
362
+ # Continuation text is every field after the leading '88'.
363
+ note = ",".join(fields[1:]).strip()
364
+ if continuation_target == 16 and pending is not None:
365
+ pending.text_parts.append(note)
366
+ # A continuation of an '03' account note has no transaction
367
+ # to attach to yet; it is informational and dropped here.
368
+ elif code in {"49", "98", "99"}:
369
+ # Trailer / control-total records are intentionally ignored.
370
+ _flush()
371
+ continuation_target = 0
372
+ # 01 and any unknown code: nothing to accumulate.
373
+
374
+ _flush()
375
+ return transactions
376
+
377
+
378
+ def load_bai2_file(path: str | Path) -> list[Transaction]:
379
+ """Parse a BAI2 file from disk into ``Transaction`` objects.
380
+
381
+ Args:
382
+ path: Filesystem path to the BAI2 file. UTF-8 is assumed.
383
+
384
+ Returns:
385
+ The parsed transactions, identical to calling :func:`load_bai2`
386
+ on the file's text content.
387
+
388
+ Raises:
389
+ ValueError: If the file does not start with an ``01`` record.
390
+ OSError: If the file cannot be read.
391
+ """
392
+ return load_bai2(Path(path).read_text(encoding="utf-8"))
393
+
394
+
395
+ def summarize_bai2(text: str) -> Bai2Summary:
396
+ """Summarise a BAI2 payload without materialising every transaction.
397
+
398
+ Args:
399
+ text: The raw BAI2 payload.
400
+
401
+ Returns:
402
+ A :class:`Bai2Summary` with the file id, group / account /
403
+ transaction counts, and the first currency observed.
404
+
405
+ Raises:
406
+ ValueError: If the file does not start with an ``01`` record.
407
+ """
408
+ records = list(_iter_records(text))
409
+ if not records or _field(records[0], 0) != "01":
410
+ raise ValueError("BAI2 payload must start with an '01' File Header")
411
+
412
+ file_id = _field(records[0], 5) or None
413
+ group_count = 0
414
+ account_count = 0
415
+ transaction_count = 0
416
+ currency: str | None = None
417
+ group_currency: str | None = None
418
+
419
+ for fields in records:
420
+ code = _field(fields, 0)
421
+ if code == "02":
422
+ group_count += 1
423
+ group_currency = _field(fields, 6) or None
424
+ if currency is None and group_currency is not None:
425
+ currency = group_currency
426
+ elif code == "03":
427
+ account_count += 1
428
+ account_currency = _field(fields, 2) or None
429
+ if account_currency is not None:
430
+ currency = account_currency
431
+ elif code == "16":
432
+ transaction_count += 1
433
+
434
+ return Bai2Summary(
435
+ file_id=file_id,
436
+ group_count=group_count,
437
+ account_count=account_count,
438
+ transaction_count=transaction_count,
439
+ currency=currency,
440
+ )
@@ -0,0 +1,109 @@
1
+ [tool.poetry]
2
+ name = "bankstatementparser-loader-bai2"
3
+ version = "0.0.10"
4
+ description = "BAI2 (Bank Administration Institute v2) cash-management loader that parses BAI2 files into bankstatementparser Transaction objects."
5
+ authors = ["Sebastien Rousseau <sebastian.rousseau@gmail.com>"]
6
+ license = "Apache-2.0"
7
+ readme = "README.md"
8
+ repository = "https://github.com/sebastienrousseau/bankstatementparser-loader-bai2"
9
+ homepage = "https://bankstatementparser.com"
10
+ keywords = ["bai2", "bank", "statement", "cash-management", "transactions"]
11
+ packages = [
12
+ { include = "bankstatementparser_loader_bai2" },
13
+ ]
14
+
15
+ [tool.poetry.dependencies]
16
+ python = ">=3.10,<4.0"
17
+ bankstatementparser = ">=0.0.9"
18
+
19
+ [tool.poetry.group.dev.dependencies]
20
+ pytest = ">=9.0.3,<10.0.0"
21
+ pytest-cov = ">=6.0.0,<8.0.0"
22
+ black = "^26.3.1"
23
+ ruff = "^0.1.0"
24
+ mypy = "^1.11.0"
25
+ bandit = "^1.7.0"
26
+ interrogate = "^1.7.0"
27
+
28
+ [build-system]
29
+ requires = ["poetry-core"]
30
+ build-backend = "poetry.core.masonry.api"
31
+
32
+ [tool.black]
33
+ line-length = 79
34
+ target-version = ['py310']
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
38
+ python_files = "test_*.py"
39
+ python_classes = "Test*"
40
+ python_functions = "test_*"
41
+ addopts = ["-ra", "--strict-markers", "--tb=short"]
42
+
43
+ [tool.coverage.run]
44
+ branch = true
45
+ source = ["bankstatementparser_loader_bai2"]
46
+
47
+ [tool.coverage.report]
48
+ exclude_also = [
49
+ "if __name__ == .__main__.:",
50
+ "pragma: no cover",
51
+ "@overload",
52
+ ]
53
+ fail_under = 100
54
+ show_missing = true
55
+ skip_covered = false
56
+
57
+ [tool.interrogate]
58
+ fail-under = 100
59
+ ignore-init-method = true
60
+ ignore-semiprivate = false
61
+ ignore-private = false
62
+ ignore-magic = true
63
+ exclude = ["tests", "examples"]
64
+
65
+ [tool.ruff]
66
+ line-length = 79
67
+ target-version = "py310"
68
+ extend-exclude = ["*.md", "tmp_env", ".venv", ".eggs"]
69
+
70
+ [tool.ruff.lint]
71
+ select = ["E", "W", "F", "I", "B", "C4", "UP"]
72
+ ignore = ["E501"]
73
+
74
+ [tool.ruff.lint.per-file-ignores]
75
+ "__init__.py" = ["F401"]
76
+
77
+ [tool.ruff.format]
78
+ quote-style = "double"
79
+ indent-style = "space"
80
+ skip-magic-trailing-comma = false
81
+
82
+ [tool.mypy]
83
+ python_version = "3.10"
84
+ strict = true
85
+ warn_return_any = true
86
+ warn_unused_configs = true
87
+ disallow_untyped_defs = true
88
+ disallow_any_unimported = false
89
+ no_implicit_optional = true
90
+ warn_redundant_casts = true
91
+ warn_unused_ignores = true
92
+ warn_no_return = true
93
+ check_untyped_defs = true
94
+ strict_equality = true
95
+ exclude = [
96
+ "tmp_env/",
97
+ ".venv/",
98
+ ".eggs/",
99
+ ]
100
+
101
+ [[tool.mypy.overrides]]
102
+ module = "bankstatementparser.*"
103
+ ignore_missing_imports = true
104
+
105
+ # Tests use pytest fixtures, subprocess plumbing, and dynamic exec of
106
+ # documented snippets; strict typing there is noise, not signal.
107
+ [[tool.mypy.overrides]]
108
+ module = ["tests.*"]
109
+ ignore_errors = true