Browse Source

feat: add financial queries and monthly reports

master
Codex 2 months ago
parent
commit
6b44ad053e
  1. 24
      finance/migrations/0002_transaction_report_indexes.py
  2. 124
      finance/queries.py
  3. 159
      finance/reports.py
  4. 107
      tests/finance/test_queries.py
  5. 121
      tests/finance/test_reports.py

24
finance/migrations/0002_transaction_report_indexes.py

@ -0,0 +1,24 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("finance", "0001_initial"),
]
operations = [
migrations.RunSQL(
sql=(
"CREATE INDEX finance_tx_posting_date_idx "
"ON finance_transaction (posting_date)"
),
reverse_sql="DROP INDEX finance_tx_posting_date_idx",
),
migrations.RunSQL(
sql=(
"CREATE INDEX finance_tx_account_posting_idx "
"ON finance_transaction (account_id, posting_date)"
),
reverse_sql="DROP INDEX finance_tx_account_posting_idx",
),
]

124
finance/queries.py

@ -0,0 +1,124 @@
from dataclasses import dataclass
from datetime import date
import re
from typing import Mapping
from uuid import UUID
from django.db.models import QuerySet
from finance.models import Transaction
_CURRENCY_RE = re.compile(r"[A-Z]{3}", re.ASCII)
class QueryInputError(ValueError):
def __init__(self, errors: dict[str, str]):
super().__init__("Invalid transaction filters")
self.errors = errors
@dataclass(frozen=True)
class TransactionFilters:
date_from: date | None = None
date_to: date | None = None
account_id: UUID | None = None
kind: str | None = None
payee_id: UUID | None = None
category_id: UUID | None = None
currency: str | None = None
uncategorized: bool | None = None
@classmethod
def from_mapping(cls, mapping: Mapping[str, object]) -> "TransactionFilters":
errors: dict[str, str] = {}
date_from = _parse_date(mapping.get("date_from"), "date_from", errors)
date_to = _parse_date(mapping.get("date_to"), "date_to", errors)
account_id = _parse_uuid(mapping.get("account"), "account", errors)
payee_id = _parse_uuid(mapping.get("payee"), "payee", errors)
category_id = _parse_uuid(mapping.get("category"), "category", errors)
kind = _optional_string(mapping.get("kind"))
if kind is not None and kind not in Transaction.Kind.values:
errors["kind"] = "invalid_choice"
currency = _optional_string(mapping.get("currency"))
if currency is not None and _CURRENCY_RE.fullmatch(currency) is None:
errors["currency"] = "invalid_currency"
uncategorized_value = _optional_string(mapping.get("uncategorized"))
uncategorized = None
if uncategorized_value == "yes":
uncategorized = True
elif uncategorized_value == "no":
uncategorized = False
elif uncategorized_value is not None:
errors["uncategorized"] = "invalid_choice"
if date_from is not None and date_to is not None and date_to < date_from:
errors["date_to"] = "before_date_from"
if errors:
raise QueryInputError(errors)
return cls(
date_from=date_from,
date_to=date_to,
account_id=account_id,
kind=kind,
payee_id=payee_id,
category_id=category_id,
currency=currency,
uncategorized=uncategorized,
)
def filtered_transactions(filters: TransactionFilters) -> QuerySet[Transaction]:
rows = Transaction.objects.select_related(
"account", "payee", "category", "statement", "refund_of", "installment_plan"
)
if filters.date_from is not None:
rows = rows.filter(posting_date__gte=filters.date_from)
if filters.date_to is not None:
rows = rows.filter(posting_date__lte=filters.date_to)
if filters.account_id is not None:
rows = rows.filter(account_id=filters.account_id)
if filters.kind is not None:
rows = rows.filter(kind=filters.kind)
if filters.payee_id is not None:
rows = rows.filter(payee_id=filters.payee_id)
if filters.category_id is not None:
rows = rows.filter(category_id=filters.category_id)
if filters.currency is not None:
rows = rows.filter(account__currency=filters.currency)
if filters.uncategorized is not None:
rows = rows.filter(category__isnull=filters.uncategorized)
return rows.order_by("-posting_date", "-id")
def _optional_string(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _parse_date(value: object, field: str, errors: dict[str, str]) -> date | None:
text = _optional_string(value)
if text is None:
return None
try:
return date.fromisoformat(text)
except ValueError:
errors[field] = "invalid_date"
return None
def _parse_uuid(value: object, field: str, errors: dict[str, str]) -> UUID | None:
text = _optional_string(value)
if text is None:
return None
try:
return UUID(text)
except (ValueError, AttributeError):
errors[field] = "invalid_uuid"
return None

159
finance/reports.py

@ -0,0 +1,159 @@
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
import re
from uuid import UUID
from django.db.models import DecimalField, F, Q, Sum, Value
from django.db.models.functions import Coalesce
from finance.models import Account, Transaction
_CURRENCY_RE = re.compile(r"[A-Z]{3}", re.ASCII)
_MONEY_FIELD = DecimalField(max_digits=24, decimal_places=2)
_ZERO = Decimal("0.00")
class ReportInputError(ValueError):
def __init__(self, errors: dict[str, str]):
super().__init__("Invalid report inputs")
self.errors = errors
@dataclass(frozen=True)
class MonthlySummary:
month: date
currency: str
inflow: Decimal
expense: Decimal
refund: Decimal
net_spending: Decimal
net_activity: Decimal
@dataclass(frozen=True)
class SpendingCategory:
category_id: UUID | None
category_name: str
expense: Decimal
refund: Decimal
net_spending: Decimal
@dataclass(frozen=True)
class AccountBalance:
account_id: UUID
account_name: str
balance: Decimal
def available_currencies() -> tuple[str, ...]:
return tuple(
Account.objects.order_by("currency")
.values_list("currency", flat=True)
.distinct()
)
def monthly_summary(month: date, currency: str) -> MonthlySummary:
month, currency, month_after = _validated_period(month, currency)
totals = _monthly_rows(month, month_after, currency).aggregate(
inflow=_sum("amount", Q(kind=Transaction.Kind.INCOME)),
expense=_sum(-F("amount"), Q(kind=Transaction.Kind.EXPENSE)),
refund=_sum("amount", Q(kind=Transaction.Kind.REFUND)),
)
inflow = totals["inflow"]
expense = totals["expense"]
refund = totals["refund"]
net_spending = expense - refund
return MonthlySummary(
month=month,
currency=currency,
inflow=inflow,
expense=expense,
refund=refund,
net_spending=net_spending,
net_activity=inflow - net_spending,
)
def spending_by_category(month: date, currency: str) -> tuple[SpendingCategory, ...]:
month, currency, month_after = _validated_period(month, currency)
grouped = (
_monthly_rows(month, month_after, currency)
.filter(kind__in=[Transaction.Kind.EXPENSE, Transaction.Kind.REFUND])
.values("category_id", "category__name")
.annotate(
expense=_sum(-F("amount"), Q(kind=Transaction.Kind.EXPENSE)),
refund=_sum("amount", Q(kind=Transaction.Kind.REFUND)),
)
)
rows = tuple(
SpendingCategory(
category_id=row["category_id"],
category_name=row["category__name"] or "Uncategorized",
expense=row["expense"],
refund=row["refund"],
net_spending=row["expense"] - row["refund"],
)
for row in grouped
)
return tuple(sorted(rows, key=lambda row: (-row.net_spending, row.category_name)))
def account_balances(month: date, currency: str) -> tuple[AccountBalance, ...]:
month, currency, month_after = _validated_period(month, currency)
del month
rows = (
Account.objects.filter(currency=currency)
.annotate(
report_balance=Coalesce(
Sum("transactions__amount", filter=Q(transactions__posting_date__lt=month_after)),
Value(_ZERO),
output_field=_MONEY_FIELD,
)
)
.order_by("name", "id")
)
return tuple(
AccountBalance(
account_id=row.pk,
account_name=row.name,
balance=row.report_balance,
)
for row in rows
)
def _monthly_rows(month: date, month_after: date, currency: str):
return Transaction.objects.filter(
posting_date__gte=month,
posting_date__lt=month_after,
account__currency=currency,
)
def _sum(expression, condition: Q):
return Coalesce(
Sum(expression, filter=condition),
Value(_ZERO),
output_field=_MONEY_FIELD,
)
def _validated_period(month: date, currency: str) -> tuple[date, str, date]:
errors: dict[str, str] = {}
if not isinstance(month, date):
errors["month"] = "invalid_date"
elif month.day != 1:
errors["month"] = "must_be_first_day"
if not isinstance(currency, str) or _CURRENCY_RE.fullmatch(currency) is None:
errors["currency"] = "invalid_currency"
if errors:
raise ReportInputError(errors)
if month.month == 12:
month_after = date(month.year + 1, 1, 1)
else:
month_after = date(month.year, month.month + 1, 1)
return month, currency, month_after

107
tests/finance/test_queries.py

@ -0,0 +1,107 @@
from datetime import date
import pytest
from finance.models import Account, Category, Payee, Transaction
from finance.queries import (
QueryInputError,
TransactionFilters,
filtered_transactions,
)
pytestmark = pytest.mark.django_db
def make_account(name="Synthetic Bank", currency="BRL"):
return Account.objects.create(name=name, kind=Account.Kind.BANK, currency=currency)
def make_transaction(account, *, posting_date, amount="-10.00", **values):
return Transaction.objects.create(
account=account,
amount=amount,
posting_date=posting_date,
kind=values.pop("kind", Transaction.Kind.EXPENSE),
**values,
)
def test_filtered_transactions_combines_filters_and_orders_stably():
account = make_account()
category = Category.objects.create(name="Synthetic Food", kind=Category.Kind.EXPENSE)
older = make_transaction(account, posting_date=date(2026, 1, 5))
newer = make_transaction(account, posting_date=date(2026, 1, 20))
make_transaction(account, posting_date=date(2026, 1, 25), category=category)
make_transaction(account, posting_date=date(2026, 2, 1))
make_transaction(make_account("Synthetic USD", "USD"), posting_date=date(2026, 1, 22))
filters = TransactionFilters.from_mapping({
"date_from": "2026-01-01",
"date_to": "2026-01-31",
"currency": "BRL",
"uncategorized": "yes",
})
rows = list(filtered_transactions(filters))
assert [row.pk for row in rows] == [newer.pk, older.pk]
def test_filtered_transactions_supports_reference_kind_and_categorized_filters():
account = make_account()
other_account = make_account("Synthetic Other")
category = Category.objects.create(name="Synthetic Food", kind=Category.Kind.EXPENSE)
payee = Payee.objects.create(name="Synthetic Merchant")
expected = make_transaction(
account,
posting_date=date(2026, 1, 10),
category=category,
payee=payee,
)
make_transaction(account, posting_date=date(2026, 1, 11), category=category)
make_transaction(other_account, posting_date=date(2026, 1, 12), category=category, payee=payee)
filters = TransactionFilters.from_mapping({
"account": str(account.pk),
"category": str(category.pk),
"payee": str(payee.pk),
"kind": "EXPENSE",
"uncategorized": "no",
})
assert list(filtered_transactions(filters)) == [expected]
@pytest.mark.parametrize(
("mapping", "errors"),
[
({"date_from": "31/01/2026"}, {"date_from": "invalid_date"}),
({"date_to": "not-a-date"}, {"date_to": "invalid_date"}),
({"account": "bad"}, {"account": "invalid_uuid"}),
({"category": "bad"}, {"category": "invalid_uuid"}),
({"payee": "bad"}, {"payee": "invalid_uuid"}),
({"kind": "UNKNOWN"}, {"kind": "invalid_choice"}),
({"currency": "brl"}, {"currency": "invalid_currency"}),
({"uncategorized": "maybe"}, {"uncategorized": "invalid_choice"}),
],
)
def test_invalid_filter_is_explicit(mapping, errors):
with pytest.raises(QueryInputError) as caught:
TransactionFilters.from_mapping(mapping)
assert caught.value.errors == errors
def test_filter_errors_are_reported_together_and_date_order_is_checked():
with pytest.raises(QueryInputError) as caught:
TransactionFilters.from_mapping({
"date_from": "2026-02-01",
"date_to": "2026-01-01",
"account": "bad",
})
assert caught.value.errors == {
"account": "invalid_uuid",
"date_to": "before_date_from",
}

121
tests/finance/test_reports.py

@ -0,0 +1,121 @@
from datetime import date
from decimal import Decimal
import pytest
from finance.models import Account, Category, Transaction
from finance.reports import (
ReportInputError,
account_balances,
available_currencies,
monthly_summary,
spending_by_category,
)
pytestmark = pytest.mark.django_db
def make_account(name="Synthetic Bank", *, kind=Account.Kind.BANK, currency="BRL"):
return Account.objects.create(name=name, kind=kind, currency=currency)
def post(account, amount, kind, posting_date, *, category=None):
return Transaction.objects.create(
account=account,
amount=Decimal(amount),
kind=kind,
posting_date=posting_date,
category=category,
)
def test_monthly_summary_counts_card_purchase_once_and_refund_in_posting_month():
bank = make_account()
card = make_account("Synthetic Card", kind=Account.Kind.CREDIT_CARD)
post(bank, "500.00", Transaction.Kind.OPENING, date(2025, 12, 31))
post(bank, "1000.00", Transaction.Kind.INCOME, date(2026, 1, 2))
post(card, "-300.00", Transaction.Kind.EXPENSE, date(2026, 1, 3))
post(bank, "-300.00", Transaction.Kind.TRANSFER, date(2026, 1, 20))
post(card, "300.00", Transaction.Kind.TRANSFER, date(2026, 1, 20))
post(card, "50.00", Transaction.Kind.REFUND, date(2026, 1, 25))
post(card, "20.00", Transaction.Kind.REFUND, date(2026, 2, 1))
result = monthly_summary(date(2026, 1, 1), "BRL")
assert result.inflow == Decimal("1000.00")
assert result.expense == Decimal("300.00")
assert result.refund == Decimal("50.00")
assert result.net_spending == Decimal("250.00")
assert result.net_activity == Decimal("750.00")
def test_spending_has_uncategorized_bucket_and_never_combines_currency():
bank = make_account()
usd = make_account("Synthetic USD", currency="USD")
food = Category.objects.create(name="Synthetic Food", kind=Category.Kind.EXPENSE)
post(bank, "-100.00", Transaction.Kind.EXPENSE, date(2026, 1, 2), category=food)
post(bank, "20.00", Transaction.Kind.REFUND, date(2026, 1, 3), category=food)
post(bank, "-20.00", Transaction.Kind.EXPENSE, date(2026, 1, 4))
post(usd, "-999.00", Transaction.Kind.EXPENSE, date(2026, 1, 5), category=food)
rows = spending_by_category(date(2026, 1, 1), "BRL")
assert [(row.category_name, row.net_spending) for row in rows] == [
("Synthetic Food", Decimal("80.00")),
("Uncategorized", Decimal("20.00")),
]
assert rows[0].expense == Decimal("100.00")
assert rows[0].refund == Decimal("20.00")
def test_empty_month_returns_explicit_zero_totals_and_no_spending_rows():
result = monthly_summary(date(2026, 1, 1), "BRL")
assert result.inflow == Decimal("0.00")
assert result.expense == Decimal("0.00")
assert result.refund == Decimal("0.00")
assert result.net_spending == Decimal("0.00")
assert result.net_activity == Decimal("0.00")
assert spending_by_category(date(2026, 1, 1), "BRL") == ()
def test_account_balances_include_openings_and_transfers_through_month_end():
beta = make_account("Synthetic Beta")
alpha = make_account("Synthetic Alpha")
usd = make_account("Synthetic USD", currency="USD")
post(alpha, "100.00", Transaction.Kind.OPENING, date(2025, 12, 1))
post(alpha, "-20.00", Transaction.Kind.TRANSFER, date(2026, 1, 20))
post(beta, "20.00", Transaction.Kind.TRANSFER, date(2026, 1, 20))
post(beta, "-5.00", Transaction.Kind.EXPENSE, date(2026, 2, 1))
post(usd, "1000.00", Transaction.Kind.OPENING, date(2025, 12, 1))
rows = account_balances(date(2026, 1, 1), "BRL")
assert [(row.account_name, row.balance) for row in rows] == [
("Synthetic Alpha", Decimal("80.00")),
("Synthetic Beta", Decimal("20.00")),
]
def test_available_currencies_are_distinct_and_sorted():
make_account("Synthetic BRL", currency="BRL")
make_account("Synthetic USD", currency="USD")
make_account("Synthetic BRL 2", currency="BRL")
assert available_currencies() == ("BRL", "USD")
@pytest.mark.parametrize(
("month", "currency", "errors"),
[
(date(2026, 1, 2), "BRL", {"month": "must_be_first_day"}),
(date(2026, 1, 1), "brl", {"currency": "invalid_currency"}),
("2026-01", "BRL", {"month": "invalid_date"}),
],
)
def test_report_inputs_are_validated(month, currency, errors):
with pytest.raises(ReportInputError) as caught:
monthly_summary(month, currency)
assert caught.value.errors == errors
Loading…
Cancel
Save