migrate to gtea from bistbucket
This commit is contained in:
@@ -0,0 +1,931 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Modules\RestaurantDelivery\Services\Earnings;
|
||||
|
||||
use Illuminate\Support\Carbon;
|
||||
use Modules\RestaurantDelivery\Models\Delivery;
|
||||
use Modules\RestaurantDelivery\Models\Rider;
|
||||
use Modules\RestaurantDelivery\Models\RiderEarning;
|
||||
|
||||
/**
|
||||
* Rider Earnings Calculator
|
||||
*
|
||||
* Handles all earnings calculations including:
|
||||
* - Base commission (fixed, percentage, per_km, hybrid)
|
||||
* - Distance-based earnings
|
||||
* - Peak hour bonuses
|
||||
* - Surge pricing multipliers
|
||||
* - Performance bonuses (rating, consecutive, targets)
|
||||
* - Weather bonuses
|
||||
* - Tips
|
||||
* - Penalties
|
||||
*/
|
||||
class EarningsCalculator
|
||||
{
|
||||
protected array $config;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->config = config('restaurant-delivery.rider_earnings');
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| MAIN CALCULATION FORMULA
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Total Earnings = Base Commission + Distance Bonus + Peak Hour Bonus
|
||||
| + Surge Bonus + Performance Bonuses + Tips - Penalties
|
||||
|
|
||||
| Base Commission Types:
|
||||
| - Fixed: flat_rate
|
||||
| - Percentage: delivery_fee × percentage
|
||||
| - Per KM: base_rate + (distance × per_km_rate)
|
||||
| - Hybrid: base_amount + (distance × per_km_rate)
|
||||
|
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculate complete earnings for a delivery
|
||||
*
|
||||
* @param Delivery $delivery The completed delivery
|
||||
* @param Rider $rider The rider who completed it
|
||||
* @return array Detailed breakdown of earnings
|
||||
*/
|
||||
public function calculateDeliveryEarnings(Delivery $delivery, Rider $rider): array
|
||||
{
|
||||
$breakdown = [
|
||||
'delivery_id' => $delivery->id,
|
||||
'rider_id' => $rider->id,
|
||||
'calculations' => [],
|
||||
'bonuses' => [],
|
||||
'penalties' => [],
|
||||
'subtotals' => [],
|
||||
];
|
||||
|
||||
// 1. Calculate base commission
|
||||
$baseCommission = $this->calculateBaseCommission($delivery, $rider);
|
||||
$breakdown['calculations']['base_commission'] = $baseCommission;
|
||||
|
||||
// 2. Calculate distance bonus (if applicable)
|
||||
$distanceBonus = $this->calculateDistanceBonus($delivery);
|
||||
$breakdown['calculations']['distance_bonus'] = $distanceBonus;
|
||||
|
||||
// 3. Calculate peak hour bonus
|
||||
$peakHourBonus = $this->calculatePeakHourBonus($delivery, $baseCommission['amount']);
|
||||
$breakdown['bonuses']['peak_hour'] = $peakHourBonus;
|
||||
|
||||
// 4. Calculate surge pricing bonus
|
||||
$surgeBonus = $this->calculateSurgeBonus($delivery, $baseCommission['amount']);
|
||||
$breakdown['bonuses']['surge'] = $surgeBonus;
|
||||
|
||||
// 5. Calculate performance bonuses
|
||||
$performanceBonuses = $this->calculatePerformanceBonuses($delivery, $rider);
|
||||
$breakdown['bonuses'] = array_merge($breakdown['bonuses'], $performanceBonuses);
|
||||
|
||||
// 6. Calculate weather bonus
|
||||
$weatherBonus = $this->calculateWeatherBonus($delivery);
|
||||
$breakdown['bonuses']['weather'] = $weatherBonus;
|
||||
|
||||
// 7. Get tip amount
|
||||
$tipAmount = $this->getTipAmount($delivery);
|
||||
$breakdown['calculations']['tip'] = [
|
||||
'amount' => $tipAmount,
|
||||
'description' => 'Customer tip',
|
||||
];
|
||||
|
||||
// 8. Calculate penalties (if any)
|
||||
$penalties = $this->calculatePenalties($delivery, $rider);
|
||||
$breakdown['penalties'] = $penalties;
|
||||
|
||||
// Calculate subtotals
|
||||
$baseEarnings = $baseCommission['amount'] + $distanceBonus['amount'];
|
||||
$totalBonuses = array_sum(array_column($breakdown['bonuses'], 'amount'));
|
||||
$totalPenalties = array_sum(array_column($breakdown['penalties'], 'amount'));
|
||||
$totalEarnings = $baseEarnings + $totalBonuses + $tipAmount - $totalPenalties;
|
||||
|
||||
$breakdown['subtotals'] = [
|
||||
'base_earnings' => round($baseEarnings, 2),
|
||||
'total_bonuses' => round($totalBonuses, 2),
|
||||
'tip' => round($tipAmount, 2),
|
||||
'total_penalties' => round($totalPenalties, 2),
|
||||
'gross_earnings' => round($baseEarnings + $totalBonuses + $tipAmount, 2),
|
||||
'net_earnings' => round($totalEarnings, 2),
|
||||
];
|
||||
|
||||
$breakdown['total_earnings'] = round($totalEarnings, 2);
|
||||
$breakdown['currency'] = config('restaurant-delivery.currency');
|
||||
|
||||
return $breakdown;
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| BASE COMMISSION CALCULATIONS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculate base commission based on commission model
|
||||
*
|
||||
* FORMULA BY MODEL:
|
||||
* - Fixed: commission = fixed_amount
|
||||
* - Percentage: commission = delivery_fee × (percentage / 100)
|
||||
* - Per KM: commission = base_rate + (distance_km × per_km_rate)
|
||||
* - Hybrid: commission = base_amount + (distance_km × per_km_rate)
|
||||
*/
|
||||
public function calculateBaseCommission(Delivery $delivery, Rider $rider): array
|
||||
{
|
||||
$model = $rider->commission_model ?? $this->config['commission_model'] ?? 'fixed';
|
||||
$rate = $rider->commission_rate ?? $this->config['default_commission_rate'] ?? 80;
|
||||
|
||||
$distance = $delivery->actual_distance ?? $delivery->estimated_distance ?? 0;
|
||||
$deliveryFee = $delivery->delivery_fee ?? 0;
|
||||
|
||||
$amount = 0;
|
||||
$formula = '';
|
||||
|
||||
switch ($model) {
|
||||
case 'fixed':
|
||||
// Fixed amount per delivery
|
||||
$amount = (float) $rate;
|
||||
$formula = "Fixed rate: {$rate}";
|
||||
break;
|
||||
|
||||
case 'percentage':
|
||||
// Percentage of delivery fee
|
||||
$percentage = (float) $rate;
|
||||
$amount = $deliveryFee * ($percentage / 100);
|
||||
$formula = "Delivery fee ({$deliveryFee}) × {$percentage}% = {$amount}";
|
||||
break;
|
||||
|
||||
case 'per_km':
|
||||
// Base + per kilometer rate
|
||||
$baseRate = $this->config['per_km']['base_rate'] ?? 30;
|
||||
$perKmRate = (float) $rate;
|
||||
$amount = $baseRate + ($distance * $perKmRate);
|
||||
$formula = "Base ({$baseRate}) + Distance ({$distance} km) × Rate ({$perKmRate}/km) = {$amount}";
|
||||
break;
|
||||
|
||||
case 'hybrid':
|
||||
// Base amount + per km (more flexible)
|
||||
$baseAmount = $this->config['hybrid']['base_amount'] ?? 40;
|
||||
$perKmRate = $this->config['hybrid']['per_km_rate'] ?? 10;
|
||||
$minAmount = $this->config['hybrid']['min_amount'] ?? 50;
|
||||
$maxAmount = $this->config['hybrid']['max_amount'] ?? 300;
|
||||
|
||||
$calculated = $baseAmount + ($distance * $perKmRate);
|
||||
$amount = max($minAmount, min($maxAmount, $calculated));
|
||||
|
||||
$formula = "Base ({$baseAmount}) + Distance ({$distance} km) × Rate ({$perKmRate}/km) = {$calculated}";
|
||||
if ($calculated !== $amount) {
|
||||
$formula .= " → Clamped to {$amount} (min: {$minAmount}, max: {$maxAmount})";
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$amount = (float) $rate;
|
||||
$formula = "Default fixed rate: {$rate}";
|
||||
}
|
||||
|
||||
return [
|
||||
'model' => $model,
|
||||
'amount' => round($amount, 2),
|
||||
'formula' => $formula,
|
||||
'inputs' => [
|
||||
'distance_km' => $distance,
|
||||
'delivery_fee' => $deliveryFee,
|
||||
'rate' => $rate,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate distance-based bonus for long deliveries
|
||||
*
|
||||
* FORMULA:
|
||||
* If distance > threshold:
|
||||
* bonus = (distance - threshold) × extra_per_km_rate
|
||||
*/
|
||||
public function calculateDistanceBonus(Delivery $delivery): array
|
||||
{
|
||||
$distanceConfig = $this->config['distance_bonus'] ?? [];
|
||||
|
||||
if (empty($distanceConfig['enabled'])) {
|
||||
return ['amount' => 0, 'formula' => 'Distance bonus disabled'];
|
||||
}
|
||||
|
||||
$distance = $delivery->actual_distance ?? $delivery->estimated_distance ?? 0;
|
||||
$threshold = $distanceConfig['threshold_km'] ?? 5;
|
||||
$extraRate = $distanceConfig['extra_per_km'] ?? 5;
|
||||
|
||||
if ($distance <= $threshold) {
|
||||
return [
|
||||
'amount' => 0,
|
||||
'formula' => "Distance ({$distance} km) ≤ Threshold ({$threshold} km) → No bonus",
|
||||
];
|
||||
}
|
||||
|
||||
$extraDistance = $distance - $threshold;
|
||||
$bonus = $extraDistance * $extraRate;
|
||||
|
||||
return [
|
||||
'amount' => round($bonus, 2),
|
||||
'formula' => "Extra distance ({$extraDistance} km) × Rate ({$extraRate}/km) = {$bonus}",
|
||||
'inputs' => [
|
||||
'distance' => $distance,
|
||||
'threshold' => $threshold,
|
||||
'extra_rate' => $extraRate,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| BONUS CALCULATIONS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculate peak hour bonus
|
||||
*
|
||||
* FORMULA:
|
||||
* If within peak hours:
|
||||
* bonus = base_commission × peak_multiplier
|
||||
*/
|
||||
public function calculatePeakHourBonus(Delivery $delivery, float $baseAmount): array
|
||||
{
|
||||
$peakConfig = $this->config['bonuses']['peak_hours'] ?? [];
|
||||
|
||||
if (empty($peakConfig['enabled'])) {
|
||||
return ['amount' => 0, 'formula' => 'Peak hour bonus disabled'];
|
||||
}
|
||||
|
||||
$deliveryTime = $delivery->delivered_at ?? $delivery->created_at;
|
||||
$hour = (int) $deliveryTime->format('H');
|
||||
|
||||
$peakPeriods = $peakConfig['periods'] ?? [
|
||||
['start' => 11, 'end' => 14], // Lunch
|
||||
['start' => 18, 'end' => 21], // Dinner
|
||||
];
|
||||
|
||||
$isPeakHour = false;
|
||||
$matchedPeriod = null;
|
||||
|
||||
foreach ($peakPeriods as $period) {
|
||||
if ($hour >= $period['start'] && $hour <= $period['end']) {
|
||||
$isPeakHour = true;
|
||||
$matchedPeriod = $period;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $isPeakHour) {
|
||||
return [
|
||||
'amount' => 0,
|
||||
'formula' => "Hour ({$hour}) not in peak periods → No bonus",
|
||||
];
|
||||
}
|
||||
|
||||
$multiplier = $peakConfig['multiplier'] ?? 0.2; // 20% bonus
|
||||
$bonus = $baseAmount * $multiplier;
|
||||
$maxBonus = $peakConfig['max_bonus'] ?? 50;
|
||||
$finalBonus = min($bonus, $maxBonus);
|
||||
|
||||
return [
|
||||
'amount' => round($finalBonus, 2),
|
||||
'formula' => "Base ({$baseAmount}) × Multiplier ({$multiplier}) = {$bonus}".
|
||||
($bonus > $maxBonus ? " → Capped at {$maxBonus}" : ''),
|
||||
'peak_period' => "{$matchedPeriod['start']}:00 - {$matchedPeriod['end']}:00",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate surge pricing bonus
|
||||
*
|
||||
* FORMULA:
|
||||
* If surge active in zone:
|
||||
* bonus = base_commission × (surge_multiplier - 1)
|
||||
*
|
||||
* Example: 1.5x surge on 100 base = 50 bonus
|
||||
*/
|
||||
public function calculateSurgeBonus(Delivery $delivery, float $baseAmount): array
|
||||
{
|
||||
$surgeMultiplier = $delivery->surge_multiplier ?? 1.0;
|
||||
|
||||
if ($surgeMultiplier <= 1.0) {
|
||||
return [
|
||||
'amount' => 0,
|
||||
'formula' => 'No surge active (multiplier: 1.0)',
|
||||
];
|
||||
}
|
||||
|
||||
// Surge bonus = base × (multiplier - 1)
|
||||
// So 1.5x surge gives 50% bonus
|
||||
$bonusMultiplier = $surgeMultiplier - 1;
|
||||
$bonus = $baseAmount * $bonusMultiplier;
|
||||
|
||||
return [
|
||||
'amount' => round($bonus, 2),
|
||||
'formula' => "Base ({$baseAmount}) × Surge bonus ({$bonusMultiplier}) = {$bonus}",
|
||||
'surge_multiplier' => $surgeMultiplier,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate performance-based bonuses
|
||||
*/
|
||||
public function calculatePerformanceBonuses(Delivery $delivery, Rider $rider): array
|
||||
{
|
||||
$bonuses = [];
|
||||
|
||||
// Rating bonus
|
||||
$bonuses['rating'] = $this->calculateRatingBonus($rider);
|
||||
|
||||
// Consecutive delivery bonus
|
||||
$bonuses['consecutive'] = $this->calculateConsecutiveBonus($rider);
|
||||
|
||||
// Early delivery bonus
|
||||
$bonuses['early_delivery'] = $this->calculateEarlyDeliveryBonus($delivery);
|
||||
|
||||
// First delivery of the day bonus
|
||||
$bonuses['first_delivery'] = $this->calculateFirstDeliveryBonus($rider);
|
||||
|
||||
return $bonuses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rating-based bonus
|
||||
*
|
||||
* FORMULA:
|
||||
* If rating >= threshold:
|
||||
* bonus = rating_bonus_amount × rating_tier_multiplier
|
||||
*/
|
||||
public function calculateRatingBonus(Rider $rider): array
|
||||
{
|
||||
$ratingConfig = $this->config['bonuses']['rating'] ?? [];
|
||||
|
||||
if (empty($ratingConfig['enabled'])) {
|
||||
return ['amount' => 0, 'formula' => 'Rating bonus disabled'];
|
||||
}
|
||||
|
||||
$rating = $rider->rating ?? 0;
|
||||
$threshold = $ratingConfig['threshold'] ?? 4.8;
|
||||
$baseBonus = $ratingConfig['bonus_amount'] ?? 10;
|
||||
|
||||
if ($rating < $threshold) {
|
||||
return [
|
||||
'amount' => 0,
|
||||
'formula' => "Rating ({$rating}) < Threshold ({$threshold}) → No bonus",
|
||||
];
|
||||
}
|
||||
|
||||
// Tiered bonus: higher rating = higher multiplier
|
||||
$tiers = $ratingConfig['tiers'] ?? [
|
||||
['min' => 4.8, 'max' => 4.89, 'multiplier' => 1.0],
|
||||
['min' => 4.9, 'max' => 4.94, 'multiplier' => 1.5],
|
||||
['min' => 4.95, 'max' => 5.0, 'multiplier' => 2.0],
|
||||
];
|
||||
|
||||
$multiplier = 1.0;
|
||||
foreach ($tiers as $tier) {
|
||||
if ($rating >= $tier['min'] && $rating <= $tier['max']) {
|
||||
$multiplier = $tier['multiplier'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$bonus = $baseBonus * $multiplier;
|
||||
|
||||
return [
|
||||
'amount' => round($bonus, 2),
|
||||
'formula' => "Base bonus ({$baseBonus}) × Tier multiplier ({$multiplier}) = {$bonus}",
|
||||
'rating' => $rating,
|
||||
'tier_multiplier' => $multiplier,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate consecutive delivery bonus
|
||||
*
|
||||
* FORMULA:
|
||||
* If consecutive_count >= threshold:
|
||||
* bonus = bonus_per_consecutive × min(consecutive_count, max_count)
|
||||
*/
|
||||
public function calculateConsecutiveBonus(Rider $rider): array
|
||||
{
|
||||
$consecutiveConfig = $this->config['bonuses']['consecutive'] ?? [];
|
||||
|
||||
if (empty($consecutiveConfig['enabled'])) {
|
||||
return ['amount' => 0, 'formula' => 'Consecutive bonus disabled'];
|
||||
}
|
||||
|
||||
// Get today's consecutive deliveries without rejection
|
||||
$today = Carbon::today();
|
||||
$consecutiveCount = $this->getConsecutiveDeliveryCount($rider, $today);
|
||||
|
||||
$threshold = $consecutiveConfig['threshold'] ?? 3;
|
||||
$bonusPerDelivery = $consecutiveConfig['bonus_per_delivery'] ?? 5;
|
||||
$maxCount = $consecutiveConfig['max_count'] ?? 10;
|
||||
|
||||
if ($consecutiveCount < $threshold) {
|
||||
return [
|
||||
'amount' => 0,
|
||||
'formula' => "Consecutive ({$consecutiveCount}) < Threshold ({$threshold}) → No bonus",
|
||||
];
|
||||
}
|
||||
|
||||
$eligibleCount = min($consecutiveCount, $maxCount);
|
||||
$bonus = $eligibleCount * $bonusPerDelivery;
|
||||
|
||||
return [
|
||||
'amount' => round($bonus, 2),
|
||||
'formula' => "Consecutive count ({$eligibleCount}) × Bonus ({$bonusPerDelivery}) = {$bonus}",
|
||||
'consecutive_count' => $consecutiveCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate early delivery bonus
|
||||
*
|
||||
* FORMULA:
|
||||
* If delivered before ETA:
|
||||
* bonus = minutes_early × bonus_per_minute (capped)
|
||||
*/
|
||||
public function calculateEarlyDeliveryBonus(Delivery $delivery): array
|
||||
{
|
||||
$earlyConfig = $this->config['bonuses']['early_delivery'] ?? [];
|
||||
|
||||
if (empty($earlyConfig['enabled'])) {
|
||||
return ['amount' => 0, 'formula' => 'Early delivery bonus disabled'];
|
||||
}
|
||||
|
||||
if (! $delivery->estimated_delivery_at || ! $delivery->delivered_at) {
|
||||
return ['amount' => 0, 'formula' => 'Missing delivery time data'];
|
||||
}
|
||||
|
||||
$estimatedTime = Carbon::parse($delivery->estimated_delivery_at);
|
||||
$actualTime = Carbon::parse($delivery->delivered_at);
|
||||
|
||||
if ($actualTime >= $estimatedTime) {
|
||||
return [
|
||||
'amount' => 0,
|
||||
'formula' => 'Delivered on time or late → No early bonus',
|
||||
];
|
||||
}
|
||||
|
||||
$minutesEarly = $estimatedTime->diffInMinutes($actualTime);
|
||||
$minMinutesRequired = $earlyConfig['min_minutes'] ?? 5;
|
||||
|
||||
if ($minutesEarly < $minMinutesRequired) {
|
||||
return [
|
||||
'amount' => 0,
|
||||
'formula' => "Only {$minutesEarly} min early (need {$minMinutesRequired}) → No bonus",
|
||||
];
|
||||
}
|
||||
|
||||
$bonusPerMinute = $earlyConfig['bonus_per_minute'] ?? 2;
|
||||
$maxBonus = $earlyConfig['max_bonus'] ?? 30;
|
||||
|
||||
$bonus = min($minutesEarly * $bonusPerMinute, $maxBonus);
|
||||
|
||||
return [
|
||||
'amount' => round($bonus, 2),
|
||||
'formula' => "Minutes early ({$minutesEarly}) × Bonus ({$bonusPerMinute}/min) = ".
|
||||
($minutesEarly * $bonusPerMinute).
|
||||
($minutesEarly * $bonusPerMinute > $maxBonus ? " → Capped at {$maxBonus}" : ''),
|
||||
'minutes_early' => $minutesEarly,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate first delivery of the day bonus
|
||||
*/
|
||||
public function calculateFirstDeliveryBonus(Rider $rider): array
|
||||
{
|
||||
$firstConfig = $this->config['bonuses']['first_delivery'] ?? [];
|
||||
|
||||
if (empty($firstConfig['enabled'])) {
|
||||
return ['amount' => 0, 'formula' => 'First delivery bonus disabled'];
|
||||
}
|
||||
|
||||
$today = Carbon::today();
|
||||
$todayDeliveries = RiderEarning::where('rider_id', $rider->id)
|
||||
->whereDate('created_at', $today)
|
||||
->where('type', 'delivery')
|
||||
->count();
|
||||
|
||||
// This is the first delivery if count is 0 (before we create the earning)
|
||||
if ($todayDeliveries > 0) {
|
||||
return [
|
||||
'amount' => 0,
|
||||
'formula' => 'Not the first delivery of the day',
|
||||
];
|
||||
}
|
||||
|
||||
$bonus = $firstConfig['bonus_amount'] ?? 20;
|
||||
|
||||
return [
|
||||
'amount' => round((float) $bonus, 2),
|
||||
'formula' => "First delivery of the day bonus: {$bonus}",
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate weather-based bonus
|
||||
*
|
||||
* FORMULA:
|
||||
* If bad weather conditions:
|
||||
* bonus = base_weather_bonus × condition_multiplier
|
||||
*/
|
||||
public function calculateWeatherBonus(Delivery $delivery): array
|
||||
{
|
||||
$weatherConfig = $this->config['bonuses']['weather'] ?? [];
|
||||
|
||||
if (empty($weatherConfig['enabled'])) {
|
||||
return ['amount' => 0, 'formula' => 'Weather bonus disabled'];
|
||||
}
|
||||
|
||||
// Weather condition should be stored with the delivery or fetched externally
|
||||
$weatherCondition = $delivery->weather_condition ?? null;
|
||||
|
||||
if (! $weatherCondition) {
|
||||
return ['amount' => 0, 'formula' => 'No weather data available'];
|
||||
}
|
||||
|
||||
$conditions = $weatherConfig['conditions'] ?? [
|
||||
'rain' => ['multiplier' => 1.0, 'bonus' => 15],
|
||||
'heavy_rain' => ['multiplier' => 1.5, 'bonus' => 25],
|
||||
'storm' => ['multiplier' => 2.0, 'bonus' => 40],
|
||||
'extreme_heat' => ['multiplier' => 1.2, 'bonus' => 10],
|
||||
'extreme_cold' => ['multiplier' => 1.2, 'bonus' => 10],
|
||||
];
|
||||
|
||||
if (! isset($conditions[$weatherCondition])) {
|
||||
return [
|
||||
'amount' => 0,
|
||||
'formula' => "Weather condition '{$weatherCondition}' not eligible for bonus",
|
||||
];
|
||||
}
|
||||
|
||||
$config = $conditions[$weatherCondition];
|
||||
$bonus = $config['bonus'] * $config['multiplier'];
|
||||
|
||||
return [
|
||||
'amount' => round($bonus, 2),
|
||||
'formula' => "Weather ({$weatherCondition}): Base ({$config['bonus']}) × Multiplier ({$config['multiplier']}) = {$bonus}",
|
||||
'condition' => $weatherCondition,
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| TIP & PENALTY CALCULATIONS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get total tip amount for delivery
|
||||
*/
|
||||
public function getTipAmount(Delivery $delivery): float
|
||||
{
|
||||
return (float) ($delivery->tips()->sum('amount') ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate penalties for the delivery
|
||||
*/
|
||||
public function calculatePenalties(Delivery $delivery, Rider $rider): array
|
||||
{
|
||||
$penalties = [];
|
||||
|
||||
// Late delivery penalty
|
||||
$latePenalty = $this->calculateLatePenalty($delivery);
|
||||
if ($latePenalty['amount'] > 0) {
|
||||
$penalties['late_delivery'] = $latePenalty;
|
||||
}
|
||||
|
||||
// Customer complaint penalty
|
||||
$complaintPenalty = $this->calculateComplaintPenalty($delivery);
|
||||
if ($complaintPenalty['amount'] > 0) {
|
||||
$penalties['complaint'] = $complaintPenalty;
|
||||
}
|
||||
|
||||
return $penalties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate late delivery penalty
|
||||
*
|
||||
* FORMULA:
|
||||
* If delivered after ETA + grace_period:
|
||||
* penalty = min(minutes_late × penalty_per_minute, max_penalty)
|
||||
*/
|
||||
public function calculateLatePenalty(Delivery $delivery): array
|
||||
{
|
||||
$penaltyConfig = $this->config['penalties']['late_delivery'] ?? [];
|
||||
|
||||
if (empty($penaltyConfig['enabled'])) {
|
||||
return ['amount' => 0, 'formula' => 'Late penalty disabled'];
|
||||
}
|
||||
|
||||
if (! $delivery->estimated_delivery_at || ! $delivery->delivered_at) {
|
||||
return ['amount' => 0, 'formula' => 'Missing delivery time data'];
|
||||
}
|
||||
|
||||
$estimatedTime = Carbon::parse($delivery->estimated_delivery_at);
|
||||
$actualTime = Carbon::parse($delivery->delivered_at);
|
||||
$gracePeriod = $penaltyConfig['grace_period_minutes'] ?? 10;
|
||||
|
||||
$estimatedWithGrace = $estimatedTime->addMinutes($gracePeriod);
|
||||
|
||||
if ($actualTime <= $estimatedWithGrace) {
|
||||
return [
|
||||
'amount' => 0,
|
||||
'formula' => 'Delivered within acceptable time window',
|
||||
];
|
||||
}
|
||||
|
||||
$minutesLate = $actualTime->diffInMinutes($estimatedWithGrace);
|
||||
$penaltyPerMinute = $penaltyConfig['penalty_per_minute'] ?? 2;
|
||||
$maxPenalty = $penaltyConfig['max_penalty'] ?? 50;
|
||||
|
||||
$penalty = min($minutesLate * $penaltyPerMinute, $maxPenalty);
|
||||
|
||||
return [
|
||||
'amount' => round($penalty, 2),
|
||||
'formula' => "Minutes late ({$minutesLate}) × Penalty ({$penaltyPerMinute}/min) = ".
|
||||
($minutesLate * $penaltyPerMinute).
|
||||
($minutesLate * $penaltyPerMinute > $maxPenalty ? " → Capped at {$maxPenalty}" : ''),
|
||||
'minutes_late' => $minutesLate,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate complaint-based penalty
|
||||
*/
|
||||
public function calculateComplaintPenalty(Delivery $delivery): array
|
||||
{
|
||||
$complaintConfig = $this->config['penalties']['complaint'] ?? [];
|
||||
|
||||
if (empty($complaintConfig['enabled'])) {
|
||||
return ['amount' => 0, 'formula' => 'Complaint penalty disabled'];
|
||||
}
|
||||
|
||||
// Check for verified complaints on this delivery
|
||||
$hasComplaint = $delivery->ratings()
|
||||
->where('overall_rating', '<=', 2)
|
||||
->where('is_verified', true)
|
||||
->exists();
|
||||
|
||||
if (! $hasComplaint) {
|
||||
return ['amount' => 0, 'formula' => 'No verified complaints'];
|
||||
}
|
||||
|
||||
$penalty = $complaintConfig['penalty_amount'] ?? 25;
|
||||
|
||||
return [
|
||||
'amount' => round((float) $penalty, 2),
|
||||
'formula' => "Verified complaint penalty: {$penalty}",
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| WEEKLY/MONTHLY TARGET BONUSES
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculate weekly target bonus
|
||||
*
|
||||
* FORMULA:
|
||||
* tiers = [
|
||||
* { min: 50, bonus: 500 },
|
||||
* { min: 75, bonus: 1000 },
|
||||
* { min: 100, bonus: 2000 }
|
||||
* ]
|
||||
* bonus = highest_matching_tier_bonus
|
||||
*/
|
||||
public function calculateWeeklyTargetBonus(Rider $rider): array
|
||||
{
|
||||
$targetConfig = $this->config['bonuses']['weekly_target'] ?? [];
|
||||
|
||||
if (empty($targetConfig['enabled'])) {
|
||||
return ['amount' => 0, 'formula' => 'Weekly target bonus disabled', 'eligible' => false];
|
||||
}
|
||||
|
||||
$weekStart = Carbon::now()->startOfWeek();
|
||||
$weekEnd = Carbon::now()->endOfWeek();
|
||||
|
||||
$weeklyDeliveries = RiderEarning::where('rider_id', $rider->id)
|
||||
->whereBetween('created_at', [$weekStart, $weekEnd])
|
||||
->where('type', 'delivery')
|
||||
->count();
|
||||
|
||||
$tiers = $targetConfig['tiers'] ?? [
|
||||
['min' => 50, 'bonus' => 500],
|
||||
['min' => 75, 'bonus' => 1000],
|
||||
['min' => 100, 'bonus' => 2000],
|
||||
];
|
||||
|
||||
// Sort tiers descending to find highest matching
|
||||
usort($tiers, fn ($a, $b) => $b['min'] <=> $a['min']);
|
||||
|
||||
$matchedTier = null;
|
||||
foreach ($tiers as $tier) {
|
||||
if ($weeklyDeliveries >= $tier['min']) {
|
||||
$matchedTier = $tier;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $matchedTier) {
|
||||
$lowestTier = end($tiers);
|
||||
|
||||
return [
|
||||
'amount' => 0,
|
||||
'formula' => "Weekly deliveries ({$weeklyDeliveries}) < Minimum target ({$lowestTier['min']})",
|
||||
'eligible' => false,
|
||||
'current_count' => $weeklyDeliveries,
|
||||
'next_target' => $lowestTier['min'],
|
||||
'next_bonus' => $lowestTier['bonus'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'amount' => round((float) $matchedTier['bonus'], 2),
|
||||
'formula' => "Weekly deliveries ({$weeklyDeliveries}) ≥ Target ({$matchedTier['min']}) → Bonus: {$matchedTier['bonus']}",
|
||||
'eligible' => true,
|
||||
'current_count' => $weeklyDeliveries,
|
||||
'achieved_tier' => $matchedTier['min'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate monthly target bonus
|
||||
*/
|
||||
public function calculateMonthlyTargetBonus(Rider $rider): array
|
||||
{
|
||||
$targetConfig = $this->config['bonuses']['monthly_target'] ?? [];
|
||||
|
||||
if (empty($targetConfig['enabled'])) {
|
||||
return ['amount' => 0, 'formula' => 'Monthly target bonus disabled', 'eligible' => false];
|
||||
}
|
||||
|
||||
$monthStart = Carbon::now()->startOfMonth();
|
||||
$monthEnd = Carbon::now()->endOfMonth();
|
||||
|
||||
$monthlyDeliveries = RiderEarning::where('rider_id', $rider->id)
|
||||
->whereBetween('created_at', [$monthStart, $monthEnd])
|
||||
->where('type', 'delivery')
|
||||
->count();
|
||||
|
||||
$tiers = $targetConfig['tiers'] ?? [
|
||||
['min' => 200, 'bonus' => 2000],
|
||||
['min' => 300, 'bonus' => 4000],
|
||||
['min' => 400, 'bonus' => 7000],
|
||||
];
|
||||
|
||||
usort($tiers, fn ($a, $b) => $b['min'] <=> $a['min']);
|
||||
|
||||
$matchedTier = null;
|
||||
foreach ($tiers as $tier) {
|
||||
if ($monthlyDeliveries >= $tier['min']) {
|
||||
$matchedTier = $tier;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $matchedTier) {
|
||||
$lowestTier = end($tiers);
|
||||
|
||||
return [
|
||||
'amount' => 0,
|
||||
'formula' => "Monthly deliveries ({$monthlyDeliveries}) < Minimum target ({$lowestTier['min']})",
|
||||
'eligible' => false,
|
||||
'current_count' => $monthlyDeliveries,
|
||||
'next_target' => $lowestTier['min'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'amount' => round((float) $matchedTier['bonus'], 2),
|
||||
'formula' => "Monthly deliveries ({$monthlyDeliveries}) ≥ Target ({$matchedTier['min']}) → Bonus: {$matchedTier['bonus']}",
|
||||
'eligible' => true,
|
||||
'current_count' => $monthlyDeliveries,
|
||||
'achieved_tier' => $matchedTier['min'],
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| CANCELLATION PENALTY
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Calculate cancellation penalty
|
||||
*
|
||||
* FORMULA:
|
||||
* penalty = base_penalty × cancellation_stage_multiplier
|
||||
*
|
||||
* Stages:
|
||||
* - before_pickup: 0.5x
|
||||
* - at_restaurant: 1.0x
|
||||
* - after_pickup: 2.0x
|
||||
*/
|
||||
public function calculateCancellationPenalty(Delivery $delivery, string $cancellationStage): array
|
||||
{
|
||||
$penaltyConfig = $this->config['penalties']['cancellation'] ?? [];
|
||||
|
||||
if (empty($penaltyConfig['enabled'])) {
|
||||
return ['amount' => 0, 'formula' => 'Cancellation penalty disabled'];
|
||||
}
|
||||
|
||||
$basePenalty = $penaltyConfig['base_penalty'] ?? 50;
|
||||
|
||||
$stageMultipliers = $penaltyConfig['stage_multipliers'] ?? [
|
||||
'before_pickup' => 0.5,
|
||||
'at_restaurant' => 1.0,
|
||||
'after_pickup' => 2.0,
|
||||
];
|
||||
|
||||
$multiplier = $stageMultipliers[$cancellationStage] ?? 1.0;
|
||||
$penalty = $basePenalty * $multiplier;
|
||||
|
||||
return [
|
||||
'amount' => round($penalty, 2),
|
||||
'formula' => "Base penalty ({$basePenalty}) × Stage multiplier ({$multiplier}) = {$penalty}",
|
||||
'stage' => $cancellationStage,
|
||||
];
|
||||
}
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HELPER METHODS
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get consecutive delivery count for a rider on a given date
|
||||
*/
|
||||
protected function getConsecutiveDeliveryCount(Rider $rider, Carbon $date): int
|
||||
{
|
||||
// Get all deliveries for the day ordered by completion time
|
||||
$deliveries = RiderEarning::where('rider_id', $rider->id)
|
||||
->whereDate('created_at', $date)
|
||||
->where('type', 'delivery')
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
// Count consecutive (no rejections or timeouts between them)
|
||||
// This is a simplified version - in production, track rejections separately
|
||||
return $deliveries->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rider's earning summary for a period
|
||||
*/
|
||||
public function getEarningSummary(Rider $rider, Carbon $startDate, Carbon $endDate): array
|
||||
{
|
||||
$earnings = RiderEarning::where('rider_id', $rider->id)
|
||||
->whereBetween('created_at', [$startDate, $endDate])
|
||||
->get();
|
||||
|
||||
$summary = [
|
||||
'period' => [
|
||||
'start' => $startDate->toDateString(),
|
||||
'end' => $endDate->toDateString(),
|
||||
],
|
||||
'totals' => [
|
||||
'deliveries' => $earnings->where('type', 'delivery')->count(),
|
||||
'base_earnings' => $earnings->where('type', 'delivery')->sum('amount'),
|
||||
'bonuses' => $earnings->where('type', 'bonus')->sum('amount'),
|
||||
'tips' => $earnings->where('type', 'tip')->sum('amount'),
|
||||
'penalties' => $earnings->where('type', 'penalty')->sum('amount'),
|
||||
],
|
||||
'breakdown_by_type' => [],
|
||||
];
|
||||
|
||||
// Group by subtype
|
||||
foreach ($earnings->groupBy('sub_type') as $subType => $group) {
|
||||
$summary['breakdown_by_type'][$subType] = [
|
||||
'count' => $group->count(),
|
||||
'total' => $group->sum('amount'),
|
||||
];
|
||||
}
|
||||
|
||||
// Calculate net
|
||||
$summary['totals']['gross'] = $summary['totals']['base_earnings']
|
||||
+ $summary['totals']['bonuses']
|
||||
+ $summary['totals']['tips'];
|
||||
|
||||
$summary['totals']['net'] = $summary['totals']['gross']
|
||||
- $summary['totals']['penalties'];
|
||||
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user