""" Pension Valuation API endpoints Exposes endpoints under /api/pensions/valuation for: - Single-life present value - Joint-survivor present value """ from __future__ import annotations from typing import Dict, Optional, List, Union, Any from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.database.base import get_db from app.models.user import User from app.auth.security import get_current_user from app.services.pension_valuation import ( SingleLifeInputs, JointSurvivorInputs, present_value_single_life, present_value_joint_survivor, ) router = APIRouter(prefix="/valuation", tags=["pensions", "pensions-valuation"]) class SingleLifeRequest(BaseModel): monthly_benefit: float = Field(ge=0) term_months: int = Field(ge=0, description="Number of months in evaluation horizon") start_age: Optional[int] = Field(default=None, ge=0) sex: str = Field(description="M, F, or A (all)") race: str = Field(description="W, B, H, or A (all)") discount_rate: float = Field(default=0.0, ge=0, description="Annual percent, e.g. 3.0") cola_rate: float = Field(default=0.0, ge=0, description="Annual percent, e.g. 2.0") defer_months: float = Field(default=0, ge=0, description="Months to delay first payment (supports fractional)") payment_period_months: int = Field(default=1, ge=1, description="Months per payment (1=monthly, 3=quarterly, 12=annual)") certain_months: int = Field(default=0, ge=0, description="Guaranteed months from commencement regardless of mortality") cola_mode: str = Field(default="monthly", description="'monthly' or 'annual_prorated'") cola_cap_percent: Optional[float] = Field(default=None, ge=0) interpolation_method: str = Field(default="linear", description="'linear' or 'step' for NA interpolation") max_age: Optional[int] = Field(default=None, ge=0, description="Optional cap on participant age for term truncation") class SingleLifeResponse(BaseModel): pv: float @router.post("/single-life", response_model=SingleLifeResponse) async def value_single_life( payload: SingleLifeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): try: pv = present_value_single_life( db, SingleLifeInputs( monthly_benefit=payload.monthly_benefit, term_months=payload.term_months, start_age=payload.start_age, sex=payload.sex, race=payload.race, discount_rate=payload.discount_rate, cola_rate=payload.cola_rate, defer_months=payload.defer_months, payment_period_months=payload.payment_period_months, certain_months=payload.certain_months, cola_mode=payload.cola_mode, cola_cap_percent=payload.cola_cap_percent, interpolation_method=payload.interpolation_method, max_age=payload.max_age, ), ) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) return SingleLifeResponse(pv=float(round(pv, 2))) class JointSurvivorRequest(BaseModel): monthly_benefit: float = Field(ge=0) term_months: int = Field(ge=0) participant_age: Optional[int] = Field(default=None, ge=0) participant_sex: str participant_race: str spouse_age: Optional[int] = Field(default=None, ge=0) spouse_sex: str spouse_race: str survivor_percent: float = Field(ge=0, le=100, description="Percent of benefit to spouse on participant death") discount_rate: float = Field(default=0.0, ge=0) cola_rate: float = Field(default=0.0, ge=0) defer_months: float = Field(default=0, ge=0) payment_period_months: int = Field(default=1, ge=1) certain_months: int = Field(default=0, ge=0) cola_mode: str = Field(default="monthly") cola_cap_percent: Optional[float] = Field(default=None, ge=0) survivor_basis: str = Field(default="contingent", description="'contingent' or 'last_survivor'") survivor_commence_participant_only: bool = Field(default=False, description="If true, survivor component uses participant survival as commencement basis") interpolation_method: str = Field(default="linear") max_age: Optional[int] = Field(default=None, ge=0) class JointSurvivorResponse(BaseModel): pv_total: float pv_participant_component: float pv_survivor_component: float @router.post("/joint-survivor", response_model=JointSurvivorResponse) async def value_joint_survivor( payload: JointSurvivorRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): try: result: Dict[str, float] = present_value_joint_survivor( db, JointSurvivorInputs( monthly_benefit=payload.monthly_benefit, term_months=payload.term_months, participant_age=payload.participant_age, participant_sex=payload.participant_sex, participant_race=payload.participant_race, spouse_age=payload.spouse_age, spouse_sex=payload.spouse_sex, spouse_race=payload.spouse_race, survivor_percent=payload.survivor_percent, discount_rate=payload.discount_rate, cola_rate=payload.cola_rate, defer_months=payload.defer_months, payment_period_months=payload.payment_period_months, certain_months=payload.certain_months, cola_mode=payload.cola_mode, cola_cap_percent=payload.cola_cap_percent, survivor_basis=payload.survivor_basis, survivor_commence_participant_only=payload.survivor_commence_participant_only, interpolation_method=payload.interpolation_method, max_age=payload.max_age, ), ) except ValueError as e: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) # Round to 2 decimals for response return JointSurvivorResponse( pv_total=float(round(result["pv_total"], 2)), pv_participant_component=float(round(result["pv_participant_component"], 2)), pv_survivor_component=float(round(result["pv_survivor_component"], 2)), ) class ErrorResponse(BaseModel): error: str class BatchSingleLifeRequest(BaseModel): # Accept raw dicts to allow per-item validation inside the loop (avoid 422 on the entire batch) items: List[Dict[str, Any]] class BatchSingleLifeItemResponse(BaseModel): success: bool result: Optional[SingleLifeResponse] = None error: Optional[str] = None class BatchSingleLifeResponse(BaseModel): results: List[BatchSingleLifeItemResponse] @router.post("/batch-single-life", response_model=BatchSingleLifeResponse) async def batch_value_single_life( payload: BatchSingleLifeRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): results = [] for item in payload.items: try: inputs = SingleLifeInputs(**item) pv = present_value_single_life(db, inputs) results.append(BatchSingleLifeItemResponse( success=True, result=SingleLifeResponse(pv=float(round(pv, 2))), )) except ValueError as e: results.append(BatchSingleLifeItemResponse( success=False, error=str(e), )) return BatchSingleLifeResponse(results=results) class BatchJointSurvivorRequest(BaseModel): # Accept raw dicts to allow per-item validation inside the loop (avoid 422 on the entire batch) items: List[Dict[str, Any]] class BatchJointSurvivorItemResponse(BaseModel): success: bool result: Optional[JointSurvivorResponse] = None error: Optional[str] = None class BatchJointSurvivorResponse(BaseModel): results: List[BatchJointSurvivorItemResponse] @router.post("/batch-joint-survivor", response_model=BatchJointSurvivorResponse) async def batch_value_joint_survivor( payload: BatchJointSurvivorRequest, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): results = [] for item in payload.items: try: inputs = JointSurvivorInputs(**item) result = present_value_joint_survivor(db, inputs) results.append(BatchJointSurvivorItemResponse( success=True, result=JointSurvivorResponse( pv_total=float(round(result["pv_total"], 2)), pv_participant_component=float(round(result["pv_participant_component"], 2)), pv_survivor_component=float(round(result["pv_survivor_component"], 2)), ), )) except ValueError as e: results.append(BatchJointSurvivorItemResponse( success=False, error=str(e), )) return BatchJointSurvivorResponse(results=results)