Files
alrahma_sunday_school_api/app/Services/Discounts/DiscountApplyService.php
T
2026-06-11 11:46:12 -04:00

296 lines
11 KiB
PHP

<?php
namespace App\Services\Discounts;
use App\Models\DiscountVoucher;
use App\Models\Invoice;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class DiscountApplyService
{
private DiscountContextService $context;
private DiscountInvoiceService $invoiceService;
public function __construct(DiscountContextService $context, DiscountInvoiceService $invoiceService)
{
$this->context = $context;
$this->invoiceService = $invoiceService;
}
public function applyVoucher(int $voucherId, array $parentIds, bool $allowAdditional, int $actorId): array
{
$schoolYear = $this->context->getSchoolYear();
$semester = $this->context->getSemester();
$voucher = DiscountVoucher::query()->find($voucherId);
if (! $voucher) {
return ['ok' => false, 'message' => 'Voucher not found.'];
}
$voucherDescription = trim((string) ($voucher->description ?? ''));
$voucherDescription = preg_replace(
'/^Auto-generated on \\d{4}-\\d{2}-\\d{2}\\s*[—-]\\s*reason:\\s*/i',
'',
$voucherDescription
);
$maxUsesRaw = $voucher->max_uses;
$maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw;
$timesUsed = (int) ($voucher->times_used ?? 0);
$remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed);
if ($remainingUses <= 0) {
return ['ok' => false, 'message' => 'This voucher has reached its maximum allowed uses.'];
}
if (! $allowAdditional) {
$rows = DB::table('discount_usages as du')
->select('i.parent_id')
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
->whereIn('i.parent_id', $parentIds)
->where('i.school_year', $schoolYear)
->groupBy('i.parent_id')
->get()
->map(fn ($row) => (array) $row)
->all();
$blockedParentIds = array_map(static fn ($r) => (int) ($r['parent_id'] ?? 0), $rows);
if (! empty($blockedParentIds)) {
$parentIds = array_values(array_diff(array_map('intval', $parentIds), $blockedParentIds));
}
if (empty($parentIds)) {
return [
'ok' => false,
'message' => 'All selected parents already have discounts. Enable "Allow additional discounts" to apply another.',
];
}
}
$paymentDate = function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString();
$fullyCoveredInvoiceIds = [];
$touchedInvoiceIds = [];
$appliedCount = 0;
DB::beginTransaction();
try {
foreach ($parentIds as $parentId) {
$invoices = Invoice::getInvoicesByUserId((int) $parentId, $schoolYear);
if (empty($invoices)) {
Log::warning('Discount apply: parent has no invoices', [
'parent_id' => (int) $parentId,
'voucher_id' => $voucherId,
]);
continue;
}
foreach ($invoices as $invoice) {
if ($remainingUses <= 0) {
break 2;
}
$invoiceId = (int) ($invoice['id'] ?? 0);
if ($invoiceId <= 0) {
continue;
}
$initialPreBalance = (float) $this->invoiceService->getCurrentInvoiceBalance($invoiceId, $schoolYear);
if ($initialPreBalance <= 0) {
continue;
}
if (! $allowAdditional) {
$exists = DB::table('discount_usages as du')
->join('invoices as i', 'du.invoice_id', '=', 'i.id')
->where('du.voucher_id', $voucherId)
->where('du.invoice_id', $invoiceId)
->where('i.school_year', $schoolYear)
->count();
if ($exists) {
continue;
}
}
$rawDiscount = $voucher->discount_type === 'percent'
? round(((float) ($invoice['total_amount'] ?? 0) * (float) $voucher->discount_value) / 100, 2)
: (float) $voucher->discount_value;
$discount = min($rawDiscount, $initialPreBalance);
if ($discount <= 0) {
continue;
}
$now = function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString();
DB::table('discount_usages')->insert([
'voucher_id' => $voucherId,
'invoice_id' => $invoiceId,
'parent_id' => (int) $parentId,
'discount_amount' => $discount,
'description' => $voucherDescription,
'school_year' => $schoolYear,
'updated_by' => $actorId,
'used_at' => $now,
'created_at' => $now,
'updated_at' => $now,
'semester' => $semester,
]);
$newBalance = max(0.0, round($initialPreBalance - $discount, 2));
DB::table('invoices')
->where('id', $invoiceId)
->update([
'balance' => $newBalance,
'has_discount' => 1,
'updated_at' => $now,
]);
$postBalance = round($initialPreBalance - $discount, 2);
if ($postBalance < 0) {
$postBalance = 0.0;
}
$currentBalance = (float) $this->invoiceService->getCurrentInvoiceBalance($invoiceId, $schoolYear);
DB::table('discount_vouchers')
->where('id', $voucherId)
->update([
'times_used' => DB::raw('COALESCE(times_used,0) + 1'),
]);
$this->triggerPaymentReceived(
$invoiceId,
(string) $voucherId,
$discount,
$paymentDate,
$initialPreBalance,
$postBalance,
$schoolYear,
$semester
);
$touchedInvoiceIds[] = $invoiceId;
$appliedCount++;
$epsilon = 0.01;
$fullyCovered = (abs($discount - $initialPreBalance) <= $epsilon)
|| ($postBalance <= $epsilon)
|| ($currentBalance <= $epsilon);
if ($fullyCovered) {
$fullyCoveredInvoiceIds[] = $invoiceId;
}
$remainingUses--;
if ($remainingUses <= 0 && $maxUses !== null) {
DB::table('discount_vouchers')
->where('id', $voucherId)
->update([
'is_active' => 0,
'updated_at' => $now,
]);
break 2;
}
}
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
return ['ok' => false, 'message' => 'Voucher application failed. Transaction rolled back.'];
}
if ($appliedCount === 0) {
return [
'ok' => false,
'message' => 'No discounts were applied. The voucher may already be used for these invoices or balances are zero.',
];
}
if ($maxUses !== null) {
$row = (array) DB::table('discount_vouchers')
->select('times_used')
->where('id', $voucherId)
->first();
$usedNow = (int) ($row['times_used'] ?? 0);
if ($usedNow >= $maxUses) {
DB::table('discount_vouchers')
->where('id', $voucherId)
->update([
'is_active' => 0,
'updated_at' => function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString(),
]);
}
}
foreach (array_unique($touchedInvoiceIds) as $iid) {
try {
$this->invoiceService->recalculateInvoice($iid, $schoolYear);
} catch (\Throwable $e) {
Log::error('recalculateInvoice failed for invoice {id}: {err}', [
'id' => $iid,
'err' => $e->getMessage(),
]);
}
}
$updatedTotal = 0;
foreach (array_unique($fullyCoveredInvoiceIds) as $iid) {
try {
$updatedTotal += $this->invoiceService->updateEnrollmentStatusIfPaid($iid, $schoolYear);
} catch (\Throwable $e) {
Log::error('updateEnrollmentStatusIfPaid failed for invoice {id}: {err}', [
'id' => $iid,
'err' => $e->getMessage(),
]);
}
}
return [
'ok' => true,
'message' => $updatedTotal > 0
? 'Voucher applied successfully and enrollments updated.'
: 'Voucher applied successfully.',
'appliedCount' => $appliedCount,
'updatedEnrollments' => $updatedTotal,
'touchedInvoices' => array_values(array_unique($touchedInvoiceIds)),
];
}
private function triggerPaymentReceived(
int $invoiceId,
string $voucherId,
float $amount,
string $paymentDate,
float $preBalance,
float $postBalance,
string $schoolYear,
string $semester
): void {
try {
[$eventData, $studentData] = $this->invoiceService->buildPaymentEventData(
$invoiceId,
$voucherId,
$amount,
'discount',
$paymentDate,
'',
0,
$preBalance,
$postBalance,
$schoolYear,
$semester
);
if (function_exists('event')) {
event('paymentReceived', [$eventData, $studentData]);
}
} catch (\Throwable $e) {
Log::warning('paymentReceived hook failed: '.$e->getMessage());
}
}
}