Compare commits

...
53 Commits
Author SHA1 Message Date
Herculino Trotta e552723078 Merge pull request #674 from eitchtee/dependabot/uv/soupsieve-4.0
chore(deps): bump soupsieve from 2.9 to 4.0
2026-11-13 15:40:59 -03:00
Herculino Trotta 01dd0920a9 Merge pull request #675 from eitchtee/fix/airdatepicker-hx-boosted
fix(datepicker): keep preserved pickers working after boosted navigation
2026-11-13 15:40:28 -03:00
Herculino Trotta e2981bf1f6 fix(datepicker): keep pickers working after history back and forward
A history restore swaps in htmx's snapshot of the body, which carries a
lifeless copy of AirDatepicker's global container. The previous fix looked
the container up by id, found that copy, and left the library's real
container detached, so pickers opened into nothing.

Track the container through datepicker.$container instead, remove any
snapshot copies after a swap, and reattach the real one when detached.
2026-11-13 15:18:06 -03:00
Herculino Trotta 9cf8cca1cb fix(datepicker): keep preserved pickers working after boosted navigation
The filter on the Monthly and All pages is wrapped in hx-preserve, so a
boosted navigation keeps its pickers instead of recreating them. The
AirDatepicker global container they render into lives on the body and is
swapped out with it, leaving those pickers drawing into a detached node.

After each swap, move the detached container's pickers into the new
container, or reattach it when no new picker created one.
2026-11-13 15:04:41 -03:00
dependabot[bot] 1ec0cfc02f chore(deps): bump soupsieve from 2.9 to 4.0
Bumps [soupsieve](https://github.com/facelessuser/soupsieve) from 2.9 to 4.0.
- [Release notes](https://github.com/facelessuser/soupsieve/releases)
- [Commits](https://github.com/facelessuser/soupsieve/compare/2.9...4.0)

---
updated-dependencies:
- dependency-name: soupsieve
  dependency-version: '4.0'
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-11-13 14:09:59 +00:00
eitchtee 1c891a0a60 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-11-12 03:24:33 +00:00
Herculino Trotta 03c22b8fc2 Merge pull request #670 from officialasishkumar/feat/import-run-account
feat(import): pick the account when running an import
2026-11-12 00:24:10 -03:00
Herculino Trotta df6ccda7b3 feat(import): read any line of the file with a line transformation
Some banks name the account once in a header cell above the table, which
skip_lines and start_row throw away. A `line` transformation returns a line
of the file (or one of its columns) as text, so a mapping can read it and
clean it up with regex or replace.

CSV keeps only the requested lines during the existing counting pass; Excel
reads them straight from each sheet. An account picked on the run still
overrides the mapped one.
2026-11-12 00:17:14 -03:00
Herculino Trotta e617e809c0 fix(import): reject archived accounts when importing through the API
The upload form only lists active accounts, but the API accepted any account
the user can access, archived ones included.
2026-11-12 00:14:06 -03:00
eitchtee 1662fe70e0 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-11-11 18:26:08 +00:00
Herculino Trotta ca09f37b51 Merge pull request #669 from officialasishkumar/feat/net-worth-consolidated-shares
feat(net-worth): break consolidated totals down by currency share
2026-11-11 15:25:44 -03:00
Asish Kumar 1ed5dc5d31 feat(import): pick the account when running an import
An import run can now be given an account, and every transaction it
creates goes into that account instead of the one the profile maps. This
lets a single profile serve several accounts at the same bank, for
statements that only name the account in a header cell or not at all,
instead of keeping one copy of the profile per account.

- ImportRun gets an optional `account` (SET_NULL), shown on the run card.
- The upload form offers the user's active accounts when the profile
  imports transactions, and leaves the field out for profiles that import
  accounts, currencies, categories, tags or entities.
- CSV and Excel rows take the run's account before deduplication, so
  rules comparing `account` check against the picked account. The
  profile's account mapping is skipped for these rows.
- QIF files use the run's account instead of the account named after the
  file.
- The import API accepts an optional `account_id`, limited to accounts
  the user can access, rejected for profiles that don't import
  transactions, and returns `account` on import runs.

Leaving the account empty keeps the current behaviour.

Closes #538
2026-11-11 22:41:37 +05:30
Herculino Trotta dea8cfee4e test(net-worth): assert share text instead of badge markup 2026-11-11 14:02:56 -03:00
Herculino Trotta 4bf2c2552b feat(net-worth): collapse consolidated breakdown and align share badges
Consolidated rows are collapsible and remember their state per currency in localStorage. Share percentages render as badges on a shared subgrid column so they line up regardless of amount width. Currency and account menus drop their extra padding to align with the card title.
2026-11-11 13:57:21 -03:00
Asish Kumar d590dc39ab feat(net-worth): break consolidated totals down by currency share
A consolidated total on the Net Worth page was a single number: it said
what everything is worth in one currency, but not how much of it each
currency makes up, which is what you want to know when you hold the same
money in several currencies (or in currencies standing for gold, stocks
or crypto).

calculate_currency_totals now keeps, next to each consolidated total, the
components it was built from: the currency's own total and the exchanged
total of every currency that exchanges into it, all in the consolidated
currency. calculate_consolidated_shares gives each of them its share of
the total as a percentage, drops the ones that add nothing and sorts the
rest from largest to smallest. Shares are taken over the absolute total,
so a currency that adds to it always has a positive share and one that
takes from it (debt) a negative one; with a zero total there's no share.

Currencies that hide their exchanged amounts still show up, since they
still count towards the consolidated total, and so do exchange targets
that only exist through the deep search.

On the Net Worth page, each Consolidated row now lists its components
with their share and amount in the consolidated currency. Clicking one
shows that currency's line on the charts, like the currency rows above.
2026-11-11 21:27:17 +05:30
Herculino Trotta 6c3c4c03ec Merge pull request #668 from eitchtee/fix/insights-overview-exchanged
fix(insights): show exchanged amounts in the overview
2026-11-11 10:53:21 -03:00
Herculino Trotta 220ccee140 fix(insights): show exchanged amounts in the overview 2026-11-11 10:53:15 -03:00
Herculino Trotta 1c7bc3db1d Merge pull request #667 from eitchtee/fix/yearly-filter-pills-wrap
fix(yearly-overview): wrap long names in account and currency filters
2026-11-11 10:53:13 -03:00
Herculino Trotta bf83975b97 fix(yearly-overview): wrap long names in account and currency filters 2026-11-11 10:53:04 -03:00
Herculino Trotta 1ab06d544d Merge pull request #666 from eitchtee/fix/badge-overflow
fix(ui): let badges grow to fit long text
2026-11-11 10:52:58 -03:00
Herculino Trotta d2cd4d577a fix(ui): let badges grow to fit long text 2026-11-11 10:52:50 -03:00
eitchtee 4d886a4fea chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-11-11 13:41:24 +00:00
Herculino Trotta 3a619dc197 Merge pull request #665 from eitchtee/feat/investments-exchanged-amounts
feat(investments): show exchanged amounts in movements and honor the display switch
2026-11-11 10:40:56 -03:00
Herculino Trotta cd33b82b4e feat(investments): show exchanged amounts in movements and honor the display switch
Movements without an other-currency transfer side now show the exchanged
amount, and the converted value card follows show_exchanged_amounts.
2026-11-11 10:39:06 -03:00
eitchtee 7aed2e8574 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-11-11 13:27:09 +00:00
Herculino Trotta 9d36af78c4 Merge pull request #664 from officialasishkumar/feat/optional-exchanged-amounts
feat(currencies): make displaying exchanged amounts optional
2026-11-11 10:26:39 -03:00
Herculino Trotta 00d37d8c81 refactor(currencies): keep the exchanged amounts display rule in one place
Account.displayed_exchange_currency holds the account/currency precedence for transactions, and shown_exchange_currency() expresses the same rule as a query expression for the insights and account totals, replacing the per-module checks.
2026-11-11 10:22:50 -03:00
Asish Kumar d3f29f5ad1 feat(currencies): make displaying exchanged amounts optional
Currencies and accounts get a "Show exchanged amounts" switch, on by
default. Turning it off hides the amounts converted to the exchange
currency without removing the exchange currency, so consolidated totals
on the Net Worth page keep working.

The switch belongs to whichever one provides the exchange currency:

- Transactions (including both legs of combined transfers) use the
  account's switch when the account has its own exchange currency, and
  the currency's switch otherwise, matching how the exchanged amount is
  picked.
- Account cards and Net Worth account rows follow the account.
- Currency cards, the monthly summary and daily allowance, Net Worth
  currency rows and the month-by-month and year-by-year insights follow
  the currency.

Currency totals still convert hidden currencies, because the
consolidated totals are built from those values; the setting only
leaves them out of the displayed data. The API keeps returning
exchanged_amount for transactions, and the account serializer exposes
the new field (currencies already serialize all fields).

Closes #486
2026-11-11 15:26:04 +05:30
eitchtee 60a0365b23 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-11-11 01:05:02 +00:00
Herculino Trotta edd045607f Merge pull request #663 from eitchtee/feat/dca-improvements
feat(dca): optional amounts, currency-checked links and entities
2026-11-10 22:04:34 -03:00
Herculino Trotta 3468b10e94 feat(dca): limit create-transaction accounts to the strategy currencies 2026-11-10 21:57:39 -03:00
Herculino Trotta 750b86394e feat(dca): optional amounts, currency-checked links and entities on entries
- Amounts default to the linked transaction's amount when left empty
- Linked transactions must be in the strategy's payment/target currency; pickers only search those
- Allow setting entities on transactions created from an entry
- Fix crash when only the expense transaction was linked
2026-11-10 21:55:29 -03:00
Herculino Trotta 706f8fea2b feat(transactions): allow filtering the transaction picker search
TransactionSelect takes a filters dict sent as query params; the search endpoint applies allowlisted ones (currency, account).
2026-11-10 21:55:29 -03:00
Herculino Trotta eafaf0027c Merge pull request #662 from eitchtee/fix/investments-short-period-return
fix(investments): solve short-period return over the holding period
2026-11-10 20:38:42 -03:00
Herculino Trotta 0093cabfc9 fix(investments): solve short-period return over the holding period
Holdings under a year annualized their rate before turning it back into
the period return, so a few days with a modest gain (+1.3% in one day is
~10,600% a year) fell outside xirr's search range and showed no percentage.
Solve the rate over the period held instead.
2026-11-10 20:35:03 -03:00
eitchtee 37ecc7da1b chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-11-10 13:59:54 +00:00
Herculino Trotta 5d5a423db5 Merge pull request #661 from eitchtee/weblate
Translations update from Weblate
2026-11-10 10:59:29 -03:00
Weblate b6ac69716c Merge remote-tracking branch 'origin/main' 2026-11-10 13:55:36 +00:00
eitchtee c256671acc chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-11-10 13:55:32 +00:00
Weblate 0e62594133 Merge remote-tracking branch 'origin/main' 2026-11-10 13:55:06 +00:00
Herculino Trotta 07933ac5c1 Merge pull request #660 from jayhemnani9910/fix/recurring-month-end-keeps-day
fix(transactions): keep a recurrence on its day after a short month
2026-11-10 10:55:03 -03:00
masttera 3c30116072 locale(Russian): update translation
Currently translated at 56.6% (543 of 959 strings)

Translation: WYGIWYH/App
Translate-URL: https://translations.herculino.com/projects/wygiwyh/app/ru/
2026-11-10 07:58:57 +00:00
eitchtee 7ebcccdeda chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-11-10 02:41:08 +00:00
Herculino Trotta 674870641c Merge pull request #659 from eitchtee/fix/linked-transfer-exchanged-values
feat(transactions): redesign combined transfer items + show exchanged amounts for both sides
2026-11-09 23:40:42 -03:00
Herculino Trotta 0854805630 feat(transactions): show combined transfers as two legs on a timeline
- Shared header (date, description, badges); each side gets its own row:
  account with its category, tags and entities, amount + exchanged amount
- Timeline rail from the transfer icon branching into the out/in icons
- Amounts drop below on small screens and sit bottom-right on lg, like
  regular items; spacing matches regular item metadata rows
- Notes move below the legs on transfers so they don't sit in the rail
2026-11-09 23:35:56 -03:00
Herculino Trotta 4826c4932a fix(transactions): show exchanged amount for both sides of combined transfers 2026-11-09 23:16:47 -03:00
Jay Hemnani 6a289327b6 fix(transactions): keep a recurrence on its day after a short month
A monthly recurrence on the 31st clamped to Feb 28 and stayed on the
28th for every later month.
2026-11-10 03:51:15 +05:30
eitchtee bdc0603327 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-11-09 02:35:42 +00:00
Herculino Trotta a7ae1c7ec4 Merge pull request #657 from eitchtee/feat/distribution-totals-bracket
feat(distribution): show combined projected + current percentage
2026-11-08 23:34:56 -03:00
Herculino Trotta 34ba0c6e6a Merge pull request #658 from eitchtee/feat/investments-redesign
feat(investments): calmer list and detail pages
2026-11-08 23:34:49 -03:00
Herculino Trotta d0b6be14e7 feat(investments): calmer list and detail pages, group holdings by asset class
- One hero number with a single return line; secondary totals behind a Details toggle
- Holdings in one list card with quiet group subheaders, value and %/yr per row
- Group holdings by account group or asset class, remembered in the session
- Detail page: value and chart up top, Your position and home-currency sidebar
- Replace tooltips with visible text; keep amounts from wrapping on phones
2026-11-08 21:48:40 -03:00
Herculino Trotta 55f064ee7c feat(distribution): bracket projected + current segments with their sum
Remove the unused exchanged branch from calculate_percentage_distribution; nothing read it and it mixed up income_current with expense_projected.
2026-11-08 20:16:13 -03:00
eitchtee 10cb4465d8 chore(locale): update translation files
[skip ci] Automatically generated by Django makemessages workflow
2026-11-08 22:24:02 +00:00
69 changed files with 8467 additions and 6327 deletions

No files matched your search

+2
View File
@@ -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",
),
),
]
+21
View File
@@ -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:
+1
View File
@@ -80,6 +80,7 @@ class AccountSerializer(serializers.ModelSerializer):
"currency_id",
"exchange_currency",
"exchange_currency_id",
"show_exchanged_amounts",
"is_asset",
"is_investment",
"investment_class",
+18 -1
View File
@@ -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
+5 -1
View File
@@ -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
+6
View File
@@ -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
+2
View File
@@ -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",
),
),
]
+9
View File
@@ -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,
+13
View File
@@ -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
View File
@@ -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)
+68 -1
View File
@@ -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)
+23 -3
View File
@@ -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'),
),
]
+21
View File
@@ -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"
)
+15
View File
@@ -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)
+67 -5
View File
@@ -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()
+11 -4
View File
@@ -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,
+6 -6
View File
@@ -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,
+16 -7
View File
@@ -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
+6 -6
View File
@@ -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,
+11 -11
View File
@@ -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
View File
@@ -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/",
+36 -14
View File
@@ -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()),
+41
View File
@@ -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%")
+41 -29
View File
@@ -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"])
+65 -71
View File
@@ -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)
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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>
+25 -66
View File
@@ -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 %}
+134 -134
View File
@@ -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 %}
+43 -58
View File
@@ -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 %}
+37 -5
View File
@@ -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"
+18 -4
View File
@@ -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,
+2 -1
View File
@@ -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 => {
+6
View File
@@ -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;
}
}
Generated
+3 -3
View File
@@ -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]]