Skip to content

Expense Category Icons, Subcategories & Credit-Card Accounts

Date: 2026-06-10

Problem / motivation

Three related gaps in expense tracking:

  1. Expense categories are plain name/description rows — the frontend has no way to render a per-category icon, so category lists look undifferentiated.
  2. Categories are flat. Users want to group expenses one level deep (e.g. Vehicle → Fuel, Vehicle → Insurance) for cleaner reporting and UI grouping.
  3. 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_CARD account 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 icon string on ExpenseCategory (the frontend decides the value/meaning — icon name, emoji, etc.).
  • A single-level parent_id self-reference on ExpenseCategory (parent → child only; no deeper nesting).
  • A new CREDIT_CARD member of AccountType (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 accounts with 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):

  1. 20260610_add_icon_to_expense_categories.py (revision = "add_icon_to_expense_categories", down_revision = "add_recurring_id_to_invoices")
  2. icon VARCHAR(255) NULL — free-form icon identifier set by the frontend.
  3. 20260610_add_parent_id_to_expense_categories.py (revision = "add_parent_id_to_exp_categories", down_revision = "add_icon_to_expense_categories")
  4. parent_id UUID NULL REFERENCES expense_categories(id) — single-level parent. Indexed for child lookups.
  5. 20260610_expense_category_no_self_parent.py (revision = "exp_category_no_self_parent", down_revision = "add_parent_id_to_exp_categories")
  6. 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 on ExpenseCategory / ExpenseCategoryLandingPage. Meaning is frontend-defined.
  • Subcategories: pass parentId to 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. Use expenseCategoryTree to render grouped lists; children on a category response holds its sub-categories.
  • Credit cards: create a credit card via the existing createAccount mutation with accountType: CREDIT_CARD (it is a liability, auto-coded in the 2400 range). Select it as an expense's paymentAccountId. 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 new expenseCategoryTree query.
  • 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 — add icon, parent_id, parent, children.
  • app/graphql/accounts/models/account_type.py — append CREDIT_CARD; add to _LIABILITIES.

Inputs / responses

  • app/graphql/expenses/strawberry/inputs/expense_category_input.pyicon, parent_id + to_orm_model.
  • app/graphql/expenses/strawberry/responses/expense_category_response.pyicon, parent_id, children.
  • app/graphql/expenses/strawberry/responses/expense_category_landing_page_response.pyicon, parent_id.

Repositories / services / queries

  • app/graphql/expenses/repositories/expense_category_mutation_repository.py — single-level enforcement on create/update; pass new fields through to_orm_model.
  • app/graphql/expenses/repositories/expense_category_query_repository.py + builders/expense_category_query_builder.pyget_expense_category_tree() (top-level + eager children).
  • app/graphql/expenses/services/expense_category_query_service.py + queries/expense_category_queries.pyexpenseCategoryTree resolver.
  • app/graphql/accounts/repositories/nominal_range_mapper.pyCREDIT_CARD: 2400.
  • app/graphql/accounts/services/account_validation_service.pyEXPENSE_PAYMENT_TYPES = CASH_BANK_TYPES | {CREDIT_CARD}; validate_expense_accounts uses it for payment_account_id.

Migrations

  • alembic/versions/20260610_add_icon_to_expense_categories.py
  • alembic/versions/20260610_add_parent_id_to_expense_categories.py

Tests

  • tests/graphql/expenses/ — category create/update with icon; single-level parent accepted; rejecting 2-level nest and re-parenting a parent; expenseCategoryTree.
  • tests/graphql/accounts/ — create CREDIT_CARD account (code 2400, is_liability); expense accepts credit-card payment_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_sync work).
  • Arbitrary-depth category trees (deferred — single level meets current need).