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>
1411 lines
56 KiB
PHP
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);
|
|
});
|
|
});
|
|
});
|