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