@specfork/budget
Local-first personal envelope budget with accounts, split and transfer transactions, monthly assign and rollover, schedules, rules, reconciliation, file import, bank feeds, reports and optional sync.
Budget
One person plans and tracks their own money with envelope budgeting: every unit of income is given a job in a category, spending draws envelopes down, and what is left (or overspent) rolls into next month by fixed rules. Data lives on the person's own devices; a sync server is optional. Behaviour derived from Actual Budget's open-source code (MIT), written fresh and stack-free. Not affiliated with or endorsed by the Actual Budget project or its contributors. The name is used only to credit the source.
| Area | Prefix | Covers |
|---|---|---|
| files.md | FILE | Budget files, currency, money and rounding, local-first, export, import, inventory |
| accounts.md | ACC | On/off-budget accounts, balances, opening balance, close/reopen/delete, reconciliation |
| transactions.md | TXN | Fields, ordering, cleared/reconciled, split transactions, transfers |
| categories.md | CAT | Payees, category groups, categories, merge and delete, notes, tags |
| budget.md | BUD | Monthly assign, activity, available, rollover, overspending, to-budget, hold, budget actions |
| rules.md | RULE | Conditions, actions, stages and order, split actions, category learning |
| schedules.md | SCH | Recurring transactions, recurrence, matching, status, auto-posting |
| import.md | IMP | CSV, OFX and QIF import, duplicate matching, generic bank feed |
| reports.md | RPT | Spending, cash flow, net worth, custom reports |
| sync.md | SYNC | Optional sync server, change log, conflict rule, end-to-end encryption, server delete-all |
Single user: a budget file belongs to one person and has no members, roles or sharing; a person may
keep several budget files. @specfork/auth is used only by the optional sync server, in single_owner
mode (SYNC); export and import follow @specfork/privacy (FILE).
Money is integer minor units (FILE-5); dates are calendar dates YYYY-MM-DD
without time zone; months are YYYY-MM; names are snake_case. The app gives no financial
advice: it shows arithmetic over the person's own data only.
Accounts
Status: Draft · Prefix: ACC
Purpose
Where money sits: on-budget accounts feed the envelope budget, off-budget accounts are only tracked. Balances, opening balance, closing, deleting and reconciling against a statement.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| ACC-1 | Account: id, name (trimmed, 1–100 chars, unique case-insensitively among open accounts, else invalid_name), on_budget (bool, set at create, immutable), closed (default false), sort_order, note?, last_reconciled_at?, feed_link? (IMP-12). New accounts sort last among accounts with the same on_budget. |
create two "Card" → second invalid_name |
| ACC-2 | Creating an account also creates its transfer payee (CAT-2). | create "Savings" → payee list has transfer payee for "Savings" |
| ACC-3 | Opening balance b at create: b ≠ 0 → one transaction dated today, amount b, payee starting_balance (system payee, created once), cleared = true, is_opening_balance = true; category starting_balances (recreated in the income group if missing) when on-budget, none when off-budget. b = 0 → no transaction. |
on-budget, b = 50000 → to-budget this month +50000 |
| ACC-4 | balance = sum of amount over the account's live transactions that are not split parents; cleared_balance = same over cleared = true; uncleared_balance = balance − cleared_balance. |
+1000 cleared, −300 uncleared → 700 / 1000 / −300 |
| ACC-5 | Budget totals: on-budget balance = Σ balance of open and closed on-budget accounts; off-budget likewise; net worth = both. |
on 700, off 5000 → net worth 5700 |
| ACC-6 | Close: balance = 0 → closed = true. balance ≠ 0 needs transfer_to (another open account, not itself, else invalid_transfer_account; missing → balance_not_zero); the app adds a transfer of −balance dated today with note "account closed" to it, then closes. On-budget → off-budget needs category for the outflow, else category_required. |
close 700 into savings → savings +700, closed account 0 |
| ACC-7 | Closed accounts: hidden from account pickers and the sidebar's open list; new transactions in them → account_closed; existing ones stay editable and count in reports. Reopen sets closed = false. |
add to closed account → account_closed |
| ACC-8 | Delete: removes the account, its transactions and its transfer payee. Transfer counterparts in other accounts stay, with payee and transfer_id cleared (TXN-12). Confirmation required. |
delete checking with 1 transfer to savings → savings row kept, no payee |
| ACC-9 | Reconcile start: the person enters statement_balance (default cleared_balance); difference = statement_balance − cleared_balance, recomputed as rows are cleared or uncleared. Reconcile adjust: with difference ≠ 0 the person may add an adjustment transaction: amount difference, dated today, cleared = true, note "reconciliation adjustment", no category unless rules set one (RULE). |
statement 1200, cleared 1000 → difference 200; difference 200 → adjust → new row +200, difference 0 |
| ACC-10 | Reconcile finish: allowed only when difference = 0, else not_balanced; sets reconciled = true on every cleared, unreconciled transaction of the account and last_reconciled_at = now. |
difference 5 → finish → not_balanced, nothing locked |
| ACC-11 | Reconciled lock: changing amount, date, account or payee of a reconciled transaction, or of a transfer whose counterpart is reconciled, needs unlock = true, else reconciled_locked. Clearing cleared on a reconciled row sets reconciled = false (needs unlock). Moving it to another account sets reconciled = false. Bulk cleared toggles skip reconciled rows. |
edit reconciled amount without unlock → reconciled_locked |
Out of scope
- Account types (checking, credit, loan…), interest, credit limits, investment holdings.
- Account groups or folders.
Decisions
- Closing an empty account closes it; the source deleted it silently.
on_budgetis fixed at create: switching would rewrite past budget months.- The reconciled lock is enforced by the data layer, not only by the editor as in the source.
- New rows in closed accounts are refused so closed balances stay at zero.
Budget
Status: Draft · Prefix: BUD
Purpose
The monthly envelope budget: how much is assigned to each category, what was spent, what is available, how leftovers and overspending move into the next month, and how much is left to assign.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| BUD-1 | Budget months are YYYY-MM from (month of the earliest live transaction − 3 months) to (current month + 12); with no transactions, from current − 3. Months extend automatically as transactions or time move. Reading or writing outside → month_out_of_range. All formulas use whole calendar months of transaction date. |
earliest 2026-05-10, today 2026-10-22 → 2026-02..2027-10 |
| BUD-2 | Budget cell per (expense category, month): assigned (integer minor units, any sign, default 0). Income categories have no cells. |
new month → all assigned 0 |
| BUD-3 | activity(c, m) = Σ amount of live transactions with category c, dated in m, in on-budget accounts, not split parents (children count). Transfers between on-budget accounts have no category (TXN-3) and so never count. |
on-budget −60 food + off-budget −40 food → activity −60 |
| BUD-4 | available(c, m) = assigned(c, m) + activity(c, m) + carry(c, m), where carry(c, m) = available(c, m−1) if rollover_overspending(c, m−1), else max(available(c, m−1), 0); carry = 0 in the first month. A positive balance therefore always rolls forward. |
assigned 500, activity −600 → available −100 |
| BUD-5 | rollover_overspending(c, m): bool, default false. Setting it for month m sets it for m and every later month in range; clearing likewise. |
set in March → true in March..last month |
| BUD-6 | overspent(m) = Σ over expense categories with rollover_overspending(c, m−1) = false of min(available(c, m−1), 0) (≤ 0). The negative part not rolled forward in BUD-4 is charged here, once, to next month's to-budget. |
Jan food −100, not flagged → Feb overspent −100, Feb food carry 0 |
| BUD-7 | income(m) = Σ activity of all income categories in m (on-budget only, BUD-3). to_budget(m) = income(m) + to_budget(m−1) + held(m−1) + overspent(m) − Σ assigned(c, m) over all expense categories (hidden included) − held(m). Month summary: income, to_budget, held, overspent (from BUD-6), assigned_total = Σ assigned, activity_total = Σ expense activity, available_total = Σ expense available. Group rows show Σ of their categories. |
Jan: income 3000, food 500/−600, rent 1000/−1000, hold 200 → to_budget 1300; Feb: food 400/−100 → overspent −100, to_budget 1000, food available 300 (flagged in Jan: 200 and 1100); 2 categories assigned 500, 1000 → assigned_total 1500 |
| BUD-8 | The month before the first month has to_budget = 0, held = 0, every available = 0 and every flag false. |
first month, income 3000, assigned 1000 → to_budget 2000 |
| BUD-9 | Hold: held(m) (default 0) sets money aside for next month. Hold a → held(m) += clamp(a, −held(m), max(to_budget(m), 0)); reset → held(m) = 0. held(m) re-enters as income-like money in m+1 (BUD-7) and never resets on its own. |
to_budget 300, hold 500 → held 300, to_budget 0 |
| BUD-10 | to_budget(m) may be negative ("over-assigned"); it is shown as such and carries into m+1 unchanged. |
Jan to_budget −50, Feb nothing else → Feb −50 |
| BUD-11 | Copy last month: for each visible expense category with a cell in m−1, assigned(c, m) = assigned(c, m−1); per-category variant copies one. Hidden categories are skipped. Set to zero: every expense category's assigned(c, m) = 0, hidden included. Copy to later months: one category's assigned(c, m) copied to every later month through December of m's year. |
Dec food 400 → copy into Jan → 400; set zero on Jan → assigned_total 0 |
| BUD-12 | Average of N ∈ {3, 6, 12}: for each visible expense category, take up to N months immediately before min(m, current month), stopping before the category's first month with a cell or activity; k = months taken; assigned(c, m) = round_half_away(−Σ activity / k); k = 0 → 0. |
activity −100, −101, −102 over 3 months → 101 |
| BUD-13 | Move a > 0 from category X to Y in month m: assigned(X, m) −= a; assigned(Y, m) += a; no limit (X may go negative); to_budget unchanged. A line "moved a from X to Y" is appended to the month note. |
X 300 → move 400 to Y → X −100, Y +400 |
| BUD-14 | From to-budget into Y: a' = clamp(a, 0, max(to_budget(m), 0)), assigned(Y) += a'. From X into to-budget: assigned(X) −= a. |
to_budget 200, move 500 to food → food +200, to_budget 0 |
| BUD-15 | Cover overspending of Y (available(Y, m) < 0) from source S: need = −available(Y, m); got = min(need, available(S, m)) for a category, or min(need, to_budget(m)) for to-budget; nothing happens if got ≤ 0; assigned(S) −= got (unless S is to-budget), assigned(Y) += got. Fix over-assigned (to_budget(m) < 0) from X: r = min(−to_budget(m), max(available(X, m), 0)); assigned(X) −= r. |
Y −80, S available 50 → Y −30, S 0; to_budget −70, X available 50 → X assigned −50, to_budget −20 |
Out of scope
- Goal and template engines (target balances, percentage-of-income, schedule-funded goals).
- The source's second budget type (income vs expense tracking without envelopes).
- Automatic holding of income for the next month.
Decisions
Categories
Status: Draft · Prefix: CAT
Purpose
Who money goes to (payees) and what it is for (categories in groups), plus notes and tags.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| CAT-1 | Payee: id, name (trimmed, 1–200 chars), favorite (default false), learn_categories (default true, RULE-11), transfer_account?. Entering a payee by name reuses an existing payee whose name matches case-insensitively, else creates one. |
type "acme" with "Acme" existing → same payee |
| CAT-2 | Each account has exactly one transfer payee (transfer_account set), shown with the account's current name; it cannot be renamed, deleted or merged (system_payee). |
rename transfer payee → system_payee |
| CAT-3 | Merge payees P1…Pn into target T (not a transfer payee): transactions, rules and schedules pointing at any Pi point at T; Pi are deleted; with remember = true a pre-stage rule "imported payee one of [Pi names] → set payee T" is created or extended (RULE-10). |
merge "ACME INC" into "Acme" → rows show "Acme" |
| CAT-4 | Delete payee: its transactions keep their rows with payee empty; rules or schedules that name it → payee_in_use listing them. A payee with no transactions, rules or schedules is "unused" and the payee list offers to merge or delete it. |
delete payee in a rule → payee_in_use |
| CAT-5 | Category group: id, name (trimmed, 1–100 chars, unique case-insensitively among groups), is_income, hidden (default false), sort_order. Exactly one income group exists; it cannot be deleted or hidden. Income groups sort after expense groups. |
create second income group → income_group_exists |
| CAT-6 | Category: id, name (trimmed, 1–100 chars, unique case-insensitively within its group, else name_taken), group, hidden (default false), sort_order, note?. Its kind (income or expense) is its group's; moving it to a group of the other kind → kind_mismatch. New categories go first in their group unless placed. |
"Food" and "food" in one group → name_taken |
| CAT-7 | Hidden categories and groups are left out of pickers, copy-last-month and averages (BUD-11), but their money still counts in every total and report. Hiding a group hides its categories. | hide "gifts" with 3000 available → to-budget unchanged |
| CAT-8 | Delete category C with move_to D: all transactions, rules and schedules using C use D; for an expense C, each month's assigned of C is added to D's. move_to is required when C has transactions or any non-zero assigned (else move_to_required); D must be of the same kind (kind_mismatch) and not C. Delete group: deletes each of its categories as above into one move_to category outside the group of the same kind, then the group. |
delete food (assigned Jan 300) into home (200) → home Jan 500; delete "fun" group into "misc" → all moved |
| CAT-9 | Notes: plain text ≤ 10,000 chars attached to an account, a category, a category in one month, or a budget month; empty text removes the note. | note on Jan budget → shown only for Jan |
| CAT-10 | Tags: a #word (no spaces or #) inside transaction notes; ## writes a literal #. Tag: name (unique, case-sensitive), color?, description?, hidden. Renaming a tag rewrites it in every note; a name with space or # → invalid_tag. |
rename #trip → #holiday → notes updated |
Out of scope
- Payee locations, payee contact details, category icons.
- Nested categories deeper than group → category.
Decisions
- One income group: the to-budget formula (BUD-7) sums all income categories; the source summed only the first income group.
- Deleting a payee used by rules is refused rather than leaving rules pointing at nothing.
Files
Status: Draft · Prefix: FILE
Purpose
Budget files, the one currency each file uses, how money is stored and rounded, offline use, and what export, import and the data inventory cover.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| FILE-1 | A budget file holds all data of one budget: accounts, transactions, payees, categories, budget months, rules, schedules, reports, notes, tags and preferences. Fields: id (random, immutable), name (trimmed, 1–100 chars, unique case-insensitively among the person's files on that device or server), currency_code, created_at. One person may keep any number of files; exactly one is open at a time. Empty or taken name → invalid_name. |
create "Home" twice → second invalid_name |
| FILE-2 | Create: name defaults to "Budget", then "Budget 2", "Budget 3"… (first free). A new file has one income group income holding the categories income and starting_balances, one expense group expenses with no categories, no accounts and no transactions. |
create two files without names → "Budget", "Budget 2" |
| FILE-3 | Operations: list, create, open, close, rename, duplicate, delete. Duplicate copies all data under a new id and name " copy" (numbered if taken) and drops the sync link and encryption key (SYNC). Delete removes the local copy; deleting the server copy is a separate, explicit choice. |
duplicate synced file → copy has no sync link |
| FILE-4 | Local-first: data is stored on the device and every operation except sync (SYNC) and bank feeds (IMP-12) works with no network. Writes are durable before the operation reports success. | airplane mode → add transaction, reopen app → present |
| FILE-5 | Money is a signed integer count of the currency's minor unit; inflow > 0, outflow < 0. Allowed range |x| ≤ 2^51 − 1; outside → amount_out_of_range. All sums, differences and balances are exact integer arithmetic; no floating point is stored. |
0.1 + 0.2 entered as two amounts in USD → 10 + 20 = 30 |
| FILE-6 | currency_code: ISO 4217, chosen at create (default from device locale, else USD); minor_digits comes from ISO 4217 (0–3, e.g. USD 2, JPY 0, KWD 3). Changing the code is allowed only to a currency with the same minor_digits, else currency_change_blocked; stored integers never change. Display preferences (no effect on stored data): number format 1,000.00|1.000,00|1 000,00|1'000.00|1,00,000.00, symbol before/after, hide fraction (display only, values unchanged), first day of week, date display format. |
file in USD → change to EUR ok; to JPY → currency_change_blocked; hide fraction on, 1250 stored → shown "13"; stored stays 1250 |
| FILE-7 | One currency per file: every account and amount is in currency_code; no exchange rates, conversion or per-account currency. A person with money in two currencies keeps two files. |
manual: account form has no currency field |
| FILE-8 | Text → integer: the decimal value is scaled by 10^minor_digits with exact decimal arithmetic and rounded once, half away from zero. This is the only place a fraction of a minor unit can arise outside the averages and percentages defined in BUD and RULE, which round the same way. |
USD "12.345" → 1235; "-12.345" → -1235; JPY "99.5" → 100 |
| FILE-9 | Amount text parsing: optional sign or parentheses (negative); optional currency symbol and spaces ignored; the last . or , is the decimal mark when followed by 1–2 digits, or by more than 3 digits; followed by exactly 3 digits it is a thousands separator. Anything else → invalid_amount. |
"1,234" → 1234.00; "1,23" → 1.23; "(5.00)" → -5.00 |
| FILE-10 | Export all my data (@specfork/privacy:PRV-1), format @specfork/budget, version 1, covers one budget file and works offline; the manifest adds name and currency_code. One JSON Lines file per entity (accounts, transactions, payees, category_groups, categories, budget_cells, budget_months, rules, schedules, reports, notes, tags, preferences) holds every live row with all fields of this spec, plus transactions.csv (FILE-11). Deleted rows (sync tombstones, SYNC-4), sync state, encryption keys and bank-feed credentials are not included. |
3 accounts, 40 transactions → manifest counts 3 and 40; zip has no key material |
| FILE-11 | transactions.csv: header date,account,payee,category_group,category,amount,notes,cleared,reconciled,split_of; amount as a decimal string with minor_digits places and . mark; split children carry the parent id in split_of, parents have empty category. Cells starting with =, +, -, @, tab or CR are prefixed with '. |
payee "=SUM(A1)" → cell '=SUM(A1) |
| FILE-12 | Import (@specfork/privacy:PRV-5) of an export ZIP creates a new budget file (new id, name made unique per FILE-1) whose data equals the exported data; another format or an incompatible version → unsupported_export. |
export, import → same balances, budget and rules |
| FILE-13 | Inventory (@specfork/privacy:PRV-9): on the device, the budget files of FILE-1, kept until the person deletes them; on the sync server, account data, snapshots and change messages (encrypted under SYNC-10), kept until deleted or reset (SYNC-8, SYNC-9). Third party: a bank-feed adapter per linked account, which receives only external_id and since and is called only while the account is linked (IMP-12, @specfork/privacy:PRV-10). |
unlink feed → no further adapter calls |
Out of scope
- Importing whole budgets from other budgeting apps; automatic local backups and undo history.
- Multi-currency within one file, exchange rates, investment prices.
Decisions
- One currency per file instead of per-account currencies: the source has only a file-level currency setting, and envelope arithmetic across currencies needs rates the spec would have to invent.
- Rounding is half away from zero everywhere; the source rounded halves toward positive infinity, which treats outflows and inflows differently.
- The export is
@specfork/privacyJSON Lines plus CSV rather than the source's raw database file, so it is readable without the app. - Exports are written on the device, not served by a server:
@specfork/privacy:PRV-11local mode applies; the optional sync server uses server mode.
Import
Status: Draft · Prefix: IMP
Purpose
Bring bank data in from statement files or a bank feed without creating duplicates.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| IMP-1 | File import into one account; type by extension: .csv, .tsv → CSV; .ofx, .qfx → OFX; .qif → QIF; else unsupported_file. Max 20 MB and 50,000 rows, else file_too_large. |
.pdf → unsupported_file |
| IMP-2 | CSV options: delimiter , (default; tab for .tsv)|;|||tab|~; has_header (default true); skip_start, skip_end lines (0–1000; their sum ≥ line count → nothing_to_import); encoding auto (BOM → UTF-16LE/BE, else UTF-8) or utf-8, utf-16le, utf-16be, windows-1252, windows-1250, iso-8859-2. Quoted fields with "; blank lines skipped; cells trimmed. |
; file with header → columns split |
| IMP-3 | CSV mapping: columns for date (required), payee, notes, category (exact category name, else empty) and amount by one of: amount; inflow + outflow (each read as absolute value; outflow wins when both set); amount + direction column with an out_value text meaning outflow. Options flip_sign (default false), multiplier (decimal > 0, default 1, applied before FILE-8 rounding). Missing date or amount mapping → mapping_incomplete. |
inflow "", outflow "12.50" → −1250 |
| IMP-4 | CSV date_format: yyyy mm dd|yy mm dd|mm dd yyyy|mm dd yy|dd mm yyyy|dd mm yy; any non-digit separator or none (fixed positions); English month names and 3-letter abbreviations accepted; 2-digit years are 20yy. Default: first format that parses row 1. A row whose date or amount does not parse aborts the import with invalid_row and the row number. |
dd mm yy, "03/04/26" → 2026-04-03 |
| IMP-5 | OFX/QFX (SGML or XML; bank, card and investment cash statements): date = first 8 digits of the posted date; amount from the transaction amount (FILE-9); imported_id = the institution's transaction id; payee = name, notes = memo with markup removed. Empty name with memo_as_payee (default true) → memo becomes payee and notes are empty; swap_payee_memo (default false) swaps them. |
empty name, memo "COFFEE 12" → payee "Coffee 12" |
| IMP-6 | QIF: must start with a !Type: line, else invalid_file; records end with ^; codes date, amount, payee, memo, cleared flag, category (group:category → category), number; split lines (category, memo, amount) become split children (TXN-6). Dates use the CSV date formats (IMP-4). Records without date or amount are skipped and counted. |
QIF record with 2 split lines → 1 parent, 2 children |
| IMP-7 | Optional start_date: rows dated earlier are dropped before matching. |
start 2026-03-01 → February rows ignored |
| IMP-8 | Per-file options: cleared for new rows (default true), import_notes (default true), payee_case: as_is|title_case (default title_case). The last options used per account (mapping, delimiter, encoding, header, skips, sign, format) are remembered and offered next time. |
reopen import for same account → previous mapping preselected |
| IMP-9 | Pipeline for every incoming row: trim payee text → imported_payee; apply payee_case; resolve payee per CAT-1; run rules (RULE-9); match (IMP-10); insert or merge; link schedules (SCH-6). The whole import is one atomic change. Preview runs the same pipeline without writing and returns each row as new, matched (with the existing row) or skipped. |
preview → nothing saved, counts shown |
| IMP-10 | Matching in the target account: (1) same imported_id (deleted rows included unless reimport_deleted = true); (2) else candidates with equal amount and date within ±7 days, not already matched in this import, and for files not carrying a different imported_id; prefer same payee, then nearest date, then candidates without imported_id. |
existing −4500 on 3 Mar, import −4500 on 5 Mar → matched |
| IMP-11 | Merge on match: reconciled rows are left unchanged; else set imported_id and imported_payee, fill payee, category, notes only if empty, cleared = existing OR incoming; date changes only with update_dates (default false). |
existing uncleared, import cleared → cleared |
| IMP-12 | Bank feed contract (provider-neutral adapter): list_accounts() → [{external_id, name, mask?, currency, balance}]; fetch(external_id, since) → {balance, transactions: [{external_id?, booked: bool, date, amount (minor units), payee_name, notes?, raw}]}; errors auth_required, rate_limited, account_missing, unavailable. A feed currency ≠ the file currency → currency_mismatch, account not linked. Link: an account gets feed_link {adapter, external_id, last_sync_at, status: ok|auth_required|rate_limited|account_missing|unavailable}; one external account links to at most one account. Unlink clears feed_link and keeps all transactions. |
adapter stub with 2 accounts → link dialog lists 2; unlink → rows kept, no sync |
| IMP-13 | Sync of a linked account: since = last_sync_at − 7 days, or on first sync max(today − 90 days, custom start); rows go through IMP-9 with cleared = booked; pending_import (default true) false → non-booked rows skipped. First sync adds an opening balance (ACC-3 rules) = feed balance − Σ fetched amounts, dated at the oldest fetched row. Pending rows: a pending row is replaced when the feed returns a booked row with its external_id; a pending, unreconciled row the person has not edited that is absent from a later fetch covering its date is deleted. |
balance 1000, fetched −200, +50 → opening 1150; pending −30 gone from feed → removed |
Out of scope
- Named bank-sync vendors and their credential flows; the adapter owns credentials and they never enter the budget file.
- CAMT, MT940 and PDF statements; investment trades from OFX.
Decisions
- QIF splits import as splits; the source dropped them.
- Stale pending rows are removed; the source left them.
- Feed start window 90 days (source: 89).
Reports
Status: Draft · Prefix: RPT
Purpose
Read-only summaries of the person's own data: where money went, what came in and out, and what they are worth over time.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| RPT-1 | Every report takes a date range (default last 6 whole months to today), interval: day|week|month (default)|year, and optional filters using RULE-2 conditions; weeks start on the file's first day of week. All sums use live, non-parent transactions. Any report table exports to CSV with the same rows and columns; amounts as decimals per FILE-11. |
range Jan–Jun, monthly → 6 columns; export spending → CSV row per category |
| RPT-2 | Net worth: per interval end e, net_worth(e) = Σ amount of transactions in all accounts (on and off budget, open and closed) dated ≤ e, also per account; filterable by account. Transfers between included accounts cancel out. |
on 700 + off 5000 at Jan 31 → 5700 |
| RPT-3 | Cash flow (on-budget accounts): per interval income = Σ positive amounts and expense = Σ negative amounts, both excluding transfers; transfers = Σ transfers to or from off-budget accounts, shown apart; balance at interval end. End date is capped at today. |
+3000, −1200, −500 to off-budget → income 3000, expense −1200, transfers −500 |
| RPT-4 | Spending (on-budget accounts, expense categories): per category and group, Σ activity (BUD-3) per interval, plus "uncategorised" for rows without category; compares a chosen month with the average of the previous 3, 6 or 12 months and with that month's assigned. |
food Jan −600, assigned 500 → over by 100 |
| RPT-5 | Custom report: group_by: category|category_group|payee|account|tag|interval; measure: payments (Σ amounts < 0)|deposits (Σ > 0)|net|assigned; chart: table|bar|line|area|donut; show_off_budget (default false), show_hidden (default true), show_uncategorised (default true); sort by amount_asc|amount_desc|name. Uncategorised buckets in custom reports: transfer (transfer rows), off_budget (rows in off-budget accounts), other (no category). |
group by payee, payments, Jan → one row per payee; off-budget row shown under off_budget |
| RPT-6 | Saved reports: id, name (unique, 1–100 chars), all RPT-5 settings and filters, date_range fixed or relative (this_month, last_n_months N 1–60, year_to_date, last_year); create, rename, update, delete. A dashboard holds report tiles in a grid. |
save relative "last 3 months" → next month the range shifts by one |
| RPT-7 | Reports show only arithmetic over the person's data: no advice, scores, product offers or predictions of returns. | manual: no recommendation text anywhere |
Out of scope
- Forecasts, investment performance, tax reports.
- Shared or public report links.
Decisions
- Net worth includes closed accounts so past months stay correct after closing.
Rules
Status: Draft · Prefix: RULE
Purpose
If-then rules that clean up payees, set categories, split and annotate transactions automatically.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| RULE-1 | Rule: id, stage: pre|default|post (default default), match: all|any (default all), conditions (≥ 1), actions (≥ 1), created_at. Empty conditions or actions, or an unknown stage → invalid_rule. |
rule with no conditions → invalid_rule |
| RULE-2 | Condition fields and operators: text imported_payee, notes: is, is_not, contains, not_contains, matches (regex), one_of, not_one_of (not for notes), has_tags; reference payee, category, category_group, account: is, is_not, one_of, not_one_of, plus account on_budget|off_budget; date: is, is_approx, before, on_or_before, after, on_or_after; amount, amount_in, amount_out: is, is_approx, between, gt, gte, lt, lte; cleared, reconciled, is_transfer, is_split_parent: is. Other pairs → invalid_rule. |
notes one_of → invalid_rule |
| RULE-3 | Text comparisons ignore case. contains, matches, has_tags need a non-empty value. An invalid regex → invalid_rule on save. An empty or missing text field compares as "". A missing reference matches is nothing. |
rule notes contains "COFFEE", notes "coffee shop" → match |
| RULE-4 | amount is_approx v matches |amount − v| ≤ round_half_away(|v| × 0.075). date is_approx d matches within ±2 days, inclusive. between {a, b} is inclusive and order-free. date is accepts YYYY-MM-DD, YYYY-MM (whole month) or YYYY (whole year). |
approx −10000 → −10750..−9250 match; −10751 no |
| RULE-5 | amount_in considers only amounts > 0; amount_out only amounts < 0 and compares their absolute value. |
amount_out gt 5000, amount −6000 → match; +6000 → no |
| RULE-6 | Order: all pre rules, then default, then post. Within a stage, ascending score, ties by created_at. Score = Σ per condition (is/is_not 10, one_of/not_one_of 9, is_approx/between 5, gt/gte/lt/lte/date bounds 1, others 0), doubled when every condition is is, is_not, is_approx, one_of or not_one_of. Actions apply in sequence, so a later (more specific) rule overwrites an earlier one; each rule sees the result of earlier rules. |
"payee is A" (score 20) and "notes contains x" (0) both set category → payee rule wins |
| RULE-7 | Actions: set field (payee, payee_name creating the payee per CAT-1, category, account, date, amount, notes, cleared); prepend_notes, append_notes (no separator added); link_schedule; split (RULE-8); delete_transaction. |
append " #work" to "lunch" → "lunch #work" |
| RULE-8 | Split action: a list of parts, each fixed (amount), percent (0–100) or remainder, with optional set actions per part; skipped on split children. Fixed parts first; rest = total − Σ fixed; each percent part = round_half_away(rest × p / 100); rest2 = rest − Σ percent parts; remainder parts get trunc(rest2 / k) each and the last gets what is left. |
total −10000: fixed −2000, 50 %, 2 remainders → −2000, −4000, −2000, −2000 |
| RULE-9 | When rules run: on every imported or fed transaction before duplicate matching (IMP-9); on a new hand-entered transaction, filling only fields the person left empty (notes actions still apply); on schedule posting; on demand for selected existing transactions. Saving or editing a rule never changes existing transactions by itself; the editor shows matching transactions and can apply the rule's actions to chosen ones. A transaction linked to a schedule (SCH-6) keeps getting that schedule's actions even when its amount or date later drifts outside the schedule's match. | save rule → past rows unchanged until "apply"; schedule amount drifts 10 % → still categorised |
| RULE-10 | Payee clean-up rule: stage pre, condition imported_payee one_of [names], action set payee T; created or extended by payee merge with remember (CAT-3). One such rule per target payee. |
merge twice into T → one rule, names unioned |
| RULE-11 | Category learning (setting learn_categories, default on; payee flag CAT-1): after a transaction with a payee and category is saved, take the payee's 5 latest non-parent transactions in open accounts within 180 days before to 180 days after the saved one; if one category appears ≥ 3 times, set it on the default rule "payee is P → set category" (create it if missing; leave alone if a one_of rule already covers P). |
3 of last 5 "grocer" rows → food → rule "payee is grocer → food" exists |
Out of scope
- Templated or formula-based action values.
- Rules on budget cells or accounts.
Decisions
- Invalid regexes are refused on save; the source accepted them and never matched.
- Rules do not rerun on save, as in the source; applying them to history is explicit.
Schedules
Status: Draft · Prefix: SCH
Purpose
Bills, paydays and other recurring transactions: when they are due, whether they happened, and optionally posting them automatically.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| SCH-1 | Schedule: id, name? (trimmed, ≤ 100 chars, unique among schedules when set, else name_taken), account?, payee?, amount with amount_op: is|is_approx|between (default is_approx), date_op: is|is_approx (default is_approx), recurrence or a single date, auto_post (default false), completed (default false), next_date, actions (set category, notes, split per RULE-7). A date is required, else invalid_schedule. |
schedule without date → invalid_schedule |
| SCH-2 | Recurrence: start date, frequency: daily|weekly|monthly|yearly, interval 1–999 (default 1), end: never (default)|after N (1–999 occurrences)|until date. |
weekly, interval 2, start Mon 5 Jan → 5, 19 Jan, 2 Feb |
| SCH-3 | Monthly patterns (optional, replace the start's day): list of day n (1–31 or −1 = last day) and weekday nth (mon…sun, nth 1–5 or −1 = last). A day n past the month's end falls on its last day. |
monthly day 31 → 31 Jan, 28 Feb, 31 Mar (2027) |
| SCH-4 | Weekend: weekend_shift: none (default)|before|after; a Saturday or Sunday occurrence moves to the previous Friday or next Monday. |
1st on Saturday, after → Monday the 3rd |
| SCH-5 | next_date = first occurrence (after shift) on or after today that is not yet paid or skipped; when the recurrence has ended, the schedule becomes completed. A one-off schedule is completed once paid. |
one-off paid → completed |
| SCH-6 | Matching: a new, imported or fed transaction matches a schedule when account (if set) and payee (if set) are equal, the amount passes amount_op (RULE-4), and its date is within ±2 days of an unpaid occurrence (is_approx) or on it (is). A match sets schedule_id, applies the schedule's actions (filling empty fields) and marks that occurrence paid. |
due 1 Mar −120000, approx; import 2 Mar −121000 → linked |
| SCH-7 | Status, first that holds: completed; paid (current occurrence linked); due (next_date = today); upcoming (within the upcoming window); missed (next_date < today); else scheduled. |
next_date yesterday, unpaid → missed |
| SCH-8 | Upcoming window: file setting, default 7 days; choices 1, 7, 14 days, 1 month, rest of current month, or custom N days/weeks/months/years (N 1–99). Upcoming list: occurrences of all non-completed schedules from today to the end of the window; the account view shows them as uncommitted rows that are not in any balance. | window 14, next_date in 10 days → upcoming; rent due in 3 days → shown, balance unchanged |
| SCH-9 | Auto-post: once per day (on open and at the first change of date while open), for each auto_post schedule with an unpaid occurrence ≤ today, create the transaction (account, payee, amount (for between, the lower bound by absolute value), actions, cleared = false, dated the occurrence) and mark it paid; repeat for every missed occurrence. Needs account, else not auto-posted and flagged. Skip next: marks the current occurrence skipped and advances next_date. Post now: creates the transaction for next_date dated today and marks it paid. |
monthly 1st, app closed Jan–Mar, open 5 Mar → 3 rows (1 Jan, 1 Feb, 1 Mar); skip on 1 Feb → next_date 1 Mar |
Out of scope
- Balance forecasting from schedules; schedule-funded budget goals.
- Business-day calendars beyond weekends.
Decisions
- A day past the month's end clamps to the last day; the source skipped those months.
- Paid occurrences are tracked per occurrence; the source inferred them from linked dates.
Sync
Status: Draft · Prefix: SYNC
Purpose
Optional: keep one person's budget files in step across their devices through a self-hostable server, without the server being able to read the data when encryption is on.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| SYNC-1 | Sync is opt-in per budget file; a file that is never synced needs no server or account (FILE-4). Offline changes queue locally and are sent on the next sync. | edit offline on 2 devices, reconnect → both changes on both |
| SYNC-2 | The server uses @specfork/auth in single_owner mode (@specfork/auth:AUTH-18) with username-only sign-in allowed (@specfork/auth:AUTH-19); more people only as admin-created users (@specfork/auth:AUTH-22). Each server user sees and syncs only files they uploaded; there is no sharing, team or role between users. |
user B lists files → none of user A's |
| SYNC-3 | Every local write is recorded as change messages {table, row_id, field, value, timestamp}, one per changed field; value is null, a number or a string. Derived values (balances, budget figures, report results) are never synced; every device recomputes them. |
change payee and notes → 2 messages |
| SYNC-4 | Deletes sync as a change to the row's deleted field; rows are never removed outside SYNC-9. |
delete on device A → hidden on device B |
| SYNC-5 | timestamp is a hybrid logical clock: UTC time to the millisecond, a counter 0–65535 and a 16-hex-char device id, ordered in that sequence. A remote time more than 5 minutes ahead of the local clock → clock_drift; counter past 65535 → clock_overflow; neither message is applied. |
remote stamp +6 min → clock_drift |
| SYNC-6 | Conflicts: per (table, row_id, field) the message with the greatest timestamp wins, on every device, regardless of arrival order. A message with an already-seen timestamp is ignored; an older one is stored but not applied. |
A sets notes "x" at t1, B "y" at t2 > t1 → both show "y" |
| SYNC-7 | Exchange: the client sends its unsent messages and its last sync time; the server stores them (idempotent by timestamp) and returns all messages of that file newer than that time from other devices, plus a digest of all stored timestamps bucketed by minute. If the client's digest differs, it resends and re-requests from the first differing minute. Sync errors: file_needs_upload (server has no snapshot), file_has_reset, file_has_new_key (key changed elsewhere; ask for the new passphrase), key_mismatch, unauthorized (sign in again). None of them changes local data. |
lost response → next sync converges, no duplicates; other device changed key → file_has_new_key |
| SYNC-8 | Upload stores a full snapshot of the file with its group_id (created on first upload). A new device downloads the latest snapshot, then syncs messages after it. The client re-uploads a snapshot at most every 7 days. Server file operations for the signed-in user: list (name, group_id, encrypted flag, last upload), download, rename, delete (removes snapshot and messages). |
sign in on new laptop → full file appears; delete on server → local copy stays |
| SYNC-9 | Reset sync: purges deleted rows and the local message log, gives the file a new group_id, uploads a new snapshot and empties the server's log for the old group. Other devices then get file_has_reset and must download again. |
reset on A → B sync → file_has_reset |
| SYNC-10 | End-to-end encryption (optional, per file): key from a passphrase with Argon2id (64 MiB, 3 passes) and a random 32-byte salt; snapshots and every message value set are encrypted with AES-256-GCM and a random 12-byte nonce. The server keeps key_id, salt and one encrypted check value only. Wrong passphrase → key_mismatch. Turning it on or changing it performs SYNC-9. |
wrong passphrase on new device → key_mismatch, nothing decrypted |
| SYNC-11 | Delete all my data (@specfork/privacy:PRV-6) and account deletion (@specfork/privacy:PRV-7) on the server remove every snapshot and message the user uploaded; copies on devices stay (FILE-3). |
delete all → server file list empty, local file still opens |
Out of scope
- Sharing a budget between people, roles, admin user management.
- Peer-to-peer sync without a server.
Decisions
- Sync kept in scope because the product is local-first with optional sync; so
@specfork/authis required, used only by the server, insingle_ownermode. - Argon2id for the key; the source used PBKDF2-SHA512 with 10,000 iterations, below current guidance.
- The source's multi-user server features (roles, per-file access) are dropped: one person per file.
Transactions
Status: Draft · Prefix: TXN
Purpose
Money moving in or out of an account, split across categories, or moving between two accounts.
Requirements
| ID | Requirement | Verified by |
|---|---|---|
| TXN-1 | Transaction: id, account (required), date (YYYY-MM-DD, 1900-01-01–2099-12-31, else invalid_date), amount (FILE-5, required), payee?, category?, notes? (≤ 4,000 chars), cleared, reconciled (default false), parent_id?, transfer_id?, schedule_id?, imported_id?, imported_payee?, is_opening_balance (default false), split_error?, sort_order, created_at. |
date 2100-01-01 → invalid_date |
| TXN-2 | cleared default: false for transactions entered by hand or posted by a schedule; imports use the import's setting (IMP-8). |
add by hand → cleared false |
| TXN-3 | category is always empty on transactions in off-budget accounts, on split parents and on transfers between two accounts with the same on_budget; a value sent there is dropped. Only categories of the expense or income kind may be set (CAT). |
off-budget row with category "food" → saved without category |
| TXN-4 | List order within an account: date descending, then opening-balance rows last within their date, then sort_order descending (newer entries first), then id. Split children follow their parent in their own sort_order. The person may move a row only among rows with the same account and date. |
two rows same date → later-entered listed first |
| TXN-5 | Delete is soft: the row is marked deleted, hidden everywhere and excluded from every sum; it syncs as a field change (SYNC-4). Deleted rows are purged only by sync reset (SYNC-9). | delete −50 → account balance +50 |
| TXN-6 | Split: a parent with ≥ 1 children (parent_id = parent). Children have their own amount, payee, category, notes, and take account, date, cleared, reconciled from the parent (changing the parent's changes theirs). The parent's own category is empty; balances and budgets count children only, never the parent (ACC-4, BUD-3). |
parent −100: food −60, home −40 → food activity −60 |
| TXN-7 | Split mismatch: split_error = parent amount − Σ children amount; saved even when ≠ 0 and flagged on the parent until fixed. Converting a row into a split adds one child with amount = parent amount unless that is 0. |
parent −100, children −60, −30 → split_error −10 |
| TXN-8 | Distribute: spreads split_error over the children whose amount is 0: each gets trunc(split_error / n), then the leftover |r| minor units go one each, sign of split_error, to the first such children in list order. |
error −100, 3 zero children → −34, −33, −33 |
| TXN-9 | Deleting a parent deletes its children. Deleting a child that leaves no children turns the parent into a normal transaction keeping its amount. |
delete only child → parent normal, no split_error |
| TXN-10 | Transfer: setting payee to the transfer payee of account B on a row in account A creates a counterpart in B: amount negated, same date and notes, payee = A's transfer payee, cleared = false, both rows linked by transfer_id. Transfer to the same account → invalid_transfer. A split parent cannot be a transfer; its children can. |
A −500 to B → B +500, linked |
| TXN-11 | Editing either side of a transfer updates the other: amount (negated), date, notes, and the target account when the payee changes to another transfer payee. Category follows TXN-3: kept only when exactly one side is on-budget, and then only on the on-budget side. |
change A side to −600 → B side +600 |
| TXN-12 | Removing the transfer payee from one side, or deleting one side, deletes the counterpart; if the counterpart is a split child it is kept with payee and transfer_id cleared. |
delete A side → B side gone |
| TXN-13 | Bulk edit: set date, account, payee, category, notes (replace, prepend or append) or cleared on selected rows in one atomic change; each row still obeys TXN-3 and ACC-11. Filter transactions by any condition of RULE-2 combined with all/any, plus free-text search over payee, category, notes and amount text; results show count and Σ amount. |
select 3 → set category food → 3 changed; filter payee is "grocer", Jan → 4 rows, Σ −23000 |
Out of scope
- Attachments and receipts, investment lots, pending-hold amounts.
Decisions
- Transfer dates always stay in step; the source kept them in step only behind a preference.
- Manual and scheduled rows start uncleared so reconciliation (ACC-9) reflects what the bank has not yet shown; the source defaulted every row to cleared.
- A mismatched split is saved and flagged, as in the source, so the person can finish it later.
From Specfork/budget at eb8ed63, licence CC-BY-4.0.