Specfork

@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.

personalfinance Version 0.1.1 113 requirements Licence CC-BY-4.0 Specfork/budget

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_budget is 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

  • income sums every income category; the source used only the first income group (moot with one group, CAT-5).
  • Month range is computed from live transactions; the source also counted deleted ones.
  • Averages round half away from zero (FILE-8).

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/privacy JSON 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-11 local 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/auth is required, used only by the server, in single_owner mode.
  • 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.