Files
WYGIWYH/app/apps/accounts/models.py
T
Herculino Trotta 00d37d8c81 refactor(currencies): keep the exchanged amounts display rule in one place
Account.displayed_exchange_currency holds the account/currency precedence for transactions, and shown_exchange_currency() expresses the same rule as a query expression for the insights and account totals, replacing the per-module checks.
2026-11-11 10:22:50 -03:00

225 lines
7.5 KiB
Python

from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.template.defaultfilters import floatformat
from django.utils.translation import gettext_lazy as _
from apps.common.middleware.thread_local import get_current_user
from apps.common.models import (
OwnedObject,
OwnedObjectManager,
SharedObject,
SharedObjectManager,
)
from apps.transactions.models import Transaction
class AccountGroup(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"))
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta:
verbose_name = _("Account Group")
verbose_name_plural = _("Account Groups")
db_table = "account_groups"
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self):
return self.name
class InvestmentClass(OwnedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"))
target = models.DecimalField(
max_digits=5,
decimal_places=2,
null=True,
blank=True,
validators=[MinValueValidator(0), MaxValueValidator(100)],
verbose_name=_("Target allocation (%)"),
help_text=_(
"Share of your investments you want in this class. Leave it empty to "
"leave the class out of the targets."
),
)
objects = OwnedObjectManager()
all_objects = models.Manager() # Unfiltered manager
def clean(self):
# Shared by the form and the API; owner is set on save, so neither can
# rely on Django's (owner, name) unique check.
others = InvestmentClass.all_objects.filter(
owner=self.owner or get_current_user()
).exclude(pk=self.pk)
errors = {}
if self.name and others.filter(name=self.name).exists():
errors["name"] = _("You already have an asset class with this name.")
if self.target:
taken = others.aggregate(total=models.Sum("target"))["total"] or 0
if taken + self.target > 100:
errors["target"] = _(
"Targets can't add up to more than 100%%. Other classes already "
"take %(taken)s%%."
) % {"taken": floatformat(taken, -2)}
if errors:
raise ValidationError(errors)
class Meta:
verbose_name = _("Asset class")
verbose_name_plural = _("Asset classes")
db_table = "investment_classes"
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self):
return self.name
class Account(SharedObject):
name = models.CharField(max_length=255, verbose_name=_("Name"))
group = models.ForeignKey(
AccountGroup,
on_delete=models.SET_NULL,
verbose_name=_("Account Group"),
blank=True,
null=True,
)
currency = models.ForeignKey(
"currencies.Currency",
verbose_name=_("Currency"),
on_delete=models.PROTECT,
related_name="accounts",
)
exchange_currency = models.ForeignKey(
"currencies.Currency",
verbose_name=_("Exchange Currency"),
on_delete=models.SET_NULL,
related_name="exchange_accounts",
null=True,
blank=True,
help_text=_("Default currency for exchange calculations"),
)
show_exchanged_amounts = models.BooleanField(
default=True,
verbose_name=_("Show exchanged amounts"),
help_text=_(
"Show amounts converted to this account's exchange currency next to its "
"transactions and totals."
),
)
is_asset = models.BooleanField(
default=False,
verbose_name=_("Asset account"),
help_text=_(
"Asset accounts count towards your Net Worth, but not towards your month."
),
)
is_investment = models.BooleanField(
default=False,
verbose_name=_("Investment"),
help_text=_(
"Show this account in the Investments tracker. Set an exchange currency to "
"price it by units (stocks, ETFs, crypto); leave it empty to track it by "
"value (fixed income, property, pensions)."
),
)
investment_class = models.ForeignKey(
InvestmentClass,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="accounts",
verbose_name=_("Asset class"),
help_text=_(
"Groups investments in the allocation charts, e.g. Fixed income, Stocks, "
"Real estate."
),
)
income_entities = models.ManyToManyField(
"transactions.TransactionEntity",
blank=True,
related_name="investment_accounts",
verbose_name=_("Income entities"),
help_text=_(
"Transactions with any of these entities count towards this investment's "
"return: in this account, and income paid into other accounts (dividends, "
"rent)."
),
)
income_categories = models.ManyToManyField(
"transactions.TransactionCategory",
blank=True,
related_name="investment_accounts",
verbose_name=_("Income categories"),
help_text=_(
"Transactions in this account with any of these categories are its result "
"(interest, taxes, fees). Everything else that isn't a transfer counts as "
"money coming in or going out."
),
)
income_tags = models.ManyToManyField(
"transactions.TransactionTag",
blank=True,
related_name="investment_accounts",
verbose_name=_("Income tags"),
help_text=_(
"Same as income categories, for transactions with any of these tags."
),
)
is_archived = models.BooleanField(
default=False,
verbose_name=_("Archived"),
help_text=_("Archived accounts don't show up nor count towards your net worth"),
)
untracked_by = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="untracked_accounts",
)
objects = SharedObjectManager()
all_objects = models.Manager() # Unfiltered manager
class Meta:
verbose_name = _("Account")
verbose_name_plural = _("Accounts")
unique_together = (("owner", "name"),)
ordering = ["name", "id"]
def __str__(self):
return self.name
def is_untracked_by(self):
user = get_current_user()
return self.untracked_by.filter(pk=user.pk).exists()
@property
def displayed_exchange_currency(self):
"""The exchange currency to show amounts in, or None when they're hidden.
The account's exchange currency takes precedence over its currency's, so
whichever one provides it also decides whether it's shown.
"""
if self.exchange_currency_id:
return self.exchange_currency if self.show_exchanged_amounts else None
if self.currency.show_exchanged_amounts:
return self.currency.exchange_currency
return None
def clean(self):
super().clean()
if self.exchange_currency == self.currency:
raise ValidationError(
{
"exchange_currency": _(
"Exchange currency cannot be the same as the account's main currency."
)
}
)