PABPAT/TCI_Shield
0
1# ============================================================2# TRADE CREDIT INSURANCE -- PYDANTIC DATA MODELS3# ============================================================4# Defines strict data models for each collection stage.5# Used to validate data before storing in session.6# If any field is missing or wrong type, validation fails7# and a clear error is returned to Nova.8# ============================================================9 10from pydantic import BaseModel, Field, field_validator, model_validator11from typing import Optional12import logging13 14# ============================================================15# SECTION 1 -- BUSINESS INFO MODEL16# ============================================================17 18class BusinessInfo(BaseModel):19 """20 Validates business information collected in Steps 1-5.21 All fields are required -- no optional fields.22 """23 business_name: str = Field(..., min_length=1, description="Name of the business")24 industries: list = Field(..., min_length=1, description="List of industry sectors")25 trade_type: str = Field(..., description="export, domestic, or both")26 annual_turnover: float = Field(..., gt=0, description="Annual turnover in GBP")27 credit_sales_percentage: float = Field(..., gt=0, le=100, description="Percentage of turnover sold on credit")28 years_in_business: int = Field(..., gt=0, description="Years the business has been trading")29 customer_country: str = Field(..., min_length=2, description="Country where business is based")30 customer_country_code: str = Field(..., min_length=2, max_length=3, description="2-letter country code")31 32 @field_validator("trade_type")33 @classmethod34 def validate_trade_type(cls, value: str) -> str:35 allowed = ["export", "domestic", "both"]36 if value.lower() not in allowed:37 raise ValueError(f"Trade type must be one of: {allowed}. Got: {value}")38 return value.lower()39 40 @field_validator("customer_country_code")41 @classmethod42 def validate_country_code(cls, value: str) -> str:43 return value.upper().strip()44 45 @field_validator("industries")46 @classmethod47 def validate_industries(cls, value: list) -> list:48 if not value or len(value) == 0:49 raise ValueError("At least one industry must be provided")50 return value51 52 53# ============================================================54# SECTION 2 -- BUYER MODEL55# ============================================================56 57class Buyer(BaseModel):58 """59 Validates a single buyer's details.60 All fields required except registration_number.61 """62 name: str = Field(..., min_length=1, description="Buyer business name")63 country: str = Field(..., min_length=2, description="Buyer country full name")64 country_code: str = Field(..., min_length=2, max_length=3, description="2-letter country code")65 industry: str = Field(..., min_length=2, description="Buyer industry sector")66 exposure_amount: float = Field(..., gt=0, description="Credit exposure in GBP")67 registration_number: str = Field("UNKNOWN", description="Company registration number")68 69 @field_validator("country_code")70 @classmethod71 def validate_country_code(cls, value: str) -> str:72 return value.upper().strip()73 74 75# ============================================================76# SECTION 3 -- BUYER INFO MODEL77# ============================================================78 79class BuyerInfo(BaseModel):80 """81 Validates all buyer and trading information collected in Steps 7-11.82 """83 buyers: list = Field(..., min_length=1, description="List of buyer objects")84 buyer_countries: list = Field(..., min_length=1, description="List of all buyer countries")85 top_buyer_percentage: float = Field(..., gt=0, le=100, description="% of exposure in largest buyer")86 payment_terms_days: int = Field(..., gt=0, description="Standard payment terms in days")87 loss_ratio: float = Field(..., ge=0, le=1, description="Historical bad debt ratio as decimal")88 declared_buyer_count: int = Field(..., gt=0, description="Total buyers declared by customer")89 90 @model_validator(mode="after")91 def validate_buyer_count(self) -> "BuyerInfo":92 if len(self.buyers) != self.declared_buyer_count:93 raise ValueError(94 f"Buyer count mismatch -- declared {self.declared_buyer_count} "95 f"but {len(self.buyers)} provided. Collect all buyers before submitting."96 )97 return self98 99 @model_validator(mode="after")100 def validate_buyer_objects(self) -> "BuyerInfo":101 validated_buyers = []102 for i, buyer in enumerate(self.buyers, 1):103 if isinstance(buyer, dict):104 try:105 validated_buyers.append(Buyer(**buyer).model_dump())106 except Exception as e:107 raise ValueError(f"Buyer {i} validation failed: {e}")108 else:109 validated_buyers.append(buyer)110 self.buyers = validated_buyers111 return self112 113 114# ============================================================115# SECTION 4 -- FINANCIAL DATA MODEL116# ============================================================117 118class FinancialData(BaseModel):119 """120 Validates financial figures extracted from financial statements.121 All 11 figures required. TNW can be negative (insolvency indicator).122 """123 annual_revenue: float = Field(..., gt=0, description="Annual revenue in GBP")124 current_assets: float = Field(..., gt=0, description="Current assets in GBP")125 current_liabilities: float = Field(..., gt=0, description="Current liabilities in GBP")126 total_liabilities: float = Field(..., gt=0, description="Total liabilities in GBP")127 tangible_net_worth: float = Field(..., description="Tangible net worth -- can be negative")128 total_assets: float = Field(..., gt=0, description="Total assets in GBP")129 capital: float = Field(..., ge=0, description="Paid up capital in GBP")130 bad_debts: float = Field(..., ge=0, description="Bad debts written off in GBP")131 debtors: float = Field(..., ge=0, description="Total debtors in GBP")132 creditors: float = Field(..., ge=0, description="Total creditors in GBP")133 cost_of_sales: float = Field(..., gt=0, description="Cost of sales in GBP")134 135 @model_validator(mode="after")136 def validate_balance_sheet(self) -> "FinancialData":137 # Basic balance sheet sanity check138 # Total assets should roughly equal total liabilities + TNW139 expected_assets = self.total_liabilities + self.tangible_net_worth140 tolerance = self.total_assets * 0.10 # 10% tolerance141 if abs(self.total_assets - expected_assets) > tolerance:142 logging.warning(143 f"Balance sheet may be inconsistent: "144 f"Total Assets={self.total_assets}, "145 f"Liabilities + TNW={expected_assets}"146 )147 return self148 149 150# ============================================================151# SECTION 5 -- VALIDATION HELPER152# ============================================================153 154def validate_model(model_class, data: dict) -> tuple[bool, str, dict]:155 """156 Validates data against a Pydantic model.157 158 Args:159 model_class : Pydantic model class to validate against160 data : dict of data to validate161 162 Returns:163 tuple of (is_valid, error_message, validated_data)164 - is_valid : True if validation passed165 - error_message : empty string if valid, error details if invalid166 - validated_data : validated and cleaned data dict if valid, else empty167 """168 try:169 validated = model_class(**data)170 return True, "", validated.model_dump()171 except Exception as e:172 # Extract clean error messages from Pydantic ValidationError173 errors = []174 if hasattr(e, "errors"):175 for err in e.errors():176 field = " -> ".join(str(f) for f in err.get("loc", []))177 message = err.get("msg", "Invalid value")178 errors.append(f"{field}: {message}")179 else:180 errors.append(str(e))181 return False, f"Validation failed: {'; '.join(errors)}", {}