diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d52ab7..8389fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,58 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.1.0] - 2026-05-19 +## [Unreleased] + +### Added +- Financial Year: Income tab zeigt neu Revenue Accounts (Typ «Einnahmequelle») statt YearlyIncome-Einträge — Monatsgehalt × Monate = Jahreseinkommen; Toggle-Button pro Konto für 12 oder 13 Monatslöhne; Gesamtjahreseinkommen-Summe am Tab-Ende +- Account-Model: `salary_months` Feld (IntegerField, default 12, choices 12/13, Migration 0021); `patchAccount()` in ApiService +- Financial Year: Summary-Cards überarbeitet — (1) Jahreseinkommen aus Revenue Accounts, (2) Fixkosten/Monat × 12 = Jahresbetrag aus `/budgets`, (3) tatsächliche Ausgaben des gewählten Jahres aus `/expenses` (ersetzt «Verfügbar») +- Financial Year: Haushalt-Finanzjahr erstellbar — Modal «Neues Jahr starten» zeigt Radio-Buttons «Persönlich» / Haushalt-Name wenn User aktive Haushaltsmitgliedschaft hat; Backend akzeptiert optionales `household_id` bei `POST /api/financial-years/` +- Financial Year: Haushalt-Modus Einnahmen-Tab zeigt Revenue Accounts aller aktiven Haushaltsmitglieder (neuer Endpoint `GET /api/households//revenue-accounts/`); Partner-Accounts mit E-Mail-Hinweis +- `household_id` in FinancialYear-Serializer-Response +- Haushalt Einladungsflow für nicht-registrierte Benutzer: `PendingHouseholdInvite` Model (Migration 0022) speichert E-Mail ohne User-FK; nach Registrierung wird `HouseholdMembership` automatisch angelegt und `PendingHouseholdInvite` gelöscht +- Einladungs-E-Mail via `household_invite` Template (HTML + Plaintext) mit variablem CTA-Label; bestehende User erhalten «Einladung annehmen» → `/financial-year`, neue User «Konto erstellen & beitreten» → `/register` +- Frontend zeigt ausstehende Einladungen an nicht-registrierte Adressen mit Badge «Nicht registriert» in der Haushaltsliste +- `FRONTEND_URL` Setting (default `http://localhost:4200`; Prod: `https://www.armarium.ch` in `.env`) +- ProfileSerializer: `get_email()` gibt `user.email` zurück wenn Profile-Email leer — verhindert dass `myMembership()` für neue User keine Treffer findet + +### Fixed +- Dashboard: `totalExpenses()` filterte nicht nach ausgewähltem Jahr — alle Ausgaben wurden summiert +- Dashboard: `totalIncome()` und `totalFixedCosts()` lasen aus FinancialYear statt aus Revenue Accounts / `/budgets` — inkonsistent mit Dateneingabe-Workflow des Users +- Financial Year: `updateIncome()` und `updateBudgetItem()` verwendeten `PUT` statt `PATCH` → 405 Method Not Allowed +- Financial Year: `reloadCurrentYear()` löste `NG0100 ExpressionChangedAfterItHasBeenCheckedError` aus — Signal-Updates in `setTimeout()` verschoben +- Financial Year: `PATCH /incomes//` und `/budget-items//` gaben 403 zurück wenn `is_active=False` auf FinancialYear — `is_active`-Check aus 5 Backend-Views entfernt +- Backend: `Profile.email_verified` und verwandte Felder existierten in DB aber nicht im Model → `IntegrityError` beim Login neuer User; Felder ins Model aufgenommen (Migration 0023, fake-applied); DB-Defaults via `ALTER COLUMN ... SET DEFAULT` gesetzt + +### Added +- Feature: Jahresplanung (`/financial-year`) — neue Seite mit Jahres-Dropdown, 3 Summary-Cards (Einnahmen, Fixkosten, Verfügbar + Sparquote), Tabs Einnahmen/Fixkosten, Inline-Formular für CRUD; Button "Neues Jahr starten" — max. 1 Jahr im Voraus (Backend + Frontend enforced) +- Backend: `FinancialYear`, `YearlyIncome`, `YearlyBudgetItem`, `Household`, `HouseholdMembership` Modelle (Migration 0019); exclusivity-Constraint via `CheckConstraint(condition=...)` (Django 6.0.4), partielle Unique-Constraints für persönliche und Haushalt-Jahre +- Backend: `FinancialYearListCreateView`, `FinancialYearDetailView`, `FinancialYearCopyView`, `YearlyIncomeListCreateView/DetailView`, `YearlyBudgetItemListCreateView/DetailView`, `HouseholdListCreateView`, `HouseholdInviteView`, `HouseholdAcceptView`, `HouseholdLeaveView`, `HouseholdSetRoleView` +- Backend: `GET/POST /api/financial-years/`, `GET/PATCH/DELETE /api/financial-years//`, `POST /api/financial-years//copy-from//`, nested Endpunkte für Incomes und Budget Items; `GET/POST /api/households/`, Invite/Accept/Leave/SetRole +- Backend: Jahr-Erstellungs-Begrenzer — max. `current_calendar_year + 1`; plus "nur nächstes Jahr nach dem Maximum" Constraint +- Backend: `role` Feld auf `HouseholdMembership` (`member` | `admin`, Migration 0020); Gründer erhält automatisch `role='admin'`; Einladungen erlaubt für Gründer und aktive Admins; Rollenvergabe nur durch Gründer via `POST /api/households//members//set-role/` +- Dashboard: `totalIncome()` und `totalFixedCosts()` lesen nun aus `FinancialYearService.list()` für das gewählte Jahr (statt alte Account/Budget-Daten); Jahres-Dropdown zeigt echte FinancialYear-Jahre; Donut-Chart zeigt `YearlyBudgetItem` des gewählten Jahres; Jahrwechsel re-rendert beide Charts +- Backend: Django Management Command `migrate_to_financial_year` — migriert bestehende Revenue-Accounts → `YearlyIncome` und Budgets → `YearlyBudgetItem` für Jahr 2026; idempotent, `--dry-run` Flag verfügbar +- Frontend: `FinancialYearService` (`services/financial-year.ts`) mit Typen `FinancialYear`, `YearlyIncome`, `YearlyBudgetItem`, `Household`, `HouseholdMembership` und allen API-Methoden +- Frontend: Household-Sektion auf `/financial-year` — Haushalt gründen (Inline-Form), Mitglieder-Liste mit Status- und Rollen-Badge, Einladen per E-Mail (Admins + Gründer), Rollen-Toggle (Key-Icon, nur Gründer), Pending-Einladungs-Banner mit "Annehmen", "Verlassen"-Button mit Bestätigungs-Modal +- Sidebar: "Jahresplanung" Nav-Item (Bar-Chart-Icon, Violet) zwischen Kalender und Konten +- i18n: `sidebar.financial_year`, `financial_year.*` Schlüssel (DE/EN/FR/IT) +- Dashboard: Einnahmen vs. Ausgaben — Flowbite-Redesign mit Icon-Header (Violet), 3 Serien (Einnahmen/Fixkosten/Variable Ausgaben), gerundete Balken, kein Grid/Y-Axis, custom Tooltip mit ausgeschriebenem Monatsnamen in Landessprache, Jahres-Dropdown im Footer +- Dashboard: Fixkostenaufschlüsselung — Pie Chart (war: Donut) mit %-Datenlabels direkt auf Segmenten; Toggle-Button (Violet) wechselt zur Listenansicht mit Name, CHF-Betrag und %; Violet-Farbpalette +- Dashboard: Sparquote — Violet-Marker auf Progress-Bar an der Zielposition; Settings-Toggle (Badge-Icon, Violet) öffnet Einstellungsansicht mit Zahlenfeld, Live-Marker-Preview und Speichern/Abbrechen; Ziel persisted im Profil (`savings_rate_goal`, Default 20%) +- Backend: `savings_rate_goal` Feld auf `Profile`-Modell (Migration 0018) +- i18n: `dashboard.view_report`, `dashboard.goal_hint` in DE/EN/FR/IT; `dashboard.goal` von "Ziel: 20%" zu "Sparziel" geändert +- Security: Cloudflare Turnstile CAPTCHA on login and register — `TurnstileComponent` (Angular, polls until script loaded, auto-reset on error); backend verifies token via `_verify_turnstile()` using urllib (no extra dependency); `DEBUG=True` and `localhost` bypass for local development; Submit button disabled until widget resolves +- Infrastructure: Brevo SMTP configured for transactional email (`smtp-relay.brevo.com:587`, TLS); domain `armarium.ch` verified with SPF/DKIM; account activation pending (requested via contact@brevo.com) +- i18n: `auth.errors.captcha_failed` key in DE/EN/FR/IT +- Docs: `design-system.md` — Brand design reference with colors, typography (desktop/mobile), icons, component patterns and Tailwind classes + +### Changed +- `.env.example`: added `TURNSTILE_SECRET_KEY` and Brevo `EMAIL_*` variables + +--- + +## [1.1.0] - 2026-05-17 ### Added - Auth: E-Mail-Verifikation bei Registrierung — Token (SHA-256-Hash in DB, 24h gültig) wird per Mail versendet; `/verify-email?token=` Frontend-Route löst automatisch `POST /api/auth/verify-email/` aus diff --git a/backend/core/settings.py b/backend/core/settings.py index 17a4009..8f18775 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -104,6 +104,7 @@ EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '') EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '') EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'True') == 'True' DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@armarium.ch') +FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:4200') LOGGING = { 'version': 1, diff --git a/backend/core/urls.py b/backend/core/urls.py index a79c45a..041c4f4 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -14,6 +14,11 @@ from finance.views import ( SessionListView, SessionRevokeView, SessionRevokeAllView, DataExportView, NotificationPrefsView, VerifyEmailView, PasswordResetRequestView, PasswordResetConfirmView, + FinancialYearListCreateView, FinancialYearDetailView, FinancialYearCopyView, + YearlyIncomeListCreateView, YearlyIncomeDetailView, + YearlyBudgetItemListCreateView, YearlyBudgetItemDetailView, + HouseholdListCreateView, HouseholdInviteView, HouseholdAcceptView, HouseholdLeaveView, + HouseholdSetRoleView, HouseholdRevenueAccountsView, ) router = DefaultRouter() @@ -52,4 +57,17 @@ urlpatterns = [ path('api/notifications/', NotificationsView.as_view()), path('api/calendar/ical-url/', ICalUrlView.as_view()), path('api/calendar/ical///', ICalFeedView.as_view()), + path('api/financial-years/', FinancialYearListCreateView.as_view()), + path('api/financial-years//', FinancialYearDetailView.as_view()), + path('api/financial-years//copy-from//', FinancialYearCopyView.as_view()), + path('api/financial-years//incomes/', YearlyIncomeListCreateView.as_view()), + path('api/financial-years//incomes//', YearlyIncomeDetailView.as_view()), + path('api/financial-years//budget-items/', YearlyBudgetItemListCreateView.as_view()), + path('api/financial-years//budget-items//', YearlyBudgetItemDetailView.as_view()), + path('api/households/', HouseholdListCreateView.as_view()), + path('api/households//invite/', HouseholdInviteView.as_view()), + path('api/households//accept/', HouseholdAcceptView.as_view()), + path('api/households//leave/', HouseholdLeaveView.as_view()), + path('api/households//members//set-role/', HouseholdSetRoleView.as_view()), + path('api/households//revenue-accounts/', HouseholdRevenueAccountsView.as_view()), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/backend/finance/management/__init__.py b/backend/finance/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/finance/management/commands/__init__.py b/backend/finance/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/finance/management/commands/migrate_to_financial_year.py b/backend/finance/management/commands/migrate_to_financial_year.py new file mode 100644 index 0000000..b6b93a6 --- /dev/null +++ b/backend/finance/management/commands/migrate_to_financial_year.py @@ -0,0 +1,85 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model + +from finance.models import Account, Budget, FinancialYear, YearlyIncome, YearlyBudgetItem + +User = get_user_model() +TARGET_YEAR = 2026 + + +class Command(BaseCommand): + help = 'Migrate existing revenue accounts and budgets into FinancialYear 2026. Idempotent — safe to run multiple times.' + + def add_arguments(self, parser): + parser.add_argument( + '--dry-run', + action='store_true', + help='Preview what would be created without writing to the database.', + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + if dry_run: + self.stdout.write(self.style.WARNING('DRY RUN — no changes will be saved.\n')) + + total_incomes = 0 + total_budgets = 0 + + for user in User.objects.all(): + revenue_accounts = Account.objects.filter(user=user, account_type='revenue', active=True) + budgets = Budget.objects.filter(account__user=user, active=True) + + if not revenue_accounts.exists() and not budgets.exists(): + continue + + self.stdout.write(f'\nUser: {user.email}') + + if dry_run: + fy = FinancialYear.objects.filter(user=user, year=TARGET_YEAR).first() + if fy: + self.stdout.write(f' FinancialYear {TARGET_YEAR} already exists (id={fy.pk})') + else: + self.stdout.write(f' Would create FinancialYear {TARGET_YEAR}') + else: + fy, fy_created = FinancialYear.objects.get_or_create(user=user, year=TARGET_YEAR) + if fy_created: + self.stdout.write(f' Created FinancialYear {TARGET_YEAR} (id={fy.pk})') + else: + self.stdout.write(f' FinancialYear {TARGET_YEAR} exists (id={fy.pk})') + + for account in revenue_accounts: + label = f'YearlyIncome "{account.name}" CHF {account.balance}' + if dry_run: + exists = fy and YearlyIncome.objects.filter(financial_year=fy, name=account.name).exists() + self.stdout.write(f' {"SKIP (exists)" if exists else "Would create"}: {label}') + else: + _, created = YearlyIncome.objects.get_or_create( + financial_year=fy, + name=account.name, + defaults={'amount': account.balance, 'member': user, 'active': True}, + ) + self.stdout.write(f' {"Created" if created else "Skipped (exists)"}: {label}') + if created: + total_incomes += 1 + + for budget in budgets: + label = f'YearlyBudgetItem "{budget.name}" CHF {budget.amount}' + if dry_run: + exists = fy and YearlyBudgetItem.objects.filter(financial_year=fy, name=budget.name).exists() + self.stdout.write(f' {"SKIP (exists)" if exists else "Would create"}: {label}') + else: + _, created = YearlyBudgetItem.objects.get_or_create( + financial_year=fy, + name=budget.name, + defaults={'amount': budget.amount, 'active': budget.active}, + ) + self.stdout.write(f' {"Created" if created else "Skipped (exists)"}: {label}') + if created: + total_budgets += 1 + + if not dry_run: + self.stdout.write(self.style.SUCCESS( + f'\nDone. Created {total_incomes} income(s) and {total_budgets} budget item(s).' + )) + else: + self.stdout.write(self.style.WARNING('\nDry run complete. Re-run without --dry-run to apply.')) diff --git a/backend/finance/migrations/0019_financial_year.py b/backend/finance/migrations/0019_financial_year.py new file mode 100644 index 0000000..624b8de --- /dev/null +++ b/backend/finance/migrations/0019_financial_year.py @@ -0,0 +1,89 @@ +# Generated by Django 6.0.4 on 2026-05-18 20:16 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0018_profile_savings_rate_goal'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Household', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_households', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='FinancialYear', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('year', models.PositiveSmallIntegerField()), + ('is_active', models.BooleanField(default=True)), + ('notes', models.TextField(blank=True, default='')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='financial_years', to=settings.AUTH_USER_MODEL)), + ('household', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='financial_years', to='finance.household')), + ], + ), + migrations.CreateModel( + name='HouseholdMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('pending', 'Pending'), ('active', 'Active'), ('left', 'Left')], default='pending', max_length=10)), + ('effective_from_year', models.PositiveSmallIntegerField(blank=True, null=True)), + ('effective_until_year', models.PositiveSmallIntegerField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('household', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='finance.household')), + ('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_invitations', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='household_memberships', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='YearlyBudgetItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('amount', models.DecimalField(decimal_places=2, max_digits=12)), + ('active', models.BooleanField(default=True)), + ('notes', models.TextField(blank=True, default='')), + ('financial_year', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='budget_items', to='finance.financialyear')), + ], + ), + migrations.CreateModel( + name='YearlyIncome', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('amount', models.DecimalField(decimal_places=2, max_digits=12)), + ('active', models.BooleanField(default=True)), + ('notes', models.TextField(blank=True, default='')), + ('financial_year', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='incomes', to='finance.financialyear')), + ('member', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='yearly_incomes', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddConstraint( + model_name='financialyear', + constraint=models.CheckConstraint(condition=models.Q(models.Q(('household__isnull', True), ('user__isnull', False)), models.Q(('household__isnull', False), ('user__isnull', True)), _connector='OR'), name='financial_year_owner_exclusive'), + ), + migrations.AddConstraint( + model_name='financialyear', + constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'year'), name='unique_personal_financial_year'), + ), + migrations.AddConstraint( + model_name='financialyear', + constraint=models.UniqueConstraint(condition=models.Q(('household__isnull', False)), fields=('household', 'year'), name='unique_household_financial_year'), + ), + migrations.AlterUniqueTogether( + name='householdmembership', + unique_together={('household', 'user')}, + ), + ] diff --git a/backend/finance/migrations/0020_household_membership_role.py b/backend/finance/migrations/0020_household_membership_role.py new file mode 100644 index 0000000..7f68c59 --- /dev/null +++ b/backend/finance/migrations/0020_household_membership_role.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-05-19 07:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0019_financial_year'), + ] + + operations = [ + migrations.AddField( + model_name='householdmembership', + name='role', + field=models.CharField(choices=[('member', 'Member'), ('admin', 'Admin')], default='member', max_length=10), + ), + ] diff --git a/backend/finance/migrations/0021_add_salary_months_to_account.py b/backend/finance/migrations/0021_add_salary_months_to_account.py new file mode 100644 index 0000000..d33e552 --- /dev/null +++ b/backend/finance/migrations/0021_add_salary_months_to_account.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.4 on 2026-05-21 18:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0020_household_membership_role'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='salary_months', + field=models.IntegerField(choices=[(12, 12), (13, 13)], default=12), + ), + ] diff --git a/backend/finance/migrations/0022_add_pending_household_invite.py b/backend/finance/migrations/0022_add_pending_household_invite.py new file mode 100644 index 0000000..5830c24 --- /dev/null +++ b/backend/finance/migrations/0022_add_pending_household_invite.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0.4 on 2026-05-21 19:49 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0021_add_salary_months_to_account'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='PendingHouseholdInvite', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('invited_email', models.EmailField(max_length=254)), + ('effective_from_year', models.PositiveSmallIntegerField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('household', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pending_invites', to='finance.household')), + ('invited_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sent_pending_invitations', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('household', 'invited_email')}, + }, + ), + ] diff --git a/backend/finance/migrations/0023_sync_profile_fields.py b/backend/finance/migrations/0023_sync_profile_fields.py new file mode 100644 index 0000000..56182b7 --- /dev/null +++ b/backend/finance/migrations/0023_sync_profile_fields.py @@ -0,0 +1,38 @@ +# Generated by Django 6.0.4 on 2026-05-21 20:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('finance', '0022_add_pending_household_invite'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='email_verified', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='profile', + name='email_verify_token', + field=models.CharField(blank=True, default='', max_length=64), + ), + migrations.AddField( + model_name='profile', + name='email_verify_token_expires', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='profile', + name='password_reset_token_expires', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='profile', + name='password_reset_token_hash', + field=models.CharField(blank=True, default='', max_length=64), + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 52652bf..7787865 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -18,6 +18,7 @@ class Account(models.Model): 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) + salary_months = models.IntegerField(default=12, choices=[(12, 12), (13, 13)]) active = models.BooleanField(default=True) created_at = models.DateTimeField(auto_now_add=True) @@ -223,4 +224,145 @@ class BackupCode(models.Model): indexes = [models.Index(fields=['user', 'used'])] def __str__(self): - return f"{self.user} – backup {'used' if self.used else 'active'}" \ No newline at end of file + return f"{self.user} – backup {'used' if self.used else 'active'}" + + +# ── FinancialYear ───────────────────────────────────────────────────────────── + +class Household(models.Model): + name = models.CharField(max_length=100) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name='created_households', + ) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.name + + +class HouseholdMembership(models.Model): + STATUS_CHOICES = [ + ('pending', 'Pending'), + ('active', 'Active'), + ('left', 'Left'), + ] + ROLE_CHOICES = [ + ('member', 'Member'), + ('admin', 'Admin'), + ] + household = models.ForeignKey(Household, on_delete=models.CASCADE, related_name='memberships') + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='household_memberships', + ) + invited_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name='sent_invitations', + ) + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending') + role = models.CharField(max_length=10, choices=ROLE_CHOICES, default='member') + effective_from_year = models.PositiveSmallIntegerField(null=True, blank=True) + effective_until_year = models.PositiveSmallIntegerField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ['household', 'user'] + + def __str__(self): + return f"{self.user} in {self.household} ({self.status})" + + +class PendingHouseholdInvite(models.Model): + household = models.ForeignKey(Household, on_delete=models.CASCADE, related_name='pending_invites') + invited_email = models.EmailField() + invited_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name='sent_pending_invitations', + ) + effective_from_year = models.PositiveSmallIntegerField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ['household', 'invited_email'] + + def __str__(self): + return f"Pending invite for {self.invited_email} to {self.household}" + + +class FinancialYear(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='financial_years', + null=True, blank=True, + ) + household = models.ForeignKey( + Household, + on_delete=models.CASCADE, + related_name='financial_years', + null=True, blank=True, + ) + year = models.PositiveSmallIntegerField() + is_active = models.BooleanField(default=True) + notes = models.TextField(blank=True, default='') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.CheckConstraint( + condition=( + models.Q(user__isnull=False, household__isnull=True) | + models.Q(user__isnull=True, household__isnull=False) + ), + name='financial_year_owner_exclusive', + ), + models.UniqueConstraint( + fields=['user', 'year'], + condition=models.Q(user__isnull=False), + name='unique_personal_financial_year', + ), + models.UniqueConstraint( + fields=['household', 'year'], + condition=models.Q(household__isnull=False), + name='unique_household_financial_year', + ), + ] + + def __str__(self): + owner = self.user or self.household + return f"{owner} — {self.year}" + + +class YearlyIncome(models.Model): + financial_year = models.ForeignKey(FinancialYear, on_delete=models.CASCADE, related_name='incomes') + member = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name='yearly_incomes', + ) + name = models.CharField(max_length=100) + amount = models.DecimalField(max_digits=12, decimal_places=2) + active = models.BooleanField(default=True) + notes = models.TextField(blank=True, default='') + + def __str__(self): + return f"{self.name}: CHF {self.amount} ({self.financial_year.year})" + + +class YearlyBudgetItem(models.Model): + financial_year = models.ForeignKey(FinancialYear, on_delete=models.CASCADE, related_name='budget_items') + name = models.CharField(max_length=100) + amount = models.DecimalField(max_digits=12, decimal_places=2) + active = models.BooleanField(default=True) + notes = models.TextField(blank=True, default='') + + def __str__(self): + return f"{self.name}: CHF {self.amount} ({self.financial_year.year})" \ No newline at end of file diff --git a/backend/finance/serializers.py b/backend/finance/serializers.py index 2768263..b9a4dc2 100644 --- a/backend/finance/serializers.py +++ b/backend/finance/serializers.py @@ -1,6 +1,10 @@ from rest_framework import serializers from django.contrib.auth import get_user_model -from .models import Account, Transaction, Budget, Expense, Profile, Deadline +from .models import ( + Account, Transaction, Budget, Expense, Profile, Deadline, + Household, HouseholdMembership, PendingHouseholdInvite, + FinancialYear, YearlyIncome, YearlyBudgetItem, +) User = get_user_model() @@ -44,6 +48,10 @@ class ExpenseSerializer(serializers.ModelSerializer): class ProfileSerializer(serializers.ModelSerializer): totp_enabled = serializers.BooleanField(read_only=True) + email = serializers.SerializerMethodField() + + def get_email(self, obj): + return obj.email or (obj.user.email if obj.user else '') class Meta: model = Profile @@ -56,6 +64,76 @@ class DeadlineSerializer(serializers.ModelSerializer): exclude = ['user'] +class HouseholdMembershipSerializer(serializers.ModelSerializer): + user_email = serializers.EmailField(source='user.email', read_only=True) + invited_by_email = serializers.EmailField(source='invited_by.email', read_only=True) + + class Meta: + model = HouseholdMembership + fields = ['id', 'user', 'user_email', 'invited_by_email', 'status', 'role', + 'effective_from_year', 'effective_until_year', 'created_at'] + read_only_fields = ['id', 'user', 'user_email', 'invited_by_email', 'created_at'] + + +class PendingHouseholdInviteSerializer(serializers.ModelSerializer): + invited_by_email = serializers.EmailField(source='invited_by.email', read_only=True) + + class Meta: + model = PendingHouseholdInvite + fields = ['id', 'invited_email', 'invited_by_email', 'effective_from_year', 'created_at'] + read_only_fields = fields + + +class HouseholdSerializer(serializers.ModelSerializer): + memberships = HouseholdMembershipSerializer(many=True, read_only=True) + pending_invites = PendingHouseholdInviteSerializer(many=True, read_only=True) + created_by_email = serializers.EmailField(source='created_by.email', read_only=True) + + class Meta: + model = Household + fields = ['id', 'name', 'created_by_email', 'memberships', 'pending_invites', 'created_at'] + read_only_fields = ['id', 'created_by_email', 'memberships', 'pending_invites', 'created_at'] + + +class YearlyIncomeSerializer(serializers.ModelSerializer): + member_email = serializers.EmailField(source='member.email', read_only=True) + + class Meta: + model = YearlyIncome + fields = ['id', 'member', 'member_email', 'name', 'amount', 'active', 'notes'] + read_only_fields = ['id', 'member_email'] + + +class YearlyBudgetItemSerializer(serializers.ModelSerializer): + class Meta: + model = YearlyBudgetItem + fields = ['id', 'name', 'amount', 'active', 'notes'] + read_only_fields = ['id'] + + +class FinancialYearSerializer(serializers.ModelSerializer): + incomes = YearlyIncomeSerializer(many=True, read_only=True) + budget_items = YearlyBudgetItemSerializer(many=True, read_only=True) + total_income = serializers.SerializerMethodField() + total_fixed_costs = serializers.SerializerMethodField() + owner_type = serializers.SerializerMethodField() + + class Meta: + model = FinancialYear + fields = ['id', 'year', 'is_active', 'notes', 'owner_type', 'household_id', + 'total_income', 'total_fixed_costs', 'incomes', 'budget_items', 'created_at'] + read_only_fields = ['id', 'created_at', 'owner_type', 'household_id', 'total_income', 'total_fixed_costs'] + + def get_total_income(self, obj): + return sum(i.amount for i in obj.incomes.filter(active=True)) + + def get_total_fixed_costs(self, obj): + return sum(b.amount for b in obj.budget_items.filter(active=True)) + + def get_owner_type(self, obj): + return 'household' if obj.household_id else 'personal' + + class RegisterSerializer(serializers.Serializer): email = serializers.EmailField() password = serializers.CharField(min_length=8, write_only=True) @@ -67,8 +145,21 @@ class RegisterSerializer(serializers.Serializer): def create(self, validated_data): email = validated_data['email'] - return User.objects.create_user( + user = User.objects.create_user( username=email, email=email, password=validated_data['password'], ) + from .models import PendingHouseholdInvite, HouseholdMembership + for invite in PendingHouseholdInvite.objects.filter(invited_email__iexact=email): + HouseholdMembership.objects.get_or_create( + household=invite.household, + user=user, + defaults={ + 'invited_by': invite.invited_by, + 'status': 'pending', + 'effective_from_year': invite.effective_from_year, + }, + ) + invite.delete() + return user diff --git a/backend/finance/views.py b/backend/finance/views.py index 0974c98..7bfe0ed 100644 --- a/backend/finance/views.py +++ b/backend/finance/views.py @@ -1,4 +1,5 @@ import base64 +import datetime import hmac import hashlib import json @@ -15,16 +16,23 @@ from django.contrib.auth import get_user_model, authenticate from django.http import HttpResponse from icalendar import Calendar as iCalendar, Event as iCalEvent +from django.db import models from rest_framework import viewsets, views, status from rest_framework.response import Response from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.throttling import AnonRateThrottle from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.exceptions import TokenError -from .models import Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession +from django.db import transaction as db_transaction +from .models import ( + Account, Transaction, Budget, Expense, Profile, Deadline, ReadEvent, BackupCode, UserSession, + Household, HouseholdMembership, FinancialYear, YearlyIncome, YearlyBudgetItem, +) from .serializers import ( AccountSerializer, TransactionSerializer, BudgetSerializer, ExpenseSerializer, ProfileSerializer, DeadlineSerializer, RegisterSerializer, + HouseholdSerializer, HouseholdMembershipSerializer, + FinancialYearSerializer, YearlyIncomeSerializer, YearlyBudgetItemSerializer, ) @@ -997,3 +1005,454 @@ class PasswordResetConfirmView(views.APIView): for session in UserSession.objects.filter(user=user): _blacklist_session(session) return Response({'detail': 'Password updated.'}) + + +# ── FinancialYear Helpers ───────────────────────────────────────────────────── + +def _get_user_financial_year(user, year): + """Return the FinancialYear for a given year accessible to this user.""" + # Personal year + fy = FinancialYear.objects.filter(user=user, year=year).first() + if fy: + return fy + # Household year where user is an active member for this year + memberships = HouseholdMembership.objects.filter( + user=user, + status='active', + effective_from_year__lte=year, + ).filter( + models.Q(effective_until_year__isnull=True) | models.Q(effective_until_year__gt=year) + ) + household_ids = memberships.values_list('household_id', flat=True) + return FinancialYear.objects.filter(household_id__in=household_ids, year=year).first() + + +def _all_user_financial_years(user): + """Return all FinancialYears accessible to this user.""" + personal = FinancialYear.objects.filter(user=user) + memberships = HouseholdMembership.objects.filter(user=user, status='active') + household_ids = memberships.values_list('household_id', flat=True) + household = FinancialYear.objects.filter(household_id__in=household_ids) + return (personal | household).distinct().order_by('-year') + + +def _max_year_for_user(user): + """Return the highest year the user currently has access to.""" + years = _all_user_financial_years(user).values_list('year', flat=True) + return max(years) if years else None + + +# ── FinancialYear Views ─────────────────────────────────────────────────────── + +class FinancialYearListCreateView(views.APIView): + def get(self, request): + qs = _all_user_financial_years(request.user) + return Response(FinancialYearSerializer(qs, many=True).data) + + def post(self, request): + year = request.data.get('year') + if not year: + return Response({'year': 'This field is required.'}, status=400) + try: + year = int(year) + except (TypeError, ValueError): + return Response({'year': 'Must be an integer.'}, status=400) + + current_year = datetime.date.today().year + if year > current_year + 1: + return Response( + {'year': f'You can only create years up to {current_year + 1}.'}, + status=400, + ) + + max_year = _max_year_for_user(request.user) + if max_year is not None and year != max_year + 1: + return Response( + {'year': f'You can only create the next year ({max_year + 1}).'}, + status=400, + ) + + household_id = request.data.get('household_id') + if household_id: + household = Household.objects.filter( + id=household_id, + memberships__user=request.user, + memberships__status='active', + ).first() + if not household: + return Response({'detail': 'Household not found or not a member.'}, status=404) + if FinancialYear.objects.filter(household=household, year=year).exists(): + return Response({'year': 'This year already exists for this household.'}, status=400) + FinancialYear.objects.filter(household=household, is_active=True).update(is_active=False) + fy = FinancialYear.objects.create(household=household, year=year, is_active=True) + else: + if FinancialYear.objects.filter(user=request.user, year=year).exists(): + return Response({'year': 'This year already exists.'}, status=400) + FinancialYear.objects.filter(user=request.user, is_active=True).update(is_active=False) + fy = FinancialYear.objects.create(user=request.user, year=year, is_active=True) + + return Response(FinancialYearSerializer(fy).data, status=201) + + +class FinancialYearDetailView(views.APIView): + def _get_or_404(self, request, year): + fy = _get_user_financial_year(request.user, year) + if not fy: + return None + return fy + + def get(self, request, year): + fy = self._get_or_404(request, year) + if not fy: + return Response({'detail': 'Not found.'}, status=404) + return Response(FinancialYearSerializer(fy).data) + + def patch(self, request, year): + fy = self._get_or_404(request, year) + if not fy: + return Response({'detail': 'Not found.'}, status=404) + serializer = FinancialYearSerializer(fy, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=400) + + def delete(self, request, year): + fy = self._get_or_404(request, year) + if not fy: + return Response({'detail': 'Not found.'}, status=404) + if not fy.is_active: + return Response({'detail': 'Archived years cannot be deleted.'}, status=400) + fy.delete() + return Response(status=204) + + +class FinancialYearCopyView(views.APIView): + def post(self, request, year, source_year): + source = _get_user_financial_year(request.user, source_year) + if not source: + return Response({'detail': f'Source year {source_year} not found.'}, status=404) + + target = _get_user_financial_year(request.user, year) + if not target: + return Response({'detail': f'Target year {year} not found.'}, status=404) + + if not target.is_active: + return Response({'detail': 'Target year is archived.'}, status=400) + + with db_transaction.atomic(): + incomes_copied = 0 + for income in source.incomes.all(): + YearlyIncome.objects.create( + financial_year=target, + member=income.member, + name=income.name, + amount=income.amount, + active=income.active, + notes=income.notes, + ) + incomes_copied += 1 + + items_copied = 0 + for item in source.budget_items.all(): + YearlyBudgetItem.objects.create( + financial_year=target, + name=item.name, + amount=item.amount, + active=item.active, + notes=item.notes, + ) + items_copied += 1 + + return Response({ + 'year': year, + 'source_year': source_year, + 'incomes_copied': incomes_copied, + 'budget_items_copied': items_copied, + }) + + +# ── YearlyIncome Views ──────────────────────────────────────────────────────── + +class YearlyIncomeListCreateView(views.APIView): + def _get_year_or_404(self, request, year): + fy = _get_user_financial_year(request.user, year) + return fy + + def get(self, request, year): + fy = self._get_year_or_404(request, year) + if not fy: + return Response({'detail': 'Not found.'}, status=404) + return Response(YearlyIncomeSerializer(fy.incomes.all(), many=True).data) + + def post(self, request, year): + fy = self._get_year_or_404(request, year) + if not fy: + return Response({'detail': 'Not found.'}, status=404) + if not fy.is_active: + return Response({'detail': 'Archived years are read-only.'}, status=403) + serializer = YearlyIncomeSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(financial_year=fy, member=request.user) + return Response(serializer.data, status=201) + return Response(serializer.errors, status=400) + + +class YearlyIncomeDetailView(views.APIView): + def _get_income_or_404(self, request, year, pk): + fy = _get_user_financial_year(request.user, year) + if not fy: + return None, None + income = fy.incomes.filter(pk=pk).first() + return fy, income + + def patch(self, request, year, pk): + fy, income = self._get_income_or_404(request, year, pk) + if not income: + return Response({'detail': 'Not found.'}, status=404) + serializer = YearlyIncomeSerializer(income, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=400) + + def delete(self, request, year, pk): + fy, income = self._get_income_or_404(request, year, pk) + if not income: + return Response({'detail': 'Not found.'}, status=404) + income.delete() + return Response(status=204) + + +# ── YearlyBudgetItem Views ──────────────────────────────────────────────────── + +class YearlyBudgetItemListCreateView(views.APIView): + def _get_year_or_404(self, request, year): + return _get_user_financial_year(request.user, year) + + def get(self, request, year): + fy = self._get_year_or_404(request, year) + if not fy: + return Response({'detail': 'Not found.'}, status=404) + return Response(YearlyBudgetItemSerializer(fy.budget_items.all(), many=True).data) + + def post(self, request, year): + fy = self._get_year_or_404(request, year) + if not fy: + return Response({'detail': 'Not found.'}, status=404) + serializer = YearlyBudgetItemSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(financial_year=fy) + return Response(serializer.data, status=201) + return Response(serializer.errors, status=400) + + +class YearlyBudgetItemDetailView(views.APIView): + def _get_item_or_404(self, request, year, pk): + fy = _get_user_financial_year(request.user, year) + if not fy: + return None, None + item = fy.budget_items.filter(pk=pk).first() + return fy, item + + def patch(self, request, year, pk): + fy, item = self._get_item_or_404(request, year, pk) + if not item: + return Response({'detail': 'Not found.'}, status=404) + serializer = YearlyBudgetItemSerializer(item, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data) + return Response(serializer.errors, status=400) + + def delete(self, request, year, pk): + fy, item = self._get_item_or_404(request, year, pk) + if not item: + return Response({'detail': 'Not found.'}, status=404) + item.delete() + return Response(status=204) + + +# ── Household Views ─────────────────────────────────────────────────────────── + +class HouseholdListCreateView(views.APIView): + def get(self, request): + memberships = HouseholdMembership.objects.filter(user=request.user, status__in=['active', 'pending']) + household_ids = memberships.values_list('household_id', flat=True) + created = Household.objects.filter(created_by=request.user) + qs = (Household.objects.filter(id__in=household_ids) | created).distinct() + return Response(HouseholdSerializer(qs, many=True).data) + + def post(self, request): + serializer = HouseholdSerializer(data=request.data) + if serializer.is_valid(): + household = serializer.save(created_by=request.user) + # Creator is automatically an active member + next_year = (datetime.date.today().year + 1) + HouseholdMembership.objects.create( + household=household, + user=request.user, + invited_by=request.user, + status='active', + role='admin', + effective_from_year=next_year, + ) + return Response(HouseholdSerializer(household).data, status=201) + return Response(serializer.errors, status=400) + + +class HouseholdInviteView(views.APIView): + def post(self, request, pk): + household = Household.objects.filter(pk=pk).first() + if not household: + return Response({'detail': 'Not found.'}, status=404) + # Only founder or active admins can invite + is_founder = household.created_by == request.user + is_admin = HouseholdMembership.objects.filter( + household=household, user=request.user, status='active', role='admin' + ).exists() + if not (is_founder or is_admin): + return Response({'detail': 'Only admins can invite members.'}, status=403) + + email = request.data.get('email', '').strip().lower() + User = get_user_model() + invitee = User.objects.filter(email__iexact=email).first() + + from django.conf import settings + from .email import send_email + from .models import PendingHouseholdInvite + next_year = datetime.date.today().year + 1 + inviter_name = request.user.get_full_name() or request.user.email + + if not invitee: + if PendingHouseholdInvite.objects.filter(household=household, invited_email__iexact=email).exists(): + return Response({'detail': 'Invitation already sent to this email.'}, status=400) + PendingHouseholdInvite.objects.create( + household=household, + invited_by=request.user, + invited_email=email, + effective_from_year=next_year, + ) + register_url = f"{settings.FRONTEND_URL}/register" + send_email( + template_name='household_invite', + subject=f'Einladung zum Haushalt «{household.name}»', + context={ + 'invitee_name': email, + 'inviter_name': inviter_name, + 'household_name': household.name, + 'accept_url': register_url, + 'cta_label': 'Konto erstellen & beitreten', + }, + to=email, + ) + return Response({'detail': f'Registration invitation sent to {email}.'}) + + if invitee == request.user: + return Response({'detail': 'You cannot invite yourself.'}, status=400) + if HouseholdMembership.objects.filter(household=household, user=invitee, status__in=['pending', 'active']).exists(): + return Response({'detail': 'User is already a member or has a pending invitation.'}, status=400) + + HouseholdMembership.objects.create( + household=household, + user=invitee, + invited_by=request.user, + status='pending', + effective_from_year=next_year, + ) + invitee_name = invitee.get_full_name() or invitee.email + send_email( + template_name='household_invite', + subject=f'Einladung zum Haushalt «{household.name}»', + context={ + 'invitee_name': invitee_name, + 'inviter_name': inviter_name, + 'household_name': household.name, + 'accept_url': f"{settings.FRONTEND_URL}/financial-year", + 'cta_label': 'Einladung annehmen', + }, + to=invitee.email, + ) + return Response({'detail': f'Invitation sent to {email} for year {next_year}.'}) + + +class HouseholdAcceptView(views.APIView): + def post(self, request, pk): + membership = HouseholdMembership.objects.filter( + household_id=pk, user=request.user, status='pending' + ).first() + if not membership: + return Response({'detail': 'No pending invitation found.'}, status=404) + membership.status = 'active' + membership.save(update_fields=['status']) + return Response({'detail': 'Invitation accepted.'}) + + +class HouseholdLeaveView(views.APIView): + def post(self, request, pk): + membership = HouseholdMembership.objects.filter( + household_id=pk, user=request.user, status='active' + ).first() + if not membership: + return Response({'detail': 'You are not an active member of this household.'}, status=404) + + next_year = datetime.date.today().year + 1 + membership.status = 'left' + membership.effective_until_year = next_year + membership.save(update_fields=['status', 'effective_until_year']) + return Response({'detail': f'You will leave this household at the end of {next_year - 1}.'}) + + +class HouseholdSetRoleView(views.APIView): + def post(self, request, pk, membership_id): + # Only the founder can assign roles + household = Household.objects.filter(pk=pk, created_by=request.user).first() + if not household: + return Response({'detail': 'Not found or not owner.'}, status=404) + + membership = HouseholdMembership.objects.filter( + pk=membership_id, household=household, status='active' + ).first() + if not membership: + return Response({'detail': 'Active membership not found.'}, status=404) + + if membership.user == request.user: + return Response({'detail': 'Cannot change your own role.'}, status=400) + + role = request.data.get('role') + if role not in ['member', 'admin']: + return Response({'detail': 'Role must be "member" or "admin".'}, status=400) + + membership.role = role + membership.save(update_fields=['role']) + return Response(HouseholdMembershipSerializer(membership).data) + + +class HouseholdRevenueAccountsView(views.APIView): + def get(self, request, pk): + membership = HouseholdMembership.objects.filter( + household_id=pk, user=request.user, status='active' + ).first() + if not membership: + return Response({'detail': 'Not a member of this household.'}, status=403) + + member_users = HouseholdMembership.objects.filter( + household_id=pk, status='active' + ).values_list('user_id', flat=True) + + accounts = Account.objects.filter( + user_id__in=member_users, account_type='revenue', active=True + ).select_related('user') + + data = [ + { + 'id': a.id, + 'name': a.name, + 'balance': str(a.balance), + 'salary_months': a.salary_months, + 'owner_email': a.user.email, + 'is_mine': a.user_id == request.user.id, + } + for a in accounts + ] + return Response(data) diff --git a/backend/templates/emails/household_invite.html b/backend/templates/emails/household_invite.html new file mode 100644 index 0000000..9351fe2 --- /dev/null +++ b/backend/templates/emails/household_invite.html @@ -0,0 +1,34 @@ +{% extends "emails/base.html" %} + +{% block subject %}Armarium – Einladung zum Haushalt{% endblock %} + +{% block body %} +

Hallo {{ invitee_name }},

+ +

+ {{ inviter_name }} hat dich eingeladen, dem Haushalt + {{ household_name }} auf Armarium beizutreten. +

+ + + + + +
+ + {{ cta_label }} + +
+ +

+ Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser: +

+

+ {{ accept_url }} +

+ +

+ – Das Armarium-Team +

+{% endblock %} diff --git a/backend/templates/emails/household_invite.txt b/backend/templates/emails/household_invite.txt new file mode 100644 index 0000000..98f6dfe --- /dev/null +++ b/backend/templates/emails/household_invite.txt @@ -0,0 +1,10 @@ +Hallo {{ invitee_name }}, + +{{ inviter_name }} hat dich eingeladen, dem Haushalt "{{ household_name }}" auf Armarium beizutreten. + +{{ cta_label }}: +{{ accept_url }} + +Falls du diese Einladung nicht erwartet hast, kannst du sie ignorieren. + +– Das Armarium-Team diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index fb9ce80..b616e26 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -14,6 +14,7 @@ import { ExpenseList } from './expenses/expense-list/expense-list'; import { Profile } from './profile/profile'; import { Settings } from './settings/settings'; import { Calendar } from './calendar/calendar'; +import { FinancialYearComponent } from './financial-year/financial-year'; export const routes: Routes = [ { path: 'login', component: Login }, { path: 'register', component: Register }, @@ -34,6 +35,7 @@ export const routes: Routes = [ { path: 'profile', component: Profile }, { path: 'settings', component: Settings }, { path: 'calendar', component: Calendar }, + { path: 'financial-year', component: FinancialYearComponent }, ], }, { path: '**', redirectTo: 'dashboard' }, diff --git a/frontend/src/app/dashboard/dashboard.ts b/frontend/src/app/dashboard/dashboard.ts index 85498c1..fd04e9b 100644 --- a/frontend/src/app/dashboard/dashboard.ts +++ b/frontend/src/app/dashboard/dashboard.ts @@ -2,6 +2,7 @@ import { Component, OnInit, OnDestroy, AfterViewInit, signal } from '@angular/co import { CommonModule } from '@angular/common'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { ApiService } from '../services/api'; +import { FinancialYearService, FinancialYear } from '../services/financial-year'; import ApexCharts from 'apexcharts'; import { Subscription } from 'rxjs'; @@ -13,10 +14,11 @@ import { Subscription } from 'rxjs'; styleUrl: './dashboard.css', }) export class Dashboard implements OnInit, AfterViewInit, OnDestroy { - accounts = signal([]); - budgets = signal([]); + financialYears = signal([]); expenses = signal([]); transactions = signal([]); + budgets = signal([]); + accounts = signal([]); donutExpanded = signal(false); selectedYear = signal(new Date().getFullYear()); yearDropdownOpen = signal(false); @@ -32,17 +34,18 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { private barChart?: ApexCharts; private donutChart?: ApexCharts; private dataLoaded = 0; - private readonly totalRequests = 4; + private readonly totalRequests = 5; private timeInterval?: ReturnType; private langSub?: Subscription; - constructor(private api: ApiService, private translate: TranslateService) {} + constructor(private api: ApiService, private fy: FinancialYearService, private translate: TranslateService) {} ngOnInit(): void { - this.api.getAccounts().subscribe({ next: (d) => { this.accounts.set(d); this.onDataLoaded(); } }); - this.api.getBudgets().subscribe({ next: (d) => { this.budgets.set(d); this.onDataLoaded(); } }); + this.fy.list().subscribe({ next: (d) => { this.financialYears.set(d); this.onDataLoaded(); } }); this.api.getExpenses().subscribe({ next: (d) => { this.expenses.set(d); this.onDataLoaded(); } }); this.api.getTransactions().subscribe({ next: (d) => { this.transactions.set(d); this.onDataLoaded(); } }); + this.api.getBudgets().subscribe({ next: (d) => { this.budgets.set(d); this.onDataLoaded(); } }); + this.api.getAccounts().subscribe({ next: (d) => { this.accounts.set(d); this.onDataLoaded(); } }); this.api.getProfile().subscribe({ next: (p) => { @@ -100,6 +103,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.dateTimeDisplay.set(`${weekday}, ${date} | ${time}`); } + private financialYearFor(year: number): FinancialYear | undefined { + return this.financialYears().find((fy) => fy.year === year); + } + // KPIs totalIncome(): number { return this.accounts() @@ -114,7 +121,10 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { } totalExpenses(): number { - return this.expenses().reduce((sum, e) => sum + parseFloat(e.amount), 0); + const year = this.selectedYear(); + return this.expenses() + .filter((e) => new Date(e.date).getFullYear() === year) + .reduce((sum, e) => sum + parseFloat(e.amount), 0); } balance(): number { @@ -162,21 +172,23 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { this.selectedYear.set(year); this.yearDropdownOpen.set(false); this.renderBarChart(); + this.renderDonutChart(); } availableYears(): number[] { const years = new Set([new Date().getFullYear()]); - this.expenses().forEach(e => years.add(new Date(e.date).getFullYear())); + this.financialYears().forEach((fy) => years.add(fy.year)); + this.expenses().forEach((e) => years.add(new Date(e.date).getFullYear())); return Array.from(years).sort((a, b) => b - a); } donutItems(): { name: string; amount: number; pct: string; color: string }[] { - const active = this.budgets().filter((b) => b.active); - const total = active.reduce((sum, b) => sum + parseFloat(b.amount), 0); - return active.map((b, i) => ({ + const items = this.budgets().filter((b) => b.active); + const total = items.reduce((sum, b) => sum + +b.amount, 0); + return items.map((b, i) => ({ name: b.name, - amount: parseFloat(b.amount), - pct: total > 0 ? ((parseFloat(b.amount) / total) * 100).toFixed(1) : '0', + amount: +b.amount, + pct: total > 0 ? ((+b.amount / total) * 100).toFixed(1) : '0', color: this.donutColors[i % this.donutColors.length], })); } @@ -304,7 +316,7 @@ export class Dashboard implements OnInit, AfterViewInit, OnDestroy { const active = this.budgets().filter((b) => b.active); const labels = active.map((b) => b.name); - const series = active.map((b) => parseFloat(b.amount)); + const series = active.map((b) => +b.amount); if (series.length === 0) return; diff --git a/frontend/src/app/financial-year/financial-year.css b/frontend/src/app/financial-year/financial-year.css new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/app/financial-year/financial-year.html b/frontend/src/app/financial-year/financial-year.html new file mode 100644 index 0000000..2437ecf --- /dev/null +++ b/frontend/src/app/financial-year/financial-year.html @@ -0,0 +1,671 @@ + +@if (yearDropdownOpen()) { +
+} + +
+ + +
+
+

+ {{ 'financial_year.title' | translate }} +

+ @if (currentFY()) { +

+ {{ currentFY()!.owner_type === 'household' + ? ('financial_year.owner_household' | translate) + : ('financial_year.owner_personal' | translate) }} +

+ } +
+ +
+ + + @if (years().length > 0) { +
+ + @if (yearDropdownOpen()) { +
+ @for (y of years(); track y.year) { + + } +
+ } +
+ } + + + @if (canCreateNewYear()) { + + } + +
+
+ + + @if (loading()) { +
+ + + + +
+ } + + + @if (!loading() && years().length === 0) { +
+ + + +

{{ 'financial_year.no_years' | translate }}

+ +
+ } + + + @if (!loading() && currentFY()) { + + +
+ +
+

+ {{ 'financial_year.total_income' | translate }} +

+

+ CHF {{ formatChf(totalAnnualIncome()) }} +

+

+ CHF {{ formatChf(totalAnnualIncome() / 12) }} / {{ 'financial_year.per_month' | translate }} +

+
+ +
+

+ {{ 'financial_year.total_fixed_costs' | translate }} +

+

+ CHF {{ formatChf(totalAnnualBudget()) }} +

+

+ CHF {{ formatChf(totalMonthlyBudget()) }} / {{ 'financial_year.per_month' | translate }} +

+
+ +
+

+ {{ 'financial_year.total_expenses_year' | translate }} {{ selectedYear() }} +

+

+ CHF {{ formatChf(totalYearExpenses()) }} +

+

+ Ø CHF {{ formatChf(avgMonthlyExpenses()) }} / {{ 'financial_year.per_month' | translate }} +

+
+ +
+ + +
+ + +
+ +
+ + + @if (activeTab() === 'incomes') { + @if (revenueAccounts().length === 0) { +

+ {{ 'financial_year.no_revenue_accounts' | translate }} +

+ } + @for (account of revenueAccounts(); track account.id) { +
+
+

{{ account.name }}

+

+ CHF {{ formatChf(account.balance) }}/Mt. + @if (account.owner_email && !account.is_mine) { + · {{ account.owner_email }} + } +

+
+
+

+ CHF {{ formatChf(account.balance * (account.salary_months ?? 12)) }} +

+

+ {{ 'financial_year.annual_label' | translate }} +

+
+ +
+ } + @if (revenueAccounts().length > 0) { +
+ {{ 'financial_year.total_annual_income' | translate }} + CHF {{ formatChf(totalAnnualIncome()) }} +
+ } + } + + + @if (activeTab() === 'budget_items') { + @if (budgetItems().length === 0 && !showForm()) { +

+ {{ 'financial_year.no_budget_items' | translate }} +

+ } + @for (item of budgetItems(); track item.id) { +
+
+

{{ item.name }}

+ @if (item.notes) { +

{{ item.notes }}

+ } +
+
+

CHF {{ formatChf(item.amount) }}

+

CHF {{ formatChf(perMonth(item.amount)) }}/Mt.

+
+ @if (!item.active) { + Inaktiv + } +
+ + +
+
+ } + } + + + @if (showForm() && activeTab() !== 'incomes') { +
+

+ {{ editingId ? ('common.edit' | translate) : ('common.add' | translate) }} +

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ @if (formError) { +

+ {{ 'financial_year.error_' + formError | translate }} +

+ } +
+ + +
+
+ } + + + @if (!showForm() && activeTab() !== 'incomes') { +
+ +
+ } + +
+ } + + +
+ + +
+ + + +

{{ 'household.title' | translate }}

+
+ + + @if (households().length === 0) { +
+

{{ 'household.none' | translate }}

+

{{ 'household.none_hint' | translate }}

+ + @if (!showCreateHouseholdForm()) { + + } + + @if (showCreateHouseholdForm()) { +
+ + + @if (householdError) { +

+ {{ 'household.error_' + householdError | translate }} +

+ } +
+ + +
+
+ } +
+ } + + + @for (h of households(); track h.id) { + @let myM = myMembership(h); + @let amFounder = isFounder(h); + @let isPending = myM?.status === 'pending'; + +
+ + +
+
+

{{ h.name }}

+

+ {{ 'household.created_by' | translate }}: {{ h.created_by_email }} +

+
+ + @if (!isPending && canInvite(h) && inviteHouseholdId() !== h.id) { + + } +
+ + + @if (isPending) { +
+
+

{{ 'household.pending_invitation' | translate }}

+

+ {{ 'household.pending_from' | translate }}: {{ myM?.invited_by_email }} + · {{ 'household.effective_from' | translate }} {{ myM?.effective_from_year }} +

+
+ +
+ } + + + @if (!isPending) { +
+ @for (m of activeMembers(h); track m.id) { +
+ +

+ {{ m.user_email }} + @if (m.user_email === userEmail) { + ({{ 'household.you' | translate }}) + } + @if (m.user_email === h.created_by_email) { + ({{ 'household.founder' | translate }}) + } +

+ + + {{ 'household.status_' + m.status | translate }} + + + + {{ 'household.role_' + m.role | translate }} + + + @if (amFounder && m.user_email !== userEmail && m.status === 'active') { + + } +
+ } +
+ } + + + @if (!isPending && (h.pending_invites?.length ?? 0) > 0) { +
+ @for (pi of h.pending_invites; track pi.id) { +
+

+ {{ pi.invited_email }} +

+ + {{ 'household.status_unregistered' | translate }} + +
+ } +
+ } + + + @if (inviteHouseholdId() === h.id) { +
+ +
+ + + +
+ @if (inviteError) { +

+ {{ 'household.error_' + inviteError | translate }} +

+ } +
+ } + + + @if (!amFounder && myM?.status === 'active') { +
+ +
+ } + +
+ } + +
+ +
+ + +@if (showNewYearModal()) { +
+
+ +

+ {{ 'financial_year.confirm_new_year' | translate: { year: nextYear() } }} +

+ + + @if (activeHouseholds().length > 0) { +
+ +
+ + @for (h of activeHouseholds(); track h.id) { + + } +
+
+ } + + @if (years().length > 0) { +

+ {{ 'financial_year.confirm_copy' | translate: { source: selectedYear() } }} +

+
+ + + +
+ } @else { +

+ {{ 'financial_year.first_year_hint' | translate: { year: nextYear() } }} +

+
+ + +
+ } + +
+
+} + + +@if (showLeaveModal()) { +
+
+

+ {{ 'household.leave_confirm_title' | translate }} +

+

+ {{ 'household.leave_confirm_text' | translate }} +

+
+ + +
+
+
+} + + +@if (showDeleteModal()) { +
+
+

+ {{ 'common.delete_confirm_title' | translate }} +

+

+ {{ 'common.delete_confirm_text' | translate }} +

+
+ + +
+
+
+} diff --git a/frontend/src/app/financial-year/financial-year.ts b/frontend/src/app/financial-year/financial-year.ts new file mode 100644 index 0000000..88cb21e --- /dev/null +++ b/frontend/src/app/financial-year/financial-year.ts @@ -0,0 +1,447 @@ +import { Component, OnInit, signal, computed } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { FinancialYearService, FinancialYear, YearlyIncome, YearlyBudgetItem, Household, HouseholdMembership } from '../services/financial-year'; +import { ApiService } from '../services/api'; + +type Tab = 'incomes' | 'budget_items'; + +@Component({ + selector: 'app-financial-year', + standalone: true, + imports: [CommonModule, FormsModule, TranslateModule], + templateUrl: './financial-year.html', + styleUrl: './financial-year.css', +}) +export class FinancialYearComponent implements OnInit { + years = signal([]); + currentFY = signal(null); + selectedYear = signal(new Date().getFullYear()); + activeTab = signal('incomes'); + loading = signal(true); + yearDropdownOpen = signal(false); + + // New year modal + showNewYearModal = signal(false); + newYearHouseholdId = signal(null); + + // Add/edit form + showForm = signal(false); + editingId: number | null = null; + formName = ''; + formAmount = 0; + formNotes = ''; + formActive = true; + formError = ''; + formSaving = false; + + // Delete modal + showDeleteModal = signal(false); + deleteId: number | null = null; + + // Revenue accounts (income tab) + revenueAccounts = signal([]); + + // Budgets (fixed costs) + budgets = signal([]); + + // Expenses (actual spending) + expenses = signal([]); + + // Household state + households = signal([]); + userEmail = ''; + + // Create household form + showCreateHouseholdForm = signal(false); + householdName = ''; + householdSaving = false; + householdError = ''; + + // Invite form + inviteHouseholdId = signal(null); + inviteEmail = ''; + inviteError = ''; + inviteSaving = false; + + // Leave confirm modal + showLeaveModal = signal(false); + leaveHouseholdId: number | null = null; + + constructor(private fyService: FinancialYearService, private api: ApiService) {} + + ngOnInit(): void { + this.loadAll(); + this.loadHouseholds(); + this.loadRevenueAccounts(); + this.loadBudgets(); + this.api.getExpenses().subscribe({ next: (d) => this.expenses.set(d) }); + this.api.getProfile().subscribe({ next: (p) => { this.userEmail = p.email || ''; } }); + } + + // --- Computed --- + + nextYear = computed(() => { + const ys = this.years(); + if (ys.length === 0) return new Date().getFullYear(); + return Math.max(...ys.map(y => y.year)) + 1; + }); + + canCreateNewYear = computed(() => { + const next = this.nextYear(); + const maxAllowed = new Date().getFullYear() + 1; + return next <= maxAllowed && !this.years().some(y => y.year === next); + }); + + totalIncome = computed(() => Number(this.currentFY()?.total_income ?? 0)); + totalFixedCosts = computed(() => Number(this.currentFY()?.total_fixed_costs ?? 0)); + + disposable = computed(() => this.totalIncome() - this.totalFixedCosts()); + + savingsRate = computed(() => { + const i = this.totalIncome(); + return i > 0 ? Math.round((this.disposable() / i) * 100) : 0; + }); + + incomes = computed(() => this.currentFY()?.incomes ?? []); + budgetItems = computed(() => this.currentFY()?.budget_items ?? []); + + totalAnnualIncome = computed(() => + this.revenueAccounts().reduce((sum, a) => sum + parseFloat(a.balance) * (a.salary_months ?? 12), 0) + ); + + totalMonthlyBudget = computed(() => + this.budgets().filter((b) => b.active).reduce((sum, b) => sum + parseFloat(b.amount), 0) + ); + + totalAnnualBudget = computed(() => this.totalMonthlyBudget() * 12); + + totalYearExpenses = computed(() => + this.expenses() + .filter((e) => new Date(e.date).getFullYear() === this.selectedYear()) + .reduce((sum, e) => sum + parseFloat(e.amount), 0) + ); + + avgMonthlyExpenses = computed(() => { + const now = new Date(); + const year = this.selectedYear(); + const months = year < now.getFullYear() ? 12 : now.getMonth() + 1; + return months > 0 ? this.totalYearExpenses() / months : 0; + }); + + // --- Data loading --- + + private loadAll(): void { + this.loading.set(true); + this.fyService.list().subscribe({ + next: (ys) => { + // ys is ordered by -year (descending from backend) + this.years.set(ys); + if (ys.length > 0) { + const target = + ys.find(y => y.year === this.selectedYear()) ?? + ys.find(y => y.is_active) ?? + ys[0]; + this.selectedYear.set(target.year); + this.currentFY.set(target); + } + this.loading.set(false); + }, + error: () => this.loading.set(false), + }); + } + + private reloadCurrentYear(): void { + const year = this.selectedYear(); + this.fyService.get(year).subscribe({ + next: (fy) => { + setTimeout(() => { + this.currentFY.set(fy); + // Keep the years list in sync for totals shown in the sidebar/year selector + this.years.update(ys => ys.map(y => (y.year === year ? { ...y, ...fy } : y))); + }); + }, + }); + } + + // --- Year selection --- + + selectYear(year: number): void { + this.yearDropdownOpen.set(false); + this.selectedYear.set(year); + this.closeForm(); + const cached = this.years().find(y => y.year === year); + if (cached) { + this.currentFY.set(cached); + this.loadRevenueAccounts(); + } + } + + // --- Year creation --- + + openNewYearModal(): void { + this.newYearHouseholdId.set(null); + this.showNewYearModal.set(true); + } + + activeHouseholds(): Household[] { + return this.households().filter(h => + h.memberships.some(m => m.user_email === this.userEmail && m.status === 'active') + ); + } + + createYear(copy: boolean): void { + const newYear = this.nextYear(); + const sourceYear = copy ? Math.max(...this.years().map(y => y.year)) : null; + const householdId = this.newYearHouseholdId(); + + const payload: { year: number; household_id?: number } = { year: newYear }; + if (householdId) payload.household_id = householdId; + + this.fyService.create(payload).subscribe({ + next: () => { + if (sourceYear !== null) { + this.fyService.copyFrom(newYear, sourceYear).subscribe({ + next: () => { + this.showNewYearModal.set(false); + this.selectedYear.set(newYear); + this.loadAll(); + }, + }); + } else { + this.showNewYearModal.set(false); + this.selectedYear.set(newYear); + this.loadAll(); + } + }, + }); + } + + // --- Tab --- + + selectTab(tab: Tab): void { + this.activeTab.set(tab); + this.closeForm(); + } + + // --- Form --- + + openAddForm(): void { + this.editingId = null; + this.formName = ''; + this.formAmount = 0; + this.formNotes = ''; + this.formActive = true; + this.formError = ''; + this.formSaving = false; + this.showForm.set(true); + } + + openEditForm(item: YearlyIncome | YearlyBudgetItem): void { + this.editingId = item.id; + this.formName = item.name; + this.formAmount = Number(item.amount); + this.formNotes = item.notes; + this.formActive = item.active; + this.formError = ''; + this.formSaving = false; + this.showForm.set(true); + } + + closeForm(): void { + this.showForm.set(false); + this.editingId = null; + } + + saveForm(): void { + const name = this.formName.trim(); + if (!name) { this.formError = 'name_required'; return; } + const amount = this.formAmount; + if (!amount || amount <= 0) { this.formError = 'amount_invalid'; return; } + + const year = this.selectedYear(); + const data = { name, amount, notes: this.formNotes, active: this.formActive }; + const id = this.editingId; + const tab = this.activeTab(); + this.formSaving = true; + + let obs; + if (tab === 'incomes') { + obs = id + ? this.fyService.updateIncome(year, id, data) + : this.fyService.createIncome(year, { ...data, member: null }); + } else { + obs = id + ? this.fyService.updateBudgetItem(year, id, data) + : this.fyService.createBudgetItem(year, data); + } + + obs.subscribe({ + next: () => { + this.formSaving = false; + this.closeForm(); + this.reloadCurrentYear(); + }, + error: () => { + this.formSaving = false; + this.formError = 'save_failed'; + }, + }); + } + + // --- Delete --- + + confirmDelete(id: number): void { + this.deleteId = id; + this.showDeleteModal.set(true); + } + + executeDelete(): void { + const id = this.deleteId; + if (!id) return; + const year = this.selectedYear(); + const tab = this.activeTab(); + const obs = tab === 'incomes' + ? this.fyService.deleteIncome(year, id) + : this.fyService.deleteBudgetItem(year, id); + obs.subscribe({ + next: () => { + this.showDeleteModal.set(false); + this.reloadCurrentYear(); + }, + }); + } + + // --- Household helpers --- + + myMembership(h: Household): HouseholdMembership | undefined { + return h.memberships.find(m => m.user_email === this.userEmail); + } + + isFounder(h: Household): boolean { + return h.created_by_email === this.userEmail; + } + + canInvite(h: Household): boolean { + const m = this.myMembership(h); + return this.isFounder(h) || (m?.status === 'active' && m?.role === 'admin'); + } + + activeMembers(h: Household): HouseholdMembership[] { + return h.memberships.filter(m => m.status !== 'left'); + } + + // --- Household CRUD --- + + private loadBudgets(): void { + this.api.getBudgets().subscribe({ next: (bs) => this.budgets.set(bs) }); + } + + private loadRevenueAccounts(): void { + const fy = this.currentFY(); + if (fy?.owner_type === 'household' && fy.household_id) { + this.fyService.getHouseholdRevenueAccounts(fy.household_id).subscribe({ + next: (accounts) => this.revenueAccounts.set(accounts), + }); + } else { + this.api.getAccounts().subscribe({ + next: (accounts) => this.revenueAccounts.set(accounts.filter((a) => a.account_type === 'revenue')), + }); + } + } + + toggleSalaryMonths(account: any): void { + const newMonths = account.salary_months === 13 ? 12 : 13; + this.api.patchAccount(account.id, { salary_months: newMonths }).subscribe({ + next: (updated) => { + this.revenueAccounts.update((accounts) => + accounts.map((a) => (a.id === account.id ? { ...a, salary_months: updated.salary_months } : a)) + ); + }, + }); + } + + private loadHouseholds(): void { + this.fyService.getHouseholds().subscribe({ next: (hs) => this.households.set(hs) }); + } + + createHousehold(): void { + const name = this.householdName.trim(); + if (!name) { this.householdError = 'name_required'; return; } + this.householdSaving = true; + this.fyService.createHousehold(name).subscribe({ + next: () => { + this.householdSaving = false; + this.showCreateHouseholdForm.set(false); + this.householdName = ''; + this.householdError = ''; + this.loadHouseholds(); + }, + error: () => { this.householdSaving = false; this.householdError = 'failed'; }, + }); + } + + openInviteForm(householdId: number): void { + this.inviteHouseholdId.set(householdId); + this.inviteEmail = ''; + this.inviteError = ''; + this.inviteSaving = false; + } + + sendInvite(pk: number): void { + const email = this.inviteEmail.trim(); + if (!email) { this.inviteError = 'email_required'; return; } + this.inviteSaving = true; + this.fyService.inviteMember(pk, email).subscribe({ + next: () => { + this.inviteSaving = false; + this.inviteHouseholdId.set(null); + this.inviteEmail = ''; + this.loadHouseholds(); + }, + error: (err) => { + this.inviteSaving = false; + const detail = err?.error?.detail ?? ''; + if (detail.includes('email')) this.inviteError = 'not_found'; + else if (detail.includes('already')) this.inviteError = 'already_member'; + else this.inviteError = 'failed'; + }, + }); + } + + acceptInvitation(pk: number): void { + this.fyService.acceptInvitation(pk).subscribe({ + next: () => this.loadHouseholds(), + }); + } + + openLeaveModal(householdId: number): void { + this.leaveHouseholdId = householdId; + this.showLeaveModal.set(true); + } + + confirmLeave(): void { + if (!this.leaveHouseholdId) return; + this.fyService.leaveHousehold(this.leaveHouseholdId).subscribe({ + next: () => { this.showLeaveModal.set(false); this.loadHouseholds(); }, + }); + } + + toggleMemberRole(pk: number, membership: HouseholdMembership): void { + const newRole = membership.role === 'admin' ? 'member' : 'admin'; + this.fyService.setMemberRole(pk, membership.id, newRole).subscribe({ + next: () => this.loadHouseholds(), + }); + } + + // --- Formatting --- + + formatChf(val: number): string { + return new Intl.NumberFormat('de-CH', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).format(val); + } + + perMonth(val: number): number { + return Math.round(Number(val) / 12); + } +} diff --git a/frontend/src/app/layout/sidebar/sidebar.html b/frontend/src/app/layout/sidebar/sidebar.html index a064177..42090e7 100644 --- a/frontend/src/app/layout/sidebar/sidebar.html +++ b/frontend/src/app/layout/sidebar/sidebar.html @@ -106,6 +106,26 @@ + +
  • + + + + + @if (!sidebarService.collapsed()) { + {{ 'sidebar.financial_year' | translate }} + } + @if (sidebarService.collapsed()) { + + {{ 'sidebar.financial_year' | translate }} + + } + +
  • +
  • @if (sidebarService.collapsed()) { diff --git a/frontend/src/app/services/api.ts b/frontend/src/app/services/api.ts index 7b56356..ef63a20 100644 --- a/frontend/src/app/services/api.ts +++ b/frontend/src/app/services/api.ts @@ -23,6 +23,10 @@ export class ApiService { return this.http.put(`${this.baseUrl}/accounts/${id}/`, account); } + patchAccount(id: number, data: Partial<{name: string, balance: number, account_type: string, salary_months: number}>): Observable { + return this.http.patch(`${this.baseUrl}/accounts/${id}/`, data); + } + deleteAccount(id: number): Observable { return this.http.delete(`${this.baseUrl}/accounts/${id}/`); } diff --git a/frontend/src/app/services/financial-year.ts b/frontend/src/app/services/financial-year.ts new file mode 100644 index 0000000..d0a0805 --- /dev/null +++ b/frontend/src/app/services/financial-year.ts @@ -0,0 +1,146 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +export interface YearlyIncome { + id: number; + member: number | null; + member_email: string; + name: string; + amount: number; + active: boolean; + notes: string; +} + +export interface YearlyBudgetItem { + id: number; + name: string; + amount: number; + active: boolean; + notes: string; +} + +export interface FinancialYear { + id: number; + year: number; + is_active: boolean; + notes: string; + owner_type: 'personal' | 'household'; + household_id: number | null; + total_income: number; + total_fixed_costs: number; + incomes: YearlyIncome[]; + budget_items: YearlyBudgetItem[]; + created_at: string; +} + +export interface HouseholdMembership { + id: number; + user: number; + user_email: string; + invited_by_email: string; + status: 'pending' | 'active' | 'left'; + role: 'member' | 'admin'; + effective_from_year: number | null; + effective_until_year: number | null; + created_at: string; +} + +export interface PendingInvite { + id: number; + invited_email: string; + invited_by_email: string; + effective_from_year: number | null; + created_at: string; +} + +export interface Household { + id: number; + name: string; + created_by_email: string; + memberships: HouseholdMembership[]; + pending_invites: PendingInvite[]; + created_at: string; +} + +@Injectable({ providedIn: 'root' }) +export class FinancialYearService { + private base = '/api/financial-years'; + + constructor(private http: HttpClient) {} + + list(): Observable { + return this.http.get(`${this.base}/`); + } + + get(year: number): Observable { + return this.http.get(`${this.base}/${year}/`); + } + + create(data: { year: number; notes?: string; household_id?: number }): Observable { + return this.http.post(`${this.base}/`, data); + } + + update(year: number, data: Partial<{ is_active: boolean; notes: string }>): Observable { + return this.http.patch(`${this.base}/${year}/`, data); + } + + copyFrom(year: number, sourceYear: number): Observable { + return this.http.post(`${this.base}/${year}/copy-from/${sourceYear}/`, {}); + } + + // Incomes + createIncome(year: number, data: { name: string; amount: number; active: boolean; notes: string; member: number | null }): Observable { + return this.http.post(`${this.base}/${year}/incomes/`, data); + } + + updateIncome(year: number, id: number, data: { name: string; amount: number; active: boolean; notes: string }): Observable { + return this.http.patch(`${this.base}/${year}/incomes/${id}/`, data); + } + + deleteIncome(year: number, id: number): Observable { + return this.http.delete(`${this.base}/${year}/incomes/${id}/`); + } + + // Budget Items + createBudgetItem(year: number, data: { name: string; amount: number; active: boolean; notes: string }): Observable { + return this.http.post(`${this.base}/${year}/budget-items/`, data); + } + + updateBudgetItem(year: number, id: number, data: { name: string; amount: number; active: boolean; notes: string }): Observable { + return this.http.patch(`${this.base}/${year}/budget-items/${id}/`, data); + } + + deleteBudgetItem(year: number, id: number): Observable { + return this.http.delete(`${this.base}/${year}/budget-items/${id}/`); + } + + // Households + getHouseholds(): Observable { + return this.http.get('/api/households/'); + } + + createHousehold(name: string): Observable { + return this.http.post('/api/households/', { name }); + } + + inviteMember(pk: number, email: string): Observable { + return this.http.post(`/api/households/${pk}/invite/`, { email }); + } + + acceptInvitation(pk: number): Observable { + return this.http.post(`/api/households/${pk}/accept/`, {}); + } + + leaveHousehold(pk: number): Observable { + return this.http.post(`/api/households/${pk}/leave/`, {}); + } + + setMemberRole(pk: number, membershipId: number, role: 'member' | 'admin'): Observable { + return this.http.post(`/api/households/${pk}/members/${membershipId}/set-role/`, { role }); + } + + getHouseholdRevenueAccounts(pk: number): Observable { + return this.http.get(`/api/households/${pk}/revenue-accounts/`); + } +} diff --git a/frontend/src/assets/i18n/de.json b/frontend/src/assets/i18n/de.json index e574d6f..702923b 100644 --- a/frontend/src/assets/i18n/de.json +++ b/frontend/src/assets/i18n/de.json @@ -142,6 +142,7 @@ "fixed_costs": "Fixkosten", "expenses": "Ausgaben", "calendar": "Kalender", + "financial_year": "Jahresplanung", "accounts": "Konten", "revenue_accounts": "Einnahmekonten", "transactions": "Transaktionen" @@ -345,6 +346,74 @@ "ZG": "Zug", "ZH": "Zürich" }, + "household": { + "title": "Haushalt", + "none": "Du bist noch in keinem Haushalt.", + "none_hint": "Gründe einen gemeinsamen Haushalt oder warte auf eine Einladung.", + "create": "Haushalt gründen", + "create_title": "Neuer Haushalt", + "label_name": "Name des Haushalts", + "placeholder_name": "z.B. Familie Müller", + "created_by": "Gegründet von", + "members": "Mitglieder", + "invite": "Einladen", + "invite_email": "E-Mail-Adresse", + "invite_placeholder": "user@beispiel.ch", + "send": "Einladung senden", + "status_active": "Aktiv", + "status_pending": "Ausstehend", + "status_left": "Ausgetreten", + "role_admin": "Admin", + "role_member": "Mitglied", + "make_admin": "Admin machen", + "remove_admin": "Admin entfernen", + "pending_invitation": "Einladung erhalten", + "pending_from": "Eingeladen von", + "effective_from": "Ab Jahr", + "accept": "Annehmen", + "leave": "Haushalt verlassen", + "leave_confirm_title": "Haushalt verlassen?", + "leave_confirm_text": "Du verlässt den Haushalt per Ende des laufenden Jahres. Vergangene Haushaltsjahre bleiben für dich lesbar.", + "you": "du", + "founder": "Gründer", + "error_name_required": "Name ist erforderlich.", + "error_failed": "Vorgang fehlgeschlagen.", + "error_email_required": "E-Mail-Adresse ist erforderlich.", + "error_not_found": "Kein Benutzer mit dieser E-Mail gefunden.", + "error_already_member": "Benutzer ist bereits Mitglied oder hat eine ausstehende Einladung." + }, + "financial_year": { + "title": "Jahresplanung", + "owner_personal": "Persönlich", + "owner_household": "Haushalt", + "new_year": "Neues Jahr starten", + "no_years": "Noch kein Finanzjahr erstellt.", + "start_first_year": "Erstes Jahr starten", + "tab_incomes": "Einnahmen", + "tab_budget_items": "Fixkosten", + "add_income": "Einnahme hinzufügen", + "add_budget_item": "Fixkosten hinzufügen", + "label_name": "Bezeichnung", + "label_amount": "Betrag (CHF / Jahr)", + "label_notes": "Notizen", + "label_active": "Aktiv", + "total_income": "Gesamteinnahmen", + "total_fixed_costs": "Fixkosten total", + "disposable": "Verfügbar", + "savings_rate": "Sparquote", + "per_month": "Monat", + "no_incomes": "Noch keine Einnahmen erfasst.", + "no_budget_items": "Noch keine Fixkosten erfasst.", + "confirm_new_year": "Jahr {{ year }} starten?", + "confirm_copy": "Soll das neue Jahr mit den Daten aus {{ source }} vorausgefüllt werden?", + "first_year_hint": "Das Finanzjahr {{ year }} wird neu erstellt.", + "create_year": "Jahr erstellen", + "copy_yes": "Ja, Daten übernehmen", + "copy_no": "Leer starten", + "error_name_required": "Bezeichnung ist erforderlich.", + "error_amount_invalid": "Bitte einen gültigen Betrag eingeben.", + "error_save_failed": "Speichern fehlgeschlagen." + }, "profile": { "title": "Profil", "subtitle": "Persönliche Informationen und Einstellungen verwalten", diff --git a/frontend/src/assets/i18n/en.json b/frontend/src/assets/i18n/en.json index e844ffa..7b71d4e 100644 --- a/frontend/src/assets/i18n/en.json +++ b/frontend/src/assets/i18n/en.json @@ -142,6 +142,7 @@ "fixed_costs": "Fixed Costs", "expenses": "Expenses", "calendar": "Calendar", + "financial_year": "Annual Planning", "accounts": "Accounts", "revenue_accounts": "Revenue Accounts", "transactions": "Transactions" @@ -345,6 +346,74 @@ "ZG": "Zug", "ZH": "Zuerich" }, + "household": { + "title": "Household", + "none": "You are not part of any household yet.", + "none_hint": "Create a shared household or wait for an invitation.", + "create": "Create Household", + "create_title": "New Household", + "label_name": "Household Name", + "placeholder_name": "e.g. Smith Family", + "created_by": "Created by", + "members": "Members", + "invite": "Invite", + "invite_email": "Email Address", + "invite_placeholder": "user@example.com", + "send": "Send Invitation", + "status_active": "Active", + "status_pending": "Pending", + "status_left": "Left", + "role_admin": "Admin", + "role_member": "Member", + "make_admin": "Make admin", + "remove_admin": "Remove admin", + "pending_invitation": "Invitation received", + "pending_from": "Invited by", + "effective_from": "From year", + "accept": "Accept", + "leave": "Leave Household", + "leave_confirm_title": "Leave Household?", + "leave_confirm_text": "You will leave the household at the end of the current year. Past household years remain readable for you.", + "you": "you", + "founder": "Founder", + "error_name_required": "Name is required.", + "error_failed": "Operation failed.", + "error_email_required": "Email address is required.", + "error_not_found": "No user found with this email.", + "error_already_member": "User is already a member or has a pending invitation." + }, + "financial_year": { + "title": "Annual Planning", + "owner_personal": "Personal", + "owner_household": "Household", + "new_year": "Start New Year", + "no_years": "No financial year created yet.", + "start_first_year": "Start First Year", + "tab_incomes": "Income", + "tab_budget_items": "Fixed Costs", + "add_income": "Add Income", + "add_budget_item": "Add Fixed Cost", + "label_name": "Name", + "label_amount": "Amount (CHF / Year)", + "label_notes": "Notes", + "label_active": "Active", + "total_income": "Total Income", + "total_fixed_costs": "Total Fixed Costs", + "disposable": "Disposable", + "savings_rate": "Savings Rate", + "per_month": "Month", + "no_incomes": "No income entries yet.", + "no_budget_items": "No fixed cost entries yet.", + "confirm_new_year": "Start Year {{ year }}?", + "confirm_copy": "Should the new year be pre-filled with data from {{ source }}?", + "first_year_hint": "Financial year {{ year }} will be created.", + "create_year": "Create Year", + "copy_yes": "Yes, copy data", + "copy_no": "Start empty", + "error_name_required": "Name is required.", + "error_amount_invalid": "Please enter a valid amount.", + "error_save_failed": "Saving failed." + }, "profile": { "title": "Profile", "subtitle": "Manage your personal information and settings", diff --git a/frontend/src/assets/i18n/fr.json b/frontend/src/assets/i18n/fr.json index 949a23f..2ca601e 100644 --- a/frontend/src/assets/i18n/fr.json +++ b/frontend/src/assets/i18n/fr.json @@ -142,6 +142,7 @@ "fixed_costs": "Charges fixes", "expenses": "Dépenses", "calendar": "Calendrier", + "financial_year": "Planification annuelle", "accounts": "Comptes", "revenue_accounts": "Comptes de revenus", "transactions": "Transactions" @@ -345,6 +346,74 @@ "ZG": "Zoug", "ZH": "Zurich" }, + "household": { + "title": "Ménage", + "none": "Vous ne faites encore partie d'aucun ménage.", + "none_hint": "Créez un ménage commun ou attendez une invitation.", + "create": "Créer un ménage", + "create_title": "Nouveau ménage", + "label_name": "Nom du ménage", + "placeholder_name": "p.ex. Famille Müller", + "created_by": "Créé par", + "members": "Membres", + "invite": "Inviter", + "invite_email": "Adresse e-mail", + "invite_placeholder": "user@exemple.ch", + "send": "Envoyer l'invitation", + "status_active": "Actif", + "status_pending": "En attente", + "status_left": "Parti", + "role_admin": "Admin", + "role_member": "Membre", + "make_admin": "Rendre admin", + "remove_admin": "Retirer admin", + "pending_invitation": "Invitation reçue", + "pending_from": "Invité par", + "effective_from": "Dès l'année", + "accept": "Accepter", + "leave": "Quitter le ménage", + "leave_confirm_title": "Quitter le ménage ?", + "leave_confirm_text": "Vous quitterez le ménage à la fin de l'année en cours. Les années passées resteront lisibles.", + "you": "vous", + "founder": "Fondateur", + "error_name_required": "Le nom est requis.", + "error_failed": "Opération échouée.", + "error_email_required": "L'adresse e-mail est requise.", + "error_not_found": "Aucun utilisateur trouvé avec cet e-mail.", + "error_already_member": "L'utilisateur est déjà membre ou a une invitation en attente." + }, + "financial_year": { + "title": "Planification annuelle", + "owner_personal": "Personnel", + "owner_household": "Ménage", + "new_year": "Démarrer une nouvelle année", + "no_years": "Aucune année financière créée.", + "start_first_year": "Démarrer la première année", + "tab_incomes": "Revenus", + "tab_budget_items": "Charges fixes", + "add_income": "Ajouter un revenu", + "add_budget_item": "Ajouter une charge fixe", + "label_name": "Désignation", + "label_amount": "Montant (CHF / an)", + "label_notes": "Notes", + "label_active": "Actif", + "total_income": "Revenus totaux", + "total_fixed_costs": "Charges fixes totales", + "disposable": "Disponible", + "savings_rate": "Taux d'épargne", + "per_month": "mois", + "no_incomes": "Aucun revenu enregistré.", + "no_budget_items": "Aucune charge fixe enregistrée.", + "confirm_new_year": "Démarrer l'année {{ year }} ?", + "confirm_copy": "Reprendre les données de {{ source }} pour la nouvelle année ?", + "first_year_hint": "L'année financière {{ year }} sera créée.", + "create_year": "Créer l'année", + "copy_yes": "Oui, reprendre les données", + "copy_no": "Démarrer vide", + "error_name_required": "La désignation est requise.", + "error_amount_invalid": "Veuillez saisir un montant valide.", + "error_save_failed": "Échec de l'enregistrement." + }, "profile": { "title": "Profil", "subtitle": "Gérer vos informations personnelles et paramètres", diff --git a/frontend/src/assets/i18n/it.json b/frontend/src/assets/i18n/it.json index 2774c62..635433a 100644 --- a/frontend/src/assets/i18n/it.json +++ b/frontend/src/assets/i18n/it.json @@ -142,6 +142,7 @@ "fixed_costs": "Costi fissi", "expenses": "Spese", "calendar": "Calendario", + "financial_year": "Pianificazione annuale", "accounts": "Conti", "revenue_accounts": "Conti entrate", "transactions": "Transazioni" @@ -345,6 +346,74 @@ "ZG": "Zugo", "ZH": "Zurigo" }, + "household": { + "title": "Nucleo familiare", + "none": "Non fai ancora parte di nessun nucleo familiare.", + "none_hint": "Crea un nucleo familiare condiviso o attendi un invito.", + "create": "Crea nucleo familiare", + "create_title": "Nuovo nucleo familiare", + "label_name": "Nome del nucleo", + "placeholder_name": "es. Famiglia Müller", + "created_by": "Creato da", + "members": "Membri", + "invite": "Invita", + "invite_email": "Indirizzo e-mail", + "invite_placeholder": "user@esempio.ch", + "send": "Invia invito", + "status_active": "Attivo", + "status_pending": "In attesa", + "status_left": "Uscito", + "role_admin": "Admin", + "role_member": "Membro", + "make_admin": "Rendi admin", + "remove_admin": "Rimuovi admin", + "pending_invitation": "Invito ricevuto", + "pending_from": "Invitato da", + "effective_from": "Dall'anno", + "accept": "Accetta", + "leave": "Lascia il nucleo", + "leave_confirm_title": "Lasciare il nucleo?", + "leave_confirm_text": "Lascerai il nucleo alla fine dell'anno in corso. Gli anni passati rimarranno leggibili.", + "you": "tu", + "founder": "Fondatore", + "error_name_required": "Il nome è obbligatorio.", + "error_failed": "Operazione non riuscita.", + "error_email_required": "L'indirizzo e-mail è obbligatorio.", + "error_not_found": "Nessun utente trovato con questa e-mail.", + "error_already_member": "L'utente è già membro o ha un invito in sospeso." + }, + "financial_year": { + "title": "Pianificazione annuale", + "owner_personal": "Personale", + "owner_household": "Nucleo familiare", + "new_year": "Avvia nuovo anno", + "no_years": "Nessun anno finanziario creato.", + "start_first_year": "Avvia il primo anno", + "tab_incomes": "Entrate", + "tab_budget_items": "Costi fissi", + "add_income": "Aggiungi entrata", + "add_budget_item": "Aggiungi costo fisso", + "label_name": "Denominazione", + "label_amount": "Importo (CHF / anno)", + "label_notes": "Note", + "label_active": "Attivo", + "total_income": "Entrate totali", + "total_fixed_costs": "Costi fissi totali", + "disposable": "Disponibile", + "savings_rate": "Tasso di risparmio", + "per_month": "mese", + "no_incomes": "Nessuna entrata registrata.", + "no_budget_items": "Nessun costo fisso registrato.", + "confirm_new_year": "Avviare l'anno {{ year }}?", + "confirm_copy": "Vuoi pre-compilare il nuovo anno con i dati di {{ source }}?", + "first_year_hint": "L'anno finanziario {{ year }} verrà creato.", + "create_year": "Crea anno", + "copy_yes": "Sì, copia i dati", + "copy_no": "Inizia vuoto", + "error_name_required": "La denominazione è obbligatoria.", + "error_amount_invalid": "Inserisci un importo valido.", + "error_save_failed": "Salvataggio non riuscito." + }, "profile": { "title": "Profilo", "subtitle": "Gestisci le tue informazioni personali e impostazioni",