From 1db3b008ec9ecd023502a2e4b60458acfce527fd Mon Sep 17 00:00:00 2001 From: kai Date: Wed, 9 Sep 2026 11:56:24 +0200 Subject: [PATCH] Migrate interest positions and deactivate obsolete assets --- README.md | 13 +++++++++++- app/database.py | 22 +++++++++++++++++++- tests/test_migrations.py | 44 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 tests/test_migrations.py diff --git a/README.md b/README.md index 4cceb29..8a36b83 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Standardpfad: **/data/finance.db**, persistent auf dem Host als **./data/finance `income_entries.amount` enthält **ganze Cent (INTEGER)**, keine Euro-Floats. Python summiert Integer und berechnet Prozentwerte mit Decimal. Formulare akzeptieren `0,04`, `0.04` und `28,00`, ohne Tausendertrennzeichen. Mehr als zwei Nachkommastellen werden abgelehnt. Negative Beträge sind für Korrekturen erlaubt. Nur Chart.js verwendet für die grafische Anzeige JavaScript-Zahlen; das ändert keine Finanzwerte in SQLite. -SQLite nutzt Foreign Keys, WAL, kurze Transaktionen und fünf Sekunden Wartezeit bei Locks. Referenzierte Positionen können nicht physisch gelöscht werden; `active=0` deaktiviert sie für neue Buchungen, vorhandene Historie bleibt erhalten. Bei temporären Datenbankproblemen antwortet die App mit HTTP 503. Schema-Version 1 wird über `PRAGMA user_version` geführt. +SQLite nutzt Foreign Keys, WAL, kurze Transaktionen und fünf Sekunden Wartezeit bei Locks. Referenzierte Positionen können nicht physisch gelöscht werden; `active=0` deaktiviert sie für neue Buchungen, vorhandene Historie bleibt erhalten. Bei temporären Datenbankproblemen antwortet die App mit HTTP 503. Schema-Version 2 wird über `PRAGMA user_version` geführt. `expected=1, received=0` bezeichnet eine offene/ausgefallene erwartete Zahlung. Der Betrag enthält dann die Erwartung, fließt aber **nicht** in tatsächliche Summen ein. Eine teilweise erhaltene Zahlung wird als erhaltene Buchung plus separate offene Restbuchung erfasst. Es gibt noch keine automatische Prognose oder Fälligkeitsverwaltung. @@ -244,3 +244,14 @@ curl --fail http://127.0.0.1:8081/health `./deploy.sh` bleibt unverändert nutzbar. Die vorhandene `/data/finance.db` wird weiterverwendet und weder gelöscht noch überschrieben. API-Tests setzen ein zufälliges Testtoken in der Testumgebung und verwenden ausschließlich temporäre Datenbanken. Technische Referenzen: [FastAPI HTTPBearer](https://fastapi.tiangolo.com/reference/security/) und [Pydantic Serialization](https://docs.pydantic.dev/latest/concepts/serialization/). + + +## Positionsänderung: Anleihezinsen und Notgroschen + +Beim ersten Start dieser Version wird automatisch eine einmalige Datenmigration ausgeführt: + +- **Anleihezinsen** (Anleihe) und **Zinsen NG** (Notgroschen/Zinskonto) werden angelegt bzw. aktiviert. +- **Zinsen** und **Steuerrückzahlung** werden deaktiviert und sind für neue Zahlungen nicht mehr auswählbar. +- Vorhandene Buchungen und ihre Zuordnung bleiben unverändert; sie bleiben in Historie und Auswertungen sichtbar. + +Die Migration wird zusammen mit ihrer Ausführungsmarkierung in einer SQLite-Transaktion gespeichert. Spätere Starts überschreiben keine danach vorgenommenen Positionsänderungen. Auf pinguAurora reicht das normale Deployment mit `./deploy.sh`; die Datenbank wird nicht über Git übertragen. diff --git a/app/database.py b/app/database.py index 8f88835..b12accc 100644 --- a/app/database.py +++ b/app/database.py @@ -71,10 +71,30 @@ def initialize(path=None): entry_id INTEGER REFERENCES income_entries(id) ON DELETE SET NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); - PRAGMA user_version = 1; + CREATE TABLE IF NOT EXISTS data_migrations ( + name TEXT PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ); ''') + db.execute('BEGIN IMMEDIATE') if first_setup: for name, kind in [('AGNC','stock'), ('Main Street Capital','stock'), ('Capital Southwest','stock'), ('Ares Capital','stock'), ('Realty Income','stock'), ('Enbridge','stock'), ('Bayer','stock'), ('STOXX Global Select Dividend 100','etf'), ('airBaltic','bond')]: ensure_asset(db, name, kind) + _update_interest_positions(db) + if db.execute('PRAGMA user_version').fetchone()[0] < 2: + db.execute('PRAGMA user_version = 2') + + +def _update_interest_positions(db): + """Apply the requested position changes once, preserving all income history.""" + migration = '2026-09-09-interest-positions' + if db.execute('SELECT 1 FROM data_migrations WHERE name=?', (migration,)).fetchone(): + return + for name, kind in [('Anleihezinsen', 'bond'), ('Zinsen NG', 'interest')]: + asset_id = ensure_asset(db, name, kind) + db.execute('UPDATE assets SET active=1 WHERE id=?', (asset_id,)) + db.execute('UPDATE assets SET active=0 WHERE normalized_name IN (?,?)', + (name_key('Zinsen'), name_key('Steuerrückzahlung'))) + db.execute('INSERT INTO data_migrations (name) VALUES (?)', (migration,)) diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..e5114fa --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,44 @@ +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'app')) +from database import connect, ensure_asset, initialize + + +class PositionMigrationTests(unittest.TestCase): + def test_existing_history_and_one_time_migration(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'finance.db' + initialize(path) + with connect(path) as db: + # Simulate the previous release: no migration marker, old active positions. + db.execute('DELETE FROM data_migrations') + for name in ['Zinsen', 'Steuerrückzahlung']: + asset_id = ensure_asset(db, name) + db.execute("INSERT INTO income_entries (date,asset_id,category,amount) VALUES ('2026-09-01',?,'interest',367)", (asset_id,)) + before = [tuple(row) for row in db.execute('SELECT * FROM income_entries ORDER BY id')] + initialize(path) + with connect(path) as db: + positions = {row['name']: row['active'] for row in db.execute('SELECT * FROM assets')} + self.assertEqual(positions['Anleihezinsen'], 1) + self.assertEqual(positions['Zinsen NG'], 1) + self.assertEqual(positions['Zinsen'], 0) + self.assertEqual(positions['Steuerrückzahlung'], 0) + self.assertEqual(before, [tuple(row) for row in db.execute('SELECT * FROM income_entries ORDER BY id')]) + db.execute("UPDATE assets SET active=0 WHERE name='Zinsen NG'") + initialize(path) + with connect(path) as db: + self.assertEqual(db.execute("SELECT active FROM assets WHERE name='Zinsen NG'").fetchone()[0], 0) + self.assertEqual(db.execute('SELECT COUNT(*) FROM data_migrations').fetchone()[0], 1) + self.assertEqual(db.execute("SELECT COUNT(*) FROM assets WHERE name='Anleihezinsen'").fetchone()[0], 1) + + def test_fresh_database(self): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'finance.db' + initialize(path) + with connect(path) as db: + self.assertEqual(db.execute("SELECT asset_type FROM assets WHERE name='Anleihezinsen'").fetchone()[0], 'bond') + self.assertEqual(db.execute("SELECT asset_type FROM assets WHERE name='Zinsen NG'").fetchone()[0], 'interest') + self.assertEqual(db.execute('PRAGMA user_version').fetchone()[0], 2)