php-commerce/Services/ReferralService.php

581 lines
18 KiB
PHP
Raw Normal View History

2026-01-27 00:24:22 +00:00
<?php
declare(strict_types=1);
namespace Core\Commerce\Services;
use Core\Mod\Tenant\Models\User;
use Mod\Bio\Models\Page;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Core\Commerce\Models\Order;
use Core\Commerce\Models\Referral;
use Core\Commerce\Models\ReferralCode;
use Core\Commerce\Models\ReferralCommission;
use Core\Commerce\Models\ReferralPayout;
/**
* Service for managing referrals and affiliate commissions.
*
* Handles:
* - Referral tracking and attribution
* - Commission calculation and maturation
* - Payout processing
* - Referral code management
*/
class ReferralService
{
/**
* Track a referral click from session data.
*/
public function trackClick(
string $code,
?string $sourceUrl = null,
?string $landingPage = null,
?string $ipAddress = null,
?string $userAgent = null,
array $utmParams = []
): ?Referral {
// Find the referrer by code (namespace or custom code)
$referrerId = $this->resolveReferrerFromCode($code);
if (! $referrerId) {
return null;
}
// Generate unique tracking ID
$trackingId = Str::uuid()->toString();
return Referral::create([
'referrer_id' => $referrerId,
'code' => $code,
'status' => Referral::STATUS_PENDING,
'source_url' => $sourceUrl,
'landing_page' => $landingPage,
'ip_address' => $ipAddress,
'user_agent' => $userAgent ? Str::limit($userAgent, 512) : null,
'utm_source' => $utmParams['source'] ?? null,
'utm_medium' => $utmParams['medium'] ?? null,
'utm_campaign' => $utmParams['campaign'] ?? null,
'tracking_id' => $trackingId,
'clicked_at' => now(),
]);
}
/**
* Resolve referrer user ID from a code.
*
* Checks:
* 1. Custom referral codes
* 2. User namespaces (bio page URLs)
*/
public function resolveReferrerFromCode(string $code): ?int
{
// Check custom referral codes first
$referralCode = ReferralCode::valid()->byCode($code)->first();
if ($referralCode && $referralCode->user_id) {
return $referralCode->user_id;
}
// Check user namespaces (bio pages)
$page = Page::with('user')
->where('url', $code)
->first();
if ($page && $page->user && $page->user->hasActivatedReferrals()) {
return $page->user_id;
}
return null;
}
/**
* Convert a referral when user signs up.
*/
public function convertReferral(User $referee, ?string $trackingId = null, ?int $referrerUserId = null): ?Referral
{
// Find pending referral by tracking ID or referrer
$referral = null;
if ($trackingId) {
$referral = Referral::pending()
->where('tracking_id', $trackingId)
->whereNull('referee_id')
->first();
}
if (! $referral && $referrerUserId) {
// Create new referral if we have referrer ID from session
$referral = Referral::create([
'referrer_id' => $referrerUserId,
'code' => '', // Will be filled from session context
'status' => Referral::STATUS_PENDING,
'clicked_at' => now(),
]);
}
if (! $referral) {
return null;
}
// Prevent self-referral
if ($referral->referrer_id === $referee->id) {
Log::info('Self-referral prevented', [
'user_id' => $referee->id,
'referral_id' => $referral->id,
]);
return null;
}
// Mark as converted
$referral->markConverted($referee);
// Update referrer's referral count
$referrer = $referral->referrer;
if ($referrer) {
$referrer->increment('referral_count');
}
// Increment code usage if applicable
$referralCode = ReferralCode::byCode($referral->code)->first();
if ($referralCode) {
$referralCode->incrementUsage();
}
Log::info('Referral converted', [
'referral_id' => $referral->id,
'referrer_id' => $referral->referrer_id,
'referee_id' => $referee->id,
]);
return $referral;
}
/**
* Get or create referral for a referee user.
*/
public function getReferralForUser(User $referee): ?Referral
{
return Referral::active()
->forReferee($referee->id)
->first();
}
/**
* Calculate and create commission for an order.
*/
public function createCommissionForOrder(Order $order): ?ReferralCommission
{
// Find the referee (user who made the purchase)
$referee = $order->user;
if (! $referee) {
return null;
}
// Find active referral for this user
$referral = Referral::active()
->forReferee($referee->id)
->first();
if (! $referral) {
return null;
}
// Check if commission already exists for this order
$existingCommission = ReferralCommission::where('order_id', $order->id)->first();
if ($existingCommission) {
return $existingCommission;
}
// Get commission rate from referral code or default
$commissionRate = $this->getCommissionRateForReferral($referral);
// Create commission
$commissionData = ReferralCommission::calculateForOrder($referral, $order, $commissionRate);
$commission = ReferralCommission::create($commissionData);
// Mark referral as qualified if first purchase
if (! $referral->isQualified()) {
$referral->markQualified();
}
Log::info('Referral commission created', [
'commission_id' => $commission->id,
'referral_id' => $referral->id,
'order_id' => $order->id,
'amount' => $commission->commission_amount,
]);
return $commission;
}
/**
* Get commission rate for a referral.
*/
public function getCommissionRateForReferral(Referral $referral): float
{
// Check if referral code has custom rate
$referralCode = ReferralCode::valid()->byCode($referral->code)->first();
if ($referralCode && $referralCode->commission_rate !== null) {
return $referralCode->commission_rate;
}
return ReferralCommission::DEFAULT_COMMISSION_RATE;
}
/**
* Mature commissions that are ready.
*/
public function matureReadyCommissions(): int
{
$commissions = ReferralCommission::readyToMature()->get();
$count = 0;
foreach ($commissions as $commission) {
$commission->markMatured();
$count++;
// Also mature the referral if this is the first matured commission
$referral = $commission->referral;
if ($referral && ! $referral->hasMatured()) {
$referral->markMatured();
}
}
if ($count > 0) {
Log::info('Matured referral commissions', ['count' => $count]);
}
return $count;
}
/**
* Cancel commission for a refunded/chargedback order.
*/
public function cancelCommissionForOrder(Order $order, string $reason = 'Order refunded'): void
{
$commission = ReferralCommission::where('order_id', $order->id)->first();
if ($commission && ! $commission->isPaid()) {
$commission->cancel($reason);
Log::info('Referral commission cancelled', [
'commission_id' => $commission->id,
'order_id' => $order->id,
'reason' => $reason,
]);
}
}
// ─────────────────────────────────────────────────────────────────────────
// Payout Management
// ─────────────────────────────────────────────────────────────────────────
/**
* Get user's available balance (matured, unpaid commissions).
*/
public function getAvailableBalance(User $user): float
{
return (float) ReferralCommission::forReferrer($user->id)
->matured()
->unpaid()
->sum('commission_amount');
}
/**
* Get user's pending balance (not yet matured).
*/
public function getPendingBalance(User $user): float
{
return (float) ReferralCommission::forReferrer($user->id)
->pending()
->sum('commission_amount');
}
/**
* Get user's total lifetime earnings.
*/
public function getLifetimeEarnings(User $user): float
{
return (float) ReferralCommission::forReferrer($user->id)
->whereIn('status', [
ReferralCommission::STATUS_MATURED,
ReferralCommission::STATUS_PAID,
])
->sum('commission_amount');
}
/**
* Get user's total paid out amount.
*/
public function getTotalPaidOut(User $user): float
{
return (float) ReferralPayout::forUser($user->id)
->completed()
->sum('amount');
}
/**
* Request a payout.
*/
public function requestPayout(
User $user,
string $method,
?float $amount = null,
?string $btcAddress = null
): ReferralPayout {
return DB::transaction(function () use ($user, $method, $amount, $btcAddress) {
// Get available balance
$availableBalance = $this->getAvailableBalance($user);
// Default to full balance
$amount = $amount ?? $availableBalance;
// Validate amount
$minimumPayout = ReferralPayout::getMinimumPayout($method);
if ($amount < $minimumPayout) {
throw new \InvalidArgumentException(
"Minimum payout amount is GBP {$minimumPayout} for {$method}"
);
}
if ($amount > $availableBalance) {
throw new \InvalidArgumentException(
"Requested amount exceeds available balance of GBP {$availableBalance}"
);
}
// Validate BTC address if needed
if ($method === ReferralPayout::METHOD_BTC && ! $btcAddress) {
throw new \InvalidArgumentException('BTC address is required for Bitcoin payouts');
}
// Create payout
$payout = ReferralPayout::create([
'user_id' => $user->id,
'payout_number' => ReferralPayout::generatePayoutNumber(),
'method' => $method,
'btc_address' => $btcAddress,
'amount' => $amount,
'currency' => 'GBP',
'status' => ReferralPayout::STATUS_REQUESTED,
'requested_at' => now(),
]);
// Assign matured commissions to this payout up to amount
$commissionsToAssign = ReferralCommission::forReferrer($user->id)
->matured()
->unpaid()
->orderBy('matured_at')
->get();
$assignedAmount = 0;
foreach ($commissionsToAssign as $commission) {
if ($assignedAmount >= $amount) {
break;
}
$commission->update(['payout_id' => $payout->id]);
$assignedAmount += $commission->commission_amount;
}
Log::info('Payout requested', [
'payout_id' => $payout->id,
'user_id' => $user->id,
'method' => $method,
'amount' => $amount,
]);
return $payout;
});
}
/**
* Process a payout (admin action).
*/
public function processPayout(ReferralPayout $payout, User $admin): void
{
if (! $payout->isRequested()) {
throw new \InvalidArgumentException('Payout is not in requested status');
}
$payout->markProcessing($admin);
Log::info('Payout processing started', [
'payout_id' => $payout->id,
'admin_id' => $admin->id,
]);
}
/**
* Complete a payout (admin action).
*/
public function completePayout(
ReferralPayout $payout,
?string $btcTxid = null,
?float $btcAmount = null,
?float $btcRate = null
): void {
if (! $payout->isProcessing()) {
throw new \InvalidArgumentException('Payout is not in processing status');
}
$payout->markCompleted($btcTxid, $btcAmount, $btcRate);
Log::info('Payout completed', [
'payout_id' => $payout->id,
'btc_txid' => $btcTxid,
]);
}
/**
* Fail a payout (admin action).
*/
public function failPayout(ReferralPayout $payout, string $reason): void
{
$payout->markFailed($reason);
Log::info('Payout failed', [
'payout_id' => $payout->id,
'reason' => $reason,
]);
}
// ─────────────────────────────────────────────────────────────────────────
// Referral Code Management
// ─────────────────────────────────────────────────────────────────────────
/**
* Create a custom referral code.
*/
public function createCode(array $data): ReferralCode
{
return ReferralCode::create(array_merge([
'type' => ReferralCode::TYPE_CUSTOM,
'cookie_days' => ReferralCode::DEFAULT_COOKIE_DAYS,
'is_active' => true,
], $data));
}
/**
* Create a campaign referral code.
*/
public function createCampaignCode(
string $code,
string $campaignName,
?int $userId = null,
?float $commissionRate = null,
array $metadata = []
): ReferralCode {
return ReferralCode::create([
'code' => strtoupper($code),
'user_id' => $userId,
'type' => ReferralCode::TYPE_CAMPAIGN,
'commission_rate' => $commissionRate,
'cookie_days' => ReferralCode::DEFAULT_COOKIE_DAYS,
'is_active' => true,
'campaign_name' => $campaignName,
'metadata' => $metadata,
]);
}
/**
* Find a referral code by code string.
*/
public function findCode(string $code): ?ReferralCode
{
return ReferralCode::byCode($code)->first();
}
/**
* Validate a referral code.
*/
public function validateCode(string $code): bool
{
// Check custom codes
$referralCode = ReferralCode::valid()->byCode($code)->first();
if ($referralCode) {
return true;
}
// Check user namespaces
$referrerId = $this->resolveReferrerFromCode($code);
return $referrerId !== null;
}
// ─────────────────────────────────────────────────────────────────────────
// Statistics
// ─────────────────────────────────────────────────────────────────────────
/**
* Get referral statistics for a user.
*/
public function getStatsForUser(User $user): array
{
$referrals = Referral::forReferrer($user->id);
return [
'total_referrals' => $referrals->count(),
'pending_referrals' => $referrals->clone()->pending()->count(),
'converted_referrals' => $referrals->clone()->converted()->count(),
'qualified_referrals' => $referrals->clone()->qualified()->count(),
'available_balance' => $this->getAvailableBalance($user),
'pending_balance' => $this->getPendingBalance($user),
'lifetime_earnings' => $this->getLifetimeEarnings($user),
'total_paid_out' => $this->getTotalPaidOut($user),
];
}
/**
* Get global referral statistics (admin).
*/
public function getGlobalStats(): array
{
return [
'total_referrals' => Referral::count(),
'active_referrals' => Referral::active()->count(),
'qualified_referrals' => Referral::qualified()->count(),
'total_commissions' => ReferralCommission::sum('commission_amount'),
'pending_commissions' => ReferralCommission::pending()->sum('commission_amount'),
'matured_commissions' => ReferralCommission::matured()->sum('commission_amount'),
'paid_commissions' => ReferralCommission::paid()->sum('commission_amount'),
'pending_payouts' => ReferralPayout::pending()->sum('amount'),
'completed_payouts' => ReferralPayout::completed()->sum('amount'),
];
}
/**
* Disqualify a referral (admin action).
*/
public function disqualifyReferral(Referral $referral, string $reason): void
{
DB::transaction(function () use ($referral, $reason) {
// Disqualify the referral
$referral->disqualify($reason);
// Cancel any unpaid commissions
$referral->commissions()
->whereIn('status', [
ReferralCommission::STATUS_PENDING,
ReferralCommission::STATUS_MATURED,
])
->each(fn ($c) => $c->cancel('Referral disqualified: '.$reason));
// Decrement referrer's referral count if they have one
$referrer = $referral->referrer;
if ($referrer && $referrer->referral_count > 0) {
$referrer->decrement('referral_count');
}
});
Log::info('Referral disqualified', [
'referral_id' => $referral->id,
'reason' => $reason,
]);
}
}