Skip to content

Domain Specification: Customer Risk Rating

Validated against PRD v1.0 — All FR-RR-* requirements implemented here. See Traceability Matrix.


1. Context Overview

Bounded Context: riskrating
Responsibility: Calculate customer risk scores using configurable rules. Produce explainable risk bands.
Owns: RiskAssessment aggregate.
Depends On: Configuration Engine (thresholds, risk factors, weights).
Events Published: RiskAssessmentRequested, RiskAssessmentCompleted.


2. Scoring Model

2.1 Risk Factors (Configurable)

{
  "methodologyVersion": "1.0.0",
  "factors": [
    {
      "id": "GEOGRAPHY",
      "name": "Geographic Risk",
      "weight": 0.25,
      "options": {
        "LOW":    { "score": 0,  "countries": ["NLD","DEU","GBR","FRA","USA","CAN","AUS","JPN","CHE","SGP"] },
        "MEDIUM": { "score": 30, "countries": ["BRA","IND","MEX","ZAF","TUR","ARE","MYS"] },
        "HIGH":   { "score": 60, "countries": ["IRN","PRK","SYR","VEN","MMR","default"] }
      }
    },
    {
      "id": "CUSTOMER_TYPE",
      "name": "Customer Type Risk",
      "weight": 0.15,
      "options": {
        "LOW":    { "score": 0,  "types": ["RETAIL_INDIVIDUAL"] },
        "MEDIUM": { "score": 25, "types": ["SME","LEASING"] },
        "HIGH":   { "score": 50, "types": ["CORPORATE","PRIVATE_BANKING"] },
        "CRITICAL": { "score": 80, "types": ["CORRESPONDENT_BANKING"] }
      }
    },
    {
      "id": "OWNERSHIP_COMPLEXITY",
      "name": "Ownership Complexity",
      "weight": 0.20,
      "options": {
        "LOW":    { "score": 0,  "condition": "ownershipLevels <= 1 && uboCount <= 2" },
        "MEDIUM": { "score": 40, "condition": "ownershipLevels <= 3 && uboCount <= 5" },
        "HIGH":   { "score": 75, "condition": "ownershipLevels > 3 || uboCount > 5" }
      }
    },
    {
      "id": "PEP_EXPOSURE",
      "name": "PEP Exposure",
      "weight": 0.20,
      "options": {
        "LOW":    { "score": 0,  "condition": "pepFlag == false" },
        "MEDIUM": { "score": 35, "condition": "pepLevel == 'NATIONAL'" },
        "HIGH":   { "score": 65, "condition": "pepLevel == 'INTERNATIONAL' || pepLevel == 'CLOSE_ASSOCIATE'" }
      }
    },
    {
      "id": "PRODUCT_RISK",
      "name": "Product Risk",
      "weight": 0.10,
      "options": {
        "LOW":    { "score": 0,  "products": ["SAVINGS","CURRENT_ACCOUNT"] },
        "MEDIUM": { "score": 30, "products": ["TERM_DEPOSIT","MORTGAGE"] },
        "HIGH":   { "score": 60, "products": ["COMMERCIAL_LENDING","TRADE_FINANCE","CORRESPONDENT_BANKING"] }
      }
    },
    {
      "id": "INDUSTRY_RISK",
      "name": "Industry Risk",
      "weight": 0.10,
      "options": {
        "LOW":    { "score": 0,  "industries": ["TECHNOLOGY","HEALTHCARE","EDUCATION","RETAIL"] },
        "MEDIUM": { "score": 30, "industries": ["MANUFACTURING","CONSTRUCTION","TRANSPORT"] },
        "HIGH":   { "score": 60, "industries": ["GAMBLING","CRYPTO","ARMS","PRECIOUS_METALS","default"] }
      }
    }
  ],
  "bandThresholds": {
    "LOW":    { "maxScore": 29 },
    "MEDIUM": { "minScore": 30, "maxScore": 59 },
    "HIGH":   { "minScore": 60 }
  },
  "routingRules": {
    "LOW":    { "action": "FAST_TRACK", "skipAnalystReview": true },
    "MEDIUM": { "action": "STANDARD_REVIEW" },
    "HIGH":   { "action": "EDD_REQUIRED", "branchState": "EDD_REVIEW" }
  }
}

2.2 Scoring Formula

TotalScore = Σ (factor.weight × factor.selectedOption.score) for each factor

Example: Corporate company in Brazil, 3 ownership levels, no PEP, lending product, construction industry

GEOGRAPHY:        0.25 × 30  =  7.5   (Brazil = MEDIUM)
CUSTOMER_TYPE:    0.15 × 50  =  7.5   (Corporate = HIGH)
OWNERSHIP:        0.20 × 40  =  8.0   (3 levels = MEDIUM)
PEP_EXPOSURE:     0.20 × 0   =  0.0   (no PEP = LOW)
PRODUCT_RISK:     0.10 × 60  =  6.0   (Commercial lending = HIGH)
INDUSTRY_RISK:    0.10 × 30  =  3.0   (Construction = MEDIUM)
                                  -----
TotalScore:                     32.0   -> MEDIUM band (30-59)

2.3 Versioning

Each RiskAssessment stores the methodologyVersion used. When the configuration engine promotes a new version, all NEW assessments use the new version. Existing assessments are not retroactively changed — a new assessment must be requested.


3. Data Model

data class RiskAssessment(
    val assessmentId: UUID,
    val customerId: UUID,
    val workflowInstanceId: UUID,
    val methodologyVersion: String,        // "1.0.0"
    val totalScore: Double,                // 32.0
    val riskBand: RiskBand,                // LOW, MEDIUM, HIGH
    val factorResults: List<FactorResult>,
    val routingAction: RoutingAction,      // FAST_TRACK, STANDARD_REVIEW, EDD_REQUIRED
    val createdAt: Instant,
    val validUntil: Instant?               // null = valid until re-assessed
)

enum class RiskBand { LOW, MEDIUM, HIGH }

data class FactorResult(
    val factorId: String,                  // "GEOGRAPHY"
    val factorName: String,                // "Geographic Risk"
    val weight: Double,                    // 0.25
    val selectedOption: String,            // "MEDIUM"
    val optionScore: Int,                  // 30
    val weightedScore: Double,             // 7.5
    val rationale: String                  // "Customer resides in Brazil (MEDIUM risk jurisdiction)"
)

enum class RoutingAction { FAST_TRACK, STANDARD_REVIEW, EDD_REQUIRED }

4. API Contracts

4.1 Assess Customer Risk

POST /api/v1/risk-rating/assess
Authorization: System

Request:
{
  "customerId": "uuid",
  "workflowInstanceId": "uuid",
  "customerContext": {
    "customerType": "LEGAL_ENTITY",
    "incorporationCountry": "BRA",
    "residenceCountries": ["BRA"],
    "nationalities": ["BRA"],
    "pepFlag": false,
    "pepLevel": null,
    "ownershipLevels": 3,
    "uboCount": 4,
    "productInterest": "COMMERCIAL_LENDING",
    "industryCode": "CONSTRUCTION"
  }
}

Response 200:
{
  "assessmentId": "uuid",
  "totalScore": 32.0,
  "riskBand": "MEDIUM",
  "routingAction": "STANDARD_REVIEW",
  "factorResults": [
    {
      "factorId": "GEOGRAPHY",
      "factorName": "Geographic Risk",
      "weight": 0.25,
      "selectedOption": "MEDIUM",
      "optionScore": 30,
      "weightedScore": 7.5,
      "rationale": "Customer incorporation country Brazil is rated MEDIUM risk jurisdiction"
    }
    // ... all 6 factors
  ],
  "methodologyVersion": "1.0.0",
  "createdAt": "2025-06-01T12:30:00Z"
}

4.2 Get Risk History

GET /api/v1/risk-rating/customers/{customerId}/history
Authorization: Bearer <jwt>

Response 200:
{
  "customerId": "uuid",
  "current": { /* RiskAssessment */ },
  "history": [
    { "assessmentId": "uuid-1", "methodologyVersion": "0.9.0", "riskBand": "LOW", "createdAt": "2024-01-15" },
    { "assessmentId": "uuid-2", "methodologyVersion": "1.0.0", "riskBand": "MEDIUM", "createdAt": "2025-06-01" }
  ]
}

5. Decision Module Integration

Risk Rating implements the standard DecisionModule contract:

class RiskRatingEngine(
    private val configEngine: ConfigurationEngine
) : DecisionModule<RiskRatingRequest, RiskAssessment> {

    override fun execute(request: ModuleRequest<RiskRatingRequest>): ModuleResponse<RiskAssessment> {
        val config = configEngine.getActiveConfig("RISK_RATING", request.configVersion)
        val factors = config.getFactors()
        val thresholds = config.getBandThresholds()

        val factorResults = factors.map { factor ->
            val option = factor.evaluate(request.payload.customerContext)
            FactorResult(
                factorId = factor.id,
                factorName = factor.name,
                weight = factor.weight,
                selectedOption = option.label,
                optionScore = option.score,
                weightedScore = factor.weight * option.score,
                rationale = option.rationale
            )
        }

        val totalScore = factorResults.sumOf { it.weightedScore }
        val riskBand = thresholds.determineBand(totalScore)
        val routingAction = config.getRoutingRule(riskBand).action

        val assessment = RiskAssessment(
            assessmentId = UUID.randomUUID(),
            customerId = request.payload.customerId,
            methodologyVersion = config.version,
            totalScore = totalScore,
            riskBand = riskBand,
            factorResults = factorResults,
            routingAction = routingAction
        )

        return ModuleResponse(
            requestId = request.requestId,
            status = ModuleStatus.SUCCESS,
            decision = assessment,
            score = assessment.totalScore,
            rationale = "Risk score $totalScore -> $riskBand band. Factors: ${factorResults.map { "${it.factorName}=${it.selectedOption}" }.joinToString()}",
            executionMetadata = ExecutionMetadata(/* ... */)
        )
    }
}

6. Error Handling

Scenario Behavior
Missing required customer context field 400 Bad Request. "Required context field 'incorporationCountry' is missing."
Unknown country in geography factor Falls to "default" option (HIGH). Rationale: "Country XX not classified — rated as default HIGH risk."
Config version not found 500 Internal Server Error. "Configuration version {version} not found. Contact administrator."
Methodology version mismatch Assessment uses the requested version. If config engine promotes new version during assessment, in-flight assessment completes with old version.
All risk factors disabled (empty config) Returns score 0, risk band LOW. Logs warning.

Spec validated against PRD v1.0 requirements FR-RR-01 through FR-RR-03.