Skip to content

Commit 9595057

Browse files
committed
Release 2.5.6: forward-coverage so upcoming income offsets obligations
1 parent 9582a41 commit 9595057

2 files changed

Lines changed: 35 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+
### 2.5.6: 2026-04-17
2+
3+
* Stop reserving obligations from current balance when upcoming income covers them
4+
15
### 2.5.5: 2026-04-16
26

37
* Use real BookBeat serif B logo from brand wordmark

src/lib/daily-budget.ts

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -80,59 +80,73 @@ export function calculateDailyBudget(params: {
8080
const totalDays = endAbsDay - today;
8181
if (totalDays <= 0) return { dailyBudget: 0, tightestSegment: null, segmentCount: 0 };
8282

83-
// No future income counted — only current balance. Recalculates when income arrives.
84-
const windowIncome = 0;
85-
86-
// Build obligations in window
87-
let windowObligations = 0;
88-
89-
// This month's unpaid bills and debts
83+
// Collect obligations in window with absolute due day
84+
const obligationEvents: { absDay: number; amount: number }[] = [];
9085
for (const b of unpaidBills) {
9186
if (b.dueDay > today && b.dueDay <= Math.min(daysInMonth, endAbsDay)) {
92-
windowObligations += b.amount;
87+
obligationEvents.push({ absDay: b.dueDay, amount: b.amount });
9388
}
9489
}
9590
for (const d of debts) {
9691
if (d.dueDay > today && d.dueDay <= Math.min(daysInMonth, endAbsDay) && d.amount > 0) {
97-
windowObligations += d.amount;
92+
obligationEvents.push({ absDay: d.dueDay, amount: d.amount });
9893
}
9994
}
100-
101-
// Next month's bills and debts if window extends past month end
10295
if (endAbsDay > daysInMonth) {
10396
const nextMonthEnd = endAbsDay - daysInMonth;
10497
if (allBills) {
10598
for (const b of allBills) {
10699
if (b.dueDay <= nextMonthEnd) {
107-
windowObligations += b.amount;
100+
obligationEvents.push({ absDay: daysInMonth + b.dueDay, amount: b.amount });
108101
}
109102
}
110103
}
111104
if (allDebts) {
112105
for (const d of allDebts) {
113106
if (d.dueDay <= nextMonthEnd && d.amount > 0) {
114-
windowObligations += d.amount;
107+
obligationEvents.push({ absDay: daysInMonth + d.dueDay, amount: d.amount });
115108
}
116109
}
117110
}
118111
}
112+
obligationEvents.sort((a, b) => a.absDay - b.absDay);
113+
114+
// Forward coverage: walk events chronologically. Credit incomes, debit obligations.
115+
// The max drop below starting balance is what current balance must reserve;
116+
// obligations covered by incoming income do not reserve against today's pool.
117+
// Incomes process before obligations on the same day.
118+
const events: { absDay: number; delta: number }[] = [
119+
...incomeEvents.map((e) => ({ absDay: e.absDay, delta: e.amount })),
120+
...obligationEvents.map((e) => ({ absDay: e.absDay, delta: -e.amount })),
121+
].sort((a, b) => a.absDay - b.absDay || b.delta - a.delta);
122+
123+
let runningLedger = balance;
124+
let maxShortfall = 0;
125+
for (const ev of events) {
126+
runningLedger += ev.delta;
127+
const shortfall = balance - runningLedger;
128+
if (shortfall > maxShortfall) maxShortfall = shortfall;
129+
}
130+
const windowObligations = maxShortfall;
119131

120132
// Proportional saving goal for the window
121133
const dailySaving = daysInMonth > 0 ? savingGoal / daysInMonth : 0;
122134
const windowSaving = Math.round(dailySaving * totalDays * 100) / 100;
123135

124-
// Pool = current balance + incoming income - obligations - savings
125-
const pool = balance + windowIncome - windowObligations - windowSaving;
136+
// Pool = current balance - obligations-net-of-coverage - savings
137+
// Future income is NOT added to raise the pool; it only offsets obligations
138+
// it arrives in time to cover. This keeps the daily rate sustainable.
139+
const pool = balance - windowObligations - windowSaving;
126140
const dailyBudget = Math.max(0, Math.round((pool / totalDays) * 100) / 100);
127141

128-
console.info("[daily-budget] balance:", Math.round(balance), "windowIncome:", Math.round(windowIncome), "obligations:", Math.round(windowObligations), "saving:", Math.round(windowSaving), "pool:", Math.round(pool), "days:", totalDays, "daily:", dailyBudget);
142+
console.info("[daily-budget] balance:", Math.round(balance), "reservedObligations:", Math.round(windowObligations), "saving:", Math.round(windowSaving), "pool:", Math.round(pool), "days:", totalDays, "daily:", dailyBudget);
129143

130144
const tightestSegment = {
131145
startDay: today,
132146
endDay: endAbsDay,
133147
days: totalDays,
134148
balanceAtStart: Math.round(balance * 100) / 100,
135-
incomeAtStart: Math.round(windowIncome * 100) / 100,
149+
incomeAtStart: 0,
136150
obligations: Math.round(windowObligations * 100) / 100,
137151
savingGoalDeducted: windowSaving,
138152
pool: Math.round(pool * 100) / 100,

0 commit comments

Comments
 (0)