Skip to content

Commit 766ff88

Browse files
committed
Release 2.7.0: per-income target account override for Synci sync
1 parent c53b08a commit 766ff88

5 files changed

Lines changed: 54 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
### 2.7.0: 2026-04-29
2+
3+
* Add per-income target account override so Synci routes income to the chosen YNAB account regardless of which bank account the deposit lands in
4+
15
### 2.6.2: 2026-04-29
26

37
* Fix month status projection to use spent-so-far plus remaining instead of double counting bills and full month discretionary average

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

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ interface Income {
2727
is_active: number;
2828
average_amount: number | null;
2929
history_count: number;
30+
target_account_id: string;
31+
}
32+
33+
interface AccountOption {
34+
id: string;
35+
name: string;
3036
}
3137

3238
export default function IncomePage() {
@@ -41,6 +47,8 @@ export default function IncomePage() {
4147
const [newPattern, setNewPattern] = useState("");
4248
const [patternMinAmount, setPatternMinAmount] = useState("");
4349
const [patternMaxAmount, setPatternMaxAmount] = useState("");
50+
const [accounts, setAccounts] = useState<AccountOption[]>([]);
51+
const [editTargetAccount, setEditTargetAccount] = useState<string>("");
4452
const addFormRef = useRef<HTMLFormElement>(null);
4553
const editFormRef = useRef<HTMLFormElement>(null);
4654

@@ -49,9 +57,13 @@ export default function IncomePage() {
4957
Promise.all([
5058
fetch("/api/income").then((r) => r.json()),
5159
fetch("/api/matches").then((r) => r.json()),
60+
fetch("/api/ynab/accounts").then((r) => r.json()),
5261
])
53-
.then(([incomeData, matchData]) => {
62+
.then(([incomeData, matchData, accountsData]) => {
5463
if (incomeData.incomes) setIncomes(incomeData.incomes);
64+
if (accountsData.accounts) {
65+
setAccounts(accountsData.accounts.filter((a: { type: string; closed: number }) => (a.type === "checking" || a.type === "savings") && !a.closed).map((a: { id: string; name: string }) => ({ id: a.id, name: a.name })));
66+
}
5567
if (matchData.patterns) {
5668
const grouped: Record<number, { id: number; payee_pattern: string }[]> = {};
5769
for (const p of matchData.patterns) {
@@ -90,7 +102,7 @@ export default function IncomePage() {
90102
const res = await fetch("/api/income", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
91103
const data = await res.json();
92104
if (data.id) {
93-
setIncomes((prev) => [...prev, { id: data.id, ...body, is_recurring: 1, is_active: 1, average_amount: null, history_count: 0 }]);
105+
setIncomes((prev) => [...prev, { id: data.id, ...body, is_recurring: 1, is_active: 1, average_amount: null, history_count: 0, target_account_id: "" }]);
94106
setAddOpen(false);
95107
form.reset();
96108
}
@@ -106,11 +118,12 @@ export default function IncomePage() {
106118
name: fd.get("name") as string,
107119
amount: parseFloat((fd.get("amount") as string).replace(",", ".")),
108120
expected_day: parseInt(fd.get("expected_day") as string, 10),
121+
target_account_id: editTargetAccount,
109122
};
110123
console.info("[income] Editing:", body.id);
111124
try {
112125
await fetch("/api/income", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
113-
setIncomes((prev) => prev.map((i) => i.id === body.id ? { ...i, name: body.name, amount: body.amount, expected_day: body.expected_day } : i));
126+
setIncomes((prev) => prev.map((i) => i.id === body.id ? { ...i, name: body.name, amount: body.amount, expected_day: body.expected_day, target_account_id: body.target_account_id } : i));
114127
setEditOpen(false);
115128
setEditTarget(null);
116129
} catch (err) { console.error("[income] Edit error:", err); }
@@ -242,7 +255,7 @@ export default function IncomePage() {
242255
<div
243256
key={income.id}
244257
className="list-item"
245-
onClick={() => { setEditTarget(income); setEditOpen(true); }}
258+
onClick={() => { setEditTarget(income); setEditTargetAccount(income.target_account_id || ""); setEditOpen(true); }}
246259
>
247260
<div className="list-item-body">
248261
<div className="list-item-name-row">
@@ -293,6 +306,24 @@ export default function IncomePage() {
293306
<Input name="expected_day" type="number" min="0" max="31" defaultValue={editTarget.expected_day} required />
294307
</div>
295308
</div>
309+
<div className="form-field">
310+
<Label>{locale === "fi" ? "Tulotili" : "Target account"}</Label>
311+
<select
312+
className="input"
313+
value={editTargetAccount}
314+
onChange={(e) => setEditTargetAccount(e.target.value)}
315+
>
316+
<option value="">{locale === "fi" ? "Käytä Synci-mappausta" : "Use Synci mapping"}</option>
317+
{accounts.map((a) => (
318+
<option key={a.id} value={a.id}>{a.name}</option>
319+
))}
320+
</select>
321+
<p className="settings-help">
322+
{locale === "fi"
323+
? "Valitse tili, jolle tämä tulo kirjataan YNABissa. Käytä jos pankki maksaa eri tilille kuin haluat seurata."
324+
: "Pick the YNAB account this income should be posted to. Use when the deposit lands on a different account than you want to track."}
325+
</p>
326+
</div>
296327
<div className="form-field">
297328
<Label>{locale === "fi" ? "Yhdistä maksajaan" : "Match payee"}</Label>
298329
<div className="match-pattern-row">

src/app/api/income/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ export async function GET() {
1010

1111
const db = getDb();
1212
const incomes = db
13-
.prepare("SELECT id, name, amount, expected_day, is_recurring, is_active FROM income_sources ORDER BY expected_day ASC")
14-
.all() as { id: number; name: string; amount: number; expected_day: number; is_recurring: number; is_active: number }[];
13+
.prepare("SELECT id, name, amount, expected_day, is_recurring, is_active, target_account_id FROM income_sources ORDER BY expected_day ASC")
14+
.all() as { id: number; name: string; amount: number; expected_day: number; is_recurring: number; is_active: number; target_account_id: string }[];
1515

1616
// Get averages from history
1717
const averages = db
@@ -101,6 +101,7 @@ export async function PUT(request: Request) {
101101
if (body.expected_day !== undefined) { updates.push("expected_day = ?"); values.push(body.expected_day); }
102102
if (body.is_recurring !== undefined) { updates.push("is_recurring = ?"); values.push(body.is_recurring ? 1 : 0); }
103103
if (body.is_active !== undefined) { updates.push("is_active = ?"); values.push(body.is_active ? 1 : 0); }
104+
if (body.target_account_id !== undefined) { updates.push("target_account_id = ?"); values.push(body.target_account_id || ""); }
104105

105106
if (updates.length > 0) {
106107
updates.push("updated_at = datetime('now')");

src/app/api/synci/sync/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,11 @@ export async function POST(request: Request) {
110110
if (pattern.max_amount > 0 && amount > pattern.max_amount) continue;
111111

112112
try {
113-
const ynabAccountId = accountMapping[synciAccountId] || "";
113+
// Income source may override the target account when the institution
114+
// deposits into a different bank account than the one the household
115+
// wants to track it under in YNAB
116+
const overrideAccount = db.prepare("SELECT target_account_id FROM income_sources WHERE id = ?").get(pattern.source_id) as { target_account_id: string } | undefined;
117+
const ynabAccountId = (overrideAccount?.target_account_id || accountMapping[synciAccountId] || "");
114118
const ynabToken = getHouseholdSetting("ynab_access_token");
115119
const ynabBudgetId = getHouseholdSetting("ynab_budget_id");
116120
let realYnabId = synciTxId;

src/lib/db.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,13 @@ function initializeDb(db: Database.Database) {
370370
db.exec("ALTER TABLE payee_matches ADD COLUMN max_amount REAL DEFAULT 0");
371371
}
372372

373+
// Add target_account_id column to income_sources if missing
374+
const incomeCols = db.prepare("PRAGMA table_info(income_sources)").all() as { name: string }[];
375+
if (incomeCols.length > 0 && !incomeCols.some((c) => c.name === "target_account_id")) {
376+
console.info("[db] Adding target_account_id column to income_sources");
377+
db.exec("ALTER TABLE income_sources ADD COLUMN target_account_id TEXT DEFAULT ''");
378+
}
379+
373380
// Add discretionary_target column to daily_budget_history if missing
374381
const dbhCols = db.prepare("PRAGMA table_info(daily_budget_history)").all() as { name: string }[];
375382
if (dbhCols.length > 0 && !dbhCols.some((c) => c.name === "discretionary_target")) {

0 commit comments

Comments
 (0)