php-commerce/tests/Feature/ReferralServiceTest.php
Claude 5fb038448b
test: add comprehensive tests for ReferralService
Cover referral code management, click tracking, conversion flow,
commission calculation, maturation, payout lifecycle, fraud prevention
(self-referral, disqualification), and statistics endpoints.

Fixes #6

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:25:09 +00:00

1411 lines
56 KiB
PHP

<?php
declare(strict_types=1);
use Core\Mod\Commerce\Models\Order;
use Core\Mod\Commerce\Models\Referral;
use Core\Mod\Commerce\Models\ReferralCode;
use Core\Mod\Commerce\Models\ReferralCommission;
use Core\Mod\Commerce\Models\ReferralPayout;
use Core\Mod\Commerce\Services\ReferralService;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Log;
uses(RefreshDatabase::class);
beforeEach(function () {
Log::spy();
$this->referrer = User::factory()->create();
$this->referee = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->referrer->id, [
'role' => 'owner',
'is_default' => true,
]);
$this->service = app(ReferralService::class);
});
// ─────────────────────────────────────────────────────────────────────────
// Referral Code Management
// ─────────────────────────────────────────────────────────────────────────
describe('ReferralService', function () {
describe('createCode() method', function () {
it('creates a custom referral code with defaults', function () {
$code = $this->service->createCode([
'code' => 'TESTCODE',
'user_id' => $this->referrer->id,
]);
expect($code)->toBeInstanceOf(ReferralCode::class)
->and($code->code)->toBe('TESTCODE')
->and($code->user_id)->toBe($this->referrer->id)
->and($code->type)->toBe(ReferralCode::TYPE_CUSTOM)
->and($code->cookie_days)->toBe(ReferralCode::DEFAULT_COOKIE_DAYS)
->and($code->is_active)->toBeTrue();
});
it('allows overriding default values', function () {
$code = $this->service->createCode([
'code' => 'SPECIAL',
'user_id' => $this->referrer->id,
'type' => ReferralCode::TYPE_CAMPAIGN,
'cookie_days' => 30,
'commission_rate' => 25.00,
]);
expect($code->type)->toBe(ReferralCode::TYPE_CAMPAIGN)
->and($code->cookie_days)->toBe(30)
->and((float) $code->commission_rate)->toBe(25.00);
});
});
describe('createCampaignCode() method', function () {
it('creates a campaign code with uppercase code', function () {
$code = $this->service->createCampaignCode(
code: 'summer2026',
campaignName: 'Summer Sale',
userId: $this->referrer->id,
commissionRate: 15.00,
metadata: ['channel' => 'instagram'],
);
expect($code)->toBeInstanceOf(ReferralCode::class)
->and($code->code)->toBe('SUMMER2026')
->and($code->type)->toBe(ReferralCode::TYPE_CAMPAIGN)
->and($code->campaign_name)->toBe('Summer Sale')
->and($code->user_id)->toBe($this->referrer->id)
->and((float) $code->commission_rate)->toBe(15.00)
->and($code->metadata)->toBe(['channel' => 'instagram'])
->and($code->is_active)->toBeTrue();
});
it('creates a campaign code without a user', function () {
$code = $this->service->createCampaignCode(
code: 'PROMO50',
campaignName: 'General Promo',
);
expect($code->user_id)->toBeNull()
->and($code->commission_rate)->toBeNull();
});
});
describe('findCode() method', function () {
it('finds an existing referral code', function () {
ReferralCode::create([
'code' => 'FINDME',
'type' => ReferralCode::TYPE_CUSTOM,
'is_active' => true,
]);
$found = $this->service->findCode('FINDME');
expect($found)->not->toBeNull()
->and($found->code)->toBe('FINDME');
});
it('returns null for non-existent code', function () {
$found = $this->service->findCode('NONEXISTENT');
expect($found)->toBeNull();
});
});
describe('validateCode() method', function () {
it('returns true for a valid active code', function () {
ReferralCode::create([
'code' => 'VALIDCODE',
'user_id' => $this->referrer->id,
'type' => ReferralCode::TYPE_CUSTOM,
'is_active' => true,
]);
expect($this->service->validateCode('VALIDCODE'))->toBeTrue();
});
it('returns false for an inactive code', function () {
ReferralCode::create([
'code' => 'INACTIVE',
'type' => ReferralCode::TYPE_CUSTOM,
'is_active' => false,
]);
expect($this->service->validateCode('INACTIVE'))->toBeFalse();
});
it('returns false for an expired code', function () {
ReferralCode::create([
'code' => 'EXPIRED',
'type' => ReferralCode::TYPE_CUSTOM,
'is_active' => true,
'valid_until' => now()->subDay(),
]);
expect($this->service->validateCode('EXPIRED'))->toBeFalse();
});
it('returns false for a code that has reached max uses', function () {
ReferralCode::create([
'code' => 'MAXED',
'type' => ReferralCode::TYPE_CUSTOM,
'is_active' => true,
'max_uses' => 5,
'uses_count' => 5,
]);
expect($this->service->validateCode('MAXED'))->toBeFalse();
});
it('returns false for a non-existent code', function () {
expect($this->service->validateCode('DOESNOTEXIST'))->toBeFalse();
});
});
// ─────────────────────────────────────────────────────────────────────
// Referral Tracking
// ─────────────────────────────────────────────────────────────────────
describe('resolveReferrerFromCode() method', function () {
it('resolves referrer from a custom referral code', function () {
ReferralCode::create([
'code' => 'MYCODE',
'user_id' => $this->referrer->id,
'type' => ReferralCode::TYPE_CUSTOM,
'is_active' => true,
]);
$referrerId = $this->service->resolveReferrerFromCode('MYCODE');
expect($referrerId)->toBe($this->referrer->id);
});
it('returns null for invalid code', function () {
$referrerId = $this->service->resolveReferrerFromCode('FAKECODE');
expect($referrerId)->toBeNull();
});
it('returns null for inactive code', function () {
ReferralCode::create([
'code' => 'DISABLED',
'user_id' => $this->referrer->id,
'type' => ReferralCode::TYPE_CUSTOM,
'is_active' => false,
]);
$referrerId = $this->service->resolveReferrerFromCode('DISABLED');
expect($referrerId)->toBeNull();
});
});
describe('trackClick() method', function () {
it('creates a referral record on click', function () {
ReferralCode::create([
'code' => 'CLICK1',
'user_id' => $this->referrer->id,
'type' => ReferralCode::TYPE_CUSTOM,
'is_active' => true,
]);
$referral = $this->service->trackClick(
code: 'CLICK1',
sourceUrl: 'https://twitter.com/post/123',
landingPage: '/pricing',
ipAddress: '192.168.1.1',
userAgent: 'Mozilla/5.0',
utmParams: [
'source' => 'twitter',
'medium' => 'social',
'campaign' => 'launch',
],
);
expect($referral)->toBeInstanceOf(Referral::class)
->and($referral->referrer_id)->toBe($this->referrer->id)
->and($referral->code)->toBe('CLICK1')
->and($referral->status)->toBe(Referral::STATUS_PENDING)
->and($referral->source_url)->toBe('https://twitter.com/post/123')
->and($referral->landing_page)->toBe('/pricing')
->and($referral->ip_address)->toBe('192.168.1.1')
->and($referral->user_agent)->toBe('Mozilla/5.0')
->and($referral->utm_source)->toBe('twitter')
->and($referral->utm_medium)->toBe('social')
->and($referral->utm_campaign)->toBe('launch')
->and($referral->tracking_id)->not->toBeNull()
->and($referral->clicked_at)->not->toBeNull();
});
it('returns null for an invalid code', function () {
$referral = $this->service->trackClick(code: 'INVALID');
expect($referral)->toBeNull();
});
it('truncates excessively long user agents', function () {
ReferralCode::create([
'code' => 'UATEST',
'user_id' => $this->referrer->id,
'type' => ReferralCode::TYPE_CUSTOM,
'is_active' => true,
]);
$longUa = str_repeat('A', 1024);
$referral = $this->service->trackClick(
code: 'UATEST',
userAgent: $longUa,
);
expect(strlen($referral->user_agent))->toBeLessThanOrEqual(515);
});
});
// ─────────────────────────────────────────────────────────────────────
// Referral Conversion
// ─────────────────────────────────────────────────────────────────────
describe('convertReferral() method', function () {
it('converts a pending referral by tracking ID', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'code' => 'CONV1',
'status' => Referral::STATUS_PENDING,
'tracking_id' => 'track-abc-123',
'clicked_at' => now(),
]);
$result = $this->service->convertReferral($this->referee, 'track-abc-123');
expect($result)->not->toBeNull()
->and($result->id)->toBe($referral->id)
->and($result->referee_id)->toBe($this->referee->id)
->and($result->status)->toBe(Referral::STATUS_CONVERTED)
->and($result->signed_up_at)->not->toBeNull();
});
it('creates and converts a new referral when only referrer ID is provided', function () {
$result = $this->service->convertReferral(
$this->referee,
referrerUserId: $this->referrer->id,
);
expect($result)->not->toBeNull()
->and($result->referrer_id)->toBe($this->referrer->id)
->and($result->referee_id)->toBe($this->referee->id)
->and($result->status)->toBe(Referral::STATUS_CONVERTED);
});
it('returns null when no tracking ID or referrer is provided', function () {
$result = $this->service->convertReferral($this->referee);
expect($result)->toBeNull();
});
it('prevents self-referral', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'code' => 'SELF',
'status' => Referral::STATUS_PENDING,
'tracking_id' => 'track-self-ref',
'clicked_at' => now(),
]);
// Attempt to convert with the referrer as the referee
$result = $this->service->convertReferral($this->referrer, 'track-self-ref');
expect($result)->toBeNull();
// Verify referral was not modified
$referral->refresh();
expect($referral->status)->toBe(Referral::STATUS_PENDING);
});
it('increments referral code usage on conversion', function () {
$code = ReferralCode::create([
'code' => 'CODETRACK',
'user_id' => $this->referrer->id,
'type' => ReferralCode::TYPE_CUSTOM,
'is_active' => true,
'uses_count' => 0,
]);
Referral::create([
'referrer_id' => $this->referrer->id,
'code' => 'CODETRACK',
'status' => Referral::STATUS_PENDING,
'tracking_id' => 'track-usage',
'clicked_at' => now(),
]);
$this->service->convertReferral($this->referee, 'track-usage');
$code->refresh();
expect($code->uses_count)->toBe(1);
});
});
describe('getReferralForUser() method', function () {
it('returns active referral for a referee', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'GETREF',
'status' => Referral::STATUS_CONVERTED,
'clicked_at' => now(),
'signed_up_at' => now(),
]);
$result = $this->service->getReferralForUser($this->referee);
expect($result)->not->toBeNull()
->and($result->id)->toBe($referral->id);
});
it('returns null for a user with no referral', function () {
$userWithoutReferral = User::factory()->create();
$result = $this->service->getReferralForUser($userWithoutReferral);
expect($result)->toBeNull();
});
it('excludes disqualified referrals', function () {
Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'DQ',
'status' => Referral::STATUS_DISQUALIFIED,
'clicked_at' => now(),
'disqualified_at' => now(),
'disqualification_reason' => 'Fraud',
]);
$result = $this->service->getReferralForUser($this->referee);
expect($result)->toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────
// Commission Calculation
// ─────────────────────────────────────────────────────────────────────
describe('getCommissionRateForReferral() method', function () {
it('returns custom rate from referral code', function () {
ReferralCode::create([
'code' => 'CUSTOM20',
'user_id' => $this->referrer->id,
'type' => ReferralCode::TYPE_CUSTOM,
'commission_rate' => 20.00,
'is_active' => true,
]);
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'code' => 'CUSTOM20',
'status' => Referral::STATUS_CONVERTED,
'clicked_at' => now(),
]);
$rate = $this->service->getCommissionRateForReferral($referral);
expect($rate)->toBe(20.00);
});
it('returns default rate when code has no custom rate', function () {
ReferralCode::create([
'code' => 'NORATE',
'user_id' => $this->referrer->id,
'type' => ReferralCode::TYPE_CUSTOM,
'commission_rate' => null,
'is_active' => true,
]);
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'code' => 'NORATE',
'status' => Referral::STATUS_CONVERTED,
'clicked_at' => now(),
]);
$rate = $this->service->getCommissionRateForReferral($referral);
expect($rate)->toBe(ReferralCommission::DEFAULT_COMMISSION_RATE);
});
it('returns default rate when referral code does not exist', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'code' => 'DELETED',
'status' => Referral::STATUS_CONVERTED,
'clicked_at' => now(),
]);
$rate = $this->service->getCommissionRateForReferral($referral);
expect($rate)->toBe(ReferralCommission::DEFAULT_COMMISSION_RATE);
});
});
describe('createCommissionForOrder() method', function () {
it('creates a commission for a referred user order', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'COMM1',
'status' => Referral::STATUS_CONVERTED,
'clicked_at' => now(),
'signed_up_at' => now(),
]);
$order = Order::create([
'user_id' => $this->referee->id,
'order_number' => 'ORD-COMM-001',
'status' => 'paid',
'subtotal' => 100.00,
'discount_amount' => 0.00,
'tax_amount' => 20.00,
'total' => 120.00,
'currency' => 'GBP',
]);
$commission = $this->service->createCommissionForOrder($order);
expect($commission)->toBeInstanceOf(ReferralCommission::class)
->and($commission->referral_id)->toBe($referral->id)
->and($commission->referrer_id)->toBe($this->referrer->id)
->and($commission->order_id)->toBe($order->id)
->and((float) $commission->order_amount)->toBe(100.00)
->and((float) $commission->commission_rate)->toBe(ReferralCommission::DEFAULT_COMMISSION_RATE)
->and((float) $commission->commission_amount)->toBe(10.00)
->and($commission->status)->toBe(ReferralCommission::STATUS_PENDING)
->and($commission->matures_at)->not->toBeNull();
});
it('applies discount to net amount before calculating commission', function () {
Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'DISC',
'status' => Referral::STATUS_CONVERTED,
'clicked_at' => now(),
'signed_up_at' => now(),
]);
$order = Order::create([
'user_id' => $this->referee->id,
'order_number' => 'ORD-DISC-001',
'status' => 'paid',
'subtotal' => 100.00,
'discount_amount' => 20.00,
'tax_amount' => 16.00,
'total' => 96.00,
'currency' => 'GBP',
]);
$commission = $this->service->createCommissionForOrder($order);
// 10% of (100 - 20) = 8.00
expect((float) $commission->order_amount)->toBe(80.00)
->and((float) $commission->commission_amount)->toBe(8.00);
});
it('returns null when user has no referral', function () {
$userWithoutReferral = User::factory()->create();
$order = Order::create([
'user_id' => $userWithoutReferral->id,
'order_number' => 'ORD-NOREF-001',
'status' => 'paid',
'subtotal' => 100.00,
'discount_amount' => 0.00,
'tax_amount' => 0.00,
'total' => 100.00,
'currency' => 'GBP',
]);
$commission = $this->service->createCommissionForOrder($order);
expect($commission)->toBeNull();
});
it('returns existing commission if one already exists for the order', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'DUPE',
'status' => Referral::STATUS_CONVERTED,
'clicked_at' => now(),
'signed_up_at' => now(),
]);
$order = Order::create([
'user_id' => $this->referee->id,
'order_number' => 'ORD-DUPE-001',
'status' => 'paid',
'subtotal' => 50.00,
'discount_amount' => 0.00,
'tax_amount' => 0.00,
'total' => 50.00,
'currency' => 'GBP',
]);
$first = $this->service->createCommissionForOrder($order);
$second = $this->service->createCommissionForOrder($order);
expect($second->id)->toBe($first->id);
expect(ReferralCommission::where('order_id', $order->id)->count())->toBe(1);
});
it('marks referral as qualified on first commission', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'QUAL',
'status' => Referral::STATUS_CONVERTED,
'clicked_at' => now(),
'signed_up_at' => now(),
]);
$order = Order::create([
'user_id' => $this->referee->id,
'order_number' => 'ORD-QUAL-001',
'status' => 'paid',
'subtotal' => 50.00,
'discount_amount' => 0.00,
'tax_amount' => 0.00,
'total' => 50.00,
'currency' => 'GBP',
]);
$this->service->createCommissionForOrder($order);
$referral->refresh();
expect($referral->status)->toBe(Referral::STATUS_QUALIFIED)
->and($referral->qualified_at)->not->toBeNull()
->and($referral->first_purchase_at)->not->toBeNull();
});
it('sets shorter maturation for crypto payments', function () {
Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'CRYPTO',
'status' => Referral::STATUS_CONVERTED,
'clicked_at' => now(),
'signed_up_at' => now(),
]);
$order = Order::create([
'user_id' => $this->referee->id,
'order_number' => 'ORD-BTC-001',
'status' => 'paid',
'subtotal' => 100.00,
'discount_amount' => 0.00,
'tax_amount' => 0.00,
'total' => 100.00,
'currency' => 'GBP',
'payment_gateway' => 'btcpay',
]);
$commission = $this->service->createCommissionForOrder($order);
// Crypto maturation: 14 days
$expectedMatures = now()->addDays(ReferralCommission::MATURATION_CRYPTO);
expect($commission->matures_at->format('Y-m-d'))
->toBe($expectedMatures->format('Y-m-d'));
});
});
describe('cancelCommissionForOrder() method', function () {
it('cancels an unpaid commission for a refunded order', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'CANCEL',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
$order = Order::create([
'user_id' => $this->referee->id,
'order_number' => 'ORD-CANCEL-001',
'status' => 'refunded',
'subtotal' => 100.00,
'discount_amount' => 0.00,
'tax_amount' => 0.00,
'total' => 100.00,
'currency' => 'GBP',
]);
$commission = ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_id' => $order->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_PENDING,
'matures_at' => now()->addDays(90),
]);
$this->service->cancelCommissionForOrder($order, 'Order refunded');
$commission->refresh();
expect($commission->status)->toBe(ReferralCommission::STATUS_CANCELLED)
->and($commission->notes)->toBe('Order refunded');
});
it('does not cancel an already paid commission', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'PAID',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
$order = Order::create([
'user_id' => $this->referee->id,
'order_number' => 'ORD-PAID-001',
'status' => 'refunded',
'subtotal' => 100.00,
'discount_amount' => 0.00,
'tax_amount' => 0.00,
'total' => 100.00,
'currency' => 'GBP',
]);
$commission = ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_id' => $order->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_PAID,
'paid_at' => now(),
]);
$this->service->cancelCommissionForOrder($order);
$commission->refresh();
expect($commission->status)->toBe(ReferralCommission::STATUS_PAID);
});
it('does nothing when no commission exists for the order', function () {
$order = Order::create([
'user_id' => $this->referee->id,
'order_number' => 'ORD-NOCOMM-001',
'status' => 'refunded',
'subtotal' => 100.00,
'discount_amount' => 0.00,
'tax_amount' => 0.00,
'total' => 100.00,
'currency' => 'GBP',
]);
// Should not throw
$this->service->cancelCommissionForOrder($order);
expect(ReferralCommission::where('order_id', $order->id)->count())->toBe(0);
});
});
// ─────────────────────────────────────────────────────────────────────
// Commission Maturation
// ─────────────────────────────────────────────────────────────────────
describe('matureReadyCommissions() method', function () {
it('matures commissions past their maturation date', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'MATURE',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
$ready = ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_PENDING,
'matures_at' => now()->subDay(),
]);
$notReady = ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 50.00,
'commission_rate' => 10.00,
'commission_amount' => 5.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_PENDING,
'matures_at' => now()->addDays(30),
]);
$count = $this->service->matureReadyCommissions();
expect($count)->toBe(1);
$ready->refresh();
$notReady->refresh();
expect($ready->status)->toBe(ReferralCommission::STATUS_MATURED)
->and($ready->matured_at)->not->toBeNull()
->and($notReady->status)->toBe(ReferralCommission::STATUS_PENDING);
});
it('returns zero when no commissions are ready', function () {
$count = $this->service->matureReadyCommissions();
expect($count)->toBe(0);
});
it('also matures the parent referral on first commission maturation', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'MATUREREF',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_PENDING,
'matures_at' => now()->subDay(),
]);
$this->service->matureReadyCommissions();
$referral->refresh();
expect($referral->matured_at)->not->toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────
// Balance and Earnings
// ─────────────────────────────────────────────────────────────────────
describe('getAvailableBalance() method', function () {
it('returns sum of matured unpaid commissions', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'BAL',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_MATURED,
]);
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 50.00,
'commission_rate' => 10.00,
'commission_amount' => 5.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_MATURED,
]);
// Pending commission should not be included
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 200.00,
'commission_rate' => 10.00,
'commission_amount' => 20.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_PENDING,
'matures_at' => now()->addDays(30),
]);
$balance = $this->service->getAvailableBalance($this->referrer);
expect($balance)->toBe(15.00);
});
it('returns zero when no matured commissions exist', function () {
$balance = $this->service->getAvailableBalance($this->referrer);
expect($balance)->toBe(0.0);
});
});
describe('getPendingBalance() method', function () {
it('returns sum of pending commissions', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'PEND',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_PENDING,
'matures_at' => now()->addDays(30),
]);
$pending = $this->service->getPendingBalance($this->referrer);
expect($pending)->toBe(10.00);
});
});
describe('getLifetimeEarnings() method', function () {
it('includes both matured and paid commissions', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'LIFE',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_MATURED,
]);
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 200.00,
'commission_rate' => 10.00,
'commission_amount' => 20.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_PAID,
'paid_at' => now(),
]);
// Cancelled should not count
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 50.00,
'commission_rate' => 10.00,
'commission_amount' => 5.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_CANCELLED,
]);
$earnings = $this->service->getLifetimeEarnings($this->referrer);
expect($earnings)->toBe(30.00);
});
});
// ─────────────────────────────────────────────────────────────────────
// Payout Management
// ─────────────────────────────────────────────────────────────────────
describe('requestPayout() method', function () {
it('creates a BTC payout request', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'PAY',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 200.00,
'commission_rate' => 10.00,
'commission_amount' => 20.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_MATURED,
]);
$payout = $this->service->requestPayout(
$this->referrer,
ReferralPayout::METHOD_BTC,
amount: 15.00,
btcAddress: 'bc1qtest123',
);
expect($payout)->toBeInstanceOf(ReferralPayout::class)
->and($payout->user_id)->toBe($this->referrer->id)
->and($payout->method)->toBe(ReferralPayout::METHOD_BTC)
->and((float) $payout->amount)->toBe(15.00)
->and($payout->btc_address)->toBe('bc1qtest123')
->and($payout->status)->toBe(ReferralPayout::STATUS_REQUESTED)
->and($payout->payout_number)->toStartWith('PAY-');
});
it('creates an account credit payout', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'CREDIT',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_MATURED,
]);
$payout = $this->service->requestPayout(
$this->referrer,
ReferralPayout::METHOD_ACCOUNT_CREDIT,
amount: 5.00,
);
expect($payout->method)->toBe(ReferralPayout::METHOD_ACCOUNT_CREDIT)
->and((float) $payout->amount)->toBe(5.00);
});
it('defaults to full available balance when amount is null', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'FULL',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 300.00,
'commission_rate' => 10.00,
'commission_amount' => 30.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_MATURED,
]);
$payout = $this->service->requestPayout(
$this->referrer,
ReferralPayout::METHOD_BTC,
btcAddress: 'bc1qfull456',
);
expect((float) $payout->amount)->toBe(30.00);
});
it('assigns matured commissions to the payout', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'ASSIGN',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
$commission = ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_MATURED,
]);
$payout = $this->service->requestPayout(
$this->referrer,
ReferralPayout::METHOD_BTC,
btcAddress: 'bc1qassign',
);
$commission->refresh();
expect($commission->payout_id)->toBe($payout->id);
});
it('throws exception when amount is below minimum for BTC', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'MINBTC',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 50.00,
'commission_rate' => 10.00,
'commission_amount' => 5.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_MATURED,
]);
expect(fn () => $this->service->requestPayout(
$this->referrer,
ReferralPayout::METHOD_BTC,
amount: 5.00,
btcAddress: 'bc1qmin',
))->toThrow(InvalidArgumentException::class, 'Minimum payout amount');
});
it('throws exception when amount exceeds available balance', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'EXCEED',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_MATURED,
]);
expect(fn () => $this->service->requestPayout(
$this->referrer,
ReferralPayout::METHOD_BTC,
amount: 50.00,
btcAddress: 'bc1qexceed',
))->toThrow(InvalidArgumentException::class, 'exceeds available balance');
});
it('throws exception when BTC address is missing for BTC payout', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'NOADDR',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 200.00,
'commission_rate' => 10.00,
'commission_amount' => 20.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_MATURED,
]);
expect(fn () => $this->service->requestPayout(
$this->referrer,
ReferralPayout::METHOD_BTC,
amount: 15.00,
))->toThrow(InvalidArgumentException::class, 'BTC address is required');
});
});
describe('processPayout() method', function () {
it('transitions payout to processing status', function () {
$payout = ReferralPayout::create([
'user_id' => $this->referrer->id,
'payout_number' => ReferralPayout::generatePayoutNumber(),
'method' => ReferralPayout::METHOD_BTC,
'btc_address' => 'bc1qprocess',
'amount' => 20.00,
'currency' => 'GBP',
'status' => ReferralPayout::STATUS_REQUESTED,
'requested_at' => now(),
]);
$admin = User::factory()->create();
$this->service->processPayout($payout, $admin);
$payout->refresh();
expect($payout->status)->toBe(ReferralPayout::STATUS_PROCESSING)
->and($payout->processed_by)->toBe($admin->id)
->and($payout->processed_at)->not->toBeNull();
});
it('throws exception when payout is not in requested status', function () {
$payout = ReferralPayout::create([
'user_id' => $this->referrer->id,
'payout_number' => ReferralPayout::generatePayoutNumber(),
'method' => ReferralPayout::METHOD_BTC,
'amount' => 20.00,
'currency' => 'GBP',
'status' => ReferralPayout::STATUS_COMPLETED,
'completed_at' => now(),
]);
$admin = User::factory()->create();
expect(fn () => $this->service->processPayout($payout, $admin))
->toThrow(InvalidArgumentException::class, 'not in requested status');
});
});
describe('completePayout() method', function () {
it('completes a payout with BTC details', function () {
$payout = ReferralPayout::create([
'user_id' => $this->referrer->id,
'payout_number' => ReferralPayout::generatePayoutNumber(),
'method' => ReferralPayout::METHOD_BTC,
'btc_address' => 'bc1qcomplete',
'amount' => 20.00,
'currency' => 'GBP',
'status' => ReferralPayout::STATUS_PROCESSING,
'processed_at' => now(),
]);
$this->service->completePayout(
$payout,
btcTxid: 'txid_abc123',
btcAmount: 0.00045,
btcRate: 44444.44,
);
$payout->refresh();
expect($payout->status)->toBe(ReferralPayout::STATUS_COMPLETED)
->and($payout->btc_txid)->toBe('txid_abc123')
->and($payout->completed_at)->not->toBeNull();
});
it('throws exception when payout is not in processing status', function () {
$payout = ReferralPayout::create([
'user_id' => $this->referrer->id,
'payout_number' => ReferralPayout::generatePayoutNumber(),
'method' => ReferralPayout::METHOD_BTC,
'amount' => 20.00,
'currency' => 'GBP',
'status' => ReferralPayout::STATUS_REQUESTED,
'requested_at' => now(),
]);
expect(fn () => $this->service->completePayout($payout))
->toThrow(InvalidArgumentException::class, 'not in processing status');
});
});
describe('failPayout() method', function () {
it('marks a payout as failed with a reason', function () {
$payout = ReferralPayout::create([
'user_id' => $this->referrer->id,
'payout_number' => ReferralPayout::generatePayoutNumber(),
'method' => ReferralPayout::METHOD_BTC,
'btc_address' => 'bc1qfail',
'amount' => 20.00,
'currency' => 'GBP',
'status' => ReferralPayout::STATUS_PROCESSING,
'processed_at' => now(),
]);
$this->service->failPayout($payout, 'Invalid BTC address');
$payout->refresh();
expect($payout->status)->toBe(ReferralPayout::STATUS_FAILED)
->and($payout->failure_reason)->toBe('Invalid BTC address')
->and($payout->failed_at)->not->toBeNull();
});
});
// ─────────────────────────────────────────────────────────────────────
// Fraud Prevention
// ─────────────────────────────────────────────────────────────────────
describe('disqualifyReferral() method', function () {
it('disqualifies a referral and cancels unpaid commissions', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'FRAUD',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
'signed_up_at' => now(),
'qualified_at' => now(),
]);
$pendingCommission = ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_PENDING,
'matures_at' => now()->addDays(30),
]);
$maturedCommission = ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 200.00,
'commission_rate' => 10.00,
'commission_amount' => 20.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_MATURED,
]);
$this->service->disqualifyReferral($referral, 'Suspected fraud');
$referral->refresh();
$pendingCommission->refresh();
$maturedCommission->refresh();
expect($referral->status)->toBe(Referral::STATUS_DISQUALIFIED)
->and($referral->disqualified_at)->not->toBeNull()
->and($referral->disqualification_reason)->toBe('Suspected fraud')
->and($pendingCommission->status)->toBe(ReferralCommission::STATUS_CANCELLED)
->and($maturedCommission->status)->toBe(ReferralCommission::STATUS_CANCELLED);
});
it('does not cancel already paid commissions during disqualification', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'PAIDFRAUD',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
]);
$paidCommission = ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_PAID,
'paid_at' => now(),
]);
$this->service->disqualifyReferral($referral, 'Late fraud detection');
$paidCommission->refresh();
expect($paidCommission->status)->toBe(ReferralCommission::STATUS_PAID);
});
});
// ─────────────────────────────────────────────────────────────────────
// Statistics
// ─────────────────────────────────────────────────────────────────────
describe('getStatsForUser() method', function () {
it('returns correct statistics for a referrer', function () {
// Create various referrals in different states
Referral::create([
'referrer_id' => $this->referrer->id,
'code' => 'STAT1',
'status' => Referral::STATUS_PENDING,
'clicked_at' => now(),
]);
Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'STAT2',
'status' => Referral::STATUS_CONVERTED,
'clicked_at' => now(),
'signed_up_at' => now(),
]);
$qualifiedReferral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => User::factory()->create()->id,
'code' => 'STAT3',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
'signed_up_at' => now(),
'qualified_at' => now(),
]);
// Add a matured commission for balance checks
ReferralCommission::create([
'referral_id' => $qualifiedReferral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_MATURED,
]);
$stats = $this->service->getStatsForUser($this->referrer);
expect($stats['total_referrals'])->toBe(3)
->and($stats['pending_referrals'])->toBe(1)
->and($stats['converted_referrals'])->toBe(1)
->and($stats['qualified_referrals'])->toBe(1)
->and($stats['available_balance'])->toBe(10.00)
->and($stats['pending_balance'])->toBe(0.0)
->and($stats['lifetime_earnings'])->toBe(10.00);
});
});
describe('getGlobalStats() method', function () {
it('returns aggregate statistics across all referrals', function () {
$referral = Referral::create([
'referrer_id' => $this->referrer->id,
'referee_id' => $this->referee->id,
'code' => 'GLOBAL',
'status' => Referral::STATUS_QUALIFIED,
'clicked_at' => now(),
'qualified_at' => now(),
]);
ReferralCommission::create([
'referral_id' => $referral->id,
'referrer_id' => $this->referrer->id,
'order_amount' => 100.00,
'commission_rate' => 10.00,
'commission_amount' => 10.00,
'currency' => 'GBP',
'status' => ReferralCommission::STATUS_MATURED,
]);
$stats = $this->service->getGlobalStats();
expect($stats['total_referrals'])->toBe(1)
->and($stats['active_referrals'])->toBe(1)
->and($stats['qualified_referrals'])->toBe(1)
->and((float) $stats['total_commissions'])->toBe(10.00)
->and((float) $stats['matured_commissions'])->toBe(10.00);
});
});
});