Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -0,0 +1,138 @@
|
||||
<?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.");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user