Browse Source

fix: complete local transaction workflows

master
Codex 2 months ago
parent
commit
31f6ea4bd5
  1. 1
      config/settings.py
  2. 9
      config/urls.py
  3. 6
      core/forms.py
  4. 5
      core/templates/core/transactions/detail.html
  5. 8
      core/templates/core/transactions/list.html
  6. 30
      core/views.py
  7. 26
      finance/queries.py
  8. 19
      tests/finance/test_queries.py
  9. 148
      tests/test_web_workflows.py

1
config/settings.py

@ -36,6 +36,7 @@ TEMPLATES = [{
}]
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
STATIC_URL = "static/"
LOCAL_SERVE_STATIC = os.environ.get("LOCAL_SERVE_STATIC", "true").casefold() in {"1", "true", "yes"}
DATABASES = {"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": required("DB_NAME"),

9
config/urls.py

@ -1,4 +1,6 @@
from django.urls import path
from django.conf import settings
from django.contrib.staticfiles.views import serve as serve_static
from django.urls import path, re_path
from core import views
urlpatterns = [
@ -18,3 +20,8 @@ urlpatterns = [
path("payees/new/", views.payee_create, name="payee-create"),
path("payees/<uuid:reference_id>/archive/", views.payee_archive, name="payee-archive"),
]
if settings.LOCAL_SERVE_STATIC:
urlpatterns.append(
re_path(r"^static/(?P<path>.*)$", serve_static, {"insecure": True}, name="local-static")
)

6
core/forms.py

@ -3,13 +3,15 @@ from uuid import UUID
from django import forms
from finance.models import Account, Category, Payee, Transaction
from finance.queries import active_accounts, active_categories, active_payees
def _reference_choices(model, *, include=None):
rows = model.objects.filter(archived=False)
rows = {Account: active_accounts, Category: active_categories, Payee: active_payees}[model]()
if include is not None:
rows = model.objects.filter(pk=include.pk) | rows
return [(str(row.pk), row.name) for row in rows.order_by("name")]
rows = rows.order_by("name", "id")
return [(str(row.pk), row.name) for row in rows]
def _uuid(value):

5
core/templates/core/transactions/detail.html

@ -11,7 +11,12 @@
<dt>Kind</dt><dd>{{ transaction.get_kind_display }}</dd>
<dt>Payee</dt><dd>{{ transaction.payee.name|default:"—" }}</dd>
<dt>Category</dt><dd>{{ transaction.category.name|default:"Uncategorized" }}</dd>
{% if transaction.statement %}<dt>Statement</dt><dd>{{ transaction.statement.period_start|date:"Y-m-d" }} to {{ transaction.statement.period_end|date:"Y-m-d" }}; due {{ transaction.statement.due_date|date:"Y-m-d" }}</dd>{% endif %}
{% if transaction.installment_plan %}<dt>Installment plan</dt><dd>{{ transaction.installment_plan.description|default:"(No description)" }} · {{ transaction.installment_number }} of {{ transaction.installment_plan.total_installments }}</dd>{% endif %}
{% if transaction.refund_of %}<dt>Refund of</dt><dd><a href="{% url 'transaction-detail' transaction.refund_of.pk %}">{{ transaction.refund_of.description|default:"(No description)" }}</a></dd>{% endif %}
{% if transaction.outgoing_transfer %}<dt>Transfer counterpart</dt><dd>Incoming to <a href="{% url 'transaction-detail' transaction.outgoing_transfer.incoming.pk %}">{{ transaction.outgoing_transfer.incoming.account.name }}</a></dd>{% elif transaction.incoming_transfer %}<dt>Transfer counterpart</dt><dd>Outgoing from <a href="{% url 'transaction-detail' transaction.incoming_transfer.outgoing.pk %}">{{ transaction.incoming_transfer.outgoing.account.name }}</a></dd>{% endif %}
</dl>
{% if transaction.refunds.all %}<h2>Refunds</h2><ul>{% for refund in transaction.refunds.all %}<li><a href="{% url 'transaction-detail' refund.pk %}">{{ refund.description|default:"(No description)" }}</a> · {{ refund.amount }} {{ refund.account.currency }}</li>{% endfor %}</ul>{% endif %}
<h2>Source provenance</h2>
{% if transaction.source_records.all %}<div class="table-wrap"><table><thead><tr><th>Format</th><th>Parser</th><th>Location</th></tr></thead><tbody>{% for source in transaction.source_records.all %}<tr><td>{{ source.source_format }}</td><td>{{ source.parser_version }}</td><td>{{ source.item_locator }}</td></tr>{% endfor %}</tbody></table></div>{% else %}<p>This transaction was entered manually.</p>{% endif %}
{% endblock %}

8
core/templates/core/transactions/list.html

@ -7,10 +7,10 @@
<div><label for="date_to">To</label><input id="date_to" name="date_to" type="date" value="{{ request.GET.date_to }}">{% if filter_errors.date_to %}<p class="error">{{ filter_errors.date_to }}</p>{% endif %}</div>
<div><label for="account">Account</label><select id="account" name="account"><option value="">All</option>{% for item in accounts %}<option value="{{ item.pk }}"{% if request.GET.account == item.pk|stringformat:'s' %} selected{% endif %}>{{ item.name }}{% if item.archived %} (Archived){% endif %}</option>{% endfor %}</select>{% if filter_errors.account %}<p class="error">{{ filter_errors.account }}</p>{% endif %}</div>
<div><label for="kind">Kind</label><select id="kind" name="kind"><option value="">All</option>{% for value, label in kinds %}<option value="{{ value }}"{% if request.GET.kind == value %} selected{% endif %}>{{ label }}</option>{% endfor %}</select></div>
<div><label for="payee">Payee</label><select id="payee" name="payee"><option value="">All</option>{% for item in payees %}<option value="{{ item.pk }}">{{ item.name }}</option>{% endfor %}</select></div>
<div><label for="category">Category</label><select id="category" name="category"><option value="">All</option>{% for item in categories %}<option value="{{ item.pk }}">{{ item.name }}</option>{% endfor %}</select></div>
<div><label for="payee">Payee</label><select id="payee" name="payee"><option value="">All</option>{% for item in payees %}<option value="{{ item.pk }}"{% if request.GET.payee == item.pk|stringformat:'s' %} selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></div>
<div><label for="category">Category</label><select id="category" name="category"><option value="">All</option>{% for item in categories %}<option value="{{ item.pk }}"{% if request.GET.category == item.pk|stringformat:'s' %} selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></div>
<div><label for="currency">Currency</label><input id="currency" name="currency" maxlength="3" value="{{ request.GET.currency }}">{% if filter_errors.currency %}<p class="error">{{ filter_errors.currency }}</p>{% endif %}</div>
<div><label for="uncategorized">Category status</label><select id="uncategorized" name="uncategorized"><option value="">All</option><option value="yes">Uncategorized</option><option value="no">Categorized</option></select></div>
<div><label for="uncategorized">Category status</label><select id="uncategorized" name="uncategorized"><option value="">All</option><option value="yes"{% if request.GET.uncategorized == 'yes' %} selected{% endif %}>Uncategorized</option><option value="no"{% if request.GET.uncategorized == 'no' %} selected{% endif %}>Categorized</option></select></div>
<button type="submit">Filter</button>
</form>
{% if filter_errors %}<p class="error" role="alert">Review the invalid filters above.</p>{% endif %}
@ -19,5 +19,5 @@
{% for row in page %}<tr><td>{{ row.posting_date|date:"Y-m-d" }}</td><td><a href="{% url 'transaction-detail' row.pk %}">{{ row.description|default:"(No description)" }}</a></td><td>{{ row.account.name }}</td><td>{{ row.get_kind_display }}</td><td>{{ row.category.name|default:"Uncategorized" }}</td><td class="number">{{ row.amount }} {{ row.account.currency }}</td></tr>{% endfor %}
</tbody></table></div>
{% else %}<p>No transactions match these filters.</p>{% endif %}
{% if page.has_other_pages %}<nav aria-label="Pagination">{% if page.has_previous %}<a href="?page={{ page.previous_page_number }}">Previous</a>{% endif %}<span>Page {{ page.number }} of {{ page.paginator.num_pages }}</span>{% if page.has_next %}<a href="?page={{ page.next_page_number }}">Next</a>{% endif %}</nav>{% endif %}
{% if page.has_other_pages %}<nav aria-label="Pagination">{% if page.has_previous %}<a href="?{% if pagination_query %}{{ pagination_query }}&amp;{% endif %}page={{ page.previous_page_number }}">Previous</a>{% endif %}<span>Page {{ page.number }} of {{ page.paginator.num_pages }}</span>{% if page.has_next %}<a href="?{% if pagination_query %}{{ pagination_query }}&amp;{% endif %}page={{ page.next_page_number }}">Next</a>{% endif %}</nav>{% endif %}
{% endblock %}

30
core/views.py

@ -6,7 +6,10 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_POST
from finance.models import Account, Category, Payee, Transaction
from finance.queries import QueryInputError, TransactionFilters, filtered_transactions
from finance.queries import (
QueryInputError, TransactionFilters, filtered_transactions,
listed_accounts, listed_categories, listed_payees,
)
from finance.services import (
Actor, DomainError, annotate_transaction, archive, create_account,
create_category, create_payee, record_transaction,
@ -53,22 +56,27 @@ def transaction_list(request):
rows = Transaction.objects.none()
filter_errors = {field: _query_message(code) for field, code in exc.errors.items()}
page = Paginator(rows, 50).get_page(request.GET.get("page"))
pagination_parameters = request.GET.copy()
pagination_parameters.pop("page", None)
return render(request, "core/transactions/list.html", {
"page": page,
"filters": filters,
"filter_errors": filter_errors,
"accounts": Account.objects.order_by("name"),
"categories": Category.objects.order_by("name"),
"payees": Payee.objects.order_by("name"),
"accounts": listed_accounts(),
"categories": listed_categories(),
"payees": listed_payees(),
"kinds": Transaction.Kind.choices,
"pagination_query": pagination_parameters.urlencode(),
})
def transaction_detail(request, transaction_id):
row = get_object_or_404(
Transaction.objects.select_related(
"account", "payee", "category", "statement", "refund_of", "installment_plan"
).prefetch_related("source_records"),
"account", "payee", "category", "statement", "refund_of",
"installment_plan", "outgoing_transfer__incoming__account",
"incoming_transfer__outgoing__account",
).prefetch_related("source_records", "refunds"),
pk=transaction_id,
)
return render(request, "core/transactions/detail.html", {"transaction": row})
@ -113,15 +121,15 @@ def transaction_annotate(request, transaction_id):
def account_list(request):
return _reference_list(request, Account, "Accounts", "account-create", "account-archive")
return _reference_list(request, listed_accounts(), "Accounts", "account-create", "account-archive")
def category_list(request):
return _reference_list(request, Category, "Categories", "category-create", "category-archive")
return _reference_list(request, listed_categories(), "Categories", "category-create", "category-archive")
def payee_list(request):
return _reference_list(request, Payee, "Payees", "payee-create", "payee-archive")
return _reference_list(request, listed_payees(), "Payees", "payee-create", "payee-archive")
def account_create(request):
@ -151,10 +159,10 @@ def payee_archive(request, reference_id):
return _reference_archive(request, Payee, reference_id, "payee-list")
def _reference_list(request, model, title, create_url, archive_url):
def _reference_list(request, rows, title, create_url, archive_url):
return render(request, "core/references/list.html", {
"title": title,
"rows": model.objects.order_by("archived", "name"),
"rows": rows,
"create_url": create_url,
"archive_url": archive_url,
})

26
finance/queries.py

@ -6,7 +6,7 @@ from uuid import UUID
from django.db.models import QuerySet
from finance.models import Transaction
from finance.models import Account, Category, Payee, Transaction
_CURRENCY_RE = re.compile(r"[A-Z]{3}", re.ASCII)
@ -95,6 +95,30 @@ def filtered_transactions(filters: TransactionFilters) -> QuerySet[Transaction]:
return rows.order_by("-posting_date", "-id")
def listed_accounts() -> QuerySet[Account]:
return Account.objects.order_by("archived", "name", "id")
def listed_categories() -> QuerySet[Category]:
return Category.objects.order_by("archived", "name", "id")
def listed_payees() -> QuerySet[Payee]:
return Payee.objects.order_by("archived", "name", "id")
def active_accounts() -> QuerySet[Account]:
return Account.objects.filter(archived=False).order_by("name", "id")
def active_categories() -> QuerySet[Category]:
return Category.objects.filter(archived=False).order_by("name", "id")
def active_payees() -> QuerySet[Payee]:
return Payee.objects.filter(archived=False).order_by("name", "id")
def _optional_string(value: object) -> str | None:
if value is None:
return None

19
tests/finance/test_queries.py

@ -1,6 +1,7 @@
from datetime import date
import pytest
import finance.queries as queries
from finance.models import Account, Category, Payee, Transaction
from finance.queries import (
@ -105,3 +106,21 @@ def test_filter_errors_are_reported_together_and_date_order_is_checked():
"account": "invalid_uuid",
"date_to": "before_date_from",
}
def test_reference_lists_include_history_after_active_rows_with_stable_names():
active_account = make_account("Synthetic Active")
archived_account = make_account("Synthetic Archived")
archived_account.archived = True
archived_account.save(update_fields=["archived"])
active_category = Category.objects.create(name="Synthetic Active", kind=Category.Kind.EXPENSE)
archived_category = Category.objects.create(name="Synthetic Archived", kind=Category.Kind.EXPENSE, archived=True)
active_payee = Payee.objects.create(name="Synthetic Active")
archived_payee = Payee.objects.create(name="Synthetic Archived", archived=True)
assert list(queries.listed_accounts()) == [active_account, archived_account]
assert list(queries.listed_categories()) == [active_category, archived_category]
assert list(queries.listed_payees()) == [active_payee, archived_payee]
assert list(queries.active_accounts()) == [active_account]
assert list(queries.active_categories()) == [active_category]
assert list(queries.active_payees()) == [active_payee]

148
tests/test_web_workflows.py

@ -1,12 +1,26 @@
from datetime import date
from decimal import Decimal
from html import unescape
import re
from urllib.parse import parse_qs, urlsplit
import pytest
from django.conf import settings
from django.test import Client
from django.urls import reverse
from finance.models import Account, AuditEvent, Category, Payee, SourceRecord, Transaction
from finance.services import Actor, create_account, create_category, create_payee, record_transaction
from finance.services import (
Actor,
DomainError,
create_account,
create_category,
create_payee,
create_plan,
create_statement,
create_transfer,
record_transaction,
)
pytestmark = pytest.mark.django_db(transaction=True)
@ -51,6 +65,16 @@ def test_root_and_transaction_pages_are_discoverable_and_filter_rows(client):
assert "Accounts" in content and "Categories" in content and "Payees" in content
def test_loopback_runtime_serves_the_application_stylesheet(client):
assert settings.DEBUG is False
assert settings.LOCAL_SERVE_STATIC is True
response = client.get("/static/core/app.css")
assert response.status_code == 200
assert b"font-family: system-ui" in b"".join(response.streaming_content)
def test_invalid_transaction_filters_are_shown_without_querying(client):
response = client.get(reverse("transaction-list"), {"date_from": "not-a-date"})
@ -58,6 +82,45 @@ def test_invalid_transaction_filters_are_shown_without_querying(client):
assert "Enter a valid date." in response.content.decode()
def test_transaction_pagination_preserves_filters_and_selected_controls(client):
bank = account()
category = create_category(name="Synthetic Food", kind="EXPENSE", actor=SETUP_ACTOR)
payee = create_payee(name="Synthetic Merchant", actor=SETUP_ACTOR)
Transaction.objects.bulk_create([
Transaction(
account=bank,
amount=Decimal("-1.00"),
posting_date=date(2026, 1, 15),
kind="EXPENSE",
description=f"Synthetic row {number}",
category=category,
payee=payee,
)
for number in range(51)
])
response = client.get(reverse("transaction-list"), {
"currency": "BRL",
"payee": str(payee.pk),
"category": str(category.pk),
"uncategorized": "no",
"page": "1",
})
content = response.content.decode()
next_href = unescape(re.search(r'href="([^"]+)">Next', content).group(1))
assert parse_qs(urlsplit(next_href).query) == {
"currency": ["BRL"],
"payee": [str(payee.pk)],
"category": [str(category.pk)],
"uncategorized": ["no"],
"page": ["2"],
}
assert f'<option value="{payee.pk}" selected>' in content
assert f'<option value="{category.pk}" selected>' in content
assert '<option value="no" selected>Categorized</option>' in content
def test_transaction_detail_shows_relationships_and_provenance(client):
bank = account()
row = transaction(bank)
@ -79,6 +142,65 @@ def test_transaction_detail_shows_relationships_and_provenance(client):
assert "row:2" in content
def test_transaction_details_show_all_financial_relationships(client):
bank = account()
other = account("Synthetic Other")
card = create_account(name="Synthetic Card", kind="CREDIT_CARD", currency="BRL", actor=SETUP_ACTOR)
statement = create_statement(
account_id=card.pk,
period_start=date(2026, 1, 1),
period_end=date(2026, 1, 31),
due_date=date(2026, 2, 10),
actor=SETUP_ACTOR,
)
plan = create_plan(
account_id=card.pk,
description="Synthetic appliance plan",
total_installments=3,
actor=SETUP_ACTOR,
)
installment = record_transaction(
account_id=card.pk,
amount="-30.00",
posting_date=date(2026, 1, 15),
kind="EXPENSE",
description="Synthetic installment",
statement_id=statement.pk,
installment_plan_id=plan.pk,
installment_number=2,
actor=SETUP_ACTOR,
)
refund = record_transaction(
account_id=bank.pk,
amount="10.00",
posting_date=date(2026, 1, 20),
kind="REFUND",
description="Synthetic refund",
refund_of_id=installment.pk,
actor=SETUP_ACTOR,
)
transfer = create_transfer(
outgoing_account_id=bank.pk,
incoming_account_id=other.pk,
amount="15.00",
outgoing_date=date(2026, 1, 21),
incoming_date=date(2026, 1, 22),
actor=SETUP_ACTOR,
)
installment_page = client.get(reverse("transaction-detail", args=[installment.pk])).content.decode()
refund_page = client.get(reverse("transaction-detail", args=[refund.pk])).content.decode()
outgoing_page = client.get(reverse("transaction-detail", args=[transfer.outgoing_id])).content.decode()
incoming_page = client.get(reverse("transaction-detail", args=[transfer.incoming_id])).content.decode()
assert "Statement" in installment_page and "2026-01-31" in installment_page
assert "Synthetic appliance plan" in installment_page and "2 of 3" in installment_page
assert "Refunds" in installment_page and "Synthetic refund" in installment_page
assert "Refund of" in refund_page and "Synthetic installment" in refund_page
assert "Transfer counterpart" in outgoing_page and "Synthetic Other" in outgoing_page
assert "Transfer counterpart" in incoming_page and "Synthetic Bank" in incoming_page
@pytest.mark.parametrize(
"route,args",
[
@ -213,3 +335,27 @@ def test_domain_errors_render_as_safe_fixed_messages(client):
assert "Expense amounts must be negative." in content
assert "Synthetic invalid sign" in content
assert Transaction.objects.filter(description="Synthetic invalid sign").count() == 0
def test_unknown_domain_error_uses_safe_fallback_without_exposing_code(client, monkeypatch):
bank = account()
def rejected_write(**_values):
raise DomainError("synthetic_private_error_code")
monkeypatch.setattr("core.views.record_transaction", rejected_write)
response = client.post(reverse("transaction-create"), {
"account": bank.pk,
"amount": "-24.50",
"posting_date": "2026-01-15",
"occurrence_date": "",
"kind": "EXPENSE",
"description": "Synthetic rejected transaction",
"payee": "",
"category": "",
})
content = response.content.decode()
assert response.status_code == 200
assert "Unable to save this change." in content
assert "synthetic_private_error_code" not in content

Loading…
Cancel
Save