dvm-finance 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dvm_finance-1.0.0/.gitignore +13 -0
- dvm_finance-1.0.0/LICENSE +21 -0
- dvm_finance-1.0.0/PKG-INFO +154 -0
- dvm_finance-1.0.0/README.md +112 -0
- dvm_finance-1.0.0/alembic/env.py +60 -0
- dvm_finance-1.0.0/alembic/script.py.mako +26 -0
- dvm_finance-1.0.0/alembic/versions/10222b47646b_initial_schema.py +218 -0
- dvm_finance-1.0.0/alembic.ini +39 -0
- dvm_finance-1.0.0/pyproject.toml +105 -0
- dvm_finance-1.0.0/src/abn_combined/__init__.py +3 -0
- dvm_finance-1.0.0/src/abn_combined/__main__.py +6 -0
- dvm_finance-1.0.0/src/abn_combined/api/__init__.py +1 -0
- dvm_finance-1.0.0/src/abn_combined/api/budgets.py +191 -0
- dvm_finance-1.0.0/src/abn_combined/api/cash_flow.py +114 -0
- dvm_finance-1.0.0/src/abn_combined/api/downloads.py +232 -0
- dvm_finance-1.0.0/src/abn_combined/api/rules.py +683 -0
- dvm_finance-1.0.0/src/abn_combined/api/snapshots.py +109 -0
- dvm_finance-1.0.0/src/abn_combined/api/tags.py +107 -0
- dvm_finance-1.0.0/src/abn_combined/api/transactions.py +253 -0
- dvm_finance-1.0.0/src/abn_combined/api/trends.py +58 -0
- dvm_finance-1.0.0/src/abn_combined/api/upload.py +83 -0
- dvm_finance-1.0.0/src/abn_combined/app.py +131 -0
- dvm_finance-1.0.0/src/abn_combined/cli.py +98 -0
- dvm_finance-1.0.0/src/abn_combined/constants.py +29 -0
- dvm_finance-1.0.0/src/abn_combined/core/__init__.py +1 -0
- dvm_finance-1.0.0/src/abn_combined/core/budget_report.py +132 -0
- dvm_finance-1.0.0/src/abn_combined/core/cash_flow.py +163 -0
- dvm_finance-1.0.0/src/abn_combined/core/categorizer.py +507 -0
- dvm_finance-1.0.0/src/abn_combined/core/dedup.py +120 -0
- dvm_finance-1.0.0/src/abn_combined/core/filters.py +472 -0
- dvm_finance-1.0.0/src/abn_combined/core/importer.py +120 -0
- dvm_finance-1.0.0/src/abn_combined/core/jobs.py +111 -0
- dvm_finance-1.0.0/src/abn_combined/core/legacy_migration.py +342 -0
- dvm_finance-1.0.0/src/abn_combined/core/models.py +229 -0
- dvm_finance-1.0.0/src/abn_combined/core/renames.py +198 -0
- dvm_finance-1.0.0/src/abn_combined/core/rule_prefill.py +99 -0
- dvm_finance-1.0.0/src/abn_combined/core/snapshots.py +606 -0
- dvm_finance-1.0.0/src/abn_combined/core/trends.py +317 -0
- dvm_finance-1.0.0/src/abn_combined/core/utils.py +67 -0
- dvm_finance-1.0.0/src/abn_combined/db.py +47 -0
- dvm_finance-1.0.0/src/abn_combined/downloaders/__init__.py +5 -0
- dvm_finance-1.0.0/src/abn_combined/downloaders/abn.py +290 -0
- dvm_finance-1.0.0/src/abn_combined/downloaders/paypal.py +614 -0
- dvm_finance-1.0.0/src/abn_combined/logging_config.py +42 -0
- dvm_finance-1.0.0/src/abn_combined/migrations.py +69 -0
- dvm_finance-1.0.0/src/abn_combined/parsers/__init__.py +52 -0
- dvm_finance-1.0.0/src/abn_combined/parsers/csv.py +81 -0
- dvm_finance-1.0.0/src/abn_combined/parsers/description.py +708 -0
- dvm_finance-1.0.0/src/abn_combined/parsers/mt940.py +353 -0
- dvm_finance-1.0.0/src/abn_combined/parsers/paypal.py +238 -0
- dvm_finance-1.0.0/src/abn_combined/parsers/seb.py +224 -0
- dvm_finance-1.0.0/src/abn_combined/parsers/utils.py +63 -0
- dvm_finance-1.0.0/src/abn_combined/parsers/wise.py +251 -0
- dvm_finance-1.0.0/src/abn_combined/parsers/xls.py +82 -0
- dvm_finance-1.0.0/src/abn_combined/settings.py +76 -0
- dvm_finance-1.0.0/src/abn_combined/web/static/app.css +109 -0
- dvm_finance-1.0.0/src/abn_combined/web/static/downloads.css +94 -0
- dvm_finance-1.0.0/src/abn_combined/web/static/js/phase2.js +279 -0
- dvm_finance-1.0.0/src/abn_combined/web/static/js/phase4.js +229 -0
- dvm_finance-1.0.0/src/abn_combined/web/static/js/rules.js +31 -0
- dvm_finance-1.0.0/src/abn_combined/web/static/js/transactions.js +16 -0
- dvm_finance-1.0.0/src/abn_combined/web/static/rules.css +99 -0
- dvm_finance-1.0.0/src/abn_combined/web/static/snapshots.css +25 -0
- dvm_finance-1.0.0/src/abn_combined/web/static/tables.css +96 -0
- dvm_finance-1.0.0/src/abn_combined/web/static/trends.css +145 -0
- dvm_finance-1.0.0/src/abn_combined/web/static/vendor/alpine.min.js +5 -0
- dvm_finance-1.0.0/src/abn_combined/web/static/vendor/htmx.min.js +1 -0
- dvm_finance-1.0.0/src/abn_combined/web/static/vendor/pico.min.css +4 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/_budgets_table.html +40 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/_cash_flow_table.html +30 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/_download_status.html +59 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/_rule_preview.html +156 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/_rule_report.html +145 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/_rules_row.html +86 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/_snapshot_import_report.html +103 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/_transaction_detail.html +37 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/_transactions_row.html +115 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/_transactions_table.html +89 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/_trends_table.html +75 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/_upload_summary.html +14 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/base.html +88 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/budgets.html +55 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/budgets_edit.html +34 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/cash_flow.html +49 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/download.html +130 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/placeholder.html +8 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/rules.html +74 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/rules_edit.html +268 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/rules_history.html +49 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/snapshots.html +104 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/tags.html +55 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/transactions.html +181 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/trends.html +57 -0
- dvm_finance-1.0.0/src/abn_combined/web/templates/upload.html +50 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Divya van Mahajan
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dvm-finance
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Integrated personal-finance app: download, parse, categorize, and review bank statements.
|
|
5
|
+
Project-URL: Homepage, https://github.com/divyavanmahajan/abn-combined
|
|
6
|
+
Project-URL: Repository, https://github.com/divyavanmahajan/abn-combined
|
|
7
|
+
Project-URL: Issues, https://github.com/divyavanmahajan/abn-combined/issues
|
|
8
|
+
Author: Divya van Mahajan
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: banking,budgeting,fastapi,finance,mt940,personal-finance,sqlite
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial :: Accounting
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Requires-Dist: alembic
|
|
22
|
+
Requires-Dist: fastapi
|
|
23
|
+
Requires-Dist: jinja2
|
|
24
|
+
Requires-Dist: openpyxl
|
|
25
|
+
Requires-Dist: pandas
|
|
26
|
+
Requires-Dist: platformdirs
|
|
27
|
+
Requires-Dist: python-multipart
|
|
28
|
+
Requires-Dist: sqlalchemy>=2
|
|
29
|
+
Requires-Dist: structlog
|
|
30
|
+
Requires-Dist: uvicorn
|
|
31
|
+
Requires-Dist: xlrd
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: httpx; extra == 'dev'
|
|
34
|
+
Requires-Dist: playwright; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest-playwright; extra == 'dev'
|
|
38
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
39
|
+
Provides-Extra: downloads
|
|
40
|
+
Requires-Dist: playwright; extra == 'downloads'
|
|
41
|
+
Description-Content-Type: text/markdown
|
|
42
|
+
|
|
43
|
+
# dvm-finance
|
|
44
|
+
|
|
45
|
+
Integrated personal-finance app: download, parse, categorize, and review bank
|
|
46
|
+
statements locally. Merges the abn-analyst analyzer and the abn-download
|
|
47
|
+
downloaders into a single, local, single-user web application. No login, no
|
|
48
|
+
charts, no LLM — rules + manual categorization, tables, and an audit trail.
|
|
49
|
+
|
|
50
|
+
## Install & run (PyPI)
|
|
51
|
+
|
|
52
|
+
With PyPI, simply:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install dvm-finance
|
|
56
|
+
dvm-finance
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or with [uv](https://docs.astral.sh/uv/) for no-venv installation:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
uvx dvm-finance
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
For development from a local checkout / wheel:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uvx --from /path/to/abn-combined dvm-finance
|
|
69
|
+
uvx --from dist/dvm_finance-0.1.0-py3-none-any.whl dvm-finance
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## First run
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
dvm-finance # serves http://127.0.0.1:8000 and prints the URL
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
On first start the app creates its data directory (SQLite DB, downloaded
|
|
79
|
+
statements, snapshot exports) and migrates the schema automatically. Defaults to
|
|
80
|
+
the platform user-data dir (macOS: `~/Library/Application Support/dvm-finance/`);
|
|
81
|
+
override with `--data-dir` or `DVM_FINANCE_DATA_DIR`. Other flags: `--port`
|
|
82
|
+
(default 8000) and `--host` (default `127.0.0.1`).
|
|
83
|
+
|
|
84
|
+
The app binds to `127.0.0.1` and has **no authentication** — do not widen the
|
|
85
|
+
bind address on an untrusted network.
|
|
86
|
+
|
|
87
|
+
An empty install offers three ways in: migrate a legacy database (below), upload
|
|
88
|
+
statement files on the **Upload** tab (MT940/STA, ABN XLS, generic CSV, PayPal
|
|
89
|
+
TXT, Wise CSV, SEB CSV), or download directly from the **Download** tab.
|
|
90
|
+
|
|
91
|
+
## Migrating from abn-analyst
|
|
92
|
+
|
|
93
|
+
One-time, idempotent import of all transactions, rules + conditions, budgets and
|
|
94
|
+
manual categorizations from a legacy `abn_analyst.db` (opened read-only):
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
dvm-finance migrate-legacy /path/to/abn_analyst.db
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Re-running skips already-present rows. A per-table summary is printed.
|
|
101
|
+
|
|
102
|
+
## Sharing data with a second person
|
|
103
|
+
|
|
104
|
+
- **Export** (Snapshots tab): one click writes a versioned, gzipped-JSON snapshot
|
|
105
|
+
of transactions, rules, budgets and change reports, offered as a download.
|
|
106
|
+
- **Import** (their machine): upload the file on the Snapshots tab. The merge is
|
|
107
|
+
**incoming wins** — new records are inserted, conflicting records (including
|
|
108
|
+
manual categorizations and rule edits) are overwritten by the snapshot; local
|
|
109
|
+
records absent from the snapshot are never deleted. The database is backed up
|
|
110
|
+
first and an import report is stored for review.
|
|
111
|
+
- Second-user setup is two commands: `uvx dvm-finance`, then import a
|
|
112
|
+
snapshot in the browser.
|
|
113
|
+
|
|
114
|
+
**Security note**: snapshot files contain your full financial data and are **not
|
|
115
|
+
encrypted**. Exchange them only over a channel you trust.
|
|
116
|
+
|
|
117
|
+
## Downloads
|
|
118
|
+
|
|
119
|
+
- **ABN AMRO**: click "Start ABN download" — a headed Chromium opens; authenticate
|
|
120
|
+
with the ABN AMRO app; statements are fetched, saved and imported automatically.
|
|
121
|
+
Requires Playwright browsers:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
playwright install chromium
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The app works fine without them (upload/rules/trends); the Download page shows
|
|
128
|
+
install instructions when they are missing.
|
|
129
|
+
|
|
130
|
+
- **PayPal**: start Chrome with remote debugging first, log in to PayPal, then
|
|
131
|
+
click "Start PayPal download":
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
|
135
|
+
--remote-debugging-port=9226 --user-data-dir=~/.chrome/debugdir \
|
|
136
|
+
https://www.paypal.com/reports/dlog
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Development
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
source ~/venv/bin/activate
|
|
143
|
+
pip install -e ".[dev]"
|
|
144
|
+
playwright install chromium
|
|
145
|
+
|
|
146
|
+
ruff check . # lint
|
|
147
|
+
pytest # unit + integration (e2e/slow deselected)
|
|
148
|
+
pytest --cov=abn_combined # coverage (fails under 80%)
|
|
149
|
+
pytest -m e2e # Playwright end-to-end flows (headless Chromium)
|
|
150
|
+
dvm-finance --data-dir ./devdata # run against a scratch data dir
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
See `docs/architecture.md`, `docs/developer.md` and `docs/product.md` for design
|
|
154
|
+
notes, and `docs/core-beliefs.md` for the project's golden principles.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# dvm-finance
|
|
2
|
+
|
|
3
|
+
Integrated personal-finance app: download, parse, categorize, and review bank
|
|
4
|
+
statements locally. Merges the abn-analyst analyzer and the abn-download
|
|
5
|
+
downloaders into a single, local, single-user web application. No login, no
|
|
6
|
+
charts, no LLM — rules + manual categorization, tables, and an audit trail.
|
|
7
|
+
|
|
8
|
+
## Install & run (PyPI)
|
|
9
|
+
|
|
10
|
+
With PyPI, simply:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install dvm-finance
|
|
14
|
+
dvm-finance
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or with [uv](https://docs.astral.sh/uv/) for no-venv installation:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uvx dvm-finance
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
For development from a local checkout / wheel:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uvx --from /path/to/abn-combined dvm-finance
|
|
27
|
+
uvx --from dist/dvm_finance-0.1.0-py3-none-any.whl dvm-finance
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## First run
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
dvm-finance # serves http://127.0.0.1:8000 and prints the URL
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
On first start the app creates its data directory (SQLite DB, downloaded
|
|
37
|
+
statements, snapshot exports) and migrates the schema automatically. Defaults to
|
|
38
|
+
the platform user-data dir (macOS: `~/Library/Application Support/dvm-finance/`);
|
|
39
|
+
override with `--data-dir` or `DVM_FINANCE_DATA_DIR`. Other flags: `--port`
|
|
40
|
+
(default 8000) and `--host` (default `127.0.0.1`).
|
|
41
|
+
|
|
42
|
+
The app binds to `127.0.0.1` and has **no authentication** — do not widen the
|
|
43
|
+
bind address on an untrusted network.
|
|
44
|
+
|
|
45
|
+
An empty install offers three ways in: migrate a legacy database (below), upload
|
|
46
|
+
statement files on the **Upload** tab (MT940/STA, ABN XLS, generic CSV, PayPal
|
|
47
|
+
TXT, Wise CSV, SEB CSV), or download directly from the **Download** tab.
|
|
48
|
+
|
|
49
|
+
## Migrating from abn-analyst
|
|
50
|
+
|
|
51
|
+
One-time, idempotent import of all transactions, rules + conditions, budgets and
|
|
52
|
+
manual categorizations from a legacy `abn_analyst.db` (opened read-only):
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
dvm-finance migrate-legacy /path/to/abn_analyst.db
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Re-running skips already-present rows. A per-table summary is printed.
|
|
59
|
+
|
|
60
|
+
## Sharing data with a second person
|
|
61
|
+
|
|
62
|
+
- **Export** (Snapshots tab): one click writes a versioned, gzipped-JSON snapshot
|
|
63
|
+
of transactions, rules, budgets and change reports, offered as a download.
|
|
64
|
+
- **Import** (their machine): upload the file on the Snapshots tab. The merge is
|
|
65
|
+
**incoming wins** — new records are inserted, conflicting records (including
|
|
66
|
+
manual categorizations and rule edits) are overwritten by the snapshot; local
|
|
67
|
+
records absent from the snapshot are never deleted. The database is backed up
|
|
68
|
+
first and an import report is stored for review.
|
|
69
|
+
- Second-user setup is two commands: `uvx dvm-finance`, then import a
|
|
70
|
+
snapshot in the browser.
|
|
71
|
+
|
|
72
|
+
**Security note**: snapshot files contain your full financial data and are **not
|
|
73
|
+
encrypted**. Exchange them only over a channel you trust.
|
|
74
|
+
|
|
75
|
+
## Downloads
|
|
76
|
+
|
|
77
|
+
- **ABN AMRO**: click "Start ABN download" — a headed Chromium opens; authenticate
|
|
78
|
+
with the ABN AMRO app; statements are fetched, saved and imported automatically.
|
|
79
|
+
Requires Playwright browsers:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
playwright install chromium
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The app works fine without them (upload/rules/trends); the Download page shows
|
|
86
|
+
install instructions when they are missing.
|
|
87
|
+
|
|
88
|
+
- **PayPal**: start Chrome with remote debugging first, log in to PayPal, then
|
|
89
|
+
click "Start PayPal download":
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
|
93
|
+
--remote-debugging-port=9226 --user-data-dir=~/.chrome/debugdir \
|
|
94
|
+
https://www.paypal.com/reports/dlog
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
source ~/venv/bin/activate
|
|
101
|
+
pip install -e ".[dev]"
|
|
102
|
+
playwright install chromium
|
|
103
|
+
|
|
104
|
+
ruff check . # lint
|
|
105
|
+
pytest # unit + integration (e2e/slow deselected)
|
|
106
|
+
pytest --cov=abn_combined # coverage (fails under 80%)
|
|
107
|
+
pytest -m e2e # Playwright end-to-end flows (headless Chromium)
|
|
108
|
+
dvm-finance --data-dir ./devdata # run against a scratch data dir
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
See `docs/architecture.md`, `docs/developer.md` and `docs/product.md` for design
|
|
112
|
+
notes, and `docs/core-beliefs.md` for the project's golden principles.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Alembic environment for abn-combined."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from logging.config import fileConfig
|
|
6
|
+
|
|
7
|
+
from sqlalchemy import engine_from_config, pool
|
|
8
|
+
|
|
9
|
+
from abn_combined.core.models import Base
|
|
10
|
+
from abn_combined.settings import Settings
|
|
11
|
+
from alembic import context
|
|
12
|
+
|
|
13
|
+
config = context.config
|
|
14
|
+
|
|
15
|
+
if config.config_file_name is not None:
|
|
16
|
+
fileConfig(config.config_file_name)
|
|
17
|
+
|
|
18
|
+
target_metadata = Base.metadata
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _database_url() -> str:
|
|
22
|
+
"""Resolve the DB URL: programmatic override wins, else the data-dir DB."""
|
|
23
|
+
url = config.get_main_option("sqlalchemy.url")
|
|
24
|
+
if url:
|
|
25
|
+
return url
|
|
26
|
+
return Settings.create().database_url
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_migrations_offline() -> None:
|
|
30
|
+
context.configure(
|
|
31
|
+
url=_database_url(),
|
|
32
|
+
target_metadata=target_metadata,
|
|
33
|
+
literal_binds=True,
|
|
34
|
+
dialect_opts={"paramstyle": "named"},
|
|
35
|
+
render_as_batch=True,
|
|
36
|
+
)
|
|
37
|
+
with context.begin_transaction():
|
|
38
|
+
context.run_migrations()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def run_migrations_online() -> None:
|
|
42
|
+
section = config.get_section(config.config_ini_section, {})
|
|
43
|
+
section["sqlalchemy.url"] = _database_url()
|
|
44
|
+
connectable = engine_from_config(
|
|
45
|
+
section, prefix="sqlalchemy.", poolclass=pool.NullPool
|
|
46
|
+
)
|
|
47
|
+
with connectable.connect() as connection:
|
|
48
|
+
context.configure(
|
|
49
|
+
connection=connection,
|
|
50
|
+
target_metadata=target_metadata,
|
|
51
|
+
render_as_batch=True,
|
|
52
|
+
)
|
|
53
|
+
with context.begin_transaction():
|
|
54
|
+
context.run_migrations()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if context.is_offline_mode():
|
|
58
|
+
run_migrations_offline()
|
|
59
|
+
else:
|
|
60
|
+
run_migrations_online()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""${message}
|
|
2
|
+
|
|
3
|
+
Revision ID: ${up_revision}
|
|
4
|
+
Revises: ${down_revision | comma,n}
|
|
5
|
+
Create Date: ${create_date}
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from typing import Sequence, Union
|
|
9
|
+
|
|
10
|
+
from alembic import op
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
${imports if imports else ""}
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = ${repr(up_revision)}
|
|
16
|
+
down_revision: Union[str, None] = ${repr(down_revision)}
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
${upgrades if upgrades else "pass"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def downgrade() -> None:
|
|
26
|
+
${downgrades if downgrades else "pass"}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""initial schema
|
|
2
|
+
|
|
3
|
+
Revision ID: 10222b47646b
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-07-07 03:16:39.912774
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from typing import Sequence, Union
|
|
9
|
+
|
|
10
|
+
from alembic import op
|
|
11
|
+
import sqlalchemy as sa
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# revision identifiers, used by Alembic.
|
|
15
|
+
revision: str = '10222b47646b'
|
|
16
|
+
down_revision: Union[str, None] = None
|
|
17
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
18
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def upgrade() -> None:
|
|
22
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
23
|
+
op.create_table('budgets',
|
|
24
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
25
|
+
sa.Column('category', sa.String(length=255), nullable=False),
|
|
26
|
+
sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=False),
|
|
27
|
+
sa.Column('period', sa.String(length=10), nullable=False),
|
|
28
|
+
sa.Column('start_date', sa.Date(), nullable=True),
|
|
29
|
+
sa.Column('end_date', sa.Date(), nullable=True),
|
|
30
|
+
sa.Column('notes', sa.Text(), nullable=True),
|
|
31
|
+
sa.Column('created_at', sa.Date(), nullable=True),
|
|
32
|
+
sa.Column('updated_at', sa.Date(), nullable=True),
|
|
33
|
+
sa.PrimaryKeyConstraint('id')
|
|
34
|
+
)
|
|
35
|
+
with op.batch_alter_table('budgets', schema=None) as batch_op:
|
|
36
|
+
batch_op.create_index('idx_budgets_category', ['category'], unique=False)
|
|
37
|
+
batch_op.create_index('idx_budgets_category_period', ['category', 'period'], unique=True)
|
|
38
|
+
batch_op.create_index('idx_budgets_period', ['period'], unique=False)
|
|
39
|
+
|
|
40
|
+
op.create_table('categorization_rules',
|
|
41
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
42
|
+
sa.Column('uuid', sa.String(length=36), nullable=False),
|
|
43
|
+
sa.Column('priority', sa.Integer(), nullable=False),
|
|
44
|
+
sa.Column('rule_type', sa.String(length=50), nullable=False),
|
|
45
|
+
sa.Column('match_pattern', sa.String(length=50), nullable=False),
|
|
46
|
+
sa.Column('field_target', sa.String(length=50), nullable=True),
|
|
47
|
+
sa.Column('match_value', sa.String(length=500), nullable=False),
|
|
48
|
+
sa.Column('category', sa.String(length=100), nullable=False),
|
|
49
|
+
sa.Column('tags', sa.String(), nullable=True),
|
|
50
|
+
sa.Column('is_active', sa.Boolean(), nullable=False),
|
|
51
|
+
sa.Column('notes', sa.Text(), nullable=True),
|
|
52
|
+
sa.Column('filter_account', sa.String(), nullable=True),
|
|
53
|
+
sa.Column('filter_currency', sa.String(length=3), nullable=True),
|
|
54
|
+
sa.Column('filter_date_from', sa.Date(), nullable=True),
|
|
55
|
+
sa.Column('filter_date_to', sa.Date(), nullable=True),
|
|
56
|
+
sa.PrimaryKeyConstraint('id')
|
|
57
|
+
)
|
|
58
|
+
with op.batch_alter_table('categorization_rules', schema=None) as batch_op:
|
|
59
|
+
batch_op.create_index('idx_rules_priority_active', ['priority', 'is_active'], unique=False)
|
|
60
|
+
batch_op.create_index('idx_rules_type', ['rule_type'], unique=False)
|
|
61
|
+
batch_op.create_index(batch_op.f('ix_categorization_rules_uuid'), ['uuid'], unique=True)
|
|
62
|
+
|
|
63
|
+
op.create_table('download_state',
|
|
64
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
65
|
+
sa.Column('source', sa.String(length=50), nullable=False),
|
|
66
|
+
sa.Column('account', sa.String(), nullable=True),
|
|
67
|
+
sa.Column('last_success_at', sa.DateTime(), nullable=True),
|
|
68
|
+
sa.Column('last_range_end', sa.Date(), nullable=True),
|
|
69
|
+
sa.PrimaryKeyConstraint('id')
|
|
70
|
+
)
|
|
71
|
+
with op.batch_alter_table('download_state', schema=None) as batch_op:
|
|
72
|
+
batch_op.create_index('idx_download_state_source_account', ['source', 'account'], unique=True)
|
|
73
|
+
batch_op.create_index(batch_op.f('ix_download_state_account'), ['account'], unique=False)
|
|
74
|
+
batch_op.create_index(batch_op.f('ix_download_state_source'), ['source'], unique=False)
|
|
75
|
+
|
|
76
|
+
op.create_table('rule_change_reports',
|
|
77
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
78
|
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
79
|
+
sa.Column('rule_id', sa.Integer(), nullable=True),
|
|
80
|
+
sa.Column('rule_uuid', sa.String(length=36), nullable=True),
|
|
81
|
+
sa.Column('action', sa.String(length=20), nullable=False),
|
|
82
|
+
sa.Column('rule_before', sa.JSON(), nullable=True),
|
|
83
|
+
sa.Column('rule_after', sa.JSON(), nullable=True),
|
|
84
|
+
sa.Column('summary', sa.JSON(), nullable=True),
|
|
85
|
+
sa.PrimaryKeyConstraint('id')
|
|
86
|
+
)
|
|
87
|
+
with op.batch_alter_table('rule_change_reports', schema=None) as batch_op:
|
|
88
|
+
batch_op.create_index(batch_op.f('ix_rule_change_reports_created_at'), ['created_at'], unique=False)
|
|
89
|
+
batch_op.create_index(batch_op.f('ix_rule_change_reports_rule_id'), ['rule_id'], unique=False)
|
|
90
|
+
batch_op.create_index(batch_op.f('ix_rule_change_reports_rule_uuid'), ['rule_uuid'], unique=False)
|
|
91
|
+
|
|
92
|
+
op.create_table('snapshot_imports',
|
|
93
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
94
|
+
sa.Column('created_at', sa.DateTime(), nullable=False),
|
|
95
|
+
sa.Column('source_machine_id', sa.String(), nullable=True),
|
|
96
|
+
sa.Column('schema_version', sa.Integer(), nullable=True),
|
|
97
|
+
sa.Column('counts', sa.JSON(), nullable=True),
|
|
98
|
+
sa.Column('overwrites', sa.JSON(), nullable=True),
|
|
99
|
+
sa.PrimaryKeyConstraint('id')
|
|
100
|
+
)
|
|
101
|
+
with op.batch_alter_table('snapshot_imports', schema=None) as batch_op:
|
|
102
|
+
batch_op.create_index(batch_op.f('ix_snapshot_imports_created_at'), ['created_at'], unique=False)
|
|
103
|
+
|
|
104
|
+
op.create_table('transactions',
|
|
105
|
+
sa.Column('id', sa.String(), nullable=False),
|
|
106
|
+
sa.Column('accountNumber', sa.String(), nullable=False),
|
|
107
|
+
sa.Column('mutationcode', sa.String(), nullable=True),
|
|
108
|
+
sa.Column('transactiondate', sa.Date(), nullable=False),
|
|
109
|
+
sa.Column('valuedate', sa.Date(), nullable=True),
|
|
110
|
+
sa.Column('startsaldo', sa.Numeric(precision=15, scale=2), nullable=True),
|
|
111
|
+
sa.Column('endsaldo', sa.Numeric(precision=15, scale=2), nullable=True),
|
|
112
|
+
sa.Column('amount', sa.Numeric(precision=15, scale=2), nullable=False),
|
|
113
|
+
sa.Column('description', sa.String(), nullable=True),
|
|
114
|
+
sa.Column('description_structured', sa.Text(), nullable=True),
|
|
115
|
+
sa.Column('category', sa.String(), nullable=True),
|
|
116
|
+
sa.Column('manual_category', sa.String(), nullable=True),
|
|
117
|
+
sa.Column('tags', sa.String(), nullable=True),
|
|
118
|
+
sa.Column('manual_tags', sa.String(), nullable=True),
|
|
119
|
+
sa.Column('categorization_source', sa.String(), nullable=True),
|
|
120
|
+
sa.Column('currency', sa.String(length=3), nullable=False),
|
|
121
|
+
sa.Column('source_file', sa.String(), nullable=True),
|
|
122
|
+
sa.Column('source_line', sa.Integer(), nullable=True),
|
|
123
|
+
sa.Column('transaction_type_code', sa.String(), nullable=True),
|
|
124
|
+
sa.Column('transaction_reference', sa.String(), nullable=True),
|
|
125
|
+
sa.Column('transaction_hash', sa.String(length=64), nullable=True),
|
|
126
|
+
sa.PrimaryKeyConstraint('id')
|
|
127
|
+
)
|
|
128
|
+
with op.batch_alter_table('transactions', schema=None) as batch_op:
|
|
129
|
+
batch_op.create_index('idx_account_date', ['accountNumber', 'transactiondate'], unique=False)
|
|
130
|
+
batch_op.create_index(batch_op.f('ix_transactions_accountNumber'), ['accountNumber'], unique=False)
|
|
131
|
+
batch_op.create_index(batch_op.f('ix_transactions_amount'), ['amount'], unique=False)
|
|
132
|
+
batch_op.create_index(batch_op.f('ix_transactions_category'), ['category'], unique=False)
|
|
133
|
+
batch_op.create_index(batch_op.f('ix_transactions_manual_category'), ['manual_category'], unique=False)
|
|
134
|
+
batch_op.create_index(batch_op.f('ix_transactions_transaction_hash'), ['transaction_hash'], unique=False)
|
|
135
|
+
batch_op.create_index(batch_op.f('ix_transactions_transactiondate'), ['transactiondate'], unique=False)
|
|
136
|
+
|
|
137
|
+
op.create_table('rule_change_items',
|
|
138
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
139
|
+
sa.Column('report_id', sa.Integer(), nullable=False),
|
|
140
|
+
sa.Column('transaction_id', sa.String(), nullable=False),
|
|
141
|
+
sa.Column('old_category', sa.String(), nullable=True),
|
|
142
|
+
sa.Column('new_category', sa.String(), nullable=True),
|
|
143
|
+
sa.Column('old_tags', sa.String(), nullable=True),
|
|
144
|
+
sa.Column('new_tags', sa.String(), nullable=True),
|
|
145
|
+
sa.ForeignKeyConstraint(['report_id'], ['rule_change_reports.id'], ondelete='CASCADE'),
|
|
146
|
+
sa.PrimaryKeyConstraint('id')
|
|
147
|
+
)
|
|
148
|
+
with op.batch_alter_table('rule_change_items', schema=None) as batch_op:
|
|
149
|
+
batch_op.create_index(batch_op.f('ix_rule_change_items_report_id'), ['report_id'], unique=False)
|
|
150
|
+
batch_op.create_index(batch_op.f('ix_rule_change_items_transaction_id'), ['transaction_id'], unique=False)
|
|
151
|
+
|
|
152
|
+
op.create_table('rule_conditions',
|
|
153
|
+
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
|
154
|
+
sa.Column('rule_id', sa.Integer(), nullable=False),
|
|
155
|
+
sa.Column('field_target', sa.String(length=50), nullable=False),
|
|
156
|
+
sa.Column('match_pattern', sa.String(length=50), nullable=False),
|
|
157
|
+
sa.Column('match_value', sa.String(length=500), nullable=False),
|
|
158
|
+
sa.Column('operator', sa.String(length=3), nullable=False),
|
|
159
|
+
sa.Column('sort_order', sa.Integer(), nullable=False),
|
|
160
|
+
sa.ForeignKeyConstraint(['rule_id'], ['categorization_rules.id'], ondelete='CASCADE'),
|
|
161
|
+
sa.PrimaryKeyConstraint('id')
|
|
162
|
+
)
|
|
163
|
+
with op.batch_alter_table('rule_conditions', schema=None) as batch_op:
|
|
164
|
+
batch_op.create_index(batch_op.f('ix_rule_conditions_rule_id'), ['rule_id'], unique=False)
|
|
165
|
+
|
|
166
|
+
# ### end Alembic commands ###
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def downgrade() -> None:
|
|
170
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
171
|
+
with op.batch_alter_table('rule_conditions', schema=None) as batch_op:
|
|
172
|
+
batch_op.drop_index(batch_op.f('ix_rule_conditions_rule_id'))
|
|
173
|
+
|
|
174
|
+
op.drop_table('rule_conditions')
|
|
175
|
+
with op.batch_alter_table('rule_change_items', schema=None) as batch_op:
|
|
176
|
+
batch_op.drop_index(batch_op.f('ix_rule_change_items_transaction_id'))
|
|
177
|
+
batch_op.drop_index(batch_op.f('ix_rule_change_items_report_id'))
|
|
178
|
+
|
|
179
|
+
op.drop_table('rule_change_items')
|
|
180
|
+
with op.batch_alter_table('transactions', schema=None) as batch_op:
|
|
181
|
+
batch_op.drop_index(batch_op.f('ix_transactions_transactiondate'))
|
|
182
|
+
batch_op.drop_index(batch_op.f('ix_transactions_transaction_hash'))
|
|
183
|
+
batch_op.drop_index(batch_op.f('ix_transactions_manual_category'))
|
|
184
|
+
batch_op.drop_index(batch_op.f('ix_transactions_category'))
|
|
185
|
+
batch_op.drop_index(batch_op.f('ix_transactions_amount'))
|
|
186
|
+
batch_op.drop_index(batch_op.f('ix_transactions_accountNumber'))
|
|
187
|
+
batch_op.drop_index('idx_account_date')
|
|
188
|
+
|
|
189
|
+
op.drop_table('transactions')
|
|
190
|
+
with op.batch_alter_table('snapshot_imports', schema=None) as batch_op:
|
|
191
|
+
batch_op.drop_index(batch_op.f('ix_snapshot_imports_created_at'))
|
|
192
|
+
|
|
193
|
+
op.drop_table('snapshot_imports')
|
|
194
|
+
with op.batch_alter_table('rule_change_reports', schema=None) as batch_op:
|
|
195
|
+
batch_op.drop_index(batch_op.f('ix_rule_change_reports_rule_uuid'))
|
|
196
|
+
batch_op.drop_index(batch_op.f('ix_rule_change_reports_rule_id'))
|
|
197
|
+
batch_op.drop_index(batch_op.f('ix_rule_change_reports_created_at'))
|
|
198
|
+
|
|
199
|
+
op.drop_table('rule_change_reports')
|
|
200
|
+
with op.batch_alter_table('download_state', schema=None) as batch_op:
|
|
201
|
+
batch_op.drop_index(batch_op.f('ix_download_state_source'))
|
|
202
|
+
batch_op.drop_index(batch_op.f('ix_download_state_account'))
|
|
203
|
+
batch_op.drop_index('idx_download_state_source_account')
|
|
204
|
+
|
|
205
|
+
op.drop_table('download_state')
|
|
206
|
+
with op.batch_alter_table('categorization_rules', schema=None) as batch_op:
|
|
207
|
+
batch_op.drop_index(batch_op.f('ix_categorization_rules_uuid'))
|
|
208
|
+
batch_op.drop_index('idx_rules_type')
|
|
209
|
+
batch_op.drop_index('idx_rules_priority_active')
|
|
210
|
+
|
|
211
|
+
op.drop_table('categorization_rules')
|
|
212
|
+
with op.batch_alter_table('budgets', schema=None) as batch_op:
|
|
213
|
+
batch_op.drop_index('idx_budgets_period')
|
|
214
|
+
batch_op.drop_index('idx_budgets_category_period')
|
|
215
|
+
batch_op.drop_index('idx_budgets_category')
|
|
216
|
+
|
|
217
|
+
op.drop_table('budgets')
|
|
218
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[alembic]
|
|
2
|
+
script_location = alembic
|
|
3
|
+
prepend_sys_path = src
|
|
4
|
+
# sqlalchemy.url is set programmatically at runtime (data-dir DB) and via env var
|
|
5
|
+
# ABN_COMBINED_DATA_DIR for offline autogenerate.
|
|
6
|
+
|
|
7
|
+
[loggers]
|
|
8
|
+
keys = root,sqlalchemy,alembic
|
|
9
|
+
|
|
10
|
+
[handlers]
|
|
11
|
+
keys = console
|
|
12
|
+
|
|
13
|
+
[formatters]
|
|
14
|
+
keys = generic
|
|
15
|
+
|
|
16
|
+
[logger_root]
|
|
17
|
+
level = WARN
|
|
18
|
+
handlers = console
|
|
19
|
+
qualname =
|
|
20
|
+
|
|
21
|
+
[logger_sqlalchemy]
|
|
22
|
+
level = WARN
|
|
23
|
+
handlers =
|
|
24
|
+
qualname = sqlalchemy.engine
|
|
25
|
+
|
|
26
|
+
[logger_alembic]
|
|
27
|
+
level = INFO
|
|
28
|
+
handlers =
|
|
29
|
+
qualname = alembic
|
|
30
|
+
|
|
31
|
+
[handler_console]
|
|
32
|
+
class = StreamHandler
|
|
33
|
+
args = (sys.stderr,)
|
|
34
|
+
level = NOTSET
|
|
35
|
+
formatter = generic
|
|
36
|
+
|
|
37
|
+
[formatter_generic]
|
|
38
|
+
format = %(levelname)-5.5s [%(name)s] %(message)s
|
|
39
|
+
datefmt = %H:%M:%S
|