Files
armarium-suite/backend/finance/models.py
T
Daniel Krähenbühl c03d2a97ab feat: insurance section — overview, documents, analysis, KVG premium comparison
- Insurance overview page (/insurance): current policies table with type,
  provider, premium, franchise, coverage, and document links
- Documents page: upload and manage insurance documents
- Analysis page: coverage gap analysis per insurance type
- Priminfo integration (/insurance/priminfo): KVG premium comparison by
  insurer, model (TAR/HMO/etc.), franchise level, and accident coverage
  via embedded Priminfo iframe (no public API available)
- Backend: Insurance, PraemienEntry, PraemienPolice models with migrations
- Sidebar: insurance nav group with flyout and dropdown
- i18n: all keys in DE/EN/FR/IT
2026-05-25 22:46:31 +02:00

334 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from django.db import models
from django.conf import settings
class Account(models.Model):
# Typen basierend auf der Firefly III Logik
ACCOUNT_TYPES = [
('asset', 'Asset Account (Bank/Cash)'),
('expense', 'Expense Account (Laden/Empfänger)'),
('revenue', 'Revenue Account (Einnahmequelle)'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='accounts',
null=True,
)
name = models.CharField(max_length=100)
account_type = models.CharField(max_length=20, choices=ACCOUNT_TYPES, default='asset')
balance = models.DecimalField(max_digits=12, decimal_places=2, default=0.00)
active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.name} ({self.get_account_type_display()})"
class Transaction(models.Model):
description = models.CharField(max_length=255)
amount = models.DecimalField(max_digits=12, decimal_places=2)
date = models.DateField()
# Die Verknüpfung zu den Konten (Double-Entry Prinzip)
source_account = models.ForeignKey(
Account,
on_delete=models.CASCADE,
related_name='withdrawals'
)
destination_account = models.ForeignKey(
Account,
on_delete=models.CASCADE,
related_name='deposits'
)
def __str__(self):
return f"{self.date}: {self.description} ({self.amount}€)"
class Budget(models.Model):
MAIN_CATEGORY_CHOICES = [
('fixed_expenses', 'Fixe Ausgaben'),
('mobile_internet', 'Mobile & Internet'),
('subscriptions', 'Abonnements'),
('leisure', 'Freizeit'),
('tax_reserves', 'Steuerrücklagen'),
('insurance', 'Versicherungen'),
('loans', 'Abzahlungen & Kredite'),
]
name = models.CharField(max_length=100)
amount = models.DecimalField(max_digits=12, decimal_places=2)
main_category = models.CharField(max_length=50, choices=MAIN_CATEGORY_CHOICES, default='fixed_expenses')
account = models.ForeignKey(
Account,
on_delete=models.CASCADE,
related_name='budgets'
)
active = models.BooleanField(default=True)
def __str__(self):
return f"{self.name} ({self.amount} CHF)"
class Expense(models.Model):
CATEGORY_CHOICES = [
('groceries', 'Groceries'),
('dining', 'Dining & Restaurants'),
('transport', 'Transport'),
('health', 'Health & Medical'),
('clothing', 'Clothing'),
('electronics', 'Electronics'),
('household', 'Household'),
('entertainment', 'Entertainment'),
('travel', 'Travel'),
('other', 'Other'),
]
name = models.CharField(max_length=255)
amount = models.DecimalField(max_digits=12, decimal_places=2)
date = models.DateField()
category = models.CharField(max_length=50, choices=CATEGORY_CHOICES, default='other')
account = models.ForeignKey(
Account,
on_delete=models.CASCADE,
related_name='expenses'
)
notes = models.TextField(blank=True, default='')
due_date = models.DateField(blank=True, null=True)
def __str__(self):
return f"{self.date}: {self.name} ({self.amount} CHF)"
CANTON_CHOICES = [
('AG', 'Aargau'), ('AI', 'Appenzell Innerrhoden'), ('AR', 'Appenzell Ausserrhoden'),
('BE', 'Bern'), ('BL', 'Basel-Landschaft'), ('BS', 'Basel-Stadt'),
('FR', 'Fribourg'), ('GE', 'Geneva'), ('GL', 'Glarus'),
('GR', 'Graubünden'), ('JU', 'Jura'), ('LU', 'Lucerne'),
('NE', 'Neuchâtel'), ('NW', 'Nidwalden'), ('OW', 'Obwalden'),
('SG', 'St. Gallen'), ('SH', 'Schaffhausen'), ('SO', 'Solothurn'),
('SZ', 'Schwyz'), ('TG', 'Thurgau'), ('TI', 'Ticino'),
('UR', 'Uri'), ('VD', 'Vaud'), ('VS', 'Valais'),
('ZG', 'Zug'), ('ZH', 'Zürich'),
]
class Deadline(models.Model):
TYPE_CHOICES = [
('tax', 'Tax'),
('insurance', 'Insurance'),
('invoice', 'Invoice'),
('personal', 'Personal'),
('other', 'Other'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='deadlines',
)
title = models.CharField(max_length=200)
date = models.DateField()
type = models.CharField(max_length=20, choices=TYPE_CHOICES, default='other')
notes = models.TextField(blank=True, default='')
def __str__(self):
return f"{self.date}: {self.title}"
class Profile(models.Model):
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='profile',
null=True,
)
first_name = models.CharField(max_length=100, blank=True, default='')
last_name = models.CharField(max_length=100, blank=True, default='')
email = models.EmailField(blank=True, default='')
avatar_color = models.CharField(max_length=7, default='#1A56DB')
avatar_image = models.ImageField(upload_to='avatars/', blank=True, null=True)
canton = models.CharField(max_length=2, choices=CANTON_CHOICES, default='ZH')
language = models.CharField(max_length=2, choices=[('de','Deutsch'),('fr','Français'),('it','Italiano'),('en','English')], default='de')
totp_secret = models.CharField(max_length=64, blank=True, default='')
totp_enabled = models.BooleanField(default=False)
totp_last_used_code = models.CharField(max_length=6, blank=True, default='')
recovery_email = models.EmailField(blank=True, default='')
recovery_code_hash = models.CharField(max_length=64, blank=True, default='')
recovery_code_expires = models.DateTimeField(null=True, blank=True)
notif_deadlines = models.BooleanField(default=True)
notif_budget_alerts = models.BooleanField(default=True)
notif_monthly_summary = models.BooleanField(default=False)
savings_rate_goal = models.PositiveSmallIntegerField(default=20)
email_verified = models.BooleanField(default=False)
email_verify_token = models.CharField(max_length=64, blank=True, default='')
email_verify_token_expires = models.DateTimeField(null=True, blank=True)
password_reset_token_hash = models.CharField(max_length=64, blank=True, default='')
password_reset_token_expires = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"{self.first_name} {self.last_name}".strip() or 'Profile'
class UserSession(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='user_sessions',
)
session_key = models.CharField(max_length=64, unique=True)
refresh_jti = models.CharField(max_length=255, blank=True, default='')
device_name = models.CharField(max_length=200, blank=True, default='')
ip_address = models.GenericIPAddressField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
last_active_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['-last_active_at']
def __str__(self):
return f"{self.user} {self.device_name}"
class ReadEvent(models.Model):
EVENT_TYPES = [
('deadline', 'Deadline'),
('expense', 'Expense'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='read_events',
)
event_type = models.CharField(max_length=20, choices=EVENT_TYPES)
event_id = models.PositiveIntegerField()
class Meta:
unique_together = ['user', 'event_type', 'event_id']
def __str__(self):
return f"{self.user} {self.event_type} {self.event_id}"
class BackupCode(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='backup_codes',
)
code_hash = models.CharField(max_length=64)
used = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [models.Index(fields=['user', 'used'])]
def __str__(self):
return f"{self.user} backup {'used' if self.used else 'active'}"
class Insurance(models.Model):
INSURANCE_TYPES = [
('kvg', 'Krankenkasse Grundversicherung (KVG)'),
('kk_zusatz', 'KK-Zusatzversicherung'),
('nbu', 'Nicht-Berufsunfallversicherung (NBU)'),
('haftpflicht', 'Privathaftpflicht'),
('hausrat', 'Hausrat'),
('mfz', 'MFZ-Haftpflicht'),
('rechtsschutz', 'Rechtsschutz'),
('saule_3a', 'Säule 3a'),
('leben', 'Lebensversicherung'),
('reise', 'Reiseversicherung'),
('other', 'Sonstiges'),
]
PERIOD_CHOICES = [
('monthly', 'Monatlich'),
('quarterly', 'Vierteljährlich'),
('semi_annual', 'Halbjährlich'),
('annual', 'Jährlich'),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='insurances',
)
insurance_type = models.CharField(max_length=30, choices=INSURANCE_TYPES)
insurer = models.CharField(max_length=200)
policy_number = models.CharField(max_length=100, blank=True, default='')
premium = models.DecimalField(max_digits=10, decimal_places=2)
premium_period = models.CharField(max_length=20, choices=PERIOD_CHOICES, default='monthly')
coverage_amount = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
deductible = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
valid_from = models.DateField(null=True, blank=True)
valid_until = models.DateField(null=True, blank=True)
notes = models.TextField(blank=True, default='')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['insurance_type']
def __str__(self):
return f"{self.get_insurance_type_display()} {self.insurer}"
class PraemienEntry(models.Model):
"""
Swiss health insurance average premium data from BAG / Priminfo.
Populated via management command: python manage.py import_praemien [year]
Source: https://www.priminfo.admin.ch/downloads/praemienregionen_{year}.xlsx
"""
plz = models.CharField(max_length=10, db_index=True)
ort = models.CharField(max_length=200)
kanton = models.CharField(max_length=2)
region = models.PositiveSmallIntegerField() # Prämienregion 0, 1, 2, or 3
bfs_nr = models.PositiveIntegerField(db_index=True)
gemeinde = models.CharField(max_length=200)
bezirk = models.CharField(max_length=200, blank=True, default='')
avg_adult = models.DecimalField(max_digits=8, decimal_places=2)
avg_young_adult = models.DecimalField(max_digits=8, decimal_places=2)
avg_child = models.DecimalField(max_digits=8, decimal_places=2)
data_year = models.PositiveSmallIntegerField(db_index=True)
class Meta:
unique_together = ['plz', 'ort', 'data_year']
ordering = ['kanton', 'ort']
def __str__(self):
return f"{self.plz} {self.ort} ({self.kanton}) Region {self.region} {self.data_year}"
class PraemienPolice(models.Model):
"""
Granular KVG premium data per insurer, canton, region, age class, model, franchise.
Populated via management command: python manage.py import_praemien [year]
Source: https://opendata.bagnet.ch (Prämien_CH.csv)
~217k rows for a full year.
"""
versicherer_id = models.PositiveIntegerField(db_index=True)
kanton = models.CharField(max_length=2)
region = models.PositiveSmallIntegerField() # 0, 1, 2, 3
altersklasse = models.CharField(max_length=10) # AKL-ERW / AKL-JUG / AKL-KIN
unfalleinschluss = models.CharField(max_length=10) # MIT-UNF / OHN-UNF
tariftyp = models.CharField(max_length=10) # TAR-BASE / TAR-HAM / TAR-HMO / TAR-DIV
tarifbezeichnung = models.CharField(max_length=200)
franchisestufe = models.CharField(max_length=10) # FRAST1 … FRAST7
franchise_chf = models.PositiveSmallIntegerField() # e.g. 300, 500, 1000 …
praemie = models.DecimalField(max_digits=8, decimal_places=2)
data_year = models.PositiveSmallIntegerField(db_index=True)
class Meta:
unique_together = [
'versicherer_id', 'kanton', 'region', 'altersklasse',
'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year',
]
indexes = [
models.Index(fields=[
'kanton', 'region', 'altersklasse',
'unfalleinschluss', 'tariftyp', 'franchisestufe', 'data_year',
]),
]
def __str__(self):
return (f"V{self.versicherer_id} {self.kanton} R{self.region} "
f"{self.altersklasse} {self.tariftyp} {self.franchisestufe}{self.praemie} CHF")