approve(1, 'admin-01'); $this->assertTrue($result['success']); $this->assertSame('approved', $repository->rewards[1]['status']); $this->assertSame(6000, $repository->mails[1001][0]['amount']); $this->assertSame(6000, $repository->mails[1002][0]['amount']); $this->assertSame('Claim your $60 referral reward', $repository->mails[1001][0]['title']); $this->assertSame('Claim your $60 welcome reward', $repository->mails[1002][0]['title']); $this->assertStringContainsString('Reward: $60', $repository->mails[1001][0]['text']); $this->assertStringContainsString('first deposit of $120', $repository->mails[1002][0]['text']); $this->assertSame('approve', $repository->audits[0]['action']); } public function test_reject_requires_reason_and_writes_audit() { $repository = new InMemoryWorldCupReviewRepository(); $service = new WorldCupReviewService($repository); $missingReason = $service->reject(1, 'admin-01', ''); $result = $service->reject(1, 'admin-01', 'same_device'); $this->assertFalse($missingReason['success']); $this->assertSame('Reason is required', $missingReason['message']); $this->assertTrue($result['success']); $this->assertSame('rejected', $repository->rewards[1]['status']); $this->assertSame(WorldCupReviewService::REJECT_REASON, $repository->rewards[1]['reason_code']); $this->assertSame('Your referral reward could not be approved', $repository->mails[1001][0]['title']); $this->assertSame('Welcome reward not approved', $repository->mails[1002][0]['title']); $this->assertStringContainsString(WorldCupReviewService::REJECT_REASON, $repository->mails[1001][0]['text']); $this->assertStringContainsString('Your welcome reward could not be approved.', $repository->mails[1002][0]['text']); $this->assertSame('reject', $repository->audits[0]['action']); } public function test_hold_moves_reviewing_reward_to_on_hold() { $repository = new InMemoryWorldCupReviewRepository(); $service = new WorldCupReviewService($repository); $result = $service->hold(1, 'admin-01'); $this->assertTrue($result['success']); $this->assertSame('on_hold', $repository->rewards[1]['status']); $this->assertSame('hold', $repository->audits[0]['action']); } public function test_clawback_only_allows_approved_reward() { $repository = new InMemoryWorldCupReviewRepository(); $service = new WorldCupReviewService($repository); $notApproved = $service->clawback(1, 'admin-01', 'fraud', true); $approved = $service->clawback(3, 'admin-01', 'fraud', true); $this->assertFalse($notApproved['success']); $this->assertSame('Only approved rewards can be clawed back', $notApproved['message']); $this->assertTrue($approved['success']); $this->assertSame('clawed_back', $repository->rewards[3]['status']); $this->assertSame(1001, $repository->banned[0]); $this->assertSame(1004, $repository->banned[1]); $this->assertSame(10000, $repository->clawedBack[1001]); $this->assertSame(10000, $repository->clawedBack[1004]); } public function test_batch_approve_rejects_high_risk_rewards() { $repository = new InMemoryWorldCupReviewRepository(); $service = new WorldCupReviewService($repository); $result = $service->batchApprove([1, 2], 'admin-01'); $this->assertFalse($result['success']); $this->assertSame('High risk rewards must be reviewed one by one', $result['message']); $this->assertSame('reviewing', $repository->rewards[1]['status']); $this->assertSame('reviewing', $repository->rewards[2]['status']); } public function test_batch_reject_requires_reason() { $repository = new InMemoryWorldCupReviewRepository(); $service = new WorldCupReviewService($repository); $missingReason = $service->batchReject([1, 2], 'admin-01', ''); $result = $service->batchReject([1, 2], 'admin-01', 'bulk_signup'); $this->assertFalse($missingReason['success']); $this->assertSame('Reason is required', $missingReason['message']); $this->assertTrue($result['success']); $this->assertSame('rejected', $repository->rewards[1]['status']); $this->assertSame('rejected', $repository->rewards[2]['status']); } } class InMemoryWorldCupReviewRepository implements WorldCupReviewRepositoryInterface { public $rewards = [ 1 => [ 'reward_id' => 1, 'referrer_id' => 1001, 'invitee_id' => 1002, 'first_deposit_amt' => 12000, 'reward_each' => 6000, 'total_liability' => 12000, 'risk_level' => 'low', 'status' => 'reviewing', ], 2 => [ 'reward_id' => 2, 'referrer_id' => 1001, 'invitee_id' => 1003, 'first_deposit_amt' => 30000, 'reward_each' => 10000, 'total_liability' => 20000, 'risk_level' => 'high', 'status' => 'reviewing', ], 3 => [ 'reward_id' => 3, 'referrer_id' => 1001, 'invitee_id' => 1004, 'first_deposit_amt' => 30000, 'reward_each' => 10000, 'total_liability' => 20000, 'risk_level' => 'low', 'status' => 'approved', ], ]; public $mails = []; public $clawedBack = []; public $banned = []; public $audits = []; public function findReward(int $rewardId): ?array { return $this->rewards[$rewardId] ?? null; } public function findRewards(array $rewardIds): array { return array_values(array_filter($this->rewards, function (array $reward) use ($rewardIds) { return in_array((int)$reward['reward_id'], $rewardIds, true); })); } public function updateRewardStatus( int $rewardId, string $status, string $actor, string $reasonCode = null ): void { $this->rewards[$rewardId]['status'] = $status; $this->rewards[$rewardId]['review_by'] = $actor; $this->rewards[$rewardId]['reason_code'] = $reasonCode; } public function payReward(array $reward): void { $this->mails[$reward['referrer_id']][] = [ 'title' => 'Claim your $60 referral reward', 'text' => 'Reward: $60', 'amount' => $reward['reward_each'], ]; $this->mails[$reward['invitee_id']][] = [ 'title' => 'Claim your $60 welcome reward', 'text' => 'Welcome! Your referral reward is ready to claim. ' . 'You signed up with an invite and made your first deposit of $120. ' . 'Reward: $60 (50% of your first deposit). ' . 'Tap Claim to add it to your balance.', 'amount' => $reward['reward_each'], ]; } public function sendRejectMail(array $reward, string $reason): void { $this->mails[$reward['referrer_id']][] = [ 'title' => 'Your referral reward could not be approved', 'text' => 'Reason: ' . $reason, 'amount' => 0, ]; $this->mails[$reward['invitee_id']][] = [ 'title' => 'Welcome reward not approved', 'text' => 'Your welcome reward could not be approved.' . "\n" . 'Reason: ' . $reason . '.' . "\n" . 'If you think this is a mistake, contact support.', 'amount' => 0, ]; } public function clawbackReward(array $reward, bool $banUsers): void { $this->clawedBack[$reward['referrer_id']] = ($this->clawedBack[$reward['referrer_id']] ?? 0) + $reward['reward_each']; $this->clawedBack[$reward['invitee_id']] = ($this->clawedBack[$reward['invitee_id']] ?? 0) + $reward['reward_each']; if ($banUsers) { $this->banned[] = $reward['referrer_id']; $this->banned[] = $reward['invitee_id']; } } public function writeAudit( int $rewardId, string $actor, string $action, ?string $reasonCode, ?string $beforeStatus, ?string $afterStatus, array $payload = [] ): void { $this->audits[] = compact( 'rewardId', 'actor', 'action', 'reasonCode', 'beforeStatus', 'afterStatus', 'payload' ); } public function queue(array $filters): array { return []; } public function kpi(): array { return []; } public function auditLogs($filters): array { return []; } }