Source code for debt_optimizer.core.validation

"""Validation utilities for financial data and inputs."""

from typing import Any, Dict, List, Tuple

from .financial_calc import Debt, Income, RecurringExpense


[docs] class ValidationError(ValueError): """Custom exception for validation errors.""" pass
[docs] def validate_debt_data(debt_data) -> List[str]: """Validate debt data - can handle both dictionaries and lists of Debt objects. Args: debt_data: Dictionary containing debt information or list of Debt objects Returns: List of validation error messages (empty if valid) """ errors = [] # Handle list of Debt objects if isinstance(debt_data, list): for i, debt in enumerate(debt_data): if isinstance(debt, Debt): # Since the Debt object was created successfully, it passed __post_init__ validation # noqa: E501 # Just check for any logical issues try: if not debt.name or debt.name.strip() == "": errors.append(f"Debt {i + 1}: Name cannot be empty") # Additional validation can go here if needed except Exception: errors.append(f"Debt {i + 1}: Invalid debt data") else: errors.append(f"Item {i + 1}: Expected Debt object, got {type(debt)}") return errors # Handle dictionary format (original functionality) if not isinstance(debt_data, dict): errors.append("Debt data must be a dictionary or list of Debt objects") return errors # Required fields required_fields = [ "name", "balance", "minimum_payment", "interest_rate", "due_date", ] for field in required_fields: if field not in debt_data or debt_data[field] is None: errors.append(f"Missing required field: {field}") # Validate numeric fields if "balance" in debt_data: try: balance = float(debt_data["balance"]) if balance < 0: errors.append("Balance cannot be negative") except (ValueError, TypeError): errors.append("Balance must be a valid number") if "minimum_payment" in debt_data: try: min_payment = float(debt_data["minimum_payment"]) if min_payment < 0: errors.append("Minimum payment cannot be negative") except (ValueError, TypeError): errors.append("Minimum payment must be a valid number") if "interest_rate" in debt_data: try: interest_rate = float(debt_data["interest_rate"]) if interest_rate < 0 or interest_rate > 100: errors.append("Interest rate must be between 0 and 100") except (ValueError, TypeError): errors.append("Interest rate must be a valid number") if "due_date" in debt_data: try: due_date = int(debt_data["due_date"]) if due_date < 1 or due_date > 31: errors.append("Due date must be between 1 and 31") except (ValueError, TypeError): errors.append("Due date must be a valid integer") return errors
[docs] def validate_income_data(income_data) -> List[str]: """Validate income data - can handle both dictionaries and lists of Income objects. Args: income_data: Dictionary containing income information or list of Income objects Returns: List of validation error messages (empty if valid) """ errors = [] # Handle list of Income objects if isinstance(income_data, list): for i, income in enumerate(income_data): if isinstance(income, Income): # Income objects have their own validation in __post_init__ try: if not income.source or income.source.strip() == "": errors.append(f"Income {i + 1}: Source cannot be empty") if income.amount <= 0: errors.append(f"Income {i + 1}: Amount must be positive") except Exception: errors.append(f"Income {i + 1}: Invalid income data") else: errors.append( f"Item {i + 1}: Expected Income object, got {type(income)}" ) # noqa: E501 return errors # Handle dictionary format (original functionality) if not isinstance(income_data, dict): errors.append("Income data must be a dictionary or list of Income objects") return errors # Required fields required_fields = ["source", "amount", "frequency"] for field in required_fields: if field not in income_data or income_data[field] is None: errors.append(f"Missing required field: {field}") # Validate amount if "amount" in income_data: try: amount = float(income_data["amount"]) if amount <= 0: errors.append("Income amount must be positive") except (ValueError, TypeError): errors.append("Income amount must be a valid number") # Validate frequency if "frequency" in income_data: valid_frequencies = ["weekly", "bi-weekly", "monthly", "quarterly", "annually"] frequency = str(income_data["frequency"]).lower().strip() if frequency not in valid_frequencies: errors.append( f"Invalid frequency. Must be one of: {', '.join(valid_frequencies)}" ) return errors
[docs] def validate_expense_data(expense_data) -> List[str]: """Validate expense data - can handle both dictionaries and lists of RecurringExpense objects. # noqa: E501 Args: expense_data: Dictionary containing expense information or list of RecurringExpense objects # noqa: E501 Returns: List of validation error messages (empty if valid) """ errors = [] # Handle list of RecurringExpense objects if isinstance(expense_data, list): for i, expense in enumerate(expense_data): if isinstance(expense, RecurringExpense): # RecurringExpense objects have their own validation in __post_init__ try: if not expense.description or expense.description.strip() == "": errors.append(f"Expense {i + 1}: Description cannot be empty") if expense.amount <= 0: errors.append(f"Expense {i + 1}: Amount must be positive") except Exception: errors.append(f"Expense {i + 1}: Invalid expense data") else: errors.append( f"Item {i + 1}: Expected RecurringExpense object, got {type(expense)}" # noqa: E501 ) return errors # Handle dictionary format (original functionality) if not isinstance(expense_data, dict): errors.append( "Expense data must be a dictionary or list of RecurringExpense objects" ) return errors # Required fields required_fields = ["description", "amount", "frequency"] for field in required_fields: if field not in expense_data or expense_data[field] is None: errors.append(f"Missing required field: {field}") # Validate amount if "amount" in expense_data: try: amount = float(expense_data["amount"]) if amount <= 0: errors.append("Expense amount must be positive") except (ValueError, TypeError): errors.append("Expense amount must be a valid number") # Validate frequency if "frequency" in expense_data: valid_frequencies = ["weekly", "bi-weekly", "monthly", "quarterly", "annually"] frequency = str(expense_data["frequency"]).lower().strip() if frequency not in valid_frequencies: errors.append( f"Invalid frequency. Must be one of: {', '.join(valid_frequencies)}" ) # Validate due_date if present if "due_date" in expense_data: try: due_date = int(expense_data["due_date"]) if due_date < 1 or due_date > 31: errors.append("Due date must be between 1 and 31") except (ValueError, TypeError): errors.append("Due date must be a valid integer") return errors
[docs] def validate_financial_scenario( debts: List[Debt], income_sources: List[Income], recurring_expenses: List[RecurringExpense], settings: Dict[str, Any], ) -> Tuple[bool, List[str]]: """Validate a complete financial scenario for logical consistency. Args: debts: List of debt objects income_sources: List of income objects recurring_expenses: List of recurring expense objects settings: Settings dictionary Returns: Tuple of (is_valid, error_messages) """ errors = [] warnings = [] # Handle None inputs if debts is None: debts = [] if income_sources is None: income_sources = [] if recurring_expenses is None: recurring_expenses = [] if settings is None: settings = {} # Check if we have debts if not debts: errors.append("No debts provided - nothing to optimize") # Check if we have income if not income_sources: errors.append("No income sources provided") # Calculate totals for validation if debts and income_sources: total_debt = sum(debt.balance for debt in debts) total_minimum_payments = sum(debt.minimum_payment for debt in debts) total_monthly_income = sum( income.get_monthly_amount() for income in income_sources ) total_monthly_expenses = sum( expense.get_monthly_amount() for expense in recurring_expenses ) # Check basic financial viability if total_monthly_income < total_minimum_payments: errors.append( f"Monthly income (${total_monthly_income:.2f}) is less than " f"minimum debt payments (${total_minimum_payments:.2f})" ) available_cashflow = ( total_monthly_income - total_minimum_payments - total_monthly_expenses ) if available_cashflow < 0: errors.append( f"Insufficient cash flow: Monthly income (${total_monthly_income:.2f}) " f"minus minimum payments (${total_minimum_payments:.2f}) " f"minus expenses (${total_monthly_expenses:.2f}) " f"= ${available_cashflow:.2f}" ) # Warnings for potential issues if ( total_debt > total_monthly_income * 60 ): # More than 5 years of income in debt warnings.append( f"Very high debt-to-income ratio: ${total_debt:.2f} debt vs ${total_monthly_income:.2f} monthly income" # noqa: E501 ) if available_cashflow < 100: warnings.append( f"Very tight cash flow: Only ${available_cashflow:.2f} available after minimums and expenses" # noqa: E501 ) # Validate settings if "current_bank_balance" in settings: try: bank_balance = float(settings["current_bank_balance"]) if bank_balance < 0: warnings.append("Negative bank balance may cause cash flow issues") except (ValueError, TypeError): errors.append("Invalid bank balance in settings") is_valid = len(errors) == 0 all_messages = errors + [f"Warning: {w}" for w in warnings] return is_valid, all_messages
[docs] def validate_optimization_goal(goal: str) -> bool: """Validate optimization goal string. Args: goal: Goal string to validate Returns: True if valid, False otherwise """ valid_goals = ["minimize_interest", "minimize_time", "maximize_cashflow"] return goal.lower() in valid_goals