from datetime import date from decimal import Decimal from django.contrib.auth.decorators import login_required from django.db.models import Sum, F, Value, DecimalField, Q from django.db.models.expressions import Case, When from django.db.models.functions import TruncMonth, Coalesce, TruncYear from django.http import Http404 from django.shortcuts import render, redirect from django.utils import timezone from apps.accounts.models import Account from apps.currencies.models import Currency from apps.currencies.utils.convert import convert from apps.transactions.models import Transaction from apps.common.decorators.htmx import only_htmx @login_required def index_by_currency(request): now = timezone.localdate(timezone.now()) return redirect(to="yearly_overview_currency", year=now.year) @login_required def index_by_account(request): now = timezone.localdate(timezone.now()) return redirect(to="yearly_overview_account", year=now.year) @login_required def index_yearly_overview_by_currency(request, year: int): next_year = year + 1 previous_year = year - 1 month_options = range(1, 13) currency_options = Currency.objects.filter( accounts__transactions__date__year=year ).distinct() return render( request, "yearly_overview/pages/overview_by_currency.html", context={ "year": year, "next_year": next_year, "previous_year": previous_year, "months": month_options, "currencies": currency_options, }, ) @only_htmx @login_required def yearly_overview_by_currency(request, year: int): month = request.GET.get("month") currency = request.GET.get("currency") # Base query filter filter_params = {"reference_date__year": year, "account__is_archived": False} # Add month filter if provided if month: month = int(month) if not 1 <= month <= 12: raise Http404("Invalid month") filter_params["reference_date__month"] = month # Add currency filter if provided if currency: filter_params["account__currency_id"] = int(currency) transactions = ( Transaction.objects.filter(**filter_params) .exclude(Q(category__mute=True) & ~Q(category=None)) .select_related("account__currency") # Optimize by pre-fetching currency data ) date_trunc = TruncMonth("reference_date") if month else TruncYear("reference_date") monthly_data = ( transactions.annotate(month=date_trunc) .values( "month", "account__currency__code", "account__currency__prefix", "account__currency__suffix", "account__currency__decimal_places", "account__currency_id", "account__currency__exchange_currency_id", ) .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")), ), 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")), ), 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")), ), 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")), ), ) .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") ) # Fetch all currencies and their exchange currencies in a single query currencies = { currency.id: currency for currency in Currency.objects.select_related("exchange_currency").all() } result = { "income_paid": [], "expense_paid": [], "income_unpaid": [], "expense_unpaid": [], "balance_unpaid": [], "balance_paid": [], "balance_total": [], } for entry in monthly_data: if all(entry[field] == 0 for field in result.keys()): continue # Skip entries where all values are 0 currency_code = entry["account__currency__code"] prefix = entry["account__currency__prefix"] suffix = entry["account__currency__suffix"] decimal_places = entry["account__currency__decimal_places"] # Get the currency objects for conversion from_currency = currencies.get(entry["account__currency_id"]) to_currency = ( None if not from_currency else currencies.get(from_currency.exchange_currency_id) ) for field in result.keys(): amount = entry[field] if amount == 0: continue item = { "code": currency_code, "prefix": prefix, "suffix": suffix, "decimal_places": decimal_places, "amount": amount, "exchanged": None, } # Add exchange calculation if possible if from_currency and to_currency: exchanged_amount, ex_prefix, ex_suffix, ex_decimal_places = convert( amount=amount, from_currency=from_currency, to_currency=to_currency, ) item["exchanged"] = { "amount": exchanged_amount, "code": to_currency.code, "prefix": ex_prefix, "suffix": ex_suffix, "decimal_places": ex_decimal_places, } result[field].append(item) return render( request, "yearly_overview/fragments/currency_data.html", context={ "year": year, "totals": result, }, ) @login_required def index_yearly_overview_by_account(request, year: int): next_year = year + 1 previous_year = year - 1 month_options = range(1, 13) account_options = ( Account.objects.filter(is_archived=False, transactions__date__year=year) .select_related("group") .distinct() .order_by("group__name", "name", "id") ) return render( request, "yearly_overview/pages/overview_by_account.html", context={ "year": year, "next_year": next_year, "previous_year": previous_year, "months": month_options, "accounts": account_options, }, ) @only_htmx @login_required def yearly_overview_by_account(request, year: int): month = request.GET.get("month") account = request.GET.get("account") # Base query filter filter_params = {"reference_date__year": year, "account__is_archived": False} # Add month filter if provided if month: month = int(month) if not 1 <= month <= 12: raise Http404("Invalid month") filter_params["reference_date__month"] = month # Add account filter if provided if account: filter_params["account_id"] = int(account) transactions = Transaction.objects.filter(**filter_params) # Use TruncYear if no month specified, otherwise use TruncMonth date_trunc = TruncMonth("reference_date") if month else TruncYear("reference_date") monthly_data = ( transactions.annotate(month=date_trunc) .select_related( "account__currency", "account__exchange_currency", ) .values( "month", "account__id", "account__name", "account__currency", "account__exchange_currency", "account__currency__code", "account__currency__prefix", "account__currency__suffix", "account__currency__decimal_places", "account__exchange_currency__code", "account__exchange_currency__prefix", "account__exchange_currency__suffix", "account__exchange_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__name") ) # Determine which dates to include if month: all_months = [date(year, month, 1)] else: all_months = [date(year, 1, 1)] # Just one entry for the whole year # Get all accounts with their currencies (filtered by account if specified) accounts_filter = {} if account: accounts_filter["account__id"] = int(account) accounts = ( transactions.filter(**accounts_filter) .values( "account__id", "account__name", "account__group__name", "account__currency__code", "account__currency__prefix", "account__currency__suffix", "account__currency__decimal_places", "account__exchange_currency__code", "account__exchange_currency__prefix", "account__exchange_currency__suffix", "account__exchange_currency__decimal_places", ) .distinct() .order_by("account__group__name", "account__name", "account__id") ) # Get Currency objects for conversion currencies = {currency.id: currency for currency in Currency.objects.all()} result = { month: { account["account__id"]: { "name": account["account__name"], "group": account["account__group__name"], "currency": { "code": account["account__currency__code"], "prefix": account["account__currency__prefix"], "suffix": account["account__currency__suffix"], "decimal_places": account["account__currency__decimal_places"], }, "exchange_currency": ( { "code": account["account__exchange_currency__code"], "prefix": account["account__exchange_currency__prefix"], "suffix": account["account__exchange_currency__suffix"], "decimal_places": account[ "account__exchange_currency__decimal_places" ], } if account["account__exchange_currency__code"] else None ), "income_paid": Decimal("0"), "expense_paid": Decimal("0"), "income_unpaid": Decimal("0"), "expense_unpaid": Decimal("0"), "balance_unpaid": Decimal("0"), "balance_paid": Decimal("0"), "balance_total": Decimal("0"), } for account in accounts } for month in all_months } # Fill in the data for entry in monthly_data: month = entry["month"] account_id = entry["account__id"] for field in [ "income_paid", "expense_paid", "income_unpaid", "expense_unpaid", "balance_unpaid", "balance_paid", "balance_total", ]: result[month][account_id][field] = entry[field] if result[month][account_id]["exchange_currency"]: from_currency = currencies[entry["account__currency"]] to_currency = currencies[entry["account__exchange_currency"]] if entry[field] > 0 or entry[field] < 0: converted_amount, prefix, suffix, decimal_places = convert( amount=entry[field], from_currency=from_currency, to_currency=to_currency, ) if isinstance(converted_amount, Decimal): result[month][account_id][ f"exchange_{field}" ] = converted_amount else: result[month][account_id][f"exchange_{field}"] = Decimal(0) return render( request, "yearly_overview/fragments/account_data.html", context={"year": year, "totals": result, "single": True if account else False}, )