diff --git a/backend/agents/deterministic/financial_reality.py b/backend/agents/deterministic/financial_reality.py index 8fafd69..9eee192 100644 --- a/backend/agents/deterministic/financial_reality.py +++ b/backend/agents/deterministic/financial_reality.py @@ -2,13 +2,29 @@ Financial Reality Agent — Pure math, no AI. Standard amortization formula. Deterministic output for the same inputs every time. No imports from Dev 2's files. + +Feature additions: + Feature 3: Bank FOIR (Eligibility) Underwriting Check + - Computes total FOIR = (new EMI + existing EMIs) / monthly income + - Flags breach if FOIR > 50% (RBI benchmark) + + Feature 5: Property Age & Structural Depreciation Factor + - RCC building expected lifespan: 60 years + - Banks typically reduce LTV below standard 80% for buildings >20 yrs old + - Buildings >40 yrs: max LTV 50–60%; structurally risky """ +from datetime import date +from typing import Optional, Tuple from schemas.schemas import ( UserInput, FinancialRealityOutput, AffordabilityStatus, IndiaCostBreakdown ) from engines.india_defaults import calculate_true_total_cost +# --------------------------------------------------------------------------- +# EMI helpers +# --------------------------------------------------------------------------- + def _calculate_emi(principal: float, annual_rate: float, tenure_years: int) -> float: """Standard EMI amortization formula: EMI = P * r * (1+r)^n / ((1+r)^n - 1)""" if principal <= 0: @@ -37,12 +53,105 @@ def _reverse_loan_from_emi(target_emi: float, annual_rate: float, tenure_years: return principal +# --------------------------------------------------------------------------- +# Feature 3: FOIR Underwriting Check +# --------------------------------------------------------------------------- + +FOIR_LIMIT = 0.50 # RBI benchmark; some banks use 0.55 for HNI + +def _compute_foir( + emi: float, + existing_obligations: float, + monthly_income: float, +) -> Tuple[float, bool, Optional[str]]: + """ + Returns (foir_ratio, foir_breach, warning_message). + + foir_ratio = (new_EMI + all_existing_EMIs) / monthly_income + """ + if monthly_income <= 0: + return 1.0, True, "Income not provided — FOIR cannot be calculated." + + total_obligations = emi + existing_obligations + foir = total_obligations / monthly_income + + if foir > FOIR_LIMIT: + warning = ( + f"FOIR is {foir*100:.1f}% — exceeds the RBI 50% underwriting limit. " + f"Total monthly debt (₹{total_obligations:,.0f}) on income of " + f"₹{monthly_income:,.0f} will likely trigger a bank rejection. " + f"Consider reducing loan amount or increasing income documentation." + ) + return round(foir, 4), True, warning + + return round(foir, 4), False, None + + +# --------------------------------------------------------------------------- +# Feature 5: Property Age & Structural Depreciation +# --------------------------------------------------------------------------- + +RCC_LIFESPAN_YEARS = 60 # Standard RCC building life expectancy +LTV_STANDARD = 0.80 # Bank standard LTV for properties < 20 yrs +LTV_MODERATE = 0.70 # 20–30 yrs old +LTV_AGED = 0.60 # 30–40 yrs old +LTV_OLD = 0.50 # > 40 yrs old + + +def _compute_depreciation( + construction_year: Optional[int], +) -> Tuple[Optional[int], Optional[float], bool, Optional[str]]: + """ + Returns (structural_life_remaining, ltv_recommended_pct, ltv_age_risk, warning). + """ + if construction_year is None: + return None, None, False, None + + current_year = date.today().year + building_age = current_year - construction_year + life_remaining = max(0, RCC_LIFESPAN_YEARS - building_age) + + if building_age < 20: + ltv_pct = LTV_STANDARD + risk = False + warning = None + elif building_age < 30: + ltv_pct = LTV_MODERATE + risk = True + warning = ( + f"Building is {building_age} years old. Banks may restrict LTV to 70% " + f"(vs. standard 80%), increasing the required down payment significantly." + ) + elif building_age < 40: + ltv_pct = LTV_AGED + risk = True + warning = ( + f"Building is {building_age} years old — entering the aging bracket. " + f"Expect bank LTV of ~60%. Structural survey strongly recommended before purchase." + ) + else: + ltv_pct = LTV_OLD + risk = True + warning = ( + f"CRITICAL: Building is {building_age} years old with only " + f"~{life_remaining} years of structural life remaining. " + f"Banks typically offer max LTV of 50%. High risk of future maintenance " + f"levy, redevelopment disputes, and difficulty reselling." + ) + + return life_remaining, ltv_pct, risk, warning + + +# --------------------------------------------------------------------------- +# Main calculation +# --------------------------------------------------------------------------- + def calculate_affordability(user_input: UserInput) -> FinancialRealityOutput: """ Calculates the complete financial reality of a home purchase. Pure math. Zero LLM involvement. Deterministic. """ - # --- India cost breakdown --- + # --- India cost breakdown (now with GST slab, district stamp duty, hidden fees) --- loan_amount = user_input.property_price - user_input.down_payment if loan_amount < 0: loan_amount = 0.0 @@ -53,6 +162,9 @@ def calculate_affordability(user_input: UserInput) -> FinancialRealityOutput: property_type=user_input.property_type.value, loan_amount=loan_amount, area_sqft=user_input.area_sqft if user_input.area_sqft else 1000, + area_sqm=user_input.area_sqm, + district=user_input.district, + is_female_buyer=user_input.is_female_buyer, ) # --- EMI calculation --- @@ -70,7 +182,6 @@ def calculate_affordability(user_input: UserInput) -> FinancialRealityOutput: monthly_surplus_after_emi = user_input.monthly_income - user_input.monthly_expenses - emi # --- 12-month cash flow projection --- - # Shows cumulative savings each month starting from current savings cash_flow_12_months = [] running_savings = user_input.total_savings - user_input.down_payment savings_depletion_month = None @@ -82,12 +193,10 @@ def calculate_affordability(user_input: UserInput) -> FinancialRealityOutput: savings_depletion_month = month # --- Safe and maximum property prices --- - # Safe: EMI = exactly 35% of income target_emi_safe = user_input.monthly_income * 0.35 safe_loan = _reverse_loan_from_emi(target_emi_safe, user_input.annual_interest_rate, user_input.tenure_years) safe_property_price = safe_loan + user_input.down_payment - # Maximum: EMI = exactly 50% of income target_emi_max = user_input.monthly_income * 0.50 max_loan = _reverse_loan_from_emi(target_emi_max, user_input.annual_interest_rate, user_input.tenure_years) maximum_property_price = max_loan + user_input.down_payment @@ -100,6 +209,18 @@ def calculate_affordability(user_input: UserInput) -> FinancialRealityOutput: else: affordability_status = AffordabilityStatus.OVEREXTENDED + # --- Feature 3: FOIR Underwriting Check --- + foir_ratio, foir_breach, foir_warning = _compute_foir( + emi=emi, + existing_obligations=user_input.existing_emi_obligations, + monthly_income=user_input.monthly_income, + ) + + # --- Feature 5: Property Age & Structural Depreciation --- + structural_life_remaining, ltv_recommended_pct, ltv_age_risk, age_depreciation_warning = ( + _compute_depreciation(user_input.construction_year) + ) + return FinancialRealityOutput( emi=round(emi, 2), emi_to_income_ratio=round(emi_to_income_ratio, 4), @@ -112,4 +233,14 @@ def calculate_affordability(user_input: UserInput) -> FinancialRealityOutput: india_cost_breakdown=india_cost_breakdown, loan_amount=round(loan_amount, 2), total_interest_payable=round(total_interest_payable, 2), + # Feature 3: FOIR + foir_ratio=foir_ratio, + foir_limit=FOIR_LIMIT, + foir_breach=foir_breach, + foir_warning=foir_warning, + # Feature 5: Depreciation + structural_life_remaining=structural_life_remaining, + ltv_recommended_pct=ltv_recommended_pct, + ltv_age_risk=ltv_age_risk, + age_depreciation_warning=age_depreciation_warning, ) diff --git a/backend/engines/compute.py b/backend/engines/compute.py new file mode 100644 index 0000000..453f940 --- /dev/null +++ b/backend/engines/compute.py @@ -0,0 +1,67 @@ +""" +Headless Compute Engine — All deterministic math in one function call. + +compute_all() runs every deterministic agent in sequence and returns the +complete bundled result. This is the function behind the /api/v1/calculate +endpoint — designed for sub-50ms execution with zero LLM involvement. + +Use cases: + - Frontend real-time sliders that update as the user drags + - Comparison tools that need fast recalculation + - Batch processing without burning API quota + - Unit testing the math pipeline in isolation +""" +import sys +import os +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from schemas.schemas import UserInput, ComputeAllOutput +from engines.india_defaults import calculate_true_total_cost +from agents.deterministic.financial_reality import calculate_affordability +from agents.deterministic.scenario_simulation import run_all_scenarios +from agents.deterministic.risk_scorer import calculate_risk_score + + +def compute_all(user_input: UserInput) -> ComputeAllOutput: + """ + Run every deterministic calculation and return the bundled result. + + Execution order (respects dependencies): + 1. india_defaults — hidden cost breakdown (independent) + 2. affordability — EMI, ratios, cash flow (independent) + 3. scenarios — stress tests (depends on #2) + 4. risk_score — composite score (depends on #2 and #3) + + Total execution time target: < 50ms on a single CPU core. + All functions are pure Python arithmetic with no I/O. + """ + loan_amount = user_input.property_price - user_input.down_payment + if loan_amount < 0: + loan_amount = 0.0 + + india_costs = calculate_true_total_cost( + base_price=user_input.property_price, + state=user_input.state, + property_type=user_input.property_type.value, + loan_amount=loan_amount, + area_sqft=user_input.area_sqft if user_input.area_sqft else 1000, + area_sqm=user_input.area_sqm, + district=user_input.district, + is_female_buyer=user_input.is_female_buyer, + ) + + financial_reality = calculate_affordability(user_input) + all_scenarios = run_all_scenarios(user_input, financial_reality) + risk_score = calculate_risk_score( + financial_reality=financial_reality, + all_scenarios=all_scenarios, + age=user_input.age, + tenure_years=user_input.tenure_years, + ) + + return ComputeAllOutput( + india_cost_breakdown=india_costs, + financial_reality=financial_reality, + all_scenarios=all_scenarios, + risk_score=risk_score, + ) diff --git a/backend/engines/india_defaults.py b/backend/engines/india_defaults.py index 724a875..1ae8e8d 100644 --- a/backend/engines/india_defaults.py +++ b/backend/engines/india_defaults.py @@ -1,35 +1,210 @@ """ India real estate hidden cost calculations. -All stamp duty rates, registration fees, GST, maintenance deposit, -loan processing, legal charges, and Section 80C/24B tax benefits. + +Feature upgrades (Institutional-Grade Financial Math): + 1. Dynamic GST Slab Auto-Classifier + - 0% for ready-to-move + - 1% for affordable housing (≤₹45L AND ≤90 sqm carpet area) + - 5% for all other under-construction properties + + 2. Exact District-Level Stamp Duty Engine + - District-level overrides for MH, DL, KA, TN, GJ, HR + - 1% female buyer concession where applicable + + 3. Bank FOIR (Eligibility) Underwriting Check [computed in financial_reality.py] + + 4. The "Hidden Fee" Aggregator + - Bank processing fee (SBI 0.35% benchmark) + - Legal / technical verification fee + - Annual BMC / municipal property tax estimate + + 5. Property Age & Structural Depreciation Factor [computed in financial_reality.py] + No AI. Pure data and math. """ +import math +from datetime import date +from typing import Optional, Dict, Tuple, Set from schemas.schemas import IndiaCostBreakdown -# State-specific stamp duty rates (as of 2024-2025) -STAMP_DUTY_RATES = { - "maharashtra": 0.05, - "karnataka": 0.056, - "delhi": 0.06, - "tamil_nadu": 0.07, - "gujarat": 0.045, - "rajasthan": 0.06, - "west_bengal": 0.06, - "telangana": 0.05, - "andhra_pradesh": 0.05, - "punjab": 0.07, - "haryana": 0.07, - "uttar_pradesh": 0.07, - "madhya_pradesh": 0.075, - "kerala": 0.08, - "goa": 0.035, + +# --------------------------------------------------------------------------- +# Feature 1: GST Slab Classification +# --------------------------------------------------------------------------- + +AFFORDABLE_HOUSING_PRICE_LIMIT = 4_500_000 # ₹45 lakh +AFFORDABLE_HOUSING_SQM_LIMIT = 90 # 90 sq metres carpet area + +GST_SLAB_EXEMPT = "exempt" +GST_SLAB_AFFORDABLE = "affordable_1pct" +GST_SLAB_STANDARD = "standard_5pct" + + +def _classify_gst_slab( + property_type: str, + base_price: float, + area_sqm: Optional[float], +) -> Tuple[str, float, float]: + """ + Determines the correct GST slab. + + Returns: + (slab_code, gst_rate, gst_amount) + """ + if property_type != "under_construction": + return GST_SLAB_EXEMPT, 0.0, 0.0 + + # Affordable housing: price ≤ ₹45L AND carpet area ≤ 90 sqm + if ( + base_price <= AFFORDABLE_HOUSING_PRICE_LIMIT + and area_sqm is not None + and area_sqm <= AFFORDABLE_HOUSING_SQM_LIMIT + ): + return GST_SLAB_AFFORDABLE, 0.01, base_price * 0.01 + + return GST_SLAB_STANDARD, 0.05, base_price * 0.05 + + +# --------------------------------------------------------------------------- +# Feature 2: District-Level Stamp Duty Engine +# --------------------------------------------------------------------------- + +# State-level base rates (2024-25) +STATE_STAMP_DUTY_RATES: Dict[str, float] = { + "maharashtra": 0.05, + "karnataka": 0.056, + "delhi": 0.06, + "tamil_nadu": 0.07, + "gujarat": 0.045, + "rajasthan": 0.06, + "west_bengal": 0.06, + "telangana": 0.05, + "andhra_pradesh": 0.05, + "punjab": 0.07, + "haryana": 0.07, + "uttar_pradesh": 0.07, + "madhya_pradesh": 0.075, + "kerala": 0.08, + "goa": 0.035, +} + +# District-level overrides (state → district → rate) +DISTRICT_STAMP_DUTY_OVERRIDES: dict[str, Dict[str, float]] = { + "maharashtra": { + "mumbai_city": 0.05, # flat 5% (registration capped ₹30k) + "mumbai_suburban":0.05, + "pune": 0.07, # Pune adds 1% metro cess + LBT + "thane": 0.07, + "nagpur": 0.06, + "nashik": 0.06, + "aurangabad": 0.06, + }, + "delhi": { + "new_delhi": 0.06, # male buyer + "south_delhi": 0.06, + "north_delhi": 0.06, + "east_delhi": 0.06, + "west_delhi": 0.06, + "central_delhi": 0.06, + }, + "karnataka": { + "bengaluru_urban":0.056, + "mysuru": 0.056, + "mangaluru": 0.056, + "hubballi": 0.056, + }, + "tamil_nadu": { + "chennai": 0.07, + "coimbatore": 0.07, + "madurai": 0.07, + "tiruchirappalli":0.07, + }, + "haryana": { + "gurugram": 0.07, + "faridabad": 0.07, + "sonipat": 0.07, + "panipat": 0.07, + }, + "gujarat": { + "ahmedabad": 0.045, + "surat": 0.045, + "vadodara": 0.045, + "rajkot": 0.045, + }, +} + +# States that offer female buyer concession (1% off stamp duty) +FEMALE_CONCESSION_STATES: Set[str] = { + "maharashtra", "delhi", "haryana", "uttar_pradesh", + "rajasthan", "punjab", "himachal_pradesh", } -# Default stamp duty rate if state is not in the lookup DEFAULT_STAMP_DUTY_RATE = 0.06 +def _calculate_stamp_duty( + base_price: float, + state: str, + district: Optional[str], + is_female_buyer: bool, +) -> tuple[float, float, bool]: + """ + Returns (stamp_duty_amount, effective_rate, concession_applied). + """ + # Look up district first, fall back to state, then global default + state_districts = DISTRICT_STAMP_DUTY_OVERRIDES.get(state, {}) + if district and district in state_districts: + base_rate = state_districts[district] + else: + base_rate = STATE_STAMP_DUTY_RATES.get(state, DEFAULT_STAMP_DUTY_RATE) + + # Apply female buyer concession + concession_applied = False + if is_female_buyer and state in FEMALE_CONCESSION_STATES: + effective_rate = max(0.0, base_rate - 0.01) + concession_applied = True + else: + effective_rate = base_rate + + stamp_duty = base_price * effective_rate + return stamp_duty, effective_rate, concession_applied + + +# --------------------------------------------------------------------------- +# Feature 4: Hidden Fee Aggregator +# --------------------------------------------------------------------------- + +# Bank processing fee (SBI benchmark: 0.35%, min ₹2k max ₹10k) +BANK_PROCESSING_FEE_RATE = 0.0035 +BANK_PROCESSING_FEE_MIN = 2_000 +BANK_PROCESSING_FEE_MAX = 10_000 + +# Legal / technical verification fee charged by bank (approximate) +LEGAL_VERIFICATION_FEE = 8_500.0 + +# Annual municipal property tax: approximately 0.1% of property value +# (BMC in Mumbai charges ₹1–3/sqft/year; 0.1% is a conservative city estimate) +ANNUAL_PROPERTY_TAX_RATE = 0.001 + + +def _compute_hidden_fees( + loan_amount: float, + base_price: float, +) -> Tuple[float, float, float]: + """ + Returns (bank_processing_fee, legal_verification_fee, annual_property_tax). + """ + bank_fee = loan_amount * BANK_PROCESSING_FEE_RATE + bank_fee = max(BANK_PROCESSING_FEE_MIN, min(BANK_PROCESSING_FEE_MAX, bank_fee)) + annual_tax = base_price * ANNUAL_PROPERTY_TAX_RATE + return round(bank_fee, 2), round(LEGAL_VERIFICATION_FEE, 2), round(annual_tax, 2) + + +# --------------------------------------------------------------------------- +# Main function +# --------------------------------------------------------------------------- + def calculate_true_total_cost( base_price: float, state: str, @@ -37,53 +212,59 @@ def calculate_true_total_cost( loan_amount: float, area_sqft: float = 1000, maintenance_per_sqft: float = 3.0, + # Feature 1 + area_sqm: Optional[float] = None, + # Feature 2 + district: Optional[str] = None, + is_female_buyer: bool = False, ) -> IndiaCostBreakdown: """ Calculates the true total cost of acquiring an Indian property, including all hidden fees that buyers typically don't account for. - Args: - base_price: Listed property price in rupees. - state: Indian state name (used for stamp duty lookup). - property_type: "under_construction" or "ready_to_move". - loan_amount: Principal loan amount in rupees. - area_sqft: Property area in sq ft for maintenance calculation. - maintenance_per_sqft: Monthly maintenance rate per sq ft. - - Returns: - IndiaCostBreakdown with every cost component and the true total. + Feature upgrades included: + - GST auto-classification (0% / 1% affordable / 5% standard) + - District-level stamp duty with female buyer concession + - Itemised bank processing fee, legal verification fee, annual property tax """ - normalized_state = state.lower().strip().replace(" ", "_") + normalized_state = state.lower().strip().replace(" ", "_") + normalized_district = district.lower().strip().replace(" ", "_") if district else None - # --- Stamp duty --- - stamp_duty_rate = STAMP_DUTY_RATES.get(normalized_state, DEFAULT_STAMP_DUTY_RATE) - stamp_duty = base_price * stamp_duty_rate + # --- Feature 2: Stamp duty (district-aware, female concession) --- + stamp_duty, stamp_duty_rate, female_concession = _calculate_stamp_duty( + base_price=base_price, + state=normalized_state, + district=normalized_district, + is_female_buyer=is_female_buyer, + ) # --- Registration fee --- # Generally 1% across India registration_fee = base_price * 0.01 # Maharashtra specific cap: max ₹30,000 - if normalized_state == "maharashtra" and registration_fee > 30000: - registration_fee = 30000.0 - - # --- GST --- - # 5% on under-construction properties, 0% on ready-to-move - gst_applicable = property_type == "under_construction" - if gst_applicable: - gst = base_price * 0.05 - else: - gst = 0.0 + if normalized_state == "maharashtra" and registration_fee > 30_000: + registration_fee = 30_000.0 + + # --- Feature 1: GST Auto-Classifier --- + gst_slab, _gst_rate, gst = _classify_gst_slab(property_type, base_price, area_sqm) + gst_applicable = gst_slab != GST_SLAB_EXEMPT # --- Maintenance deposit --- # Typically 24 months of maintenance upfront maintenance_deposit = maintenance_per_sqft * area_sqft * 24.0 - # --- Loan processing fee --- - # 0.75% of loan amount + # --- Loan processing fee (legacy, kept for backward compat) --- + # Now superseded by itemised bank_processing_fee below loan_processing_fee = loan_amount * 0.0075 - # --- Legal charges --- - legal_charges = 15000.0 + # --- Feature 4: Hidden fee aggregator --- + bank_processing_fee, legal_verification_fee, annual_property_tax = _compute_hidden_fees( + loan_amount=loan_amount, + base_price=base_price, + ) + + # --- Legacy legal charges (solicitor / title search) --- + legal_charges = 15_000.0 # --- True total cost --- true_total_cost = ( @@ -92,27 +273,33 @@ def calculate_true_total_cost( + registration_fee + gst + maintenance_deposit - + loan_processing_fee + + bank_processing_fee # replaces old loan_processing_fee in true cost + + legal_verification_fee + legal_charges + # annual_property_tax is recurring so NOT added to one-time true_total_cost + # but surfaced separately in the breakdown for transparency ) # --- Tax benefits --- - # Section 80C: deduction on principal repayment, max ₹1,50,000/year - tax_benefit_80c = 150000.0 - # Section 24B: deduction on interest payment, max ₹2,00,000/year - tax_benefit_24b = 200000.0 + tax_benefit_80c = 150_000.0 # Section 80C: max ₹1.5L/yr on principal + tax_benefit_24b = 200_000.0 # Section 24B: max ₹2L/yr on interest return IndiaCostBreakdown( base_price=base_price, - stamp_duty=stamp_duty, - stamp_duty_rate=stamp_duty_rate, - registration_fee=registration_fee, - gst=gst, + stamp_duty=round(stamp_duty, 2), + stamp_duty_rate=round(stamp_duty_rate, 4), + female_buyer_concession_applied=female_concession, + registration_fee=round(registration_fee, 2), + gst=round(gst, 2), gst_applicable=gst_applicable, - maintenance_deposit=maintenance_deposit, - loan_processing_fee=loan_processing_fee, - legal_charges=legal_charges, - true_total_cost=true_total_cost, + gst_slab=gst_slab, + maintenance_deposit=round(maintenance_deposit, 2), + loan_processing_fee=round(loan_processing_fee, 2), + bank_processing_fee=bank_processing_fee, + legal_verification_fee=legal_verification_fee, + annual_property_tax=annual_property_tax, + legal_charges=round(legal_charges, 2), + true_total_cost=round(true_total_cost, 2), tax_benefit_80c=tax_benefit_80c, tax_benefit_24b=tax_benefit_24b, ) diff --git a/backend/schemas/schemas.py b/backend/schemas/schemas.py index 7724f9e..a75c843 100644 --- a/backend/schemas/schemas.py +++ b/backend/schemas/schemas.py @@ -129,6 +129,19 @@ class UserInput(BaseModel): area_sqft: Optional[float] = None # Session this input belongs to session_id: str + # --- Feature 1: GST Auto-Classifier --- + # Property carpet area in sq metres (needed for affordable housing GST classification) + area_sqm: Optional[float] = None + # --- Feature 2: District-Level Stamp Duty --- + # Specific district within the state (e.g. 'mumbai_city', 'pune') for exact rates + district: Optional[str] = None + # True if primary buyer is female (gets 1% concession in most states) + is_female_buyer: bool = False + # --- Feature 5: Property Age / Depreciation --- + # Year the building was constructed (for resale properties) + construction_year: Optional[int] = None + # Existing loan obligations per month in rupees (for FOIR calculation) + existing_emi_obligations: float = 0.0 class BehavioralAnswer(BaseModel): @@ -161,11 +174,21 @@ class IndiaCostBreakdown(BaseModel): base_price: float stamp_duty: float stamp_duty_rate: float + # True if female buyer concession was applied + female_buyer_concession_applied: bool = False registration_fee: float gst: float gst_applicable: bool + # GST classification: 'exempt', 'affordable_1pct', 'standard_5pct' + gst_slab: str = "exempt" maintenance_deposit: float loan_processing_fee: float + # Itemised bank processing fee (e.g. SBI 0.35%) + bank_processing_fee: float = 0.0 + # One-time legal / technical verification fee charged by bank + legal_verification_fee: float = 0.0 + # Estimated annual BMC/municipal property tax + annual_property_tax: float = 0.0 legal_charges: float true_total_cost: float # Annual tax benefit under Section 80C on principal repayment @@ -194,6 +217,24 @@ class FinancialRealityOutput(BaseModel): india_cost_breakdown: IndiaCostBreakdown loan_amount: float total_interest_payable: float + # --- Feature 3: FOIR Underwriting Check --- + # Total Fixed Obligation to Income Ratio (EMI + existing obligations / income) + foir_ratio: float = 0.0 + # RBI standard threshold (typically 0.50–0.55) + foir_limit: float = 0.50 + # True if FOIR exceeds limit — bank will likely reject the loan + foir_breach: bool = False + # Human-readable FOIR warning message + foir_warning: Optional[str] = None + # --- Feature 5: Property Age / Depreciation --- + # Remaining structural life in years (RCC buildings: 60yr lifespan) + structural_life_remaining: Optional[int] = None + # Recommended max LTV banks will offer based on building age + ltv_recommended_pct: Optional[float] = None + # True if bank may reduce LTV due to building age + ltv_age_risk: bool = False + # Human-readable age risk warning + age_depreciation_warning: Optional[str] = None class ScenarioOutput(BaseModel): @@ -576,4 +617,16 @@ class SessionStartResponse(BaseModel): class ConversationResponse(BaseModel): session_id: str conversation_output: ConversationOutput - updated_analysis: Optional[AnalysisResponse] = None \ No newline at end of file + updated_analysis: Optional[AnalysisResponse] = None + + +# ----------------------------------------------------------------------------- +# Headless compute engine output (PR: headless-calculate-endpoint) +# ----------------------------------------------------------------------------- + +class ComputeAllOutput(BaseModel): + """Bundled output of all deterministic calculations — no LLM involved.""" + india_cost_breakdown: IndiaCostBreakdown + financial_reality: FinancialRealityOutput + all_scenarios: AllScenariosOutput + risk_score: RiskScoreOutput \ No newline at end of file diff --git a/frontend/niv-ai .html b/frontend/niv-ai .html index 548803c..c51db39 100644 --- a/frontend/niv-ai .html +++ b/frontend/niv-ai .html @@ -1356,12 +1356,40 @@

Input Engine

-
Affects GST applicability
+
GST auto-classified by price & area
+ + + +
🔬 Advanced Parameters (Optional — unlock institutional-grade analysis)
+
+
+ + +
Enables district-level stamp duty (overrides state flat rate)
+
+
+ + +
Required to classify affordable housing GST (≤90 sqm + ≤₹45L = 1% GST)
+
+
+ + +
Car loans, personal loans, credit card EMIs — used for FOIR underwriting check
+
+
+ + +
For resale — triggers structural life & LTV risk assessment
+
+
+
+
+ +
@@ -1510,23 +1538,45 @@

Decision Analysis
Report

EMI / Income Ratio
Monthly Surplus
Loan Amount
+
FOIR (Total Debt / Income)
+ +
-
Hidden Cost Breakdown TRUE COST
+
True Cost of Ownership HIDDEN FEE AGGREGATOR
Base Price
-
GST
-
Stamp Duty
-
Registration
+
GST
+
Stamp Duty
+
Registration Fee
+
Bank Processing Fee
+
Legal Verification Fee
+
Maintenance Deposit
+
+ 📌 Annual Property Tax (recurring) + +
Total Acquisition Cost
+ + +
Composite Risk Score RISK ENGINE
@@ -1949,16 +1999,24 @@

Decision Analysis
Report

function getFormValues() { return { - monthly_income: parseFloat(document.getElementById('f-income').value) || null, - monthly_expenses: parseFloat(document.getElementById('f-expenses').value) || null, - total_savings: parseFloat(document.getElementById('f-savings').value) || null, - property_price: parseFloat(document.getElementById('f-price').value) || null, - down_payment: parseFloat(document.getElementById('f-down').value) || null, - tenure_years: parseInt(document.getElementById('f-tenure').value) || null, - annual_interest_rate: parseFloat(document.getElementById('f-rate').value) || null, - age: parseInt(document.getElementById('f-age').value) || null, - state: document.getElementById('f-state').value || null, - property_type: document.getElementById('f-ptype').value || null, + monthly_income: parseFloat(document.getElementById('f-income').value) || null, + monthly_expenses: parseFloat(document.getElementById('f-expenses').value) || null, + total_savings: parseFloat(document.getElementById('f-savings').value) || null, + property_price: parseFloat(document.getElementById('f-price').value) || null, + down_payment: parseFloat(document.getElementById('f-down').value) || null, + tenure_years: parseInt(document.getElementById('f-tenure').value) || null, + annual_interest_rate: parseFloat(document.getElementById('f-rate').value) || null, + age: parseInt(document.getElementById('f-age').value) || null, + state: document.getElementById('f-state').value || null, + property_type: document.getElementById('f-ptype').value || null, + // Feature 1 & 2: GST + District stamp duty + area_sqm: parseFloat(document.getElementById('f-area-sqm').value) || null, + district: document.getElementById('f-district').value.trim() || null, + is_female_buyer: document.getElementById('f-female-buyer').checked, + // Feature 3: FOIR + existing_emi_obligations: parseFloat(document.getElementById('f-existing-emi').value) || 0, + // Feature 5: Property Age + construction_year: parseInt(document.getElementById('f-construction-year').value) || null, }; } @@ -2554,9 +2612,9 @@

Decision Analysis
Report

if (!r) { showToast('No analysis results available'); return; } const fr = r.financial_reality || {}; - const hc = r.hidden_costs || {}; - const ra = r.risk_assessment || {}; - const v = r.verdict || {}; + const hc = (fr.india_cost_breakdown) || r.hidden_costs || {}; + const ra = r.risk_score || r.risk_assessment || {}; + const v = r.verdict || {}; const now = new Date().toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }); // Meta @@ -2564,9 +2622,10 @@

Decision Analysis
Report

// Verdict badge const badge = document.getElementById('v-go-badge'); - const decision = v.decision || '—'; - badge.className = `go-badge ${decision === 'GO' ? 'go' : 'nogo'}`; - badge.innerHTML = `${decision}${decision === 'GO' ? 'PROCEED WITH CAUTION' : 'AVOID COMMITMENT'}`; + const decision = v.decision || (v.verdict ? v.verdict.toUpperCase() : '—'); + const isGo = decision === 'GO' || decision === 'BUY_SAFE' || decision === 'BUY_CAUTION'; + badge.className = `go-badge ${isGo ? 'go' : 'nogo'}`; + badge.innerHTML = `${isGo ? 'GO' : 'NO-GO'}${isGo ? 'PROCEED WITH CAUTION' : 'AVOID COMMITMENT'}`; // Confidence const conf = v.confidence || 0; @@ -2574,54 +2633,108 @@

Decision Analysis
Report

setTimeout(() => { document.getElementById('v-conf-bar').style.width = (conf * 100) + '%'; }, 200); // Reasoning - document.getElementById('v-reasoning').innerHTML = `Analysis Summary: ${v.reasoning || 'No reasoning provided.'}`; + document.getElementById('v-reasoning').innerHTML = `Analysis Summary: ${v.final_narrative || v.reasoning || 'No reasoning provided.'}`; - // Financial Reality + // ── Financial Reality ── document.getElementById('v-emi').textContent = fmt(fr.emi); const emiRatio = fr.emi_to_income_ratio || 0; const ratioEl = document.getElementById('v-emi-ratio'); ratioEl.textContent = (emiRatio * 100).toFixed(1) + '%'; ratioEl.className = 'stat-val ' + (emiRatio > 0.4 ? 'warn' : emiRatio > 0.3 ? 'ok' : 'safe'); - const surplus = fr.monthly_surplus || 0; + const surplus = fr.monthly_surplus_after_emi || fr.monthly_surplus || 0; const surplusEl = document.getElementById('v-surplus'); surplusEl.textContent = fmt(surplus); surplusEl.className = 'stat-val ' + (surplus < 0 ? 'warn' : 'safe'); document.getElementById('v-loan').textContent = fmt(fr.loan_amount); + // Feature 3: FOIR + const foirEl = document.getElementById('v-foir'); + if (fr.foir_ratio !== undefined) { + const foirPct = (fr.foir_ratio * 100).toFixed(1) + '%'; + foirEl.textContent = foirPct + (fr.foir_breach ? ' ⚠' : ''); + foirEl.className = 'stat-val ' + (fr.foir_breach ? 'warn' : fr.foir_ratio > 0.40 ? 'ok' : 'safe'); + const alertEl = document.getElementById('v-foir-alert'); + if (fr.foir_breach && fr.foir_warning) { + alertEl.style.display = 'block'; + alertEl.innerHTML = `⚠ FOIR Breach — Bank Rejection Risk:
${fr.foir_warning}`; + } else { + alertEl.style.display = 'none'; + } + } + const affordStatus = (fr.affordability_status || '').toUpperCase(); - const affordClass = affordStatus === 'AFFORDABLE' ? 'affordable' : affordStatus === 'STRETCHED' ? 'stretched' : 'unaffordable'; + const affordClass = affordStatus === 'COMFORTABLE' ? 'affordable' : affordStatus === 'STRETCHED' ? 'stretched' : 'unaffordable'; document.getElementById('v-afford-badge').innerHTML = `${affordStatus || '—'}`; - // Hidden Costs - document.getElementById('v-base').textContent = fmt(hc.base_price || hc.property_price); - document.getElementById('v-gst').textContent = fmt(hc.gst); - document.getElementById('v-stamp').textContent = fmt(hc.stamp_duty); - document.getElementById('v-reg').textContent = fmt(hc.registration); - document.getElementById('v-total').textContent = fmt(hc.total_cost); + // ── Feature 1: GST Slab label ── + const gstLabelEl = document.getElementById('v-gst-label'); + if (hc.gst_slab) { + const slabMap = { exempt: 'GST (0% — Ready to Move)', affordable_1pct: 'GST (1% — Affordable Housing)', standard_5pct: 'GST (5% — Under Construction)' }; + gstLabelEl.textContent = slabMap[hc.gst_slab] || 'GST'; + } + + // ── Feature 2: Stamp Duty label with female concession ── + const stampLabelEl = document.getElementById('v-stamp-label'); + if (stampLabelEl) { + const rate = hc.stamp_duty_rate ? ` (${(hc.stamp_duty_rate * 100).toFixed(1)}%)` : ''; + const concession = hc.female_buyer_concession_applied ? ' ✓ 1% female concession applied' : ''; + stampLabelEl.textContent = `Stamp Duty${rate}${concession}`; + } + + // ── Feature 4: Expanded Hidden Costs ── + document.getElementById('v-base').textContent = fmt(hc.base_price); + document.getElementById('v-gst').textContent = fmt(hc.gst); + document.getElementById('v-stamp').textContent = fmt(hc.stamp_duty); + document.getElementById('v-reg').textContent = fmt(hc.registration_fee || hc.registration); + document.getElementById('v-bank-fee').textContent = fmt(hc.bank_processing_fee); + document.getElementById('v-legal-fee').textContent = fmt(hc.legal_verification_fee); + document.getElementById('v-maint').textContent = fmt(hc.maintenance_deposit); + document.getElementById('v-prop-tax').textContent = hc.annual_property_tax ? `${fmt(hc.annual_property_tax)}/yr` : '—'; + document.getElementById('v-total').textContent = fmt(hc.true_total_cost || hc.total_cost); + + // ── Feature 5: Building Age / Depreciation ── + const agePanel = document.getElementById('v-age-panel'); + if (fr.ltv_age_risk) { + agePanel.style.display = 'block'; + document.getElementById('v-struct-life').textContent = fr.structural_life_remaining !== null ? `${fr.structural_life_remaining} years` : '—'; + document.getElementById('v-ltv').textContent = fr.ltv_recommended_pct !== null ? `${(fr.ltv_recommended_pct * 100).toFixed(0)}% Max LTV` : '—'; + if (fr.age_depreciation_warning) { + document.getElementById('v-age-warning').innerHTML = `🏗️ Structural Depreciation Risk:
${fr.age_depreciation_warning}`; + } + } else { + agePanel.style.display = 'none'; + } - // Risk Score - const score = ra.composite_score || 0; - const riskLabel = (ra.risk_label || 'MEDIUM').toLowerCase(); + // ── Risk Score ── + const score = ra.composite_score || 0; + const riskLabel = (ra.risk_label || 'MEDIUM').toLowerCase().replace(' ', '_').replace(' risk', ''); + const riskClass = riskLabel.includes('safe') ? 'low' : riskLabel.includes('moderate') ? 'medium' : 'high'; const scoreEl = document.getElementById('v-risk-score'); scoreEl.textContent = score; - scoreEl.className = `risk-score-display ${riskLabel}`; - document.getElementById('v-risk-label').innerHTML = `${(ra.risk_label || '—').toUpperCase()} RISK`; + scoreEl.className = `risk-score-display ${riskClass}`; + document.getElementById('v-risk-label').innerHTML = `${(ra.risk_label || '—').toUpperCase()}`; const factorsEl = document.getElementById('v-risk-factors'); - factorsEl.innerHTML = ra.factors?.length - ? ra.factors.map(f => `
${f.label}
`).join('') + const factors = ra.risk_factors || ra.factors; + factorsEl.innerHTML = factors?.length + ? factors.map(f => { + const label = typeof f === 'string' ? f : (f.label || f.description || JSON.stringify(f)); + const triggered = typeof f === 'string' ? true : (f.triggered !== false); + return `
${label}
`; + }).join('') : '
No factor data.
'; - // Scenarios - const scenEl = document.getElementById('v-scenarios'); - scenEl.innerHTML = r.scenarios?.length - ? r.scenarios.map(s => { - const sev = (s.severity || 'MODERATE').toUpperCase(); - const sevClass = sev === 'CRITICAL' ? 'critical' : sev === 'LOW' || sev === 'SAFE' ? 'safe' : 'moderate'; + // ── Scenarios ── + const scenEl = document.getElementById('v-scenarios'); + const scenarios = r.all_scenarios ? Object.values(r.all_scenarios).filter(s => s && typeof s === 'object' && 'scenario_name' in s) : r.scenarios; + scenEl.innerHTML = scenarios?.length + ? scenarios.map(s => { + const sev = (s.severity || 'moderate').toLowerCase(); + const sevClass = sev === 'critical' ? 'critical' : (sev === 'low') ? 'safe' : 'moderate'; return `
-
${s.name || 'Scenario'}
+
${s.scenario_name || s.name || 'Scenario'}
${s.description || ''}
- ${sev} + ${sev.toUpperCase()} ${s.survivable !== undefined ? `${s.survivable ? '✓ Survivable' : '✗ Critical'}` : ''} ${s.buffer_months !== undefined ? `Buffer: ${s.buffer_months} mo.` : ''}