Commit 4: Added future year forecasting logic

This commit is contained in:
2025-08-01 10:09:35 -04:00
parent fef7ae9aad
commit 05c446bc82
7 changed files with 43 additions and 25 deletions

View File

View File

@@ -378,9 +378,9 @@ const Forecasting: React.FC = () => {
smooth: true,
};
// Add a markLine to show the boundary between actual and projected data
// 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) ? {
// Only add the markLine if we're viewing the current year and the current month is within the data range
console.log('Debug markLine:', { year, currentYear, chartDataLength: chartData.length, currentMonth, shouldShow: year === currentYear && currentMonth < chartData.length });
const markLine = (year === currentYear && currentMonth < chartData.length) ? {
symbol: 'none',
lineStyle: {
color: '#666',

0
backend/backend/hoa.db Normal file
View File

Binary file not shown.

View File

@@ -8,7 +8,7 @@ from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.exc import IntegrityError
from .schemas import BucketCreate, BucketRead, AccountCreate, AccountRead, TransactionCreate, TransactionRead, CashFlowCreate, CashFlowRead, ForecastRequest, ForecastResponse, ForecastPoint, ReportRequest, ReportResponse, ReportEntry, CashFlowCategoryCreate, CashFlowCategoryRead, CashFlowEntryCreate, CashFlowEntryRead
from datetime import datetime, timedelta, date as date_cls
from sqlalchemy import and_, func
from sqlalchemy import and_, func, or_
from .logging_config import setup_logging, DEBUG_LOGGING
import logging
import os
@@ -669,6 +669,8 @@ def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(ge
# --- Cash Flow Projections: Only for Operating bucket ---
cashflow_by_year_month = defaultdict(float)
# --- CD Maturity Tracking: For Reserve bucket ---
cd_maturity_by_year_month = defaultdict(float)
if bucket_name.lower() == "operating" and primary_account_id is not None:
# Get all cash flow categories for Operating
op_categories = db.query(models.CashFlowCategory).filter(models.CashFlowCategory.funding_type == models.CashFlowFundingTypeEnum.operating).all()
@@ -719,31 +721,46 @@ def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(ge
cd_funding_tx = None
# Look for funding transactions in the forecast year first
for m in range(1, months + 1):
# Check both direct deposits and transfers TO this CD
month_txs = txs_by_year_month.get((acc.id, year, m), [])
for tx in month_txs:
if tx.type in ('deposit', 'transfer'):
cd_funding_tx = tx
break
if not cd_funding_tx:
# Also check for transfers TO this CD (where related_account_id == acc.id)
month_txs = txs_by_year_month.get((acc.id, year, m), [])
for tx in month_txs:
if tx.type == 'transfer' and tx.related_account_id == acc.id:
cd_funding_tx = tx
break
if cd_funding_tx:
funding_month = cd_funding_tx.date.month
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
# If no funding found in forecast year, look for historical funding
# If no funding found in forecast year, look for historical funding (including planned transactions)
if not cd_funding_tx:
# Get all historical transactions for this CD
# Get all transactions for this CD (both reconciled and planned)
# Look for both direct deposits and transfers TO 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
or_(
models.Transaction.account_id == acc.id, # Direct deposit to CD
models.Transaction.related_account_id == acc.id # Transfer TO CD
)
).order_by(models.Transaction.date.asc()).all()
logger.debug(f"CD {acc.name} (id={acc.id}): Found {len(all_cd_txs)} funding transactions")
for tx in all_cd_txs:
logger.debug(f" - {tx.date}: {tx.amount} ({tx.type}, reconciled={tx.reconciled})")
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}")
logger.debug(f"CD funding for {acc.name} (id={acc.id}) found in historical data: month {funding_month}, amount={funding_amount}, reconciled={cd_funding_tx.reconciled}")
# For CDs, determine starting balance based on whether they should exist
if cd_funding_tx:
@@ -751,7 +768,7 @@ def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(ge
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
# CD should exist, prioritize previous year's ending balance (which includes projected Dec 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}")
@@ -787,6 +804,9 @@ def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(ge
prev_dec_balance = None
if (year-1, 12) in monthly_actuals:
prev_dec_balance = monthly_actuals[(year-1, 12)]
logger.debug(f"[{acc.name}] Found Dec {year-1} balance in monthly_actuals: {prev_dec_balance}")
else:
logger.debug(f"[{acc.name}] No Dec {year-1} balance found in monthly_actuals. Available keys: {list(monthly_actuals.keys())}")
# Determine starting balance for forecast (skip for CDs since we already set them to $0)
if not is_cd:
if earliest_actual_month == 1 and (year, 1) in monthly_actuals:
@@ -841,21 +861,13 @@ def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(ge
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")
# For CD maturity, we need to calculate the balance just before maturity
# This will be the balance from the previous month's calculation
# We'll store this in a variable that gets updated as we process each account
matured_amount = balance # This is the CD balance before maturity
if matured_amount > 0:
cd_maturity_by_year_month[(forecast_year, forecast_month)] += matured_amount
logger.debug(f"CD {acc.name} matured: adding ${matured_amount} to CD maturity tracking")
continue
@@ -900,6 +912,12 @@ def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(ge
if cf != 0.0:
logger.debug(f"Applying cash flow projection to primary account {acc.name} (id={acc.id}) {forecast_year}-{forecast_month:02d}: {cf}")
bal += cf
# --- Apply matured CD funds to primary account (Reserve only) ---
elif bucket_name.lower() == "reserve" and acc.id == primary_account_id:
matured_cd_amount = cd_maturity_by_year_month.get((forecast_year, forecast_month), 0.0)
if matured_cd_amount != 0.0:
logger.debug(f"Applying matured CD funds to primary account {acc.name} (id={acc.id}) {forecast_year}-{forecast_month:02d}: {matured_cd_amount}")
bal += matured_cd_amount
# --- Apply interest if applicable ---
if acc.interest_rate and acc.interest_rate > 0:
interest = bal * (acc.interest_rate / 100.0) / 12.0