2025-11-17 15:27:21 +01:00
<? php
declare ( strict_types = 1 );
namespace App\Jobs ;
use App\Models\Company ;
use App\Models\Transaction ;
use Carbon\Carbon ;
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\DB ;
use Illuminate\Support\Facades\Log ;
class TransformDataPoolToProduction implements ShouldQueue
{
use Dispatchable ;
use InteractsWithQueue ;
use Queueable ;
use SerializesModels ;
public int $timeout = 3600 ; // 1 hour timeout
public int $tries = 3 ;
/**
* Create a new job instance.
*/
public function __construct (
public ? int $batchSize = 100 ,
2025-11-19 12:23:27 +01:00
) {}
2025-11-17 15:27:21 +01:00
/**
* Execute the job.
*/
2025-11-19 12:23:27 +01:00
public function handle () : void
2025-11-17 15:27:21 +01:00
{
$startTime = now ();
Log :: info ( 'TransformDataPoolToProduction: Starting transformation' , [
'batch_size' => $this -> batchSize ,
]);
try {
2025-11-19 12:23:27 +01:00
DB :: transaction ( function () {
$this -> transformData ();
2025-11-17 15:27:21 +01:00
});
$duration = now () -> diffInSeconds ( $startTime );
Log :: info ( 'TransformDataPoolToProduction: Transformation completed successfully' , [
'duration_seconds' => $duration ,
]);
} catch ( \Exception $e ) {
Log :: error ( 'TransformDataPoolToProduction: Transformation failed' , [
'error' => $e -> getMessage (),
'trace' => $e -> getTraceAsString (),
]);
throw $e ;
}
}
/**
* Transform data from backend_data_pool to companies and transactions
*/
2025-11-19 12:23:27 +01:00
private function transformData () : void
2025-11-17 15:27:21 +01:00
{
// Get all unique transaction IDs from data pool
$transactionIds = DB :: table ( 'backend_data_pool' )
-> select ( 'transaction_id' )
-> distinct ()
-> pluck ( 'transaction_id' );
Log :: info ( 'TransformDataPoolToProduction: Found transactions to process' , [
'count' => $transactionIds -> count (),
]);
$processedCompanies = 0 ;
$processedTransactions = 0 ;
foreach ( $transactionIds as $transactionId ) {
// Get all outputs for this transaction
$outputs = DB :: table ( 'backend_data_pool' )
-> where ( 'transaction_id' , $transactionId )
-> get ();
if ( $outputs -> isEmpty ()) {
continue ;
}
$firstOutput = $outputs -> first ();
// 1. Create or update Company
2025-11-19 12:23:27 +01:00
$company = $this -> createOrUpdateCompany ( $firstOutput , $outputs );
2025-11-17 15:27:21 +01:00
if ( $company -> wasRecentlyCreated ) {
$processedCompanies ++ ;
}
// 2. Create or update Transaction
$transaction = $this -> createOrUpdateTransaction ( $company , $firstOutput , $outputs );
if ( $transaction -> wasRecentlyCreated ) {
$processedTransactions ++ ;
}
}
Log :: info ( 'TransformDataPoolToProduction: Processing completed' , [
'new_companies' => $processedCompanies ,
'new_transactions' => $processedTransactions ,
]);
}
/**
* Create or update a Company from data pool outputs
*/
2025-11-19 12:23:27 +01:00
private function createOrUpdateCompany ( $firstOutput , $outputs ) : Company
2025-11-17 15:27:21 +01:00
{
$corporateEntity = $firstOutput -> corporate_entity ;
2025-11-19 12:23:27 +01:00
// Use backend risk_score and map to risk_level
$backendRiskScore = $firstOutput -> risk_score ?? 50 ;
$kycRiskLevel = $this -> mapBackendRiskScoreToLevel ( $backendRiskScore );
2025-11-17 15:27:21 +01:00
// Extract company data from outputs
$companyData = [
'country' => $this -> extractCountry ( $firstOutput , $outputs ),
'kyc_risk_level' => $kycRiskLevel ,
2025-11-19 12:23:27 +01:00
'sector' => $this -> extractLeanField ( $outputs , 'entity_corporate_sector' , 255 ),
'headquarters' => $this -> extractLeanField ( $outputs , 'entity_corporate_HQ' , 255 ),
'summary' => $this -> extractField ( $outputs , 'entity_corporate_summary' , 5000 ), // text field, longer OK
'legal_name' => $this -> extractLegalName ( $outputs , $corporateEntity ),
'ticker' => null , // Ticker is not available in backend_data_pool
2025-11-17 15:27:21 +01:00
];
return Company :: updateOrCreate (
[ 'name' => $corporateEntity ],
$companyData
);
}
/**
* Create or update a Transaction from data pool outputs
*/
private function createOrUpdateTransaction ( Company $company , $firstOutput , $outputs ) : Transaction
{
2025-11-19 12:23:27 +01:00
// Generate better reference number
$reference = $this -> generateTransactionReference ( $firstOutput );
2025-11-17 15:27:21 +01:00
2025-11-19 12:23:27 +01:00
// Use risk_score from backend (leading field)
$backendRiskScore = $firstOutput -> risk_score ?? 50 ; // Default to 50 (high risk) if not set
// Map backend risk_score to risk_level
// -100 to 20 = Critical
// 20 to 80 = High
// 80 to 100 = Low
$riskLevel = $this -> mapBackendRiskScoreToLevel ( $backendRiskScore );
// Convert to 0-255 scale for frontend transactions table
$riskScore = $this -> mapBackendRiskScoreTo255Scale ( $backendRiskScore );
// Map risk level to Transaction Status
$status = match ( $riskLevel ) {
2025-11-17 15:27:21 +01:00
'critical' => Transaction :: STATUS_TRUE_POSITIVE ,
'high' => Transaction :: STATUS_FALSE_POSITIVE ,
'low' => Transaction :: STATUS_CLEARED ,
2025-11-19 12:23:27 +01:00
default => Transaction :: STATUS_FALSE_POSITIVE ,
2025-11-17 15:27:21 +01:00
};
// Core transaction data
$transactionData = [
'company_id' => $company -> id ,
'amount' => $firstOutput -> tx_amount ,
'currency' => $firstOutput -> tx_currency ?? 'EUR' ,
'counterparty' => $firstOutput -> corporate_counterparty ,
2025-11-19 12:23:27 +01:00
'counterparty_country' => $this -> extractCountryCode ( $firstOutput -> tx_country_incoming , $firstOutput -> corporate_counterparty ),
'counterparty_kyc_risk_level' => $riskLevel ,
'channel' => $this -> determineTransactionType ( $firstOutput ),
2025-11-17 15:27:21 +01:00
'executed_at' => $this -> parseDate ( $firstOutput -> tx_date ),
2025-11-19 12:23:27 +01:00
'risk_score' => $riskScore ,
2025-11-17 15:27:21 +01:00
'status' => $status ,
2025-11-19 12:23:27 +01:00
'requires_review' => $this -> shouldRequireReview ( $riskLevel ),
2025-11-17 15:27:21 +01:00
'flagged_reason' => $firstOutput -> tx_purpose ,
2025-11-19 12:23:27 +01:00
// Store backend risk data
'entity_risk_breakdown' => $firstOutput -> risk_details_json ,
'counterparty_risk_breakdown' => null ,
'transaction_plausibility_breakdown' => null ,
'combined_risk_breakdown' => null ,
2025-11-17 15:27:21 +01:00
];
// Map all output_keys to their respective columns
foreach ( $outputs as $output ) {
$columnName = $output -> output_key ;
// Skip if column doesn't exist in transactions table
if ( ! $this -> columnExists ( $columnName )) {
continue ;
}
// Decode JSON content, store NULL if empty or invalid
$content = json_decode ( $output -> content , true );
$transactionData [ $columnName ] = $content ?: null ;
}
return Transaction :: updateOrCreate (
[ 'reference' => $reference ],
$transactionData
);
}
/**
* Extract country code from data
*/
private function extractCountry ( $firstOutput , $outputs ) : string
{
// Try to get from tx_country_incoming
if ( ! empty ( $firstOutput -> tx_country_incoming )) {
$country = $this -> extractCountryCode ( $firstOutput -> tx_country_incoming );
if ( $country ) {
return $country ;
}
}
// Try to extract from corporate_HQ
$hq = $this -> extractField ( $outputs , 'corporate_HQ' );
if ( $hq && is_array ( $hq ) && isset ( $hq [ 'answer' ])) {
// Simple pattern matching for country codes
if ( preg_match ( '/\b([A-Z]{2})\b/' , $hq [ 'answer' ], $matches )) {
return $matches [ 1 ];
}
}
return 'DE' ; // Default
}
/**
* Extract country code (convert to ISO 2-letter if needed)
2025-11-19 12:23:27 +01:00
* Also checks counterparty name for country indicators
2025-11-17 15:27:21 +01:00
*/
2025-11-19 12:23:27 +01:00
private function extractCountryCode ( ? string $country , ? string $counterpartyName = null ) : ? string
2025-11-17 15:27:21 +01:00
{
2025-11-19 12:23:27 +01:00
// First, try to extract from counterparty name (highest priority for tax havens)
if ( ! empty ( $counterpartyName )) {
$extractedFromName = $this -> extractCountryFromText ( $counterpartyName );
if ( $extractedFromName ) {
return $extractedFromName ;
}
}
2025-11-17 15:27:21 +01:00
if ( empty ( $country )) {
return null ;
}
2025-11-19 12:23:27 +01:00
// If already 2 letters, check if it's a suspicious/wrong code
2025-11-17 15:27:21 +01:00
if ( strlen ( $country ) === 2 ) {
2025-11-19 12:23:27 +01:00
$suspiciousCodes = [ 'IO' ]; // British Indian Ocean Territory often wrong for Virgin Islands
if ( in_array ( strtoupper ( $country ), $suspiciousCodes )) {
return null ; // Reject suspicious codes
}
2025-11-17 15:27:21 +01:00
return strtoupper ( $country );
}
// Simple country name mapping (extend as needed)
$countryMap = [
'Germany' => 'DE' ,
'Deutschland' => 'DE' ,
'United States' => 'US' ,
'USA' => 'US' ,
'United Kingdom' => 'GB' ,
'UK' => 'GB' ,
'France' => 'FR' ,
'Spain' => 'ES' ,
'Italy' => 'IT' ,
'Netherlands' => 'NL' ,
'Belgium' => 'BE' ,
'Austria' => 'AT' ,
'Switzerland' => 'CH' ,
'Poland' => 'PL' ,
'Czech Republic' => 'CZ' ,
'Denmark' => 'DK' ,
'Sweden' => 'SE' ,
'Norway' => 'NO' ,
'Finland' => 'FI' ,
];
return $countryMap [ $country ] ?? null ;
}
/**
2025-11-19 12:23:27 +01:00
* Extract country code from text (e.g., company name)
2025-11-17 15:27:21 +01:00
*/
2025-11-19 12:23:27 +01:00
private function extractCountryFromText ( string $text ) : ? string
2025-11-17 15:27:21 +01:00
{
2025-11-19 12:23:27 +01:00
$countryPatterns = [
'/\b(?:British )?Virgin Islands\b/i' => 'VG' ,
'/\bCayman Islands\b/i' => 'KY' ,
'/\bBermuda\b/i' => 'BM' ,
'/\bBahamas\b/i' => 'BS' ,
'/\bPanama\b/i' => 'PA' ,
'/\bLiechtenstein\b/i' => 'LI' ,
'/\bMonaco\b/i' => 'MC' ,
'/\bLuxembourg\b/i' => 'LU' ,
'/\bSwitzerland\b/i' => 'CH' ,
'/\bAzerbaijan\b/i' => 'AZ' ,
'/\bRussia\b/i' => 'RU' ,
'/\bChina\b/i' => 'CN' ,
'/\bGermany\b/i' => 'DE' ,
];
2025-11-17 15:27:21 +01:00
2025-11-19 12:23:27 +01:00
foreach ( $countryPatterns as $pattern => $code ) {
if ( preg_match ( $pattern , $text )) {
return $code ;
}
2025-11-17 15:27:21 +01:00
}
2025-11-19 12:23:27 +01:00
return null ;
}
2025-11-17 15:27:21 +01:00
2025-11-19 12:23:27 +01:00
/**
* Map backend risk_score (-100 to 100) to risk_level
* -100 to 20 = Critical
* 20 to 80 = High
* 80 to 100 = Low
*/
private function mapBackendRiskScoreToLevel ( int $backendRiskScore ) : string
{
if ( $backendRiskScore <= 20 ) {
return 'critical' ;
2025-11-17 15:27:21 +01:00
}
2025-11-19 12:23:27 +01:00
if ( $backendRiskScore <= 80 ) {
return 'high' ;
}
return 'low' ;
}
/**
* Map backend risk_score (-100 to 100) to 0-255 scale
*/
private function mapBackendRiskScoreTo255Scale ( int $backendRiskScore ) : int
{
// Normalize -100 to 100 → 0 to 255
// Formula: ((score + 100) / 200) * 255
$normalized = (( $backendRiskScore + 100 ) / 200 ) * 255 ;
return ( int ) round ( max ( 0 , min ( 255 , $normalized )));
}
/**
* Determine if transaction requires review based on risk level
*/
private function shouldRequireReview ( string $riskLevel ) : bool
{
return match ( $riskLevel ) {
'critical' => true ,
'high' => true ,
'low' => false ,
default => true ,
};
2025-11-17 15:27:21 +01:00
}
/**
* Extract a field from outputs
*/
private function extractField ( $outputs , string $outputKey , int $maxLength = 255 ) : mixed
{
$output = $outputs -> firstWhere ( 'output_key' , $outputKey );
if ( ! $output ) {
return null ;
}
$data = json_decode ( $output -> content , true );
// Extract 'answer' field if it exists
if ( is_array ( $data ) && isset ( $data [ 'answer' ])) {
$answer = $data [ 'answer' ];
// If answer is an object/array, convert to string
if ( is_array ( $answer )) {
$answer = json_encode ( $answer );
}
// Truncate to max length if needed
return $this -> truncateString (( string ) $answer , $maxLength );
}
// If data is an object/array, convert to JSON string
if ( is_array ( $data )) {
return $this -> truncateString ( json_encode ( $data ), $maxLength );
}
return $this -> truncateString (( string ) $data , $maxLength );
}
2025-11-19 12:23:27 +01:00
/**
* Extract a lean/concise field from outputs (extracts first sentence only)
*/
private function extractLeanField ( $outputs , string $outputKey , int $maxLength = 255 ) : mixed
{
$fullText = $this -> extractField ( $outputs , $outputKey , 5000 );
if ( ! $fullText ) {
return null ;
}
// Extract first sentence or up to first period
$firstSentence = preg_split ( '/\.(?:\s|$)/' , $fullText , 2 )[ 0 ];
if ( $firstSentence ) {
$firstSentence = trim ( $firstSentence ) . '.' ;
return $this -> truncateString ( $firstSentence , $maxLength );
}
return $this -> truncateString ( $fullText , $maxLength );
}
/**
* Extract legal name from corporate name field
*/
private function extractLegalName ( $outputs , string $fallbackName ) : ? string
{
$fullText = $this -> extractField ( $outputs , 'entity_corporate_name' , 5000 );
if ( ! $fullText ) {
return $fallbackName ;
}
// Pattern 1: "is exactly as shown in an official register: [NAME]"
if ( preg_match ( '/is exactly as shown in an official register:\s*([A-Z][^.,]+(?:AG|SE|GmbH|Inc\.|Ltd\.|Corp\.|N\.V\.|S\.A\.|Aktiengesellschaft))/i' , $fullText , $matches )) {
return trim ( $matches [ 1 ]);
}
// Pattern 2: "is [NAME]" - matches various patterns like "legal name is NAME", "company is NAME"
if ( preg_match ( '/(?:legal name|name|company) (?:of .+ )?is\s+([A-Z][^.,]+(?:AG|SE|GmbH|Inc\.|Ltd\.|Corp\.|N\.V\.|S\.A\.|Aktiengesellschaft))/i' , $fullText , $matches )) {
$name = trim ( $matches [ 1 ]);
// Remove trailing explanatory text
$name = preg_replace ( '/\s+(?:is exactly|exactly as shown|with former|former names).*$/i' , '' , $name );
return trim ( $name );
}
// Pattern 3: "of [NAME]" - matches "legal name of Deutsche Bank AG"
if ( preg_match ( '/legal name of\s+([A-Z][^.,]+(?:AG|SE|GmbH|Inc\.|Ltd\.|Corp\.|N\.V\.|S\.A\.|Aktiengesellschaft))/i' , $fullText , $matches )) {
$name = trim ( $matches [ 1 ]);
$name = preg_replace ( '/\s+(?:is exactly|with former).*$/i' , '' , $name );
return trim ( $name );
}
// Pattern 4: Extract company name with suffix at start of sentence
if ( preg_match ( '/^(?:The current legal name (?:of|is) )?([A-Z][^.,]+(?:AG|SE|GmbH|Inc\.|Ltd\.|Corp\.|N\.V\.|S\.A\.|Aktiengesellschaft))/i' , $fullText , $matches )) {
return trim ( $matches [ 1 ]);
}
// If no pattern matches, use the fallback name
return $fallbackName ;
}
2025-11-17 15:27:21 +01:00
/**
* Truncate string to max length
*/
private function truncateString ( string $text , int $maxLength ) : string
{
if ( strlen ( $text ) <= $maxLength ) {
return $text ;
}
2025-11-19 12:23:27 +01:00
return substr ( $text , 0 , $maxLength - 3 ) . '...' ;
2025-11-17 15:27:21 +01:00
}
/**
* Parse date string to Carbon instance
*/
private function parseDate ( ? string $date ) : ? Carbon
{
if ( empty ( $date )) {
return null ;
}
try {
return Carbon :: parse ( $date );
} catch ( \Exception $e ) {
Log :: warning ( 'Failed to parse date' , [ 'date' => $date ]);
return null ;
}
}
2025-11-19 12:23:27 +01:00
/**
* Generate a meaningful transaction reference
*/
private function generateTransactionReference ( $firstOutput ) : string
{
// Try to extract reference from raw_payload
if ( ! empty ( $firstOutput -> raw_payload )) {
$payload = json_decode ( $firstOutput -> raw_payload , true );
// Look for common reference fields
$referenceFields = [
'Transaction Reference' ,
'Reference' ,
'Transaction ID' ,
'Payment Reference' ,
'Transfer Reference' ,
];
foreach ( $referenceFields as $field ) {
if ( isset ( $payload [ $field ]) && ! empty ( $payload [ $field ])) {
return ( string ) $payload [ $field ];
}
}
}
// Generate a date-based reference if no reference found
$date = $this -> parseDate ( $firstOutput -> tx_date );
$dateStr = $date ? $date -> format ( 'Ymd' ) : date ( 'Ymd' );
return sprintf ( 'TXN-%s-%05d' , $dateStr , $firstOutput -> transaction_id );
}
/**
* Determine transaction type from available data
*/
private function determineTransactionType ( $firstOutput ) : string
{
// Try to determine from raw_payload first
if ( ! empty ( $firstOutput -> raw_payload )) {
$payload = json_decode ( $firstOutput -> raw_payload , true );
// Check for explicit transaction type field
if ( isset ( $payload [ 'Transaction Type' ]) && ! empty ( $payload [ 'Transaction Type' ])) {
return $payload [ 'Transaction Type' ];
}
if ( isset ( $payload [ 'Payment Type' ]) && ! empty ( $payload [ 'Payment Type' ])) {
return $payload [ 'Payment Type' ];
}
}
// Determine based on amount and countries
$amount = ( float ) $firstOutput -> tx_amount ;
$isInternational = $firstOutput -> tx_country_outgoing !== $firstOutput -> tx_country_incoming ;
// Large international transactions are likely SWIFT
if ( $isInternational && $amount > 100000 ) {
return 'SWIFT Transfer' ;
}
// European transfers
if ( $isInternational && $this -> isEuropeanCountry ( $firstOutput -> tx_country_incoming )) {
return 'SEPA Transfer' ;
}
// Domestic high-value
if ( ! $isInternational && $amount > 50000 ) {
return 'Wire Transfer' ;
}
// Default
return 'Bank Transfer' ;
}
/**
* Check if country is in Europe
*/
private function isEuropeanCountry ( ? string $countryCode ) : bool
{
if ( empty ( $countryCode )) {
return false ;
}
$europeanCountries = [
'AT' , 'BE' , 'BG' , 'HR' , 'CY' , 'CZ' , 'DK' , 'EE' , 'FI' , 'FR' ,
'DE' , 'GR' , 'HU' , 'IE' , 'IT' , 'LV' , 'LT' , 'LU' , 'MT' , 'NL' ,
'PL' , 'PT' , 'RO' , 'SK' , 'SI' , 'ES' , 'SE' , 'CH' , 'NO' , 'IS' ,
];
return in_array ( strtoupper ( $countryCode ), $europeanCountries );
}
2025-11-17 15:27:21 +01:00
/**
* Check if column exists in transactions table
*/
private function columnExists ( string $columnName ) : bool
{
static $columns = null ;
if ( $columns === null ) {
$columns = DB :: getSchemaBuilder () -> getColumnListing ( 'transactions' );
}
return in_array ( $columnName , $columns );
}
/**
* Get statistics about the transformation
*/
public function getStats () : array
{
return [
'data_pool_transactions' => DB :: table ( 'backend_data_pool' )
-> select ( 'transaction_id' )
-> distinct ()
-> count (),
'companies_count' => Company :: count (),
'transactions_count' => Transaction :: count (),
'migrated_transactions' => Transaction :: where ( 'reference' , 'like' , 'MIGRATED-%' ) -> count (),
];
}
}