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