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.
A transaction category answers “what was this spent on?” – e.g.
Food & Drink : Groceries. Categories are a flat, two-part taxonomy:
Food & Drink).Groceries).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:
full_name is a derived property, f"{group} : {name}" (note the
spaced ` : ` separator). There is no stored full_name column; the
two columns are the source of truth.Transaction.category or
TransactionAllocation.category of NULL is the “no category yet”
state. There is no sentinel row – the old Uncategorized:Unassigned
placeholder was removed. (Uncategorized : Unknown survives as a real
category for genuinely unknowable spend.)(group, name), enforced by two
partial unique constraints: one across all global rows, one per owner
(transaction_category_unique_global and
transaction_category_unique_per_owner). Application code normalizes
before writing (see §4).Every category has an owner FK to a user, which may be NULL:
owner IS NULL) – the shared base set, seeded
by migration 0038_seed_global_categories (155 rows) and managed
through the django-admin. Everyone sees them.owner = <user>) – created by a user via the
API. Visible to the owner and to anyone the owner shares a bank
account with.Visibility is computed per request by
TransactionCategoryQuerySet.visible_to(user)
(app/moneypools/models.py). A category is visible when any of:
owner IS NULL), orowner__bankaccount__owners=user), ortransactions__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.
A category is stored in two places, on purpose:
Transaction.category – the transaction’s overall category,
seeded from the provider hint and user-editable.TransactionAllocation.category – the category of one portion of
the transaction.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.
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.
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):
normalize_category(raw) turns any category string into a
(group, name) pair: split on the first colon, strip each side,
collapse internal whitespace. A string with no colon becomes
group == name. This is why Education: Tuition & Fees (a real typo
in the old data) and Education : Tuition & Fees resolve to the same
row.find_category_for_user(user, raw) resolves an API-supplied full
name among the categories visible to the caller
(case-insensitive; a global row wins over a same-named custom row).
It never creates anything: an unknown name yields a per-item warning
in the response and the transaction stays unassigned.resolve_category_string(raw) serves mibudge’s own data files
(import_bank_account): it matches global rows, accepts legacy
enum-era spellings (ENUM_SPELLING_CHANGES), and auto-creates a
global row for an unknown pair so a restore never drops data.On the importer side (importers/bofa_categories.py):
BOFA_CATEGORY_MAP is the built-in reviewed mapping from normalized
BofA strings (casefolded "group:name" keys) to mibudge full names.
A None value means “deliberately leave the transaction unassigned”.--category-map / MIBUDGE_CATEGORY_MAP, a flat
YAML mapping in the same key/value shape) merges over the built-ins.
This is how a newly discovered BofA category gets mapped without a
code release.app/tests/moneypools/test_categories.py verifies every
map target names a seeded canonical row.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).
Budget.auto_spendBudget.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.
TransactionCategoryViewSet (/api/v1/transaction-categories/):
group,
archived, scope (global | mine | shared); search over group
and name.archive action – sets archived=True (hidden from pickers,
existing references stay valid), mirroring BudgetViewSet.archive.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
categoryfilter param changed from the old enum string to the category UUID.
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:
Merchant model, with transactions mapped to a merchant by matching
the description / normalized name (the merchant_signature idea in
importers/bofa_common.py is an early sketch of that matching).iso18245 Python package
(now a main dependency; the transaction-details enrichment already
stores per-transaction MCCs under exactly this warn-only policy) to a
human-readable description. MCC is a second
identity signal: it can seed or corroborate a merchant’s default
category (e.g. MCC 5411 → grocery), and it gives the future
auto-allocation rules a stable, provider-independent thing to match on.
Where a provider supplies an MCC on a transaction (BofA does, on the
detail dialog), we store the raw 4-digit code and use iso18245 only
to look up / warn – the package lags the registry, so an unknown code
is still stored, never rejected.#groceries) feeding auto_spend matchers
(tag:groceries, §5), so budgets can target categories of merchants
rather than enumerating categories.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.