Financial Risk Assessment Engine
Difficulty: Advanced | Time: 3+ hours | Integration: External APIs, Queues, Events, Validation, Caching
Create a real-time risk assessment system integrating multiple data sources, complex business rules, and asynchronous processing with comprehensive audit trails and fallback strategies.
The Challenge: Complex Risk Assessment
Your financial services application needs to assess loan applications in real-time, combining data from multiple external services (credit bureaus, fraud detection, income verification) with internal business rules and regulatory compliance requirements.
🔍 The Problem: Scattered Risk Logic
Here's the nightmarish risk assessment code that has grown organically:
<?php
namespace App\Services;
use App\Models\LoanApplication;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class LoanAssessmentService
{
public function assessApplication(LoanApplication $application): array
{
$riskScore = 0;
$riskFactors = [];
$warnings = [];
// Credit score check (external API)
try {
$creditResponse = Http::timeout(10)->get('https://credit-bureau.com/api/score', [
'ssn' => $application->applicant_ssn,
'api_key' => config('services.credit_bureau.key')
]);
if ($creditResponse->successful()) {
$creditScore = $creditResponse->json()['score'];
if ($creditScore < 300) {
return ['status' => 'rejected', 'reason' => 'Invalid credit score'];
} elseif ($creditScore < 580) {
$riskScore += 40;
$riskFactors[] = 'Poor credit score';
} elseif ($creditScore < 670) {
$riskScore += 20;
$riskFactors[] = 'Fair credit score';
} elseif ($creditScore < 740) {
$riskScore += 10;
} else {
$riskScore -= 5; // Excellent credit
}
} else {
$warnings[] = 'Credit score unavailable';
$riskScore += 25; // Penalty for missing data
}
} catch (\Exception $e) {
Log::error('Credit bureau API failed', ['error' => $e->getMessage()]);
$warnings[] = 'Credit verification failed';
$riskScore += 30; // Higher penalty for API failure
}
// Income verification (another external service)
try {
$incomeResponse = Http::timeout(15)->post('https://income-verify.com/api/verify', [
'ssn' => $application->applicant_ssn,
'employer' => $application->employer_name,
'stated_income' => $application->annual_income,
'token' => config('services.income_verify.token')
]);
if ($incomeResponse->successful()) {
$verifiedIncome = $incomeResponse->json()['verified_income'];
$statedIncome = $application->annual_income;
$incomeDiscrepancy = abs($verifiedIncome - $statedIncome) / $statedIncome;
if ($incomeDiscrepancy > 0.5) {
return ['status' => 'rejected', 'reason' => 'Income discrepancy too high'];
} elseif ($incomeDiscrepancy > 0.2) {
$riskScore += 25;
$riskFactors[] = 'Significant income discrepancy';
} elseif ($incomeDiscrepancy > 0.1) {
$riskScore += 10;
$riskFactors[] = 'Minor income discrepancy';
}
// Debt-to-income ratio
$monthlyIncome = $verifiedIncome / 12;
$totalDebt = $application->monthly_debt_payments + ($application->loan_amount * 0.05); // Estimate monthly payment
$dtiRatio = $totalDebt / $monthlyIncome;
if ($dtiRatio > 0.6) {
return ['status' => 'rejected', 'reason' => 'Debt-to-income ratio too high'];
} elseif ($dtiRatio > 0.45) {
$riskScore += 30;
$riskFactors[] = 'High debt-to-income ratio';
} elseif ($dtiRatio > 0.36) {
$riskScore += 15;
$riskFactors[] = 'Elevated debt-to-income ratio';
}
} else {
$warnings[] = 'Income verification unavailable';
$riskScore += 20;
}
} catch (\Exception $e) {
Log::error('Income verification failed', ['error' => $e->getMessage()]);
$warnings[] = 'Income verification failed';
$riskScore += 25;
}
// Fraud detection (yet another external service)
try {
$fraudResponse = Http::timeout(5)->post('https://fraud-detection.com/api/check', [
'applicant_data' => [
'ssn' => $application->applicant_ssn,
'email' => $application->email,
'phone' => $application->phone,
'address' => $application->address,
'ip_address' => $application->ip_address,
]
]);
if ($fraudResponse->successful()) {
$fraudScore = $fraudResponse->json()['risk_score'];
if ($fraudScore > 0.8) {
return ['status' => 'rejected', 'reason' => 'High fraud risk detected'];
} elseif ($fraudScore > 0.6) {
$riskScore += 35;
$riskFactors[] = 'Elevated fraud risk';
} elseif ($fraudScore > 0.4) {
$riskScore += 15;
$riskFactors[] = 'Moderate fraud risk';
}
}
} catch (\Exception $e) {
// Fraud detection is less critical, just log and continue
Log::warning('Fraud detection unavailable', ['error' => $e->getMessage()]);
}
// Internal business rules (hardcoded mess!)
if ($application->loan_amount > 500000) {
$riskScore += 20;
$riskFactors[] = 'High loan amount';
}
if ($application->loan_term > 30) {
$riskScore += 10;
$riskFactors[] = 'Extended loan term';
}
if ($application->down_payment_percentage < 0.10) {
$riskScore += 25;
$riskFactors[] = 'Low down payment';
}
// Employment history
if ($application->employment_years < 1) {
$riskScore += 20;
$riskFactors[] = 'Limited employment history';
} elseif ($application->employment_years < 2) {
$riskScore += 10;
$riskFactors[] = 'Short employment history';
}
// Age-based risk (getting controversial!)
$age = now()->diffInYears($application->date_of_birth);
if ($age < 25) {
$riskScore += 15;
$riskFactors[] = 'Young applicant';
} elseif ($age > 65) {
$riskScore += 10;
$riskFactors[] = 'Older applicant';
}
// Final decision logic (more hardcoded rules!)
if ($riskScore >= 100) {
$status = 'rejected';
} elseif ($riskScore >= 75) {
$status = 'manual_review';
} elseif ($riskScore >= 50) {
$status = 'conditional_approval';
} else {
$status = 'approved';
}
return [
'status' => $status,
'risk_score' => $riskScore,
'risk_factors' => $riskFactors,
'warnings' => $warnings,
'recommendation' => $this->getRecommendation($status, $riskScore),
];
}
private function getRecommendation(string $status, int $riskScore): string
{
// More hardcoded business logic...
return match($status) {
'approved' => 'Approve at standard rate',
'conditional_approval' => 'Approve with higher interest rate',
'manual_review' => 'Requires underwriter review',
'rejected' => 'Decline application',
default => 'Unknown recommendation'
};
}
}
🎯 Risk Assessment Requirements Analysis
Let's identify the complex business rules:
- Credit Score Evaluation: Multiple tiers with different risk penalties
- Income Verification: External service integration with discrepancy analysis
- Debt-to-Income Ratios: Complex financial calculations
- Fraud Detection: External service with risk scoring
- Loan Parameters: Amount, term, down payment analysis
- Employment History: Stability assessment
- Regulatory Compliance: Age-based considerations (with proper compliance)
- External Service Failures: Graceful degradation and fallback logic
- Audit Trail: Complete decision tracking for regulatory requirements
Step 1: Generate Risk Assessment Specifications
Using the Artisan command to create our risk assessment specifications:
# External service specifications
php artisan make:specification Risk/CreditScoreSpecification --model=LoanApplication
php artisan make:specification Risk/IncomeVerificationSpecification --model=LoanApplication
php artisan make:specification Risk/FraudDetectionSpecification --model=LoanApplication
# Internal business rule specifications
php artisan make:specification Risk/LoanParametersSpecification --model=LoanApplication
php artisan make:specification Risk/EmploymentHistorySpecification --model=LoanApplication
php artisan make:specification Risk/DebtToIncomeSpecification --model=LoanApplication
php artisan make:specification Risk/ComplianceSpecification --model=LoanApplication
# Service availability and fallback specifications
php artisan make:specification Risk/ServiceAvailabilitySpecification
php artisan make:specification Risk/DataCompletenessSpecification --model=LoanApplication
# Composite assessment specifications
php artisan make:specification Risk/LoanApprovalSpecification --composite
php artisan make:specification Risk/ManualReviewSpecification --composite
Step 2: External Service Specifications
CreditScoreSpecification
<?php
namespace App\Specifications\Risk;
use App\Models\LoanApplication;
use App\Services\CreditBureauService;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class CreditScoreSpecification extends AbstractSpecification
{
private ?int $creditScore = null;
private ?string $errorMessage = null;
public function __construct(
private readonly CreditBureauService $creditService,
private readonly int $minimumScore = 300,
private readonly int $cacheTtl = 3600
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof LoanApplication) {
return false;
}
$creditScore = $this->getCreditScore($candidate);
if ($creditScore === null) {
// Service unavailable - let other specifications handle this
return true;
}
return $creditScore >= $this->minimumScore;
}
public function toQuery(Builder $query): Builder
{
// Credit scores are external data, can't filter at DB level
return $query;
}
public function getCreditScore(LoanApplication $application): ?int
{
if ($this->creditScore !== null) {
return $this->creditScore;
}
$cacheKey = "credit_score_{$application->applicant_ssn}";
$this->creditScore = Cache::remember($cacheKey, $this->cacheTtl, function () use ($application) {
try {
return $this->creditService->getCreditScore($application->applicant_ssn);
} catch (\Exception $e) {
Log::error('Credit bureau service failed', [
'application_id' => $application->id,
'error' => $e->getMessage()
]);
$this->errorMessage = $e->getMessage();
return null;
}
});
return $this->creditScore;
}
public function getRiskScore(LoanApplication $application): int
{
$creditScore = $this->getCreditScore($application);
if ($creditScore === null) {
return 30; // Penalty for unavailable data
}
return match(true) {
$creditScore < 580 => 40,
$creditScore < 670 => 20,
$creditScore < 740 => 10,
$creditScore >= 740 => -5,
default => 25
};
}
public function getRiskFactor(LoanApplication $application): ?string
{
$creditScore = $this->getCreditScore($application);
if ($creditScore === null) {
return 'Credit score unavailable';
}
return match(true) {
$creditScore < 580 => 'Poor credit score',
$creditScore < 670 => 'Fair credit score',
$creditScore >= 740 => null, // No risk factor for excellent credit
default => null
};
}
public function hasServiceError(): bool
{
return $this->errorMessage !== null;
}
public function getErrorMessage(): ?string
{
return $this->errorMessage;
}
}
IncomeVerificationSpecification
<?php
namespace App\Specifications\Risk;
use App\Models\LoanApplication;
use App\Services\IncomeVerificationService;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class IncomeVerificationSpecification extends AbstractSpecification
{
private ?array $verificationResult = null;
private ?string $errorMessage = null;
public function __construct(
private readonly IncomeVerificationService $incomeService,
private readonly float $maxDiscrepancyRatio = 0.5,
private readonly int $cacheTtl = 1800
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof LoanApplication) {
return false;
}
$result = $this->getVerificationResult($candidate);
if ($result === null) {
// Service unavailable - let fallback logic handle this
return true;
}
return $this->getIncomeDiscrepancy($candidate) <= $this->maxDiscrepancyRatio;
}
public function toQuery(Builder $query): Builder
{
// Income verification is external, can't filter at DB level
return $query;
}
public function getVerificationResult(LoanApplication $application): ?array
{
if ($this->verificationResult !== null) {
return $this->verificationResult;
}
$cacheKey = "income_verification_{$application->id}_{$application->updated_at->timestamp}";
$this->verificationResult = Cache::remember($cacheKey, $this->cacheTtl, function () use ($application) {
try {
return $this->incomeService->verifyIncome([
'ssn' => $application->applicant_ssn,
'employer' => $application->employer_name,
'stated_income' => $application->annual_income,
]);
} catch (\Exception $e) {
Log::error('Income verification service failed', [
'application_id' => $application->id,
'error' => $e->getMessage()
]);
$this->errorMessage = $e->getMessage();
return null;
}
});
return $this->verificationResult;
}
public function getIncomeDiscrepancy(LoanApplication $application): float
{
$result = $this->getVerificationResult($application);
if ($result === null) {
return 0; // No discrepancy if we can't verify
}
$verifiedIncome = $result['verified_income'];
$statedIncome = $application->annual_income;
if ($statedIncome == 0) {
return 1.0; // Maximum discrepancy
}
return abs($verifiedIncome - $statedIncome) / $statedIncome;
}
public function getRiskScore(LoanApplication $application): int
{
$result = $this->getVerificationResult($application);
if ($result === null) {
return 25; // Penalty for unavailable verification
}
$discrepancy = $this->getIncomeDiscrepancy($application);
return match(true) {
$discrepancy > 0.2 => 25,
$discrepancy > 0.1 => 10,
default => 0
};
}
public function getRiskFactor(LoanApplication $application): ?string
{
$result = $this->getVerificationResult($application);
if ($result === null) {
return 'Income verification unavailable';
}
$discrepancy = $this->getIncomeDiscrepancy($application);
return match(true) {
$discrepancy > 0.2 => 'Significant income discrepancy',
$discrepancy > 0.1 => 'Minor income discrepancy',
default => null
};
}
public function hasServiceError(): bool
{
return $this->errorMessage !== null;
}
public function getErrorMessage(): ?string
{
return $this->errorMessage;
}
}
FraudDetectionSpecification
<?php
namespace App\Specifications\Risk;
use App\Models\LoanApplication;
use App\Services\FraudDetectionService;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class FraudDetectionSpecification extends AbstractSpecification
{
private ?float $fraudScore = null;
private ?string $errorMessage = null;
public function __construct(
private readonly FraudDetectionService $fraudService,
private readonly float $maxFraudScore = 0.8,
private readonly int $cacheTtl = 900
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof LoanApplication) {
return false;
}
$fraudScore = $this->getFraudScore($candidate);
if ($fraudScore === null) {
// Fraud detection is less critical - assume no fraud if service unavailable
return true;
}
return $fraudScore <= $this->maxFraudScore;
}
public function toQuery(Builder $query): Builder
{
// Fraud scores are external, can't filter at DB level
return $query;
}
public function getFraudScore(LoanApplication $application): ?float
{
if ($this->fraudScore !== null) {
return $this->fraudScore;
}
$cacheKey = "fraud_score_{$application->id}";
$this->fraudScore = Cache::remember($cacheKey, $this->cacheTtl, function () use ($application) {
try {
return $this->fraudService->checkApplication([
'ssn' => $application->applicant_ssn,
'email' => $application->email,
'phone' => $application->phone,
'address' => $application->address,
'ip_address' => $application->ip_address,
]);
} catch (\Exception $e) {
Log::warning('Fraud detection service unavailable', [
'application_id' => $application->id,
'error' => $e->getMessage()
]);
$this->errorMessage = $e->getMessage();
return null;
}
});
return $this->fraudScore;
}
public function getRiskScore(LoanApplication $application): int
{
$fraudScore = $this->getFraudScore($application);
if ($fraudScore === null) {
return 0; // No penalty for unavailable fraud detection
}
return match(true) {
$fraudScore > 0.6 => 35,
$fraudScore > 0.4 => 15,
default => 0
};
}
public function getRiskFactor(LoanApplication $application): ?string
{
$fraudScore = $this->getFraudScore($application);
if ($fraudScore === null) {
return null; // Don't report as risk factor if service unavailable
}
return match(true) {
$fraudScore > 0.6 => 'Elevated fraud risk',
$fraudScore > 0.4 => 'Moderate fraud risk',
default => null
};
}
public function hasServiceError(): bool
{
return $this->errorMessage !== null;
}
}
Step 3: Internal Business Rule Specifications
DebtToIncomeSpecification
<?php
namespace App\Specifications\Risk;
use App\Models\LoanApplication;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class DebtToIncomeSpecification extends AbstractSpecification
{
public function __construct(
private readonly float $maxDtiRatio = 0.6,
private readonly float $estimatedInterestRate = 0.05
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof LoanApplication) {
return false;
}
return $this->calculateDtiRatio($candidate) <= $this->maxDtiRatio;
}
public function toQuery(Builder $query): Builder
{
// This requires calculation, so we'll filter at collection level
// Or we could add a computed column to the database
return $query;
}
public function calculateDtiRatio(LoanApplication $application): float
{
$monthlyIncome = $application->annual_income / 12;
if ($monthlyIncome <= 0) {
return 1.0; // Maximum ratio if no income
}
// Estimate new loan payment
$loanAmount = $application->loan_amount;
$estimatedPayment = $loanAmount * ($this->estimatedInterestRate / 12) *
pow(1 + ($this->estimatedInterestRate / 12), $application->loan_term * 12) /
(pow(1 + ($this->estimatedInterestRate / 12), $application->loan_term * 12) - 1);
$totalDebt = $application->monthly_debt_payments + $estimatedPayment;
return $totalDebt / $monthlyIncome;
}
public function getRiskScore(LoanApplication $application): int
{
$dtiRatio = $this->calculateDtiRatio($application);
return match(true) {
$dtiRatio > 0.45 => 30,
$dtiRatio > 0.36 => 15,
default => 0
};
}
public function getRiskFactor(LoanApplication $application): ?string
{
$dtiRatio = $this->calculateDtiRatio($application);
return match(true) {
$dtiRatio > 0.45 => 'High debt-to-income ratio',
$dtiRatio > 0.36 => 'Elevated debt-to-income ratio',
default => null
};
}
}
LoanParametersSpecification
<?php
namespace App\Specifications\Risk;
use App\Models\LoanApplication;
use DangerWayne\LaravelSpecifications\AbstractSpecification;
use Illuminate\Database\Eloquent\Builder;
class LoanParametersSpecification extends AbstractSpecification
{
public function __construct(
private readonly int $maxLoanAmount = 1000000,
private readonly int $maxLoanTerm = 30,
private readonly float $minDownPaymentPercentage = 0.05
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
if (!$candidate instanceof LoanApplication) {
return false;
}
return $this->isValidLoanAmount($candidate) &&
$this->isValidLoanTerm($candidate) &&
$this->isValidDownPayment($candidate);
}
public function toQuery(Builder $query): Builder
{
return $query->where('loan_amount', '<=', $this->maxLoanAmount)
->where('loan_term', '<=', $this->maxLoanTerm)
->where('down_payment_percentage', '>=', $this->minDownPaymentPercentage);
}
private function isValidLoanAmount(LoanApplication $application): bool
{
return $application->loan_amount <= $this->maxLoanAmount;
}
private function isValidLoanTerm(LoanApplication $application): bool
{
return $application->loan_term <= $this->maxLoanTerm;
}
private function isValidDownPayment(LoanApplication $application): bool
{
return $application->down_payment_percentage >= $this->minDownPaymentPercentage;
}
public function getRiskScore(LoanApplication $application): int
{
$riskScore = 0;
if ($application->loan_amount > 500000) {
$riskScore += 20;
}
if ($application->loan_term > 25) {
$riskScore += 10;
}
if ($application->down_payment_percentage < 0.10) {
$riskScore += 25;
}
return $riskScore;
}
public function getRiskFactors(LoanApplication $application): array
{
$factors = [];
if ($application->loan_amount > 500000) {
$factors[] = 'High loan amount';
}
if ($application->loan_term > 25) {
$factors[] = 'Extended loan term';
}
if ($application->down_payment_percentage < 0.10) {
$factors[] = 'Low down payment';
}
return $factors;
}
}
Step 4: Risk Assessment Service
Create a comprehensive risk assessment service using specifications:
<?php
namespace App\Services;
use App\Models\LoanApplication;
use App\Models\RiskAssessment;
use App\Specifications\Risk\CreditScoreSpecification;
use App\Specifications\Risk\IncomeVerificationSpecification;
use App\Specifications\Risk\FraudDetectionSpecification;
use App\Specifications\Risk\DebtToIncomeSpecification;
use App\Specifications\Risk\LoanParametersSpecification;
use App\Specifications\Risk\EmploymentHistorySpecification;
use App\Specifications\Risk\ComplianceSpecification;
use App\Events\RiskAssessmentCompleted;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class RiskAssessmentService
{
public function __construct(
private readonly CreditScoreSpecification $creditSpec,
private readonly IncomeVerificationSpecification $incomeSpec,
private readonly FraudDetectionSpecification $fraudSpec,
private readonly DebtToIncomeSpecification $dtiSpec,
private readonly LoanParametersSpecification $loanSpec,
private readonly EmploymentHistorySpecification $employmentSpec,
private readonly ComplianceSpecification $complianceSpec
) {}
public function assessApplication(LoanApplication $application): RiskAssessment
{
return DB::transaction(function () use ($application) {
$assessment = new RiskAssessment([
'loan_application_id' => $application->id,
'started_at' => now(),
'status' => 'in_progress'
]);
$assessment->save();
try {
$results = $this->performAssessment($application);
$assessment->update([
'completed_at' => now(),
'status' => 'completed',
'risk_score' => $results['risk_score'],
'decision' => $results['decision'],
'risk_factors' => $results['risk_factors'],
'warnings' => $results['warnings'],
'recommendation' => $results['recommendation'],
'external_data' => $results['external_data'],
]);
event(new RiskAssessmentCompleted($assessment));
} catch (\Exception $e) {
$assessment->update([
'completed_at' => now(),
'status' => 'failed',
'error_message' => $e->getMessage(),
]);
Log::error('Risk assessment failed', [
'application_id' => $application->id,
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString()
]);
throw $e;
}
return $assessment;
});
}
private function performAssessment(LoanApplication $application): array
{
$riskScore = 0;
$riskFactors = [];
$warnings = [];
$externalData = [];
// Run all specifications and collect results
$specifications = [
'credit' => $this->creditSpec,
'income' => $this->incomeSpec,
'fraud' => $this->fraudSpec,
'dti' => $this->dtiSpec,
'loan_params' => $this->loanSpec,
'employment' => $this->employmentSpec,
'compliance' => $this->complianceSpec,
];
foreach ($specifications as $name => $spec) {
$specResult = $this->evaluateSpecification($spec, $application, $name);
$riskScore += $specResult['risk_score'];
$riskFactors = array_merge($riskFactors, $specResult['risk_factors']);
$warnings = array_merge($warnings, $specResult['warnings']);
if (!empty($specResult['external_data'])) {
$externalData[$name] = $specResult['external_data'];
}
// Hard rejections
if ($specResult['hard_rejection']) {
return [
'decision' => 'rejected',
'risk_score' => 100,
'risk_factors' => [$specResult['rejection_reason']],
'warnings' => $warnings,
'recommendation' => 'Application does not meet minimum requirements',
'external_data' => $externalData,
];
}
}
// Make final decision based on risk score
$decision = $this->determineDecision($riskScore);
$recommendation = $this->getRecommendation($decision, $riskScore);
return [
'risk_score' => $riskScore,
'decision' => $decision,
'risk_factors' => array_unique($riskFactors),
'warnings' => array_unique($warnings),
'recommendation' => $recommendation,
'external_data' => $externalData,
];
}
private function evaluateSpecification($spec, LoanApplication $application, string $name): array
{
$result = [
'risk_score' => 0,
'risk_factors' => [],
'warnings' => [],
'external_data' => [],
'hard_rejection' => false,
'rejection_reason' => null,
];
try {
// Check if specification passes
if (!$spec->isSatisfiedBy($application)) {
// Handle hard rejections
if (method_exists($spec, 'isHardRejection') && $spec->isHardRejection($application)) {
$result['hard_rejection'] = true;
$result['rejection_reason'] = $spec->getRejectionReason($application);
return $result;
}
}
// Get risk score if specification supports it
if (method_exists($spec, 'getRiskScore')) {
$result['risk_score'] = $spec->getRiskScore($application);
}
// Get risk factors if specification supports it
if (method_exists($spec, 'getRiskFactor')) {
$factor = $spec->getRiskFactor($application);
if ($factor) {
$result['risk_factors'][] = $factor;
}
} elseif (method_exists($spec, 'getRiskFactors')) {
$result['risk_factors'] = $spec->getRiskFactors($application);
}
// Check for service errors
if (method_exists($spec, 'hasServiceError') && $spec->hasServiceError()) {
$result['warnings'][] = $spec->getErrorMessage();
}
// Collect external data for audit trail
if (method_exists($spec, 'getExternalData')) {
$result['external_data'] = $spec->getExternalData($application);
}
} catch (\Exception $e) {
Log::error("Specification evaluation failed: {$name}", [
'application_id' => $application->id,
'error' => $e->getMessage()
]);
$result['warnings'][] = "Failed to evaluate {$name} criteria";
$result['risk_score'] = 20; // Penalty for evaluation failure
}
return $result;
}
private function determineDecision(int $riskScore): string
{
return match(true) {
$riskScore >= 100 => 'rejected',
$riskScore >= 75 => 'manual_review',
$riskScore >= 50 => 'conditional_approval',
default => 'approved'
};
}
private function getRecommendation(string $decision, int $riskScore): string
{
return match($decision) {
'approved' => 'Approve at standard rate',
'conditional_approval' => 'Approve with adjusted terms or higher interest rate',
'manual_review' => 'Requires underwriter review and additional documentation',
'rejected' => 'Decline application - risk criteria not met',
default => 'Unable to determine recommendation'
};
}
}
Step 5: Asynchronous Processing with Jobs
Create background jobs for handling risk assessments:
<?php
namespace App\Jobs;
use App\Models\LoanApplication;
use App\Services\RiskAssessmentService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class ProcessRiskAssessmentJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 300; // 5 minutes
public int $tries = 3;
public int $backoff = 60; // Wait 60 seconds between retries
public function __construct(
private readonly LoanApplication $application
) {}
public function handle(RiskAssessmentService $assessmentService): void
{
Log::info('Starting risk assessment', [
'application_id' => $this->application->id,
'job_id' => $this->job->uuid()
]);
try {
$assessment = $assessmentService->assessApplication($this->application);
Log::info('Risk assessment completed', [
'application_id' => $this->application->id,
'assessment_id' => $assessment->id,
'decision' => $assessment->decision,
'risk_score' => $assessment->risk_score
]);
} catch (\Exception $e) {
Log::error('Risk assessment job failed', [
'application_id' => $this->application->id,
'error' => $e->getMessage(),
'attempt' => $this->attempts()
]);
// Re-throw to trigger retry mechanism
throw $e;
}
}
public function failed(\Throwable $exception): void
{
Log::error('Risk assessment job permanently failed', [
'application_id' => $this->application->id,
'error' => $exception->getMessage(),
'attempts' => $this->attempts()
]);
// Update application status to indicate assessment failure
$this->application->update(['status' => 'assessment_failed']);
// Could also send notification to admin team
}
}
Step 6: Event-Driven Architecture
Create events and listeners for risk assessment workflow:
<?php
namespace App\Events;
use App\Models\RiskAssessment;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class RiskAssessmentCompleted implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly RiskAssessment $assessment
) {}
public function broadcastOn(): array
{
return [
new PresenceChannel('loan-application.' . $this->assessment->loan_application_id),
new Channel('risk-assessments'),
];
}
public function broadcastWith(): array
{
return [
'assessment_id' => $this->assessment->id,
'application_id' => $this->assessment->loan_application_id,
'decision' => $this->assessment->decision,
'risk_score' => $this->assessment->risk_score,
'completed_at' => $this->assessment->completed_at,
];
}
}
<?php
namespace App\Listeners;
use App\Events\RiskAssessmentCompleted;
use App\Notifications\RiskAssessmentResultNotification;
use App\Jobs\UpdateCreditModelJob;
use App\Models\User;
class ProcessRiskAssessmentResult
{
public function handle(RiskAssessmentCompleted $event): void
{
$assessment = $event->assessment;
$application = $assessment->loanApplication;
// Update application status
$application->update([
'status' => match($assessment->decision) {
'approved' => 'approved',
'conditional_approval' => 'conditionally_approved',
'manual_review' => 'pending_review',
'rejected' => 'rejected',
default => 'unknown'
}
]);
// Notify the applicant
if ($application->user) {
$application->user->notify(new RiskAssessmentResultNotification($assessment));
}
// Notify underwriters for manual review cases
if ($assessment->decision === 'manual_review') {
$underwriters = User::role('underwriter')->get();
foreach ($underwriters as $underwriter) {
$underwriter->notify(new ManualReviewRequiredNotification($assessment));
}
}
// Update machine learning models with new data
UpdateCreditModelJob::dispatch($assessment);
}
}
Step 7: Comprehensive Testing
Integration Testing
<?php
namespace Tests\Feature;
use App\Models\LoanApplication;
use App\Models\User;
use App\Services\RiskAssessmentService;
use App\Services\CreditBureauService;
use App\Services\IncomeVerificationService;
use App\Services\FraudDetectionService;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
class RiskAssessmentIntegrationTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// Mock external services
$this->mockCreditBureauService();
$this->mockIncomeVerificationService();
$this->mockFraudDetectionService();
}
/** @test */
public function complete_risk_assessment_with_all_services_available()
{
// Arrange
$application = LoanApplication::factory()->create([
'loan_amount' => 250000,
'loan_term' => 30,
'annual_income' => 80000,
'monthly_debt_payments' => 1500,
'down_payment_percentage' => 0.20,
'employment_years' => 5,
]);
// Act
$service = app(RiskAssessmentService::class);
$assessment = $service->assessApplication($application);
// Assert
$this->assertEquals('completed', $assessment->status);
$this->assertNotNull($assessment->risk_score);
$this->assertContains($assessment->decision, ['approved', 'conditional_approval', 'manual_review', 'rejected']);
$this->assertNotNull($assessment->recommendation);
// Check that external data was collected
$this->assertArrayHasKey('credit', $assessment->external_data);
$this->assertArrayHasKey('income', $assessment->external_data);
$this->assertArrayHasKey('fraud', $assessment->external_data);
}
/** @test */
public function handles_external_service_failures_gracefully()
{
// Arrange - Mock service failures
Http::fake([
'credit-bureau.com/api/*' => Http::response(null, 500),
'income-verify.com/api/*' => Http::response(null, 503),
'fraud-detection.com/api/*' => Http::response(null, 404),
]);
$application = LoanApplication::factory()->create();
// Act
$service = app(RiskAssessmentService::class);
$assessment = $service->assessApplication($application);
// Assert
$this->assertEquals('completed', $assessment->status);
$this->assertNotEmpty($assessment->warnings);
$this->assertGreaterThan(0, $assessment->risk_score); // Should have penalties for missing data
// Should still make a decision even with service failures
$this->assertNotNull($assessment->decision);
}
/** @test */
public function rejects_applications_with_hard_rejection_criteria()
{
// Arrange - Application with very poor credit
Http::fake([
'credit-bureau.com/api/*' => Http::response(['score' => 250], 200),
]);
$application = LoanApplication::factory()->create();
// Act
$service = app(RiskAssessmentService::class);
$assessment = $service->assessApplication($application);
// Assert
$this->assertEquals('rejected', $assessment->decision);
$this->assertEquals(100, $assessment->risk_score);
$this->assertContains('Invalid credit score', $assessment->risk_factors);
}
/** @test */
public function triggers_manual_review_for_borderline_cases()
{
// Arrange - Borderline application
Http::fake([
'credit-bureau.com/api/*' => Http::response(['score' => 650], 200),
'income-verify.com/api/*' => Http::response(['verified_income' => 70000], 200),
'fraud-detection.com/api/*' => Http::response(['risk_score' => 0.5], 200),
]);
$application = LoanApplication::factory()->create([
'annual_income' => 75000, // Slight income discrepancy
'monthly_debt_payments' => 2500, // High debt
'down_payment_percentage' => 0.05, // Low down payment
]);
// Act
$service = app(RiskAssessmentService::class);
$assessment = $service->assessApplication($application);
// Assert
$this->assertEquals('manual_review', $assessment->decision);
$this->assertGreaterThanOrEqual(75, $assessment->risk_score);
$this->assertLessThan(100, $assessment->risk_score);
$this->assertStringContains('underwriter review', $assessment->recommendation);
}
private function mockCreditBureauService(): void
{
Http::fake([
'credit-bureau.com/api/*' => Http::response(['score' => 720], 200)
]);
}
private function mockIncomeVerificationService(): void
{
Http::fake([
'income-verify.com/api/*' => Http::response(['verified_income' => 80000], 200)
]);
}
private function mockFraudDetectionService(): void
{
Http::fake([
'fraud-detection.com/api/*' => Http::response(['risk_score' => 0.2], 200)
]);
}
}
Specification Unit Tests
<?php
namespace Tests\Unit\Specifications;
use App\Models\LoanApplication;
use App\Specifications\Risk\DebtToIncomeSpecification;
use App\Specifications\Risk\LoanParametersSpecification;
use Tests\TestCase;
class RiskSpecificationTest extends TestCase
{
/** @test */
public function debt_to_income_specification_calculates_ratio_correctly()
{
// Arrange
$spec = new DebtToIncomeSpecification(0.6);
$application = LoanApplication::factory()->make([
'annual_income' => 60000,
'monthly_debt_payments' => 1000,
'loan_amount' => 200000,
'loan_term' => 30,
]);
// Act
$dtiRatio = $spec->calculateDtiRatio($application);
// Assert
$this->assertGreaterThan(0, $dtiRatio);
$this->assertLessThan(1, $dtiRatio);
// With $5000 monthly income and ~$1800 estimated loan payment + $1000 existing debt
// DTI should be around 0.56 (2800/5000)
$this->assertGreaterThan(0.5, $dtiRatio);
$this->assertLessThan(0.6, $dtiRatio);
}
/** @test */
public function loan_parameters_specification_validates_loan_terms()
{
// Arrange
$spec = new LoanParametersSpecification(1000000, 30, 0.05);
$validApplication = LoanApplication::factory()->make([
'loan_amount' => 500000,
'loan_term' => 25,
'down_payment_percentage' => 0.20,
]);
$invalidApplication = LoanApplication::factory()->make([
'loan_amount' => 1200000, // Too high
'loan_term' => 35, // Too long
'down_payment_percentage' => 0.02, // Too low
]);
// Act & Assert
$this->assertTrue($spec->isSatisfiedBy($validApplication));
$this->assertFalse($spec->isSatisfiedBy($invalidApplication));
$riskFactors = $spec->getRiskFactors($invalidApplication);
$this->assertNotEmpty($riskFactors);
$this->assertContains('High loan amount', $riskFactors);
$this->assertContains('Extended loan term', $riskFactors);
$this->assertContains('Low down payment', $riskFactors);
}
}
Key Learnings
This advanced example demonstrates:
✅ External Service Integration: Graceful handling of API failures and timeouts
✅ Complex Business Logic: Multi-layered risk assessment with fallback strategies
✅ Asynchronous Processing: Background jobs for time-intensive assessments
✅ Event-Driven Architecture: Real-time updates and workflow automation
✅ Comprehensive Caching: Performance optimization for external service calls
✅ Audit Trail: Complete decision tracking for regulatory compliance
✅ Error Handling: Robust failure recovery and partial data handling
Next Steps
With your financial risk assessment system:
- Add machine learning models for dynamic risk scoring
- Implement A/B testing for different risk thresholds
- Create regulatory reporting with audit trail data
- Add real-time monitoring for service availability and performance
Master the final challenge: Complete the series with Distributed Workflow Engine.