Skip to content

Commit 7065ed9

Browse files
committed
Add per-transaction exclude-from-budget flag across all budget math
1 parent cc2ffa4 commit 7065ed9

19 files changed

Lines changed: 128 additions & 44 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
### 3.15.0: 2026-07-11
2+
3+
* Add a per-transaction "exclude from budget" toggle in the transaction editor: an excluded row drops out of every budget figure (daily budget, category activity and available, Ready to Assign, cash flow, income, spending trends) but keeps affecting the real account balance and staying in the ledger, and shows an "Ei budjetissa" badge
4+
* Net excluded transactions out of the budgetable balance so Ready to Assign and the daily budget stay consistent while the real account balance still reflects the money that moved
5+
16
### 3.14.2: 2026-07-11
27

38
* Fix every transfer opening with an empty Vastatili ("Ei toista tiliä"): the edit dialog always reset the counterpart to blank, so a paired transfer looked counterpart-less and saving it stripped the counterpart off; the dialog now pre-selects the real counterpart account parsed from the transfer's "Transfer : <account>" leg

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.14.2",
3+
"version": "3.15.0",
44
"private": true,
55
"scripts": {
66
"dev": "next dev -H 0.0.0.0 -p 3030",

src/app/(app)/dashboard/page.tsx

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -241,11 +241,13 @@ export default function DashboardPage() {
241241
// Real income from YNAB month budget (matches YNAB's own reports)
242242
const realIncome = data.monthBudget.income;
243243

244-
// Available = total checking + savings accounts (excluding budget-excluded accounts)
244+
// Available = budgetable checking + savings (excluding budget-excluded accounts). Each account's
245+
// balance is netted against its budget-excluded transactions, so an excluded outflow does not shrink
246+
// the spendable pool the daily budget divides up (the money still shows in the real account balance).
245247
const availableBalance = Math.round(
246248
data.summary.accounts
247249
.filter((a) => (a.type === "checking" || a.type === "savings") && !excludedAccountIds.includes(a.id))
248-
.reduce((s, a) => s + a.balance, 0) * 100
250+
.reduce((s, a) => s + a.balance - (a.budgetExcludedNet || 0), 0) * 100
249251
) / 100;
250252

251253
// Upcoming income = active, unmatched income sources with expected_day still ahead
@@ -281,10 +283,10 @@ export default function DashboardPage() {
281283
return false;
282284
};
283285
const todaySpentAll = data.transactions
284-
.filter((t) => t.date === todayStr && t.amount < 0 && !isTransfer(t.payee, t.category) && !isFixedCost(t.payee, t.category))
286+
.filter((t) => t.date === todayStr && t.amount < 0 && !t.excluded && !isTransfer(t.payee, t.category) && !isFixedCost(t.payee, t.category))
285287
.reduce((s, t) => s + Math.abs(t.amount), 0);
286288
const todaySpentPersonal = data.transactions
287-
.filter((t) => t.date === todayStr && t.amount < 0 && !isTransfer(t.payee, t.category) && !isFixedCost(t.payee, t.category)
289+
.filter((t) => t.date === todayStr && t.amount < 0 && !t.excluded && !isTransfer(t.payee, t.category) && !isFixedCost(t.payee, t.category)
288290
&& (linkedAccountIds.length === 0 || linkedAccountIds.includes(t.account_id || "")))
289291
.reduce((s, t) => s + Math.abs(t.amount), 0);
290292

@@ -401,7 +403,7 @@ export default function DashboardPage() {
401403
// Burn rate = average daily real spending this month
402404
const daysPassed = now.getDate();
403405
const realSpendingTotal = data.transactions
404-
.filter((t) => t.amount < 0 && !isTransfer(t.payee, t.category))
406+
.filter((t) => t.amount < 0 && !t.excluded && !isTransfer(t.payee, t.category))
405407
.reduce((s, t) => s + Math.abs(t.amount), 0);
406408
const dailyBurnRate = daysPassed > 0 ? Math.round((realSpendingTotal / daysPassed) * 100) / 100 : 0;
407409

@@ -426,7 +428,7 @@ export default function DashboardPage() {
426428
data.transactions
427429
.filter((t) => {
428430
const day = parseInt(t.date.split("-")[2], 10);
429-
return day >= start && day <= end && t.amount < 0 && !isTransfer(t.payee, t.category);
431+
return day >= start && day <= end && t.amount < 0 && !t.excluded && !isTransfer(t.payee, t.category);
430432
})
431433
.reduce((s, t) => s + Math.abs(t.amount), 0);
432434

@@ -440,7 +442,7 @@ export default function DashboardPage() {
440442

441443
// Personal spending share: configured % or calculated from actual spending ratio
442444
const personalMonthSpend = data.transactions
443-
.filter((t) => t.amount < 0 && !isTransfer(t.payee, t.category)
445+
.filter((t) => t.amount < 0 && !t.excluded && !isTransfer(t.payee, t.category)
444446
&& (linkedAccountIds.length === 0 || linkedAccountIds.includes(t.account_id || "")))
445447
.reduce((s, t) => s + Math.abs(t.amount), 0);
446448
const calculatedShare = realSpendingTotal > 0 ? personalMonthSpend / realSpendingTotal : 0.5;
@@ -451,7 +453,7 @@ export default function DashboardPage() {
451453
// Build spending chart data from transactions (exclude transfers)
452454
const spendingByDay: Record<string, number> = {};
453455
const sortedTx = [...data.transactions]
454-
.filter((t) => t.amount < 0 && !isTransfer(t.payee, t.category))
456+
.filter((t) => t.amount < 0 && !t.excluded && !isTransfer(t.payee, t.category))
455457
.sort((a, b) => a.date.localeCompare(b.date));
456458

457459
let cumulative = 0;
@@ -474,7 +476,7 @@ export default function DashboardPage() {
474476
discretionaryByDay[day] = Math.round(discCumulative);
475477
}
476478
const discretionarySpendingTrue = data.transactions
477-
.filter((t) => t.amount < 0 && !isTransfer(t.payee, t.category) && !isFixedCost(t.payee, t.category))
479+
.filter((t) => t.amount < 0 && !t.excluded && !isTransfer(t.payee, t.category) && !isFixedCost(t.payee, t.category))
478480
.reduce((s, t) => s + Math.abs(t.amount), 0);
479481
const dailyDiscretionaryTrue = daysPassed > 0 ? Math.round((discretionarySpendingTrue / daysPassed) * 100) / 100 : 0;
480482

src/app/(app)/transactions/page.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { DateField } from "@/components/ui/date-field";
1313
import { Label } from "@/components/ui/label";
1414
import { Badge } from "@/components/ui/badge";
1515
import { Button } from "@/components/ui/button";
16+
import { Switch } from "@/components/ui/switch";
1617
import {
1718
Dialog,
1819
DialogContent,
@@ -97,6 +98,7 @@ export default function TransactionsPage() {
9798
const [editTx, setEditTx] = useState<{ id: string; payee: string; amount: number; category: string; memo: string | null; account_id: string; date: string } | null>(null);
9899
const [editType, setEditType] = useState<"expense" | "income" | "transfer">("expense");
99100
const [editTransferTo, setEditTransferTo] = useState("");
101+
const [editExcluded, setEditExcluded] = useState(false);
100102
const [editSuggestions, setEditSuggestions] = useState<string[]>([]);
101103
const [editSaving, setEditSaving] = useState(false);
102104
const [splitMode, setSplitMode] = useState(false);
@@ -182,6 +184,7 @@ export default function TransactionsPage() {
182184
category,
183185
inflow,
184186
transfer_account_id: editType === "transfer" && editTransferTo ? editTransferTo : undefined,
187+
budget_excluded: editExcluded,
185188
}),
186189
});
187190
const result = await res.json();
@@ -303,6 +306,7 @@ export default function TransactionsPage() {
303306
setEditTx({ id: t.id, payee: t.payee, amount: t.amount, category: t.category, memo: t.memo, account_id: t.account_id || "", date: t.date });
304307
setEditType(isTransfer(t.payee, t.category) ? "transfer" : t.amount > 0 ? "income" : "expense");
305308
setEditTransferTo(isTransfer(t.payee, t.category) ? counterpartIdFor(t.payee) : "");
309+
setEditExcluded(!!t.excluded);
306310
setSplitMode(false);
307311
setSplitLines([]);
308312
window.history.replaceState({}, "", "/transactions");
@@ -456,6 +460,7 @@ export default function TransactionsPage() {
456460
setEditTx({ id: tx.id, payee: tx.payee, amount: tx.amount, category: tx.category, memo: tx.memo, account_id: tx.account_id || "", date: tx.date });
457461
setEditType(txIsTransfer ? "transfer" : tx.amount > 0 ? "income" : "expense");
458462
setEditTransferTo(txIsTransfer ? counterpartIdFor(tx.payee) : "");
463+
setEditExcluded(!!tx.excluded);
459464
if (tx.isSplit && tx.parts) {
460465
setSplitMode(true);
461466
setSplitLines(tx.parts.map((p) => ({ category: p.category, amount: String(Math.abs(p.amount)) })));
@@ -494,6 +499,7 @@ export default function TransactionsPage() {
494499
<p className="list-item-name">{tx.payee}</p>
495500
{txIsTransfer && <Badge variant="secondary">{locale === "fi" ? "Siirto" : "Transfer"}</Badge>}
496501
{tx.isSplit && <Badge variant="secondary">{locale === "fi" ? "Jaettu" : "Split"}</Badge>}
502+
{tx.excluded && <Badge variant="secondary">{locale === "fi" ? "Ei budjetissa" : "Excluded"}</Badge>}
497503
</div>
498504
<p className="list-item-meta">{(() => {
499505
const acct = allAccounts.find((a) => a.id === tx.account_id)?.name || "";
@@ -638,6 +644,13 @@ export default function TransactionsPage() {
638644
<Label>{locale === "fi" ? "Kuvaus" : "Memo"}</Label>
639645
<PayeeInput value={editTx.memo || ""} onChange={(v) => setEditTx({ ...editTx, memo: v })} payees={memos} placeholder={locale === "fi" ? "esim. bussikortti" : "e.g. bus card"} />
640646
</div>
647+
<div className="form-field">
648+
<div className="settings-row">
649+
<Switch checked={editExcluded} onCheckedChange={setEditExcluded} />
650+
<Label>{locale === "fi" ? "Jätä pois budjetista" : "Exclude from budget"}</Label>
651+
</div>
652+
<p className="settings-help">{locale === "fi" ? "Ei lasketa mihinkään budjettilukuun (päiväbudjetti, kategoriat, kassavirta, tulot). Tilin saldo muuttuu silti." : "Left out of every budget figure (daily budget, categories, cash flow, income). The account balance still changes."}</p>
653+
</div>
641654
<div className="insp-actions">
642655
<Button onClick={handleEditSave} disabled={editSaving}>
643656
{editSaving ? <Loader2 className="icon-sm animate-spin" /> : (locale === "fi" ? "Tallenna" : "Save")}

src/app/api/chat/route.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getDb } from "@/lib/db";
66
import { getYnabToken, getYnabBudgetId } from "@/lib/household";
77
import { localDateIso } from "@/lib/date-utils";
88
import { eventBus } from "@/lib/event-bus";
9-
import { cashFlowHistory } from "@/lib/budget-math";
9+
import { cashFlowHistory, budgetExcludedNetByAccount, NOT_BUDGET_EXCLUDED } from "@/lib/budget-math";
1010

1111
export async function POST(request: Request) {
1212
try {
@@ -69,9 +69,12 @@ export async function POST(request: Request) {
6969
).all() as { id: string; name: string; type: string; balance: number }[];
7070
const accountSource = localAccounts.length > 0 ? localAccounts : summary.accounts;
7171

72+
// Budgetable balance: net out budget-excluded transactions on the budgeted accounts (see
73+
// summary route) so the daily budget ignores money the household chose to exclude.
74+
const chatExcludedNet = budgetExcludedNetByAccount(getDb());
7275
const checkingSavings = accountSource
7376
.filter((a: any) => (a.type === "checking" || a.type === "savings") && !excludedIds.includes(a.id))
74-
.reduce((s: number, a: any) => s + a.balance, 0);
77+
.reduce((s: number, a: any) => s + a.balance - (chatExcludedNet.get(a.id) || 0), 0);
7578

7679
// Load account notes for AI context
7780
const accountNotesRows = getDb()
@@ -127,7 +130,7 @@ export async function POST(request: Request) {
127130

128131
// Use local transactions table for recent transactions — always fresh
129132
const recentTx = chatDb.prepare(
130-
"SELECT t.date, t.payee, t.amount, t.category, u.display_name as spender FROM transactions t LEFT JOIN users u ON t.user_id = u.id WHERE t.payee NOT LIKE 'Transfer%' AND t.payee NOT LIKE 'Starting Balance%' AND t.payee NOT LIKE 'Reconciliation%' GROUP BY t.ynab_id ORDER BY t.date DESC LIMIT 10"
133+
"SELECT t.date, t.payee, t.amount, t.category, u.display_name as spender FROM transactions t LEFT JOIN users u ON t.user_id = u.id WHERE t.payee NOT LIKE 'Transfer%' AND t.payee NOT LIKE 'Starting Balance%' AND t.payee NOT LIKE 'Reconciliation%' AND COALESCE(t.budget_excluded, 0) = 0 GROUP BY t.ynab_id ORDER BY t.date DESC LIMIT 10"
131134
).all() as { date: string; payee: string; amount: number; category: string; spender: string | null }[];
132135

133136
// Load recurring bills with paid/overdue status
@@ -300,7 +303,7 @@ export async function POST(request: Request) {
300303
const debtNames: string[] = debts.map((d: { name: string }) => d.name.toLowerCase());
301304
const debtNameSet = new Set(debtNames);
302305
const todayTxRows = chatDb.prepare(
303-
"SELECT amount, payee, category FROM transactions WHERE date = ? AND amount < 0 AND payee NOT LIKE 'Transfer%' AND payee NOT LIKE 'Starting Balance%' GROUP BY ynab_id"
306+
"SELECT amount, payee, category FROM transactions WHERE date = ? AND amount < 0 AND payee NOT LIKE 'Transfer%' AND payee NOT LIKE 'Starting Balance%' AND " + NOT_BUDGET_EXCLUDED + " GROUP BY ynab_id"
304307
).all(todayStr) as { amount: number; payee: string; category: string }[];
305308
const isFixedCost = (p: string, c: string) => {
306309
const pl = p.toLowerCase(); const cl = c.toLowerCase();
@@ -316,7 +319,7 @@ export async function POST(request: Request) {
316319
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
317320
const localMonthlyExpenses = Math.round(
318321
(chatDb.prepare(
319-
"SELECT COALESCE(SUM(ABS(amount)), 0) as total FROM (SELECT amount FROM transactions WHERE date >= ? AND amount < 0 AND payee NOT LIKE 'Transfer%' AND payee NOT LIKE 'Starting Balance%' AND payee NOT LIKE 'Reconciliation%' AND category != 'Uncategorized' GROUP BY ynab_id)"
322+
"SELECT COALESCE(SUM(ABS(amount)), 0) as total FROM (SELECT amount FROM transactions WHERE date >= ? AND amount < 0 AND payee NOT LIKE 'Transfer%' AND payee NOT LIKE 'Starting Balance%' AND payee NOT LIKE 'Reconciliation%' AND category != 'Uncategorized' AND " + NOT_BUDGET_EXCLUDED + " GROUP BY ynab_id)"
320323
).get(monthStart) as { total: number }).total * 100
321324
) / 100;
322325
const daysToNextIncome = (() => {

src/app/api/heatmap/route.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextResponse } from "next/server";
22
import { getSession } from "@/lib/auth";
33
import { getDb } from "@/lib/db";
4+
import { NOT_BUDGET_EXCLUDED } from "@/lib/budget-math";
45

56
export async function GET() {
67
try {
@@ -14,7 +15,7 @@ export async function GET() {
1415
const sinceDate = `${since.getFullYear()}-${String(since.getMonth() + 1).padStart(2, "0")}-01`;
1516

1617
const transactions = db.prepare(
17-
"SELECT date, payee, amount, category FROM transactions WHERE date >= ? AND amount < 0 GROUP BY ynab_id ORDER BY date ASC"
18+
"SELECT date, payee, amount, category FROM transactions WHERE date >= ? AND amount < 0 AND " + NOT_BUDGET_EXCLUDED + " GROUP BY ynab_id ORDER BY date ASC"
1819
).all(sinceDate) as { date: string; payee: string; amount: number; category: string }[];
1920

2021
console.debug("[heatmap] Loaded", transactions.length, "transactions since", sinceDate);

src/app/api/summary/route.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { getDb } from "@/lib/db";
44
import { getYnabToken, getYnabBudgetId, getHouseholdSetting, getBudgetMode } from "@/lib/household";
55
import { DEFAULT_SUMMARY_INSTRUCTIONS } from "@/lib/ai/default-prompts";
66
import { resolveDayInMonth, dateForDayInMonth, formatDate } from "@/lib/date-utils";
7-
import { cashFlowHistory } from "@/lib/budget-math";
7+
import { cashFlowHistory, budgetExcludedNetByAccount, NOT_BUDGET_EXCLUDED } from "@/lib/budget-math";
88
import { spawn } from "child_process";
99

1010
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -82,9 +82,14 @@ export async function GET(request: Request) {
8282
const excludedRaw = getExcludedSetting("budget_excluded_accounts");
8383
const excludedIds: string[] = excludedRaw ? JSON.parse(excludedRaw) : [];
8484

85+
// Budgetable checking+savings balance: real balances of the budgeted accounts, with the net of
86+
// any budget-excluded transactions on those accounts pulled back out (an excluded outflow moved
87+
// the real balance but must not shrink the daily budget). Accounts already excluded at the
88+
// account level drop out entirely, balance and excluded transactions alike.
89+
const excludedNetByAccount = budgetExcludedNetByAccount(db);
8590
const checkingSavings = summary.accounts
8691
.filter((a: any) => (a.type === "checking" || a.type === "savings") && !excludedIds.includes(a.id))
87-
.reduce((s: number, a: any) => s + a.balance, 0);
92+
.reduce((s: number, a: any) => s + a.balance - (excludedNetByAccount.get(a.id) || 0), 0);
8893

8994
// Load account notes for context
9095
const accountNotesRows = db
@@ -103,7 +108,7 @@ export async function GET(request: Request) {
103108
// Use local transactions table for fresh data (same as dashboard)
104109
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
105110
const localTx = db.prepare(
106-
"SELECT date, payee, amount, category FROM transactions WHERE date >= ? GROUP BY ynab_id ORDER BY date DESC"
111+
"SELECT date, payee, amount, category FROM transactions WHERE date >= ? AND " + NOT_BUDGET_EXCLUDED + " GROUP BY ynab_id ORDER BY date DESC"
107112
).all(monthStart) as { date: string; payee: string; amount: number; category: string }[];
108113
const realExpenses = localTx.filter((t) => t.amount < 0 && !t.payee.startsWith("Transfer") && !t.payee.startsWith("Starting Balance") && !t.payee.startsWith("Reconciliation") && t.category !== "Uncategorized");
109114
const realIncome = localTx.filter((t) => t.amount > 0 && !t.payee.startsWith("Transfer") && !t.payee.startsWith("Starting Balance") && !t.payee.startsWith("Reconciliation"));

src/app/api/transactions/list/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ export async function GET(request: Request) {
2323
// Same-day tie-break on MAX(rowid) DESC (the autoincrement primary key = insertion order), so a
2424
// just-added transaction lands at the top of its day. The output alias `id` is ynab_id (a random
2525
// local_<uuid> for new rows), so ordering by it would scatter same-day items arbitrarily.
26-
const transactions = db.prepare(
27-
"SELECT ynab_id as id, date, amount, payee, category, memo, approved, cleared, account_id, COALESCE(split_group, '') AS split_group " +
26+
const transactions = (db.prepare(
27+
"SELECT ynab_id as id, date, amount, payee, category, memo, approved, cleared, account_id, COALESCE(split_group, '') AS split_group, COALESCE(budget_excluded, 0) AS budget_excluded " +
2828
"FROM transactions WHERE date >= ? AND date <= ? GROUP BY ynab_id ORDER BY date DESC, MAX(rowid) DESC"
29-
).all(start, end);
29+
).all(start, end) as { budget_excluded: number }[]).map((t) => ({ ...t, excluded: !!t.budget_excluded }));
3030

3131
console.debug("[transactions/list] month", month, "->", (transactions as unknown[]).length, "transactions");
3232
return NextResponse.json({ transactions });

0 commit comments

Comments
 (0)