Salta al contenuto principale

01 - Policy Management Cloud-Native: Architettura API-First

Policy Management Cloud-Native: Architettura API-First per Piattaforme Assicurative Il sistema di gestione delle polizze (Policy Administration System,...

Cosa ne pensi di questo articolo?

01 - Policy Management Cloud-Native: Architettura API-First
InsurTechFoto:Brett SaylesPexels

In Breve

Policy Management Cloud-Native: Architettura API-First per Piattaforme Assicurative Il sistema di gestione delle polizze (Policy Administration System,...

Policy Management Cloud-Native: Architettura API-First per Piattaforme Assicurative

Il sistema di gestione delle polizze (Policy Administration System, PAS) e il cuore pulsante di qualsiasi compagnia assicurativa. E il sistema che definisce cosa un cliente ha acquistato, per quanto tempo, a quale prezzo e con quali condizioni. Eppure, nella stragrande maggioranza delle compagnie assicurative italiane ed europee, questo sistema critico gira ancora su mainframe degli anni '90, con batch notturni che durano ore e API che non esistono o si riducono a file SFTP schedulati.

La transizione verso architetture cloud-native API-first non e solo una questione tecnologica: e una necessità competitiva. I nuovi operatori InsurTech lanciano prodotti in settimane invece di mesi, servono clienti in real-time e integrano dati da decine di fonti esterne. Il mercato InsurTech globale, valutato 5,3 miliardi di dollari nel 2024, crescera a oltre 132,9 miliardi entro il 2034 (CAGR 22%). Il 74% degli assicuratori tradizionali sta già investendo nella modernizzazione dei core system (Capgemini World InsurTech Report 2024).

In questo articolo costruiamo un sistema di policy management cloud-native da zero: dalla modellazione del dominio all'API design, dal lifecycle management delle polizze alle integrazioni con broker, canali digitali e sistemi regolamentari.

Cosa Imparerai

  • Architettura API-first per policy administration cloud-native
  • Modellazione del ciclo di vita completo di una polizza
  • Design di REST API per quote, bind, endorse, cancel, renew
  • Event sourcing e CQRS nel contesto assicurativo
  • Integrazione con broker portals, e-commerce e intermediari
  • Versioning delle polizze e gestione degli endorsement
  • Pattern di testing per sistemi assicurativi complessi

1. Il Ciclo di Vita di una Polizza: Da Quote a Lapse

Prima di scrivere una sola riga di codice, dobbiamo comprendere il ciclo di vita completo di una polizza. Il modello standard del settore prevede questi stati principali:

Policy Lifecycle States

  • QUOTED: Il cliente ha ricevuto un preventivo ma non ha ancora acquistato
  • APPLICATION: L'applicazione e in fase di underwriting review
  • BOUND: La copertura e attiva, in attesa di emissione formale
  • IN_FORCE: Polizza attiva e pienamente efficace
  • SUSPENDED: Copertura temporaneamente sospesa (es. mancato pagamento)
  • RENEWED: Polizza rinnovata per un nuovo periodo
  • CANCELLED: Polizza cancellata prima della scadenza naturale
  • EXPIRED / LAPSED: Polizza giunta a scadenza naturale o decaduta

Ogni transizione di stato ha regole di business precise: chi può attivarla, quali validazioni sono richieste, quali eventi vengono generati, quali notifiche vengono inviate. Il codice deve esprimere queste regole in modo esplicito e testabile.

2. Domain Model: Entità Fondamentali

Un buon policy management system si basa su un domain model preciso. Ecco le entità fondamentali e le loro relazioni in Python con type annotations:

from dataclasses import dataclass, field
from datetime import date, datetime
from decimal import Decimal
from enum import Enum
from typing import Optional
from uuid import UUID, uuid4


class PolicyStatus(str, Enum):
    QUOTED = "QUOTED"
    APPLICATION = "APPLICATION"
    BOUND = "BOUND"
    IN_FORCE = "IN_FORCE"
    SUSPENDED = "SUSPENDED"
    RENEWED = "RENEWED"
    CANCELLED = "CANCELLED"
    EXPIRED = "EXPIRED"


class CoverageType(str, Enum):
    LIABILITY = "LIABILITY"
    COMPREHENSIVE = "COMPREHENSIVE"
    COLLISION = "COLLISION"
    PERSONAL_INJURY = "PERSONAL_INJURY"
    PROPERTY = "PROPERTY"
    BUSINESS_INTERRUPTION = "BUSINESS_INTERRUPTION"


@dataclass(frozen=True)
class Money:
    """Value object immutabile per importi monetari."""
    amount: Decimal
    currency: str = "EUR"

    def __add__(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError(f"Cannot add {self.currency} and {other.currency}")
        return Money(self.amount + other.amount, self.currency)

    def __mul__(self, factor: Decimal) -> "Money":
        return Money(self.amount * factor, self.currency)


@dataclass(frozen=True)
class Coverage:
    """Una singola copertura assicurativa all'interno di una polizza."""
    coverage_id: UUID
    coverage_type: CoverageType
    limit: Money          # Massimale di indennizzo
    deductible: Money     # Franchigia
    premium: Money        # Premio per questa copertura
    effective_date: date
    expiry_date: date
    is_active: bool = True


@dataclass
class PolicyHolder:
    """Il contraente/assicurato della polizza."""
    party_id: UUID
    first_name: str
    last_name: str
    fiscal_code: str       # Codice fiscale IT
    date_of_birth: date
    email: str
    phone: Optional[str]
    address: dict          # Indirizzo strutturato


@dataclass
class Endorsement:
    """Una modifica alla polizza originale (endorsement / appendice)."""
    endorsement_id: UUID
    policy_id: UUID
    endorsement_type: str  # ADD_COVERAGE, REMOVE_COVERAGE, CHANGE_LIMIT, etc.
    effective_date: date
    description: str
    premium_adjustment: Money  # Positivo = aumento, negativo = rimborso
    previous_state: dict   # Snapshot dello stato prima dell'endorsement
    new_state: dict        # Snapshot dello stato dopo l'endorsement
    created_at: datetime = field(default_factory=datetime.utcnow)


@dataclass
class Policy:
    """Entità radice dell'aggregato polizza."""
    policy_id: UUID
    policy_number: str     # Es. "AUTO-IT-2024-0001234"
    product_code: str      # Es. "AUTO_RC", "HOME_ALL_RISK"
    status: PolicyStatus
    holder: PolicyHolder
    coverages: list[Coverage]
    endorsements: list[Endorsement]
    effective_date: date
    expiry_date: date
    annual_premium: Money
    created_at: datetime
    updated_at: datetime

    # Metadata di distribuzione
    channel: str           # "DIRECT", "BROKER", "AGGREGATOR"
    agent_code: Optional[str]
    broker_code: Optional[str]

    def total_premium(self) -> Money:
        base = sum(
            (c.premium for c in self.coverages if c.is_active),
            start=Money(Decimal("0"))
        )
        adjustments = sum(
            (e.premium_adjustment for e in self.endorsements),
            start=Money(Decimal("0"))
        )
        return base + adjustments

    def is_active(self) -> bool:
        return self.status == PolicyStatus.IN_FORCE

    def can_file_claim(self) -> bool:
        today = date.today()
        return (
            self.is_active()
            and self.effective_date <= today <= self.expiry_date
        )

3. API Design: REST API per Policy Operations

Un'architettura API-first significa che le API sono la prima cosa che si progetta, prima ancora dell'implementazione. Il contratto API deve essere chiaro, versionato e compatibile con le aspettative del settore (ACORD standards, OpenAPI 3.1).

Di seguito la struttura delle principali API endpoint per un policy management service:

Method Endpoint Operazione Body / Response
POST /v1/quotes Genera un preventivo QuoteRequest -> QuoteResponse
POST /v1/quotes/{quoteId}/bind Converte preventivo in polizza BindRequest -> PolicyResponse
GET /v1/policies/{policyId} Recupera polizza -> PolicyResponse
POST /v1/policies/{policyId}/endorse Emette endorsement EndorseRequest -> EndorsementResponse
POST /v1/policies/{policyId}/cancel Cancella polizza CancellationRequest -> PolicyResponse
POST /v1/policies/{policyId}/renew Rinnova polizza RenewalRequest -> PolicyResponse
GET /v1/policies/{policyId}/documents Documenti polizza -> DocumentListResponse

Implementazione FastAPI con validazione Pydantic:

from fastapi import FastAPI, HTTPException, Depends, BackgroundTasks
from pydantic import BaseModel, Field, validator
from typing import Optional
from decimal import Decimal
from datetime import date
from uuid import UUID
import uuid

app = FastAPI(
    title="Policy Management API",
    version="1.0.0",
    description="Cloud-native insurance policy management"
)


# --- Request/Response Models ---

class VehicleInfo(BaseModel):
    plate: str = Field(..., pattern=r"^[A-Z]{2}[0-9]{3}[A-Z]{2}$|^[A-Z]{2}[0-9]{5}$")
    make: str
    model: str
    year: int = Field(..., ge=1900, le=2026)
    value: Decimal = Field(..., gt=0)
    engine_cc: int


class QuoteRequest(BaseModel):
    product_code: str = Field(..., pattern="^[A-Z_]+$")
    holder_fiscal_code: str
    vehicle: Optional[VehicleInfo]
    effective_date: date
    desired_coverages: list[str]
    channel: str = Field(default="DIRECT")

    @validator("effective_date")
    def effective_date_not_past(cls, v: date) -> date:
        if v < date.today():
            raise ValueError("Effective date cannot be in the past")
        return v


class CoverageQuote(BaseModel):
    coverage_type: str
    limit: Decimal
    deductible: Decimal
    annual_premium: Decimal
    currency: str = "EUR"


class QuoteResponse(BaseModel):
    quote_id: UUID
    product_code: str
    coverages: list[CoverageQuote]
    total_annual_premium: Decimal
    currency: str = "EUR"
    valid_until: date
    terms_version: str


class BindRequest(BaseModel):
    payment_reference: str
    holder_data: dict  # Validated separately by KYC service
    selected_coverages: list[str]
    broker_code: Optional[str]


class PolicyResponse(BaseModel):
    policy_id: UUID
    policy_number: str
    status: str
    product_code: str
    effective_date: date
    expiry_date: date
    annual_premium: Decimal
    currency: str = "EUR"
    created_at: str


# --- Service Layer ---

class PolicyService:
    def __init__(self, repo, pricer, underwriter, event_bus):
        self.repo = repo
        self.pricer = pricer
        self.underwriter = underwriter
        self.event_bus = event_bus

    async def create_quote(self, request: QuoteRequest) -> QuoteResponse:
        # 1. Validate product exists
        product = await self.repo.get_product(request.product_code)
        if not product:
            raise ValueError(f"Unknown product: {request.product_code}")

        # 2. Score the risk
        risk_score = await self.underwriter.score(request)

        # 3. Calculate premium
        coverages = await self.pricer.calculate(product, risk_score, request)

        # 4. Persist quote with TTL
        quote = await self.repo.save_quote(
            product_code=request.product_code,
            coverages=coverages,
            valid_hours=72
        )

        return QuoteResponse(
            quote_id=quote.id,
            product_code=request.product_code,
            coverages=coverages,
            total_annual_premium=sum(c.annual_premium for c in coverages),
            valid_until=quote.valid_until,
            terms_version=product.terms_version
        )

    async def bind_policy(
        self,
        quote_id: UUID,
        request: BindRequest,
        background_tasks: BackgroundTasks
    ) -> PolicyResponse:
        # 1. Retrieve and validate quote
        quote = await self.repo.get_quote(quote_id)
        if not quote or quote.is_expired():
            raise ValueError("Quote expired or not found")

        # 2. Final underwriting check
        uw_result = await self.underwriter.final_check(quote, request.holder_data)
        if uw_result.is_declined:
            raise ValueError(f"Policy declined: {uw_result.reason}")

        # 3. Create policy
        policy = await self.repo.create_policy(quote, request, uw_result)

        # 4. Async operations (non-blocking)
        background_tasks.add_task(self._post_bind_workflow, policy)

        return PolicyResponse(
            policy_id=policy.id,
            policy_number=policy.number,
            status=policy.status,
            product_code=policy.product_code,
            effective_date=policy.effective_date,
            expiry_date=policy.expiry_date,
            annual_premium=policy.annual_premium.amount,
            currency=policy.annual_premium.currency,
            created_at=policy.created_at.isoformat()
        )

    async def _post_bind_workflow(self, policy):
        """Operazioni asincrone post-bind."""
        await self.event_bus.publish("policy.bound", {"policy_id": str(policy.id)})
        # Trigger: document generation, CRM update, payment setup


# --- API Routes ---

@app.post("/v1/quotes", response_model=QuoteResponse, status_code=201)
async def create_quote(
    request: QuoteRequest,
    service: PolicyService = Depends(get_policy_service)
):
    try:
        return await service.create_quote(request)
    except ValueError as e:
        raise HTTPException(status_code=422, detail=str(e))


@app.post("/v1/quotes/{quote_id}/bind", response_model=PolicyResponse, status_code=201)
async def bind_policy(
    quote_id: UUID,
    request: BindRequest,
    background_tasks: BackgroundTasks,
    service: PolicyService = Depends(get_policy_service)
):
    try:
        return await service.bind_policy(quote_id, request, background_tasks)
    except ValueError as e:
        raise HTTPException(status_code=422, detail=str(e))

4. Event Sourcing per l'Audit Trail Assicurativo

Il settore assicurativo ha requisiti stringenti di audit e tracciabilita: ogni modifica a una polizza deve essere tracciata in modo immutabile per ragioni normative (IVASS, EIOPA) e legali. L'event sourcing e il pattern architetturale più adatto a questo requisito.

Invece di aggiornare lo stato direttamente, ogni operazione sulla polizza genera un evento immutabile. Lo stato corrente e la proiezione di tutti gli eventi passati:

from dataclasses import dataclass
from datetime import datetime
from typing import Union
from uuid import UUID


# --- Policy Domain Events ---

@dataclass(frozen=True)
class PolicyQuoted:
    event_type: str = "PolicyQuoted"
    policy_id: UUID = None
    quote_id: UUID = None
    product_code: str = None
    holder_id: UUID = None
    annual_premium: dict = None  # {amount, currency}
    occurred_at: datetime = None


@dataclass(frozen=True)
class PolicyBound:
    event_type: str = "PolicyBound"
    policy_id: UUID = None
    policy_number: str = None
    effective_date: str = None
    expiry_date: str = None
    channel: str = None
    agent_code: str = None
    occurred_at: datetime = None


@dataclass(frozen=True)
class PolicyEndorsed:
    event_type: str = "PolicyEndorsed"
    policy_id: UUID = None
    endorsement_id: UUID = None
    endorsement_type: str = None
    premium_adjustment: dict = None
    occurred_at: datetime = None


@dataclass(frozen=True)
class PolicyCancelled:
    event_type: str = "PolicyCancelled"
    policy_id: UUID = None
    cancellation_reason: str = None
    cancellation_date: str = None
    refund_amount: dict = None
    requested_by: str = None   # "HOLDER", "INSURER", "REGULATOR"
    occurred_at: datetime = None


PolicyEvent = Union[PolicyQuoted, PolicyBound, PolicyEndorsed, PolicyCancelled]


# --- Event Store ---

class PolicyEventStore:
    """Append-only store per eventi di polizza."""

    def __init__(self, db_pool):
        self.db = db_pool

    async def append(self, policy_id: UUID, event: PolicyEvent) -> int:
        """Appende un evento e restituisce il nuovo sequence number."""
        async with self.db.acquire() as conn:
            row = await conn.fetchrow(
                """
                INSERT INTO policy_events
                    (policy_id, event_type, event_data, occurred_at)
                VALUES ($1, $2, $3, $4)
                RETURNING sequence_number
                """,
                str(policy_id),
                event.event_type,
                event.__dict__,
                event.occurred_at or datetime.utcnow()
            )
            return row["sequence_number"]

    async def get_history(self, policy_id: UUID) -> list[dict]:
        """Recupera tutti gli eventi di una polizza in ordine cronologico."""
        async with self.db.acquire() as conn:
            rows = await conn.fetch(
                """
                SELECT event_type, event_data, occurred_at, sequence_number
                FROM policy_events
                WHERE policy_id = $1
                ORDER BY sequence_number ASC
                """,
                str(policy_id)
            )
            return [dict(r) for r in rows]

    async def rebuild_state(self, policy_id: UUID) -> dict:
        """Ricostruisce lo stato corrente della polizza dagli eventi."""
        events = await self.get_history(policy_id)
        state = {}
        for evt in events:
            state = self._apply_event(state, evt)
        return state

    def _apply_event(self, state: dict, event: dict) -> dict:
        event_type = event["event_type"]
        data = event["event_data"]

        if event_type == "PolicyQuoted":
            return {
                **state,
                "status": "QUOTED",
                "policy_id": data["policy_id"],
                "product_code": data["product_code"],
                "annual_premium": data["annual_premium"]
            }
        elif event_type == "PolicyBound":
            return {
                **state,
                "status": "IN_FORCE",
                "policy_number": data["policy_number"],
                "effective_date": data["effective_date"],
                "expiry_date": data["expiry_date"],
                "channel": data["channel"]
            }
        elif event_type == "PolicyEndorsed":
            # Aggiorna il premio
            current_premium = state.get("annual_premium", {"amount": "0"})
            adjustment = data.get("premium_adjustment", {"amount": "0"})
            return {
                **state,
                "annual_premium": {
                    "amount": str(
                        float(current_premium["amount"])
                        + float(adjustment["amount"])
                    ),
                    "currency": current_premium.get("currency", "EUR")
                }
            }
        elif event_type == "PolicyCancelled":
            return {
                **state,
                "status": "CANCELLED",
                "cancellation_reason": data["cancellation_reason"],
                "cancellation_date": data["cancellation_date"]
            }
        return state

5. Endorsement Management: Modifiche Mid-Term

Gli endorsement (modifiche mid-term) sono operazioni frequenti e delicate: l'aggiunta di un conducente, la variazione del veicolo, il cambio di indirizzo. Ogni endorsement può avere impatti sul premio (pro-rata) e deve generare documentazione aggiornata.

from decimal import Decimal
from datetime import date


class EndorsementCalculator:
    """Calcola l'aggiustamento di premio per un endorsement mid-term."""

    def calculate_prorata_adjustment(
        self,
        current_annual_premium: Decimal,
        new_annual_premium: Decimal,
        endorsement_date: date,
        policy_expiry: date
    ) -> Decimal:
        """
        Calcola l'aggiustamento pro-rata del premio.
        Restituisce importo positivo (addebitare) o negativo (rimborsare).
        """
        remaining_days = (policy_expiry - endorsement_date).days
        total_days = 365  # Semplificazione; usare calendario reale per polizze annuali

        premium_difference = new_annual_premium - current_annual_premium
        prorata_factor = Decimal(remaining_days) / Decimal(total_days)

        return premium_difference * prorata_factor


class EndorsementService:
    def __init__(self, repo, event_store, calculator, doc_service):
        self.repo = repo
        self.event_store = event_store
        self.calculator = calculator
        self.doc_service = doc_service

    async def add_driver(
        self,
        policy_id: str,
        driver_data: dict,
        effective_date: date
    ) -> dict:
        """Aggiunge un conducente aggiuntivo alla polizza auto."""

        # 1. Recupera polizza corrente
        policy = await self.repo.get_policy(policy_id)
        if not policy.is_active():
            raise ValueError("Cannot endorse inactive policy")

        # 2. Calcola nuovo premio con driver aggiuntivo
        new_premium = await self._reprice_with_driver(policy, driver_data)

        # 3. Calcola aggiustamento pro-rata
        adjustment = self.calculator.calculate_prorata_adjustment(
            current_annual_premium=policy.annual_premium.amount,
            new_annual_premium=new_premium,
            endorsement_date=effective_date,
            policy_expiry=policy.expiry_date
        )

        # 4. Persisti endorsement
        endorsement_id = await self.repo.create_endorsement(
            policy_id=policy_id,
            type="ADD_DRIVER",
            effective_date=effective_date,
            driver_data=driver_data,
            premium_adjustment=adjustment
        )

        # 5. Pubblica evento
        await self.event_store.append(policy_id, PolicyEndorsed(
            policy_id=policy_id,
            endorsement_id=endorsement_id,
            endorsement_type="ADD_DRIVER",
            premium_adjustment={"amount": str(adjustment), "currency": "EUR"}
        ))

        # 6. Genera documento endorsement (async)
        await self.doc_service.generate_endorsement_certificate(
            policy_id, endorsement_id
        )

        return {
            "endorsement_id": str(endorsement_id),
            "premium_adjustment": float(adjustment),
            "currency": "EUR",
            "effective_date": effective_date.isoformat()
        }

    async def _reprice_with_driver(self, policy, driver_data: dict) -> Decimal:
        """Riprice la polizza includendo il nuovo conducente."""
        # Logic depends on product rating algorithm
        base_premium = policy.annual_premium.amount
        driver_age = driver_data.get("age", 30)

        # Young driver surcharge (semplificato)
        if driver_age < 25:
            surcharge = base_premium * Decimal("0.25")
        elif driver_age < 30:
            surcharge = base_premium * Decimal("0.10")
        else:
            surcharge = Decimal("0")

        return base_premium + surcharge

6. Architettura Microservizi: Decomposizione del Policy Domain

Un sistema di policy management enterprise si decompone tipicamente nei seguenti microservizi, ognuno con il proprio bounded context e responsabilità ben definite:

Servizio Responsabilità Database Comunicazione
quote-service Preventivazione, rating Redis (cache) + PostgreSQL REST + Events
policy-service Lifecycle polizza, endorsement PostgreSQL (event store) REST + Events
billing-service Pagamenti, installments, refund PostgreSQL Events
document-service Generazione PDF, archiviazione S3 / Azure Blob Events (async)
notification-service Email, SMS, push Redis (queue) Events (async)
underwriting-service Risk scoring, accept/decline PostgreSQL + ML Store REST (sync)

7. Integrazione Broker Portal

I broker assicurativi accedono al sistema tramite un portale dedicato. L'integrazione API deve gestire autenticazione, accesso ai dati dei clienti del broker, commissioni e reporting aggregato. Di seguito un esempio di middleware OAuth2 per broker:

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
import httpx

BROKER_PORTAL_SCOPES = [
    "policy:read",
    "policy:quote",
    "policy:bind",
    "policy:endorse",
    "commission:read"
]


class BrokerAuth:
    """Middleware per autenticazione e autorizzazione broker."""

    def __init__(self, jwks_url: str, audience: str):
        self.jwks_url = jwks_url
        self.audience = audience
        self._jwks_cache = None

    async def get_jwks(self) -> dict:
        if not self._jwks_cache:
            async with httpx.AsyncClient() as client:
                resp = await client.get(self.jwks_url)
                self._jwks_cache = resp.json()
        return self._jwks_cache

    async def verify_broker_token(
        self,
        credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer())
    ) -> dict:
        """Verifica JWT token del broker e restituisce claims."""
        try:
            jwks = await self.get_jwks()
            payload = jwt.decode(
                credentials.credentials,
                jwks,
                algorithms=["RS256"],
                audience=self.audience
            )
            broker_code = payload.get("broker_code")
            if not broker_code:
                raise HTTPException(
                    status_code=status.HTTP_403_FORBIDDEN,
                    detail="Missing broker_code claim"
                )
            return payload
        except JWTError as e:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail=f"Invalid token: {str(e)}"
            )


# Dependency injection
broker_auth = BrokerAuth(
    jwks_url="https://auth.insurer.com/.well-known/jwks.json",
    audience="broker-portal-api"
)


@app.get("/v1/broker/{broker_code}/portfolio")
async def get_broker_portfolio(
    broker_code: str,
    broker_claims: dict = Depends(broker_auth.verify_broker_token),
    policy_service: PolicyService = Depends(get_policy_service)
):
    """Restituisce il portfolio polizze di un broker."""
    # Verifica che il broker acceda solo ai propri dati
    if broker_claims["broker_code"] != broker_code:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Access denied: broker_code mismatch"
        )

    policies = await policy_service.get_policies_by_broker(broker_code)

    return {
        "broker_code": broker_code,
        "total_policies": len(policies),
        "in_force_count": sum(1 for p in policies if p["status"] == "IN_FORCE"),
        "total_gwp": sum(p["annual_premium"] for p in policies),
        "policies": policies[:50]  # Paginare in produzione
    }

8. Rinnovi Automatici: Batch Processing e Outreach

I rinnovi sono operazioni critiche: la perdita di un cliente al rinnovo e costosa (costo acquisizione cliente 5-7x rispetto al mantenimento). Un sistema moderno automatizza il processo di rinnovo con outreach personalizzato e pricing dinamico.

import asyncio
from datetime import date, timedelta
from dataclasses import dataclass


@dataclass
class RenewalCandidate:
    policy_id: str
    holder_email: str
    expiry_date: date
    current_premium: float
    renewal_premium: float
    days_to_expiry: int


class RenewalOrchestrator:
    """Gestisce il processo automatico di rinnovo polizze."""

    RENEWAL_WINDOWS = [60, 45, 30, 15, 7]  # Giorni prima della scadenza

    def __init__(self, policy_repo, pricer, notification_svc, event_bus):
        self.repo = policy_repo
        self.pricer = pricer
        self.notification = notification_svc
        self.event_bus = event_bus

    async def run_daily_renewal_batch(self) -> dict:
        """Job giornaliero: identifica polizze da rinnovare e avvia outreach."""
        today = date.today()
        stats = {"processed": 0, "quoted": 0, "errors": 0}

        for days_ahead in self.RENEWAL_WINDOWS:
            target_date = today + timedelta(days=days_ahead)
            candidates = await self.repo.get_expiring_policies(target_date)

            tasks = [
                self._process_renewal_candidate(c, days_ahead)
                for c in candidates
            ]
            results = await asyncio.gather(*tasks, return_exceptions=True)

            for result in results:
                stats["processed"] += 1
                if isinstance(result, Exception):
                    stats["errors"] += 1
                else:
                    stats["quoted"] += 1

        return stats

    async def _process_renewal_candidate(
        self,
        policy_id: str,
        days_to_expiry: int
    ) -> dict:
        policy = await self.repo.get_policy(policy_id)

        # Calcola nuovo premio di rinnovo
        renewal_premium = await self.pricer.calculate_renewal(policy)
        premium_change_pct = (
            (renewal_premium - float(policy["annual_premium"])) /
            float(policy["annual_premium"]) * 100
        )

        # Salva preventivo rinnovo
        renewal_quote = await self.repo.save_renewal_quote(
            policy_id=policy_id,
            renewal_premium=renewal_premium,
            valid_days=days_to_expiry + 30
        )

        # Scegli template notifica in base ai giorni rimanenti
        if days_to_expiry == 60:
            template = "renewal_early_notice"
        elif days_to_expiry == 30:
            template = "renewal_30_day"
        elif days_to_expiry == 7:
            template = "renewal_final_reminder"
        else:
            template = "renewal_standard"

        # Invia notifica
        await self.notification.send_renewal_outreach(
            holder_email=policy["holder"]["email"],
            template=template,
            context={
                "policy_number": policy["policy_number"],
                "expiry_date": policy["expiry_date"],
                "renewal_premium": renewal_premium,
                "premium_change_pct": premium_change_pct,
                "renewal_link": f"https://portal.insurer.com/renew/{renewal_quote['id']}"
            }
        )

        # Pubblica evento
        await self.event_bus.publish("policy.renewal_quoted", {
            "policy_id": policy_id,
            "renewal_quote_id": renewal_quote["id"],
            "days_to_expiry": days_to_expiry
        })

        return renewal_quote

9. Testing: Approcci per Sistemi Assicurativi Complessi

I sistemi assicurativi hanno logica di business complessa e alta dipendenza da regole che cambiano nel tempo. Il testing deve coprire unit test per le regole di business, integration test per i flussi end-to-end, e contract test per le API esposte ai broker.

import pytest
from decimal import Decimal
from datetime import date, timedelta
from unittest.mock import AsyncMock, MagicMock


class TestEndorsementCalculator:
    """Unit test per il calcolo pro-rata degli endorsement."""

    def setup_method(self):
        self.calc = EndorsementCalculator()

    def test_prorata_increase_with_full_year_remaining(self):
        adjustment = self.calc.calculate_prorata_adjustment(
            current_annual_premium=Decimal("600"),
            new_annual_premium=Decimal("750"),
            endorsement_date=date(2025, 1, 1),
            policy_expiry=date(2026, 1, 1)
        )
        # 150 difference * (365/365) = 150
        assert abs(adjustment - Decimal("150")) < Decimal("1")

    def test_prorata_decrease_at_midterm(self):
        adjustment = self.calc.calculate_prorata_adjustment(
            current_annual_premium=Decimal("600"),
            new_annual_premium=Decimal("500"),
            endorsement_date=date(2025, 7, 1),  # 6 mesi dopo inizio
            policy_expiry=date(2026, 1, 1)
        )
        # Aggiustamento negativo (rimborso)
        assert adjustment < Decimal("0")

    def test_endorsement_on_last_day_is_zero(self):
        today = date.today()
        adjustment = self.calc.calculate_prorata_adjustment(
            current_annual_premium=Decimal("600"),
            new_annual_premium=Decimal("800"),
            endorsement_date=today,
            policy_expiry=today  # Scadenza oggi
        )
        assert adjustment == Decimal("0")


@pytest.mark.asyncio
class TestPolicyService:
    """Integration test del service layer."""

    async def test_create_quote_success(self):
        # Arrange
        mock_repo = AsyncMock()
        mock_repo.get_product.return_value = MagicMock(terms_version="v2.1")
        mock_pricer = AsyncMock()
        mock_pricer.calculate.return_value = [
            CoverageQuote(
                coverage_type="LIABILITY",
                limit=Decimal("1000000"),
                deductible=Decimal("0"),
                annual_premium=Decimal("400")
            )
        ]
        mock_underwriter = AsyncMock()
        mock_underwriter.score.return_value = MagicMock(score=0.3)
        mock_repo.save_quote.return_value = MagicMock(
            id="q-123",
            valid_until=date.today() + timedelta(days=72)
        )

        service = PolicyService(
            repo=mock_repo,
            pricer=mock_pricer,
            underwriter=mock_underwriter,
            event_bus=AsyncMock()
        )

        request = QuoteRequest(
            product_code="AUTO_RC",
            holder_fiscal_code="CLDFRC80A01H501U",
            effective_date=date.today() + timedelta(days=1),
            desired_coverages=["LIABILITY"]
        )

        # Act
        result = await service.create_quote(request)

        # Assert
        assert result.total_annual_premium == Decimal("400")
        assert result.terms_version == "v2.1"
        mock_underwriter.score.assert_awaited_once()

10. Best Practices e Anti-Pattern

Anti-Pattern: God Object Policy

Non creare un'unica tabella "policies" con 200 colonne. Usa un domain model ricco con value objects (Money, Coverage, Address) e aggregate boundaries chiari. Una colonna "coverage_data JSONB" non e un domain model.

Anti-Pattern: Stato Mutabile Senza Audit Trail

Non fare UPDATE diretti su una polizza senza tracciare la storia. Il settore assicurativo richiede audit completi per IVASS. Usa event sourcing o almeno una tabella di history.

Best Practice: Idempotency per Operazioni Critiche

Le operazioni di bind e cancellazione devono essere idempotenti. Usa un idempotency_key header nelle richieste critiche. Persisti il risultato e restituiscilo se la stessa chiave viene inviata di nuovo.

Best Practice: Contract Testing con Pact

Per le integrazioni con broker e aggregatori, usa contract testing (Pact). Ogni consumer definisce il contratto che si aspetta; il provider verifica che lo rispetti. Questo previene breaking changes nelle API critiche.

Conclusioni

Un sistema di policy management cloud-native API-first richiede investimento nella modellazione del dominio prima ancora dell'infrastruttura. Il domain model ricco (aggregate, value objects, events), l'API design ACORD-compatible e l'event sourcing per l'audit trail sono i fondamentali su cui costruire.

Il 74% degli assicuratori sta modernizzando i core system: la competizione con gli InsurTech nativi digitali rende questa trasformazione non più opzionale. I pattern descritti in questo articolo - event sourcing, microservizi, API-first, endorsement management - sono la base tecnica per costruire piattaforme assicurative scalabili e manutenibili.

Prossimi Articoli della Serie InsurTech

  • Pipeline Telematica: Processing Dati UBI su Scala
  • AI Underwriting: Feature Engineering e Risk Scoring
  • Automazione Sinistri: Computer Vision e NLP
  • Fraud Detection: Graph Analytics e Behavioral Signal

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti