Merge remote-tracking branch 'origin/main' into feat/bank-sync-2

# Conflicts:
#	app/apps/export_app/views.py
#	app/templates/includes/sidebar.html
This commit is contained in:
Herculino Trotta committed 2026-11-08 16:17:06 -03:00
commit 1369c3edbf
83 files changed
+13812 -3428

No files matched your search

-6
View File
@@ -60,12 +60,6 @@ jobs:
- name: Install dependencies
run: uv sync --frozen
# Import code reads/writes the Docker image's paths directly
- name: Create container paths used by the app
run: |
sudo mkdir -p /usr/src/app/temp /usr/src/app/import_presets
sudo chown -R "$(id -u):$(id -g)" /usr/src/app
- name: Check for missing migrations
working-directory: app
run: uv run python manage.py makemigrations --check --dry-run
+1
View File
@@ -168,3 +168,4 @@ postgres_data/
# Private local uploads
app/attachments/
app/temp/
+2
View File
@@ -192,6 +192,8 @@ To create the first user, open the container's console using Unraid's UI, by cli
| ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. |
| KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. |
| TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases |
| IMPORT_TEMP_ROOT | string | /usr/src/app/temp | Where uploaded import files wait to be processed. Defaults to the `temp` folder inside the app folder. Must be writable and shared between the web and task processes. |
| CACHE_ROOT | string | /var/tmp | The writable folder the file-based caches are stored in. |
| DEMO | true\|false | false | If demo mode is enabled. |
| ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. |
| ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. |
+8 -2
View File
@@ -78,6 +78,7 @@ INSTALLED_APPS = [
"apps.rules.apps.RulesConfig",
"apps.calendar_view.apps.CalendarViewConfig",
"apps.dca.apps.DcaConfig",
"apps.investments.apps.InvestmentsConfig",
"pwa",
"allauth",
"allauth.account",
@@ -314,6 +315,9 @@ LOCALE_PATHS = [BASE_DIR / "locale"]
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static_files"
ATTACHMENT_MEDIA_ROOT = BASE_DIR / "attachments"
IMPORT_PRESETS_ROOT = BASE_DIR / "import_presets"
# Web and task workers must see the same folder: uploads wait here to be processed
IMPORT_TEMP_ROOT = Path(os.getenv("IMPORT_TEMP_ROOT") or BASE_DIR / "temp").resolve()
STATICFILES_DIRS = [
ROOT_DIR / "frontend" / "build",
@@ -325,15 +329,17 @@ STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
]
CACHE_ROOT = Path(os.getenv("CACHE_ROOT") or "/var/tmp").resolve()
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
"LOCATION": "/var/tmp/django_cache",
"LOCATION": CACHE_ROOT / "django_cache",
},
# Kept apart from "default": cachalot fills it and FileBasedCache culls random entries past MAX_ENTRIES
"update_check": {
"BACKEND": "django.core.cache.backends.filebased.FileBasedCache",
"LOCATION": "/var/tmp/django_cache_update_check",
"LOCATION": CACHE_ROOT / "django_cache_update_check",
},
}
+1
View File
@@ -94,6 +94,7 @@ urlpatterns = [
path("", include("apps.rules.urls")),
path("", include("apps.calendar_view.urls")),
path("", include("apps.dca.urls")),
path("", include("apps.investments.urls")),
path("", include("apps.mini_tools.urls")),
path("", include("apps.import_app.urls")),
path("", include("apps.export_app.urls")),
+79 -4
View File
@@ -1,6 +1,7 @@
import calendar
from apps.accounts.models import Account, AccountGroup
from apps.accounts.models import Account, AccountGroup, InvestmentClass
from apps.common.middleware.thread_local import get_current_user
from apps.common.fields.forms.dynamic_select import (
DynamicModelChoiceField,
DynamicModelMultipleChoiceField,
@@ -9,12 +10,16 @@ from apps.common.widgets.crispy.daisyui import Switch
from apps.common.widgets.crispy.submit import NoClassSubmit
from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
from apps.common.widgets.tom_select import TomSelect
from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple
from apps.currencies.models import Currency
from apps.transactions.models import TransactionCategory, TransactionTag
from apps.transactions.models import (
TransactionCategory,
TransactionEntity,
TransactionTag,
)
from crispy_forms.bootstrap import FormActions
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Column, Field, Layout, Row
from crispy_forms.layout import HTML, Column, Div, Field, Layout, Row
from django import forms
from django.db.models import Q
from django.utils import timezone
@@ -51,6 +56,30 @@ class AccountGroupForm(forms.ModelForm):
)
class InvestmentClassForm(forms.ModelForm):
class Meta:
model = InvestmentClass
fields = ["name", "target"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = False
self.helper.form_method = "post"
self.helper.layout = Layout(
"name",
"target",
FormActions(
NoClassSubmit(
"submit",
_("Update") if self.instance.pk else _("Add"),
css_class="btn btn-primary",
),
),
)
class AccountForm(forms.ModelForm):
group = DynamicModelChoiceField(
create_field="name",
@@ -59,6 +88,14 @@ class AccountForm(forms.ModelForm):
required=False,
)
investment_class = DynamicModelChoiceField(
create_field="name",
label=_("Asset class"),
model=InvestmentClass,
required=False,
help_text=Account._meta.get_field("investment_class").help_text,
)
class Meta:
model = Account
fields = [
@@ -68,16 +105,39 @@ class AccountForm(forms.ModelForm):
"exchange_currency",
"is_asset",
"is_archived",
"is_investment",
"investment_class",
"income_entities",
"income_categories",
"income_tags",
]
widgets = {
"currency": TomSelect(),
"exchange_currency": TomSelect(),
"income_entities": TomSelectMultiple(),
"income_categories": TomSelectMultiple(),
"income_tags": TomSelectMultiple(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["group"].queryset = AccountGroup.objects.all()
self.fields["investment_class"].queryset = InvestmentClass.all_objects.filter(
Q(owner=get_current_user())
| Q(owner=None)
# Keep a class from the account's owner when it's shared with us
| Q(accounts=self.instance.id or 0)
).distinct()
selectable = Q(active=True)
if self.instance.id:
selectable |= Q(investment_accounts=self.instance.id)
for name, model in (
("income_entities", TransactionEntity),
("income_categories", TransactionCategory),
("income_tags", TransactionTag),
):
self.fields[name].queryset = model.objects.filter(selectable).distinct()
if self.instance.id:
qs = Currency.objects.filter(
@@ -101,6 +161,21 @@ class AccountForm(forms.ModelForm):
Switch("is_archived"),
"currency",
"exchange_currency",
Switch("is_investment"),
# Only relevant for investments: shown while the switch above is on
Div(
"investment_class",
"income_entities",
"income_categories",
"income_tags",
css_id="investment_income",
css_class="" if self.instance.is_investment else "hidden",
),
HTML(
'<div class="hidden" _="on change from closest <form/> '
"if #id_is_investment.checked remove .hidden from #investment_income "
'else add .hidden to #investment_income end"></div>'
),
)
if self.instance and self.instance.pk:
@@ -0,0 +1,25 @@
# Generated by Django 5.2.17 on 2026-11-05 01:12
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0016_account_untracked_by'),
('transactions', '0052_transfer_templates'),
]
operations = [
migrations.AddField(
model_name='account',
name='income_entity',
field=models.ForeignKey(blank=True, help_text="Income transactions with this entity (dividends, interest, rent) count towards this investment's return.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='investment_accounts', to='transactions.transactionentity', verbose_name='Income entity'),
),
migrations.AddField(
model_name='account',
name='is_investment',
field=models.BooleanField(default=False, 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).', verbose_name='Investment'),
),
]
@@ -0,0 +1,43 @@
import django.db.models.deletion
from django.db import migrations, models
def copy_income_entity(apps, schema_editor):
Account = apps.get_model("accounts", "Account")
for account in Account.objects.exclude(income_entity=None):
account.income_entities.add(account.income_entity_id)
class Migration(migrations.Migration):
dependencies = [
("accounts", "0017_account_income_entity_account_is_investment"),
("transactions", "0052_transfer_templates"),
]
operations = [
# Free the "investment_accounts" reverse name for the new field
migrations.AlterField(
model_name="account",
name="income_entity",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="transactions.transactionentity",
),
),
migrations.AddField(
model_name="account",
name="income_entities",
field=models.ManyToManyField(
blank=True,
help_text="Income transactions with any of these entities (dividends, interest, rent) count towards this investment's return.",
related_name="investment_accounts",
to="transactions.transactionentity",
verbose_name="Income entities",
),
),
migrations.RunPython(copy_income_entity, migrations.RunPython.noop),
migrations.RemoveField(model_name="account", name="income_entity"),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.2.17 on 2026-11-07 04:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0018_income_entities'),
('transactions', '0052_transfer_templates'),
]
operations = [
migrations.AddField(
model_name='account',
name='income_categories',
field=models.ManyToManyField(blank=True, help_text="Transactions in this account with any of these categories are its result (interest, taxes, fees). On accounts tracked by value, everything else that isn't a transfer counts as money coming in or going out.", related_name='investment_accounts', to='transactions.transactioncategory', verbose_name='Income categories'),
),
migrations.AddField(
model_name='account',
name='income_tags',
field=models.ManyToManyField(blank=True, help_text='Same as income categories, for transactions with any of these tags.', related_name='investment_accounts', to='transactions.transactiontag', verbose_name='Income tags'),
),
migrations.AlterField(
model_name='account',
name='income_entities',
field=models.ManyToManyField(blank=True, 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).", related_name='investment_accounts', to='transactions.transactionentity', verbose_name='Income entities'),
),
]
@@ -0,0 +1,38 @@
# Generated by Django 5.2.17 on 2026-11-08 03:26
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0019_income_categories_and_tags'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='InvestmentClass',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Name')),
('target', models.DecimalField(blank=True, decimal_places=2, help_text='Share of your investments you want in this class. Leave it empty to leave the class out of the targets.', max_digits=5, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Target allocation (%)')),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Asset class',
'verbose_name_plural': 'Asset classes',
'db_table': 'investment_classes',
'ordering': ['name', 'id'],
'unique_together': {('owner', 'name')},
},
),
migrations.AddField(
model_name='account',
name='investment_class',
field=models.ForeignKey(blank=True, help_text='Groups investments in the allocation charts, e.g. Fixed income, Stocks, Real estate.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='accounts', to='accounts.investmentclass', verbose_name='Asset class'),
),
]
+108 -1
View File
@@ -1,10 +1,17 @@
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 SharedObject, SharedObjectManager
from apps.common.models import (
OwnedObject,
OwnedObjectManager,
SharedObject,
SharedObjectManager,
)
from apps.transactions.models import Transaction
@@ -25,6 +32,54 @@ class AccountGroup(SharedObject):
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(
@@ -57,6 +112,58 @@ class Account(SharedObject):
"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). On accounts tracked by value, 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"),
+80 -1
View File
@@ -1,9 +1,13 @@
from copy import copy
from decimal import Decimal
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db.models import Q
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from apps.accounts.models import AccountGroup, Account, InvestmentClass
from apps.api.serializers.currencies import CurrencySerializer
from apps.accounts.models import AccountGroup, Account
from apps.currencies.models import Currency
@@ -15,6 +19,24 @@ class AccountGroupSerializer(serializers.ModelSerializer):
fields = "__all__"
class InvestmentClassSerializer(serializers.ModelSerializer):
permission_classes = [IsAuthenticated]
class Meta:
model = InvestmentClass
fields = ["id", "name", "target"]
def validate(self, attrs):
instance = copy(self.instance) if self.instance else InvestmentClass()
for name, value in attrs.items():
setattr(instance, name, value)
try:
instance.clean() # unique name and targets up to 100%
except DjangoValidationError as e:
raise serializers.ValidationError(e.message_dict)
return attrs
class AccountSerializer(serializers.ModelSerializer):
group = AccountGroupSerializer(read_only=True)
group_id = serializers.PrimaryKeyRelatedField(
@@ -36,6 +58,15 @@ class AccountSerializer(serializers.ModelSerializer):
allow_null=True,
)
investment_class = InvestmentClassSerializer(read_only=True)
investment_class_id = serializers.PrimaryKeyRelatedField(
queryset=InvestmentClass.objects.all(),
source="investment_class",
write_only=True,
allow_null=True,
required=False,
)
permission_classes = [IsAuthenticated]
class Meta:
@@ -50,6 +81,12 @@ class AccountSerializer(serializers.ModelSerializer):
"exchange_currency",
"exchange_currency_id",
"is_asset",
"is_investment",
"investment_class",
"investment_class_id",
"income_entities",
"income_categories",
"income_tags",
]
def __init__(self, *args, **kwargs):
@@ -58,6 +95,7 @@ class AccountSerializer(serializers.ModelSerializer):
if request and request.user.is_authenticated:
# Reload the queryset to get an updated version with the requesting user
self.fields["group_id"].queryset = AccountGroup.objects.all()
self.fields["investment_class_id"].queryset = InvestmentClass.objects.all()
def create(self, validated_data):
return Account.objects.create(**validated_data)
@@ -76,3 +114,44 @@ class AccountBalanceSerializer(serializers.Serializer):
projected_balance = serializers.DecimalField(max_digits=20, decimal_places=10)
currency = CurrencySerializer()
class InvestmentPerformanceSerializer(serializers.Serializer):
"""Serializer for an investment account's performance."""
currency = CurrencySerializer()
priced = serializers.BooleanField()
quantity = serializers.DecimalField(max_digits=42, decimal_places=30)
price = serializers.DecimalField(max_digits=42, decimal_places=30, allow_null=True)
price_date = serializers.DateTimeField(allow_null=True)
average_cost = serializers.DecimalField(
max_digits=42, decimal_places=30, allow_null=True
)
cost_basis = serializers.DecimalField(max_digits=42, decimal_places=30)
market_value = serializers.DecimalField(
max_digits=42, decimal_places=30, allow_null=True
)
unrealized = serializers.DecimalField(
max_digits=42, decimal_places=30, allow_null=True
)
realized = serializers.DecimalField(max_digits=42, decimal_places=30)
income = serializers.DecimalField(max_digits=42, decimal_places=30)
contributed = serializers.DecimalField(max_digits=42, decimal_places=30)
withdrawn = serializers.DecimalField(max_digits=42, decimal_places=30)
total_return = serializers.DecimalField(max_digits=42, decimal_places=30)
return_percentage = serializers.DecimalField(
max_digits=42, decimal_places=30, allow_null=True
)
estimated = serializers.BooleanField()
money_weighted_return = serializers.SerializerMethodField(
help_text="Return weighted by when money went in and out, in percent: "
"annual (XIRR) when the first contribution is a year old or more, "
"otherwise over the period held."
)
money_weighted_annualized = serializers.SerializerMethodField()
def get_money_weighted_return(self, perf) -> Decimal | None:
return perf.money_weighted and perf.money_weighted["percentage"]
def get_money_weighted_annualized(self, perf) -> bool | None:
return perf.money_weighted and perf.money_weighted["annualized"]
@@ -0,0 +1,81 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase, override_settings
from rest_framework.test import APIClient
from apps.accounts.models import Account, InvestmentClass
from apps.currencies.models import Currency
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
DEMO=False,
)
class InvestmentClassAPITests(TestCase):
def setUp(self):
User = get_user_model()
perms = Permission.objects.filter(
codename__in=[
"add_investmentclass",
"change_investmentclass",
"delete_investmentclass",
"change_account",
]
)
self.user = User.objects.create_user(email="u@test.com", password="pw")
self.other = User.objects.create_user(email="o@test.com", password="pw")
for user in (self.user, self.other):
user.user_permissions.set(perms)
self.api = APIClient()
self.api.force_authenticate(User.objects.get(pk=self.user.pk))
def test_crud_and_validation(self):
response = self.api.post(
"/api/investment-classes/", {"name": "Stocks", "target": "60"}, format="json"
)
self.assertEqual(response.status_code, 201, response.data)
stocks_id = response.data["id"]
# Same name again, and targets past 100%: rejected, not a server error
response = self.api.post("/api/investment-classes/", {"name": "Stocks"}, format="json")
self.assertEqual(response.status_code, 400)
self.assertIn("name", response.data)
response = self.api.post(
"/api/investment-classes/", {"name": "Bonds", "target": "50"}, format="json"
)
self.assertEqual(response.status_code, 400)
self.assertIn("target", response.data)
# Editing keeps its own target out of the sum
response = self.api.patch(
f"/api/investment-classes/{stocks_id}/", {"target": "100"}, format="json"
)
self.assertEqual(response.status_code, 200, response.data)
# Another user's names and targets don't count, and they can't see ours
other = APIClient()
other.force_authenticate(get_user_model().objects.get(pk=self.other.pk))
response = other.post(
"/api/investment-classes/", {"name": "Stocks", "target": "100"}, format="json"
)
self.assertEqual(response.status_code, 201, response.data)
self.assertEqual(other.get(f"/api/investment-classes/{stocks_id}/").status_code, 404)
def test_account_takes_a_class(self):
brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2)
stocks = InvestmentClass.all_objects.create(name="Stocks", owner=self.user)
account = Account.all_objects.create(name="PETR4", currency=brl, owner=self.user)
response = self.api.patch(
f"/api/accounts/{account.id}/", {"investment_class_id": stocks.id}, format="json"
)
self.assertEqual(response.status_code, 200, response.data)
self.assertEqual(response.data["investment_class"]["name"], "Stocks")
response = self.api.get("/api/accounts/", {"investment_class": stocks.id})
self.assertEqual(response.data["count"], 1)
+1
View File
@@ -12,6 +12,7 @@ router.register(r"installment-plans", views.InstallmentPlanViewSet)
router.register(r"recurring-transactions", views.RecurringTransactionViewSet)
router.register(r"account-groups", views.AccountGroupViewSet)
router.register(r"accounts", views.AccountViewSet)
router.register(r"investment-classes", views.InvestmentClassViewSet)
router.register(r"currencies", views.CurrencyViewSet)
router.register(r"exchange-rates", views.ExchangeRateViewSet)
router.register(r"dca/strategies", views.DCAStrategyViewSet)
+34 -2
View File
@@ -4,14 +4,17 @@ from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.accounts.models import AccountGroup, Account
from apps.accounts.models import AccountGroup, Account, InvestmentClass
from apps.accounts.services import get_account_balance
from apps.api.permissions import SHARED_OBJECT_PERMISSIONS
from apps.api.serializers import (
AccountGroupSerializer,
InvestmentClassSerializer,
AccountSerializer,
AccountBalanceSerializer,
InvestmentPerformanceSerializer,
)
from apps.investments.services import get_holding_performance
class AccountGroupViewSet(viewsets.ModelViewSet):
@@ -32,12 +35,31 @@ class AccountGroupViewSet(viewsets.ModelViewSet):
return AccountGroup.objects.all()
class InvestmentClassViewSet(viewsets.ModelViewSet):
"""ViewSet for managing asset classes and their target allocation."""
queryset = InvestmentClass.objects.all()
serializer_class = InvestmentClassSerializer
filterset_fields = {"name": ["exact", "icontains"]}
search_fields = ["name"]
ordering_fields = "__all__"
ordering = ["id"]
def get_queryset(self):
return InvestmentClass.objects.all()
@extend_schema_view(
balance=extend_schema(
summary="Get account balance",
description="Returns the current and projected balance for the account, along with currency data.",
responses={200: AccountBalanceSerializer},
),
performance=extend_schema(
summary="Get investment performance",
description="Returns quantity, cost basis, market value, realized/unrealized gains, income and total return, in the account's exchange currency (or its own currency when it has none).",
responses={200: InvestmentPerformanceSerializer},
),
)
class AccountViewSet(viewsets.ModelViewSet):
"""ViewSet for managing accounts."""
@@ -48,9 +70,11 @@ class AccountViewSet(viewsets.ModelViewSet):
filterset_fields = {
"name": ["exact", "icontains"],
"group": ["exact", "isnull"],
"investment_class": ["exact", "isnull"],
"currency": ["exact"],
"exchange_currency": ["exact", "isnull"],
"is_asset": ["exact"],
"is_investment": ["exact"],
"is_archived": ["exact"],
"owner": ["exact"],
}
@@ -60,7 +84,7 @@ class AccountViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return Account.objects.all().select_related(
"group", "currency", "exchange_currency"
"group", "currency", "exchange_currency", "investment_class"
)
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
@@ -80,3 +104,11 @@ class AccountViewSet(viewsets.ModelViewSet):
)
return Response(serializer.data)
@action(detail=True, methods=["get"], permission_classes=[IsAuthenticated])
def performance(self, request, pk=None):
"""Get investment performance (cost basis, value, returns) for an account."""
account = self.get_object()
return Response(
InvestmentPerformanceSerializer(get_holding_performance(account)).data
)
+2 -1
View File
@@ -1,3 +1,4 @@
from django.conf import settings
from django.core.files.storage import FileSystemStorage
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view, inline_serializer
@@ -130,7 +131,7 @@ class ImportViewSet(viewsets.ViewSet):
uploaded_file = serializer.validated_data["file"]
# Save file to temp location
fs = FileSystemStorage(location="/usr/src/app/temp")
fs = FileSystemStorage(location=settings.IMPORT_TEMP_ROOT)
filename = fs.save(uploaded_file.name, uploaded_file)
file_path = fs.path(filename)
+10 -2
View File
@@ -21,11 +21,19 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
super().__init__(queryset=self.queryset, *args, **kwargs)
self._created_instance = None
def _get(self, **lookup):
# The form's queryset may hold choices the default manager hides, like
# another user's object already set on a shared record
try:
return self.queryset.get(**lookup)
except self.model.DoesNotExist:
return self.model.objects.get(**lookup)
def to_python(self, value):
if value in self.empty_values:
return None
try:
return self.model.objects.get(**{self.to_field_name: value})
return self._get(**{self.to_field_name: value})
except (ValueError, TypeError, self.model.DoesNotExist):
return value # Return the raw value; we'll handle creation in clean()
@@ -49,7 +57,7 @@ class DynamicModelChoiceField(forms.ModelChoiceField):
try:
if value.isdigit():
return self.model.objects.get(id=value)
return self._get(id=value)
else:
raise self.model.DoesNotExist
except self.model.DoesNotExist:
+1 -1
View File
@@ -88,7 +88,7 @@ def _dcr_initial_access_token_ok(request):
scheme, _, value = header.partition(" ")
if scheme.lower() != "bearer" or not value:
return False
return hmac.compare_digest(value, expected)
return hmac.compare_digest(value.encode(), expected.encode())
@require_http_methods(["GET"])
+12
View File
@@ -224,6 +224,18 @@ class DynamicClientRegistrationInitialAccessTokenTests(TestCase):
self.assertEqual(response.json()["error"], "invalid_token")
self.assertEqual(Application.objects.count(), 0)
def test_rejects_registration_with_non_ascii_initial_access_token(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
data=json.dumps({"redirect_uris": ["http://127.0.0.1:8765/callback"]}),
content_type="application/json",
HTTP_AUTHORIZATION="Bearer s3cret-iat\u00e9",
)
self.assertEqual(response.status_code, 401)
self.assertEqual(response.json()["error"], "invalid_token")
self.assertEqual(Application.objects.count(), 0)
def test_allows_registration_with_initial_access_token(self):
response = self.client.post(
reverse("oauth-dynamic-client-registration"),
+13
View File
@@ -1,3 +1,5 @@
import json
from django.forms import SelectMultiple, widgets
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@@ -142,6 +144,7 @@ class TransactionSelect(TomSelect):
def build_attrs(self, base_attrs, extra_attrs=None):
attrs = super().build_attrs(base_attrs, extra_attrs)
attrs["data-render"] = "transaction"
if self.load_income and self.load_expense:
attrs["data-load"] = reverse("transactions_search")
@@ -155,3 +158,13 @@ class TransactionSelect(TomSelect):
)
return attrs
def create_option(self, name, value, *args, **kwargs):
option = super().create_option(name, value, *args, **kwargs)
instance = getattr(value, "instance", None)
if instance is not None:
from apps.transactions.utils.select_search import transaction_option
# tom-select reads this to render the selected transaction like the search results
option["attrs"]["data-data"] = json.dumps(transaction_option(instance))
return option
+31 -62
View File
@@ -166,6 +166,31 @@ class ExchangeRateFetcher:
return target_currencies_qs, exchange_currencies
@staticmethod
def _store_rate(service, from_currency, to_currency, rate):
now = timezone.now()
rates = ExchangeRate.objects.filter(
automatic=True, from_currency=from_currency, to_currency=to_currency
)
today = rates.filter(date__date=timezone.localdate(now)).order_by("-date")
existing = today.first() if service.one_per_day else None
if existing:
existing.rate, existing.date = rate, now
existing.save()
else:
ExchangeRate.objects.create(
automatic=True,
from_currency=from_currency,
to_currency=to_currency,
rate=rate,
date=now,
)
if service.max_rates:
stale = rates.order_by("-date").values_list("id", flat=True)[
service.max_rates :
]
ExchangeRate.objects.filter(id__in=list(stale)).delete()
@staticmethod
def _fetch_service_rates(service: ExchangeRateService) -> None:
"""Fetch rates for a specific service"""
@@ -193,71 +218,15 @@ class ExchangeRateFetcher:
processed_pairs = set()
for from_currency, to_currency, rate in rates:
# Create a unique identifier for this currency pair
if provider.rates_inverted:
from_currency, to_currency = to_currency, from_currency
pair_key = (from_currency.id, to_currency.id)
if pair_key in processed_pairs:
continue
if provider.rates_inverted:
# If rates are inverted, we need to swap currencies
if service.singleton:
# Try to get the last automatically created exchange rate
exchange_rate = (
ExchangeRate.objects.filter(
automatic=True,
from_currency=to_currency,
to_currency=from_currency,
)
.order_by("-date")
.first()
)
else:
exchange_rate = None
if not exchange_rate:
ExchangeRate.objects.create(
automatic=True,
from_currency=to_currency,
to_currency=from_currency,
rate=rate,
date=timezone.now(),
)
else:
exchange_rate.rate = rate
exchange_rate.date = timezone.now()
exchange_rate.save()
processed_pairs.add((to_currency.id, from_currency.id))
else:
# If rates are not inverted, we can use them as is
if service.singleton:
# Try to get the last automatically created exchange rate
exchange_rate = (
ExchangeRate.objects.filter(
automatic=True,
from_currency=from_currency,
to_currency=to_currency,
)
.order_by("-date")
.first()
)
else:
exchange_rate = None
if not exchange_rate:
ExchangeRate.objects.create(
automatic=True,
from_currency=from_currency,
to_currency=to_currency,
rate=rate,
date=timezone.now(),
)
else:
exchange_rate.rate = rate
exchange_rate.date = timezone.now()
exchange_rate.save()
processed_pairs.add((from_currency.id, to_currency.id))
ExchangeRateFetcher._store_rate(
service, from_currency, to_currency, rate
)
processed_pairs.add(pair_key)
service.last_fetch = timezone.now()
service.failure_count = 0
+4 -2
View File
@@ -112,7 +112,8 @@ class ExchangeRateServiceForm(forms.ModelForm):
"fetch_interval",
"target_currencies",
"target_accounts",
"singleton",
"one_per_day",
"max_rates",
]
widgets = {"options": forms.Textarea(attrs={"rows": 2})}
@@ -149,7 +150,6 @@ class ExchangeRateServiceForm(forms.ModelForm):
"name",
"service_type",
Switch("is_active"),
Switch("singleton"),
"api_key",
"options",
Row(
@@ -158,6 +158,8 @@ class ExchangeRateServiceForm(forms.ModelForm):
),
"target_currencies",
"target_accounts",
Switch("one_per_day"),
"max_rates",
)
if self.instance and self.instance.pk:
@@ -0,0 +1,43 @@
import django.core.validators
from django.db import migrations, models
def singleton_to_max_rates(apps, schema_editor):
Service = apps.get_model("currencies", "ExchangeRateService")
Service.objects.filter(singleton=True).update(max_rates=1)
def max_rates_to_singleton(apps, schema_editor):
Service = apps.get_model("currencies", "ExchangeRateService")
Service.objects.filter(max_rates=1).update(singleton=True)
class Migration(migrations.Migration):
dependencies = [
("currencies", "0026_exchangerateservice_options"),
]
operations = [
migrations.AddField(
model_name="exchangerateservice",
name="one_per_day",
field=models.BooleanField(
default=False,
help_text="Update the day's exchange rate on each fetch instead of adding a new one.",
verbose_name="One exchange rate per day",
),
),
migrations.AddField(
model_name="exchangerateservice",
name="max_rates",
field=models.PositiveIntegerField(
blank=True,
null=True,
validators=[django.core.validators.MinValueValidator(1)],
help_text="Keep at most this many automatic exchange rates per currency pair, deleting the oldest first. Leave empty to keep all.",
verbose_name="Exchange rates to keep",
),
),
migrations.RunPython(singleton_to_max_rates, max_rates_to_singleton),
migrations.RemoveField(model_name="exchangerateservice", name="singleton"),
]
+12 -3
View File
@@ -167,11 +167,20 @@ class ExchangeRateService(models.Model):
blank=True,
)
singleton = models.BooleanField(
verbose_name=_("Single exchange rate"),
one_per_day = models.BooleanField(
verbose_name=_("One exchange rate per day"),
default=False,
help_text=_(
"Create one exchange rate and keep updating it. Avoids database clutter."
"Update the day's exchange rate on each fetch instead of adding a new one."
),
)
max_rates = models.PositiveIntegerField(
verbose_name=_("Exchange rates to keep"),
null=True,
blank=True,
validators=[MinValueValidator(1)],
help_text=_(
"Keep at most this many automatic exchange rates per currency pair, deleting the oldest first. Leave empty to keep all."
),
)
@@ -1,10 +1,11 @@
import datetime
from decimal import Decimal
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.utils import timezone
from apps.currencies.models import Currency, ExchangeRateService
from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService
from apps.currencies.exchange_rates.fetcher import ExchangeRateFetcher
@@ -107,3 +108,42 @@ class ExchangeRateServiceFailureTrackingTests(TestCase):
self.service.refresh_from_db()
self.assertIsNotNone(self.service.last_fetch)
self.assertEqual(self.service.failure_count, 0)
class ExchangeRateRetentionTests(TestCase):
def setUp(self):
self.usd = Currency.objects.create(code="USD", name="Dollar", decimal_places=2)
self.eur = Currency.objects.create(
code="EUR", name="Euro", decimal_places=2, exchange_currency=self.usd
)
self.service = ExchangeRateService.objects.create(
name="S", service_type=ExchangeRateService.ServiceType.FRANKFURTER
)
def fetch(self, rate, when):
with patch("django.utils.timezone.now", return_value=when):
ExchangeRateFetcher._store_rate(self.service, self.eur, self.usd, Decimal(rate))
def rates(self):
return list(
ExchangeRate.objects.order_by("date").values_list("rate", flat=True)
)
def test_keeps_every_fetch_by_default(self):
for hour in (1, 2, 3):
self.fetch(hour, timezone.now().replace(hour=hour))
self.assertEqual(len(self.rates()), 3)
def test_max_rates_deletes_the_oldest(self):
self.service.max_rates = 2
for hour in (1, 2, 3):
self.fetch(hour, timezone.now().replace(hour=hour))
self.assertEqual(self.rates(), [Decimal(2), Decimal(3)])
def test_one_per_day_updates_the_days_rate(self):
self.service.one_per_day = True
day = timezone.localtime().replace(hour=10)
self.fetch(1, day)
self.fetch(2, day.replace(hour=11))
self.fetch(3, day + datetime.timedelta(days=1))
self.assertEqual(self.rates(), [Decimal(2), Decimal(3)])
+25 -26
View File
@@ -12,7 +12,7 @@ from apps.dca.models import DCAEntry, DCAStrategy
from apps.transactions.models import Transaction, TransactionCategory, TransactionTag
from crispy_forms.bootstrap import AccordionGroup, FormActions, Accordion
from crispy_forms.helper import FormHelper
from crispy_forms.layout import HTML, Column, Layout, Row
from crispy_forms.layout import HTML, Column, Div, Layout, Row
from django import forms
from django.utils.translation import gettext_lazy as _
@@ -157,34 +157,21 @@ class DCAEntryForm(forms.ModelForm):
AccordionGroup(
_("Create transaction"),
Switch("create_transaction"),
Row(
Column(
Row(
Column(
"from_account",
),
),
Row(
Column("from_category"),
Column("from_tags"),
),
Div(
"from_account",
Row(
Column("from_category"),
Column("from_tags"),
),
css_class="p-1 mx-1 my-3 border rounded-3",
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border my-3",
),
Row(
Column(
Row(
Column(
"to_account",
css_class="form-group",
),
),
Row(
Column("to_category"),
Column("to_tags"),
),
Div(
"to_account",
Row(
Column("to_category"),
Column("to_tags"),
),
css_class="p-1 mx-1 my-3 border rounded-3",
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border",
),
active=False,
),
@@ -289,6 +276,14 @@ class DCAEntryForm(forms.ModelForm):
raise forms.ValidationError(
_("From and To accounts must be different.")
)
else:
# Both sides of a purchase are linked as a transfer on save
expense = cleaned_data.get("expense_transaction")
income = cleaned_data.get("income_transaction")
if expense and income:
error = expense.transfer_link_error(income)
if error:
self.add_error("income_transaction", error)
return cleaned_data
@@ -334,6 +329,8 @@ class DCAEntryForm(forms.ModelForm):
)
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
from_transaction.link_transfer(to_transaction)
instance.expense_transaction = from_transaction
instance.income_transaction = to_transaction
else:
@@ -343,6 +340,8 @@ class DCAEntryForm(forms.ModelForm):
if instance.income_transaction:
instance.income_transaction.amount = instance.amount_received
instance.income_transaction.save()
if instance.expense_transaction and instance.income_transaction:
instance.expense_transaction.link_transfer(instance.income_transaction)
instance.strategy = self.strategy
instance.save()
@@ -0,0 +1,39 @@
from django.db import migrations
def link_entry_transactions(apps, schema_editor):
"""Joins each DCA entry's expense and income into a transfer, so investment
tracking sees the purchase. Mirrors Transaction.transfer_link_error()."""
DCAEntry = apps.get_model("dca", "DCAEntry")
Transaction = apps.get_model("transactions", "Transaction")
entries = DCAEntry.objects.filter(
expense_transaction__isnull=False, income_transaction__isnull=False
).select_related("expense_transaction", "income_transaction")
for entry in entries:
expense, income = entry.expense_transaction, entry.income_transaction
if (
expense.type != "EX"
or income.type != "IN"
or expense.account_id == income.account_id
or expense.linked_transaction_id
or income.linked_transaction_id
# a legacy one-way link already points at one of them
or Transaction.objects.filter(
linked_transaction_id__in=(expense.pk, income.pk)
).exists()
):
continue
Transaction.objects.filter(pk=expense.pk).update(linked_transaction=income)
Transaction.objects.filter(pk=income.pk).update(linked_transaction=expense)
class Migration(migrations.Migration):
dependencies = [
("dca", "0004_alter_dcastrategy_owner_and_more"),
("transactions", "0052_transfer_templates"),
]
operations = [
migrations.RunPython(link_entry_transactions, migrations.RunPython.noop),
]
@@ -0,0 +1,99 @@
from datetime import date
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.test import TestCase
from apps.accounts.models import Account
from apps.common.middleware.thread_local import delete_current_user, write_current_user
from apps.currencies.models import Currency
from apps.dca.forms import DCAEntryForm
from apps.dca.models import DCAStrategy
from apps.transactions.models import Transaction
IN, EX = Transaction.Type.INCOME, Transaction.Type.EXPENSE
class DCATransferLinkingTests(TestCase):
def setUp(self):
# Form querysets are scoped to the current user
write_current_user(get_user_model().objects.create_user(email="d@test.com"))
self.addCleanup(delete_current_user)
brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2)
btc = Currency.objects.create(code="BTC", name="Bitcoin", decimal_places=8)
self.cash = Account.objects.create(name="Cash", currency=brl)
self.wallet = Account.objects.create(name="Wallet", currency=btc)
self.strategy = DCAStrategy.objects.create(
name="BTC", target_currency=btc, payment_currency=brl
)
def tx(self, account, type_, amount):
return Transaction.objects.create(
account=account, type=type_, amount=Decimal(amount), date=date(2026, 1, 5)
)
def entry_data(self, **extra):
return {
"date": "2026-01-05",
"amount_paid": "500",
"amount_received": "0.001",
**extra,
}
def test_created_transactions_are_linked(self):
form = DCAEntryForm(
data=self.entry_data(
create_transaction="on",
from_account=self.cash.id,
to_account=self.wallet.id,
),
strategy=self.strategy,
)
self.assertTrue(form.is_valid(), form.errors)
entry = form.save()
self.assertEqual(
entry.expense_transaction.linked_transaction, entry.income_transaction
)
self.assertEqual(
entry.income_transaction.linked_transaction, entry.expense_transaction
)
def test_existing_transactions_are_linked(self):
expense, income = self.tx(self.cash, EX, "500"), self.tx(self.wallet, IN, "0.001")
form = DCAEntryForm(
data=self.entry_data(
expense_transaction=str(expense.id), income_transaction=str(income.id)
),
strategy=self.strategy,
)
self.assertTrue(form.is_valid(), form.errors)
form.save()
expense.refresh_from_db()
income.refresh_from_db()
self.assertEqual(expense.linked_transaction, income)
self.assertEqual(income.linked_transaction, expense)
def test_transaction_in_another_transfer_is_rejected(self):
expense, income = self.tx(self.cash, EX, "500"), self.tx(self.wallet, IN, "0.001")
other = self.tx(self.wallet, IN, "0.002")
expense.link_transfer(other)
form = DCAEntryForm(
data=self.entry_data(
expense_transaction=str(expense.id), income_transaction=str(income.id)
),
strategy=self.strategy,
)
self.assertFalse(form.is_valid())
self.assertIn("income_transaction", form.errors)
def test_link_transfer_rules(self):
expense = self.tx(self.cash, EX, "500")
with self.assertRaises(ValidationError):
expense.link_transfer(self.tx(self.cash, IN, "500")) # same account
with self.assertRaises(ValidationError):
expense.link_transfer(self.tx(self.wallet, EX, "1")) # two expenses
income = self.tx(self.wallet, IN, "0.001")
expense.link_transfer(income)
expense.link_transfer(income) # already linked to each other: no-op
self.assertEqual(Transaction.objects.get(pk=income.pk).linked_transaction, expense)
+2
View File
@@ -108,6 +108,7 @@ class ExportForm(forms.Form):
self.helper.layout = Layout(
"users",
"accounts",
"investment_classes",
"currencies",
"transactions",
"categories",
@@ -135,6 +136,7 @@ class RestoreForm(forms.Form):
)
users = forms.FileField(required=False, label=_("Users"))
accounts = forms.FileField(required=False, label=_("Accounts"))
investment_classes = forms.FileField(required=False, label=_("Asset classes"))
currencies = forms.FileField(required=False, label=_("Currencies"))
transactions_categories = forms.FileField(required=False, label=_("Categories"))
transactions_tags = forms.FileField(required=False, label=_("Tags"))
+21 -1
View File
@@ -1,10 +1,24 @@
from import_export import fields, resources, widgets
from apps.accounts.models import Account, AccountGroup
from apps.accounts.models import Account, AccountGroup, InvestmentClass
from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget
from apps.currencies.models import Currency
class InvestmentClassResource(resources.ModelResource):
class Meta:
model = InvestmentClass
def get_queryset(self):
return InvestmentClass.all_objects.all()
class AllClassesWidget(widgets.ForeignKeyWidget):
# By id, across owners: a shared account may carry another user's class
def get_queryset(self, value, row, *args, **kwargs):
return InvestmentClass.all_objects.all()
class AccountResource(resources.ModelResource):
group = fields.Field(
attribute="group",
@@ -22,6 +36,12 @@ class AccountResource(resources.ModelResource):
widget=widgets.ForeignKeyWidget(Currency, "name"),
)
investment_class = fields.Field(
attribute="investment_class",
column_name="investment_class",
widget=AllClassesWidget(InvestmentClass),
)
class Meta:
model = Account
+51 -1
View File
@@ -1,3 +1,53 @@
import io
import zipfile
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase
# Create your tests here.
from apps.accounts.models import Account, InvestmentClass
from apps.common.middleware.thread_local import delete_current_user, write_current_user
from apps.currencies.models import Currency
from apps.export_app.resources.accounts import AccountResource, InvestmentClassResource
from apps.export_app.resources.transactions import TransactionEntityResource
from apps.export_app.views import process_imports
from apps.transactions.models import TransactionEntity
class InvestmentRestoreTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(email="u@test.com", password="pw")
write_current_user(self.user)
def tearDown(self):
delete_current_user()
def test_round_trip_keeps_asset_class_and_income_links(self):
brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2)
stocks = InvestmentClass.objects.create(name="Stocks", target=Decimal("60"))
broker = TransactionEntity.objects.create(name="Broker")
account = Account.objects.create(
name="PETR4", currency=brl, is_investment=True, investment_class=stocks
)
account.income_entities.add(broker)
backup = io.BytesIO()
with zipfile.ZipFile(backup, "w") as z:
for resource, name in (
(AccountResource, "accounts"),
(InvestmentClassResource, "investment_classes"),
(TransactionEntityResource, "transactions_entities"),
):
z.writestr(f"{name}.csv", resource().export().csv)
Account.all_objects.all().delete()
InvestmentClass.all_objects.all().delete()
TransactionEntity.all_objects.all().delete()
backup.seek(0)
process_imports(None, {"zip_file": backup})
account = Account.all_objects.get(name="PETR4")
self.assertEqual(account.investment_class.name, "Stocks")
self.assertEqual(account.investment_class.target, Decimal("60"))
self.assertEqual([e.name for e in account.income_entities.all()], ["Broker"])
+10 -1
View File
@@ -14,7 +14,10 @@ from tablib import Dataset
from apps.common.decorators.htmx import only_htmx
from apps.export_app.forms import ExportForm, RestoreForm
from apps.export_app.resources.accounts import AccountResource
from apps.export_app.resources.accounts import (
AccountResource,
InvestmentClassResource,
)
from apps.export_app.resources.bank_sync import (
BankSyncAccountResource,
BankSyncServiceResource,
@@ -95,6 +98,9 @@ def export_form(request):
exports.append((UserResource().export(), "users"))
if export_accounts:
exports.append((AccountResource().export(), "accounts"))
exports.append(
(InvestmentClassResource().export(), "investment_classes")
)
if export_currencies:
exports.append((CurrencyResource().export(), "currencies"))
if export_transactions:
@@ -233,10 +239,13 @@ def process_imports(request, cleaned_data):
"currencies",
CurrencyResource,
), # We do a double pass because exchange_currency may not exist when currency is initially created
("investment_classes", InvestmentClassResource),
("accounts", AccountResource),
("transactions_categories", TransactionCategoyResource),
("transactions_tags", TransactionTagResource),
("transactions_entities", TransactionEntityResource),
# Again: income categories, tags and entities don't exist on the first pass
("accounts", AccountResource),
("automatic_exchange_rates", ExchangeRateServiceResource),
("exchange_rates", ExchangeRateResource),
("installment_plans", InstallmentPlanResource),
+3 -3
View File
@@ -1,17 +1,17 @@
import json
from pathlib import Path
from django.conf import settings
from apps.import_app.models import ImportProfile
class PresetService:
PRESET_PATH = "/usr/src/app/import_presets"
@classmethod
def get_all_presets(cls):
presets = []
for folder in Path(cls.PRESET_PATH).iterdir():
for folder in settings.IMPORT_PRESETS_ROOT.iterdir():
if folder.is_dir():
manifest_path = folder / "manifest.json"
config_path = folder / "config.yml"
+9 -6
View File
@@ -7,12 +7,14 @@ import zipfile
from django.db import transaction
from datetime import datetime, date
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Dict, Any, Literal, Union
import openpyxl
import xlrd
import yaml
from cachalot.api import cachalot_disabled
from django.conf import settings
from django.core.exceptions import FieldDoesNotExist
from django.utils import timezone
from openpyxl.utils.exceptions import InvalidFileException
@@ -39,8 +41,6 @@ logger = logging.getLogger(__name__)
class ImportService:
TEMP_DIR = "/usr/src/app/temp"
def __init__(self, import_run: ImportRun):
self.import_run: ImportRun = import_run
self.profile: ImportProfile = import_run.profile
@@ -54,7 +54,7 @@ class ImportService:
self.mapping: Dict[str, version_1.ColumnMapping] = self.config.mapping
# Ensure temp directory exists
os.makedirs(self.TEMP_DIR, exist_ok=True)
os.makedirs(settings.IMPORT_TEMP_ROOT, exist_ok=True)
def _load_config(self) -> version_1.ImportProfileSchema:
yaml_data = yaml.safe_load(self.profile.yaml_config)
@@ -1131,9 +1131,12 @@ class ImportService:
Validates that the file path is within the allowed temporary directory.
Returns the absolute path.
"""
abs_path = os.path.abspath(file_path)
if not abs_path.startswith(self.TEMP_DIR):
raise ValueError(f"Invalid file path. File must be in {self.TEMP_DIR}")
# resolve() like IMPORT_TEMP_ROOT, so symlinks can't point outside it
abs_path = str(Path(file_path).resolve())
if not Path(abs_path).is_relative_to(settings.IMPORT_TEMP_ROOT):
raise ValueError(
f"Invalid file path. File must be in {settings.IMPORT_TEMP_ROOT}"
)
return abs_path
def process_file(self, file_path: str):
+161
View File
@@ -0,0 +1,161 @@
"""Tests for the folders import code reads from and writes to."""
import json
import os
import runpy
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import SimpleTestCase, override_settings
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APIClient
from apps.import_app.models import ImportRun
from apps.import_app.services import PresetService
from apps.import_app.services.v1 import ImportService
from apps.import_app.tests.test_view_permissions import (
HTMX,
ImportObjectPermissionTestCase,
)
def load_settings(**env):
"""Evaluates the settings module from scratch under the given env vars."""
with patch.dict(os.environ, env):
return runpy.run_path(str(Path(settings.BASE_DIR) / "WYGIWYH" / "settings.py"))
class PathSettingsTests(SimpleTestCase):
def test_defaults_match_the_docker_image_layout(self):
loaded = load_settings(IMPORT_TEMP_ROOT="", CACHE_ROOT="")
base_dir = loaded["BASE_DIR"]
self.assertEqual(loaded["IMPORT_TEMP_ROOT"], base_dir / "temp")
self.assertEqual(loaded["IMPORT_PRESETS_ROOT"], base_dir / "import_presets")
self.assertEqual(
loaded["CACHES"]["default"]["LOCATION"],
Path("/var/tmp/django_cache").resolve(),
)
self.assertEqual(
loaded["CACHES"]["update_check"]["LOCATION"],
Path("/var/tmp/django_cache_update_check").resolve(),
)
def test_env_vars_move_temp_and_cache_folders(self):
with tempfile.TemporaryDirectory() as root:
root = Path(root).resolve()
loaded = load_settings(
IMPORT_TEMP_ROOT=str(root / "uploads"), CACHE_ROOT=str(root / "cache")
)
self.assertEqual(loaded["IMPORT_TEMP_ROOT"], root / "uploads")
self.assertEqual(
loaded["CACHES"]["default"]["LOCATION"], root / "cache" / "django_cache"
)
self.assertEqual(
loaded["CACHES"]["update_check"]["LOCATION"],
root / "cache" / "django_cache_update_check",
)
class PresetServiceTests(SimpleTestCase):
def test_lists_the_presets_shipped_with_the_app(self):
folder_names = {
preset["folder_name"] for preset in PresetService.get_all_presets()
}
self.assertIn("qif_standard", folder_names)
def test_reads_presets_from_configured_folder(self):
with tempfile.TemporaryDirectory() as root:
preset = Path(root) / "my_bank"
preset.mkdir()
(preset / "manifest.json").write_text(json.dumps({"name": "My Bank"}))
(preset / "config.yml").write_text("settings: {}")
with override_settings(IMPORT_PRESETS_ROOT=Path(root)):
presets = PresetService.get_all_presets()
self.assertEqual([p["name"] for p in presets], ["My Bank"])
class ImportTempRootTests(ImportObjectPermissionTestCase):
def setUp(self):
super().setUp()
self.temp_root = Path(tempfile.mkdtemp()).resolve()
self.addCleanup(shutil.rmtree, self.temp_root, ignore_errors=True)
temp_root_override = override_settings(IMPORT_TEMP_ROOT=self.temp_root)
temp_root_override.enable()
self.addCleanup(temp_root_override.disable)
@patch("apps.import_app.views.process_import.defer")
def test_web_upload_is_stored_in_temp_root(self, mock_defer):
self.login(self.owner)
response = self.client.post(
reverse("import_run_add", kwargs={"profile_id": self.private_profile.id}),
{"file": SimpleUploadedFile("web.csv", b"date,amount\n")},
**HTMX,
)
self.assertEqual(response.status_code, 204)
self.assertEqual(
mock_defer.call_args.kwargs["file_path"], str(self.temp_root / "web.csv")
)
self.assertTrue((self.temp_root / "web.csv").is_file())
@patch("apps.api.views.imports.process_import.defer")
def test_api_upload_is_stored_in_temp_root(self, mock_defer):
client = APIClient()
client.force_authenticate(user=self.owner)
response = client.post(
"/api/import/import/",
{
"profile_id": self.private_profile.id,
"file": SimpleUploadedFile("api.csv", b"date,amount\n"),
},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
self.assertEqual(
mock_defer.call_args.kwargs["file_path"], str(self.temp_root / "api.csv")
)
self.assertTrue((self.temp_root / "api.csv").is_file())
def _service(self):
run = ImportRun.objects.create(
profile=self.private_profile, file_name="file.csv", owner=self.owner
)
return ImportService(run)
def test_service_accepts_files_inside_temp_root(self):
path = str(self.temp_root / "file.csv")
self.assertEqual(self._service()._validate_file_path(path), path)
def test_service_rejects_files_outside_temp_root(self):
service = self._service()
for path in (
"/etc/passwd",
self.temp_root / ".." / "file.csv",
# Shares the temp root as a string prefix, but is a different folder
f"{self.temp_root}-other/file.csv",
):
with self.subTest(path=path), self.assertRaises(ValueError):
service._validate_file_path(str(path))
def test_service_rejects_symlinks_pointing_outside_temp_root(self):
link = self.temp_root / "link.csv"
try:
link.symlink_to("/etc/passwd")
except OSError:
self.skipTest("symlinks not available")
with self.assertRaises(ValueError):
self._service()._validate_file_path(str(link))
+5 -5
View File
@@ -1,7 +1,7 @@
from decimal import Decimal
import os
import shutil
from django.test import TestCase
from django.test import TestCase, override_settings
from django.contrib.auth import get_user_model
from apps.accounts.models import Account, AccountGroup
from apps.currencies.models import Currency
@@ -15,10 +15,11 @@ from apps.transactions.models import (
class QIFImportTests(TestCase):
def setUp(self):
# Patch TEMP_DIR for testing
self.original_temp_dir = ImportService.TEMP_DIR
# Point the import temp folder somewhere disposable for testing
self.test_dir = os.path.abspath("temp_test_import")
ImportService.TEMP_DIR = self.test_dir
temp_root_override = override_settings(IMPORT_TEMP_ROOT=self.test_dir)
temp_root_override.enable()
self.addCleanup(temp_root_override.disable)
os.makedirs(self.test_dir, exist_ok=True)
# Create user and set context
@@ -41,7 +42,6 @@ class QIFImportTests(TestCase):
def tearDown(self):
delete_current_user()
ImportService.TEMP_DIR = self.original_temp_dir
if os.path.exists(self.test_dir):
shutil.rmtree(self.test_dir)
+2 -1
View File
@@ -1,5 +1,6 @@
import shutil
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
@@ -204,7 +205,7 @@ def import_run_add(request, profile_id):
if form.is_valid():
uploaded_file = request.FILES["file"]
fs = FileSystemStorage(location="/usr/src/app/temp")
fs = FileSystemStorage(location=settings.IMPORT_TEMP_ROOT)
filename = fs.save(uploaded_file.name, uploaded_file)
file_path = fs.path(filename)
View File
Whitespace-only changes.
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class InvestmentsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.investments"
+515
View File
@@ -0,0 +1,515 @@
"""Investment performance, derived from an account's transactions.
Rules (see the account's "Investment" help text):
- A linked transaction (a transfer) moves money in or out. Its other side says
what was paid or received, which is the cost or the proceeds.
- An unlinked transaction on an account tracked by value is money coming in or
going out (a salary, a bill), unless it has one of the account's income
categories, tags or entities: then it is a gain or a loss (a reconciled balance,
interest, a tax withheld).
- On a unit-priced account an unlinked outflow is a loss, and an unlinked inflow
(a gift, an opening balance) has no known cost, so it is costed at the market
price of that day and the result is flagged as estimated.
- Cost is tracked with the average cost method.
"""
import datetime
from dataclasses import dataclass, field
from decimal import Decimal
from functools import cached_property
from django.db.models import Q
from django.utils import timezone
from apps.currencies.models import ExchangeRate
from apps.currencies.utils.convert import convert, get_exchange_rate
from apps.transactions.models import Transaction
ZERO = Decimal("0")
@dataclass
class HoldingPerformance:
account: object
currency: object # valuation currency
priced: bool # True: units x price. False: tracked by value.
quantity: Decimal = ZERO
cost_basis: Decimal = ZERO
price: Decimal | None = None
price_date: datetime.datetime | None = None
realized: Decimal = ZERO
income: Decimal = ZERO
contributed: Decimal = ZERO
withdrawn: Decimal = ZERO
estimated: bool = False
# Unlinked transactions counted as gains or losses, on accounts tracked by value
result_ids: set = field(default_factory=set)
history: list = field(default_factory=list)
# (date, "buy" or "sell", unit price) for linked trades on unit-priced accounts
trades: list = field(default_factory=list)
# (date, signed amount) for the gains and losses of an account tracked by value
results: list = field(default_factory=list)
# (date, amount, kind) in the valuation currency: negative = money in ("in"),
# positive = money out ("out" sales/withdrawals, "income"). Used for
# money-weighted returns and currency conversion.
flows: list = field(default_factory=list)
as_of: datetime.date | None = None
@cached_property
def money_weighted(self):
return money_weighted_return(self.flows, self.market_value, self.as_of)
@property
def average_cost(self):
return self.cost_basis / self.quantity if self.quantity else None
@property
def market_value(self):
if self.price is None:
return None
return self.quantity * self.price
@property
def unrealized(self):
value = self.market_value
return None if value is None else value - self.cost_basis
@property
def total_return(self):
return (self.unrealized or ZERO) + self.realized + self.income
@property
def return_percentage(self):
if not self.contributed:
return None
return self.total_return / self.contributed * 100
@property
def unrealized_percentage(self):
if not self.cost_basis or self.unrealized is None:
return None
return self.unrealized / self.cost_basis * 100
def _to(amount, from_currency, to_currency, date):
if from_currency == to_currency or not amount:
return amount
return convert(amount, from_currency, to_currency, date)[0]
def _price(account, valuation_currency, date):
if account.currency == valuation_currency:
return Decimal("1"), None
rate = get_exchange_rate(account.currency, valuation_currency, date)
if rate is None:
return None, None
return rate.effective_rate, rate.date
def _linked_ids(account, field_name):
# The join table, not the related manager: that one is per-user and comes back
# empty without a current user
relation = account._meta.get_field(field_name)
return list(
relation.remote_field.through.objects.filter(account=account).values_list(
relation.m2m_reverse_name(), flat=True
)
)
def income_entity_ids(account):
return _linked_ids(account, "income_entities")
def _result_ids(account):
return set(
Transaction.objects.filter(account=account)
.filter(
Q(category__in=_linked_ids(account, "income_categories"))
| Q(tags__in=_linked_ids(account, "income_tags"))
| Q(entities__in=income_entity_ids(account))
)
.values_list("id", flat=True)
)
def get_holding_performance(account, as_of=None, with_history=False):
"""Performance of an investment account up to ``as_of`` (today by default),
in the account's exchange currency, or its own currency if it has none."""
today = timezone.localdate()
as_of = as_of or today
valuation_currency = account.exchange_currency or account.currency
perf = HoldingPerformance(
account=account,
currency=valuation_currency,
priced=account.exchange_currency_id is not None,
)
transactions = (
Transaction.objects.filter(account=account, is_paid=True, date__lte=as_of)
.select_related("linked_transaction__account__currency")
.order_by("date", "id")
)
month_ends = _month_ends(transactions, as_of) if with_history else []
if not perf.priced:
perf.result_ids = _result_ids(account)
for tx in transactions:
while month_ends and month_ends[0] < tx.date:
perf.history.append(_snapshot(perf, account, month_ends.pop(0)))
# shortcut: only the forward link is read; WYGIWYH transfers link both ways.
partner = tx.linked_transaction
if partner and (partner.account_id == account.id or partner.deleted):
partner = None
partner_value = (
_to(partner.amount, partner.account.currency, valuation_currency, tx.date)
if partner
else None
)
external = not (perf.priced or partner or tx.id in perf.result_ids)
if perf.priced and partner_value is not None and tx.amount:
kind = "buy" if tx.type == Transaction.Type.INCOME else "sell"
perf.trades.append((tx.date, kind, partner_value / tx.amount))
elif not (perf.priced or partner or external):
sign = 1 if tx.type == Transaction.Type.INCOME else -1
perf.results.append((tx.date, sign * tx.amount))
if tx.type == Transaction.Type.INCOME:
if external:
cost = tx.amount
perf.contributed += cost
elif partner_value is not None:
cost = partner_value
perf.contributed += cost
elif perf.priced or partner:
# No known cost: price it at that day's market value
cost = _to(tx.amount, account.currency, valuation_currency, tx.date)
perf.estimated = True
if partner:
perf.contributed += cost or ZERO
else:
cost = ZERO # value-tracked: a gain
if cost:
perf.flows.append((tx.date, -cost, "in"))
perf.quantity += tx.amount
perf.cost_basis += cost or ZERO
else:
if perf.quantity > 0:
removed = perf.cost_basis * min(tx.amount, perf.quantity) / perf.quantity
else:
removed = ZERO
if partner or external:
proceeds = tx.amount if external else partner_value
if proceeds is None:
proceeds = _to(tx.amount, account.currency, valuation_currency, tx.date) or ZERO
perf.estimated = True
perf.realized += proceeds - removed
perf.withdrawn += proceeds
perf.flows.append((tx.date, proceeds, "out"))
perf.cost_basis -= removed
# otherwise a loss, tax or fee: cost stays, so value drops
perf.quantity -= tx.amount
if perf.quantity <= 0 and perf.cost_basis:
perf.realized -= perf.cost_basis
perf.cost_basis = ZERO
for month_end in month_ends:
perf.history.append(_snapshot(perf, account, month_end))
perf.price, perf.price_date = _price(
account,
valuation_currency,
timezone.now() if as_of == today else as_of,
)
entity_ids = income_entity_ids(account)
if entity_ids:
income = Transaction.objects.filter(
entities__in=entity_ids,
type=Transaction.Type.INCOME,
is_paid=True,
date__lte=as_of,
).exclude(account=account).distinct().select_related("account__currency")
for tx in income:
amount = _to(tx.amount, tx.account.currency, valuation_currency, tx.date)
if amount:
perf.income += amount
perf.flows.append((tx.date, amount, "income"))
perf.as_of = as_of
return perf
def xirr(flows):
"""Annual rate r where the dated flows' present value is zero, as a float
(0.138 = 13.8% a year). None when it can't be solved."""
if not flows:
return None
start = min(flow[0] for flow in flows)
points = [((flow[0] - start).days / 365, float(flow[1])) for flow in flows]
if not (any(a < 0 for _, a in points) and any(a > 0 for _, a in points)):
return None
def npv(rate):
return sum(a / (1 + rate) ** t for t, a in points)
# Bisection: slower than Newton but can't diverge on odd cash flows
low, high = -0.9999, 100.0
npv_low = npv(low)
if npv_low * npv(high) > 0:
return None
for _ in range(200):
mid = (low + high) / 2
npv_mid = npv(mid)
if npv_low * npv_mid <= 0:
high = mid
else:
low, npv_low = mid, npv_mid
return (low + high) / 2
def money_weighted_return(flows, market_value, as_of):
"""Return that accounts for when money went in and out, as
``{"percentage": Decimal, "annualized": bool}`` or None.
Over a year or more it is annualized (XIRR). Below a year an annual rate
exaggerates, so it is turned back into the return over the period held."""
if not flows or market_value is None:
return None
rate = xirr(flows + [(as_of, market_value)])
if rate is None:
return None
days = (as_of - min(flow[0] for flow in flows)).days
if days >= 365:
return {"percentage": Decimal(str(round(rate * 100, 4))), "annualized": True}
period = (1 + rate) ** (days / 365) - 1
return {"percentage": Decimal(str(round(period * 100, 4))), "annualized": False}
def _month_ends(transactions, as_of):
first = transactions.values_list("date", flat=True).first()
if not first:
return []
ends = []
year, month = first.year, first.month
while True:
next_first = datetime.date(year + month // 12, month % 12 + 1, 1)
end = next_first - datetime.timedelta(days=1)
if end >= as_of:
ends.append(as_of)
return ends
ends.append(end)
year, month = next_first.year, next_first.month
def _snapshot(perf, account, date):
price, _ = _price(account, perf.currency, date)
return {
"date": date,
"invested": perf.cost_basis,
"value": perf.quantity * price if price is not None else None,
"price": price,
"average_cost": perf.average_cost,
}
def conversion_path(currency):
"""``currency`` followed by its exchange currencies (USD -> BRL -> ...), for as
long as an exchange rate between each step exists."""
path = [currency]
while True:
target = path[-1].exchange_currency
if (
target is None
or target in path
or get_exchange_rate(path[-1], target, timezone.now()) is None
):
return path
path.append(target)
def _along(amount, path, date):
for from_currency, to_currency in zip(path, path[1:]):
amount = _to(amount, from_currency, to_currency, date)
return amount
def _native(perf):
return {
"currency": perf.currency,
"market_value": perf.market_value,
"cost_basis": perf.cost_basis,
"income": perf.income,
"total_return": perf.total_return,
"currency_effect": ZERO,
"flows": perf.flows,
}
def in_home_currency(perf):
"""The holding converted along its currency's exchange chain, each cash flow
at its own date's rate, so currency moves count towards the return.
None when its currency converts to nothing."""
path = conversion_path(perf.currency)
if len(path) == 1:
return None
flows = [(d, _along(a, path, d), kind) for d, a, kind in perf.flows]
value = None
if perf.market_value is not None:
value = _along(perf.market_value, path, perf.as_of)
total_return = (value or ZERO) + sum((a for _, a, _ in flows), ZERO)
put_in = -sum((a for _, a, k in flows if k == "in"), ZERO)
put_in_native = -sum((a for _, a, k in perf.flows if k == "in"), ZERO)
# shortcut: what you still hold is valued at the average rate you paid in,
# not lot by lot; exact for buy-and-hold, approximate after partial sales.
cost_basis = (
perf.cost_basis * put_in / put_in_native
if put_in_native
else _along(perf.cost_basis, path, perf.as_of)
)
return {
"currency": path[-1],
"path": path,
"market_value": value,
"cost_basis": cost_basis,
"income": sum((a for _, a, k in flows if k == "income"), ZERO),
"total_return": total_return,
# what the return gained or lost from the exchange rate moving
"currency_effect": total_return
- _along(perf.total_return, path, perf.as_of),
# With a single rate every date converts the same, so the effect reads 0
"has_rate_history": all(
ExchangeRate.objects.filter(
Q(from_currency=a, to_currency=b) | Q(from_currency=b, to_currency=a)
)[:2].count()
> 1
for a, b in zip(path, path[1:])
),
"flows": flows,
"money_weighted": money_weighted_return(flows, value, perf.as_of),
}
def summarize(performances, convert=False):
"""Totals per currency. With ``convert``, holdings are first converted along
their exchange chain (see in_home_currency); the ones that can't be stay in
their own currency."""
totals = {}
for perf in performances:
figures = (convert and in_home_currency(perf)) or _native(perf)
total = totals.setdefault(
figures["currency"].id,
{
"currency": figures["currency"],
"market_value": ZERO,
"cost_basis": ZERO,
"total_return": ZERO,
"income": ZERO,
"currency_effect": ZERO,
"converted_from": [],
"has_rate_history": True,
"flows": [],
"as_of": perf.as_of,
"holdings": [],
},
)
total["holdings"].append(perf)
for key in ("cost_basis", "total_return", "income", "currency_effect"):
total[key] += figures[key]
total["has_rate_history"] &= figures.get("has_rate_history", True)
if figures["currency"] != perf.currency and perf.currency not in total["converted_from"]:
total["converted_from"].append(perf.currency)
# A holding without a price has no end value, so its flows would skew the rate
if figures["market_value"] is not None:
total["market_value"] += figures["market_value"]
total["flows"] += figures["flows"]
for total in totals.values():
total["money_weighted"] = money_weighted_return(
total["flows"], total["market_value"], total["as_of"]
)
# The same holdings in their own currencies, for a converted total
total["breakdown"] = (
sorted(
summarize(total["holdings"]),
key=lambda part: part["currency"] != total["currency"],
)
if total["converted_from"]
else []
)
return list(totals.values())
def portfolio_charts(performances):
"""Chart data per total currency (as in summarize with ``convert``): value by
asset class and by currency, return by investment, and value against invested
at each month end. Classes are InvestmentClass objects, None for holdings
without one. Unpriced holdings are left out of the value figures."""
charts = {}
for perf in performances:
figures = in_home_currency(perf) or _native(perf)
path = figures.get("path", [perf.currency])
chart = charts.setdefault(
figures["currency"].id,
{
"currency": figures["currency"],
"classes": {},
"currencies": {},
"returns": [],
"history": {},
},
)
value = figures["market_value"]
if value:
key = perf.account.investment_class
chart["classes"][key] = chart["classes"].get(key, ZERO) + value
name = perf.currency.name
chart["currencies"][name] = chart["currencies"].get(name, ZERO) + value
chart["returns"].append((perf.account.name, figures["total_return"]))
# shortcut: one rate lookup per holding, month and hop; cache rates per
# date if portfolios with long histories make this tab slow.
for point in perf.history:
totals = chart["history"].setdefault(point["date"], [ZERO, ZERO])
for i, amount in enumerate((point["value"], point["invested"])):
if amount is not None:
totals[i] += _along(amount, path, point["date"]) or ZERO
for chart in charts.values():
for key in ("classes", "currencies"):
chart[key] = sorted(chart[key].items(), key=lambda item: -item[1])
chart["returns"].sort(key=lambda item: -item[1])
chart["history"] = sorted(chart["history"].items())
return list(charts.values())
def drift_limit(target):
"""How far a class may drift from its target, in percentage points: the
common 5/25 rule, 5 points or a quarter of the target, whichever is less."""
return min(Decimal(5), target / 4)
def rebalance(classes, targeted):
"""What to buy or sell so the classes in ``targeted`` (InvestmentClass with a
target) match their targets, given ``classes`` as (class, value) from
portfolio_charts. Holdings without a class or target are left out and their
value is returned as ``left_out``. ``move`` is positive to buy."""
values = dict(classes)
total = sum((values.get(cls, ZERO) for cls in targeted), ZERO)
rows = []
for cls in targeted:
value = values.get(cls, ZERO)
share = value / total * 100 if total else ZERO
move = total * cls.target / 100 - value
rows.append(
{
"class": cls,
"value": value,
"share": share,
"target": cls.target,
"move": move,
"off": abs(share - cls.target) > drift_limit(cls.target),
}
)
left_out = sum(values.values(), ZERO) - total
return {"total": total, "rows": rows, "left_out": left_out}
Whitespace-only changes.
+415
View File
@@ -0,0 +1,415 @@
import datetime
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import Account, InvestmentClass
from apps.common.middleware.thread_local import delete_current_user, write_current_user
from apps.currencies.models import Currency, ExchangeRate
from apps.investments.services import get_holding_performance, summarize
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionEntity,
TransactionTag,
)
D = Decimal
IN, EX = Transaction.Type.INCOME, Transaction.Type.EXPENSE
def tx(account, type_, amount, date, **kwargs):
return Transaction.objects.create(
account=account, type=type_, amount=D(amount), date=date, **kwargs
)
def transfer(from_account, from_amount, to_account, to_amount, date):
out = tx(from_account, EX, from_amount, date)
inc = tx(to_account, IN, to_amount, date, linked_transaction=out)
out.linked_transaction = inc
out.save()
return out, inc
class HoldingPerformanceTests(TestCase):
def setUp(self):
self.eur = Currency.objects.create(code="EUR", name="Euro", decimal_places=2)
self.brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2)
self.vwce = Currency.objects.create(
code="VWCE.DE", name="VWCE", decimal_places=4, exchange_currency=self.eur
)
self.cash = Account.objects.create(name="Cash", currency=self.eur)
self.etf = Account.objects.create(
name="VWCE",
currency=self.vwce,
exchange_currency=self.eur,
is_investment=True,
is_asset=True,
)
self.d1 = datetime.date(2026, 1, 10)
self.d2 = datetime.date(2026, 2, 10)
self.d3 = datetime.date(2026, 3, 10)
def rate(self, value, date):
ExchangeRate.objects.create(
from_currency=self.vwce,
to_currency=self.eur,
rate=D(value),
date=timezone.make_aware(datetime.datetime.combine(date, datetime.time())),
)
def test_priced_average_cost_realized_and_income(self):
transfer(self.cash, "1000", self.etf, "10", self.d1)
transfer(self.cash, "1200", self.etf, "10", self.d2)
transfer(self.etf, "5", self.cash, "700", self.d3)
self.rate("130", timezone.localdate())
entity = TransactionEntity.objects.create(name="Vanguard")
other = TransactionEntity.objects.create(name="Vanguard JCP")
self.etf.income_entities.set([entity, other])
dividend = tx(self.cash, IN, "30", self.d3)
dividend.entities.add(entity, other) # matches twice, counted once
perf = get_holding_performance(self.etf)
self.assertTrue(perf.priced)
self.assertEqual(perf.quantity, D("15"))
self.assertEqual(perf.cost_basis, D("1650"))
self.assertEqual(perf.average_cost, D("110"))
self.assertEqual(perf.realized, D("150")) # 700 - 5 * 110
self.assertEqual(perf.market_value, D("1950"))
self.assertEqual(perf.unrealized, D("300"))
self.assertEqual(perf.income, D("30"))
self.assertEqual(perf.contributed, D("2200"))
self.assertEqual(perf.withdrawn, D("700"))
self.assertEqual(perf.total_return, D("480"))
self.assertFalse(perf.estimated)
self.assertEqual(
perf.trades,
[(self.d1, "buy", D("100")), (self.d2, "buy", D("120")), (self.d3, "sell", D("140"))],
)
def test_unlinked_inflow_on_priced_account_is_costed_at_market(self):
self.rate("90", self.d1)
tx(self.etf, IN, "2", self.d1)
perf = get_holding_performance(self.etf)
self.assertEqual(perf.cost_basis, D("180"))
self.assertTrue(perf.estimated)
self.assertEqual(perf.contributed, D("0"))
def test_value_tracked_cdb_with_tax_on_redemption(self):
cdb = Account.objects.create(
name="CDB", currency=self.brl, is_investment=True, is_asset=True
)
checking = Account.objects.create(name="Checking", currency=self.brl)
result = TransactionCategory.objects.create(name="Yield")
cdb.income_categories.set([result])
transfer(checking, "1000", cdb, "1000", self.d1)
tx(cdb, IN, "100", self.d2, category=result) # balance reconciliation
tx(cdb, EX, "22.50", self.d3, category=result) # income tax withheld
perf = get_holding_performance(cdb)
self.assertFalse(perf.priced)
self.assertEqual(perf.results, [(self.d2, D("100")), (self.d3, D("-22.50"))])
self.assertEqual(perf.market_value, D("1077.50"))
self.assertEqual(perf.cost_basis, D("1000"))
self.assertEqual(perf.unrealized, D("77.50"))
transfer(cdb, "1077.50", checking, "1077.50", self.d3)
perf = get_holding_performance(cdb)
self.assertEqual(perf.quantity, D("0"))
self.assertEqual(perf.cost_basis, D("0"))
self.assertEqual(perf.realized, D("77.50"))
self.assertEqual(perf.total_return, D("77.50"))
def test_value_tracked_salary_account_separates_money_from_yield(self):
account = Account.objects.create(
name="Salary", currency=self.brl, is_investment=True
)
tag = TransactionTag.objects.create(name="Yield")
account.income_tags.set([tag])
tx(account, IN, "5000", self.d1) # salary, from outside
tx(account, EX, "1000", self.d2) # rent, not a loss
tx(account, IN, "40", self.d3).tags.add(tag) # reconciled balance
perf = get_holding_performance(account)
self.assertEqual(perf.contributed, D("5000"))
self.assertEqual(perf.withdrawn, D("1000"))
self.assertEqual(perf.market_value, D("4040"))
self.assertEqual(perf.cost_basis, D("4000"))
self.assertEqual(perf.total_return, D("40"))
self.assertFalse(perf.estimated)
def test_own_entity_is_a_result_but_only_income_counts_elsewhere(self):
account = Account.objects.create(
name="Savings", currency=self.brl, is_investment=True
)
other = Account.objects.create(name="Other", currency=self.brl)
bank = TransactionEntity.objects.create(name="Bank")
account.income_entities.set([bank])
tx(account, IN, "1000", self.d1)
tx(account, IN, "10", self.d2).entities.add(bank)
tx(other, EX, "99", self.d2).entities.add(bank)
perf = get_holding_performance(account)
self.assertEqual(perf.total_return, D("10"))
self.assertEqual(perf.income, D("0"))
def test_history_has_one_point_per_month_end(self):
transfer(self.cash, "1000", self.etf, "10", self.d1)
self.rate("100", self.d1)
self.rate("120", self.d3)
perf = get_holding_performance(
self.etf, as_of=datetime.date(2026, 3, 15), with_history=True
)
self.assertEqual(
[p["date"] for p in perf.history],
[
datetime.date(2026, 1, 31),
datetime.date(2026, 2, 28),
datetime.date(2026, 3, 15),
],
)
self.assertEqual(perf.history[-1]["value"], D("1200"))
self.assertEqual(perf.history[0]["invested"], D("1000"))
self.assertEqual(perf.history[-1]["price"], D("120"))
self.assertEqual(perf.history[-1]["average_cost"], D("100"))
def test_summarize_groups_by_currency(self):
transfer(self.cash, "1000", self.etf, "10", self.d1)
self.rate("110", timezone.localdate())
totals = summarize([get_holding_performance(self.etf)])
self.assertEqual(len(totals), 1)
self.assertEqual(totals[0]["market_value"], D("1100"))
self.assertEqual(totals[0]["total_return"], D("100"))
def test_portfolio_charts_group_by_asset_class(self):
from apps.investments.services import portfolio_charts
stocks = InvestmentClass.objects.create(name="Stocks")
self.etf.investment_class = stocks
self.etf.save()
cdb = Account.objects.create(name="CDB", currency=self.eur, is_investment=True)
transfer(self.cash, "1000", self.etf, "10", self.d1)
transfer(self.cash, "500", cdb, "500", self.d1)
self.rate("110", self.d1)
perfs = [get_holding_performance(a, with_history=True) for a in (self.etf, cdb)]
[chart] = portfolio_charts(perfs)
self.assertEqual(chart["classes"], [(stocks, D("1100")), (None, D("500"))])
# Priced in euros, so that is its currency exposure
self.assertEqual(chart["currencies"], [("Euro", D("1600"))])
self.assertEqual(chart["returns"][0], ("VWCE", D("100")))
self.assertEqual(chart["history"][-1][1], [D("1600"), D("1500")])
def test_rebalance_moves_to_targets(self):
from apps.investments.services import rebalance
stocks = InvestmentClass.objects.create(name="Stocks", target=D("60"))
bonds = InvestmentClass.objects.create(name="Bonds", target=D("30"))
gold = InvestmentClass.objects.create(name="Gold", target=D("10"))
loose = InvestmentClass.objects.create(name="Loose") # no target
plan = rebalance(
[(stocks, D("700")), (bonds, D("300")), (loose, D("50")), (None, D("25"))],
[stocks, bonds, gold],
)
self.assertEqual(plan["total"], D("1000"))
self.assertEqual(plan["left_out"], D("75"))
moves = {row["class"].name: row["move"] for row in plan["rows"]}
self.assertEqual(moves, {"Stocks": D("-100"), "Bonds": D("0"), "Gold": D("100")})
self.assertEqual(sum(moves.values()), 0)
# 70% vs 60%: past the 5 points; gold at 0% vs 10%: past 2.5 (a quarter)
off = {row["class"].name: row["off"] for row in plan["rows"]}
self.assertEqual(off, {"Stocks": True, "Bonds": False, "Gold": True})
def test_class_names_are_unique(self):
from apps.accounts.forms import InvestmentClassForm
stocks = InvestmentClass.objects.create(name="Stocks")
self.assertFalse(InvestmentClassForm({"name": "Stocks"}).is_valid())
self.assertTrue(InvestmentClassForm({"name": "Stocks"}, instance=stocks).is_valid())
def test_targets_cannot_pass_100(self):
from apps.accounts.forms import InvestmentClassForm
InvestmentClass.objects.create(name="Stocks", target=D("70"))
bonds = InvestmentClass.objects.create(name="Bonds", target=D("30"))
self.assertFalse(InvestmentClassForm({"name": "Gold", "target": "1"}).is_valid())
# Editing a class doesn't count its own current target
self.assertTrue(
InvestmentClassForm({"name": "Bonds", "target": "30"}, instance=bonds).is_valid()
)
def test_xirr_and_money_weighted_return(self):
from apps.investments.services import money_weighted_return, xirr
start, year = datetime.date(2025, 1, 1), datetime.date(2026, 1, 1)
self.assertAlmostEqual(xirr([(start, D(-1000)), (year, D(1100))]), 0.10, places=6)
self.assertIsNone(xirr([(start, D(-1000))]))
# A year or more: annualized
mw = money_weighted_return([(start, D(-1000), "in")], D(1100), year)
self.assertTrue(mw["annualized"])
self.assertAlmostEqual(float(mw["percentage"]), 10, places=3)
# Less than a year: the return over the period, not an inflated annual rate
half = datetime.date(2025, 7, 2)
mw = money_weighted_return([(start, D(-1000), "in")], D(1050), half)
self.assertFalse(mw["annualized"])
self.assertAlmostEqual(float(mw["percentage"]), 5, places=3)
# Money added late weighs less: +100 on 1000 held all year beats the
# simple +100 / 2000 = 5% when the second 1000 only came in at the end
flows = [(start, D(-1000), "in"), (datetime.date(2025, 12, 31), D(-1000), "in")]
mw = money_weighted_return(flows, D(2100), year)
self.assertGreater(float(mw["percentage"]), 9.9)
def test_home_currency_counts_the_exchange_rate_move(self):
from apps.investments.services import in_home_currency
usd = Currency.objects.create(code="USD", name="Dollar", decimal_places=2)
usd.exchange_currency = self.brl
usd.save()
etf = Currency.objects.create(
code="VT", name="VT", decimal_places=4, exchange_currency=usd
)
cash = Account.objects.create(name="USD cash", currency=usd)
holding = Account.objects.create(
name="VT", currency=etf, exchange_currency=usd, is_investment=True
)
aware = lambda d: timezone.make_aware(datetime.datetime.combine(d, datetime.time()))
ExchangeRate.objects.create(from_currency=usd, to_currency=self.brl, rate=D("5"), date=aware(self.d1))
ExchangeRate.objects.create(from_currency=usd, to_currency=self.brl, rate=D("6"), date=aware(timezone.localdate()))
ExchangeRate.objects.create(from_currency=etf, to_currency=usd, rate=D("100"), date=aware(timezone.localdate()))
transfer(cash, "1000", holding, "10", self.d1)
perf = get_holding_performance(holding)
self.assertEqual(perf.total_return, D("0")) # flat in dollars
home = in_home_currency(perf)
self.assertEqual(home["currency"], self.brl)
self.assertEqual(home["market_value"], D("6000"))
self.assertEqual(home["cost_basis"], D("5000"))
self.assertEqual(home["total_return"], D("1000"))
self.assertEqual(home["currency_effect"], D("1000")) # all of it from the dollar
totals = summarize([perf], convert=True)
self.assertEqual([t["currency"] for t in totals], [self.brl])
self.assertEqual(totals[0]["converted_from"], [usd])
self.assertEqual(totals[0]["breakdown"][0]["currency"], usd)
self.assertEqual(totals[0]["breakdown"][0]["market_value"], D("1000"))
# A single-currency total has nothing to break down
self.assertEqual(summarize([perf])[0]["breakdown"], [])
# No rate to anything: stays in its own currency
self.assertEqual(summarize([get_holding_performance(self.etf)], convert=True)[0]["currency"], self.eur)
@override_settings(
STORAGES={
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
},
},
WHITENOISE_AUTOREFRESH=True,
DEMO=False,
)
class InvestmentViewTests(TestCase):
HTMX = {"HTTP_HX_REQUEST": "true"}
def setUp(self):
User = get_user_model()
self.owner = User.objects.create_user(email="o@test.com", password="pw")
self.stranger = User.objects.create_user(email="s@test.com", password="pw")
brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2)
self.account = Account.all_objects.create(
name="CDB", currency=brl, is_investment=True, owner=self.owner
)
def test_owner_sees_list_and_detail(self):
self.client.force_login(self.owner)
response = self.client.get(reverse("investments_list"), **self.HTMX)
self.assertContains(response, "CDB")
response = self.client.get(
reverse("investment_detail", args=[self.account.id]), **self.HTMX
)
self.assertEqual(response.status_code, 200)
def test_shared_account_keeps_its_owners_class(self):
from apps.accounts.forms import AccountForm
cls = InvestmentClass.all_objects.create(name="Stocks", owner=self.owner)
self.account.investment_class = cls
self.account.save()
self.account.shared_with.add(self.stranger)
write_current_user(self.stranger)
try:
form = AccountForm(
{"name": "CDB", "currency": self.account.currency_id,
"is_investment": "on", "investment_class": str(cls.id)},
instance=self.account,
)
self.assertTrue(form.is_valid(), form.errors)
self.assertEqual(form.cleaned_data["investment_class"], cls)
finally:
delete_current_user()
# Not a new class named after the id
self.assertEqual(InvestmentClass.all_objects.count(), 1)
def test_rebalance_and_class_pages_render(self):
self.client.force_login(self.owner)
stocks = InvestmentClass.all_objects.create(name="Stocks", target=D("60"), owner=self.owner)
self.account.investment_class = stocks
self.account.save()
# Targets short of 100%: a warning instead of amounts
response = self.client.get(reverse("investments_rebalance"), **self.HTMX)
self.assertContains(response, "alert-warning")
response = self.client.post(
reverse("investment_class_add"), {"name": "Bonds", "target": "40"}, **self.HTMX
)
self.assertEqual(response.status_code, 204)
response = self.client.get(reverse("investments_rebalance"), **self.HTMX)
self.assertContains(response, "Bonds")
self.assertNotContains(response, "alert-warning")
response = self.client.get(reverse("investment_classes_list"), **self.HTMX)
self.assertContains(response, "Stocks")
response = self.client.get(
reverse("investment_class_edit", args=[stocks.id]), **self.HTMX
)
self.assertContains(response, "Stocks")
# Someone else's class is out of reach
self.client.force_login(self.stranger)
response = self.client.get(
reverse("investment_class_edit", args=[stocks.id]), **self.HTMX
)
self.assertEqual(response.status_code, 404)
def test_charts_render(self):
self.client.force_login(self.owner)
response = self.client.get(reverse("investments_charts"), **self.HTMX)
self.assertContains(response, "investment-charts-0")
def test_stranger_cannot_see_detail(self):
self.client.force_login(self.stranger)
response = self.client.get(
reverse("investment_detail_index", args=[self.account.id])
)
self.assertEqual(response.status_code, 404)
+49
View File
@@ -0,0 +1,49 @@
from django.urls import path
from . import views
urlpatterns = [
path("investments/", views.investments_index, name="investments_index"),
path("investments/list/", views.investments_list, name="investments_list"),
path("investments/charts/", views.investments_charts, name="investments_charts"),
path(
"investments/rebalance/",
views.investments_rebalance,
name="investments_rebalance",
),
path(
"investments/classes/",
views.investment_classes_index,
name="investment_classes_index",
),
path(
"investments/classes/list/",
views.investment_classes_list,
name="investment_classes_list",
),
path(
"investments/classes/add/",
views.investment_class_add,
name="investment_class_add",
),
path(
"investments/classes/<int:pk>/edit/",
views.investment_class_edit,
name="investment_class_edit",
),
path(
"investments/classes/<int:pk>/delete/",
views.investment_class_delete,
name="investment_class_delete",
),
path(
"investments/<int:account_id>/",
views.investment_detail_index,
name="investment_detail_index",
),
path(
"investments/<int:account_id>/details/",
views.investment_detail,
name="investment_detail",
),
]
+297
View File
@@ -0,0 +1,297 @@
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render
from django.utils.formats import date_format
from django.utils.translation import gettext as _
from django.views.decorators.http import require_http_methods
from apps.accounts.forms import InvestmentClassForm
from apps.accounts.models import Account, InvestmentClass
from apps.common.decorators.htmx import only_htmx
from apps.common.functions.format import get_format
from apps.common.functions.permissions import READ, get_shared_object_or_error
from apps.investments.services import (
ZERO,
get_holding_performance,
in_home_currency,
income_entity_ids,
portfolio_charts,
rebalance,
summarize,
)
from apps.transactions.models import Transaction
def _holdings():
return Account.objects.filter(is_investment=True, is_archived=False).select_related(
"group", "currency", "exchange_currency"
)
@login_required
def investments_index(request):
return render(request, "investments/pages/index.html")
@only_htmx
@login_required
def investments_list(request):
performances = [get_holding_performance(account) for account in _holdings()]
groups = {}
for perf in performances:
group = perf.account.group
groups.setdefault(group.id if group else None, (group, []))[1].append(perf)
return render(
request,
"investments/fragments/list.html",
{
"groups": [
{"group": group, "holdings": items, "totals": summarize(items)}
for group, items in sorted(
groups.values(), key=lambda g: (g[0] is None, g[0] and g[0].name)
)
],
"totals": summarize(performances, convert=True),
},
)
def _money(currency):
"""What chartMoney (charts.js) needs to format amounts like the rest of the app."""
return {
"prefix": currency.prefix,
"suffix": currency.suffix,
"decimals": currency.decimal_places,
"decimal": get_format("DECIMAL_SEPARATOR"),
"thousand": get_format("THOUSAND_SEPARATOR"),
}
def _number(amount):
return None if amount is None else float(amount)
def _per_month(history, entries):
"""Sums (date, amount) entries into the months of ``history``, zero when empty."""
sums = {(p["date"].year, p["date"].month): ZERO for p in history}
for date, amount in entries:
if (date.year, date.month) in sums:
sums[(date.year, date.month)] += amount
return [float(amount) for amount in sums.values()]
def _trades_per_month(history, trades, kind):
"""The last trade price of each month, None in months without one."""
prices = {(p["date"].year, p["date"].month): None for p in history}
for date, trade_kind, price in trades:
if trade_kind == kind and (date.year, date.month) in prices:
prices[(date.year, date.month)] = float(price)
return list(prices.values())
# Categorical slots in fixed order; past the last one, the rest fold into "Other"
PALETTE = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6"]
OTHER_COLOR = "#898781"
def _shares(items):
colors = PALETTE[: len(items)]
if len(items) > len(PALETTE):
rest = sum(amount for _name, amount in items[len(PALETTE) - 1 :])
items = items[: len(PALETTE) - 1] + [(_("Other"), rest)]
colors = PALETTE[:-1] + [OTHER_COLOR]
total = sum(amount for _name, amount in items)
return [
{
"key": name,
"name": str(name),
"amount": amount,
"share": amount / total * 100 if total else 0,
"color": color,
}
for (name, amount), color in zip(items, colors)
]
@only_htmx
@login_required
def investments_charts(request):
performances = [
get_holding_performance(account, with_history=True) for account in _holdings()
]
charts = portfolio_charts(performances)
targeted = _targeted()
# Shares only mean something against targets that add up to 100%
if sum(cls.target for cls in targeted) != 100:
targeted = []
for i, chart in enumerate(charts):
chart["data_id"] = f"investment-charts-{i}"
chart["returns_height"] = max(128, 32 * len(chart["returns"]))
plan = {row["class"]: row for row in rebalance(chart["classes"], targeted)["rows"]}
chart["classes"] = _shares(
[(cls or _("Unclassified"), amount) for cls, amount in chart["classes"]]
)
for share in chart["classes"]:
share["plan"] = plan.get(share["key"])
chart["currencies"] = _shares(chart["currencies"])
chart["data"] = {
"currency": _money(chart["currency"]),
"classes": [[s["name"], float(s["amount"]), s["color"]] for s in chart["classes"]],
"currencies": [[s["name"], float(s["amount"]), s["color"]] for s in chart["currencies"]],
"returns": [[name, float(amount)] for name, amount in chart["returns"]],
"history": {
"labels": [date_format(d, "M Y") for d, _t in chart["history"]],
"value": [float(t[0]) for _d, t in chart["history"]],
"invested": [float(t[1]) for _d, t in chart["history"]],
},
}
return render(request, "investments/fragments/charts.html", {"charts": charts})
def _targeted():
return list(InvestmentClass.objects.filter(target__isnull=False))
@only_htmx
@login_required
def investments_rebalance(request):
targeted = _targeted()
total_target = sum(cls.target for cls in targeted)
plans = []
for chart in portfolio_charts([get_holding_performance(a) for a in _holdings()]):
plan = rebalance(chart["classes"], targeted)
plan["currency"] = chart["currency"]
for row in plan["rows"]:
# Fractions of a cent shouldn't show as "Buy 0.00"
row["move"] = round(row["move"], chart["currency"].decimal_places)
row["size"] = abs(row["move"])
plans.append(plan)
return render(
request,
"investments/fragments/rebalance.html",
{"plans": plans, "total_target": total_target, "targeted": targeted},
)
@login_required
def investment_detail_index(request, account_id):
account = get_shared_object_or_error(
Account, request, id=account_id, level=READ, is_investment=True
)
return render(request, "investments/pages/detail.html", {"account": account})
@only_htmx
@login_required
def investment_detail(request, account_id):
account = get_shared_object_or_error(
Account, request, id=account_id, level=READ, is_investment=True
)
perf = get_holding_performance(account, with_history=True)
transactions = (
Transaction.objects.filter(account=account)
.select_related("linked_transaction__account__currency", "category")
.order_by("-date", "-id")[:50]
)
income = []
entity_ids = income_entity_ids(account)
if entity_ids:
income = (
Transaction.objects.filter(
entities__in=entity_ids, type=Transaction.Type.INCOME
)
.exclude(account=account)
.distinct()
.select_related("account__currency")
.order_by("-date", "-id")[:20]
)
history = perf.history
income_flows = [(d, amount) for d, amount, kind in perf.flows if kind == "income"]
chart = {
"currency": _money(perf.currency),
"labels": [date_format(p["date"], "M Y") for p in history],
"value": [_number(p["value"]) for p in history],
"invested": [float(p["invested"]) for p in history],
"price": [_number(p["price"]) for p in history] if perf.priced else None,
"average_cost": [_number(p["average_cost"]) for p in history],
"buys": _trades_per_month(history, perf.trades, "buy"),
"sells": _trades_per_month(history, perf.trades, "sell"),
"earnings": _per_month(history, perf.results) if perf.results else None,
"income": _per_month(history, income_flows) if income_flows else None,
}
return render(
request,
"investments/fragments/detail.html",
{
"account": account,
"perf": perf,
"home": in_home_currency(perf),
"transactions": transactions,
"income": income,
"income_entities": list(account.income_entities.all()),
"chart": chart,
},
)
@login_required
@require_http_methods(["GET"])
def investment_classes_index(request):
return render(request, "investment_classes/pages/index.html")
@only_htmx
@login_required
@require_http_methods(["GET"])
def investment_classes_list(request):
classes = InvestmentClass.objects.all()
return render(
request,
"investment_classes/fragments/list.html",
{
"classes": classes,
"total_target": sum(cls.target or 0 for cls in classes),
},
)
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def investment_class_add(request):
return _investment_class_form(request, InvestmentClass(), _("Asset class added successfully"))
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
def investment_class_edit(request, pk):
cls = get_object_or_404(InvestmentClass, pk=pk)
return _investment_class_form(request, cls, _("Asset class updated successfully"))
def _investment_class_form(request, instance, success):
form = InvestmentClassForm(request.POST or None, instance=instance)
if request.method == "POST" and form.is_valid():
form.save()
messages.success(request, success)
return HttpResponse(status=204, headers={"HX-Trigger": "updated, hide_offcanvas"})
return render(
request,
"investment_classes/fragments/form.html",
{"form": form, "object": instance},
)
@only_htmx
@login_required
@require_http_methods(["DELETE"])
def investment_class_delete(request, pk):
get_object_or_404(InvestmentClass, pk=pk).delete()
messages.success(request, _("Asset class deleted successfully"))
return HttpResponse(status=204, headers={"HX-Trigger": "updated, hide_offcanvas"})
+24
View File
@@ -509,6 +509,30 @@ class Transaction(OwnedObject):
self.save()
return to_transaction
def transfer_link_error(self, other):
"""Why ``self`` and ``other`` can't be joined into a transfer, or None."""
if self.linked_transaction_id == other.pk and other.linked_transaction_id == self.pk:
return None
if {self.type, other.type} != {self.Type.INCOME, self.Type.EXPENSE}:
return _("A transfer needs one expense and one income.")
if self.account_id == other.account_id:
return _("A transfer needs two different accounts.")
if self.linked_transaction_id not in (None, other.pk) or (
other.linked_transaction_id not in (None, self.pk)
):
return _("This transaction is already part of another transfer.")
return None
def link_transfer(self, other):
"""Joins an existing expense and income into a transfer."""
error = self.transfer_link_error(other)
if error:
raise ValidationError(error)
# update(): only the link changes; dates, amounts and rules are left alone
Transaction.all_objects.filter(pk=self.pk).update(linked_transaction=other)
Transaction.all_objects.filter(pk=other.pk).update(linked_transaction=self)
self.linked_transaction, other.linked_transaction = other, self
@property
def transfer_from(self):
if self.linked_transaction_id and self.type == self.Type.INCOME:
@@ -0,0 +1,74 @@
from datetime import timedelta
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import Account
from apps.currencies.models import Currency
from apps.transactions.models import (
Transaction,
TransactionCategory,
TransactionEntity,
TransactionTag,
)
class TransactionSearchTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
email="search@test.com", password="pass"
)
self.client.login(username="search@test.com", password="pass")
self.user.settings.date_format = "d/m/Y"
self.user.settings.number_format = "DC"
self.user.settings.save()
currency = Currency.objects.create(code="BRL", name="Real", decimal_places=2)
self.account = Account.objects.create(name="Wallet", currency=currency)
self.today = timezone.localdate(timezone.now())
def make(self, days, description="", amount="10", **kwargs):
return Transaction.objects.create(
account=self.account,
type=Transaction.Type.EXPENSE,
date=self.today + timedelta(days=days),
reference_date=(self.today + timedelta(days=days)).replace(day=1),
amount=Decimal(amount),
description=description,
**kwargs,
)
def search(self, q=""):
response = self.client.get(reverse("transactions_search"), {"q": q})
self.assertEqual(response.status_code, 200)
return [row["description"] for row in response.json()]
def test_order_matches_monthly_view(self):
for days, name in [(-30, "old"), (-2, "recent"), (-1, "yesterday"),
(10, "far"), (2, "soon"), (1, "tomorrow"), (0, "today")]:
self.make(days, name)
self.assertEqual(
self.search(),
["today", "tomorrow", "yesterday", "soon", "far", "recent", "old"],
)
def test_search_fields(self):
t = self.make(-5, "Groceries", amount="1234.56")
t.category = TransactionCategory.objects.create(name="Food")
t.save()
t.tags.add(TransactionTag.objects.create(name="home"),
TransactionTag.objects.create(name="weekly"))
t.entities.add(TransactionEntity.objects.create(name="Market"))
self.make(-6, "Other", amount="5")
day = (self.today - timedelta(days=5))
for q in ["food", "market", "1.234,56", "1234",
day.strftime("%d/%m/%Y"), day.strftime("%d/%m"),
"home weekly", "groceries food"]:
self.assertEqual(self.search(q), ["Groceries"], q)
self.assertEqual(self.search("wallet"), ["Groceries", "Other"])
self.assertEqual(self.search("groceries nomatch"), [])
@@ -0,0 +1,95 @@
import datetime
import re
from decimal import Decimal, InvalidOperation
from apps.common.functions.format import get_format
from apps.common.utils.django import django_to_python_datetime
from apps.transactions.templatetags.currency_display import currency_display
from django.db.models import Q
from django.template.defaultfilters import date as date_filter
def transaction_option(t):
"""Data used by the transaction picker (tom-select) to render a mini transaction."""
currency = t.account.currency
return {
"value": str(t.id),
"text": str(t),
"description": t.description,
"date": date_filter(t.date, "SHORT_DATE_FORMAT"),
"type": t.type,
"is_paid": t.is_paid,
"amount": currency_display(
t.amount,
currency.prefix,
currency.suffix,
currency.decimal_places,
string=True,
),
"account": (
f"{t.account.group.name} • {t.account.name}"
if t.account.group
else t.account.name
),
"category": t.category.name if t.category else "",
"tags": [tag.name for tag in t.tags.all()],
"entities": [entity.name for entity in t.entities.all()],
}
def _parse_date(term):
"""Match a term against the user's date format; without a year it matches any year."""
fmt = django_to_python_datetime(get_format("SHORT_DATE_FORMAT"))
try:
return Q(date=datetime.datetime.strptime(term, fmt).date())
except ValueError:
pass
# Drop the year and its adjacent separator, e.g. "%d/%m/%Y" -> "%d/%m"
no_year = re.sub(r"[^%\w]?%[Yy][^%\w]?", "", fmt, count=1).strip()
if no_year == fmt or not no_year:
return None
try:
# 2000 is a leap year, so 29/02 parses
parsed = datetime.datetime.strptime(f"{term} 2000", f"{no_year} %Y")
except ValueError:
return None
return Q(date__day=parsed.day, date__month=parsed.month)
def _parse_amount(term):
if not re.fullmatch(r"[\d.,\s]*\d[\d.,\s]*", term):
return None
thousand = get_format("THOUSAND_SEPARATOR")
decimal = get_format("DECIMAL_SEPARATOR")
normalized = term.replace(thousand, "").replace(decimal, ".")
try:
value = Decimal(normalized)
except InvalidOperation:
return None
if decimal not in term:
# Whole number: "12" finds 12.00 to 12.99
return Q(amount__gte=value, amount__lt=value + 1)
return Q(amount=value)
def search_transactions(queryset, search_term):
"""Every word must match some field (description, category, entity, date, amount...)."""
matches = queryset.model.objects.all()
for term in search_term.split():
term_q = (
Q(description__icontains=term)
| Q(notes__icontains=term)
| Q(internal_note__icontains=term)
| Q(tags__name__icontains=term)
| Q(entities__name__icontains=term)
| Q(category__name__icontains=term)
| Q(account__name__icontains=term)
| Q(account__group__name__icontains=term)
)
for extra in (_parse_date(term), _parse_amount(term)):
if extra is not None:
term_q |= extra
# One filter() per word so different words can match different tags/entities
matches = matches.filter(term_q)
# id__in avoids duplicate rows from the tag/entity joins
return queryset.filter(id__in=matches.values("id"))
+40 -33
View File
@@ -20,11 +20,15 @@ from apps.transactions.utils.calculations import (
calculate_percentage_distribution,
)
from apps.transactions.utils.default_ordering import default_order
from apps.transactions.utils.select_search import (
search_transactions,
transaction_option,
)
from dateutil.relativedelta import relativedelta
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db.models import Case, IntegerField, Q, Value, When
from django.db.models import Case, F, IntegerField, Q, Value, When
from django.http import FileResponse, Http404, HttpResponse, JsonResponse, QueryDict
from django.shortcuts import get_object_or_404, render
from django.utils import timezone
@@ -119,6 +123,15 @@ def transaction_attachment_delete(request, attachment_id):
return response
def _prefill(request, *fields, entities=False):
"""Form initial values passed as query parameters; only the named ones, and
only when present, so they never override other defaults."""
initial = {name: request.GET[name] for name in fields if request.GET.get(name)}
if entities and request.GET.getlist("entities"):
initial["entities"] = request.GET.getlist("entities") # entity names
return initial
@only_htmx
@login_required
@require_http_methods(["GET", "POST"])
@@ -204,6 +217,8 @@ def transaction_add(request):
initial={
"date": expected_date,
"type": transaction_type,
# Optional prefill, e.g. a dividend from the investments page
**_prefill(request, "account", entities=True),
},
)
@@ -541,6 +556,8 @@ def transactions_transfer(request):
initial={
"reference_date": expected_date,
"date": expected_date,
# Optional prefill, e.g. buy/sell from the investments page
**_prefill(request, "from_account", "to_account"),
},
)
@@ -971,51 +988,41 @@ def transactions_trash_can_list(request):
@login_required
@require_http_methods(["GET"])
def get_recent_transactions(request, filter_type=None):
"""Return the 100 most recent non-deleted transactions with optional search."""
# Get search term from query params
"""Return up to 100 non-deleted transactions, ordered like the monthly view, with optional search."""
search_term = request.GET.get("q", "").strip()
today = timezone.localdate(timezone.now())
yesterday = today - timezone.timedelta(days=1)
tomorrow = today + timezone.timedelta(days=1)
# Base queryset with selected fields
queryset = Transaction.objects.filter(deleted=False)
if filter_type == "expenses":
queryset = queryset.filter(type=Transaction.Type.EXPENSE)
elif filter_type == "income":
queryset = queryset.filter(type=Transaction.Type.INCOME)
if search_term:
queryset = search_transactions(queryset, search_term)
# Today, tomorrow, yesterday, then upcoming (soonest first), then past (newest first)
queryset = (
Transaction.objects.filter(deleted=False)
.annotate(
queryset.annotate(
date_order=Case(
When(date=today, then=Value(0)),
When(date=tomorrow, then=Value(1)),
When(date=yesterday, then=Value(2)),
When(date__gt=tomorrow, then=Value(3)),
When(date__lt=yesterday, then=Value(4)),
default=Value(5),
default=Value(4),
output_field=IntegerField(),
)
),
upcoming_date=Case(When(date__gt=tomorrow, then=F("date"))),
)
.select_related("account", "category")
.order_by("date_order", "date", "id")
.select_related("account__currency", "account__group", "category")
.prefetch_related("tags", "entities")
.order_by(
"date_order", F("upcoming_date").asc(nulls_last=True), "-date", "-id"
)[:100]
)
if filter_type:
if filter_type == "expenses":
queryset = queryset.filter(type=Transaction.Type.EXPENSE)
elif filter_type == "income":
queryset = queryset.filter(type=Transaction.Type.INCOME)
# Apply search if provided
if search_term:
queryset = queryset.filter(
Q(description__icontains=search_term)
| Q(notes__icontains=search_term)
| Q(internal_note__icontains=search_term)
| Q(tags__name__icontains=search_term)
| Q(category__name__icontains=search_term)
)
# Prepare data for JSON response
data = []
for t in queryset:
data.append({"text": str(t), "value": str(t.id)})
return JsonResponse(data, safe=False)
return JsonResponse([transaction_option(t) for t in queryset], safe=False)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+12 -15
View File
@@ -2,25 +2,22 @@
{% load toast_bg %}
{% if messages %}
{% for message in messages %}
<div class="toasty alert alert-{{ message.tags | toast_bg }}"
<div class="toasty alert alert-{{ message.tags | toast_bg }} grid-cols-[auto_1fr_auto] items-start gap-3 pe-2"
role="alert"
aria-live="assertive"
aria-atomic="true">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<i class="{{ message.tags | toast_icon }} fa-fw"></i>
<div>
<strong>{{ message.tags | toast_title }}</strong>
<div>{{ message }}</div>
</div>
</div>
<button type="button"
class="btn btn-ghost btn-sm btn-circle"
_="on click remove closest .toasty"
aria-label={% translate 'Close' %}>
<i class="fa-solid fa-xmark"></i>
</button>
<i class="{{ message.tags | toast_icon }} fa-fw mt-1"></i>
<div class="min-w-0 break-words">
<strong>{{ message.tags | toast_title }}</strong>
<div>{{ message }}</div>
</div>
{# -mt-1 centers the 2rem button on the 1.5rem title line #}
<button type="button"
class="btn btn-ghost btn-sm btn-square -mt-1"
_="on click remove closest .toasty"
aria-label="{% translate 'Close' %}">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
{% endfor %}
{% endif %}
@@ -11,7 +11,7 @@
{% crispy_field field 'class' 'toggle toggle-sm' %}
{% endif %}
{{ field.label }}{% if field.field.required %}<span class="asteriskField">*</span>{% endif %}
{% include 'crispy-daisyui/layout/help_text_and_errors.html' %}
</label>
{% include 'crispy-daisyui/layout/help_text_and_errors.html' %}
</{% if tag %}{{ tag }}{% else %}fieldset{% endif %}>
{% endif %}
+15 -1
View File
@@ -114,6 +114,12 @@
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Tools' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Investments' %}"
url='investments_index'
active="investments_index||investment_detail_index"
icon="fa-solid fa-chart-pie">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-item
title="{% translate 'Dollar Cost Average Tracker' %}"
url='dca_strategy_index'
@@ -144,7 +150,7 @@
<c-components.sidebar-collapsible-panel
title="{% translate 'Management' %}"
icon="fa-solid fa-toolbox"
active="tags_index||entities_index||categories_index||accounts_index||account_groups_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||bank_sync_index||export_index||users_index">
active="tags_index||entities_index||categories_index||accounts_index||account_groups_index||investment_classes_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||bank_sync_index||export_index||users_index">
<c-components.sidebar-menu-header title="{% translate 'Transactions' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Categories' %}"
@@ -179,6 +185,14 @@
icon="fa-solid fa-wallet">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Investments' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Asset classes' %}"
url='investment_classes_index'
active="investment_classes_index"
icon="fa-solid fa-chart-pie">
</c-components.sidebar-menu-item>
<c-components.sidebar-menu-header title="{% translate 'Currencies' %}"></c-components.sidebar-menu-header>
<c-components.sidebar-menu-item
title="{% translate 'Currencies' %}"
@@ -0,0 +1,11 @@
{% extends 'extends/offcanvas.html' %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title %}{% if object.pk %}{% translate 'Edit asset class' %}{% else %}{% translate 'Add asset class' %}{% endif %}{% endblock %}
{% block body %}
<form hx-post="{% if object.pk %}{% url 'investment_class_edit' pk=object.pk %}{% else %}{% url 'investment_class_add' %}{% endif %}" hx-target="#generic-offcanvas" novalidate>
{% crispy form %}
</form>
{% endblock %}
@@ -0,0 +1,78 @@
{% load i18n %}
<c-ui.fab-single-action
url="{% url 'investment_class_add' %}"
hx_target="#generic-offcanvas">
</c-ui.fab-single-action>
<div class="container">
<div class="text-3xl font-bold font-mono w-full mb-3">
{% spaceless %}
<div>{% translate 'Asset classes' %}</div>
{% endspaceless %}
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
{% if classes %}
{% if total_target and total_target != 100 %}
<div role="alert" class="alert alert-warning alert-soft">
<i class="fa-solid fa-triangle-exclamation"></i>
<span>{% blocktranslate with total=total_target|floatformat:"-2" %}Targets add up to {{ total }}%. Set them to 100% to see the rebalance.{% endblocktranslate %}</span>
</div>
{% endif %}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th scope="col" class="table-col-auto"></th>
<th scope="col">{% translate 'Name' %}</th>
<th scope="col" class="text-end">{% translate 'Target' %}</th>
<th scope="col" class="text-end">{% translate 'Investments' %}</th>
</tr>
</thead>
<tbody>
{% for cls in classes %}
<tr>
<td class="table-col-auto">
<div class="join" role="group" aria-label="{% translate 'Actions' %}">
<a class="btn btn-secondary btn-sm join-item"
role="button"
data-tippy-content="{% translate "Edit" %}"
hx-get="{% url 'investment_class_edit' pk=cls.id %}"
hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil fa-fw"></i></a>
<a class="btn btn-error btn-sm join-item"
role="button"
data-tippy-content="{% translate "Delete" %}"
hx-delete="{% url 'investment_class_delete' pk=cls.id %}"
hx-trigger='confirmed'
data-bypass-on-ctrl="true"
data-title="{% translate "Are you sure?" %}"
data-text="{% translate "Its investments will be left without an asset class." %}"
data-confirm-text="{% translate "Yes, delete it!" %}"
_="install prompt_swal"><i class="fa-solid fa-trash fa-fw"></i></a>
</div>
</td>
<td>{{ cls.name }}</td>
<td class="text-end tabular-nums">{% if cls.target is not None %}{{ cls.target|floatformat:"-2" }}%{% else %}<span class="text-base-content/50">-</span>{% endif %}</td>
<td class="text-end tabular-nums">{{ cls.accounts.count }}</td>
</tr>
{% endfor %}
</tbody>
{% if total_target %}
<tfoot>
<tr>
<td></td>
<td>{% translate 'Total' %}</td>
<td class="text-end tabular-nums">{{ total_target|floatformat:"-2" }}%</td>
<td></td>
</tr>
</tfoot>
{% endif %}
</table>
</div>
{% else %}
<c-msg.empty title="{% translate "No asset classes" %}" remove-padding></c-msg.empty>
{% endif %}
</div>
</div>
</div>
@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Asset classes' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'investment_classes_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
{% endblock %}
@@ -0,0 +1,18 @@
{% load i18n %}
<div class="flex flex-col sm:flex-row items-center gap-5">
<div class="size-40 shrink-0"><canvas data-chart="{{ kind }}" aria-label="{% translate 'Allocation chart' %}" role="img"></canvas></div>
<ul class="w-full flex flex-col gap-2 text-sm">
{% for share in shares %}
<li class="flex items-center gap-2">
<span class="size-2.5 rounded-full shrink-0" style="background: {{ share.color }}"></span>
<span class="truncate">{{ share.name }}</span>
{% if share.plan.off %}
<i class="fa-solid fa-triangle-exclamation text-warning text-xs"
data-tippy-content="{% blocktranslate with share=share.plan.share|floatformat:1 target=share.plan.target|floatformat:'-2' %}{{ share }}% of your investments with a target, against a target of {{ target }}%{% endblocktranslate %}"></i>
{% endif %}
<span class="ms-auto tabular-nums text-base-content/60">{{ share.share|floatformat:1 }}%</span>
<span class="w-28 text-end tabular-nums whitespace-nowrap"><c-amount.display :amount="share.amount" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></span>
</li>
{% endfor %}
</ul>
</div>
@@ -0,0 +1 @@
{% load i18n %}{% if mw %}<span class="whitespace-nowrap" data-tippy-content="{% if mw.annualized %}{% translate 'Annual return, weighted by when money went in and out' %}{% else %}{% translate 'Return since the first contribution, weighted by when money went in and out' %}{% endif %}">{% include 'investments/fragments/_percentage.html' with value=mw.percentage %}{% if mw.annualized %}<span class="text-base-content/50">{% translate '/yr' %}</span>{% endif %}</span>{% endif %}
@@ -0,0 +1 @@
{% if value is not None %}<span class="tabular-nums font-medium whitespace-nowrap {% if value > 0 %}text-income{% elif value < 0 %}text-expense{% else %}text-base-content/60{% endif %} {{ css }}">{% if value > 0 %}+{% endif %}{{ value|floatformat:"2g" }}%</span>{% endif %}
@@ -0,0 +1,51 @@
{% load i18n %}
{% with c=total.currency %}
<div class="card bg-base-100 card-border shadow">
<div class="card-body p-5 gap-4">
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
<div>
<div class="text-3xl font-bold tabular-nums">
<c-amount.display :amount="total.market_value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
</div>
</div>
<div class="sm:text-right">
<div class="text-lg tabular-nums flex items-baseline gap-2 sm:justify-end">
<c-amount.display :amount="total.total_return" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display>
<span class="text-sm">{% include 'investments/fragments/_mwr.html' with mw=total.money_weighted %}</span>
<span class="text-sm text-base-content/50">{% translate 'total return' %}</span>
</div>
</div>
</div>
<div class="flex flex-wrap gap-x-6 gap-y-1 text-sm text-base-content/60">
<div>{% translate 'Invested' %} <span class="text-base-content tabular-nums"><c-amount.display :amount="total.cost_basis" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></span></div>
<div>{% translate 'Income' %} <span class="text-base-content tabular-nums"><c-amount.display :amount="total.income" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></span></div>
{% if total.converted_from %}
{% if total.has_rate_history %}
<div data-tippy-content="{% translate 'How much of the return came from exchange rates moving since you invested' %}">{% translate 'Currency effect' %} <span class="tabular-nums"><c-amount.display :amount="total.currency_effect" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display></span></div>
{% else %}
<div>{% translate 'Currency effect' %} <i class="fa-solid fa-triangle-exclamation text-warning text-xs" data-tippy-content="{% translate 'Every date is converted with the same rate. Add historical exchange rates (or an automatic rate service) to see how much exchange rates moved your return.' %}"></i></div>
{% endif %}
{% endif %}
</div>
{% if total.breakdown %}
<div class="flex flex-col gap-1 -mx-2 -mb-2">
{% for part in total.breakdown %}
{% with p=part.currency %}
<details class="collapse collapse-arrow rounded-box">
<summary class="collapse-title min-h-0 py-2 px-2 pe-10 text-sm flex items-baseline justify-between gap-4">
<span class="text-base-content/60">{{ p.name }}</span>
<span class="tabular-nums"><c-amount.display :amount="part.market_value" :prefix="p.prefix" :suffix="p.suffix" :decimal_places="p.decimal_places" divless></c-amount.display></span>
</summary>
<div class="collapse-content px-2 text-sm text-base-content/60 flex flex-wrap gap-x-6 gap-y-1">
<div>{% translate 'Return' %} <span class="tabular-nums"><c-amount.display :amount="part.total_return" :prefix="p.prefix" :suffix="p.suffix" :decimal_places="p.decimal_places" color="auto" divless></c-amount.display></span> {% include 'investments/fragments/_mwr.html' with mw=part.money_weighted %}</div>
<div>{% translate 'Invested' %} <span class="text-base-content tabular-nums"><c-amount.display :amount="part.cost_basis" :prefix="p.prefix" :suffix="p.suffix" :decimal_places="p.decimal_places" divless></c-amount.display></span></div>
<div>{% translate 'Income' %} <span class="text-base-content tabular-nums"><c-amount.display :amount="part.income" :prefix="p.prefix" :suffix="p.suffix" :decimal_places="p.decimal_places" divless></c-amount.display></span></div>
</div>
</details>
{% endwith %}
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endwith %}
@@ -0,0 +1,149 @@
{% load i18n %}
{% for chart in charts %}
{% with c=chart.currency %}
{% if charts|length > 1 %}<div class="font-semibold mb-2 {% if not forloop.first %}mt-6{% endif %}">{{ c.name }}</div>{% endif %}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3" data-investment-charts="{{ chart.data_id }}">
{{ chart.data|json_script:chart.data_id }}
<div class="card bg-base-100 card-border shadow">
<div class="card-body p-5">
<div class="font-semibold">{% translate 'By asset class' %}</div>
{% include 'investments/fragments/_allocation.html' with shares=chart.classes kind='classes' %}
</div>
</div>
<div class="card bg-base-100 card-border shadow">
<div class="card-body p-5">
<div class="font-semibold">{% translate 'By currency' %}</div>
{% include 'investments/fragments/_allocation.html' with shares=chart.currencies kind='currencies' %}
</div>
</div>
<div class="card bg-base-100 card-border shadow lg:col-span-2">
<div class="card-body p-5">
<div class="font-semibold">{% translate 'Value and invested' %}</div>
<div class="h-64"><canvas data-chart="history"></canvas></div>
</div>
</div>
<div class="card bg-base-100 card-border shadow lg:col-span-2">
<div class="card-body p-5">
<div class="font-semibold">{% translate 'Return by investment' %}</div>
<div style="height: {{ chart.returns_height }}px"><canvas data-chart="returns"></canvas></div>
</div>
</div>
</div>
{% endwith %}
{% empty %}
<c-msg.empty title="{% translate 'Nothing to chart yet' %}"></c-msg.empty>
{% endfor %}
<script>
document.querySelectorAll('[data-investment-charts]').forEach((root) => {
const data = JSON.parse(document.getElementById(root.dataset.investmentCharts).textContent);
const ink = chartInk(75);
const muted = chartInk(50);
const grid = chartInk(12);
const fmt = chartMoney(data.currency);
const tick = (v) => fmt(v, 0);
// A few cents below zero shouldn't stretch the axis down a whole step
const floor = (values) => (Math.min(...values) > -Math.max(...values) * 0.01 ? 0 : undefined);
const canvas = (name) => root.querySelector(`[data-chart="${name}"]`);
for (const kind of ['classes', 'currencies']) {
const el = canvas(kind);
if (!el) continue;
const total = data[kind].reduce((sum, row) => sum + row[1], 0);
new Chart(el, {
type: 'doughnut',
data: {
labels: data[kind].map((row) => row[0]),
datasets: [{
data: data[kind].map((row) => row[1]),
backgroundColor: data[kind].map((row) => row[2]),
borderColor: getComputedStyle(root).getPropertyValue('--color-base-100') || '#fff',
borderWidth: 2,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: (ctx) => ` ${fmt(ctx.raw)} · ${(ctx.raw / total * 100).toFixed(1)}%` } },
},
},
});
}
new Chart(canvas('history'), {
type: 'line',
data: {
labels: data.history.labels,
datasets: [
{
label: '{% translate "Value" %}',
data: data.history.value,
borderColor: '#2a78d6',
backgroundColor: 'rgba(42, 120, 214, 0.12)',
fill: true,
borderWidth: 2,
pointRadius: 0,
pointHoverRadius: 4,
},
{
label: '{% translate "Invested" %}',
data: data.history.invested,
borderColor: muted,
stepped: true,
borderWidth: 1.5,
borderDash: [4, 4],
pointRadius: 0,
pointHoverRadius: 3,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: { grid: { display: false }, ticks: { color: ink, maxTicksLimit: 6, maxRotation: 0 } },
y: { beginAtZero: true, min: floor(data.history.value), border: { display: false }, grid: { color: grid }, ticks: { color: ink, maxTicksLimit: 5, callback: tick } },
},
plugins: {
legend: { align: 'end', labels: { color: ink, boxWidth: 12, boxHeight: 2 } },
tooltip: { callbacks: { label: (ctx) => ` ${ctx.dataset.label}: ${fmt(ctx.raw)}` } },
},
},
});
new Chart(canvas('returns'), {
type: 'bar',
data: {
labels: data.returns.map((row) => row[0]),
datasets: [{
data: data.returns.map((row) => row[1]),
backgroundColor: data.returns.map((row) => (row[1] < 0 ? '#d03b3b' : '#0ca30c')),
borderRadius: 4,
borderSkipped: false,
barThickness: 14,
}],
},
options: {
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
scales: {
x: { border: { display: false }, grid: { color: grid }, ticks: { color: ink, maxTicksLimit: 5, callback: tick } },
y: { grid: { display: false }, border: { display: false }, ticks: { color: ink } },
},
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: (ctx) => ` ${fmt(ctx.raw)}` } },
},
},
});
});
</script>
@@ -0,0 +1,343 @@
{% load i18n %}
{% with c=perf.currency a=account.currency %}
{# Buy/sell are transfers so the tracker sees them; income uses the account's income entity #}
<c-components.fab>
<c-components.fab_menu_button
color="success" hx_target="#generic-offcanvas" hx_trigger="click"
hx_vals='{"to_account": {{ account.id }}}'
url="{% url 'transactions_transfer' %}"
icon="fa-solid fa-arrow-right-to-bracket"
title="{% translate 'Buy / Contribute' %}"></c-components.fab_menu_button>
<c-components.fab_menu_button
color="error" hx_target="#generic-offcanvas" hx_trigger="click"
hx_vals='{"from_account": {{ account.id }}}'
url="{% url 'transactions_transfer' %}"
icon="fa-solid fa-arrow-right-from-bracket"
title="{% translate 'Sell / Withdraw' %}"></c-components.fab_menu_button>
{% if income_entities %}
<c-components.fab_menu_button
color="success" hx_target="#generic-offcanvas" hx_trigger="click"
hx_vals='{"type": "IN", "entities": "{{ income_entities.0.name|escapejs }}"}'
url="{% url 'transaction_add' %}"
icon="fa-solid fa-hand-holding-dollar"
title="{% translate 'Dividend / Income' %}"></c-components.fab_menu_button>
{% endif %}
{% if not perf.priced %}
<c-components.fab_menu_button
color="info" hx_target="#generic-offcanvas" hx_trigger="click"
url="{% url 'account_reconciliation' %}"
icon="fa-solid fa-scale-balanced"
title="{% translate 'Update value' %}"></c-components.fab_menu_button>
{% endif %}
</c-components.fab>
<div class="container pb-20">
<div class="flex flex-wrap justify-between items-start gap-3 mb-4">
<div class="min-w-0">
<div class="text-3xl font-bold font-mono flex items-center gap-2">
<a href="{% url 'investments_index' %}" hx-boost="true" class="no-underline text-base-content/40 hover:text-base-content transition-colors duration-150" aria-label="{% translate 'Investments' %}"><i class="fa-solid fa-arrow-left fa-fw text-lg"></i></a>
<span class="truncate">{{ account.name }}</span>
<button class="btn btn-ghost btn-sm btn-square text-base-content/40 hover:text-base-content"
aria-label="{% translate 'Edit account' %}" data-tippy-content="{% translate 'Edit account' %}"
hx-get="{% url 'account_edit' pk=account.id %}" hx-target="#generic-offcanvas">
<i class="fa-solid fa-pencil text-base"></i>
</button>
</div>
<div class="text-sm text-base-content/60 mt-1">
{% if account.group %}{{ account.group.name }} · {% endif %}
{% if perf.priced %}{{ a.name }} → {{ c.name }}{% else %}{% translate 'Tracked by value' %}{% endif %}
{% if perf.estimated %}<i class="fa-solid fa-circle-exclamation text-warning ml-1" data-tippy-content="{% translate 'Some incoming units have no linked payment, so their cost was estimated from the market price of that day' %}"></i>{% endif %}
</div>
</div>
</div>
<div class="card bg-base-100 card-border shadow mb-4">
<div class="card-body p-5 gap-5">
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
<div>
<div class="text-4xl font-bold tabular-nums">
{% if perf.market_value is not None %}<c-amount.display :amount="perf.market_value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}-{% endif %}
</div>
<div class="text-sm tabular-nums mt-1 flex items-baseline gap-2">
<c-amount.display :amount="perf.total_return" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display>
{% include 'investments/fragments/_mwr.html' with mw=perf.money_weighted %}
<span class="text-base-content/50">{% translate 'total return' %}</span>
</div>
{% if home %}
{% with h=home.currency %}
<div class="text-sm tabular-nums mt-1 flex flex-wrap items-baseline gap-x-2 text-base-content/60">
<span>{% translate 'In' %} {{ h.name }}:</span>
<c-amount.display :amount="home.market_value" :prefix="h.prefix" :suffix="h.suffix" :decimal_places="h.decimal_places" divless></c-amount.display>
<span>·</span>
<c-amount.display :amount="home.total_return" :prefix="h.prefix" :suffix="h.suffix" :decimal_places="h.decimal_places" color="auto" divless></c-amount.display>
{% include 'investments/fragments/_mwr.html' with mw=home.money_weighted %}
{% if home.has_rate_history %}
<span data-tippy-content="{% translate 'How much of the return came from exchange rates moving since you invested' %}">· {% translate 'currency effect' %}
<c-amount.display :amount="home.currency_effect" :prefix="h.prefix" :suffix="h.suffix" :decimal_places="h.decimal_places" color="auto" divless></c-amount.display>
</span>
{% else %}
<span>· {% translate 'currency effect' %} <i class="fa-solid fa-triangle-exclamation text-warning text-xs" data-tippy-content="{% translate 'Every date is converted with the same rate. Add historical exchange rates (or an automatic rate service) to see how much exchange rates moved your return.' %}"></i></span>
{% endif %}
</div>
{% endwith %}
{% endif %}
</div>
{% if perf.priced %}
<div class="sm:text-right text-sm text-base-content/60 tabular-nums">
<div><c-amount.display :amount="perf.quantity" :prefix="a.prefix" :suffix="a.suffix" :decimal_places="a.decimal_places" divless></c-amount.display></div>
<div>
{% if perf.price is not None %}
× <c-amount.display :amount="perf.price" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
{% if perf.price_date %}<span class="text-base-content/40"> · {{ perf.price_date|date:"SHORT_DATE_FORMAT" }}</span>{% endif %}
{% else %}
<span class="text-error">{% translate "No exchange rate available" %}</span>
{% endif %}
</div>
</div>
{% endif %}
</div>
<div>
{{ chart|json_script:"investment-chart-data" }}
{% if chart.price or chart.earnings or chart.income %}
<div class="tabs tabs-box tabs-sm w-fit mb-3" role="tablist" id="investment-chart-tabs">
<input type="radio" name="investment_chart" class="tab" role="tab" aria-label="{% translate 'Value' %}" value="value" checked>
{% if chart.price %}<input type="radio" name="investment_chart" class="tab" role="tab" aria-label="{% translate 'Price' %}" value="price">{% endif %}
{% if chart.earnings %}<input type="radio" name="investment_chart" class="tab" role="tab" aria-label="{% translate 'Earnings' %}" value="earnings">{% endif %}
{% if chart.income %}<input type="radio" name="investment_chart" class="tab" role="tab" aria-label="{% translate 'Income' %}" value="income">{% endif %}
</div>
{% endif %}
<div class="h-64" data-panel="value"><canvas data-chart="value"></canvas></div>
{% if chart.price %}<div class="h-64 hidden" data-panel="price"><canvas data-chart="price"></canvas></div>{% endif %}
{% if chart.earnings %}<div class="h-64 hidden" data-panel="earnings"><canvas data-chart="earnings"></canvas></div>{% endif %}
{% if chart.income %}<div class="h-64 hidden" data-panel="income"><canvas data-chart="income"></canvas></div>{% endif %}
</div>
<script>
(() => {
const data = JSON.parse(document.getElementById('investment-chart-data').textContent);
const root = document.getElementById('investment-chart-data').parentElement;
const ink = chartInk(75);
const muted = chartInk(50);
const grid = chartInk(12);
const fmt = chartMoney(data.currency);
const blue = '#2a78d6';
const canvas = (name) => root.querySelector(`[data-chart="${name}"]`);
const options = (extra = {}) => ({
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
scales: {
x: { grid: { display: false }, ticks: { color: ink, maxTicksLimit: 6, maxRotation: 0 } },
y: { border: { display: false }, grid: { color: grid }, ticks: { color: ink, maxTicksLimit: 5, callback: (v) => fmt(v, 0) } },
},
plugins: {
legend: {
align: 'end',
labels: {
color: ink, boxWidth: 12, boxHeight: 2,
// A series with no points (no sales yet) has nothing to name
filter: (item, chart) => chart.datasets[item.datasetIndex].data.some((v) => v !== null),
},
},
tooltip: { callbacks: { label: (ctx) => ctx.raw === null ? null : ` ${ctx.dataset.label}: ${fmt(ctx.raw)}` } },
},
...extra,
});
const line = (label, values, color, more = {}) => ({
label, data: values, borderColor: color, borderWidth: 2, pointRadius: 0, pointHoverRadius: 4, pointStyle: 'line', spanGaps: true, ...more,
});
const dashed = { borderWidth: 1.5, borderDash: [4, 4], stepped: true };
const bars = (label, values) => ({
label,
data: values,
backgroundColor: values.map((v) => (v < 0 ? '#d03b3b' : '#0ca30c')),
borderRadius: 4,
maxBarThickness: 24,
});
new Chart(canvas('value'), {
type: 'line',
data: {
labels: data.labels,
datasets: [
line('{% translate "Value" %}', data.value, blue, { fill: true, backgroundColor: 'rgba(42, 120, 214, 0.12)' }),
line('{% translate "Invested" %}', data.invested, muted, dashed),
],
},
options: (() => { const o = options(); o.scales.y.beginAtZero = true; return o; })(),
});
if (data.price) {
const marker = (label, values, color, style) => ({
type: 'line', label, data: values, showLine: false, pointStyle: style, pointRadius: 6, pointHoverRadius: 8,
backgroundColor: color, borderColor: chartInk(100), borderWidth: 1,
});
new Chart(canvas('price'), {
type: 'line',
data: {
labels: data.labels,
datasets: [
line('{% translate "Price" %}', data.price, blue),
line('{% translate "Average cost" %}', data.average_cost, muted, dashed),
marker('{% translate "Buy" %}', data.buys, '#0ca30c', 'triangle'),
marker('{% translate "Sell" %}', data.sells, '#d03b3b', 'rectRot'),
],
},
options: (() => { const o = options(); o.plugins.legend.labels.usePointStyle = true; o.plugins.legend.labels.boxHeight = 8; return o; })(),
});
}
if (data.earnings) {
const opts = options();
// The month's result against what was invested at the end of the month before
opts.plugins.tooltip.callbacks.label = (ctx) => {
const base = data.invested[Math.max(ctx.dataIndex - 1, 0)];
const share = base ? ` (${(ctx.raw / base * 100).toFixed(2)}%)` : '';
return ` ${fmt(ctx.raw)}${share}`;
};
opts.plugins.legend.display = false;
new Chart(canvas('earnings'), {
type: 'bar',
data: { labels: data.labels, datasets: [bars('{% translate "Earnings" %}', data.earnings)] },
options: opts,
});
}
if (data.income) {
const opts = options();
opts.plugins.legend.display = false;
new Chart(canvas('income'), {
type: 'bar',
data: { labels: data.labels, datasets: [bars('{% translate "Income" %}', data.income)] },
options: opts,
});
}
root.querySelectorAll('input[name=investment_chart]').forEach((tab) => tab.addEventListener('change', () => {
root.querySelectorAll('[data-panel]').forEach((panel) => panel.classList.toggle('hidden', panel.dataset.panel !== tab.value));
}));
})();
</script>
<dl class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-x-6 gap-y-4 border-t border-base-content/10 pt-4 text-sm tabular-nums">
<div>
<dt class="text-xs text-base-content/50">{% translate 'Invested' %}</dt>
<dd><c-amount.display :amount="perf.cost_basis" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></dd>
</div>
<div>
<dt class="text-xs text-base-content/50">{% if perf.priced %}{% translate 'Average cost' %}{% else %}{% translate 'Put in / taken out' %}{% endif %}</dt>
<dd>
{% if perf.priced %}
{% if perf.average_cost is not None %}<c-amount.display :amount="perf.average_cost" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}-{% endif %}
{% else %}
<c-amount.display :amount="perf.contributed" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
<span class="text-base-content/50">/</span>
<c-amount.display :amount="perf.withdrawn" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
{% endif %}
</dd>
</div>
<div>
<dt class="text-xs text-base-content/50">{% if perf.priced %}{% translate 'Unrealized' %}{% else %}{% translate 'Earnings' %}{% endif %}</dt>
<dd>
{% if perf.unrealized is not None %}
<c-amount.display :amount="perf.unrealized" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display>
{% include 'investments/fragments/_percentage.html' with value=perf.unrealized_percentage css="text-xs" %}
{% else %}-{% endif %}
</dd>
</div>
<div>
<dt class="text-xs text-base-content/50">{% translate 'Realized' %}</dt>
<dd><c-amount.display :amount="perf.realized" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display></dd>
</div>
<div>
<dt class="text-xs text-base-content/50">{% translate 'Income' %}{% if income_entities %} · {{ income_entities|join:", " }}{% endif %}</dt>
<dd><c-amount.display :amount="perf.income" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display></dd>
</div>
{% if perf.priced %}
<div>
<dt class="text-xs text-base-content/50">{% translate 'Put in / taken out' %}</dt>
<dd>
<c-amount.display :amount="perf.contributed" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
<span class="text-base-content/50">/</span>
<c-amount.display :amount="perf.withdrawn" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
</dd>
</div>
{% endif %}
</dl>
</div>
</div>
<div class="grid grid-cols-1 {% if income %}xl:grid-cols-3{% endif %} gap-4">
<div class="card bg-base-100 card-border shadow {% if income %}xl:col-span-2{% endif %}">
<div class="px-5 pt-4 pb-2 font-semibold">{% translate 'Movements' %}</div>
{% if transactions %}
<div class="overflow-x-auto">
<table class="table text-sm">
<tbody>
{% for tx in transactions %}
{% with p=tx.linked_transaction %}
<tr class="{% if not tx.is_paid %}opacity-50{% endif %}">
<td class="whitespace-nowrap text-base-content/60 tabular-nums w-28">{{ tx.date|date:"SHORT_DATE_FORMAT" }}</td>
<td>
<div>{{ tx.description }}</div>
<div class="text-xs text-base-content/50">
{% if p %}
{% if tx.type == 'IN' %}{% translate 'Bought / contributed' %}{% else %}{% translate 'Sold / withdrew' %}{% endif %}
· {{ p.account.name }}
{% else %}
{% if not perf.priced and tx.id not in perf.result_ids %}
{% if tx.type == 'IN' %}{% translate 'Money in' %}{% else %}{% translate 'Money out' %}{% endif %}
{% else %}
{% if tx.type == 'IN' %}{% if perf.priced %}{% translate 'Gain' %}{% else %}{% translate 'Earnings' %}{% endif %}{% else %}{% translate 'Loss, tax or fee' %}{% endif %}
{% endif %}
{% endif %}
</div>
</td>
<td class="text-end tabular-nums whitespace-nowrap">
<c-amount.display :amount="tx.amount" :prefix="a.prefix" :suffix="a.suffix" :decimal_places="a.decimal_places" color="{% if tx.type == 'IN' %}green{% else %}red{% endif %}" divless></c-amount.display>
{% if p and p.account.currency != a %}
<div class="text-xs text-base-content/50">
<c-amount.display :amount="p.amount" :prefix="p.account.currency.prefix" :suffix="p.account.currency.suffix" :decimal_places="p.account.currency.decimal_places" divless></c-amount.display>
</div>
{% endif %}
</td>
<td class="w-8 px-1">
<button class="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
aria-label="{% translate 'Edit' %}" data-tippy-content="{% translate 'Edit' %}"
hx-get="{% if p %}{% url 'transaction_transfer_edit' transaction_id=tx.id %}{% else %}{% url 'transaction_edit' transaction_id=tx.id %}{% endif %}" hx-target="#generic-offcanvas"><i class="fa-solid fa-pencil"></i></button>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<c-msg.empty title="{% translate 'No movements yet' %}" subtitle="{% translate 'Use the + button to buy or contribute' %}"></c-msg.empty>
{% endif %}
</div>
{% if income %}
<div class="card bg-base-100 card-border shadow">
<div class="px-5 pt-4 pb-2 font-semibold">{% translate 'Income' %}</div>
<div class="overflow-x-auto">
<table class="table text-sm">
<tbody>
{% for tx in income %}
<tr class="{% if not tx.is_paid %}opacity-50{% endif %}">
<td class="whitespace-nowrap text-base-content/60 tabular-nums">{{ tx.date|date:"SHORT_DATE_FORMAT" }}</td>
<td>{{ tx.description }}<div class="text-xs text-base-content/50">{{ tx.account.name }}</div></td>
<td class="text-end tabular-nums whitespace-nowrap"><c-amount.display :amount="tx.amount" :prefix="tx.account.currency.prefix" :suffix="tx.account.currency.suffix" :decimal_places="tx.account.currency.decimal_places" color="green" divless></c-amount.display></td>
<td class="w-8 px-1">
<button class="btn btn-ghost btn-xs btn-square text-base-content/40 hover:text-base-content"
aria-label="{% translate 'Edit' %}" data-tippy-content="{% translate 'Edit' %}"
hx-get="{% url 'transaction_edit' transaction_id=tx.id %}" hx-target="#generic-offcanvas"><i class="fa-solid fa-pencil"></i></button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
</div>
{% endwith %}
@@ -0,0 +1,108 @@
{% load i18n %}
<c-ui.fab-single-action
url="{% url 'transactions_transfer' %}"
hx_target="#generic-offcanvas">
</c-ui.fab-single-action>
<div class="container pb-20">
<div class="text-3xl font-bold font-mono w-full mb-4">
{% spaceless %}
<div>{% translate 'Investments' %}</div>
{% endspaceless %}
</div>
{% if groups %}
<div class="grid grid-cols-1 {% if totals|length > 1 %}lg:grid-cols-2{% endif %} gap-3 mb-6">
{% for total in totals %}
{% include 'investments/fragments/_totals.html' %}
{% endfor %}
</div>
<div role="tablist" class="tabs tabs-border">
<input type="radio" name="investment_tabs" role="tab" class="tab" aria-label="{% translate 'Holdings' %}" checked="checked"/>
<div role="tabpanel" class="tab-content pt-4">
{% for item in groups %}
<div class="card bg-base-100 card-border shadow mb-4">
<div class="flex flex-wrap justify-between items-baseline gap-2 px-5 pt-4 pb-2">
<span class="font-semibold">{% if item.group %}{{ item.group.name }}{% else %}{% translate 'Ungrouped' %}{% endif %}</span>
<div class="flex flex-wrap gap-4 text-sm">
{% for total in item.totals %}
<div class="flex items-baseline gap-2 tabular-nums">
<c-amount.display :amount="total.market_value" :prefix="total.currency.prefix" :suffix="total.currency.suffix" :decimal_places="total.currency.decimal_places" divless></c-amount.display>
{% include 'investments/fragments/_mwr.html' with mw=total.money_weighted %}
</div>
{% endfor %}
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-fixed">
<colgroup>
<col>
<col class="w-36 hidden md:table-column">
<col class="w-36 hidden sm:table-column">
<col class="w-36">
</colgroup>
<thead>
<tr class="text-xs text-base-content/50">
<th class="font-normal">{% translate 'Investment' %}</th>
<th class="font-normal text-end hidden md:table-cell">{% translate 'Invested' %}</th>
<th class="font-normal text-end hidden sm:table-cell">{% translate 'Value' %}</th>
<th class="font-normal text-end"><span class="sm:hidden">{% translate 'Value' %}</span><span class="hidden sm:inline">{% translate 'Return' %}</span></th>
</tr>
</thead>
<tbody>
{% for perf in item.holdings %}
{% with c=perf.currency a=perf.account.currency %}
<tr class="cursor-pointer transition-colors duration-150 hover:bg-base-200/60"
_="on click go to url '{% url 'investment_detail_index' account_id=perf.account.id %}'">
<td>
<div class="font-medium truncate">
{{ perf.account.name }}
{% if perf.estimated %}<i class="fa-solid fa-circle-exclamation text-warning text-xs" data-tippy-content="{% translate 'Some costs were estimated from market prices' %}"></i>{% endif %}
</div>
<div class="text-xs text-base-content/50 tabular-nums truncate">
{% if perf.priced %}
<c-amount.display :amount="perf.quantity" :prefix="a.prefix" :suffix="a.suffix" :decimal_places="a.decimal_places" divless></c-amount.display>
{% if perf.price is not None %}× <c-amount.display :amount="perf.price" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}· <span class="text-error">{% translate 'No exchange rate available' %}</span>{% endif %}
{% else %}
{% translate 'Tracked by value' %}
{% endif %}
</div>
</td>
<td class="text-end tabular-nums whitespace-nowrap text-base-content/70 hidden md:table-cell">
<c-amount.display :amount="perf.cost_basis" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
</td>
<td class="text-end tabular-nums whitespace-nowrap hidden sm:table-cell">
{% if perf.market_value is not None %}<c-amount.display :amount="perf.market_value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}-{% endif %}
</td>
<td class="text-end tabular-nums whitespace-nowrap">
{# Phones show value + return %, wider screens split them into columns #}
<div class="sm:hidden font-medium">{% if perf.market_value is not None %}<c-amount.display :amount="perf.market_value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>{% else %}-{% endif %}</div>
<div class="hidden sm:block"><c-amount.display :amount="perf.total_return" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display></div>
<div class="text-xs">{% include 'investments/fragments/_mwr.html' with mw=perf.money_weighted %}</div>
</td>
</tr>
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
</div>
<input type="radio" name="investment_tabs" role="tab" class="tab" aria-label="{% translate 'Charts' %}"
hx-get="{% url 'investments_charts' %}" hx-trigger="click once" hx-target="#investment-charts" hx-indicator="#investment-charts"/>
<div role="tabpanel" class="tab-content pt-4">
<div id="investment-charts" class="show-loading min-h-40"></div>
</div>
<input type="radio" name="investment_tabs" role="tab" class="tab" aria-label="{% translate 'Rebalance' %}"
hx-get="{% url 'investments_rebalance' %}" hx-trigger="click once" hx-target="#investment-rebalance" hx-indicator="#investment-rebalance"/>
<div role="tabpanel" class="tab-content pt-4">
<div id="investment-rebalance" class="show-loading min-h-40"></div>
</div>
</div>
{% else %}
<c-msg.empty
title="{% translate 'No investments yet' %}"
subtitle="{% translate 'Mark an account as an investment in its settings to see it here' %}"></c-msg.empty>
{% endif %}
</div>
@@ -0,0 +1,62 @@
{% load i18n %}
{% if not targeted %}
<c-msg.empty
title="{% translate 'No allocation targets' %}"
subtitle="{% translate 'Set a target for your asset classes to see what to buy and sell' %}"></c-msg.empty>
<div class="text-center"><a class="btn btn-sm" href="{% url 'investment_classes_index' %}">{% translate 'Asset classes' %}</a></div>
{% elif total_target != 100 %}
<div role="alert" class="alert alert-warning alert-soft">
<i class="fa-solid fa-triangle-exclamation"></i>
<span>{% blocktranslate with total=total_target|floatformat:"-2" %}Targets add up to {{ total }}%. Set them to 100% to see the rebalance.{% endblocktranslate %}</span>
<a class="btn btn-sm" href="{% url 'investment_classes_index' %}">{% translate 'Asset classes' %}</a>
</div>
{% else %}
{% for plan in plans %}
{% with c=plan.currency %}
<div class="card bg-base-100 card-border shadow mb-4">
{% if plans|length > 1 %}<div class="font-semibold px-5 pt-4">{{ c.name }}</div>{% endif %}
<div class="overflow-x-auto">
<table class="table">
<thead>
<tr class="text-xs text-base-content/50">
<th class="font-normal">{% translate 'Asset class' %}</th>
<th class="font-normal text-end">{% translate 'Value' %}</th>
<th class="font-normal text-end">{% translate 'Now' %}</th>
<th class="font-normal text-end">{% translate 'Target' %}</th>
<th class="font-normal text-end">{% translate 'To reach the target' %}</th>
</tr>
</thead>
<tbody>
{% for row in plan.rows %}
<tr>
<td class="font-medium">
{{ row.class.name }}
{% if row.off %}<i class="fa-solid fa-triangle-exclamation text-warning text-xs" data-tippy-content="{% translate 'Off target' %}"></i>{% endif %}
</td>
<td class="text-end tabular-nums whitespace-nowrap"><c-amount.display :amount="row.value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></td>
<td class="text-end tabular-nums">{{ row.share|floatformat:1 }}%</td>
<td class="text-end tabular-nums text-base-content/70">{{ row.target|floatformat:"-2" }}%</td>
<td class="text-end tabular-nums whitespace-nowrap">
{% if row.move > 0 %}
<span class="text-success">{% translate 'Buy' %}</span> <c-amount.display :amount="row.size" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
{% elif row.move < 0 %}
<span class="text-error">{% translate 'Sell' %}</span> <c-amount.display :amount="row.size" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
{% else %}
<span class="text-base-content/50">-</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if plan.left_out %}
<div class="text-xs text-base-content/60 px-5 pb-4">
{% translate 'Left out: investments without an asset class or a target' %} ·
<c-amount.display :amount="plan.left_out" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
</div>
{% endif %}
</div>
{% endwith %}
{% endfor %}
{% endif %}
@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{{ account.name }} :: {% translate 'Investments' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'investment_detail' account_id=account.id %}" hx-trigger="load, updated from:window" class="show-loading" id="investment-details"></div>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% block title %}{% translate 'Investments' %}{% endblock %}
{% block content %}
<div hx-get="{% url 'investments_list' %}" hx-trigger="load, updated from:window" class="show-loading"></div>
{% endblock %}
+1 -3
View File
@@ -69,9 +69,7 @@ RUN sed -i 's/\r$//g' /start && \
COPY --chown=app:app ./app .
RUN chown -R app:app /usr/src/app && \
chown -R app:app /usr/src/frontend && \
mkdir /temp && \
chown -R app:app /temp
chown -R app:app /usr/src/frontend
USER app
+20
View File
@@ -4,3 +4,23 @@ import zoomPlugin from 'chartjs-plugin-zoom';
Chart.register(SankeyController, Flow, zoomPlugin);
window.Chart = Chart;
// The theme's text color at a given strength, resolved to a value canvas can draw
// with. shortcut: read once per chart, so a theme switch shows on the next render.
window.chartInk = (percent) => {
const probe = document.createElement('span');
probe.style.color = `color-mix(in oklab, var(--color-base-content) ${percent}%, transparent)`;
document.body.appendChild(probe);
const color = getComputedStyle(probe).color;
probe.remove();
return color;
};
// Formats amounts with the user's separators, as the amounts elsewhere in the app.
// currency: {prefix, suffix, decimals, decimal, thousand}
window.chartMoney = (currency) => (v, decimals = currency.decimals) => {
const [whole, fraction] = Math.abs(v).toFixed(decimals).split('.');
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, currency.thousand);
const number = fraction ? `${grouped}${currency.decimal}${fraction}` : grouped;
return `${v < 0 ? '-' : ''}${currency.prefix}${number}${currency.suffix}`;
};
+38
View File
@@ -3,6 +3,31 @@ import * as Popper from "@popperjs/core";
import '../styles/_tom-select.scss'
// Mini version of cotton/transaction/item.html, used by TransactionSelect
function renderTransaction(data, escape, compact) {
if (!data.date) {
return `<div>${escape(data.text)}</div>`;
}
const kind = data.type === 'EX' ? 'expense' : 'income';
const meta = [
data.category && `<span><i class="fa-solid fa-icons fa-fw"></i>${escape(data.category)}</span>`,
data.tags && data.tags.length && `<span><i class="fa-solid fa-hashtag fa-fw"></i>${escape(data.tags.join(', '))}</span>`,
data.entities && data.entities.length && `<span><i class="fa-solid fa-user-group fa-fw"></i>${escape(data.entities.join(', '))}</span>`,
].filter(Boolean).join('');
return `<div class="ts-tx ts-tx-${kind}${compact ? ' ts-tx-compact' : ''}">
<i class="ts-tx-paid fa-regular ${data.is_paid ? 'fa-circle-check' : 'fa-circle'}"></i>
<div class="ts-tx-main">
<div class="ts-tx-date"><i class="fa-solid fa-calendar fa-fw"></i>${escape(data.date)}</div>
<div class="ts-tx-desc">${escape(data.description || '')}</div>
${meta && !compact ? `<div class="ts-tx-meta">${meta}</div>` : ''}
</div>
<div class="ts-tx-side">
<div class="ts-tx-amount text-${kind}">${escape(data.amount)}</div>
<div class="ts-tx-account">${escape(data.account)}</div>
</div>
</div>`;
}
window.TomSelect = function createDynamicTomSelect(element) {
const schedulePopperUpdate = function (instance) {
// Wait for TomSelect DOM updates before recalculating dropdown position.
@@ -98,12 +123,25 @@ window.TomSelect = function createDynamicTomSelect(element) {
};
}
if (element.dataset.render === 'transaction') {
config.dataAttr = 'data'; // dataset key of the data-data attribute
config.render.option = (data, escape) => renderTransaction(data, escape, false);
config.render.item = (data, escape) => renderTransaction(data, escape, true);
}
if (element.dataset.load) {
// The server searches and orders; don't let tom-select re-filter or re-sort the results
config.score = () => () => 1;
config.sortField = [{field: '$order'}];
config.preload = 'focus';
config.shouldLoad = () => true;
config.load = function (query, callback) {
let url = element.dataset.load + '?q=' + encodeURIComponent(query);
fetch(url)
.then(response => response.json())
.then(json => {
// Drop results from the previous query, keeping the selected item
this.clearOptions();
callback(json);
}).catch(() => {
callback();
+95
View File
@@ -399,3 +399,98 @@
.tomselect-checkbox {
margin-right: 0.5rem;
}
/* Mini transaction (TransactionSelect) */
.ts-tx {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
min-width: 0;
padding: 0.375rem 0.5rem;
border-left: 4px solid var(--color-success);
border-radius: var(--radius-field, 0.25rem);
background-color: var(--color-base-100);
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.8125rem;
line-height: 1.25rem;
text-align: left;
}
.ts-tx-expense {
border-left-color: var(--color-error);
}
.ts-tx-paid {
color: color-mix(in oklab, var(--color-base-content) 50%, transparent);
font-size: 1.125rem;
}
.ts-tx-main,
.ts-tx-side {
min-width: 0;
}
.ts-tx-main {
flex: 1 1 auto;
}
.ts-tx-side {
flex: 0 1 auto;
max-width: 45%;
text-align: right;
}
.ts-tx-date,
.ts-tx-meta,
.ts-tx-account {
color: color-mix(in oklab, var(--color-base-content) 70%, transparent);
font-size: 0.75rem;
}
.ts-tx i.fa-fw {
margin-right: 0.25rem;
font-size: 0.625rem;
}
.ts-tx-desc {
color: var(--color-base-content);
font-size: 0.875rem;
}
.ts-tx-meta {
display: flex;
flex-wrap: wrap;
column-gap: 0.75rem;
}
.ts-tx-amount {
font-weight: 500;
white-space: nowrap;
}
.ts-tx-desc,
.ts-tx-account {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Selected value inside the control: no card chrome, leave room for the clear button and arrow */
.ts-control .ts-tx-compact {
padding: 0 1.5rem 0 0.5rem;
background: transparent;
}
.ts-dropdown .option:has(.ts-tx) {
padding: 0.25rem 0.375rem;
}
.ts-dropdown .active .ts-tx {
background-color: transparent;
}
.ts-control > .item:has(.ts-tx) {
flex: 1 1 auto;
min-width: 0;
}