mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 12:23:44 +01:00
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:
commit
1369c3edbf
83 files changed
+13812
-3428
No files matched your search
@@ -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
|
||||
|
||||
@@ -168,3 +168,4 @@ postgres_data/
|
||||
|
||||
# Private local uploads
|
||||
app/attachments/
|
||||
app/temp/
|
||||
@@ -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. |
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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
@@ -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)
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"])
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InvestmentsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.investments"
|
||||
@@ -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.
@@ -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)
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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"})
|
||||
@@ -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"))
|
||||
@@ -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)
|
||||
+695
-217
File diff suppressed because it is too large.
Load diff
+639
-215
File diff suppressed because it is too large.
Load diff
+697
-217
File diff suppressed because it is too large.
Load diff
+697
-217
File diff suppressed because it is too large.
Load diff
+665
-215
File diff suppressed because it is too large.
Load diff
+639
-215
File diff suppressed because it is too large.
Load diff
+697
-217
File diff suppressed because it is too large.
Load diff
+697
-217
File diff suppressed because it is too large.
Load diff
+682
-216
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+687
-217
File diff suppressed because it is too large.
Load diff
+641
-215
File diff suppressed because it is too large.
Load diff
+641
-215
File diff suppressed because it is too large.
Load diff
+673
-217
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user