139 lines
7.6 KiB
PHP
139 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Loyalty;
|
|
|
|
use App\Enums\{LoyaltyTransactionType, RewardPositionMode, RewardStatus, RewardType};
|
|
use App\Models\{Appointment, Business, CustomerProfile, LoyaltyAccount, LoyaltyProgramme, LoyaltyReward, LoyaltyTransaction, StaffProfile};
|
|
use Illuminate\Support\Facades\DB;
|
|
use RuntimeException;
|
|
|
|
class LoyaltyService
|
|
{
|
|
public function summary(Business $business, CustomerProfile $customer): ?array
|
|
{
|
|
$programme = $business->loyaltyProgramme;
|
|
if (! $programme) return null;
|
|
|
|
$account = LoyaltyAccount::query()
|
|
->where('customerId', $customer->id)
|
|
->where('loyaltyProgrammeId', $programme->id)
|
|
->with(['rewards' => fn ($query) => $query->where('status', RewardStatus::Available->value)->latest('createdAt')])
|
|
->first();
|
|
|
|
return [
|
|
'programme' => $programme,
|
|
'account' => $account,
|
|
'currentStamps' => $account?->currentStamps ?? 0,
|
|
'threshold' => $this->threshold($programme),
|
|
'availableReward' => $account?->rewards->first(),
|
|
];
|
|
}
|
|
|
|
public function addStamp(Business $business, CustomerProfile $customer, StaffProfile $staff, int $amount = 1, ?Appointment $appointment = null, ?string $reason = null): LoyaltyAccount
|
|
{
|
|
if ($amount < 1) throw new RuntimeException('Stamp amount must be positive.');
|
|
$programme = $business->loyaltyProgramme;
|
|
if (! $programme?->isEnabled) throw new RuntimeException('Loyalty is not enabled for this business.');
|
|
$this->assertEligible($programme, $appointment);
|
|
|
|
return DB::transaction(function () use ($programme, $customer, $staff, $amount, $appointment, $reason): LoyaltyAccount {
|
|
$account = LoyaltyAccount::query()->firstOrCreate(['customerId' => $customer->id, 'loyaltyProgrammeId' => $programme->id], ['currentStamps' => 0]);
|
|
$account = LoyaltyAccount::query()->whereKey($account->id)->lockForUpdate()->firstOrFail();
|
|
$account->currentStamps += $amount;
|
|
$account->save();
|
|
LoyaltyTransaction::create([
|
|
'loyaltyAccountId' => $account->id,
|
|
'type' => LoyaltyTransactionType::StampAdded,
|
|
'amount' => $amount,
|
|
'reason' => $reason,
|
|
'appointmentId' => $appointment?->id,
|
|
'authoredByStaffId' => $staff->id,
|
|
]);
|
|
|
|
if ($account->currentStamps >= $this->threshold($programme) && ! $account->rewards()->where('status', RewardStatus::Available->value)->exists()) {
|
|
LoyaltyReward::create([
|
|
'loyaltyAccountId' => $account->id,
|
|
'rewardType' => $programme->rewardType,
|
|
'value' => $programme->rewardValue,
|
|
'label' => $programme->rewardLabel ?: 'Reward',
|
|
'status' => RewardStatus::Available,
|
|
'expiresAt' => $programme->rewardExpiresAfterDays ? now()->addDays($programme->rewardExpiresAfterDays) : null,
|
|
]);
|
|
}
|
|
|
|
return $account->fresh();
|
|
});
|
|
}
|
|
|
|
public function correctStamp(LoyaltyAccount $account, StaffProfile $staff, int $amount, string $reason): void
|
|
{
|
|
if ($amount < 1 || trim($reason) === '') throw new RuntimeException('A positive amount and reason are required.');
|
|
DB::transaction(function () use ($account, $staff, $amount, $reason): void {
|
|
$locked = LoyaltyAccount::query()->whereKey($account->id)->lockForUpdate()->firstOrFail();
|
|
$removed = min($amount, $locked->currentStamps);
|
|
$locked->currentStamps -= $removed;
|
|
$locked->save();
|
|
LoyaltyTransaction::create(['loyaltyAccountId' => $locked->id, 'type' => LoyaltyTransactionType::StampRemoved, 'amount' => -$removed, 'reason' => trim($reason), 'authoredByStaffId' => $staff->id]);
|
|
});
|
|
}
|
|
|
|
public function redeemReward(Business $business, LoyaltyReward $reward, Appointment $appointment, StaffProfile $staff): string
|
|
{
|
|
$reward->loadMissing('account.programme');
|
|
$account = $reward->account;
|
|
$programme = $account?->programme;
|
|
if (! $programme || $reward->status !== RewardStatus::Available || $appointment->businessId !== $business->id || $appointment->customerId !== $account->customerId) {
|
|
throw new RuntimeException('That reward is not available for this appointment.');
|
|
}
|
|
|
|
$subtotal = (float) $appointment->services()->sum('priceAtBooking');
|
|
$discount = match ($reward->rewardType) {
|
|
RewardType::Percentage => min($subtotal, round($subtotal * ((float) $reward->value / 100), 2)),
|
|
RewardType::FixedAmount => min($subtotal, (float) $reward->value),
|
|
RewardType::FreeService => min($subtotal, (float) ($programme->freeService?->price ?? $subtotal)),
|
|
};
|
|
|
|
DB::transaction(function () use ($reward, $account, $appointment, $staff, $discount, $programme): void {
|
|
$locked = LoyaltyReward::query()->whereKey($reward->id)->lockForUpdate()->firstOrFail();
|
|
if ($locked->status !== RewardStatus::Available) throw new RuntimeException('That reward has already been redeemed.');
|
|
$locked->status = RewardStatus::Redeemed;
|
|
$locked->redeemedAt = now();
|
|
$locked->redeemedByStaffId = $staff->id;
|
|
$locked->appliedToAppointmentId = $appointment->id;
|
|
$locked->save();
|
|
$lockedAccount = LoyaltyAccount::query()->whereKey($account->id)->lockForUpdate()->firstOrFail();
|
|
$lockedAccount->currentStamps = max(0, $lockedAccount->currentStamps - $programme->stampsRequired);
|
|
$lockedAccount->save();
|
|
LoyaltyTransaction::create(['loyaltyAccountId' => $lockedAccount->id, 'type' => LoyaltyTransactionType::RewardRedeemed, 'amount' => -$programme->stampsRequired, 'reason' => 'Reward redeemed', 'appointmentId' => $appointment->id, 'authoredByStaffId' => $staff->id]);
|
|
$appointment->discountAmount = $discount;
|
|
$appointment->save();
|
|
});
|
|
|
|
return number_format($discount, 2, '.', '');
|
|
}
|
|
|
|
public function awardForCompletedAppointment(Appointment $appointment, StaffProfile $staff): ?LoyaltyAccount
|
|
{
|
|
if ($appointment->status->value !== 'COMPLETED') return null;
|
|
$already = LoyaltyTransaction::query()->where('appointmentId', $appointment->id)->where('type', LoyaltyTransactionType::StampAdded->value)->exists();
|
|
if ($already) return null;
|
|
$appointment->loadMissing('business', 'customer', 'services');
|
|
return $this->addStamp($appointment->business, $appointment->customer, $staff, 1, $appointment);
|
|
}
|
|
|
|
private function threshold(LoyaltyProgramme $programme): int
|
|
{
|
|
return max(1, $programme->rewardPosition === RewardPositionMode::FinalSlotIsReward ? $programme->stampsRequired - 1 : $programme->stampsRequired);
|
|
}
|
|
|
|
private function assertEligible(LoyaltyProgramme $programme, ?Appointment $appointment): void
|
|
{
|
|
if (! $appointment) return;
|
|
$serviceIds = $appointment->services->pluck('serviceId');
|
|
$eligibleServices = $programme->eligibleServices()->pluck('serviceId');
|
|
$eligibleStaff = $programme->eligibleStaff()->pluck('staffId');
|
|
if ($eligibleStaff->isNotEmpty() && ! $eligibleStaff->contains($appointment->staffId)) throw new RuntimeException("This staff member's visits don't earn loyalty stamps.");
|
|
if ($eligibleServices->isNotEmpty() && $serviceIds->intersect($eligibleServices)->isEmpty()) throw new RuntimeException("None of the services on this visit earn a loyalty stamp.");
|
|
}
|
|
}
|