- 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
288 lines
11 KiB
PHP
288 lines
11 KiB
PHP
<?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');
|
|
});
|
|
});
|