Migrate interest positions and deactivate obsolete assets

This commit is contained in:
kai
2026-09-09 11:56:24 +02:00
parent ad452045be
commit 1db3b008ec
3 changed files with 77 additions and 2 deletions
+12 -1
View File
@@ -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.
+21 -1
View File
@@ -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,))
+44
View File
@@ -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)