feat(commerce): implement FraudService with 5 methods + FraudScore DTO (#859)
- score(order) → FraudScore (score 0-100, signals[], recommendation) - flag(order, reason) → void (marks for review) - block(order, reason) → void (rejects order) - reviewQueue() → Collection<Order> - 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 <noreply@openai.com> Closes tasks.lthn.sh/view.php?id=859
This commit is contained in:
parent
cd16c7474e
commit
20fb740d61
3 changed files with 692 additions and 8 deletions
39
Data/FraudScore.php
Normal file
39
Data/FraudScore.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Commerce\Data;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Order-level fraud score for manual review and blocking decisions.
|
||||
*/
|
||||
readonly class FraudScore
|
||||
{
|
||||
public function __construct(
|
||||
public int $score,
|
||||
public array $signals,
|
||||
public string $recommendation,
|
||||
) {
|
||||
if ($this->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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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<int, Order>
|
||||
*/
|
||||
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<string, mixed> $signals
|
||||
* @param array<string, mixed> $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<string, mixed> $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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $fraudState
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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}";
|
||||
|
|
|
|||
288
tests/Unit/Services/FraudServiceTest.php
Normal file
288
tests/Unit/Services/FraudServiceTest.php
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Core\Mod\Commerce\Data\FraudScore;
|
||||
use Core\Mod\Commerce\Models\Order;
|
||||
use Core\Mod\Commerce\Services\FraudService;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Schema::dropIfExists('orders');
|
||||
|
||||
Schema::create('orders', function (Blueprint $table): void {
|
||||
$table->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');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue