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); }); }); });