13 changed files with 690 additions and 0 deletions
@ -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" |
||||||
@ -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"), |
||||||
|
] |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
def navigation(_request): |
||||||
|
return {"navigation": ( |
||||||
|
("home", "Home"), |
||||||
|
("account-list", "Accounts"), |
||||||
|
("transaction-list", "Transactions"), |
||||||
|
("category-list", "Categories"), |
||||||
|
("payee-list", "Payees"), |
||||||
|
)} |
||||||
@ -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) |
||||||
@ -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; } } |
||||||
@ -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> |
||||||
@ -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 %} |
||||||
@ -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 %} |
||||||
@ -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 %} |
||||||
@ -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 %} |
||||||
@ -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 %} |
||||||
@ -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.") |
||||||
@ -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…
Reference in new issue