How BudgetDork's data actually fits together
A real relational model, not one flat spreadsheet — accounts, categories, and transactions are separate tables that reference each other, so renaming a category or fixing a payee doesn't mean touching every row that mentions it.
The full schema
Nine tables. Everything else in the app — the budget grid, every report, every chart — is a query over these.
erDiagram
CATEGORY_GROUPS ||--o{ CATEGORIES : "groups"
CATEGORY_GROUPS {
string id PK
}
CATEGORIES {
string id PK
string groupId FK
}
ACCOUNTS ||--o{ TRANSACTIONS : "posts to"
ACCOUNTS {
string id PK
}
PAYEES ||--o{ TRANSACTIONS : "paid to"
PAYEES {
string id PK
string defaultCategoryId FK
}
TAGS ||--o{ TRANSACTION_TAGS : "applied via"
TAGS {
string id PK
}
CATEGORIES ||--o{ TRANSACTIONS : "categorizes"
CATEGORIES ||--o{ BUDGET_ALLOCATIONS : "assigned in"
BUDGET_ALLOCATIONS {
string categoryId FK
string month PK
}
TRANSACTIONS ||--o{ TRANSACTIONS : "splits into"
TRANSACTIONS ||--o{ TRANSACTION_TAGS : "tagged via"
TRANSACTIONS {
string id PK
string accountId FK
string payeeId FK
string categoryId FK
string transferAccountId FK
string linkedTxnId FK
string parentTxnId FK
}
TRANSACTION_TAGS {
string transactionId FK
string tagId FK
}
ACCOUNTS ||--o{ RECURRING_TEMPLATES : "scheduled on"
CATEGORIES ||--o{ RECURRING_TEMPLATES : "categorizes"
PAYEES ||--o{ RECURRING_TEMPLATES : "paid to"
RECURRING_TEMPLATES ||--o{ TRANSACTIONS : "generates"
RECURRING_TEMPLATES {
string id PK
string accountId FK
string payeeId FK
string categoryId FK
}
Trimmed to keys and foreign keys — every field that actually draws a line between tables above.
The full column list for each one (types, which fields are nullable, plain attributes like an
account's type or a transaction's amount/date/cleared)
isn't repeated here — this diagram is trimmed to just the relationships. Every id is a short prefixed string
(acct_a1b2c3d4, txn_9f8e7d6c, …) generated client-side — readable in devtools,
not meant to be guessable or sequential. Money is always a plain signed number (positive = inflow,
negative = outflow), never cents-as-integers or a separate currency object.
Two things that aren't obvious from the schema alone
An ER diagram shows what tables exist — not the rules that keep them honest. These two are worth seeing drawn out.
A transfer is two linked rows
Moving money between two of your own accounts isn't a special row type — it's an ordinary
transaction on each account, cross-referenced by linkedTxnId, with
opposite signs. Edit or delete one leg and the other stays in sync automatically.
flowchart LR
A["Checking transaction
amount: -200.00
transferAccountId: Savings"]
B["Savings transaction
amount: +200.00
transferAccountId: Checking"]
A <-->|linkedTxnId| B
A split is one parent + N children
One receipt, several categories. The parent is the real register line (date, payee, total) with no category of its own; each hidden child carries its own category and amount. Balances read the parent only — category reports read the children only — so neither double-counts.
flowchart TD
P["Parent transaction
Target · -77.00
categoryId: null"]
C1["Child · Groceries
-45.00"]
C2["Child · Hobbies
-20.00"]
C3["Child · Pharmacy
-12.00"]
P -->|parentTxnId| C1
P -->|parentTxnId| C2
P -->|parentTxnId| C3
How a dollar actually moves
The tables above, traced through one real dollar — from landing in a Cash account to rolling into next month.
flowchart TD
Income(["Paycheck lands in Checking
uncategorized or income-tagged"]) --> RTA["Ready to Assign
(readyToAssign)"]
RTA -->|"you type a number"| Assigned["budgetAllocations
assignedAmount"]
Assigned --> Available["Category: Available
(rolls forward every month)"]
Available -->|"you spend it"| Activity["Category: Activity
(categorized transactions)"]
Activity --> NextMonth["Whatever's left rolls
into next month's Available"]
NextMonth --> Available
This is the entire mechanism behind envelope budgeting — every other screen in the app (Insights, Reports, the "Settle prior months" button) is just a different lens on this same loop.