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 21808ff..df4923a 100644 Binary files a/backend/backend/hoa_app/__pycache__/main.cpython-311.pyc and b/backend/backend/hoa_app/__pycache__/main.cpython-311.pyc differ 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]