Browse Source

feat: add local transaction workflows

master
Codex 2 months ago
parent
commit
c9331c0315
  1. 52
      config/settings.py
  2. 20
      config/urls.py
  3. 8
      core/context.py
  4. 84
      core/forms.py
  5. 26
      core/static/core/app.css
  6. 22
      core/templates/core/base.html
  7. 7
      core/templates/core/references/form.html
  8. 8
      core/templates/core/references/list.html
  9. 17
      core/templates/core/transactions/detail.html
  10. 10
      core/templates/core/transactions/form.html
  11. 23
      core/templates/core/transactions/list.html
  12. 198
      core/views.py
  13. 215
      tests/test_web_workflows.py

52
config/settings.py

@ -0,0 +1,52 @@
import os
from django.core.exceptions import ImproperlyConfigured
def required(name):
value = os.environ.get(name)
if not value or value.startswith("replace-"):
raise ImproperlyConfigured(f"Set {name} in your local environment.")
return value
SECRET_KEY = required("DJANGO_SECRET_KEY")
DEBUG = False
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
ROOT_URLCONF = "config.urls"
INSTALLED_APPS = [
"django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.staticfiles", "core", "finance", "ingestion",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
]
TEMPLATES = [{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {"context_processors": [
"django.template.context_processors.request",
"django.contrib.messages.context_processors.messages",
"core.context.navigation",
]},
}]
SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
STATIC_URL = "static/"
DATABASES = {"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": required("DB_NAME"),
"USER": required("DB_USER"),
"PASSWORD": required("DB_PASSWORD"),
"HOST": required("DB_HOST"),
"PORT": "5432",
"OPTIONS": {"connect_timeout": 3},
}}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
USE_TZ = True
TIME_ZONE = "UTC"
IMPORT_ROOT = "/app/imports"

20
config/urls.py

@ -0,0 +1,20 @@
from django.urls import path
from core import views
urlpatterns = [
path("", views.home, name="home"),
path("health/", views.health, name="health"),
path("transactions/", views.transaction_list, name="transaction-list"),
path("transactions/new/", views.transaction_create, name="transaction-create"),
path("transactions/<uuid:transaction_id>/", views.transaction_detail, name="transaction-detail"),
path("transactions/<uuid:transaction_id>/annotate/", views.transaction_annotate, name="transaction-annotate"),
path("accounts/", views.account_list, name="account-list"),
path("accounts/new/", views.account_create, name="account-create"),
path("accounts/<uuid:reference_id>/archive/", views.account_archive, name="account-archive"),
path("categories/", views.category_list, name="category-list"),
path("categories/new/", views.category_create, name="category-create"),
path("categories/<uuid:reference_id>/archive/", views.category_archive, name="category-archive"),
path("payees/", views.payee_list, name="payee-list"),
path("payees/new/", views.payee_create, name="payee-create"),
path("payees/<uuid:reference_id>/archive/", views.payee_archive, name="payee-archive"),
]

8
core/context.py

@ -0,0 +1,8 @@
def navigation(_request):
return {"navigation": (
("home", "Home"),
("account-list", "Accounts"),
("transaction-list", "Transactions"),
("category-list", "Categories"),
("payee-list", "Payees"),
)}

84
core/forms.py

@ -0,0 +1,84 @@
from uuid import UUID
from django import forms
from finance.models import Account, Category, Payee, Transaction
def _reference_choices(model, *, include=None):
rows = model.objects.filter(archived=False)
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")]
def _uuid(value):
return None if value == "" else UUID(value)
class ManualTransactionForm(forms.Form):
account = forms.TypedChoiceField(coerce=UUID)
amount = forms.DecimalField(max_digits=18, decimal_places=2)
posting_date = forms.DateField(widget=forms.DateInput(attrs={"type": "date"}))
occurrence_date = forms.DateField(required=False, widget=forms.DateInput(attrs={"type": "date"}))
kind = forms.ChoiceField(choices=Transaction.Kind.choices)
description = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 3}))
payee = forms.TypedChoiceField(required=False, empty_value=None, coerce=_uuid)
category = forms.TypedChoiceField(required=False, empty_value=None, coerce=_uuid)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["account"].choices = _reference_choices(Account)
self.fields["payee"].choices = [("", "None"), *_reference_choices(Payee)]
self.fields["category"].choices = [("", "Uncategorized"), *_reference_choices(Category)]
def clean(self):
values = super().clean()
amount = values.get("amount")
kind = values.get("kind")
if amount is not None and kind == Transaction.Kind.EXPENSE and amount >= 0:
self.add_error("amount", "Expense amounts must be negative.")
if amount is not None and kind in {Transaction.Kind.INCOME, Transaction.Kind.REFUND} and amount <= 0:
self.add_error("amount", "Income and refund amounts must be positive.")
return values
class AnnotationForm(forms.Form):
description = forms.CharField(required=False, widget=forms.Textarea(attrs={"rows": 3}))
payee = forms.TypedChoiceField(required=False, empty_value=None, coerce=_uuid)
category = forms.TypedChoiceField(required=False, empty_value=None, coerce=_uuid)
def __init__(self, *args, transaction, **kwargs):
super().__init__(*args, **kwargs)
self.transaction = transaction
self.fields["payee"].choices = [("", "None"), *_reference_choices(Payee, include=transaction.payee)]
self.fields["category"].choices = [("", "Uncategorized"), *_reference_choices(Category, include=transaction.category)]
if not self.is_bound:
self.initial = {
"description": transaction.description,
"payee": transaction.payee_id,
"category": transaction.category_id,
}
def changes(self):
values = self.cleaned_data
changes = {}
for form_name, model_name in (("description", "description"), ("payee", "payee_id"), ("category", "category_id")):
if values[form_name] != getattr(self.transaction, model_name):
changes[model_name] = values[form_name]
return changes
class AccountForm(forms.Form):
name = forms.CharField(max_length=255)
kind = forms.ChoiceField(choices=Account.Kind.choices)
currency = forms.RegexField(regex=r"^[A-Z]{3}$", max_length=3)
class CategoryForm(forms.Form):
name = forms.CharField(max_length=255)
kind = forms.ChoiceField(choices=Category.Kind.choices)
class PayeeForm(forms.Form):
name = forms.CharField(max_length=255)

26
core/static/core/app.css

@ -0,0 +1,26 @@
:root { color-scheme: light; font-family: system-ui, sans-serif; color: #17221b; background: #f4f7f4; }
* { box-sizing: border-box; }
body { margin: 0; }
header { display: flex; align-items: center; gap: 2rem; padding: 1rem max(1rem, calc((100% - 72rem) / 2)); background: #173d2a; color: white; }
header a { color: white; }
.brand { font-size: 1.25rem; font-weight: 700; text-decoration: none; }
nav { display: flex; flex-wrap: wrap; gap: 1rem; }
main { max-width: 72rem; margin: auto; padding: 2rem 1rem; }
.heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: 1rem; align-items: end; padding: 1rem; background: white; border-radius: .5rem; }
label { display: block; font-weight: 650; margin-bottom: .3rem; }
input, select, textarea, button, .button { width: 100%; padding: .65rem; border: 1px solid #7b8d82; border-radius: .3rem; font: inherit; }
button, .button { width: auto; background: #236642; color: white; border-color: #236642; cursor: pointer; text-decoration: none; }
.secondary { background: white; color: #294b38; }
:focus-visible { outline: 3px solid #de9f22; outline-offset: 2px; }
.field { margin-bottom: 1rem; max-width: 36rem; }
.table-wrap { overflow-x: auto; margin-top: 1rem; }
table { width: 100%; border-collapse: collapse; background: white; }
th, td { padding: .75rem; border-bottom: 1px solid #d7dfd9; text-align: left; }
.number { text-align: right; font-variant-numeric: tabular-nums; }
.details { display: grid; grid-template-columns: max-content 1fr; gap: .5rem 1.5rem; }
.details dt { font-weight: 700; }
.details dd { margin: 0; }
.error, .errorlist, .error-summary { color: #8d1b16; }
.error-summary, .message { padding: .75rem; background: white; border-left: .3rem solid currentColor; }
@media (max-width: 40rem) { header, .heading { align-items: flex-start; flex-direction: column; } .details { grid-template-columns: 1fr; } }

22
core/templates/core/base.html

@ -0,0 +1,22 @@
{% load static %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Gizmoduck{% endblock %}</title>
<link rel="stylesheet" href="{% static 'core/app.css' %}">
</head>
<body>
<header>
<a class="brand" href="{% url 'home' %}">Gizmoduck</a>
<nav aria-label="Main navigation">
{% for route, label in navigation %}<a href="{% url route %}">{{ label }}</a>{% endfor %}
</nav>
</header>
<main>
{% if messages %}<div aria-live="polite">{% for message in messages %}<p class="message {{ message.tags }}">{{ message }}</p>{% endfor %}</div>{% endif %}
{% block content %}{% endblock %}
</main>
</body>
</html>

7
core/templates/core/references/form.html

@ -0,0 +1,7 @@
{% extends "core/base.html" %}
{% block title %}{{ title }} · Gizmoduck{% endblock %}
{% block content %}
<h1>{{ title }}</h1>
{% if form.errors %}<div class="error-summary" role="alert"><strong>Review the highlighted fields.</strong>{{ form.non_field_errors }}</div>{% endif %}
<form method="post">{% csrf_token %}{% for field in form %}<div class="field">{{ field.label_tag }}{{ field }}{{ field.errors }}</div>{% endfor %}<button type="submit">Save</button></form>
{% endblock %}

8
core/templates/core/references/list.html

@ -0,0 +1,8 @@
{% extends "core/base.html" %}
{% block title %}{{ title }} · Gizmoduck{% endblock %}
{% block content %}
<div class="heading"><h1>{{ title }}</h1><a class="button" href="{% url create_url %}">Create</a></div>
{% if rows %}<div class="table-wrap"><table><thead><tr><th>Name</th><th>Type</th><th>Status</th><th>Action</th></tr></thead><tbody>
{% for row in rows %}<tr><td>{{ row.name }}</td><td>{{ row.get_kind_display|default:"—" }}{% if row.currency %} · {{ row.currency }}{% endif %}</td><td>{% if row.archived %}Archived{% else %}Active{% endif %}</td><td>{% if not row.archived %}<form method="post" action="{% url archive_url row.pk %}">{% csrf_token %}<button type="submit" class="secondary">Archive</button></form>{% else %}Historical{% endif %}</td></tr>{% endfor %}
</tbody></table></div>{% else %}<p>No entries yet.</p>{% endif %}
{% endblock %}

17
core/templates/core/transactions/detail.html

@ -0,0 +1,17 @@
{% extends "core/base.html" %}
{% block title %}Transaction · Gizmoduck{% endblock %}
{% block content %}
<div class="heading"><h1>Transaction</h1><a class="button" href="{% url 'transaction-annotate' transaction.pk %}">Edit annotations</a></div>
<dl class="details">
<dt>Description</dt><dd>{{ transaction.description|default:"(No description)" }}</dd>
<dt>Posting date</dt><dd>{{ transaction.posting_date|date:"Y-m-d" }}</dd>
<dt>Occurrence date</dt><dd>{{ transaction.occurrence_date|date:"Y-m-d"|default:"—" }}</dd>
<dt>Amount</dt><dd>{{ transaction.amount }} {{ transaction.account.currency }}</dd>
<dt>Account</dt><dd>{{ transaction.account.name }}</dd>
<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>
</dl>
<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 %}

10
core/templates/core/transactions/form.html

@ -0,0 +1,10 @@
{% extends "core/base.html" %}
{% block title %}{{ title }} · Gizmoduck{% endblock %}
{% block content %}
<h1>{{ title }}</h1>
{% if form.errors %}<div class="error-summary" role="alert"><strong>Review the highlighted fields.</strong>{{ form.non_field_errors }}</div>{% endif %}
<form method="post">{% csrf_token %}
{% for field in form %}<div class="field">{{ field.label_tag }}{{ field }}{% if field.help_text %}<small>{{ field.help_text }}</small>{% endif %}{{ field.errors }}</div>{% endfor %}
<button type="submit">Save</button>
</form>
{% endblock %}

23
core/templates/core/transactions/list.html

@ -0,0 +1,23 @@
{% extends "core/base.html" %}
{% block title %}Transactions · Gizmoduck{% endblock %}
{% block content %}
<div class="heading"><h1>Transactions</h1><a class="button" href="{% url 'transaction-create' %}">New transaction</a></div>
<form method="get" class="filters">
<div><label for="date_from">From</label><input id="date_from" name="date_from" type="date" value="{{ request.GET.date_from }}">{% if filter_errors.date_from %}<p class="error">{{ filter_errors.date_from }}</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="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="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>
<button type="submit">Filter</button>
</form>
{% if filter_errors %}<p class="error" role="alert">Review the invalid filters above.</p>{% endif %}
{% if page.object_list %}
<div class="table-wrap"><table><thead><tr><th>Date</th><th>Description</th><th>Account</th><th>Kind</th><th>Category</th><th class="number">Amount</th></tr></thead><tbody>
{% 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 %}
{% endblock %}

198
core/views.py

@ -0,0 +1,198 @@
from django.contrib import messages
from django.core.paginator import Paginator
from django.db import DatabaseError, connection
from django.http import JsonResponse
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.services import (
Actor, DomainError, annotate_transaction, archive, create_account,
create_category, create_payee, record_transaction,
)
from .forms import AccountForm, AnnotationForm, CategoryForm, ManualTransactionForm, PayeeForm
LOCAL_ACTOR = Actor(kind="HUMAN", identifier="local-owner")
DOMAIN_MESSAGES = {
"archived_reference": "That reference is archived and cannot be selected.",
"category_kind_mismatch": "The category does not match the transaction kind.",
"conflict": "A record with these values already exists.",
"invalid_amount": "Enter a valid amount with at most two decimal places.",
"invalid_name": "Enter a name.",
"not_found": "The requested record was not found.",
"validation_error": "The values do not satisfy the financial rules.",
"zero_amount": "The amount cannot be zero.",
}
UNKNOWN_DOMAIN_MESSAGE = "Unable to save this change."
def health(request):
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
except DatabaseError:
return JsonResponse({"status": "unavailable"}, status=503)
return JsonResponse({"status": "ok"})
def home(request):
return transaction_list(request)
def transaction_list(request):
filter_errors = {}
try:
filters = TransactionFilters.from_mapping(request.GET)
rows = filtered_transactions(filters)
except QueryInputError as exc:
filters = TransactionFilters()
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"))
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"),
"kinds": Transaction.Kind.choices,
})
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"),
pk=transaction_id,
)
return render(request, "core/transactions/detail.html", {"transaction": row})
def transaction_create(request):
form = ManualTransactionForm(request.POST or None)
if request.method == "POST" and form.is_valid():
values = form.cleaned_data
try:
row = record_transaction(
account_id=values["account"], amount=values["amount"],
posting_date=values["posting_date"], occurrence_date=values["occurrence_date"],
kind=values["kind"], description=values["description"],
payee_id=values["payee"], category_id=values["category"], actor=LOCAL_ACTOR,
)
except DomainError as exc:
form.add_error(None, _domain_message(exc))
else:
messages.success(request, "Transaction recorded.")
return redirect("transaction-detail", transaction_id=row.pk)
return render(request, "core/transactions/form.html", {"form": form, "title": "New transaction"})
def transaction_annotate(request, transaction_id):
row = get_object_or_404(Transaction.objects.select_related("payee", "category"), pk=transaction_id)
form = AnnotationForm(request.POST or None, transaction=row)
if request.method == "POST" and form.is_valid():
changes = form.changes()
if not changes:
return redirect("transaction-detail", transaction_id=row.pk)
try:
annotate_transaction(transaction_id=row.pk, actor=LOCAL_ACTOR, **changes)
except DomainError as exc:
form.add_error(None, _domain_message(exc))
else:
messages.success(request, "Transaction annotations updated.")
return redirect("transaction-detail", transaction_id=row.pk)
return render(request, "core/transactions/form.html", {
"form": form, "title": "Edit transaction annotations", "transaction": row,
})
def account_list(request):
return _reference_list(request, Account, "Accounts", "account-create", "account-archive")
def category_list(request):
return _reference_list(request, Category, "Categories", "category-create", "category-archive")
def payee_list(request):
return _reference_list(request, Payee, "Payees", "payee-create", "payee-archive")
def account_create(request):
return _reference_create(request, AccountForm, create_account, "New account", "account-list")
def category_create(request):
return _reference_create(request, CategoryForm, create_category, "New category", "category-list")
def payee_create(request):
return _reference_create(request, PayeeForm, create_payee, "New payee", "payee-list")
@require_POST
def account_archive(request, reference_id):
return _reference_archive(request, Account, reference_id, "account-list")
@require_POST
def category_archive(request, reference_id):
return _reference_archive(request, Category, reference_id, "category-list")
@require_POST
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):
return render(request, "core/references/list.html", {
"title": title,
"rows": model.objects.order_by("archived", "name"),
"create_url": create_url,
"archive_url": archive_url,
})
def _reference_create(request, form_class, service, title, redirect_name):
form = form_class(request.POST or None)
if request.method == "POST" and form.is_valid():
try:
service(actor=LOCAL_ACTOR, **form.cleaned_data)
except DomainError as exc:
form.add_error(None, _domain_message(exc))
else:
messages.success(request, f"{title.removeprefix('New ')} created.")
return redirect(redirect_name)
return render(request, "core/references/form.html", {"form": form, "title": title})
def _reference_archive(request, model, reference_id, redirect_name):
item = get_object_or_404(model, pk=reference_id)
try:
archive(item, actor=LOCAL_ACTOR)
except DomainError as exc:
messages.error(request, _domain_message(exc))
else:
messages.success(request, "Reference archived.")
return redirect(redirect_name)
def _domain_message(error):
return DOMAIN_MESSAGES.get(error.code, UNKNOWN_DOMAIN_MESSAGE)
def _query_message(code):
return {
"invalid_date": "Enter a valid date.",
"before_date_from": "The end date must be on or after the start date.",
"invalid_uuid": "Select a valid reference.",
"invalid_choice": "Select a valid choice.",
"invalid_currency": "Enter a three-letter uppercase currency code.",
}.get(code, "Invalid filter.")

215
tests/test_web_workflows.py

@ -0,0 +1,215 @@
from datetime import date
from decimal import Decimal
import pytest
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
pytestmark = pytest.mark.django_db(transaction=True)
SETUP_ACTOR = Actor(kind="AGENT", identifier="synthetic-web-test")
def account(name="Synthetic Bank"):
return create_account(name=name, kind="BANK", currency="BRL", actor=SETUP_ACTOR)
def transaction(target, description="Synthetic purchase"):
return record_transaction(
account_id=target.pk,
amount=Decimal("-24.50"),
posting_date=date(2026, 1, 15),
kind="EXPENSE",
description=description,
actor=SETUP_ACTOR,
)
def test_root_and_transaction_pages_are_discoverable_and_filter_rows(client):
bank = account()
expected = transaction(bank)
transaction(bank, "Other month").__class__.objects.filter(description="Other month").update(
posting_date=date(2026, 2, 1)
)
home = client.get(reverse("home"))
page = client.get(
reverse("transaction-list"),
{"currency": "BRL", "date_from": "2026-01-01", "date_to": "2026-01-31"},
)
assert home.status_code == 200
assert page.status_code == 200
content = page.content.decode()
assert "Synthetic purchase" in content
assert "Other month" not in content
assert reverse("transaction-detail", args=[expected.pk]) in content
assert "<h1>Transactions</h1>" in content
assert "Accounts" in content and "Categories" in content and "Payees" in content
def test_invalid_transaction_filters_are_shown_without_querying(client):
response = client.get(reverse("transaction-list"), {"date_from": "not-a-date"})
assert response.status_code == 200
assert "Enter a valid date." in response.content.decode()
def test_transaction_detail_shows_relationships_and_provenance(client):
bank = account()
row = transaction(bank)
SourceRecord.objects.create(
transaction=row,
document_hash="a" * 64,
source_format="BANK_CSV",
parser_version="synthetic-v1",
item_locator="row:2",
)
response = client.get(reverse("transaction-detail", args=[row.pk]))
content = response.content.decode()
assert response.status_code == 200
assert "Synthetic purchase" in content
assert "Synthetic Bank" in content
assert "BANK_CSV" in content
assert "row:2" in content
@pytest.mark.parametrize(
"route,args",
[
("transaction-create", ()),
("transaction-annotate", ("00000000-0000-0000-0000-000000000001",)),
("account-create", ()),
("account-archive", ("00000000-0000-0000-0000-000000000001",)),
("category-create", ()),
("category-archive", ("00000000-0000-0000-0000-000000000001",)),
("payee-create", ()),
("payee-archive", ("00000000-0000-0000-0000-000000000001",)),
],
)
def test_mutations_require_csrf(route, args):
guarded = Client(enforce_csrf_checks=True)
response = guarded.post(reverse(route, args=args), {})
assert response.status_code == 403
def test_manual_transaction_and_annotation_use_local_owner_audit(client):
bank = account()
category = create_category(name="Synthetic Food", kind="EXPENSE", actor=SETUP_ACTOR)
payee = create_payee(name="Synthetic Merchant", actor=SETUP_ACTOR)
created = client.post(
reverse("transaction-create"),
{
"account": bank.pk,
"amount": "-24.50",
"posting_date": "2026-01-15",
"occurrence_date": "",
"kind": "EXPENSE",
"description": "Synthetic purchase",
"payee": "",
"category": "",
},
)
row = Transaction.objects.get(description="Synthetic purchase")
assert created.status_code == 302
assert created.url == reverse("transaction-detail", args=[row.pk])
create_audit = AuditEvent.objects.get(operation="record_transaction", target_id=row.pk)
assert (create_audit.actor_kind, create_audit.actor_identifier) == ("HUMAN", "local-owner")
annotated = client.post(
reverse("transaction-annotate", args=[row.pk]),
{"description": "Synthetic revised", "payee": payee.pk, "category": category.pk},
)
row.refresh_from_db()
assert annotated.status_code == 302
assert row.description == "Synthetic revised"
assert row.payee_id == payee.pk and row.category_id == category.pk
assert row.amount == Decimal("-24.50") and row.posting_date == date(2026, 1, 15)
annotation_audit = AuditEvent.objects.get(operation="annotate_transaction", target_id=row.pk)
assert (annotation_audit.actor_kind, annotation_audit.actor_identifier) == ("HUMAN", "local-owner")
@pytest.mark.parametrize(
"kind,create_route,list_route,archive_route,payload,model",
[
(
"account",
"account-create",
"account-list",
"account-archive",
{"name": "Synthetic Savings", "kind": "BANK", "currency": "BRL"},
Account,
),
(
"category",
"category-create",
"category-list",
"category-archive",
{"name": "Synthetic Dining", "kind": "EXPENSE"},
Category,
),
(
"payee",
"payee-create",
"payee-list",
"payee-archive",
{"name": "Synthetic Shop"},
Payee,
),
],
)
def test_reference_create_list_and_archive_are_audited(
client, kind, create_route, list_route, archive_route, payload, model
):
created = client.post(reverse(create_route), payload)
obj = model.objects.get(name=payload["name"])
assert created.status_code == 302
assert created.url == reverse(list_route)
assert payload["name"] in client.get(reverse(list_route)).content.decode()
create_audit = AuditEvent.objects.get(operation=f"create_{kind}", target_id=obj.pk)
assert create_audit.actor_identifier == "local-owner"
archived = client.post(reverse(archive_route, args=[obj.pk]))
obj.refresh_from_db()
assert archived.status_code == 302
assert obj.archived is True
assert "Archived" in client.get(reverse(list_route)).content.decode()
archive_audit = AuditEvent.objects.get(operation="archive", target_id=obj.pk)
assert archive_audit.actor_identifier == "local-owner"
assert client.get(reverse(archive_route, args=[obj.pk])).status_code == 405
def test_domain_errors_render_as_safe_fixed_messages(client):
bank = account()
response = client.post(
reverse("transaction-create"),
{
"account": bank.pk,
"amount": "24.50",
"posting_date": "2026-01-15",
"occurrence_date": "",
"kind": "EXPENSE",
"description": "Synthetic invalid sign",
"payee": "",
"category": "",
},
)
content = response.content.decode()
assert response.status_code == 200
assert "Expense amounts must be negative." in content
assert "Synthetic invalid sign" in content
assert Transaction.objects.filter(description="Synthetic invalid sign").count() == 0
Loading…
Cancel
Save