5 changed files with 535 additions and 0 deletions
@ -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", |
||||||
|
), |
||||||
|
] |
||||||
@ -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 |
||||||
@ -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 |
||||||
@ -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", |
||||||
|
} |
||||||
@ -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…
Reference in new issue