feat: financial year planning — annual budgets, income tracking, household sharing

- Financial year page (/financial-year): year selector, 3 KPI cards (income,
  fixed costs, actual expenses), income and budget-items tabs with inline CRUD
- Revenue accounts as income source: salary-months toggle (12/13) per account
- Household support: create household, invite members by email (existing and
  new users via PendingHouseholdInvite), accept invitations, set roles
- Combined household income view across all active members
- FinancialYear, YearlyIncome, YearlyBudgetItem, Household, HouseholdMembership
  models with migrations; household invite email template
- Management command to migrate existing accounts/budgets to financial years
- FinancialYearService in Angular with full API integration
- Dashboard updated: income/fixed-costs read from financial year data,
  year dropdown synced with available financial years
- Sidebar: financial year nav item added
- i18n: all keys in DE/EN/FR/IT
This commit is contained in:
Daniel Krähenbühl
2026-05-25 22:05:05 +02:00
parent 1a7ef09805
commit fe4aeb3034
28 changed files with 2681 additions and 19 deletions
+1
View File
@@ -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,
+18
View File
@@ -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/<int:user_id>/<str:token>/', ICalFeedView.as_view()),
path('api/financial-years/', FinancialYearListCreateView.as_view()),
path('api/financial-years/<int:year>/', FinancialYearDetailView.as_view()),
path('api/financial-years/<int:year>/copy-from/<int:source_year>/', FinancialYearCopyView.as_view()),
path('api/financial-years/<int:year>/incomes/', YearlyIncomeListCreateView.as_view()),
path('api/financial-years/<int:year>/incomes/<int:pk>/', YearlyIncomeDetailView.as_view()),
path('api/financial-years/<int:year>/budget-items/', YearlyBudgetItemListCreateView.as_view()),
path('api/financial-years/<int:year>/budget-items/<int:pk>/', YearlyBudgetItemDetailView.as_view()),
path('api/households/', HouseholdListCreateView.as_view()),
path('api/households/<int:pk>/invite/', HouseholdInviteView.as_view()),
path('api/households/<int:pk>/accept/', HouseholdAcceptView.as_view()),
path('api/households/<int:pk>/leave/', HouseholdLeaveView.as_view()),
path('api/households/<int:pk>/members/<int:membership_id>/set-role/', HouseholdSetRoleView.as_view()),
path('api/households/<int:pk>/revenue-accounts/', HouseholdRevenueAccountsView.as_view()),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
@@ -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.'))
@@ -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')},
),
]
@@ -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),
),
]
@@ -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),
),
]
@@ -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')},
},
),
]
@@ -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),
),
]
+143 -1
View File
@@ -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'}"
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})"
+93 -2
View File
@@ -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
+460 -1
View File
@@ -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)
@@ -0,0 +1,34 @@
{% extends "emails/base.html" %}
{% block subject %}Armarium Einladung zum Haushalt{% endblock %}
{% block body %}
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">Hallo {{ invitee_name }},</p>
<p style="margin:0 0 20px;font-size:15px;color:#374151;line-height:1.6;">
<strong>{{ inviter_name }}</strong> hat dich eingeladen, dem Haushalt
<strong>{{ household_name }}</strong> auf Armarium beizutreten.
</p>
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 28px;">
<tr>
<td align="center">
<a href="{{ accept_url }}"
style="display:inline-block;background-color:#7c3aed;color:#ffffff;font-size:15px;font-weight:600;text-decoration:none;padding:14px 32px;border-radius:8px;letter-spacing:0.1px;">
{{ cta_label }}
</a>
</td>
</tr>
</table>
<p style="margin:0 0 8px;font-size:13px;color:#6b7280;line-height:1.6;">
Falls der Button nicht funktioniert, kopiere diesen Link in deinen Browser:
</p>
<p style="margin:0 0 24px;font-size:13px;color:#7c3aed;line-height:1.6;word-break:break-all;">
{{ accept_url }}
</p>
<p style="margin:0;font-size:15px;color:#374151;line-height:1.6;">
Das Armarium-Team
</p>
{% endblock %}
@@ -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