295 lines
11 KiB
PHP
295 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 (class_exists(\CodeIgniter\Events\Events::class)) {
|
|
\CodeIgniter\Events\Events::trigger('paymentReceived', $eventData, $studentData);
|
|
} elseif (function_exists('event')) {
|
|
event('paymentReceived', [$eventData, $studentData]);
|
|
}
|
|
} catch (\Throwable $e) {
|
|
Log::warning('paymentReceived hook failed: ' . $e->getMessage());
|
|
}
|
|
}
|
|
}
|