Skip to content

Commit ead7a93

Browse files
committed
Fix budget claiming money the accounts do not hold after excluding a transaction
1 parent 6b0c3e3 commit ead7a93

3 files changed

Lines changed: 14 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
### 3.15.2: 2026-07-11
2+
3+
* Fix the budget claiming more money to allocate than the accounts actually hold once a transaction was excluded: exclusion pulled the cost out of both the category and Ready to Assign, so money that had really left the account was absorbed nowhere. `budget_excluded` now only hides a transaction from spending reports (cash flow, trends, heatmap, burn rate, today's spent, month expenses); the category it is filed under still absorbs the cost and Ready to Assign stays untouched, so the accounts and the budget reconcile again
4+
15
### 3.15.1: 2026-07-11
26

37
* Keep the daily budget on real cash balances for excluded transactions instead of adding the excluded amount back: excluding a transaction still drops it from spending, category and cash-flow reporting and holds Ready to Assign steady, but no longer inflates the spendable pool (which double-counted money that moved in from an excluded account)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dough",
3-
"version": "3.15.1",
3+
"version": "3.15.2",
44
"private": true,
55
"scripts": {
66
"dev": "next dev -H 0.0.0.0 -p 3030",

src/lib/budget-math.ts

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,16 @@ function ym(monthYM: string, offset: number): string {
1717
// (tracking) account — investing, debt paydown — IS activity, and so are reconciliation/balance
1818
// adjustments categorised to it, matching YNAB. The counterparty account is the name after
1919
// "Transfer : " (11 chars), matched to ynab_accounts; an unknown counterparty counts as activity.
20-
// A transaction flagged budget_excluded is invisible to every budget figure (activity, available,
21-
// Ready to Assign, cash flow, income, spending). Its money still moved the real account balance, so
22-
// balance/reconcile reads keep it; the budgetable-balance reconciliation (budgetExcludedNetByAccount)
23-
// nets it back out so Ready to Assign and the daily budget stay consistent. Appended to every
24-
// spending/income predicate below and inlined into the routes that build their own predicate.
20+
// A transaction flagged budget_excluded is hidden from SPENDING REPORTS (cash flow, trends, heatmap,
21+
// burn rate, today's spent, the month's expense totals) so a one-off does not distort the analysis.
22+
// It deliberately does NOT change the budget's accounting: its money really left the account, so the
23+
// category it is filed under still absorbs it and Ready to Assign is untouched. Excluding it from
24+
// category activity as well would leave the cost absorbed nowhere, and the budget would claim money
25+
// the accounts do not hold. Applied only to the reporting queries, never to activity/available/RTA.
2526
export const NOT_BUDGET_EXCLUDED = "COALESCE(budget_excluded, 0) = 0";
2627

2728
export const CATEGORY_ACTIVITY_PREDICATE =
28-
"(payee NOT LIKE 'Transfer%' OR EXISTS (SELECT 1 FROM ynab_accounts a WHERE a.name = SUBSTR(payee, 12) AND a.on_budget = 0)) AND " +
29-
NOT_BUDGET_EXCLUDED;
29+
"(payee NOT LIKE 'Transfer%' OR EXISTS (SELECT 1 FROM ynab_accounts a WHERE a.name = SUBSTR(payee, 12) AND a.on_budget = 0))";
3030

3131
// Opening balance anchor for a category: the carry-in available as of anchor_month, seeded
3232
// from YNAB at cutover (see seedOpeningBalancesFromYnab). When present, the carryover walk
@@ -54,7 +54,7 @@ export function incomeInflowForMonth(db: ReturnType<typeof getDb>, month: string
5454
const end = `${month}-${String(new Date(yy, mm, 0).getDate()).padStart(2, "0")}`;
5555
return (db
5656
.prepare(
57-
"SELECT COALESCE(SUM(amount), 0) AS v FROM transactions WHERE amount > 0 AND (category = 'Inflow: Ready to Assign' OR category LIKE 'Inflow%') AND date >= ? AND date <= ? AND " + NOT_BUDGET_EXCLUDED
57+
"SELECT COALESCE(SUM(amount), 0) AS v FROM transactions WHERE amount > 0 AND (category = 'Inflow: Ready to Assign' OR category LIKE 'Inflow%') AND date >= ? AND date <= ?"
5858
)
5959
.get(start, end) as { v: number }).v || 0;
6060
}
@@ -235,15 +235,8 @@ export function monthBudgetNumbers(
235235
// in any category. Reconciling against balances surfaces that money as assignable. Past and future
236236
// months keep the carry-forward, since account balances are only meaningful for "now".
237237
if (inLocalEra && month === localDateIso().slice(0, 7)) {
238-
// Budgetable on-budget balance = real balance minus the net of budget-excluded transactions on
239-
// on-budget accounts. An excluded outflow dropped the real balance but was pulled out of category
240-
// activity, so without this its cost would silently fall onto Ready to Assign (breaking the golden
241-
// equation). Netting it back keeps RTA and every category available consistent.
242238
const onBudget = (db.prepare("SELECT COALESCE(SUM(balance), 0) AS v FROM ynab_accounts WHERE on_budget = 1 AND closed = 0").get() as { v: number }).v || 0;
243-
const excludedNet = (db.prepare(
244-
"SELECT COALESCE(SUM(t.amount), 0) AS v FROM transactions t JOIN ynab_accounts a ON a.id = t.account_id WHERE a.on_budget = 1 AND a.closed = 0 AND COALESCE(t.budget_excluded, 0) = 1"
245-
).get() as { v: number }).v || 0;
246-
readyToAssign = round(onBudget - excludedNet - sumCategoryAvailable(db, month) - futureCommitted);
239+
readyToAssign = round(onBudget - sumCategoryAvailable(db, month) - futureCommitted);
247240
}
248241

249242
return { income, readyToAssign };

0 commit comments

Comments
 (0)