From fef7ae9aada3f226ddc6371572173d355bf71aef Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 31 Jul 2025 15:13:10 -0400 Subject: [PATCH] Fixes for logo and Forecasting --- .../frontend/public/images/hoapro-logo.svg | 17 ++- backend/backend/frontend/src/Forecasting.tsx | 19 ++- .../hoa_app/__pycache__/main.cpython-311.pyc | Bin 67565 -> 70903 bytes backend/backend/hoa_app/main.py | 115 ++++++++++++++++-- 4 files changed, 127 insertions(+), 24 deletions(-) diff --git a/backend/backend/frontend/public/images/hoapro-logo.svg b/backend/backend/frontend/public/images/hoapro-logo.svg index 0519ecb..c7b28f6 100644 --- a/backend/backend/frontend/public/images/hoapro-logo.svg +++ b/backend/backend/frontend/public/images/hoapro-logo.svg @@ -1 +1,16 @@ - \ No newline at end of file + + + + + + + + + + + + + + HOApro + + \ No newline at end of file diff --git a/backend/backend/frontend/src/Forecasting.tsx b/backend/backend/frontend/src/Forecasting.tsx index ea3035b..d089151 100644 --- a/backend/backend/frontend/src/Forecasting.tsx +++ b/backend/backend/frontend/src/Forecasting.tsx @@ -320,8 +320,8 @@ const Forecasting: React.FC = () => { // Create data with different itemStyle for actual vs projected const styledData = chartData.map((row, dataIdx) => { const rowDate = new Date(row.rawDate); - const isProjected = rowDate.getFullYear() > 2025 || - (rowDate.getFullYear() === 2025 && rowDate.getMonth() >= 7); // July and later + const isProjected = rowDate.getFullYear() > currentYear || + (rowDate.getFullYear() === currentYear && rowDate.getMonth() > currentMonth); return { value: row[key], @@ -353,8 +353,8 @@ const Forecasting: React.FC = () => { // Add total as a line with actual vs projected styling const totalStyledData = chartData.map((row, dataIdx) => { const rowDate = new Date(row.rawDate); - const isProjected = rowDate.getFullYear() > 2025 || - (rowDate.getFullYear() === 2025 && rowDate.getMonth() >= 7); // July and later + const isProjected = rowDate.getFullYear() > currentYear || + (rowDate.getFullYear() === currentYear && rowDate.getMonth() > currentMonth); return { value: row.total, @@ -378,12 +378,9 @@ const Forecasting: React.FC = () => { smooth: true, }; // Add a markLine to show the boundary between actual and projected data - // Position the line between July and August (month 6 and 7, 0-based) - const julyIndex = 6; // July is month 7, but 0-based index is 6 - const augustIndex = 7; // August is month 8, but 0-based index is 7 - - // Only add the markLine if we're viewing 2025 and have data for August - const markLine = (year === 2025 && chartData.length > augustIndex) ? { + // Only add the markLine if we're viewing the current year and have data beyond the current month + console.log('Debug markLine:', { year, currentYear, chartDataLength: chartData.length, currentMonth, shouldShow: year === currentYear && chartData.length > currentMonth + 1 }); + const markLine = (year === currentYear && chartData.length > currentMonth + 1) ? { symbol: 'none', lineStyle: { color: '#666', @@ -398,7 +395,7 @@ const Forecasting: React.FC = () => { color: '#666' }, data: [ - { xAxis: julyIndex + 0.5 } // Position between July and August + { xAxis: currentMonth + 0.5 } // Position between current month and next month ] } : null; diff --git a/backend/backend/hoa_app/__pycache__/main.cpython-311.pyc b/backend/backend/hoa_app/__pycache__/main.cpython-311.pyc index 21808ffae8dbc4b61b6ccb1dcad78d536fbea43c..df4923a3e027dff6dd19ab5526dd668ed7c32708 100644 GIT binary patch delta 5049 zcmb7Hd2kcg8Q)#`#3z<)$(E0`CD|78eM-KKxdNCY2Fwv+gjP24A>);Rpgcuk(oD<5 zlRCUV!X)uTNeD3$3NA_-LO4tz;I?V9(kw;sBoil-w9`LY?3PSs@=yEiu4QCFdWg5* zzVGe#zVG)P?|bHNzl{3fOjPnaNlD8H_&s;-g##m3ew}<~BIdA;Ab#HAyD}jo$qsz| zOcB}P`|FwQ6fkd0HUs@?ax4Dy?4OtLtIjRuFP&fHeIOqX!j+evlg01#`aZanKqhqb zl1%>3;*j23gWJ^w_})j}s4c{8K#pA!+NW;HS=a(c#a|l`E46C;|Lw89}@YkM#3uF!@0~-f~`!Wxwf8uSAvX8dS-|WQGI?cRAKv z=_y))N?#=y11fu!7>_$mf)043A4v$-*_7iQs2qQNNzvkhC^5@i(LhsBdA}+o*qSKCTjmhik`Vy4fpAwP=!==YNm=s|b%*#*>fQX3%VgCXp z;;mB|iJ&k_R9Gf1C^2_CMV68r6jjamj7L^q_Eg=blKvhev zuOjv;-L+_m9;U*T0A31Fs1#=II{04aN`hIVM~zl zm=>)-#_*&|0d%N@b=-4R=`k`X4@zPoyfMh+t_KNiFXc9lHiF6OP?nfwy%f?PCfFpT z4_|wr!(EL^9M_P6Ns-Eutr6rFc9luHdZ~~+02j)lHq!gr;~h{C@}DS(=b@lVq=IUX z+*ucb1^es6v^-SX_HI;vtQOS^_B$iB7?*|(bmhWISg2sdB%*s*ub9uGNR17T$Y-j% z0dJnlBpb19swg&Uug(qqqqUa^HYJ!s)jMgO^lx1)A|CI*V#`-nWOL+xPnvHzZj)sM)-!?iU=jjlu* zF;?{uD@~|HKx;EaX14x+)#yew&&mYsVz5p4+GQls?7I~0C`=x+_p0Z3YC&TPX+Z|TVf{MN1QvD;6_Knsb%BMlibBrz60`X)a3q-q(n z1H}U1C}^tYQ|Zw~U4*{d*L_E7W( zc80du=>s;0bC|VR%!4#zcA6XLp<%o80PTt|q0K`e-t&ZFo(n>p7 zv)y5~IBmmryzkQj--SP_NDrz1F;2IuR5w(rWAqeT2H4qaKxW9@t{6i_MMFh}O9nx5 z#py#aOlqJz&CWwCfEl*auH=$XkmzPwTj5Hhhor9!w3!iOsCDJg&SCl>Ya24NM?>+Z zEoR4oI=Xjo_=ro{KyO;&B3*3m6M~*08FlEF8o{}rbz=8t-;gKpKJG=b&3BJuNF#ei z03PRXvb|2*kd?hEICDcF!mY;M6u!o?)`Np)i0tFS$gM-KU34CGN;W|o-; z2YW0`kMk+Vj2zx#gnIx@k61#Enbd=<^>7bkwe&3HPKsBe__=4ex7T5H&cxa5PAhA5 zIPnF(WRN`of0)lCFBVIWP%<;iq#eRsggTQbYF^MflOl2tWM<;Tg#qi)nIym!hKREC zvgKgGu?pP5m3OLf+nrR*+{sEf535GFq8%A3-|;(_W1{C`h%B9N-4{)=xE@(_!aUI< z!=qpQDf;^uVz~-iXB$&y6H@1irg)Z|&8C0R`5it#d)t#tDDvlt#H1b6Y);h?LGnIhd4s6K2bbg$E!@oA=OnoF7wb(eK> z@x*fVTq2>!og<=_@1TO(vWbyXonxIi@9Qkj96=OprzWf4GfFdwPHAoyhV$Iu(Qp># ztEnz3d}*E_(sxkzMGEUMhsdSo%oEv35Sb#w#1LQB#+R-37p+k9$hGPb#iC<;sRd!As!A!$M$JsS}X7!{wkXg@V)&~n}gkbXpO%HT>bQVdeh0|HM z-DZL6EPfTks~Aok;D)C1WbCXumsgj}5wgshNjYD+a%yX!at&9x=F5s&uI@2^#d^MC z{TxB%)QH5J7&KH(TKKxP(>j0MX1`$z*VXMebbI51bkT&-Pgi?my@F#N$+H0o1h_H4 z?Y9Y3H{e$d@~S~j8~`G(U~Fwrk@fP%6C1hwUT@c_o$!)|U(v@a`Z#fbq&jyjHkg|6 za^i_ZF6W?Eeo6%|Y4}q|_|y?Db!0ZTfQwTH)xK}OuAt(qWIU9smBgxVR|^%Ns>A!f zJ?#-@>GMQ+QvR$wgO_XP33_>cP+P=nYsWgjROWk2-_&aQxnx^jdLDxDlJ^pTowuvK1p=CES%%|oZ+lAhlBby#%_-3g2{?Ug;zm$gAY?DuY!Glf@UCr^~oKPx#kA!9CgIuiD2~ z?enGtOH3DArrZ6Eot$A4RD#AZ;o&qbFoM-9r;L2{MlbvWn>CH%%R`q;m;-s`TwZyQ zuDh5t^$b_PgQL4)@Id$U`RP81_0!!!y6~OEHxs#1mvENh1!EdXZ=inqX`X(Xqo4j- zMS}%JJ|{1k)m2UIox(c|86d9tfhYof_*D)8-us zD2q8|ab&)_rF=npy2bQ#Q=xQ)cqv^WjvIMKNKuHD=0du5is{-ZrfVl<{6F($`&{%b zRqng`T~<8#HvatYx5;7r#yvSXg5STlDZvfRouvfg!^v~n=nrA~ny-G&N|6_Qr-s_Uy-X)!WP^k>&;8Hd0+_dCS&-AWCvJYl*+&gy9u|pDs>~x?j3E4fjx>jLO zKJ19 zY&!mR2NrrL?Og%P-+KF{ywYu^Jp-D#DP;QQFjgYF_HcYEF$tKMT!>Et&9pD56LH+9 z@D(q_@toTN-%N;Y@y0GY9?@haDJ}LYFGWZrnau{0LNa-oX3~wTk}|O__7OW~b|I1& zfFBmf7i7fhwt$bJ61y{7?T|OC#m-)ocDsMo>139V&jXTu0^mmMYQ>>dfF53*w|TPjFo77%Z2&r?(8<)dX*%x1zo9D z)=eIR<2Kt^`!HWjSDiDWY}#~g)wE|iW$;>)(N}EIGQ_pXTCH@;%dx@yvt%07C50zy z!+v#8(hAPF*pqqQC_x|5vWrL1$5cH$f(GcH!-Hs$9=^0Q=stR}v5v_Z_*2uP|$s3^z68zK`Z46rKW@$Qvz?qLOPpSA3&pI z!9snb-01UYOtq^^MwW`7ceMckjEg6 zfr&x2=hCPS2YIWd^T*Ouqv2Zw$F_C6qhg(}L}4S{GNy|i#TAvKa;K}_Avhpg2K<54 z&-`n>GRbv~!6UhuFOe7FppZLU5vIkze=oTi9z$o} zvWJa?gimncinaLqQ~K>CW3>9#$)->)CT@H2NND7)B|DZ(a9ms|-o0Y)8c}+Pji?xd z;zX(xMLvrniRyQP%9nq#-)y^5#&bg?y zT-I8?ygdWUauu`gaJe*Lyhw6A%s^|$JDaYDsV2A}rDnHjC@!OWUVq7O=CYy8Wh2p= zHpxhMbbIJ~p%8uMGTf25KUb7Q?2bR2I92aYpQ?deh?la0!F4J^*%8ZG*??fj5zFa~ zaVwM)={a>bPJtHFU;aL+c0s>|3^qjXz8zrF7TWjE(va8K9n9bp8vDQy)D2>xr|^LT zqb|?Ee^m%M-|t1Gu~m%17GhLZ?yPfE)C)Eiq`KXNkOpmTFGlDMRNHLaU4aD> zeq@|-2I~Rt%kyh$`K{HCdBW)<7cqK0!d@gr>IshF?uN^{Y*GBGSp1!ecO)EcK$p?B P3GDTGUf#Y#g=+o-mN}7Z diff --git a/backend/backend/hoa_app/main.py b/backend/backend/hoa_app/main.py index f257a2b..9b9ca68 100644 --- a/backend/backend/hoa_app/main.py +++ b/backend/backend/hoa_app/main.py @@ -660,6 +660,7 @@ def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(ge primary_account = acc break alert_threshold = 1000.0 + # 2. Get historical balances for each account history_by_account = defaultdict(list) histories = db.query(models.AccountBalanceHistory).filter(models.AccountBalanceHistory.account_id.in_(account_ids)).order_by(models.AccountBalanceHistory.date.asc()).all() @@ -707,13 +708,16 @@ def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(ge earliest_actual_month = None if monthly_actuals: earliest_actual_month = min([m for (y, m) in monthly_actuals.keys() if y == year], default=None) + # --- CD-specific logic (must happen before balance determination) --- is_cd = hasattr(acc, 'account_type') and acc.account_type and acc.account_type.name.lower() == 'cd' funding_month = None funding_amount = 0.0 + if is_cd: - # Find first reconciled deposit/transfer into this CD in the forecast year + # Find first reconciled deposit/transfer into this CD in ANY year (not just forecast year) cd_funding_tx = None + # Look for funding transactions in the forecast year first for m in range(1, months + 1): month_txs = txs_by_year_month.get((acc.id, year, m), []) for tx in month_txs: @@ -725,10 +729,52 @@ def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(ge funding_amount = cd_funding_tx.amount logger.debug(f"CD funding for {acc.name} (id={acc.id}) in month {funding_month}: amount={funding_amount}") break - # For CDs, always start with $0 balance - they don't exist before funding - balance = 0.0 - logger.debug(f"[{acc.name}] CD account, using $0 starting balance (will be funded in month {funding_month if funding_month else 'unknown'})") - + + # If no funding found in forecast year, look for historical funding + if not cd_funding_tx: + # Get all historical transactions for this CD + all_cd_txs = db.query(models.Transaction).filter( + models.Transaction.account_id == acc.id, + models.Transaction.type.in_(('deposit', 'transfer')), + models.Transaction.reconciled == True + ).order_by(models.Transaction.date.asc()).all() + + if all_cd_txs: + cd_funding_tx = all_cd_txs[0] # First funding transaction + funding_month = cd_funding_tx.date.month + funding_amount = cd_funding_tx.amount + logger.debug(f"CD funding for {acc.name} (id={acc.id}) found in historical data: month {funding_month}, amount={funding_amount}") + + # For CDs, determine starting balance based on whether they should exist + if cd_funding_tx: + # Calculate if CD should exist at the start of the forecast year + cd_start_age_months = (year - cd_funding_tx.date.year) * 12 + (1 - cd_funding_tx.date.month) + + if cd_start_age_months >= 0: + # CD should exist, use previous year's ending balance or validated balance + if prev_dec_balance is not None: + balance = prev_dec_balance + logger.debug(f"[{acc.name}] CD account, using Dec {year-1} ending balance: {balance}") + elif validated_balances[acc.id] > 0: + balance = validated_balances[acc.id] + logger.debug(f"[{acc.name}] CD account, using validated balance: {balance}") + else: + # Calculate balance from funding amount and interest + balance = funding_amount + # Apply interest for months since funding + if acc.interest_rate and acc.interest_rate > 0: + for month_offset in range(cd_start_age_months): + interest = balance * (acc.interest_rate / 100.0) / 12.0 + balance += interest + logger.debug(f"[{acc.name}] CD account, calculated balance from funding: {balance}") + else: + # CD hasn't been funded yet + balance = 0.0 + logger.debug(f"[{acc.name}] CD account, not funded yet, using $0 starting balance") + else: + # No funding transaction found, start with $0 + balance = 0.0 + logger.debug(f"[{acc.name}] CD account, no funding transaction found, using $0 starting balance") # --- Find the latest balance from any previous year as starting point --- latest_prev_balance = None latest_prev_date = None @@ -767,6 +813,7 @@ def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(ge forecast_month = ((m - 1) % 12) + 1 month_date = datetime(forecast_year, forecast_month, 1) key = (forecast_year, forecast_month) + # --- Universal rule: For months before the first validated balance, use $0 --- if earliest_actual_month is not None and forecast_year == year and forecast_month < earliest_actual_month: bal = 0.0 @@ -774,13 +821,57 @@ def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(ge balance = bal forecast_points.append({"date": month_date.strftime("%Y-%m-%d"), "balance": bal}) continue - # --- CD logic: $0 until funded, even if historical --- - if is_cd and (funding_month is None or forecast_month < funding_month): - bal = 0.0 - logger.debug(f"CD {acc.name} (id={acc.id}) month {forecast_month}: Forcing balance to $0 (before funding month {funding_month})") - balance = bal - forecast_points.append({"date": month_date.strftime("%Y-%m-%d"), "balance": bal}) - continue + + # --- CD logic: Check if CD should exist at this point --- + if is_cd: + # Check if CD has matured + if acc.maturity_date: + maturity_date = acc.maturity_date + if isinstance(maturity_date, str): + maturity_date = datetime.fromisoformat(maturity_date.replace('Z', '+00:00')) + + # If CD has matured before this month, it should be $0 + if maturity_date and maturity_date < month_date: + bal = 0.0 + logger.debug(f"CD {acc.name} (id={acc.id}) month {forecast_month}: Matured on {maturity_date}, balance = $0") + balance = bal + forecast_points.append({"date": month_date.strftime("%Y-%m-%d"), "balance": bal}) + + # Add matured CD funds to primary account if this is the maturity month + if (maturity_date.year == forecast_year and + maturity_date.month == forecast_month and + primary_account_id is not None): + # Find the CD balance just before maturity + prev_balance = balance + if forecast_month > 1: + prev_month_date = datetime(forecast_year, forecast_month - 1, 1) + prev_key = (forecast_year, forecast_month - 1) + if prev_key in monthly_actuals: + prev_balance = monthly_actuals[prev_key] + else: + # Calculate previous month's balance + prev_balance = balance # This will be calculated in the loop + + # Add matured amount to primary account's cash flow + if prev_balance > 0: + cashflow_by_year_month[(forecast_year, forecast_month)] += prev_balance + logger.debug(f"CD {acc.name} matured: adding ${prev_balance} to primary account cash flow") + + continue + + # Check if CD should exist based on funding date + if funding_month is not None: + # Calculate the CD's age in months from funding + cd_age_months = (forecast_year - cd_funding_tx.date.year) * 12 + (forecast_month - cd_funding_tx.date.month) + + # If CD hasn't been funded yet, it should be $0 + if cd_age_months < 0: + bal = 0.0 + logger.debug(f"CD {acc.name} (id={acc.id}) month {forecast_month}: Not funded yet (age: {cd_age_months} months), balance = $0") + balance = bal + forecast_points.append({"date": month_date.strftime("%Y-%m-%d"), "balance": bal}) + continue + # Use actual if available (for non-CDs, or for CDs after funding) if key in monthly_actuals: bal = monthly_actuals[key]