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, smooth: true,
}; };
// Add a markLine to show the boundary between actual and projected data // 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 // 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 && chartData.length > currentMonth + 1 }); console.log('Debug markLine:', { year, currentYear, chartDataLength: chartData.length, currentMonth, shouldShow: year === currentYear && currentMonth < chartData.length });
const markLine = (year === currentYear && chartData.length > currentMonth + 1) ? { const markLine = (year === currentYear && currentMonth < chartData.length) ? {
symbol: 'none', symbol: 'none',
lineStyle: { lineStyle: {
color: '#666', 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 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 .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 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 from .logging_config import setup_logging, DEBUG_LOGGING
import logging import logging
import os import os
@@ -669,6 +669,8 @@ def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(ge
# --- Cash Flow Projections: Only for Operating bucket --- # --- Cash Flow Projections: Only for Operating bucket ---
cashflow_by_year_month = defaultdict(float) 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: if bucket_name.lower() == "operating" and primary_account_id is not None:
# Get all cash flow categories for Operating # Get all cash flow categories for Operating
op_categories = db.query(models.CashFlowCategory).filter(models.CashFlowCategory.funding_type == models.CashFlowFundingTypeEnum.operating).all() 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 cd_funding_tx = None
# Look for funding transactions in the forecast year first # Look for funding transactions in the forecast year first
for m in range(1, months + 1): 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), []) month_txs = txs_by_year_month.get((acc.id, year, m), [])
for tx in month_txs: for tx in month_txs:
if tx.type in ('deposit', 'transfer'): if tx.type in ('deposit', 'transfer'):
cd_funding_tx = tx cd_funding_tx = tx
break 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: if cd_funding_tx:
funding_month = cd_funding_tx.date.month funding_month = cd_funding_tx.date.month
funding_amount = cd_funding_tx.amount funding_amount = cd_funding_tx.amount
logger.debug(f"CD funding for {acc.name} (id={acc.id}) in month {funding_month}: amount={funding_amount}") logger.debug(f"CD funding for {acc.name} (id={acc.id}) in month {funding_month}: amount={funding_amount}")
break 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: 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( all_cd_txs = db.query(models.Transaction).filter(
models.Transaction.account_id == acc.id,
models.Transaction.type.in_(('deposit', 'transfer')), 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() ).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: if all_cd_txs:
cd_funding_tx = all_cd_txs[0] # First funding transaction cd_funding_tx = all_cd_txs[0] # First funding transaction
funding_month = cd_funding_tx.date.month funding_month = cd_funding_tx.date.month
funding_amount = cd_funding_tx.amount 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 # For CDs, determine starting balance based on whether they should exist
if cd_funding_tx: 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) cd_start_age_months = (year - cd_funding_tx.date.year) * 12 + (1 - cd_funding_tx.date.month)
if cd_start_age_months >= 0: 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: if prev_dec_balance is not None:
balance = prev_dec_balance balance = prev_dec_balance
logger.debug(f"[{acc.name}] CD account, using Dec {year-1} ending balance: {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 prev_dec_balance = None
if (year-1, 12) in monthly_actuals: if (year-1, 12) in monthly_actuals:
prev_dec_balance = monthly_actuals[(year-1, 12)] 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) # Determine starting balance for forecast (skip for CDs since we already set them to $0)
if not is_cd: if not is_cd:
if earliest_actual_month == 1 and (year, 1) in monthly_actuals: 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 if (maturity_date.year == forecast_year and
maturity_date.month == forecast_month and maturity_date.month == forecast_month and
primary_account_id is not None): primary_account_id is not None):
# Find the CD balance just before maturity # For CD maturity, we need to calculate the balance just before maturity
prev_balance = balance # This will be the balance from the previous month's calculation
if forecast_month > 1: # We'll store this in a variable that gets updated as we process each account
prev_month_date = datetime(forecast_year, forecast_month - 1, 1) matured_amount = balance # This is the CD balance before maturity
prev_key = (forecast_year, forecast_month - 1) if matured_amount > 0:
if prev_key in monthly_actuals: cd_maturity_by_year_month[(forecast_year, forecast_month)] += matured_amount
prev_balance = monthly_actuals[prev_key] logger.debug(f"CD {acc.name} matured: adding ${matured_amount} to CD maturity tracking")
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 continue
@@ -900,6 +912,12 @@ def forecast_balances(request: schemas.ForecastRequest, db: Session = Depends(ge
if cf != 0.0: 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}") logger.debug(f"Applying cash flow projection to primary account {acc.name} (id={acc.id}) {forecast_year}-{forecast_month:02d}: {cf}")
bal += 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 --- # --- Apply interest if applicable ---
if acc.interest_rate and acc.interest_rate > 0: if acc.interest_rate and acc.interest_rate > 0:
interest = bal * (acc.interest_rate / 100.0) / 12.0 interest = bal * (acc.interest_rate / 100.0) / 12.0