diff --git a/app/WYGIWYH/urls.py b/app/WYGIWYH/urls.py index ef8e8f3..6324361 100644 --- a/app/WYGIWYH/urls.py +++ b/app/WYGIWYH/urls.py @@ -41,5 +41,6 @@ urlpatterns = [ path("", include("apps.accounts.urls")), path("", include("apps.net_worth.urls")), path("", include("apps.monthly_overview.urls")), + path("", include("apps.yearly_overview.urls")), path("", include("apps.currencies.urls")), ] diff --git a/app/apps/accounts/views/balance.py b/app/apps/accounts/views/balance.py index e968044..c6207e5 100644 --- a/app/apps/accounts/views/balance.py +++ b/app/apps/accounts/views/balance.py @@ -83,7 +83,7 @@ def account_reconciliation(request): ) return HttpResponse( status=204, - headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"}, + headers={"HX-Trigger": "updated, hide_offcanvas, toast"}, ) else: formset = AccountBalanceFormSet(initial=initial_data) diff --git a/app/apps/currencies/utils/convert.py b/app/apps/currencies/utils/convert.py index 19a8f1e..f1b6b81 100644 --- a/app/apps/currencies/utils/convert.py +++ b/app/apps/currencies/utils/convert.py @@ -5,11 +5,17 @@ from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from apps.currencies.models import ExchangeRate +from apps.currencies.models import Currency -def convert(amount, from_currency, to_currency, date=None): +def convert(amount, from_currency: Currency, to_currency: Currency, date=None): if from_currency == to_currency: - return amount + return ( + amount, + to_currency.prefix, + to_currency.suffix, + to_currency.decimal_places, + ) if date is None: date = timezone.localtime(timezone.now()) @@ -35,8 +41,13 @@ def convert(amount, from_currency, to_currency, date=None): ) if exchange_rate is None: - return None + return None, None, None, None - return amount * exchange_rate.effective_rate + return ( + amount * exchange_rate.effective_rate, + to_currency.prefix, + to_currency.suffix, + to_currency.decimal_places, + ) except ExchangeRate.DoesNotExist: - return None + return None, None, None, None diff --git a/app/apps/monthly_overview/models.py b/app/apps/monthly_overview/models.py deleted file mode 100644 index 71a8362..0000000 --- a/app/apps/monthly_overview/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/app/apps/monthly_overview/tests.py b/app/apps/monthly_overview/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/app/apps/monthly_overview/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/app/apps/net_worth/utils/calculate_net_worth.py b/app/apps/net_worth/utils/calculate_net_worth.py index a018889..1a9643d 100644 --- a/app/apps/net_worth/utils/calculate_net_worth.py +++ b/app/apps/net_worth/utils/calculate_net_worth.py @@ -2,10 +2,77 @@ from django.db.models import Sum from decimal import Decimal from django.db.models.functions import TruncMonth +from django.utils.translation import gettext_lazy as _ from apps.transactions.models import Transaction from apps.accounts.models import Account from apps.currencies.models import Currency +from apps.currencies.utils.convert import convert + + +def calculate_account_net_worth(): + account_net_worth = {} + ungrouped_id = None # Special ID for ungrouped accounts + + # Initialize the "Ungrouped" category + account_net_worth[ungrouped_id] = {"name": _("Ungrouped"), "accounts": {}} + + # Get all accounts + accounts = Account.objects.all() + + for account in accounts: + currency = account.currency + + income = Transaction.objects.filter( + account=account, type=Transaction.Type.INCOME, is_paid=True + ).aggregate(total=Sum("amount"))["total"] or Decimal("0") + + expenses = Transaction.objects.filter( + account=account, type=Transaction.Type.EXPENSE, is_paid=True + ).aggregate(total=Sum("amount"))["total"] or Decimal("0") + + account_balance = income - expenses + + account_data = { + "name": account.name, + "balance": account_balance, + "currency": { + "code": currency.code, + "name": currency.name, + "prefix": currency.prefix, + "suffix": currency.suffix, + "decimal_places": currency.decimal_places, + }, + } + + if account.exchange_currency: + converted_amount, prefix, suffix, decimal_places = convert( + amount=account_balance, + from_currency=account.currency, + to_currency=account.exchange_currency, + ) + if converted_amount: + account_data["exchange"] = { + "amount": converted_amount, + "prefix": prefix, + "suffix": suffix, + "decimal_places": decimal_places, + } + + if account.group: + group_id = account.group.id + group_name = account.group.name + if group_id not in account_net_worth: + account_net_worth[group_id] = {"name": group_name, "accounts": {}} + account_net_worth[group_id]["accounts"][account.id] = account_data + else: + account_net_worth[ungrouped_id]["accounts"][account.id] = account_data + + # Remove the "Ungrouped" category if it's empty + if not account_net_worth[ungrouped_id]["accounts"]: + del account_net_worth[ungrouped_id] + + return account_net_worth def calculate_net_worth(): @@ -41,7 +108,7 @@ def calculate_historical_net_worth(start_date, end_date): # Get all months between start_date and end_date months = ( Transaction.objects.filter(account__in=asset_accounts) - .annotate(month=TruncMonth("date")) + .annotate(month=TruncMonth("reference_date")) .values("month") .distinct() .order_by("month") @@ -61,14 +128,14 @@ def calculate_historical_net_worth(start_date, end_date): account=account, type=Transaction.Type.INCOME, is_paid=True, - date__lte=month, + reference_date__lte=month, ).aggregate(total=Sum("amount"))["total"] or Decimal("0.00") expenses = Transaction.objects.filter( account=account, type=Transaction.Type.EXPENSE, is_paid=True, - date__lte=month, + reference_date__lte=month, ).aggregate(total=Sum("amount"))["total"] or Decimal("0.00") account_balance = income - expenses diff --git a/app/apps/net_worth/views.py b/app/apps/net_worth/views.py index 4a6ad20..ed92d8e 100644 --- a/app/apps/net_worth/views.py +++ b/app/apps/net_worth/views.py @@ -1,11 +1,14 @@ from datetime import datetime +from dateutil.relativedelta import relativedelta from django.http import JsonResponse from django.shortcuts import render +from django.utils import timezone from apps.net_worth.utils.calculate_net_worth import ( calculate_net_worth, calculate_historical_net_worth, + calculate_account_net_worth, ) from apps.currencies.models import Currency @@ -13,12 +16,14 @@ from apps.currencies.models import Currency # Create your views here. def net_worth_main(request): net_worth = calculate_net_worth() + detailed_net_worth = calculate_account_net_worth() # historical = calculate_historical_net_worth( # start_date=datetime(day=1, month=1, year=2021).date(), # end_date=datetime(day=1, month=1, year=2025).date(), # ) # print(historical) + print(detailed_net_worth) # Format the net worth with currency details formatted_net_worth = [] for currency_code, amount in net_worth.items(): @@ -34,6 +39,25 @@ def net_worth_main(request): } ) + end_date = timezone.now() + start_date = end_date - relativedelta(years=5) # Last year + + # Calculate historical net worth + historical_data = calculate_historical_net_worth(start_date, end_date) + + # Prepare data for the template + currencies = Currency.objects.all() + print(historical_data) + return render( - request, "net_worth/net_worth.html", {"currency_net_worth": formatted_net_worth} + request, + "net_worth/net_worth.html", + { + "currency_net_worth": formatted_net_worth, + "account_net_worth": detailed_net_worth, + "currencies": currencies, + "historical_data_json": JsonResponse(historical_data).content.decode( + "utf-8" + ), + }, ) diff --git a/app/apps/transactions/models.py b/app/apps/transactions/models.py index efe73a9..4aa49e3 100644 --- a/app/apps/transactions/models.py +++ b/app/apps/transactions/models.py @@ -119,18 +119,18 @@ class Transaction(models.Model): def exchanged_amount(self): if self.account.exchange_currency: - converted_amount = convert( + converted_amount, prefix, suffix, decimal_places = convert( self.amount, - self.account.exchange_currency, - self.account.currency, + to_currency=self.account.exchange_currency, + from_currency=self.account.currency, date=self.date, ) if converted_amount: return { "amount": converted_amount, - "suffix": self.account.exchange_currency.suffix, - "prefix": self.account.exchange_currency.prefix, - "decimal_places": self.account.exchange_currency.decimal_places, + "prefix": prefix, + "suffix": suffix, + "decimal_places": decimal_places, } return None diff --git a/app/apps/transactions/views/transactions.py b/app/apps/transactions/views/transactions.py index f16190e..c987314 100644 --- a/app/apps/transactions/views/transactions.py +++ b/app/apps/transactions/views/transactions.py @@ -38,7 +38,7 @@ def transaction_add(request): return HttpResponse( status=204, - headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"}, + headers={"HX-Trigger": "updated, hide_offcanvas, toast"}, ) else: form = TransactionForm( @@ -69,7 +69,7 @@ def transaction_edit(request, transaction_id, **kwargs): return HttpResponse( status=204, - headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"}, + headers={"HX-Trigger": "updated, hide_offcanvas, toast"}, ) else: form = TransactionForm(instance=transaction) @@ -102,7 +102,7 @@ def transaction_delete(request, transaction_id, **kwargs): return HttpResponse( status=204, - headers={"HX-Trigger": "transaction_updated, toast"}, + headers={"HX-Trigger": "updated, toast"}, ) @@ -127,7 +127,7 @@ def transactions_transfer(request): messages.success(request, _("Transfer added successfully")) return HttpResponse( status=204, - headers={"HX-Trigger": "transaction_updated, toast, hide_offcanvas"}, + headers={"HX-Trigger": "updated, toast, hide_offcanvas"}, ) else: form = TransferForm( @@ -175,7 +175,7 @@ class AddInstallmentPlanView(View): return HttpResponse( status=204, - headers={"HX-Trigger": "transaction_updated, hide_offcanvas, toast"}, + headers={"HX-Trigger": "updated, hide_offcanvas, toast"}, ) return render(request, self.template_name, {"form": form}) diff --git a/app/apps/users/views.py b/app/apps/users/views.py index adf265b..71f63b4 100644 --- a/app/apps/users/views.py +++ b/app/apps/users/views.py @@ -45,7 +45,7 @@ def toggle_amount_visibility(request): messages.info(request, _("Transaction amounts are now displayed")) response = render(request, "users/generic/hide_amounts.html") - response.headers["HX-Trigger"] = "transaction_updated, toast" + response.headers["HX-Trigger"] = "updated, toast" return response diff --git a/app/apps/yearly_overview/__init__.py b/app/apps/yearly_overview/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/yearly_overview/apps.py b/app/apps/yearly_overview/apps.py new file mode 100644 index 0000000..d1e22b8 --- /dev/null +++ b/app/apps/yearly_overview/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class YearlyOverviewConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.yearly_overview" diff --git a/app/apps/yearly_overview/migrations/__init__.py b/app/apps/yearly_overview/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/apps/yearly_overview/urls.py b/app/apps/yearly_overview/urls.py new file mode 100644 index 0000000..64ad627 --- /dev/null +++ b/app/apps/yearly_overview/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("yearly/", views.index, name="yearly_index"), + path( + "yearly//", + views.yearly_overview, + name="yearly_overview", + ), +] diff --git a/app/apps/yearly_overview/views.py b/app/apps/yearly_overview/views.py new file mode 100644 index 0000000..bba95d1 --- /dev/null +++ b/app/apps/yearly_overview/views.py @@ -0,0 +1,468 @@ +from datetime import date +from decimal import Decimal + +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect +from django.utils import timezone +from django.db.models import Sum, F, Q, Value, CharField, DecimalField +from django.db.models.functions import TruncMonth, Coalesce +from django.db.models.expressions import Case, When +from django.db.models.functions import Concat + +from apps.transactions.models import Transaction + + +# Create your views here. +@login_required +def index(request): + now = timezone.localdate(timezone.now()) + + return redirect(to="yearly_overview", year=now.year) + + +# def yearly_overview(request, year: int): +# transactions = Transaction.objects.filter(date__year=year) +# +# monthly_data = ( +# transactions.annotate(month=TruncMonth("date")) +# .values( +# "month", +# "account__id", +# "account__name", +# "account__group__name", +# "account__currency__code", +# "account__currency__suffix", +# "account__currency__prefix", +# "account__currency__decimal_places", +# ) +# .annotate( +# income_paid=Coalesce( +# Sum( +# Case( +# When( +# type=Transaction.Type.INCOME, is_paid=True, then=F("amount") +# ), +# default=Value(Decimal("0")), +# output_field=DecimalField(), +# ) +# ), +# Value(Decimal("0")), +# output_field=DecimalField(), +# ), +# expense_paid=Coalesce( +# Sum( +# Case( +# When( +# type=Transaction.Type.EXPENSE, +# is_paid=True, +# then=F("amount"), +# ), +# default=Value(Decimal("0")), +# output_field=DecimalField(), +# ) +# ), +# Value(Decimal("0")), +# output_field=DecimalField(), +# ), +# income_unpaid=Coalesce( +# Sum( +# Case( +# When( +# type=Transaction.Type.INCOME, +# is_paid=False, +# then=F("amount"), +# ), +# default=Value(Decimal("0")), +# output_field=DecimalField(), +# ) +# ), +# Value(Decimal("0")), +# output_field=DecimalField(), +# ), +# expense_unpaid=Coalesce( +# Sum( +# Case( +# When( +# type=Transaction.Type.EXPENSE, +# is_paid=False, +# then=F("amount"), +# ), +# default=Value(Decimal("0")), +# output_field=DecimalField(), +# ) +# ), +# Value(Decimal("0")), +# output_field=DecimalField(), +# ), +# ) +# .annotate( +# balance_unpaid=F("income_unpaid") - F("expense_unpaid"), +# balance_paid=F("income_paid") - F("expense_paid"), +# balance_total=F("income_paid") +# + F("income_unpaid") +# - F("expense_paid") +# - F("expense_unpaid"), +# ) +# .order_by("month", "account__group__name") +# ) +# +# # Create a list of all months in the year +# all_months = [date(year, month, 1) for month in range(1, 13)] +# +# # Create a dictionary to store the final result +# result = { +# month: { +# "income_paid": [], +# "expense_paid": [], +# "income_unpaid": [], +# "expense_unpaid": [], +# "balance_unpaid": [], +# "balance_paid": [], +# "balance_total": [], +# } +# for month in all_months +# } +# +# # Fill in the data +# for entry in monthly_data: +# month = entry["month"] +# account_info = { +# "id": entry["account__id"], +# "name": entry["account__name"], +# "currency": entry["account__currency__code"], +# "suffix": entry["account__currency__suffix"], +# "prefix": entry["account__currency__prefix"], +# "decimal_places": entry["account__currency__decimal_places"], +# "group": entry["account__group__name"], +# } +# +# for field in [ +# "income_paid", +# "expense_paid", +# "income_unpaid", +# "expense_unpaid", +# "balance_unpaid", +# "balance_paid", +# "balance_total", +# ]: +# result[month][field].append( +# {"account": account_info, "amount": entry[field]} +# ) +# +# # Fill in missing months with empty lists +# for month in all_months: +# if not any(result[month].values()): +# result[month] = { +# "income_paid": [], +# "expense_paid": [], +# "income_unpaid": [], +# "expense_unpaid": [], +# "balance_unpaid": [], +# "balance_paid": [], +# "balance_total": [], +# } +# +# from pprint import pprint +# +# pprint(result) +# +# return render( +# request, +# "yearly_overview/pages/overview2.html", +# context={ +# "year": year, +# # "next_month": next_month, +# # "next_year": next_year, +# # "previous_month": previous_month, +# # "previous_year": previous_year, +# "data": result, +# }, +# ) + + +def yearly_overview(request, year: int): + # First, let's create a base queryset for the given year + base_queryset = Transaction.objects.filter(date__year=year) + + # Create a list of all months in the year + months = [f"{year}-{month:02d}-01" for month in range(1, 13)] + + # Create the queryset with all the required annotations + queryset = ( + base_queryset.annotate(month=TruncMonth("date")) + .values("month", "account__group__name") + .annotate( + income_paid=Coalesce( + Sum( + Case( + When( + Q(type=Transaction.Type.INCOME, is_paid=True), + then=F("amount"), + ), + default=Value(0), + output_field=DecimalField(), + ) + ), + Value(0, output_field=DecimalField()), + ), + expense_paid=Coalesce( + Sum( + Case( + When( + Q(type=Transaction.Type.EXPENSE, is_paid=True), + then=F("amount"), + ), + default=Value(0), + output_field=DecimalField(), + ) + ), + Value(0, output_field=DecimalField()), + ), + income_unpaid=Coalesce( + Sum( + Case( + When( + Q(type=Transaction.Type.INCOME, is_paid=False), + then=F("amount"), + ), + default=Value(0), + output_field=DecimalField(), + ) + ), + Value(0, output_field=DecimalField()), + ), + expense_unpaid=Coalesce( + Sum( + Case( + When( + Q(type=Transaction.Type.EXPENSE, is_paid=False), + then=F("amount"), + ), + default=Value(0), + output_field=DecimalField(), + ) + ), + Value(0, output_field=DecimalField()), + ), + ) + .annotate( + balance_unpaid=F("income_unpaid") - F("expense_unpaid"), + balance_paid=F("income_paid") - F("expense_paid"), + balance_total=F("income_paid") + + F("income_unpaid") + - F("expense_paid") + - F("expense_unpaid"), + ) + .order_by("month", "account__group__name") + ) + + # Create a dictionary to store results + results = {month: {} for month in months} + + # Populate the results dictionary + for entry in queryset: + month = entry["month"] + account_group = entry["account__group__name"] + + if account_group not in results[month]: + results[month][account_group] = { + "income_paid": entry["income_paid"], + "expense_paid": entry["expense_paid"], + "income_unpaid": entry["income_unpaid"], + "expense_unpaid": entry["expense_unpaid"], + "balance_unpaid": entry["balance_unpaid"], + "balance_paid": entry["balance_paid"], + "balance_total": entry["balance_total"], + } + else: + # If the account group already exists, update the values + for key in [ + "income_paid", + "expense_paid", + "income_unpaid", + "expense_unpaid", + "balance_unpaid", + "balance_paid", + "balance_total", + ]: + results[month][account_group][key] += entry[key] + + # Replace empty months with "-" + for month in results: + if not results[month]: + results[month] = "-" + + from pprint import pprint + + pprint(results) + + return render( + request, + "yearly_overview/pages/overview2.html", + context={ + "year": year, + # "next_month": next_month, + # "next_year": next_year, + # "previous_month": previous_month, + # "previous_year": previous_year, + "data": results, + }, + ) + + +# def yearly_overview(request, year: int): +# transactions = Transaction.objects.filter(reference_date__year=year) +# +# monthly_data = ( +# transactions.annotate(month=TruncMonth("reference_date")) +# .values( +# "month", +# "account__currency__code", +# "account__currency__prefix", +# "account__currency__suffix", +# "account__currency__decimal_places", +# ) +# .annotate( +# income_paid=Coalesce( +# Sum( +# Case( +# When( +# type=Transaction.Type.INCOME, is_paid=True, then=F("amount") +# ), +# default=Value(Decimal("0")), +# output_field=DecimalField(), +# ) +# ), +# Value(Decimal("0")), +# output_field=DecimalField(), +# ), +# expense_paid=Coalesce( +# Sum( +# Case( +# When( +# type=Transaction.Type.EXPENSE, +# is_paid=True, +# then=F("amount"), +# ), +# default=Value(Decimal("0")), +# output_field=DecimalField(), +# ) +# ), +# Value(Decimal("0")), +# output_field=DecimalField(), +# ), +# income_unpaid=Coalesce( +# Sum( +# Case( +# When( +# type=Transaction.Type.INCOME, +# is_paid=False, +# then=F("amount"), +# ), +# default=Value(Decimal("0")), +# output_field=DecimalField(), +# ) +# ), +# Value(Decimal("0")), +# output_field=DecimalField(), +# ), +# expense_unpaid=Coalesce( +# Sum( +# Case( +# When( +# type=Transaction.Type.EXPENSE, +# is_paid=False, +# then=F("amount"), +# ), +# default=Value(Decimal("0")), +# output_field=DecimalField(), +# ) +# ), +# Value(Decimal("0")), +# output_field=DecimalField(), +# ), +# ) +# .annotate( +# balance_unpaid=F("income_unpaid") - F("expense_unpaid"), +# balance_paid=F("income_paid") - F("expense_paid"), +# balance_total=F("income_paid") +# + F("income_unpaid") +# - F("expense_paid") +# - F("expense_unpaid"), +# ) +# .order_by("month", "account__currency__code") +# ) +# +# # Create a list of all months in the year +# all_months = [date(year, month, 1) for month in range(1, 13)] +# +# # Create a dictionary to store the final result +# result = { +# month: { +# "income_paid": [], +# "expense_paid": [], +# "income_unpaid": [], +# "expense_unpaid": [], +# "balance_unpaid": [], +# "balance_paid": [], +# "balance_total": [], +# } +# for month in all_months +# } +# +# # Fill in the data +# for entry in monthly_data: +# month = entry["month"] +# currency_code = entry["account__currency__code"] +# prefix = entry["account__currency__prefix"] +# suffix = entry["account__currency__suffix"] +# decimal_places = entry["account__currency__decimal_places"] +# +# for field in [ +# "income_paid", +# "expense_paid", +# "income_unpaid", +# "expense_unpaid", +# "balance_unpaid", +# "balance_paid", +# "balance_total", +# ]: +# result[month][field].append( +# { +# "code": currency_code, +# "prefix": prefix, +# "suffix": suffix, +# "decimal_places": decimal_places, +# "amount": entry[field], +# } +# ) +# +# # Fill in missing months with empty lists +# for month in all_months: +# if not any(result[month].values()): +# result[month] = { +# "income_paid": [], +# "expense_paid": [], +# "income_unpaid": [], +# "expense_unpaid": [], +# "balance_unpaid": [], +# "balance_paid": [], +# "balance_total": [], +# } +# +# from pprint import pprint +# +# pprint(result) +# +# return render( +# request, +# "yearly_overview/pages/overview.html", +# context={ +# "year": year, +# # "next_month": next_month, +# # "next_year": next_year, +# # "previous_month": previous_month, +# # "previous_year": previous_year, +# "totals": result, +# }, +# ) diff --git a/app/templates/accounts/pages/list.html b/app/templates/accounts/pages/list.html index b70e9ee..ec6cfd9 100644 --- a/app/templates/accounts/pages/list.html +++ b/app/templates/accounts/pages/list.html @@ -41,9 +41,7 @@ data-bs-title="{% translate "Edit" %}" hx-get="{% url 'account_edit' pk=account.id %}" hx-target="#generic-offcanvas" - _="on click send action_clicked to .tag-action in the closest parent .tag end - on action_clicked call bootstrap.Tooltip.getOrCreateInstance(me).dispose() end - install tooltip"> + _="install tooltip"> + _="install tooltip">