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:
+460
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user