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")