diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2a704fa5..038881a1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,12 +60,6 @@ jobs: - name: Install dependencies run: uv sync --frozen - # Import code reads/writes the Docker image's paths directly - - name: Create container paths used by the app - run: | - sudo mkdir -p /usr/src/app/temp /usr/src/app/import_presets - sudo chown -R "$(id -u):$(id -g)" /usr/src/app - - name: Check for missing migrations working-directory: app run: uv run python manage.py makemigrations --check --dry-run diff --git a/.gitignore b/.gitignore index 93ba54a4..7c4160ef 100644 --- a/.gitignore +++ b/.gitignore @@ -168,3 +168,4 @@ postgres_data/ # Private local uploads app/attachments/ +app/temp/ diff --git a/README.md b/README.md index 9154a734..ba019170 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,8 @@ To create the first user, open the container's console using Unraid's UI, by cli | ENABLE_SOFT_DELETE | true\|false | false | Whether to enable transactions soft delete, if enabled, deleted transactions will remain in the database. Useful for imports and avoiding duplicate entries. | | KEEP_DELETED_TRANSACTIONS_FOR | int | 365 | Time in days to keep soft deleted transactions for. If 0, will keep all transactions indefinitely. Only works if ENABLE_SOFT_DELETE is true. | | TASK_WORKERS | int | 1 | How many workers to have for async tasks. One should be enough for most use cases | +| IMPORT_TEMP_ROOT | string | /usr/src/app/temp | Where uploaded import files wait to be processed. Defaults to the `temp` folder inside the app folder. Must be writable and shared between the web and task processes. | +| CACHE_ROOT | string | /var/tmp | The writable folder the file-based caches are stored in. | | DEMO | true\|false | false | If demo mode is enabled. | | ADMIN_EMAIL | string | None | Automatically creates an admin account with this email. Must have `ADMIN_PASSWORD` also set. | | ADMIN_PASSWORD | string | None | Automatically creates an admin account with this password. Must have `ADMIN_EMAIL` also set. | diff --git a/app/WYGIWYH/settings.py b/app/WYGIWYH/settings.py index c87e0f93..9adb0d72 100644 --- a/app/WYGIWYH/settings.py +++ b/app/WYGIWYH/settings.py @@ -78,6 +78,7 @@ INSTALLED_APPS = [ "apps.rules.apps.RulesConfig", "apps.calendar_view.apps.CalendarViewConfig", "apps.dca.apps.DcaConfig", + "apps.investments.apps.InvestmentsConfig", "pwa", "allauth", "allauth.account", @@ -314,6 +315,9 @@ LOCALE_PATHS = [BASE_DIR / "locale"] STATIC_URL = "static/" STATIC_ROOT = BASE_DIR / "static_files" ATTACHMENT_MEDIA_ROOT = BASE_DIR / "attachments" +IMPORT_PRESETS_ROOT = BASE_DIR / "import_presets" +# Web and task workers must see the same folder: uploads wait here to be processed +IMPORT_TEMP_ROOT = Path(os.getenv("IMPORT_TEMP_ROOT") or BASE_DIR / "temp").resolve() STATICFILES_DIRS = [ ROOT_DIR / "frontend" / "build", @@ -325,15 +329,17 @@ STATICFILES_FINDERS = [ "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] +CACHE_ROOT = Path(os.getenv("CACHE_ROOT") or "/var/tmp").resolve() + CACHES = { "default": { "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", - "LOCATION": "/var/tmp/django_cache", + "LOCATION": CACHE_ROOT / "django_cache", }, # Kept apart from "default": cachalot fills it and FileBasedCache culls random entries past MAX_ENTRIES "update_check": { "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", - "LOCATION": "/var/tmp/django_cache_update_check", + "LOCATION": CACHE_ROOT / "django_cache_update_check", }, } diff --git a/app/WYGIWYH/urls.py b/app/WYGIWYH/urls.py index 10cd8fc6..fb8c9cf0 100644 --- a/app/WYGIWYH/urls.py +++ b/app/WYGIWYH/urls.py @@ -94,6 +94,7 @@ urlpatterns = [ path("", include("apps.rules.urls")), path("", include("apps.calendar_view.urls")), path("", include("apps.dca.urls")), + path("", include("apps.investments.urls")), path("", include("apps.mini_tools.urls")), path("", include("apps.import_app.urls")), path("", include("apps.export_app.urls")), diff --git a/app/apps/accounts/forms.py b/app/apps/accounts/forms.py index 59030301..abb3cf8d 100644 --- a/app/apps/accounts/forms.py +++ b/app/apps/accounts/forms.py @@ -1,6 +1,7 @@ import calendar -from apps.accounts.models import Account, AccountGroup +from apps.accounts.models import Account, AccountGroup, InvestmentClass +from apps.common.middleware.thread_local import get_current_user from apps.common.fields.forms.dynamic_select import ( DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -9,12 +10,16 @@ from apps.common.widgets.crispy.daisyui import Switch from apps.common.widgets.crispy.submit import NoClassSubmit from apps.common.widgets.datepicker import AirDatePickerInput, AirMonthYearPickerInput from apps.common.widgets.decimal import ArbitraryDecimalDisplayNumberInput -from apps.common.widgets.tom_select import TomSelect +from apps.common.widgets.tom_select import TomSelect, TomSelectMultiple from apps.currencies.models import Currency -from apps.transactions.models import TransactionCategory, TransactionTag +from apps.transactions.models import ( + TransactionCategory, + TransactionEntity, + TransactionTag, +) from crispy_forms.bootstrap import FormActions from crispy_forms.helper import FormHelper -from crispy_forms.layout import Column, Field, Layout, Row +from crispy_forms.layout import HTML, Column, Div, Field, Layout, Row from django import forms from django.db.models import Q from django.utils import timezone @@ -51,6 +56,30 @@ class AccountGroupForm(forms.ModelForm): ) +class InvestmentClassForm(forms.ModelForm): + class Meta: + model = InvestmentClass + fields = ["name", "target"] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.helper = FormHelper() + self.helper.form_tag = False + self.helper.form_method = "post" + self.helper.layout = Layout( + "name", + "target", + FormActions( + NoClassSubmit( + "submit", + _("Update") if self.instance.pk else _("Add"), + css_class="btn btn-primary", + ), + ), + ) + + class AccountForm(forms.ModelForm): group = DynamicModelChoiceField( create_field="name", @@ -59,6 +88,14 @@ class AccountForm(forms.ModelForm): required=False, ) + investment_class = DynamicModelChoiceField( + create_field="name", + label=_("Asset class"), + model=InvestmentClass, + required=False, + help_text=Account._meta.get_field("investment_class").help_text, + ) + class Meta: model = Account fields = [ @@ -68,16 +105,39 @@ class AccountForm(forms.ModelForm): "exchange_currency", "is_asset", "is_archived", + "is_investment", + "investment_class", + "income_entities", + "income_categories", + "income_tags", ] widgets = { "currency": TomSelect(), "exchange_currency": TomSelect(), + "income_entities": TomSelectMultiple(), + "income_categories": TomSelectMultiple(), + "income_tags": TomSelectMultiple(), } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["group"].queryset = AccountGroup.objects.all() + self.fields["investment_class"].queryset = InvestmentClass.all_objects.filter( + Q(owner=get_current_user()) + | Q(owner=None) + # Keep a class from the account's owner when it's shared with us + | Q(accounts=self.instance.id or 0) + ).distinct() + selectable = Q(active=True) + if self.instance.id: + selectable |= Q(investment_accounts=self.instance.id) + for name, model in ( + ("income_entities", TransactionEntity), + ("income_categories", TransactionCategory), + ("income_tags", TransactionTag), + ): + self.fields[name].queryset = model.objects.filter(selectable).distinct() if self.instance.id: qs = Currency.objects.filter( @@ -101,6 +161,21 @@ class AccountForm(forms.ModelForm): Switch("is_archived"), "currency", "exchange_currency", + Switch("is_investment"), + # Only relevant for investments: shown while the switch above is on + Div( + "investment_class", + "income_entities", + "income_categories", + "income_tags", + css_id="investment_income", + css_class="" if self.instance.is_investment else "hidden", + ), + HTML( + '' + ), ) if self.instance and self.instance.pk: diff --git a/app/apps/accounts/migrations/0017_account_income_entity_account_is_investment.py b/app/apps/accounts/migrations/0017_account_income_entity_account_is_investment.py new file mode 100644 index 00000000..56649971 --- /dev/null +++ b/app/apps/accounts/migrations/0017_account_income_entity_account_is_investment.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.17 on 2026-11-05 01:12 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0016_account_untracked_by'), + ('transactions', '0052_transfer_templates'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='income_entity', + field=models.ForeignKey(blank=True, help_text="Income transactions with this entity (dividends, interest, rent) count towards this investment's return.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='investment_accounts', to='transactions.transactionentity', verbose_name='Income entity'), + ), + migrations.AddField( + model_name='account', + name='is_investment', + field=models.BooleanField(default=False, help_text='Show this account in the Investments tracker. Set an exchange currency to price it by units (stocks, ETFs, crypto); leave it empty to track it by value (fixed income, property, pensions).', verbose_name='Investment'), + ), + ] diff --git a/app/apps/accounts/migrations/0018_income_entities.py b/app/apps/accounts/migrations/0018_income_entities.py new file mode 100644 index 00000000..fa4c91e7 --- /dev/null +++ b/app/apps/accounts/migrations/0018_income_entities.py @@ -0,0 +1,43 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def copy_income_entity(apps, schema_editor): + Account = apps.get_model("accounts", "Account") + for account in Account.objects.exclude(income_entity=None): + account.income_entities.add(account.income_entity_id) + + +class Migration(migrations.Migration): + dependencies = [ + ("accounts", "0017_account_income_entity_account_is_investment"), + ("transactions", "0052_transfer_templates"), + ] + + operations = [ + # Free the "investment_accounts" reverse name for the new field + migrations.AlterField( + model_name="account", + name="income_entity", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="transactions.transactionentity", + ), + ), + migrations.AddField( + model_name="account", + name="income_entities", + field=models.ManyToManyField( + blank=True, + help_text="Income transactions with any of these entities (dividends, interest, rent) count towards this investment's return.", + related_name="investment_accounts", + to="transactions.transactionentity", + verbose_name="Income entities", + ), + ), + migrations.RunPython(copy_income_entity, migrations.RunPython.noop), + migrations.RemoveField(model_name="account", name="income_entity"), + ] diff --git a/app/apps/accounts/migrations/0019_income_categories_and_tags.py b/app/apps/accounts/migrations/0019_income_categories_and_tags.py new file mode 100644 index 00000000..9a30e0b2 --- /dev/null +++ b/app/apps/accounts/migrations/0019_income_categories_and_tags.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.17 on 2026-11-07 04:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0018_income_entities'), + ('transactions', '0052_transfer_templates'), + ] + + operations = [ + migrations.AddField( + model_name='account', + name='income_categories', + field=models.ManyToManyField(blank=True, help_text="Transactions in this account with any of these categories are its result (interest, taxes, fees). On accounts tracked by value, everything else that isn't a transfer counts as money coming in or going out.", related_name='investment_accounts', to='transactions.transactioncategory', verbose_name='Income categories'), + ), + migrations.AddField( + model_name='account', + name='income_tags', + field=models.ManyToManyField(blank=True, help_text='Same as income categories, for transactions with any of these tags.', related_name='investment_accounts', to='transactions.transactiontag', verbose_name='Income tags'), + ), + migrations.AlterField( + model_name='account', + name='income_entities', + field=models.ManyToManyField(blank=True, help_text="Transactions with any of these entities count towards this investment's return: in this account, and income paid into other accounts (dividends, rent).", related_name='investment_accounts', to='transactions.transactionentity', verbose_name='Income entities'), + ), + ] diff --git a/app/apps/accounts/migrations/0020_investment_classes.py b/app/apps/accounts/migrations/0020_investment_classes.py new file mode 100644 index 00000000..274abe34 --- /dev/null +++ b/app/apps/accounts/migrations/0020_investment_classes.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.17 on 2026-11-08 03:26 + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0019_income_categories_and_tags'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='InvestmentClass', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('target', models.DecimalField(blank=True, decimal_places=2, help_text='Share of your investments you want in this class. Leave it empty to leave the class out of the targets.', max_digits=5, null=True, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(100)], verbose_name='Target allocation (%)')), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)s_owned', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Asset class', + 'verbose_name_plural': 'Asset classes', + 'db_table': 'investment_classes', + 'ordering': ['name', 'id'], + 'unique_together': {('owner', 'name')}, + }, + ), + migrations.AddField( + model_name='account', + name='investment_class', + field=models.ForeignKey(blank=True, help_text='Groups investments in the allocation charts, e.g. Fixed income, Stocks, Real estate.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='accounts', to='accounts.investmentclass', verbose_name='Asset class'), + ), + ] diff --git a/app/apps/accounts/models.py b/app/apps/accounts/models.py index 55c064c0..79d5e383 100644 --- a/app/apps/accounts/models.py +++ b/app/apps/accounts/models.py @@ -1,10 +1,17 @@ from django.conf import settings from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from django.template.defaultfilters import floatformat from django.utils.translation import gettext_lazy as _ from apps.common.middleware.thread_local import get_current_user -from apps.common.models import SharedObject, SharedObjectManager +from apps.common.models import ( + OwnedObject, + OwnedObjectManager, + SharedObject, + SharedObjectManager, +) from apps.transactions.models import Transaction @@ -25,6 +32,54 @@ class AccountGroup(SharedObject): return self.name +class InvestmentClass(OwnedObject): + name = models.CharField(max_length=255, verbose_name=_("Name")) + target = models.DecimalField( + max_digits=5, + decimal_places=2, + null=True, + blank=True, + validators=[MinValueValidator(0), MaxValueValidator(100)], + verbose_name=_("Target allocation (%)"), + help_text=_( + "Share of your investments you want in this class. Leave it empty to " + "leave the class out of the targets." + ), + ) + + objects = OwnedObjectManager() + all_objects = models.Manager() # Unfiltered manager + + def clean(self): + # Shared by the form and the API; owner is set on save, so neither can + # rely on Django's (owner, name) unique check. + others = InvestmentClass.all_objects.filter( + owner=self.owner or get_current_user() + ).exclude(pk=self.pk) + errors = {} + if self.name and others.filter(name=self.name).exists(): + errors["name"] = _("You already have an asset class with this name.") + if self.target: + taken = others.aggregate(total=models.Sum("target"))["total"] or 0 + if taken + self.target > 100: + errors["target"] = _( + "Targets can't add up to more than 100%%. Other classes already " + "take %(taken)s%%." + ) % {"taken": floatformat(taken, -2)} + if errors: + raise ValidationError(errors) + + class Meta: + verbose_name = _("Asset class") + verbose_name_plural = _("Asset classes") + db_table = "investment_classes" + unique_together = (("owner", "name"),) + ordering = ["name", "id"] + + def __str__(self): + return self.name + + class Account(SharedObject): name = models.CharField(max_length=255, verbose_name=_("Name")) group = models.ForeignKey( @@ -57,6 +112,58 @@ class Account(SharedObject): "Asset accounts count towards your Net Worth, but not towards your month." ), ) + is_investment = models.BooleanField( + default=False, + verbose_name=_("Investment"), + help_text=_( + "Show this account in the Investments tracker. Set an exchange currency to " + "price it by units (stocks, ETFs, crypto); leave it empty to track it by " + "value (fixed income, property, pensions)." + ), + ) + investment_class = models.ForeignKey( + InvestmentClass, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="accounts", + verbose_name=_("Asset class"), + help_text=_( + "Groups investments in the allocation charts, e.g. Fixed income, Stocks, " + "Real estate." + ), + ) + income_entities = models.ManyToManyField( + "transactions.TransactionEntity", + blank=True, + related_name="investment_accounts", + verbose_name=_("Income entities"), + help_text=_( + "Transactions with any of these entities count towards this investment's " + "return: in this account, and income paid into other accounts (dividends, " + "rent)." + ), + ) + income_categories = models.ManyToManyField( + "transactions.TransactionCategory", + blank=True, + related_name="investment_accounts", + verbose_name=_("Income categories"), + help_text=_( + "Transactions in this account with any of these categories are its result " + "(interest, taxes, fees). On accounts tracked by value, everything else " + "that isn't a transfer counts as money coming in or going out." + ), + ) + income_tags = models.ManyToManyField( + "transactions.TransactionTag", + blank=True, + related_name="investment_accounts", + verbose_name=_("Income tags"), + help_text=_( + "Same as income categories, for transactions with any of these tags." + ), + ) is_archived = models.BooleanField( default=False, verbose_name=_("Archived"), diff --git a/app/apps/api/serializers/accounts.py b/app/apps/api/serializers/accounts.py index db95f256..02082ba1 100644 --- a/app/apps/api/serializers/accounts.py +++ b/app/apps/api/serializers/accounts.py @@ -1,9 +1,13 @@ +from copy import copy +from decimal import Decimal + +from django.core.exceptions import ValidationError as DjangoValidationError from django.db.models import Q from rest_framework import serializers from rest_framework.permissions import IsAuthenticated +from apps.accounts.models import AccountGroup, Account, InvestmentClass from apps.api.serializers.currencies import CurrencySerializer -from apps.accounts.models import AccountGroup, Account from apps.currencies.models import Currency @@ -15,6 +19,24 @@ class AccountGroupSerializer(serializers.ModelSerializer): fields = "__all__" +class InvestmentClassSerializer(serializers.ModelSerializer): + permission_classes = [IsAuthenticated] + + class Meta: + model = InvestmentClass + fields = ["id", "name", "target"] + + def validate(self, attrs): + instance = copy(self.instance) if self.instance else InvestmentClass() + for name, value in attrs.items(): + setattr(instance, name, value) + try: + instance.clean() # unique name and targets up to 100% + except DjangoValidationError as e: + raise serializers.ValidationError(e.message_dict) + return attrs + + class AccountSerializer(serializers.ModelSerializer): group = AccountGroupSerializer(read_only=True) group_id = serializers.PrimaryKeyRelatedField( @@ -36,6 +58,15 @@ class AccountSerializer(serializers.ModelSerializer): allow_null=True, ) + investment_class = InvestmentClassSerializer(read_only=True) + investment_class_id = serializers.PrimaryKeyRelatedField( + queryset=InvestmentClass.objects.all(), + source="investment_class", + write_only=True, + allow_null=True, + required=False, + ) + permission_classes = [IsAuthenticated] class Meta: @@ -50,6 +81,12 @@ class AccountSerializer(serializers.ModelSerializer): "exchange_currency", "exchange_currency_id", "is_asset", + "is_investment", + "investment_class", + "investment_class_id", + "income_entities", + "income_categories", + "income_tags", ] def __init__(self, *args, **kwargs): @@ -58,6 +95,7 @@ class AccountSerializer(serializers.ModelSerializer): if request and request.user.is_authenticated: # Reload the queryset to get an updated version with the requesting user self.fields["group_id"].queryset = AccountGroup.objects.all() + self.fields["investment_class_id"].queryset = InvestmentClass.objects.all() def create(self, validated_data): return Account.objects.create(**validated_data) @@ -76,3 +114,44 @@ class AccountBalanceSerializer(serializers.Serializer): projected_balance = serializers.DecimalField(max_digits=20, decimal_places=10) currency = CurrencySerializer() + + +class InvestmentPerformanceSerializer(serializers.Serializer): + """Serializer for an investment account's performance.""" + + currency = CurrencySerializer() + priced = serializers.BooleanField() + quantity = serializers.DecimalField(max_digits=42, decimal_places=30) + price = serializers.DecimalField(max_digits=42, decimal_places=30, allow_null=True) + price_date = serializers.DateTimeField(allow_null=True) + average_cost = serializers.DecimalField( + max_digits=42, decimal_places=30, allow_null=True + ) + cost_basis = serializers.DecimalField(max_digits=42, decimal_places=30) + market_value = serializers.DecimalField( + max_digits=42, decimal_places=30, allow_null=True + ) + unrealized = serializers.DecimalField( + max_digits=42, decimal_places=30, allow_null=True + ) + realized = serializers.DecimalField(max_digits=42, decimal_places=30) + income = serializers.DecimalField(max_digits=42, decimal_places=30) + contributed = serializers.DecimalField(max_digits=42, decimal_places=30) + withdrawn = serializers.DecimalField(max_digits=42, decimal_places=30) + total_return = serializers.DecimalField(max_digits=42, decimal_places=30) + return_percentage = serializers.DecimalField( + max_digits=42, decimal_places=30, allow_null=True + ) + estimated = serializers.BooleanField() + money_weighted_return = serializers.SerializerMethodField( + help_text="Return weighted by when money went in and out, in percent: " + "annual (XIRR) when the first contribution is a year old or more, " + "otherwise over the period held." + ) + money_weighted_annualized = serializers.SerializerMethodField() + + def get_money_weighted_return(self, perf) -> Decimal | None: + return perf.money_weighted and perf.money_weighted["percentage"] + + def get_money_weighted_annualized(self, perf) -> bool | None: + return perf.money_weighted and perf.money_weighted["annualized"] diff --git a/app/apps/api/tests/test_investment_classes.py b/app/apps/api/tests/test_investment_classes.py new file mode 100644 index 00000000..00409afe --- /dev/null +++ b/app/apps/api/tests/test_investment_classes.py @@ -0,0 +1,81 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission +from django.test import TestCase, override_settings +from rest_framework.test import APIClient + +from apps.accounts.models import Account, InvestmentClass +from apps.currencies.models import Currency + + +@override_settings( + STORAGES={ + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" + }, + }, + WHITENOISE_AUTOREFRESH=True, + DEMO=False, +) +class InvestmentClassAPITests(TestCase): + def setUp(self): + User = get_user_model() + perms = Permission.objects.filter( + codename__in=[ + "add_investmentclass", + "change_investmentclass", + "delete_investmentclass", + "change_account", + ] + ) + self.user = User.objects.create_user(email="u@test.com", password="pw") + self.other = User.objects.create_user(email="o@test.com", password="pw") + for user in (self.user, self.other): + user.user_permissions.set(perms) + self.api = APIClient() + self.api.force_authenticate(User.objects.get(pk=self.user.pk)) + + def test_crud_and_validation(self): + response = self.api.post( + "/api/investment-classes/", {"name": "Stocks", "target": "60"}, format="json" + ) + self.assertEqual(response.status_code, 201, response.data) + stocks_id = response.data["id"] + + # Same name again, and targets past 100%: rejected, not a server error + response = self.api.post("/api/investment-classes/", {"name": "Stocks"}, format="json") + self.assertEqual(response.status_code, 400) + self.assertIn("name", response.data) + response = self.api.post( + "/api/investment-classes/", {"name": "Bonds", "target": "50"}, format="json" + ) + self.assertEqual(response.status_code, 400) + self.assertIn("target", response.data) + + # Editing keeps its own target out of the sum + response = self.api.patch( + f"/api/investment-classes/{stocks_id}/", {"target": "100"}, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + + # Another user's names and targets don't count, and they can't see ours + other = APIClient() + other.force_authenticate(get_user_model().objects.get(pk=self.other.pk)) + response = other.post( + "/api/investment-classes/", {"name": "Stocks", "target": "100"}, format="json" + ) + self.assertEqual(response.status_code, 201, response.data) + self.assertEqual(other.get(f"/api/investment-classes/{stocks_id}/").status_code, 404) + + def test_account_takes_a_class(self): + brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + stocks = InvestmentClass.all_objects.create(name="Stocks", owner=self.user) + account = Account.all_objects.create(name="PETR4", currency=brl, owner=self.user) + + response = self.api.patch( + f"/api/accounts/{account.id}/", {"investment_class_id": stocks.id}, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["investment_class"]["name"], "Stocks") + response = self.api.get("/api/accounts/", {"investment_class": stocks.id}) + self.assertEqual(response.data["count"], 1) diff --git a/app/apps/api/urls.py b/app/apps/api/urls.py index ee2d1fee..40d2b95e 100644 --- a/app/apps/api/urls.py +++ b/app/apps/api/urls.py @@ -12,6 +12,7 @@ router.register(r"installment-plans", views.InstallmentPlanViewSet) router.register(r"recurring-transactions", views.RecurringTransactionViewSet) router.register(r"account-groups", views.AccountGroupViewSet) router.register(r"accounts", views.AccountViewSet) +router.register(r"investment-classes", views.InvestmentClassViewSet) router.register(r"currencies", views.CurrencyViewSet) router.register(r"exchange-rates", views.ExchangeRateViewSet) router.register(r"dca/strategies", views.DCAStrategyViewSet) diff --git a/app/apps/api/views/accounts.py b/app/apps/api/views/accounts.py index f7e44e1b..c30157b9 100644 --- a/app/apps/api/views/accounts.py +++ b/app/apps/api/views/accounts.py @@ -4,14 +4,17 @@ from rest_framework.decorators import action from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from apps.accounts.models import AccountGroup, Account +from apps.accounts.models import AccountGroup, Account, InvestmentClass from apps.accounts.services import get_account_balance from apps.api.permissions import SHARED_OBJECT_PERMISSIONS from apps.api.serializers import ( AccountGroupSerializer, + InvestmentClassSerializer, AccountSerializer, AccountBalanceSerializer, + InvestmentPerformanceSerializer, ) +from apps.investments.services import get_holding_performance class AccountGroupViewSet(viewsets.ModelViewSet): @@ -32,12 +35,31 @@ class AccountGroupViewSet(viewsets.ModelViewSet): return AccountGroup.objects.all() +class InvestmentClassViewSet(viewsets.ModelViewSet): + """ViewSet for managing asset classes and their target allocation.""" + + queryset = InvestmentClass.objects.all() + serializer_class = InvestmentClassSerializer + filterset_fields = {"name": ["exact", "icontains"]} + search_fields = ["name"] + ordering_fields = "__all__" + ordering = ["id"] + + def get_queryset(self): + return InvestmentClass.objects.all() + + @extend_schema_view( balance=extend_schema( summary="Get account balance", description="Returns the current and projected balance for the account, along with currency data.", responses={200: AccountBalanceSerializer}, ), + performance=extend_schema( + summary="Get investment performance", + description="Returns quantity, cost basis, market value, realized/unrealized gains, income and total return, in the account's exchange currency (or its own currency when it has none).", + responses={200: InvestmentPerformanceSerializer}, + ), ) class AccountViewSet(viewsets.ModelViewSet): """ViewSet for managing accounts.""" @@ -48,9 +70,11 @@ class AccountViewSet(viewsets.ModelViewSet): filterset_fields = { "name": ["exact", "icontains"], "group": ["exact", "isnull"], + "investment_class": ["exact", "isnull"], "currency": ["exact"], "exchange_currency": ["exact", "isnull"], "is_asset": ["exact"], + "is_investment": ["exact"], "is_archived": ["exact"], "owner": ["exact"], } @@ -60,7 +84,7 @@ class AccountViewSet(viewsets.ModelViewSet): def get_queryset(self): return Account.objects.all().select_related( - "group", "currency", "exchange_currency" + "group", "currency", "exchange_currency", "investment_class" ) @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) @@ -80,3 +104,11 @@ class AccountViewSet(viewsets.ModelViewSet): ) return Response(serializer.data) + + @action(detail=True, methods=["get"], permission_classes=[IsAuthenticated]) + def performance(self, request, pk=None): + """Get investment performance (cost basis, value, returns) for an account.""" + account = self.get_object() + return Response( + InvestmentPerformanceSerializer(get_holding_performance(account)).data + ) diff --git a/app/apps/api/views/imports.py b/app/apps/api/views/imports.py index f8e6edb7..9abb945d 100644 --- a/app/apps/api/views/imports.py +++ b/app/apps/api/views/imports.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.core.files.storage import FileSystemStorage from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema, extend_schema_view, inline_serializer @@ -130,7 +131,7 @@ class ImportViewSet(viewsets.ViewSet): uploaded_file = serializer.validated_data["file"] # Save file to temp location - fs = FileSystemStorage(location="/usr/src/app/temp") + fs = FileSystemStorage(location=settings.IMPORT_TEMP_ROOT) filename = fs.save(uploaded_file.name, uploaded_file) file_path = fs.path(filename) diff --git a/app/apps/common/fields/forms/dynamic_select.py b/app/apps/common/fields/forms/dynamic_select.py index 3a49517a..4259c542 100644 --- a/app/apps/common/fields/forms/dynamic_select.py +++ b/app/apps/common/fields/forms/dynamic_select.py @@ -21,11 +21,19 @@ class DynamicModelChoiceField(forms.ModelChoiceField): super().__init__(queryset=self.queryset, *args, **kwargs) self._created_instance = None + def _get(self, **lookup): + # The form's queryset may hold choices the default manager hides, like + # another user's object already set on a shared record + try: + return self.queryset.get(**lookup) + except self.model.DoesNotExist: + return self.model.objects.get(**lookup) + def to_python(self, value): if value in self.empty_values: return None try: - return self.model.objects.get(**{self.to_field_name: value}) + return self._get(**{self.to_field_name: value}) except (ValueError, TypeError, self.model.DoesNotExist): return value # Return the raw value; we'll handle creation in clean() @@ -49,7 +57,7 @@ class DynamicModelChoiceField(forms.ModelChoiceField): try: if value.isdigit(): - return self.model.objects.get(id=value) + return self._get(id=value) else: raise self.model.DoesNotExist except self.model.DoesNotExist: diff --git a/app/apps/common/oauth_views.py b/app/apps/common/oauth_views.py index 54733580..a73b3b84 100644 --- a/app/apps/common/oauth_views.py +++ b/app/apps/common/oauth_views.py @@ -88,7 +88,7 @@ def _dcr_initial_access_token_ok(request): scheme, _, value = header.partition(" ") if scheme.lower() != "bearer" or not value: return False - return hmac.compare_digest(value, expected) + return hmac.compare_digest(value.encode(), expected.encode()) @require_http_methods(["GET"]) diff --git a/app/apps/common/tests/test_oauth.py b/app/apps/common/tests/test_oauth.py index b096ada9..7453c923 100644 --- a/app/apps/common/tests/test_oauth.py +++ b/app/apps/common/tests/test_oauth.py @@ -224,6 +224,18 @@ class DynamicClientRegistrationInitialAccessTokenTests(TestCase): self.assertEqual(response.json()["error"], "invalid_token") self.assertEqual(Application.objects.count(), 0) + def test_rejects_registration_with_non_ascii_initial_access_token(self): + response = self.client.post( + reverse("oauth-dynamic-client-registration"), + data=json.dumps({"redirect_uris": ["http://127.0.0.1:8765/callback"]}), + content_type="application/json", + HTTP_AUTHORIZATION="Bearer s3cret-iat\u00e9", + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(response.json()["error"], "invalid_token") + self.assertEqual(Application.objects.count(), 0) + def test_allows_registration_with_initial_access_token(self): response = self.client.post( reverse("oauth-dynamic-client-registration"), diff --git a/app/apps/common/widgets/tom_select.py b/app/apps/common/widgets/tom_select.py index 5c9b0548..0f22d106 100644 --- a/app/apps/common/widgets/tom_select.py +++ b/app/apps/common/widgets/tom_select.py @@ -1,3 +1,5 @@ +import json + from django.forms import SelectMultiple, widgets from django.urls import reverse from django.utils.translation import gettext_lazy as _ @@ -142,6 +144,7 @@ class TransactionSelect(TomSelect): def build_attrs(self, base_attrs, extra_attrs=None): attrs = super().build_attrs(base_attrs, extra_attrs) + attrs["data-render"] = "transaction" if self.load_income and self.load_expense: attrs["data-load"] = reverse("transactions_search") @@ -155,3 +158,13 @@ class TransactionSelect(TomSelect): ) return attrs + + def create_option(self, name, value, *args, **kwargs): + option = super().create_option(name, value, *args, **kwargs) + instance = getattr(value, "instance", None) + if instance is not None: + from apps.transactions.utils.select_search import transaction_option + + # tom-select reads this to render the selected transaction like the search results + option["attrs"]["data-data"] = json.dumps(transaction_option(instance)) + return option diff --git a/app/apps/currencies/exchange_rates/fetcher.py b/app/apps/currencies/exchange_rates/fetcher.py index 6b486304..c8450f70 100644 --- a/app/apps/currencies/exchange_rates/fetcher.py +++ b/app/apps/currencies/exchange_rates/fetcher.py @@ -166,6 +166,31 @@ class ExchangeRateFetcher: return target_currencies_qs, exchange_currencies + @staticmethod + def _store_rate(service, from_currency, to_currency, rate): + now = timezone.now() + rates = ExchangeRate.objects.filter( + automatic=True, from_currency=from_currency, to_currency=to_currency + ) + today = rates.filter(date__date=timezone.localdate(now)).order_by("-date") + existing = today.first() if service.one_per_day else None + if existing: + existing.rate, existing.date = rate, now + existing.save() + else: + ExchangeRate.objects.create( + automatic=True, + from_currency=from_currency, + to_currency=to_currency, + rate=rate, + date=now, + ) + if service.max_rates: + stale = rates.order_by("-date").values_list("id", flat=True)[ + service.max_rates : + ] + ExchangeRate.objects.filter(id__in=list(stale)).delete() + @staticmethod def _fetch_service_rates(service: ExchangeRateService) -> None: """Fetch rates for a specific service""" @@ -193,71 +218,15 @@ class ExchangeRateFetcher: processed_pairs = set() for from_currency, to_currency, rate in rates: - # Create a unique identifier for this currency pair + if provider.rates_inverted: + from_currency, to_currency = to_currency, from_currency pair_key = (from_currency.id, to_currency.id) if pair_key in processed_pairs: continue - - if provider.rates_inverted: - # If rates are inverted, we need to swap currencies - if service.singleton: - # Try to get the last automatically created exchange rate - exchange_rate = ( - ExchangeRate.objects.filter( - automatic=True, - from_currency=to_currency, - to_currency=from_currency, - ) - .order_by("-date") - .first() - ) - else: - exchange_rate = None - - if not exchange_rate: - ExchangeRate.objects.create( - automatic=True, - from_currency=to_currency, - to_currency=from_currency, - rate=rate, - date=timezone.now(), - ) - else: - exchange_rate.rate = rate - exchange_rate.date = timezone.now() - exchange_rate.save() - - processed_pairs.add((to_currency.id, from_currency.id)) - else: - # If rates are not inverted, we can use them as is - if service.singleton: - # Try to get the last automatically created exchange rate - exchange_rate = ( - ExchangeRate.objects.filter( - automatic=True, - from_currency=from_currency, - to_currency=to_currency, - ) - .order_by("-date") - .first() - ) - else: - exchange_rate = None - - if not exchange_rate: - ExchangeRate.objects.create( - automatic=True, - from_currency=from_currency, - to_currency=to_currency, - rate=rate, - date=timezone.now(), - ) - else: - exchange_rate.rate = rate - exchange_rate.date = timezone.now() - exchange_rate.save() - - processed_pairs.add((from_currency.id, to_currency.id)) + ExchangeRateFetcher._store_rate( + service, from_currency, to_currency, rate + ) + processed_pairs.add(pair_key) service.last_fetch = timezone.now() service.failure_count = 0 diff --git a/app/apps/currencies/forms.py b/app/apps/currencies/forms.py index 3090e8d5..526368a2 100644 --- a/app/apps/currencies/forms.py +++ b/app/apps/currencies/forms.py @@ -112,7 +112,8 @@ class ExchangeRateServiceForm(forms.ModelForm): "fetch_interval", "target_currencies", "target_accounts", - "singleton", + "one_per_day", + "max_rates", ] widgets = {"options": forms.Textarea(attrs={"rows": 2})} @@ -149,7 +150,6 @@ class ExchangeRateServiceForm(forms.ModelForm): "name", "service_type", Switch("is_active"), - Switch("singleton"), "api_key", "options", Row( @@ -158,6 +158,8 @@ class ExchangeRateServiceForm(forms.ModelForm): ), "target_currencies", "target_accounts", + Switch("one_per_day"), + "max_rates", ) if self.instance and self.instance.pk: diff --git a/app/apps/currencies/migrations/0027_exchangerateservice_retention.py b/app/apps/currencies/migrations/0027_exchangerateservice_retention.py new file mode 100644 index 00000000..abddedcf --- /dev/null +++ b/app/apps/currencies/migrations/0027_exchangerateservice_retention.py @@ -0,0 +1,43 @@ +import django.core.validators +from django.db import migrations, models + + +def singleton_to_max_rates(apps, schema_editor): + Service = apps.get_model("currencies", "ExchangeRateService") + Service.objects.filter(singleton=True).update(max_rates=1) + + +def max_rates_to_singleton(apps, schema_editor): + Service = apps.get_model("currencies", "ExchangeRateService") + Service.objects.filter(max_rates=1).update(singleton=True) + + +class Migration(migrations.Migration): + dependencies = [ + ("currencies", "0026_exchangerateservice_options"), + ] + + operations = [ + migrations.AddField( + model_name="exchangerateservice", + name="one_per_day", + field=models.BooleanField( + default=False, + help_text="Update the day's exchange rate on each fetch instead of adding a new one.", + verbose_name="One exchange rate per day", + ), + ), + migrations.AddField( + model_name="exchangerateservice", + name="max_rates", + field=models.PositiveIntegerField( + blank=True, + null=True, + validators=[django.core.validators.MinValueValidator(1)], + help_text="Keep at most this many automatic exchange rates per currency pair, deleting the oldest first. Leave empty to keep all.", + verbose_name="Exchange rates to keep", + ), + ), + migrations.RunPython(singleton_to_max_rates, max_rates_to_singleton), + migrations.RemoveField(model_name="exchangerateservice", name="singleton"), + ] diff --git a/app/apps/currencies/models.py b/app/apps/currencies/models.py index cd01d36b..6cf9c173 100644 --- a/app/apps/currencies/models.py +++ b/app/apps/currencies/models.py @@ -167,11 +167,20 @@ class ExchangeRateService(models.Model): blank=True, ) - singleton = models.BooleanField( - verbose_name=_("Single exchange rate"), + one_per_day = models.BooleanField( + verbose_name=_("One exchange rate per day"), default=False, help_text=_( - "Create one exchange rate and keep updating it. Avoids database clutter." + "Update the day's exchange rate on each fetch instead of adding a new one." + ), + ) + max_rates = models.PositiveIntegerField( + verbose_name=_("Exchange rates to keep"), + null=True, + blank=True, + validators=[MinValueValidator(1)], + help_text=_( + "Keep at most this many automatic exchange rates per currency pair, deleting the oldest first. Leave empty to keep all." ), ) diff --git a/app/apps/currencies/tests/test_automatic_exchange_rates.py b/app/apps/currencies/tests/test_automatic_exchange_rates.py index c3e04d9b..8c10b874 100644 --- a/app/apps/currencies/tests/test_automatic_exchange_rates.py +++ b/app/apps/currencies/tests/test_automatic_exchange_rates.py @@ -1,10 +1,11 @@ +import datetime from decimal import Decimal from unittest.mock import patch, MagicMock from django.test import TestCase from django.utils import timezone -from apps.currencies.models import Currency, ExchangeRateService +from apps.currencies.models import Currency, ExchangeRate, ExchangeRateService from apps.currencies.exchange_rates.fetcher import ExchangeRateFetcher @@ -107,3 +108,42 @@ class ExchangeRateServiceFailureTrackingTests(TestCase): self.service.refresh_from_db() self.assertIsNotNone(self.service.last_fetch) self.assertEqual(self.service.failure_count, 0) + + +class ExchangeRateRetentionTests(TestCase): + def setUp(self): + self.usd = Currency.objects.create(code="USD", name="Dollar", decimal_places=2) + self.eur = Currency.objects.create( + code="EUR", name="Euro", decimal_places=2, exchange_currency=self.usd + ) + self.service = ExchangeRateService.objects.create( + name="S", service_type=ExchangeRateService.ServiceType.FRANKFURTER + ) + + def fetch(self, rate, when): + with patch("django.utils.timezone.now", return_value=when): + ExchangeRateFetcher._store_rate(self.service, self.eur, self.usd, Decimal(rate)) + + def rates(self): + return list( + ExchangeRate.objects.order_by("date").values_list("rate", flat=True) + ) + + def test_keeps_every_fetch_by_default(self): + for hour in (1, 2, 3): + self.fetch(hour, timezone.now().replace(hour=hour)) + self.assertEqual(len(self.rates()), 3) + + def test_max_rates_deletes_the_oldest(self): + self.service.max_rates = 2 + for hour in (1, 2, 3): + self.fetch(hour, timezone.now().replace(hour=hour)) + self.assertEqual(self.rates(), [Decimal(2), Decimal(3)]) + + def test_one_per_day_updates_the_days_rate(self): + self.service.one_per_day = True + day = timezone.localtime().replace(hour=10) + self.fetch(1, day) + self.fetch(2, day.replace(hour=11)) + self.fetch(3, day + datetime.timedelta(days=1)) + self.assertEqual(self.rates(), [Decimal(2), Decimal(3)]) diff --git a/app/apps/dca/forms.py b/app/apps/dca/forms.py index 39b73aca..03a9f539 100644 --- a/app/apps/dca/forms.py +++ b/app/apps/dca/forms.py @@ -12,7 +12,7 @@ from apps.dca.models import DCAEntry, DCAStrategy from apps.transactions.models import Transaction, TransactionCategory, TransactionTag from crispy_forms.bootstrap import AccordionGroup, FormActions, Accordion from crispy_forms.helper import FormHelper -from crispy_forms.layout import HTML, Column, Layout, Row +from crispy_forms.layout import HTML, Column, Div, Layout, Row from django import forms from django.utils.translation import gettext_lazy as _ @@ -157,34 +157,21 @@ class DCAEntryForm(forms.ModelForm): AccordionGroup( _("Create transaction"), Switch("create_transaction"), - Row( - Column( - Row( - Column( - "from_account", - ), - ), - Row( - Column("from_category"), - Column("from_tags"), - ), + Div( + "from_account", + Row( + Column("from_category"), + Column("from_tags"), ), - css_class="p-1 mx-1 my-3 border rounded-3", + css_class="bg-base-100 rounded-box p-4 border-base-content/60 border my-3", ), - Row( - Column( - Row( - Column( - "to_account", - css_class="form-group", - ), - ), - Row( - Column("to_category"), - Column("to_tags"), - ), + Div( + "to_account", + Row( + Column("to_category"), + Column("to_tags"), ), - css_class="p-1 mx-1 my-3 border rounded-3", + css_class="bg-base-100 rounded-box p-4 border-base-content/60 border", ), active=False, ), @@ -289,6 +276,14 @@ class DCAEntryForm(forms.ModelForm): raise forms.ValidationError( _("From and To accounts must be different.") ) + else: + # Both sides of a purchase are linked as a transfer on save + expense = cleaned_data.get("expense_transaction") + income = cleaned_data.get("income_transaction") + if expense and income: + error = expense.transfer_link_error(income) + if error: + self.add_error("income_transaction", error) return cleaned_data @@ -334,6 +329,8 @@ class DCAEntryForm(forms.ModelForm): ) to_transaction.tags.set(self.cleaned_data.get("to_tags", [])) + from_transaction.link_transfer(to_transaction) + instance.expense_transaction = from_transaction instance.income_transaction = to_transaction else: @@ -343,6 +340,8 @@ class DCAEntryForm(forms.ModelForm): if instance.income_transaction: instance.income_transaction.amount = instance.amount_received instance.income_transaction.save() + if instance.expense_transaction and instance.income_transaction: + instance.expense_transaction.link_transfer(instance.income_transaction) instance.strategy = self.strategy instance.save() diff --git a/app/apps/dca/migrations/0005_link_entry_transactions.py b/app/apps/dca/migrations/0005_link_entry_transactions.py new file mode 100644 index 00000000..e440deeb --- /dev/null +++ b/app/apps/dca/migrations/0005_link_entry_transactions.py @@ -0,0 +1,39 @@ +from django.db import migrations + + +def link_entry_transactions(apps, schema_editor): + """Joins each DCA entry's expense and income into a transfer, so investment + tracking sees the purchase. Mirrors Transaction.transfer_link_error().""" + DCAEntry = apps.get_model("dca", "DCAEntry") + Transaction = apps.get_model("transactions", "Transaction") + + entries = DCAEntry.objects.filter( + expense_transaction__isnull=False, income_transaction__isnull=False + ).select_related("expense_transaction", "income_transaction") + for entry in entries: + expense, income = entry.expense_transaction, entry.income_transaction + if ( + expense.type != "EX" + or income.type != "IN" + or expense.account_id == income.account_id + or expense.linked_transaction_id + or income.linked_transaction_id + # a legacy one-way link already points at one of them + or Transaction.objects.filter( + linked_transaction_id__in=(expense.pk, income.pk) + ).exists() + ): + continue + Transaction.objects.filter(pk=expense.pk).update(linked_transaction=income) + Transaction.objects.filter(pk=income.pk).update(linked_transaction=expense) + + +class Migration(migrations.Migration): + dependencies = [ + ("dca", "0004_alter_dcastrategy_owner_and_more"), + ("transactions", "0052_transfer_templates"), + ] + + operations = [ + migrations.RunPython(link_entry_transactions, migrations.RunPython.noop), + ] diff --git a/app/apps/dca/tests/test_transfer_linking.py b/app/apps/dca/tests/test_transfer_linking.py new file mode 100644 index 00000000..c98c9e2a --- /dev/null +++ b/app/apps/dca/tests/test_transfer_linking.py @@ -0,0 +1,99 @@ +from datetime import date +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.test import TestCase + +from apps.accounts.models import Account +from apps.common.middleware.thread_local import delete_current_user, write_current_user +from apps.currencies.models import Currency +from apps.dca.forms import DCAEntryForm +from apps.dca.models import DCAStrategy +from apps.transactions.models import Transaction + +IN, EX = Transaction.Type.INCOME, Transaction.Type.EXPENSE + + +class DCATransferLinkingTests(TestCase): + def setUp(self): + # Form querysets are scoped to the current user + write_current_user(get_user_model().objects.create_user(email="d@test.com")) + self.addCleanup(delete_current_user) + brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + btc = Currency.objects.create(code="BTC", name="Bitcoin", decimal_places=8) + self.cash = Account.objects.create(name="Cash", currency=brl) + self.wallet = Account.objects.create(name="Wallet", currency=btc) + self.strategy = DCAStrategy.objects.create( + name="BTC", target_currency=btc, payment_currency=brl + ) + + def tx(self, account, type_, amount): + return Transaction.objects.create( + account=account, type=type_, amount=Decimal(amount), date=date(2026, 1, 5) + ) + + def entry_data(self, **extra): + return { + "date": "2026-01-05", + "amount_paid": "500", + "amount_received": "0.001", + **extra, + } + + def test_created_transactions_are_linked(self): + form = DCAEntryForm( + data=self.entry_data( + create_transaction="on", + from_account=self.cash.id, + to_account=self.wallet.id, + ), + strategy=self.strategy, + ) + self.assertTrue(form.is_valid(), form.errors) + entry = form.save() + self.assertEqual( + entry.expense_transaction.linked_transaction, entry.income_transaction + ) + self.assertEqual( + entry.income_transaction.linked_transaction, entry.expense_transaction + ) + + def test_existing_transactions_are_linked(self): + expense, income = self.tx(self.cash, EX, "500"), self.tx(self.wallet, IN, "0.001") + form = DCAEntryForm( + data=self.entry_data( + expense_transaction=str(expense.id), income_transaction=str(income.id) + ), + strategy=self.strategy, + ) + self.assertTrue(form.is_valid(), form.errors) + form.save() + expense.refresh_from_db() + income.refresh_from_db() + self.assertEqual(expense.linked_transaction, income) + self.assertEqual(income.linked_transaction, expense) + + def test_transaction_in_another_transfer_is_rejected(self): + expense, income = self.tx(self.cash, EX, "500"), self.tx(self.wallet, IN, "0.001") + other = self.tx(self.wallet, IN, "0.002") + expense.link_transfer(other) + form = DCAEntryForm( + data=self.entry_data( + expense_transaction=str(expense.id), income_transaction=str(income.id) + ), + strategy=self.strategy, + ) + self.assertFalse(form.is_valid()) + self.assertIn("income_transaction", form.errors) + + def test_link_transfer_rules(self): + expense = self.tx(self.cash, EX, "500") + with self.assertRaises(ValidationError): + expense.link_transfer(self.tx(self.cash, IN, "500")) # same account + with self.assertRaises(ValidationError): + expense.link_transfer(self.tx(self.wallet, EX, "1")) # two expenses + income = self.tx(self.wallet, IN, "0.001") + expense.link_transfer(income) + expense.link_transfer(income) # already linked to each other: no-op + self.assertEqual(Transaction.objects.get(pk=income.pk).linked_transaction, expense) diff --git a/app/apps/export_app/forms.py b/app/apps/export_app/forms.py index f9b2d229..caa436c8 100644 --- a/app/apps/export_app/forms.py +++ b/app/apps/export_app/forms.py @@ -108,6 +108,7 @@ class ExportForm(forms.Form): self.helper.layout = Layout( "users", "accounts", + "investment_classes", "currencies", "transactions", "categories", @@ -135,6 +136,7 @@ class RestoreForm(forms.Form): ) users = forms.FileField(required=False, label=_("Users")) accounts = forms.FileField(required=False, label=_("Accounts")) + investment_classes = forms.FileField(required=False, label=_("Asset classes")) currencies = forms.FileField(required=False, label=_("Currencies")) transactions_categories = forms.FileField(required=False, label=_("Categories")) transactions_tags = forms.FileField(required=False, label=_("Tags")) diff --git a/app/apps/export_app/resources/accounts.py b/app/apps/export_app/resources/accounts.py index 7c993bae..45cac926 100644 --- a/app/apps/export_app/resources/accounts.py +++ b/app/apps/export_app/resources/accounts.py @@ -1,10 +1,24 @@ from import_export import fields, resources, widgets -from apps.accounts.models import Account, AccountGroup +from apps.accounts.models import Account, AccountGroup, InvestmentClass from apps.export_app.widgets.foreign_key import AutoCreateForeignKeyWidget from apps.currencies.models import Currency +class InvestmentClassResource(resources.ModelResource): + class Meta: + model = InvestmentClass + + def get_queryset(self): + return InvestmentClass.all_objects.all() + + +class AllClassesWidget(widgets.ForeignKeyWidget): + # By id, across owners: a shared account may carry another user's class + def get_queryset(self, value, row, *args, **kwargs): + return InvestmentClass.all_objects.all() + + class AccountResource(resources.ModelResource): group = fields.Field( attribute="group", @@ -22,6 +36,12 @@ class AccountResource(resources.ModelResource): widget=widgets.ForeignKeyWidget(Currency, "name"), ) + investment_class = fields.Field( + attribute="investment_class", + column_name="investment_class", + widget=AllClassesWidget(InvestmentClass), + ) + class Meta: model = Account diff --git a/app/apps/export_app/tests.py b/app/apps/export_app/tests.py index 7ce503c2..e70c0357 100644 --- a/app/apps/export_app/tests.py +++ b/app/apps/export_app/tests.py @@ -1,3 +1,53 @@ +import io +import zipfile +from decimal import Decimal + +from django.contrib.auth import get_user_model from django.test import TestCase -# Create your tests here. +from apps.accounts.models import Account, InvestmentClass +from apps.common.middleware.thread_local import delete_current_user, write_current_user +from apps.currencies.models import Currency +from apps.export_app.resources.accounts import AccountResource, InvestmentClassResource +from apps.export_app.resources.transactions import TransactionEntityResource +from apps.export_app.views import process_imports +from apps.transactions.models import TransactionEntity + + +class InvestmentRestoreTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user(email="u@test.com", password="pw") + write_current_user(self.user) + + def tearDown(self): + delete_current_user() + + def test_round_trip_keeps_asset_class_and_income_links(self): + brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + stocks = InvestmentClass.objects.create(name="Stocks", target=Decimal("60")) + broker = TransactionEntity.objects.create(name="Broker") + account = Account.objects.create( + name="PETR4", currency=brl, is_investment=True, investment_class=stocks + ) + account.income_entities.add(broker) + + backup = io.BytesIO() + with zipfile.ZipFile(backup, "w") as z: + for resource, name in ( + (AccountResource, "accounts"), + (InvestmentClassResource, "investment_classes"), + (TransactionEntityResource, "transactions_entities"), + ): + z.writestr(f"{name}.csv", resource().export().csv) + + Account.all_objects.all().delete() + InvestmentClass.all_objects.all().delete() + TransactionEntity.all_objects.all().delete() + + backup.seek(0) + process_imports(None, {"zip_file": backup}) + + account = Account.all_objects.get(name="PETR4") + self.assertEqual(account.investment_class.name, "Stocks") + self.assertEqual(account.investment_class.target, Decimal("60")) + self.assertEqual([e.name for e in account.income_entities.all()], ["Broker"]) diff --git a/app/apps/export_app/views.py b/app/apps/export_app/views.py index a74e4ae0..c413d3e4 100644 --- a/app/apps/export_app/views.py +++ b/app/apps/export_app/views.py @@ -14,7 +14,10 @@ from tablib import Dataset from apps.common.decorators.htmx import only_htmx from apps.export_app.forms import ExportForm, RestoreForm -from apps.export_app.resources.accounts import AccountResource +from apps.export_app.resources.accounts import ( + AccountResource, + InvestmentClassResource, +) from apps.export_app.resources.bank_sync import ( BankSyncAccountResource, BankSyncServiceResource, @@ -95,6 +98,9 @@ def export_form(request): exports.append((UserResource().export(), "users")) if export_accounts: exports.append((AccountResource().export(), "accounts")) + exports.append( + (InvestmentClassResource().export(), "investment_classes") + ) if export_currencies: exports.append((CurrencyResource().export(), "currencies")) if export_transactions: @@ -233,10 +239,13 @@ def process_imports(request, cleaned_data): "currencies", CurrencyResource, ), # We do a double pass because exchange_currency may not exist when currency is initially created + ("investment_classes", InvestmentClassResource), ("accounts", AccountResource), ("transactions_categories", TransactionCategoyResource), ("transactions_tags", TransactionTagResource), ("transactions_entities", TransactionEntityResource), + # Again: income categories, tags and entities don't exist on the first pass + ("accounts", AccountResource), ("automatic_exchange_rates", ExchangeRateServiceResource), ("exchange_rates", ExchangeRateResource), ("installment_plans", InstallmentPlanResource), diff --git a/app/apps/import_app/services/presets.py b/app/apps/import_app/services/presets.py index 824a2468..e4cef311 100644 --- a/app/apps/import_app/services/presets.py +++ b/app/apps/import_app/services/presets.py @@ -1,17 +1,17 @@ import json from pathlib import Path +from django.conf import settings + from apps.import_app.models import ImportProfile class PresetService: - PRESET_PATH = "/usr/src/app/import_presets" - @classmethod def get_all_presets(cls): presets = [] - for folder in Path(cls.PRESET_PATH).iterdir(): + for folder in settings.IMPORT_PRESETS_ROOT.iterdir(): if folder.is_dir(): manifest_path = folder / "manifest.json" config_path = folder / "config.yml" diff --git a/app/apps/import_app/services/v1.py b/app/apps/import_app/services/v1.py index bed860ac..2bc16981 100644 --- a/app/apps/import_app/services/v1.py +++ b/app/apps/import_app/services/v1.py @@ -7,12 +7,14 @@ import zipfile from django.db import transaction from datetime import datetime, date from decimal import Decimal, InvalidOperation +from pathlib import Path from typing import Dict, Any, Literal, Union import openpyxl import xlrd import yaml from cachalot.api import cachalot_disabled +from django.conf import settings from django.core.exceptions import FieldDoesNotExist from django.utils import timezone from openpyxl.utils.exceptions import InvalidFileException @@ -39,8 +41,6 @@ logger = logging.getLogger(__name__) class ImportService: - TEMP_DIR = "/usr/src/app/temp" - def __init__(self, import_run: ImportRun): self.import_run: ImportRun = import_run self.profile: ImportProfile = import_run.profile @@ -54,7 +54,7 @@ class ImportService: self.mapping: Dict[str, version_1.ColumnMapping] = self.config.mapping # Ensure temp directory exists - os.makedirs(self.TEMP_DIR, exist_ok=True) + os.makedirs(settings.IMPORT_TEMP_ROOT, exist_ok=True) def _load_config(self) -> version_1.ImportProfileSchema: yaml_data = yaml.safe_load(self.profile.yaml_config) @@ -1131,9 +1131,12 @@ class ImportService: Validates that the file path is within the allowed temporary directory. Returns the absolute path. """ - abs_path = os.path.abspath(file_path) - if not abs_path.startswith(self.TEMP_DIR): - raise ValueError(f"Invalid file path. File must be in {self.TEMP_DIR}") + # resolve() like IMPORT_TEMP_ROOT, so symlinks can't point outside it + abs_path = str(Path(file_path).resolve()) + if not Path(abs_path).is_relative_to(settings.IMPORT_TEMP_ROOT): + raise ValueError( + f"Invalid file path. File must be in {settings.IMPORT_TEMP_ROOT}" + ) return abs_path def process_file(self, file_path: str): diff --git a/app/apps/import_app/tests/test_paths.py b/app/apps/import_app/tests/test_paths.py new file mode 100644 index 00000000..a9eb7807 --- /dev/null +++ b/app/apps/import_app/tests/test_paths.py @@ -0,0 +1,161 @@ +"""Tests for the folders import code reads from and writes to.""" + +import json +import os +import runpy +import shutil +import tempfile +from pathlib import Path +from unittest.mock import patch + +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import SimpleTestCase, override_settings +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +from apps.import_app.models import ImportRun +from apps.import_app.services import PresetService +from apps.import_app.services.v1 import ImportService +from apps.import_app.tests.test_view_permissions import ( + HTMX, + ImportObjectPermissionTestCase, +) + + +def load_settings(**env): + """Evaluates the settings module from scratch under the given env vars.""" + with patch.dict(os.environ, env): + return runpy.run_path(str(Path(settings.BASE_DIR) / "WYGIWYH" / "settings.py")) + + +class PathSettingsTests(SimpleTestCase): + def test_defaults_match_the_docker_image_layout(self): + loaded = load_settings(IMPORT_TEMP_ROOT="", CACHE_ROOT="") + + base_dir = loaded["BASE_DIR"] + self.assertEqual(loaded["IMPORT_TEMP_ROOT"], base_dir / "temp") + self.assertEqual(loaded["IMPORT_PRESETS_ROOT"], base_dir / "import_presets") + self.assertEqual( + loaded["CACHES"]["default"]["LOCATION"], + Path("/var/tmp/django_cache").resolve(), + ) + self.assertEqual( + loaded["CACHES"]["update_check"]["LOCATION"], + Path("/var/tmp/django_cache_update_check").resolve(), + ) + + def test_env_vars_move_temp_and_cache_folders(self): + with tempfile.TemporaryDirectory() as root: + root = Path(root).resolve() + loaded = load_settings( + IMPORT_TEMP_ROOT=str(root / "uploads"), CACHE_ROOT=str(root / "cache") + ) + + self.assertEqual(loaded["IMPORT_TEMP_ROOT"], root / "uploads") + self.assertEqual( + loaded["CACHES"]["default"]["LOCATION"], root / "cache" / "django_cache" + ) + self.assertEqual( + loaded["CACHES"]["update_check"]["LOCATION"], + root / "cache" / "django_cache_update_check", + ) + + +class PresetServiceTests(SimpleTestCase): + def test_lists_the_presets_shipped_with_the_app(self): + folder_names = { + preset["folder_name"] for preset in PresetService.get_all_presets() + } + + self.assertIn("qif_standard", folder_names) + + def test_reads_presets_from_configured_folder(self): + with tempfile.TemporaryDirectory() as root: + preset = Path(root) / "my_bank" + preset.mkdir() + (preset / "manifest.json").write_text(json.dumps({"name": "My Bank"})) + (preset / "config.yml").write_text("settings: {}") + + with override_settings(IMPORT_PRESETS_ROOT=Path(root)): + presets = PresetService.get_all_presets() + + self.assertEqual([p["name"] for p in presets], ["My Bank"]) + + +class ImportTempRootTests(ImportObjectPermissionTestCase): + def setUp(self): + super().setUp() + self.temp_root = Path(tempfile.mkdtemp()).resolve() + self.addCleanup(shutil.rmtree, self.temp_root, ignore_errors=True) + temp_root_override = override_settings(IMPORT_TEMP_ROOT=self.temp_root) + temp_root_override.enable() + self.addCleanup(temp_root_override.disable) + + @patch("apps.import_app.views.process_import.defer") + def test_web_upload_is_stored_in_temp_root(self, mock_defer): + self.login(self.owner) + response = self.client.post( + reverse("import_run_add", kwargs={"profile_id": self.private_profile.id}), + {"file": SimpleUploadedFile("web.csv", b"date,amount\n")}, + **HTMX, + ) + + self.assertEqual(response.status_code, 204) + self.assertEqual( + mock_defer.call_args.kwargs["file_path"], str(self.temp_root / "web.csv") + ) + self.assertTrue((self.temp_root / "web.csv").is_file()) + + @patch("apps.api.views.imports.process_import.defer") + def test_api_upload_is_stored_in_temp_root(self, mock_defer): + client = APIClient() + client.force_authenticate(user=self.owner) + response = client.post( + "/api/import/import/", + { + "profile_id": self.private_profile.id, + "file": SimpleUploadedFile("api.csv", b"date,amount\n"), + }, + format="multipart", + ) + + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual( + mock_defer.call_args.kwargs["file_path"], str(self.temp_root / "api.csv") + ) + self.assertTrue((self.temp_root / "api.csv").is_file()) + + def _service(self): + run = ImportRun.objects.create( + profile=self.private_profile, file_name="file.csv", owner=self.owner + ) + return ImportService(run) + + def test_service_accepts_files_inside_temp_root(self): + path = str(self.temp_root / "file.csv") + + self.assertEqual(self._service()._validate_file_path(path), path) + + def test_service_rejects_files_outside_temp_root(self): + service = self._service() + + for path in ( + "/etc/passwd", + self.temp_root / ".." / "file.csv", + # Shares the temp root as a string prefix, but is a different folder + f"{self.temp_root}-other/file.csv", + ): + with self.subTest(path=path), self.assertRaises(ValueError): + service._validate_file_path(str(path)) + + def test_service_rejects_symlinks_pointing_outside_temp_root(self): + link = self.temp_root / "link.csv" + try: + link.symlink_to("/etc/passwd") + except OSError: + self.skipTest("symlinks not available") + + with self.assertRaises(ValueError): + self._service()._validate_file_path(str(link)) diff --git a/app/apps/import_app/tests/test_qif_import.py b/app/apps/import_app/tests/test_qif_import.py index a79662b3..d2b7e9db 100644 --- a/app/apps/import_app/tests/test_qif_import.py +++ b/app/apps/import_app/tests/test_qif_import.py @@ -1,7 +1,7 @@ from decimal import Decimal import os import shutil -from django.test import TestCase +from django.test import TestCase, override_settings from django.contrib.auth import get_user_model from apps.accounts.models import Account, AccountGroup from apps.currencies.models import Currency @@ -15,10 +15,11 @@ from apps.transactions.models import ( class QIFImportTests(TestCase): def setUp(self): - # Patch TEMP_DIR for testing - self.original_temp_dir = ImportService.TEMP_DIR + # Point the import temp folder somewhere disposable for testing self.test_dir = os.path.abspath("temp_test_import") - ImportService.TEMP_DIR = self.test_dir + temp_root_override = override_settings(IMPORT_TEMP_ROOT=self.test_dir) + temp_root_override.enable() + self.addCleanup(temp_root_override.disable) os.makedirs(self.test_dir, exist_ok=True) # Create user and set context @@ -41,7 +42,6 @@ class QIFImportTests(TestCase): def tearDown(self): delete_current_user() - ImportService.TEMP_DIR = self.original_temp_dir if os.path.exists(self.test_dir): shutil.rmtree(self.test_dir) diff --git a/app/apps/import_app/views.py b/app/apps/import_app/views.py index adef7679..45dc61ab 100644 --- a/app/apps/import_app/views.py +++ b/app/apps/import_app/views.py @@ -1,5 +1,6 @@ import shutil +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied @@ -204,7 +205,7 @@ def import_run_add(request, profile_id): if form.is_valid(): uploaded_file = request.FILES["file"] - fs = FileSystemStorage(location="/usr/src/app/temp") + fs = FileSystemStorage(location=settings.IMPORT_TEMP_ROOT) filename = fs.save(uploaded_file.name, uploaded_file) file_path = fs.path(filename) diff --git a/app/apps/investments/__init__.py b/app/apps/investments/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/apps/investments/apps.py b/app/apps/investments/apps.py new file mode 100644 index 00000000..4b50cd38 --- /dev/null +++ b/app/apps/investments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class InvestmentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.investments" diff --git a/app/apps/investments/services.py b/app/apps/investments/services.py new file mode 100644 index 00000000..a6371ada --- /dev/null +++ b/app/apps/investments/services.py @@ -0,0 +1,515 @@ +"""Investment performance, derived from an account's transactions. + +Rules (see the account's "Investment" help text): +- A linked transaction (a transfer) moves money in or out. Its other side says + what was paid or received, which is the cost or the proceeds. +- An unlinked transaction on an account tracked by value is money coming in or + going out (a salary, a bill), unless it has one of the account's income + categories, tags or entities: then it is a gain or a loss (a reconciled balance, + interest, a tax withheld). +- On a unit-priced account an unlinked outflow is a loss, and an unlinked inflow + (a gift, an opening balance) has no known cost, so it is costed at the market + price of that day and the result is flagged as estimated. +- Cost is tracked with the average cost method. +""" + +import datetime +from dataclasses import dataclass, field +from decimal import Decimal +from functools import cached_property + +from django.db.models import Q +from django.utils import timezone + +from apps.currencies.models import ExchangeRate +from apps.currencies.utils.convert import convert, get_exchange_rate +from apps.transactions.models import Transaction + +ZERO = Decimal("0") + + +@dataclass +class HoldingPerformance: + account: object + currency: object # valuation currency + priced: bool # True: units x price. False: tracked by value. + quantity: Decimal = ZERO + cost_basis: Decimal = ZERO + price: Decimal | None = None + price_date: datetime.datetime | None = None + realized: Decimal = ZERO + income: Decimal = ZERO + contributed: Decimal = ZERO + withdrawn: Decimal = ZERO + estimated: bool = False + # Unlinked transactions counted as gains or losses, on accounts tracked by value + result_ids: set = field(default_factory=set) + history: list = field(default_factory=list) + # (date, "buy" or "sell", unit price) for linked trades on unit-priced accounts + trades: list = field(default_factory=list) + # (date, signed amount) for the gains and losses of an account tracked by value + results: list = field(default_factory=list) + # (date, amount, kind) in the valuation currency: negative = money in ("in"), + # positive = money out ("out" sales/withdrawals, "income"). Used for + # money-weighted returns and currency conversion. + flows: list = field(default_factory=list) + as_of: datetime.date | None = None + + @cached_property + def money_weighted(self): + return money_weighted_return(self.flows, self.market_value, self.as_of) + + @property + def average_cost(self): + return self.cost_basis / self.quantity if self.quantity else None + + @property + def market_value(self): + if self.price is None: + return None + return self.quantity * self.price + + @property + def unrealized(self): + value = self.market_value + return None if value is None else value - self.cost_basis + + @property + def total_return(self): + return (self.unrealized or ZERO) + self.realized + self.income + + @property + def return_percentage(self): + if not self.contributed: + return None + return self.total_return / self.contributed * 100 + + @property + def unrealized_percentage(self): + if not self.cost_basis or self.unrealized is None: + return None + return self.unrealized / self.cost_basis * 100 + + +def _to(amount, from_currency, to_currency, date): + if from_currency == to_currency or not amount: + return amount + return convert(amount, from_currency, to_currency, date)[0] + + +def _price(account, valuation_currency, date): + if account.currency == valuation_currency: + return Decimal("1"), None + rate = get_exchange_rate(account.currency, valuation_currency, date) + if rate is None: + return None, None + return rate.effective_rate, rate.date + + +def _linked_ids(account, field_name): + # The join table, not the related manager: that one is per-user and comes back + # empty without a current user + relation = account._meta.get_field(field_name) + return list( + relation.remote_field.through.objects.filter(account=account).values_list( + relation.m2m_reverse_name(), flat=True + ) + ) + + +def income_entity_ids(account): + return _linked_ids(account, "income_entities") + + +def _result_ids(account): + return set( + Transaction.objects.filter(account=account) + .filter( + Q(category__in=_linked_ids(account, "income_categories")) + | Q(tags__in=_linked_ids(account, "income_tags")) + | Q(entities__in=income_entity_ids(account)) + ) + .values_list("id", flat=True) + ) + + +def get_holding_performance(account, as_of=None, with_history=False): + """Performance of an investment account up to ``as_of`` (today by default), + in the account's exchange currency, or its own currency if it has none.""" + today = timezone.localdate() + as_of = as_of or today + valuation_currency = account.exchange_currency or account.currency + perf = HoldingPerformance( + account=account, + currency=valuation_currency, + priced=account.exchange_currency_id is not None, + ) + + transactions = ( + Transaction.objects.filter(account=account, is_paid=True, date__lte=as_of) + .select_related("linked_transaction__account__currency") + .order_by("date", "id") + ) + + month_ends = _month_ends(transactions, as_of) if with_history else [] + if not perf.priced: + perf.result_ids = _result_ids(account) + + for tx in transactions: + while month_ends and month_ends[0] < tx.date: + perf.history.append(_snapshot(perf, account, month_ends.pop(0))) + + # shortcut: only the forward link is read; WYGIWYH transfers link both ways. + partner = tx.linked_transaction + if partner and (partner.account_id == account.id or partner.deleted): + partner = None + partner_value = ( + _to(partner.amount, partner.account.currency, valuation_currency, tx.date) + if partner + else None + ) + external = not (perf.priced or partner or tx.id in perf.result_ids) + if perf.priced and partner_value is not None and tx.amount: + kind = "buy" if tx.type == Transaction.Type.INCOME else "sell" + perf.trades.append((tx.date, kind, partner_value / tx.amount)) + elif not (perf.priced or partner or external): + sign = 1 if tx.type == Transaction.Type.INCOME else -1 + perf.results.append((tx.date, sign * tx.amount)) + + if tx.type == Transaction.Type.INCOME: + if external: + cost = tx.amount + perf.contributed += cost + elif partner_value is not None: + cost = partner_value + perf.contributed += cost + elif perf.priced or partner: + # No known cost: price it at that day's market value + cost = _to(tx.amount, account.currency, valuation_currency, tx.date) + perf.estimated = True + if partner: + perf.contributed += cost or ZERO + else: + cost = ZERO # value-tracked: a gain + if cost: + perf.flows.append((tx.date, -cost, "in")) + perf.quantity += tx.amount + perf.cost_basis += cost or ZERO + else: + if perf.quantity > 0: + removed = perf.cost_basis * min(tx.amount, perf.quantity) / perf.quantity + else: + removed = ZERO + if partner or external: + proceeds = tx.amount if external else partner_value + if proceeds is None: + proceeds = _to(tx.amount, account.currency, valuation_currency, tx.date) or ZERO + perf.estimated = True + perf.realized += proceeds - removed + perf.withdrawn += proceeds + perf.flows.append((tx.date, proceeds, "out")) + perf.cost_basis -= removed + # otherwise a loss, tax or fee: cost stays, so value drops + perf.quantity -= tx.amount + if perf.quantity <= 0 and perf.cost_basis: + perf.realized -= perf.cost_basis + perf.cost_basis = ZERO + + for month_end in month_ends: + perf.history.append(_snapshot(perf, account, month_end)) + + perf.price, perf.price_date = _price( + account, + valuation_currency, + timezone.now() if as_of == today else as_of, + ) + + entity_ids = income_entity_ids(account) + if entity_ids: + income = Transaction.objects.filter( + entities__in=entity_ids, + type=Transaction.Type.INCOME, + is_paid=True, + date__lte=as_of, + ).exclude(account=account).distinct().select_related("account__currency") + for tx in income: + amount = _to(tx.amount, tx.account.currency, valuation_currency, tx.date) + if amount: + perf.income += amount + perf.flows.append((tx.date, amount, "income")) + + perf.as_of = as_of + return perf + + +def xirr(flows): + """Annual rate r where the dated flows' present value is zero, as a float + (0.138 = 13.8% a year). None when it can't be solved.""" + if not flows: + return None + start = min(flow[0] for flow in flows) + points = [((flow[0] - start).days / 365, float(flow[1])) for flow in flows] + if not (any(a < 0 for _, a in points) and any(a > 0 for _, a in points)): + return None + + def npv(rate): + return sum(a / (1 + rate) ** t for t, a in points) + + # Bisection: slower than Newton but can't diverge on odd cash flows + low, high = -0.9999, 100.0 + npv_low = npv(low) + if npv_low * npv(high) > 0: + return None + for _ in range(200): + mid = (low + high) / 2 + npv_mid = npv(mid) + if npv_low * npv_mid <= 0: + high = mid + else: + low, npv_low = mid, npv_mid + return (low + high) / 2 + + +def money_weighted_return(flows, market_value, as_of): + """Return that accounts for when money went in and out, as + ``{"percentage": Decimal, "annualized": bool}`` or None. + + Over a year or more it is annualized (XIRR). Below a year an annual rate + exaggerates, so it is turned back into the return over the period held.""" + if not flows or market_value is None: + return None + rate = xirr(flows + [(as_of, market_value)]) + if rate is None: + return None + days = (as_of - min(flow[0] for flow in flows)).days + if days >= 365: + return {"percentage": Decimal(str(round(rate * 100, 4))), "annualized": True} + period = (1 + rate) ** (days / 365) - 1 + return {"percentage": Decimal(str(round(period * 100, 4))), "annualized": False} + + +def _month_ends(transactions, as_of): + first = transactions.values_list("date", flat=True).first() + if not first: + return [] + ends = [] + year, month = first.year, first.month + while True: + next_first = datetime.date(year + month // 12, month % 12 + 1, 1) + end = next_first - datetime.timedelta(days=1) + if end >= as_of: + ends.append(as_of) + return ends + ends.append(end) + year, month = next_first.year, next_first.month + + +def _snapshot(perf, account, date): + price, _ = _price(account, perf.currency, date) + return { + "date": date, + "invested": perf.cost_basis, + "value": perf.quantity * price if price is not None else None, + "price": price, + "average_cost": perf.average_cost, + } + + +def conversion_path(currency): + """``currency`` followed by its exchange currencies (USD -> BRL -> ...), for as + long as an exchange rate between each step exists.""" + path = [currency] + while True: + target = path[-1].exchange_currency + if ( + target is None + or target in path + or get_exchange_rate(path[-1], target, timezone.now()) is None + ): + return path + path.append(target) + + +def _along(amount, path, date): + for from_currency, to_currency in zip(path, path[1:]): + amount = _to(amount, from_currency, to_currency, date) + return amount + + +def _native(perf): + return { + "currency": perf.currency, + "market_value": perf.market_value, + "cost_basis": perf.cost_basis, + "income": perf.income, + "total_return": perf.total_return, + "currency_effect": ZERO, + "flows": perf.flows, + } + + +def in_home_currency(perf): + """The holding converted along its currency's exchange chain, each cash flow + at its own date's rate, so currency moves count towards the return. + None when its currency converts to nothing.""" + path = conversion_path(perf.currency) + if len(path) == 1: + return None + flows = [(d, _along(a, path, d), kind) for d, a, kind in perf.flows] + value = None + if perf.market_value is not None: + value = _along(perf.market_value, path, perf.as_of) + total_return = (value or ZERO) + sum((a for _, a, _ in flows), ZERO) + + put_in = -sum((a for _, a, k in flows if k == "in"), ZERO) + put_in_native = -sum((a for _, a, k in perf.flows if k == "in"), ZERO) + # shortcut: what you still hold is valued at the average rate you paid in, + # not lot by lot; exact for buy-and-hold, approximate after partial sales. + cost_basis = ( + perf.cost_basis * put_in / put_in_native + if put_in_native + else _along(perf.cost_basis, path, perf.as_of) + ) + return { + "currency": path[-1], + "path": path, + "market_value": value, + "cost_basis": cost_basis, + "income": sum((a for _, a, k in flows if k == "income"), ZERO), + "total_return": total_return, + # what the return gained or lost from the exchange rate moving + "currency_effect": total_return + - _along(perf.total_return, path, perf.as_of), + # With a single rate every date converts the same, so the effect reads 0 + "has_rate_history": all( + ExchangeRate.objects.filter( + Q(from_currency=a, to_currency=b) | Q(from_currency=b, to_currency=a) + )[:2].count() + > 1 + for a, b in zip(path, path[1:]) + ), + "flows": flows, + "money_weighted": money_weighted_return(flows, value, perf.as_of), + } + + +def summarize(performances, convert=False): + """Totals per currency. With ``convert``, holdings are first converted along + their exchange chain (see in_home_currency); the ones that can't be stay in + their own currency.""" + totals = {} + for perf in performances: + figures = (convert and in_home_currency(perf)) or _native(perf) + total = totals.setdefault( + figures["currency"].id, + { + "currency": figures["currency"], + "market_value": ZERO, + "cost_basis": ZERO, + "total_return": ZERO, + "income": ZERO, + "currency_effect": ZERO, + "converted_from": [], + "has_rate_history": True, + "flows": [], + "as_of": perf.as_of, + "holdings": [], + }, + ) + total["holdings"].append(perf) + for key in ("cost_basis", "total_return", "income", "currency_effect"): + total[key] += figures[key] + total["has_rate_history"] &= figures.get("has_rate_history", True) + if figures["currency"] != perf.currency and perf.currency not in total["converted_from"]: + total["converted_from"].append(perf.currency) + # A holding without a price has no end value, so its flows would skew the rate + if figures["market_value"] is not None: + total["market_value"] += figures["market_value"] + total["flows"] += figures["flows"] + for total in totals.values(): + total["money_weighted"] = money_weighted_return( + total["flows"], total["market_value"], total["as_of"] + ) + # The same holdings in their own currencies, for a converted total + total["breakdown"] = ( + sorted( + summarize(total["holdings"]), + key=lambda part: part["currency"] != total["currency"], + ) + if total["converted_from"] + else [] + ) + return list(totals.values()) + + +def portfolio_charts(performances): + """Chart data per total currency (as in summarize with ``convert``): value by + asset class and by currency, return by investment, and value against invested + at each month end. Classes are InvestmentClass objects, None for holdings + without one. Unpriced holdings are left out of the value figures.""" + charts = {} + for perf in performances: + figures = in_home_currency(perf) or _native(perf) + path = figures.get("path", [perf.currency]) + chart = charts.setdefault( + figures["currency"].id, + { + "currency": figures["currency"], + "classes": {}, + "currencies": {}, + "returns": [], + "history": {}, + }, + ) + value = figures["market_value"] + if value: + key = perf.account.investment_class + chart["classes"][key] = chart["classes"].get(key, ZERO) + value + name = perf.currency.name + chart["currencies"][name] = chart["currencies"].get(name, ZERO) + value + chart["returns"].append((perf.account.name, figures["total_return"])) + # shortcut: one rate lookup per holding, month and hop; cache rates per + # date if portfolios with long histories make this tab slow. + for point in perf.history: + totals = chart["history"].setdefault(point["date"], [ZERO, ZERO]) + for i, amount in enumerate((point["value"], point["invested"])): + if amount is not None: + totals[i] += _along(amount, path, point["date"]) or ZERO + for chart in charts.values(): + for key in ("classes", "currencies"): + chart[key] = sorted(chart[key].items(), key=lambda item: -item[1]) + chart["returns"].sort(key=lambda item: -item[1]) + chart["history"] = sorted(chart["history"].items()) + return list(charts.values()) + + +def drift_limit(target): + """How far a class may drift from its target, in percentage points: the + common 5/25 rule, 5 points or a quarter of the target, whichever is less.""" + return min(Decimal(5), target / 4) + + +def rebalance(classes, targeted): + """What to buy or sell so the classes in ``targeted`` (InvestmentClass with a + target) match their targets, given ``classes`` as (class, value) from + portfolio_charts. Holdings without a class or target are left out and their + value is returned as ``left_out``. ``move`` is positive to buy.""" + values = dict(classes) + total = sum((values.get(cls, ZERO) for cls in targeted), ZERO) + rows = [] + for cls in targeted: + value = values.get(cls, ZERO) + share = value / total * 100 if total else ZERO + move = total * cls.target / 100 - value + rows.append( + { + "class": cls, + "value": value, + "share": share, + "target": cls.target, + "move": move, + "off": abs(share - cls.target) > drift_limit(cls.target), + } + ) + left_out = sum(values.values(), ZERO) - total + return {"total": total, "rows": rows, "left_out": left_out} diff --git a/app/apps/investments/tests/__init__.py b/app/apps/investments/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/apps/investments/tests/test_services.py b/app/apps/investments/tests/test_services.py new file mode 100644 index 00000000..5ac80688 --- /dev/null +++ b/app/apps/investments/tests/test_services.py @@ -0,0 +1,415 @@ +import datetime +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils import timezone + +from apps.accounts.models import Account, InvestmentClass +from apps.common.middleware.thread_local import delete_current_user, write_current_user +from apps.currencies.models import Currency, ExchangeRate +from apps.investments.services import get_holding_performance, summarize +from apps.transactions.models import ( + Transaction, + TransactionCategory, + TransactionEntity, + TransactionTag, +) + +D = Decimal +IN, EX = Transaction.Type.INCOME, Transaction.Type.EXPENSE + + +def tx(account, type_, amount, date, **kwargs): + return Transaction.objects.create( + account=account, type=type_, amount=D(amount), date=date, **kwargs + ) + + +def transfer(from_account, from_amount, to_account, to_amount, date): + out = tx(from_account, EX, from_amount, date) + inc = tx(to_account, IN, to_amount, date, linked_transaction=out) + out.linked_transaction = inc + out.save() + return out, inc + + +class HoldingPerformanceTests(TestCase): + def setUp(self): + self.eur = Currency.objects.create(code="EUR", name="Euro", decimal_places=2) + self.brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + self.vwce = Currency.objects.create( + code="VWCE.DE", name="VWCE", decimal_places=4, exchange_currency=self.eur + ) + self.cash = Account.objects.create(name="Cash", currency=self.eur) + self.etf = Account.objects.create( + name="VWCE", + currency=self.vwce, + exchange_currency=self.eur, + is_investment=True, + is_asset=True, + ) + self.d1 = datetime.date(2026, 1, 10) + self.d2 = datetime.date(2026, 2, 10) + self.d3 = datetime.date(2026, 3, 10) + + def rate(self, value, date): + ExchangeRate.objects.create( + from_currency=self.vwce, + to_currency=self.eur, + rate=D(value), + date=timezone.make_aware(datetime.datetime.combine(date, datetime.time())), + ) + + def test_priced_average_cost_realized_and_income(self): + transfer(self.cash, "1000", self.etf, "10", self.d1) + transfer(self.cash, "1200", self.etf, "10", self.d2) + transfer(self.etf, "5", self.cash, "700", self.d3) + self.rate("130", timezone.localdate()) + + entity = TransactionEntity.objects.create(name="Vanguard") + other = TransactionEntity.objects.create(name="Vanguard JCP") + self.etf.income_entities.set([entity, other]) + dividend = tx(self.cash, IN, "30", self.d3) + dividend.entities.add(entity, other) # matches twice, counted once + + perf = get_holding_performance(self.etf) + + self.assertTrue(perf.priced) + self.assertEqual(perf.quantity, D("15")) + self.assertEqual(perf.cost_basis, D("1650")) + self.assertEqual(perf.average_cost, D("110")) + self.assertEqual(perf.realized, D("150")) # 700 - 5 * 110 + self.assertEqual(perf.market_value, D("1950")) + self.assertEqual(perf.unrealized, D("300")) + self.assertEqual(perf.income, D("30")) + self.assertEqual(perf.contributed, D("2200")) + self.assertEqual(perf.withdrawn, D("700")) + self.assertEqual(perf.total_return, D("480")) + self.assertFalse(perf.estimated) + self.assertEqual( + perf.trades, + [(self.d1, "buy", D("100")), (self.d2, "buy", D("120")), (self.d3, "sell", D("140"))], + ) + + def test_unlinked_inflow_on_priced_account_is_costed_at_market(self): + self.rate("90", self.d1) + tx(self.etf, IN, "2", self.d1) + + perf = get_holding_performance(self.etf) + + self.assertEqual(perf.cost_basis, D("180")) + self.assertTrue(perf.estimated) + self.assertEqual(perf.contributed, D("0")) + + def test_value_tracked_cdb_with_tax_on_redemption(self): + cdb = Account.objects.create( + name="CDB", currency=self.brl, is_investment=True, is_asset=True + ) + checking = Account.objects.create(name="Checking", currency=self.brl) + result = TransactionCategory.objects.create(name="Yield") + cdb.income_categories.set([result]) + transfer(checking, "1000", cdb, "1000", self.d1) + tx(cdb, IN, "100", self.d2, category=result) # balance reconciliation + tx(cdb, EX, "22.50", self.d3, category=result) # income tax withheld + + perf = get_holding_performance(cdb) + self.assertFalse(perf.priced) + self.assertEqual(perf.results, [(self.d2, D("100")), (self.d3, D("-22.50"))]) + self.assertEqual(perf.market_value, D("1077.50")) + self.assertEqual(perf.cost_basis, D("1000")) + self.assertEqual(perf.unrealized, D("77.50")) + + transfer(cdb, "1077.50", checking, "1077.50", self.d3) + perf = get_holding_performance(cdb) + self.assertEqual(perf.quantity, D("0")) + self.assertEqual(perf.cost_basis, D("0")) + self.assertEqual(perf.realized, D("77.50")) + self.assertEqual(perf.total_return, D("77.50")) + + def test_value_tracked_salary_account_separates_money_from_yield(self): + account = Account.objects.create( + name="Salary", currency=self.brl, is_investment=True + ) + tag = TransactionTag.objects.create(name="Yield") + account.income_tags.set([tag]) + tx(account, IN, "5000", self.d1) # salary, from outside + tx(account, EX, "1000", self.d2) # rent, not a loss + tx(account, IN, "40", self.d3).tags.add(tag) # reconciled balance + + perf = get_holding_performance(account) + + self.assertEqual(perf.contributed, D("5000")) + self.assertEqual(perf.withdrawn, D("1000")) + self.assertEqual(perf.market_value, D("4040")) + self.assertEqual(perf.cost_basis, D("4000")) + self.assertEqual(perf.total_return, D("40")) + self.assertFalse(perf.estimated) + + def test_own_entity_is_a_result_but_only_income_counts_elsewhere(self): + account = Account.objects.create( + name="Savings", currency=self.brl, is_investment=True + ) + other = Account.objects.create(name="Other", currency=self.brl) + bank = TransactionEntity.objects.create(name="Bank") + account.income_entities.set([bank]) + tx(account, IN, "1000", self.d1) + tx(account, IN, "10", self.d2).entities.add(bank) + tx(other, EX, "99", self.d2).entities.add(bank) + + perf = get_holding_performance(account) + + self.assertEqual(perf.total_return, D("10")) + self.assertEqual(perf.income, D("0")) + + def test_history_has_one_point_per_month_end(self): + transfer(self.cash, "1000", self.etf, "10", self.d1) + self.rate("100", self.d1) + self.rate("120", self.d3) + + perf = get_holding_performance( + self.etf, as_of=datetime.date(2026, 3, 15), with_history=True + ) + + self.assertEqual( + [p["date"] for p in perf.history], + [ + datetime.date(2026, 1, 31), + datetime.date(2026, 2, 28), + datetime.date(2026, 3, 15), + ], + ) + self.assertEqual(perf.history[-1]["value"], D("1200")) + self.assertEqual(perf.history[0]["invested"], D("1000")) + self.assertEqual(perf.history[-1]["price"], D("120")) + self.assertEqual(perf.history[-1]["average_cost"], D("100")) + + def test_summarize_groups_by_currency(self): + transfer(self.cash, "1000", self.etf, "10", self.d1) + self.rate("110", timezone.localdate()) + totals = summarize([get_holding_performance(self.etf)]) + self.assertEqual(len(totals), 1) + self.assertEqual(totals[0]["market_value"], D("1100")) + self.assertEqual(totals[0]["total_return"], D("100")) + + def test_portfolio_charts_group_by_asset_class(self): + from apps.investments.services import portfolio_charts + + stocks = InvestmentClass.objects.create(name="Stocks") + self.etf.investment_class = stocks + self.etf.save() + cdb = Account.objects.create(name="CDB", currency=self.eur, is_investment=True) + transfer(self.cash, "1000", self.etf, "10", self.d1) + transfer(self.cash, "500", cdb, "500", self.d1) + self.rate("110", self.d1) + + perfs = [get_holding_performance(a, with_history=True) for a in (self.etf, cdb)] + [chart] = portfolio_charts(perfs) + + self.assertEqual(chart["classes"], [(stocks, D("1100")), (None, D("500"))]) + # Priced in euros, so that is its currency exposure + self.assertEqual(chart["currencies"], [("Euro", D("1600"))]) + self.assertEqual(chart["returns"][0], ("VWCE", D("100"))) + self.assertEqual(chart["history"][-1][1], [D("1600"), D("1500")]) + + def test_rebalance_moves_to_targets(self): + from apps.investments.services import rebalance + + stocks = InvestmentClass.objects.create(name="Stocks", target=D("60")) + bonds = InvestmentClass.objects.create(name="Bonds", target=D("30")) + gold = InvestmentClass.objects.create(name="Gold", target=D("10")) + loose = InvestmentClass.objects.create(name="Loose") # no target + + plan = rebalance( + [(stocks, D("700")), (bonds, D("300")), (loose, D("50")), (None, D("25"))], + [stocks, bonds, gold], + ) + + self.assertEqual(plan["total"], D("1000")) + self.assertEqual(plan["left_out"], D("75")) + moves = {row["class"].name: row["move"] for row in plan["rows"]} + self.assertEqual(moves, {"Stocks": D("-100"), "Bonds": D("0"), "Gold": D("100")}) + self.assertEqual(sum(moves.values()), 0) + # 70% vs 60%: past the 5 points; gold at 0% vs 10%: past 2.5 (a quarter) + off = {row["class"].name: row["off"] for row in plan["rows"]} + self.assertEqual(off, {"Stocks": True, "Bonds": False, "Gold": True}) + + def test_class_names_are_unique(self): + from apps.accounts.forms import InvestmentClassForm + + stocks = InvestmentClass.objects.create(name="Stocks") + self.assertFalse(InvestmentClassForm({"name": "Stocks"}).is_valid()) + self.assertTrue(InvestmentClassForm({"name": "Stocks"}, instance=stocks).is_valid()) + + def test_targets_cannot_pass_100(self): + from apps.accounts.forms import InvestmentClassForm + + InvestmentClass.objects.create(name="Stocks", target=D("70")) + bonds = InvestmentClass.objects.create(name="Bonds", target=D("30")) + + self.assertFalse(InvestmentClassForm({"name": "Gold", "target": "1"}).is_valid()) + # Editing a class doesn't count its own current target + self.assertTrue( + InvestmentClassForm({"name": "Bonds", "target": "30"}, instance=bonds).is_valid() + ) + + def test_xirr_and_money_weighted_return(self): + from apps.investments.services import money_weighted_return, xirr + + start, year = datetime.date(2025, 1, 1), datetime.date(2026, 1, 1) + self.assertAlmostEqual(xirr([(start, D(-1000)), (year, D(1100))]), 0.10, places=6) + self.assertIsNone(xirr([(start, D(-1000))])) + + # A year or more: annualized + mw = money_weighted_return([(start, D(-1000), "in")], D(1100), year) + self.assertTrue(mw["annualized"]) + self.assertAlmostEqual(float(mw["percentage"]), 10, places=3) + + # Less than a year: the return over the period, not an inflated annual rate + half = datetime.date(2025, 7, 2) + mw = money_weighted_return([(start, D(-1000), "in")], D(1050), half) + self.assertFalse(mw["annualized"]) + self.assertAlmostEqual(float(mw["percentage"]), 5, places=3) + + # Money added late weighs less: +100 on 1000 held all year beats the + # simple +100 / 2000 = 5% when the second 1000 only came in at the end + flows = [(start, D(-1000), "in"), (datetime.date(2025, 12, 31), D(-1000), "in")] + mw = money_weighted_return(flows, D(2100), year) + self.assertGreater(float(mw["percentage"]), 9.9) + + def test_home_currency_counts_the_exchange_rate_move(self): + from apps.investments.services import in_home_currency + + usd = Currency.objects.create(code="USD", name="Dollar", decimal_places=2) + usd.exchange_currency = self.brl + usd.save() + etf = Currency.objects.create( + code="VT", name="VT", decimal_places=4, exchange_currency=usd + ) + cash = Account.objects.create(name="USD cash", currency=usd) + holding = Account.objects.create( + name="VT", currency=etf, exchange_currency=usd, is_investment=True + ) + aware = lambda d: timezone.make_aware(datetime.datetime.combine(d, datetime.time())) + ExchangeRate.objects.create(from_currency=usd, to_currency=self.brl, rate=D("5"), date=aware(self.d1)) + ExchangeRate.objects.create(from_currency=usd, to_currency=self.brl, rate=D("6"), date=aware(timezone.localdate())) + ExchangeRate.objects.create(from_currency=etf, to_currency=usd, rate=D("100"), date=aware(timezone.localdate())) + transfer(cash, "1000", holding, "10", self.d1) + + perf = get_holding_performance(holding) + self.assertEqual(perf.total_return, D("0")) # flat in dollars + + home = in_home_currency(perf) + self.assertEqual(home["currency"], self.brl) + self.assertEqual(home["market_value"], D("6000")) + self.assertEqual(home["cost_basis"], D("5000")) + self.assertEqual(home["total_return"], D("1000")) + self.assertEqual(home["currency_effect"], D("1000")) # all of it from the dollar + + totals = summarize([perf], convert=True) + self.assertEqual([t["currency"] for t in totals], [self.brl]) + self.assertEqual(totals[0]["converted_from"], [usd]) + self.assertEqual(totals[0]["breakdown"][0]["currency"], usd) + self.assertEqual(totals[0]["breakdown"][0]["market_value"], D("1000")) + # A single-currency total has nothing to break down + self.assertEqual(summarize([perf])[0]["breakdown"], []) + # No rate to anything: stays in its own currency + self.assertEqual(summarize([get_holding_performance(self.etf)], convert=True)[0]["currency"], self.eur) + + +@override_settings( + STORAGES={ + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage" + }, + }, + WHITENOISE_AUTOREFRESH=True, + DEMO=False, +) +class InvestmentViewTests(TestCase): + HTMX = {"HTTP_HX_REQUEST": "true"} + + def setUp(self): + User = get_user_model() + self.owner = User.objects.create_user(email="o@test.com", password="pw") + self.stranger = User.objects.create_user(email="s@test.com", password="pw") + brl = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + self.account = Account.all_objects.create( + name="CDB", currency=brl, is_investment=True, owner=self.owner + ) + + def test_owner_sees_list_and_detail(self): + self.client.force_login(self.owner) + response = self.client.get(reverse("investments_list"), **self.HTMX) + self.assertContains(response, "CDB") + response = self.client.get( + reverse("investment_detail", args=[self.account.id]), **self.HTMX + ) + self.assertEqual(response.status_code, 200) + + def test_shared_account_keeps_its_owners_class(self): + from apps.accounts.forms import AccountForm + + cls = InvestmentClass.all_objects.create(name="Stocks", owner=self.owner) + self.account.investment_class = cls + self.account.save() + self.account.shared_with.add(self.stranger) + + write_current_user(self.stranger) + try: + form = AccountForm( + {"name": "CDB", "currency": self.account.currency_id, + "is_investment": "on", "investment_class": str(cls.id)}, + instance=self.account, + ) + self.assertTrue(form.is_valid(), form.errors) + self.assertEqual(form.cleaned_data["investment_class"], cls) + finally: + delete_current_user() + # Not a new class named after the id + self.assertEqual(InvestmentClass.all_objects.count(), 1) + + def test_rebalance_and_class_pages_render(self): + self.client.force_login(self.owner) + stocks = InvestmentClass.all_objects.create(name="Stocks", target=D("60"), owner=self.owner) + self.account.investment_class = stocks + self.account.save() + + # Targets short of 100%: a warning instead of amounts + response = self.client.get(reverse("investments_rebalance"), **self.HTMX) + self.assertContains(response, "alert-warning") + + response = self.client.post( + reverse("investment_class_add"), {"name": "Bonds", "target": "40"}, **self.HTMX + ) + self.assertEqual(response.status_code, 204) + response = self.client.get(reverse("investments_rebalance"), **self.HTMX) + self.assertContains(response, "Bonds") + self.assertNotContains(response, "alert-warning") + + response = self.client.get(reverse("investment_classes_list"), **self.HTMX) + self.assertContains(response, "Stocks") + response = self.client.get( + reverse("investment_class_edit", args=[stocks.id]), **self.HTMX + ) + self.assertContains(response, "Stocks") + # Someone else's class is out of reach + self.client.force_login(self.stranger) + response = self.client.get( + reverse("investment_class_edit", args=[stocks.id]), **self.HTMX + ) + self.assertEqual(response.status_code, 404) + + def test_charts_render(self): + self.client.force_login(self.owner) + response = self.client.get(reverse("investments_charts"), **self.HTMX) + self.assertContains(response, "investment-charts-0") + + def test_stranger_cannot_see_detail(self): + self.client.force_login(self.stranger) + response = self.client.get( + reverse("investment_detail_index", args=[self.account.id]) + ) + self.assertEqual(response.status_code, 404) diff --git a/app/apps/investments/urls.py b/app/apps/investments/urls.py new file mode 100644 index 00000000..25c52f19 --- /dev/null +++ b/app/apps/investments/urls.py @@ -0,0 +1,49 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("investments/", views.investments_index, name="investments_index"), + path("investments/list/", views.investments_list, name="investments_list"), + path("investments/charts/", views.investments_charts, name="investments_charts"), + path( + "investments/rebalance/", + views.investments_rebalance, + name="investments_rebalance", + ), + path( + "investments/classes/", + views.investment_classes_index, + name="investment_classes_index", + ), + path( + "investments/classes/list/", + views.investment_classes_list, + name="investment_classes_list", + ), + path( + "investments/classes/add/", + views.investment_class_add, + name="investment_class_add", + ), + path( + "investments/classes//edit/", + views.investment_class_edit, + name="investment_class_edit", + ), + path( + "investments/classes//delete/", + views.investment_class_delete, + name="investment_class_delete", + ), + path( + "investments//", + views.investment_detail_index, + name="investment_detail_index", + ), + path( + "investments//details/", + views.investment_detail, + name="investment_detail", + ), +] diff --git a/app/apps/investments/views.py b/app/apps/investments/views.py new file mode 100644 index 00000000..db0dec6c --- /dev/null +++ b/app/apps/investments/views.py @@ -0,0 +1,297 @@ +from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, render +from django.utils.formats import date_format +from django.utils.translation import gettext as _ +from django.views.decorators.http import require_http_methods + +from apps.accounts.forms import InvestmentClassForm +from apps.accounts.models import Account, InvestmentClass +from apps.common.decorators.htmx import only_htmx +from apps.common.functions.format import get_format +from apps.common.functions.permissions import READ, get_shared_object_or_error +from apps.investments.services import ( + ZERO, + get_holding_performance, + in_home_currency, + income_entity_ids, + portfolio_charts, + rebalance, + summarize, +) +from apps.transactions.models import Transaction + + +def _holdings(): + return Account.objects.filter(is_investment=True, is_archived=False).select_related( + "group", "currency", "exchange_currency" + ) + + +@login_required +def investments_index(request): + return render(request, "investments/pages/index.html") + + +@only_htmx +@login_required +def investments_list(request): + performances = [get_holding_performance(account) for account in _holdings()] + + groups = {} + for perf in performances: + group = perf.account.group + groups.setdefault(group.id if group else None, (group, []))[1].append(perf) + + return render( + request, + "investments/fragments/list.html", + { + "groups": [ + {"group": group, "holdings": items, "totals": summarize(items)} + for group, items in sorted( + groups.values(), key=lambda g: (g[0] is None, g[0] and g[0].name) + ) + ], + "totals": summarize(performances, convert=True), + }, + ) + + +def _money(currency): + """What chartMoney (charts.js) needs to format amounts like the rest of the app.""" + return { + "prefix": currency.prefix, + "suffix": currency.suffix, + "decimals": currency.decimal_places, + "decimal": get_format("DECIMAL_SEPARATOR"), + "thousand": get_format("THOUSAND_SEPARATOR"), + } + + +def _number(amount): + return None if amount is None else float(amount) + + +def _per_month(history, entries): + """Sums (date, amount) entries into the months of ``history``, zero when empty.""" + sums = {(p["date"].year, p["date"].month): ZERO for p in history} + for date, amount in entries: + if (date.year, date.month) in sums: + sums[(date.year, date.month)] += amount + return [float(amount) for amount in sums.values()] + + +def _trades_per_month(history, trades, kind): + """The last trade price of each month, None in months without one.""" + prices = {(p["date"].year, p["date"].month): None for p in history} + for date, trade_kind, price in trades: + if trade_kind == kind and (date.year, date.month) in prices: + prices[(date.year, date.month)] = float(price) + return list(prices.values()) + + +# Categorical slots in fixed order; past the last one, the rest fold into "Other" +PALETTE = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4", "#008300", "#6250d6"] +OTHER_COLOR = "#898781" + + +def _shares(items): + colors = PALETTE[: len(items)] + if len(items) > len(PALETTE): + rest = sum(amount for _name, amount in items[len(PALETTE) - 1 :]) + items = items[: len(PALETTE) - 1] + [(_("Other"), rest)] + colors = PALETTE[:-1] + [OTHER_COLOR] + total = sum(amount for _name, amount in items) + return [ + { + "key": name, + "name": str(name), + "amount": amount, + "share": amount / total * 100 if total else 0, + "color": color, + } + for (name, amount), color in zip(items, colors) + ] + + +@only_htmx +@login_required +def investments_charts(request): + performances = [ + get_holding_performance(account, with_history=True) for account in _holdings() + ] + charts = portfolio_charts(performances) + targeted = _targeted() + # Shares only mean something against targets that add up to 100% + if sum(cls.target for cls in targeted) != 100: + targeted = [] + for i, chart in enumerate(charts): + chart["data_id"] = f"investment-charts-{i}" + chart["returns_height"] = max(128, 32 * len(chart["returns"])) + plan = {row["class"]: row for row in rebalance(chart["classes"], targeted)["rows"]} + chart["classes"] = _shares( + [(cls or _("Unclassified"), amount) for cls, amount in chart["classes"]] + ) + for share in chart["classes"]: + share["plan"] = plan.get(share["key"]) + chart["currencies"] = _shares(chart["currencies"]) + chart["data"] = { + "currency": _money(chart["currency"]), + "classes": [[s["name"], float(s["amount"]), s["color"]] for s in chart["classes"]], + "currencies": [[s["name"], float(s["amount"]), s["color"]] for s in chart["currencies"]], + "returns": [[name, float(amount)] for name, amount in chart["returns"]], + "history": { + "labels": [date_format(d, "M Y") for d, _t in chart["history"]], + "value": [float(t[0]) for _d, t in chart["history"]], + "invested": [float(t[1]) for _d, t in chart["history"]], + }, + } + return render(request, "investments/fragments/charts.html", {"charts": charts}) + + +def _targeted(): + return list(InvestmentClass.objects.filter(target__isnull=False)) + + +@only_htmx +@login_required +def investments_rebalance(request): + targeted = _targeted() + total_target = sum(cls.target for cls in targeted) + plans = [] + for chart in portfolio_charts([get_holding_performance(a) for a in _holdings()]): + plan = rebalance(chart["classes"], targeted) + plan["currency"] = chart["currency"] + for row in plan["rows"]: + # Fractions of a cent shouldn't show as "Buy 0.00" + row["move"] = round(row["move"], chart["currency"].decimal_places) + row["size"] = abs(row["move"]) + plans.append(plan) + return render( + request, + "investments/fragments/rebalance.html", + {"plans": plans, "total_target": total_target, "targeted": targeted}, + ) + + +@login_required +def investment_detail_index(request, account_id): + account = get_shared_object_or_error( + Account, request, id=account_id, level=READ, is_investment=True + ) + return render(request, "investments/pages/detail.html", {"account": account}) + + +@only_htmx +@login_required +def investment_detail(request, account_id): + account = get_shared_object_or_error( + Account, request, id=account_id, level=READ, is_investment=True + ) + perf = get_holding_performance(account, with_history=True) + transactions = ( + Transaction.objects.filter(account=account) + .select_related("linked_transaction__account__currency", "category") + .order_by("-date", "-id")[:50] + ) + income = [] + entity_ids = income_entity_ids(account) + if entity_ids: + income = ( + Transaction.objects.filter( + entities__in=entity_ids, type=Transaction.Type.INCOME + ) + .exclude(account=account) + .distinct() + .select_related("account__currency") + .order_by("-date", "-id")[:20] + ) + + history = perf.history + income_flows = [(d, amount) for d, amount, kind in perf.flows if kind == "income"] + chart = { + "currency": _money(perf.currency), + "labels": [date_format(p["date"], "M Y") for p in history], + "value": [_number(p["value"]) for p in history], + "invested": [float(p["invested"]) for p in history], + "price": [_number(p["price"]) for p in history] if perf.priced else None, + "average_cost": [_number(p["average_cost"]) for p in history], + "buys": _trades_per_month(history, perf.trades, "buy"), + "sells": _trades_per_month(history, perf.trades, "sell"), + "earnings": _per_month(history, perf.results) if perf.results else None, + "income": _per_month(history, income_flows) if income_flows else None, + } + + return render( + request, + "investments/fragments/detail.html", + { + "account": account, + "perf": perf, + "home": in_home_currency(perf), + "transactions": transactions, + "income": income, + "income_entities": list(account.income_entities.all()), + "chart": chart, + }, + ) + + +@login_required +@require_http_methods(["GET"]) +def investment_classes_index(request): + return render(request, "investment_classes/pages/index.html") + + +@only_htmx +@login_required +@require_http_methods(["GET"]) +def investment_classes_list(request): + classes = InvestmentClass.objects.all() + return render( + request, + "investment_classes/fragments/list.html", + { + "classes": classes, + "total_target": sum(cls.target or 0 for cls in classes), + }, + ) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def investment_class_add(request): + return _investment_class_form(request, InvestmentClass(), _("Asset class added successfully")) + + +@only_htmx +@login_required +@require_http_methods(["GET", "POST"]) +def investment_class_edit(request, pk): + cls = get_object_or_404(InvestmentClass, pk=pk) + return _investment_class_form(request, cls, _("Asset class updated successfully")) + + +def _investment_class_form(request, instance, success): + form = InvestmentClassForm(request.POST or None, instance=instance) + if request.method == "POST" and form.is_valid(): + form.save() + messages.success(request, success) + return HttpResponse(status=204, headers={"HX-Trigger": "updated, hide_offcanvas"}) + return render( + request, + "investment_classes/fragments/form.html", + {"form": form, "object": instance}, + ) + + +@only_htmx +@login_required +@require_http_methods(["DELETE"]) +def investment_class_delete(request, pk): + get_object_or_404(InvestmentClass, pk=pk).delete() + messages.success(request, _("Asset class deleted successfully")) + return HttpResponse(status=204, headers={"HX-Trigger": "updated, hide_offcanvas"}) diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index 03255867..8093b2e1 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -509,6 +509,30 @@ class Transaction(OwnedObject): self.save() return to_transaction + def transfer_link_error(self, other): + """Why ``self`` and ``other`` can't be joined into a transfer, or None.""" + if self.linked_transaction_id == other.pk and other.linked_transaction_id == self.pk: + return None + if {self.type, other.type} != {self.Type.INCOME, self.Type.EXPENSE}: + return _("A transfer needs one expense and one income.") + if self.account_id == other.account_id: + return _("A transfer needs two different accounts.") + if self.linked_transaction_id not in (None, other.pk) or ( + other.linked_transaction_id not in (None, self.pk) + ): + return _("This transaction is already part of another transfer.") + return None + + def link_transfer(self, other): + """Joins an existing expense and income into a transfer.""" + error = self.transfer_link_error(other) + if error: + raise ValidationError(error) + # update(): only the link changes; dates, amounts and rules are left alone + Transaction.all_objects.filter(pk=self.pk).update(linked_transaction=other) + Transaction.all_objects.filter(pk=other.pk).update(linked_transaction=self) + self.linked_transaction, other.linked_transaction = other, self + @property def transfer_from(self): if self.linked_transaction_id and self.type == self.Type.INCOME: diff --git a/app/apps/transactions/tests/test_transaction_search.py b/app/apps/transactions/tests/test_transaction_search.py new file mode 100644 index 00000000..6b02e95b --- /dev/null +++ b/app/apps/transactions/tests/test_transaction_search.py @@ -0,0 +1,74 @@ +from datetime import timedelta +from decimal import Decimal + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from apps.accounts.models import Account +from apps.currencies.models import Currency +from apps.transactions.models import ( + Transaction, + TransactionCategory, + TransactionEntity, + TransactionTag, +) + + +class TransactionSearchTests(TestCase): + def setUp(self): + self.user = get_user_model().objects.create_user( + email="search@test.com", password="pass" + ) + self.client.login(username="search@test.com", password="pass") + self.user.settings.date_format = "d/m/Y" + self.user.settings.number_format = "DC" + self.user.settings.save() + + currency = Currency.objects.create(code="BRL", name="Real", decimal_places=2) + self.account = Account.objects.create(name="Wallet", currency=currency) + self.today = timezone.localdate(timezone.now()) + + def make(self, days, description="", amount="10", **kwargs): + return Transaction.objects.create( + account=self.account, + type=Transaction.Type.EXPENSE, + date=self.today + timedelta(days=days), + reference_date=(self.today + timedelta(days=days)).replace(day=1), + amount=Decimal(amount), + description=description, + **kwargs, + ) + + def search(self, q=""): + response = self.client.get(reverse("transactions_search"), {"q": q}) + self.assertEqual(response.status_code, 200) + return [row["description"] for row in response.json()] + + def test_order_matches_monthly_view(self): + for days, name in [(-30, "old"), (-2, "recent"), (-1, "yesterday"), + (10, "far"), (2, "soon"), (1, "tomorrow"), (0, "today")]: + self.make(days, name) + self.assertEqual( + self.search(), + ["today", "tomorrow", "yesterday", "soon", "far", "recent", "old"], + ) + + def test_search_fields(self): + t = self.make(-5, "Groceries", amount="1234.56") + t.category = TransactionCategory.objects.create(name="Food") + t.save() + t.tags.add(TransactionTag.objects.create(name="home"), + TransactionTag.objects.create(name="weekly")) + t.entities.add(TransactionEntity.objects.create(name="Market")) + self.make(-6, "Other", amount="5") + + day = (self.today - timedelta(days=5)) + for q in ["food", "market", "1.234,56", "1234", + day.strftime("%d/%m/%Y"), day.strftime("%d/%m"), + "home weekly", "groceries food"]: + self.assertEqual(self.search(q), ["Groceries"], q) + + self.assertEqual(self.search("wallet"), ["Groceries", "Other"]) + self.assertEqual(self.search("groceries nomatch"), []) diff --git a/app/apps/transactions/utils/select_search.py b/app/apps/transactions/utils/select_search.py new file mode 100644 index 00000000..161383a5 --- /dev/null +++ b/app/apps/transactions/utils/select_search.py @@ -0,0 +1,95 @@ +import datetime +import re +from decimal import Decimal, InvalidOperation + +from apps.common.functions.format import get_format +from apps.common.utils.django import django_to_python_datetime +from apps.transactions.templatetags.currency_display import currency_display +from django.db.models import Q +from django.template.defaultfilters import date as date_filter + + +def transaction_option(t): + """Data used by the transaction picker (tom-select) to render a mini transaction.""" + currency = t.account.currency + return { + "value": str(t.id), + "text": str(t), + "description": t.description, + "date": date_filter(t.date, "SHORT_DATE_FORMAT"), + "type": t.type, + "is_paid": t.is_paid, + "amount": currency_display( + t.amount, + currency.prefix, + currency.suffix, + currency.decimal_places, + string=True, + ), + "account": ( + f"{t.account.group.name} • {t.account.name}" + if t.account.group + else t.account.name + ), + "category": t.category.name if t.category else "", + "tags": [tag.name for tag in t.tags.all()], + "entities": [entity.name for entity in t.entities.all()], + } + + +def _parse_date(term): + """Match a term against the user's date format; without a year it matches any year.""" + fmt = django_to_python_datetime(get_format("SHORT_DATE_FORMAT")) + try: + return Q(date=datetime.datetime.strptime(term, fmt).date()) + except ValueError: + pass + # Drop the year and its adjacent separator, e.g. "%d/%m/%Y" -> "%d/%m" + no_year = re.sub(r"[^%\w]?%[Yy][^%\w]?", "", fmt, count=1).strip() + if no_year == fmt or not no_year: + return None + try: + # 2000 is a leap year, so 29/02 parses + parsed = datetime.datetime.strptime(f"{term} 2000", f"{no_year} %Y") + except ValueError: + return None + return Q(date__day=parsed.day, date__month=parsed.month) + + +def _parse_amount(term): + if not re.fullmatch(r"[\d.,\s]*\d[\d.,\s]*", term): + return None + thousand = get_format("THOUSAND_SEPARATOR") + decimal = get_format("DECIMAL_SEPARATOR") + normalized = term.replace(thousand, "").replace(decimal, ".") + try: + value = Decimal(normalized) + except InvalidOperation: + return None + if decimal not in term: + # Whole number: "12" finds 12.00 to 12.99 + return Q(amount__gte=value, amount__lt=value + 1) + return Q(amount=value) + + +def search_transactions(queryset, search_term): + """Every word must match some field (description, category, entity, date, amount...).""" + matches = queryset.model.objects.all() + for term in search_term.split(): + term_q = ( + Q(description__icontains=term) + | Q(notes__icontains=term) + | Q(internal_note__icontains=term) + | Q(tags__name__icontains=term) + | Q(entities__name__icontains=term) + | Q(category__name__icontains=term) + | Q(account__name__icontains=term) + | Q(account__group__name__icontains=term) + ) + for extra in (_parse_date(term), _parse_amount(term)): + if extra is not None: + term_q |= extra + # One filter() per word so different words can match different tags/entities + matches = matches.filter(term_q) + # id__in avoids duplicate rows from the tag/entity joins + return queryset.filter(id__in=matches.values("id")) diff --git a/app/apps/transactions/views/transactions.py b/app/apps/transactions/views/transactions.py index a18c17bf..07b7f731 100644 --- a/app/apps/transactions/views/transactions.py +++ b/app/apps/transactions/views/transactions.py @@ -20,11 +20,15 @@ from apps.transactions.utils.calculations import ( calculate_percentage_distribution, ) from apps.transactions.utils.default_ordering import default_order +from apps.transactions.utils.select_search import ( + search_transactions, + transaction_option, +) from dateutil.relativedelta import relativedelta from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator -from django.db.models import Case, IntegerField, Q, Value, When +from django.db.models import Case, F, IntegerField, Q, Value, When from django.http import FileResponse, Http404, HttpResponse, JsonResponse, QueryDict from django.shortcuts import get_object_or_404, render from django.utils import timezone @@ -119,6 +123,15 @@ def transaction_attachment_delete(request, attachment_id): return response +def _prefill(request, *fields, entities=False): + """Form initial values passed as query parameters; only the named ones, and + only when present, so they never override other defaults.""" + initial = {name: request.GET[name] for name in fields if request.GET.get(name)} + if entities and request.GET.getlist("entities"): + initial["entities"] = request.GET.getlist("entities") # entity names + return initial + + @only_htmx @login_required @require_http_methods(["GET", "POST"]) @@ -204,6 +217,8 @@ def transaction_add(request): initial={ "date": expected_date, "type": transaction_type, + # Optional prefill, e.g. a dividend from the investments page + **_prefill(request, "account", entities=True), }, ) @@ -541,6 +556,8 @@ def transactions_transfer(request): initial={ "reference_date": expected_date, "date": expected_date, + # Optional prefill, e.g. buy/sell from the investments page + **_prefill(request, "from_account", "to_account"), }, ) @@ -971,51 +988,41 @@ def transactions_trash_can_list(request): @login_required @require_http_methods(["GET"]) def get_recent_transactions(request, filter_type=None): - """Return the 100 most recent non-deleted transactions with optional search.""" - # Get search term from query params + """Return up to 100 non-deleted transactions, ordered like the monthly view, with optional search.""" search_term = request.GET.get("q", "").strip() today = timezone.localdate(timezone.now()) yesterday = today - timezone.timedelta(days=1) tomorrow = today + timezone.timedelta(days=1) - # Base queryset with selected fields + queryset = Transaction.objects.filter(deleted=False) + + if filter_type == "expenses": + queryset = queryset.filter(type=Transaction.Type.EXPENSE) + elif filter_type == "income": + queryset = queryset.filter(type=Transaction.Type.INCOME) + + if search_term: + queryset = search_transactions(queryset, search_term) + + # Today, tomorrow, yesterday, then upcoming (soonest first), then past (newest first) queryset = ( - Transaction.objects.filter(deleted=False) - .annotate( + queryset.annotate( date_order=Case( When(date=today, then=Value(0)), When(date=tomorrow, then=Value(1)), When(date=yesterday, then=Value(2)), When(date__gt=tomorrow, then=Value(3)), - When(date__lt=yesterday, then=Value(4)), - default=Value(5), + default=Value(4), output_field=IntegerField(), - ) + ), + upcoming_date=Case(When(date__gt=tomorrow, then=F("date"))), ) - .select_related("account", "category") - .order_by("date_order", "date", "id") + .select_related("account__currency", "account__group", "category") + .prefetch_related("tags", "entities") + .order_by( + "date_order", F("upcoming_date").asc(nulls_last=True), "-date", "-id" + )[:100] ) - if filter_type: - if filter_type == "expenses": - queryset = queryset.filter(type=Transaction.Type.EXPENSE) - elif filter_type == "income": - queryset = queryset.filter(type=Transaction.Type.INCOME) - - # Apply search if provided - if search_term: - queryset = queryset.filter( - Q(description__icontains=search_term) - | Q(notes__icontains=search_term) - | Q(internal_note__icontains=search_term) - | Q(tags__name__icontains=search_term) - | Q(category__name__icontains=search_term) - ) - - # Prepare data for JSON response - data = [] - for t in queryset: - data.append({"text": str(t), "value": str(t.id)}) - - return JsonResponse(data, safe=False) + return JsonResponse([transaction_option(t) for t in queryset], safe=False) diff --git a/app/locale/de/LC_MESSAGES/django.po b/app/locale/de/LC_MESSAGES/django.po index 7371dcab..4ed1ee37 100644 --- a/app/locale/de/LC_MESSAGES/django.po +++ b/app/locale/de/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-08 06:01+0000\n" +"POT-Creation-Date: 2026-11-08 18:27+0000\n" "PO-Revision-Date: 2026-08-10 12:57+0000\n" "Last-Translator: GitEbu \n" "Language-Team: German \n" "Language-Team: LANGUAGE \n" @@ -18,73 +18,80 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: apps/accounts/forms.py:28 +#: apps/accounts/forms.py:33 msgid "Group name" msgstr "" -#: apps/accounts/forms.py:43 apps/accounts/forms.py:109 -#: apps/currencies/forms.py:57 apps/currencies/forms.py:91 -#: apps/currencies/forms.py:166 apps/dca/forms.py:46 apps/dca/forms.py:205 -#: apps/import_app/forms.py:32 apps/rules/forms.py:60 apps/rules/forms.py:100 -#: apps/rules/forms.py:385 apps/transactions/forms.py:219 -#: apps/transactions/forms.py:452 apps/transactions/forms.py:571 -#: apps/transactions/forms.py:718 apps/transactions/forms.py:940 -#: apps/transactions/forms.py:987 apps/transactions/forms.py:1015 -#: apps/transactions/forms.py:1046 apps/transactions/forms.py:1208 -#: apps/users/forms.py:264 apps/users/forms.py:422 +#: apps/accounts/forms.py:48 apps/accounts/forms.py:76 +#: apps/accounts/forms.py:184 apps/currencies/forms.py:57 +#: apps/currencies/forms.py:91 apps/currencies/forms.py:168 +#: apps/dca/forms.py:46 apps/dca/forms.py:192 apps/import_app/forms.py:32 +#: apps/rules/forms.py:60 apps/rules/forms.py:100 apps/rules/forms.py:385 +#: apps/transactions/forms.py:219 apps/transactions/forms.py:452 +#: apps/transactions/forms.py:571 apps/transactions/forms.py:718 +#: apps/transactions/forms.py:940 apps/transactions/forms.py:987 +#: apps/transactions/forms.py:1015 apps/transactions/forms.py:1046 +#: apps/transactions/forms.py:1208 apps/users/forms.py:264 +#: apps/users/forms.py:422 #: templates/rules/fragments/transaction_rule/dry_run/updated.html:5 #: templates/rules/fragments/transaction_rule/view.html:128 msgid "Update" msgstr "" -#: apps/accounts/forms.py:49 apps/accounts/forms.py:115 -#: apps/common/widgets/tom_select.py:13 apps/currencies/forms.py:63 -#: apps/currencies/forms.py:97 apps/currencies/forms.py:172 -#: apps/dca/forms.py:52 apps/dca/forms.py:211 apps/import_app/forms.py:38 -#: apps/rules/forms.py:66 apps/rules/forms.py:106 apps/rules/forms.py:391 -#: apps/transactions/forms.py:206 apps/transactions/forms.py:226 -#: apps/transactions/forms.py:459 apps/transactions/forms.py:946 -#: apps/transactions/forms.py:993 apps/transactions/forms.py:1021 -#: apps/transactions/forms.py:1052 apps/transactions/forms.py:1214 -#: apps/users/forms.py:270 apps/users/forms.py:428 -#: templates/mini_tools/simulator.html:177 +#: apps/accounts/forms.py:54 apps/accounts/forms.py:76 +#: apps/accounts/forms.py:190 apps/common/widgets/tom_select.py:15 +#: apps/currencies/forms.py:63 apps/currencies/forms.py:97 +#: apps/currencies/forms.py:174 apps/dca/forms.py:52 apps/dca/forms.py:198 +#: apps/import_app/forms.py:38 apps/rules/forms.py:66 apps/rules/forms.py:106 +#: apps/rules/forms.py:391 apps/transactions/forms.py:206 +#: apps/transactions/forms.py:226 apps/transactions/forms.py:459 +#: apps/transactions/forms.py:946 apps/transactions/forms.py:993 +#: apps/transactions/forms.py:1021 apps/transactions/forms.py:1052 +#: apps/transactions/forms.py:1214 apps/users/forms.py:270 +#: apps/users/forms.py:428 templates/mini_tools/simulator.html:177 #: templates/mini_tools/unit_price_calculator.html:168 msgid "Add" msgstr "" -#: apps/accounts/forms.py:57 templates/accounts/fragments/list.html:23 +#: apps/accounts/forms.py:86 templates/accounts/fragments/list.html:23 msgid "Group" msgstr "" -#: apps/accounts/forms.py:123 +#: apps/accounts/forms.py:93 apps/accounts/models.py:73 +#: apps/accounts/models.py:130 +#: templates/investments/fragments/rebalance.html:22 +msgid "Asset class" +msgstr "" + +#: apps/accounts/forms.py:198 msgid "New balance" msgstr "" -#: apps/accounts/forms.py:129 apps/dca/forms.py:79 apps/dca/forms.py:86 +#: apps/accounts/forms.py:204 apps/dca/forms.py:79 apps/dca/forms.py:86 #: apps/insights/forms.py:117 apps/insights/views.py:44 apps/rules/forms.py:181 #: apps/rules/forms.py:197 apps/rules/models.py:44 apps/rules/models.py:311 #: apps/transactions/forms.py:62 apps/transactions/forms.py:330 #: apps/transactions/forms.py:510 apps/transactions/forms.py:607 #: apps/transactions/forms.py:614 apps/transactions/forms.py:823 #: apps/transactions/forms.py:1075 apps/transactions/models.py:357 -#: apps/transactions/models.py:743 apps/transactions/models.py:999 -#: apps/transactions/models.py:1306 +#: apps/transactions/models.py:767 apps/transactions/models.py:1023 +#: apps/transactions/models.py:1330 #: templates/insights/fragments/month_by_month.html:84 #: templates/insights/fragments/year_by_year.html:52 msgid "Category" msgstr "" -#: apps/accounts/forms.py:136 apps/dca/forms.py:95 apps/dca/forms.py:103 -#: apps/export_app/forms.py:43 apps/export_app/forms.py:132 +#: apps/accounts/forms.py:211 apps/dca/forms.py:95 apps/dca/forms.py:103 +#: apps/export_app/forms.py:43 apps/export_app/forms.py:134 #: apps/insights/views.py:50 apps/rules/forms.py:184 apps/rules/forms.py:194 #: apps/rules/models.py:45 apps/rules/models.py:315 #: apps/transactions/filters.py:79 apps/transactions/forms.py:70 #: apps/transactions/forms.py:338 apps/transactions/forms.py:518 #: apps/transactions/forms.py:623 apps/transactions/forms.py:631 #: apps/transactions/forms.py:816 apps/transactions/forms.py:1068 -#: apps/transactions/models.py:363 apps/transactions/models.py:745 -#: apps/transactions/models.py:1003 apps/transactions/models.py:1312 -#: templates/includes/sidebar.html:156 +#: apps/transactions/models.py:363 apps/transactions/models.py:769 +#: apps/transactions/models.py:1027 apps/transactions/models.py:1336 +#: templates/includes/sidebar.html:162 #: templates/insights/fragments/month_by_month.html:29 #: templates/insights/fragments/month_by_month.html:32 #: templates/insights/fragments/year_by_year.html:24 @@ -93,23 +100,23 @@ msgstr "" msgid "Tags" msgstr "" -#: apps/accounts/forms.py:179 +#: apps/accounts/forms.py:254 msgid "All paid transactions" msgstr "" -#: apps/accounts/forms.py:180 +#: apps/accounts/forms.py:255 msgid "Up to date" msgstr "" -#: apps/accounts/forms.py:181 +#: apps/accounts/forms.py:256 msgid "Up to reference month" msgstr "" -#: apps/accounts/forms.py:185 +#: apps/accounts/forms.py:260 msgid "Balance against" msgstr "" -#: apps/accounts/forms.py:189 apps/currencies/forms.py:70 +#: apps/accounts/forms.py:264 apps/currencies/forms.py:70 #: apps/dca/models.py:158 apps/rules/forms.py:176 apps/rules/forms.py:190 #: apps/rules/models.py:38 apps/rules/models.py:279 #: apps/transactions/forms.py:86 apps/transactions/forms.py:482 @@ -120,14 +127,15 @@ msgstr "" msgid "Date" msgstr "" -#: apps/accounts/forms.py:194 +#: apps/accounts/forms.py:269 msgid "Reference month" msgstr "" -#: apps/accounts/models.py:12 apps/accounts/models.py:29 apps/dca/models.py:13 +#: apps/accounts/models.py:19 apps/accounts/models.py:36 +#: apps/accounts/models.py:84 apps/dca/models.py:13 #: apps/import_app/models.py:20 apps/rules/models.py:13 #: apps/transactions/models.py:249 apps/transactions/models.py:274 -#: apps/transactions/models.py:298 apps/transactions/models.py:1274 +#: apps/transactions/models.py:298 apps/transactions/models.py:1298 #: apps/users/models.py:577 templates/account_groups/fragments/list.html:22 #: templates/accounts/fragments/list.html:22 #: templates/categories/fragments/table.html:17 @@ -136,6 +144,7 @@ msgstr "" #: templates/exchange_rates_services/fragments/list.html:28 #: templates/import_app/fragments/profiles/list.html:34 #: templates/installment_plans/fragments/table.html:16 +#: templates/investment_classes/fragments/list.html:27 #: templates/quick_transactions/fragments/list.html:13 #: templates/recurring_transactions/fragments/table.html:18 #: templates/rules/fragments/list.html:24 @@ -144,42 +153,119 @@ msgstr "" msgid "Name" msgstr "" -#: apps/accounts/models.py:18 apps/accounts/models.py:33 +#: apps/accounts/models.py:25 apps/accounts/models.py:88 msgid "Account Group" msgstr "" -#: apps/accounts/models.py:19 templates/account_groups/fragments/list.html:9 +#: apps/accounts/models.py:26 templates/account_groups/fragments/list.html:9 #: templates/account_groups/pages/index.html:4 -#: templates/includes/sidebar.html:176 +#: templates/includes/sidebar.html:182 msgid "Account Groups" msgstr "" -#: apps/accounts/models.py:39 apps/currencies/models.py:44 +#: apps/accounts/models.py:43 +msgid "Target allocation (%)" +msgstr "" + +#: apps/accounts/models.py:45 +msgid "" +"Share of your investments you want in this class. Leave it empty to leave " +"the class out of the targets." +msgstr "" + +#: apps/accounts/models.py:61 +msgid "You already have an asset class with this name." +msgstr "" + +#: apps/accounts/models.py:66 +#, python-format +msgid "" +"Targets can't add up to more than 100%%. Other classes already take " +"%(taken)s%%." +msgstr "" + +#: apps/accounts/models.py:74 apps/export_app/forms.py:131 +#: templates/includes/sidebar.html:190 +#: templates/investment_classes/fragments/list.html:9 +#: templates/investment_classes/pages/index.html:4 +#: templates/investments/fragments/rebalance.html:6 +#: templates/investments/fragments/rebalance.html:11 +msgid "Asset classes" +msgstr "" + +#: apps/accounts/models.py:94 apps/currencies/models.py:44 #: templates/accounts/fragments/list.html:24 #: templates/mini_tools/simulator.html:37 #: templates/yearly_overview/pages/yearly_overview_base.html:19 msgid "Currency" msgstr "" -#: apps/accounts/models.py:45 apps/currencies/models.py:27 +#: apps/accounts/models.py:100 apps/currencies/models.py:27 #: templates/accounts/fragments/list.html:25 msgid "Exchange Currency" msgstr "" -#: apps/accounts/models.py:50 apps/currencies/models.py:32 +#: apps/accounts/models.py:105 apps/currencies/models.py:32 msgid "Default currency for exchange calculations" msgstr "" -#: apps/accounts/models.py:55 +#: apps/accounts/models.py:110 msgid "Asset account" msgstr "" -#: apps/accounts/models.py:57 +#: apps/accounts/models.py:112 msgid "" "Asset accounts count towards your Net Worth, but not towards your month." msgstr "" -#: apps/accounts/models.py:62 apps/currencies/models.py:37 +#: apps/accounts/models.py:117 templates/investments/fragments/list.html:46 +msgid "Investment" +msgstr "" + +#: apps/accounts/models.py:119 +msgid "" +"Show this account in the Investments tracker. Set an exchange currency to " +"price it by units (stocks, ETFs, crypto); leave it empty to track it by " +"value (fixed income, property, pensions)." +msgstr "" + +#: apps/accounts/models.py:132 +msgid "" +"Groups investments in the allocation charts, e.g. Fixed income, Stocks, Real " +"estate." +msgstr "" + +#: apps/accounts/models.py:140 +msgid "Income entities" +msgstr "" + +#: apps/accounts/models.py:142 +msgid "" +"Transactions with any of these entities count towards this investment's " +"return: in this account, and income paid into other accounts (dividends, " +"rent)." +msgstr "" + +#: apps/accounts/models.py:151 +msgid "Income categories" +msgstr "" + +#: apps/accounts/models.py:153 +msgid "" +"Transactions in this account with any of these categories are its result " +"(interest, taxes, fees). On accounts tracked by value, everything else that " +"isn't a transfer counts as money coming in or going out." +msgstr "" + +#: apps/accounts/models.py:162 +msgid "Income tags" +msgstr "" + +#: apps/accounts/models.py:164 +msgid "Same as income categories, for transactions with any of these tags." +msgstr "" + +#: apps/accounts/models.py:169 apps/currencies/models.py:37 #: templates/accounts/fragments/list.html:27 #: templates/categories/fragments/list.html:20 #: templates/currencies/fragments/list.html:24 @@ -188,17 +274,17 @@ msgstr "" msgid "Archived" msgstr "" -#: apps/accounts/models.py:63 +#: apps/accounts/models.py:170 msgid "Archived accounts don't show up nor count towards your net worth" msgstr "" -#: apps/accounts/models.py:75 apps/rules/forms.py:173 apps/rules/forms.py:187 +#: apps/accounts/models.py:182 apps/rules/forms.py:173 apps/rules/forms.py:187 #: apps/rules/models.py:35 apps/rules/models.py:267 #: apps/transactions/forms.py:82 apps/transactions/forms.py:350 #: apps/transactions/forms.py:477 apps/transactions/forms.py:808 #: apps/transactions/forms.py:1060 apps/transactions/models.py:329 -#: apps/transactions/models.py:703 apps/transactions/models.py:981 -#: apps/transactions/models.py:1280 +#: apps/transactions/models.py:727 apps/transactions/models.py:1005 +#: apps/transactions/models.py:1304 #: templates/installment_plans/fragments/table.html:17 #: templates/quick_transactions/fragments/list.html:14 #: templates/recurring_transactions/fragments/table.html:19 @@ -206,17 +292,17 @@ msgstr "" msgid "Account" msgstr "" -#: apps/accounts/models.py:76 apps/export_app/forms.py:19 -#: apps/export_app/forms.py:129 apps/transactions/filters.py:63 +#: apps/accounts/models.py:183 apps/export_app/forms.py:19 +#: apps/export_app/forms.py:130 apps/transactions/filters.py:63 #: templates/accounts/fragments/list.html:9 -#: templates/accounts/pages/index.html:4 templates/includes/sidebar.html:168 -#: templates/includes/sidebar.html:170 +#: templates/accounts/pages/index.html:4 templates/includes/sidebar.html:174 +#: templates/includes/sidebar.html:176 #: templates/monthly_overview/pages/overview.html:77 #: templates/transactions/pages/transactions.html:26 msgid "Accounts" msgstr "" -#: apps/accounts/models.py:93 +#: apps/accounts/models.py:200 msgid "Exchange currency cannot be the same as the account's main currency." msgstr "" @@ -234,7 +320,7 @@ msgstr "" #: apps/accounts/views/account_groups.py:109 #: apps/accounts/views/accounts.py:137 apps/dca/views.py:106 -#: apps/import_app/views.py:147 apps/rules/views.py:191 +#: apps/import_app/views.py:148 apps/rules/views.py:191 #: apps/transactions/views/categories.py:163 #: apps/transactions/views/entities.py:135 apps/transactions/views/tags.py:131 msgid "Item no longer shared with you" @@ -242,7 +328,7 @@ msgstr "" #: apps/accounts/views/account_groups.py:132 #: apps/accounts/views/accounts.py:182 apps/dca/views.py:131 -#: apps/import_app/views.py:273 apps/rules/views.py:217 +#: apps/import_app/views.py:274 apps/rules/views.py:217 #: apps/transactions/views/categories.py:188 #: apps/transactions/views/entities.py:160 apps/transactions/views/tags.py:154 msgid "Ownership taken successfully" @@ -250,7 +336,7 @@ msgstr "" #: apps/accounts/views/account_groups.py:152 #: apps/accounts/views/accounts.py:106 apps/dca/views.py:151 -#: apps/import_app/views.py:294 apps/rules/views.py:238 +#: apps/import_app/views.py:295 apps/rules/views.py:238 #: apps/transactions/views/categories.py:130 #: apps/transactions/views/entities.py:180 apps/transactions/views/tags.py:174 msgid "Configuration saved successfully" @@ -308,7 +394,7 @@ msgstr "" msgid "Invalid entity data. Provide an ID or name." msgstr "" -#: apps/api/serializers/transactions.py:141 apps/transactions/models.py:953 +#: apps/api/serializers/transactions.py:141 apps/transactions/models.py:977 msgid "This field is required." msgstr "" @@ -328,13 +414,14 @@ msgstr "" msgid "Shared with users" msgstr "" -#: apps/common/fields/forms/dynamic_select.py:71 -#: apps/common/fields/forms/dynamic_select.py:142 +#: apps/common/fields/forms/dynamic_select.py:79 +#: apps/common/fields/forms/dynamic_select.py:150 msgid "Error creating new instance" msgstr "" #: apps/common/fields/forms/grouped_select.py:24 -#: apps/common/widgets/tom_select.py:92 apps/common/widgets/tom_select.py:95 +#: apps/common/widgets/tom_select.py:94 apps/common/widgets/tom_select.py:97 +#: templates/investments/fragments/list.html:26 msgid "Ungrouped" msgstr "" @@ -477,14 +564,15 @@ msgid "Today" msgstr "" #: apps/common/widgets/datepicker.py:142 +#: templates/investments/fragments/rebalance.html:24 msgid "Now" msgstr "" -#: apps/common/widgets/tom_select.py:11 +#: apps/common/widgets/tom_select.py:13 msgid "Remove" msgstr "" -#: apps/common/widgets/tom_select.py:15 templates/mini_tools/simulator.html:187 +#: apps/common/widgets/tom_select.py:17 templates/mini_tools/simulator.html:187 #: templates/mini_tools/unit_price_calculator.html:180 #: templates/monthly_overview/pages/overview.html:286 #: templates/transactions/fragments/filter_actions.html:48 @@ -492,7 +580,7 @@ msgstr "" msgid "Clear" msgstr "" -#: apps/common/widgets/tom_select.py:16 +#: apps/common/widgets/tom_select.py:18 msgid "No results..." msgstr "" @@ -526,10 +614,10 @@ msgid "Decimal Places" msgstr "" #: apps/currencies/models.py:45 apps/export_app/forms.py:25 -#: apps/export_app/forms.py:130 apps/transactions/filters.py:70 +#: apps/export_app/forms.py:132 apps/transactions/filters.py:70 #: templates/currencies/fragments/list.html:9 -#: templates/currencies/pages/index.html:4 templates/includes/sidebar.html:182 -#: templates/includes/sidebar.html:184 +#: templates/currencies/pages/index.html:4 templates/includes/sidebar.html:196 +#: templates/includes/sidebar.html:198 #: templates/monthly_overview/pages/overview.html:63 #: templates/transactions/pages/transactions.html:12 msgid "Currencies" @@ -561,9 +649,9 @@ msgid "Auto" msgstr "" #: apps/currencies/models.py:82 apps/export_app/forms.py:67 -#: apps/export_app/forms.py:142 templates/exchange_rates/fragments/list.html:10 +#: apps/export_app/forms.py:144 templates/exchange_rates/fragments/list.html:10 #: templates/exchange_rates/pages/index.html:4 -#: templates/includes/sidebar.html:190 +#: templates/includes/sidebar.html:204 msgid "Exchange Rates" msgstr "" @@ -652,36 +740,47 @@ msgid "" msgstr "" #: apps/currencies/models.py:171 -msgid "Single exchange rate" +msgid "One exchange rate per day" msgstr "" #: apps/currencies/models.py:174 -msgid "Create one exchange rate and keep updating it. Avoids database clutter." +msgid "" +"Update the day's exchange rate on each fetch instead of adding a new one." msgstr "" -#: apps/currencies/models.py:179 +#: apps/currencies/models.py:178 +msgid "Exchange rates to keep" +msgstr "" + +#: apps/currencies/models.py:183 +msgid "" +"Keep at most this many automatic exchange rates per currency pair, deleting " +"the oldest first. Leave empty to keep all." +msgstr "" + +#: apps/currencies/models.py:188 msgid "Exchange Rate Service" msgstr "" -#: apps/currencies/models.py:180 +#: apps/currencies/models.py:189 msgid "Exchange Rate Services" msgstr "" -#: apps/currencies/models.py:234 +#: apps/currencies/models.py:243 msgid "'Every X hours' interval type requires a positive integer." msgstr "" -#: apps/currencies/models.py:243 +#: apps/currencies/models.py:252 msgid "'Every X hours' interval must be between 1 and 24." msgstr "" -#: apps/currencies/models.py:257 +#: apps/currencies/models.py:266 msgid "" "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "'1-5,8,10-12')." msgstr "" -#: apps/currencies/models.py:268 +#: apps/currencies/models.py:277 msgid "" "Invalid format. Please check the requirements for your selected interval " "type." @@ -751,21 +850,21 @@ msgstr "" msgid "Income Transaction" msgstr "" -#: apps/dca/forms.py:192 +#: apps/dca/forms.py:179 msgid "Link transaction" msgstr "" -#: apps/dca/forms.py:275 apps/dca/forms.py:276 apps/dca/forms.py:281 -#: apps/dca/forms.py:285 +#: apps/dca/forms.py:262 apps/dca/forms.py:263 apps/dca/forms.py:268 +#: apps/dca/forms.py:272 msgid "You must provide an account." msgstr "" -#: apps/dca/forms.py:290 apps/transactions/forms.py:752 -#: apps/transactions/models.py:956 +#: apps/dca/forms.py:277 apps/transactions/forms.py:752 +#: apps/transactions/models.py:980 msgid "From and To accounts must be different." msgstr "" -#: apps/dca/forms.py:304 +#: apps/dca/forms.py:299 #, python-format msgid "DCA for %(strategy_name)s" msgstr "" @@ -781,8 +880,8 @@ msgstr "" #: apps/dca/models.py:26 apps/dca/models.py:181 apps/rules/forms.py:180 #: apps/rules/forms.py:196 apps/rules/models.py:43 apps/rules/models.py:295 #: apps/transactions/forms.py:504 apps/transactions/forms.py:651 -#: apps/transactions/models.py:353 apps/transactions/models.py:752 -#: apps/transactions/models.py:1009 apps/transactions/models.py:1302 +#: apps/transactions/models.py:353 apps/transactions/models.py:776 +#: apps/transactions/models.py:1033 apps/transactions/models.py:1326 msgid "Notes" msgstr "" @@ -790,7 +889,7 @@ msgstr "" msgid "DCA Strategy" msgstr "" -#: apps/dca/models.py:35 apps/export_app/forms.py:150 +#: apps/dca/models.py:35 apps/export_app/forms.py:152 msgid "DCA Strategies" msgstr "" @@ -810,7 +909,7 @@ msgstr "" msgid "DCA Entry" msgstr "" -#: apps/dca/models.py:187 apps/export_app/forms.py:151 +#: apps/dca/models.py:187 apps/export_app/forms.py:153 msgid "DCA Entries" msgstr "" @@ -838,25 +937,25 @@ msgstr "" msgid "Entry deleted successfully" msgstr "" -#: apps/export_app/forms.py:13 apps/export_app/forms.py:128 -#: templates/includes/sidebar.html:227 templates/users/fragments/list.html:10 +#: apps/export_app/forms.py:13 apps/export_app/forms.py:129 +#: templates/includes/sidebar.html:241 templates/users/fragments/list.html:10 #: templates/users/pages/index.html:4 msgid "Users" msgstr "" -#: apps/export_app/forms.py:31 apps/export_app/forms.py:134 +#: apps/export_app/forms.py:31 apps/export_app/forms.py:136 #: apps/transactions/models.py:422 templates/includes/sidebar.html:81 -#: templates/includes/sidebar.html:148 +#: templates/includes/sidebar.html:154 #: templates/recurring_transactions/fragments/list_transactions.html:5 #: templates/recurring_transactions/fragments/table.html:37 #: templates/transactions/pages/transactions.html:5 msgid "Transactions" msgstr "" -#: apps/export_app/forms.py:37 apps/export_app/forms.py:131 +#: apps/export_app/forms.py:37 apps/export_app/forms.py:133 #: apps/insights/views.py:43 apps/transactions/filters.py:74 #: templates/categories/fragments/list.html:9 -#: templates/categories/pages/index.html:4 templates/includes/sidebar.html:150 +#: templates/categories/pages/index.html:4 templates/includes/sidebar.html:156 #: templates/insights/fragments/month_by_month.html:18 #: templates/insights/fragments/month_by_month.html:21 #: templates/insights/fragments/year_by_year.html:13 @@ -864,16 +963,16 @@ msgstr "" msgid "Categories" msgstr "" -#: apps/export_app/forms.py:49 apps/export_app/forms.py:133 +#: apps/export_app/forms.py:49 apps/export_app/forms.py:135 #: apps/insights/views.py:57 apps/rules/forms.py:185 apps/rules/forms.py:195 #: apps/rules/models.py:46 apps/rules/models.py:307 #: apps/transactions/filters.py:84 apps/transactions/forms.py:78 #: apps/transactions/forms.py:346 apps/transactions/forms.py:526 #: apps/transactions/forms.py:831 apps/transactions/forms.py:1083 #: apps/transactions/models.py:312 apps/transactions/models.py:368 -#: apps/transactions/models.py:748 apps/transactions/models.py:1006 -#: apps/transactions/models.py:1317 templates/entities/fragments/list.html:9 -#: templates/entities/pages/index.html:4 templates/includes/sidebar.html:162 +#: apps/transactions/models.py:772 apps/transactions/models.py:1030 +#: apps/transactions/models.py:1341 templates/entities/fragments/list.html:9 +#: templates/entities/pages/index.html:4 templates/includes/sidebar.html:168 #: templates/insights/fragments/month_by_month.html:40 #: templates/insights/fragments/month_by_month.html:43 #: templates/insights/fragments/year_by_year.html:35 @@ -881,28 +980,28 @@ msgstr "" msgid "Entities" msgstr "" -#: apps/export_app/forms.py:55 apps/export_app/forms.py:137 -#: apps/transactions/models.py:1046 templates/includes/sidebar.html:110 +#: apps/export_app/forms.py:55 apps/export_app/forms.py:139 +#: apps/transactions/models.py:1070 templates/includes/sidebar.html:110 #: templates/recurring_transactions/fragments/list.html:9 #: templates/recurring_transactions/pages/index.html:4 msgid "Recurring Transactions" msgstr "" -#: apps/export_app/forms.py:61 apps/export_app/forms.py:135 -#: apps/transactions/models.py:766 templates/includes/sidebar.html:104 +#: apps/export_app/forms.py:61 apps/export_app/forms.py:137 +#: apps/transactions/models.py:790 templates/includes/sidebar.html:104 #: templates/installment_plans/fragments/list.html:9 #: templates/installment_plans/pages/index.html:4 msgid "Installment Plans" msgstr "" -#: apps/export_app/forms.py:73 apps/export_app/forms.py:140 +#: apps/export_app/forms.py:73 apps/export_app/forms.py:142 #: templates/exchange_rates_services/fragments/list.html:10 #: templates/exchange_rates_services/pages/index.html:4 -#: templates/includes/sidebar.html:218 +#: templates/includes/sidebar.html:232 msgid "Automatic Exchange Rates" msgstr "" -#: apps/export_app/forms.py:79 templates/includes/sidebar.html:198 +#: apps/export_app/forms.py:79 templates/includes/sidebar.html:212 #: templates/rules/fragments/list.html:9 templates/rules/pages/index.html:4 msgid "Rules" msgstr "" @@ -911,57 +1010,57 @@ msgstr "" msgid "DCA" msgstr "" -#: apps/export_app/forms.py:91 apps/export_app/forms.py:152 +#: apps/export_app/forms.py:91 apps/export_app/forms.py:154 #: templates/import_app/fragments/profiles/list.html:21 #: templates/import_app/pages/profiles_index.html:4 msgid "Import Profiles" msgstr "" -#: apps/export_app/forms.py:117 templates/export_app/fragments/export.html:5 +#: apps/export_app/forms.py:118 templates/export_app/fragments/export.html:5 #: templates/export_app/pages/index.html:13 msgid "Export" msgstr "" -#: apps/export_app/forms.py:125 +#: apps/export_app/forms.py:126 msgid "Import a ZIP file exported from WYGIWYH" msgstr "" -#: apps/export_app/forms.py:126 +#: apps/export_app/forms.py:127 msgid "ZIP File" msgstr "" -#: apps/export_app/forms.py:143 apps/rules/models.py:27 +#: apps/export_app/forms.py:145 apps/rules/models.py:27 msgid "Transaction rules" msgstr "" -#: apps/export_app/forms.py:145 apps/rules/models.py:68 +#: apps/export_app/forms.py:147 apps/rules/models.py:68 msgid "Edit transaction action" msgstr "" -#: apps/export_app/forms.py:148 apps/rules/models.py:327 +#: apps/export_app/forms.py:150 apps/rules/models.py:327 msgid "Update or create transaction actions" msgstr "" -#: apps/export_app/forms.py:181 templates/cotton/transaction/item.html:341 +#: apps/export_app/forms.py:183 templates/cotton/transaction/item.html:341 #: templates/cotton/ui/deleted_transactions_action_bar.html:53 #: templates/export_app/fragments/restore.html:5 #: templates/export_app/pages/index.html:19 msgid "Restore" msgstr "" -#: apps/export_app/forms.py:191 +#: apps/export_app/forms.py:193 msgid "Please upload either a ZIP file or at least one CSV file" msgstr "" -#: apps/export_app/views.py:177 +#: apps/export_app/views.py:183 msgid "You have to select at least one export" msgstr "" -#: apps/export_app/views.py:197 +#: apps/export_app/views.py:203 msgid "Data restored successfully" msgstr "" -#: apps/export_app/views.py:209 +#: apps/export_app/views.py:215 msgid "" "There was an error restoring your data. Check the logs for more details." msgstr "" @@ -972,7 +1071,7 @@ msgstr "" #: apps/import_app/forms.py:55 #: templates/import_app/fragments/profiles/list.html:75 -#: templates/includes/sidebar.html:204 +#: templates/includes/sidebar.html:218 msgid "Import" msgstr "" @@ -1020,23 +1119,23 @@ msgstr "" msgid "File name" msgstr "" -#: apps/import_app/views.py:75 +#: apps/import_app/views.py:76 msgid "Import Profile added successfully" msgstr "" -#: apps/import_app/views.py:113 +#: apps/import_app/views.py:114 msgid "Import Profile update successfully" msgstr "" -#: apps/import_app/views.py:142 +#: apps/import_app/views.py:143 msgid "Import Profile deleted successfully" msgstr "" -#: apps/import_app/views.py:222 +#: apps/import_app/views.py:223 msgid "Import Run queued successfully" msgstr "" -#: apps/import_app/views.py:249 +#: apps/import_app/views.py:250 msgid "Run deleted successfully" msgstr "" @@ -1105,7 +1204,7 @@ msgstr "" msgid "Untagged" msgstr "" -#: apps/insights/views.py:53 apps/transactions/models.py:589 +#: apps/insights/views.py:53 apps/transactions/models.py:613 #: templates/tags/fragments/table.html:69 msgid "No tags" msgstr "" @@ -1126,6 +1225,26 @@ msgstr "" msgid "No entities" msgstr "" +#: apps/investments/views.py:104 +msgid "Other" +msgstr "" + +#: apps/investments/views.py:135 +msgid "Unclassified" +msgstr "" + +#: apps/investments/views.py:267 +msgid "Asset class added successfully" +msgstr "" + +#: apps/investments/views.py:275 +msgid "Asset class updated successfully" +msgstr "" + +#: apps/investments/views.py:296 +msgid "Asset class deleted successfully" +msgstr "" + #: apps/net_worth/views.py:137 templates/net_worth/net_worth.html:84 #: templates/net_worth/net_worth.html:85 msgid "Consolidated" @@ -1179,8 +1298,8 @@ msgstr "" #: apps/rules/forms.py:174 apps/rules/forms.py:188 apps/rules/models.py:36 #: apps/rules/models.py:271 apps/transactions/forms.py:468 -#: apps/transactions/models.py:336 apps/transactions/models.py:708 -#: apps/transactions/models.py:987 apps/transactions/models.py:1287 +#: apps/transactions/models.py:336 apps/transactions/models.py:732 +#: apps/transactions/models.py:1011 apps/transactions/models.py:1311 #: templates/mini_tools/simulator.html:63 msgid "Type" msgstr "" @@ -1188,7 +1307,7 @@ msgstr "" #: apps/rules/forms.py:175 apps/rules/forms.py:189 apps/rules/models.py:37 #: apps/rules/models.py:275 apps/transactions/filters.py:22 #: apps/transactions/forms.py:472 apps/transactions/models.py:338 -#: apps/transactions/models.py:1289 templates/cotton/transaction/item.html:23 +#: apps/transactions/models.py:1313 templates/cotton/transaction/item.html:23 #: templates/cotton/transaction/item.html:34 #: templates/transactions/widgets/paid_toggle_button.html:10 #: templates/transactions/widgets/unselectable_paid_toggle_button.html:13 @@ -1199,14 +1318,14 @@ msgstr "" #: apps/rules/models.py:283 apps/transactions/forms.py:90 #: apps/transactions/forms.py:488 apps/transactions/forms.py:638 #: apps/transactions/forms.py:837 apps/transactions/models.py:340 -#: apps/transactions/models.py:726 apps/transactions/models.py:1011 +#: apps/transactions/models.py:750 apps/transactions/models.py:1035 msgid "Reference Date" msgstr "" #: apps/rules/forms.py:178 apps/rules/forms.py:192 apps/rules/models.py:41 #: apps/rules/models.py:287 apps/transactions/forms.py:495 -#: apps/transactions/models.py:346 apps/transactions/models.py:992 -#: apps/transactions/models.py:1295 +#: apps/transactions/models.py:346 apps/transactions/models.py:1016 +#: apps/transactions/models.py:1319 #: templates/insights/fragments/sankey.html:102 #: templates/installment_plans/fragments/table.html:18 #: templates/mini_tools/simulator.html:70 @@ -1218,27 +1337,27 @@ msgstr "" #: apps/rules/forms.py:179 apps/rules/forms.py:193 apps/rules/models.py:14 #: apps/rules/models.py:42 apps/rules/models.py:291 #: apps/transactions/forms.py:499 apps/transactions/forms.py:642 -#: apps/transactions/models.py:351 apps/transactions/models.py:710 -#: apps/transactions/models.py:995 apps/transactions/models.py:1300 +#: apps/transactions/models.py:351 apps/transactions/models.py:734 +#: apps/transactions/models.py:1019 apps/transactions/models.py:1324 msgid "Description" msgstr "" #: apps/rules/forms.py:182 apps/rules/forms.py:198 apps/rules/models.py:47 #: apps/rules/models.py:299 apps/transactions/models.py:398 -#: apps/transactions/models.py:1322 +#: apps/transactions/models.py:1346 msgid "Internal Note" msgstr "" #: apps/rules/forms.py:183 apps/rules/forms.py:199 apps/rules/models.py:48 #: apps/rules/models.py:303 apps/transactions/models.py:400 -#: apps/transactions/models.py:1324 +#: apps/transactions/models.py:1348 msgid "Internal ID" msgstr "" #: apps/rules/forms.py:186 apps/rules/forms.py:200 apps/rules/models.py:40 #: apps/rules/models.py:319 apps/transactions/forms.py:655 #: apps/transactions/models.py:250 apps/transactions/models.py:341 -#: apps/transactions/models.py:1290 +#: apps/transactions/models.py:1314 msgid "Mute" msgstr "" @@ -1251,7 +1370,7 @@ msgid "Set Values" msgstr "" #: apps/rules/forms.py:407 apps/rules/forms.py:442 apps/rules/forms.py:477 -#: apps/transactions/models.py:421 apps/transactions/models.py:645 +#: apps/transactions/models.py:421 apps/transactions/models.py:669 msgid "Transaction" msgstr "" @@ -1287,7 +1406,12 @@ msgstr "" msgid "Field" msgstr "" -#: apps/rules/models.py:61 +#: apps/rules/models.py:61 templates/investments/fragments/charts.html:86 +#: templates/investments/fragments/detail.html:103 +#: templates/investments/fragments/detail.html:162 +#: templates/investments/fragments/list.html:48 +#: templates/investments/fragments/list.html:49 +#: templates/investments/fragments/rebalance.html:23 msgid "Value" msgstr "" @@ -1469,7 +1593,7 @@ msgid "Upload" msgstr "" #: apps/transactions/forms.py:355 apps/transactions/forms.py:1089 -#: apps/transactions/models.py:925 +#: apps/transactions/models.py:949 msgid "To account" msgstr "" @@ -1485,8 +1609,8 @@ msgstr "" msgid "To Amount" msgstr "" -#: apps/transactions/forms.py:718 apps/transactions/models.py:970 -#: apps/transactions/models.py:1267 templates/cotton/transaction/item.html:52 +#: apps/transactions/forms.py:718 apps/transactions/models.py:994 +#: apps/transactions/models.py:1291 templates/cotton/transaction/item.html:52 #: templates/cotton/transaction/item.html:60 #: templates/cotton/ui/quick_transactions_buttons.html:40 #: templates/cotton/ui/transactions_fab.html:44 @@ -1546,8 +1670,8 @@ msgid "" "transactions" msgstr "" -#: apps/transactions/models.py:323 apps/transactions/models.py:969 -#: apps/transactions/models.py:1266 +#: apps/transactions/models.py:323 apps/transactions/models.py:993 +#: apps/transactions/models.py:1290 #: templates/calendar_view/fragments/list.html:42 #: templates/calendar_view/fragments/list.html:44 #: templates/calendar_view/fragments/list.html:52 @@ -1555,13 +1679,19 @@ msgstr "" #: templates/cotton/ui/quick_transactions_buttons.html:10 #: templates/cotton/ui/transactions_fab.html:10 #: templates/insights/fragments/overview/_results.html:12 +#: templates/investments/fragments/_totals.html:21 +#: templates/investments/fragments/_totals.html:42 +#: templates/investments/fragments/detail.html:106 +#: templates/investments/fragments/detail.html:210 +#: templates/investments/fragments/detail.html:252 +#: templates/investments/fragments/detail.html:320 #: templates/mini_tools/simulator.html:66 #: templates/monthly_overview/fragments/monthly_summary.html:42 msgid "Income" msgstr "" -#: apps/transactions/models.py:324 apps/transactions/models.py:971 -#: apps/transactions/models.py:1268 +#: apps/transactions/models.py:324 apps/transactions/models.py:995 +#: apps/transactions/models.py:1292 #: templates/calendar_view/fragments/list.html:46 #: templates/calendar_view/fragments/list.html:48 #: templates/calendar_view/fragments/list.html:56 @@ -1573,14 +1703,14 @@ msgstr "" msgid "Expense" msgstr "" -#: apps/transactions/models.py:379 apps/transactions/models.py:765 +#: apps/transactions/models.py:379 apps/transactions/models.py:789 #: templates/cotton/transaction/item.html:291 #: templates/cotton/transaction/item.html:308 #: templates/cotton/transaction/item.html:318 msgid "Installment Plan" msgstr "" -#: apps/transactions/models.py:388 apps/transactions/models.py:1045 +#: apps/transactions/models.py:388 apps/transactions/models.py:1069 #: templates/cotton/transaction/item.html:281 #: templates/cotton/transaction/item.html:301 msgid "Recurring Transaction" @@ -1598,150 +1728,162 @@ msgstr "" msgid "Deleted At" msgstr "" -#: apps/transactions/models.py:591 +#: apps/transactions/models.py:517 +msgid "A transfer needs one expense and one income." +msgstr "" + +#: apps/transactions/models.py:519 +msgid "A transfer needs two different accounts." +msgstr "" + +#: apps/transactions/models.py:523 +msgid "This transaction is already part of another transfer." +msgstr "" + +#: apps/transactions/models.py:615 msgid "No category" msgstr "" -#: apps/transactions/models.py:593 +#: apps/transactions/models.py:617 msgid "No description" msgstr "" -#: apps/transactions/models.py:650 +#: apps/transactions/models.py:674 msgid "File" msgstr "" -#: apps/transactions/models.py:652 +#: apps/transactions/models.py:676 msgid "Original Name" msgstr "" -#: apps/transactions/models.py:654 +#: apps/transactions/models.py:678 msgid "Content Type" msgstr "" -#: apps/transactions/models.py:656 +#: apps/transactions/models.py:680 msgid "Size" msgstr "" -#: apps/transactions/models.py:661 +#: apps/transactions/models.py:685 msgid "Uploaded By" msgstr "" -#: apps/transactions/models.py:666 +#: apps/transactions/models.py:690 msgid "Transaction Attachment" msgstr "" -#: apps/transactions/models.py:667 +#: apps/transactions/models.py:691 msgid "Transaction Attachments" msgstr "" -#: apps/transactions/models.py:697 templates/includes/sidebar.html:57 +#: apps/transactions/models.py:721 templates/includes/sidebar.html:57 msgid "Yearly" msgstr "" -#: apps/transactions/models.py:698 apps/users/models.py:469 +#: apps/transactions/models.py:722 apps/users/models.py:469 #: templates/includes/sidebar.html:51 msgid "Monthly" msgstr "" -#: apps/transactions/models.py:699 +#: apps/transactions/models.py:723 msgid "Weekly" msgstr "" -#: apps/transactions/models.py:700 +#: apps/transactions/models.py:724 msgid "Daily" msgstr "" -#: apps/transactions/models.py:713 +#: apps/transactions/models.py:737 msgid "Number of Installments" msgstr "" -#: apps/transactions/models.py:718 +#: apps/transactions/models.py:742 msgid "Installment Start" msgstr "" -#: apps/transactions/models.py:719 +#: apps/transactions/models.py:743 msgid "The installment number to start counting from" msgstr "" -#: apps/transactions/models.py:724 apps/transactions/models.py:1015 +#: apps/transactions/models.py:748 apps/transactions/models.py:1039 msgid "Start Date" msgstr "" -#: apps/transactions/models.py:728 apps/transactions/models.py:1016 +#: apps/transactions/models.py:752 apps/transactions/models.py:1040 msgid "End Date" msgstr "" -#: apps/transactions/models.py:733 +#: apps/transactions/models.py:757 msgid "Recurrence" msgstr "" -#: apps/transactions/models.py:736 +#: apps/transactions/models.py:760 msgid "Installment Amount" msgstr "" -#: apps/transactions/models.py:755 apps/transactions/models.py:1035 +#: apps/transactions/models.py:779 apps/transactions/models.py:1059 msgid "Add description to transactions" msgstr "" -#: apps/transactions/models.py:758 apps/transactions/models.py:1038 +#: apps/transactions/models.py:782 apps/transactions/models.py:1062 msgid "Add notes to transactions" msgstr "" -#: apps/transactions/models.py:932 +#: apps/transactions/models.py:956 msgid "To amount" msgstr "" -#: apps/transactions/models.py:933 +#: apps/transactions/models.py:957 msgid "Leave empty to use the same amount" msgstr "" -#: apps/transactions/models.py:974 templates/mini_tools/simulator.html:129 +#: apps/transactions/models.py:998 templates/mini_tools/simulator.html:129 msgid "day(s)" msgstr "" -#: apps/transactions/models.py:975 templates/mini_tools/simulator.html:130 +#: apps/transactions/models.py:999 templates/mini_tools/simulator.html:130 msgid "week(s)" msgstr "" -#: apps/transactions/models.py:976 templates/mini_tools/simulator.html:131 +#: apps/transactions/models.py:1000 templates/mini_tools/simulator.html:131 msgid "month(s)" msgstr "" -#: apps/transactions/models.py:977 templates/mini_tools/simulator.html:132 +#: apps/transactions/models.py:1001 templates/mini_tools/simulator.html:132 msgid "year(s)" msgstr "" -#: apps/transactions/models.py:979 +#: apps/transactions/models.py:1003 #: templates/recurring_transactions/fragments/list.html:18 msgid "Paused" msgstr "" -#: apps/transactions/models.py:1018 +#: apps/transactions/models.py:1042 msgid "Recurrence Type" msgstr "" -#: apps/transactions/models.py:1021 +#: apps/transactions/models.py:1045 msgid "Recurrence Interval" msgstr "" -#: apps/transactions/models.py:1024 +#: apps/transactions/models.py:1048 msgid "Keep at most" msgstr "" -#: apps/transactions/models.py:1028 +#: apps/transactions/models.py:1052 msgid "Last Generated Date" msgstr "" -#: apps/transactions/models.py:1031 +#: apps/transactions/models.py:1055 msgid "Last Generated Reference Date" msgstr "" -#: apps/transactions/models.py:1334 templates/cotton/transaction/item.html:274 +#: apps/transactions/models.py:1358 templates/cotton/transaction/item.html:274 #: templates/cotton/ui/transactions_fab.html:59 msgid "Quick Transaction" msgstr "" -#: apps/transactions/models.py:1335 templates/includes/sidebar.html:98 +#: apps/transactions/models.py:1359 templates/includes/sidebar.html:98 #: templates/quick_transactions/pages/index.html:5 #: templates/quick_transactions/pages/index.html:15 msgid "Quick Transactions" @@ -1864,8 +2006,8 @@ msgid "Item deleted successfully" msgstr "" #: apps/transactions/views/quick_transactions.py:164 -#: apps/transactions/views/transactions.py:143 -#: apps/transactions/views/transactions.py:328 +#: apps/transactions/views/transactions.py:156 +#: apps/transactions/views/transactions.py:343 msgid "Transaction added successfully" msgstr "" @@ -1901,42 +2043,42 @@ msgstr "" msgid "Tag deleted successfully" msgstr "" -#: apps/transactions/views/transactions.py:61 +#: apps/transactions/views/transactions.py:65 msgid "Attachment uploaded successfully" msgstr "" -#: apps/transactions/views/transactions.py:112 +#: apps/transactions/views/transactions.py:116 msgid "Attachment deleted successfully" msgstr "" -#: apps/transactions/views/transactions.py:355 +#: apps/transactions/views/transactions.py:370 msgid "Transaction updated successfully" msgstr "" -#: apps/transactions/views/transactions.py:406 +#: apps/transactions/views/transactions.py:421 #, python-format msgid "%(count)s transaction updated successfully" msgid_plural "%(count)s transactions updated successfully" msgstr[0] "" msgstr[1] "" -#: apps/transactions/views/transactions.py:433 +#: apps/transactions/views/transactions.py:448 msgid "Transaction duplicated successfully" msgstr "" -#: apps/transactions/views/transactions.py:478 +#: apps/transactions/views/transactions.py:493 msgid "Transaction deleted successfully" msgstr "" -#: apps/transactions/views/transactions.py:508 +#: apps/transactions/views/transactions.py:523 msgid "Transaction restored successfully" msgstr "" -#: apps/transactions/views/transactions.py:534 +#: apps/transactions/views/transactions.py:549 msgid "Transfer added successfully" msgstr "" -#: apps/transactions/views/transactions.py:562 +#: apps/transactions/views/transactions.py:579 msgid "Transfer updated successfully" msgstr "" @@ -2240,6 +2382,7 @@ msgstr "" #: templates/exchange_rates_services/fragments/table.html:20 #: templates/import_app/fragments/profiles/list.html:42 #: templates/installment_plans/fragments/table.html:25 +#: templates/investment_classes/fragments/list.html:36 #: templates/quick_transactions/fragments/list.html:22 #: templates/recurring_transactions/fragments/table.html:27 #: templates/rules/fragments/list.html:31 @@ -2262,6 +2405,9 @@ msgstr "" #: templates/exchange_rates_services/fragments/table.html:23 #: templates/import_app/fragments/profiles/list.html:46 #: templates/installment_plans/fragments/table.html:28 +#: templates/investment_classes/fragments/list.html:39 +#: templates/investments/fragments/detail.html:305 +#: templates/investments/fragments/detail.html:331 #: templates/quick_transactions/fragments/list.html:25 #: templates/recurring_transactions/fragments/table.html:30 #: templates/rules/fragments/transaction_rule/view.html:22 @@ -2311,6 +2457,7 @@ msgstr "" #: templates/import_app/fragments/profiles/list.html:81 #: templates/import_app/fragments/runs/list.html:100 #: templates/installment_plans/fragments/table.html:54 +#: templates/investment_classes/fragments/list.html:45 #: templates/mini_tools/simulator.html:109 #: templates/mini_tools/unit_price_calculator.html:16 #: templates/quick_transactions/fragments/list.html:32 @@ -2344,6 +2491,7 @@ msgstr "" #: templates/import_app/fragments/runs/list.html:104 #: templates/installment_plans/fragments/table.html:47 #: templates/installment_plans/fragments/table.html:58 +#: templates/investment_classes/fragments/list.html:49 #: templates/quick_transactions/fragments/list.html:37 #: templates/recurring_transactions/fragments/table.html:52 #: templates/recurring_transactions/fragments/table.html:65 @@ -2393,6 +2541,7 @@ msgstr "" #: templates/import_app/fragments/profiles/list.html:87 #: templates/import_app/fragments/runs/list.html:106 #: templates/installment_plans/fragments/table.html:60 +#: templates/investment_classes/fragments/list.html:51 #: templates/quick_transactions/fragments/list.html:39 #: templates/recurring_transactions/fragments/table.html:94 #: templates/rules/fragments/list.html:62 @@ -2441,6 +2590,7 @@ msgid "Add account" msgstr "" #: templates/accounts/fragments/edit.html:5 +#: templates/investments/fragments/detail.html:40 msgid "Edit account" msgstr "" @@ -2513,7 +2663,7 @@ msgstr "" msgid "Pick a month" msgstr "" -#: templates/common/fragments/toasts.html:20 +#: templates/common/fragments/toasts.html:18 #: templates/cotton/components/fab.html:9 #: templates/cotton/components/sidebar_collapsible_panel.html:28 #: templates/extends/offcanvas.html:4 templates/includes/sidebar.html:44 @@ -2742,6 +2892,8 @@ msgid "Add DCA strategy" msgstr "" #: templates/dca/fragments/strategy/details.html:28 +#: templates/investments/fragments/detail.html:92 +#: templates/investments/fragments/list.html:65 msgid "No exchange rate available" msgstr "" @@ -2923,7 +3075,7 @@ msgstr "" msgid "No services configured" msgstr "" -#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:211 +#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:225 msgid "Export and Restore" msgstr "" @@ -3094,52 +3246,61 @@ msgstr "" msgid "Tools" msgstr "" -#: templates/includes/sidebar.html:118 -msgid "Dollar Cost Average Tracker" +#: templates/includes/sidebar.html:118 templates/includes/sidebar.html:188 +#: templates/investment_classes/fragments/list.html:29 +#: templates/investments/fragments/detail.html:37 +#: templates/investments/fragments/list.html:9 +#: templates/investments/pages/detail.html:4 +#: templates/investments/pages/index.html:4 +msgid "Investments" msgstr "" #: templates/includes/sidebar.html:124 +msgid "Dollar Cost Average Tracker" +msgstr "" + +#: templates/includes/sidebar.html:130 #: templates/mini_tools/unit_price_calculator.html:4 #: templates/mini_tools/unit_price_calculator.html:9 msgid "Unit Price Calculator" msgstr "" -#: templates/includes/sidebar.html:130 +#: templates/includes/sidebar.html:136 #: templates/mini_tools/currency_converter/currency_converter.html:7 #: templates/mini_tools/currency_converter/currency_converter.html:14 msgid "Currency Converter" msgstr "" -#: templates/includes/sidebar.html:136 templates/mini_tools/simulator.html:5 +#: templates/includes/sidebar.html:142 templates/mini_tools/simulator.html:5 #: templates/mini_tools/simulator.html:21 msgid "Simulator" msgstr "" -#: templates/includes/sidebar.html:145 +#: templates/includes/sidebar.html:151 msgid "Management" msgstr "" -#: templates/includes/sidebar.html:196 +#: templates/includes/sidebar.html:210 msgid "Automation" msgstr "" -#: templates/includes/sidebar.html:225 +#: templates/includes/sidebar.html:239 msgid "Admin" msgstr "" -#: templates/includes/sidebar.html:233 +#: templates/includes/sidebar.html:247 msgid "Django Admin" msgstr "" -#: templates/includes/sidebar.html:234 +#: templates/includes/sidebar.html:248 msgid "Only use this if you know what you're doing" msgstr "" -#: templates/includes/sidebar.html:249 +#: templates/includes/sidebar.html:263 msgid "is available" msgstr "" -#: templates/includes/sidebar.html:257 +#: templates/includes/sidebar.html:271 msgid "Calculator" msgstr "" @@ -3206,6 +3367,7 @@ msgstr "" #: templates/insights/fragments/overview/_results.html:173 #: templates/insights/fragments/year_by_year.html:59 #: templates/insights/fragments/year_by_year.html:140 +#: templates/investment_classes/fragments/list.html:65 #: templates/monthly_overview/fragments/monthly_summary.html:168 msgid "Total" msgstr "" @@ -3401,6 +3563,273 @@ msgstr "" msgid "No installment plans" msgstr "" +#: templates/investment_classes/fragments/form.html:5 +msgid "Edit asset class" +msgstr "" + +#: templates/investment_classes/fragments/form.html:5 +msgid "Add asset class" +msgstr "" + +#: templates/investment_classes/fragments/list.html:19 +#: templates/investments/fragments/rebalance.html:10 +#, python-format +msgid "Targets add up to %(total)s%%. Set them to 100%% to see the rebalance." +msgstr "" + +#: templates/investment_classes/fragments/list.html:28 +#: templates/investments/fragments/rebalance.html:25 +msgid "Target" +msgstr "" + +#: templates/investment_classes/fragments/list.html:50 +msgid "Its investments will be left without an asset class." +msgstr "" + +#: templates/investment_classes/fragments/list.html:74 +msgid "No asset classes" +msgstr "" + +#: templates/investments/fragments/_allocation.html:3 +msgid "Allocation chart" +msgstr "" + +#: templates/investments/fragments/_allocation.html:11 +#, python-format +msgid "" +"%(share)s%% of your investments with a target, against a target of " +"%(target)s%%" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "Annual return, weighted by when money went in and out" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "" +"Return since the first contribution, weighted by when money went in and out" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "/yr" +msgstr "" + +#: templates/investments/fragments/_totals.html:15 +#: templates/investments/fragments/detail.html:63 +msgid "total return" +msgstr "" + +#: templates/investments/fragments/_totals.html:20 +#: templates/investments/fragments/_totals.html:41 +#: templates/investments/fragments/charts.html:96 +#: templates/investments/fragments/detail.html:163 +#: templates/investments/fragments/detail.html:223 +#: templates/investments/fragments/list.html:47 +msgid "Invested" +msgstr "" + +#: templates/investments/fragments/_totals.html:24 +#: templates/investments/fragments/detail.html:74 +msgid "" +"How much of the return came from exchange rates moving since you invested" +msgstr "" + +#: templates/investments/fragments/_totals.html:24 +#: templates/investments/fragments/_totals.html:26 +msgid "Currency effect" +msgstr "" + +#: templates/investments/fragments/_totals.html:26 +#: templates/investments/fragments/detail.html:78 +msgid "" +"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." +msgstr "" + +#: templates/investments/fragments/_totals.html:40 +#: templates/investments/fragments/list.html:49 +msgid "Return" +msgstr "" + +#: templates/investments/fragments/charts.html:10 +msgid "By asset class" +msgstr "" + +#: templates/investments/fragments/charts.html:17 +#: templates/net_worth/net_worth.html:52 +#: templates/yearly_overview/pages/overview_by_currency.html:6 +msgid "By currency" +msgstr "" + +#: templates/investments/fragments/charts.html:24 +msgid "Value and invested" +msgstr "" + +#: templates/investments/fragments/charts.html:31 +msgid "Return by investment" +msgstr "" + +#: templates/investments/fragments/charts.html:38 +msgid "Nothing to chart yet" +msgstr "" + +#: templates/investments/fragments/detail.html:10 +msgid "Buy / Contribute" +msgstr "" + +#: templates/investments/fragments/detail.html:16 +msgid "Sell / Withdraw" +msgstr "" + +#: templates/investments/fragments/detail.html:23 +msgid "Dividend / Income" +msgstr "" + +#: templates/investments/fragments/detail.html:30 +msgid "Update value" +msgstr "" + +#: templates/investments/fragments/detail.html:47 +#: templates/investments/fragments/list.html:67 +msgid "Tracked by value" +msgstr "" + +#: templates/investments/fragments/detail.html:48 +msgid "" +"Some incoming units have no linked payment, so their cost was estimated from " +"the market price of that day" +msgstr "" + +#: templates/investments/fragments/detail.html:68 +msgid "In" +msgstr "" + +#: templates/investments/fragments/detail.html:74 +#: templates/investments/fragments/detail.html:78 +msgid "currency effect" +msgstr "" + +#: templates/investments/fragments/detail.html:104 +#: templates/investments/fragments/detail.html:179 +msgid "Price" +msgstr "" + +#: templates/investments/fragments/detail.html:105 +#: templates/investments/fragments/detail.html:200 +#: templates/investments/fragments/detail.html:239 +#: templates/investments/fragments/detail.html:290 +msgid "Earnings" +msgstr "" + +#: templates/investments/fragments/detail.html:180 +#: templates/investments/fragments/detail.html:227 +msgid "Average cost" +msgstr "" + +#: templates/investments/fragments/detail.html:181 +#: templates/investments/fragments/rebalance.html:41 +msgid "Buy" +msgstr "" + +#: templates/investments/fragments/detail.html:182 +#: templates/investments/fragments/rebalance.html:43 +msgid "Sell" +msgstr "" + +#: templates/investments/fragments/detail.html:227 +#: templates/investments/fragments/detail.html:257 +msgid "Put in / taken out" +msgstr "" + +#: templates/investments/fragments/detail.html:239 +msgid "Unrealized" +msgstr "" + +#: templates/investments/fragments/detail.html:248 +msgid "Realized" +msgstr "" + +#: templates/investments/fragments/detail.html:271 +msgid "Movements" +msgstr "" + +#: templates/investments/fragments/detail.html:284 +msgid "Bought / contributed" +msgstr "" + +#: templates/investments/fragments/detail.html:284 +msgid "Sold / withdrew" +msgstr "" + +#: templates/investments/fragments/detail.html:288 +msgid "Money in" +msgstr "" + +#: templates/investments/fragments/detail.html:288 +msgid "Money out" +msgstr "" + +#: templates/investments/fragments/detail.html:290 +msgid "Gain" +msgstr "" + +#: templates/investments/fragments/detail.html:290 +msgid "Loss, tax or fee" +msgstr "" + +#: templates/investments/fragments/detail.html:315 +msgid "No movements yet" +msgstr "" + +#: templates/investments/fragments/detail.html:315 +msgid "Use the + button to buy or contribute" +msgstr "" + +#: templates/investments/fragments/list.html:21 +msgid "Holdings" +msgstr "" + +#: templates/investments/fragments/list.html:60 +msgid "Some costs were estimated from market prices" +msgstr "" + +#: templates/investments/fragments/list.html:92 +msgid "Charts" +msgstr "" + +#: templates/investments/fragments/list.html:97 +msgid "Rebalance" +msgstr "" + +#: templates/investments/fragments/list.html:105 +msgid "No investments yet" +msgstr "" + +#: templates/investments/fragments/list.html:106 +msgid "Mark an account as an investment in its settings to see it here" +msgstr "" + +#: templates/investments/fragments/rebalance.html:4 +msgid "No allocation targets" +msgstr "" + +#: templates/investments/fragments/rebalance.html:5 +msgid "Set a target for your asset classes to see what to buy and sell" +msgstr "" + +#: templates/investments/fragments/rebalance.html:26 +msgid "To reach the target" +msgstr "" + +#: templates/investments/fragments/rebalance.html:34 +msgid "Off target" +msgstr "" + +#: templates/investments/fragments/rebalance.html:55 +msgid "Left out: investments without an asset class or a target" +msgstr "" + #: templates/layouts/base.html:38 msgid "This is a demo!" msgstr "" @@ -3604,11 +4033,6 @@ msgstr "" msgid "Order by" msgstr "" -#: templates/net_worth/net_worth.html:52 -#: templates/yearly_overview/pages/overview_by_currency.html:6 -msgid "By currency" -msgstr "" - #: templates/net_worth/net_worth.html:135 msgid "Evolution" msgstr "" diff --git a/app/locale/es/LC_MESSAGES/django.po b/app/locale/es/LC_MESSAGES/django.po index cc3089e0..af38a69d 100644 --- a/app/locale/es/LC_MESSAGES/django.po +++ b/app/locale/es/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-08 06:01+0000\n" +"POT-Creation-Date: 2026-11-08 18:27+0000\n" "PO-Revision-Date: 2026-07-10 04:57+0000\n" "Last-Translator: Juan David Afanador \n" "Language-Team: Spanish \n" "Language-Team: French 1;\n" "X-Generator: Weblate 2026.10.1\n" -#: apps/accounts/forms.py:28 +#: apps/accounts/forms.py:33 msgid "Group name" msgstr "Nom du groupe" -#: apps/accounts/forms.py:43 apps/accounts/forms.py:109 -#: apps/currencies/forms.py:57 apps/currencies/forms.py:91 -#: apps/currencies/forms.py:166 apps/dca/forms.py:46 apps/dca/forms.py:205 -#: apps/import_app/forms.py:32 apps/rules/forms.py:60 apps/rules/forms.py:100 -#: apps/rules/forms.py:385 apps/transactions/forms.py:219 -#: apps/transactions/forms.py:452 apps/transactions/forms.py:571 -#: apps/transactions/forms.py:718 apps/transactions/forms.py:940 -#: apps/transactions/forms.py:987 apps/transactions/forms.py:1015 -#: apps/transactions/forms.py:1046 apps/transactions/forms.py:1208 -#: apps/users/forms.py:264 apps/users/forms.py:422 +#: apps/accounts/forms.py:48 apps/accounts/forms.py:76 +#: apps/accounts/forms.py:184 apps/currencies/forms.py:57 +#: apps/currencies/forms.py:91 apps/currencies/forms.py:168 +#: apps/dca/forms.py:46 apps/dca/forms.py:192 apps/import_app/forms.py:32 +#: apps/rules/forms.py:60 apps/rules/forms.py:100 apps/rules/forms.py:385 +#: apps/transactions/forms.py:219 apps/transactions/forms.py:452 +#: apps/transactions/forms.py:571 apps/transactions/forms.py:718 +#: apps/transactions/forms.py:940 apps/transactions/forms.py:987 +#: apps/transactions/forms.py:1015 apps/transactions/forms.py:1046 +#: apps/transactions/forms.py:1208 apps/users/forms.py:264 +#: apps/users/forms.py:422 #: templates/rules/fragments/transaction_rule/dry_run/updated.html:5 #: templates/rules/fragments/transaction_rule/view.html:128 msgid "Update" msgstr "Mise à jour" -#: apps/accounts/forms.py:49 apps/accounts/forms.py:115 -#: apps/common/widgets/tom_select.py:13 apps/currencies/forms.py:63 -#: apps/currencies/forms.py:97 apps/currencies/forms.py:172 -#: apps/dca/forms.py:52 apps/dca/forms.py:211 apps/import_app/forms.py:38 -#: apps/rules/forms.py:66 apps/rules/forms.py:106 apps/rules/forms.py:391 -#: apps/transactions/forms.py:206 apps/transactions/forms.py:226 -#: apps/transactions/forms.py:459 apps/transactions/forms.py:946 -#: apps/transactions/forms.py:993 apps/transactions/forms.py:1021 -#: apps/transactions/forms.py:1052 apps/transactions/forms.py:1214 -#: apps/users/forms.py:270 apps/users/forms.py:428 -#: templates/mini_tools/simulator.html:177 +#: apps/accounts/forms.py:54 apps/accounts/forms.py:76 +#: apps/accounts/forms.py:190 apps/common/widgets/tom_select.py:15 +#: apps/currencies/forms.py:63 apps/currencies/forms.py:97 +#: apps/currencies/forms.py:174 apps/dca/forms.py:52 apps/dca/forms.py:198 +#: apps/import_app/forms.py:38 apps/rules/forms.py:66 apps/rules/forms.py:106 +#: apps/rules/forms.py:391 apps/transactions/forms.py:206 +#: apps/transactions/forms.py:226 apps/transactions/forms.py:459 +#: apps/transactions/forms.py:946 apps/transactions/forms.py:993 +#: apps/transactions/forms.py:1021 apps/transactions/forms.py:1052 +#: apps/transactions/forms.py:1214 apps/users/forms.py:270 +#: apps/users/forms.py:428 templates/mini_tools/simulator.html:177 #: templates/mini_tools/unit_price_calculator.html:168 msgid "Add" msgstr "Ajouter" -#: apps/accounts/forms.py:57 templates/accounts/fragments/list.html:23 +#: apps/accounts/forms.py:86 templates/accounts/fragments/list.html:23 msgid "Group" msgstr "Groupe" -#: apps/accounts/forms.py:123 +#: apps/accounts/forms.py:93 apps/accounts/models.py:73 +#: apps/accounts/models.py:130 +#: templates/investments/fragments/rebalance.html:22 +msgid "Asset class" +msgstr "" + +#: apps/accounts/forms.py:198 msgid "New balance" msgstr "Nouveau solde" -#: apps/accounts/forms.py:129 apps/dca/forms.py:79 apps/dca/forms.py:86 +#: apps/accounts/forms.py:204 apps/dca/forms.py:79 apps/dca/forms.py:86 #: apps/insights/forms.py:117 apps/insights/views.py:44 apps/rules/forms.py:181 #: apps/rules/forms.py:197 apps/rules/models.py:44 apps/rules/models.py:311 #: apps/transactions/forms.py:62 apps/transactions/forms.py:330 #: apps/transactions/forms.py:510 apps/transactions/forms.py:607 #: apps/transactions/forms.py:614 apps/transactions/forms.py:823 #: apps/transactions/forms.py:1075 apps/transactions/models.py:357 -#: apps/transactions/models.py:743 apps/transactions/models.py:999 -#: apps/transactions/models.py:1306 +#: apps/transactions/models.py:767 apps/transactions/models.py:1023 +#: apps/transactions/models.py:1330 #: templates/insights/fragments/month_by_month.html:84 #: templates/insights/fragments/year_by_year.html:52 msgid "Category" msgstr "Catégorie" -#: apps/accounts/forms.py:136 apps/dca/forms.py:95 apps/dca/forms.py:103 -#: apps/export_app/forms.py:43 apps/export_app/forms.py:132 +#: apps/accounts/forms.py:211 apps/dca/forms.py:95 apps/dca/forms.py:103 +#: apps/export_app/forms.py:43 apps/export_app/forms.py:134 #: apps/insights/views.py:50 apps/rules/forms.py:184 apps/rules/forms.py:194 #: apps/rules/models.py:45 apps/rules/models.py:315 #: apps/transactions/filters.py:79 apps/transactions/forms.py:70 #: apps/transactions/forms.py:338 apps/transactions/forms.py:518 #: apps/transactions/forms.py:623 apps/transactions/forms.py:631 #: apps/transactions/forms.py:816 apps/transactions/forms.py:1068 -#: apps/transactions/models.py:363 apps/transactions/models.py:745 -#: apps/transactions/models.py:1003 apps/transactions/models.py:1312 -#: templates/includes/sidebar.html:156 +#: apps/transactions/models.py:363 apps/transactions/models.py:769 +#: apps/transactions/models.py:1027 apps/transactions/models.py:1336 +#: templates/includes/sidebar.html:162 #: templates/insights/fragments/month_by_month.html:29 #: templates/insights/fragments/month_by_month.html:32 #: templates/insights/fragments/year_by_year.html:24 @@ -94,23 +101,23 @@ msgstr "Catégorie" msgid "Tags" msgstr "Etiquettes" -#: apps/accounts/forms.py:179 +#: apps/accounts/forms.py:254 msgid "All paid transactions" msgstr "Toutes les transactions payées" -#: apps/accounts/forms.py:180 +#: apps/accounts/forms.py:255 msgid "Up to date" msgstr "à jour" -#: apps/accounts/forms.py:181 +#: apps/accounts/forms.py:256 msgid "Up to reference month" msgstr "Aller au mois de référence" -#: apps/accounts/forms.py:185 +#: apps/accounts/forms.py:260 msgid "Balance against" msgstr "Comparer" -#: apps/accounts/forms.py:189 apps/currencies/forms.py:70 +#: apps/accounts/forms.py:264 apps/currencies/forms.py:70 #: apps/dca/models.py:158 apps/rules/forms.py:176 apps/rules/forms.py:190 #: apps/rules/models.py:38 apps/rules/models.py:279 #: apps/transactions/forms.py:86 apps/transactions/forms.py:482 @@ -121,14 +128,15 @@ msgstr "Comparer" msgid "Date" msgstr "Date" -#: apps/accounts/forms.py:194 +#: apps/accounts/forms.py:269 msgid "Reference month" msgstr "Mois de référence" -#: apps/accounts/models.py:12 apps/accounts/models.py:29 apps/dca/models.py:13 +#: apps/accounts/models.py:19 apps/accounts/models.py:36 +#: apps/accounts/models.py:84 apps/dca/models.py:13 #: apps/import_app/models.py:20 apps/rules/models.py:13 #: apps/transactions/models.py:249 apps/transactions/models.py:274 -#: apps/transactions/models.py:298 apps/transactions/models.py:1274 +#: apps/transactions/models.py:298 apps/transactions/models.py:1298 #: apps/users/models.py:577 templates/account_groups/fragments/list.html:22 #: templates/accounts/fragments/list.html:22 #: templates/categories/fragments/table.html:17 @@ -137,6 +145,7 @@ msgstr "Mois de référence" #: templates/exchange_rates_services/fragments/list.html:28 #: templates/import_app/fragments/profiles/list.html:34 #: templates/installment_plans/fragments/table.html:16 +#: templates/investment_classes/fragments/list.html:27 #: templates/quick_transactions/fragments/list.html:13 #: templates/recurring_transactions/fragments/table.html:18 #: templates/rules/fragments/list.html:24 @@ -145,43 +154,128 @@ msgstr "Mois de référence" msgid "Name" msgstr "Nom" -#: apps/accounts/models.py:18 apps/accounts/models.py:33 +#: apps/accounts/models.py:25 apps/accounts/models.py:88 msgid "Account Group" msgstr "Groupe de comptes" -#: apps/accounts/models.py:19 templates/account_groups/fragments/list.html:9 +#: apps/accounts/models.py:26 templates/account_groups/fragments/list.html:9 #: templates/account_groups/pages/index.html:4 -#: templates/includes/sidebar.html:176 +#: templates/includes/sidebar.html:182 msgid "Account Groups" msgstr "Groupes de comptes" -#: apps/accounts/models.py:39 apps/currencies/models.py:44 +#: apps/accounts/models.py:43 +msgid "Target allocation (%)" +msgstr "" + +#: apps/accounts/models.py:45 +msgid "" +"Share of your investments you want in this class. Leave it empty to leave " +"the class out of the targets." +msgstr "" + +#: apps/accounts/models.py:61 +msgid "You already have an asset class with this name." +msgstr "" + +#: apps/accounts/models.py:66 +#, python-format +msgid "" +"Targets can't add up to more than 100%%. Other classes already take " +"%(taken)s%%." +msgstr "" + +#: apps/accounts/models.py:74 apps/export_app/forms.py:131 +#: templates/includes/sidebar.html:190 +#: templates/investment_classes/fragments/list.html:9 +#: templates/investment_classes/pages/index.html:4 +#: templates/investments/fragments/rebalance.html:6 +#: templates/investments/fragments/rebalance.html:11 +msgid "Asset classes" +msgstr "" + +#: apps/accounts/models.py:94 apps/currencies/models.py:44 #: templates/accounts/fragments/list.html:24 #: templates/mini_tools/simulator.html:37 #: templates/yearly_overview/pages/yearly_overview_base.html:19 msgid "Currency" msgstr "Devise" -#: apps/accounts/models.py:45 apps/currencies/models.py:27 +#: apps/accounts/models.py:100 apps/currencies/models.py:27 #: templates/accounts/fragments/list.html:25 msgid "Exchange Currency" msgstr "Devise d'échange" -#: apps/accounts/models.py:50 apps/currencies/models.py:32 +#: apps/accounts/models.py:105 apps/currencies/models.py:32 msgid "Default currency for exchange calculations" msgstr "Devise par défaut pour le calcul de change" -#: apps/accounts/models.py:55 +#: apps/accounts/models.py:110 msgid "Asset account" msgstr "Compte d'actif" -#: apps/accounts/models.py:57 +#: apps/accounts/models.py:112 msgid "" "Asset accounts count towards your Net Worth, but not towards your month." msgstr "" "Les comptes d'actifs contribue à votre Valeur Nette mais pas à votre mois." -#: apps/accounts/models.py:62 apps/currencies/models.py:37 +#: apps/accounts/models.py:117 templates/investments/fragments/list.html:46 +#, fuzzy +#| msgid "Installment" +msgid "Investment" +msgstr "Paiement en plusieurs fois" + +#: apps/accounts/models.py:119 +msgid "" +"Show this account in the Investments tracker. Set an exchange currency to " +"price it by units (stocks, ETFs, crypto); leave it empty to track it by " +"value (fixed income, property, pensions)." +msgstr "" + +#: apps/accounts/models.py:132 +msgid "" +"Groups investments in the allocation charts, e.g. Fixed income, Stocks, Real " +"estate." +msgstr "" + +#: apps/accounts/models.py:140 +#, fuzzy +#| msgid "No entities" +msgid "Income entities" +msgstr "Pas d'entités" + +#: apps/accounts/models.py:142 +msgid "" +"Transactions with any of these entities count towards this investment's " +"return: in this account, and income paid into other accounts (dividends, " +"rent)." +msgstr "" + +#: apps/accounts/models.py:151 +#, fuzzy +#| msgid "No categories" +msgid "Income categories" +msgstr "Pas de catégories" + +#: apps/accounts/models.py:153 +msgid "" +"Transactions in this account with any of these categories are its result " +"(interest, taxes, fees). On accounts tracked by value, everything else that " +"isn't a transfer counts as money coming in or going out." +msgstr "" + +#: apps/accounts/models.py:162 +#, fuzzy +#| msgid "Income" +msgid "Income tags" +msgstr "Revenus" + +#: apps/accounts/models.py:164 +msgid "Same as income categories, for transactions with any of these tags." +msgstr "" + +#: apps/accounts/models.py:169 apps/currencies/models.py:37 #: templates/accounts/fragments/list.html:27 #: templates/categories/fragments/list.html:20 #: templates/currencies/fragments/list.html:24 @@ -190,19 +284,19 @@ msgstr "" msgid "Archived" msgstr "Archivé" -#: apps/accounts/models.py:63 +#: apps/accounts/models.py:170 msgid "Archived accounts don't show up nor count towards your net worth" msgstr "" "Les comptes archivés ne sont ni affichés ni comptabilisés dans votre valeur " "nette" -#: apps/accounts/models.py:75 apps/rules/forms.py:173 apps/rules/forms.py:187 +#: apps/accounts/models.py:182 apps/rules/forms.py:173 apps/rules/forms.py:187 #: apps/rules/models.py:35 apps/rules/models.py:267 #: apps/transactions/forms.py:82 apps/transactions/forms.py:350 #: apps/transactions/forms.py:477 apps/transactions/forms.py:808 #: apps/transactions/forms.py:1060 apps/transactions/models.py:329 -#: apps/transactions/models.py:703 apps/transactions/models.py:981 -#: apps/transactions/models.py:1280 +#: apps/transactions/models.py:727 apps/transactions/models.py:1005 +#: apps/transactions/models.py:1304 #: templates/installment_plans/fragments/table.html:17 #: templates/quick_transactions/fragments/list.html:14 #: templates/recurring_transactions/fragments/table.html:19 @@ -210,17 +304,17 @@ msgstr "" msgid "Account" msgstr "Compte" -#: apps/accounts/models.py:76 apps/export_app/forms.py:19 -#: apps/export_app/forms.py:129 apps/transactions/filters.py:63 +#: apps/accounts/models.py:183 apps/export_app/forms.py:19 +#: apps/export_app/forms.py:130 apps/transactions/filters.py:63 #: templates/accounts/fragments/list.html:9 -#: templates/accounts/pages/index.html:4 templates/includes/sidebar.html:168 -#: templates/includes/sidebar.html:170 +#: templates/accounts/pages/index.html:4 templates/includes/sidebar.html:174 +#: templates/includes/sidebar.html:176 #: templates/monthly_overview/pages/overview.html:77 #: templates/transactions/pages/transactions.html:26 msgid "Accounts" msgstr "Comptes" -#: apps/accounts/models.py:93 +#: apps/accounts/models.py:200 msgid "Exchange currency cannot be the same as the account's main currency." msgstr "" "La devise de change ne peut pas être identique à la devise principal du " @@ -240,7 +334,7 @@ msgstr "Groupe de compte supprimé avec succès" #: apps/accounts/views/account_groups.py:109 #: apps/accounts/views/accounts.py:137 apps/dca/views.py:106 -#: apps/import_app/views.py:147 apps/rules/views.py:191 +#: apps/import_app/views.py:148 apps/rules/views.py:191 #: apps/transactions/views/categories.py:163 #: apps/transactions/views/entities.py:135 apps/transactions/views/tags.py:131 msgid "Item no longer shared with you" @@ -248,7 +342,7 @@ msgstr "Cette objet n'est plus partagé avec vous" #: apps/accounts/views/account_groups.py:132 #: apps/accounts/views/accounts.py:182 apps/dca/views.py:131 -#: apps/import_app/views.py:273 apps/rules/views.py:217 +#: apps/import_app/views.py:274 apps/rules/views.py:217 #: apps/transactions/views/categories.py:188 #: apps/transactions/views/entities.py:160 apps/transactions/views/tags.py:154 msgid "Ownership taken successfully" @@ -256,7 +350,7 @@ msgstr "Propriété acquise avec succès" #: apps/accounts/views/account_groups.py:152 #: apps/accounts/views/accounts.py:106 apps/dca/views.py:151 -#: apps/import_app/views.py:294 apps/rules/views.py:238 +#: apps/import_app/views.py:295 apps/rules/views.py:238 #: apps/transactions/views/categories.py:130 #: apps/transactions/views/entities.py:180 apps/transactions/views/tags.py:174 msgid "Configuration saved successfully" @@ -314,7 +408,7 @@ msgstr "Aucune entité avec cet ID n'existe." msgid "Invalid entity data. Provide an ID or name." msgstr "Donnée d'entité invalide. Veuillez fournir un ID ou nom." -#: apps/api/serializers/transactions.py:141 apps/transactions/models.py:953 +#: apps/api/serializers/transactions.py:141 apps/transactions/models.py:977 msgid "This field is required." msgstr "Ce champ est obligatoire." @@ -334,13 +428,14 @@ msgstr "Rendre privé" msgid "Shared with users" msgstr "Partagé avec les utilisateurs" -#: apps/common/fields/forms/dynamic_select.py:71 -#: apps/common/fields/forms/dynamic_select.py:142 +#: apps/common/fields/forms/dynamic_select.py:79 +#: apps/common/fields/forms/dynamic_select.py:150 msgid "Error creating new instance" msgstr "Erreur lors de la création d'une nouvelle instance" #: apps/common/fields/forms/grouped_select.py:24 -#: apps/common/widgets/tom_select.py:92 apps/common/widgets/tom_select.py:95 +#: apps/common/widgets/tom_select.py:94 apps/common/widgets/tom_select.py:97 +#: templates/investments/fragments/list.html:26 msgid "Ungrouped" msgstr "Non-groupé" @@ -488,14 +583,15 @@ msgid "Today" msgstr "Aujourd'hui" #: apps/common/widgets/datepicker.py:142 +#: templates/investments/fragments/rebalance.html:24 msgid "Now" msgstr "Maintenant" -#: apps/common/widgets/tom_select.py:11 +#: apps/common/widgets/tom_select.py:13 msgid "Remove" msgstr "Enlever" -#: apps/common/widgets/tom_select.py:15 templates/mini_tools/simulator.html:187 +#: apps/common/widgets/tom_select.py:17 templates/mini_tools/simulator.html:187 #: templates/mini_tools/unit_price_calculator.html:180 #: templates/monthly_overview/pages/overview.html:286 #: templates/transactions/fragments/filter_actions.html:48 @@ -503,7 +599,7 @@ msgstr "Enlever" msgid "Clear" msgstr "Vider" -#: apps/common/widgets/tom_select.py:16 +#: apps/common/widgets/tom_select.py:18 msgid "No results..." msgstr "Pas de résultats..." @@ -537,10 +633,10 @@ msgid "Decimal Places" msgstr "Décimales" #: apps/currencies/models.py:45 apps/export_app/forms.py:25 -#: apps/export_app/forms.py:130 apps/transactions/filters.py:70 +#: apps/export_app/forms.py:132 apps/transactions/filters.py:70 #: templates/currencies/fragments/list.html:9 -#: templates/currencies/pages/index.html:4 templates/includes/sidebar.html:182 -#: templates/includes/sidebar.html:184 +#: templates/currencies/pages/index.html:4 templates/includes/sidebar.html:196 +#: templates/includes/sidebar.html:198 #: templates/monthly_overview/pages/overview.html:63 #: templates/transactions/pages/transactions.html:12 msgid "Currencies" @@ -572,9 +668,9 @@ msgid "Auto" msgstr "Auto" #: apps/currencies/models.py:82 apps/export_app/forms.py:67 -#: apps/export_app/forms.py:142 templates/exchange_rates/fragments/list.html:10 +#: apps/export_app/forms.py:144 templates/exchange_rates/fragments/list.html:10 #: templates/exchange_rates/pages/index.html:4 -#: templates/includes/sidebar.html:190 +#: templates/includes/sidebar.html:204 msgid "Exchange Rates" msgstr "Taux de changes" @@ -669,32 +765,45 @@ msgstr "" "récupérés pour chaque compte par rapport à leur devise d'échange." #: apps/currencies/models.py:171 -msgid "Single exchange rate" +#, fuzzy +#| msgid "Single exchange rate" +msgid "One exchange rate per day" msgstr "Taux de change unique" #: apps/currencies/models.py:174 -msgid "Create one exchange rate and keep updating it. Avoids database clutter." +msgid "" +"Update the day's exchange rate on each fetch instead of adding a new one." msgstr "" -"Ne créer qu'un seul taux de change et le mettre à jour. Evite d'engorger la " -"base de donnée." -#: apps/currencies/models.py:179 +#: apps/currencies/models.py:178 +#, fuzzy +#| msgid "Exchange Rates" +msgid "Exchange rates to keep" +msgstr "Taux de changes" + +#: apps/currencies/models.py:183 +msgid "" +"Keep at most this many automatic exchange rates per currency pair, deleting " +"the oldest first. Leave empty to keep all." +msgstr "" + +#: apps/currencies/models.py:188 msgid "Exchange Rate Service" msgstr "Service de taux de change" -#: apps/currencies/models.py:180 +#: apps/currencies/models.py:189 msgid "Exchange Rate Services" msgstr "Services de taux de change" -#: apps/currencies/models.py:234 +#: apps/currencies/models.py:243 msgid "'Every X hours' interval type requires a positive integer." msgstr "'Toutes les X heures' l'intervalle requiert un entier positif." -#: apps/currencies/models.py:243 +#: apps/currencies/models.py:252 msgid "'Every X hours' interval must be between 1 and 24." msgstr "'Toutes les X heures' l'intervalle doit être compris entre 1 et 24." -#: apps/currencies/models.py:257 +#: apps/currencies/models.py:266 msgid "" "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "'1-5,8,10-12')." @@ -702,7 +811,7 @@ msgstr "" "Format d'heure invalide. Utilisez les heures séparé par virgule (0-23) et/ou " "une plage (ex : '1-5,8,10-12')." -#: apps/currencies/models.py:268 +#: apps/currencies/models.py:277 msgid "" "Invalid format. Please check the requirements for your selected interval " "type." @@ -774,22 +883,22 @@ msgstr "Ecrivez pour rechercher une transaction à lier avec cette entrée" msgid "Income Transaction" msgstr "Transaction entrante" -#: apps/dca/forms.py:192 +#: apps/dca/forms.py:179 msgid "Link transaction" msgstr "Lié transaction" -#: apps/dca/forms.py:275 apps/dca/forms.py:276 apps/dca/forms.py:281 -#: apps/dca/forms.py:285 +#: apps/dca/forms.py:262 apps/dca/forms.py:263 apps/dca/forms.py:268 +#: apps/dca/forms.py:272 msgid "You must provide an account." msgstr "Vous devez fournir un compte." -#: apps/dca/forms.py:290 apps/transactions/forms.py:752 -#: apps/transactions/models.py:956 +#: apps/dca/forms.py:277 apps/transactions/forms.py:752 +#: apps/transactions/models.py:980 msgid "From and To accounts must be different." msgstr "" "Le compte originateur et le compte bénéficiaire doivent être différent." -#: apps/dca/forms.py:304 +#: apps/dca/forms.py:299 #, python-format msgid "DCA for %(strategy_name)s" msgstr "DCA pour %(strategy_name)s" @@ -805,8 +914,8 @@ msgstr "Devise de paiement" #: apps/dca/models.py:26 apps/dca/models.py:181 apps/rules/forms.py:180 #: apps/rules/forms.py:196 apps/rules/models.py:43 apps/rules/models.py:295 #: apps/transactions/forms.py:504 apps/transactions/forms.py:651 -#: apps/transactions/models.py:353 apps/transactions/models.py:752 -#: apps/transactions/models.py:1009 apps/transactions/models.py:1302 +#: apps/transactions/models.py:353 apps/transactions/models.py:776 +#: apps/transactions/models.py:1033 apps/transactions/models.py:1326 msgid "Notes" msgstr "Notes" @@ -814,7 +923,7 @@ msgstr "Notes" msgid "DCA Strategy" msgstr "Stratégie DCA" -#: apps/dca/models.py:35 apps/export_app/forms.py:150 +#: apps/dca/models.py:35 apps/export_app/forms.py:152 msgid "DCA Strategies" msgstr "Stratégies DCA" @@ -834,7 +943,7 @@ msgstr "Somme reçue" msgid "DCA Entry" msgstr "Entrée DCA" -#: apps/dca/models.py:187 apps/export_app/forms.py:151 +#: apps/dca/models.py:187 apps/export_app/forms.py:153 msgid "DCA Entries" msgstr "Entrées DCA" @@ -862,25 +971,25 @@ msgstr "Entrée mise à jour avec succès" msgid "Entry deleted successfully" msgstr "Entrée supprimée avec succès" -#: apps/export_app/forms.py:13 apps/export_app/forms.py:128 -#: templates/includes/sidebar.html:227 templates/users/fragments/list.html:10 +#: apps/export_app/forms.py:13 apps/export_app/forms.py:129 +#: templates/includes/sidebar.html:241 templates/users/fragments/list.html:10 #: templates/users/pages/index.html:4 msgid "Users" msgstr "Utilisateurs" -#: apps/export_app/forms.py:31 apps/export_app/forms.py:134 +#: apps/export_app/forms.py:31 apps/export_app/forms.py:136 #: apps/transactions/models.py:422 templates/includes/sidebar.html:81 -#: templates/includes/sidebar.html:148 +#: templates/includes/sidebar.html:154 #: templates/recurring_transactions/fragments/list_transactions.html:5 #: templates/recurring_transactions/fragments/table.html:37 #: templates/transactions/pages/transactions.html:5 msgid "Transactions" msgstr "Transactions" -#: apps/export_app/forms.py:37 apps/export_app/forms.py:131 +#: apps/export_app/forms.py:37 apps/export_app/forms.py:133 #: apps/insights/views.py:43 apps/transactions/filters.py:74 #: templates/categories/fragments/list.html:9 -#: templates/categories/pages/index.html:4 templates/includes/sidebar.html:150 +#: templates/categories/pages/index.html:4 templates/includes/sidebar.html:156 #: templates/insights/fragments/month_by_month.html:18 #: templates/insights/fragments/month_by_month.html:21 #: templates/insights/fragments/year_by_year.html:13 @@ -888,16 +997,16 @@ msgstr "Transactions" msgid "Categories" msgstr "Catégories" -#: apps/export_app/forms.py:49 apps/export_app/forms.py:133 +#: apps/export_app/forms.py:49 apps/export_app/forms.py:135 #: apps/insights/views.py:57 apps/rules/forms.py:185 apps/rules/forms.py:195 #: apps/rules/models.py:46 apps/rules/models.py:307 #: apps/transactions/filters.py:84 apps/transactions/forms.py:78 #: apps/transactions/forms.py:346 apps/transactions/forms.py:526 #: apps/transactions/forms.py:831 apps/transactions/forms.py:1083 #: apps/transactions/models.py:312 apps/transactions/models.py:368 -#: apps/transactions/models.py:748 apps/transactions/models.py:1006 -#: apps/transactions/models.py:1317 templates/entities/fragments/list.html:9 -#: templates/entities/pages/index.html:4 templates/includes/sidebar.html:162 +#: apps/transactions/models.py:772 apps/transactions/models.py:1030 +#: apps/transactions/models.py:1341 templates/entities/fragments/list.html:9 +#: templates/entities/pages/index.html:4 templates/includes/sidebar.html:168 #: templates/insights/fragments/month_by_month.html:40 #: templates/insights/fragments/month_by_month.html:43 #: templates/insights/fragments/year_by_year.html:35 @@ -905,28 +1014,28 @@ msgstr "Catégories" msgid "Entities" msgstr "Entités" -#: apps/export_app/forms.py:55 apps/export_app/forms.py:137 -#: apps/transactions/models.py:1046 templates/includes/sidebar.html:110 +#: apps/export_app/forms.py:55 apps/export_app/forms.py:139 +#: apps/transactions/models.py:1070 templates/includes/sidebar.html:110 #: templates/recurring_transactions/fragments/list.html:9 #: templates/recurring_transactions/pages/index.html:4 msgid "Recurring Transactions" msgstr "Transactions récurrentes" -#: apps/export_app/forms.py:61 apps/export_app/forms.py:135 -#: apps/transactions/models.py:766 templates/includes/sidebar.html:104 +#: apps/export_app/forms.py:61 apps/export_app/forms.py:137 +#: apps/transactions/models.py:790 templates/includes/sidebar.html:104 #: templates/installment_plans/fragments/list.html:9 #: templates/installment_plans/pages/index.html:4 msgid "Installment Plans" msgstr "Paiements en plusieurs fois" -#: apps/export_app/forms.py:73 apps/export_app/forms.py:140 +#: apps/export_app/forms.py:73 apps/export_app/forms.py:142 #: templates/exchange_rates_services/fragments/list.html:10 #: templates/exchange_rates_services/pages/index.html:4 -#: templates/includes/sidebar.html:218 +#: templates/includes/sidebar.html:232 msgid "Automatic Exchange Rates" msgstr "Taux de change automatique" -#: apps/export_app/forms.py:79 templates/includes/sidebar.html:198 +#: apps/export_app/forms.py:79 templates/includes/sidebar.html:212 #: templates/rules/fragments/list.html:9 templates/rules/pages/index.html:4 msgid "Rules" msgstr "Règles" @@ -935,57 +1044,57 @@ msgstr "Règles" msgid "DCA" msgstr "DCA" -#: apps/export_app/forms.py:91 apps/export_app/forms.py:152 +#: apps/export_app/forms.py:91 apps/export_app/forms.py:154 #: templates/import_app/fragments/profiles/list.html:21 #: templates/import_app/pages/profiles_index.html:4 msgid "Import Profiles" msgstr "Importer des profils" -#: apps/export_app/forms.py:117 templates/export_app/fragments/export.html:5 +#: apps/export_app/forms.py:118 templates/export_app/fragments/export.html:5 #: templates/export_app/pages/index.html:13 msgid "Export" msgstr "Exporter" -#: apps/export_app/forms.py:125 +#: apps/export_app/forms.py:126 msgid "Import a ZIP file exported from WYGIWYH" msgstr "Importer un fichier ZIP exporté de WYGIWYH" -#: apps/export_app/forms.py:126 +#: apps/export_app/forms.py:127 msgid "ZIP File" msgstr "Fichier ZIP" -#: apps/export_app/forms.py:143 apps/rules/models.py:27 +#: apps/export_app/forms.py:145 apps/rules/models.py:27 msgid "Transaction rules" msgstr "Règles de transaction" -#: apps/export_app/forms.py:145 apps/rules/models.py:68 +#: apps/export_app/forms.py:147 apps/rules/models.py:68 msgid "Edit transaction action" msgstr "Modifier l'action de transaction" -#: apps/export_app/forms.py:148 apps/rules/models.py:327 +#: apps/export_app/forms.py:150 apps/rules/models.py:327 msgid "Update or create transaction actions" msgstr "Mettre à jour ou créer des actions de transaction" -#: apps/export_app/forms.py:181 templates/cotton/transaction/item.html:341 +#: apps/export_app/forms.py:183 templates/cotton/transaction/item.html:341 #: templates/cotton/ui/deleted_transactions_action_bar.html:53 #: templates/export_app/fragments/restore.html:5 #: templates/export_app/pages/index.html:19 msgid "Restore" msgstr "Restaurer" -#: apps/export_app/forms.py:191 +#: apps/export_app/forms.py:193 msgid "Please upload either a ZIP file or at least one CSV file" msgstr "Veuillez charger un fichier ZIP ou au moins un fichier CSV" -#: apps/export_app/views.py:177 +#: apps/export_app/views.py:183 msgid "You have to select at least one export" msgstr "Vous devez sélectionner au moins un export" -#: apps/export_app/views.py:197 +#: apps/export_app/views.py:203 msgid "Data restored successfully" msgstr "Donnée restaurée avec succès" -#: apps/export_app/views.py:209 +#: apps/export_app/views.py:215 msgid "" "There was an error restoring your data. Check the logs for more details." msgstr "" @@ -998,7 +1107,7 @@ msgstr "Sélectionnez un fichier" #: apps/import_app/forms.py:55 #: templates/import_app/fragments/profiles/list.html:75 -#: templates/includes/sidebar.html:204 +#: templates/includes/sidebar.html:218 msgid "Import" msgstr "Importer" @@ -1046,23 +1155,23 @@ msgstr "Statut" msgid "File name" msgstr "Nom du fichier" -#: apps/import_app/views.py:75 +#: apps/import_app/views.py:76 msgid "Import Profile added successfully" msgstr "Profil d'import ajouté avec succès" -#: apps/import_app/views.py:113 +#: apps/import_app/views.py:114 msgid "Import Profile update successfully" msgstr "Profil d'import mis à jour avec succès" -#: apps/import_app/views.py:142 +#: apps/import_app/views.py:143 msgid "Import Profile deleted successfully" msgstr "Profil d'import supprimé avec succès" -#: apps/import_app/views.py:222 +#: apps/import_app/views.py:223 msgid "Import Run queued successfully" msgstr "Import d’exécution mis en file d'attente avec succès" -#: apps/import_app/views.py:249 +#: apps/import_app/views.py:250 msgid "Run deleted successfully" msgstr "Exécution supprimé avec succès" @@ -1131,7 +1240,7 @@ msgstr "Etiquette" msgid "Untagged" msgstr "Sans étiquettes" -#: apps/insights/views.py:53 apps/transactions/models.py:589 +#: apps/insights/views.py:53 apps/transactions/models.py:613 #: templates/tags/fragments/table.html:69 msgid "No tags" msgstr "Aucune étiquette" @@ -1152,6 +1261,32 @@ msgstr "Pas d'entité" msgid "No entities" msgstr "Pas d'entités" +#: apps/investments/views.py:104 +msgid "Other" +msgstr "" + +#: apps/investments/views.py:135 +msgid "Unclassified" +msgstr "" + +#: apps/investments/views.py:267 +#, fuzzy +#| msgid "Installment Plan added successfully" +msgid "Asset class added successfully" +msgstr "Paiement en plusieurs fois ajouté avec succès" + +#: apps/investments/views.py:275 +#, fuzzy +#| msgid "Tag updated successfully" +msgid "Asset class updated successfully" +msgstr "Etiquette mise à jour avec succès" + +#: apps/investments/views.py:296 +#, fuzzy +#| msgid "Tag deleted successfully" +msgid "Asset class deleted successfully" +msgstr "Etiquette supprimée avec succès" + #: apps/net_worth/views.py:137 templates/net_worth/net_worth.html:84 #: templates/net_worth/net_worth.html:85 msgid "Consolidated" @@ -1205,8 +1340,8 @@ msgstr "Opérateur" #: apps/rules/forms.py:174 apps/rules/forms.py:188 apps/rules/models.py:36 #: apps/rules/models.py:271 apps/transactions/forms.py:468 -#: apps/transactions/models.py:336 apps/transactions/models.py:708 -#: apps/transactions/models.py:987 apps/transactions/models.py:1287 +#: apps/transactions/models.py:336 apps/transactions/models.py:732 +#: apps/transactions/models.py:1011 apps/transactions/models.py:1311 #: templates/mini_tools/simulator.html:63 msgid "Type" msgstr "Type" @@ -1214,7 +1349,7 @@ msgstr "Type" #: apps/rules/forms.py:175 apps/rules/forms.py:189 apps/rules/models.py:37 #: apps/rules/models.py:275 apps/transactions/filters.py:22 #: apps/transactions/forms.py:472 apps/transactions/models.py:338 -#: apps/transactions/models.py:1289 templates/cotton/transaction/item.html:23 +#: apps/transactions/models.py:1313 templates/cotton/transaction/item.html:23 #: templates/cotton/transaction/item.html:34 #: templates/transactions/widgets/paid_toggle_button.html:10 #: templates/transactions/widgets/unselectable_paid_toggle_button.html:13 @@ -1225,14 +1360,14 @@ msgstr "Payé" #: apps/rules/models.py:283 apps/transactions/forms.py:90 #: apps/transactions/forms.py:488 apps/transactions/forms.py:638 #: apps/transactions/forms.py:837 apps/transactions/models.py:340 -#: apps/transactions/models.py:726 apps/transactions/models.py:1011 +#: apps/transactions/models.py:750 apps/transactions/models.py:1035 msgid "Reference Date" msgstr "Date de référence" #: apps/rules/forms.py:178 apps/rules/forms.py:192 apps/rules/models.py:41 #: apps/rules/models.py:287 apps/transactions/forms.py:495 -#: apps/transactions/models.py:346 apps/transactions/models.py:992 -#: apps/transactions/models.py:1295 +#: apps/transactions/models.py:346 apps/transactions/models.py:1016 +#: apps/transactions/models.py:1319 #: templates/insights/fragments/sankey.html:102 #: templates/installment_plans/fragments/table.html:18 #: templates/mini_tools/simulator.html:70 @@ -1244,27 +1379,27 @@ msgstr "Montant" #: apps/rules/forms.py:179 apps/rules/forms.py:193 apps/rules/models.py:14 #: apps/rules/models.py:42 apps/rules/models.py:291 #: apps/transactions/forms.py:499 apps/transactions/forms.py:642 -#: apps/transactions/models.py:351 apps/transactions/models.py:710 -#: apps/transactions/models.py:995 apps/transactions/models.py:1300 +#: apps/transactions/models.py:351 apps/transactions/models.py:734 +#: apps/transactions/models.py:1019 apps/transactions/models.py:1324 msgid "Description" msgstr "Description" #: apps/rules/forms.py:182 apps/rules/forms.py:198 apps/rules/models.py:47 #: apps/rules/models.py:299 apps/transactions/models.py:398 -#: apps/transactions/models.py:1322 +#: apps/transactions/models.py:1346 msgid "Internal Note" msgstr "Note interne" #: apps/rules/forms.py:183 apps/rules/forms.py:199 apps/rules/models.py:48 #: apps/rules/models.py:303 apps/transactions/models.py:400 -#: apps/transactions/models.py:1324 +#: apps/transactions/models.py:1348 msgid "Internal ID" msgstr "ID interne" #: apps/rules/forms.py:186 apps/rules/forms.py:200 apps/rules/models.py:40 #: apps/rules/models.py:319 apps/transactions/forms.py:655 #: apps/transactions/models.py:250 apps/transactions/models.py:341 -#: apps/transactions/models.py:1290 +#: apps/transactions/models.py:1314 msgid "Mute" msgstr "Silencieux" @@ -1277,7 +1412,7 @@ msgid "Set Values" msgstr "Configurer les valeurs" #: apps/rules/forms.py:407 apps/rules/forms.py:442 apps/rules/forms.py:477 -#: apps/transactions/models.py:421 apps/transactions/models.py:645 +#: apps/transactions/models.py:421 apps/transactions/models.py:669 msgid "Transaction" msgstr "Transaction" @@ -1313,7 +1448,12 @@ msgstr "Règle" msgid "Field" msgstr "Champ" -#: apps/rules/models.py:61 +#: apps/rules/models.py:61 templates/investments/fragments/charts.html:86 +#: templates/investments/fragments/detail.html:103 +#: templates/investments/fragments/detail.html:162 +#: templates/investments/fragments/list.html:48 +#: templates/investments/fragments/list.html:49 +#: templates/investments/fragments/rebalance.html:23 msgid "Value" msgstr "Valeur" @@ -1501,7 +1641,7 @@ msgid "Upload" msgstr "Envoi" #: apps/transactions/forms.py:355 apps/transactions/forms.py:1089 -#: apps/transactions/models.py:925 +#: apps/transactions/models.py:949 #, fuzzy #| msgid "To Account" msgid "To account" @@ -1520,8 +1660,8 @@ msgstr "Montant de départ" msgid "To Amount" msgstr "Montant d'arrivée" -#: apps/transactions/forms.py:718 apps/transactions/models.py:970 -#: apps/transactions/models.py:1267 templates/cotton/transaction/item.html:52 +#: apps/transactions/forms.py:718 apps/transactions/models.py:994 +#: apps/transactions/models.py:1291 templates/cotton/transaction/item.html:52 #: templates/cotton/transaction/item.html:60 #: templates/cotton/ui/quick_transactions_buttons.html:40 #: templates/cotton/ui/transactions_fab.html:44 @@ -1588,8 +1728,8 @@ msgstr "" "Les entités désactivées ne pourront pas être sélectionnées lors de la " "création de nouvelles transactions" -#: apps/transactions/models.py:323 apps/transactions/models.py:969 -#: apps/transactions/models.py:1266 +#: apps/transactions/models.py:323 apps/transactions/models.py:993 +#: apps/transactions/models.py:1290 #: templates/calendar_view/fragments/list.html:42 #: templates/calendar_view/fragments/list.html:44 #: templates/calendar_view/fragments/list.html:52 @@ -1597,13 +1737,19 @@ msgstr "" #: templates/cotton/ui/quick_transactions_buttons.html:10 #: templates/cotton/ui/transactions_fab.html:10 #: templates/insights/fragments/overview/_results.html:12 +#: templates/investments/fragments/_totals.html:21 +#: templates/investments/fragments/_totals.html:42 +#: templates/investments/fragments/detail.html:106 +#: templates/investments/fragments/detail.html:210 +#: templates/investments/fragments/detail.html:252 +#: templates/investments/fragments/detail.html:320 #: templates/mini_tools/simulator.html:66 #: templates/monthly_overview/fragments/monthly_summary.html:42 msgid "Income" msgstr "Revenus" -#: apps/transactions/models.py:324 apps/transactions/models.py:971 -#: apps/transactions/models.py:1268 +#: apps/transactions/models.py:324 apps/transactions/models.py:995 +#: apps/transactions/models.py:1292 #: templates/calendar_view/fragments/list.html:46 #: templates/calendar_view/fragments/list.html:48 #: templates/calendar_view/fragments/list.html:56 @@ -1615,14 +1761,14 @@ msgstr "Revenus" msgid "Expense" msgstr "Dépense" -#: apps/transactions/models.py:379 apps/transactions/models.py:765 +#: apps/transactions/models.py:379 apps/transactions/models.py:789 #: templates/cotton/transaction/item.html:291 #: templates/cotton/transaction/item.html:308 #: templates/cotton/transaction/item.html:318 msgid "Installment Plan" msgstr "Plan d'aménagement" -#: apps/transactions/models.py:388 apps/transactions/models.py:1045 +#: apps/transactions/models.py:388 apps/transactions/models.py:1069 #: templates/cotton/transaction/item.html:281 #: templates/cotton/transaction/item.html:301 msgid "Recurring Transaction" @@ -1640,152 +1786,164 @@ msgstr "Supprimé" msgid "Deleted At" msgstr "Supprimé à" -#: apps/transactions/models.py:591 +#: apps/transactions/models.py:517 +msgid "A transfer needs one expense and one income." +msgstr "" + +#: apps/transactions/models.py:519 +msgid "A transfer needs two different accounts." +msgstr "" + +#: apps/transactions/models.py:523 +msgid "This transaction is already part of another transfer." +msgstr "" + +#: apps/transactions/models.py:615 msgid "No category" msgstr "Pas de catégorie" -#: apps/transactions/models.py:593 +#: apps/transactions/models.py:617 msgid "No description" msgstr "Pas de description" -#: apps/transactions/models.py:650 +#: apps/transactions/models.py:674 msgid "File" msgstr "Fichier" -#: apps/transactions/models.py:652 +#: apps/transactions/models.py:676 msgid "Original Name" msgstr "Nom initial" -#: apps/transactions/models.py:654 +#: apps/transactions/models.py:678 msgid "Content Type" msgstr "Type de contenu" -#: apps/transactions/models.py:656 +#: apps/transactions/models.py:680 msgid "Size" msgstr "Taille" -#: apps/transactions/models.py:661 +#: apps/transactions/models.py:685 msgid "Uploaded By" msgstr "Envoyer par" -#: apps/transactions/models.py:666 +#: apps/transactions/models.py:690 msgid "Transaction Attachment" msgstr "Fichiers de la transaction" -#: apps/transactions/models.py:667 +#: apps/transactions/models.py:691 msgid "Transaction Attachments" msgstr "Fichiers de transactions" -#: apps/transactions/models.py:697 templates/includes/sidebar.html:57 +#: apps/transactions/models.py:721 templates/includes/sidebar.html:57 msgid "Yearly" msgstr "Annuel" -#: apps/transactions/models.py:698 apps/users/models.py:469 +#: apps/transactions/models.py:722 apps/users/models.py:469 #: templates/includes/sidebar.html:51 msgid "Monthly" msgstr "Mensuel" -#: apps/transactions/models.py:699 +#: apps/transactions/models.py:723 msgid "Weekly" msgstr "Hebdomadaire" -#: apps/transactions/models.py:700 +#: apps/transactions/models.py:724 msgid "Daily" msgstr "Quotidien" -#: apps/transactions/models.py:713 +#: apps/transactions/models.py:737 msgid "Number of Installments" msgstr "Nombre d'écheances" -#: apps/transactions/models.py:718 +#: apps/transactions/models.py:742 msgid "Installment Start" msgstr "Commencer à l'échéance" -#: apps/transactions/models.py:719 +#: apps/transactions/models.py:743 msgid "The installment number to start counting from" msgstr "L'échéance à partir de laquelle compter" -#: apps/transactions/models.py:724 apps/transactions/models.py:1015 +#: apps/transactions/models.py:748 apps/transactions/models.py:1039 msgid "Start Date" msgstr "Date de début" -#: apps/transactions/models.py:728 apps/transactions/models.py:1016 +#: apps/transactions/models.py:752 apps/transactions/models.py:1040 msgid "End Date" msgstr "Date de fin" -#: apps/transactions/models.py:733 +#: apps/transactions/models.py:757 msgid "Recurrence" msgstr "Récurrence" -#: apps/transactions/models.py:736 +#: apps/transactions/models.py:760 msgid "Installment Amount" msgstr "Montant des échéances" -#: apps/transactions/models.py:755 apps/transactions/models.py:1035 +#: apps/transactions/models.py:779 apps/transactions/models.py:1059 msgid "Add description to transactions" msgstr "Rajouter une description à la transaction" -#: apps/transactions/models.py:758 apps/transactions/models.py:1038 +#: apps/transactions/models.py:782 apps/transactions/models.py:1062 msgid "Add notes to transactions" msgstr "Ajouter des notes aux transactions" -#: apps/transactions/models.py:932 +#: apps/transactions/models.py:956 #, fuzzy #| msgid "To Amount" msgid "To amount" msgstr "Montant d'arrivée" -#: apps/transactions/models.py:933 +#: apps/transactions/models.py:957 msgid "Leave empty to use the same amount" msgstr "Laisser vide pour garder le même montant" -#: apps/transactions/models.py:974 templates/mini_tools/simulator.html:129 +#: apps/transactions/models.py:998 templates/mini_tools/simulator.html:129 msgid "day(s)" msgstr "jour(s)" -#: apps/transactions/models.py:975 templates/mini_tools/simulator.html:130 +#: apps/transactions/models.py:999 templates/mini_tools/simulator.html:130 msgid "week(s)" msgstr "semaine(s)" -#: apps/transactions/models.py:976 templates/mini_tools/simulator.html:131 +#: apps/transactions/models.py:1000 templates/mini_tools/simulator.html:131 msgid "month(s)" msgstr "mois" -#: apps/transactions/models.py:977 templates/mini_tools/simulator.html:132 +#: apps/transactions/models.py:1001 templates/mini_tools/simulator.html:132 msgid "year(s)" msgstr "année(s)" -#: apps/transactions/models.py:979 +#: apps/transactions/models.py:1003 #: templates/recurring_transactions/fragments/list.html:18 msgid "Paused" msgstr "Interrompu" -#: apps/transactions/models.py:1018 +#: apps/transactions/models.py:1042 msgid "Recurrence Type" msgstr "Type de récurrence" -#: apps/transactions/models.py:1021 +#: apps/transactions/models.py:1045 msgid "Recurrence Interval" msgstr "Intervalle de récurrence" -#: apps/transactions/models.py:1024 +#: apps/transactions/models.py:1048 msgid "Keep at most" msgstr "Répéter un maximum de" -#: apps/transactions/models.py:1028 +#: apps/transactions/models.py:1052 msgid "Last Generated Date" msgstr "Dernière date générée" -#: apps/transactions/models.py:1031 +#: apps/transactions/models.py:1055 msgid "Last Generated Reference Date" msgstr "Dernière date de référence générée" -#: apps/transactions/models.py:1334 templates/cotton/transaction/item.html:274 +#: apps/transactions/models.py:1358 templates/cotton/transaction/item.html:274 #: templates/cotton/ui/transactions_fab.html:59 msgid "Quick Transaction" msgstr "Transaction rapide" -#: apps/transactions/models.py:1335 templates/includes/sidebar.html:98 +#: apps/transactions/models.py:1359 templates/includes/sidebar.html:98 #: templates/quick_transactions/pages/index.html:5 #: templates/quick_transactions/pages/index.html:15 msgid "Quick Transactions" @@ -1908,8 +2066,8 @@ msgid "Item deleted successfully" msgstr "Item supprimée avec succès" #: apps/transactions/views/quick_transactions.py:164 -#: apps/transactions/views/transactions.py:143 -#: apps/transactions/views/transactions.py:328 +#: apps/transactions/views/transactions.py:156 +#: apps/transactions/views/transactions.py:343 msgid "Transaction added successfully" msgstr "Transaction ajoutée avec succès" @@ -1945,42 +2103,42 @@ msgstr "Etiquette mise à jour avec succès" msgid "Tag deleted successfully" msgstr "Etiquette supprimée avec succès" -#: apps/transactions/views/transactions.py:61 +#: apps/transactions/views/transactions.py:65 msgid "Attachment uploaded successfully" msgstr "Fichier téléchargé avec succès" -#: apps/transactions/views/transactions.py:112 +#: apps/transactions/views/transactions.py:116 msgid "Attachment deleted successfully" msgstr "Fichier supprimé avec succès" -#: apps/transactions/views/transactions.py:355 +#: apps/transactions/views/transactions.py:370 msgid "Transaction updated successfully" msgstr "Transaction mise à jour avec succès" -#: apps/transactions/views/transactions.py:406 +#: apps/transactions/views/transactions.py:421 #, python-format msgid "%(count)s transaction updated successfully" msgid_plural "%(count)s transactions updated successfully" msgstr[0] "%(count)s transaction mise à jour avec succès" msgstr[1] "%(count)s transactions mises à jour avec succès" -#: apps/transactions/views/transactions.py:433 +#: apps/transactions/views/transactions.py:448 msgid "Transaction duplicated successfully" msgstr "Transaction dupliquée avec succès" -#: apps/transactions/views/transactions.py:478 +#: apps/transactions/views/transactions.py:493 msgid "Transaction deleted successfully" msgstr "Transaction supprimée avec succès" -#: apps/transactions/views/transactions.py:508 +#: apps/transactions/views/transactions.py:523 msgid "Transaction restored successfully" msgstr "Transaction restaurée avec succès" -#: apps/transactions/views/transactions.py:534 +#: apps/transactions/views/transactions.py:549 msgid "Transfer added successfully" msgstr "Virement ajouté avec succès" -#: apps/transactions/views/transactions.py:562 +#: apps/transactions/views/transactions.py:579 msgid "Transfer updated successfully" msgstr "Virement mis à jour avec succès" @@ -2301,6 +2459,7 @@ msgstr "Éditer un groupe de comptes" #: templates/exchange_rates_services/fragments/table.html:20 #: templates/import_app/fragments/profiles/list.html:42 #: templates/installment_plans/fragments/table.html:25 +#: templates/investment_classes/fragments/list.html:36 #: templates/quick_transactions/fragments/list.html:22 #: templates/recurring_transactions/fragments/table.html:27 #: templates/rules/fragments/list.html:31 @@ -2323,6 +2482,9 @@ msgstr "Actions" #: templates/exchange_rates_services/fragments/table.html:23 #: templates/import_app/fragments/profiles/list.html:46 #: templates/installment_plans/fragments/table.html:28 +#: templates/investment_classes/fragments/list.html:39 +#: templates/investments/fragments/detail.html:305 +#: templates/investments/fragments/detail.html:331 #: templates/quick_transactions/fragments/list.html:25 #: templates/recurring_transactions/fragments/table.html:30 #: templates/rules/fragments/transaction_rule/view.html:22 @@ -2372,6 +2534,7 @@ msgstr "Partager" #: templates/import_app/fragments/profiles/list.html:81 #: templates/import_app/fragments/runs/list.html:100 #: templates/installment_plans/fragments/table.html:54 +#: templates/investment_classes/fragments/list.html:45 #: templates/mini_tools/simulator.html:109 #: templates/mini_tools/unit_price_calculator.html:16 #: templates/quick_transactions/fragments/list.html:32 @@ -2405,6 +2568,7 @@ msgstr "Supprimer" #: templates/import_app/fragments/runs/list.html:104 #: templates/installment_plans/fragments/table.html:47 #: templates/installment_plans/fragments/table.html:58 +#: templates/investment_classes/fragments/list.html:49 #: templates/quick_transactions/fragments/list.html:37 #: templates/recurring_transactions/fragments/table.html:52 #: templates/recurring_transactions/fragments/table.html:65 @@ -2454,6 +2618,7 @@ msgstr "Cette opération est irréversible, vous ne pourrez pas l'annuler !" #: templates/import_app/fragments/profiles/list.html:87 #: templates/import_app/fragments/runs/list.html:106 #: templates/installment_plans/fragments/table.html:60 +#: templates/investment_classes/fragments/list.html:51 #: templates/quick_transactions/fragments/list.html:39 #: templates/recurring_transactions/fragments/table.html:94 #: templates/rules/fragments/list.html:62 @@ -2502,6 +2667,7 @@ msgid "Add account" msgstr "Ajouter un compte" #: templates/accounts/fragments/edit.html:5 +#: templates/investments/fragments/detail.html:40 msgid "Edit account" msgstr "Editer le compte" @@ -2574,7 +2740,7 @@ msgstr "Modifier la catégorie" msgid "Pick a month" msgstr "Sélectionner un mois" -#: templates/common/fragments/toasts.html:20 +#: templates/common/fragments/toasts.html:18 #: templates/cotton/components/fab.html:9 #: templates/cotton/components/sidebar_collapsible_panel.html:28 #: templates/extends/offcanvas.html:4 templates/includes/sidebar.html:44 @@ -2805,6 +2971,8 @@ msgid "Add DCA strategy" msgstr "Ajouter une stratégie DCA" #: templates/dca/fragments/strategy/details.html:28 +#: templates/investments/fragments/detail.html:92 +#: templates/investments/fragments/list.html:65 msgid "No exchange rate available" msgstr "Pas de taux de changes disponibles" @@ -2987,7 +3155,7 @@ msgstr "Comptes" msgid "No services configured" msgstr "Pas de services configurés" -#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:211 +#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:225 msgid "Export and Restore" msgstr "Export et Restauration" @@ -3165,52 +3333,63 @@ msgstr "Corbeille" msgid "Tools" msgstr "Outils" -#: templates/includes/sidebar.html:118 +#: templates/includes/sidebar.html:118 templates/includes/sidebar.html:188 +#: templates/investment_classes/fragments/list.html:29 +#: templates/investments/fragments/detail.html:37 +#: templates/investments/fragments/list.html:9 +#: templates/investments/pages/detail.html:4 +#: templates/investments/pages/index.html:4 +#, fuzzy +#| msgid "Installments" +msgid "Investments" +msgstr "Paiements en plusieurs fois" + +#: templates/includes/sidebar.html:124 msgid "Dollar Cost Average Tracker" msgstr "Suivi Dollar Cost Average" -#: templates/includes/sidebar.html:124 +#: templates/includes/sidebar.html:130 #: templates/mini_tools/unit_price_calculator.html:4 #: templates/mini_tools/unit_price_calculator.html:9 msgid "Unit Price Calculator" msgstr "Calculateur de prix unitaire" -#: templates/includes/sidebar.html:130 +#: templates/includes/sidebar.html:136 #: templates/mini_tools/currency_converter/currency_converter.html:7 #: templates/mini_tools/currency_converter/currency_converter.html:14 msgid "Currency Converter" msgstr "Convertisseur de devises" -#: templates/includes/sidebar.html:136 templates/mini_tools/simulator.html:5 +#: templates/includes/sidebar.html:142 templates/mini_tools/simulator.html:5 #: templates/mini_tools/simulator.html:21 msgid "Simulator" msgstr "Simulateur" -#: templates/includes/sidebar.html:145 +#: templates/includes/sidebar.html:151 msgid "Management" msgstr "Gestion" -#: templates/includes/sidebar.html:196 +#: templates/includes/sidebar.html:210 msgid "Automation" msgstr "Automatisation" -#: templates/includes/sidebar.html:225 +#: templates/includes/sidebar.html:239 msgid "Admin" msgstr "Admin" -#: templates/includes/sidebar.html:233 +#: templates/includes/sidebar.html:247 msgid "Django Admin" msgstr "Administration Django" -#: templates/includes/sidebar.html:234 +#: templates/includes/sidebar.html:248 msgid "Only use this if you know what you're doing" msgstr "A n'utiliser que si vous savez ce que vous faites" -#: templates/includes/sidebar.html:249 +#: templates/includes/sidebar.html:263 msgid "is available" msgstr "est disponible" -#: templates/includes/sidebar.html:257 +#: templates/includes/sidebar.html:271 msgid "Calculator" msgstr "Calculatrice" @@ -3277,6 +3456,7 @@ msgstr "Aucune transaction récente" #: templates/insights/fragments/overview/_results.html:173 #: templates/insights/fragments/year_by_year.html:59 #: templates/insights/fragments/year_by_year.html:140 +#: templates/investment_classes/fragments/list.html:65 #: templates/monthly_overview/fragments/monthly_summary.html:168 msgid "Total" msgstr "Total" @@ -3480,6 +3660,305 @@ msgstr "" msgid "No installment plans" msgstr "Aucuns paiements en plusieurs fois" +#: templates/investment_classes/fragments/form.html:5 +msgid "Edit asset class" +msgstr "" + +#: templates/investment_classes/fragments/form.html:5 +msgid "Add asset class" +msgstr "" + +#: templates/investment_classes/fragments/list.html:19 +#: templates/investments/fragments/rebalance.html:10 +#, python-format +msgid "Targets add up to %(total)s%%. Set them to 100%% to see the rebalance." +msgstr "" + +#: templates/investment_classes/fragments/list.html:28 +#: templates/investments/fragments/rebalance.html:25 +#, fuzzy +#| msgid "Targeting" +msgid "Target" +msgstr "Ciblage" + +#: templates/investment_classes/fragments/list.html:50 +msgid "Its investments will be left without an asset class." +msgstr "" + +#: templates/investment_classes/fragments/list.html:74 +msgid "No asset classes" +msgstr "" + +#: templates/investments/fragments/_allocation.html:3 +msgid "Allocation chart" +msgstr "" + +#: templates/investments/fragments/_allocation.html:11 +#, python-format +msgid "" +"%(share)s%% of your investments with a target, against a target of " +"%(target)s%%" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "Annual return, weighted by when money went in and out" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "" +"Return since the first contribution, weighted by when money went in and out" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "/yr" +msgstr "" + +#: templates/investments/fragments/_totals.html:15 +#: templates/investments/fragments/detail.html:63 +msgid "total return" +msgstr "" + +#: templates/investments/fragments/_totals.html:20 +#: templates/investments/fragments/_totals.html:41 +#: templates/investments/fragments/charts.html:96 +#: templates/investments/fragments/detail.html:163 +#: templates/investments/fragments/detail.html:223 +#: templates/investments/fragments/list.html:47 +#, fuzzy +#| msgid "Total Invested" +msgid "Invested" +msgstr "Total Investissement" + +#: templates/investments/fragments/_totals.html:24 +#: templates/investments/fragments/detail.html:74 +msgid "" +"How much of the return came from exchange rates moving since you invested" +msgstr "" + +#: templates/investments/fragments/_totals.html:24 +#: templates/investments/fragments/_totals.html:26 +#, fuzzy +#| msgid "Currency Code" +msgid "Currency effect" +msgstr "Code de devise" + +#: templates/investments/fragments/_totals.html:26 +#: templates/investments/fragments/detail.html:78 +msgid "" +"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." +msgstr "" + +#: templates/investments/fragments/_totals.html:40 +#: templates/investments/fragments/list.html:49 +msgid "Return" +msgstr "" + +#: templates/investments/fragments/charts.html:10 +msgid "By asset class" +msgstr "" + +#: templates/investments/fragments/charts.html:17 +#: templates/net_worth/net_worth.html:52 +#: templates/yearly_overview/pages/overview_by_currency.html:6 +msgid "By currency" +msgstr "Par devises" + +#: templates/investments/fragments/charts.html:24 +#, fuzzy +#| msgid "Total Invested" +msgid "Value and invested" +msgstr "Total Investissement" + +#: templates/investments/fragments/charts.html:31 +msgid "Return by investment" +msgstr "" + +#: templates/investments/fragments/charts.html:38 +#, fuzzy +#| msgid "Nothing to see here..." +msgid "Nothing to chart yet" +msgstr "Rien à voir ici..." + +#: templates/investments/fragments/detail.html:10 +msgid "Buy / Contribute" +msgstr "" + +#: templates/investments/fragments/detail.html:16 +msgid "Sell / Withdraw" +msgstr "" + +#: templates/investments/fragments/detail.html:23 +msgid "Dividend / Income" +msgstr "" + +#: templates/investments/fragments/detail.html:30 +#, fuzzy +#| msgid "Updated at" +msgid "Update value" +msgstr "Mis à jour le" + +#: templates/investments/fragments/detail.html:47 +#: templates/investments/fragments/list.html:67 +msgid "Tracked by value" +msgstr "" + +#: templates/investments/fragments/detail.html:48 +msgid "" +"Some incoming units have no linked payment, so their cost was estimated from " +"the market price of that day" +msgstr "" + +#: templates/investments/fragments/detail.html:68 +#, fuzzy +#| msgid "Info" +msgid "In" +msgstr "Info" + +#: templates/investments/fragments/detail.html:74 +#: templates/investments/fragments/detail.html:78 +#, fuzzy +#| msgid "Currency Code" +msgid "currency effect" +msgstr "Code de devise" + +#: templates/investments/fragments/detail.html:104 +#: templates/investments/fragments/detail.html:179 +#, fuzzy +#| msgid "Entry Price" +msgid "Price" +msgstr "Prix d'entrée" + +#: templates/investments/fragments/detail.html:105 +#: templates/investments/fragments/detail.html:200 +#: templates/investments/fragments/detail.html:239 +#: templates/investments/fragments/detail.html:290 +#, fuzzy +#| msgid "Warning" +msgid "Earnings" +msgstr "Avertissement" + +#: templates/investments/fragments/detail.html:180 +#: templates/investments/fragments/detail.html:227 +msgid "Average cost" +msgstr "" + +#: templates/investments/fragments/detail.html:181 +#: templates/investments/fragments/rebalance.html:41 +#, fuzzy +#| msgid "By" +msgid "Buy" +msgstr "Par" + +#: templates/investments/fragments/detail.html:182 +#: templates/investments/fragments/rebalance.html:43 +msgid "Sell" +msgstr "" + +#: templates/investments/fragments/detail.html:227 +#: templates/investments/fragments/detail.html:257 +msgid "Put in / taken out" +msgstr "" + +#: templates/investments/fragments/detail.html:239 +#, fuzzy +#| msgid "Uncategorized" +msgid "Unrealized" +msgstr "Sans catégorie" + +#: templates/investments/fragments/detail.html:248 +msgid "Realized" +msgstr "" + +#: templates/investments/fragments/detail.html:271 +#, fuzzy +#| msgid "Management" +msgid "Movements" +msgstr "Gestion" + +#: templates/investments/fragments/detail.html:284 +msgid "Bought / contributed" +msgstr "" + +#: templates/investments/fragments/detail.html:284 +msgid "Sold / withdrew" +msgstr "" + +#: templates/investments/fragments/detail.html:288 +msgid "Money in" +msgstr "" + +#: templates/investments/fragments/detail.html:288 +msgid "Money out" +msgstr "" + +#: templates/investments/fragments/detail.html:290 +msgid "Gain" +msgstr "" + +#: templates/investments/fragments/detail.html:290 +msgid "Loss, tax or fee" +msgstr "" + +#: templates/investments/fragments/detail.html:315 +#, fuzzy +#| msgid "No presets yet" +msgid "No movements yet" +msgstr "Pas encore de préréglages" + +#: templates/investments/fragments/detail.html:315 +msgid "Use the + button to buy or contribute" +msgstr "" + +#: templates/investments/fragments/list.html:21 +msgid "Holdings" +msgstr "" + +#: templates/investments/fragments/list.html:60 +msgid "Some costs were estimated from market prices" +msgstr "" + +#: templates/investments/fragments/list.html:92 +msgid "Charts" +msgstr "" + +#: templates/investments/fragments/list.html:97 +#, fuzzy +#| msgid "New balance" +msgid "Rebalance" +msgstr "Nouveau solde" + +#: templates/investments/fragments/list.html:105 +#, fuzzy +#| msgid "No presets yet" +msgid "No investments yet" +msgstr "Pas encore de préréglages" + +#: templates/investments/fragments/list.html:106 +msgid "Mark an account as an investment in its settings to see it here" +msgstr "" + +#: templates/investments/fragments/rebalance.html:4 +msgid "No allocation targets" +msgstr "" + +#: templates/investments/fragments/rebalance.html:5 +msgid "Set a target for your asset classes to see what to buy and sell" +msgstr "" + +#: templates/investments/fragments/rebalance.html:26 +msgid "To reach the target" +msgstr "" + +#: templates/investments/fragments/rebalance.html:34 +msgid "Off target" +msgstr "" + +#: templates/investments/fragments/rebalance.html:55 +msgid "Left out: investments without an asset class or a target" +msgstr "" + #: templates/layouts/base.html:38 msgid "This is a demo!" msgstr "C'est une démo !" @@ -3691,11 +4170,6 @@ msgstr "Filtrer les transactions" msgid "Order by" msgstr "Trier par" -#: templates/net_worth/net_worth.html:52 -#: templates/yearly_overview/pages/overview_by_currency.html:6 -msgid "By currency" -msgstr "Par devises" - #: templates/net_worth/net_worth.html:135 msgid "Evolution" msgstr "Évolution" @@ -4153,6 +4627,12 @@ msgstr "Se connecter avec" msgid "Yearly Overview" msgstr "Aperçu annuel" +#~ msgid "" +#~ "Create one exchange rate and keep updating it. Avoids database clutter." +#~ msgstr "" +#~ "Ne créer qu'un seul taux de change et le mettre à jour. Evite d'engorger " +#~ "la base de donnée." + #~ msgid "Add as quick transaction" #~ msgstr "Ajouter aux transactions rapides" diff --git a/app/locale/hu/LC_MESSAGES/django.po b/app/locale/hu/LC_MESSAGES/django.po index 3666adba..15fd64cc 100644 --- a/app/locale/hu/LC_MESSAGES/django.po +++ b/app/locale/hu/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-08 06:01+0000\n" +"POT-Creation-Date: 2026-11-08 18:27+0000\n" "PO-Revision-Date: 2026-01-10 03:09+0000\n" "Last-Translator: Herculino Trotta \n" "Language-Team: Hungarian \n" "Language-Team: Italian \n" "Language-Team: Dutch \n" "Language-Team: Polish =20) ? 1 : 2;\n" "X-Generator: Weblate 2026.6\n" -#: apps/accounts/forms.py:28 +#: apps/accounts/forms.py:33 msgid "Group name" msgstr "Nazwa grupy" -#: apps/accounts/forms.py:43 apps/accounts/forms.py:109 -#: apps/currencies/forms.py:57 apps/currencies/forms.py:91 -#: apps/currencies/forms.py:166 apps/dca/forms.py:46 apps/dca/forms.py:205 -#: apps/import_app/forms.py:32 apps/rules/forms.py:60 apps/rules/forms.py:100 -#: apps/rules/forms.py:385 apps/transactions/forms.py:219 -#: apps/transactions/forms.py:452 apps/transactions/forms.py:571 -#: apps/transactions/forms.py:718 apps/transactions/forms.py:940 -#: apps/transactions/forms.py:987 apps/transactions/forms.py:1015 -#: apps/transactions/forms.py:1046 apps/transactions/forms.py:1208 -#: apps/users/forms.py:264 apps/users/forms.py:422 +#: apps/accounts/forms.py:48 apps/accounts/forms.py:76 +#: apps/accounts/forms.py:184 apps/currencies/forms.py:57 +#: apps/currencies/forms.py:91 apps/currencies/forms.py:168 +#: apps/dca/forms.py:46 apps/dca/forms.py:192 apps/import_app/forms.py:32 +#: apps/rules/forms.py:60 apps/rules/forms.py:100 apps/rules/forms.py:385 +#: apps/transactions/forms.py:219 apps/transactions/forms.py:452 +#: apps/transactions/forms.py:571 apps/transactions/forms.py:718 +#: apps/transactions/forms.py:940 apps/transactions/forms.py:987 +#: apps/transactions/forms.py:1015 apps/transactions/forms.py:1046 +#: apps/transactions/forms.py:1208 apps/users/forms.py:264 +#: apps/users/forms.py:422 #: templates/rules/fragments/transaction_rule/dry_run/updated.html:5 #: templates/rules/fragments/transaction_rule/view.html:128 msgid "Update" msgstr "Aktualizuj" -#: apps/accounts/forms.py:49 apps/accounts/forms.py:115 -#: apps/common/widgets/tom_select.py:13 apps/currencies/forms.py:63 -#: apps/currencies/forms.py:97 apps/currencies/forms.py:172 -#: apps/dca/forms.py:52 apps/dca/forms.py:211 apps/import_app/forms.py:38 -#: apps/rules/forms.py:66 apps/rules/forms.py:106 apps/rules/forms.py:391 -#: apps/transactions/forms.py:206 apps/transactions/forms.py:226 -#: apps/transactions/forms.py:459 apps/transactions/forms.py:946 -#: apps/transactions/forms.py:993 apps/transactions/forms.py:1021 -#: apps/transactions/forms.py:1052 apps/transactions/forms.py:1214 -#: apps/users/forms.py:270 apps/users/forms.py:428 -#: templates/mini_tools/simulator.html:177 +#: apps/accounts/forms.py:54 apps/accounts/forms.py:76 +#: apps/accounts/forms.py:190 apps/common/widgets/tom_select.py:15 +#: apps/currencies/forms.py:63 apps/currencies/forms.py:97 +#: apps/currencies/forms.py:174 apps/dca/forms.py:52 apps/dca/forms.py:198 +#: apps/import_app/forms.py:38 apps/rules/forms.py:66 apps/rules/forms.py:106 +#: apps/rules/forms.py:391 apps/transactions/forms.py:206 +#: apps/transactions/forms.py:226 apps/transactions/forms.py:459 +#: apps/transactions/forms.py:946 apps/transactions/forms.py:993 +#: apps/transactions/forms.py:1021 apps/transactions/forms.py:1052 +#: apps/transactions/forms.py:1214 apps/users/forms.py:270 +#: apps/users/forms.py:428 templates/mini_tools/simulator.html:177 #: templates/mini_tools/unit_price_calculator.html:168 msgid "Add" msgstr "Dodaj" -#: apps/accounts/forms.py:57 templates/accounts/fragments/list.html:23 +#: apps/accounts/forms.py:86 templates/accounts/fragments/list.html:23 msgid "Group" msgstr "Grupa" -#: apps/accounts/forms.py:123 +#: apps/accounts/forms.py:93 apps/accounts/models.py:73 +#: apps/accounts/models.py:130 +#: templates/investments/fragments/rebalance.html:22 +msgid "Asset class" +msgstr "" + +#: apps/accounts/forms.py:198 msgid "New balance" msgstr "Nowe saldo" -#: apps/accounts/forms.py:129 apps/dca/forms.py:79 apps/dca/forms.py:86 +#: apps/accounts/forms.py:204 apps/dca/forms.py:79 apps/dca/forms.py:86 #: apps/insights/forms.py:117 apps/insights/views.py:44 apps/rules/forms.py:181 #: apps/rules/forms.py:197 apps/rules/models.py:44 apps/rules/models.py:311 #: apps/transactions/forms.py:62 apps/transactions/forms.py:330 #: apps/transactions/forms.py:510 apps/transactions/forms.py:607 #: apps/transactions/forms.py:614 apps/transactions/forms.py:823 #: apps/transactions/forms.py:1075 apps/transactions/models.py:357 -#: apps/transactions/models.py:743 apps/transactions/models.py:999 -#: apps/transactions/models.py:1306 +#: apps/transactions/models.py:767 apps/transactions/models.py:1023 +#: apps/transactions/models.py:1330 #: templates/insights/fragments/month_by_month.html:84 #: templates/insights/fragments/year_by_year.html:52 msgid "Category" msgstr "Kategoria" -#: apps/accounts/forms.py:136 apps/dca/forms.py:95 apps/dca/forms.py:103 -#: apps/export_app/forms.py:43 apps/export_app/forms.py:132 +#: apps/accounts/forms.py:211 apps/dca/forms.py:95 apps/dca/forms.py:103 +#: apps/export_app/forms.py:43 apps/export_app/forms.py:134 #: apps/insights/views.py:50 apps/rules/forms.py:184 apps/rules/forms.py:194 #: apps/rules/models.py:45 apps/rules/models.py:315 #: apps/transactions/filters.py:79 apps/transactions/forms.py:70 #: apps/transactions/forms.py:338 apps/transactions/forms.py:518 #: apps/transactions/forms.py:623 apps/transactions/forms.py:631 #: apps/transactions/forms.py:816 apps/transactions/forms.py:1068 -#: apps/transactions/models.py:363 apps/transactions/models.py:745 -#: apps/transactions/models.py:1003 apps/transactions/models.py:1312 -#: templates/includes/sidebar.html:156 +#: apps/transactions/models.py:363 apps/transactions/models.py:769 +#: apps/transactions/models.py:1027 apps/transactions/models.py:1336 +#: templates/includes/sidebar.html:162 #: templates/insights/fragments/month_by_month.html:29 #: templates/insights/fragments/month_by_month.html:32 #: templates/insights/fragments/year_by_year.html:24 @@ -95,27 +102,27 @@ msgstr "Kategoria" msgid "Tags" msgstr "Tagi" -#: apps/accounts/forms.py:179 +#: apps/accounts/forms.py:254 #, fuzzy #| msgid "All Transactions" msgid "All paid transactions" msgstr "Wszystkie transakcje" -#: apps/accounts/forms.py:180 +#: apps/accounts/forms.py:255 #, fuzzy #| msgid "Update" msgid "Up to date" msgstr "Aktualizuj" -#: apps/accounts/forms.py:181 +#: apps/accounts/forms.py:256 msgid "Up to reference month" msgstr "" -#: apps/accounts/forms.py:185 +#: apps/accounts/forms.py:260 msgid "Balance against" msgstr "" -#: apps/accounts/forms.py:189 apps/currencies/forms.py:70 +#: apps/accounts/forms.py:264 apps/currencies/forms.py:70 #: apps/dca/models.py:158 apps/rules/forms.py:176 apps/rules/forms.py:190 #: apps/rules/models.py:38 apps/rules/models.py:279 #: apps/transactions/forms.py:86 apps/transactions/forms.py:482 @@ -126,16 +133,17 @@ msgstr "" msgid "Date" msgstr "Data" -#: apps/accounts/forms.py:194 +#: apps/accounts/forms.py:269 #, fuzzy #| msgid "Reference Date" msgid "Reference month" msgstr "Data referencyjna" -#: apps/accounts/models.py:12 apps/accounts/models.py:29 apps/dca/models.py:13 +#: apps/accounts/models.py:19 apps/accounts/models.py:36 +#: apps/accounts/models.py:84 apps/dca/models.py:13 #: apps/import_app/models.py:20 apps/rules/models.py:13 #: apps/transactions/models.py:249 apps/transactions/models.py:274 -#: apps/transactions/models.py:298 apps/transactions/models.py:1274 +#: apps/transactions/models.py:298 apps/transactions/models.py:1298 #: apps/users/models.py:577 templates/account_groups/fragments/list.html:22 #: templates/accounts/fragments/list.html:22 #: templates/categories/fragments/table.html:17 @@ -144,6 +152,7 @@ msgstr "Data referencyjna" #: templates/exchange_rates_services/fragments/list.html:28 #: templates/import_app/fragments/profiles/list.html:34 #: templates/installment_plans/fragments/table.html:16 +#: templates/investment_classes/fragments/list.html:27 #: templates/quick_transactions/fragments/list.html:13 #: templates/recurring_transactions/fragments/table.html:18 #: templates/rules/fragments/list.html:24 @@ -152,44 +161,129 @@ msgstr "Data referencyjna" msgid "Name" msgstr "Nazwa" -#: apps/accounts/models.py:18 apps/accounts/models.py:33 +#: apps/accounts/models.py:25 apps/accounts/models.py:88 msgid "Account Group" msgstr "Grupa konta" -#: apps/accounts/models.py:19 templates/account_groups/fragments/list.html:9 +#: apps/accounts/models.py:26 templates/account_groups/fragments/list.html:9 #: templates/account_groups/pages/index.html:4 -#: templates/includes/sidebar.html:176 +#: templates/includes/sidebar.html:182 msgid "Account Groups" msgstr "Grupy kont" -#: apps/accounts/models.py:39 apps/currencies/models.py:44 +#: apps/accounts/models.py:43 +msgid "Target allocation (%)" +msgstr "" + +#: apps/accounts/models.py:45 +msgid "" +"Share of your investments you want in this class. Leave it empty to leave " +"the class out of the targets." +msgstr "" + +#: apps/accounts/models.py:61 +msgid "You already have an asset class with this name." +msgstr "" + +#: apps/accounts/models.py:66 +#, python-format +msgid "" +"Targets can't add up to more than 100%%. Other classes already take " +"%(taken)s%%." +msgstr "" + +#: apps/accounts/models.py:74 apps/export_app/forms.py:131 +#: templates/includes/sidebar.html:190 +#: templates/investment_classes/fragments/list.html:9 +#: templates/investment_classes/pages/index.html:4 +#: templates/investments/fragments/rebalance.html:6 +#: templates/investments/fragments/rebalance.html:11 +msgid "Asset classes" +msgstr "" + +#: apps/accounts/models.py:94 apps/currencies/models.py:44 #: templates/accounts/fragments/list.html:24 #: templates/mini_tools/simulator.html:37 #: templates/yearly_overview/pages/yearly_overview_base.html:19 msgid "Currency" msgstr "Waluta" -#: apps/accounts/models.py:45 apps/currencies/models.py:27 +#: apps/accounts/models.py:100 apps/currencies/models.py:27 #: templates/accounts/fragments/list.html:25 msgid "Exchange Currency" msgstr "Wymiana waluty" -#: apps/accounts/models.py:50 apps/currencies/models.py:32 +#: apps/accounts/models.py:105 apps/currencies/models.py:32 msgid "Default currency for exchange calculations" msgstr "Domyślna waluta na potrzeby przeliczenia kursu wymiany" -#: apps/accounts/models.py:55 +#: apps/accounts/models.py:110 msgid "Asset account" msgstr "Rachunek aktywów" -#: apps/accounts/models.py:57 +#: apps/accounts/models.py:112 msgid "" "Asset accounts count towards your Net Worth, but not towards your month." msgstr "" "Rachunek aktywów wlicza się do dostępnej wartości netto, ale nie jest " "uwzględniany w miesięcznych podsumowaniach." -#: apps/accounts/models.py:62 apps/currencies/models.py:37 +#: apps/accounts/models.py:117 templates/investments/fragments/list.html:46 +#, fuzzy +#| msgid "Invert" +msgid "Investment" +msgstr "Zamień" + +#: apps/accounts/models.py:119 +msgid "" +"Show this account in the Investments tracker. Set an exchange currency to " +"price it by units (stocks, ETFs, crypto); leave it empty to track it by " +"value (fixed income, property, pensions)." +msgstr "" + +#: apps/accounts/models.py:132 +msgid "" +"Groups investments in the allocation charts, e.g. Fixed income, Stocks, Real " +"estate." +msgstr "" + +#: apps/accounts/models.py:140 +#, fuzzy +#| msgid "Entities" +msgid "Income entities" +msgstr "Encje" + +#: apps/accounts/models.py:142 +msgid "" +"Transactions with any of these entities count towards this investment's " +"return: in this account, and income paid into other accounts (dividends, " +"rent)." +msgstr "" + +#: apps/accounts/models.py:151 +#, fuzzy +#| msgid "No categories" +msgid "Income categories" +msgstr "Brak kategorii" + +#: apps/accounts/models.py:153 +msgid "" +"Transactions in this account with any of these categories are its result " +"(interest, taxes, fees). On accounts tracked by value, everything else that " +"isn't a transfer counts as money coming in or going out." +msgstr "" + +#: apps/accounts/models.py:162 +#, fuzzy +#| msgid "Income" +msgid "Income tags" +msgstr "Przychody" + +#: apps/accounts/models.py:164 +msgid "Same as income categories, for transactions with any of these tags." +msgstr "" + +#: apps/accounts/models.py:169 apps/currencies/models.py:37 #: templates/accounts/fragments/list.html:27 #: templates/categories/fragments/list.html:20 #: templates/currencies/fragments/list.html:24 @@ -198,19 +292,19 @@ msgstr "" msgid "Archived" msgstr "Zarchiwizowane" -#: apps/accounts/models.py:63 +#: apps/accounts/models.py:170 msgid "Archived accounts don't show up nor count towards your net worth" msgstr "" "Zarchiwizowane konta nie są wyświetlane oraz nie są uwzględniane w " "podsumowaniach" -#: apps/accounts/models.py:75 apps/rules/forms.py:173 apps/rules/forms.py:187 +#: apps/accounts/models.py:182 apps/rules/forms.py:173 apps/rules/forms.py:187 #: apps/rules/models.py:35 apps/rules/models.py:267 #: apps/transactions/forms.py:82 apps/transactions/forms.py:350 #: apps/transactions/forms.py:477 apps/transactions/forms.py:808 #: apps/transactions/forms.py:1060 apps/transactions/models.py:329 -#: apps/transactions/models.py:703 apps/transactions/models.py:981 -#: apps/transactions/models.py:1280 +#: apps/transactions/models.py:727 apps/transactions/models.py:1005 +#: apps/transactions/models.py:1304 #: templates/installment_plans/fragments/table.html:17 #: templates/quick_transactions/fragments/list.html:14 #: templates/recurring_transactions/fragments/table.html:19 @@ -218,17 +312,17 @@ msgstr "" msgid "Account" msgstr "Konto" -#: apps/accounts/models.py:76 apps/export_app/forms.py:19 -#: apps/export_app/forms.py:129 apps/transactions/filters.py:63 +#: apps/accounts/models.py:183 apps/export_app/forms.py:19 +#: apps/export_app/forms.py:130 apps/transactions/filters.py:63 #: templates/accounts/fragments/list.html:9 -#: templates/accounts/pages/index.html:4 templates/includes/sidebar.html:168 -#: templates/includes/sidebar.html:170 +#: templates/accounts/pages/index.html:4 templates/includes/sidebar.html:174 +#: templates/includes/sidebar.html:176 #: templates/monthly_overview/pages/overview.html:77 #: templates/transactions/pages/transactions.html:26 msgid "Accounts" msgstr "Konta" -#: apps/accounts/models.py:93 +#: apps/accounts/models.py:200 msgid "Exchange currency cannot be the same as the account's main currency." msgstr "Waluta do wymiany nie może być taka sama jak waluta konta." @@ -246,7 +340,7 @@ msgstr "Usunięto grupę kont" #: apps/accounts/views/account_groups.py:109 #: apps/accounts/views/accounts.py:137 apps/dca/views.py:106 -#: apps/import_app/views.py:147 apps/rules/views.py:191 +#: apps/import_app/views.py:148 apps/rules/views.py:191 #: apps/transactions/views/categories.py:163 #: apps/transactions/views/entities.py:135 apps/transactions/views/tags.py:131 msgid "Item no longer shared with you" @@ -254,7 +348,7 @@ msgstr "Ten wpis nie jest już dostępny dla Ciebie" #: apps/accounts/views/account_groups.py:132 #: apps/accounts/views/accounts.py:182 apps/dca/views.py:131 -#: apps/import_app/views.py:273 apps/rules/views.py:217 +#: apps/import_app/views.py:274 apps/rules/views.py:217 #: apps/transactions/views/categories.py:188 #: apps/transactions/views/entities.py:160 apps/transactions/views/tags.py:154 msgid "Ownership taken successfully" @@ -262,7 +356,7 @@ msgstr "Własność przejęta pomyślnie" #: apps/accounts/views/account_groups.py:152 #: apps/accounts/views/accounts.py:106 apps/dca/views.py:151 -#: apps/import_app/views.py:294 apps/rules/views.py:238 +#: apps/import_app/views.py:295 apps/rules/views.py:238 #: apps/transactions/views/categories.py:130 #: apps/transactions/views/entities.py:180 apps/transactions/views/tags.py:174 msgid "Configuration saved successfully" @@ -320,7 +414,7 @@ msgstr "Encja o zadanym ID nie istnieje." msgid "Invalid entity data. Provide an ID or name." msgstr "Niepoprawne dane encji. Wprowadź ID lub nazwę encji." -#: apps/api/serializers/transactions.py:141 apps/transactions/models.py:953 +#: apps/api/serializers/transactions.py:141 apps/transactions/models.py:977 msgid "This field is required." msgstr "" @@ -342,13 +436,14 @@ msgstr "Uczyń prywatne" msgid "Shared with users" msgstr "Udostępnione użytkownikom" -#: apps/common/fields/forms/dynamic_select.py:71 -#: apps/common/fields/forms/dynamic_select.py:142 +#: apps/common/fields/forms/dynamic_select.py:79 +#: apps/common/fields/forms/dynamic_select.py:150 msgid "Error creating new instance" msgstr "Błąd podczas tworzenia wpisu" #: apps/common/fields/forms/grouped_select.py:24 -#: apps/common/widgets/tom_select.py:92 apps/common/widgets/tom_select.py:95 +#: apps/common/widgets/tom_select.py:94 apps/common/widgets/tom_select.py:97 +#: templates/investments/fragments/list.html:26 msgid "Ungrouped" msgstr "Niezgrupowane" @@ -504,14 +599,15 @@ msgid "Today" msgstr "Dzisiaj" #: apps/common/widgets/datepicker.py:142 +#: templates/investments/fragments/rebalance.html:24 msgid "Now" msgstr "Teraz" -#: apps/common/widgets/tom_select.py:11 +#: apps/common/widgets/tom_select.py:13 msgid "Remove" msgstr "Usuń" -#: apps/common/widgets/tom_select.py:15 templates/mini_tools/simulator.html:187 +#: apps/common/widgets/tom_select.py:17 templates/mini_tools/simulator.html:187 #: templates/mini_tools/unit_price_calculator.html:180 #: templates/monthly_overview/pages/overview.html:286 #: templates/transactions/fragments/filter_actions.html:48 @@ -519,7 +615,7 @@ msgstr "Usuń" msgid "Clear" msgstr "Wyczyść" -#: apps/common/widgets/tom_select.py:16 +#: apps/common/widgets/tom_select.py:18 msgid "No results..." msgstr "Brak wyników..." @@ -553,10 +649,10 @@ msgid "Decimal Places" msgstr "Liczba miejsc po przecinku" #: apps/currencies/models.py:45 apps/export_app/forms.py:25 -#: apps/export_app/forms.py:130 apps/transactions/filters.py:70 +#: apps/export_app/forms.py:132 apps/transactions/filters.py:70 #: templates/currencies/fragments/list.html:9 -#: templates/currencies/pages/index.html:4 templates/includes/sidebar.html:182 -#: templates/includes/sidebar.html:184 +#: templates/currencies/pages/index.html:4 templates/includes/sidebar.html:196 +#: templates/includes/sidebar.html:198 #: templates/monthly_overview/pages/overview.html:63 #: templates/transactions/pages/transactions.html:12 msgid "Currencies" @@ -588,9 +684,9 @@ msgid "Auto" msgstr "Automatycznie" #: apps/currencies/models.py:82 apps/export_app/forms.py:67 -#: apps/export_app/forms.py:142 templates/exchange_rates/fragments/list.html:10 +#: apps/export_app/forms.py:144 templates/exchange_rates/fragments/list.html:10 #: templates/exchange_rates/pages/index.html:4 -#: templates/includes/sidebar.html:190 +#: templates/includes/sidebar.html:204 msgid "Exchange Rates" msgstr "Kursy wymiany" @@ -685,30 +781,45 @@ msgstr "" "z walut na podstawie jej docelowej waluty wymiany." #: apps/currencies/models.py:171 -msgid "Single exchange rate" -msgstr "" +#, fuzzy +#| msgid "No exchange rates" +msgid "One exchange rate per day" +msgstr "Brak kursów wymiany walut" #: apps/currencies/models.py:174 -msgid "Create one exchange rate and keep updating it. Avoids database clutter." +msgid "" +"Update the day's exchange rate on each fetch instead of adding a new one." msgstr "" -#: apps/currencies/models.py:179 +#: apps/currencies/models.py:178 +#, fuzzy +#| msgid "Exchange Rates" +msgid "Exchange rates to keep" +msgstr "Kursy wymiany" + +#: apps/currencies/models.py:183 +msgid "" +"Keep at most this many automatic exchange rates per currency pair, deleting " +"the oldest first. Leave empty to keep all." +msgstr "" + +#: apps/currencies/models.py:188 msgid "Exchange Rate Service" msgstr "" -#: apps/currencies/models.py:180 +#: apps/currencies/models.py:189 msgid "Exchange Rate Services" msgstr "" -#: apps/currencies/models.py:234 +#: apps/currencies/models.py:243 msgid "'Every X hours' interval type requires a positive integer." msgstr "Cykliczność \"co X godzin\" wymaga dodatniej liczby." -#: apps/currencies/models.py:243 +#: apps/currencies/models.py:252 msgid "'Every X hours' interval must be between 1 and 24." msgstr "Cykliczność \"co X godzin\" musi być z przedziału 1-24." -#: apps/currencies/models.py:257 +#: apps/currencies/models.py:266 msgid "" "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "'1-5,8,10-12')." @@ -716,7 +827,7 @@ msgstr "" "Niepoprawny format godziny. Użyj zakresów (0-23) i/lub oddziel godziny " "przecinkami (np. \"1-5,8,10-12\")." -#: apps/currencies/models.py:268 +#: apps/currencies/models.py:277 msgid "" "Invalid format. Please check the requirements for your selected interval " "type." @@ -786,21 +897,21 @@ msgstr "" msgid "Income Transaction" msgstr "" -#: apps/dca/forms.py:192 +#: apps/dca/forms.py:179 msgid "Link transaction" msgstr "" -#: apps/dca/forms.py:275 apps/dca/forms.py:276 apps/dca/forms.py:281 -#: apps/dca/forms.py:285 +#: apps/dca/forms.py:262 apps/dca/forms.py:263 apps/dca/forms.py:268 +#: apps/dca/forms.py:272 msgid "You must provide an account." msgstr "Należy wybrać konto." -#: apps/dca/forms.py:290 apps/transactions/forms.py:752 -#: apps/transactions/models.py:956 +#: apps/dca/forms.py:277 apps/transactions/forms.py:752 +#: apps/transactions/models.py:980 msgid "From and To accounts must be different." msgstr "Konta \"Z\" oraz \"Do\" muszą się różnić." -#: apps/dca/forms.py:304 +#: apps/dca/forms.py:299 #, python-format msgid "DCA for %(strategy_name)s" msgstr "" @@ -816,8 +927,8 @@ msgstr "Waluta dla płatności" #: apps/dca/models.py:26 apps/dca/models.py:181 apps/rules/forms.py:180 #: apps/rules/forms.py:196 apps/rules/models.py:43 apps/rules/models.py:295 #: apps/transactions/forms.py:504 apps/transactions/forms.py:651 -#: apps/transactions/models.py:353 apps/transactions/models.py:752 -#: apps/transactions/models.py:1009 apps/transactions/models.py:1302 +#: apps/transactions/models.py:353 apps/transactions/models.py:776 +#: apps/transactions/models.py:1033 apps/transactions/models.py:1326 msgid "Notes" msgstr "Notatki" @@ -825,7 +936,7 @@ msgstr "Notatki" msgid "DCA Strategy" msgstr "" -#: apps/dca/models.py:35 apps/export_app/forms.py:150 +#: apps/dca/models.py:35 apps/export_app/forms.py:152 msgid "DCA Strategies" msgstr "" @@ -845,7 +956,7 @@ msgstr "" msgid "DCA Entry" msgstr "" -#: apps/dca/models.py:187 apps/export_app/forms.py:151 +#: apps/dca/models.py:187 apps/export_app/forms.py:153 msgid "DCA Entries" msgstr "" @@ -873,25 +984,25 @@ msgstr "Pomyślnie zaktualizowano wpis" msgid "Entry deleted successfully" msgstr "Pomyślnie usunięto wpis" -#: apps/export_app/forms.py:13 apps/export_app/forms.py:128 -#: templates/includes/sidebar.html:227 templates/users/fragments/list.html:10 +#: apps/export_app/forms.py:13 apps/export_app/forms.py:129 +#: templates/includes/sidebar.html:241 templates/users/fragments/list.html:10 #: templates/users/pages/index.html:4 msgid "Users" msgstr "Użytkownicy" -#: apps/export_app/forms.py:31 apps/export_app/forms.py:134 +#: apps/export_app/forms.py:31 apps/export_app/forms.py:136 #: apps/transactions/models.py:422 templates/includes/sidebar.html:81 -#: templates/includes/sidebar.html:148 +#: templates/includes/sidebar.html:154 #: templates/recurring_transactions/fragments/list_transactions.html:5 #: templates/recurring_transactions/fragments/table.html:37 #: templates/transactions/pages/transactions.html:5 msgid "Transactions" msgstr "Transakcje" -#: apps/export_app/forms.py:37 apps/export_app/forms.py:131 +#: apps/export_app/forms.py:37 apps/export_app/forms.py:133 #: apps/insights/views.py:43 apps/transactions/filters.py:74 #: templates/categories/fragments/list.html:9 -#: templates/categories/pages/index.html:4 templates/includes/sidebar.html:150 +#: templates/categories/pages/index.html:4 templates/includes/sidebar.html:156 #: templates/insights/fragments/month_by_month.html:18 #: templates/insights/fragments/month_by_month.html:21 #: templates/insights/fragments/year_by_year.html:13 @@ -899,16 +1010,16 @@ msgstr "Transakcje" msgid "Categories" msgstr "Kategorie" -#: apps/export_app/forms.py:49 apps/export_app/forms.py:133 +#: apps/export_app/forms.py:49 apps/export_app/forms.py:135 #: apps/insights/views.py:57 apps/rules/forms.py:185 apps/rules/forms.py:195 #: apps/rules/models.py:46 apps/rules/models.py:307 #: apps/transactions/filters.py:84 apps/transactions/forms.py:78 #: apps/transactions/forms.py:346 apps/transactions/forms.py:526 #: apps/transactions/forms.py:831 apps/transactions/forms.py:1083 #: apps/transactions/models.py:312 apps/transactions/models.py:368 -#: apps/transactions/models.py:748 apps/transactions/models.py:1006 -#: apps/transactions/models.py:1317 templates/entities/fragments/list.html:9 -#: templates/entities/pages/index.html:4 templates/includes/sidebar.html:162 +#: apps/transactions/models.py:772 apps/transactions/models.py:1030 +#: apps/transactions/models.py:1341 templates/entities/fragments/list.html:9 +#: templates/entities/pages/index.html:4 templates/includes/sidebar.html:168 #: templates/insights/fragments/month_by_month.html:40 #: templates/insights/fragments/month_by_month.html:43 #: templates/insights/fragments/year_by_year.html:35 @@ -916,28 +1027,28 @@ msgstr "Kategorie" msgid "Entities" msgstr "Encje" -#: apps/export_app/forms.py:55 apps/export_app/forms.py:137 -#: apps/transactions/models.py:1046 templates/includes/sidebar.html:110 +#: apps/export_app/forms.py:55 apps/export_app/forms.py:139 +#: apps/transactions/models.py:1070 templates/includes/sidebar.html:110 #: templates/recurring_transactions/fragments/list.html:9 #: templates/recurring_transactions/pages/index.html:4 msgid "Recurring Transactions" msgstr "Zlecenia stałe" -#: apps/export_app/forms.py:61 apps/export_app/forms.py:135 -#: apps/transactions/models.py:766 templates/includes/sidebar.html:104 +#: apps/export_app/forms.py:61 apps/export_app/forms.py:137 +#: apps/transactions/models.py:790 templates/includes/sidebar.html:104 #: templates/installment_plans/fragments/list.html:9 #: templates/installment_plans/pages/index.html:4 msgid "Installment Plans" msgstr "Plany ratalne" -#: apps/export_app/forms.py:73 apps/export_app/forms.py:140 +#: apps/export_app/forms.py:73 apps/export_app/forms.py:142 #: templates/exchange_rates_services/fragments/list.html:10 #: templates/exchange_rates_services/pages/index.html:4 -#: templates/includes/sidebar.html:218 +#: templates/includes/sidebar.html:232 msgid "Automatic Exchange Rates" msgstr "Automatyczne kursy walut" -#: apps/export_app/forms.py:79 templates/includes/sidebar.html:198 +#: apps/export_app/forms.py:79 templates/includes/sidebar.html:212 #: templates/rules/fragments/list.html:9 templates/rules/pages/index.html:4 msgid "Rules" msgstr "Reguły" @@ -946,57 +1057,57 @@ msgstr "Reguły" msgid "DCA" msgstr "DCA" -#: apps/export_app/forms.py:91 apps/export_app/forms.py:152 +#: apps/export_app/forms.py:91 apps/export_app/forms.py:154 #: templates/import_app/fragments/profiles/list.html:21 #: templates/import_app/pages/profiles_index.html:4 msgid "Import Profiles" msgstr "Profile importu" -#: apps/export_app/forms.py:117 templates/export_app/fragments/export.html:5 +#: apps/export_app/forms.py:118 templates/export_app/fragments/export.html:5 #: templates/export_app/pages/index.html:13 msgid "Export" msgstr "Eksport" -#: apps/export_app/forms.py:125 +#: apps/export_app/forms.py:126 msgid "Import a ZIP file exported from WYGIWYH" msgstr "Zaimportuj plik ZIP wyeksportowany z instancji WYGIWYH" -#: apps/export_app/forms.py:126 +#: apps/export_app/forms.py:127 msgid "ZIP File" msgstr "Plik ZIP" -#: apps/export_app/forms.py:143 apps/rules/models.py:27 +#: apps/export_app/forms.py:145 apps/rules/models.py:27 msgid "Transaction rules" msgstr "Reguły transakcji" -#: apps/export_app/forms.py:145 apps/rules/models.py:68 +#: apps/export_app/forms.py:147 apps/rules/models.py:68 msgid "Edit transaction action" msgstr "Edycja akcji transakcyjnych" -#: apps/export_app/forms.py:148 apps/rules/models.py:327 +#: apps/export_app/forms.py:150 apps/rules/models.py:327 msgid "Update or create transaction actions" msgstr "Stwórz lub uaktualnij akcje transakcyjne" -#: apps/export_app/forms.py:181 templates/cotton/transaction/item.html:341 +#: apps/export_app/forms.py:183 templates/cotton/transaction/item.html:341 #: templates/cotton/ui/deleted_transactions_action_bar.html:53 #: templates/export_app/fragments/restore.html:5 #: templates/export_app/pages/index.html:19 msgid "Restore" msgstr "Importuj" -#: apps/export_app/forms.py:191 +#: apps/export_app/forms.py:193 msgid "Please upload either a ZIP file or at least one CSV file" msgstr "Wrzuć plik ZIP lub przynajmniej jeden plik CSV" -#: apps/export_app/views.py:177 +#: apps/export_app/views.py:183 msgid "You have to select at least one export" msgstr "Musisz wybrać co najmniej jedną opcję do eksportu" -#: apps/export_app/views.py:197 +#: apps/export_app/views.py:203 msgid "Data restored successfully" msgstr "Pomyślnie odtworzono dane" -#: apps/export_app/views.py:209 +#: apps/export_app/views.py:215 msgid "" "There was an error restoring your data. Check the logs for more details." msgstr "" @@ -1009,7 +1120,7 @@ msgstr "Wybierz plik" #: apps/import_app/forms.py:55 #: templates/import_app/fragments/profiles/list.html:75 -#: templates/includes/sidebar.html:204 +#: templates/includes/sidebar.html:218 msgid "Import" msgstr "Import" @@ -1057,23 +1168,23 @@ msgstr "Status" msgid "File name" msgstr "Nazwa pliku" -#: apps/import_app/views.py:75 +#: apps/import_app/views.py:76 msgid "Import Profile added successfully" msgstr "Profil importu dodany pomyślnie" -#: apps/import_app/views.py:113 +#: apps/import_app/views.py:114 msgid "Import Profile update successfully" msgstr "Profil importu zaktualizowany pomyślnie" -#: apps/import_app/views.py:142 +#: apps/import_app/views.py:143 msgid "Import Profile deleted successfully" msgstr "Usunięto profil importu" -#: apps/import_app/views.py:222 +#: apps/import_app/views.py:223 msgid "Import Run queued successfully" msgstr "Import dodany do kolejki" -#: apps/import_app/views.py:249 +#: apps/import_app/views.py:250 msgid "Run deleted successfully" msgstr "Usunięto import" @@ -1142,7 +1253,7 @@ msgstr "Tag" msgid "Untagged" msgstr "Nieotagowane" -#: apps/insights/views.py:53 apps/transactions/models.py:589 +#: apps/insights/views.py:53 apps/transactions/models.py:613 #: templates/tags/fragments/table.html:69 msgid "No tags" msgstr "Brak tagów" @@ -1163,6 +1274,32 @@ msgstr "Brak encji" msgid "No entities" msgstr "" +#: apps/investments/views.py:104 +msgid "Other" +msgstr "" + +#: apps/investments/views.py:135 +msgid "Unclassified" +msgstr "" + +#: apps/investments/views.py:267 +#, fuzzy +#| msgid "Installment Plan added successfully" +msgid "Asset class added successfully" +msgstr "Dodano plan ratalny" + +#: apps/investments/views.py:275 +#, fuzzy +#| msgid "Tag updated successfully" +msgid "Asset class updated successfully" +msgstr "Zaktualizowano tag" + +#: apps/investments/views.py:296 +#, fuzzy +#| msgid "Tag deleted successfully" +msgid "Asset class deleted successfully" +msgstr "Usunięto tag" + #: apps/net_worth/views.py:137 templates/net_worth/net_worth.html:84 #: templates/net_worth/net_worth.html:85 msgid "Consolidated" @@ -1216,8 +1353,8 @@ msgstr "Operator" #: apps/rules/forms.py:174 apps/rules/forms.py:188 apps/rules/models.py:36 #: apps/rules/models.py:271 apps/transactions/forms.py:468 -#: apps/transactions/models.py:336 apps/transactions/models.py:708 -#: apps/transactions/models.py:987 apps/transactions/models.py:1287 +#: apps/transactions/models.py:336 apps/transactions/models.py:732 +#: apps/transactions/models.py:1011 apps/transactions/models.py:1311 #: templates/mini_tools/simulator.html:63 msgid "Type" msgstr "Typ" @@ -1225,7 +1362,7 @@ msgstr "Typ" #: apps/rules/forms.py:175 apps/rules/forms.py:189 apps/rules/models.py:37 #: apps/rules/models.py:275 apps/transactions/filters.py:22 #: apps/transactions/forms.py:472 apps/transactions/models.py:338 -#: apps/transactions/models.py:1289 templates/cotton/transaction/item.html:23 +#: apps/transactions/models.py:1313 templates/cotton/transaction/item.html:23 #: templates/cotton/transaction/item.html:34 #: templates/transactions/widgets/paid_toggle_button.html:10 #: templates/transactions/widgets/unselectable_paid_toggle_button.html:13 @@ -1236,14 +1373,14 @@ msgstr "Zapłacono" #: apps/rules/models.py:283 apps/transactions/forms.py:90 #: apps/transactions/forms.py:488 apps/transactions/forms.py:638 #: apps/transactions/forms.py:837 apps/transactions/models.py:340 -#: apps/transactions/models.py:726 apps/transactions/models.py:1011 +#: apps/transactions/models.py:750 apps/transactions/models.py:1035 msgid "Reference Date" msgstr "Data referencyjna" #: apps/rules/forms.py:178 apps/rules/forms.py:192 apps/rules/models.py:41 #: apps/rules/models.py:287 apps/transactions/forms.py:495 -#: apps/transactions/models.py:346 apps/transactions/models.py:992 -#: apps/transactions/models.py:1295 +#: apps/transactions/models.py:346 apps/transactions/models.py:1016 +#: apps/transactions/models.py:1319 #: templates/insights/fragments/sankey.html:102 #: templates/installment_plans/fragments/table.html:18 #: templates/mini_tools/simulator.html:70 @@ -1255,27 +1392,27 @@ msgstr "Kwota" #: apps/rules/forms.py:179 apps/rules/forms.py:193 apps/rules/models.py:14 #: apps/rules/models.py:42 apps/rules/models.py:291 #: apps/transactions/forms.py:499 apps/transactions/forms.py:642 -#: apps/transactions/models.py:351 apps/transactions/models.py:710 -#: apps/transactions/models.py:995 apps/transactions/models.py:1300 +#: apps/transactions/models.py:351 apps/transactions/models.py:734 +#: apps/transactions/models.py:1019 apps/transactions/models.py:1324 msgid "Description" msgstr "Opis" #: apps/rules/forms.py:182 apps/rules/forms.py:198 apps/rules/models.py:47 #: apps/rules/models.py:299 apps/transactions/models.py:398 -#: apps/transactions/models.py:1322 +#: apps/transactions/models.py:1346 msgid "Internal Note" msgstr "Notatka" #: apps/rules/forms.py:183 apps/rules/forms.py:199 apps/rules/models.py:48 #: apps/rules/models.py:303 apps/transactions/models.py:400 -#: apps/transactions/models.py:1324 +#: apps/transactions/models.py:1348 msgid "Internal ID" msgstr "Wewnętrzne ID" #: apps/rules/forms.py:186 apps/rules/forms.py:200 apps/rules/models.py:40 #: apps/rules/models.py:319 apps/transactions/forms.py:655 #: apps/transactions/models.py:250 apps/transactions/models.py:341 -#: apps/transactions/models.py:1290 +#: apps/transactions/models.py:1314 msgid "Mute" msgstr "Wycisz" @@ -1288,7 +1425,7 @@ msgid "Set Values" msgstr "Ustaw wartości" #: apps/rules/forms.py:407 apps/rules/forms.py:442 apps/rules/forms.py:477 -#: apps/transactions/models.py:421 apps/transactions/models.py:645 +#: apps/transactions/models.py:421 apps/transactions/models.py:669 msgid "Transaction" msgstr "Transakcja" @@ -1324,7 +1461,12 @@ msgstr "Reguła" msgid "Field" msgstr "Pole" -#: apps/rules/models.py:61 +#: apps/rules/models.py:61 templates/investments/fragments/charts.html:86 +#: templates/investments/fragments/detail.html:103 +#: templates/investments/fragments/detail.html:162 +#: templates/investments/fragments/list.html:48 +#: templates/investments/fragments/list.html:49 +#: templates/investments/fragments/rebalance.html:23 msgid "Value" msgstr "Wartość" @@ -1510,7 +1652,7 @@ msgid "Upload" msgstr "Odśwież" #: apps/transactions/forms.py:355 apps/transactions/forms.py:1089 -#: apps/transactions/models.py:925 +#: apps/transactions/models.py:949 #, fuzzy #| msgid "To Account" msgid "To account" @@ -1528,8 +1670,8 @@ msgstr "Od kwoty" msgid "To Amount" msgstr "Do kwoty" -#: apps/transactions/forms.py:718 apps/transactions/models.py:970 -#: apps/transactions/models.py:1267 templates/cotton/transaction/item.html:52 +#: apps/transactions/forms.py:718 apps/transactions/models.py:994 +#: apps/transactions/models.py:1291 templates/cotton/transaction/item.html:52 #: templates/cotton/transaction/item.html:60 #: templates/cotton/ui/quick_transactions_buttons.html:40 #: templates/cotton/ui/transactions_fab.html:44 @@ -1595,8 +1737,8 @@ msgstr "" "Zdezaktywowanych encji nie da się wykorzystać przy tworzeniu nowych " "transakcji" -#: apps/transactions/models.py:323 apps/transactions/models.py:969 -#: apps/transactions/models.py:1266 +#: apps/transactions/models.py:323 apps/transactions/models.py:993 +#: apps/transactions/models.py:1290 #: templates/calendar_view/fragments/list.html:42 #: templates/calendar_view/fragments/list.html:44 #: templates/calendar_view/fragments/list.html:52 @@ -1604,13 +1746,19 @@ msgstr "" #: templates/cotton/ui/quick_transactions_buttons.html:10 #: templates/cotton/ui/transactions_fab.html:10 #: templates/insights/fragments/overview/_results.html:12 +#: templates/investments/fragments/_totals.html:21 +#: templates/investments/fragments/_totals.html:42 +#: templates/investments/fragments/detail.html:106 +#: templates/investments/fragments/detail.html:210 +#: templates/investments/fragments/detail.html:252 +#: templates/investments/fragments/detail.html:320 #: templates/mini_tools/simulator.html:66 #: templates/monthly_overview/fragments/monthly_summary.html:42 msgid "Income" msgstr "Przychody" -#: apps/transactions/models.py:324 apps/transactions/models.py:971 -#: apps/transactions/models.py:1268 +#: apps/transactions/models.py:324 apps/transactions/models.py:995 +#: apps/transactions/models.py:1292 #: templates/calendar_view/fragments/list.html:46 #: templates/calendar_view/fragments/list.html:48 #: templates/calendar_view/fragments/list.html:56 @@ -1622,14 +1770,14 @@ msgstr "Przychody" msgid "Expense" msgstr "Wydatek" -#: apps/transactions/models.py:379 apps/transactions/models.py:765 +#: apps/transactions/models.py:379 apps/transactions/models.py:789 #: templates/cotton/transaction/item.html:291 #: templates/cotton/transaction/item.html:308 #: templates/cotton/transaction/item.html:318 msgid "Installment Plan" msgstr "" -#: apps/transactions/models.py:388 apps/transactions/models.py:1045 +#: apps/transactions/models.py:388 apps/transactions/models.py:1069 #: templates/cotton/transaction/item.html:281 #: templates/cotton/transaction/item.html:301 msgid "Recurring Transaction" @@ -1649,156 +1797,168 @@ msgstr "Usunięte" msgid "Deleted At" msgstr "" -#: apps/transactions/models.py:591 +#: apps/transactions/models.py:517 +msgid "A transfer needs one expense and one income." +msgstr "" + +#: apps/transactions/models.py:519 +msgid "A transfer needs two different accounts." +msgstr "" + +#: apps/transactions/models.py:523 +msgid "This transaction is already part of another transfer." +msgstr "" + +#: apps/transactions/models.py:615 msgid "No category" msgstr "Brak kategorii" -#: apps/transactions/models.py:593 +#: apps/transactions/models.py:617 msgid "No description" msgstr "Brak opisu" -#: apps/transactions/models.py:650 +#: apps/transactions/models.py:674 #, fuzzy #| msgid "ZIP File" msgid "File" msgstr "Plik ZIP" -#: apps/transactions/models.py:652 +#: apps/transactions/models.py:676 msgid "Original Name" msgstr "" -#: apps/transactions/models.py:654 +#: apps/transactions/models.py:678 #, fuzzy #| msgid "Content" msgid "Content Type" msgstr "Zawartość" -#: apps/transactions/models.py:656 +#: apps/transactions/models.py:680 msgid "Size" msgstr "" -#: apps/transactions/models.py:661 +#: apps/transactions/models.py:685 msgid "Uploaded By" msgstr "" -#: apps/transactions/models.py:666 +#: apps/transactions/models.py:690 msgid "Transaction Attachment" msgstr "Załącznik transakcji" -#: apps/transactions/models.py:667 +#: apps/transactions/models.py:691 msgid "Transaction Attachments" msgstr "Załączniki transakcji" -#: apps/transactions/models.py:697 templates/includes/sidebar.html:57 +#: apps/transactions/models.py:721 templates/includes/sidebar.html:57 msgid "Yearly" msgstr "Rocznie" -#: apps/transactions/models.py:698 apps/users/models.py:469 +#: apps/transactions/models.py:722 apps/users/models.py:469 #: templates/includes/sidebar.html:51 msgid "Monthly" msgstr "Miesięcznie" -#: apps/transactions/models.py:699 +#: apps/transactions/models.py:723 msgid "Weekly" msgstr "Tygodniowo" -#: apps/transactions/models.py:700 +#: apps/transactions/models.py:724 msgid "Daily" msgstr "Dziennie" -#: apps/transactions/models.py:713 +#: apps/transactions/models.py:737 msgid "Number of Installments" msgstr "" -#: apps/transactions/models.py:718 +#: apps/transactions/models.py:742 msgid "Installment Start" msgstr "" -#: apps/transactions/models.py:719 +#: apps/transactions/models.py:743 msgid "The installment number to start counting from" msgstr "" -#: apps/transactions/models.py:724 apps/transactions/models.py:1015 +#: apps/transactions/models.py:748 apps/transactions/models.py:1039 msgid "Start Date" msgstr "Data startowa" -#: apps/transactions/models.py:728 apps/transactions/models.py:1016 +#: apps/transactions/models.py:752 apps/transactions/models.py:1040 msgid "End Date" msgstr "Data końcowa" -#: apps/transactions/models.py:733 +#: apps/transactions/models.py:757 msgid "Recurrence" msgstr "Zlecenie stałe" -#: apps/transactions/models.py:736 +#: apps/transactions/models.py:760 msgid "Installment Amount" msgstr "" -#: apps/transactions/models.py:755 apps/transactions/models.py:1035 +#: apps/transactions/models.py:779 apps/transactions/models.py:1059 msgid "Add description to transactions" msgstr "Dodaj opis dla transakcji" -#: apps/transactions/models.py:758 apps/transactions/models.py:1038 +#: apps/transactions/models.py:782 apps/transactions/models.py:1062 msgid "Add notes to transactions" msgstr "Dodaj notatki do transakcji" -#: apps/transactions/models.py:932 +#: apps/transactions/models.py:956 #, fuzzy #| msgid "To Amount" msgid "To amount" msgstr "Do kwoty" -#: apps/transactions/models.py:933 +#: apps/transactions/models.py:957 msgid "Leave empty to use the same amount" msgstr "" -#: apps/transactions/models.py:974 templates/mini_tools/simulator.html:129 +#: apps/transactions/models.py:998 templates/mini_tools/simulator.html:129 msgid "day(s)" msgstr "dni" -#: apps/transactions/models.py:975 templates/mini_tools/simulator.html:130 +#: apps/transactions/models.py:999 templates/mini_tools/simulator.html:130 msgid "week(s)" msgstr "tygodni" -#: apps/transactions/models.py:976 templates/mini_tools/simulator.html:131 +#: apps/transactions/models.py:1000 templates/mini_tools/simulator.html:131 msgid "month(s)" msgstr "miesięcy" -#: apps/transactions/models.py:977 templates/mini_tools/simulator.html:132 +#: apps/transactions/models.py:1001 templates/mini_tools/simulator.html:132 msgid "year(s)" msgstr "lat" -#: apps/transactions/models.py:979 +#: apps/transactions/models.py:1003 #: templates/recurring_transactions/fragments/list.html:18 msgid "Paused" msgstr "Zapauzowane" -#: apps/transactions/models.py:1018 +#: apps/transactions/models.py:1042 msgid "Recurrence Type" msgstr "Typ powtarzania" -#: apps/transactions/models.py:1021 +#: apps/transactions/models.py:1045 msgid "Recurrence Interval" msgstr "Co ile powtarzać" -#: apps/transactions/models.py:1024 +#: apps/transactions/models.py:1048 msgid "Keep at most" msgstr "Zachowuj co najwyżej" -#: apps/transactions/models.py:1028 +#: apps/transactions/models.py:1052 msgid "Last Generated Date" msgstr "" -#: apps/transactions/models.py:1031 +#: apps/transactions/models.py:1055 msgid "Last Generated Reference Date" msgstr "" -#: apps/transactions/models.py:1334 templates/cotton/transaction/item.html:274 +#: apps/transactions/models.py:1358 templates/cotton/transaction/item.html:274 #: templates/cotton/ui/transactions_fab.html:59 msgid "Quick Transaction" msgstr "Szablon transakcji" -#: apps/transactions/models.py:1335 templates/includes/sidebar.html:98 +#: apps/transactions/models.py:1359 templates/includes/sidebar.html:98 #: templates/quick_transactions/pages/index.html:5 #: templates/quick_transactions/pages/index.html:15 msgid "Quick Transactions" @@ -1932,8 +2092,8 @@ msgid "Item deleted successfully" msgstr "Pomyślnie usunięto" #: apps/transactions/views/quick_transactions.py:164 -#: apps/transactions/views/transactions.py:143 -#: apps/transactions/views/transactions.py:328 +#: apps/transactions/views/transactions.py:156 +#: apps/transactions/views/transactions.py:343 msgid "Transaction added successfully" msgstr "Dodano transakcję" @@ -1969,19 +2129,19 @@ msgstr "Zaktualizowano tag" msgid "Tag deleted successfully" msgstr "Usunięto tag" -#: apps/transactions/views/transactions.py:61 +#: apps/transactions/views/transactions.py:65 msgid "Attachment uploaded successfully" msgstr "Pomyślnie dodano załącznik" -#: apps/transactions/views/transactions.py:112 +#: apps/transactions/views/transactions.py:116 msgid "Attachment deleted successfully" msgstr "Usunięto załącznik" -#: apps/transactions/views/transactions.py:355 +#: apps/transactions/views/transactions.py:370 msgid "Transaction updated successfully" msgstr "Zaktualizowano transakcję" -#: apps/transactions/views/transactions.py:406 +#: apps/transactions/views/transactions.py:421 #, python-format msgid "%(count)s transaction updated successfully" msgid_plural "%(count)s transactions updated successfully" @@ -1989,23 +2149,23 @@ msgstr[0] "Zaktualizowano %(count)s transakcję" msgstr[1] "Zaktualizowano %(count)s transakcje" msgstr[2] "Zaktualizowano %(count)s transakcji" -#: apps/transactions/views/transactions.py:433 +#: apps/transactions/views/transactions.py:448 msgid "Transaction duplicated successfully" msgstr "Zduplikowano transakcję" -#: apps/transactions/views/transactions.py:478 +#: apps/transactions/views/transactions.py:493 msgid "Transaction deleted successfully" msgstr "Usunięto transakcję" -#: apps/transactions/views/transactions.py:508 +#: apps/transactions/views/transactions.py:523 msgid "Transaction restored successfully" msgstr "Odtworzono transakcję" -#: apps/transactions/views/transactions.py:534 +#: apps/transactions/views/transactions.py:549 msgid "Transfer added successfully" msgstr "Dodano przelew" -#: apps/transactions/views/transactions.py:562 +#: apps/transactions/views/transactions.py:579 #, fuzzy #| msgid "Transfer added successfully" msgid "Transfer updated successfully" @@ -2337,6 +2497,7 @@ msgstr "Edytuj grupę kont" #: templates/exchange_rates_services/fragments/table.html:20 #: templates/import_app/fragments/profiles/list.html:42 #: templates/installment_plans/fragments/table.html:25 +#: templates/investment_classes/fragments/list.html:36 #: templates/quick_transactions/fragments/list.html:22 #: templates/recurring_transactions/fragments/table.html:27 #: templates/rules/fragments/list.html:31 @@ -2359,6 +2520,9 @@ msgstr "Akcje" #: templates/exchange_rates_services/fragments/table.html:23 #: templates/import_app/fragments/profiles/list.html:46 #: templates/installment_plans/fragments/table.html:28 +#: templates/investment_classes/fragments/list.html:39 +#: templates/investments/fragments/detail.html:305 +#: templates/investments/fragments/detail.html:331 #: templates/quick_transactions/fragments/list.html:25 #: templates/recurring_transactions/fragments/table.html:30 #: templates/rules/fragments/transaction_rule/view.html:22 @@ -2408,6 +2572,7 @@ msgstr "Udostępnij" #: templates/import_app/fragments/profiles/list.html:81 #: templates/import_app/fragments/runs/list.html:100 #: templates/installment_plans/fragments/table.html:54 +#: templates/investment_classes/fragments/list.html:45 #: templates/mini_tools/simulator.html:109 #: templates/mini_tools/unit_price_calculator.html:16 #: templates/quick_transactions/fragments/list.html:32 @@ -2441,6 +2606,7 @@ msgstr "Usuń" #: templates/import_app/fragments/runs/list.html:104 #: templates/installment_plans/fragments/table.html:47 #: templates/installment_plans/fragments/table.html:58 +#: templates/investment_classes/fragments/list.html:49 #: templates/quick_transactions/fragments/list.html:37 #: templates/recurring_transactions/fragments/table.html:52 #: templates/recurring_transactions/fragments/table.html:65 @@ -2490,6 +2656,7 @@ msgstr "Tej akcji nie da się odwrócić!" #: templates/import_app/fragments/profiles/list.html:87 #: templates/import_app/fragments/runs/list.html:106 #: templates/installment_plans/fragments/table.html:60 +#: templates/investment_classes/fragments/list.html:51 #: templates/quick_transactions/fragments/list.html:39 #: templates/recurring_transactions/fragments/table.html:94 #: templates/rules/fragments/list.html:62 @@ -2538,6 +2705,7 @@ msgid "Add account" msgstr "Dodaj konto" #: templates/accounts/fragments/edit.html:5 +#: templates/investments/fragments/detail.html:40 msgid "Edit account" msgstr "Edytuj konto" @@ -2610,7 +2778,7 @@ msgstr "Edytuj kategorię" msgid "Pick a month" msgstr "Wybierz miesiąc" -#: templates/common/fragments/toasts.html:20 +#: templates/common/fragments/toasts.html:18 #: templates/cotton/components/fab.html:9 #: templates/cotton/components/sidebar_collapsible_panel.html:28 #: templates/extends/offcanvas.html:4 templates/includes/sidebar.html:44 @@ -2841,6 +3009,8 @@ msgid "Add DCA strategy" msgstr "" #: templates/dca/fragments/strategy/details.html:28 +#: templates/investments/fragments/detail.html:92 +#: templates/investments/fragments/list.html:65 msgid "No exchange rate available" msgstr "" @@ -3023,7 +3193,7 @@ msgstr "konta" msgid "No services configured" msgstr "Nie zdefiniowano serwisów" -#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:211 +#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:225 msgid "Export and Restore" msgstr "Eksport i przywracanie" @@ -3197,54 +3367,65 @@ msgstr "" msgid "Tools" msgstr "Narzędzia" -#: templates/includes/sidebar.html:118 +#: templates/includes/sidebar.html:118 templates/includes/sidebar.html:188 +#: templates/investment_classes/fragments/list.html:29 +#: templates/investments/fragments/detail.html:37 +#: templates/investments/fragments/list.html:9 +#: templates/investments/pages/detail.html:4 +#: templates/investments/pages/index.html:4 +#, fuzzy +#| msgid "Installment Plans" +msgid "Investments" +msgstr "Plany ratalne" + +#: templates/includes/sidebar.html:124 msgid "Dollar Cost Average Tracker" msgstr "" -#: templates/includes/sidebar.html:124 +#: templates/includes/sidebar.html:130 #: templates/mini_tools/unit_price_calculator.html:4 #: templates/mini_tools/unit_price_calculator.html:9 msgid "Unit Price Calculator" msgstr "Kalkulator ceny jednostkowej" -#: templates/includes/sidebar.html:130 +#: templates/includes/sidebar.html:136 #: templates/mini_tools/currency_converter/currency_converter.html:7 #: templates/mini_tools/currency_converter/currency_converter.html:14 msgid "Currency Converter" msgstr "Kalkulator walut" -#: templates/includes/sidebar.html:136 templates/mini_tools/simulator.html:5 +#: templates/includes/sidebar.html:142 templates/mini_tools/simulator.html:5 #: templates/mini_tools/simulator.html:21 #, fuzzy #| msgid "Calculator" msgid "Simulator" msgstr "Kalkulator" -#: templates/includes/sidebar.html:145 +#: templates/includes/sidebar.html:151 msgid "Management" msgstr "Zarządzanie" -#: templates/includes/sidebar.html:196 +#: templates/includes/sidebar.html:210 msgid "Automation" msgstr "" -#: templates/includes/sidebar.html:225 +#: templates/includes/sidebar.html:239 msgid "Admin" msgstr "" -#: templates/includes/sidebar.html:233 +#: templates/includes/sidebar.html:247 msgid "Django Admin" msgstr "" -#: templates/includes/sidebar.html:234 +#: templates/includes/sidebar.html:248 msgid "Only use this if you know what you're doing" msgstr "" -#: templates/includes/sidebar.html:249 +#: templates/includes/sidebar.html:263 msgid "is available" msgstr "" -#: templates/includes/sidebar.html:257 +#: templates/includes/sidebar.html:271 msgid "Calculator" msgstr "Kalkulator" @@ -3311,6 +3492,7 @@ msgstr "Brak ostatnich transakcji" #: templates/insights/fragments/overview/_results.html:173 #: templates/insights/fragments/year_by_year.html:59 #: templates/insights/fragments/year_by_year.html:140 +#: templates/investment_classes/fragments/list.html:65 #: templates/monthly_overview/fragments/monthly_summary.html:168 msgid "Total" msgstr "Łącznie" @@ -3508,6 +3690,295 @@ msgstr "" msgid "No installment plans" msgstr "Brak planów ratalnych" +#: templates/investment_classes/fragments/form.html:5 +msgid "Edit asset class" +msgstr "" + +#: templates/investment_classes/fragments/form.html:5 +msgid "Add asset class" +msgstr "" + +#: templates/investment_classes/fragments/list.html:19 +#: templates/investments/fragments/rebalance.html:10 +#, python-format +msgid "Targets add up to %(total)s%%. Set them to 100%% to see the rebalance." +msgstr "" + +#: templates/investment_classes/fragments/list.html:28 +#: templates/investments/fragments/rebalance.html:25 +msgid "Target" +msgstr "" + +#: templates/investment_classes/fragments/list.html:50 +msgid "Its investments will be left without an asset class." +msgstr "" + +#: templates/investment_classes/fragments/list.html:74 +msgid "No asset classes" +msgstr "" + +#: templates/investments/fragments/_allocation.html:3 +msgid "Allocation chart" +msgstr "" + +#: templates/investments/fragments/_allocation.html:11 +#, python-format +msgid "" +"%(share)s%% of your investments with a target, against a target of " +"%(target)s%%" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "Annual return, weighted by when money went in and out" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "" +"Return since the first contribution, weighted by when money went in and out" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "/yr" +msgstr "" + +#: templates/investments/fragments/_totals.html:15 +#: templates/investments/fragments/detail.html:63 +msgid "total return" +msgstr "" + +#: templates/investments/fragments/_totals.html:20 +#: templates/investments/fragments/_totals.html:41 +#: templates/investments/fragments/charts.html:96 +#: templates/investments/fragments/detail.html:163 +#: templates/investments/fragments/detail.html:223 +#: templates/investments/fragments/list.html:47 +#, fuzzy +#| msgid "Invert" +msgid "Invested" +msgstr "Zamień" + +#: templates/investments/fragments/_totals.html:24 +#: templates/investments/fragments/detail.html:74 +msgid "" +"How much of the return came from exchange rates moving since you invested" +msgstr "" + +#: templates/investments/fragments/_totals.html:24 +#: templates/investments/fragments/_totals.html:26 +#, fuzzy +#| msgid "Currency Code" +msgid "Currency effect" +msgstr "Symbol waluty" + +#: templates/investments/fragments/_totals.html:26 +#: templates/investments/fragments/detail.html:78 +msgid "" +"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." +msgstr "" + +#: templates/investments/fragments/_totals.html:40 +#: templates/investments/fragments/list.html:49 +msgid "Return" +msgstr "" + +#: templates/investments/fragments/charts.html:10 +msgid "By asset class" +msgstr "" + +#: templates/investments/fragments/charts.html:17 +#: templates/net_worth/net_worth.html:52 +#: templates/yearly_overview/pages/overview_by_currency.html:6 +msgid "By currency" +msgstr "Według walut" + +#: templates/investments/fragments/charts.html:24 +msgid "Value and invested" +msgstr "" + +#: templates/investments/fragments/charts.html:31 +msgid "Return by investment" +msgstr "" + +#: templates/investments/fragments/charts.html:38 +msgid "Nothing to chart yet" +msgstr "" + +#: templates/investments/fragments/detail.html:10 +msgid "Buy / Contribute" +msgstr "" + +#: templates/investments/fragments/detail.html:16 +msgid "Sell / Withdraw" +msgstr "" + +#: templates/investments/fragments/detail.html:23 +msgid "Dividend / Income" +msgstr "" + +#: templates/investments/fragments/detail.html:30 +#, fuzzy +#| msgid "Update" +msgid "Update value" +msgstr "Aktualizuj" + +#: templates/investments/fragments/detail.html:47 +#: templates/investments/fragments/list.html:67 +msgid "Tracked by value" +msgstr "" + +#: templates/investments/fragments/detail.html:48 +msgid "" +"Some incoming units have no linked payment, so their cost was estimated from " +"the market price of that day" +msgstr "" + +#: templates/investments/fragments/detail.html:68 +#, fuzzy +#| msgid "Info" +msgid "In" +msgstr "Informacja" + +#: templates/investments/fragments/detail.html:74 +#: templates/investments/fragments/detail.html:78 +#, fuzzy +#| msgid "Currency Code" +msgid "currency effect" +msgstr "Symbol waluty" + +#: templates/investments/fragments/detail.html:104 +#: templates/investments/fragments/detail.html:179 +msgid "Price" +msgstr "" + +#: templates/investments/fragments/detail.html:105 +#: templates/investments/fragments/detail.html:200 +#: templates/investments/fragments/detail.html:239 +#: templates/investments/fragments/detail.html:290 +#, fuzzy +#| msgid "Warning" +msgid "Earnings" +msgstr "Ostrzeżenie" + +#: templates/investments/fragments/detail.html:180 +#: templates/investments/fragments/detail.html:227 +msgid "Average cost" +msgstr "" + +#: templates/investments/fragments/detail.html:181 +#: templates/investments/fragments/rebalance.html:41 +msgid "Buy" +msgstr "" + +#: templates/investments/fragments/detail.html:182 +#: templates/investments/fragments/rebalance.html:43 +msgid "Sell" +msgstr "" + +#: templates/investments/fragments/detail.html:227 +#: templates/investments/fragments/detail.html:257 +msgid "Put in / taken out" +msgstr "" + +#: templates/investments/fragments/detail.html:239 +#, fuzzy +#| msgid "Uncategorized" +msgid "Unrealized" +msgstr "Nieskategoryzowane" + +#: templates/investments/fragments/detail.html:248 +msgid "Realized" +msgstr "" + +#: templates/investments/fragments/detail.html:271 +#, fuzzy +#| msgid "Management" +msgid "Movements" +msgstr "Zarządzanie" + +#: templates/investments/fragments/detail.html:284 +msgid "Bought / contributed" +msgstr "" + +#: templates/investments/fragments/detail.html:284 +msgid "Sold / withdrew" +msgstr "" + +#: templates/investments/fragments/detail.html:288 +msgid "Money in" +msgstr "" + +#: templates/investments/fragments/detail.html:288 +msgid "Money out" +msgstr "" + +#: templates/investments/fragments/detail.html:290 +msgid "Gain" +msgstr "" + +#: templates/investments/fragments/detail.html:290 +msgid "Loss, tax or fee" +msgstr "" + +#: templates/investments/fragments/detail.html:315 +#, fuzzy +#| msgid "No attachments yet" +msgid "No movements yet" +msgstr "Brak załączników" + +#: templates/investments/fragments/detail.html:315 +msgid "Use the + button to buy or contribute" +msgstr "" + +#: templates/investments/fragments/list.html:21 +msgid "Holdings" +msgstr "" + +#: templates/investments/fragments/list.html:60 +msgid "Some costs were estimated from market prices" +msgstr "" + +#: templates/investments/fragments/list.html:92 +msgid "Charts" +msgstr "" + +#: templates/investments/fragments/list.html:97 +#, fuzzy +#| msgid "New balance" +msgid "Rebalance" +msgstr "Nowe saldo" + +#: templates/investments/fragments/list.html:105 +#, fuzzy +#| msgid "No attachments yet" +msgid "No investments yet" +msgstr "Brak załączników" + +#: templates/investments/fragments/list.html:106 +msgid "Mark an account as an investment in its settings to see it here" +msgstr "" + +#: templates/investments/fragments/rebalance.html:4 +msgid "No allocation targets" +msgstr "" + +#: templates/investments/fragments/rebalance.html:5 +msgid "Set a target for your asset classes to see what to buy and sell" +msgstr "" + +#: templates/investments/fragments/rebalance.html:26 +msgid "To reach the target" +msgstr "" + +#: templates/investments/fragments/rebalance.html:34 +msgid "Off target" +msgstr "" + +#: templates/investments/fragments/rebalance.html:55 +msgid "Left out: investments without an asset class or a target" +msgstr "" + #: templates/layouts/base.html:38 msgid "This is a demo!" msgstr "" @@ -3727,11 +4198,6 @@ msgstr "" msgid "Order by" msgstr "" -#: templates/net_worth/net_worth.html:52 -#: templates/yearly_overview/pages/overview_by_currency.html:6 -msgid "By currency" -msgstr "Według walut" - #: templates/net_worth/net_worth.html:135 msgid "Evolution" msgstr "Ewolucja" diff --git a/app/locale/pt_BR/LC_MESSAGES/django.po b/app/locale/pt_BR/LC_MESSAGES/django.po index 9a21a7dc..e7856217 100644 --- a/app/locale/pt_BR/LC_MESSAGES/django.po +++ b/app/locale/pt_BR/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-08 06:01+0000\n" +"POT-Creation-Date: 2026-11-08 18:27+0000\n" "PO-Revision-Date: 2026-11-02 19:49+0000\n" "Last-Translator: Herculino Trotta \n" "Language-Team: Portuguese (Brazil) 1;\n" "X-Generator: Weblate 2026.10.1\n" -#: apps/accounts/forms.py:28 +#: apps/accounts/forms.py:33 msgid "Group name" msgstr "Nome do grupo" -#: apps/accounts/forms.py:43 apps/accounts/forms.py:109 -#: apps/currencies/forms.py:57 apps/currencies/forms.py:91 -#: apps/currencies/forms.py:166 apps/dca/forms.py:46 apps/dca/forms.py:205 -#: apps/import_app/forms.py:32 apps/rules/forms.py:60 apps/rules/forms.py:100 -#: apps/rules/forms.py:385 apps/transactions/forms.py:219 -#: apps/transactions/forms.py:452 apps/transactions/forms.py:571 -#: apps/transactions/forms.py:718 apps/transactions/forms.py:940 -#: apps/transactions/forms.py:987 apps/transactions/forms.py:1015 -#: apps/transactions/forms.py:1046 apps/transactions/forms.py:1208 -#: apps/users/forms.py:264 apps/users/forms.py:422 +#: apps/accounts/forms.py:48 apps/accounts/forms.py:76 +#: apps/accounts/forms.py:184 apps/currencies/forms.py:57 +#: apps/currencies/forms.py:91 apps/currencies/forms.py:168 +#: apps/dca/forms.py:46 apps/dca/forms.py:192 apps/import_app/forms.py:32 +#: apps/rules/forms.py:60 apps/rules/forms.py:100 apps/rules/forms.py:385 +#: apps/transactions/forms.py:219 apps/transactions/forms.py:452 +#: apps/transactions/forms.py:571 apps/transactions/forms.py:718 +#: apps/transactions/forms.py:940 apps/transactions/forms.py:987 +#: apps/transactions/forms.py:1015 apps/transactions/forms.py:1046 +#: apps/transactions/forms.py:1208 apps/users/forms.py:264 +#: apps/users/forms.py:422 #: templates/rules/fragments/transaction_rule/dry_run/updated.html:5 #: templates/rules/fragments/transaction_rule/view.html:128 msgid "Update" msgstr "Atualizar" -#: apps/accounts/forms.py:49 apps/accounts/forms.py:115 -#: apps/common/widgets/tom_select.py:13 apps/currencies/forms.py:63 -#: apps/currencies/forms.py:97 apps/currencies/forms.py:172 -#: apps/dca/forms.py:52 apps/dca/forms.py:211 apps/import_app/forms.py:38 -#: apps/rules/forms.py:66 apps/rules/forms.py:106 apps/rules/forms.py:391 -#: apps/transactions/forms.py:206 apps/transactions/forms.py:226 -#: apps/transactions/forms.py:459 apps/transactions/forms.py:946 -#: apps/transactions/forms.py:993 apps/transactions/forms.py:1021 -#: apps/transactions/forms.py:1052 apps/transactions/forms.py:1214 -#: apps/users/forms.py:270 apps/users/forms.py:428 -#: templates/mini_tools/simulator.html:177 +#: apps/accounts/forms.py:54 apps/accounts/forms.py:76 +#: apps/accounts/forms.py:190 apps/common/widgets/tom_select.py:15 +#: apps/currencies/forms.py:63 apps/currencies/forms.py:97 +#: apps/currencies/forms.py:174 apps/dca/forms.py:52 apps/dca/forms.py:198 +#: apps/import_app/forms.py:38 apps/rules/forms.py:66 apps/rules/forms.py:106 +#: apps/rules/forms.py:391 apps/transactions/forms.py:206 +#: apps/transactions/forms.py:226 apps/transactions/forms.py:459 +#: apps/transactions/forms.py:946 apps/transactions/forms.py:993 +#: apps/transactions/forms.py:1021 apps/transactions/forms.py:1052 +#: apps/transactions/forms.py:1214 apps/users/forms.py:270 +#: apps/users/forms.py:428 templates/mini_tools/simulator.html:177 #: templates/mini_tools/unit_price_calculator.html:168 msgid "Add" msgstr "Adicionar" -#: apps/accounts/forms.py:57 templates/accounts/fragments/list.html:23 +#: apps/accounts/forms.py:86 templates/accounts/fragments/list.html:23 msgid "Group" msgstr "Grupo" -#: apps/accounts/forms.py:123 +#: apps/accounts/forms.py:93 apps/accounts/models.py:73 +#: apps/accounts/models.py:130 +#: templates/investments/fragments/rebalance.html:22 +msgid "Asset class" +msgstr "" + +#: apps/accounts/forms.py:198 msgid "New balance" msgstr "Novo saldo" -#: apps/accounts/forms.py:129 apps/dca/forms.py:79 apps/dca/forms.py:86 +#: apps/accounts/forms.py:204 apps/dca/forms.py:79 apps/dca/forms.py:86 #: apps/insights/forms.py:117 apps/insights/views.py:44 apps/rules/forms.py:181 #: apps/rules/forms.py:197 apps/rules/models.py:44 apps/rules/models.py:311 #: apps/transactions/forms.py:62 apps/transactions/forms.py:330 #: apps/transactions/forms.py:510 apps/transactions/forms.py:607 #: apps/transactions/forms.py:614 apps/transactions/forms.py:823 #: apps/transactions/forms.py:1075 apps/transactions/models.py:357 -#: apps/transactions/models.py:743 apps/transactions/models.py:999 -#: apps/transactions/models.py:1306 +#: apps/transactions/models.py:767 apps/transactions/models.py:1023 +#: apps/transactions/models.py:1330 #: templates/insights/fragments/month_by_month.html:84 #: templates/insights/fragments/year_by_year.html:52 msgid "Category" msgstr "Categoria" -#: apps/accounts/forms.py:136 apps/dca/forms.py:95 apps/dca/forms.py:103 -#: apps/export_app/forms.py:43 apps/export_app/forms.py:132 +#: apps/accounts/forms.py:211 apps/dca/forms.py:95 apps/dca/forms.py:103 +#: apps/export_app/forms.py:43 apps/export_app/forms.py:134 #: apps/insights/views.py:50 apps/rules/forms.py:184 apps/rules/forms.py:194 #: apps/rules/models.py:45 apps/rules/models.py:315 #: apps/transactions/filters.py:79 apps/transactions/forms.py:70 #: apps/transactions/forms.py:338 apps/transactions/forms.py:518 #: apps/transactions/forms.py:623 apps/transactions/forms.py:631 #: apps/transactions/forms.py:816 apps/transactions/forms.py:1068 -#: apps/transactions/models.py:363 apps/transactions/models.py:745 -#: apps/transactions/models.py:1003 apps/transactions/models.py:1312 -#: templates/includes/sidebar.html:156 +#: apps/transactions/models.py:363 apps/transactions/models.py:769 +#: apps/transactions/models.py:1027 apps/transactions/models.py:1336 +#: templates/includes/sidebar.html:162 #: templates/insights/fragments/month_by_month.html:29 #: templates/insights/fragments/month_by_month.html:32 #: templates/insights/fragments/year_by_year.html:24 @@ -94,23 +101,23 @@ msgstr "Categoria" msgid "Tags" msgstr "Tags" -#: apps/accounts/forms.py:179 +#: apps/accounts/forms.py:254 msgid "All paid transactions" msgstr "Todas as transações pagas" -#: apps/accounts/forms.py:180 +#: apps/accounts/forms.py:255 msgid "Up to date" msgstr "Até a data" -#: apps/accounts/forms.py:181 +#: apps/accounts/forms.py:256 msgid "Up to reference month" msgstr "Até o mês de referência" -#: apps/accounts/forms.py:185 +#: apps/accounts/forms.py:260 msgid "Balance against" msgstr "Balancear contra" -#: apps/accounts/forms.py:189 apps/currencies/forms.py:70 +#: apps/accounts/forms.py:264 apps/currencies/forms.py:70 #: apps/dca/models.py:158 apps/rules/forms.py:176 apps/rules/forms.py:190 #: apps/rules/models.py:38 apps/rules/models.py:279 #: apps/transactions/forms.py:86 apps/transactions/forms.py:482 @@ -121,14 +128,15 @@ msgstr "Balancear contra" msgid "Date" msgstr "Data" -#: apps/accounts/forms.py:194 +#: apps/accounts/forms.py:269 msgid "Reference month" msgstr "Mês de referência" -#: apps/accounts/models.py:12 apps/accounts/models.py:29 apps/dca/models.py:13 +#: apps/accounts/models.py:19 apps/accounts/models.py:36 +#: apps/accounts/models.py:84 apps/dca/models.py:13 #: apps/import_app/models.py:20 apps/rules/models.py:13 #: apps/transactions/models.py:249 apps/transactions/models.py:274 -#: apps/transactions/models.py:298 apps/transactions/models.py:1274 +#: apps/transactions/models.py:298 apps/transactions/models.py:1298 #: apps/users/models.py:577 templates/account_groups/fragments/list.html:22 #: templates/accounts/fragments/list.html:22 #: templates/categories/fragments/table.html:17 @@ -137,6 +145,7 @@ msgstr "Mês de referência" #: templates/exchange_rates_services/fragments/list.html:28 #: templates/import_app/fragments/profiles/list.html:34 #: templates/installment_plans/fragments/table.html:16 +#: templates/investment_classes/fragments/list.html:27 #: templates/quick_transactions/fragments/list.html:13 #: templates/recurring_transactions/fragments/table.html:18 #: templates/rules/fragments/list.html:24 @@ -145,44 +154,129 @@ msgstr "Mês de referência" msgid "Name" msgstr "Nome" -#: apps/accounts/models.py:18 apps/accounts/models.py:33 +#: apps/accounts/models.py:25 apps/accounts/models.py:88 msgid "Account Group" msgstr "Grupo da Conta" -#: apps/accounts/models.py:19 templates/account_groups/fragments/list.html:9 +#: apps/accounts/models.py:26 templates/account_groups/fragments/list.html:9 #: templates/account_groups/pages/index.html:4 -#: templates/includes/sidebar.html:176 +#: templates/includes/sidebar.html:182 msgid "Account Groups" msgstr "Grupos da Conta" -#: apps/accounts/models.py:39 apps/currencies/models.py:44 +#: apps/accounts/models.py:43 +msgid "Target allocation (%)" +msgstr "" + +#: apps/accounts/models.py:45 +msgid "" +"Share of your investments you want in this class. Leave it empty to leave " +"the class out of the targets." +msgstr "" + +#: apps/accounts/models.py:61 +msgid "You already have an asset class with this name." +msgstr "" + +#: apps/accounts/models.py:66 +#, python-format +msgid "" +"Targets can't add up to more than 100%%. Other classes already take " +"%(taken)s%%." +msgstr "" + +#: apps/accounts/models.py:74 apps/export_app/forms.py:131 +#: templates/includes/sidebar.html:190 +#: templates/investment_classes/fragments/list.html:9 +#: templates/investment_classes/pages/index.html:4 +#: templates/investments/fragments/rebalance.html:6 +#: templates/investments/fragments/rebalance.html:11 +msgid "Asset classes" +msgstr "" + +#: apps/accounts/models.py:94 apps/currencies/models.py:44 #: templates/accounts/fragments/list.html:24 #: templates/mini_tools/simulator.html:37 #: templates/yearly_overview/pages/yearly_overview_base.html:19 msgid "Currency" msgstr "Moeda" -#: apps/accounts/models.py:45 apps/currencies/models.py:27 +#: apps/accounts/models.py:100 apps/currencies/models.py:27 #: templates/accounts/fragments/list.html:25 msgid "Exchange Currency" msgstr "Moeda de Câmbio" -#: apps/accounts/models.py:50 apps/currencies/models.py:32 +#: apps/accounts/models.py:105 apps/currencies/models.py:32 msgid "Default currency for exchange calculations" msgstr "Moeda padrão para os cálculos de câmbio" -#: apps/accounts/models.py:55 +#: apps/accounts/models.py:110 msgid "Asset account" msgstr "Conta de ativos" -#: apps/accounts/models.py:57 +#: apps/accounts/models.py:112 msgid "" "Asset accounts count towards your Net Worth, but not towards your month." msgstr "" "As contas de ativos contam para o seu patrimônio líquido, mas não para o seu " "mês." -#: apps/accounts/models.py:62 apps/currencies/models.py:37 +#: apps/accounts/models.py:117 templates/investments/fragments/list.html:46 +#, fuzzy +#| msgid "Installment" +msgid "Investment" +msgstr "Parcelamento" + +#: apps/accounts/models.py:119 +msgid "" +"Show this account in the Investments tracker. Set an exchange currency to " +"price it by units (stocks, ETFs, crypto); leave it empty to track it by " +"value (fixed income, property, pensions)." +msgstr "" + +#: apps/accounts/models.py:132 +msgid "" +"Groups investments in the allocation charts, e.g. Fixed income, Stocks, Real " +"estate." +msgstr "" + +#: apps/accounts/models.py:140 +#, fuzzy +#| msgid "No entities" +msgid "Income entities" +msgstr "Sem entidades" + +#: apps/accounts/models.py:142 +msgid "" +"Transactions with any of these entities count towards this investment's " +"return: in this account, and income paid into other accounts (dividends, " +"rent)." +msgstr "" + +#: apps/accounts/models.py:151 +#, fuzzy +#| msgid "No categories" +msgid "Income categories" +msgstr "Nenhum categoria" + +#: apps/accounts/models.py:153 +msgid "" +"Transactions in this account with any of these categories are its result " +"(interest, taxes, fees). On accounts tracked by value, everything else that " +"isn't a transfer counts as money coming in or going out." +msgstr "" + +#: apps/accounts/models.py:162 +#, fuzzy +#| msgid "Income" +msgid "Income tags" +msgstr "Renda" + +#: apps/accounts/models.py:164 +msgid "Same as income categories, for transactions with any of these tags." +msgstr "" + +#: apps/accounts/models.py:169 apps/currencies/models.py:37 #: templates/accounts/fragments/list.html:27 #: templates/categories/fragments/list.html:20 #: templates/currencies/fragments/list.html:24 @@ -191,18 +285,18 @@ msgstr "" msgid "Archived" msgstr "Arquivada" -#: apps/accounts/models.py:63 +#: apps/accounts/models.py:170 msgid "Archived accounts don't show up nor count towards your net worth" msgstr "" "Contas arquivadas não aparecem nem contam para o seu patrimônio líquido" -#: apps/accounts/models.py:75 apps/rules/forms.py:173 apps/rules/forms.py:187 +#: apps/accounts/models.py:182 apps/rules/forms.py:173 apps/rules/forms.py:187 #: apps/rules/models.py:35 apps/rules/models.py:267 #: apps/transactions/forms.py:82 apps/transactions/forms.py:350 #: apps/transactions/forms.py:477 apps/transactions/forms.py:808 #: apps/transactions/forms.py:1060 apps/transactions/models.py:329 -#: apps/transactions/models.py:703 apps/transactions/models.py:981 -#: apps/transactions/models.py:1280 +#: apps/transactions/models.py:727 apps/transactions/models.py:1005 +#: apps/transactions/models.py:1304 #: templates/installment_plans/fragments/table.html:17 #: templates/quick_transactions/fragments/list.html:14 #: templates/recurring_transactions/fragments/table.html:19 @@ -210,17 +304,17 @@ msgstr "" msgid "Account" msgstr "Conta" -#: apps/accounts/models.py:76 apps/export_app/forms.py:19 -#: apps/export_app/forms.py:129 apps/transactions/filters.py:63 +#: apps/accounts/models.py:183 apps/export_app/forms.py:19 +#: apps/export_app/forms.py:130 apps/transactions/filters.py:63 #: templates/accounts/fragments/list.html:9 -#: templates/accounts/pages/index.html:4 templates/includes/sidebar.html:168 -#: templates/includes/sidebar.html:170 +#: templates/accounts/pages/index.html:4 templates/includes/sidebar.html:174 +#: templates/includes/sidebar.html:176 #: templates/monthly_overview/pages/overview.html:77 #: templates/transactions/pages/transactions.html:26 msgid "Accounts" msgstr "Contas" -#: apps/accounts/models.py:93 +#: apps/accounts/models.py:200 msgid "Exchange currency cannot be the same as the account's main currency." msgstr "A moeda de câmbio não pode ser a mesma que a moeda principal da conta." @@ -238,7 +332,7 @@ msgstr "Grupo de Conta apagado com sucesso" #: apps/accounts/views/account_groups.py:109 #: apps/accounts/views/accounts.py:137 apps/dca/views.py:106 -#: apps/import_app/views.py:147 apps/rules/views.py:191 +#: apps/import_app/views.py:148 apps/rules/views.py:191 #: apps/transactions/views/categories.py:163 #: apps/transactions/views/entities.py:135 apps/transactions/views/tags.py:131 msgid "Item no longer shared with you" @@ -246,7 +340,7 @@ msgstr "Item não está mais compartilhado com você" #: apps/accounts/views/account_groups.py:132 #: apps/accounts/views/accounts.py:182 apps/dca/views.py:131 -#: apps/import_app/views.py:273 apps/rules/views.py:217 +#: apps/import_app/views.py:274 apps/rules/views.py:217 #: apps/transactions/views/categories.py:188 #: apps/transactions/views/entities.py:160 apps/transactions/views/tags.py:154 msgid "Ownership taken successfully" @@ -254,7 +348,7 @@ msgstr "Propriedade assumida com sucesso" #: apps/accounts/views/account_groups.py:152 #: apps/accounts/views/accounts.py:106 apps/dca/views.py:151 -#: apps/import_app/views.py:294 apps/rules/views.py:238 +#: apps/import_app/views.py:295 apps/rules/views.py:238 #: apps/transactions/views/categories.py:130 #: apps/transactions/views/entities.py:180 apps/transactions/views/tags.py:174 msgid "Configuration saved successfully" @@ -312,7 +406,7 @@ msgstr "Entidade com esse ID não existe." msgid "Invalid entity data. Provide an ID or name." msgstr "Dados da entidade inválidos. Forneça um ID ou nome." -#: apps/api/serializers/transactions.py:141 apps/transactions/models.py:953 +#: apps/api/serializers/transactions.py:141 apps/transactions/models.py:977 msgid "This field is required." msgstr "Este campo é obrigatório." @@ -332,13 +426,14 @@ msgstr "Tornar privado" msgid "Shared with users" msgstr "Compartilhado com os usuários" -#: apps/common/fields/forms/dynamic_select.py:71 -#: apps/common/fields/forms/dynamic_select.py:142 +#: apps/common/fields/forms/dynamic_select.py:79 +#: apps/common/fields/forms/dynamic_select.py:150 msgid "Error creating new instance" msgstr "Erro criando nova instância" #: apps/common/fields/forms/grouped_select.py:24 -#: apps/common/widgets/tom_select.py:92 apps/common/widgets/tom_select.py:95 +#: apps/common/widgets/tom_select.py:94 apps/common/widgets/tom_select.py:97 +#: templates/investments/fragments/list.html:26 msgid "Ungrouped" msgstr "Não agrupado" @@ -486,14 +581,15 @@ msgid "Today" msgstr "Hoje" #: apps/common/widgets/datepicker.py:142 +#: templates/investments/fragments/rebalance.html:24 msgid "Now" msgstr "Agora" -#: apps/common/widgets/tom_select.py:11 +#: apps/common/widgets/tom_select.py:13 msgid "Remove" msgstr "Remover" -#: apps/common/widgets/tom_select.py:15 templates/mini_tools/simulator.html:187 +#: apps/common/widgets/tom_select.py:17 templates/mini_tools/simulator.html:187 #: templates/mini_tools/unit_price_calculator.html:180 #: templates/monthly_overview/pages/overview.html:286 #: templates/transactions/fragments/filter_actions.html:48 @@ -501,7 +597,7 @@ msgstr "Remover" msgid "Clear" msgstr "Limpar" -#: apps/common/widgets/tom_select.py:16 +#: apps/common/widgets/tom_select.py:18 msgid "No results..." msgstr "Sem resultados..." @@ -535,10 +631,10 @@ msgid "Decimal Places" msgstr "Casas Decimais" #: apps/currencies/models.py:45 apps/export_app/forms.py:25 -#: apps/export_app/forms.py:130 apps/transactions/filters.py:70 +#: apps/export_app/forms.py:132 apps/transactions/filters.py:70 #: templates/currencies/fragments/list.html:9 -#: templates/currencies/pages/index.html:4 templates/includes/sidebar.html:182 -#: templates/includes/sidebar.html:184 +#: templates/currencies/pages/index.html:4 templates/includes/sidebar.html:196 +#: templates/includes/sidebar.html:198 #: templates/monthly_overview/pages/overview.html:63 #: templates/transactions/pages/transactions.html:12 msgid "Currencies" @@ -570,9 +666,9 @@ msgid "Auto" msgstr "Automático" #: apps/currencies/models.py:82 apps/export_app/forms.py:67 -#: apps/export_app/forms.py:142 templates/exchange_rates/fragments/list.html:10 +#: apps/export_app/forms.py:144 templates/exchange_rates/fragments/list.html:10 #: templates/exchange_rates/pages/index.html:4 -#: templates/includes/sidebar.html:190 +#: templates/includes/sidebar.html:204 msgid "Exchange Rates" msgstr "Taxas de Câmbio" @@ -668,33 +764,46 @@ msgstr "" "definida." #: apps/currencies/models.py:171 -msgid "Single exchange rate" +#, fuzzy +#| msgid "Single exchange rate" +msgid "One exchange rate per day" msgstr "Taxa de câmbio única" #: apps/currencies/models.py:174 -msgid "Create one exchange rate and keep updating it. Avoids database clutter." +msgid "" +"Update the day's exchange rate on each fetch instead of adding a new one." msgstr "" -"Cria uma taxa de câmbio e mantenha-a atualizada. Evita a poluição do banco " -"de dados." -#: apps/currencies/models.py:179 +#: apps/currencies/models.py:178 +#, fuzzy +#| msgid "Exchange Rates" +msgid "Exchange rates to keep" +msgstr "Taxas de Câmbio" + +#: apps/currencies/models.py:183 +msgid "" +"Keep at most this many automatic exchange rates per currency pair, deleting " +"the oldest first. Leave empty to keep all." +msgstr "" + +#: apps/currencies/models.py:188 msgid "Exchange Rate Service" msgstr "Serviço de Taxa de Câmbio" -#: apps/currencies/models.py:180 +#: apps/currencies/models.py:189 msgid "Exchange Rate Services" msgstr "Serviços de Taxa de Câmbio" -#: apps/currencies/models.py:234 +#: apps/currencies/models.py:243 msgid "'Every X hours' interval type requires a positive integer." msgstr "" "Intervalo do tipo 'A cada X horas' requerer um número inteiro positivo." -#: apps/currencies/models.py:243 +#: apps/currencies/models.py:252 msgid "'Every X hours' interval must be between 1 and 24." msgstr "Intervalo do tipo 'A cada X horas' requerer um número entre 1 e 24." -#: apps/currencies/models.py:257 +#: apps/currencies/models.py:266 msgid "" "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "'1-5,8,10-12')." @@ -702,7 +811,7 @@ msgstr "" "Formato inválido de hora. Use uma lista de horas separada por vírgulas " "(0-23) e/ou uma faixa (ex.: '1-5,8,10-12')." -#: apps/currencies/models.py:268 +#: apps/currencies/models.py:277 msgid "" "Invalid format. Please check the requirements for your selected interval " "type." @@ -774,21 +883,21 @@ msgstr "Digite para buscar uma transação para conectar à esta entrada" msgid "Income Transaction" msgstr "Transação de entrada" -#: apps/dca/forms.py:192 +#: apps/dca/forms.py:179 msgid "Link transaction" msgstr "Conectar transação" -#: apps/dca/forms.py:275 apps/dca/forms.py:276 apps/dca/forms.py:281 -#: apps/dca/forms.py:285 +#: apps/dca/forms.py:262 apps/dca/forms.py:263 apps/dca/forms.py:268 +#: apps/dca/forms.py:272 msgid "You must provide an account." msgstr "Você deve informar uma conta." -#: apps/dca/forms.py:290 apps/transactions/forms.py:752 -#: apps/transactions/models.py:956 +#: apps/dca/forms.py:277 apps/transactions/forms.py:752 +#: apps/transactions/models.py:980 msgid "From and To accounts must be different." msgstr "As contas De e Para devem ser diferentes." -#: apps/dca/forms.py:304 +#: apps/dca/forms.py:299 #, python-format msgid "DCA for %(strategy_name)s" msgstr "CMP para %(strategy_name)s" @@ -804,8 +913,8 @@ msgstr "Moeda de pagamento" #: apps/dca/models.py:26 apps/dca/models.py:181 apps/rules/forms.py:180 #: apps/rules/forms.py:196 apps/rules/models.py:43 apps/rules/models.py:295 #: apps/transactions/forms.py:504 apps/transactions/forms.py:651 -#: apps/transactions/models.py:353 apps/transactions/models.py:752 -#: apps/transactions/models.py:1009 apps/transactions/models.py:1302 +#: apps/transactions/models.py:353 apps/transactions/models.py:776 +#: apps/transactions/models.py:1033 apps/transactions/models.py:1326 msgid "Notes" msgstr "Notas" @@ -813,7 +922,7 @@ msgstr "Notas" msgid "DCA Strategy" msgstr "Estratégia CMP" -#: apps/dca/models.py:35 apps/export_app/forms.py:150 +#: apps/dca/models.py:35 apps/export_app/forms.py:152 msgid "DCA Strategies" msgstr "Estratégias CMP" @@ -833,7 +942,7 @@ msgstr "Quantia recebida" msgid "DCA Entry" msgstr "Entrada CMP" -#: apps/dca/models.py:187 apps/export_app/forms.py:151 +#: apps/dca/models.py:187 apps/export_app/forms.py:153 msgid "DCA Entries" msgstr "Entradas CMP" @@ -861,25 +970,25 @@ msgstr "Entrada atualizada com sucesso" msgid "Entry deleted successfully" msgstr "Entrada apagada com sucesso" -#: apps/export_app/forms.py:13 apps/export_app/forms.py:128 -#: templates/includes/sidebar.html:227 templates/users/fragments/list.html:10 +#: apps/export_app/forms.py:13 apps/export_app/forms.py:129 +#: templates/includes/sidebar.html:241 templates/users/fragments/list.html:10 #: templates/users/pages/index.html:4 msgid "Users" msgstr "Usuários" -#: apps/export_app/forms.py:31 apps/export_app/forms.py:134 +#: apps/export_app/forms.py:31 apps/export_app/forms.py:136 #: apps/transactions/models.py:422 templates/includes/sidebar.html:81 -#: templates/includes/sidebar.html:148 +#: templates/includes/sidebar.html:154 #: templates/recurring_transactions/fragments/list_transactions.html:5 #: templates/recurring_transactions/fragments/table.html:37 #: templates/transactions/pages/transactions.html:5 msgid "Transactions" msgstr "Transações" -#: apps/export_app/forms.py:37 apps/export_app/forms.py:131 +#: apps/export_app/forms.py:37 apps/export_app/forms.py:133 #: apps/insights/views.py:43 apps/transactions/filters.py:74 #: templates/categories/fragments/list.html:9 -#: templates/categories/pages/index.html:4 templates/includes/sidebar.html:150 +#: templates/categories/pages/index.html:4 templates/includes/sidebar.html:156 #: templates/insights/fragments/month_by_month.html:18 #: templates/insights/fragments/month_by_month.html:21 #: templates/insights/fragments/year_by_year.html:13 @@ -887,16 +996,16 @@ msgstr "Transações" msgid "Categories" msgstr "Categorias" -#: apps/export_app/forms.py:49 apps/export_app/forms.py:133 +#: apps/export_app/forms.py:49 apps/export_app/forms.py:135 #: apps/insights/views.py:57 apps/rules/forms.py:185 apps/rules/forms.py:195 #: apps/rules/models.py:46 apps/rules/models.py:307 #: apps/transactions/filters.py:84 apps/transactions/forms.py:78 #: apps/transactions/forms.py:346 apps/transactions/forms.py:526 #: apps/transactions/forms.py:831 apps/transactions/forms.py:1083 #: apps/transactions/models.py:312 apps/transactions/models.py:368 -#: apps/transactions/models.py:748 apps/transactions/models.py:1006 -#: apps/transactions/models.py:1317 templates/entities/fragments/list.html:9 -#: templates/entities/pages/index.html:4 templates/includes/sidebar.html:162 +#: apps/transactions/models.py:772 apps/transactions/models.py:1030 +#: apps/transactions/models.py:1341 templates/entities/fragments/list.html:9 +#: templates/entities/pages/index.html:4 templates/includes/sidebar.html:168 #: templates/insights/fragments/month_by_month.html:40 #: templates/insights/fragments/month_by_month.html:43 #: templates/insights/fragments/year_by_year.html:35 @@ -904,28 +1013,28 @@ msgstr "Categorias" msgid "Entities" msgstr "Entidades" -#: apps/export_app/forms.py:55 apps/export_app/forms.py:137 -#: apps/transactions/models.py:1046 templates/includes/sidebar.html:110 +#: apps/export_app/forms.py:55 apps/export_app/forms.py:139 +#: apps/transactions/models.py:1070 templates/includes/sidebar.html:110 #: templates/recurring_transactions/fragments/list.html:9 #: templates/recurring_transactions/pages/index.html:4 msgid "Recurring Transactions" msgstr "Transações Recorrentes" -#: apps/export_app/forms.py:61 apps/export_app/forms.py:135 -#: apps/transactions/models.py:766 templates/includes/sidebar.html:104 +#: apps/export_app/forms.py:61 apps/export_app/forms.py:137 +#: apps/transactions/models.py:790 templates/includes/sidebar.html:104 #: templates/installment_plans/fragments/list.html:9 #: templates/installment_plans/pages/index.html:4 msgid "Installment Plans" msgstr "Parcelamentos" -#: apps/export_app/forms.py:73 apps/export_app/forms.py:140 +#: apps/export_app/forms.py:73 apps/export_app/forms.py:142 #: templates/exchange_rates_services/fragments/list.html:10 #: templates/exchange_rates_services/pages/index.html:4 -#: templates/includes/sidebar.html:218 +#: templates/includes/sidebar.html:232 msgid "Automatic Exchange Rates" msgstr "Taxas de Câmbio Automáticas" -#: apps/export_app/forms.py:79 templates/includes/sidebar.html:198 +#: apps/export_app/forms.py:79 templates/includes/sidebar.html:212 #: templates/rules/fragments/list.html:9 templates/rules/pages/index.html:4 msgid "Rules" msgstr "Regras" @@ -934,57 +1043,57 @@ msgstr "Regras" msgid "DCA" msgstr "CMP" -#: apps/export_app/forms.py:91 apps/export_app/forms.py:152 +#: apps/export_app/forms.py:91 apps/export_app/forms.py:154 #: templates/import_app/fragments/profiles/list.html:21 #: templates/import_app/pages/profiles_index.html:4 msgid "Import Profiles" msgstr "Perfis de Importação" -#: apps/export_app/forms.py:117 templates/export_app/fragments/export.html:5 +#: apps/export_app/forms.py:118 templates/export_app/fragments/export.html:5 #: templates/export_app/pages/index.html:13 msgid "Export" msgstr "Exportar" -#: apps/export_app/forms.py:125 +#: apps/export_app/forms.py:126 msgid "Import a ZIP file exported from WYGIWYH" msgstr "Importe um arquivo ZIP exportado do WYGIWYH" -#: apps/export_app/forms.py:126 +#: apps/export_app/forms.py:127 msgid "ZIP File" msgstr "Arquivo ZIP" -#: apps/export_app/forms.py:143 apps/rules/models.py:27 +#: apps/export_app/forms.py:145 apps/rules/models.py:27 msgid "Transaction rules" msgstr "Regra da transação" -#: apps/export_app/forms.py:145 apps/rules/models.py:68 +#: apps/export_app/forms.py:147 apps/rules/models.py:68 msgid "Edit transaction action" msgstr "Ação de editar de transação" -#: apps/export_app/forms.py:148 apps/rules/models.py:327 +#: apps/export_app/forms.py:150 apps/rules/models.py:327 msgid "Update or create transaction actions" msgstr "Ações de atualizar ou criar transação" -#: apps/export_app/forms.py:181 templates/cotton/transaction/item.html:341 +#: apps/export_app/forms.py:183 templates/cotton/transaction/item.html:341 #: templates/cotton/ui/deleted_transactions_action_bar.html:53 #: templates/export_app/fragments/restore.html:5 #: templates/export_app/pages/index.html:19 msgid "Restore" msgstr "Restaurar" -#: apps/export_app/forms.py:191 +#: apps/export_app/forms.py:193 msgid "Please upload either a ZIP file or at least one CSV file" msgstr "Carregue um arquivo ZIP ou pelo menos um arquivo CSV" -#: apps/export_app/views.py:177 +#: apps/export_app/views.py:183 msgid "You have to select at least one export" msgstr "É necessário selecionar pelo menos uma exportação" -#: apps/export_app/views.py:197 +#: apps/export_app/views.py:203 msgid "Data restored successfully" msgstr "Dados restaurados com sucesso" -#: apps/export_app/views.py:209 +#: apps/export_app/views.py:215 msgid "" "There was an error restoring your data. Check the logs for more details." msgstr "" @@ -997,7 +1106,7 @@ msgstr "Selecione um arquivo" #: apps/import_app/forms.py:55 #: templates/import_app/fragments/profiles/list.html:75 -#: templates/includes/sidebar.html:204 +#: templates/includes/sidebar.html:218 msgid "Import" msgstr "Importar" @@ -1045,23 +1154,23 @@ msgstr "Status" msgid "File name" msgstr "Nome do Arquivo" -#: apps/import_app/views.py:75 +#: apps/import_app/views.py:76 msgid "Import Profile added successfully" msgstr "Perfil de Importação adicionado com sucesso" -#: apps/import_app/views.py:113 +#: apps/import_app/views.py:114 msgid "Import Profile update successfully" msgstr "Importação atualizada com sucesso" -#: apps/import_app/views.py:142 +#: apps/import_app/views.py:143 msgid "Import Profile deleted successfully" msgstr "Perfil de Importação apagado com sucesso" -#: apps/import_app/views.py:222 +#: apps/import_app/views.py:223 msgid "Import Run queued successfully" msgstr "Importação adicionada à fila com sucesso" -#: apps/import_app/views.py:249 +#: apps/import_app/views.py:250 msgid "Run deleted successfully" msgstr "Importação apagada com sucesso" @@ -1130,7 +1239,7 @@ msgstr "Tag" msgid "Untagged" msgstr "Sem tag" -#: apps/insights/views.py:53 apps/transactions/models.py:589 +#: apps/insights/views.py:53 apps/transactions/models.py:613 #: templates/tags/fragments/table.html:69 msgid "No tags" msgstr "Nenhuma tag" @@ -1151,6 +1260,32 @@ msgstr "Sem entidade" msgid "No entities" msgstr "Sem entidades" +#: apps/investments/views.py:104 +msgid "Other" +msgstr "" + +#: apps/investments/views.py:135 +msgid "Unclassified" +msgstr "" + +#: apps/investments/views.py:267 +#, fuzzy +#| msgid "Installment Plan added successfully" +msgid "Asset class added successfully" +msgstr "Parcelamento adicionado com sucesso" + +#: apps/investments/views.py:275 +#, fuzzy +#| msgid "Tag updated successfully" +msgid "Asset class updated successfully" +msgstr "Tag atualizada com sucesso" + +#: apps/investments/views.py:296 +#, fuzzy +#| msgid "Tag deleted successfully" +msgid "Asset class deleted successfully" +msgstr "Tag apagada com sucesso" + #: apps/net_worth/views.py:137 templates/net_worth/net_worth.html:84 #: templates/net_worth/net_worth.html:85 msgid "Consolidated" @@ -1204,8 +1339,8 @@ msgstr "Operador" #: apps/rules/forms.py:174 apps/rules/forms.py:188 apps/rules/models.py:36 #: apps/rules/models.py:271 apps/transactions/forms.py:468 -#: apps/transactions/models.py:336 apps/transactions/models.py:708 -#: apps/transactions/models.py:987 apps/transactions/models.py:1287 +#: apps/transactions/models.py:336 apps/transactions/models.py:732 +#: apps/transactions/models.py:1011 apps/transactions/models.py:1311 #: templates/mini_tools/simulator.html:63 msgid "Type" msgstr "Tipo" @@ -1213,7 +1348,7 @@ msgstr "Tipo" #: apps/rules/forms.py:175 apps/rules/forms.py:189 apps/rules/models.py:37 #: apps/rules/models.py:275 apps/transactions/filters.py:22 #: apps/transactions/forms.py:472 apps/transactions/models.py:338 -#: apps/transactions/models.py:1289 templates/cotton/transaction/item.html:23 +#: apps/transactions/models.py:1313 templates/cotton/transaction/item.html:23 #: templates/cotton/transaction/item.html:34 #: templates/transactions/widgets/paid_toggle_button.html:10 #: templates/transactions/widgets/unselectable_paid_toggle_button.html:13 @@ -1224,14 +1359,14 @@ msgstr "Pago" #: apps/rules/models.py:283 apps/transactions/forms.py:90 #: apps/transactions/forms.py:488 apps/transactions/forms.py:638 #: apps/transactions/forms.py:837 apps/transactions/models.py:340 -#: apps/transactions/models.py:726 apps/transactions/models.py:1011 +#: apps/transactions/models.py:750 apps/transactions/models.py:1035 msgid "Reference Date" msgstr "Data de Referência" #: apps/rules/forms.py:178 apps/rules/forms.py:192 apps/rules/models.py:41 #: apps/rules/models.py:287 apps/transactions/forms.py:495 -#: apps/transactions/models.py:346 apps/transactions/models.py:992 -#: apps/transactions/models.py:1295 +#: apps/transactions/models.py:346 apps/transactions/models.py:1016 +#: apps/transactions/models.py:1319 #: templates/insights/fragments/sankey.html:102 #: templates/installment_plans/fragments/table.html:18 #: templates/mini_tools/simulator.html:70 @@ -1243,27 +1378,27 @@ msgstr "Quantia" #: apps/rules/forms.py:179 apps/rules/forms.py:193 apps/rules/models.py:14 #: apps/rules/models.py:42 apps/rules/models.py:291 #: apps/transactions/forms.py:499 apps/transactions/forms.py:642 -#: apps/transactions/models.py:351 apps/transactions/models.py:710 -#: apps/transactions/models.py:995 apps/transactions/models.py:1300 +#: apps/transactions/models.py:351 apps/transactions/models.py:734 +#: apps/transactions/models.py:1019 apps/transactions/models.py:1324 msgid "Description" msgstr "Descrição" #: apps/rules/forms.py:182 apps/rules/forms.py:198 apps/rules/models.py:47 #: apps/rules/models.py:299 apps/transactions/models.py:398 -#: apps/transactions/models.py:1322 +#: apps/transactions/models.py:1346 msgid "Internal Note" msgstr "Nota Interna" #: apps/rules/forms.py:183 apps/rules/forms.py:199 apps/rules/models.py:48 #: apps/rules/models.py:303 apps/transactions/models.py:400 -#: apps/transactions/models.py:1324 +#: apps/transactions/models.py:1348 msgid "Internal ID" msgstr "ID Interna" #: apps/rules/forms.py:186 apps/rules/forms.py:200 apps/rules/models.py:40 #: apps/rules/models.py:319 apps/transactions/forms.py:655 #: apps/transactions/models.py:250 apps/transactions/models.py:341 -#: apps/transactions/models.py:1290 +#: apps/transactions/models.py:1314 msgid "Mute" msgstr "Silenciada" @@ -1276,7 +1411,7 @@ msgid "Set Values" msgstr "Definir valores" #: apps/rules/forms.py:407 apps/rules/forms.py:442 apps/rules/forms.py:477 -#: apps/transactions/models.py:421 apps/transactions/models.py:645 +#: apps/transactions/models.py:421 apps/transactions/models.py:669 msgid "Transaction" msgstr "Transação" @@ -1312,7 +1447,12 @@ msgstr "Regra" msgid "Field" msgstr "Campo" -#: apps/rules/models.py:61 +#: apps/rules/models.py:61 templates/investments/fragments/charts.html:86 +#: templates/investments/fragments/detail.html:103 +#: templates/investments/fragments/detail.html:162 +#: templates/investments/fragments/list.html:48 +#: templates/investments/fragments/list.html:49 +#: templates/investments/fragments/rebalance.html:23 msgid "Value" msgstr "Valor" @@ -1498,7 +1638,7 @@ msgid "Upload" msgstr "Upload" #: apps/transactions/forms.py:355 apps/transactions/forms.py:1089 -#: apps/transactions/models.py:925 +#: apps/transactions/models.py:949 msgid "To account" msgstr "Conta de destino" @@ -1514,8 +1654,8 @@ msgstr "Quantia de origem" msgid "To Amount" msgstr "Quantia de destino" -#: apps/transactions/forms.py:718 apps/transactions/models.py:970 -#: apps/transactions/models.py:1267 templates/cotton/transaction/item.html:52 +#: apps/transactions/forms.py:718 apps/transactions/models.py:994 +#: apps/transactions/models.py:1291 templates/cotton/transaction/item.html:52 #: templates/cotton/transaction/item.html:60 #: templates/cotton/ui/quick_transactions_buttons.html:40 #: templates/cotton/ui/transactions_fab.html:44 @@ -1580,8 +1720,8 @@ msgstr "" "As entidades desativadas não poderão ser selecionadas ao criar novas " "transações" -#: apps/transactions/models.py:323 apps/transactions/models.py:969 -#: apps/transactions/models.py:1266 +#: apps/transactions/models.py:323 apps/transactions/models.py:993 +#: apps/transactions/models.py:1290 #: templates/calendar_view/fragments/list.html:42 #: templates/calendar_view/fragments/list.html:44 #: templates/calendar_view/fragments/list.html:52 @@ -1589,13 +1729,19 @@ msgstr "" #: templates/cotton/ui/quick_transactions_buttons.html:10 #: templates/cotton/ui/transactions_fab.html:10 #: templates/insights/fragments/overview/_results.html:12 +#: templates/investments/fragments/_totals.html:21 +#: templates/investments/fragments/_totals.html:42 +#: templates/investments/fragments/detail.html:106 +#: templates/investments/fragments/detail.html:210 +#: templates/investments/fragments/detail.html:252 +#: templates/investments/fragments/detail.html:320 #: templates/mini_tools/simulator.html:66 #: templates/monthly_overview/fragments/monthly_summary.html:42 msgid "Income" msgstr "Renda" -#: apps/transactions/models.py:324 apps/transactions/models.py:971 -#: apps/transactions/models.py:1268 +#: apps/transactions/models.py:324 apps/transactions/models.py:995 +#: apps/transactions/models.py:1292 #: templates/calendar_view/fragments/list.html:46 #: templates/calendar_view/fragments/list.html:48 #: templates/calendar_view/fragments/list.html:56 @@ -1607,14 +1753,14 @@ msgstr "Renda" msgid "Expense" msgstr "Despesa" -#: apps/transactions/models.py:379 apps/transactions/models.py:765 +#: apps/transactions/models.py:379 apps/transactions/models.py:789 #: templates/cotton/transaction/item.html:291 #: templates/cotton/transaction/item.html:308 #: templates/cotton/transaction/item.html:318 msgid "Installment Plan" msgstr "Parcelamento" -#: apps/transactions/models.py:388 apps/transactions/models.py:1045 +#: apps/transactions/models.py:388 apps/transactions/models.py:1069 #: templates/cotton/transaction/item.html:281 #: templates/cotton/transaction/item.html:301 msgid "Recurring Transaction" @@ -1632,150 +1778,162 @@ msgstr "Apagado" msgid "Deleted At" msgstr "Apagado Em" -#: apps/transactions/models.py:591 +#: apps/transactions/models.py:517 +msgid "A transfer needs one expense and one income." +msgstr "" + +#: apps/transactions/models.py:519 +msgid "A transfer needs two different accounts." +msgstr "" + +#: apps/transactions/models.py:523 +msgid "This transaction is already part of another transfer." +msgstr "" + +#: apps/transactions/models.py:615 msgid "No category" msgstr "Sem categoria" -#: apps/transactions/models.py:593 +#: apps/transactions/models.py:617 msgid "No description" msgstr "Sem descrição" -#: apps/transactions/models.py:650 +#: apps/transactions/models.py:674 msgid "File" msgstr "Arquivo" -#: apps/transactions/models.py:652 +#: apps/transactions/models.py:676 msgid "Original Name" msgstr "Nome Original" -#: apps/transactions/models.py:654 +#: apps/transactions/models.py:678 msgid "Content Type" msgstr "Tipo de Conteúdo" -#: apps/transactions/models.py:656 +#: apps/transactions/models.py:680 msgid "Size" msgstr "Tamanho" -#: apps/transactions/models.py:661 +#: apps/transactions/models.py:685 msgid "Uploaded By" msgstr "Enviado por" -#: apps/transactions/models.py:666 +#: apps/transactions/models.py:690 msgid "Transaction Attachment" msgstr "Anexo de transação" -#: apps/transactions/models.py:667 +#: apps/transactions/models.py:691 msgid "Transaction Attachments" msgstr "Anexo de transações" -#: apps/transactions/models.py:697 templates/includes/sidebar.html:57 +#: apps/transactions/models.py:721 templates/includes/sidebar.html:57 msgid "Yearly" msgstr "Anual" -#: apps/transactions/models.py:698 apps/users/models.py:469 +#: apps/transactions/models.py:722 apps/users/models.py:469 #: templates/includes/sidebar.html:51 msgid "Monthly" msgstr "Mensal" -#: apps/transactions/models.py:699 +#: apps/transactions/models.py:723 msgid "Weekly" msgstr "Semanal" -#: apps/transactions/models.py:700 +#: apps/transactions/models.py:724 msgid "Daily" msgstr "Diária" -#: apps/transactions/models.py:713 +#: apps/transactions/models.py:737 msgid "Number of Installments" msgstr "Número de Parcelas" -#: apps/transactions/models.py:718 +#: apps/transactions/models.py:742 msgid "Installment Start" msgstr "Parcela inicial" -#: apps/transactions/models.py:719 +#: apps/transactions/models.py:743 msgid "The installment number to start counting from" msgstr "O número da parcela a partir do qual se inicia a contagem" -#: apps/transactions/models.py:724 apps/transactions/models.py:1015 +#: apps/transactions/models.py:748 apps/transactions/models.py:1039 msgid "Start Date" msgstr "Data de Início" -#: apps/transactions/models.py:728 apps/transactions/models.py:1016 +#: apps/transactions/models.py:752 apps/transactions/models.py:1040 msgid "End Date" msgstr "Data Final" -#: apps/transactions/models.py:733 +#: apps/transactions/models.py:757 msgid "Recurrence" msgstr "Recorrência" -#: apps/transactions/models.py:736 +#: apps/transactions/models.py:760 msgid "Installment Amount" msgstr "Valor da Parcela" -#: apps/transactions/models.py:755 apps/transactions/models.py:1035 +#: apps/transactions/models.py:779 apps/transactions/models.py:1059 msgid "Add description to transactions" msgstr "Adicionar descrição às transações" -#: apps/transactions/models.py:758 apps/transactions/models.py:1038 +#: apps/transactions/models.py:782 apps/transactions/models.py:1062 msgid "Add notes to transactions" msgstr "Adicionar notas às transações" -#: apps/transactions/models.py:932 +#: apps/transactions/models.py:956 msgid "To amount" msgstr "Quantia de destino" -#: apps/transactions/models.py:933 +#: apps/transactions/models.py:957 msgid "Leave empty to use the same amount" msgstr "Deixe em branco para usar a mesma quantia" -#: apps/transactions/models.py:974 templates/mini_tools/simulator.html:129 +#: apps/transactions/models.py:998 templates/mini_tools/simulator.html:129 msgid "day(s)" msgstr "dia(s)" -#: apps/transactions/models.py:975 templates/mini_tools/simulator.html:130 +#: apps/transactions/models.py:999 templates/mini_tools/simulator.html:130 msgid "week(s)" msgstr "semana(s)" -#: apps/transactions/models.py:976 templates/mini_tools/simulator.html:131 +#: apps/transactions/models.py:1000 templates/mini_tools/simulator.html:131 msgid "month(s)" msgstr "mês(es)" -#: apps/transactions/models.py:977 templates/mini_tools/simulator.html:132 +#: apps/transactions/models.py:1001 templates/mini_tools/simulator.html:132 msgid "year(s)" msgstr "ano(s)" -#: apps/transactions/models.py:979 +#: apps/transactions/models.py:1003 #: templates/recurring_transactions/fragments/list.html:18 msgid "Paused" msgstr "Pausado" -#: apps/transactions/models.py:1018 +#: apps/transactions/models.py:1042 msgid "Recurrence Type" msgstr "Tipo de recorrência" -#: apps/transactions/models.py:1021 +#: apps/transactions/models.py:1045 msgid "Recurrence Interval" msgstr "Intervalo de recorrência" -#: apps/transactions/models.py:1024 +#: apps/transactions/models.py:1048 msgid "Keep at most" msgstr "Manter no máximo" -#: apps/transactions/models.py:1028 +#: apps/transactions/models.py:1052 msgid "Last Generated Date" msgstr "Última data gerada" -#: apps/transactions/models.py:1031 +#: apps/transactions/models.py:1055 msgid "Last Generated Reference Date" msgstr "Última data de referência gerada" -#: apps/transactions/models.py:1334 templates/cotton/transaction/item.html:274 +#: apps/transactions/models.py:1358 templates/cotton/transaction/item.html:274 #: templates/cotton/ui/transactions_fab.html:59 msgid "Quick Transaction" msgstr "Transação Rápida" -#: apps/transactions/models.py:1335 templates/includes/sidebar.html:98 +#: apps/transactions/models.py:1359 templates/includes/sidebar.html:98 #: templates/quick_transactions/pages/index.html:5 #: templates/quick_transactions/pages/index.html:15 msgid "Quick Transactions" @@ -1898,8 +2056,8 @@ msgid "Item deleted successfully" msgstr "Item apagado com sucesso" #: apps/transactions/views/quick_transactions.py:164 -#: apps/transactions/views/transactions.py:143 -#: apps/transactions/views/transactions.py:328 +#: apps/transactions/views/transactions.py:156 +#: apps/transactions/views/transactions.py:343 msgid "Transaction added successfully" msgstr "Transação adicionada com sucesso" @@ -1935,42 +2093,42 @@ msgstr "Tag atualizada com sucesso" msgid "Tag deleted successfully" msgstr "Tag apagada com sucesso" -#: apps/transactions/views/transactions.py:61 +#: apps/transactions/views/transactions.py:65 msgid "Attachment uploaded successfully" msgstr "Anexo enviado com sucesso" -#: apps/transactions/views/transactions.py:112 +#: apps/transactions/views/transactions.py:116 msgid "Attachment deleted successfully" msgstr "Anexo apagado com sucesso" -#: apps/transactions/views/transactions.py:355 +#: apps/transactions/views/transactions.py:370 msgid "Transaction updated successfully" msgstr "Transação atualizada com sucesso" -#: apps/transactions/views/transactions.py:406 +#: apps/transactions/views/transactions.py:421 #, python-format msgid "%(count)s transaction updated successfully" msgid_plural "%(count)s transactions updated successfully" msgstr[0] "%(count)s transação atualizada com sucesso" msgstr[1] "%(count)s transações atualizadas com sucesso" -#: apps/transactions/views/transactions.py:433 +#: apps/transactions/views/transactions.py:448 msgid "Transaction duplicated successfully" msgstr "Transação duplicada com sucesso" -#: apps/transactions/views/transactions.py:478 +#: apps/transactions/views/transactions.py:493 msgid "Transaction deleted successfully" msgstr "Transação apagada com sucesso" -#: apps/transactions/views/transactions.py:508 +#: apps/transactions/views/transactions.py:523 msgid "Transaction restored successfully" msgstr "Transação restaurada com sucesso" -#: apps/transactions/views/transactions.py:534 +#: apps/transactions/views/transactions.py:549 msgid "Transfer added successfully" msgstr "Transferência adicionada com sucesso" -#: apps/transactions/views/transactions.py:562 +#: apps/transactions/views/transactions.py:579 msgid "Transfer updated successfully" msgstr "Transferência atualizada com sucesso" @@ -2283,6 +2441,7 @@ msgstr "Editar grupo de conta" #: templates/exchange_rates_services/fragments/table.html:20 #: templates/import_app/fragments/profiles/list.html:42 #: templates/installment_plans/fragments/table.html:25 +#: templates/investment_classes/fragments/list.html:36 #: templates/quick_transactions/fragments/list.html:22 #: templates/recurring_transactions/fragments/table.html:27 #: templates/rules/fragments/list.html:31 @@ -2305,6 +2464,9 @@ msgstr "Ações" #: templates/exchange_rates_services/fragments/table.html:23 #: templates/import_app/fragments/profiles/list.html:46 #: templates/installment_plans/fragments/table.html:28 +#: templates/investment_classes/fragments/list.html:39 +#: templates/investments/fragments/detail.html:305 +#: templates/investments/fragments/detail.html:331 #: templates/quick_transactions/fragments/list.html:25 #: templates/recurring_transactions/fragments/table.html:30 #: templates/rules/fragments/transaction_rule/view.html:22 @@ -2354,6 +2516,7 @@ msgstr "Compartilhar" #: templates/import_app/fragments/profiles/list.html:81 #: templates/import_app/fragments/runs/list.html:100 #: templates/installment_plans/fragments/table.html:54 +#: templates/investment_classes/fragments/list.html:45 #: templates/mini_tools/simulator.html:109 #: templates/mini_tools/unit_price_calculator.html:16 #: templates/quick_transactions/fragments/list.html:32 @@ -2387,6 +2550,7 @@ msgstr "Apagar" #: templates/import_app/fragments/runs/list.html:104 #: templates/installment_plans/fragments/table.html:47 #: templates/installment_plans/fragments/table.html:58 +#: templates/investment_classes/fragments/list.html:49 #: templates/quick_transactions/fragments/list.html:37 #: templates/recurring_transactions/fragments/table.html:52 #: templates/recurring_transactions/fragments/table.html:65 @@ -2436,6 +2600,7 @@ msgstr "Você não será capaz de reverter isso!" #: templates/import_app/fragments/profiles/list.html:87 #: templates/import_app/fragments/runs/list.html:106 #: templates/installment_plans/fragments/table.html:60 +#: templates/investment_classes/fragments/list.html:51 #: templates/quick_transactions/fragments/list.html:39 #: templates/recurring_transactions/fragments/table.html:94 #: templates/rules/fragments/list.html:62 @@ -2484,6 +2649,7 @@ msgid "Add account" msgstr "Adicionar conta" #: templates/accounts/fragments/edit.html:5 +#: templates/investments/fragments/detail.html:40 msgid "Edit account" msgstr "Editar conta" @@ -2556,7 +2722,7 @@ msgstr "Editar categoria" msgid "Pick a month" msgstr "Escolha um mês" -#: templates/common/fragments/toasts.html:20 +#: templates/common/fragments/toasts.html:18 #: templates/cotton/components/fab.html:9 #: templates/cotton/components/sidebar_collapsible_panel.html:28 #: templates/extends/offcanvas.html:4 templates/includes/sidebar.html:44 @@ -2785,6 +2951,8 @@ msgid "Add DCA strategy" msgstr "Adicionar estratégia CMP" #: templates/dca/fragments/strategy/details.html:28 +#: templates/investments/fragments/detail.html:92 +#: templates/investments/fragments/list.html:65 msgid "No exchange rate available" msgstr "Nenhuma taxa de câmbio disponível" @@ -2967,7 +3135,7 @@ msgstr "contas" msgid "No services configured" msgstr "Nenhum serviço configurado" -#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:211 +#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:225 msgid "Export and Restore" msgstr "Exportar e Restaurar" @@ -3142,52 +3310,63 @@ msgstr "Lixeira" msgid "Tools" msgstr "Ferramentas" -#: templates/includes/sidebar.html:118 +#: templates/includes/sidebar.html:118 templates/includes/sidebar.html:188 +#: templates/investment_classes/fragments/list.html:29 +#: templates/investments/fragments/detail.html:37 +#: templates/investments/fragments/list.html:9 +#: templates/investments/pages/detail.html:4 +#: templates/investments/pages/index.html:4 +#, fuzzy +#| msgid "Installments" +msgid "Investments" +msgstr "Parcelas" + +#: templates/includes/sidebar.html:124 msgid "Dollar Cost Average Tracker" msgstr "Rastreador de Custo Médio Ponderado" -#: templates/includes/sidebar.html:124 +#: templates/includes/sidebar.html:130 #: templates/mini_tools/unit_price_calculator.html:4 #: templates/mini_tools/unit_price_calculator.html:9 msgid "Unit Price Calculator" msgstr "Calculadora de preço unitário" -#: templates/includes/sidebar.html:130 +#: templates/includes/sidebar.html:136 #: templates/mini_tools/currency_converter/currency_converter.html:7 #: templates/mini_tools/currency_converter/currency_converter.html:14 msgid "Currency Converter" msgstr "Conversor de Moeda" -#: templates/includes/sidebar.html:136 templates/mini_tools/simulator.html:5 +#: templates/includes/sidebar.html:142 templates/mini_tools/simulator.html:5 #: templates/mini_tools/simulator.html:21 msgid "Simulator" msgstr "Simulador" -#: templates/includes/sidebar.html:145 +#: templates/includes/sidebar.html:151 msgid "Management" msgstr "Gerenciar" -#: templates/includes/sidebar.html:196 +#: templates/includes/sidebar.html:210 msgid "Automation" msgstr "Automação" -#: templates/includes/sidebar.html:225 +#: templates/includes/sidebar.html:239 msgid "Admin" msgstr "Admin" -#: templates/includes/sidebar.html:233 +#: templates/includes/sidebar.html:247 msgid "Django Admin" msgstr "Django Admin" -#: templates/includes/sidebar.html:234 +#: templates/includes/sidebar.html:248 msgid "Only use this if you know what you're doing" msgstr "Só use isso se você souber o que está fazendo" -#: templates/includes/sidebar.html:249 +#: templates/includes/sidebar.html:263 msgid "is available" msgstr "está disponível" -#: templates/includes/sidebar.html:257 +#: templates/includes/sidebar.html:271 msgid "Calculator" msgstr "Calculadora" @@ -3254,6 +3433,7 @@ msgstr "Nenhuma transação recente" #: templates/insights/fragments/overview/_results.html:173 #: templates/insights/fragments/year_by_year.html:59 #: templates/insights/fragments/year_by_year.html:140 +#: templates/investment_classes/fragments/list.html:65 #: templates/monthly_overview/fragments/monthly_summary.html:168 msgid "Total" msgstr "Total" @@ -3454,6 +3634,305 @@ msgstr "Isso excluirá o parcelamento e todas as transações associadas a ele" msgid "No installment plans" msgstr "Nenhum parcelamento" +#: templates/investment_classes/fragments/form.html:5 +msgid "Edit asset class" +msgstr "" + +#: templates/investment_classes/fragments/form.html:5 +msgid "Add asset class" +msgstr "" + +#: templates/investment_classes/fragments/list.html:19 +#: templates/investments/fragments/rebalance.html:10 +#, python-format +msgid "Targets add up to %(total)s%%. Set them to 100%% to see the rebalance." +msgstr "" + +#: templates/investment_classes/fragments/list.html:28 +#: templates/investments/fragments/rebalance.html:25 +#, fuzzy +#| msgid "Targeting" +msgid "Target" +msgstr "Alvos" + +#: templates/investment_classes/fragments/list.html:50 +msgid "Its investments will be left without an asset class." +msgstr "" + +#: templates/investment_classes/fragments/list.html:74 +msgid "No asset classes" +msgstr "" + +#: templates/investments/fragments/_allocation.html:3 +msgid "Allocation chart" +msgstr "" + +#: templates/investments/fragments/_allocation.html:11 +#, python-format +msgid "" +"%(share)s%% of your investments with a target, against a target of " +"%(target)s%%" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "Annual return, weighted by when money went in and out" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "" +"Return since the first contribution, weighted by when money went in and out" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "/yr" +msgstr "" + +#: templates/investments/fragments/_totals.html:15 +#: templates/investments/fragments/detail.html:63 +msgid "total return" +msgstr "" + +#: templates/investments/fragments/_totals.html:20 +#: templates/investments/fragments/_totals.html:41 +#: templates/investments/fragments/charts.html:96 +#: templates/investments/fragments/detail.html:163 +#: templates/investments/fragments/detail.html:223 +#: templates/investments/fragments/list.html:47 +#, fuzzy +#| msgid "Total Invested" +msgid "Invested" +msgstr "Total investido" + +#: templates/investments/fragments/_totals.html:24 +#: templates/investments/fragments/detail.html:74 +msgid "" +"How much of the return came from exchange rates moving since you invested" +msgstr "" + +#: templates/investments/fragments/_totals.html:24 +#: templates/investments/fragments/_totals.html:26 +#, fuzzy +#| msgid "Currency Code" +msgid "Currency effect" +msgstr "Código da Moeda" + +#: templates/investments/fragments/_totals.html:26 +#: templates/investments/fragments/detail.html:78 +msgid "" +"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." +msgstr "" + +#: templates/investments/fragments/_totals.html:40 +#: templates/investments/fragments/list.html:49 +msgid "Return" +msgstr "" + +#: templates/investments/fragments/charts.html:10 +msgid "By asset class" +msgstr "" + +#: templates/investments/fragments/charts.html:17 +#: templates/net_worth/net_worth.html:52 +#: templates/yearly_overview/pages/overview_by_currency.html:6 +msgid "By currency" +msgstr "Por moeda" + +#: templates/investments/fragments/charts.html:24 +#, fuzzy +#| msgid "Total Invested" +msgid "Value and invested" +msgstr "Total investido" + +#: templates/investments/fragments/charts.html:31 +msgid "Return by investment" +msgstr "" + +#: templates/investments/fragments/charts.html:38 +#, fuzzy +#| msgid "Nothing to see here..." +msgid "Nothing to chart yet" +msgstr "Nada para ver aqui..." + +#: templates/investments/fragments/detail.html:10 +msgid "Buy / Contribute" +msgstr "" + +#: templates/investments/fragments/detail.html:16 +msgid "Sell / Withdraw" +msgstr "" + +#: templates/investments/fragments/detail.html:23 +msgid "Dividend / Income" +msgstr "" + +#: templates/investments/fragments/detail.html:30 +#, fuzzy +#| msgid "Updated at" +msgid "Update value" +msgstr "Atualizado em" + +#: templates/investments/fragments/detail.html:47 +#: templates/investments/fragments/list.html:67 +msgid "Tracked by value" +msgstr "" + +#: templates/investments/fragments/detail.html:48 +msgid "" +"Some incoming units have no linked payment, so their cost was estimated from " +"the market price of that day" +msgstr "" + +#: templates/investments/fragments/detail.html:68 +#, fuzzy +#| msgid "Info" +msgid "In" +msgstr "Informação" + +#: templates/investments/fragments/detail.html:74 +#: templates/investments/fragments/detail.html:78 +#, fuzzy +#| msgid "Currency Code" +msgid "currency effect" +msgstr "Código da Moeda" + +#: templates/investments/fragments/detail.html:104 +#: templates/investments/fragments/detail.html:179 +#, fuzzy +#| msgid "Entry Price" +msgid "Price" +msgstr "Preço de Entrada" + +#: templates/investments/fragments/detail.html:105 +#: templates/investments/fragments/detail.html:200 +#: templates/investments/fragments/detail.html:239 +#: templates/investments/fragments/detail.html:290 +#, fuzzy +#| msgid "Warning" +msgid "Earnings" +msgstr "Aviso" + +#: templates/investments/fragments/detail.html:180 +#: templates/investments/fragments/detail.html:227 +msgid "Average cost" +msgstr "" + +#: templates/investments/fragments/detail.html:181 +#: templates/investments/fragments/rebalance.html:41 +#, fuzzy +#| msgid "By" +msgid "Buy" +msgstr "Por" + +#: templates/investments/fragments/detail.html:182 +#: templates/investments/fragments/rebalance.html:43 +msgid "Sell" +msgstr "" + +#: templates/investments/fragments/detail.html:227 +#: templates/investments/fragments/detail.html:257 +msgid "Put in / taken out" +msgstr "" + +#: templates/investments/fragments/detail.html:239 +#, fuzzy +#| msgid "Uncategorized" +msgid "Unrealized" +msgstr "Sem categoria" + +#: templates/investments/fragments/detail.html:248 +msgid "Realized" +msgstr "" + +#: templates/investments/fragments/detail.html:271 +#, fuzzy +#| msgid "Management" +msgid "Movements" +msgstr "Gerenciar" + +#: templates/investments/fragments/detail.html:284 +msgid "Bought / contributed" +msgstr "" + +#: templates/investments/fragments/detail.html:284 +msgid "Sold / withdrew" +msgstr "" + +#: templates/investments/fragments/detail.html:288 +msgid "Money in" +msgstr "" + +#: templates/investments/fragments/detail.html:288 +msgid "Money out" +msgstr "" + +#: templates/investments/fragments/detail.html:290 +msgid "Gain" +msgstr "" + +#: templates/investments/fragments/detail.html:290 +msgid "Loss, tax or fee" +msgstr "" + +#: templates/investments/fragments/detail.html:315 +#, fuzzy +#| msgid "No presets yet" +msgid "No movements yet" +msgstr "Nenhuma predefinição de importação ainda" + +#: templates/investments/fragments/detail.html:315 +msgid "Use the + button to buy or contribute" +msgstr "" + +#: templates/investments/fragments/list.html:21 +msgid "Holdings" +msgstr "" + +#: templates/investments/fragments/list.html:60 +msgid "Some costs were estimated from market prices" +msgstr "" + +#: templates/investments/fragments/list.html:92 +msgid "Charts" +msgstr "" + +#: templates/investments/fragments/list.html:97 +#, fuzzy +#| msgid "New balance" +msgid "Rebalance" +msgstr "Novo saldo" + +#: templates/investments/fragments/list.html:105 +#, fuzzy +#| msgid "No presets yet" +msgid "No investments yet" +msgstr "Nenhuma predefinição de importação ainda" + +#: templates/investments/fragments/list.html:106 +msgid "Mark an account as an investment in its settings to see it here" +msgstr "" + +#: templates/investments/fragments/rebalance.html:4 +msgid "No allocation targets" +msgstr "" + +#: templates/investments/fragments/rebalance.html:5 +msgid "Set a target for your asset classes to see what to buy and sell" +msgstr "" + +#: templates/investments/fragments/rebalance.html:26 +msgid "To reach the target" +msgstr "" + +#: templates/investments/fragments/rebalance.html:34 +msgid "Off target" +msgstr "" + +#: templates/investments/fragments/rebalance.html:55 +msgid "Left out: investments without an asset class or a target" +msgstr "" + #: templates/layouts/base.html:38 msgid "This is a demo!" msgstr "Isto é uma demonstração!" @@ -3665,11 +4144,6 @@ msgstr "Filtrar transações" msgid "Order by" msgstr "Ordernar por" -#: templates/net_worth/net_worth.html:52 -#: templates/yearly_overview/pages/overview_by_currency.html:6 -msgid "By currency" -msgstr "Por moeda" - #: templates/net_worth/net_worth.html:135 msgid "Evolution" msgstr "Evolução" @@ -4123,6 +4597,12 @@ msgstr "Login com" msgid "Yearly Overview" msgstr "Visão Anual" +#~ msgid "" +#~ "Create one exchange rate and keep updating it. Avoids database clutter." +#~ msgstr "" +#~ "Cria uma taxa de câmbio e mantenha-a atualizada. Evita a poluição do " +#~ "banco de dados." + #~ msgid "Add as quick transaction" #~ msgstr "Adicionar como transação rápida" diff --git a/app/locale/ru/LC_MESSAGES/django.po b/app/locale/ru/LC_MESSAGES/django.po index 31f9fb87..462a4df4 100644 --- a/app/locale/ru/LC_MESSAGES/django.po +++ b/app/locale/ru/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-08 06:01+0000\n" +"POT-Creation-Date: 2026-11-08 18:27+0000\n" "PO-Revision-Date: 2026-05-01 07:24+0000\n" "Last-Translator: masttera \n" "Language-Team: Russian =20) ? 1 : 2;\n" "X-Generator: Weblate 5.17\n" -#: apps/accounts/forms.py:28 +#: apps/accounts/forms.py:33 msgid "Group name" msgstr "Имя группы" -#: apps/accounts/forms.py:43 apps/accounts/forms.py:109 -#: apps/currencies/forms.py:57 apps/currencies/forms.py:91 -#: apps/currencies/forms.py:166 apps/dca/forms.py:46 apps/dca/forms.py:205 -#: apps/import_app/forms.py:32 apps/rules/forms.py:60 apps/rules/forms.py:100 -#: apps/rules/forms.py:385 apps/transactions/forms.py:219 -#: apps/transactions/forms.py:452 apps/transactions/forms.py:571 -#: apps/transactions/forms.py:718 apps/transactions/forms.py:940 -#: apps/transactions/forms.py:987 apps/transactions/forms.py:1015 -#: apps/transactions/forms.py:1046 apps/transactions/forms.py:1208 -#: apps/users/forms.py:264 apps/users/forms.py:422 +#: apps/accounts/forms.py:48 apps/accounts/forms.py:76 +#: apps/accounts/forms.py:184 apps/currencies/forms.py:57 +#: apps/currencies/forms.py:91 apps/currencies/forms.py:168 +#: apps/dca/forms.py:46 apps/dca/forms.py:192 apps/import_app/forms.py:32 +#: apps/rules/forms.py:60 apps/rules/forms.py:100 apps/rules/forms.py:385 +#: apps/transactions/forms.py:219 apps/transactions/forms.py:452 +#: apps/transactions/forms.py:571 apps/transactions/forms.py:718 +#: apps/transactions/forms.py:940 apps/transactions/forms.py:987 +#: apps/transactions/forms.py:1015 apps/transactions/forms.py:1046 +#: apps/transactions/forms.py:1208 apps/users/forms.py:264 +#: apps/users/forms.py:422 #: templates/rules/fragments/transaction_rule/dry_run/updated.html:5 #: templates/rules/fragments/transaction_rule/view.html:128 msgid "Update" msgstr "Обновить" -#: apps/accounts/forms.py:49 apps/accounts/forms.py:115 -#: apps/common/widgets/tom_select.py:13 apps/currencies/forms.py:63 -#: apps/currencies/forms.py:97 apps/currencies/forms.py:172 -#: apps/dca/forms.py:52 apps/dca/forms.py:211 apps/import_app/forms.py:38 -#: apps/rules/forms.py:66 apps/rules/forms.py:106 apps/rules/forms.py:391 -#: apps/transactions/forms.py:206 apps/transactions/forms.py:226 -#: apps/transactions/forms.py:459 apps/transactions/forms.py:946 -#: apps/transactions/forms.py:993 apps/transactions/forms.py:1021 -#: apps/transactions/forms.py:1052 apps/transactions/forms.py:1214 -#: apps/users/forms.py:270 apps/users/forms.py:428 -#: templates/mini_tools/simulator.html:177 +#: apps/accounts/forms.py:54 apps/accounts/forms.py:76 +#: apps/accounts/forms.py:190 apps/common/widgets/tom_select.py:15 +#: apps/currencies/forms.py:63 apps/currencies/forms.py:97 +#: apps/currencies/forms.py:174 apps/dca/forms.py:52 apps/dca/forms.py:198 +#: apps/import_app/forms.py:38 apps/rules/forms.py:66 apps/rules/forms.py:106 +#: apps/rules/forms.py:391 apps/transactions/forms.py:206 +#: apps/transactions/forms.py:226 apps/transactions/forms.py:459 +#: apps/transactions/forms.py:946 apps/transactions/forms.py:993 +#: apps/transactions/forms.py:1021 apps/transactions/forms.py:1052 +#: apps/transactions/forms.py:1214 apps/users/forms.py:270 +#: apps/users/forms.py:428 templates/mini_tools/simulator.html:177 #: templates/mini_tools/unit_price_calculator.html:168 msgid "Add" msgstr "Добавить" -#: apps/accounts/forms.py:57 templates/accounts/fragments/list.html:23 +#: apps/accounts/forms.py:86 templates/accounts/fragments/list.html:23 msgid "Group" msgstr "Группа" -#: apps/accounts/forms.py:123 +#: apps/accounts/forms.py:93 apps/accounts/models.py:73 +#: apps/accounts/models.py:130 +#: templates/investments/fragments/rebalance.html:22 +msgid "Asset class" +msgstr "" + +#: apps/accounts/forms.py:198 msgid "New balance" msgstr "Новый баланс" -#: apps/accounts/forms.py:129 apps/dca/forms.py:79 apps/dca/forms.py:86 +#: apps/accounts/forms.py:204 apps/dca/forms.py:79 apps/dca/forms.py:86 #: apps/insights/forms.py:117 apps/insights/views.py:44 apps/rules/forms.py:181 #: apps/rules/forms.py:197 apps/rules/models.py:44 apps/rules/models.py:311 #: apps/transactions/forms.py:62 apps/transactions/forms.py:330 #: apps/transactions/forms.py:510 apps/transactions/forms.py:607 #: apps/transactions/forms.py:614 apps/transactions/forms.py:823 #: apps/transactions/forms.py:1075 apps/transactions/models.py:357 -#: apps/transactions/models.py:743 apps/transactions/models.py:999 -#: apps/transactions/models.py:1306 +#: apps/transactions/models.py:767 apps/transactions/models.py:1023 +#: apps/transactions/models.py:1330 #: templates/insights/fragments/month_by_month.html:84 #: templates/insights/fragments/year_by_year.html:52 msgid "Category" msgstr "Категория" -#: apps/accounts/forms.py:136 apps/dca/forms.py:95 apps/dca/forms.py:103 -#: apps/export_app/forms.py:43 apps/export_app/forms.py:132 +#: apps/accounts/forms.py:211 apps/dca/forms.py:95 apps/dca/forms.py:103 +#: apps/export_app/forms.py:43 apps/export_app/forms.py:134 #: apps/insights/views.py:50 apps/rules/forms.py:184 apps/rules/forms.py:194 #: apps/rules/models.py:45 apps/rules/models.py:315 #: apps/transactions/filters.py:79 apps/transactions/forms.py:70 #: apps/transactions/forms.py:338 apps/transactions/forms.py:518 #: apps/transactions/forms.py:623 apps/transactions/forms.py:631 #: apps/transactions/forms.py:816 apps/transactions/forms.py:1068 -#: apps/transactions/models.py:363 apps/transactions/models.py:745 -#: apps/transactions/models.py:1003 apps/transactions/models.py:1312 -#: templates/includes/sidebar.html:156 +#: apps/transactions/models.py:363 apps/transactions/models.py:769 +#: apps/transactions/models.py:1027 apps/transactions/models.py:1336 +#: templates/includes/sidebar.html:162 #: templates/insights/fragments/month_by_month.html:29 #: templates/insights/fragments/month_by_month.html:32 #: templates/insights/fragments/year_by_year.html:24 @@ -95,31 +102,31 @@ msgstr "Категория" msgid "Tags" msgstr "Тэг" -#: apps/accounts/forms.py:179 +#: apps/accounts/forms.py:254 #, fuzzy #| msgid "All Transactions" msgid "All paid transactions" msgstr "Все транзакции" -#: apps/accounts/forms.py:180 +#: apps/accounts/forms.py:255 #, fuzzy #| msgid "Update" msgid "Up to date" msgstr "Обновить" -#: apps/accounts/forms.py:181 +#: apps/accounts/forms.py:256 #, fuzzy #| msgid "Move to next month" msgid "Up to reference month" msgstr "Перенести на следующий месяц" -#: apps/accounts/forms.py:185 +#: apps/accounts/forms.py:260 #, fuzzy #| msgid "Balance" msgid "Balance against" msgstr "Баланс" -#: apps/accounts/forms.py:189 apps/currencies/forms.py:70 +#: apps/accounts/forms.py:264 apps/currencies/forms.py:70 #: apps/dca/models.py:158 apps/rules/forms.py:176 apps/rules/forms.py:190 #: apps/rules/models.py:38 apps/rules/models.py:279 #: apps/transactions/forms.py:86 apps/transactions/forms.py:482 @@ -130,16 +137,17 @@ msgstr "Баланс" msgid "Date" msgstr "Дата" -#: apps/accounts/forms.py:194 +#: apps/accounts/forms.py:269 #, fuzzy #| msgid "Reference Date" msgid "Reference month" msgstr "Дата ссылки" -#: apps/accounts/models.py:12 apps/accounts/models.py:29 apps/dca/models.py:13 +#: apps/accounts/models.py:19 apps/accounts/models.py:36 +#: apps/accounts/models.py:84 apps/dca/models.py:13 #: apps/import_app/models.py:20 apps/rules/models.py:13 #: apps/transactions/models.py:249 apps/transactions/models.py:274 -#: apps/transactions/models.py:298 apps/transactions/models.py:1274 +#: apps/transactions/models.py:298 apps/transactions/models.py:1298 #: apps/users/models.py:577 templates/account_groups/fragments/list.html:22 #: templates/accounts/fragments/list.html:22 #: templates/categories/fragments/table.html:17 @@ -148,6 +156,7 @@ msgstr "Дата ссылки" #: templates/exchange_rates_services/fragments/list.html:28 #: templates/import_app/fragments/profiles/list.html:34 #: templates/installment_plans/fragments/table.html:16 +#: templates/investment_classes/fragments/list.html:27 #: templates/quick_transactions/fragments/list.html:13 #: templates/recurring_transactions/fragments/table.html:18 #: templates/rules/fragments/list.html:24 @@ -156,44 +165,129 @@ msgstr "Дата ссылки" msgid "Name" msgstr "Имя" -#: apps/accounts/models.py:18 apps/accounts/models.py:33 +#: apps/accounts/models.py:25 apps/accounts/models.py:88 msgid "Account Group" msgstr "Группа счетов" -#: apps/accounts/models.py:19 templates/account_groups/fragments/list.html:9 +#: apps/accounts/models.py:26 templates/account_groups/fragments/list.html:9 #: templates/account_groups/pages/index.html:4 -#: templates/includes/sidebar.html:176 +#: templates/includes/sidebar.html:182 msgid "Account Groups" msgstr "Группы счетов" -#: apps/accounts/models.py:39 apps/currencies/models.py:44 +#: apps/accounts/models.py:43 +msgid "Target allocation (%)" +msgstr "" + +#: apps/accounts/models.py:45 +msgid "" +"Share of your investments you want in this class. Leave it empty to leave " +"the class out of the targets." +msgstr "" + +#: apps/accounts/models.py:61 +msgid "You already have an asset class with this name." +msgstr "" + +#: apps/accounts/models.py:66 +#, python-format +msgid "" +"Targets can't add up to more than 100%%. Other classes already take " +"%(taken)s%%." +msgstr "" + +#: apps/accounts/models.py:74 apps/export_app/forms.py:131 +#: templates/includes/sidebar.html:190 +#: templates/investment_classes/fragments/list.html:9 +#: templates/investment_classes/pages/index.html:4 +#: templates/investments/fragments/rebalance.html:6 +#: templates/investments/fragments/rebalance.html:11 +msgid "Asset classes" +msgstr "" + +#: apps/accounts/models.py:94 apps/currencies/models.py:44 #: templates/accounts/fragments/list.html:24 #: templates/mini_tools/simulator.html:37 #: templates/yearly_overview/pages/yearly_overview_base.html:19 msgid "Currency" msgstr "Валюта" -#: apps/accounts/models.py:45 apps/currencies/models.py:27 +#: apps/accounts/models.py:100 apps/currencies/models.py:27 #: templates/accounts/fragments/list.html:25 msgid "Exchange Currency" msgstr "Обмен валюты" -#: apps/accounts/models.py:50 apps/currencies/models.py:32 +#: apps/accounts/models.py:105 apps/currencies/models.py:32 msgid "Default currency for exchange calculations" msgstr "Валюта по умолчанию для расчетов обмена валют" -#: apps/accounts/models.py:55 +#: apps/accounts/models.py:110 msgid "Asset account" msgstr "Счет активов" -#: apps/accounts/models.py:57 +#: apps/accounts/models.py:112 msgid "" "Asset accounts count towards your Net Worth, but not towards your month." msgstr "" "Активы, находящиеся на счетах, учитываются при расчете вашего чистого " "капитала, но не в ежемесячном исчислении." -#: apps/accounts/models.py:62 apps/currencies/models.py:37 +#: apps/accounts/models.py:117 templates/investments/fragments/list.html:46 +#, fuzzy +#| msgid "Installment" +msgid "Investment" +msgstr "Рассрочка" + +#: apps/accounts/models.py:119 +msgid "" +"Show this account in the Investments tracker. Set an exchange currency to " +"price it by units (stocks, ETFs, crypto); leave it empty to track it by " +"value (fixed income, property, pensions)." +msgstr "" + +#: apps/accounts/models.py:132 +msgid "" +"Groups investments in the allocation charts, e.g. Fixed income, Stocks, Real " +"estate." +msgstr "" + +#: apps/accounts/models.py:140 +#, fuzzy +#| msgid "No entities" +msgid "Income entities" +msgstr "Нет объектов" + +#: apps/accounts/models.py:142 +msgid "" +"Transactions with any of these entities count towards this investment's " +"return: in this account, and income paid into other accounts (dividends, " +"rent)." +msgstr "" + +#: apps/accounts/models.py:151 +#, fuzzy +#| msgid "No categories" +msgid "Income categories" +msgstr "Категории отсутствуют" + +#: apps/accounts/models.py:153 +msgid "" +"Transactions in this account with any of these categories are its result " +"(interest, taxes, fees). On accounts tracked by value, everything else that " +"isn't a transfer counts as money coming in or going out." +msgstr "" + +#: apps/accounts/models.py:162 +#, fuzzy +#| msgid "Income" +msgid "Income tags" +msgstr "Доход" + +#: apps/accounts/models.py:164 +msgid "Same as income categories, for transactions with any of these tags." +msgstr "" + +#: apps/accounts/models.py:169 apps/currencies/models.py:37 #: templates/accounts/fragments/list.html:27 #: templates/categories/fragments/list.html:20 #: templates/currencies/fragments/list.html:24 @@ -202,19 +296,19 @@ msgstr "" msgid "Archived" msgstr "Архив" -#: apps/accounts/models.py:63 +#: apps/accounts/models.py:170 msgid "Archived accounts don't show up nor count towards your net worth" msgstr "" "Архивированные счета не отображаются и не учитываются при расчете вашего " "чистого капитала" -#: apps/accounts/models.py:75 apps/rules/forms.py:173 apps/rules/forms.py:187 +#: apps/accounts/models.py:182 apps/rules/forms.py:173 apps/rules/forms.py:187 #: apps/rules/models.py:35 apps/rules/models.py:267 #: apps/transactions/forms.py:82 apps/transactions/forms.py:350 #: apps/transactions/forms.py:477 apps/transactions/forms.py:808 #: apps/transactions/forms.py:1060 apps/transactions/models.py:329 -#: apps/transactions/models.py:703 apps/transactions/models.py:981 -#: apps/transactions/models.py:1280 +#: apps/transactions/models.py:727 apps/transactions/models.py:1005 +#: apps/transactions/models.py:1304 #: templates/installment_plans/fragments/table.html:17 #: templates/quick_transactions/fragments/list.html:14 #: templates/recurring_transactions/fragments/table.html:19 @@ -222,17 +316,17 @@ msgstr "" msgid "Account" msgstr "Счет" -#: apps/accounts/models.py:76 apps/export_app/forms.py:19 -#: apps/export_app/forms.py:129 apps/transactions/filters.py:63 +#: apps/accounts/models.py:183 apps/export_app/forms.py:19 +#: apps/export_app/forms.py:130 apps/transactions/filters.py:63 #: templates/accounts/fragments/list.html:9 -#: templates/accounts/pages/index.html:4 templates/includes/sidebar.html:168 -#: templates/includes/sidebar.html:170 +#: templates/accounts/pages/index.html:4 templates/includes/sidebar.html:174 +#: templates/includes/sidebar.html:176 #: templates/monthly_overview/pages/overview.html:77 #: templates/transactions/pages/transactions.html:26 msgid "Accounts" msgstr "Счета" -#: apps/accounts/models.py:93 +#: apps/accounts/models.py:200 msgid "Exchange currency cannot be the same as the account's main currency." msgstr "Обменная валюта не может совпадать с основной валютой счета." @@ -250,7 +344,7 @@ msgstr "Группа счетов успешно удалена" #: apps/accounts/views/account_groups.py:109 #: apps/accounts/views/accounts.py:137 apps/dca/views.py:106 -#: apps/import_app/views.py:147 apps/rules/views.py:191 +#: apps/import_app/views.py:148 apps/rules/views.py:191 #: apps/transactions/views/categories.py:163 #: apps/transactions/views/entities.py:135 apps/transactions/views/tags.py:131 msgid "Item no longer shared with you" @@ -258,7 +352,7 @@ msgstr "Информация больше не предоставляется в #: apps/accounts/views/account_groups.py:132 #: apps/accounts/views/accounts.py:182 apps/dca/views.py:131 -#: apps/import_app/views.py:273 apps/rules/views.py:217 +#: apps/import_app/views.py:274 apps/rules/views.py:217 #: apps/transactions/views/categories.py:188 #: apps/transactions/views/entities.py:160 apps/transactions/views/tags.py:154 msgid "Ownership taken successfully" @@ -266,7 +360,7 @@ msgstr "Владелец успешно присвоен" #: apps/accounts/views/account_groups.py:152 #: apps/accounts/views/accounts.py:106 apps/dca/views.py:151 -#: apps/import_app/views.py:294 apps/rules/views.py:238 +#: apps/import_app/views.py:295 apps/rules/views.py:238 #: apps/transactions/views/categories.py:130 #: apps/transactions/views/entities.py:180 apps/transactions/views/tags.py:174 msgid "Configuration saved successfully" @@ -324,7 +418,7 @@ msgstr "Объект с таким идентификатором не суще msgid "Invalid entity data. Provide an ID or name." msgstr "Неверные данные сущности. Укажите идентификатор или имя." -#: apps/api/serializers/transactions.py:141 apps/transactions/models.py:953 +#: apps/api/serializers/transactions.py:141 apps/transactions/models.py:977 msgid "This field is required." msgstr "" @@ -345,13 +439,14 @@ msgstr "Сделать приватным" msgid "Shared with users" msgstr "Доступно пользователям" -#: apps/common/fields/forms/dynamic_select.py:71 -#: apps/common/fields/forms/dynamic_select.py:142 +#: apps/common/fields/forms/dynamic_select.py:79 +#: apps/common/fields/forms/dynamic_select.py:150 msgid "Error creating new instance" msgstr "Ошибка при создании нового экземпляра" #: apps/common/fields/forms/grouped_select.py:24 -#: apps/common/widgets/tom_select.py:92 apps/common/widgets/tom_select.py:95 +#: apps/common/widgets/tom_select.py:94 apps/common/widgets/tom_select.py:97 +#: templates/investments/fragments/list.html:26 msgid "Ungrouped" msgstr "Негруппированный" @@ -506,14 +601,15 @@ msgid "Today" msgstr "Сегодня" #: apps/common/widgets/datepicker.py:142 +#: templates/investments/fragments/rebalance.html:24 msgid "Now" msgstr "Сейчас" -#: apps/common/widgets/tom_select.py:11 +#: apps/common/widgets/tom_select.py:13 msgid "Remove" msgstr "Снять" -#: apps/common/widgets/tom_select.py:15 templates/mini_tools/simulator.html:187 +#: apps/common/widgets/tom_select.py:17 templates/mini_tools/simulator.html:187 #: templates/mini_tools/unit_price_calculator.html:180 #: templates/monthly_overview/pages/overview.html:286 #: templates/transactions/fragments/filter_actions.html:48 @@ -521,7 +617,7 @@ msgstr "Снять" msgid "Clear" msgstr "Очистить" -#: apps/common/widgets/tom_select.py:16 +#: apps/common/widgets/tom_select.py:18 msgid "No results..." msgstr "Нет результатов..." @@ -555,10 +651,10 @@ msgid "Decimal Places" msgstr "Десятичные знаки" #: apps/currencies/models.py:45 apps/export_app/forms.py:25 -#: apps/export_app/forms.py:130 apps/transactions/filters.py:70 +#: apps/export_app/forms.py:132 apps/transactions/filters.py:70 #: templates/currencies/fragments/list.html:9 -#: templates/currencies/pages/index.html:4 templates/includes/sidebar.html:182 -#: templates/includes/sidebar.html:184 +#: templates/currencies/pages/index.html:4 templates/includes/sidebar.html:196 +#: templates/includes/sidebar.html:198 #: templates/monthly_overview/pages/overview.html:63 #: templates/transactions/pages/transactions.html:12 msgid "Currencies" @@ -590,9 +686,9 @@ msgid "Auto" msgstr "Авто" #: apps/currencies/models.py:82 apps/export_app/forms.py:67 -#: apps/export_app/forms.py:142 templates/exchange_rates/fragments/list.html:10 +#: apps/export_app/forms.py:144 templates/exchange_rates/fragments/list.html:10 #: templates/exchange_rates/pages/index.html:4 -#: templates/includes/sidebar.html:190 +#: templates/includes/sidebar.html:204 msgid "Exchange Rates" msgstr "Обменные курсы" @@ -686,33 +782,46 @@ msgstr "" "валюты." #: apps/currencies/models.py:171 -msgid "Single exchange rate" +#, fuzzy +#| msgid "Single exchange rate" +msgid "One exchange rate per day" msgstr "Единый обменный курс" #: apps/currencies/models.py:174 -msgid "Create one exchange rate and keep updating it. Avoids database clutter." +msgid "" +"Update the day's exchange rate on each fetch instead of adding a new one." msgstr "" -"Создайте один обменный курс и постоянно обновляйте его. Это позволит " -"избежать захламления базы данных." -#: apps/currencies/models.py:179 +#: apps/currencies/models.py:178 +#, fuzzy +#| msgid "Exchange Rates" +msgid "Exchange rates to keep" +msgstr "Обменные курсы" + +#: apps/currencies/models.py:183 +msgid "" +"Keep at most this many automatic exchange rates per currency pair, deleting " +"the oldest first. Leave empty to keep all." +msgstr "" + +#: apps/currencies/models.py:188 msgid "Exchange Rate Service" msgstr "Сервис обмена курсов" -#: apps/currencies/models.py:180 +#: apps/currencies/models.py:189 msgid "Exchange Rate Services" msgstr "Сервисы обмена курсов" -#: apps/currencies/models.py:234 +#: apps/currencies/models.py:243 msgid "'Every X hours' interval type requires a positive integer." msgstr "" "Для интервалов типа «Каждые X часов» требуется положительное целое число." -#: apps/currencies/models.py:243 +#: apps/currencies/models.py:252 msgid "'Every X hours' interval must be between 1 and 24." msgstr "Интервал «каждые X часов» должен составлять от 1 до 24." -#: apps/currencies/models.py:257 +#: apps/currencies/models.py:266 msgid "" "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "'1-5,8,10-12')." @@ -720,7 +829,7 @@ msgstr "" "Неверный формат времени. Используйте часы, разделенные запятыми (0-23), и/" "или диапазоны (например, '1-5,8,10-12')." -#: apps/currencies/models.py:268 +#: apps/currencies/models.py:277 msgid "" "Invalid format. Please check the requirements for your selected interval " "type." @@ -792,21 +901,21 @@ msgstr "Введите текст для поиска транзакции, чт msgid "Income Transaction" msgstr "Доходная транзакция" -#: apps/dca/forms.py:192 +#: apps/dca/forms.py:179 msgid "Link transaction" msgstr "Ссылка на транзакцию" -#: apps/dca/forms.py:275 apps/dca/forms.py:276 apps/dca/forms.py:281 -#: apps/dca/forms.py:285 +#: apps/dca/forms.py:262 apps/dca/forms.py:263 apps/dca/forms.py:268 +#: apps/dca/forms.py:272 msgid "You must provide an account." msgstr "Вы должны предоставить учетную запись." -#: apps/dca/forms.py:290 apps/transactions/forms.py:752 -#: apps/transactions/models.py:956 +#: apps/dca/forms.py:277 apps/transactions/forms.py:752 +#: apps/transactions/models.py:980 msgid "From and To accounts must be different." msgstr "Счета «От» и «Получатель» должны быть разными." -#: apps/dca/forms.py:304 +#: apps/dca/forms.py:299 #, python-format msgid "DCA for %(strategy_name)s" msgstr "" @@ -822,8 +931,8 @@ msgstr "Валюта платежа" #: apps/dca/models.py:26 apps/dca/models.py:181 apps/rules/forms.py:180 #: apps/rules/forms.py:196 apps/rules/models.py:43 apps/rules/models.py:295 #: apps/transactions/forms.py:504 apps/transactions/forms.py:651 -#: apps/transactions/models.py:353 apps/transactions/models.py:752 -#: apps/transactions/models.py:1009 apps/transactions/models.py:1302 +#: apps/transactions/models.py:353 apps/transactions/models.py:776 +#: apps/transactions/models.py:1033 apps/transactions/models.py:1326 msgid "Notes" msgstr "Заметки" @@ -831,7 +940,7 @@ msgstr "Заметки" msgid "DCA Strategy" msgstr "Стратегия DCA" -#: apps/dca/models.py:35 apps/export_app/forms.py:150 +#: apps/dca/models.py:35 apps/export_app/forms.py:152 msgid "DCA Strategies" msgstr "Стратегии DCA" @@ -851,7 +960,7 @@ msgstr "Полученная сумма" msgid "DCA Entry" msgstr "" -#: apps/dca/models.py:187 apps/export_app/forms.py:151 +#: apps/dca/models.py:187 apps/export_app/forms.py:153 msgid "DCA Entries" msgstr "Записи DCA" @@ -879,25 +988,25 @@ msgstr "Запись успешно обновлена" msgid "Entry deleted successfully" msgstr "Запись успешно удалена" -#: apps/export_app/forms.py:13 apps/export_app/forms.py:128 -#: templates/includes/sidebar.html:227 templates/users/fragments/list.html:10 +#: apps/export_app/forms.py:13 apps/export_app/forms.py:129 +#: templates/includes/sidebar.html:241 templates/users/fragments/list.html:10 #: templates/users/pages/index.html:4 msgid "Users" msgstr "Пользователи" -#: apps/export_app/forms.py:31 apps/export_app/forms.py:134 +#: apps/export_app/forms.py:31 apps/export_app/forms.py:136 #: apps/transactions/models.py:422 templates/includes/sidebar.html:81 -#: templates/includes/sidebar.html:148 +#: templates/includes/sidebar.html:154 #: templates/recurring_transactions/fragments/list_transactions.html:5 #: templates/recurring_transactions/fragments/table.html:37 #: templates/transactions/pages/transactions.html:5 msgid "Transactions" msgstr "Транзакции" -#: apps/export_app/forms.py:37 apps/export_app/forms.py:131 +#: apps/export_app/forms.py:37 apps/export_app/forms.py:133 #: apps/insights/views.py:43 apps/transactions/filters.py:74 #: templates/categories/fragments/list.html:9 -#: templates/categories/pages/index.html:4 templates/includes/sidebar.html:150 +#: templates/categories/pages/index.html:4 templates/includes/sidebar.html:156 #: templates/insights/fragments/month_by_month.html:18 #: templates/insights/fragments/month_by_month.html:21 #: templates/insights/fragments/year_by_year.html:13 @@ -905,16 +1014,16 @@ msgstr "Транзакции" msgid "Categories" msgstr "Категории" -#: apps/export_app/forms.py:49 apps/export_app/forms.py:133 +#: apps/export_app/forms.py:49 apps/export_app/forms.py:135 #: apps/insights/views.py:57 apps/rules/forms.py:185 apps/rules/forms.py:195 #: apps/rules/models.py:46 apps/rules/models.py:307 #: apps/transactions/filters.py:84 apps/transactions/forms.py:78 #: apps/transactions/forms.py:346 apps/transactions/forms.py:526 #: apps/transactions/forms.py:831 apps/transactions/forms.py:1083 #: apps/transactions/models.py:312 apps/transactions/models.py:368 -#: apps/transactions/models.py:748 apps/transactions/models.py:1006 -#: apps/transactions/models.py:1317 templates/entities/fragments/list.html:9 -#: templates/entities/pages/index.html:4 templates/includes/sidebar.html:162 +#: apps/transactions/models.py:772 apps/transactions/models.py:1030 +#: apps/transactions/models.py:1341 templates/entities/fragments/list.html:9 +#: templates/entities/pages/index.html:4 templates/includes/sidebar.html:168 #: templates/insights/fragments/month_by_month.html:40 #: templates/insights/fragments/month_by_month.html:43 #: templates/insights/fragments/year_by_year.html:35 @@ -922,28 +1031,28 @@ msgstr "Категории" msgid "Entities" msgstr "Объекты" -#: apps/export_app/forms.py:55 apps/export_app/forms.py:137 -#: apps/transactions/models.py:1046 templates/includes/sidebar.html:110 +#: apps/export_app/forms.py:55 apps/export_app/forms.py:139 +#: apps/transactions/models.py:1070 templates/includes/sidebar.html:110 #: templates/recurring_transactions/fragments/list.html:9 #: templates/recurring_transactions/pages/index.html:4 msgid "Recurring Transactions" msgstr "Повторяющиеся транзакции" -#: apps/export_app/forms.py:61 apps/export_app/forms.py:135 -#: apps/transactions/models.py:766 templates/includes/sidebar.html:104 +#: apps/export_app/forms.py:61 apps/export_app/forms.py:137 +#: apps/transactions/models.py:790 templates/includes/sidebar.html:104 #: templates/installment_plans/fragments/list.html:9 #: templates/installment_plans/pages/index.html:4 msgid "Installment Plans" msgstr "Планы рассрочки" -#: apps/export_app/forms.py:73 apps/export_app/forms.py:140 +#: apps/export_app/forms.py:73 apps/export_app/forms.py:142 #: templates/exchange_rates_services/fragments/list.html:10 #: templates/exchange_rates_services/pages/index.html:4 -#: templates/includes/sidebar.html:218 +#: templates/includes/sidebar.html:232 msgid "Automatic Exchange Rates" msgstr "Автоматические обменные курсы" -#: apps/export_app/forms.py:79 templates/includes/sidebar.html:198 +#: apps/export_app/forms.py:79 templates/includes/sidebar.html:212 #: templates/rules/fragments/list.html:9 templates/rules/pages/index.html:4 msgid "Rules" msgstr "Правила" @@ -952,57 +1061,57 @@ msgstr "Правила" msgid "DCA" msgstr "" -#: apps/export_app/forms.py:91 apps/export_app/forms.py:152 +#: apps/export_app/forms.py:91 apps/export_app/forms.py:154 #: templates/import_app/fragments/profiles/list.html:21 #: templates/import_app/pages/profiles_index.html:4 msgid "Import Profiles" msgstr "Импорт профилей" -#: apps/export_app/forms.py:117 templates/export_app/fragments/export.html:5 +#: apps/export_app/forms.py:118 templates/export_app/fragments/export.html:5 #: templates/export_app/pages/index.html:13 msgid "Export" msgstr "Экспорт" -#: apps/export_app/forms.py:125 +#: apps/export_app/forms.py:126 msgid "Import a ZIP file exported from WYGIWYH" msgstr "Импорт ZIP-файла, экспортированного из WYGIWYH" -#: apps/export_app/forms.py:126 +#: apps/export_app/forms.py:127 msgid "ZIP File" msgstr "ZIP File" -#: apps/export_app/forms.py:143 apps/rules/models.py:27 +#: apps/export_app/forms.py:145 apps/rules/models.py:27 msgid "Transaction rules" msgstr "Правила транзакций" -#: apps/export_app/forms.py:145 apps/rules/models.py:68 +#: apps/export_app/forms.py:147 apps/rules/models.py:68 msgid "Edit transaction action" msgstr "Редактировать действие транзакции" -#: apps/export_app/forms.py:148 apps/rules/models.py:327 +#: apps/export_app/forms.py:150 apps/rules/models.py:327 msgid "Update or create transaction actions" msgstr "Обновление или создание действий транзакции" -#: apps/export_app/forms.py:181 templates/cotton/transaction/item.html:341 +#: apps/export_app/forms.py:183 templates/cotton/transaction/item.html:341 #: templates/cotton/ui/deleted_transactions_action_bar.html:53 #: templates/export_app/fragments/restore.html:5 #: templates/export_app/pages/index.html:19 msgid "Restore" msgstr "Восстановить" -#: apps/export_app/forms.py:191 +#: apps/export_app/forms.py:193 msgid "Please upload either a ZIP file or at least one CSV file" msgstr "Пожалуйста, загрузите либо ZIP-файл, либо хотя бы один CSV-файл" -#: apps/export_app/views.py:177 +#: apps/export_app/views.py:183 msgid "You have to select at least one export" msgstr "Необходимо выбрать как минимум один экспорт" -#: apps/export_app/views.py:197 +#: apps/export_app/views.py:203 msgid "Data restored successfully" msgstr "Данные успешно восстановлены" -#: apps/export_app/views.py:209 +#: apps/export_app/views.py:215 msgid "" "There was an error restoring your data. Check the logs for more details." msgstr "" @@ -1015,7 +1124,7 @@ msgstr "Выберите файл" #: apps/import_app/forms.py:55 #: templates/import_app/fragments/profiles/list.html:75 -#: templates/includes/sidebar.html:204 +#: templates/includes/sidebar.html:218 msgid "Import" msgstr "Импорт" @@ -1063,23 +1172,23 @@ msgstr "Статус" msgid "File name" msgstr "Имя файла" -#: apps/import_app/views.py:75 +#: apps/import_app/views.py:76 msgid "Import Profile added successfully" msgstr "Профиль импорта успешно добавлен" -#: apps/import_app/views.py:113 +#: apps/import_app/views.py:114 msgid "Import Profile update successfully" msgstr "Обновление профиля импорта прошло успешно" -#: apps/import_app/views.py:142 +#: apps/import_app/views.py:143 msgid "Import Profile deleted successfully" msgstr "Импорт профиля успешно удален" -#: apps/import_app/views.py:222 +#: apps/import_app/views.py:223 msgid "Import Run queued successfully" msgstr "Операция импорта успешно поставлена в очередь" -#: apps/import_app/views.py:249 +#: apps/import_app/views.py:250 msgid "Run deleted successfully" msgstr "Запуск успешно удален" @@ -1148,7 +1257,7 @@ msgstr "" msgid "Untagged" msgstr "" -#: apps/insights/views.py:53 apps/transactions/models.py:589 +#: apps/insights/views.py:53 apps/transactions/models.py:613 #: templates/tags/fragments/table.html:69 msgid "No tags" msgstr "Нет тегов" @@ -1169,6 +1278,32 @@ msgstr "" msgid "No entities" msgstr "Нет объектов" +#: apps/investments/views.py:104 +msgid "Other" +msgstr "" + +#: apps/investments/views.py:135 +msgid "Unclassified" +msgstr "" + +#: apps/investments/views.py:267 +#, fuzzy +#| msgid "Installment Plan added successfully" +msgid "Asset class added successfully" +msgstr "План рассрочки успешно добавлен" + +#: apps/investments/views.py:275 +#, fuzzy +#| msgid "Tag updated successfully" +msgid "Asset class updated successfully" +msgstr "Тег успешно обновлен" + +#: apps/investments/views.py:296 +#, fuzzy +#| msgid "Tag deleted successfully" +msgid "Asset class deleted successfully" +msgstr "Тег успешно удален" + #: apps/net_worth/views.py:137 templates/net_worth/net_worth.html:84 #: templates/net_worth/net_worth.html:85 msgid "Consolidated" @@ -1222,8 +1357,8 @@ msgstr "Оператор" #: apps/rules/forms.py:174 apps/rules/forms.py:188 apps/rules/models.py:36 #: apps/rules/models.py:271 apps/transactions/forms.py:468 -#: apps/transactions/models.py:336 apps/transactions/models.py:708 -#: apps/transactions/models.py:987 apps/transactions/models.py:1287 +#: apps/transactions/models.py:336 apps/transactions/models.py:732 +#: apps/transactions/models.py:1011 apps/transactions/models.py:1311 #: templates/mini_tools/simulator.html:63 msgid "Type" msgstr "" @@ -1231,7 +1366,7 @@ msgstr "" #: apps/rules/forms.py:175 apps/rules/forms.py:189 apps/rules/models.py:37 #: apps/rules/models.py:275 apps/transactions/filters.py:22 #: apps/transactions/forms.py:472 apps/transactions/models.py:338 -#: apps/transactions/models.py:1289 templates/cotton/transaction/item.html:23 +#: apps/transactions/models.py:1313 templates/cotton/transaction/item.html:23 #: templates/cotton/transaction/item.html:34 #: templates/transactions/widgets/paid_toggle_button.html:10 #: templates/transactions/widgets/unselectable_paid_toggle_button.html:13 @@ -1242,14 +1377,14 @@ msgstr "Оплаченный" #: apps/rules/models.py:283 apps/transactions/forms.py:90 #: apps/transactions/forms.py:488 apps/transactions/forms.py:638 #: apps/transactions/forms.py:837 apps/transactions/models.py:340 -#: apps/transactions/models.py:726 apps/transactions/models.py:1011 +#: apps/transactions/models.py:750 apps/transactions/models.py:1035 msgid "Reference Date" msgstr "Дата ссылки" #: apps/rules/forms.py:178 apps/rules/forms.py:192 apps/rules/models.py:41 #: apps/rules/models.py:287 apps/transactions/forms.py:495 -#: apps/transactions/models.py:346 apps/transactions/models.py:992 -#: apps/transactions/models.py:1295 +#: apps/transactions/models.py:346 apps/transactions/models.py:1016 +#: apps/transactions/models.py:1319 #: templates/insights/fragments/sankey.html:102 #: templates/installment_plans/fragments/table.html:18 #: templates/mini_tools/simulator.html:70 @@ -1261,27 +1396,27 @@ msgstr "" #: apps/rules/forms.py:179 apps/rules/forms.py:193 apps/rules/models.py:14 #: apps/rules/models.py:42 apps/rules/models.py:291 #: apps/transactions/forms.py:499 apps/transactions/forms.py:642 -#: apps/transactions/models.py:351 apps/transactions/models.py:710 -#: apps/transactions/models.py:995 apps/transactions/models.py:1300 +#: apps/transactions/models.py:351 apps/transactions/models.py:734 +#: apps/transactions/models.py:1019 apps/transactions/models.py:1324 msgid "Description" msgstr "" #: apps/rules/forms.py:182 apps/rules/forms.py:198 apps/rules/models.py:47 #: apps/rules/models.py:299 apps/transactions/models.py:398 -#: apps/transactions/models.py:1322 +#: apps/transactions/models.py:1346 msgid "Internal Note" msgstr "" #: apps/rules/forms.py:183 apps/rules/forms.py:199 apps/rules/models.py:48 #: apps/rules/models.py:303 apps/transactions/models.py:400 -#: apps/transactions/models.py:1324 +#: apps/transactions/models.py:1348 msgid "Internal ID" msgstr "" #: apps/rules/forms.py:186 apps/rules/forms.py:200 apps/rules/models.py:40 #: apps/rules/models.py:319 apps/transactions/forms.py:655 #: apps/transactions/models.py:250 apps/transactions/models.py:341 -#: apps/transactions/models.py:1290 +#: apps/transactions/models.py:1314 msgid "Mute" msgstr "" @@ -1294,7 +1429,7 @@ msgid "Set Values" msgstr "" #: apps/rules/forms.py:407 apps/rules/forms.py:442 apps/rules/forms.py:477 -#: apps/transactions/models.py:421 apps/transactions/models.py:645 +#: apps/transactions/models.py:421 apps/transactions/models.py:669 msgid "Transaction" msgstr "Транзакция" @@ -1330,7 +1465,12 @@ msgstr "" msgid "Field" msgstr "" -#: apps/rules/models.py:61 +#: apps/rules/models.py:61 templates/investments/fragments/charts.html:86 +#: templates/investments/fragments/detail.html:103 +#: templates/investments/fragments/detail.html:162 +#: templates/investments/fragments/list.html:48 +#: templates/investments/fragments/list.html:49 +#: templates/investments/fragments/rebalance.html:23 msgid "Value" msgstr "" @@ -1516,7 +1656,7 @@ msgid "Upload" msgstr "Перезагрузка" #: apps/transactions/forms.py:355 apps/transactions/forms.py:1089 -#: apps/transactions/models.py:925 +#: apps/transactions/models.py:949 #, fuzzy #| msgid "To Account" msgid "To account" @@ -1534,8 +1674,8 @@ msgstr "" msgid "To Amount" msgstr "" -#: apps/transactions/forms.py:718 apps/transactions/models.py:970 -#: apps/transactions/models.py:1267 templates/cotton/transaction/item.html:52 +#: apps/transactions/forms.py:718 apps/transactions/models.py:994 +#: apps/transactions/models.py:1291 templates/cotton/transaction/item.html:52 #: templates/cotton/transaction/item.html:60 #: templates/cotton/ui/quick_transactions_buttons.html:40 #: templates/cotton/ui/transactions_fab.html:44 @@ -1598,8 +1738,8 @@ msgid "" msgstr "" "Деактивированные объекты нельзя будет выбрать при создании новых транзакций" -#: apps/transactions/models.py:323 apps/transactions/models.py:969 -#: apps/transactions/models.py:1266 +#: apps/transactions/models.py:323 apps/transactions/models.py:993 +#: apps/transactions/models.py:1290 #: templates/calendar_view/fragments/list.html:42 #: templates/calendar_view/fragments/list.html:44 #: templates/calendar_view/fragments/list.html:52 @@ -1607,13 +1747,19 @@ msgstr "" #: templates/cotton/ui/quick_transactions_buttons.html:10 #: templates/cotton/ui/transactions_fab.html:10 #: templates/insights/fragments/overview/_results.html:12 +#: templates/investments/fragments/_totals.html:21 +#: templates/investments/fragments/_totals.html:42 +#: templates/investments/fragments/detail.html:106 +#: templates/investments/fragments/detail.html:210 +#: templates/investments/fragments/detail.html:252 +#: templates/investments/fragments/detail.html:320 #: templates/mini_tools/simulator.html:66 #: templates/monthly_overview/fragments/monthly_summary.html:42 msgid "Income" msgstr "Доход" -#: apps/transactions/models.py:324 apps/transactions/models.py:971 -#: apps/transactions/models.py:1268 +#: apps/transactions/models.py:324 apps/transactions/models.py:995 +#: apps/transactions/models.py:1292 #: templates/calendar_view/fragments/list.html:46 #: templates/calendar_view/fragments/list.html:48 #: templates/calendar_view/fragments/list.html:56 @@ -1625,14 +1771,14 @@ msgstr "Доход" msgid "Expense" msgstr "Расход" -#: apps/transactions/models.py:379 apps/transactions/models.py:765 +#: apps/transactions/models.py:379 apps/transactions/models.py:789 #: templates/cotton/transaction/item.html:291 #: templates/cotton/transaction/item.html:308 #: templates/cotton/transaction/item.html:318 msgid "Installment Plan" msgstr "План рассрочки" -#: apps/transactions/models.py:388 apps/transactions/models.py:1045 +#: apps/transactions/models.py:388 apps/transactions/models.py:1069 #: templates/cotton/transaction/item.html:281 #: templates/cotton/transaction/item.html:301 msgid "Recurring Transaction" @@ -1652,160 +1798,172 @@ msgstr "Удалено" msgid "Deleted At" msgstr "" -#: apps/transactions/models.py:591 +#: apps/transactions/models.py:517 +msgid "A transfer needs one expense and one income." +msgstr "" + +#: apps/transactions/models.py:519 +msgid "A transfer needs two different accounts." +msgstr "" + +#: apps/transactions/models.py:523 +msgid "This transaction is already part of another transfer." +msgstr "" + +#: apps/transactions/models.py:615 msgid "No category" msgstr "Нет категории" -#: apps/transactions/models.py:593 +#: apps/transactions/models.py:617 msgid "No description" msgstr "Нет описания" -#: apps/transactions/models.py:650 +#: apps/transactions/models.py:674 #, fuzzy #| msgid "ZIP File" msgid "File" msgstr "ZIP File" -#: apps/transactions/models.py:652 +#: apps/transactions/models.py:676 msgid "Original Name" msgstr "" -#: apps/transactions/models.py:654 +#: apps/transactions/models.py:678 #, fuzzy #| msgid "Interval Type" msgid "Content Type" msgstr "Тип интервала" -#: apps/transactions/models.py:656 +#: apps/transactions/models.py:680 msgid "Size" msgstr "" -#: apps/transactions/models.py:661 +#: apps/transactions/models.py:685 msgid "Uploaded By" msgstr "" -#: apps/transactions/models.py:666 +#: apps/transactions/models.py:690 #, fuzzy #| msgid "Transaction rule" msgid "Transaction Attachment" msgstr "Правило транзакции" -#: apps/transactions/models.py:667 +#: apps/transactions/models.py:691 #, fuzzy #| msgid "Transaction Tags" msgid "Transaction Attachments" msgstr "Теги транзакций" -#: apps/transactions/models.py:697 templates/includes/sidebar.html:57 +#: apps/transactions/models.py:721 templates/includes/sidebar.html:57 msgid "Yearly" msgstr "Ежегодно" -#: apps/transactions/models.py:698 apps/users/models.py:469 +#: apps/transactions/models.py:722 apps/users/models.py:469 #: templates/includes/sidebar.html:51 msgid "Monthly" msgstr "Ежемесячно" -#: apps/transactions/models.py:699 +#: apps/transactions/models.py:723 msgid "Weekly" msgstr "Еженедельно" -#: apps/transactions/models.py:700 +#: apps/transactions/models.py:724 msgid "Daily" msgstr "Ежедневно" -#: apps/transactions/models.py:713 +#: apps/transactions/models.py:737 msgid "Number of Installments" msgstr "Количество платежей" -#: apps/transactions/models.py:718 +#: apps/transactions/models.py:742 msgid "Installment Start" msgstr "Начало выплаты рассрочки" -#: apps/transactions/models.py:719 +#: apps/transactions/models.py:743 msgid "The installment number to start counting from" msgstr "Номер платежа, с которого начинается отсчет" -#: apps/transactions/models.py:724 apps/transactions/models.py:1015 +#: apps/transactions/models.py:748 apps/transactions/models.py:1039 msgid "Start Date" msgstr "Дата начала" -#: apps/transactions/models.py:728 apps/transactions/models.py:1016 +#: apps/transactions/models.py:752 apps/transactions/models.py:1040 msgid "End Date" msgstr "Дата окончания" -#: apps/transactions/models.py:733 +#: apps/transactions/models.py:757 msgid "Recurrence" msgstr "" -#: apps/transactions/models.py:736 +#: apps/transactions/models.py:760 msgid "Installment Amount" msgstr "Сумма рассрочки" -#: apps/transactions/models.py:755 apps/transactions/models.py:1035 +#: apps/transactions/models.py:779 apps/transactions/models.py:1059 msgid "Add description to transactions" msgstr "Добавить описание к транзакциям" -#: apps/transactions/models.py:758 apps/transactions/models.py:1038 +#: apps/transactions/models.py:782 apps/transactions/models.py:1062 msgid "Add notes to transactions" msgstr "Добавить примечания к транзакциям" -#: apps/transactions/models.py:932 +#: apps/transactions/models.py:956 #, fuzzy #| msgid "Show amounts" msgid "To amount" msgstr "Показать суммы" -#: apps/transactions/models.py:933 +#: apps/transactions/models.py:957 msgid "Leave empty to use the same amount" msgstr "" -#: apps/transactions/models.py:974 templates/mini_tools/simulator.html:129 +#: apps/transactions/models.py:998 templates/mini_tools/simulator.html:129 msgid "day(s)" msgstr "день" -#: apps/transactions/models.py:975 templates/mini_tools/simulator.html:130 +#: apps/transactions/models.py:999 templates/mini_tools/simulator.html:130 msgid "week(s)" msgstr "неделя" -#: apps/transactions/models.py:976 templates/mini_tools/simulator.html:131 +#: apps/transactions/models.py:1000 templates/mini_tools/simulator.html:131 msgid "month(s)" msgstr "месяц" -#: apps/transactions/models.py:977 templates/mini_tools/simulator.html:132 +#: apps/transactions/models.py:1001 templates/mini_tools/simulator.html:132 msgid "year(s)" msgstr "год" -#: apps/transactions/models.py:979 +#: apps/transactions/models.py:1003 #: templates/recurring_transactions/fragments/list.html:18 msgid "Paused" msgstr "Приостановлено" -#: apps/transactions/models.py:1018 +#: apps/transactions/models.py:1042 msgid "Recurrence Type" msgstr "" -#: apps/transactions/models.py:1021 +#: apps/transactions/models.py:1045 msgid "Recurrence Interval" msgstr "" -#: apps/transactions/models.py:1024 +#: apps/transactions/models.py:1048 msgid "Keep at most" msgstr "Хранить максимум" -#: apps/transactions/models.py:1028 +#: apps/transactions/models.py:1052 msgid "Last Generated Date" msgstr "" -#: apps/transactions/models.py:1031 +#: apps/transactions/models.py:1055 msgid "Last Generated Reference Date" msgstr "" -#: apps/transactions/models.py:1334 templates/cotton/transaction/item.html:274 +#: apps/transactions/models.py:1358 templates/cotton/transaction/item.html:274 #: templates/cotton/ui/transactions_fab.html:59 msgid "Quick Transaction" msgstr "Быстрая сделка" -#: apps/transactions/models.py:1335 templates/includes/sidebar.html:98 +#: apps/transactions/models.py:1359 templates/includes/sidebar.html:98 #: templates/quick_transactions/pages/index.html:5 #: templates/quick_transactions/pages/index.html:15 msgid "Quick Transactions" @@ -1934,8 +2092,8 @@ msgid "Item deleted successfully" msgstr "Элемент успешно удален" #: apps/transactions/views/quick_transactions.py:164 -#: apps/transactions/views/transactions.py:143 -#: apps/transactions/views/transactions.py:328 +#: apps/transactions/views/transactions.py:156 +#: apps/transactions/views/transactions.py:343 msgid "Transaction added successfully" msgstr "Транзакция успешно добавлена" @@ -1971,46 +2129,46 @@ msgstr "Тег успешно обновлен" msgid "Tag deleted successfully" msgstr "Тег успешно удален" -#: apps/transactions/views/transactions.py:61 +#: apps/transactions/views/transactions.py:65 #, fuzzy #| msgid "Account updated successfully" msgid "Attachment uploaded successfully" msgstr "Счет успешно обновлен" -#: apps/transactions/views/transactions.py:112 +#: apps/transactions/views/transactions.py:116 #, fuzzy #| msgid "Account deleted successfully" msgid "Attachment deleted successfully" msgstr "Счет успешно удален" -#: apps/transactions/views/transactions.py:355 +#: apps/transactions/views/transactions.py:370 msgid "Transaction updated successfully" msgstr "Транзакция успешно обновлена" -#: apps/transactions/views/transactions.py:406 +#: apps/transactions/views/transactions.py:421 #, python-format msgid "%(count)s transaction updated successfully" msgid_plural "%(count)s transactions updated successfully" msgstr[0] "" msgstr[1] "" -#: apps/transactions/views/transactions.py:433 +#: apps/transactions/views/transactions.py:448 msgid "Transaction duplicated successfully" msgstr "Транзакция успешно продублирована" -#: apps/transactions/views/transactions.py:478 +#: apps/transactions/views/transactions.py:493 msgid "Transaction deleted successfully" msgstr "Транзакция успешно удалена" -#: apps/transactions/views/transactions.py:508 +#: apps/transactions/views/transactions.py:523 msgid "Transaction restored successfully" msgstr "Транзакция успешно восстановлена" -#: apps/transactions/views/transactions.py:534 +#: apps/transactions/views/transactions.py:549 msgid "Transfer added successfully" msgstr "Перевод успешно добавлен" -#: apps/transactions/views/transactions.py:562 +#: apps/transactions/views/transactions.py:579 #, fuzzy #| msgid "Transfer added successfully" msgid "Transfer updated successfully" @@ -2341,6 +2499,7 @@ msgstr "Редактировать группу учетных записей" #: templates/exchange_rates_services/fragments/table.html:20 #: templates/import_app/fragments/profiles/list.html:42 #: templates/installment_plans/fragments/table.html:25 +#: templates/investment_classes/fragments/list.html:36 #: templates/quick_transactions/fragments/list.html:22 #: templates/recurring_transactions/fragments/table.html:27 #: templates/rules/fragments/list.html:31 @@ -2363,6 +2522,9 @@ msgstr "" #: templates/exchange_rates_services/fragments/table.html:23 #: templates/import_app/fragments/profiles/list.html:46 #: templates/installment_plans/fragments/table.html:28 +#: templates/investment_classes/fragments/list.html:39 +#: templates/investments/fragments/detail.html:305 +#: templates/investments/fragments/detail.html:331 #: templates/quick_transactions/fragments/list.html:25 #: templates/recurring_transactions/fragments/table.html:30 #: templates/rules/fragments/transaction_rule/view.html:22 @@ -2412,6 +2574,7 @@ msgstr "Поделиться" #: templates/import_app/fragments/profiles/list.html:81 #: templates/import_app/fragments/runs/list.html:100 #: templates/installment_plans/fragments/table.html:54 +#: templates/investment_classes/fragments/list.html:45 #: templates/mini_tools/simulator.html:109 #: templates/mini_tools/unit_price_calculator.html:16 #: templates/quick_transactions/fragments/list.html:32 @@ -2445,6 +2608,7 @@ msgstr "Удалить" #: templates/import_app/fragments/runs/list.html:104 #: templates/installment_plans/fragments/table.html:47 #: templates/installment_plans/fragments/table.html:58 +#: templates/investment_classes/fragments/list.html:49 #: templates/quick_transactions/fragments/list.html:37 #: templates/recurring_transactions/fragments/table.html:52 #: templates/recurring_transactions/fragments/table.html:65 @@ -2494,6 +2658,7 @@ msgstr "Вы не сможете отменить это!" #: templates/import_app/fragments/profiles/list.html:87 #: templates/import_app/fragments/runs/list.html:106 #: templates/installment_plans/fragments/table.html:60 +#: templates/investment_classes/fragments/list.html:51 #: templates/quick_transactions/fragments/list.html:39 #: templates/recurring_transactions/fragments/table.html:94 #: templates/rules/fragments/list.html:62 @@ -2542,6 +2707,7 @@ msgid "Add account" msgstr "Добавить счёт" #: templates/accounts/fragments/edit.html:5 +#: templates/investments/fragments/detail.html:40 msgid "Edit account" msgstr "" @@ -2614,7 +2780,7 @@ msgstr "Редактировать категорию" msgid "Pick a month" msgstr "Выберите месяц" -#: templates/common/fragments/toasts.html:20 +#: templates/common/fragments/toasts.html:18 #: templates/cotton/components/fab.html:9 #: templates/cotton/components/sidebar_collapsible_panel.html:28 #: templates/extends/offcanvas.html:4 templates/includes/sidebar.html:44 @@ -2843,6 +3009,8 @@ msgid "Add DCA strategy" msgstr "" #: templates/dca/fragments/strategy/details.html:28 +#: templates/investments/fragments/detail.html:92 +#: templates/investments/fragments/list.html:65 msgid "No exchange rate available" msgstr "Курс обмена недоступен" @@ -3024,7 +3192,7 @@ msgstr "" msgid "No services configured" msgstr "Службы не настроены" -#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:211 +#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:225 msgid "Export and Restore" msgstr "Экспорт и восстановление" @@ -3195,54 +3363,65 @@ msgstr "" msgid "Tools" msgstr "Инструменты" -#: templates/includes/sidebar.html:118 +#: templates/includes/sidebar.html:118 templates/includes/sidebar.html:188 +#: templates/investment_classes/fragments/list.html:29 +#: templates/investments/fragments/detail.html:37 +#: templates/investments/fragments/list.html:9 +#: templates/investments/pages/detail.html:4 +#: templates/investments/pages/index.html:4 +#, fuzzy +#| msgid "Installments" +msgid "Investments" +msgstr "Рассрочки" + +#: templates/includes/sidebar.html:124 msgid "Dollar Cost Average Tracker" msgstr "Трекер средней стоимости доллара" -#: templates/includes/sidebar.html:124 +#: templates/includes/sidebar.html:130 #: templates/mini_tools/unit_price_calculator.html:4 #: templates/mini_tools/unit_price_calculator.html:9 msgid "Unit Price Calculator" msgstr "Калькулятор цены за единицу" -#: templates/includes/sidebar.html:130 +#: templates/includes/sidebar.html:136 #: templates/mini_tools/currency_converter/currency_converter.html:7 #: templates/mini_tools/currency_converter/currency_converter.html:14 msgid "Currency Converter" msgstr "Конвертер валют" -#: templates/includes/sidebar.html:136 templates/mini_tools/simulator.html:5 +#: templates/includes/sidebar.html:142 templates/mini_tools/simulator.html:5 #: templates/mini_tools/simulator.html:21 #, fuzzy #| msgid "Calculator" msgid "Simulator" msgstr "Калькулятор" -#: templates/includes/sidebar.html:145 +#: templates/includes/sidebar.html:151 msgid "Management" msgstr "Управление" -#: templates/includes/sidebar.html:196 +#: templates/includes/sidebar.html:210 msgid "Automation" msgstr "" -#: templates/includes/sidebar.html:225 +#: templates/includes/sidebar.html:239 msgid "Admin" msgstr "Администратор" -#: templates/includes/sidebar.html:233 +#: templates/includes/sidebar.html:247 msgid "Django Admin" msgstr "Администратор Django" -#: templates/includes/sidebar.html:234 +#: templates/includes/sidebar.html:248 msgid "Only use this if you know what you're doing" msgstr "" -#: templates/includes/sidebar.html:249 +#: templates/includes/sidebar.html:263 msgid "is available" msgstr "" -#: templates/includes/sidebar.html:257 +#: templates/includes/sidebar.html:271 msgid "Calculator" msgstr "Калькулятор" @@ -3309,6 +3488,7 @@ msgstr "Нет недавних транзакций" #: templates/insights/fragments/overview/_results.html:173 #: templates/insights/fragments/year_by_year.html:59 #: templates/insights/fragments/year_by_year.html:140 +#: templates/investment_classes/fragments/list.html:65 #: templates/monthly_overview/fragments/monthly_summary.html:168 msgid "Total" msgstr "Итог" @@ -3506,6 +3686,295 @@ msgstr "" msgid "No installment plans" msgstr "" +#: templates/investment_classes/fragments/form.html:5 +msgid "Edit asset class" +msgstr "" + +#: templates/investment_classes/fragments/form.html:5 +msgid "Add asset class" +msgstr "" + +#: templates/investment_classes/fragments/list.html:19 +#: templates/investments/fragments/rebalance.html:10 +#, python-format +msgid "Targets add up to %(total)s%%. Set them to 100%% to see the rebalance." +msgstr "" + +#: templates/investment_classes/fragments/list.html:28 +#: templates/investments/fragments/rebalance.html:25 +msgid "Target" +msgstr "" + +#: templates/investment_classes/fragments/list.html:50 +msgid "Its investments will be left without an asset class." +msgstr "" + +#: templates/investment_classes/fragments/list.html:74 +msgid "No asset classes" +msgstr "" + +#: templates/investments/fragments/_allocation.html:3 +msgid "Allocation chart" +msgstr "" + +#: templates/investments/fragments/_allocation.html:11 +#, python-format +msgid "" +"%(share)s%% of your investments with a target, against a target of " +"%(target)s%%" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "Annual return, weighted by when money went in and out" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "" +"Return since the first contribution, weighted by when money went in and out" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "/yr" +msgstr "" + +#: templates/investments/fragments/_totals.html:15 +#: templates/investments/fragments/detail.html:63 +msgid "total return" +msgstr "" + +#: templates/investments/fragments/_totals.html:20 +#: templates/investments/fragments/_totals.html:41 +#: templates/investments/fragments/charts.html:96 +#: templates/investments/fragments/detail.html:163 +#: templates/investments/fragments/detail.html:223 +#: templates/investments/fragments/list.html:47 +#, fuzzy +#| msgid "Total Invested" +msgid "Invested" +msgstr "Общий объем инвестиций" + +#: templates/investments/fragments/_totals.html:24 +#: templates/investments/fragments/detail.html:74 +msgid "" +"How much of the return came from exchange rates moving since you invested" +msgstr "" + +#: templates/investments/fragments/_totals.html:24 +#: templates/investments/fragments/_totals.html:26 +#, fuzzy +#| msgid "Currency Code" +msgid "Currency effect" +msgstr "Код валюты" + +#: templates/investments/fragments/_totals.html:26 +#: templates/investments/fragments/detail.html:78 +msgid "" +"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." +msgstr "" + +#: templates/investments/fragments/_totals.html:40 +#: templates/investments/fragments/list.html:49 +msgid "Return" +msgstr "" + +#: templates/investments/fragments/charts.html:10 +msgid "By asset class" +msgstr "" + +#: templates/investments/fragments/charts.html:17 +#: templates/net_worth/net_worth.html:52 +#: templates/yearly_overview/pages/overview_by_currency.html:6 +msgid "By currency" +msgstr "По валюте" + +#: templates/investments/fragments/charts.html:24 +#, fuzzy +#| msgid "Total Invested" +msgid "Value and invested" +msgstr "Общий объем инвестиций" + +#: templates/investments/fragments/charts.html:31 +msgid "Return by investment" +msgstr "" + +#: templates/investments/fragments/charts.html:38 +msgid "Nothing to chart yet" +msgstr "" + +#: templates/investments/fragments/detail.html:10 +msgid "Buy / Contribute" +msgstr "" + +#: templates/investments/fragments/detail.html:16 +msgid "Sell / Withdraw" +msgstr "" + +#: templates/investments/fragments/detail.html:23 +msgid "Dividend / Income" +msgstr "" + +#: templates/investments/fragments/detail.html:30 +#, fuzzy +#| msgid "Update" +msgid "Update value" +msgstr "Обновить" + +#: templates/investments/fragments/detail.html:47 +#: templates/investments/fragments/list.html:67 +msgid "Tracked by value" +msgstr "" + +#: templates/investments/fragments/detail.html:48 +msgid "" +"Some incoming units have no linked payment, so their cost was estimated from " +"the market price of that day" +msgstr "" + +#: templates/investments/fragments/detail.html:68 +#, fuzzy +#| msgid "Info" +msgid "In" +msgstr "Информация" + +#: templates/investments/fragments/detail.html:74 +#: templates/investments/fragments/detail.html:78 +#, fuzzy +#| msgid "Currency Code" +msgid "currency effect" +msgstr "Код валюты" + +#: templates/investments/fragments/detail.html:104 +#: templates/investments/fragments/detail.html:179 +msgid "Price" +msgstr "" + +#: templates/investments/fragments/detail.html:105 +#: templates/investments/fragments/detail.html:200 +#: templates/investments/fragments/detail.html:239 +#: templates/investments/fragments/detail.html:290 +#, fuzzy +#| msgid "Warning" +msgid "Earnings" +msgstr "Предупреждение" + +#: templates/investments/fragments/detail.html:180 +#: templates/investments/fragments/detail.html:227 +msgid "Average cost" +msgstr "" + +#: templates/investments/fragments/detail.html:181 +#: templates/investments/fragments/rebalance.html:41 +msgid "Buy" +msgstr "" + +#: templates/investments/fragments/detail.html:182 +#: templates/investments/fragments/rebalance.html:43 +msgid "Sell" +msgstr "" + +#: templates/investments/fragments/detail.html:227 +#: templates/investments/fragments/detail.html:257 +msgid "Put in / taken out" +msgstr "" + +#: templates/investments/fragments/detail.html:239 +#, fuzzy +#| msgid "Uncategorized" +msgid "Unrealized" +msgstr "Без категории" + +#: templates/investments/fragments/detail.html:248 +msgid "Realized" +msgstr "" + +#: templates/investments/fragments/detail.html:271 +#, fuzzy +#| msgid "Management" +msgid "Movements" +msgstr "Управление" + +#: templates/investments/fragments/detail.html:284 +msgid "Bought / contributed" +msgstr "" + +#: templates/investments/fragments/detail.html:284 +msgid "Sold / withdrew" +msgstr "" + +#: templates/investments/fragments/detail.html:288 +msgid "Money in" +msgstr "" + +#: templates/investments/fragments/detail.html:288 +msgid "Money out" +msgstr "" + +#: templates/investments/fragments/detail.html:290 +msgid "Gain" +msgstr "" + +#: templates/investments/fragments/detail.html:290 +msgid "Loss, tax or fee" +msgstr "" + +#: templates/investments/fragments/detail.html:315 +msgid "No movements yet" +msgstr "" + +#: templates/investments/fragments/detail.html:315 +msgid "Use the + button to buy or contribute" +msgstr "" + +#: templates/investments/fragments/list.html:21 +msgid "Holdings" +msgstr "" + +#: templates/investments/fragments/list.html:60 +msgid "Some costs were estimated from market prices" +msgstr "" + +#: templates/investments/fragments/list.html:92 +msgid "Charts" +msgstr "" + +#: templates/investments/fragments/list.html:97 +#, fuzzy +#| msgid "New balance" +msgid "Rebalance" +msgstr "Новый баланс" + +#: templates/investments/fragments/list.html:105 +#, fuzzy +#| msgid "Number of Installments" +msgid "No investments yet" +msgstr "Количество платежей" + +#: templates/investments/fragments/list.html:106 +msgid "Mark an account as an investment in its settings to see it here" +msgstr "" + +#: templates/investments/fragments/rebalance.html:4 +msgid "No allocation targets" +msgstr "" + +#: templates/investments/fragments/rebalance.html:5 +msgid "Set a target for your asset classes to see what to buy and sell" +msgstr "" + +#: templates/investments/fragments/rebalance.html:26 +msgid "To reach the target" +msgstr "" + +#: templates/investments/fragments/rebalance.html:34 +msgid "Off target" +msgstr "" + +#: templates/investments/fragments/rebalance.html:55 +msgid "Left out: investments without an asset class or a target" +msgstr "" + #: templates/layouts/base.html:38 msgid "This is a demo!" msgstr "Это демо-версия!" @@ -3723,11 +4192,6 @@ msgstr "Фильтр транзакций" msgid "Order by" msgstr "Сортировать по" -#: templates/net_worth/net_worth.html:52 -#: templates/yearly_overview/pages/overview_by_currency.html:6 -msgid "By currency" -msgstr "По валюте" - #: templates/net_worth/net_worth.html:135 msgid "Evolution" msgstr "" @@ -4194,6 +4658,12 @@ msgstr "Войти с помощью" msgid "Yearly Overview" msgstr "Годовой обзор" +#~ msgid "" +#~ "Create one exchange rate and keep updating it. Avoids database clutter." +#~ msgstr "" +#~ "Создайте один обменный курс и постоянно обновляйте его. Это позволит " +#~ "избежать захламления базы данных." + #~ msgid "Add as quick transaction" #~ msgstr "Добавить как быструю транзакцию" diff --git a/app/locale/sv/LC_MESSAGES/django.po b/app/locale/sv/LC_MESSAGES/django.po index a3ea05c6..97346144 100644 --- a/app/locale/sv/LC_MESSAGES/django.po +++ b/app/locale/sv/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-08 06:01+0000\n" +"POT-Creation-Date: 2026-11-08 18:27+0000\n" "PO-Revision-Date: 2025-04-14 06:16+0000\n" "Last-Translator: Emil \n" "Language-Team: Swedish \n" "Language-Team: Swahili \n" "Language-Team: Ukrainian =20) ? 1 : 2;\n" "X-Generator: Weblate 5.14\n" -#: apps/accounts/forms.py:28 +#: apps/accounts/forms.py:33 msgid "Group name" msgstr "Назва групи" -#: apps/accounts/forms.py:43 apps/accounts/forms.py:109 -#: apps/currencies/forms.py:57 apps/currencies/forms.py:91 -#: apps/currencies/forms.py:166 apps/dca/forms.py:46 apps/dca/forms.py:205 -#: apps/import_app/forms.py:32 apps/rules/forms.py:60 apps/rules/forms.py:100 -#: apps/rules/forms.py:385 apps/transactions/forms.py:219 -#: apps/transactions/forms.py:452 apps/transactions/forms.py:571 -#: apps/transactions/forms.py:718 apps/transactions/forms.py:940 -#: apps/transactions/forms.py:987 apps/transactions/forms.py:1015 -#: apps/transactions/forms.py:1046 apps/transactions/forms.py:1208 -#: apps/users/forms.py:264 apps/users/forms.py:422 +#: apps/accounts/forms.py:48 apps/accounts/forms.py:76 +#: apps/accounts/forms.py:184 apps/currencies/forms.py:57 +#: apps/currencies/forms.py:91 apps/currencies/forms.py:168 +#: apps/dca/forms.py:46 apps/dca/forms.py:192 apps/import_app/forms.py:32 +#: apps/rules/forms.py:60 apps/rules/forms.py:100 apps/rules/forms.py:385 +#: apps/transactions/forms.py:219 apps/transactions/forms.py:452 +#: apps/transactions/forms.py:571 apps/transactions/forms.py:718 +#: apps/transactions/forms.py:940 apps/transactions/forms.py:987 +#: apps/transactions/forms.py:1015 apps/transactions/forms.py:1046 +#: apps/transactions/forms.py:1208 apps/users/forms.py:264 +#: apps/users/forms.py:422 #: templates/rules/fragments/transaction_rule/dry_run/updated.html:5 #: templates/rules/fragments/transaction_rule/view.html:128 msgid "Update" msgstr "Оновлення" -#: apps/accounts/forms.py:49 apps/accounts/forms.py:115 -#: apps/common/widgets/tom_select.py:13 apps/currencies/forms.py:63 -#: apps/currencies/forms.py:97 apps/currencies/forms.py:172 -#: apps/dca/forms.py:52 apps/dca/forms.py:211 apps/import_app/forms.py:38 -#: apps/rules/forms.py:66 apps/rules/forms.py:106 apps/rules/forms.py:391 -#: apps/transactions/forms.py:206 apps/transactions/forms.py:226 -#: apps/transactions/forms.py:459 apps/transactions/forms.py:946 -#: apps/transactions/forms.py:993 apps/transactions/forms.py:1021 -#: apps/transactions/forms.py:1052 apps/transactions/forms.py:1214 -#: apps/users/forms.py:270 apps/users/forms.py:428 -#: templates/mini_tools/simulator.html:177 +#: apps/accounts/forms.py:54 apps/accounts/forms.py:76 +#: apps/accounts/forms.py:190 apps/common/widgets/tom_select.py:15 +#: apps/currencies/forms.py:63 apps/currencies/forms.py:97 +#: apps/currencies/forms.py:174 apps/dca/forms.py:52 apps/dca/forms.py:198 +#: apps/import_app/forms.py:38 apps/rules/forms.py:66 apps/rules/forms.py:106 +#: apps/rules/forms.py:391 apps/transactions/forms.py:206 +#: apps/transactions/forms.py:226 apps/transactions/forms.py:459 +#: apps/transactions/forms.py:946 apps/transactions/forms.py:993 +#: apps/transactions/forms.py:1021 apps/transactions/forms.py:1052 +#: apps/transactions/forms.py:1214 apps/users/forms.py:270 +#: apps/users/forms.py:428 templates/mini_tools/simulator.html:177 #: templates/mini_tools/unit_price_calculator.html:168 msgid "Add" msgstr "Додати" -#: apps/accounts/forms.py:57 templates/accounts/fragments/list.html:23 +#: apps/accounts/forms.py:86 templates/accounts/fragments/list.html:23 msgid "Group" msgstr "Група" -#: apps/accounts/forms.py:123 +#: apps/accounts/forms.py:93 apps/accounts/models.py:73 +#: apps/accounts/models.py:130 +#: templates/investments/fragments/rebalance.html:22 +msgid "Asset class" +msgstr "" + +#: apps/accounts/forms.py:198 msgid "New balance" msgstr "Новий баланс" -#: apps/accounts/forms.py:129 apps/dca/forms.py:79 apps/dca/forms.py:86 +#: apps/accounts/forms.py:204 apps/dca/forms.py:79 apps/dca/forms.py:86 #: apps/insights/forms.py:117 apps/insights/views.py:44 apps/rules/forms.py:181 #: apps/rules/forms.py:197 apps/rules/models.py:44 apps/rules/models.py:311 #: apps/transactions/forms.py:62 apps/transactions/forms.py:330 #: apps/transactions/forms.py:510 apps/transactions/forms.py:607 #: apps/transactions/forms.py:614 apps/transactions/forms.py:823 #: apps/transactions/forms.py:1075 apps/transactions/models.py:357 -#: apps/transactions/models.py:743 apps/transactions/models.py:999 -#: apps/transactions/models.py:1306 +#: apps/transactions/models.py:767 apps/transactions/models.py:1023 +#: apps/transactions/models.py:1330 #: templates/insights/fragments/month_by_month.html:84 #: templates/insights/fragments/year_by_year.html:52 msgid "Category" msgstr "Категорія" -#: apps/accounts/forms.py:136 apps/dca/forms.py:95 apps/dca/forms.py:103 -#: apps/export_app/forms.py:43 apps/export_app/forms.py:132 +#: apps/accounts/forms.py:211 apps/dca/forms.py:95 apps/dca/forms.py:103 +#: apps/export_app/forms.py:43 apps/export_app/forms.py:134 #: apps/insights/views.py:50 apps/rules/forms.py:184 apps/rules/forms.py:194 #: apps/rules/models.py:45 apps/rules/models.py:315 #: apps/transactions/filters.py:79 apps/transactions/forms.py:70 #: apps/transactions/forms.py:338 apps/transactions/forms.py:518 #: apps/transactions/forms.py:623 apps/transactions/forms.py:631 #: apps/transactions/forms.py:816 apps/transactions/forms.py:1068 -#: apps/transactions/models.py:363 apps/transactions/models.py:745 -#: apps/transactions/models.py:1003 apps/transactions/models.py:1312 -#: templates/includes/sidebar.html:156 +#: apps/transactions/models.py:363 apps/transactions/models.py:769 +#: apps/transactions/models.py:1027 apps/transactions/models.py:1336 +#: templates/includes/sidebar.html:162 #: templates/insights/fragments/month_by_month.html:29 #: templates/insights/fragments/month_by_month.html:32 #: templates/insights/fragments/year_by_year.html:24 @@ -95,27 +102,27 @@ msgstr "Категорія" msgid "Tags" msgstr "Мітки" -#: apps/accounts/forms.py:179 +#: apps/accounts/forms.py:254 #, fuzzy #| msgid "Transactions" msgid "All paid transactions" msgstr "Транзакції" -#: apps/accounts/forms.py:180 +#: apps/accounts/forms.py:255 #, fuzzy #| msgid "Update" msgid "Up to date" msgstr "Оновлення" -#: apps/accounts/forms.py:181 +#: apps/accounts/forms.py:256 msgid "Up to reference month" msgstr "" -#: apps/accounts/forms.py:185 +#: apps/accounts/forms.py:260 msgid "Balance against" msgstr "" -#: apps/accounts/forms.py:189 apps/currencies/forms.py:70 +#: apps/accounts/forms.py:264 apps/currencies/forms.py:70 #: apps/dca/models.py:158 apps/rules/forms.py:176 apps/rules/forms.py:190 #: apps/rules/models.py:38 apps/rules/models.py:279 #: apps/transactions/forms.py:86 apps/transactions/forms.py:482 @@ -126,14 +133,15 @@ msgstr "" msgid "Date" msgstr "Дата" -#: apps/accounts/forms.py:194 +#: apps/accounts/forms.py:269 msgid "Reference month" msgstr "" -#: apps/accounts/models.py:12 apps/accounts/models.py:29 apps/dca/models.py:13 +#: apps/accounts/models.py:19 apps/accounts/models.py:36 +#: apps/accounts/models.py:84 apps/dca/models.py:13 #: apps/import_app/models.py:20 apps/rules/models.py:13 #: apps/transactions/models.py:249 apps/transactions/models.py:274 -#: apps/transactions/models.py:298 apps/transactions/models.py:1274 +#: apps/transactions/models.py:298 apps/transactions/models.py:1298 #: apps/users/models.py:577 templates/account_groups/fragments/list.html:22 #: templates/accounts/fragments/list.html:22 #: templates/categories/fragments/table.html:17 @@ -142,6 +150,7 @@ msgstr "" #: templates/exchange_rates_services/fragments/list.html:28 #: templates/import_app/fragments/profiles/list.html:34 #: templates/installment_plans/fragments/table.html:16 +#: templates/investment_classes/fragments/list.html:27 #: templates/quick_transactions/fragments/list.html:13 #: templates/recurring_transactions/fragments/table.html:18 #: templates/rules/fragments/list.html:24 @@ -150,44 +159,127 @@ msgstr "" msgid "Name" msgstr "Ім'я" -#: apps/accounts/models.py:18 apps/accounts/models.py:33 +#: apps/accounts/models.py:25 apps/accounts/models.py:88 msgid "Account Group" msgstr "Група рахунків" -#: apps/accounts/models.py:19 templates/account_groups/fragments/list.html:9 +#: apps/accounts/models.py:26 templates/account_groups/fragments/list.html:9 #: templates/account_groups/pages/index.html:4 -#: templates/includes/sidebar.html:176 +#: templates/includes/sidebar.html:182 msgid "Account Groups" msgstr "Групи рахунків" -#: apps/accounts/models.py:39 apps/currencies/models.py:44 +#: apps/accounts/models.py:43 +msgid "Target allocation (%)" +msgstr "" + +#: apps/accounts/models.py:45 +msgid "" +"Share of your investments you want in this class. Leave it empty to leave " +"the class out of the targets." +msgstr "" + +#: apps/accounts/models.py:61 +msgid "You already have an asset class with this name." +msgstr "" + +#: apps/accounts/models.py:66 +#, python-format +msgid "" +"Targets can't add up to more than 100%%. Other classes already take " +"%(taken)s%%." +msgstr "" + +#: apps/accounts/models.py:74 apps/export_app/forms.py:131 +#: templates/includes/sidebar.html:190 +#: templates/investment_classes/fragments/list.html:9 +#: templates/investment_classes/pages/index.html:4 +#: templates/investments/fragments/rebalance.html:6 +#: templates/investments/fragments/rebalance.html:11 +msgid "Asset classes" +msgstr "" + +#: apps/accounts/models.py:94 apps/currencies/models.py:44 #: templates/accounts/fragments/list.html:24 #: templates/mini_tools/simulator.html:37 #: templates/yearly_overview/pages/yearly_overview_base.html:19 msgid "Currency" msgstr "Валюта" -#: apps/accounts/models.py:45 apps/currencies/models.py:27 +#: apps/accounts/models.py:100 apps/currencies/models.py:27 #: templates/accounts/fragments/list.html:25 msgid "Exchange Currency" msgstr "Валюта обміну" -#: apps/accounts/models.py:50 apps/currencies/models.py:32 +#: apps/accounts/models.py:105 apps/currencies/models.py:32 msgid "Default currency for exchange calculations" msgstr "Валюта за замовчуванням для обмінних розрахунків" -#: apps/accounts/models.py:55 +#: apps/accounts/models.py:110 #, fuzzy msgid "Asset account" msgstr "Рахунок активу" -#: apps/accounts/models.py:57 +#: apps/accounts/models.py:112 msgid "" "Asset accounts count towards your Net Worth, but not towards your month." msgstr "" "Рахунки активів враховуються у вашій чистій вартості, але не у вашому місяці." -#: apps/accounts/models.py:62 apps/currencies/models.py:37 +#: apps/accounts/models.py:117 templates/investments/fragments/list.html:46 +msgid "Investment" +msgstr "" + +#: apps/accounts/models.py:119 +msgid "" +"Show this account in the Investments tracker. Set an exchange currency to " +"price it by units (stocks, ETFs, crypto); leave it empty to track it by " +"value (fixed income, property, pensions)." +msgstr "" + +#: apps/accounts/models.py:132 +msgid "" +"Groups investments in the allocation charts, e.g. Fixed income, Stocks, Real " +"estate." +msgstr "" + +#: apps/accounts/models.py:140 +#, fuzzy +#| msgid "Income Transaction" +msgid "Income entities" +msgstr "Дохідна транзакція" + +#: apps/accounts/models.py:142 +msgid "" +"Transactions with any of these entities count towards this investment's " +"return: in this account, and income paid into other accounts (dividends, " +"rent)." +msgstr "" + +#: apps/accounts/models.py:151 +#, fuzzy +#| msgid "Categories" +msgid "Income categories" +msgstr "Категорії" + +#: apps/accounts/models.py:153 +msgid "" +"Transactions in this account with any of these categories are its result " +"(interest, taxes, fees). On accounts tracked by value, everything else that " +"isn't a transfer counts as money coming in or going out." +msgstr "" + +#: apps/accounts/models.py:162 +#, fuzzy +#| msgid "Income Transaction" +msgid "Income tags" +msgstr "Дохідна транзакція" + +#: apps/accounts/models.py:164 +msgid "Same as income categories, for transactions with any of these tags." +msgstr "" + +#: apps/accounts/models.py:169 apps/currencies/models.py:37 #: templates/accounts/fragments/list.html:27 #: templates/categories/fragments/list.html:20 #: templates/currencies/fragments/list.html:24 @@ -196,19 +288,19 @@ msgstr "" msgid "Archived" msgstr "Архівовано" -#: apps/accounts/models.py:63 +#: apps/accounts/models.py:170 msgid "Archived accounts don't show up nor count towards your net worth" msgstr "" "Заархівовані рахунки не відображаються і не враховуються у вашій чистій " "вартості" -#: apps/accounts/models.py:75 apps/rules/forms.py:173 apps/rules/forms.py:187 +#: apps/accounts/models.py:182 apps/rules/forms.py:173 apps/rules/forms.py:187 #: apps/rules/models.py:35 apps/rules/models.py:267 #: apps/transactions/forms.py:82 apps/transactions/forms.py:350 #: apps/transactions/forms.py:477 apps/transactions/forms.py:808 #: apps/transactions/forms.py:1060 apps/transactions/models.py:329 -#: apps/transactions/models.py:703 apps/transactions/models.py:981 -#: apps/transactions/models.py:1280 +#: apps/transactions/models.py:727 apps/transactions/models.py:1005 +#: apps/transactions/models.py:1304 #: templates/installment_plans/fragments/table.html:17 #: templates/quick_transactions/fragments/list.html:14 #: templates/recurring_transactions/fragments/table.html:19 @@ -216,17 +308,17 @@ msgstr "" msgid "Account" msgstr "Рахунок" -#: apps/accounts/models.py:76 apps/export_app/forms.py:19 -#: apps/export_app/forms.py:129 apps/transactions/filters.py:63 +#: apps/accounts/models.py:183 apps/export_app/forms.py:19 +#: apps/export_app/forms.py:130 apps/transactions/filters.py:63 #: templates/accounts/fragments/list.html:9 -#: templates/accounts/pages/index.html:4 templates/includes/sidebar.html:168 -#: templates/includes/sidebar.html:170 +#: templates/accounts/pages/index.html:4 templates/includes/sidebar.html:174 +#: templates/includes/sidebar.html:176 #: templates/monthly_overview/pages/overview.html:77 #: templates/transactions/pages/transactions.html:26 msgid "Accounts" msgstr "Рахунки" -#: apps/accounts/models.py:93 +#: apps/accounts/models.py:200 msgid "Exchange currency cannot be the same as the account's main currency." msgstr "Валюта обміну не може збігатися з основною валютою рахунку." @@ -244,7 +336,7 @@ msgstr "Групу рахунків успішно видалено" #: apps/accounts/views/account_groups.py:109 #: apps/accounts/views/accounts.py:137 apps/dca/views.py:106 -#: apps/import_app/views.py:147 apps/rules/views.py:191 +#: apps/import_app/views.py:148 apps/rules/views.py:191 #: apps/transactions/views/categories.py:163 #: apps/transactions/views/entities.py:135 apps/transactions/views/tags.py:131 msgid "Item no longer shared with you" @@ -252,7 +344,7 @@ msgstr "Елемент більше не доступний для вас" #: apps/accounts/views/account_groups.py:132 #: apps/accounts/views/accounts.py:182 apps/dca/views.py:131 -#: apps/import_app/views.py:273 apps/rules/views.py:217 +#: apps/import_app/views.py:274 apps/rules/views.py:217 #: apps/transactions/views/categories.py:188 #: apps/transactions/views/entities.py:160 apps/transactions/views/tags.py:154 msgid "Ownership taken successfully" @@ -260,7 +352,7 @@ msgstr "Успішно прийнято право власності" #: apps/accounts/views/account_groups.py:152 #: apps/accounts/views/accounts.py:106 apps/dca/views.py:151 -#: apps/import_app/views.py:294 apps/rules/views.py:238 +#: apps/import_app/views.py:295 apps/rules/views.py:238 #: apps/transactions/views/categories.py:130 #: apps/transactions/views/entities.py:180 apps/transactions/views/tags.py:174 msgid "Configuration saved successfully" @@ -318,7 +410,7 @@ msgstr "Об'єкт з таким ідентифікатором не існує msgid "Invalid entity data. Provide an ID or name." msgstr "Невірні дані про об'єкт. Вкажіть ідентифікатор або ім'я." -#: apps/api/serializers/transactions.py:141 apps/transactions/models.py:953 +#: apps/api/serializers/transactions.py:141 apps/transactions/models.py:977 msgid "This field is required." msgstr "" @@ -340,13 +432,14 @@ msgstr "Private" msgid "Shared with users" msgstr "Поділитися з користувачами" -#: apps/common/fields/forms/dynamic_select.py:71 -#: apps/common/fields/forms/dynamic_select.py:142 +#: apps/common/fields/forms/dynamic_select.py:79 +#: apps/common/fields/forms/dynamic_select.py:150 msgid "Error creating new instance" msgstr "Помилка створення нового екземпляра" #: apps/common/fields/forms/grouped_select.py:24 -#: apps/common/widgets/tom_select.py:92 apps/common/widgets/tom_select.py:95 +#: apps/common/widgets/tom_select.py:94 apps/common/widgets/tom_select.py:97 +#: templates/investments/fragments/list.html:26 msgid "Ungrouped" msgstr "Не згруповані" @@ -500,14 +593,15 @@ msgid "Today" msgstr "Сьогодні" #: apps/common/widgets/datepicker.py:142 +#: templates/investments/fragments/rebalance.html:24 msgid "Now" msgstr "Зараз" -#: apps/common/widgets/tom_select.py:11 +#: apps/common/widgets/tom_select.py:13 msgid "Remove" msgstr "Видалити" -#: apps/common/widgets/tom_select.py:15 templates/mini_tools/simulator.html:187 +#: apps/common/widgets/tom_select.py:17 templates/mini_tools/simulator.html:187 #: templates/mini_tools/unit_price_calculator.html:180 #: templates/monthly_overview/pages/overview.html:286 #: templates/transactions/fragments/filter_actions.html:48 @@ -515,7 +609,7 @@ msgstr "Видалити" msgid "Clear" msgstr "Чисто" -#: apps/common/widgets/tom_select.py:16 +#: apps/common/widgets/tom_select.py:18 msgid "No results..." msgstr "Жодних результатів..." @@ -549,10 +643,10 @@ msgid "Decimal Places" msgstr "Десяткові знаки" #: apps/currencies/models.py:45 apps/export_app/forms.py:25 -#: apps/export_app/forms.py:130 apps/transactions/filters.py:70 +#: apps/export_app/forms.py:132 apps/transactions/filters.py:70 #: templates/currencies/fragments/list.html:9 -#: templates/currencies/pages/index.html:4 templates/includes/sidebar.html:182 -#: templates/includes/sidebar.html:184 +#: templates/currencies/pages/index.html:4 templates/includes/sidebar.html:196 +#: templates/includes/sidebar.html:198 #: templates/monthly_overview/pages/overview.html:63 #: templates/transactions/pages/transactions.html:12 msgid "Currencies" @@ -584,9 +678,9 @@ msgid "Auto" msgstr "Авто" #: apps/currencies/models.py:82 apps/export_app/forms.py:67 -#: apps/export_app/forms.py:142 templates/exchange_rates/fragments/list.html:10 +#: apps/export_app/forms.py:144 templates/exchange_rates/fragments/list.html:10 #: templates/exchange_rates/pages/index.html:4 -#: templates/includes/sidebar.html:190 +#: templates/includes/sidebar.html:204 msgid "Exchange Rates" msgstr "Обмінні курси" @@ -681,32 +775,43 @@ msgstr "" #: apps/currencies/models.py:171 #, fuzzy #| msgid "Exchange Rate" -msgid "Single exchange rate" +msgid "One exchange rate per day" msgstr "Обмінний курс" #: apps/currencies/models.py:174 -msgid "Create one exchange rate and keep updating it. Avoids database clutter." +msgid "" +"Update the day's exchange rate on each fetch instead of adding a new one." msgstr "" -"Створіть один курс обміну та оновлюйте його постійно. Це запобігає " -"засміченню бази даних." -#: apps/currencies/models.py:179 +#: apps/currencies/models.py:178 +#, fuzzy +#| msgid "Exchange Rates" +msgid "Exchange rates to keep" +msgstr "Обмінні курси" + +#: apps/currencies/models.py:183 +msgid "" +"Keep at most this many automatic exchange rates per currency pair, deleting " +"the oldest first. Leave empty to keep all." +msgstr "" + +#: apps/currencies/models.py:188 msgid "Exchange Rate Service" msgstr "Сервіс Курсів Обміну" -#: apps/currencies/models.py:180 +#: apps/currencies/models.py:189 msgid "Exchange Rate Services" msgstr "Сервіси Курсів Обміну" -#: apps/currencies/models.py:234 +#: apps/currencies/models.py:243 msgid "'Every X hours' interval type requires a positive integer." msgstr "Інтервал типу «Кожні X годин» потребує додатнього цілого числа." -#: apps/currencies/models.py:243 +#: apps/currencies/models.py:252 msgid "'Every X hours' interval must be between 1 and 24." msgstr "Інтервал типу «Кожні X годин» повинен бути між 1 та 24." -#: apps/currencies/models.py:257 +#: apps/currencies/models.py:266 msgid "" "Invalid hour format. Use comma-separated hours (0-23) and/or ranges (e.g., " "'1-5,8,10-12')." @@ -714,7 +819,7 @@ msgstr "" "Неправильний формат годин. Використовуйте години, розділені комами (0–23) та/" "або діапазони (наприклад, '1-5,8,10-12')." -#: apps/currencies/models.py:268 +#: apps/currencies/models.py:277 msgid "" "Invalid format. Please check the requirements for your selected interval " "type." @@ -786,21 +891,21 @@ msgstr "Введіть для пошуку транзакції, щоб пов msgid "Income Transaction" msgstr "Дохідна транзакція" -#: apps/dca/forms.py:192 +#: apps/dca/forms.py:179 msgid "Link transaction" msgstr "Пов’язати транзакцію" -#: apps/dca/forms.py:275 apps/dca/forms.py:276 apps/dca/forms.py:281 -#: apps/dca/forms.py:285 +#: apps/dca/forms.py:262 apps/dca/forms.py:263 apps/dca/forms.py:268 +#: apps/dca/forms.py:272 msgid "You must provide an account." msgstr "Необхідно вказати рахунок." -#: apps/dca/forms.py:290 apps/transactions/forms.py:752 -#: apps/transactions/models.py:956 +#: apps/dca/forms.py:277 apps/transactions/forms.py:752 +#: apps/transactions/models.py:980 msgid "From and To accounts must be different." msgstr "Рахунки «З» та «На» повинні бути різними." -#: apps/dca/forms.py:304 +#: apps/dca/forms.py:299 #, python-format msgid "DCA for %(strategy_name)s" msgstr "" @@ -816,8 +921,8 @@ msgstr "Валюта платежу" #: apps/dca/models.py:26 apps/dca/models.py:181 apps/rules/forms.py:180 #: apps/rules/forms.py:196 apps/rules/models.py:43 apps/rules/models.py:295 #: apps/transactions/forms.py:504 apps/transactions/forms.py:651 -#: apps/transactions/models.py:353 apps/transactions/models.py:752 -#: apps/transactions/models.py:1009 apps/transactions/models.py:1302 +#: apps/transactions/models.py:353 apps/transactions/models.py:776 +#: apps/transactions/models.py:1033 apps/transactions/models.py:1326 msgid "Notes" msgstr "Примітки" @@ -825,7 +930,7 @@ msgstr "Примітки" msgid "DCA Strategy" msgstr "" -#: apps/dca/models.py:35 apps/export_app/forms.py:150 +#: apps/dca/models.py:35 apps/export_app/forms.py:152 msgid "DCA Strategies" msgstr "" @@ -845,7 +950,7 @@ msgstr "Отримана Сума" msgid "DCA Entry" msgstr "" -#: apps/dca/models.py:187 apps/export_app/forms.py:151 +#: apps/dca/models.py:187 apps/export_app/forms.py:153 msgid "DCA Entries" msgstr "" @@ -873,25 +978,25 @@ msgstr "Запис успішно оновлено" msgid "Entry deleted successfully" msgstr "Запис успішно видалено" -#: apps/export_app/forms.py:13 apps/export_app/forms.py:128 -#: templates/includes/sidebar.html:227 templates/users/fragments/list.html:10 +#: apps/export_app/forms.py:13 apps/export_app/forms.py:129 +#: templates/includes/sidebar.html:241 templates/users/fragments/list.html:10 #: templates/users/pages/index.html:4 msgid "Users" msgstr "Користувачі" -#: apps/export_app/forms.py:31 apps/export_app/forms.py:134 +#: apps/export_app/forms.py:31 apps/export_app/forms.py:136 #: apps/transactions/models.py:422 templates/includes/sidebar.html:81 -#: templates/includes/sidebar.html:148 +#: templates/includes/sidebar.html:154 #: templates/recurring_transactions/fragments/list_transactions.html:5 #: templates/recurring_transactions/fragments/table.html:37 #: templates/transactions/pages/transactions.html:5 msgid "Transactions" msgstr "Транзакції" -#: apps/export_app/forms.py:37 apps/export_app/forms.py:131 +#: apps/export_app/forms.py:37 apps/export_app/forms.py:133 #: apps/insights/views.py:43 apps/transactions/filters.py:74 #: templates/categories/fragments/list.html:9 -#: templates/categories/pages/index.html:4 templates/includes/sidebar.html:150 +#: templates/categories/pages/index.html:4 templates/includes/sidebar.html:156 #: templates/insights/fragments/month_by_month.html:18 #: templates/insights/fragments/month_by_month.html:21 #: templates/insights/fragments/year_by_year.html:13 @@ -899,16 +1004,16 @@ msgstr "Транзакції" msgid "Categories" msgstr "Категорії" -#: apps/export_app/forms.py:49 apps/export_app/forms.py:133 +#: apps/export_app/forms.py:49 apps/export_app/forms.py:135 #: apps/insights/views.py:57 apps/rules/forms.py:185 apps/rules/forms.py:195 #: apps/rules/models.py:46 apps/rules/models.py:307 #: apps/transactions/filters.py:84 apps/transactions/forms.py:78 #: apps/transactions/forms.py:346 apps/transactions/forms.py:526 #: apps/transactions/forms.py:831 apps/transactions/forms.py:1083 #: apps/transactions/models.py:312 apps/transactions/models.py:368 -#: apps/transactions/models.py:748 apps/transactions/models.py:1006 -#: apps/transactions/models.py:1317 templates/entities/fragments/list.html:9 -#: templates/entities/pages/index.html:4 templates/includes/sidebar.html:162 +#: apps/transactions/models.py:772 apps/transactions/models.py:1030 +#: apps/transactions/models.py:1341 templates/entities/fragments/list.html:9 +#: templates/entities/pages/index.html:4 templates/includes/sidebar.html:168 #: templates/insights/fragments/month_by_month.html:40 #: templates/insights/fragments/month_by_month.html:43 #: templates/insights/fragments/year_by_year.html:35 @@ -916,28 +1021,28 @@ msgstr "Категорії" msgid "Entities" msgstr "" -#: apps/export_app/forms.py:55 apps/export_app/forms.py:137 -#: apps/transactions/models.py:1046 templates/includes/sidebar.html:110 +#: apps/export_app/forms.py:55 apps/export_app/forms.py:139 +#: apps/transactions/models.py:1070 templates/includes/sidebar.html:110 #: templates/recurring_transactions/fragments/list.html:9 #: templates/recurring_transactions/pages/index.html:4 msgid "Recurring Transactions" msgstr "Регулярні транзакції" -#: apps/export_app/forms.py:61 apps/export_app/forms.py:135 -#: apps/transactions/models.py:766 templates/includes/sidebar.html:104 +#: apps/export_app/forms.py:61 apps/export_app/forms.py:137 +#: apps/transactions/models.py:790 templates/includes/sidebar.html:104 #: templates/installment_plans/fragments/list.html:9 #: templates/installment_plans/pages/index.html:4 msgid "Installment Plans" msgstr "Плани Розстрочки" -#: apps/export_app/forms.py:73 apps/export_app/forms.py:140 +#: apps/export_app/forms.py:73 apps/export_app/forms.py:142 #: templates/exchange_rates_services/fragments/list.html:10 #: templates/exchange_rates_services/pages/index.html:4 -#: templates/includes/sidebar.html:218 +#: templates/includes/sidebar.html:232 msgid "Automatic Exchange Rates" msgstr "Автоматичні Курси Обміну" -#: apps/export_app/forms.py:79 templates/includes/sidebar.html:198 +#: apps/export_app/forms.py:79 templates/includes/sidebar.html:212 #: templates/rules/fragments/list.html:9 templates/rules/pages/index.html:4 msgid "Rules" msgstr "Правила" @@ -946,57 +1051,57 @@ msgstr "Правила" msgid "DCA" msgstr "" -#: apps/export_app/forms.py:91 apps/export_app/forms.py:152 +#: apps/export_app/forms.py:91 apps/export_app/forms.py:154 #: templates/import_app/fragments/profiles/list.html:21 #: templates/import_app/pages/profiles_index.html:4 msgid "Import Profiles" msgstr "Імпортувати Профілі" -#: apps/export_app/forms.py:117 templates/export_app/fragments/export.html:5 +#: apps/export_app/forms.py:118 templates/export_app/fragments/export.html:5 #: templates/export_app/pages/index.html:13 msgid "Export" msgstr "Експортувати" -#: apps/export_app/forms.py:125 +#: apps/export_app/forms.py:126 msgid "Import a ZIP file exported from WYGIWYH" msgstr "Імпортувати ZIP-файл, експортований із WYGIWYH" -#: apps/export_app/forms.py:126 +#: apps/export_app/forms.py:127 msgid "ZIP File" msgstr "ZIP-Файл" -#: apps/export_app/forms.py:143 apps/rules/models.py:27 +#: apps/export_app/forms.py:145 apps/rules/models.py:27 msgid "Transaction rules" msgstr "Правила транзакцій" -#: apps/export_app/forms.py:145 apps/rules/models.py:68 +#: apps/export_app/forms.py:147 apps/rules/models.py:68 msgid "Edit transaction action" msgstr "" -#: apps/export_app/forms.py:148 apps/rules/models.py:327 +#: apps/export_app/forms.py:150 apps/rules/models.py:327 msgid "Update or create transaction actions" msgstr "" -#: apps/export_app/forms.py:181 templates/cotton/transaction/item.html:341 +#: apps/export_app/forms.py:183 templates/cotton/transaction/item.html:341 #: templates/cotton/ui/deleted_transactions_action_bar.html:53 #: templates/export_app/fragments/restore.html:5 #: templates/export_app/pages/index.html:19 msgid "Restore" msgstr "Відновити" -#: apps/export_app/forms.py:191 +#: apps/export_app/forms.py:193 msgid "Please upload either a ZIP file or at least one CSV file" msgstr "Будь ласка, завантажте або ZIP-файл, або принаймні один CSV-файл" -#: apps/export_app/views.py:177 +#: apps/export_app/views.py:183 msgid "You have to select at least one export" msgstr "Ви повинні обрати принаймні один експорт" -#: apps/export_app/views.py:197 +#: apps/export_app/views.py:203 msgid "Data restored successfully" msgstr "Дані успішно відновлено" -#: apps/export_app/views.py:209 +#: apps/export_app/views.py:215 msgid "" "There was an error restoring your data. Check the logs for more details." msgstr "" @@ -1009,7 +1114,7 @@ msgstr "Оберіть файл" #: apps/import_app/forms.py:55 #: templates/import_app/fragments/profiles/list.html:75 -#: templates/includes/sidebar.html:204 +#: templates/includes/sidebar.html:218 msgid "Import" msgstr "Імпортувати" @@ -1057,23 +1162,23 @@ msgstr "" msgid "File name" msgstr "" -#: apps/import_app/views.py:75 +#: apps/import_app/views.py:76 msgid "Import Profile added successfully" msgstr "" -#: apps/import_app/views.py:113 +#: apps/import_app/views.py:114 msgid "Import Profile update successfully" msgstr "" -#: apps/import_app/views.py:142 +#: apps/import_app/views.py:143 msgid "Import Profile deleted successfully" msgstr "" -#: apps/import_app/views.py:222 +#: apps/import_app/views.py:223 msgid "Import Run queued successfully" msgstr "" -#: apps/import_app/views.py:249 +#: apps/import_app/views.py:250 msgid "Run deleted successfully" msgstr "" @@ -1144,7 +1249,7 @@ msgstr "Мітки" msgid "Untagged" msgstr "" -#: apps/insights/views.py:53 apps/transactions/models.py:589 +#: apps/insights/views.py:53 apps/transactions/models.py:613 #: templates/tags/fragments/table.html:69 msgid "No tags" msgstr "" @@ -1165,6 +1270,32 @@ msgstr "" msgid "No entities" msgstr "" +#: apps/investments/views.py:104 +msgid "Other" +msgstr "" + +#: apps/investments/views.py:135 +msgid "Unclassified" +msgstr "" + +#: apps/investments/views.py:267 +#, fuzzy +#| msgid "Account added successfully" +msgid "Asset class added successfully" +msgstr "Рахунок додано успішно" + +#: apps/investments/views.py:275 +#, fuzzy +#| msgid "Account updated successfully" +msgid "Asset class updated successfully" +msgstr "Рахунок успішно оновлено" + +#: apps/investments/views.py:296 +#, fuzzy +#| msgid "Account deleted successfully" +msgid "Asset class deleted successfully" +msgstr "Рахунок успішно видалено" + #: apps/net_worth/views.py:137 templates/net_worth/net_worth.html:84 #: templates/net_worth/net_worth.html:85 msgid "Consolidated" @@ -1218,8 +1349,8 @@ msgstr "" #: apps/rules/forms.py:174 apps/rules/forms.py:188 apps/rules/models.py:36 #: apps/rules/models.py:271 apps/transactions/forms.py:468 -#: apps/transactions/models.py:336 apps/transactions/models.py:708 -#: apps/transactions/models.py:987 apps/transactions/models.py:1287 +#: apps/transactions/models.py:336 apps/transactions/models.py:732 +#: apps/transactions/models.py:1011 apps/transactions/models.py:1311 #: templates/mini_tools/simulator.html:63 msgid "Type" msgstr "" @@ -1227,7 +1358,7 @@ msgstr "" #: apps/rules/forms.py:175 apps/rules/forms.py:189 apps/rules/models.py:37 #: apps/rules/models.py:275 apps/transactions/filters.py:22 #: apps/transactions/forms.py:472 apps/transactions/models.py:338 -#: apps/transactions/models.py:1289 templates/cotton/transaction/item.html:23 +#: apps/transactions/models.py:1313 templates/cotton/transaction/item.html:23 #: templates/cotton/transaction/item.html:34 #: templates/transactions/widgets/paid_toggle_button.html:10 #: templates/transactions/widgets/unselectable_paid_toggle_button.html:13 @@ -1238,14 +1369,14 @@ msgstr "" #: apps/rules/models.py:283 apps/transactions/forms.py:90 #: apps/transactions/forms.py:488 apps/transactions/forms.py:638 #: apps/transactions/forms.py:837 apps/transactions/models.py:340 -#: apps/transactions/models.py:726 apps/transactions/models.py:1011 +#: apps/transactions/models.py:750 apps/transactions/models.py:1035 msgid "Reference Date" msgstr "" #: apps/rules/forms.py:178 apps/rules/forms.py:192 apps/rules/models.py:41 #: apps/rules/models.py:287 apps/transactions/forms.py:495 -#: apps/transactions/models.py:346 apps/transactions/models.py:992 -#: apps/transactions/models.py:1295 +#: apps/transactions/models.py:346 apps/transactions/models.py:1016 +#: apps/transactions/models.py:1319 #: templates/insights/fragments/sankey.html:102 #: templates/installment_plans/fragments/table.html:18 #: templates/mini_tools/simulator.html:70 @@ -1257,27 +1388,27 @@ msgstr "" #: apps/rules/forms.py:179 apps/rules/forms.py:193 apps/rules/models.py:14 #: apps/rules/models.py:42 apps/rules/models.py:291 #: apps/transactions/forms.py:499 apps/transactions/forms.py:642 -#: apps/transactions/models.py:351 apps/transactions/models.py:710 -#: apps/transactions/models.py:995 apps/transactions/models.py:1300 +#: apps/transactions/models.py:351 apps/transactions/models.py:734 +#: apps/transactions/models.py:1019 apps/transactions/models.py:1324 msgid "Description" msgstr "" #: apps/rules/forms.py:182 apps/rules/forms.py:198 apps/rules/models.py:47 #: apps/rules/models.py:299 apps/transactions/models.py:398 -#: apps/transactions/models.py:1322 +#: apps/transactions/models.py:1346 msgid "Internal Note" msgstr "" #: apps/rules/forms.py:183 apps/rules/forms.py:199 apps/rules/models.py:48 #: apps/rules/models.py:303 apps/transactions/models.py:400 -#: apps/transactions/models.py:1324 +#: apps/transactions/models.py:1348 msgid "Internal ID" msgstr "" #: apps/rules/forms.py:186 apps/rules/forms.py:200 apps/rules/models.py:40 #: apps/rules/models.py:319 apps/transactions/forms.py:655 #: apps/transactions/models.py:250 apps/transactions/models.py:341 -#: apps/transactions/models.py:1290 +#: apps/transactions/models.py:1314 msgid "Mute" msgstr "" @@ -1290,7 +1421,7 @@ msgid "Set Values" msgstr "" #: apps/rules/forms.py:407 apps/rules/forms.py:442 apps/rules/forms.py:477 -#: apps/transactions/models.py:421 apps/transactions/models.py:645 +#: apps/transactions/models.py:421 apps/transactions/models.py:669 msgid "Transaction" msgstr "" @@ -1326,7 +1457,12 @@ msgstr "" msgid "Field" msgstr "" -#: apps/rules/models.py:61 +#: apps/rules/models.py:61 templates/investments/fragments/charts.html:86 +#: templates/investments/fragments/detail.html:103 +#: templates/investments/fragments/detail.html:162 +#: templates/investments/fragments/list.html:48 +#: templates/investments/fragments/list.html:49 +#: templates/investments/fragments/rebalance.html:23 msgid "Value" msgstr "" @@ -1510,7 +1646,7 @@ msgid "Upload" msgstr "" #: apps/transactions/forms.py:355 apps/transactions/forms.py:1089 -#: apps/transactions/models.py:925 +#: apps/transactions/models.py:949 #, fuzzy #| msgid "To Account" msgid "To account" @@ -1528,8 +1664,8 @@ msgstr "" msgid "To Amount" msgstr "" -#: apps/transactions/forms.py:718 apps/transactions/models.py:970 -#: apps/transactions/models.py:1267 templates/cotton/transaction/item.html:52 +#: apps/transactions/forms.py:718 apps/transactions/models.py:994 +#: apps/transactions/models.py:1291 templates/cotton/transaction/item.html:52 #: templates/cotton/transaction/item.html:60 #: templates/cotton/ui/quick_transactions_buttons.html:40 #: templates/cotton/ui/transactions_fab.html:44 @@ -1589,8 +1725,8 @@ msgid "" "transactions" msgstr "" -#: apps/transactions/models.py:323 apps/transactions/models.py:969 -#: apps/transactions/models.py:1266 +#: apps/transactions/models.py:323 apps/transactions/models.py:993 +#: apps/transactions/models.py:1290 #: templates/calendar_view/fragments/list.html:42 #: templates/calendar_view/fragments/list.html:44 #: templates/calendar_view/fragments/list.html:52 @@ -1598,13 +1734,19 @@ msgstr "" #: templates/cotton/ui/quick_transactions_buttons.html:10 #: templates/cotton/ui/transactions_fab.html:10 #: templates/insights/fragments/overview/_results.html:12 +#: templates/investments/fragments/_totals.html:21 +#: templates/investments/fragments/_totals.html:42 +#: templates/investments/fragments/detail.html:106 +#: templates/investments/fragments/detail.html:210 +#: templates/investments/fragments/detail.html:252 +#: templates/investments/fragments/detail.html:320 #: templates/mini_tools/simulator.html:66 #: templates/monthly_overview/fragments/monthly_summary.html:42 msgid "Income" msgstr "" -#: apps/transactions/models.py:324 apps/transactions/models.py:971 -#: apps/transactions/models.py:1268 +#: apps/transactions/models.py:324 apps/transactions/models.py:995 +#: apps/transactions/models.py:1292 #: templates/calendar_view/fragments/list.html:46 #: templates/calendar_view/fragments/list.html:48 #: templates/calendar_view/fragments/list.html:56 @@ -1616,14 +1758,14 @@ msgstr "" msgid "Expense" msgstr "" -#: apps/transactions/models.py:379 apps/transactions/models.py:765 +#: apps/transactions/models.py:379 apps/transactions/models.py:789 #: templates/cotton/transaction/item.html:291 #: templates/cotton/transaction/item.html:308 #: templates/cotton/transaction/item.html:318 msgid "Installment Plan" msgstr "" -#: apps/transactions/models.py:388 apps/transactions/models.py:1045 +#: apps/transactions/models.py:388 apps/transactions/models.py:1069 #: templates/cotton/transaction/item.html:281 #: templates/cotton/transaction/item.html:301 msgid "Recurring Transaction" @@ -1643,160 +1785,172 @@ msgstr "" msgid "Deleted At" msgstr "" -#: apps/transactions/models.py:591 +#: apps/transactions/models.py:517 +msgid "A transfer needs one expense and one income." +msgstr "" + +#: apps/transactions/models.py:519 +msgid "A transfer needs two different accounts." +msgstr "" + +#: apps/transactions/models.py:523 +msgid "This transaction is already part of another transfer." +msgstr "" + +#: apps/transactions/models.py:615 msgid "No category" msgstr "" -#: apps/transactions/models.py:593 +#: apps/transactions/models.py:617 msgid "No description" msgstr "" -#: apps/transactions/models.py:650 +#: apps/transactions/models.py:674 #, fuzzy #| msgid "ZIP File" msgid "File" msgstr "ZIP-Файл" -#: apps/transactions/models.py:652 +#: apps/transactions/models.py:676 msgid "Original Name" msgstr "" -#: apps/transactions/models.py:654 +#: apps/transactions/models.py:678 #, fuzzy #| msgid "Interval Type" msgid "Content Type" msgstr "Тип інтервалу" -#: apps/transactions/models.py:656 +#: apps/transactions/models.py:680 msgid "Size" msgstr "" -#: apps/transactions/models.py:661 +#: apps/transactions/models.py:685 msgid "Uploaded By" msgstr "" -#: apps/transactions/models.py:666 +#: apps/transactions/models.py:690 #, fuzzy #| msgid "Transaction rules" msgid "Transaction Attachment" msgstr "Правила транзакцій" -#: apps/transactions/models.py:667 +#: apps/transactions/models.py:691 #, fuzzy #| msgid "Transaction rules" msgid "Transaction Attachments" msgstr "Правила транзакцій" -#: apps/transactions/models.py:697 templates/includes/sidebar.html:57 +#: apps/transactions/models.py:721 templates/includes/sidebar.html:57 msgid "Yearly" msgstr "" -#: apps/transactions/models.py:698 apps/users/models.py:469 +#: apps/transactions/models.py:722 apps/users/models.py:469 #: templates/includes/sidebar.html:51 msgid "Monthly" msgstr "" -#: apps/transactions/models.py:699 +#: apps/transactions/models.py:723 msgid "Weekly" msgstr "" -#: apps/transactions/models.py:700 +#: apps/transactions/models.py:724 msgid "Daily" msgstr "" -#: apps/transactions/models.py:713 +#: apps/transactions/models.py:737 msgid "Number of Installments" msgstr "" -#: apps/transactions/models.py:718 +#: apps/transactions/models.py:742 msgid "Installment Start" msgstr "" -#: apps/transactions/models.py:719 +#: apps/transactions/models.py:743 msgid "The installment number to start counting from" msgstr "" -#: apps/transactions/models.py:724 apps/transactions/models.py:1015 +#: apps/transactions/models.py:748 apps/transactions/models.py:1039 msgid "Start Date" msgstr "" -#: apps/transactions/models.py:728 apps/transactions/models.py:1016 +#: apps/transactions/models.py:752 apps/transactions/models.py:1040 msgid "End Date" msgstr "" -#: apps/transactions/models.py:733 +#: apps/transactions/models.py:757 msgid "Recurrence" msgstr "" -#: apps/transactions/models.py:736 +#: apps/transactions/models.py:760 msgid "Installment Amount" msgstr "" -#: apps/transactions/models.py:755 apps/transactions/models.py:1035 +#: apps/transactions/models.py:779 apps/transactions/models.py:1059 msgid "Add description to transactions" msgstr "" -#: apps/transactions/models.py:758 apps/transactions/models.py:1038 +#: apps/transactions/models.py:782 apps/transactions/models.py:1062 msgid "Add notes to transactions" msgstr "" -#: apps/transactions/models.py:932 +#: apps/transactions/models.py:956 #, fuzzy #| msgid "To Account" msgid "To amount" msgstr "На рахунок" -#: apps/transactions/models.py:933 +#: apps/transactions/models.py:957 msgid "Leave empty to use the same amount" msgstr "" -#: apps/transactions/models.py:974 templates/mini_tools/simulator.html:129 +#: apps/transactions/models.py:998 templates/mini_tools/simulator.html:129 msgid "day(s)" msgstr "" -#: apps/transactions/models.py:975 templates/mini_tools/simulator.html:130 +#: apps/transactions/models.py:999 templates/mini_tools/simulator.html:130 msgid "week(s)" msgstr "" -#: apps/transactions/models.py:976 templates/mini_tools/simulator.html:131 +#: apps/transactions/models.py:1000 templates/mini_tools/simulator.html:131 msgid "month(s)" msgstr "" -#: apps/transactions/models.py:977 templates/mini_tools/simulator.html:132 +#: apps/transactions/models.py:1001 templates/mini_tools/simulator.html:132 msgid "year(s)" msgstr "" -#: apps/transactions/models.py:979 +#: apps/transactions/models.py:1003 #: templates/recurring_transactions/fragments/list.html:18 msgid "Paused" msgstr "" -#: apps/transactions/models.py:1018 +#: apps/transactions/models.py:1042 msgid "Recurrence Type" msgstr "" -#: apps/transactions/models.py:1021 +#: apps/transactions/models.py:1045 msgid "Recurrence Interval" msgstr "" -#: apps/transactions/models.py:1024 +#: apps/transactions/models.py:1048 msgid "Keep at most" msgstr "" -#: apps/transactions/models.py:1028 +#: apps/transactions/models.py:1052 msgid "Last Generated Date" msgstr "" -#: apps/transactions/models.py:1031 +#: apps/transactions/models.py:1055 msgid "Last Generated Reference Date" msgstr "" -#: apps/transactions/models.py:1334 templates/cotton/transaction/item.html:274 +#: apps/transactions/models.py:1358 templates/cotton/transaction/item.html:274 #: templates/cotton/ui/transactions_fab.html:59 msgid "Quick Transaction" msgstr "" -#: apps/transactions/models.py:1335 templates/includes/sidebar.html:98 +#: apps/transactions/models.py:1359 templates/includes/sidebar.html:98 #: templates/quick_transactions/pages/index.html:5 #: templates/quick_transactions/pages/index.html:15 msgid "Quick Transactions" @@ -1927,8 +2081,8 @@ msgid "Item deleted successfully" msgstr "Рахунок успішно видалено" #: apps/transactions/views/quick_transactions.py:164 -#: apps/transactions/views/transactions.py:143 -#: apps/transactions/views/transactions.py:328 +#: apps/transactions/views/transactions.py:156 +#: apps/transactions/views/transactions.py:343 msgid "Transaction added successfully" msgstr "" @@ -1964,46 +2118,46 @@ msgstr "" msgid "Tag deleted successfully" msgstr "" -#: apps/transactions/views/transactions.py:61 +#: apps/transactions/views/transactions.py:65 #, fuzzy #| msgid "Account updated successfully" msgid "Attachment uploaded successfully" msgstr "Рахунок успішно оновлено" -#: apps/transactions/views/transactions.py:112 +#: apps/transactions/views/transactions.py:116 #, fuzzy #| msgid "Account deleted successfully" msgid "Attachment deleted successfully" msgstr "Рахунок успішно видалено" -#: apps/transactions/views/transactions.py:355 +#: apps/transactions/views/transactions.py:370 msgid "Transaction updated successfully" msgstr "" -#: apps/transactions/views/transactions.py:406 +#: apps/transactions/views/transactions.py:421 #, python-format msgid "%(count)s transaction updated successfully" msgid_plural "%(count)s transactions updated successfully" msgstr[0] "" msgstr[1] "" -#: apps/transactions/views/transactions.py:433 +#: apps/transactions/views/transactions.py:448 msgid "Transaction duplicated successfully" msgstr "" -#: apps/transactions/views/transactions.py:478 +#: apps/transactions/views/transactions.py:493 msgid "Transaction deleted successfully" msgstr "" -#: apps/transactions/views/transactions.py:508 +#: apps/transactions/views/transactions.py:523 msgid "Transaction restored successfully" msgstr "" -#: apps/transactions/views/transactions.py:534 +#: apps/transactions/views/transactions.py:549 msgid "Transfer added successfully" msgstr "" -#: apps/transactions/views/transactions.py:562 +#: apps/transactions/views/transactions.py:579 #, fuzzy #| msgid "Entry updated successfully" msgid "Transfer updated successfully" @@ -2330,6 +2484,7 @@ msgstr "" #: templates/exchange_rates_services/fragments/table.html:20 #: templates/import_app/fragments/profiles/list.html:42 #: templates/installment_plans/fragments/table.html:25 +#: templates/investment_classes/fragments/list.html:36 #: templates/quick_transactions/fragments/list.html:22 #: templates/recurring_transactions/fragments/table.html:27 #: templates/rules/fragments/list.html:31 @@ -2352,6 +2507,9 @@ msgstr "" #: templates/exchange_rates_services/fragments/table.html:23 #: templates/import_app/fragments/profiles/list.html:46 #: templates/installment_plans/fragments/table.html:28 +#: templates/investment_classes/fragments/list.html:39 +#: templates/investments/fragments/detail.html:305 +#: templates/investments/fragments/detail.html:331 #: templates/quick_transactions/fragments/list.html:25 #: templates/recurring_transactions/fragments/table.html:30 #: templates/rules/fragments/transaction_rule/view.html:22 @@ -2401,6 +2559,7 @@ msgstr "" #: templates/import_app/fragments/profiles/list.html:81 #: templates/import_app/fragments/runs/list.html:100 #: templates/installment_plans/fragments/table.html:54 +#: templates/investment_classes/fragments/list.html:45 #: templates/mini_tools/simulator.html:109 #: templates/mini_tools/unit_price_calculator.html:16 #: templates/quick_transactions/fragments/list.html:32 @@ -2434,6 +2593,7 @@ msgstr "" #: templates/import_app/fragments/runs/list.html:104 #: templates/installment_plans/fragments/table.html:47 #: templates/installment_plans/fragments/table.html:58 +#: templates/investment_classes/fragments/list.html:49 #: templates/quick_transactions/fragments/list.html:37 #: templates/recurring_transactions/fragments/table.html:52 #: templates/recurring_transactions/fragments/table.html:65 @@ -2483,6 +2643,7 @@ msgstr "" #: templates/import_app/fragments/profiles/list.html:87 #: templates/import_app/fragments/runs/list.html:106 #: templates/installment_plans/fragments/table.html:60 +#: templates/investment_classes/fragments/list.html:51 #: templates/quick_transactions/fragments/list.html:39 #: templates/recurring_transactions/fragments/table.html:94 #: templates/rules/fragments/list.html:62 @@ -2531,6 +2692,7 @@ msgid "Add account" msgstr "" #: templates/accounts/fragments/edit.html:5 +#: templates/investments/fragments/detail.html:40 msgid "Edit account" msgstr "" @@ -2603,7 +2765,7 @@ msgstr "" msgid "Pick a month" msgstr "" -#: templates/common/fragments/toasts.html:20 +#: templates/common/fragments/toasts.html:18 #: templates/cotton/components/fab.html:9 #: templates/cotton/components/sidebar_collapsible_panel.html:28 #: templates/extends/offcanvas.html:4 templates/includes/sidebar.html:44 @@ -2832,6 +2994,8 @@ msgid "Add DCA strategy" msgstr "" #: templates/dca/fragments/strategy/details.html:28 +#: templates/investments/fragments/detail.html:92 +#: templates/investments/fragments/list.html:65 msgid "No exchange rate available" msgstr "" @@ -3014,7 +3178,7 @@ msgstr "" msgid "No services configured" msgstr "" -#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:211 +#: templates/export_app/pages/index.html:4 templates/includes/sidebar.html:225 msgid "Export and Restore" msgstr "" @@ -3185,52 +3349,63 @@ msgstr "" msgid "Tools" msgstr "" -#: templates/includes/sidebar.html:118 +#: templates/includes/sidebar.html:118 templates/includes/sidebar.html:188 +#: templates/investment_classes/fragments/list.html:29 +#: templates/investments/fragments/detail.html:37 +#: templates/investments/fragments/list.html:9 +#: templates/investments/pages/detail.html:4 +#: templates/investments/pages/index.html:4 +#, fuzzy +#| msgid "Installment Plans" +msgid "Investments" +msgstr "Плани Розстрочки" + +#: templates/includes/sidebar.html:124 msgid "Dollar Cost Average Tracker" msgstr "" -#: templates/includes/sidebar.html:124 +#: templates/includes/sidebar.html:130 #: templates/mini_tools/unit_price_calculator.html:4 #: templates/mini_tools/unit_price_calculator.html:9 msgid "Unit Price Calculator" msgstr "" -#: templates/includes/sidebar.html:130 +#: templates/includes/sidebar.html:136 #: templates/mini_tools/currency_converter/currency_converter.html:7 #: templates/mini_tools/currency_converter/currency_converter.html:14 msgid "Currency Converter" msgstr "" -#: templates/includes/sidebar.html:136 templates/mini_tools/simulator.html:5 +#: templates/includes/sidebar.html:142 templates/mini_tools/simulator.html:5 #: templates/mini_tools/simulator.html:21 msgid "Simulator" msgstr "" -#: templates/includes/sidebar.html:145 +#: templates/includes/sidebar.html:151 msgid "Management" msgstr "" -#: templates/includes/sidebar.html:196 +#: templates/includes/sidebar.html:210 msgid "Automation" msgstr "" -#: templates/includes/sidebar.html:225 +#: templates/includes/sidebar.html:239 msgid "Admin" msgstr "" -#: templates/includes/sidebar.html:233 +#: templates/includes/sidebar.html:247 msgid "Django Admin" msgstr "" -#: templates/includes/sidebar.html:234 +#: templates/includes/sidebar.html:248 msgid "Only use this if you know what you're doing" msgstr "" -#: templates/includes/sidebar.html:249 +#: templates/includes/sidebar.html:263 msgid "is available" msgstr "" -#: templates/includes/sidebar.html:257 +#: templates/includes/sidebar.html:271 msgid "Calculator" msgstr "" @@ -3297,6 +3472,7 @@ msgstr "" #: templates/insights/fragments/overview/_results.html:173 #: templates/insights/fragments/year_by_year.html:59 #: templates/insights/fragments/year_by_year.html:140 +#: templates/investment_classes/fragments/list.html:65 #: templates/monthly_overview/fragments/monthly_summary.html:168 msgid "Total" msgstr "" @@ -3498,6 +3674,285 @@ msgstr "" msgid "No installment plans" msgstr "" +#: templates/investment_classes/fragments/form.html:5 +msgid "Edit asset class" +msgstr "" + +#: templates/investment_classes/fragments/form.html:5 +msgid "Add asset class" +msgstr "" + +#: templates/investment_classes/fragments/list.html:19 +#: templates/investments/fragments/rebalance.html:10 +#, python-format +msgid "Targets add up to %(total)s%%. Set them to 100%% to see the rebalance." +msgstr "" + +#: templates/investment_classes/fragments/list.html:28 +#: templates/investments/fragments/rebalance.html:25 +msgid "Target" +msgstr "" + +#: templates/investment_classes/fragments/list.html:50 +msgid "Its investments will be left without an asset class." +msgstr "" + +#: templates/investment_classes/fragments/list.html:74 +msgid "No asset classes" +msgstr "" + +#: templates/investments/fragments/_allocation.html:3 +msgid "Allocation chart" +msgstr "" + +#: templates/investments/fragments/_allocation.html:11 +#, python-format +msgid "" +"%(share)s%% of your investments with a target, against a target of " +"%(target)s%%" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "Annual return, weighted by when money went in and out" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "" +"Return since the first contribution, weighted by when money went in and out" +msgstr "" + +#: templates/investments/fragments/_mwr.html:1 +msgid "/yr" +msgstr "" + +#: templates/investments/fragments/_totals.html:15 +#: templates/investments/fragments/detail.html:63 +msgid "total return" +msgstr "" + +#: templates/investments/fragments/_totals.html:20 +#: templates/investments/fragments/_totals.html:41 +#: templates/investments/fragments/charts.html:96 +#: templates/investments/fragments/detail.html:163 +#: templates/investments/fragments/detail.html:223 +#: templates/investments/fragments/list.html:47 +msgid "Invested" +msgstr "" + +#: templates/investments/fragments/_totals.html:24 +#: templates/investments/fragments/detail.html:74 +msgid "" +"How much of the return came from exchange rates moving since you invested" +msgstr "" + +#: templates/investments/fragments/_totals.html:24 +#: templates/investments/fragments/_totals.html:26 +#, fuzzy +#| msgid "Currency Code" +msgid "Currency effect" +msgstr "Код валюти" + +#: templates/investments/fragments/_totals.html:26 +#: templates/investments/fragments/detail.html:78 +msgid "" +"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." +msgstr "" + +#: templates/investments/fragments/_totals.html:40 +#: templates/investments/fragments/list.html:49 +msgid "Return" +msgstr "" + +#: templates/investments/fragments/charts.html:10 +msgid "By asset class" +msgstr "" + +#: templates/investments/fragments/charts.html:17 +#: templates/net_worth/net_worth.html:52 +#: templates/yearly_overview/pages/overview_by_currency.html:6 +msgid "By currency" +msgstr "" + +#: templates/investments/fragments/charts.html:24 +msgid "Value and invested" +msgstr "" + +#: templates/investments/fragments/charts.html:31 +msgid "Return by investment" +msgstr "" + +#: templates/investments/fragments/charts.html:38 +msgid "Nothing to chart yet" +msgstr "" + +#: templates/investments/fragments/detail.html:10 +msgid "Buy / Contribute" +msgstr "" + +#: templates/investments/fragments/detail.html:16 +msgid "Sell / Withdraw" +msgstr "" + +#: templates/investments/fragments/detail.html:23 +msgid "Dividend / Income" +msgstr "" + +#: templates/investments/fragments/detail.html:30 +#, fuzzy +#| msgid "Update" +msgid "Update value" +msgstr "Оновлення" + +#: templates/investments/fragments/detail.html:47 +#: templates/investments/fragments/list.html:67 +msgid "Tracked by value" +msgstr "" + +#: templates/investments/fragments/detail.html:48 +msgid "" +"Some incoming units have no linked payment, so their cost was estimated from " +"the market price of that day" +msgstr "" + +#: templates/investments/fragments/detail.html:68 +#, fuzzy +#| msgid "Info" +msgid "In" +msgstr "Info" + +#: templates/investments/fragments/detail.html:74 +#: templates/investments/fragments/detail.html:78 +#, fuzzy +#| msgid "Currency Code" +msgid "currency effect" +msgstr "Код валюти" + +#: templates/investments/fragments/detail.html:104 +#: templates/investments/fragments/detail.html:179 +msgid "Price" +msgstr "" + +#: templates/investments/fragments/detail.html:105 +#: templates/investments/fragments/detail.html:200 +#: templates/investments/fragments/detail.html:239 +#: templates/investments/fragments/detail.html:290 +#, fuzzy +#| msgid "Warning" +msgid "Earnings" +msgstr "Попередження" + +#: templates/investments/fragments/detail.html:180 +#: templates/investments/fragments/detail.html:227 +msgid "Average cost" +msgstr "" + +#: templates/investments/fragments/detail.html:181 +#: templates/investments/fragments/rebalance.html:41 +msgid "Buy" +msgstr "" + +#: templates/investments/fragments/detail.html:182 +#: templates/investments/fragments/rebalance.html:43 +msgid "Sell" +msgstr "" + +#: templates/investments/fragments/detail.html:227 +#: templates/investments/fragments/detail.html:257 +msgid "Put in / taken out" +msgstr "" + +#: templates/investments/fragments/detail.html:239 +msgid "Unrealized" +msgstr "" + +#: templates/investments/fragments/detail.html:248 +msgid "Realized" +msgstr "" + +#: templates/investments/fragments/detail.html:271 +msgid "Movements" +msgstr "" + +#: templates/investments/fragments/detail.html:284 +msgid "Bought / contributed" +msgstr "" + +#: templates/investments/fragments/detail.html:284 +msgid "Sold / withdrew" +msgstr "" + +#: templates/investments/fragments/detail.html:288 +msgid "Money in" +msgstr "" + +#: templates/investments/fragments/detail.html:288 +msgid "Money out" +msgstr "" + +#: templates/investments/fragments/detail.html:290 +msgid "Gain" +msgstr "" + +#: templates/investments/fragments/detail.html:290 +msgid "Loss, tax or fee" +msgstr "" + +#: templates/investments/fragments/detail.html:315 +msgid "No movements yet" +msgstr "" + +#: templates/investments/fragments/detail.html:315 +msgid "Use the + button to buy or contribute" +msgstr "" + +#: templates/investments/fragments/list.html:21 +msgid "Holdings" +msgstr "" + +#: templates/investments/fragments/list.html:60 +msgid "Some costs were estimated from market prices" +msgstr "" + +#: templates/investments/fragments/list.html:92 +msgid "Charts" +msgstr "" + +#: templates/investments/fragments/list.html:97 +#, fuzzy +#| msgid "New balance" +msgid "Rebalance" +msgstr "Новий баланс" + +#: templates/investments/fragments/list.html:105 +msgid "No investments yet" +msgstr "" + +#: templates/investments/fragments/list.html:106 +msgid "Mark an account as an investment in its settings to see it here" +msgstr "" + +#: templates/investments/fragments/rebalance.html:4 +msgid "No allocation targets" +msgstr "" + +#: templates/investments/fragments/rebalance.html:5 +msgid "Set a target for your asset classes to see what to buy and sell" +msgstr "" + +#: templates/investments/fragments/rebalance.html:26 +msgid "To reach the target" +msgstr "" + +#: templates/investments/fragments/rebalance.html:34 +msgid "Off target" +msgstr "" + +#: templates/investments/fragments/rebalance.html:55 +msgid "Left out: investments without an asset class or a target" +msgstr "" + #: templates/layouts/base.html:38 msgid "This is a demo!" msgstr "" @@ -3711,11 +4166,6 @@ msgstr "" msgid "Order by" msgstr "" -#: templates/net_worth/net_worth.html:52 -#: templates/yearly_overview/pages/overview_by_currency.html:6 -msgid "By currency" -msgstr "" - #: templates/net_worth/net_worth.html:135 msgid "Evolution" msgstr "" @@ -4169,3 +4619,9 @@ msgstr "Увiйти за допомогою" #: templates/yearly_overview/pages/yearly_overview_base.html:6 msgid "Yearly Overview" msgstr "" + +#~ msgid "" +#~ "Create one exchange rate and keep updating it. Avoids database clutter." +#~ msgstr "" +#~ "Створіть один курс обміну та оновлюйте його постійно. Це запобігає " +#~ "засміченню бази даних." diff --git a/app/locale/zh_Hant/LC_MESSAGES/django.po b/app/locale/zh_Hant/LC_MESSAGES/django.po index 600568a6..90aeda74 100644 --- a/app/locale/zh_Hant/LC_MESSAGES/django.po +++ b/app/locale/zh_Hant/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-08 06:01+0000\n" +"POT-Creation-Date: 2026-11-08 18:27+0000\n" "PO-Revision-Date: 2025-10-08 16:17+0000\n" "Last-Translator: doody \n" "Language-Team: Chinese (Traditional Han script) -
-
- -
- {{ message.tags | toast_title }} -
{{ message }}
-
-
- + +
+ {{ message.tags | toast_title }} +
{{ message }}
+ {# -mt-1 centers the 2rem button on the 1.5rem title line #} +
{% endfor %} {% endif %} diff --git a/app/templates/crispy-daisyui/layout/switch.html b/app/templates/crispy-daisyui/layout/switch.html index f45e63dc..48ffbe29 100644 --- a/app/templates/crispy-daisyui/layout/switch.html +++ b/app/templates/crispy-daisyui/layout/switch.html @@ -11,7 +11,7 @@ {% crispy_field field 'class' 'toggle toggle-sm' %} {% endif %} {{ field.label }}{% if field.field.required %}*{% endif %} - {% include 'crispy-daisyui/layout/help_text_and_errors.html' %} + {% include 'crispy-daisyui/layout/help_text_and_errors.html' %} {% endif %} diff --git a/app/templates/includes/sidebar.html b/app/templates/includes/sidebar.html index 2ed7d4a3..8d398600 100644 --- a/app/templates/includes/sidebar.html +++ b/app/templates/includes/sidebar.html @@ -114,6 +114,12 @@ + + + active="tags_index||entities_index||categories_index||accounts_index||account_groups_index||investment_classes_index||currencies_index||exchange_rates_index||rules_index||import_profiles_index||automatic_exchange_rates_index||bank_sync_index||export_index||users_index"> + + + + + {% crispy form %} + +{% endblock %} diff --git a/app/templates/investment_classes/fragments/list.html b/app/templates/investment_classes/fragments/list.html new file mode 100644 index 00000000..e40ba58c --- /dev/null +++ b/app/templates/investment_classes/fragments/list.html @@ -0,0 +1,78 @@ +{% load i18n %} + + +
+
+ {% spaceless %} +
{% translate 'Asset classes' %}
+ {% endspaceless %} +
+ +
+
+ {% if classes %} + {% if total_target and total_target != 100 %} + + {% endif %} +
+ + + + + + + + + + + {% for cls in classes %} + + + + + + + {% endfor %} + + {% if total_target %} + + + + + + + + + {% endif %} +
{% translate 'Name' %}{% translate 'Target' %}{% translate 'Investments' %}
+
+ + + +
+
{{ cls.name }}{% if cls.target is not None %}{{ cls.target|floatformat:"-2" }}%{% else %}-{% endif %}{{ cls.accounts.count }}
{% translate 'Total' %}{{ total_target|floatformat:"-2" }}%
+
+ {% else %} + + {% endif %} +
+
+
diff --git a/app/templates/investment_classes/pages/index.html b/app/templates/investment_classes/pages/index.html new file mode 100644 index 00000000..5e86b514 --- /dev/null +++ b/app/templates/investment_classes/pages/index.html @@ -0,0 +1,8 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{% translate 'Asset classes' %}{% endblock %} + +{% block content %} +
+{% endblock %} diff --git a/app/templates/investments/fragments/_allocation.html b/app/templates/investments/fragments/_allocation.html new file mode 100644 index 00000000..3e21bc23 --- /dev/null +++ b/app/templates/investments/fragments/_allocation.html @@ -0,0 +1,18 @@ +{% load i18n %} +
+
+
    + {% for share in shares %} +
  • + + {{ share.name }} + {% if share.plan.off %} + + {% endif %} + {{ share.share|floatformat:1 }}% + +
  • + {% endfor %} +
+
diff --git a/app/templates/investments/fragments/_mwr.html b/app/templates/investments/fragments/_mwr.html new file mode 100644 index 00000000..9ade9d0e --- /dev/null +++ b/app/templates/investments/fragments/_mwr.html @@ -0,0 +1 @@ +{% load i18n %}{% if mw %}{% include 'investments/fragments/_percentage.html' with value=mw.percentage %}{% if mw.annualized %}{% translate '/yr' %}{% endif %}{% endif %} diff --git a/app/templates/investments/fragments/_percentage.html b/app/templates/investments/fragments/_percentage.html new file mode 100644 index 00000000..3bc2415c --- /dev/null +++ b/app/templates/investments/fragments/_percentage.html @@ -0,0 +1 @@ +{% if value is not None %}{% if value > 0 %}+{% endif %}{{ value|floatformat:"2g" }}%{% endif %} diff --git a/app/templates/investments/fragments/_totals.html b/app/templates/investments/fragments/_totals.html new file mode 100644 index 00000000..8dbdbfa1 --- /dev/null +++ b/app/templates/investments/fragments/_totals.html @@ -0,0 +1,51 @@ +{% load i18n %} +{% with c=total.currency %} +
+
+
+
+
+ +
+
+
+
+ + {% include 'investments/fragments/_mwr.html' with mw=total.money_weighted %} + {% translate 'total return' %} +
+
+
+
+
{% translate 'Invested' %}
+
{% translate 'Income' %}
+ {% if total.converted_from %} + {% if total.has_rate_history %} +
{% translate 'Currency effect' %}
+ {% else %} +
{% translate 'Currency effect' %}
+ {% endif %} + {% endif %} +
+ {% if total.breakdown %} +
+ {% for part in total.breakdown %} + {% with p=part.currency %} +
+ + {{ p.name }} + + +
+
{% translate 'Return' %} {% include 'investments/fragments/_mwr.html' with mw=part.money_weighted %}
+
{% translate 'Invested' %}
+
{% translate 'Income' %}
+
+
+ {% endwith %} + {% endfor %} +
+ {% endif %} +
+
+{% endwith %} diff --git a/app/templates/investments/fragments/charts.html b/app/templates/investments/fragments/charts.html new file mode 100644 index 00000000..76268512 --- /dev/null +++ b/app/templates/investments/fragments/charts.html @@ -0,0 +1,149 @@ +{% load i18n %} +{% for chart in charts %} + {% with c=chart.currency %} + {% if charts|length > 1 %}
{{ c.name }}
{% endif %} +
+ {{ chart.data|json_script:chart.data_id }} + +
+
+
{% translate 'By asset class' %}
+ {% include 'investments/fragments/_allocation.html' with shares=chart.classes kind='classes' %} +
+
+ +
+
+
{% translate 'By currency' %}
+ {% include 'investments/fragments/_allocation.html' with shares=chart.currencies kind='currencies' %} +
+
+ +
+
+
{% translate 'Value and invested' %}
+
+
+
+ +
+
+
{% translate 'Return by investment' %}
+
+
+
+
+ {% endwith %} +{% empty %} + +{% endfor %} + + diff --git a/app/templates/investments/fragments/detail.html b/app/templates/investments/fragments/detail.html new file mode 100644 index 00000000..98f1ba04 --- /dev/null +++ b/app/templates/investments/fragments/detail.html @@ -0,0 +1,343 @@ +{% load i18n %} +{% with c=perf.currency a=account.currency %} +{# Buy/sell are transfers so the tracker sees them; income uses the account's income entity #} + + + + {% if income_entities %} + + {% endif %} + {% if not perf.priced %} + + {% endif %} + +
+
+
+
+ + {{ account.name }} + +
+
+ {% if account.group %}{{ account.group.name }} · {% endif %} + {% if perf.priced %}{{ a.name }} → {{ c.name }}{% else %}{% translate 'Tracked by value' %}{% endif %} + {% if perf.estimated %}{% endif %} +
+
+
+ +
+
+
+
+
+ {% if perf.market_value is not None %}{% else %}-{% endif %} +
+
+ + {% include 'investments/fragments/_mwr.html' with mw=perf.money_weighted %} + {% translate 'total return' %} +
+ {% if home %} + {% with h=home.currency %} +
+ {% translate 'In' %} {{ h.name }}: + + · + + {% include 'investments/fragments/_mwr.html' with mw=home.money_weighted %} + {% if home.has_rate_history %} + · {% translate 'currency effect' %} + + + {% else %} + · {% translate 'currency effect' %} + {% endif %} +
+ {% endwith %} + {% endif %} +
+ {% if perf.priced %} +
+
+
+ {% if perf.price is not None %} + × + {% if perf.price_date %} · {{ perf.price_date|date:"SHORT_DATE_FORMAT" }}{% endif %} + {% else %} + {% translate "No exchange rate available" %} + {% endif %} +
+
+ {% endif %} +
+ +
+ {{ chart|json_script:"investment-chart-data" }} + {% if chart.price or chart.earnings or chart.income %} +
+ + {% if chart.price %}{% endif %} + {% if chart.earnings %}{% endif %} + {% if chart.income %}{% endif %} +
+ {% endif %} +
+ {% if chart.price %}{% endif %} + {% if chart.earnings %}{% endif %} + {% if chart.income %}{% endif %} +
+ + +
+
+
{% translate 'Invested' %}
+
+
+
+
{% if perf.priced %}{% translate 'Average cost' %}{% else %}{% translate 'Put in / taken out' %}{% endif %}
+
+ {% if perf.priced %} + {% if perf.average_cost is not None %}{% else %}-{% endif %} + {% else %} + + / + + {% endif %} +
+
+
+
{% if perf.priced %}{% translate 'Unrealized' %}{% else %}{% translate 'Earnings' %}{% endif %}
+
+ {% if perf.unrealized is not None %} + + {% include 'investments/fragments/_percentage.html' with value=perf.unrealized_percentage css="text-xs" %} + {% else %}-{% endif %} +
+
+
+
{% translate 'Realized' %}
+
+
+
+
{% translate 'Income' %}{% if income_entities %} · {{ income_entities|join:", " }}{% endif %}
+
+
+ {% if perf.priced %} +
+
{% translate 'Put in / taken out' %}
+
+ + / + +
+
+ {% endif %} +
+
+
+ +
+
+
{% translate 'Movements' %}
+ {% if transactions %} +
+ + + {% for tx in transactions %} + {% with p=tx.linked_transaction %} + + + + + + + {% endwith %} + {% endfor %} + +
{{ tx.date|date:"SHORT_DATE_FORMAT" }} +
{{ tx.description }}
+
+ {% if p %} + {% if tx.type == 'IN' %}{% translate 'Bought / contributed' %}{% else %}{% translate 'Sold / withdrew' %}{% endif %} + · {{ p.account.name }} + {% else %} + {% if not perf.priced and tx.id not in perf.result_ids %} + {% if tx.type == 'IN' %}{% translate 'Money in' %}{% else %}{% translate 'Money out' %}{% endif %} + {% else %} + {% if tx.type == 'IN' %}{% if perf.priced %}{% translate 'Gain' %}{% else %}{% translate 'Earnings' %}{% endif %}{% else %}{% translate 'Loss, tax or fee' %}{% endif %} + {% endif %} + {% endif %} +
+
+ + {% if p and p.account.currency != a %} +
+ +
+ {% endif %} +
+ +
+
+ {% else %} + + {% endif %} +
+ {% if income %} +
+
{% translate 'Income' %}
+
+ + + {% for tx in income %} + + + + + + + {% endfor %} + +
{{ tx.date|date:"SHORT_DATE_FORMAT" }}{{ tx.description }}
{{ tx.account.name }}
+ +
+
+
+ {% endif %} +
+
+{% endwith %} diff --git a/app/templates/investments/fragments/list.html b/app/templates/investments/fragments/list.html new file mode 100644 index 00000000..4f6404c2 --- /dev/null +++ b/app/templates/investments/fragments/list.html @@ -0,0 +1,108 @@ +{% load i18n %} + + +
+
+ {% spaceless %} +
{% translate 'Investments' %}
+ {% endspaceless %} +
+ + {% if groups %} +
+ {% for total in totals %} + {% include 'investments/fragments/_totals.html' %} + {% endfor %} +
+ +
+ +
+ {% for item in groups %} +
+
+ {% if item.group %}{{ item.group.name }}{% else %}{% translate 'Ungrouped' %}{% endif %} +
+ {% for total in item.totals %} +
+ + {% include 'investments/fragments/_mwr.html' with mw=total.money_weighted %} +
+ {% endfor %} +
+
+
+ + + + + + + + + + + + + + + + + {% for perf in item.holdings %} + {% with c=perf.currency a=perf.account.currency %} + + + + + + + {% endwith %} + {% endfor %} + +
{% translate 'Investment' %}{% translate 'Value' %}
+
+ {{ perf.account.name }} + {% if perf.estimated %}{% endif %} +
+
+ {% if perf.priced %} + + {% if perf.price is not None %}× {% else %}· {% translate 'No exchange rate available' %}{% endif %} + {% else %} + {% translate 'Tracked by value' %} + {% endif %} +
+
+ {# Phones show value + return %, wider screens split them into columns #} +
{% if perf.market_value is not None %}{% else %}-{% endif %}
+ +
{% include 'investments/fragments/_mwr.html' with mw=perf.money_weighted %}
+
+
+
+ {% endfor %} +
+ +
+
+
+ +
+
+
+
+ {% else %} + + {% endif %} +
diff --git a/app/templates/investments/fragments/rebalance.html b/app/templates/investments/fragments/rebalance.html new file mode 100644 index 00000000..66adbfad --- /dev/null +++ b/app/templates/investments/fragments/rebalance.html @@ -0,0 +1,62 @@ +{% load i18n %} +{% if not targeted %} + + +{% elif total_target != 100 %} + +{% else %} + {% for plan in plans %} + {% with c=plan.currency %} +
+ {% if plans|length > 1 %}
{{ c.name }}
{% endif %} +
+ + + + + + + + + + + + {% for row in plan.rows %} + + + + + + + + {% endfor %} + +
{% translate 'Asset class' %}{% translate 'Value' %}{% translate 'Now' %}{% translate 'Target' %}{% translate 'To reach the target' %}
+ {{ row.class.name }} + {% if row.off %}{% endif %} + {{ row.share|floatformat:1 }}%{{ row.target|floatformat:"-2" }}% + {% if row.move > 0 %} + {% translate 'Buy' %} + {% elif row.move < 0 %} + {% translate 'Sell' %} + {% else %} + - + {% endif %} +
+
+ {% if plan.left_out %} +
+ {% translate 'Left out: investments without an asset class or a target' %} · + +
+ {% endif %} +
+ {% endwith %} + {% endfor %} +{% endif %} diff --git a/app/templates/investments/pages/detail.html b/app/templates/investments/pages/detail.html new file mode 100644 index 00000000..20f0532f --- /dev/null +++ b/app/templates/investments/pages/detail.html @@ -0,0 +1,8 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{{ account.name }} :: {% translate 'Investments' %}{% endblock %} + +{% block content %} +
+{% endblock %} diff --git a/app/templates/investments/pages/index.html b/app/templates/investments/pages/index.html new file mode 100644 index 00000000..ac48ee38 --- /dev/null +++ b/app/templates/investments/pages/index.html @@ -0,0 +1,8 @@ +{% extends "layouts/base.html" %} +{% load i18n %} + +{% block title %}{% translate 'Investments' %}{% endblock %} + +{% block content %} +
+{% endblock %} diff --git a/docker/prod/django/Dockerfile b/docker/prod/django/Dockerfile index 9ad00cb2..fd83829f 100644 --- a/docker/prod/django/Dockerfile +++ b/docker/prod/django/Dockerfile @@ -69,9 +69,7 @@ RUN sed -i 's/\r$//g' /start && \ COPY --chown=app:app ./app . RUN chown -R app:app /usr/src/app && \ - chown -R app:app /usr/src/frontend && \ - mkdir /temp && \ - chown -R app:app /temp + chown -R app:app /usr/src/frontend USER app diff --git a/frontend/src/js/charts.js b/frontend/src/js/charts.js index ceb318e6..62b8664c 100644 --- a/frontend/src/js/charts.js +++ b/frontend/src/js/charts.js @@ -4,3 +4,23 @@ import zoomPlugin from 'chartjs-plugin-zoom'; Chart.register(SankeyController, Flow, zoomPlugin); window.Chart = Chart; + +// The theme's text color at a given strength, resolved to a value canvas can draw +// with. shortcut: read once per chart, so a theme switch shows on the next render. +window.chartInk = (percent) => { + const probe = document.createElement('span'); + probe.style.color = `color-mix(in oklab, var(--color-base-content) ${percent}%, transparent)`; + document.body.appendChild(probe); + const color = getComputedStyle(probe).color; + probe.remove(); + return color; +}; + +// Formats amounts with the user's separators, as the amounts elsewhere in the app. +// currency: {prefix, suffix, decimals, decimal, thousand} +window.chartMoney = (currency) => (v, decimals = currency.decimals) => { + const [whole, fraction] = Math.abs(v).toFixed(decimals).split('.'); + const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, currency.thousand); + const number = fraction ? `${grouped}${currency.decimal}${fraction}` : grouped; + return `${v < 0 ? '-' : ''}${currency.prefix}${number}${currency.suffix}`; +}; diff --git a/frontend/src/js/select.js b/frontend/src/js/select.js index f40494ab..dc573918 100644 --- a/frontend/src/js/select.js +++ b/frontend/src/js/select.js @@ -3,6 +3,31 @@ import * as Popper from "@popperjs/core"; import '../styles/_tom-select.scss' +// Mini version of cotton/transaction/item.html, used by TransactionSelect +function renderTransaction(data, escape, compact) { + if (!data.date) { + return `
${escape(data.text)}
`; + } + const kind = data.type === 'EX' ? 'expense' : 'income'; + const meta = [ + data.category && `${escape(data.category)}`, + data.tags && data.tags.length && `${escape(data.tags.join(', '))}`, + data.entities && data.entities.length && `${escape(data.entities.join(', '))}`, + ].filter(Boolean).join(''); + return `
+ +
+
${escape(data.date)}
+
${escape(data.description || '')}
+ ${meta && !compact ? `
${meta}
` : ''} +
+
+
${escape(data.amount)}
+ +
+
`; +} + window.TomSelect = function createDynamicTomSelect(element) { const schedulePopperUpdate = function (instance) { // Wait for TomSelect DOM updates before recalculating dropdown position. @@ -98,12 +123,25 @@ window.TomSelect = function createDynamicTomSelect(element) { }; } + if (element.dataset.render === 'transaction') { + config.dataAttr = 'data'; // dataset key of the data-data attribute + config.render.option = (data, escape) => renderTransaction(data, escape, false); + config.render.item = (data, escape) => renderTransaction(data, escape, true); + } + if (element.dataset.load) { + // The server searches and orders; don't let tom-select re-filter or re-sort the results + config.score = () => () => 1; + config.sortField = [{field: '$order'}]; + config.preload = 'focus'; + config.shouldLoad = () => true; config.load = function (query, callback) { let url = element.dataset.load + '?q=' + encodeURIComponent(query); fetch(url) .then(response => response.json()) .then(json => { + // Drop results from the previous query, keeping the selected item + this.clearOptions(); callback(json); }).catch(() => { callback(); diff --git a/frontend/src/styles/_tom-select.scss b/frontend/src/styles/_tom-select.scss index 73f5122a..5cd4637b 100644 --- a/frontend/src/styles/_tom-select.scss +++ b/frontend/src/styles/_tom-select.scss @@ -399,3 +399,98 @@ .tomselect-checkbox { margin-right: 0.5rem; } + +/* Mini transaction (TransactionSelect) */ +.ts-tx { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + min-width: 0; + padding: 0.375rem 0.5rem; + border-left: 4px solid var(--color-success); + border-radius: var(--radius-field, 0.25rem); + background-color: var(--color-base-100); + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.8125rem; + line-height: 1.25rem; + text-align: left; +} + +.ts-tx-expense { + border-left-color: var(--color-error); +} + +.ts-tx-paid { + color: color-mix(in oklab, var(--color-base-content) 50%, transparent); + font-size: 1.125rem; +} + +.ts-tx-main, +.ts-tx-side { + min-width: 0; +} + +.ts-tx-main { + flex: 1 1 auto; +} + +.ts-tx-side { + flex: 0 1 auto; + max-width: 45%; + text-align: right; +} + +.ts-tx-date, +.ts-tx-meta, +.ts-tx-account { + color: color-mix(in oklab, var(--color-base-content) 70%, transparent); + font-size: 0.75rem; +} + +.ts-tx i.fa-fw { + margin-right: 0.25rem; + font-size: 0.625rem; +} + +.ts-tx-desc { + color: var(--color-base-content); + font-size: 0.875rem; +} + +.ts-tx-meta { + display: flex; + flex-wrap: wrap; + column-gap: 0.75rem; +} + +.ts-tx-amount { + font-weight: 500; + white-space: nowrap; +} + +.ts-tx-desc, +.ts-tx-account { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Selected value inside the control: no card chrome, leave room for the clear button and arrow */ +.ts-control .ts-tx-compact { + padding: 0 1.5rem 0 0.5rem; + background: transparent; +} + +.ts-dropdown .option:has(.ts-tx) { + padding: 0.25rem 0.375rem; +} + +.ts-dropdown .active .ts-tx { + background-color: transparent; +} + +.ts-control > .item:has(.ts-tx) { + flex: 1 1 auto; + min-width: 0; +}