Files
NextChair/app/Services/Loyalty/LoyaltyService.php
T
Stefan Lazic 2ef3654841
CI / ci (push) Canceled after 0s
PHP Rebuild
Initial PHP rebuild of the app
2026-09-05 08:21:33 +01:00

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.");
}
}