367 lines
10 KiB
PHP
367 lines
10 KiB
PHP
<?php
|
|||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace App\Services;
|
||
|
|
|
||
|
|
use Illuminate\Support\Collection;
|
||
|
|
|
||
|
|
class KycRiskCalculator
|
||
|
|
{
|
||
|
|
/**
|
||
|
|
* KYC Risk Level Schwellenwerte (3 Levels)
|
||
|
|
*/
|
||
|
|
private const RISK_THRESHOLDS = [
|
||
|
|
'low' => 40, // 0-40 = Geringes Risiko
|
||
|
|
'high' => 70, // 41-70 = Hohes Risiko
|
||
|
|
'critical' => 100, // 71-100 = Kritisches Risiko
|
||
|
|
];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Gewichtung der verschiedenen Risk-Faktoren
|
||
|
|
*/
|
||
|
|
private const RISK_WEIGHTS = [
|
||
|
|
'transaction_score' => 0.40, // 40% Gewichtung
|
||
|
|
'sanctions' => 0.25, // 25% Gewichtung
|
||
|
|
'country_risk' => 0.15, // 15% Gewichtung
|
||
|
|
'pep_adverse' => 0.10, // 10% Gewichtung
|
||
|
|
'corruption' => 0.10, // 10% Gewichtung
|
||
|
|
];
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Berechne KYC Risk Level basierend auf Transaction Outputs
|
||
|
|
*
|
||
|
|
* @param Collection $outputs Collection von backend_data_pool records für eine Transaction
|
||
|
|
* @return string 'low', 'high', or 'critical'
|
||
|
|
*/
|
||
|
|
public function calculateKycRiskLevel(Collection $outputs): string
|
||
|
|
{
|
||
|
|
// Wenn keine Outputs vorhanden: Default = high (vorsichtig)
|
||
|
|
if ($outputs->isEmpty()) {
|
||
|
|
return 'high';
|
||
|
|
}
|
||
|
|
|
||
|
|
// Konvertiere Collection zu Key-Value Array für einfacheren Zugriff
|
||
|
|
$outputsArray = $outputs->pluck('content', 'output_key')->toArray();
|
||
|
|
|
||
|
|
$scores = [
|
||
|
|
'transaction_score' => $this->calculateTransactionScore($outputsArray),
|
||
|
|
'sanctions' => $this->calculateSanctionsScore($outputsArray),
|
||
|
|
'country_risk' => $this->calculateCountryRiskScore($outputsArray),
|
||
|
|
'pep_adverse' => $this->calculatePepAdverseScore($outputsArray),
|
||
|
|
'corruption' => $this->calculateCorruptionScore($outputsArray),
|
||
|
|
];
|
||
|
|
|
||
|
|
$weightedScore = $this->calculateWeightedScore($scores);
|
||
|
|
|
||
|
|
return $this->determineRiskLevel($weightedScore);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Berechne Transaction Score (0-100)
|
||
|
|
*/
|
||
|
|
private function calculateTransactionScore(array $outputs): int
|
||
|
|
{
|
||
|
|
if (! isset($outputs['tranx_score'])) {
|
||
|
|
return 60; // Default: Hohes Risiko wenn keine Daten
|
||
|
|
}
|
||
|
|
|
||
|
|
$scoreData = json_decode($outputs['tranx_score'], true);
|
||
|
|
|
||
|
|
// Format: {"score": 75}
|
||
|
|
if (is_array($scoreData) && isset($scoreData['score'])) {
|
||
|
|
return min(100, max(0, (int) $scoreData['score']));
|
||
|
|
}
|
||
|
|
|
||
|
|
// Format: {"risk_level": "high"}
|
||
|
|
if (is_array($scoreData) && isset($scoreData['risk_level'])) {
|
||
|
|
return $this->mapRiskLevelToScore($scoreData['risk_level']);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Falls nur numerischer Wert
|
||
|
|
if (is_numeric($scoreData)) {
|
||
|
|
return min(100, max(0, (int) $scoreData));
|
||
|
|
}
|
||
|
|
|
||
|
|
return 60; // Default
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Berechne Sanctions Score (0-100)
|
||
|
|
*/
|
||
|
|
private function calculateSanctionsScore(array $outputs): int
|
||
|
|
{
|
||
|
|
$sanctionFlags = [
|
||
|
|
'corporate_eusanctions',
|
||
|
|
'corporate_ofacsanctions',
|
||
|
|
'corporate_uksanctions',
|
||
|
|
'sanctions_circumvention',
|
||
|
|
];
|
||
|
|
|
||
|
|
$score = 0;
|
||
|
|
$foundSanctions = 0;
|
||
|
|
|
||
|
|
foreach ($sanctionFlags as $flag) {
|
||
|
|
if (! isset($outputs[$flag])) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
$data = json_decode($outputs[$flag], true);
|
||
|
|
|
||
|
|
if ($this->hasSanctionsMatch($data)) {
|
||
|
|
$foundSanctions++;
|
||
|
|
|
||
|
|
if (is_array($data) && isset($data['severity'])) {
|
||
|
|
$score += $this->mapSeverityToScore($data['severity']);
|
||
|
|
} else {
|
||
|
|
$score += 70; // Default: Hohes Risiko
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($foundSanctions === 0) {
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
$avgScore = $score / $foundSanctions;
|
||
|
|
|
||
|
|
// Mehrere Sanctions → Kritisches Risiko
|
||
|
|
if ($foundSanctions > 1) {
|
||
|
|
return min(100, (int) round($avgScore * 1.3));
|
||
|
|
}
|
||
|
|
|
||
|
|
return (int) round($avgScore);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Berechne Country Risk Score (0-100)
|
||
|
|
*/
|
||
|
|
private function calculateCountryRiskScore(array $outputs): int
|
||
|
|
{
|
||
|
|
if (! isset($outputs['country_risk'])) {
|
||
|
|
return 40; // Default: Geringes Risiko
|
||
|
|
}
|
||
|
|
|
||
|
|
$countryRiskData = json_decode($outputs['country_risk'], true);
|
||
|
|
|
||
|
|
if (is_array($countryRiskData) && isset($countryRiskData['score'])) {
|
||
|
|
return min(100, max(0, (int) $countryRiskData['score']));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (is_array($countryRiskData) && isset($countryRiskData['level'])) {
|
||
|
|
return $this->mapRiskLevelToScore($countryRiskData['level']);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (is_bool($countryRiskData)) {
|
||
|
|
return $countryRiskData ? 80 : 20;
|
||
|
|
}
|
||
|
|
|
||
|
|
return 40;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Berechne PEP & Adverse Media Score (0-100)
|
||
|
|
*/
|
||
|
|
private function calculatePepAdverseScore(array $outputs): int
|
||
|
|
{
|
||
|
|
$score = 0;
|
||
|
|
$count = 0;
|
||
|
|
|
||
|
|
$pepFields = [
|
||
|
|
'corporate_pepexposure' => 75,
|
||
|
|
'corporate_pep' => 80,
|
||
|
|
'corporate_adverse' => 70,
|
||
|
|
'corporate_AMLexposure' => 85,
|
||
|
|
];
|
||
|
|
|
||
|
|
foreach ($pepFields as $field => $defaultScore) {
|
||
|
|
if (! isset($outputs[$field])) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
$data = json_decode($outputs[$field], true);
|
||
|
|
|
||
|
|
if ($this->hasPositiveMatch($data)) {
|
||
|
|
$score += $this->extractScoreFromData($data, $defaultScore);
|
||
|
|
$count++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return $count > 0 ? (int) round($score / $count) : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Berechne Corruption Score (0-100)
|
||
|
|
*/
|
||
|
|
private function calculateCorruptionScore(array $outputs): int
|
||
|
|
{
|
||
|
|
$corruptionFields = [
|
||
|
|
'corruption_sector',
|
||
|
|
'corruption_country',
|
||
|
|
'corruption_relationship',
|
||
|
|
'corporate_corruptionexposure',
|
||
|
|
];
|
||
|
|
|
||
|
|
$totalScore = 0;
|
||
|
|
$count = 0;
|
||
|
|
|
||
|
|
foreach ($corruptionFields as $field) {
|
||
|
|
if (! isset($outputs[$field])) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
$data = json_decode($outputs[$field], true);
|
||
|
|
|
||
|
|
if ($this->hasPositiveMatch($data)) {
|
||
|
|
$totalScore += $this->extractScoreFromData($data, 70);
|
||
|
|
$count++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return $count > 0 ? (int) round($totalScore / $count) : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Berechne gewichteten Gesamt-Score
|
||
|
|
*/
|
||
|
|
private function calculateWeightedScore(array $scores): float
|
||
|
|
{
|
||
|
|
$totalScore = 0.0;
|
||
|
|
|
||
|
|
foreach ($scores as $category => $score) {
|
||
|
|
$weight = self::RISK_WEIGHTS[$category] ?? 0;
|
||
|
|
$totalScore += $score * $weight;
|
||
|
|
}
|
||
|
|
|
||
|
|
return $totalScore;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Bestimme Risk Level basierend auf Score (3 Levels)
|
||
|
|
*/
|
||
|
|
private function determineRiskLevel(float $score): string
|
||
|
|
{
|
||
|
|
if ($score <= self::RISK_THRESHOLDS['low']) {
|
||
|
|
return 'low'; // Geringes Risiko
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($score <= self::RISK_THRESHOLDS['high']) {
|
||
|
|
return 'high'; // Hohes Risiko
|
||
|
|
}
|
||
|
|
|
||
|
|
return 'critical'; // Kritisches Risiko
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Hilfsfunktionen
|
||
|
|
*/
|
||
|
|
private function mapRiskLevelToScore(string $level): int
|
||
|
|
{
|
||
|
|
return match (strtolower($level)) {
|
||
|
|
'low', 'green', 'gering', 'geringes risiko' => 25,
|
||
|
|
'high', 'yellow', 'orange', 'hoch', 'hohes risiko' => 60,
|
||
|
|
'critical', 'red', 'kritisch', 'kritisches risiko' => 85,
|
||
|
|
default => 60,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private function mapSeverityToScore(string $severity): int
|
||
|
|
{
|
||
|
|
return match (strtolower($severity)) {
|
||
|
|
'minor', 'low', 'gering' => 40,
|
||
|
|
'moderate', 'high', 'hoch' => 65,
|
||
|
|
'critical', 'severe', 'kritisch' => 90,
|
||
|
|
default => 65,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
private function hasSanctionsMatch($data): bool
|
||
|
|
{
|
||
|
|
if (! is_array($data)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (isset($data['matches']) && ! empty($data['matches']))
|
||
|
|
|| (isset($data['found']) && $data['found'] === true)
|
||
|
|
|| (isset($data['sanctioned']) && $data['sanctioned'] === true);
|
||
|
|
}
|
||
|
|
|
||
|
|
private function hasPositiveMatch($data): bool
|
||
|
|
{
|
||
|
|
if (is_array($data)) {
|
||
|
|
return (isset($data['match']) && $data['match'] === true)
|
||
|
|
|| (isset($data['found']) && $data['found'] === true)
|
||
|
|
|| (isset($data['matches']) && ! empty($data['matches']))
|
||
|
|
|| (isset($data['exposure']) && $data['exposure'] === true);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (is_bool($data)) {
|
||
|
|
return $data;
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
private function extractScoreFromData($data, int $default): int
|
||
|
|
{
|
||
|
|
if (is_array($data) && isset($data['score'])) {
|
||
|
|
return min(100, max(0, (int) $data['score']));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (is_array($data) && isset($data['risk_score'])) {
|
||
|
|
return min(100, max(0, (int) $data['risk_score']));
|
||
|
|
}
|
||
|
|
|
||
|
|
return $default;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Berechne aggregierten Risk Level für eine Company basierend auf allen Transaktionen
|
||
|
|
*/
|
||
|
|
public function calculateCompanyRiskLevel(Collection $transactions): string
|
||
|
|
{
|
||
|
|
if ($transactions->isEmpty()) {
|
||
|
|
return 'high'; // Default: Hohes Risiko wenn keine Daten
|
||
|
|
}
|
||
|
|
|
||
|
|
$riskScores = [
|
||
|
|
'low' => 0,
|
||
|
|
'high' => 0,
|
||
|
|
'critical' => 0,
|
||
|
|
];
|
||
|
|
|
||
|
|
// Zähle Risk Levels aller Transaktionen
|
||
|
|
foreach ($transactions as $transactionOutputs) {
|
||
|
|
$riskLevel = $this->calculateKycRiskLevel($transactionOutputs);
|
||
|
|
$riskScores[$riskLevel]++;
|
||
|
|
}
|
||
|
|
|
||
|
|
$totalTransactions = $transactions->count();
|
||
|
|
|
||
|
|
// Worst-Case-Prinzip:
|
||
|
|
// 1. Wenn EINE Transaktion kritisches Risiko hat → Company ist kritisch
|
||
|
|
if ($riskScores['critical'] > 0) {
|
||
|
|
return 'critical';
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Wenn >30% der Transaktionen hohes Risiko haben → Company ist kritisch
|
||
|
|
$highRiskPercentage = $riskScores['high'] / $totalTransactions;
|
||
|
|
if ($highRiskPercentage > 0.3) {
|
||
|
|
return 'critical';
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. Wenn >10% hohes Risiko → Company ist hohes Risiko
|
||
|
|
if ($highRiskPercentage > 0.1) {
|
||
|
|
return 'high';
|
||
|
|
}
|
||
|
|
|
||
|
|
// 4. Wenn >80% geringes Risiko → Company ist geringes Risiko
|
||
|
|
if ($riskScores['low'] / $totalTransactions > 0.8) {
|
||
|
|
return 'low';
|
||
|
|
}
|
||
|
|
|
||
|
|
// 5. Default: Hohes Risiko (vorsichtig)
|
||
|
|
return 'high';
|
||
|
|
}
|
||
|
|
}
|