Expense Category Icons, Subcategories & Credit-Card Accounts¶
Date: 2026-06-10
Problem / motivation¶
Three related gaps in expense tracking:
- Expense categories are plain
name/descriptionrows — the frontend has no way to render a per-category icon, so category lists look undifferentiated. - Categories are flat. Users want to group expenses one level deep (e.g. Vehicle → Fuel, Vehicle → Insurance) for cleaner reporting and UI grouping.
- There is no first-class way to track spend on a credit card. The chart of accounts has cash/bank assets and liability buckets, but no
CREDIT_CARDaccount type, and expense payment validation only accepts cash/bank accounts — so a credit-card liability cannot be selected as the thing an expense was paid with.
In scope¶
- A free-form
iconstring onExpenseCategory(the frontend decides the value/meaning — icon name, emoji, etc.). - A single-level
parent_idself-reference onExpenseCategory(parent → child only; no deeper nesting). - A new
CREDIT_CARDmember ofAccountType(a liability), with nominal code range, liability grouping, and acceptance as an expense payment account.
Out of scope¶
- Arbitrary-depth category trees (decided: single level only).
- Uploaded/stored icon images (decided: free string only).
- A separate user-facing "financial accounts" concept distinct from the GL chart of accounts (decided: reuse
accountswith the new type). - Credit-card statement reconciliation, balances, or due-date tracking (future).
- QuickBooks mapping for the new account type (future —
qb_*fields remain untouched).
Data model changes¶
expense_categories table (two migrations, one concern each, chained off current tip add_recurring_id_to_invoices):
20260610_add_icon_to_expense_categories.py(revision = "add_icon_to_expense_categories",down_revision = "add_recurring_id_to_invoices")icon VARCHAR(255) NULL— free-form icon identifier set by the frontend.20260610_add_parent_id_to_expense_categories.py(revision = "add_parent_id_to_exp_categories",down_revision = "add_icon_to_expense_categories")parent_id UUID NULL REFERENCES expense_categories(id)— single-level parent. Indexed for child lookups.20260610_expense_category_no_self_parent.py(revision = "exp_category_no_self_parent",down_revision = "add_parent_id_to_exp_categories")- NULLs any existing self-parented rows, then adds CHECK constraint
ck_expense_categories_no_self_parent(parent_id IS NULL OR parent_id <> id) — DB-level backstop for the app validation.
No migration for AccountType: it is persisted via IntEnum (integer column), so adding a new member is a code-only change. CREDIT_CARD is appended to the enum (new highest value) so existing stored integers do not shift.
GraphQL surface¶
No new top-level resource — these are additive fields/values on existing expenses and accounts modules. RBAC unchanged (Path.EXPENSES, existing accounts path); no permission backfill required.
Changed types/inputs:
- ExpenseCategoryInput: add icon: str | None, parent_id: uuid.UUID | None.
- ExpenseCategory (response): add icon: str | None, parent_id: uuid.UUID | None, children: list[ExpenseCategoryLite] (sub-categories; empty for a child).
- New ExpenseCategoryLite response — a flat subcategory shape (no children of its own). Categories nest one level deep, so children never have children; the lite type keeps ExpenseCategory from being self-recursive.
- ExpenseCategoryLandingPage: add icon: str | None, parent_id: uuid.UUID | None.
- AccountType enum: add CREDIT_CARD.
New query:
- expenseCategoryTree — top-level categories with their children loaded (for grouped UI). Reuses ExpenseCategoryResponse.
Mutations: unchanged signatures (createExpenseCategory / updateExpenseCategory now accept the new input fields; createAccount now accepts CREDIT_CARD).
RBAC¶
None. No new Path/Resource members, no menu wiring, no backfill script — both touched modules already own their permissions.
Background tasks / cron¶
None.
Frontend contract¶
- Category icons: send
icon(any string) on create/update; read it back onExpenseCategory/ExpenseCategoryLandingPage. Meaning is frontend-defined. - Subcategories: pass
parentIdto nest a category one level under another. The backend rejects nesting under a category that already has a parent, and rejects giving a parent to a category that already has children. UseexpenseCategoryTreeto render grouped lists;childrenon a category response holds its sub-categories. - Credit cards: create a credit card via the existing
createAccountmutation withaccountType: CREDIT_CARD(it is a liability, auto-coded in the 2400 range). Select it as an expense'spaymentAccountId. Cash/bank payment accounts still work; receipts/customer-payments remain cash/bank only.
What shipped¶
All three items in scope shipped in v1.10.0:
expense_categories.icon(free string) +expense_categories.parent_id(single-level self-FK), surfaced on the input, response, landing page, and a newexpenseCategoryTreequery.- One-level-deep nesting enforced in the mutation repository (rejects nesting under a child, re-parenting a category with children, and self-parenting).
AccountType.CREDIT_CARD(liability, code 2400) accepted as an expense payment account.
Nothing was deferred from the original scope; the items under Future additions were never in scope for this release.
What's being implemented¶
Models¶
app/graphql/expenses/models/expense_category.py— addicon,parent_id,parent,children.app/graphql/accounts/models/account_type.py— appendCREDIT_CARD; add to_LIABILITIES.
Inputs / responses¶
app/graphql/expenses/strawberry/inputs/expense_category_input.py—icon,parent_id+to_orm_model.app/graphql/expenses/strawberry/responses/expense_category_response.py—icon,parent_id,children.app/graphql/expenses/strawberry/responses/expense_category_landing_page_response.py—icon,parent_id.
Repositories / services / queries¶
app/graphql/expenses/repositories/expense_category_mutation_repository.py— single-level enforcement on create/update; pass new fields throughto_orm_model.app/graphql/expenses/repositories/expense_category_query_repository.py+builders/expense_category_query_builder.py—get_expense_category_tree()(top-level + eagerchildren).app/graphql/expenses/services/expense_category_query_service.py+queries/expense_category_queries.py—expenseCategoryTreeresolver.app/graphql/accounts/repositories/nominal_range_mapper.py—CREDIT_CARD: 2400.app/graphql/accounts/services/account_validation_service.py—EXPENSE_PAYMENT_TYPES = CASH_BANK_TYPES | {CREDIT_CARD};validate_expense_accountsuses it forpayment_account_id.
Migrations¶
alembic/versions/20260610_add_icon_to_expense_categories.pyalembic/versions/20260610_add_parent_id_to_expense_categories.py
Tests¶
tests/graphql/expenses/— category create/update withicon; single-level parent accepted; rejecting 2-level nest and re-parenting a parent;expenseCategoryTree.tests/graphql/accounts/— createCREDIT_CARDaccount (code 2400,is_liability); expense accepts credit-cardpayment_account_id; non-payment flows still reject it.
Future additions¶
- Credit-card balances / statement reconciliation (deferred — needs ledger design).
- QuickBooks Online mapping for
CREDIT_CARD(deferred —qb_syncwork). - Arbitrary-depth category trees (deferred — single level meets current need).