From 20fb740d6135ff8999d765093497c785e9a18ade Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 04:51:30 +0100 Subject: [PATCH] feat(commerce): implement FraudService with 5 methods + FraudScore DTO (#859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - score(order) → FraudScore (score 0-100, signals[], recommendation) - flag(order, reason) → void (marks for review) - block(order, reason) → void (rejects order) - reviewQueue() → Collection - approve(order) → void Data/FraudScore.php as readonly DTO. Pest tests _Good/_Bad/_Ugly per AX-10 for all 5 methods. pint/pest skipped (vendor binaries missing). Co-authored-by: Codex Closes tasks.lthn.sh/view.php?id=859 --- Data/FraudScore.php | 39 +++ Services/FraudService.php | 373 ++++++++++++++++++++++- tests/Unit/Services/FraudServiceTest.php | 288 +++++++++++++++++ 3 files changed, 692 insertions(+), 8 deletions(-) create mode 100644 Data/FraudScore.php create mode 100644 tests/Unit/Services/FraudServiceTest.php diff --git a/Data/FraudScore.php b/Data/FraudScore.php new file mode 100644 index 0000000..05cd023 --- /dev/null +++ b/Data/FraudScore.php @@ -0,0 +1,39 @@ +score < 0 || $this->score > 100) { + throw new InvalidArgumentException('Fraud score must be between 0 and 100.'); + } + + if (! in_array($this->recommendation, ['approve', 'review', 'block'], true)) { + throw new InvalidArgumentException('Fraud recommendation must be approve, review, or block.'); + } + } + + /** + * @return array{score: int, signals: array, recommendation: string} + */ + public function toArray(): array + { + return [ + 'score' => $this->score, + 'signals' => $this->signals, + 'recommendation' => $this->recommendation, + ]; + } +} diff --git a/Services/FraudService.php b/Services/FraudService.php index 42dd696..2b238e4 100644 --- a/Services/FraudService.php +++ b/Services/FraudService.php @@ -5,10 +5,14 @@ declare(strict_types=1); namespace Core\Mod\Commerce\Services; use Core\Mod\Commerce\Data\FraudAssessment; +use Core\Mod\Commerce\Data\FraudScore; use Core\Mod\Commerce\Models\Order; use Core\Mod\Commerce\Models\Payment; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Log; +use InvalidArgumentException; +use RuntimeException; /** * Fraud detection and scoring service. @@ -29,6 +33,145 @@ class FraudService public const RISK_NOT_ASSESSED = 'not_assessed'; + public const RECOMMENDATION_APPROVE = 'approve'; + + public const RECOMMENDATION_REVIEW = 'review'; + + public const RECOMMENDATION_BLOCK = 'block'; + + public const ORDER_STATUS_PENDING_REVIEW = 'pending_review'; + + private const FRAUD_REVIEW_PENDING = 'pending'; + + private const FRAUD_REVIEW_APPROVED = 'approved'; + + private const FRAUD_REVIEW_BLOCKED = 'blocked'; + + private const MAX_REASON_LENGTH = 500; + + private const SIGNAL_WEIGHTS = [ + 'velocity_ip_exceeded' => 35, + 'velocity_email_exceeded' => 25, + 'velocity_failed_exceeded' => 35, + 'geo_country_mismatch' => 20, + 'high_risk_country' => 60, + 'card_bin_country_mismatch' => 25, + 'network_declined' => 15, + ]; + + /** + * Score an order for fraud risk. + */ + public function score(Order $order): FraudScore + { + if (! config('commerce.fraud.enabled', true)) { + return new FraudScore( + score: 0, + signals: [], + recommendation: self::RECOMMENDATION_APPROVE + ); + } + + $score = 0; + $signals = []; + + if (config('commerce.fraud.velocity.enabled', true)) { + $this->addSignalsToScore($signals, $score, $this->checkVelocity($order)); + } + + if (config('commerce.fraud.geo.enabled', true)) { + $this->addSignalsToScore($signals, $score, $this->checkGeoAnomalies($order)); + } + + $this->addSignalsToScore($signals, $score, $this->checkCardBinMismatch($order)); + $score = max($score, $this->scoreStripeRadarSignals($order, $signals)); + $score = $this->clampScore($score); + + return new FraudScore( + score: $score, + signals: $signals, + recommendation: $this->recommendationForScore($score) + ); + } + + /** + * Mark an order for manual fraud review. + */ + public function flag(Order $order, string $reason): void + { + $reason = $this->normaliseReason($reason); + $metadata = $this->metadataWithFraudState($order, [ + 'review_status' => self::FRAUD_REVIEW_PENDING, + 'review_reason' => $reason, + 'previous_status' => $this->previousOrderStatus($order), + 'flagged_at' => now()->toIso8601String(), + 'approved_at' => null, + 'blocked_at' => null, + ]); + + $order->update([ + 'status' => self::ORDER_STATUS_PENDING_REVIEW, + 'metadata' => $metadata, + ]); + } + + /** + * Reject an order due to confirmed fraud. + */ + public function block(Order $order, string $reason): void + { + $reason = $this->normaliseReason($reason); + $metadata = $this->metadataWithFraudState($order, [ + 'review_status' => self::FRAUD_REVIEW_BLOCKED, + 'block_reason' => $reason, + 'blocked_at' => now()->toIso8601String(), + 'failure_reason' => $reason, + ]); + + $metadata['failure_reason'] = $reason; + $metadata['failed_at'] = now()->toIso8601String(); + + $order->update([ + 'status' => 'failed', + 'metadata' => $metadata, + ]); + } + + /** + * Orders waiting for manual fraud review. + * + * @return Collection + */ + public function reviewQueue(): Collection + { + return Order::query() + ->where('status', self::ORDER_STATUS_PENDING_REVIEW) + ->oldest() + ->get() + ->filter(fn (Order $order): bool => data_get($order->metadata, 'fraud.review_status') === self::FRAUD_REVIEW_PENDING) + ->values(); + } + + /** + * Approve an order that was held for fraud review. + */ + public function approve(Order $order): void + { + if (data_get($order->metadata, 'fraud.review_status') !== self::FRAUD_REVIEW_PENDING) { + throw new RuntimeException('Only orders pending fraud review can be approved.'); + } + + $metadata = $this->metadataWithFraudState($order, [ + 'review_status' => self::FRAUD_REVIEW_APPROVED, + 'approved_at' => now()->toIso8601String(), + ]); + + $order->update([ + 'status' => data_get($metadata, 'fraud.previous_status', 'pending'), + 'metadata' => $metadata, + ]); + } + /** * Assess fraud risk for an order before checkout. * @@ -162,9 +305,9 @@ class FraudService protected function checkVelocity(Order $order): array { $signals = []; - $ip = request()->ip(); + $ip = $this->getOrderIp($order); $email = $order->billing_email; - $workspaceId = $order->orderable_id; + $workspaceId = $this->getOrderWorkspaceId($order); $maxOrdersPerIpHourly = config('commerce.fraud.velocity.max_orders_per_ip_hourly', 5); $maxOrdersPerEmailDaily = config('commerce.fraud.velocity.max_orders_per_email_daily', 10); @@ -227,8 +370,8 @@ class FraudService protected function checkGeoAnomalies(Order $order): array { $signals = []; - $billingCountry = $order->billing_address['country'] ?? $order->tax_country ?? null; - $ipCountry = $this->getIpCountry(); + $billingCountry = $this->getBillingCountry($order); + $ipCountry = $this->getIpCountry($order); // Check for country mismatch if (config('commerce.fraud.geo.flag_country_mismatch', true)) { @@ -241,7 +384,11 @@ class FraudService } // Check for high-risk countries - $highRiskCountries = config('commerce.fraud.geo.high_risk_countries', []); + $configuredHighRiskCountries = config('commerce.fraud.geo.high_risk_countries', []); + $highRiskCountries = array_map( + fn (mixed $country): ?string => $this->normaliseCountry($country), + is_array($configuredHighRiskCountries) ? $configuredHighRiskCountries : [] + ); if (! empty($highRiskCountries) && $billingCountry) { if (in_array($billingCountry, $highRiskCountries, true)) { $signals['high_risk_country'] = $billingCountry; @@ -254,9 +401,21 @@ class FraudService /** * Get country code from IP address. */ - protected function getIpCountry(): ?string + protected function getIpCountry(?Order $order = null): ?string { - $ip = request()->ip(); + if ($order) { + $metadata = $order->metadata ?? []; + $metadataCountry = data_get($metadata, 'ip_country') + ?? data_get($metadata, 'ip_country_code') + ?? data_get($metadata, 'geo.country') + ?? data_get($metadata, 'ip.country'); + + if ($metadataCountry) { + return $this->normaliseCountry($metadataCountry); + } + } + + $ip = $order ? $this->getOrderIp($order) : request()->ip(); if (! $ip || $ip === '127.0.0.1' || str_starts_with($ip, '192.168.')) { return null; } @@ -279,6 +438,204 @@ class FraudService }); } + /** + * Check for card issuing country mismatch against billing country. + */ + protected function checkCardBinMismatch(Order $order): array + { + $billingCountry = $this->getBillingCountry($order); + $metadata = $order->metadata ?? []; + $cardCountry = $this->normaliseCountry( + data_get($metadata, 'card_bin_country') + ?? data_get($metadata, 'card.bin_country') + ?? data_get($metadata, 'payment_method.card_country') + ?? data_get($metadata, 'payment_method_details.card.country') + ?? data_get($metadata, 'stripe.payment_method_details.card.country') + ); + + if (! $billingCountry || ! $cardCountry || $billingCountry === $cardCountry) { + return []; + } + + return [ + 'card_bin_country_mismatch' => [ + 'billing_country' => $billingCountry, + 'card_country' => $cardCountry, + ], + ]; + } + + /** + * Fold weighted signals into the running fraud score. + * + * @param array $signals + * @param array $newSignals + */ + protected function addSignalsToScore(array &$signals, int &$score, array $newSignals): void + { + foreach ($newSignals as $key => $value) { + $signals[$key] = $value; + $score += self::SIGNAL_WEIGHTS[$key] ?? 10; + } + } + + /** + * Convert Stripe Radar metadata into score and signals. + * + * @param array $signals + */ + protected function scoreStripeRadarSignals(Order $order, array &$signals): int + { + $radar = $this->getStripeRadarMetadata($order); + + if ($radar === []) { + return 0; + } + + $score = 0; + $riskLevel = data_get($radar, 'risk_level') ?? data_get($radar, 'riskLevel'); + $riskScore = data_get($radar, 'risk_score') ?? data_get($radar, 'stripe_risk_score'); + + if ($riskLevel === self::RISK_HIGHEST) { + $signals['stripe_risk_highest'] = true; + $score = max($score, 90); + } elseif ($riskLevel === self::RISK_ELEVATED) { + $signals['stripe_risk_elevated'] = true; + $score = max($score, 60); + } + + if (is_numeric($riskScore)) { + $signals['stripe_risk_score'] = (int) $riskScore; + $score = max($score, (int) $riskScore); + } + + $ruleAction = data_get($radar, 'rule.action') ?? data_get($radar, 'stripe_rule_action'); + if ($ruleAction) { + $signals['stripe_rule_action'] = $ruleAction; + } + + if ($ruleAction === 'block') { + $score = 100; + } + + $networkStatus = data_get($radar, 'network_status'); + if ($networkStatus === 'declined_by_network') { + $signals['network_declined'] = true; + $score += self::SIGNAL_WEIGHTS['network_declined']; + } + + return $this->clampScore($score); + } + + /** + * Extract Stripe Radar metadata from known order metadata locations. + * + * @return array + */ + protected function getStripeRadarMetadata(Order $order): array + { + $metadata = $order->metadata ?? []; + $radar = data_get($metadata, 'stripe_radar') + ?? data_get($metadata, 'stripe.outcome') + ?? data_get($metadata, 'payment.outcome') + ?? data_get($metadata, 'fraud_assessment'); + + return is_array($radar) ? $radar : []; + } + + protected function clampScore(int $score): int + { + return max(0, min(100, $score)); + } + + protected function recommendationForScore(int $score): string + { + $blockThreshold = (int) config('commerce.fraud.score.block_threshold', 80); + $reviewThreshold = (int) config('commerce.fraud.score.review_threshold', 50); + + if ($score >= $blockThreshold) { + return self::RECOMMENDATION_BLOCK; + } + + if ($score >= $reviewThreshold) { + return self::RECOMMENDATION_REVIEW; + } + + return self::RECOMMENDATION_APPROVE; + } + + protected function getBillingCountry(Order $order): ?string + { + return $this->normaliseCountry( + data_get($order->billing_address, 'country') + ?? data_get($order->metadata, 'billing_country') + ?? $order->tax_country + ); + } + + protected function normaliseCountry(mixed $country): ?string + { + if (! is_string($country) || trim($country) === '') { + return null; + } + + return strtoupper(substr(trim($country), 0, 2)); + } + + protected function getOrderIp(Order $order): ?string + { + $metadata = $order->metadata ?? []; + $ip = data_get($metadata, 'ip_address') + ?? data_get($metadata, 'ip') + ?? data_get($metadata, 'customer_ip') + ?? request()->ip(); + + return is_string($ip) && trim($ip) !== '' ? trim($ip) : null; + } + + protected function getOrderWorkspaceId(Order $order): ?int + { + $workspaceId = $order->getAttribute('workspace_id') + ?? $order->getAttribute('workspaceId') + ?? $order->workspace_id + ?? $order->orderable_id; + + return $workspaceId === null ? null : (int) $workspaceId; + } + + protected function normaliseReason(string $reason): string + { + $reason = trim((string) preg_replace('/[[:cntrl:]]+/', ' ', $reason)); + + if ($reason === '') { + throw new InvalidArgumentException('Fraud reason is required.'); + } + + return substr($reason, 0, self::MAX_REASON_LENGTH); + } + + /** + * @param array $fraudState + * @return array + */ + protected function metadataWithFraudState(Order $order, array $fraudState): array + { + $metadata = $order->metadata ?? []; + $fraud = is_array($metadata['fraud'] ?? null) ? $metadata['fraud'] : []; + $metadata['fraud'] = array_merge($fraud, $fraudState); + + return $metadata; + } + + protected function previousOrderStatus(Order $order): string + { + if ($order->status !== self::ORDER_STATUS_PENDING_REVIEW) { + return $order->status; + } + + return data_get($order->metadata, 'fraud.previous_status', 'pending'); + } + /** * Determine if order should be blocked based on risk level. */ @@ -366,7 +723,7 @@ class FraudService */ public function recordFailedPayment(Order $order): void { - $workspaceId = $order->orderable_id; + $workspaceId = $this->getOrderWorkspaceId($order); if ($workspaceId) { $failedKey = "fraud:failed:workspace:{$workspaceId}"; diff --git a/tests/Unit/Services/FraudServiceTest.php b/tests/Unit/Services/FraudServiceTest.php new file mode 100644 index 0000000..7bc2752 --- /dev/null +++ b/tests/Unit/Services/FraudServiceTest.php @@ -0,0 +1,288 @@ +id(); + $table->unsignedBigInteger('workspace_id')->nullable(); + $table->string('orderable_type')->nullable(); + $table->unsignedBigInteger('orderable_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->string('order_number')->unique(); + $table->string('status')->default('pending'); + $table->string('type')->default('new'); + $table->string('billing_cycle')->nullable(); + $table->string('currency', 3)->default('GBP'); + $table->decimal('subtotal', 10, 2)->default(0); + $table->decimal('tax_amount', 10, 2)->default(0); + $table->decimal('discount_amount', 10, 2)->default(0); + $table->decimal('total', 10, 2)->default(0); + $table->string('billing_name')->nullable(); + $table->string('billing_email')->nullable(); + $table->decimal('tax_rate', 6, 4)->nullable(); + $table->string('tax_country', 2)->nullable(); + $table->json('billing_address')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + }); + + Order::unsetEventDispatcher(); + Cache::flush(); + + config([ + 'commerce.fraud.enabled' => true, + 'commerce.fraud.score.review_threshold' => 50, + 'commerce.fraud.score.block_threshold' => 80, + 'commerce.fraud.velocity.enabled' => true, + 'commerce.fraud.velocity.max_orders_per_ip_hourly' => 1, + 'commerce.fraud.velocity.max_orders_per_email_daily' => 1, + 'commerce.fraud.velocity.max_failed_payments_hourly' => 1, + 'commerce.fraud.geo.enabled' => true, + 'commerce.fraud.geo.flag_country_mismatch' => true, + 'commerce.fraud.geo.high_risk_countries' => ['IR'], + 'commerce.fraud.actions.log' => false, + 'commerce.fraud.actions.auto_block' => true, + 'commerce.fraud.stripe_radar.enabled' => true, + 'commerce.fraud.stripe_radar.block_threshold' => 'highest', + 'commerce.fraud.stripe_radar.review_threshold' => 'elevated', + ]); + + $this->service = new FraudService(); +}); + +afterEach(function (): void { + Schema::dropIfExists('orders'); +}); + +function fraudServiceTestOrder(array $overrides = []): Order +{ + return Order::forceCreate(array_merge([ + 'workspace_id' => 10, + 'orderable_id' => 10, + 'user_id' => null, + 'order_number' => 'ORD-'.uniqid(), + 'status' => 'pending', + 'type' => 'new', + 'currency' => 'GBP', + 'subtotal' => 100, + 'tax_amount' => 20, + 'discount_amount' => 0, + 'total' => 120, + 'billing_name' => 'Ada Lovelace', + 'billing_email' => 'ada@example.test', + 'tax_country' => 'GB', + 'billing_address' => ['country' => 'GB'], + 'metadata' => [ + 'ip_address' => '203.0.113.10', + 'ip_country' => 'GB', + ], + ], $overrides)); +} + +describe('FraudService score()', function (): void { + it('Good: approves a clean order with no risk signals', function (): void { + $score = $this->service->score(fraudServiceTestOrder()); + + expect($score)->toBeInstanceOf(FraudScore::class) + ->and($score->score)->toBe(0) + ->and($score->signals)->toBe([]) + ->and($score->recommendation)->toBe('approve'); + }); + + it('Bad: recommends review for velocity and geo mismatch signals', function (): void { + Cache::put('fraud:orders:ip:203.0.113.20', 1, now()->addHour()); + + $score = $this->service->score(fraudServiceTestOrder([ + 'metadata' => [ + 'ip_address' => '203.0.113.20', + 'ip_country' => 'US', + ], + ])); + + expect($score->recommendation)->toBe('review') + ->and($score->score)->toBeGreaterThanOrEqual(50) + ->and($score->signals)->toHaveKeys(['velocity_ip_exceeded', 'geo_country_mismatch']); + }); + + it('Ugly: clamps severe Stripe Radar and BIN signals at a block recommendation', function (): void { + $score = $this->service->score(fraudServiceTestOrder([ + 'metadata' => [ + 'ip_address' => '203.0.113.30', + 'ip_country' => 'US', + 'card_bin_country' => 'CA', + 'stripe_radar' => [ + 'risk_level' => 'highest', + 'risk_score' => 97, + 'rule' => ['action' => 'block'], + ], + ], + ])); + + expect($score->score)->toBe(100) + ->and($score->recommendation)->toBe('block') + ->and($score->signals)->toHaveKeys([ + 'geo_country_mismatch', + 'card_bin_country_mismatch', + 'stripe_risk_highest', + 'stripe_risk_score', + 'stripe_rule_action', + ]); + }); +}); + +describe('FraudService flag()', function (): void { + it('Good: marks an order as pending fraud review', function (): void { + $order = fraudServiceTestOrder(); + + $this->service->flag($order, 'Velocity threshold exceeded'); + + $order->refresh(); + expect($order->status)->toBe(FraudService::ORDER_STATUS_PENDING_REVIEW) + ->and(data_get($order->metadata, 'fraud.review_status'))->toBe('pending') + ->and(data_get($order->metadata, 'fraud.review_reason'))->toBe('Velocity threshold exceeded'); + }); + + it('Bad: rejects a blank review reason without changing the order', function (): void { + $order = fraudServiceTestOrder(); + + $this->service->flag($order, " \n\t "); + })->throws(InvalidArgumentException::class, 'Fraud reason is required.'); + + it('Ugly: preserves existing metadata and truncates oversized reasons', function (): void { + $order = fraudServiceTestOrder([ + 'metadata' => [ + 'ip_address' => '203.0.113.40', + 'ip_country' => 'GB', + 'checkout_reference' => 'abc123', + ], + ]); + + $this->service->flag($order, str_repeat('x', 700)); + + $order->refresh(); + expect(data_get($order->metadata, 'checkout_reference'))->toBe('abc123') + ->and(strlen(data_get($order->metadata, 'fraud.review_reason')))->toBe(500); + }); +}); + +describe('FraudService block()', function (): void { + it('Good: rejects an unpaid order with fraud metadata', function (): void { + $order = fraudServiceTestOrder(); + + $this->service->block($order, 'Confirmed card testing'); + + $order->refresh(); + expect($order->status)->toBe('failed') + ->and(data_get($order->metadata, 'failure_reason'))->toBe('Confirmed card testing') + ->and(data_get($order->metadata, 'fraud.review_status'))->toBe('blocked') + ->and(data_get($order->metadata, 'fraud.block_reason'))->toBe('Confirmed card testing'); + }); + + it('Bad: rejects a blank block reason', function (): void { + $order = fraudServiceTestOrder(); + + $this->service->block($order, ''); + })->throws(InvalidArgumentException::class, 'Fraud reason is required.'); + + it('Ugly: removes a previously flagged order from the review queue', function (): void { + $order = fraudServiceTestOrder(); + $this->service->flag($order, 'Manual review'); + + $this->service->block($order->refresh(), 'Confirmed fraud'); + + $order->refresh(); + expect($this->service->reviewQueue())->toHaveCount(0) + ->and($order->status)->toBe('failed') + ->and(data_get($order->metadata, 'fraud.review_status'))->toBe('blocked'); + }); +}); + +describe('FraudService reviewQueue()', function (): void { + it('Good: returns pending fraud review orders oldest first', function (): void { + $newest = fraudServiceTestOrder(['order_number' => 'ORD-NEW']); + $oldest = fraudServiceTestOrder(['order_number' => 'ORD-OLD']); + + $this->service->flag($newest, 'Second review'); + $newest->update(['created_at' => now()->addMinute()]); + $this->service->flag($oldest, 'First review'); + $oldest->update(['created_at' => now()->subMinute()]); + + $queue = $this->service->reviewQueue(); + + expect($queue)->toBeInstanceOf(Collection::class) + ->and($queue->pluck('order_number')->all())->toBe(['ORD-OLD', 'ORD-NEW']); + }); + + it('Bad: excludes blocked and approved orders', function (): void { + $blocked = fraudServiceTestOrder(['order_number' => 'ORD-BLOCKED']); + $approved = fraudServiceTestOrder(['order_number' => 'ORD-APPROVED']); + $pending = fraudServiceTestOrder(['order_number' => 'ORD-PENDING']); + + $this->service->block($blocked, 'Confirmed fraud'); + $this->service->flag($approved, 'Manual check'); + $this->service->approve($approved->refresh()); + $this->service->flag($pending, 'Manual check'); + + expect($this->service->reviewQueue()->pluck('order_number')->all())->toBe(['ORD-PENDING']); + }); + + it('Ugly: excludes stale pending-review statuses without fraud metadata', function (): void { + fraudServiceTestOrder([ + 'order_number' => 'ORD-STALE', + 'status' => FraudService::ORDER_STATUS_PENDING_REVIEW, + 'metadata' => ['note' => 'legacy status only'], + ]); + + expect($this->service->reviewQueue())->toHaveCount(0); + }); +}); + +describe('FraudService approve()', function (): void { + it('Good: approves a flagged order and restores its prior status', function (): void { + $order = fraudServiceTestOrder(['status' => 'processing']); + $this->service->flag($order, 'Manual check'); + + $this->service->approve($order->refresh()); + + $order->refresh(); + expect($order->status)->toBe('processing') + ->and(data_get($order->metadata, 'fraud.review_status'))->toBe('approved') + ->and(data_get($order->metadata, 'fraud.approved_at'))->not->toBeNull(); + }); + + it('Bad: refuses to approve an order that is not pending review', function (): void { + $order = fraudServiceTestOrder(); + + $this->service->approve($order); + })->throws(RuntimeException::class, 'Only orders pending fraud review can be approved.'); + + it('Ugly: removes the approved order from the review queue without dropping metadata', function (): void { + $order = fraudServiceTestOrder([ + 'metadata' => [ + 'ip_address' => '203.0.113.50', + 'ip_country' => 'GB', + 'checkout_reference' => 'keep-me', + ], + ]); + + $this->service->flag($order, 'Manual check'); + $this->service->approve($order->refresh()); + + $order->refresh(); + expect($this->service->reviewQueue())->toHaveCount(0) + ->and(data_get($order->metadata, 'checkout_reference'))->toBe('keep-me') + ->and(data_get($order->metadata, 'fraud.review_reason'))->toBe('Manual check'); + }); +});