Browse Source

feat: support category groups

master
Codex 2 months ago
parent
commit
ac86f01887
  1. 3
      config/urls.py
  2. 10
      core/forms.py
  3. 2
      core/templates/core/dashboard.html
  4. 2
      core/templates/core/reports/monthly.html
  5. 2
      core/templates/core/transactions/list.html
  6. 19
      core/views.py
  7. 8
      finance/exports.py
  8. 12
      finance/queries.py
  9. 18
      finance/reports.py
  10. 4
      finance/services/__init__.py
  11. 13
      finance/services/posting.py
  12. 14
      finance/services/reference.py
  13. 10
      tests/finance/test_exports.py

3
config/urls.py

@ -26,6 +26,9 @@ urlpatterns = [
path("categories/", views.category_list, name="category-list"), path("categories/", views.category_list, name="category-list"),
path("categories/new/", views.category_create, name="category-create"), path("categories/new/", views.category_create, name="category-create"),
path("categories/<uuid:reference_id>/archive/", views.category_archive, name="category-archive"), path("categories/<uuid:reference_id>/archive/", views.category_archive, name="category-archive"),
path("category-groups/", views.category_group_list, name="category-group-list"),
path("category-groups/new/", views.category_group_create, name="category-group-create"),
path("category-groups/<uuid:reference_id>/archive/", views.category_group_archive, name="category-group-archive"),
path("payees/", views.payee_list, name="payee-list"), path("payees/", views.payee_list, name="payee-list"),
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"),

10
core/forms.py

@ -4,8 +4,8 @@ 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, CategoryGroup, Payee, Transaction
from finance.queries import active_accounts, active_categories, active_payees from finance.queries import active_accounts, active_categories, active_payees, category_label
_MONTH_RE = re.compile(r"(?P<year>\d{4})-(?P<month>0[1-9]|1[0-2])", re.ASCII) _MONTH_RE = re.compile(r"(?P<year>\d{4})-(?P<month>0[1-9]|1[0-2])", re.ASCII)
@ -16,7 +16,7 @@ def _reference_choices(model, *, include=None):
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
rows = rows.order_by("name", "id") rows = rows.order_by("name", "id")
return [(str(row.pk), row.name) for row in rows] return [(str(row.pk), category_label(row) if model is Category else row.name) for row in rows]
def _uuid(value): def _uuid(value):
@ -87,6 +87,10 @@ class CategoryForm(forms.Form):
kind = forms.ChoiceField(choices=Category.Kind.choices) kind = forms.ChoiceField(choices=Category.Kind.choices)
class CategoryGroupForm(forms.Form):
name = forms.CharField(max_length=255)
class PayeeForm(forms.Form): class PayeeForm(forms.Form):
name = forms.CharField(max_length=255) name = forms.CharField(max_length=255)

2
core/templates/core/dashboard.html

@ -19,7 +19,7 @@
</dl> </dl>
<h2>Spending by category</h2> <h2>Spending by category</h2>
{% if spending_rows %}<div class="table-wrap"><table><thead><tr><th>Category</th><th class="number">Expense</th><th class="number">Refund</th><th class="number">Net spending</th></tr></thead><tbody> {% if spending_rows %}<div class="table-wrap"><table><thead><tr><th>Category</th><th class="number">Expense</th><th class="number">Refund</th><th class="number">Net spending</th></tr></thead><tbody>
{% for row in spending_rows %}<tr><td>{{ row.category_name }}</td><td class="number">{{ row.expense|floatformat:"2g" }}</td><td class="number">{{ row.refund|floatformat:"2g" }}</td><td class="number">{{ row.net_spending|floatformat:"2g" }} {{ summary.currency }}</td></tr>{% endfor %} {% for row in spending_rows %}<tr>{% if row.is_group_total %}<th>{{ row.category_name }}</th>{% else %}<td>{% if row.group_name %}↳ {% endif %}{{ row.category_name }}</td>{% endif %}<td class="number">{{ row.expense|floatformat:"2g" }}</td><td class="number">{{ row.refund|floatformat:"2g" }}</td><td class="number">{{ row.net_spending|floatformat:"2g" }} {{ summary.currency }}</td></tr>{% endfor %}
</tbody></table></div>{% else %}<p>No spending activity for this month.</p>{% endif %} </tbody></table></div>{% else %}<p>No spending activity for this month.</p>{% endif %}
<h2>Account balances</h2> <h2>Account balances</h2>
{% if balances %}<div class="table-wrap"><table><thead><tr><th>Account</th><th class="number">Balance</th></tr></thead><tbody> {% if balances %}<div class="table-wrap"><table><thead><tr><th>Account</th><th class="number">Balance</th></tr></thead><tbody>

2
core/templates/core/reports/monthly.html

@ -19,7 +19,7 @@
</dl> </dl>
<h2>Spending by category</h2> <h2>Spending by category</h2>
{% if spending_rows %}<div class="table-wrap"><table><thead><tr><th>Category</th><th class="number">Expense</th><th class="number">Refund</th><th class="number">Net spending</th></tr></thead><tbody> {% if spending_rows %}<div class="table-wrap"><table><thead><tr><th>Category</th><th class="number">Expense</th><th class="number">Refund</th><th class="number">Net spending</th></tr></thead><tbody>
{% for row in spending_rows %}<tr><td>{{ row.category_name }}</td><td class="number">{{ row.expense|floatformat:"2g" }}</td><td class="number">{{ row.refund|floatformat:"2g" }}</td><td class="number">{{ row.net_spending|floatformat:"2g" }} {{ summary.currency }}</td></tr>{% endfor %} {% for row in spending_rows %}<tr>{% if row.is_group_total %}<th>{{ row.category_name }}</th>{% else %}<td>{% if row.group_name %}↳ {% endif %}{{ row.category_name }}</td>{% endif %}<td class="number">{{ row.expense|floatformat:"2g" }}</td><td class="number">{{ row.refund|floatformat:"2g" }}</td><td class="number">{{ row.net_spending|floatformat:"2g" }} {{ summary.currency }}</td></tr>{% endfor %}
</tbody></table></div>{% else %}<p>No spending activity for this month.</p>{% endif %} </tbody></table></div>{% else %}<p>No spending activity for this month.</p>{% endif %}
<h2>Account balances</h2> <h2>Account balances</h2>
{% if balances %}<div class="table-wrap"><table><thead><tr><th>Account</th><th class="number">Balance</th></tr></thead><tbody> {% if balances %}<div class="table-wrap"><table><thead><tr><th>Account</th><th class="number">Balance</th></tr></thead><tbody>

2
core/templates/core/transactions/list.html

@ -16,7 +16,7 @@
{% 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 %}
{% if page.object_list %} {% 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> <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 %} {% 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>{% if row.category %}{% if row.category.group %}{{ row.category.group.name }} · {% endif %}{{ row.category.name }}{% else %}Uncategorized{% endif %}</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="?{% 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 %} {% 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 %}

19
core/views.py

@ -11,7 +11,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone from django.utils import timezone
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, CategoryGroup, Payee, Transaction
from finance.exports import build_export from finance.exports import build_export
from finance.queries import ( from finance.queries import (
QueryInputError, TransactionFilters, filtered_transactions, QueryInputError, TransactionFilters, filtered_transactions,
@ -25,13 +25,13 @@ from finance.reconciliation import (
from finance.reports import account_balances, available_currencies, monthly_summary, spending_by_category from finance.reports import account_balances, available_currencies, monthly_summary, spending_by_category
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, create_reconciliation_checkpoint, create_category, create_category_group, create_payee, create_reconciliation_checkpoint,
record_transaction, record_transaction,
) )
from ingestion import guided_imports from ingestion import guided_imports
from .forms import ( from .forms import (
AccountForm, AnnotationForm, CategoryForm, ManualTransactionForm, PayeeForm, AccountForm, AnnotationForm, CategoryForm, CategoryGroupForm, ManualTransactionForm, PayeeForm,
ImportMappingForm, ReconciliationForm, ReportSelectionForm, ImportMappingForm, ReconciliationForm, ReportSelectionForm,
) )
@ -375,6 +375,10 @@ def category_list(request):
return _reference_list(request, listed_categories(), "Categories", "category-create", "category-archive") return _reference_list(request, listed_categories(), "Categories", "category-create", "category-archive")
def category_group_list(request):
return _reference_list(request, CategoryGroup.objects.order_by("archived", "name", "id"), "Category groups", "category-group-create", "category-group-archive")
def payee_list(request): def payee_list(request):
return _reference_list(request, listed_payees(), "Payees", "payee-create", "payee-archive") return _reference_list(request, listed_payees(), "Payees", "payee-create", "payee-archive")
@ -387,6 +391,10 @@ def category_create(request):
return _reference_create(request, CategoryForm, create_category, "New category", "category-list") return _reference_create(request, CategoryForm, create_category, "New category", "category-list")
def category_group_create(request):
return _reference_create(request, CategoryGroupForm, create_category_group, "New category group", "category-group-list")
def payee_create(request): def payee_create(request):
return _reference_create(request, PayeeForm, create_payee, "New payee", "payee-list") return _reference_create(request, PayeeForm, create_payee, "New payee", "payee-list")
@ -401,6 +409,11 @@ def category_archive(request, reference_id):
return _reference_archive(request, Category, reference_id, "category-list") return _reference_archive(request, Category, reference_id, "category-list")
@require_POST
def category_group_archive(request, reference_id):
return _reference_archive(request, CategoryGroup, reference_id, "category-group-list")
@require_POST @require_POST
def payee_archive(request, reference_id): def payee_archive(request, reference_id):
return _reference_archive(request, Payee, reference_id, "payee-list") return _reference_archive(request, Payee, reference_id, "payee-list")

8
finance/exports.py

@ -20,6 +20,7 @@ from finance.models import (
Account, Account,
CardStatement, CardStatement,
Category, Category,
CategoryGroup,
InstallmentPlan, InstallmentPlan,
Payee, Payee,
SourceRecord, SourceRecord,
@ -69,10 +70,15 @@ SCHEMAS = (
Payee, Payee,
("id", "name", "archived", "created_at", "updated_at"), ("id", "name", "archived", "created_at", "updated_at"),
), ),
(
"category_groups.csv",
CategoryGroup,
("id", "name", "archived", "created_at", "updated_at"),
),
( (
"categories.csv", "categories.csv",
Category, Category,
("id", "name", "kind", "archived", "created_at", "updated_at"), ("id", "name", "kind", "group_id", "archived", "created_at", "updated_at"),
), ),
( (
"statements.csv", "statements.csv",

12
finance/queries.py

@ -4,7 +4,7 @@ import re
from typing import Mapping from typing import Mapping
from uuid import UUID from uuid import UUID
from django.db.models import QuerySet from django.db.models import Q, QuerySet
from finance.models import Account, Category, Payee, Transaction from finance.models import Account, Category, Payee, Transaction
@ -74,7 +74,7 @@ class TransactionFilters:
def filtered_transactions(filters: TransactionFilters) -> QuerySet[Transaction]: def filtered_transactions(filters: TransactionFilters) -> QuerySet[Transaction]:
rows = Transaction.objects.select_related( rows = Transaction.objects.select_related(
"account", "payee", "category", "statement", "refund_of", "installment_plan" "account", "payee", "category", "category__group", "statement", "refund_of", "installment_plan"
) )
if filters.date_from is not None: if filters.date_from is not None:
rows = rows.filter(posting_date__gte=filters.date_from) rows = rows.filter(posting_date__gte=filters.date_from)
@ -100,7 +100,7 @@ def listed_accounts() -> QuerySet[Account]:
def listed_categories() -> QuerySet[Category]: def listed_categories() -> QuerySet[Category]:
return Category.objects.order_by("archived", "name", "id") return Category.objects.select_related("group").order_by("group__name", "archived", "name", "id")
def listed_payees() -> QuerySet[Payee]: def listed_payees() -> QuerySet[Payee]:
@ -112,7 +112,11 @@ def active_accounts() -> QuerySet[Account]:
def active_categories() -> QuerySet[Category]: def active_categories() -> QuerySet[Category]:
return Category.objects.filter(archived=False).order_by("name", "id") return Category.objects.filter(archived=False).filter(Q(group__isnull=True) | Q(group__archived=False)).select_related("group").order_by("group__name", "name", "id")
def category_label(category: Category) -> str:
return f"{category.group.name} · {category.name}" if category.group_id else category.name
def active_payees() -> QuerySet[Payee]: def active_payees() -> QuerySet[Payee]:

18
finance/reports.py

@ -35,7 +35,9 @@ class MonthlySummary:
@dataclass(frozen=True) @dataclass(frozen=True)
class SpendingCategory: class SpendingCategory:
category_id: UUID | None category_id: UUID | None
group_name: str | None
category_name: str category_name: str
is_group_total: bool
expense: Decimal expense: Decimal
refund: Decimal refund: Decimal
net_spending: Decimal net_spending: Decimal
@ -83,23 +85,33 @@ def spending_by_category(month: date, currency: str) -> tuple[SpendingCategory,
grouped = ( grouped = (
_monthly_rows(month, month_after, currency) _monthly_rows(month, month_after, currency)
.filter(kind__in=[Transaction.Kind.EXPENSE, Transaction.Kind.REFUND]) .filter(kind__in=[Transaction.Kind.EXPENSE, Transaction.Kind.REFUND])
.values("category_id", "category__name") .values("category_id", "category__name", "category__group__name")
.annotate( .annotate(
expense=_sum(-F("amount"), Q(kind=Transaction.Kind.EXPENSE)), expense=_sum(-F("amount"), Q(kind=Transaction.Kind.EXPENSE)),
refund=_sum("amount", Q(kind=Transaction.Kind.REFUND)), refund=_sum("amount", Q(kind=Transaction.Kind.REFUND)),
) )
) )
rows = tuple( leaves = tuple(
SpendingCategory( SpendingCategory(
category_id=row["category_id"], category_id=row["category_id"],
group_name=row["category__group__name"],
category_name=row["category__name"] or "Uncategorized", category_name=row["category__name"] or "Uncategorized",
is_group_total=False,
expense=row["expense"], expense=row["expense"],
refund=row["refund"], refund=row["refund"],
net_spending=row["expense"] - row["refund"], net_spending=row["expense"] - row["refund"],
) )
for row in grouped for row in grouped
) )
return tuple(sorted(rows, key=lambda row: (-row.net_spending, row.category_name))) grouped_rows = []
for group_name in sorted({row.group_name for row in leaves if row.group_name}):
children = [row for row in leaves if row.group_name == group_name]
grouped_rows.append(SpendingCategory(None, group_name, group_name, True,
sum((row.expense for row in children), _ZERO), sum((row.refund for row in children), _ZERO),
sum((row.net_spending for row in children), _ZERO)))
grouped_rows.extend(sorted(children, key=lambda row: (-row.net_spending, row.category_name)))
grouped_rows.extend(sorted((row for row in leaves if not row.group_name), key=lambda row: (-row.net_spending, row.category_name)))
return tuple(grouped_rows)
def account_balances(month: date, currency: str) -> tuple[AccountBalance, ...]: def account_balances(month: date, currency: str) -> tuple[AccountBalance, ...]:

4
finance/services/__init__.py

@ -1,8 +1,8 @@
"""Supported financial writes. Direct ORM mutation bypasses cross-row invariants.""" """Supported financial writes. Direct ORM mutation bypasses cross-row invariants."""
from .base import Actor, DomainError from .base import Actor, DomainError
from .reference import create_account, create_payee, create_category, archive, create_statement, create_plan from .reference import create_account, create_payee, create_category, create_category_group, archive, create_statement, create_plan
from .posting import record_transaction, annotate_transaction, attach_source from .posting import record_transaction, annotate_transaction, attach_source
from .reconciliation import create_reconciliation_checkpoint from .reconciliation import create_reconciliation_checkpoint
from .transfers import create_transfer, match_transfer from .transfers import create_transfer, match_transfer
__all__ = ["Actor", "DomainError", "create_account", "create_payee", "create_category", "archive", "create_statement", "create_plan", "record_transaction", "annotate_transaction", "attach_source", "create_reconciliation_checkpoint", "create_transfer", "match_transfer"] __all__ = ["Actor", "DomainError", "create_account", "create_payee", "create_category", "create_category_group", "archive", "create_statement", "create_plan", "record_transaction", "annotate_transaction", "attach_source", "create_reconciliation_checkpoint", "create_transfer", "match_transfer"]

13
finance/services/posting.py

@ -1,4 +1,4 @@
from finance.models import Account, Payee, Category, CardStatement, InstallmentPlan, Transaction, SourceRecord from finance.models import Account, Payee, Category, CategoryGroup, CardStatement, InstallmentPlan, Transaction, SourceRecord
from .base import DomainError, identity, lock, money, save, write from .base import DomainError, identity, lock, money, save, write
@ -10,13 +10,20 @@ def validate_category(kind, category):
raise DomainError("category_kind_mismatch") raise DomainError("category_kind_mismatch")
def lock_category(identifier):
category = lock(Category, identifier, active=True)
if category.group_id is not None:
lock(CategoryGroup, category.group_id, active=True)
return category
def record(*, account_id, amount, posting_date, kind, description="", occurrence_date=None, def record(*, account_id, amount, posting_date, kind, description="", occurrence_date=None,
payee_id=None, category_id=None, statement_id=None, refund_of_id=None, payee_id=None, category_id=None, statement_id=None, refund_of_id=None,
installment_plan_id=None, installment_number=None): installment_plan_id=None, installment_number=None):
amount = money(amount) amount = money(amount)
account = lock(Account, account_id, active=True) account = lock(Account, account_id, active=True)
payee = lock(Payee, payee_id, active=True) if payee_id is not None else None payee = lock(Payee, payee_id, active=True) if payee_id is not None else None
category = lock(Category, category_id, active=True) if category_id is not None else None category = lock_category(category_id) if category_id is not None else None
validate_category(kind, category) validate_category(kind, category)
statement = lock(CardStatement, statement_id) if statement_id is not None else None statement = lock(CardStatement, statement_id) if statement_id is not None else None
if statement and (account.kind != "CREDIT_CARD" or statement.account_id != account.pk): if statement and (account.kind != "CREDIT_CARD" or statement.account_id != account.pk):
@ -55,7 +62,7 @@ def annotate_transaction(*, transaction_id, **changes):
for field, model in (("payee_id", Payee), ("category_id", Category)): for field, model in (("payee_id", Payee), ("category_id", Category)):
if field in changes: if field in changes:
value = changes[field] value = changes[field]
references[field] = lock(model, value, active=True) if value is not None else None references[field] = (lock_category(value) if model is Category else lock(model, value, active=True)) if value is not None else None
obj = lock(Transaction, transaction_id) obj = lock(Transaction, transaction_id)
if "description" in changes: if "description" in changes:
obj.description = changes["description"] obj.description = changes["description"]

14
finance/services/reference.py

@ -1,4 +1,4 @@
from finance.models import Account, Payee, Category, CardStatement, InstallmentPlan from finance.models import Account, Payee, Category, CategoryGroup, CardStatement, InstallmentPlan
from .base import DomainError, lock, money, save, write from .base import DomainError, lock, money, save, write
@ -20,13 +20,19 @@ def create_payee(*, name):
@write("create_category") @write("create_category")
def create_category(*, name, kind): def create_category(*, name, kind, group_id=None):
return save(Category(name=normalized_name(name), kind=kind)), ["name", "kind"] group = lock(CategoryGroup, group_id, active=True) if group_id is not None else None
return save(Category(name=normalized_name(name), kind=kind, group=group)), ["name", "kind", "group"]
@write("create_category_group")
def create_category_group(*, name):
return save(CategoryGroup(name=normalized_name(name))), ["name"]
@write("archive") @write("archive")
def archive(entity): def archive(entity):
if type(entity) not in (Account, Payee, Category): if type(entity) not in (Account, Payee, Category, CategoryGroup):
raise DomainError("invalid_reference") raise DomainError("invalid_reference")
obj = lock(type(entity), entity.pk) obj = lock(type(entity), entity.pk)
obj.archived = True obj.archived = True

10
tests/finance/test_exports.py

@ -18,6 +18,7 @@ from finance.models import (
AuditEvent, AuditEvent,
CardStatement, CardStatement,
Category, Category,
CategoryGroup,
InstallmentPlan, InstallmentPlan,
Payee, Payee,
SourceRecord, SourceRecord,
@ -31,6 +32,7 @@ pytestmark = pytest.mark.django_db(transaction=True)
EXPECTED_FILES = [ EXPECTED_FILES = [
"accounts.csv", "accounts.csv",
"payees.csv", "payees.csv",
"category_groups.csv",
"categories.csv", "categories.csv",
"statements.csv", "statements.csv",
"installment_plans.csv", "installment_plans.csv",
@ -41,7 +43,8 @@ EXPECTED_FILES = [
EXPECTED_HEADERS = { EXPECTED_HEADERS = {
"accounts.csv": "id,name,kind,currency,archived,created_at,updated_at", "accounts.csv": "id,name,kind,currency,archived,created_at,updated_at",
"payees.csv": "id,name,archived,created_at,updated_at", "payees.csv": "id,name,archived,created_at,updated_at",
"categories.csv": "id,name,kind,archived,created_at,updated_at", "category_groups.csv": "id,name,archived,created_at,updated_at",
"categories.csv": "id,name,kind,group_id,archived,created_at,updated_at",
"statements.csv": ( "statements.csv": (
"id,account_id,period_start,period_end,due_date,statement_balance,created_at,updated_at" "id,account_id,period_start,period_end,due_date,statement_balance,created_at,updated_at"
), ),
@ -83,10 +86,15 @@ def seed_complete_export():
id=UUID("20000000-0000-4000-8000-000000000001"), id=UUID("20000000-0000-4000-8000-000000000001"),
name="Synthetic Merchant", name="Synthetic Merchant",
) )
group = CategoryGroup.objects.create(
id=UUID("30000000-0000-4000-8000-000000000002"),
name="Synthetic Household",
)
category = Category.objects.create( category = Category.objects.create(
id=UUID("30000000-0000-4000-8000-000000000001"), id=UUID("30000000-0000-4000-8000-000000000001"),
name="Synthetic Food", name="Synthetic Food",
kind=Category.Kind.EXPENSE, kind=Category.Kind.EXPENSE,
group=group,
) )
statement = CardStatement.objects.create( statement = CardStatement.objects.create(
id=UUID("40000000-0000-4000-8000-000000000001"), id=UUID("40000000-0000-4000-8000-000000000001"),

Loading…
Cancel
Save