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" SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
STATIC_URL = "static/" STATIC_URL = "static/"
LOCAL_SERVE_STATIC = os.environ.get("LOCAL_SERVE_STATIC", "true").casefold() in {"1", "true", "yes"}
DATABASES = {"default": { DATABASES = {"default": {
"ENGINE": "django.db.backends.postgresql", "ENGINE": "django.db.backends.postgresql",
"NAME": required("DB_NAME"), "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 from core import views
urlpatterns = [ urlpatterns = [
@ -18,3 +20,8 @@ urlpatterns = [
path("payees/new/", views.payee_create, name="payee-create"), path("payees/new/", views.payee_create, name="payee-create"),
path("payees/<uuid:reference_id>/archive/", views.payee_archive, name="payee-archive"), 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 django import forms
from finance.models import Account, Category, Payee, Transaction from finance.models import Account, Category, Payee, Transaction
from finance.queries import active_accounts, active_categories, active_payees
def _reference_choices(model, *, include=None): 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: if include is not None:
rows = model.objects.filter(pk=include.pk) | rows 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): def _uuid(value):

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

@ -11,7 +11,12 @@
<dt>Kind</dt><dd>{{ transaction.get_kind_display }}</dd> <dt>Kind</dt><dd>{{ transaction.get_kind_display }}</dd>
<dt>Payee</dt><dd>{{ transaction.payee.name|default:"—" }}</dd> <dt>Payee</dt><dd>{{ transaction.payee.name|default:"—" }}</dd>
<dt>Category</dt><dd>{{ transaction.category.name|default:"Uncategorized" }}</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> </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> <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 %} {% 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 %} {% 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="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="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="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="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 }}">{{ 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="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> <button type="submit">Filter</button>
</form> </form>
{% if filter_errors %}<p class="error" role="alert">Review the invalid filters above.</p>{% endif %} {% 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 %} {% 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> </tbody></table></div>
{% else %}<p>No transactions match these filters.</p>{% endif %} {% 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 %} {% 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 django.views.decorators.http import require_POST
from finance.models import Account, Category, Payee, Transaction 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 ( from finance.services import (
Actor, DomainError, annotate_transaction, archive, create_account, Actor, DomainError, annotate_transaction, archive, create_account,
create_category, create_payee, record_transaction, create_category, create_payee, record_transaction,
@ -53,22 +56,27 @@ def transaction_list(request):
rows = Transaction.objects.none() rows = Transaction.objects.none()
filter_errors = {field: _query_message(code) for field, code in exc.errors.items()} filter_errors = {field: _query_message(code) for field, code in exc.errors.items()}
page = Paginator(rows, 50).get_page(request.GET.get("page")) 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", { return render(request, "core/transactions/list.html", {
"page": page, "page": page,
"filters": filters, "filters": filters,
"filter_errors": filter_errors, "filter_errors": filter_errors,
"accounts": Account.objects.order_by("name"), "accounts": listed_accounts(),
"categories": Category.objects.order_by("name"), "categories": listed_categories(),
"payees": Payee.objects.order_by("name"), "payees": listed_payees(),
"kinds": Transaction.Kind.choices, "kinds": Transaction.Kind.choices,
"pagination_query": pagination_parameters.urlencode(),
}) })
def transaction_detail(request, transaction_id): def transaction_detail(request, transaction_id):
row = get_object_or_404( row = get_object_or_404(
Transaction.objects.select_related( Transaction.objects.select_related(
"account", "payee", "category", "statement", "refund_of", "installment_plan" "account", "payee", "category", "statement", "refund_of",
).prefetch_related("source_records"), "installment_plan", "outgoing_transfer__incoming__account",
"incoming_transfer__outgoing__account",
).prefetch_related("source_records", "refunds"),
pk=transaction_id, pk=transaction_id,
) )
return render(request, "core/transactions/detail.html", {"transaction": row}) return render(request, "core/transactions/detail.html", {"transaction": row})
@ -113,15 +121,15 @@ def transaction_annotate(request, transaction_id):
def account_list(request): 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): 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): 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): def account_create(request):
@ -151,10 +159,10 @@ def payee_archive(request, reference_id):
return _reference_archive(request, Payee, reference_id, "payee-list") 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", { return render(request, "core/references/list.html", {
"title": title, "title": title,
"rows": model.objects.order_by("archived", "name"), "rows": rows,
"create_url": create_url, "create_url": create_url,
"archive_url": archive_url, "archive_url": archive_url,
}) })

26
finance/queries.py

@ -6,7 +6,7 @@ from uuid import UUID
from django.db.models import QuerySet 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) _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") 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: def _optional_string(value: object) -> str | None:
if value is None: if value is None:
return None return None

19
tests/finance/test_queries.py

@ -1,6 +1,7 @@
from datetime import date from datetime import date
import pytest import pytest
import finance.queries as queries
from finance.models import Account, Category, Payee, Transaction from finance.models import Account, Category, Payee, Transaction
from finance.queries import ( from finance.queries import (
@ -105,3 +106,21 @@ def test_filter_errors_are_reported_together_and_date_order_is_checked():
"account": "invalid_uuid", "account": "invalid_uuid",
"date_to": "before_date_from", "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 datetime import date
from decimal import Decimal from decimal import Decimal
from html import unescape
import re
from urllib.parse import parse_qs, urlsplit
import pytest import pytest
from django.conf import settings
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
from finance.models import Account, AuditEvent, Category, Payee, SourceRecord, Transaction 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) 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 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): def test_invalid_transaction_filters_are_shown_without_querying(client):
response = client.get(reverse("transaction-list"), {"date_from": "not-a-date"}) 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() 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): def test_transaction_detail_shows_relationships_and_provenance(client):
bank = account() bank = account()
row = transaction(bank) row = transaction(bank)
@ -79,6 +142,65 @@ def test_transaction_detail_shows_relationships_and_provenance(client):
assert "row:2" in content 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( @pytest.mark.parametrize(
"route,args", "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 "Expense amounts must be negative." in content
assert "Synthetic invalid sign" in content assert "Synthetic invalid sign" in content
assert Transaction.objects.filter(description="Synthetic invalid sign").count() == 0 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