Files

580 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace Modules\RestaurantDelivery\Services\Earnings;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\RestaurantDelivery\Models\Delivery;
use Modules\RestaurantDelivery\Models\Rider;
use Modules\RestaurantDelivery\Models\RiderBonus;
use Modules\RestaurantDelivery\Models\RiderEarning;
use Modules\RestaurantDelivery\Models\RiderPayout;
class EarningsService
{
protected array $config;
public function __construct()
{
$this->config = config('restaurant-delivery.earnings');
}
/*
|--------------------------------------------------------------------------
| Delivery Earnings
|--------------------------------------------------------------------------
*/
/**
* Create earning record for a completed delivery
*/
public function createDeliveryEarning(Delivery $delivery): ?RiderEarning
{
if (! $delivery->rider) {
return null;
}
try {
$earning = DB::transaction(function () use ($delivery) {
// Create base earning
$earning = RiderEarning::createForDelivery($delivery);
if (! $earning) {
return null;
}
// Check and apply bonuses
$this->applyDeliveryBonuses($delivery, $earning);
// Auto-confirm if configured
$earning->confirm();
return $earning;
});
Log::info('Delivery earning created', [
'earning_id' => $earning?->id,
'delivery_id' => $delivery->id,
'amount' => $earning?->net_amount,
]);
return $earning;
} catch (\Exception $e) {
Log::error('Failed to create delivery earning', [
'delivery_id' => $delivery->id,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Apply bonuses to a delivery earning
*/
protected function applyDeliveryBonuses(Delivery $delivery, RiderEarning $earning): void
{
$rider = $delivery->rider;
$bonuses = $this->config['bonuses'];
// Peak hour bonus
if ($bonuses['peak_hour_bonus']['enabled'] && $this->isPeakHour()) {
$this->createBonus(
$rider,
'peak_hour',
$bonuses['peak_hour_bonus']['amount'],
'Peak hour bonus',
$delivery
);
}
// Rating bonus
if ($bonuses['rating_bonus']['enabled'] && $rider->rating >= $bonuses['rating_bonus']['min_rating']) {
$this->createBonus(
$rider,
'rating',
$bonuses['rating_bonus']['amount'],
'High rating bonus',
$delivery
);
}
// Consecutive delivery bonus
if ($bonuses['consecutive_delivery_bonus']['enabled']) {
$consecutiveCount = $this->getConsecutiveDeliveryCount($rider);
if ($consecutiveCount >= $bonuses['consecutive_delivery_bonus']['threshold']) {
$this->createBonus(
$rider,
'consecutive',
$bonuses['consecutive_delivery_bonus']['amount'],
"Consecutive delivery bonus ({$consecutiveCount} deliveries)",
$delivery
);
}
}
}
/*
|--------------------------------------------------------------------------
| Bonus Management
|--------------------------------------------------------------------------
*/
/**
* Create a bonus earning
*/
public function createBonus(
Rider $rider,
string $bonusType,
float $amount,
string $description,
?Delivery $delivery = null
): RiderEarning {
return RiderEarning::createBonus($rider, $bonusType, $amount, $description, $delivery);
}
/**
* Check and apply weekly target bonus
*/
public function checkWeeklyTargetBonus(Rider $rider): ?RiderEarning
{
$bonusConfig = $this->config['bonuses']['weekly_target_bonus'];
if (! $bonusConfig['enabled']) {
return null;
}
$weekStart = now()->startOfWeek();
$weekEnd = now()->endOfWeek();
// Get weekly delivery count
$weeklyDeliveries = $rider->deliveries()
->completed()
->whereBetween('delivered_at', [$weekStart, $weekEnd])
->count();
// Check if already received bonus this week
$existingBonus = RiderEarning::where('rider_id', $rider->id)
->where('type', 'bonus')
->where('sub_type', 'weekly_target')
->whereBetween('earning_date', [$weekStart, $weekEnd])
->exists();
if ($existingBonus) {
return null;
}
// Find applicable bonus tier
$targets = collect($bonusConfig['targets'])->sortByDesc('deliveries');
foreach ($targets as $target) {
if ($weeklyDeliveries >= $target['deliveries']) {
return $this->createBonus(
$rider,
'weekly_target',
$target['bonus'],
"Weekly target bonus ({$weeklyDeliveries} deliveries)"
);
}
}
return null;
}
/**
* Get available bonuses for a rider
*/
public function getAvailableBonuses(Rider $rider): array
{
return RiderBonus::where('is_active', true)
->where(function ($q) {
$q->whereNull('valid_from')
->orWhere('valid_from', '<=', now());
})
->where(function ($q) {
$q->whereNull('valid_until')
->orWhere('valid_until', '>=', now());
})
->where(function ($q) use ($rider) {
$q->whereNull('min_rider_rating')
->orWhere('min_rider_rating', '<=', $rider->rating);
})
->get()
->filter(fn ($bonus) => $this->riderEligibleForBonus($rider, $bonus))
->map(fn ($bonus) => [
'id' => $bonus->id,
'name' => $bonus->name,
'type' => $bonus->type,
'amount' => $bonus->amount,
'description' => $bonus->description,
])
->values()
->toArray();
}
protected function riderEligibleForBonus(Rider $rider, RiderBonus $bonus): bool
{
// Check rider type
if ($bonus->applicable_rider_types) {
if (! in_array($rider->type->value, $bonus->applicable_rider_types)) {
return false;
}
}
// Check zones
if ($bonus->applicable_zones && $rider->assigned_zones) {
$intersection = array_intersect($bonus->applicable_zones, $rider->assigned_zones);
if (empty($intersection)) {
return false;
}
}
// Check usage limits
if ($bonus->max_uses_per_rider) {
$riderUses = RiderEarning::where('rider_id', $rider->id)
->where('sub_type', $bonus->code)
->count();
if ($riderUses >= $bonus->max_uses_per_rider) {
return false;
}
}
return true;
}
/*
|--------------------------------------------------------------------------
| Penalty Management
|--------------------------------------------------------------------------
*/
/**
* Create a penalty
*/
public function createPenalty(
Rider $rider,
string $penaltyType,
float $amount,
string $description,
?Delivery $delivery = null
): RiderEarning {
return RiderEarning::createPenalty($rider, $penaltyType, $amount, $description, $delivery);
}
/**
* Apply cancellation penalty
*/
public function applyCancellationPenalty(Rider $rider, Delivery $delivery): ?RiderEarning
{
$penaltyConfig = $this->config['penalties']['cancellation'];
if (! $penaltyConfig['enabled']) {
return null;
}
// Check free cancellations
$todayCancellations = RiderEarning::where('rider_id', $rider->id)
->where('type', 'penalty')
->where('sub_type', 'cancellation')
->whereDate('earning_date', now()->toDateString())
->count();
if ($todayCancellations < $penaltyConfig['free_cancellations']) {
return null;
}
return $this->createPenalty(
$rider,
'cancellation',
$penaltyConfig['amount'],
"Cancellation penalty for delivery #{$delivery->tracking_code}",
$delivery
);
}
/**
* Apply late delivery penalty
*/
public function applyLateDeliveryPenalty(Rider $rider, Delivery $delivery): ?RiderEarning
{
$penaltyConfig = $this->config['penalties']['late_delivery'];
if (! $penaltyConfig['enabled']) {
return null;
}
$delayMinutes = $delivery->getDelayMinutes();
if (! $delayMinutes || $delayMinutes < $penaltyConfig['threshold']) {
return null;
}
return $this->createPenalty(
$rider,
'late_delivery',
$penaltyConfig['amount'],
"Late delivery penalty ({$delayMinutes} min late)",
$delivery
);
}
/*
|--------------------------------------------------------------------------
| Payout Management
|--------------------------------------------------------------------------
*/
/**
* Generate payouts for all eligible riders
*/
public function generatePayouts(string $periodType = 'weekly'): array
{
$results = [
'generated' => 0,
'skipped' => 0,
'errors' => 0,
];
// Get period dates
[$periodStart, $periodEnd] = $this->getPayoutPeriodDates($periodType);
// Get all active riders
$riders = Rider::active()->get();
foreach ($riders as $rider) {
try {
$payout = RiderPayout::createForRider($rider, $periodStart, $periodEnd, $periodType);
if ($payout) {
$results['generated']++;
} else {
$results['skipped']++;
}
} catch (\Exception $e) {
$results['errors']++;
Log::error('Failed to generate payout', [
'rider_id' => $rider->id,
'error' => $e->getMessage(),
]);
}
}
return $results;
}
/**
* Process a payout
*/
public function processPayout(RiderPayout $payout, array $paymentDetails): bool
{
try {
DB::transaction(function () use ($payout, $paymentDetails) {
$payout->markAsCompleted(
$paymentDetails['reference'],
$paymentDetails
);
});
Log::info('Payout processed', [
'payout_id' => $payout->id,
'amount' => $payout->net_amount,
'reference' => $paymentDetails['reference'],
]);
return true;
} catch (\Exception $e) {
Log::error('Failed to process payout', [
'payout_id' => $payout->id,
'error' => $e->getMessage(),
]);
return false;
}
}
protected function getPayoutPeriodDates(string $periodType): array
{
return match ($periodType) {
'daily' => [
now()->subDay()->startOfDay(),
now()->subDay()->endOfDay(),
],
'weekly' => [
now()->subWeek()->startOfWeek(),
now()->subWeek()->endOfWeek(),
],
'monthly' => [
now()->subMonth()->startOfMonth(),
now()->subMonth()->endOfMonth(),
],
default => [
now()->subWeek()->startOfWeek(),
now()->subWeek()->endOfWeek(),
],
};
}
/*
|--------------------------------------------------------------------------
| Statistics
|--------------------------------------------------------------------------
*/
/**
* Get rider earnings summary
*/
public function getRiderEarningsSummary(Rider $rider, ?string $period = null): array
{
$query = $rider->earnings()->confirmed();
$startDate = match ($period) {
'today' => now()->startOfDay(),
'week' => now()->startOfWeek(),
'month' => now()->startOfMonth(),
'year' => now()->startOfYear(),
default => null,
};
if ($startDate) {
$query->where('earning_date', '>=', $startDate);
}
$earnings = $query->get();
$deliveryEarnings = $earnings->where('type', 'delivery');
$tips = $earnings->where('type', 'tip');
$bonuses = $earnings->where('type', 'bonus');
$penalties = $earnings->where('type', 'penalty');
return [
'total_earnings' => $earnings->sum('net_amount'),
'delivery_earnings' => [
'amount' => $deliveryEarnings->sum('net_amount'),
'count' => $deliveryEarnings->count(),
],
'tips' => [
'amount' => $tips->sum('net_amount'),
'count' => $tips->count(),
],
'bonuses' => [
'amount' => $bonuses->sum('net_amount'),
'count' => $bonuses->count(),
'breakdown' => $bonuses->groupBy('sub_type')->map->sum('net_amount')->toArray(),
],
'penalties' => [
'amount' => abs($penalties->sum('net_amount')),
'count' => $penalties->count(),
],
'pending_payout' => $this->getPendingPayoutAmount($rider),
];
}
/**
* Get pending payout amount
*/
public function getPendingPayoutAmount(Rider $rider): float
{
return $rider->earnings()
->payable()
->sum('net_amount');
}
/**
* Get earnings history
*/
public function getEarningsHistory(Rider $rider, int $limit = 50, ?string $type = null): array
{
$query = $rider->earnings()->confirmed()->orderBy('earning_date', 'desc');
if ($type) {
$query->where('type', $type);
}
return $query->limit($limit)->get()->map(fn ($earning) => [
'id' => $earning->uuid,
'type' => $earning->type,
'sub_type' => $earning->sub_type,
'amount' => $earning->net_amount,
'description' => $earning->description,
'delivery_code' => $earning->delivery?->tracking_code,
'date' => $earning->earning_date->format('Y-m-d'),
'is_paid' => $earning->is_paid,
])->toArray();
}
/**
* Get payout history
*/
public function getPayoutHistory(Rider $rider, int $limit = 20): array
{
return $rider->payouts()
->orderBy('created_at', 'desc')
->limit($limit)
->get()
->map(fn ($payout) => [
'id' => $payout->uuid,
'payout_number' => $payout->payout_number,
'period' => $payout->period_start->format('M d').' - '.$payout->period_end->format('M d, Y'),
'amount' => $payout->net_amount,
'deliveries' => $payout->total_deliveries,
'status' => $payout->status,
'payment_method' => $payout->payment_method,
'paid_at' => $payout->paid_at?->format('Y-m-d H:i'),
])
->toArray();
}
/*
|--------------------------------------------------------------------------
| Helper Methods
|--------------------------------------------------------------------------
*/
protected function isPeakHour(): bool
{
$peakHours = config('restaurant-delivery.pricing.peak_hours.slots', []);
$now = now();
$currentDay = $now->dayOfWeekIso;
foreach ($peakHours as $slot) {
if (! in_array($currentDay, $slot['days'] ?? [])) {
continue;
}
$start = Carbon::createFromTimeString($slot['start']);
$end = Carbon::createFromTimeString($slot['end']);
if ($now->between($start, $end)) {
return true;
}
}
return false;
}
protected function getConsecutiveDeliveryCount(Rider $rider): int
{
// Get today's deliveries in chronological order
$deliveries = $rider->deliveries()
->whereDate('delivered_at', now()->toDateString())
->orderBy('delivered_at', 'desc')
->get();
$consecutive = 0;
foreach ($deliveries as $delivery) {
if ($delivery->isCompleted()) {
$consecutive++;
} else {
break;
}
}
return $consecutive;
}
}