diff --git a/routes/api.php b/routes/api.php index 23d1b63..1c0e3fc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -52,10 +52,12 @@ Route::prefix('webhooks')->group(function () { // }); // ───────────────────────────────────────────────────────────────────────────── -// Commerce Billing API (authenticated) +// Commerce Billing API (authenticated + verified) // ───────────────────────────────────────────────────────────────────────────── -Route::middleware('auth')->prefix('commerce')->group(function () { +Route::middleware(['auth', 'verified'])->prefix('commerce')->group(function () { + // ── Read-only endpoints ────────────────────────────────────────────── + // Billing overview Route::get('/billing', [CommerceController::class, 'billing']) ->name('api.commerce.billing'); @@ -74,21 +76,27 @@ Route::middleware('auth')->prefix('commerce')->group(function () { Route::get('/invoices/{invoice}/download', [CommerceController::class, 'downloadInvoice']) ->name('api.commerce.invoices.download'); - // Subscription + // Subscription (read) Route::get('/subscription', [CommerceController::class, 'subscription']) ->name('api.commerce.subscription'); - Route::post('/cancel', [CommerceController::class, 'cancelSubscription']) - ->name('api.commerce.cancel'); - Route::post('/resume', [CommerceController::class, 'resumeSubscription']) - ->name('api.commerce.resume'); // Usage Route::get('/usage', [CommerceController::class, 'usage']) ->name('api.commerce.usage'); - // Plan changes - Route::post('/upgrade/preview', [CommerceController::class, 'previewUpgrade']) - ->name('api.commerce.upgrade.preview'); - Route::post('/upgrade', [CommerceController::class, 'executeUpgrade']) - ->name('api.commerce.upgrade'); + // ── State-changing endpoints (rate-limited) ────────────────────────── + + Route::middleware('throttle:6,1')->group(function () { + // Subscription management + Route::post('/cancel', [CommerceController::class, 'cancelSubscription']) + ->name('api.commerce.cancel'); + Route::post('/resume', [CommerceController::class, 'resumeSubscription']) + ->name('api.commerce.resume'); + + // Plan changes + Route::post('/upgrade/preview', [CommerceController::class, 'previewUpgrade']) + ->name('api.commerce.upgrade.preview'); + Route::post('/upgrade', [CommerceController::class, 'executeUpgrade']) + ->name('api.commerce.upgrade'); + }); }); diff --git a/tests/Feature/ReferralServiceTest.php b/tests/Feature/ReferralServiceTest.php new file mode 100644 index 0000000..4774d39 --- /dev/null +++ b/tests/Feature/ReferralServiceTest.php @@ -0,0 +1,1411 @@ +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); + }); + }); +});