mibudge

Transaction Categories – Implementation Reference

This document describes how mibudge models transaction categories, how a transaction or split gets a category, and how categories from external providers (bank scrapers, importers) are mapped onto ours. It is written for contributors; every claim is tied to a specific file and function.


1. Overview

A transaction category answers “what was this spent on?” – e.g. Food & Drink : Groceries. Categories are a flat, two-part taxonomy:

There is no parent/child nesting. Every row is a leaf; “group-level” questions are answered with a category__group filter, not a separate group object. A provider’s single-level category (Travel : Travel) maps to a row whose group equals its name.

The model is TransactionCategory in app/moneypools/models.py. It replaced a 151-member TextChoices enum – categories needed to be shared, user-extensible, and queryable, which an enum cannot be.

Key rules:


2. Global vs. owned categories, and visibility

Every category has an owner FK to a user, which may be NULL:

Visibility is computed per request by TransactionCategoryQuerySet.visible_to(user) (app/moneypools/models.py). A category is visible when any of:

  1. it is global (owner IS NULL), or
  2. the user owns it, or
  3. its owner co-owns at least one bank account with the user (owner__bankaccount__owners=user), or
  4. it is still referenced by a transaction or allocation on an account the user owns (transactions__bank_account__owners=user / allocations__transaction__bank_account__owners=user).

Clause 4 is the grandfather rule: if you shared an account, the co-owner categorized some transactions with a custom category, and then you stopped sharing, those transactions stay readable. This needs no tombstones or snapshots – the reverse join through the referencing rows keeps a used category visible for exactly as long as it is used.

The API viewset (TransactionCategoryViewSet) applies visible_to in get_queryset(), so list/retrieve/filters all operate on the visible set automatically.


3. Where a category lives: Transaction and Allocation

A category is stored in two places, on purpose:

Both are nullable FKs with on_delete=SET_NULL (a DB-level safety net; the API refuses to delete a referenced category – see §6).

Why both? A single purchase can be split across budgets with different categories – a Costco run that is part Groceries, part Home Supplies. The per-portion category lives on the allocation; the transaction keeps the single “headline” category. Multiple categories per transaction = multiple allocations. There is no M2M anywhere.

The copy hook (one direction, once)

When an allocation is created, it copies the transaction’s current category unless the caller supplies one. This is the single hook, in app/moneypools/service/transaction_allocation.py::create():

kwargs.setdefault("category", transaction.category)

This copy happens only at allocation-creation time. Edits never propagate afterward, in either direction: changing the transaction’s category does not rewrite its allocations, and changing an allocation’s category does not touch the transaction. They are independent after the initial copy.


4. Provider category mapping (importer-side)

External providers supply category hints in their own vocabulary, not ours. BofA says Groceries : Groceries and Shopping & Entertainment : General Merchandise; we say Food & Drink : Groceries and Uncategorized : Other Shopping.

Translating between the two is the importer’s job, not mibudge’s. mibudge carries no provider mappings: the transaction-details endpoint accepts an optional per-item category holding a mibudge category full name ("{group} : {name}"), and the importer translates its provider’s strings into those names before submitting. Full names are the stable category identity across deployments – global rows are API-immutable and every deployment seeds the same canonical set (migration 0038).

On the server side (app/moneypools/service/categories.py):

On the importer side (importers/bofa_categories.py):

Because the mapping lives client-side, mibudge cannot re-derive categories from the stored details JSON on its own; category re-map passes run through the importer (saved-scrape replay).


5. Budget.auto_spend

Budget.auto_spend is a JSONField holding a list of matcher strings. When a new spend matches one of a budget’s entries, it can be auto-routed to that budget (the full auto-allocation-rules feature is future work).

Entries are currently transaction-category full names in canonical {group} : {name} form. They are stored as strings, not category UUIDs, deliberately: an export/import must stay portable across deployments, and category UUIDs are not stable between them.

BudgetSerializer.validate_auto_spend (app/moneypools/api/v1/serializers.py) validates each entry against the categories visible to the requester and rewrites it to the matched category’s canonical full name.

The string form intentionally leaves room for other matcher kinds without a schema change – e.g. a future tag:groceries entry matching merchants tagged #groceries, once merchants and tags exist (see §7). Keep the field loose until the auto-allocation-rules feature formalizes it.


6. API surface

TransactionCategoryViewSet (/api/v1/transaction-categories/):

On Transaction and TransactionAllocation serializers, category is the category UUID (a SlugRelatedField(id)), writable and visibility-checked, with a read-only category_full_name for display. Filters: TransactionFilter and TransactionAllocationFilter gained category (UUID), category_group, and uncategorized (isnull).

Breaking change: the allocation category filter param changed from the old enum string to the category UUID.


7. Future direction: merchants as first-class objects

Scraping per-transaction detail from providers is expensive and fragile – BofA rate-limits the detail dialog hard and changes its DOM. The intended longer-term answer is to make merchants first-class Django objects and drive categorization from the merchant, not the provider:

In that world, provider category hints become a bootstrap and a fallback: useful for seeding a new merchant’s default category, but not the primary path. The importer-side maps described here remain the mechanism for turning whatever hint we do get into one of our categories; the merchant layer sits on top, deciding a category from identity when a provider hint is missing or not worth fetching. The only mapping mibudge itself would then serve is a versioned MCC <-> category table on the category endpoint, giving any importer a provider-neutral bootstrap for its own map.

Related future work – budget auto-allocation rules – will match on merchant name / transaction category / merchant category code (MCC), so those columns are kept queryable as they are added. MCC is stored as the raw 4-digit code (indexed) and interpreted through the iso18245 package, so rules can match a code directly or a category derived from it.