mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-11-13 20:33:48 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e552723078 | ||
|
|
01dd0920a9 | ||
|
|
e2981bf1f6 | ||
|
|
9cf8cca1cb | ||
|
|
1ec0cfc02f | ||
|
|
1c891a0a60 | ||
|
|
03c22b8fc2 | ||
|
|
df6ccda7b3 | ||
|
|
e617e809c0 | ||
|
|
1662fe70e0 | ||
|
|
ca09f37b51 | ||
|
|
1ed5dc5d31 | ||
|
|
dea8cfee4e | ||
|
|
4bf2c2552b | ||
|
|
d590dc39ab | ||
|
|
6c3c4c03ec | ||
|
|
220ccee140 | ||
|
|
1c7bc3db1d | ||
|
|
bf83975b97 | ||
|
|
1ab06d544d | ||
|
|
d2cd4d577a | ||
|
|
4d886a4fea | ||
|
|
3a619dc197 | ||
|
|
cd33b82b4e | ||
|
|
7aed2e8574 | ||
|
|
9d36af78c4 | ||
|
|
00d37d8c81 | ||
|
|
d3f29f5ad1 | ||
|
|
60a0365b23 | ||
|
|
edd045607f | ||
|
|
3468b10e94 | ||
|
|
750b86394e | ||
|
|
706f8fea2b | ||
|
|
eafaf0027c | ||
|
|
0093cabfc9 | ||
|
|
37ecc7da1b | ||
|
|
5d5a423db5 | ||
|
|
b6ac69716c | ||
|
|
c256671acc | ||
|
|
0e62594133 | ||
|
|
07933ac5c1 | ||
|
|
3c30116072 | ||
|
|
7ebcccdeda | ||
|
|
674870641c | ||
|
|
0854805630 | ||
|
|
4826c4932a | ||
|
|
6a289327b6 | ||
|
|
bdc0603327 | ||
|
|
a7ae1c7ec4 | ||
|
|
34ba0c6e6a | ||
|
|
d0b6be14e7 | ||
|
|
55f064ee7c | ||
|
|
10cb4465d8 |
No files matched your search
@@ -103,6 +103,7 @@ class AccountForm(forms.ModelForm):
|
||||
"group",
|
||||
"currency",
|
||||
"exchange_currency",
|
||||
"show_exchanged_amounts",
|
||||
"is_asset",
|
||||
"is_archived",
|
||||
"is_investment",
|
||||
@@ -161,6 +162,7 @@ class AccountForm(forms.ModelForm):
|
||||
Switch("is_archived"),
|
||||
"currency",
|
||||
"exchange_currency",
|
||||
Switch("show_exchanged_amounts"),
|
||||
Switch("is_investment"),
|
||||
# Only relevant for investments: shown while the switch above is on
|
||||
Div(
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.17 on 2026-11-11 09:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("accounts", "0021_alter_account_income_categories_help"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="account",
|
||||
name="show_exchanged_amounts",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="Show amounts converted to this account's exchange currency next to its transactions and totals.",
|
||||
verbose_name="Show exchanged amounts",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -104,6 +104,14 @@ class Account(SharedObject):
|
||||
blank=True,
|
||||
help_text=_("Default currency for exchange calculations"),
|
||||
)
|
||||
show_exchanged_amounts = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Show exchanged amounts"),
|
||||
help_text=_(
|
||||
"Show amounts converted to this account's exchange currency next to its "
|
||||
"transactions and totals."
|
||||
),
|
||||
)
|
||||
|
||||
is_asset = models.BooleanField(
|
||||
default=False,
|
||||
@@ -191,6 +199,19 @@ class Account(SharedObject):
|
||||
user = get_current_user()
|
||||
return self.untracked_by.filter(pk=user.pk).exists()
|
||||
|
||||
@property
|
||||
def displayed_exchange_currency(self):
|
||||
"""The exchange currency to show amounts in, or None when they're hidden.
|
||||
|
||||
The account's exchange currency takes precedence over its currency's, so
|
||||
whichever one provides it also decides whether it's shown.
|
||||
"""
|
||||
if self.exchange_currency_id:
|
||||
return self.exchange_currency if self.show_exchanged_amounts else None
|
||||
if self.currency.show_exchanged_amounts:
|
||||
return self.currency.exchange_currency
|
||||
return None
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
if self.exchange_currency == self.currency:
|
||||
|
||||
@@ -80,6 +80,7 @@ class AccountSerializer(serializers.ModelSerializer):
|
||||
"currency_id",
|
||||
"exchange_currency",
|
||||
"exchange_currency_id",
|
||||
"show_exchanged_amounts",
|
||||
"is_asset",
|
||||
"is_investment",
|
||||
"investment_class",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.accounts.models import Account
|
||||
from apps.import_app.models import ImportProfile, ImportRun
|
||||
|
||||
|
||||
@@ -21,6 +23,7 @@ class ImportRunSerializer(serializers.ModelSerializer):
|
||||
"status",
|
||||
"profile",
|
||||
"file_name",
|
||||
"account",
|
||||
"logs",
|
||||
"processed_rows",
|
||||
"total_rows",
|
||||
@@ -39,8 +42,22 @@ class ImportFileSerializer(serializers.Serializer):
|
||||
queryset=ImportProfile.objects.all(), source="profile"
|
||||
)
|
||||
file = serializers.FileField()
|
||||
account_id = serializers.PrimaryKeyRelatedField(
|
||||
queryset=Account.objects.filter(is_archived=False),
|
||||
source="account",
|
||||
required=False,
|
||||
allow_null=True,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Reload the queryset to get an updated version with the requesting user
|
||||
# Reload the querysets to get an updated version with the requesting user
|
||||
self.fields["profile_id"].queryset = ImportProfile.objects.all()
|
||||
self.fields["account_id"].queryset = Account.objects.filter(is_archived=False)
|
||||
|
||||
def validate(self, data):
|
||||
if data.get("account") and not data["profile"].imports_transactions:
|
||||
raise serializers.ValidationError(
|
||||
{"account_id": _("Only transaction imports can use an account.")}
|
||||
)
|
||||
return data
|
||||
@@ -102,6 +102,7 @@ class ImportRunViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"properties": {
|
||||
"profile_id": {"type": "integer", "description": "ID of the ImportProfile to use"},
|
||||
"file": {"type": "string", "format": "binary", "description": "CSV or XLSX file to import"},
|
||||
"account_id": {"type": "integer", "description": "ID of the Account to import every transaction into. Leave empty to let the profile pick the account. Only for profiles that import transactions"},
|
||||
},
|
||||
"required": ["profile_id", "file"],
|
||||
},
|
||||
@@ -137,7 +138,10 @@ class ImportViewSet(viewsets.ViewSet):
|
||||
|
||||
# Create ImportRun record
|
||||
import_run = ImportRun.objects.create(
|
||||
profile=profile, file_name=filename, owner=request.user
|
||||
profile=profile,
|
||||
file_name=filename,
|
||||
account=serializer.validated_data.get("account"),
|
||||
owner=request.user,
|
||||
)
|
||||
|
||||
# Queue import task
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.forms import SelectMultiple, widgets
|
||||
from django.urls import reverse
|
||||
@@ -141,6 +142,9 @@ class TransactionSelect(TomSelect):
|
||||
self.load_income = income
|
||||
self.load_expense = expense
|
||||
self.create = False
|
||||
# Extra search filters sent as query params, e.g. {"currency": 1};
|
||||
# keys must be in SEARCH_FILTERS of the transactions_search view
|
||||
self.filters = {}
|
||||
|
||||
def build_attrs(self, base_attrs, extra_attrs=None):
|
||||
attrs = super().build_attrs(base_attrs, extra_attrs)
|
||||
@@ -156,6 +160,8 @@ class TransactionSelect(TomSelect):
|
||||
attrs["data-load"] = reverse(
|
||||
"transactions_search", kwargs={"filter_type": "expenses"}
|
||||
)
|
||||
if self.filters:
|
||||
attrs["data-load"] += "?" + urlencode(self.filters)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class CurrencyForm(forms.ModelForm):
|
||||
"suffix",
|
||||
"code",
|
||||
"exchange_currency",
|
||||
"show_exchanged_amounts",
|
||||
"is_archived",
|
||||
]
|
||||
widgets = {
|
||||
@@ -49,6 +50,7 @@ class CurrencyForm(forms.ModelForm):
|
||||
"prefix",
|
||||
"suffix",
|
||||
"exchange_currency",
|
||||
Switch("show_exchanged_amounts"),
|
||||
)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.17 on 2026-11-11 09:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("currencies", "0027_exchangerateservice_retention"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="currency",
|
||||
name="show_exchanged_amounts",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="Show amounts converted to the exchange currency next to transactions and totals in this currency. Accounts with their own exchange currency use their own setting, and consolidated totals are calculated either way.",
|
||||
verbose_name="Show exchanged amounts",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -31,6 +31,15 @@ class Currency(models.Model):
|
||||
blank=True,
|
||||
help_text=_("Default currency for exchange calculations"),
|
||||
)
|
||||
show_exchanged_amounts = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Show exchanged amounts"),
|
||||
help_text=_(
|
||||
"Show amounts converted to the exchange currency next to transactions and "
|
||||
"totals in this currency. Accounts with their own exchange currency use "
|
||||
"their own setting, and consolidated totals are calculated either way."
|
||||
),
|
||||
)
|
||||
|
||||
is_archived = models.BooleanField(
|
||||
default=False,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from django.db.models import Case, F, When
|
||||
|
||||
|
||||
def shown_exchange_currency(path):
|
||||
"""Query expression for the exchange currency id at ``path`` (an account or a
|
||||
currency lookup, such as ``"account__currency__"``), or NULL when that object
|
||||
hides its exchanged amounts."""
|
||||
return Case(
|
||||
When(
|
||||
**{f"{path}show_exchanged_amounts": True},
|
||||
then=F(f"{path}exchange_currency"),
|
||||
)
|
||||
)
|
||||
+72
-25
@@ -9,7 +9,12 @@ from apps.common.widgets.datepicker import AirDatePickerInput
|
||||
from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput
|
||||
from apps.common.widgets.tom_select import TomSelect, TransactionSelect
|
||||
from apps.dca.models import DCAEntry, DCAStrategy
|
||||
from apps.transactions.models import Transaction, TransactionCategory, TransactionTag
|
||||
from apps.transactions.models import (
|
||||
Transaction,
|
||||
TransactionCategory,
|
||||
TransactionEntity,
|
||||
TransactionTag,
|
||||
)
|
||||
from crispy_forms.bootstrap import AccordionGroup, FormActions, Accordion
|
||||
from crispy_forms.helper import FormHelper
|
||||
from crispy_forms.layout import HTML, Column, Div, Layout, Row
|
||||
@@ -104,6 +109,23 @@ class DCAEntryForm(forms.ModelForm):
|
||||
queryset=TransactionTag.objects.filter(active=True),
|
||||
)
|
||||
|
||||
from_entities = DynamicModelMultipleChoiceField(
|
||||
model=TransactionEntity,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Entities"),
|
||||
queryset=TransactionEntity.objects.filter(active=True),
|
||||
)
|
||||
to_entities = DynamicModelMultipleChoiceField(
|
||||
model=TransactionEntity,
|
||||
to_field_name="name",
|
||||
create_field="name",
|
||||
required=False,
|
||||
label=_("Entities"),
|
||||
queryset=TransactionEntity.objects.filter(active=True),
|
||||
)
|
||||
|
||||
expense_transaction = DynamicModelChoiceField(
|
||||
model=Transaction,
|
||||
to_field_name="id",
|
||||
@@ -163,6 +185,7 @@ class DCAEntryForm(forms.ModelForm):
|
||||
Column("from_category"),
|
||||
Column("from_tags"),
|
||||
),
|
||||
"from_entities",
|
||||
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border my-3",
|
||||
),
|
||||
Div(
|
||||
@@ -171,6 +194,7 @@ class DCAEntryForm(forms.ModelForm):
|
||||
Column("to_category"),
|
||||
Column("to_tags"),
|
||||
),
|
||||
"to_entities",
|
||||
css_class="bg-base-100 rounded-box p-4 border-base-content/60 border",
|
||||
),
|
||||
active=False,
|
||||
@@ -203,36 +227,30 @@ class DCAEntryForm(forms.ModelForm):
|
||||
self.fields["amount_received"].widget = ArbitraryDecimalDisplayNumberInput()
|
||||
self.fields["date"].widget = AirDatePickerInput(clear_button=False)
|
||||
|
||||
expense_transaction = None
|
||||
income_transaction = None
|
||||
if self.instance and self.instance.pk:
|
||||
# Edit mode - get from instance
|
||||
expense_transaction = self.instance.expense_transaction
|
||||
income_transaction = self.instance.income_transaction
|
||||
elif self.data.get("expense_transaction"):
|
||||
# Form validation - get from submitted data
|
||||
try:
|
||||
expense_transaction = Transaction.objects.get(
|
||||
id=self.data["expense_transaction"]
|
||||
)
|
||||
income_transaction = Transaction.objects.get(
|
||||
id=self.data["income_transaction"]
|
||||
)
|
||||
except Transaction.DoesNotExist:
|
||||
pass
|
||||
# Amounts can come from the linked transactions instead
|
||||
self.fields["amount_paid"].required = False
|
||||
self.fields["amount_received"].required = False
|
||||
|
||||
# If we have a current transaction, ensure it's in the queryset
|
||||
if income_transaction:
|
||||
self.fields["income_transaction"].queryset = Transaction.objects.filter(
|
||||
id=income_transaction.id
|
||||
# The picker only searches the currency each side expects (enforced in clean)
|
||||
for name, currency in (
|
||||
("expense_transaction", self.strategy.payment_currency),
|
||||
("income_transaction", self.strategy.target_currency),
|
||||
):
|
||||
field = self.fields[name]
|
||||
field.widget.filters = {"currency": currency.id}
|
||||
selected = (
|
||||
self.data.get(name)
|
||||
if self.is_bound
|
||||
else getattr(self.instance, f"{name}_id", None)
|
||||
)
|
||||
if expense_transaction:
|
||||
self.fields["expense_transaction"].queryset = Transaction.objects.filter(
|
||||
id=expense_transaction.id
|
||||
# Only the selected one: the picker loads the rest by search
|
||||
field.queryset = Transaction.objects.filter(
|
||||
id=selected if str(selected or "").isdigit() else None
|
||||
)
|
||||
|
||||
self.fields["from_account"].queryset = Account.objects.filter(
|
||||
is_archived=False,
|
||||
currency=self.strategy.payment_currency,
|
||||
)
|
||||
|
||||
self.fields["from_category"].queryset = TransactionCategory.objects.filter(
|
||||
@@ -242,16 +260,43 @@ class DCAEntryForm(forms.ModelForm):
|
||||
|
||||
self.fields["to_account"].queryset = Account.objects.filter(
|
||||
is_archived=False,
|
||||
currency=self.strategy.target_currency,
|
||||
)
|
||||
|
||||
self.fields["to_category"].queryset = TransactionCategory.objects.filter(
|
||||
active=True
|
||||
)
|
||||
self.fields["to_tags"].queryset = TransactionTag.objects.filter(active=True)
|
||||
self.fields["from_entities"].queryset = TransactionEntity.objects.filter(
|
||||
active=True
|
||||
)
|
||||
self.fields["to_entities"].queryset = TransactionEntity.objects.filter(
|
||||
active=True
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
creating = cleaned_data.get("create_transaction")
|
||||
for amount_field, link_field, currency in (
|
||||
("amount_paid", "expense_transaction", self.strategy.payment_currency),
|
||||
("amount_received", "income_transaction", self.strategy.target_currency),
|
||||
):
|
||||
linked = cleaned_data.get(link_field)
|
||||
if linked and linked.account.currency_id != currency.id:
|
||||
self.add_error(
|
||||
link_field,
|
||||
_("Transaction must be in %(currency)s.")
|
||||
% {"currency": currency.name},
|
||||
)
|
||||
linked = cleaned_data[link_field] = None
|
||||
if cleaned_data.get(amount_field) is not None:
|
||||
continue
|
||||
if linked and not creating:
|
||||
cleaned_data[amount_field] = linked.amount
|
||||
elif amount_field not in self.errors:
|
||||
self.add_error(amount_field, _("This field is required."))
|
||||
|
||||
if cleaned_data.get("create_transaction"):
|
||||
from_account = cleaned_data.get("from_account")
|
||||
to_account = cleaned_data.get("to_account")
|
||||
@@ -315,6 +360,7 @@ class DCAEntryForm(forms.ModelForm):
|
||||
notes=notes,
|
||||
)
|
||||
from_transaction.tags.set(self.cleaned_data.get("from_tags", []))
|
||||
from_transaction.entities.set(self.cleaned_data.get("from_entities", []))
|
||||
|
||||
# Create "To" transaction
|
||||
to_transaction = Transaction.objects.create(
|
||||
@@ -328,6 +374,7 @@ class DCAEntryForm(forms.ModelForm):
|
||||
notes=notes,
|
||||
)
|
||||
to_transaction.tags.set(self.cleaned_data.get("to_tags", []))
|
||||
to_transaction.entities.set(self.cleaned_data.get("to_entities", []))
|
||||
|
||||
from_transaction.link_transfer(to_transaction)
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from apps.common.middleware.thread_local import delete_current_user, write_curre
|
||||
from apps.currencies.models import Currency
|
||||
from apps.dca.forms import DCAEntryForm
|
||||
from apps.dca.models import DCAStrategy
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.transactions.models import Transaction, TransactionEntity
|
||||
|
||||
IN, EX = Transaction.Type.INCOME, Transaction.Type.EXPENSE
|
||||
|
||||
@@ -97,3 +97,70 @@ class DCATransferLinkingTests(TestCase):
|
||||
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)
|
||||
|
||||
def test_amounts_come_from_linked_transactions(self):
|
||||
expense, income = self.tx(self.cash, EX, "500"), self.tx(self.wallet, IN, "0.001")
|
||||
form = DCAEntryForm(
|
||||
data=self.entry_data(
|
||||
amount_paid="",
|
||||
amount_received="",
|
||||
expense_transaction=str(expense.id),
|
||||
income_transaction=str(income.id),
|
||||
),
|
||||
strategy=self.strategy,
|
||||
)
|
||||
self.assertTrue(form.is_valid(), form.errors)
|
||||
entry = form.save()
|
||||
self.assertEqual(entry.amount_paid, Decimal("500"))
|
||||
self.assertEqual(entry.amount_received, Decimal("0.001"))
|
||||
|
||||
def test_amount_required_without_link(self):
|
||||
form = DCAEntryForm(
|
||||
data=self.entry_data(amount_received=""), strategy=self.strategy
|
||||
)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn("amount_received", form.errors)
|
||||
|
||||
def test_link_in_wrong_currency_is_rejected(self):
|
||||
# Expense must be in the payment currency, income in the target currency
|
||||
expense, income = self.tx(self.wallet, EX, "1"), self.tx(self.cash, IN, "500")
|
||||
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("expense_transaction", form.errors)
|
||||
self.assertIn("income_transaction", form.errors)
|
||||
|
||||
def test_created_transactions_get_entities(self):
|
||||
broker = TransactionEntity.objects.create(name="Broker")
|
||||
form = DCAEntryForm(
|
||||
data=self.entry_data(
|
||||
create_transaction="on",
|
||||
from_account=self.cash.id,
|
||||
to_account=self.wallet.id,
|
||||
from_entities=["Broker"],
|
||||
to_entities=["Broker"],
|
||||
),
|
||||
strategy=self.strategy,
|
||||
)
|
||||
self.assertTrue(form.is_valid(), form.errors)
|
||||
entry = form.save()
|
||||
self.assertEqual(list(entry.expense_transaction.entities.all()), [broker])
|
||||
self.assertEqual(list(entry.income_transaction.entities.all()), [broker])
|
||||
|
||||
def test_create_accounts_must_hold_strategy_currency(self):
|
||||
# Swapped: paying from the BTC wallet into the BRL cash account
|
||||
form = DCAEntryForm(
|
||||
data=self.entry_data(
|
||||
create_transaction="on",
|
||||
from_account=self.wallet.id,
|
||||
to_account=self.cash.id,
|
||||
),
|
||||
strategy=self.strategy,
|
||||
)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn("from_account", form.errors)
|
||||
self.assertIn("to_account", form.errors)
|
||||
@@ -1,4 +1,6 @@
|
||||
from apps.accounts.models import Account
|
||||
from apps.common.widgets.crispy.submit import NoClassSubmit
|
||||
from apps.common.widgets.tom_select import TomSelect
|
||||
from apps.import_app.models import ImportProfile
|
||||
from crispy_forms.bootstrap import FormActions
|
||||
from crispy_forms.helper import FormHelper
|
||||
@@ -42,15 +44,33 @@ class ImportProfileForm(forms.ModelForm):
|
||||
|
||||
class ImportRunFileUploadForm(forms.Form):
|
||||
file = forms.FileField(label=_("Select a file"))
|
||||
account = forms.ModelChoiceField(
|
||||
queryset=Account.objects.filter(is_archived=False),
|
||||
required=False,
|
||||
label=_("Account"),
|
||||
help_text=_(
|
||||
"Import every transaction into this account. Leave empty to let the "
|
||||
"profile pick the account."
|
||||
),
|
||||
widget=TomSelect(group_by="group"),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, profile, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.helper = FormHelper()
|
||||
self.helper.form_tag = False
|
||||
self.helper.form_method = "post"
|
||||
self.helper.layout = Layout(
|
||||
"file",
|
||||
self.helper.layout = Layout("file")
|
||||
|
||||
if profile.imports_transactions:
|
||||
self.fields["account"].queryset = Account.objects.filter(is_archived=False)
|
||||
self.helper.layout.append("account")
|
||||
else:
|
||||
# Only transactions belong to an account
|
||||
del self.fields["account"]
|
||||
|
||||
self.helper.layout.append(
|
||||
FormActions(
|
||||
NoClassSubmit("submit", _("Import"), css_class="btn btn-primary"),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 5.2.17 on 2026-11-11 17:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0022_account_show_exchanged_amounts'),
|
||||
('import_app', '0003_owner_and_sharing'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='importrun',
|
||||
name='account',
|
||||
field=models.ForeignKey(blank=True, help_text='Import every transaction into this account. Leave empty to let the profile pick the account.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='import_runs', to='accounts.account', verbose_name='Account'),
|
||||
),
|
||||
]
|
||||
@@ -38,6 +38,15 @@ class ImportProfile(SharedObject):
|
||||
version_number = self.Versions(self.version).name.split("_")[1]
|
||||
return _("Version {number}").format(number=version_number)
|
||||
|
||||
@property
|
||||
def imports_transactions(self):
|
||||
"""Whether this profile creates transactions, as opposed to accounts, tags, etc."""
|
||||
try:
|
||||
config = version_1.ImportProfileSchema(**yaml.safe_load(self.yaml_config))
|
||||
except Exception:
|
||||
return False
|
||||
return config.settings.importing == "transactions"
|
||||
|
||||
def clean(self):
|
||||
if self.version and self.version == self.Versions.VERSION_1:
|
||||
try:
|
||||
@@ -70,6 +79,18 @@ class ImportRun(OwnedObject):
|
||||
max_length=10000,
|
||||
help_text=_("File name"),
|
||||
)
|
||||
account = models.ForeignKey(
|
||||
"accounts.Account",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="import_runs",
|
||||
verbose_name=_("Account"),
|
||||
help_text=_(
|
||||
"Import every transaction into this account. Leave empty to let the "
|
||||
"profile pick the account."
|
||||
),
|
||||
)
|
||||
transactions = models.ManyToManyField(
|
||||
"transactions.Transaction", related_name="import_runs"
|
||||
)
|
||||
|
||||
@@ -75,6 +75,20 @@ class SubtractTransformationRule(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class LineTransformationRule(BaseModel):
|
||||
type: Literal["line"]
|
||||
line: int = Field(
|
||||
...,
|
||||
ge=1,
|
||||
description="Line of the file to read, counted from 1 at the top of the file, including skipped lines",
|
||||
)
|
||||
column: int | None = Field(
|
||||
default=None,
|
||||
ge=1,
|
||||
description="Column of that line to read, counted from 1. Empty to read the whole line (Excel cells are joined with commas).",
|
||||
)
|
||||
|
||||
|
||||
class CSVImportSettings(BaseModel):
|
||||
skip_errors: bool = Field(
|
||||
default=False,
|
||||
@@ -133,6 +147,7 @@ class ColumnMapping(BaseModel):
|
||||
| SplitTransformationRule
|
||||
| AddTransformationRule
|
||||
| SubtractTransformationRule
|
||||
| LineTransformationRule
|
||||
]
|
||||
] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -53,6 +53,15 @@ class ImportService:
|
||||
)
|
||||
self.mapping: Dict[str, version_1.ColumnMapping] = self.config.mapping
|
||||
|
||||
# Lines read by `line` transformations, captured as (text, cells) while the file is read
|
||||
self._wanted_lines = {
|
||||
transform.line
|
||||
for mapping in self.mapping.values()
|
||||
for transform in mapping.transformations or []
|
||||
if transform.type == "line"
|
||||
}
|
||||
self._file_lines: Dict[int, tuple[str, list[str]]] = {}
|
||||
|
||||
# Ensure temp directory exists
|
||||
os.makedirs(settings.IMPORT_TEMP_ROOT, exist_ok=True)
|
||||
|
||||
@@ -189,6 +198,15 @@ class ImportService:
|
||||
transform.pattern, transform.replacement, transformed
|
||||
)
|
||||
|
||||
elif transform.type == "line":
|
||||
text, cells = self._file_lines.get(transform.line, ("", []))
|
||||
if transform.column is None:
|
||||
transformed = text
|
||||
elif transform.column <= len(cells):
|
||||
transformed = cells[transform.column - 1]
|
||||
else:
|
||||
transformed = ""
|
||||
|
||||
elif transform.type == "date_format":
|
||||
transformed = datetime.strptime(
|
||||
transformed, transform.original_format
|
||||
@@ -289,7 +307,8 @@ class ImportService:
|
||||
# Ignore if category doesn't exist and create is False or not set
|
||||
data["category"] = None
|
||||
|
||||
if "account" in data:
|
||||
# Look the account up, unless the run already picked one
|
||||
if "account" in data and not isinstance(data["account"], Account):
|
||||
account_id = data.pop("account")
|
||||
account_mapping = next(
|
||||
(
|
||||
@@ -710,6 +729,10 @@ class ImportService:
|
||||
if mapped_data:
|
||||
# Handle different import types
|
||||
if self.settings.importing == "transactions":
|
||||
if self.import_run.account:
|
||||
# The run's account wins over the profile's account mapping
|
||||
mapped_data["account"] = self.import_run.account
|
||||
|
||||
if self.deduplication and self._check_duplicate_transaction(
|
||||
mapped_data
|
||||
):
|
||||
@@ -744,14 +767,31 @@ class ImportService:
|
||||
|
||||
logger.error(f"Fatal error processing row {row_number}", exc_info=e)
|
||||
|
||||
def _capture_csv_lines(self, csv_file):
|
||||
"""Pass the file's lines through, keeping the ones `line` transformations read."""
|
||||
for number, line in enumerate(csv_file, start=1):
|
||||
if number in self._wanted_lines:
|
||||
text = line.rstrip("\r\n")
|
||||
cells = next(csv.reader([text], delimiter=self.settings.delimiter), [])
|
||||
self._file_lines[number] = (text, cells)
|
||||
yield line
|
||||
|
||||
def _capture_excel_lines(self, read_row):
|
||||
"""Keep the sheet rows `line` transformations read. read_row(n) returns row n's values."""
|
||||
self._file_lines = {}
|
||||
for number in self._wanted_lines:
|
||||
cells = ["" if value is None else str(value) for value in read_row(number)]
|
||||
self._file_lines[number] = (",".join(cells), cells)
|
||||
|
||||
def _process_csv(self, file_path):
|
||||
# First pass: count rows
|
||||
# First pass: count rows, and capture the lines `line` transformations read
|
||||
with open(file_path, "r", encoding=self.settings.encoding) as csv_file:
|
||||
lines = self._capture_csv_lines(csv_file)
|
||||
# Skip specified number of rows
|
||||
for _ in range(self.settings.skip_lines):
|
||||
next(csv_file)
|
||||
next(lines)
|
||||
|
||||
reader = csv.DictReader(csv_file, delimiter=self.settings.delimiter)
|
||||
reader = csv.DictReader(lines, delimiter=self.settings.delimiter)
|
||||
self._update_totals("total", value=sum(1 for _ in reader))
|
||||
|
||||
with open(file_path, "r", encoding=self.settings.encoding) as csv_file:
|
||||
@@ -803,6 +843,14 @@ class ImportService:
|
||||
|
||||
sheet = workbook[sheet_name]
|
||||
self._log("info", f"Processing sheet: {sheet_name}")
|
||||
self._capture_excel_lines(
|
||||
lambda number: next(
|
||||
sheet.iter_rows(
|
||||
min_row=number, max_row=number, values_only=True
|
||||
),
|
||||
(),
|
||||
)
|
||||
)
|
||||
headers = [
|
||||
str(cell.value or "") for cell in sheet[self.settings.start_row]
|
||||
]
|
||||
@@ -863,6 +911,13 @@ class ImportService:
|
||||
continue
|
||||
sheet = workbook.sheet_by_name(sheet_name)
|
||||
self._log("info", f"Processing sheet: {sheet_name}")
|
||||
self._capture_excel_lines(
|
||||
lambda number: (
|
||||
sheet.row_values(number - 1)
|
||||
if number <= sheet.nrows
|
||||
else []
|
||||
)
|
||||
)
|
||||
headers = [
|
||||
str(sheet.cell_value(self.settings.start_row - 1, col) or "")
|
||||
for col in range(sheet.ncols)
|
||||
@@ -920,7 +975,9 @@ class ImportService:
|
||||
current_transaction = {}
|
||||
raw_lines_buffer = []
|
||||
|
||||
account = Account.objects.filter(name=account_name).first()
|
||||
account = (
|
||||
self.import_run.account or Account.objects.filter(name=account_name).first()
|
||||
)
|
||||
if not account:
|
||||
raise ValueError(f"Account '{account_name}' not found.")
|
||||
|
||||
@@ -1149,6 +1206,11 @@ class ImportService:
|
||||
self.import_run.save(update_fields=["started_at"])
|
||||
|
||||
self._log("info", "Starting import process")
|
||||
if self.import_run.account:
|
||||
self._log(
|
||||
"info",
|
||||
f"Importing transactions into account '{self.import_run.account.name}'",
|
||||
)
|
||||
|
||||
try:
|
||||
if isinstance(self.settings, version_1.CSVImportSettings):
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Tests for the `line` transformation, which reads a line outside the table, like a header cell."""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import openpyxl
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
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.import_app.models import ImportProfile, ImportRun
|
||||
from apps.import_app.services.v1 import ImportService
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
MAPPING = """
|
||||
mapping:
|
||||
date:
|
||||
target: date
|
||||
source: date
|
||||
format: "%Y-%m-%d"
|
||||
amount:
|
||||
target: amount
|
||||
source: amount
|
||||
type:
|
||||
target: type
|
||||
source: amount
|
||||
detection_method: sign
|
||||
description:
|
||||
target: description
|
||||
source: description
|
||||
account:
|
||||
target: account
|
||||
transformations:
|
||||
{account_transformations}
|
||||
"""
|
||||
|
||||
# The account name sits in the header, above the table
|
||||
CSV_CONTENT = """Bank statement
|
||||
Account:;Savings
|
||||
Account number:;Savings 1234
|
||||
|
||||
date;amount;description
|
||||
2026-01-05;-50.00;Groceries
|
||||
2026-01-06;1000.00;Salary
|
||||
"""
|
||||
|
||||
|
||||
class LineTransformationTests(TestCase):
|
||||
def setUp(self):
|
||||
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)
|
||||
|
||||
self.user = get_user_model().objects.create_user(
|
||||
email="test@example.com", password="password"
|
||||
)
|
||||
write_current_user(self.user)
|
||||
self.addCleanup(delete_current_user)
|
||||
|
||||
currency = Currency.objects.create(
|
||||
code="EUR", name="Euro", decimal_places=2, prefix="€ "
|
||||
)
|
||||
Account.objects.create(name="Checking", currency=currency, owner=self.user)
|
||||
self.savings = Account.objects.create(
|
||||
name="Savings", currency=currency, owner=self.user
|
||||
)
|
||||
|
||||
def _import(self, settings, account_transformations, file_name, write):
|
||||
yaml_config = settings + MAPPING.format(
|
||||
account_transformations=account_transformations
|
||||
)
|
||||
profile = ImportProfile.objects.create(
|
||||
name="Profile", yaml_config=yaml_config, owner=self.user
|
||||
)
|
||||
run = ImportRun.objects.create(
|
||||
profile=profile, file_name=file_name, owner=self.user
|
||||
)
|
||||
file_path = self.temp_root / file_name
|
||||
write(file_path)
|
||||
|
||||
ImportService(run).process_file(str(file_path))
|
||||
|
||||
run.refresh_from_db()
|
||||
return run
|
||||
|
||||
def _import_csv(self, account_transformations):
|
||||
settings = """
|
||||
settings:
|
||||
file_type: csv
|
||||
importing: transactions
|
||||
delimiter: ";"
|
||||
skip_lines: 4
|
||||
trigger_transaction_rules: false
|
||||
"""
|
||||
return self._import(
|
||||
settings,
|
||||
account_transformations,
|
||||
"statement.csv",
|
||||
lambda path: path.write_text(CSV_CONTENT, encoding="utf-8"),
|
||||
)
|
||||
|
||||
def assert_imported_into_savings(self, run):
|
||||
self.assertEqual(run.status, ImportRun.Status.FINISHED, run.logs)
|
||||
self.assertEqual(run.successful_rows, 2)
|
||||
self.assertEqual(Transaction.objects.filter(account=self.savings).count(), 2)
|
||||
|
||||
def test_csv_reads_a_cell_from_a_skipped_line(self):
|
||||
run = self._import_csv(
|
||||
"""
|
||||
- type: line
|
||||
line: 2
|
||||
column: 2
|
||||
"""
|
||||
)
|
||||
|
||||
self.assert_imported_into_savings(run)
|
||||
|
||||
def test_csv_whole_line_cleaned_with_regex(self):
|
||||
run = self._import_csv(
|
||||
"""
|
||||
- type: line
|
||||
line: 3
|
||||
- type: regex
|
||||
pattern: '^Account number:;(\\w+) \\d+$'
|
||||
replacement: '\\1'
|
||||
"""
|
||||
)
|
||||
|
||||
self.assert_imported_into_savings(run)
|
||||
|
||||
def test_xlsx_reads_a_cell_above_the_header(self):
|
||||
def write(path):
|
||||
workbook = openpyxl.Workbook()
|
||||
sheet = workbook.active
|
||||
sheet["A1"] = "Bank statement"
|
||||
sheet["A4"] = "Account:"
|
||||
sheet["B4"] = "Savings"
|
||||
sheet.append([])
|
||||
sheet.append(["date", "amount", "description"])
|
||||
sheet.append(["2026-01-05", "-50.00", "Groceries"])
|
||||
sheet.append(["2026-01-06", "1000.00", "Salary"])
|
||||
workbook.save(path)
|
||||
|
||||
settings = """
|
||||
settings:
|
||||
file_type: xlsx
|
||||
importing: transactions
|
||||
start_row: 6
|
||||
trigger_transaction_rules: false
|
||||
"""
|
||||
run = self._import(
|
||||
settings,
|
||||
"""
|
||||
- type: line
|
||||
line: 4
|
||||
column: 2
|
||||
""",
|
||||
"statement.xlsx",
|
||||
write,
|
||||
)
|
||||
|
||||
self.assert_imported_into_savings(run)
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Tests for importing a file into an account picked for the import run."""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
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.import_app.models import ImportProfile, ImportRun
|
||||
from apps.import_app.services.v1 import ImportService
|
||||
from apps.import_app.tests.test_view_permissions import (
|
||||
HTMX,
|
||||
ImportObjectPermissionTestCase,
|
||||
)
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
CSV_SETTINGS = """
|
||||
settings:
|
||||
file_type: csv
|
||||
importing: transactions
|
||||
trigger_transaction_rules: false
|
||||
"""
|
||||
|
||||
CSV_MAPPING = """
|
||||
mapping:
|
||||
date:
|
||||
target: date
|
||||
source: date
|
||||
format: "%Y-%m-%d"
|
||||
amount:
|
||||
target: amount
|
||||
source: amount
|
||||
type:
|
||||
target: type
|
||||
source: amount
|
||||
detection_method: sign
|
||||
description:
|
||||
target: description
|
||||
source: description
|
||||
"""
|
||||
|
||||
CSV_ACCOUNT_MAPPING = """
|
||||
account:
|
||||
target: account
|
||||
default: Checking
|
||||
"""
|
||||
|
||||
CSV_ACCOUNT_DEDUPLICATION = """
|
||||
deduplication:
|
||||
- type: compare
|
||||
fields:
|
||||
- date
|
||||
- amount
|
||||
- account
|
||||
match_type: strict
|
||||
"""
|
||||
|
||||
CSV_CONTENT = """date,amount,description
|
||||
2026-01-05,-50.00,Groceries
|
||||
2026-01-06,1000.00,Salary
|
||||
"""
|
||||
|
||||
TAGS_YAML_CONFIG = """
|
||||
settings:
|
||||
file_type: csv
|
||||
importing: tags
|
||||
mapping:
|
||||
name:
|
||||
target: tag_name
|
||||
source: name
|
||||
"""
|
||||
|
||||
|
||||
class ImportRunAccountServiceTests(TestCase):
|
||||
def setUp(self):
|
||||
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)
|
||||
|
||||
self.user = get_user_model().objects.create_user(
|
||||
email="test@example.com", password="password"
|
||||
)
|
||||
write_current_user(self.user)
|
||||
self.addCleanup(delete_current_user)
|
||||
|
||||
currency = Currency.objects.create(
|
||||
code="EUR", name="Euro", decimal_places=2, prefix="€ "
|
||||
)
|
||||
self.checking = Account.objects.create(
|
||||
name="Checking", currency=currency, owner=self.user
|
||||
)
|
||||
self.savings = Account.objects.create(
|
||||
name="Savings", currency=currency, owner=self.user
|
||||
)
|
||||
|
||||
def _import(self, yaml_config, file_name, content, account=None):
|
||||
profile = ImportProfile.objects.create(
|
||||
name="Profile", yaml_config=yaml_config, owner=self.user
|
||||
)
|
||||
run = ImportRun.objects.create(
|
||||
profile=profile, file_name=file_name, account=account, owner=self.user
|
||||
)
|
||||
file_path = self.temp_root / file_name
|
||||
file_path.write_text(content, encoding="utf-8")
|
||||
|
||||
ImportService(run).process_file(str(file_path))
|
||||
|
||||
run.refresh_from_db()
|
||||
return run
|
||||
|
||||
def test_run_account_overrides_the_profile_account(self):
|
||||
run = self._import(
|
||||
CSV_SETTINGS + CSV_MAPPING + CSV_ACCOUNT_MAPPING,
|
||||
"statement.csv",
|
||||
CSV_CONTENT,
|
||||
account=self.savings,
|
||||
)
|
||||
|
||||
self.assertEqual(run.status, ImportRun.Status.FINISHED)
|
||||
self.assertEqual(run.successful_rows, 2)
|
||||
self.assertEqual(Transaction.objects.filter(account=self.savings).count(), 2)
|
||||
self.assertFalse(Transaction.objects.filter(account=self.checking).exists())
|
||||
self.assertIn("Importing transactions into account 'Savings'", run.logs)
|
||||
|
||||
def test_profile_account_is_used_without_a_run_account(self):
|
||||
run = self._import(
|
||||
CSV_SETTINGS + CSV_MAPPING + CSV_ACCOUNT_MAPPING,
|
||||
"statement.csv",
|
||||
CSV_CONTENT,
|
||||
)
|
||||
|
||||
self.assertEqual(run.successful_rows, 2)
|
||||
self.assertEqual(Transaction.objects.filter(account=self.checking).count(), 2)
|
||||
|
||||
def test_run_account_imports_with_a_profile_without_account_mapping(self):
|
||||
run = self._import(
|
||||
CSV_SETTINGS + CSV_MAPPING,
|
||||
"statement.csv",
|
||||
CSV_CONTENT,
|
||||
account=self.checking,
|
||||
)
|
||||
|
||||
self.assertEqual(run.successful_rows, 2)
|
||||
expense = Transaction.objects.get(description="Groceries")
|
||||
self.assertEqual(expense.account, self.checking)
|
||||
self.assertEqual(expense.type, Transaction.Type.EXPENSE)
|
||||
self.assertEqual(expense.amount, Decimal("50.00"))
|
||||
|
||||
def test_deduplication_by_account_uses_the_run_account(self):
|
||||
yaml_config = CSV_SETTINGS + CSV_MAPPING + CSV_ACCOUNT_DEDUPLICATION
|
||||
|
||||
self._import(yaml_config, "first.csv", CSV_CONTENT, account=self.checking)
|
||||
# Same rows, other account: not duplicates
|
||||
into_savings = self._import(
|
||||
yaml_config, "second.csv", CSV_CONTENT, account=self.savings
|
||||
)
|
||||
# Same rows, same account: duplicates
|
||||
into_savings_again = self._import(
|
||||
yaml_config, "third.csv", CSV_CONTENT, account=self.savings
|
||||
)
|
||||
|
||||
self.assertEqual(into_savings.successful_rows, 2)
|
||||
self.assertEqual(into_savings.skipped_rows, 0)
|
||||
self.assertEqual(into_savings_again.successful_rows, 0)
|
||||
self.assertEqual(into_savings_again.skipped_rows, 2)
|
||||
self.assertEqual(Transaction.objects.filter(account=self.savings).count(), 2)
|
||||
|
||||
def test_qif_run_account_is_used_instead_of_the_file_name(self):
|
||||
yaml_config = """
|
||||
settings:
|
||||
file_type: qif
|
||||
importing: transactions
|
||||
date_format: "%d/%m/%Y"
|
||||
mapping: {}
|
||||
"""
|
||||
content = """!Type:Bank
|
||||
D05/01/2026
|
||||
T-100.00
|
||||
PSupermarket
|
||||
^
|
||||
"""
|
||||
run = self._import(
|
||||
yaml_config, "export-2026.qif", content, account=self.savings
|
||||
)
|
||||
|
||||
self.assertEqual(run.status, ImportRun.Status.FINISHED)
|
||||
transaction = Transaction.objects.get()
|
||||
self.assertEqual(transaction.account, self.savings)
|
||||
self.assertEqual(transaction.amount, Decimal("100.00"))
|
||||
|
||||
|
||||
class ImportRunAccountTestCase(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)
|
||||
|
||||
currency = Currency.objects.create(
|
||||
code="EUR", name="Euro", decimal_places=2, prefix="€ "
|
||||
)
|
||||
self.account = Account.all_objects.create(
|
||||
name="Checking", currency=currency, owner=self.owner
|
||||
)
|
||||
self.archived_account = Account.all_objects.create(
|
||||
name="Old checking", currency=currency, owner=self.owner, is_archived=True
|
||||
)
|
||||
self.strangers_account = Account.all_objects.create(
|
||||
name="Stranger's checking", currency=currency, owner=self.stranger
|
||||
)
|
||||
self.tags_profile = ImportProfile.all_objects.create(
|
||||
name="Tags profile", yaml_config=TAGS_YAML_CONFIG, owner=self.owner
|
||||
)
|
||||
|
||||
|
||||
class ImportRunAccountViewTests(ImportRunAccountTestCase):
|
||||
def _upload_form(self, profile):
|
||||
self.login(self.owner)
|
||||
response = self.client.get(
|
||||
reverse("import_run_add", kwargs={"profile_id": profile.id}), **HTMX
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return response.context["form"]
|
||||
|
||||
def test_upload_form_offers_active_accounts_for_transaction_profiles(self):
|
||||
form = self._upload_form(self.private_profile)
|
||||
|
||||
self.assertIn("account", form.fields)
|
||||
self.assertFalse(form.fields["account"].required)
|
||||
self.assertEqual(list(form.fields["account"].queryset), [self.account])
|
||||
|
||||
def test_upload_form_has_no_account_for_other_profiles(self):
|
||||
form = self._upload_form(self.tags_profile)
|
||||
|
||||
self.assertNotIn("account", form.fields)
|
||||
|
||||
@patch("apps.import_app.views.process_import.defer")
|
||||
def test_web_upload_stores_the_account_on_the_run(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"),
|
||||
"account": self.account.id,
|
||||
},
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 204)
|
||||
run = ImportRun.all_objects.get(file_name="web.csv")
|
||||
self.assertEqual(run.account, self.account)
|
||||
mock_defer.assert_called_once()
|
||||
|
||||
@patch("apps.import_app.views.process_import.defer")
|
||||
def test_web_upload_without_account_keeps_the_profile_account(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.assertIsNone(ImportRun.all_objects.get(file_name="web.csv").account)
|
||||
|
||||
@patch("apps.import_app.views.process_import.defer")
|
||||
def test_web_upload_rejects_another_users_account(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"),
|
||||
"account": self.strangers_account.id,
|
||||
},
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("account", response.context["form"].errors)
|
||||
self.assertFalse(ImportRun.all_objects.filter(file_name="web.csv").exists())
|
||||
mock_defer.assert_not_called()
|
||||
|
||||
def test_runs_list_shows_the_run_account(self):
|
||||
ImportRun.all_objects.create(
|
||||
profile=self.private_profile,
|
||||
file_name="statement.csv",
|
||||
account=self.account,
|
||||
owner=self.owner,
|
||||
)
|
||||
self.login(self.owner)
|
||||
response = self.client.get(
|
||||
reverse(
|
||||
"import_profile_runs_list",
|
||||
kwargs={"profile_id": self.private_profile.id},
|
||||
),
|
||||
**HTMX,
|
||||
)
|
||||
|
||||
self.assertContains(response, "Checking")
|
||||
|
||||
|
||||
class ImportRunAccountAPITests(ImportRunAccountTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.api = APIClient()
|
||||
self.api.force_authenticate(user=self.owner)
|
||||
|
||||
def _post(self, profile, **data):
|
||||
return self.api.post(
|
||||
"/api/import/import/",
|
||||
{
|
||||
"profile_id": profile.id,
|
||||
"file": SimpleUploadedFile("api.csv", b"date,amount\n"),
|
||||
**data,
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
@patch("apps.api.views.imports.process_import.defer")
|
||||
def test_api_upload_stores_the_account_on_the_run(self, mock_defer):
|
||||
response = self._post(self.private_profile, account_id=self.account.id)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
run = ImportRun.all_objects.get(id=response.data["import_run_id"])
|
||||
self.assertEqual(run.account, self.account)
|
||||
|
||||
response = self.api.get(f"/api/import/runs/{run.id}/")
|
||||
self.assertEqual(response.data["account"], self.account.id)
|
||||
|
||||
@patch("apps.api.views.imports.process_import.defer")
|
||||
def test_api_upload_without_account_keeps_the_profile_account(self, mock_defer):
|
||||
response = self._post(self.private_profile)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED)
|
||||
run = ImportRun.all_objects.get(id=response.data["import_run_id"])
|
||||
self.assertIsNone(run.account)
|
||||
|
||||
@patch("apps.api.views.imports.process_import.defer")
|
||||
def test_api_rejects_another_users_account(self, mock_defer):
|
||||
response = self._post(
|
||||
self.private_profile, account_id=self.strangers_account.id
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("account_id", response.data)
|
||||
mock_defer.assert_not_called()
|
||||
|
||||
@patch("apps.api.views.imports.process_import.defer")
|
||||
def test_api_rejects_an_archived_account(self, mock_defer):
|
||||
response = self._post(
|
||||
self.private_profile, account_id=self.archived_account.id
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("account_id", response.data)
|
||||
mock_defer.assert_not_called()
|
||||
|
||||
@patch("apps.api.views.imports.process_import.defer")
|
||||
def test_api_rejects_an_account_for_profiles_not_importing_transactions(
|
||||
self, mock_defer
|
||||
):
|
||||
response = self._post(self.tags_profile, account_id=self.account.id)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn("account_id", response.data)
|
||||
mock_defer.assert_not_called()
|
||||
@@ -168,7 +168,11 @@ def import_runs_list(request, profile_id):
|
||||
|
||||
# ImportRun.objects only returns the requesting user's runs, so a shared
|
||||
# profile does not expose the runs other users made with it.
|
||||
runs = ImportRun.objects.filter(profile=profile).order_by("-id")
|
||||
runs = (
|
||||
ImportRun.objects.filter(profile=profile)
|
||||
.select_related("account")
|
||||
.order_by("-id")
|
||||
)
|
||||
|
||||
return render(
|
||||
request,
|
||||
@@ -201,7 +205,7 @@ def import_run_add(request, profile_id):
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ImportRunFileUploadForm(request.POST, request.FILES)
|
||||
form = ImportRunFileUploadForm(request.POST, request.FILES, profile=profile)
|
||||
|
||||
if form.is_valid():
|
||||
uploaded_file = request.FILES["file"]
|
||||
@@ -210,7 +214,10 @@ def import_run_add(request, profile_id):
|
||||
file_path = fs.path(filename)
|
||||
|
||||
import_run = ImportRun.objects.create(
|
||||
profile=profile, file_name=filename, owner=request.user
|
||||
profile=profile,
|
||||
file_name=filename,
|
||||
account=form.cleaned_data.get("account"),
|
||||
owner=request.user,
|
||||
)
|
||||
|
||||
# Defer the procrastinate task
|
||||
@@ -229,7 +236,7 @@ def import_run_add(request, profile_id):
|
||||
},
|
||||
)
|
||||
else:
|
||||
form = ImportRunFileUploadForm()
|
||||
form = ImportRunFileUploadForm(profile=profile)
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.utils import timezone
|
||||
|
||||
from apps.currencies.models import Currency
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.currencies.utils.display import shown_exchange_currency
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@@ -94,7 +95,7 @@ def get_month_by_month_data(year=None, group_by="categories"):
|
||||
"account__currency__decimal_places",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__exchange_currency",
|
||||
exchange_currency_id=shown_exchange_currency("account__currency__"),
|
||||
)
|
||||
.annotate(
|
||||
expense_total=Coalesce(
|
||||
@@ -139,6 +140,7 @@ def get_month_by_month_data(year=None, group_by="categories"):
|
||||
item_name = metric[name_field]
|
||||
month = metric["reference_date__month"]
|
||||
currency_id = metric["account__currency"]
|
||||
exchange_currency_id = metric["exchange_currency_id"]
|
||||
|
||||
# Use a consistent key for None (uncategorized/untagged/no entity)
|
||||
item_key = item_id if item_id is not None else "__none__"
|
||||
@@ -164,7 +166,7 @@ def get_month_by_month_data(year=None, group_by="categories"):
|
||||
"decimal_places": metric["account__currency__decimal_places"],
|
||||
"prefix": metric["account__currency__prefix"],
|
||||
"suffix": metric["account__currency__suffix"],
|
||||
"exchange_currency_id": metric["account__currency__exchange_currency"],
|
||||
"exchange_currency_id": exchange_currency_id,
|
||||
}
|
||||
|
||||
currency_data = {
|
||||
@@ -181,11 +183,9 @@ def get_month_by_month_data(year=None, group_by="categories"):
|
||||
}
|
||||
|
||||
# Handle currency conversion if exchange currency is set
|
||||
if metric["account__currency__exchange_currency"]:
|
||||
if exchange_currency_id:
|
||||
from_currency = Currency.objects.get(id=currency_id)
|
||||
exchange_currency = Currency.objects.get(
|
||||
id=metric["account__currency__exchange_currency"]
|
||||
)
|
||||
exchange_currency = Currency.objects.get(id=exchange_currency_id)
|
||||
|
||||
converted_amount, prefix, suffix, decimal_places = convert(
|
||||
amount=final_total,
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.db.models.functions import Coalesce
|
||||
|
||||
from apps.currencies.models import Currency
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.currencies.utils.display import shown_exchange_currency
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
# Grouping levels the overview can be built from. Any ordering of these keys is
|
||||
@@ -25,11 +26,11 @@ CURRENCY_FIELDS = (
|
||||
"account__currency__decimal_places",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__exchange_currency",
|
||||
)
|
||||
|
||||
# Aggregated amounts, plus the totals derived from them. Every one of these is
|
||||
# converted when the account's currency has an exchange currency.
|
||||
# converted when the account's currency has an exchange currency and shows
|
||||
# exchanged amounts.
|
||||
AGGREGATED_FIELDS = (
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
@@ -78,7 +79,12 @@ def _sum_of(transaction_type, is_paid):
|
||||
def _aggregate(transactions_queryset, group_fields, name_field):
|
||||
"""Sum income/expense per currency, grouped by the given fields."""
|
||||
return (
|
||||
transactions_queryset.values(*group_fields, name_field, *CURRENCY_FIELDS)
|
||||
transactions_queryset.values(
|
||||
*group_fields,
|
||||
name_field,
|
||||
*CURRENCY_FIELDS,
|
||||
exchange_currency_id=shown_exchange_currency("account__currency__"),
|
||||
)
|
||||
.annotate(
|
||||
expense_current=_sum_of(Transaction.Type.EXPENSE, is_paid=True),
|
||||
expense_projected=_sum_of(Transaction.Type.EXPENSE, is_paid=False),
|
||||
@@ -120,11 +126,9 @@ def _build_currency_data(metric, showing):
|
||||
"total": currency_data[total],
|
||||
}
|
||||
|
||||
if metric["account__currency__exchange_currency"]:
|
||||
if metric["exchange_currency_id"]:
|
||||
from_currency = Currency.objects.get(id=metric["account__currency"])
|
||||
exchange_currency = Currency.objects.get(
|
||||
id=metric["account__currency__exchange_currency"]
|
||||
)
|
||||
exchange_currency = Currency.objects.get(id=metric["exchange_currency_id"])
|
||||
|
||||
exchanged = {}
|
||||
for field in AGGREGATED_FIELDS + TOTAL_FIELDS:
|
||||
@@ -144,6 +148,11 @@ def _build_currency_data(metric, showing):
|
||||
"name": exchange_currency.name,
|
||||
}
|
||||
if exchanged:
|
||||
exchanged["shown"] = {
|
||||
"income": exchanged.get(income),
|
||||
"expense": exchanged.get(expense),
|
||||
"total": exchanged.get(total),
|
||||
}
|
||||
currency_data["exchanged"] = exchanged
|
||||
|
||||
return currency_data
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.db.models.functions import Coalesce
|
||||
|
||||
from apps.currencies.models import Currency
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.currencies.utils.display import shown_exchange_currency
|
||||
from apps.transactions.models import Transaction
|
||||
|
||||
|
||||
@@ -83,7 +84,7 @@ def get_year_by_year_data(group_by="categories"):
|
||||
"account__currency__decimal_places",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__currency__exchange_currency",
|
||||
exchange_currency_id=shown_exchange_currency("account__currency__"),
|
||||
)
|
||||
.annotate(
|
||||
expense_total=Coalesce(
|
||||
@@ -126,6 +127,7 @@ def get_year_by_year_data(group_by="categories"):
|
||||
item_name = metric[name_field]
|
||||
year = metric["reference_date__year"]
|
||||
currency_id = metric["account__currency"]
|
||||
exchange_currency_id = metric["exchange_currency_id"]
|
||||
|
||||
# Use a consistent key for None (uncategorized/untagged/no entity)
|
||||
item_key = item_id if item_id is not None else "__none__"
|
||||
@@ -151,7 +153,7 @@ def get_year_by_year_data(group_by="categories"):
|
||||
"decimal_places": metric["account__currency__decimal_places"],
|
||||
"prefix": metric["account__currency__prefix"],
|
||||
"suffix": metric["account__currency__suffix"],
|
||||
"exchange_currency_id": metric["account__currency__exchange_currency"],
|
||||
"exchange_currency_id": exchange_currency_id,
|
||||
}
|
||||
|
||||
currency_data = {
|
||||
@@ -168,11 +170,9 @@ def get_year_by_year_data(group_by="categories"):
|
||||
}
|
||||
|
||||
# Handle currency conversion if exchange currency is set
|
||||
if metric["account__currency__exchange_currency"]:
|
||||
if exchange_currency_id:
|
||||
from_currency = Currency.objects.get(id=currency_id)
|
||||
exchange_currency = Currency.objects.get(
|
||||
id=metric["account__currency__exchange_currency"]
|
||||
)
|
||||
exchange_currency = Currency.objects.get(id=exchange_currency_id)
|
||||
|
||||
converted_amount, prefix, suffix, decimal_places = convert(
|
||||
amount=final_total,
|
||||
|
||||
@@ -239,13 +239,14 @@ def get_holding_performance(account, as_of=None, with_history=False):
|
||||
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."""
|
||||
def xirr(flows, period_days=365):
|
||||
"""Rate r per ``period_days`` (a year by default) where the dated flows'
|
||||
present value is zero, as a float (0.138 = 13.8% a period). 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]
|
||||
points = [((flow[0] - start).days / period_days, 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
|
||||
|
||||
@@ -272,17 +273,16 @@ def money_weighted_return(flows, market_value, as_of):
|
||||
``{"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."""
|
||||
exaggerates, so the rate is solved over the period held instead: annualizing
|
||||
a few days blows past what xirr can solve (+1.3% in one day is ~10,600% a year)."""
|
||||
if not flows or market_value is None:
|
||||
return None
|
||||
rate = xirr(flows + [(as_of, market_value)])
|
||||
days = (as_of - min(flow[0] for flow in flows)).days
|
||||
annualized = days >= 365
|
||||
rate = xirr(flows + [(as_of, market_value)], 365 if annualized else max(days, 1))
|
||||
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}
|
||||
return {"percentage": Decimal(str(round(rate * 100, 4))), "annualized": annualized}
|
||||
|
||||
|
||||
def _month_ends(transactions, as_of):
|
||||
|
||||
@@ -306,6 +306,12 @@ class HoldingPerformanceTests(TestCase):
|
||||
self.assertFalse(mw["annualized"])
|
||||
self.assertAlmostEqual(float(mw["percentage"]), 5, places=3)
|
||||
|
||||
# A day old with a big move: annualized it'd be far past what xirr can solve
|
||||
day = datetime.date(2025, 1, 2)
|
||||
mw = money_weighted_return([(start, D(-1000), "in")], D(1050), day)
|
||||
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")]
|
||||
@@ -441,6 +447,34 @@ class InvestmentViewTests(TestCase):
|
||||
response = self.client.get(reverse("investments_charts"), **self.HTMX)
|
||||
self.assertContains(response, "investment-charts-0")
|
||||
|
||||
def test_detail_follows_show_exchanged_amounts(self):
|
||||
brl = self.account.currency
|
||||
usd = Currency.objects.create(
|
||||
code="USD", name="Dollar", decimal_places=2, prefix="US$ ",
|
||||
exchange_currency=brl,
|
||||
)
|
||||
ExchangeRate.objects.create(
|
||||
from_currency=usd, to_currency=brl, rate=D("5"),
|
||||
date=timezone.make_aware(datetime.datetime(2026, 1, 1)),
|
||||
)
|
||||
# Tracked by value in USD: movements and the "In Real" card come from USD's chain
|
||||
account = Account.all_objects.create(
|
||||
name="Broker", currency=usd, is_investment=True, owner=self.owner
|
||||
)
|
||||
tx(account, IN, "10", datetime.date(2026, 1, 2))
|
||||
self.client.force_login(self.owner)
|
||||
url = reverse("investment_detail", args=[account.id])
|
||||
|
||||
response = self.client.get(url, **self.HTMX)
|
||||
self.assertContains(response, 'data-amount="50"', count=3) # 10 USD at 5: ≈ line, card, movement
|
||||
self.assertContains(response, "In Real")
|
||||
|
||||
usd.show_exchanged_amounts = False
|
||||
usd.save()
|
||||
response = self.client.get(url, **self.HTMX)
|
||||
self.assertNotContains(response, 'data-amount="50"')
|
||||
self.assertNotContains(response, "In Real")
|
||||
|
||||
def test_stranger_cannot_see_detail(self):
|
||||
self.client.force_login(self.stranger)
|
||||
response = self.client.get(
|
||||
|
||||
@@ -5,6 +5,11 @@ from . import views
|
||||
urlpatterns = [
|
||||
path("investments/", views.investments_index, name="investments_index"),
|
||||
path("investments/list/", views.investments_list, name="investments_list"),
|
||||
path(
|
||||
"investments/group-by/",
|
||||
views.investments_group_by,
|
||||
name="investments_group_by",
|
||||
),
|
||||
path("investments/charts/", views.investments_charts, name="investments_charts"),
|
||||
path(
|
||||
"investments/rebalance/",
|
||||
|
||||
@@ -25,7 +25,7 @@ from apps.transactions.models import Transaction
|
||||
|
||||
def _holdings():
|
||||
return Account.objects.filter(is_investment=True, is_archived=False).select_related(
|
||||
"group", "currency", "exchange_currency"
|
||||
"group", "investment_class", "currency", "exchange_currency"
|
||||
)
|
||||
|
||||
|
||||
@@ -34,31 +34,47 @@ def investments_index(request):
|
||||
return render(request, "investments/pages/index.html")
|
||||
|
||||
|
||||
GROUP_BY = ("group", "investment_class")
|
||||
|
||||
|
||||
def _grouped(performances, attr):
|
||||
groups = {}
|
||||
for perf in performances:
|
||||
group = getattr(perf.account, attr)
|
||||
groups.setdefault(group.id if group else None, (group, []))[1].append(perf)
|
||||
return [
|
||||
{"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)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
# Both groupings are rendered so the toggle flips client-side without recomputing
|
||||
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)
|
||||
)
|
||||
],
|
||||
"groupings": [(attr, _grouped(performances, attr)) for attr in GROUP_BY],
|
||||
"totals": summarize(performances, convert=True),
|
||||
"group_by": request.session.get("investments_group_by", "group"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@only_htmx
|
||||
@login_required
|
||||
def investments_group_by(request):
|
||||
if request.GET.get("group_by") in GROUP_BY:
|
||||
request.session["investments_group_by"] = request.GET["group_by"]
|
||||
return HttpResponse(status=204)
|
||||
|
||||
|
||||
def _money(currency):
|
||||
"""What chartMoney (charts.js) needs to format amounts like the rest of the app."""
|
||||
return {
|
||||
@@ -193,7 +209,12 @@ def investment_detail(request, account_id):
|
||||
perf = get_holding_performance(account, with_history=True)
|
||||
transactions = (
|
||||
Transaction.objects.filter(account=account)
|
||||
.select_related("linked_transaction__account__currency", "category")
|
||||
.select_related(
|
||||
"linked_transaction__account__currency",
|
||||
"category",
|
||||
"account__currency__exchange_currency",
|
||||
"account__exchange_currency",
|
||||
)
|
||||
.order_by("-date", "-id")[:50]
|
||||
)
|
||||
income = []
|
||||
@@ -230,7 +251,8 @@ def investment_detail(request, account_id):
|
||||
{
|
||||
"account": account,
|
||||
"perf": perf,
|
||||
"home": in_home_currency(perf),
|
||||
# The valuation currency provides the next hop, so its switch decides
|
||||
"home": in_home_currency(perf) if perf.currency.show_exchanged_amounts else None,
|
||||
"transactions": transactions,
|
||||
"income": income,
|
||||
"income_entities": list(account.income_entities.all()),
|
||||
|
||||
@@ -80,3 +80,44 @@ class NetWorthCurrencyChartTests(TestCase):
|
||||
'<span class="text-start shrink">Consolidated</span>',
|
||||
html=False,
|
||||
)
|
||||
|
||||
def test_consolidated_total_lists_each_currency_share(self):
|
||||
user = get_user_model().objects.create_user(
|
||||
email="shares@example.com", password="password"
|
||||
)
|
||||
usd = Currency.objects.create(code="USD", name="US Dollar", prefix="$ ")
|
||||
eur = Currency.objects.create(
|
||||
code="EUR", name="Euro", prefix="€ ", exchange_currency=usd
|
||||
)
|
||||
ExchangeRate.objects.create(
|
||||
from_currency=eur,
|
||||
to_currency=usd,
|
||||
rate=Decimal("2"),
|
||||
date=timezone.now(),
|
||||
)
|
||||
for currency, amount in ((usd, "300"), (eur, "50")):
|
||||
Transaction.userless_all_objects.create(
|
||||
account=Account.all_objects.create(
|
||||
name=f"{currency.code} account", currency=currency, owner=user
|
||||
),
|
||||
owner=user,
|
||||
type=Transaction.Type.INCOME,
|
||||
amount=Decimal(amount),
|
||||
date=date(2026, 1, 15),
|
||||
reference_date=date(2026, 1, 1),
|
||||
is_paid=True,
|
||||
)
|
||||
|
||||
self.client.force_login(user)
|
||||
response = self.client.get(reverse("net_worth"))
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
components = response.context["currency_net_worth"][usd.id]["consolidated"][
|
||||
"components"
|
||||
]
|
||||
self.assertEqual(
|
||||
[(part["currency"]["name"], part["share"]) for part in components],
|
||||
[("US Dollar", Decimal("75")), ("Euro", Decimal("25"))],
|
||||
)
|
||||
self.assertContains(response, "75.0%")
|
||||
self.assertContains(response, "25.0%")
|
||||
@@ -1,3 +1,4 @@
|
||||
import calendar
|
||||
import decimal
|
||||
import logging
|
||||
import uuid
|
||||
@@ -572,34 +573,31 @@ class Transaction(OwnedObject):
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
def exchanged_amount(self):
|
||||
if self.account.exchange_currency:
|
||||
converted_amount, prefix, suffix, decimal_places = convert(
|
||||
self.amount,
|
||||
to_currency=self.account.exchange_currency,
|
||||
from_currency=self.account.currency,
|
||||
date=self.date,
|
||||
)
|
||||
if converted_amount:
|
||||
return {
|
||||
"amount": converted_amount,
|
||||
"prefix": prefix,
|
||||
"suffix": suffix,
|
||||
"decimal_places": decimal_places,
|
||||
}
|
||||
elif self.account.currency.exchange_currency:
|
||||
converted_amount, prefix, suffix, decimal_places = convert(
|
||||
self.amount,
|
||||
to_currency=self.account.currency.exchange_currency,
|
||||
from_currency=self.account.currency,
|
||||
date=self.date,
|
||||
)
|
||||
if converted_amount:
|
||||
return {
|
||||
"amount": converted_amount,
|
||||
"prefix": prefix,
|
||||
"suffix": suffix,
|
||||
"decimal_places": decimal_places,
|
||||
}
|
||||
return self._exchanged_to(
|
||||
self.account.exchange_currency or self.account.currency.exchange_currency
|
||||
)
|
||||
|
||||
def displayed_exchanged_amount(self):
|
||||
"""The exchanged amount to display, or None when it's hidden."""
|
||||
return self._exchanged_to(self.account.displayed_exchange_currency)
|
||||
|
||||
def _exchanged_to(self, exchange_currency):
|
||||
if not exchange_currency:
|
||||
return None
|
||||
|
||||
converted_amount, prefix, suffix, decimal_places = convert(
|
||||
self.amount,
|
||||
to_currency=exchange_currency,
|
||||
from_currency=self.account.currency,
|
||||
date=self.date,
|
||||
)
|
||||
if converted_amount:
|
||||
return {
|
||||
"amount": converted_amount,
|
||||
"prefix": prefix,
|
||||
"suffix": suffix,
|
||||
"decimal_places": decimal_places,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@@ -1183,7 +1181,21 @@ class RecurringTransaction(TransferTemplate):
|
||||
return relativedelta(years=self.recurrence_interval)
|
||||
|
||||
def get_next_date(self, current_date):
|
||||
return current_date + self.get_recurrence_delta()
|
||||
next_date = current_date + self.get_recurrence_delta()
|
||||
|
||||
# A start on the 29th-31st is clamped in shorter months; return to that day
|
||||
# afterwards instead of keeping the clamped one.
|
||||
if (
|
||||
self.recurrence_type
|
||||
in (self.RecurrenceType.MONTH, self.RecurrenceType.YEAR)
|
||||
and self.start_date.day > current_date.day
|
||||
and current_date.day
|
||||
== calendar.monthrange(current_date.year, current_date.month)[1]
|
||||
):
|
||||
last_day = calendar.monthrange(next_date.year, next_date.month)[1]
|
||||
next_date = next_date.replace(day=min(self.start_date.day, last_day))
|
||||
|
||||
return next_date
|
||||
|
||||
@classmethod
|
||||
def generate_upcoming_transactions(cls):
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
from datetime import date, datetime, timezone as dt_timezone
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
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, ExchangeRate
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.transactions.utils.calculations import (
|
||||
calculate_consolidated_shares,
|
||||
calculate_currency_totals,
|
||||
)
|
||||
|
||||
|
||||
def component(name, total_final):
|
||||
return {"currency": {"name": name}, "total_final": Decimal(total_final)}
|
||||
|
||||
|
||||
class CalculateConsolidatedSharesTests(TestCase):
|
||||
def test_shares_add_up_to_the_total(self):
|
||||
shares = calculate_consolidated_shares(
|
||||
[component("USD", "150"), component("EUR", "50")], Decimal("200")
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[(share["currency"]["name"], share["share"]) for share in shares],
|
||||
[("USD", Decimal("75")), ("EUR", Decimal("25"))],
|
||||
)
|
||||
|
||||
def test_sorted_from_largest_to_smallest(self):
|
||||
shares = calculate_consolidated_shares(
|
||||
[component("USD", "10"), component("BTC", "-5"), component("EUR", "20")],
|
||||
Decimal("25"),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[share["currency"]["name"] for share in shares], ["EUR", "USD", "BTC"]
|
||||
)
|
||||
|
||||
def test_component_that_takes_from_the_total_has_a_negative_share(self):
|
||||
shares = calculate_consolidated_shares(
|
||||
[component("USD", "300"), component("EUR", "-100")], Decimal("200")
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[share["share"] for share in shares], [Decimal("150"), Decimal("-50")]
|
||||
)
|
||||
|
||||
def test_sign_follows_the_component_when_the_total_is_negative(self):
|
||||
shares = calculate_consolidated_shares(
|
||||
[component("USD", "100"), component("EUR", "-300")], Decimal("-200")
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[share["share"] for share in shares], [Decimal("50"), Decimal("-150")]
|
||||
)
|
||||
|
||||
def test_components_that_add_nothing_are_left_out(self):
|
||||
shares = calculate_consolidated_shares(
|
||||
[component("USD", "0"), component("EUR", "50")], Decimal("50")
|
||||
)
|
||||
|
||||
self.assertEqual([share["currency"]["name"] for share in shares], ["EUR"])
|
||||
self.assertEqual(shares[0]["share"], Decimal("100"))
|
||||
|
||||
def test_no_share_without_a_total(self):
|
||||
shares = calculate_consolidated_shares(
|
||||
[component("USD", "100"), component("EUR", "-100")], Decimal("0")
|
||||
)
|
||||
|
||||
self.assertEqual([share["share"] for share in shares], [None, None])
|
||||
|
||||
|
||||
class CurrencyTotalsConsolidatedComponentsTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(
|
||||
email="shares@test.com", password="testpass123"
|
||||
)
|
||||
write_current_user(self.user)
|
||||
self.addCleanup(delete_current_user)
|
||||
|
||||
self.usd = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
self.eur = Currency.objects.create(
|
||||
code="EUR", name="Euro", decimal_places=2, exchange_currency=self.usd
|
||||
)
|
||||
self.gbp = Currency.objects.create(
|
||||
code="GBP", name="Pound", decimal_places=2, exchange_currency=self.usd
|
||||
)
|
||||
for currency in (self.eur, self.gbp):
|
||||
ExchangeRate.objects.create(
|
||||
from_currency=currency,
|
||||
to_currency=self.usd,
|
||||
rate=Decimal("2"),
|
||||
date=datetime(2026, 1, 1, tzinfo=dt_timezone.utc),
|
||||
)
|
||||
|
||||
self._add(self.usd, Transaction.Type.INCOME, "150")
|
||||
self._add(self.eur, Transaction.Type.INCOME, "50")
|
||||
self._add(self.gbp, Transaction.Type.EXPENSE, "25")
|
||||
|
||||
def _add(self, currency, transaction_type, amount):
|
||||
Transaction.objects.create(
|
||||
account=Account.objects.create(
|
||||
name=f"{currency.code} account", currency=currency
|
||||
),
|
||||
type=transaction_type,
|
||||
is_paid=True,
|
||||
date=date(2026, 3, 10),
|
||||
reference_date=date(2026, 3, 1),
|
||||
amount=Decimal(amount),
|
||||
)
|
||||
|
||||
def _components(self, totals, currency):
|
||||
return [
|
||||
(part["currency"]["name"], part["total_final"], part["share"])
|
||||
for part in totals[currency.id]["consolidated"]["components"]
|
||||
]
|
||||
|
||||
def test_components_are_in_the_consolidated_currency(self):
|
||||
totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True)
|
||||
|
||||
self.assertEqual(
|
||||
totals[self.usd.id]["consolidated"]["total_final"], Decimal("200")
|
||||
)
|
||||
self.assertEqual(
|
||||
self._components(totals, self.usd),
|
||||
[
|
||||
("US Dollar", Decimal("150"), Decimal("75")),
|
||||
("Euro", Decimal("100"), Decimal("50")),
|
||||
("Pound", Decimal("-50"), Decimal("-25")),
|
||||
],
|
||||
)
|
||||
|
||||
def test_currency_without_sources_is_its_only_component(self):
|
||||
totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True)
|
||||
|
||||
self.assertEqual(
|
||||
self._components(totals, self.eur),
|
||||
[("Euro", Decimal("50"), Decimal("100"))],
|
||||
)
|
||||
|
||||
def test_hidden_exchanged_amounts_still_count(self):
|
||||
self.eur.show_exchanged_amounts = False
|
||||
self.eur.save()
|
||||
|
||||
totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True)
|
||||
|
||||
self.assertNotIn("exchanged", totals[self.eur.id])
|
||||
self.assertIn(
|
||||
("Euro", Decimal("100"), Decimal("50")),
|
||||
self._components(totals, self.usd),
|
||||
)
|
||||
|
||||
def test_target_found_by_deep_search(self):
|
||||
Transaction.objects.filter(account__currency=self.usd).delete()
|
||||
|
||||
totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True)
|
||||
|
||||
self.assertEqual(
|
||||
self._components(totals, self.usd),
|
||||
[
|
||||
("Euro", Decimal("100"), Decimal("200")),
|
||||
("Pound", Decimal("-50"), Decimal("-100")),
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,189 @@
|
||||
from datetime import date, datetime, timezone as dt_timezone
|
||||
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 apps.accounts.models import Account, AccountGroup
|
||||
from apps.common.middleware.thread_local import delete_current_user, write_current_user
|
||||
from apps.currencies.models import Currency, ExchangeRate
|
||||
from apps.insights.utils.month_by_month import get_month_by_month_data
|
||||
from apps.insights.utils.overview import get_grouped_totals
|
||||
from apps.insights.utils.year_by_year import get_year_by_year_data
|
||||
from apps.transactions.models import Transaction, TransactionCategory
|
||||
from apps.transactions.utils.calculations import (
|
||||
calculate_account_totals,
|
||||
calculate_currency_totals,
|
||||
)
|
||||
|
||||
|
||||
@override_settings(
|
||||
STORAGES={
|
||||
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
|
||||
"staticfiles": {
|
||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||
},
|
||||
},
|
||||
WHITENOISE_AUTOREFRESH=True,
|
||||
)
|
||||
class ExchangedAmountsTestBase(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user(
|
||||
email="exchanged@test.com", password="testpass123"
|
||||
)
|
||||
self.client.login(username="exchanged@test.com", password="testpass123")
|
||||
write_current_user(self.user)
|
||||
self.addCleanup(delete_current_user)
|
||||
|
||||
self.usd = Currency.objects.create(
|
||||
code="USD", name="US Dollar", decimal_places=2, prefix="$ "
|
||||
)
|
||||
self.eur = Currency.objects.create(
|
||||
code="EUR",
|
||||
name="Euro",
|
||||
decimal_places=2,
|
||||
prefix="€ ",
|
||||
exchange_currency=self.usd,
|
||||
)
|
||||
ExchangeRate.objects.create(
|
||||
from_currency=self.eur,
|
||||
to_currency=self.usd,
|
||||
rate=Decimal("2"),
|
||||
date=datetime(2026, 1, 1, tzinfo=dt_timezone.utc),
|
||||
)
|
||||
self.account = Account.objects.create(
|
||||
name="Euro Account",
|
||||
group=AccountGroup.objects.create(name="Group"),
|
||||
currency=self.eur,
|
||||
)
|
||||
self.transaction = Transaction.objects.create(
|
||||
account=self.account,
|
||||
type=Transaction.Type.INCOME,
|
||||
is_paid=True,
|
||||
date=date(2026, 3, 10),
|
||||
reference_date=date(2026, 3, 1),
|
||||
amount=Decimal("100"),
|
||||
description="Salary",
|
||||
category=TransactionCategory.objects.create(name="Work"),
|
||||
)
|
||||
|
||||
def _hide_for(self, obj):
|
||||
obj.show_exchanged_amounts = False
|
||||
obj.save()
|
||||
|
||||
|
||||
class TransactionExchangedAmountTests(ExchangedAmountsTestBase):
|
||||
def test_shown_by_default(self):
|
||||
exchanged = self.transaction.displayed_exchanged_amount()
|
||||
|
||||
self.assertEqual(exchanged["amount"], Decimal("200"))
|
||||
self.assertEqual(exchanged["prefix"], "$ ")
|
||||
|
||||
def test_currency_can_hide_it(self):
|
||||
self._hide_for(self.eur)
|
||||
self.transaction.refresh_from_db()
|
||||
|
||||
self.assertIsNone(self.transaction.displayed_exchanged_amount())
|
||||
# Only hidden from display, the API keeps returning it
|
||||
self.assertEqual(self.transaction.exchanged_amount()["amount"], Decimal("200"))
|
||||
|
||||
def test_account_exchange_currency_follows_the_account_setting(self):
|
||||
self.account.exchange_currency = self.usd
|
||||
self.account.save()
|
||||
self._hide_for(self.eur)
|
||||
self.transaction.refresh_from_db()
|
||||
|
||||
self.assertEqual(
|
||||
self.transaction.displayed_exchanged_amount()["amount"], Decimal("200")
|
||||
)
|
||||
|
||||
self._hide_for(self.account)
|
||||
self.transaction.refresh_from_db()
|
||||
|
||||
self.assertIsNone(self.transaction.displayed_exchanged_amount())
|
||||
|
||||
def test_monthly_list_hides_it(self):
|
||||
url = reverse("monthly_transactions_list", kwargs={"month": 3, "year": 2026})
|
||||
|
||||
response = self.client.get(url, HTTP_HX_REQUEST="true")
|
||||
self.assertContains(response, "exchanged-amount")
|
||||
|
||||
self._hide_for(self.eur)
|
||||
response = self.client.get(url, HTTP_HX_REQUEST="true")
|
||||
self.assertContains(response, f'data-transaction-id="{self.transaction.id}"')
|
||||
self.assertNotContains(response, "exchanged-amount")
|
||||
|
||||
|
||||
class TotalsExchangedAmountTests(ExchangedAmountsTestBase):
|
||||
def test_currency_totals_hide_it_but_keep_consolidating(self):
|
||||
self._hide_for(self.eur)
|
||||
|
||||
totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True)
|
||||
|
||||
self.assertNotIn("exchanged", totals[self.eur.id])
|
||||
self.assertEqual(
|
||||
totals[self.usd.id]["consolidated"]["total_final"], Decimal("200")
|
||||
)
|
||||
|
||||
def test_currency_totals_hide_it_for_currencies_found_by_deep_search(self):
|
||||
brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2)
|
||||
self.usd.exchange_currency = brl
|
||||
self.usd.save()
|
||||
|
||||
totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True)
|
||||
self.assertIn("exchanged", totals[self.usd.id])
|
||||
|
||||
self._hide_for(self.usd)
|
||||
totals = calculate_currency_totals(Transaction.objects.all(), deep_search=True)
|
||||
self.assertNotIn("exchanged", totals[self.usd.id])
|
||||
|
||||
def test_account_totals_follow_the_account_setting(self):
|
||||
self.account.exchange_currency = self.usd
|
||||
self.account.save()
|
||||
|
||||
totals = calculate_account_totals(Transaction.objects.all())
|
||||
self.assertEqual(
|
||||
totals[self.account.id]["exchanged"]["total_final"], Decimal("200")
|
||||
)
|
||||
|
||||
self._hide_for(self.account)
|
||||
totals = calculate_account_totals(Transaction.objects.all())
|
||||
self.assertNotIn("exchanged", totals[self.account.id])
|
||||
|
||||
def test_insights_hide_it(self):
|
||||
def exchanged_totals():
|
||||
month_data = get_month_by_month_data(year=2026)
|
||||
year_data = get_year_by_year_data()
|
||||
return [
|
||||
month_data["grand_total"]["currencies"][self.eur.id],
|
||||
month_data["month_totals"][3]["currencies"][self.eur.id],
|
||||
year_data["grand_total"]["currencies"][self.eur.id],
|
||||
year_data["year_totals"][2026]["currencies"][self.eur.id],
|
||||
]
|
||||
|
||||
def overview_total():
|
||||
overview = get_grouped_totals(Transaction.objects.all(), ["categories"])
|
||||
return next(iter(overview.values()))["currencies"][self.eur.id]
|
||||
|
||||
for total in exchanged_totals():
|
||||
self.assertEqual(total["exchanged"]["final_total"], Decimal("200"))
|
||||
self.assertEqual(overview_total()["exchanged"]["shown"]["total"], Decimal("200"))
|
||||
|
||||
self._hide_for(self.eur)
|
||||
for total in exchanged_totals():
|
||||
self.assertNotIn("exchanged", total)
|
||||
self.assertNotIn("exchanged", overview_total())
|
||||
|
||||
def test_net_worth_keeps_the_consolidated_total(self):
|
||||
self._hide_for(self.eur)
|
||||
|
||||
response = self.client.get(reverse("net_worth"), {"view_type": "current"})
|
||||
|
||||
currency_net_worth = response.context["currency_net_worth"]
|
||||
self.assertNotIn("exchanged", currency_net_worth[self.eur.id])
|
||||
self.assertEqual(
|
||||
currency_net_worth[self.usd.id]["consolidated"]["total_final"],
|
||||
Decimal("200"),
|
||||
)
|
||||
self.assertContains(response, "Consolidated")
|
||||
@@ -242,6 +242,30 @@ class RecurringTransactionTests(TestCase):
|
||||
self.assertEqual(recurring.recurrence_interval, 1)
|
||||
self.assertEqual(recurring.account.currency.code, "USD")
|
||||
|
||||
def test_month_end_recurrence_returns_to_its_day(self):
|
||||
"""A recurrence on the 31st goes back to it after a short month"""
|
||||
recurring = RecurringTransaction.objects.create(
|
||||
account=self.account,
|
||||
type=Transaction.Type.EXPENSE,
|
||||
amount=Decimal("100.00"),
|
||||
description="Rent",
|
||||
start_date=datetime.date(2026, 1, 31),
|
||||
recurrence_type=RecurringTransaction.RecurrenceType.MONTH,
|
||||
recurrence_interval=1,
|
||||
)
|
||||
|
||||
feb = recurring.get_next_date(datetime.date(2026, 1, 31))
|
||||
march = recurring.get_next_date(feb)
|
||||
april = recurring.get_next_date(march)
|
||||
|
||||
self.assertEqual(feb, datetime.date(2026, 2, 28))
|
||||
self.assertEqual(march, datetime.date(2026, 3, 31))
|
||||
self.assertEqual(april, datetime.date(2026, 4, 30))
|
||||
self.assertEqual(
|
||||
recurring.get_next_date(datetime.date(2026, 2, 1)),
|
||||
datetime.date(2026, 3, 1),
|
||||
)
|
||||
|
||||
def test_generate_upcoming_transactions_keeps_tags_and_entities(self):
|
||||
"""Generation must copy tags/entities even with no current user"""
|
||||
tag = TransactionTag.objects.create(name="Essential")
|
||||
|
||||
@@ -32,7 +32,7 @@ class TransactionSearchTests(TestCase):
|
||||
|
||||
def make(self, days, description="", amount="10", **kwargs):
|
||||
return Transaction.objects.create(
|
||||
account=self.account,
|
||||
account=kwargs.pop("account", self.account),
|
||||
type=Transaction.Type.EXPENSE,
|
||||
date=self.today + timedelta(days=days),
|
||||
reference_date=(self.today + timedelta(days=days)).replace(day=1),
|
||||
@@ -72,3 +72,13 @@ class TransactionSearchTests(TestCase):
|
||||
|
||||
self.assertEqual(self.search("wallet"), ["Groceries", "Other"])
|
||||
self.assertEqual(self.search("groceries nomatch"), [])
|
||||
|
||||
def test_filters_by_currency(self):
|
||||
usd = Currency.objects.create(code="USD", name="Dollar", decimal_places=2)
|
||||
other = Account.objects.create(name="Bank", currency=usd)
|
||||
self.make(0, "brl")
|
||||
self.make(0, "usd", account=other)
|
||||
response = self.client.get(
|
||||
reverse("transactions_search"), {"q": "", "currency": usd.id}
|
||||
)
|
||||
self.assertEqual([r["description"] for r in response.json()], ["usd"])
|
||||
@@ -6,6 +6,7 @@ from django.db.models.functions import Coalesce
|
||||
|
||||
from apps.transactions.models import Transaction
|
||||
from apps.currencies.utils.convert import convert
|
||||
from apps.currencies.utils.display import shown_exchange_currency
|
||||
from apps.currencies.models import Currency
|
||||
|
||||
|
||||
@@ -29,6 +30,7 @@ def calculate_currency_totals(
|
||||
"account__currency__exchange_currency__decimal_places",
|
||||
"account__currency__exchange_currency__prefix",
|
||||
"account__currency__exchange_currency__suffix",
|
||||
"account__currency__show_exchanged_amounts",
|
||||
)
|
||||
.annotate(
|
||||
expense_current=Coalesce(
|
||||
@@ -177,7 +179,10 @@ def calculate_currency_totals(
|
||||
converted_val if converted_val is not None else Decimal("0")
|
||||
)
|
||||
|
||||
currency_data["exchanged"] = exchanged_details
|
||||
# Consolidated totals below need the exchanged values either way;
|
||||
# the currency's setting only hides them from display
|
||||
if total["account__currency__show_exchanged_amounts"]:
|
||||
currency_data["exchanged"] = exchanged_details
|
||||
|
||||
if exchange_currency_obj_for_this_total.id not in currencies_using_exchange:
|
||||
currencies_using_exchange[exchange_currency_obj_for_this_total.id] = []
|
||||
@@ -255,9 +260,10 @@ def calculate_currency_totals(
|
||||
converted_val if converted_val is not None else Decimal("0")
|
||||
)
|
||||
|
||||
currency_data_for_db_currency["exchanged"] = (
|
||||
exchanged_details_for_db_currency
|
||||
)
|
||||
if db_currency.show_exchanged_amounts:
|
||||
currency_data_for_db_currency["exchanged"] = (
|
||||
exchanged_details_for_db_currency
|
||||
)
|
||||
|
||||
# Ensure its own exchange_currency is registered in currencies_using_exchange
|
||||
# and add it to the queue if it hasn't been processed yet for deep add.
|
||||
@@ -297,12 +303,28 @@ def calculate_currency_totals(
|
||||
"total_projected": data_consolidated_currency["total_projected"],
|
||||
"total_final": data_consolidated_currency["total_final"],
|
||||
}
|
||||
# What each currency adds to the consolidated total, in the
|
||||
# consolidated currency, starting with the currency's own total
|
||||
components = [
|
||||
{
|
||||
"currency": data_consolidated_currency["currency"],
|
||||
"total_final": data_consolidated_currency["total_final"],
|
||||
}
|
||||
]
|
||||
|
||||
if currency_id_consolidated in currencies_using_exchange:
|
||||
for original_currency_info in currencies_using_exchange[
|
||||
currency_id_consolidated
|
||||
]:
|
||||
exchanged_values_from_original = original_currency_info["exchanged"]
|
||||
components.append(
|
||||
{
|
||||
"currency": result[original_currency_info["currency_id"]][
|
||||
"currency"
|
||||
],
|
||||
"total_final": exchanged_values_from_original["total_final"],
|
||||
}
|
||||
)
|
||||
for field in [
|
||||
"expense_current",
|
||||
"expense_projected",
|
||||
@@ -317,6 +339,9 @@ def calculate_currency_totals(
|
||||
field
|
||||
]
|
||||
|
||||
consolidated_data["components"] = calculate_consolidated_shares(
|
||||
components, consolidated_data["total_final"]
|
||||
)
|
||||
result[currency_id_consolidated]["consolidated"] = consolidated_data
|
||||
|
||||
# Sort currencies by their final_total or consolidated final_total, descending
|
||||
@@ -335,6 +360,36 @@ def calculate_currency_totals(
|
||||
return result
|
||||
|
||||
|
||||
def calculate_consolidated_shares(components, consolidated_total):
|
||||
"""
|
||||
Give each component of a consolidated total its share of that total, as a
|
||||
percentage. Components that add nothing are left out and the rest are sorted
|
||||
from the largest to the smallest.
|
||||
|
||||
Shares are taken over the absolute total, so a component that adds to it
|
||||
always has a positive share and one that takes from it (a debt, for
|
||||
example) a negative one. Without a total to divide by, the share is None.
|
||||
"""
|
||||
shares = []
|
||||
|
||||
for component in components:
|
||||
if not component["total_final"]:
|
||||
continue
|
||||
|
||||
shares.append(
|
||||
{
|
||||
**component,
|
||||
"share": (
|
||||
component["total_final"] / abs(consolidated_total) * 100
|
||||
if consolidated_total
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
return sorted(shares, key=lambda share: share["total_final"], reverse=True)
|
||||
|
||||
|
||||
def calculate_percentage_distribution(currency_totals):
|
||||
"""
|
||||
Calculate percentage distribution of financial metrics for each currency.
|
||||
@@ -377,70 +432,9 @@ def calculate_percentage_distribution(currency_totals):
|
||||
"expense_projected": 0,
|
||||
}
|
||||
|
||||
# If there's exchanged data, calculate percentages for that too
|
||||
if "exchanged" in data:
|
||||
exchanged_total = sum(
|
||||
[
|
||||
abs(data.get("exchanged", {}).get("income_current", Decimal("0"))),
|
||||
abs(
|
||||
data.get("exchanged", {}).get("income_projected", Decimal("0"))
|
||||
),
|
||||
abs(data.get("exchanged", {}).get("expense_current", Decimal("0"))),
|
||||
abs(data.get("exchanged", {}).get("income_current", Decimal("0"))),
|
||||
]
|
||||
)
|
||||
|
||||
percentages[currency_id]["exchanged"] = {
|
||||
"currency": data["exchanged"]["currency"],
|
||||
"percentages": {},
|
||||
}
|
||||
|
||||
if exchanged_total > 0:
|
||||
percentages[currency_id]["exchanged"]["percentages"] = {
|
||||
"income_current": (
|
||||
abs(
|
||||
data.get("exchanged", {}).get(
|
||||
"income_current", Decimal("0")
|
||||
)
|
||||
)
|
||||
/ exchanged_total
|
||||
)
|
||||
* 100,
|
||||
"income_projected": (
|
||||
abs(
|
||||
data.get("exchanged", {}).get(
|
||||
"income_projected", Decimal("0")
|
||||
)
|
||||
)
|
||||
/ exchanged_total
|
||||
)
|
||||
* 100,
|
||||
"expense_current": (
|
||||
abs(
|
||||
data.get("exchanged", {}).get(
|
||||
"expense_current", Decimal("0")
|
||||
)
|
||||
)
|
||||
/ exchanged_total
|
||||
)
|
||||
* 100,
|
||||
"expense_projected": (
|
||||
abs(
|
||||
data.get("exchanged", {}).get(
|
||||
"income_current", Decimal("0")
|
||||
)
|
||||
)
|
||||
/ exchanged_total
|
||||
)
|
||||
* 100,
|
||||
}
|
||||
else:
|
||||
percentages[currency_id]["exchanged"]["percentages"] = {
|
||||
"income_current": 0,
|
||||
"income_projected": 0,
|
||||
"expense_current": 0,
|
||||
"expense_projected": 0,
|
||||
}
|
||||
p = percentages[currency_id]["percentages"]
|
||||
p["income_total"] = p["income_current"] + p["income_projected"]
|
||||
p["expense_total"] = p["expense_current"] + p["expense_projected"]
|
||||
|
||||
return percentages
|
||||
|
||||
@@ -460,7 +454,7 @@ def calculate_account_totals(transactions_queryset, ignore_empty=False):
|
||||
"account__currency__decimal_places",
|
||||
"account__currency__prefix",
|
||||
"account__currency__suffix",
|
||||
"account__exchange_currency",
|
||||
exchange_currency_id=shown_exchange_currency("account__"),
|
||||
).annotate(
|
||||
expense_current=Coalesce(
|
||||
Sum(
|
||||
@@ -527,8 +521,8 @@ def calculate_account_totals(transactions_queryset, ignore_empty=False):
|
||||
account_id = total["account"]
|
||||
currency = Currency.objects.get(id=total["account__currency__id"])
|
||||
exchange_currency = (
|
||||
Currency.objects.get(id=total["account__exchange_currency"])
|
||||
if total["account__exchange_currency"]
|
||||
Currency.objects.get(id=total["exchange_currency_id"])
|
||||
if total["exchange_currency_id"]
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
@@ -985,6 +985,13 @@ def transactions_trash_can_list(request):
|
||||
)
|
||||
|
||||
|
||||
# Query params the transaction picker may filter by (TransactionSelect.filters)
|
||||
SEARCH_FILTERS = {
|
||||
"currency": "account__currency_id",
|
||||
"account": "account_id",
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(["GET"])
|
||||
def get_recent_transactions(request, filter_type=None):
|
||||
@@ -1002,6 +1009,11 @@ def get_recent_transactions(request, filter_type=None):
|
||||
elif filter_type == "income":
|
||||
queryset = queryset.filter(type=Transaction.Type.INCOME)
|
||||
|
||||
for param, lookup in SEARCH_FILTERS.items():
|
||||
value = request.GET.get(param, "")
|
||||
if value.isdigit():
|
||||
queryset = queryset.filter(**{lookup: value})
|
||||
|
||||
if search_term:
|
||||
queryset = search_transactions(queryset, search_term)
|
||||
|
||||
|
||||
+429
-383
File diff suppressed because it is too large.
Load diff
+406
-377
File diff suppressed because it is too large.
Load diff
+429
-383
File diff suppressed because it is too large.
Load diff
+433
-384
File diff suppressed because it is too large.
Load diff
+423
-382
File diff suppressed because it is too large.
Load diff
+406
-377
File diff suppressed because it is too large.
Load diff
+429
-383
File diff suppressed because it is too large.
Load diff
+433
-384
File diff suppressed because it is too large.
Load diff
+427
-383
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+429
-468
File diff suppressed because it is too large.
Load diff
+406
-377
File diff suppressed because it is too large.
Load diff
+409
-378
File diff suppressed because it is too large.
Load diff
+426
-383
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
{% load i18n %}
|
||||
{# One side of a combined transfer, laid out like a mini transaction: account + metadata | amount #}
|
||||
{# Only the divider between legs gets the extra space; the outer edges stay flush #}
|
||||
<div class="relative flex items-start gap-x-3 py-3.5 first:pt-0 last:pb-0">
|
||||
{# Timeline rail: x sits under the transfer icon's center (7.5px into the column, minus the container's ps-5 = 20px). #}
|
||||
{# The first leg's rail reaches up past the description's mb + container mt to the icon; the last one stops at its icon, making └ #}
|
||||
<span aria-hidden="true" class="absolute -left-[13px] w-px bg-base-content/20
|
||||
{% if first %}-top-[20px] lg:-top-[16px] bottom-0{% else %}top-0 h-[24px]{% endif %}"></span>
|
||||
{# Stub from the rail into this leg's icon, at the icon's vertical center #}
|
||||
<span aria-hidden="true" class="absolute -left-[13px] w-[13px] h-px bg-base-content/20
|
||||
{% if first %}top-[10px]{% else %}top-[24px]{% endif %}"></span>
|
||||
{# h-5 matches the account line's height so the icon centers on it #}
|
||||
<div class="h-5 flex items-center" title="{{ label }}">
|
||||
<i class="fa-solid {{ icon }} fa-fw fa-xs {% if color == 'red' %}text-expense{% else %}text-income{% endif %}"></i>
|
||||
</div>
|
||||
{# Like a regular item: amount drops below on small screens, bottom-right on lg #}
|
||||
<div class="flex-1 min-w-0 flex flex-wrap lg:flex-nowrap gap-x-3 gap-y-2 lg:gap-y-1">
|
||||
<div class="w-full lg:flex-1 min-w-0">
|
||||
<div class="text-base-content truncate">{% if leg.account.group %}{{ leg.account.group.name }} • {% endif %}{{ leg.account.name }}</div>
|
||||
{# Same rhythm as a regular item's metadata rows: mb-2 lg:mb-1 between rows, pe-1 + mr-1 after the icon #}
|
||||
<div class="flex flex-col gap-2 lg:gap-1 mt-1 text-xs text-base-content/60">
|
||||
{# h-4 = one text-xs line, so each icon centers on its row's first line #}
|
||||
{% if leg.category %}<div class="flex gap-2"><span class="h-4 flex items-center"><i class="fa-solid fa-icons fa-fw fa-xs"></i></span><span>{{ leg.category.name }}</span></div>{% endif %}
|
||||
{% if leg.tags.all %}<div class="flex gap-2"><span class="h-4 flex items-center"><i class="fa-solid fa-hashtag fa-fw fa-xs"></i></span><span>{{ leg.tags.all|join:", " }}</span></div>{% endif %}
|
||||
{% if leg.entities.all %}<div class="flex gap-2"><span class="h-4 flex items-center"><i class="fa-solid fa-user-group fa-fw fa-xs"></i></span><span>{{ leg.entities.all|join:", " }}</span></div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{# Mobile gaps match a regular item's amount rows (mb-2 lg:mb-0) #}
|
||||
<div class="w-full lg:w-auto lg:text-right lg:self-end lg:pe-1 shrink-0 flex flex-col gap-2 lg:gap-0">
|
||||
<c-amount.display
|
||||
:amount="leg.amount"
|
||||
:prefix="leg.account.currency.prefix"
|
||||
:suffix="leg.account.currency.suffix"
|
||||
:decimal_places="leg.account.currency.decimal_places"
|
||||
:color="color"></c-amount.display>
|
||||
{% if not dummy %}
|
||||
{% with exchanged=leg.displayed_exchanged_amount %}
|
||||
{% if exchanged %}
|
||||
<c-amount.display
|
||||
:amount="exchanged.amount"
|
||||
:prefix="exchanged.prefix"
|
||||
:suffix="exchanged.suffix"
|
||||
:decimal_places="exchanged.decimal_places"
|
||||
color="grey"></c-amount.display>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,42 +76,22 @@
|
||||
<div class="flex-1 ps-0">{{ overriden_entities|join:", " }}</div>
|
||||
</div>
|
||||
|
||||
{% elif is_transfer %}
|
||||
{% if transaction.entities.all or transfer_to.entities.all %}
|
||||
<div class="grid grid-cols-[auto_1fr] mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-user-group fa-fw mr-1 fa-xs"></i></div>
|
||||
<div>{{ transaction.entities.all|join:", "|default:"—" }}</div>
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-arrow-right fa-fw mr-1 fa-xs text-base-content/40"></i></div>
|
||||
<div>{{ transfer_to.entities.all|join:", "|default:"—" }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% comment %} If no override, fall back to transaction entities, but ONLY if the transaction has an ID. {% endcomment %}
|
||||
{% elif transaction.id and transaction.entities.all %}
|
||||
{% elif transaction.id and transaction.entities.all and not is_transfer %}
|
||||
<div class="flex flex-wrap mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-user-group fa-fw mr-1 fa-xs"></i></div>
|
||||
<div class="flex-1 ps-0">{{ transaction.entities.all|join:", " }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Notes#}
|
||||
{% if transaction.notes %}
|
||||
{% if transaction.notes and not is_transfer %}
|
||||
<div class="flex flex-wrap mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-align-left fa-fw mr-1 fa-xs"></i></div>
|
||||
<div class="flex-1 ps-0">{{ transaction.notes | limited_markdown | linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Category#}
|
||||
{% if is_transfer %}
|
||||
{# Each side keeps its own category: "from" line, then "to" line with the arrow under the icon #}
|
||||
{% if transaction.category or transfer_to.category %}
|
||||
<div class="grid grid-cols-[auto_1fr] mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-icons fa-fw mr-1 fa-xs"></i></div>
|
||||
<div>{{ transaction.category.name|default:"—" }}</div>
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-arrow-right fa-fw mr-1 fa-xs text-base-content/40"></i></div>
|
||||
<div>{{ transfer_to.category.name|default:"—" }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% elif transaction.category %}
|
||||
{% if transaction.category and not is_transfer %}
|
||||
<div class="flex flex-wrap mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-icons fa-fw mr-1 fa-xs"></i></div>
|
||||
<div class="flex-1 ps-0">{{ transaction.category.name }}</div>
|
||||
@@ -125,52 +105,33 @@
|
||||
<div class="flex-1 ps-0">{{ overriden_tags|join:", " }}</div>
|
||||
</div>
|
||||
|
||||
{% elif is_transfer %}
|
||||
{% if transaction.tags.all or transfer_to.tags.all %}
|
||||
<div class="grid grid-cols-[auto_1fr] mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-hashtag fa-fw mr-1 fa-xs"></i></div>
|
||||
<div>{{ transaction.tags.all|join:", "|default:"—" }}</div>
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-arrow-right fa-fw mr-1 fa-xs text-base-content/40"></i></div>
|
||||
<div>{{ transfer_to.tags.all|join:", "|default:"—" }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% comment %} If no override, fall back to transaction tags, but ONLY if the transaction has an ID. {% endcomment %}
|
||||
{% elif transaction.id and transaction.tags.all %}
|
||||
{% elif transaction.id and transaction.tags.all and not is_transfer %}
|
||||
<div class="flex flex-wrap mb-2 lg:mb-1 text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-hashtag fa-fw mr-1 fa-xs"></i></div>
|
||||
<div class="flex-1 ps-0">{{ transaction.tags.all|join:", " }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if is_transfer %}
|
||||
{# ps-5 leaves room for the timeline rail that drops from the transfer icon (see _transfer_leg) #}
|
||||
<div class="main-amount mt-2 mb-1 ps-5 text-sm">
|
||||
{% translate 'From' as from_label %}{% translate 'To' as to_label %}
|
||||
{% include "cotton/transaction/_transfer_leg.html" with leg=transaction color="red" icon="fa-arrow-right-from-bracket" label=from_label first=True %}
|
||||
{% include "cotton/transaction/_transfer_leg.html" with leg=transfer_to color="green" icon="fa-arrow-right-to-bracket" label=to_label first=False %}
|
||||
</div>
|
||||
{# Notes go below the legs so they don't sit in the rail's path #}
|
||||
{% if transaction.notes %}
|
||||
<div class="flex flex-wrap mt-2 text-sm text-base-content/70">
|
||||
<div class="w-auto pe-1"><i class="fa-solid fa-align-left fa-fw mr-1 fa-xs"></i></div>
|
||||
<div class="flex-1 ps-0">{{ transaction.notes | limited_markdown | linebreaksbr }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not is_transfer %}
|
||||
<div
|
||||
class="lg:w-auto lg:min-w-0 lg:max-w-1/2 w-full lg:text-right self-end {% if transaction.account.is_untracked_by or transaction.category.mute or transaction.mute %}opacity-70{% endif %}">
|
||||
{% if is_transfer %}
|
||||
{# Each side: amount, with its account below, like a regular item #}
|
||||
<div class="main-amount mt-3 lg:mt-0 mb-2 lg:mb-0 grid grid-cols-[auto_minmax(0,auto)] gap-x-2 gap-y-2 items-baseline w-fit max-w-full lg:ms-auto">
|
||||
<span></span>
|
||||
<div class="min-w-0">
|
||||
<c-amount.display
|
||||
:amount="transaction.amount"
|
||||
:prefix="transaction.account.currency.prefix"
|
||||
:suffix="transaction.account.currency.suffix"
|
||||
:decimal_places="transaction.account.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
<div class="truncate">{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}</div>
|
||||
</div>
|
||||
<i class="fa-solid fa-arrow-right fa-xs text-base-content/40"></i>
|
||||
<div class="min-w-0">
|
||||
<c-amount.display
|
||||
:amount="transfer_to.amount"
|
||||
:prefix="transfer_to.account.currency.prefix"
|
||||
:suffix="transfer_to.account.currency.suffix"
|
||||
:decimal_places="transfer_to.account.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
<div class="truncate">{% if transfer_to.account.group %}{{ transfer_to.account.group.name }} • {% endif %}{{ transfer_to.account.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="main-amount mb-2 lg:mb-0">
|
||||
<c-amount.display
|
||||
:amount="transaction.amount"
|
||||
@@ -179,10 +140,9 @@
|
||||
:decimal_places="transaction.account.currency.decimal_places"
|
||||
color="{% if transaction.type == "EX" %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Exchange Rate#}
|
||||
{% if not dummy %}
|
||||
{% with exchanged=transaction.exchanged_amount %}
|
||||
{% with exchanged=transaction.displayed_exchanged_amount %}
|
||||
{% if exchanged %}
|
||||
<div class="exchanged-amount mb-2 lg:mb-0">
|
||||
<c-amount.display
|
||||
@@ -195,12 +155,11 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% if not is_transfer %}
|
||||
<div>
|
||||
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
{% if transaction.account.group %}{{ transaction.account.group.name }} • {% endif %}{{ transaction.account.name }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not dummy %}
|
||||
<div class="z-1000">
|
||||
{# Item actions#}
|
||||
|
||||
@@ -54,4 +54,22 @@
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% with p=percentage.percentages %}
|
||||
{% if p.income_projected > 0 and p.income_current > 0 or p.expense_projected > 0 and p.expense_current > 0 %}
|
||||
<div class="flex w-full text-xs text-base-content/70" aria-hidden="true">
|
||||
<div style="width: {{ p.income_total|floatformat:'2u' }}%">
|
||||
{% if p.income_projected > 0 and p.income_current > 0 %}
|
||||
<div class="h-1.5 border-x border-b border-base-content/40 rounded-b mx-px"></div>
|
||||
<div class="text-center">{{ p.income_total|floatformat:2 }}%</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="width: {{ p.expense_total|floatformat:'2u' }}%">
|
||||
{% if p.expense_projected > 0 and p.expense_current > 0 %}
|
||||
<div class="h-1.5 border-x border-b border-base-content/40 rounded-b mx-px"></div>
|
||||
<div class="text-center">{{ p.expense_total|floatformat:2 }}%</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
@@ -87,6 +87,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if run.account %}
|
||||
<div class="col">
|
||||
<div class="flex flex-row">
|
||||
<div class="flex flex-col">
|
||||
<div class="text-base-content/70 text-xs font-medium">
|
||||
{% trans 'Account' %}
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
{{ run.account.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-base-200 p-4 text-base-content/70">
|
||||
|
||||
@@ -20,6 +20,15 @@
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="green"></c-amount.display>
|
||||
{% if currency.exchanged %}
|
||||
<div class="text-xs text-base-content/60">
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.shown.income"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
@@ -34,6 +43,15 @@
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="red"></c-amount.display>
|
||||
{% if currency.exchanged %}
|
||||
<div class="text-xs text-base-content/60">
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.shown.expense"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
@@ -48,6 +66,15 @@
|
||||
:suffix="currency.currency.suffix"
|
||||
:decimal_places="currency.currency.decimal_places"
|
||||
color="{% if currency.total_final < 0 %}red{% else %}green{% endif %}"></c-amount.display>
|
||||
{% if currency.exchanged %}
|
||||
<div class="text-xs text-base-content/60">
|
||||
<c-amount.display
|
||||
:amount="currency.exchanged.shown.total"
|
||||
:prefix="currency.exchanged.currency.prefix"
|
||||
:suffix="currency.exchanged.currency.suffix"
|
||||
:decimal_places="currency.exchanged.currency.decimal_places"></c-amount.display>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div>-</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
<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>
|
||||
<span class="text-xs text-warning whitespace-nowrap">{% blocktranslate with target=share.plan.target|floatformat:'-2' %}target {{ target }}%{% endblocktranslate %}</span>
|
||||
{% 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>
|
||||
|
||||
@@ -1 +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 %}
|
||||
{% load i18n %}{% if mw %}<span class="whitespace-nowrap">{% include 'investments/fragments/_percentage.html' with value=mw.percentage %}{% if mw.annualized %}<span class="text-base-content/50">{% translate '/yr' %}</span>{% endif %}</span>{% endif %}
|
||||
@@ -1,51 +1,56 @@
|
||||
{% 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 class="card-body p-5 gap-1">
|
||||
<div class="text-sm text-base-content/50">{% translate 'Portfolio value' %}{% if totals|length > 1 %} · {{ c.name }}{% endif %}</div>
|
||||
<div class="text-3xl sm:text-4xl font-bold tabular-nums tracking-tight whitespace-nowrap">
|
||||
<c-amount.display :amount="total.market_value" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display>
|
||||
</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>
|
||||
<div class="text-sm tabular-nums flex flex-wrap items-baseline gap-x-2">
|
||||
<span class="whitespace-nowrap"><c-amount.display :amount="total.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=total.money_weighted %}</span>
|
||||
<span class="text-base-content/50">{% translate 'all time' %}</span>
|
||||
</div>
|
||||
|
||||
{# Secondary figures stay out of sight until asked for #}
|
||||
<details class="group mt-3 text-sm">
|
||||
<summary class="cursor-pointer list-none text-base-content/50 hover:text-base-content transition-colors duration-150 w-fit select-none">
|
||||
{% translate 'Details' %} <i class="fa-solid fa-chevron-down text-xs transition-transform duration-150 group-open:rotate-180"></i>
|
||||
</summary>
|
||||
<dl class="mt-3 flex flex-col divide-y divide-base-content/10 tabular-nums">
|
||||
<div class="flex justify-between gap-4 py-2">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'Invested' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap"><c-amount.display :amount="total.cost_basis" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4 py-2">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'Income' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap"><c-amount.display :amount="total.income" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></dd>
|
||||
</div>
|
||||
{% if total.converted_from %}
|
||||
<div class="flex justify-between gap-4 py-2">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'Currency effect' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap">
|
||||
{% if total.has_rate_history %}
|
||||
<c-amount.display :amount="total.currency_effect" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" color="auto" divless></c-amount.display>
|
||||
{% else %}
|
||||
<span class="text-base-content/50 whitespace-normal">{% translate 'Needs historical exchange rates' %}</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</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>
|
||||
<div class="flex justify-between gap-4 py-2">
|
||||
<dt class="text-base-content/60 min-w-0">{% blocktranslate with name=p.name %}In {{ name }}{% endblocktranslate %}</dt>
|
||||
<dd class="flex items-baseline gap-2 whitespace-nowrap">
|
||||
<c-amount.display :amount="part.market_value" :prefix="p.prefix" :suffix="p.suffix" :decimal_places="p.decimal_places" divless></c-amount.display>
|
||||
<span class="text-xs">{% include 'investments/fragments/_mwr.html' with mw=part.money_weighted %}</span>
|
||||
</dd>
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
@@ -31,69 +31,34 @@
|
||||
{% 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 units moved in or out without a linked payment, so they were valued at the market price of that day' %}"></i>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<a href="{% url 'investments_index' %}" hx-boost="true" class="btn btn-ghost btn-sm btn-square text-base-content/50 hover:text-base-content" aria-label="{% translate 'Investments' %}"><i class="fa-solid fa-arrow-left"></i></a>
|
||||
<h1 class="text-2xl font-bold truncate">{{ account.name }}</h1>
|
||||
<button class="btn btn-ghost btn-sm btn-square text-base-content/40 hover:text-base-content"
|
||||
aria-label="{% translate 'Edit account' %}"
|
||||
hx-get="{% url 'account_edit' pk=account.id %}" hx-target="#generic-offcanvas">
|
||||
<i class="fa-solid fa-pencil"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-base-content/50 mb-6 ps-10">
|
||||
{% if account.group %}{{ account.group.name }} · {% endif %}
|
||||
{% if perf.priced %}{{ a.name }} → {{ c.name }}{% else %}{% translate 'Tracked by value' %}{% endif %}
|
||||
</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>
|
||||
<div class="text-3xl sm:text-4xl font-bold tabular-nums tracking-tight whitespace-nowrap">
|
||||
{% 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>
|
||||
{% 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 class="text-sm tabular-nums mt-1 flex flex-wrap items-baseline gap-x-2">
|
||||
<span class="whitespace-nowrap"><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>
|
||||
<span class="text-base-content/50">{% translate 'all time' %}</span>
|
||||
</div>
|
||||
{% if home %}{% with h=home.currency %}
|
||||
<div class="text-sm tabular-nums mt-1 text-base-content/50">≈ <c-amount.display :amount="home.market_value" :prefix="h.prefix" :suffix="h.suffix" :decimal_places="h.decimal_places" divless></c-amount.display></div>
|
||||
{% endwith %}{% endif %}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -217,69 +182,96 @@
|
||||
}));
|
||||
})();
|
||||
</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>
|
||||
{% if perf.estimated %}
|
||||
<div class="text-sm text-base-content/60 mb-4 flex gap-2 items-baseline">
|
||||
<i class="fa-solid fa-circle-info text-warning"></i>
|
||||
<span>{% translate 'Some units moved in or out without a linked payment, so they were valued at the market price of that day.' %}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 items-start">
|
||||
<div class="flex flex-col gap-4 lg:order-last">
|
||||
<div class="card bg-base-100 card-border shadow">
|
||||
<div class="px-5 pt-4 font-semibold">{% translate 'Your position' %}</div>
|
||||
<dl class="px-5 pb-3 flex flex-col divide-y divide-base-content/10 text-sm tabular-nums">
|
||||
{% if perf.priced %}
|
||||
<div class="flex justify-between items-baseline gap-4 py-2.5">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'Units' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap"><c-amount.display :amount="perf.quantity" :prefix="a.prefix" :suffix="a.suffix" :decimal_places="a.decimal_places" divless></c-amount.display></dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-baseline gap-4 py-2.5">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'Price' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap">{% 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 %}<div class="text-xs text-base-content/40">{{ perf.price_date|date:"SHORT_DATE_FORMAT" }}</div>{% endif %}{% else %}<span class="text-error">{% translate "No exchange rate available" %}</span>{% endif %}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-baseline gap-4 py-2.5">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'Average cost' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap">{% 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 %}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex justify-between items-baseline gap-4 py-2.5">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'Invested' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap"><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 class="flex justify-between items-baseline gap-4 py-2.5">
|
||||
<dt class="text-base-content/60 min-w-0">{% if perf.priced %}{% translate 'Unrealized' %}{% else %}{% translate 'Earnings' %}{% endif %}</dt>
|
||||
<dd class="text-end whitespace-nowrap">{% 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>{% else %}-{% endif %}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-baseline gap-4 py-2.5">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'Realized' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap"><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 class="flex justify-between items-baseline gap-4 py-2.5">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'Income' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap"><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>
|
||||
<div class="flex justify-between items-baseline gap-4 py-2.5">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'Put in' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap"><c-amount.display :amount="perf.contributed" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-baseline gap-4 py-2.5">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'Taken out' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap"><c-amount.display :amount="perf.withdrawn" :prefix="c.prefix" :suffix="c.suffix" :decimal_places="c.decimal_places" divless></c-amount.display></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
{% if home %}{% with h=home.currency %}
|
||||
<div class="card bg-base-100 card-border shadow">
|
||||
<div class="px-5 pt-4 font-semibold">{% blocktranslate with name=h.name %}In {{ name }}{% endblocktranslate %}</div>
|
||||
<dl class="px-5 pb-3 flex flex-col divide-y divide-base-content/10 text-sm tabular-nums">
|
||||
<div class="flex justify-between items-baseline gap-4 py-2.5">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'Value' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap"><c-amount.display :amount="home.market_value" :prefix="h.prefix" :suffix="h.suffix" :decimal_places="h.decimal_places" divless></c-amount.display></dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-baseline gap-4 py-2.5">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'Return' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap"><c-amount.display :amount="home.total_return" :prefix="h.prefix" :suffix="h.suffix" :decimal_places="h.decimal_places" color="auto" divless></c-amount.display> <span class="text-xs">{% include 'investments/fragments/_mwr.html' with mw=home.money_weighted %}</span></dd>
|
||||
</div>
|
||||
<div class="flex justify-between items-baseline gap-4 py-2.5">
|
||||
<dt class="text-base-content/60 min-w-0">{% translate 'From exchange rates' %}</dt>
|
||||
<dd class="text-end whitespace-nowrap">{% if home.has_rate_history %}<c-amount.display :amount="home.currency_effect" :prefix="h.prefix" :suffix="h.suffix" :decimal_places="h.decimal_places" color="auto" divless></c-amount.display>{% else %}<span class="text-base-content/50 whitespace-normal">{% translate "Needs historical exchange rates" %}</span>{% endif %}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
{% endwith %}{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 lg:col-span-2">
|
||||
<div class="card bg-base-100 card-border shadow">
|
||||
<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">
|
||||
<span class="tabular-nums">{{ tx.date|date:"SHORT_DATE_FORMAT" }}</span> ·
|
||||
{% if p %}
|
||||
{% if tx.type == 'IN' %}{% translate 'Bought / contributed' %}{% else %}{% translate 'Sold / withdrew' %}{% endif %}
|
||||
· {{ p.account.name }}
|
||||
@@ -294,50 +286,58 @@
|
||||
</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>
|
||||
{# What the other side paid beats a rate conversion, so it's shown first #}
|
||||
{% 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 %}
|
||||
{% else %}{% with exchanged=tx.displayed_exchanged_amount %}{% if exchanged %}
|
||||
<div class="text-xs text-base-content/50">
|
||||
<c-amount.display :amount="exchanged.amount" :prefix="exchanged.prefix" :suffix="exchanged.suffix" :decimal_places="exchanged.decimal_places" divless></c-amount.display>
|
||||
</div>
|
||||
{% endif %}{% endwith %}{% 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' %}"
|
||||
aria-label="{% 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>
|
||||
</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">
|
||||
<div class="font-semibold">{% translate 'Income' %}</div>
|
||||
{% if income_entities %}<div class="text-xs text-base-content/50">{{ income_entities|join:", " }}</div>{% endif %}
|
||||
</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>{{ tx.description }}<div class="text-xs text-base-content/50"><span class="tabular-nums">{{ tx.date|date:"SHORT_DATE_FORMAT" }}</span> · {{ 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' %}"
|
||||
aria-label="{% 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>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endwith %}
|
||||
@@ -10,8 +10,8 @@
|
||||
{% endspaceless %}
|
||||
</div>
|
||||
|
||||
{% if groups %}
|
||||
<div class="grid grid-cols-1 {% if totals|length > 1 %}lg:grid-cols-2{% endif %} gap-3 mb-6">
|
||||
{% if groupings.0.1 %}
|
||||
<div class="grid grid-cols-1 {% if totals|length > 1 %}lg:grid-cols-2{% endif %} gap-3 mb-8">
|
||||
{% for total in totals %}
|
||||
{% include 'investments/fragments/_totals.html' %}
|
||||
{% endfor %}
|
||||
@@ -20,73 +20,58 @@
|
||||
<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">
|
||||
<div role="tablist" class="tabs tabs-box tabs-sm w-fit mb-4">
|
||||
<button type="button" role="tab" class="tab group-by-tab {% if group_by == 'group' %}tab-active{% endif %}"
|
||||
hx-get="{% url 'investments_group_by' %}?group_by=group" hx-swap="none"
|
||||
_="on click take .tab-active from .group-by-tab for me then add .hidden to .holdings-grouping then remove .hidden from #holdings-group">
|
||||
{% translate 'Account group' %}
|
||||
</button>
|
||||
<button type="button" role="tab" class="tab group-by-tab {% if group_by == 'investment_class' %}tab-active{% endif %}"
|
||||
hx-get="{% url 'investments_group_by' %}?group_by=investment_class" hx-swap="none"
|
||||
_="on click take .tab-active from .group-by-tab for me then add .hidden to .holdings-grouping then remove .hidden from #holdings-investment_class">
|
||||
{% translate 'Asset class' %}
|
||||
</button>
|
||||
</div>
|
||||
{% for attr, groups in groupings %}
|
||||
<div id="holdings-{{ attr }}" class="holdings-grouping card bg-base-100 card-border shadow {% if attr != group_by %}hidden{% endif %}">
|
||||
{% for item in groups %}
|
||||
<div class="flex flex-wrap justify-between items-baseline gap-x-4 gap-y-1 px-5 pt-5 pb-2 {% if not forloop.first %}border-t border-base-content/10 mt-2{% endif %}">
|
||||
<span class="text-xs font-semibold uppercase tracking-wide text-base-content/50 truncate min-w-0">{% if item.group %}{{ item.group.name }}{% elif attr == 'investment_class' %}{% translate 'Unclassified' %}{% else %}{% translate 'Ungrouped' %}{% endif %}</span>
|
||||
<div class="flex flex-wrap gap-x-4 text-sm text-base-content/60 tabular-nums whitespace-nowrap">
|
||||
{% 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>
|
||||
<c-amount.display :amount="total.market_value" :prefix="total.currency.prefix" :suffix="total.currency.suffix" :decimal_places="total.currency.decimal_places" custom_class="font-normal" divless></c-amount.display>
|
||||
{% 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>
|
||||
<ul>
|
||||
{% for perf in item.holdings %}
|
||||
{% with c=perf.currency a=perf.account.currency %}
|
||||
<li>
|
||||
<a href="{% url 'investment_detail_index' account_id=perf.account.id %}"
|
||||
class="flex items-center justify-between gap-4 px-5 py-3 no-underline text-base-content transition-colors duration-150 hover:bg-base-200/60">
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ perf.account.name }}</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 %}
|
||||
<c-amount.display :amount="perf.quantity" :prefix="a.prefix" :suffix="a.suffix" :decimal_places="a.decimal_places" custom_class="font-normal" divless></c-amount.display>
|
||||
{% if perf.price is None %}· <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>
|
||||
<div class="text-end tabular-nums whitespace-nowrap">
|
||||
<div class="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="text-xs">{% include 'investments/fragments/_mwr.html' with mw=perf.money_weighted %}</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
<div class="pb-2"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="radio" name="investment_tabs" role="tab" class="tab" aria-label="{% translate 'Charts' %}"
|
||||
|
||||
@@ -31,10 +31,9 @@
|
||||
<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 {% if row.off %}text-warning{% endif %}">{{ 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 %}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<div>
|
||||
<c-ui.info-card color="yellow" icon="fa-solid fa-coins" title="{% trans "By currency" %}"
|
||||
title_css_classes="cursor-pointer" _="on click showAllDatasetsCurrency()">
|
||||
<ul class="menu bg-base-100 w-full rounded-box">
|
||||
<ul class="menu bg-base-100 rounded-box p-0 -mx-3 w-[calc(100%+1.5rem)]">
|
||||
{% for currency in currency_net_worth.values %}
|
||||
<li>
|
||||
{% if currency.consolidated and currency.consolidated.total_final != currency.total_final %}
|
||||
@@ -80,8 +80,14 @@
|
||||
</a>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="cursor-pointer text-base-content/60 select-auto flex justify-between items-center w-full"
|
||||
{# Collapsed by default; each currency's open state is remembered per browser #}
|
||||
<details _="init if localStorage.getItem('net-worth-consolidated-{{ currency.currency.id }}') is 'open' set my.open to true end
|
||||
on toggle if my.open call localStorage.setItem('net-worth-consolidated-{{ currency.currency.id }}', 'open')
|
||||
else call localStorage.setItem('net-worth-consolidated-{{ currency.currency.id }}', 'closed') end
|
||||
catch e log e end">
|
||||
<summary class="cursor-pointer text-base-content/60 select-auto flex"
|
||||
_="on click showOnlyCurrencyDataset('{{ currency.currency.name }} {% trans "Consolidated" %}', '{{ currency.currency.name }}')">
|
||||
<span class="flex justify-between items-center w-full">
|
||||
<span class="text-start shrink">{% trans "Consolidated" %}</span>
|
||||
<span class="text-end shrink-0">
|
||||
<c-amount.display :amount="currency.consolidated.total_final"
|
||||
@@ -90,8 +96,34 @@
|
||||
:decimal_places="currency.consolidated.currency.decimal_places"
|
||||
color="{% if currency.consolidated.total_final > 0 %}green{% elif currency.consolidated.total_final < 0 %}red{% endif %}"
|
||||
text-end></c-amount.display>
|
||||
</span>
|
||||
</a>
|
||||
</span>
|
||||
</span>
|
||||
</summary>
|
||||
{# Rows share the ul's columns via subgrid so badges line up regardless of amount width #}
|
||||
<ul class="grid grid-cols-[minmax(0,1fr)_auto_auto]">
|
||||
{% for component in currency.consolidated.components %}
|
||||
<li class="col-span-3 grid grid-cols-subgrid">
|
||||
<a class="col-span-3 grid grid-cols-subgrid items-center cursor-pointer text-base-content/60 select-auto text-sm"
|
||||
_="on click showOnlyCurrencyDataset('{{ component.currency.name }}', '{{ component.currency.name }}')">
|
||||
<span class="currency-name text-start font-mono truncate">{{ component.currency.name }}</span>
|
||||
<span class="justify-self-start">
|
||||
{% if component.share is not None %}
|
||||
<span class="badge badge-sm badge-ghost tabular-nums">{{ component.share|floatformat:1 }}%</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="text-end whitespace-nowrap">
|
||||
<c-amount.display :amount="component.total_final"
|
||||
:prefix="currency.consolidated.currency.prefix"
|
||||
:suffix="currency.consolidated.currency.suffix"
|
||||
:decimal_places="currency.consolidated.currency.decimal_places"
|
||||
color="grey"
|
||||
divless></c-amount.display>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
{% else %}
|
||||
@@ -162,7 +194,7 @@
|
||||
<div>
|
||||
<c-ui.info-card color="blue" icon="fa-solid fa-wallet" title="{% trans "By account" %}"
|
||||
title_css_classes="cursor-pointer" _="on click showAllDatasetsAccount()">
|
||||
<ul class="menu bg-base-100 w-full rounded-box">
|
||||
<ul class="menu bg-base-100 rounded-box p-0 -mx-3 w-[calc(100%+1.5rem)]">
|
||||
{% regroup account_net_worth.values by account.group as account_data %}
|
||||
{% for data in account_data %}
|
||||
{% if data.grouper %}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
{% translate 'All' %}
|
||||
</button>
|
||||
{% for account in accounts %}
|
||||
<button class="btn btn-ghost justify-start w-full"
|
||||
<button class="btn btn-ghost btn-free justify-start text-start w-full"
|
||||
role="tab"
|
||||
hx-get="{% url 'yearly_overview_account_data' year=year %}"
|
||||
hx-target="#data-content"
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
{% translate 'All' %}
|
||||
</button>
|
||||
{% for currency in currencies %}
|
||||
<button class="btn btn-ghost justify-start w-full"
|
||||
<button class="btn btn-ghost btn-free justify-start text-start w-full"
|
||||
role="tab"
|
||||
hx-get="{% url 'yearly_overview_currency_data' year=year %}"
|
||||
hx-target="#data-content"
|
||||
|
||||
@@ -113,18 +113,32 @@ function isMobile() {
|
||||
// swaps the body: AirDatepicker then rebuilds the container, but keeps the old
|
||||
// overlay, which is left detached and can't receive the clicks that close a picker.
|
||||
let mobileOverlay = null;
|
||||
// The container AirDatepicker opens every picker into (its own reference, not a lookup by
|
||||
// id: a history restore brings back lifeless copies of it from htmx's snapshot).
|
||||
let globalContainer = null;
|
||||
|
||||
function createAirDatepicker(element, opts) {
|
||||
const datepicker = new AirDatepicker(element, opts);
|
||||
mobileOverlay ??= document.querySelector('.air-datepicker-overlay');
|
||||
const container = document.getElementById(AirDatepicker.defaultGlobalContainerId);
|
||||
if (mobileOverlay && !mobileOverlay.isConnected && container) {
|
||||
globalContainer = datepicker.$container;
|
||||
mobileOverlay ??= globalContainer.querySelector('.air-datepicker-overlay');
|
||||
if (mobileOverlay && !mobileOverlay.isConnected) {
|
||||
mobileOverlay.classList.remove('-active-'); // its picker was swapped out while open
|
||||
container.appendChild(mobileOverlay);
|
||||
globalContainer.appendChild(mobileOverlay);
|
||||
}
|
||||
return datepicker;
|
||||
}
|
||||
|
||||
// Pickers kept by hx-preserve aren't recreated, so nothing makes AirDatepicker rebuild
|
||||
// the container the swap removed: put it back, and drop any snapshot copies of it.
|
||||
// Runs after htmx:load, so pickers created by this swap have already set globalContainer.
|
||||
document.addEventListener('htmx:afterSwap', () => {
|
||||
if (!globalContainer) return;
|
||||
document.querySelectorAll(`#${AirDatepicker.defaultGlobalContainerId}`).forEach(c => {
|
||||
if (c !== globalContainer) c.remove();
|
||||
});
|
||||
if (!globalContainer.isConnected) document.body.appendChild(globalContainer);
|
||||
});
|
||||
|
||||
window.DatePicker = async function createDynamicDatePicker(element) {
|
||||
let todayButton = {
|
||||
content: element.dataset.nowButtonTxt,
|
||||
|
||||
@@ -136,7 +136,8 @@ window.TomSelect = function createDynamicTomSelect(element) {
|
||||
config.preload = 'focus';
|
||||
config.shouldLoad = () => true;
|
||||
config.load = function (query, callback) {
|
||||
let url = element.dataset.load + '?q=' + encodeURIComponent(query);
|
||||
const url = new URL(element.dataset.load, window.location.origin);
|
||||
url.searchParams.set('q', query);
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(json => {
|
||||
|
||||
@@ -189,6 +189,12 @@
|
||||
@apply min-h-(--size) py-1;
|
||||
height: unset;
|
||||
}
|
||||
|
||||
/* daisyUI fixes the badge height, so long names wrap outside the border */
|
||||
.badge {
|
||||
@apply min-h-(--size);
|
||||
height: unset;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1069,11 +1069,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "soupsieve"
|
||||
version = "2.9"
|
||||
version = "4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/f1/93422647dd7e461f23d254e6b2bfa687a85b53aeb4903fcdbb74474d4584/soupsieve-2.9.tar.gz", hash = "sha256:acee8417325c5653e1377dc31eccad59eb82cbc65942afe6174c53b3aaad63fc", size = 122122, upload-time = "2026-07-19T01:35:18.425Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/5e/19b7c5d20bc2f226c44f33a9d9aab9740efed7555b96e656231fd564e514/soupsieve-4.0.tar.gz", hash = "sha256:ccd6470b256394ac44b08b86fd94b30a17d335a5335c8a3d15565f7b03d3c8cf", size = 151929, upload-time = "2026-11-03T14:28:55.388Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/d6/3185ab5ad1280319b31986898f3206dd7227cd75e293d4dba2a5e6bf27a0/soupsieve-2.9-py3-none-any.whl", hash = "sha256:a2b2c76d67df2382d245409fd71e321a571717e58463efa32ace87dcadac2c12", size = 37387, upload-time = "2026-07-19T01:35:17.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/18/d24df2f733ee0655c58717d51add4118a70e64692d0e47d403b9aac4cf98/soupsieve-4.0-py3-none-any.whl", hash = "sha256:5e7494bfce90e06676cfb4c5c2cf22243e98e5cf24646bb1ecdda46d318550a0", size = 50158, upload-time = "2026-11-03T14:28:53.988Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user