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