@@ -108,9 +108,41 @@ class DiscountController extends BaseController
|
||||
// Collect invoice IDs that end up fully covered by the voucher in this run
|
||||
$fullyCoveredInvoiceIds = [];
|
||||
$touchedInvoiceIds = [];
|
||||
$pendingEvents = [];
|
||||
$appliedCount = 0;
|
||||
|
||||
$this->db->transStart();
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
$lockedVoucher = $this->db->query('SELECT * FROM discount_vouchers WHERE id = ? FOR UPDATE', [(int)$voucherId])->getRowArray();
|
||||
if (!$lockedVoucher) {
|
||||
throw new \RuntimeException('Voucher not found.');
|
||||
}
|
||||
$today = date('Y-m-d');
|
||||
if ((int)($lockedVoucher['is_active'] ?? 0) !== 1) {
|
||||
throw new \RuntimeException('Voucher is inactive.');
|
||||
}
|
||||
if (!empty($lockedVoucher['valid_from']) && (string)$lockedVoucher['valid_from'] > $today) {
|
||||
throw new \RuntimeException('Voucher is not active yet.');
|
||||
}
|
||||
if (!empty($lockedVoucher['valid_until']) && (string)$lockedVoucher['valid_until'] < $today) {
|
||||
throw new \RuntimeException('Voucher is expired.');
|
||||
}
|
||||
if (!empty($lockedVoucher['school_year']) && (string)$lockedVoucher['school_year'] !== (string)$this->schoolYear) {
|
||||
throw new \RuntimeException('Voucher is not valid for this school year.');
|
||||
}
|
||||
if (!empty($lockedVoucher['semester']) && (string)$lockedVoucher['semester'] !== (string)$this->semester) {
|
||||
throw new \RuntimeException('Voucher is not valid for this semester.');
|
||||
}
|
||||
|
||||
$voucher = $lockedVoucher;
|
||||
$maxUsesRaw = $voucher['max_uses'] ?? null;
|
||||
$maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw;
|
||||
$timesUsed = (int) ($voucher['times_used'] ?? 0);
|
||||
$remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed);
|
||||
if ($remainingUses <= 0) {
|
||||
throw new \RuntimeException('This voucher has reached its maximum allowed uses.');
|
||||
}
|
||||
|
||||
foreach ($parentIds as $parentId) {
|
||||
// Fetch invoices for this parent & school year
|
||||
@@ -125,10 +157,17 @@ class DiscountController extends BaseController
|
||||
if ($remainingUses <= 0) break 2; // out of parentIds loop too
|
||||
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $invoice['id']]);
|
||||
$this->db->query('SELECT id FROM discount_usages WHERE invoice_id = ? FOR UPDATE', [(int)$invoice['id']])->getResultArray();
|
||||
|
||||
// Snapshot current balance BEFORE applying
|
||||
$initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
||||
if ($initialPreBalance <= 0) {
|
||||
$ledgerBefore = $this->invoiceLedgerService->calculateInvoice((int)$invoice['id']);
|
||||
$initialPreBalance = (float)($ledgerBefore['balance'] ?? 0);
|
||||
$eligibleBaseCents = max(
|
||||
0,
|
||||
(int)($ledgerBefore['discount_eligible_base_cents'] ?? 0)
|
||||
- (int)($ledgerBefore['applied_discount_cents'] ?? 0)
|
||||
);
|
||||
if ($eligibleBaseCents <= 0) {
|
||||
log_message(
|
||||
'error',
|
||||
'applyVoucher skip: zero balance | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} balance={bal}',
|
||||
@@ -168,11 +207,14 @@ class DiscountController extends BaseController
|
||||
|
||||
// Calculate discount
|
||||
$rawDiscount = ($voucher['discount_type'] === 'percent')
|
||||
? round(((float)$invoice['total_amount'] * (float)$voucher['discount_value']) / 100, 2)
|
||||
? round(($eligibleBaseCents / 100 * (float)$voucher['discount_value']) / 100, 2)
|
||||
: (float) $voucher['discount_value'];
|
||||
|
||||
// Cap by CURRENT invoice balance snapshot
|
||||
$discount = min($rawDiscount, $initialPreBalance);
|
||||
$requestedDiscountCents = max(0, (int)round($rawDiscount * 100));
|
||||
$appliedDiscountCents = min($requestedDiscountCents, $eligibleBaseCents);
|
||||
$discount = $appliedDiscountCents / 100;
|
||||
|
||||
// Nothing to do if no discount
|
||||
if ($discount <= 0) {
|
||||
@@ -193,7 +235,7 @@ class DiscountController extends BaseController
|
||||
|
||||
// Insert discount usage
|
||||
$now = utc_now();
|
||||
$this->db->table('discount_usages')->insert([
|
||||
$usagePayload = [
|
||||
'voucher_id' => $voucherId,
|
||||
'invoice_id' => $invoice['id'],
|
||||
'parent_id' => $parentId,
|
||||
@@ -204,17 +246,30 @@ class DiscountController extends BaseController
|
||||
'used_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
];
|
||||
if ($this->db->fieldExists('requested_discount_cents', 'discount_usages')) {
|
||||
$usagePayload['requested_discount_cents'] = $requestedDiscountCents;
|
||||
$usagePayload['eligible_base_cents'] = $eligibleBaseCents;
|
||||
$usagePayload['eligible_base_before_cents'] = $eligibleBaseCents;
|
||||
$usagePayload['applied_discount_cents'] = $appliedDiscountCents;
|
||||
$usagePayload['application_order'] = $this->nextDiscountApplicationOrder((int)$invoice['id']);
|
||||
}
|
||||
if (!$this->db->table('discount_usages')->insert($usagePayload)) {
|
||||
throw new \RuntimeException('Discount usage could not be recorded.');
|
||||
}
|
||||
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice((int) $invoice['id']);
|
||||
$postBalance = (float) ($ledger['balance'] ?? 0.0);
|
||||
$currentBalance = $postBalance;
|
||||
|
||||
// Increment voucher usage
|
||||
$this->db->table('discount_vouchers')
|
||||
$updatedVoucher = $this->db->table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->set('times_used', 'COALESCE(times_used,0) + 1', false)
|
||||
->update();
|
||||
if (!$updatedVoucher) {
|
||||
throw new \RuntimeException('Voucher usage could not be updated.');
|
||||
}
|
||||
|
||||
// Prepare and trigger payment event
|
||||
[$eventData, $studentData] = $this->buildPaymentEventData(
|
||||
@@ -228,7 +283,7 @@ class DiscountController extends BaseController
|
||||
$initialPreBalance, // pre-payment snapshot
|
||||
$postBalance // computed post-payment
|
||||
);
|
||||
Events::trigger('paymentReceived', $eventData, $studentData);
|
||||
$pendingEvents[] = [$eventData, $studentData];
|
||||
|
||||
$touchedInvoiceIds[] = (int) $invoice['id'];
|
||||
$appliedCount++;
|
||||
@@ -248,20 +303,27 @@ class DiscountController extends BaseController
|
||||
|
||||
// Deactivate if we just hit the cap
|
||||
if ($remainingUses <= 0 && $maxUses !== null) {
|
||||
$this->db->table('discount_vouchers')
|
||||
$deactivated = $this->db->table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->update([
|
||||
'is_active' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
if (!$deactivated) {
|
||||
throw new \RuntimeException('Voucher could not be deactivated.');
|
||||
}
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if ($this->db->transStatus() === false) {
|
||||
if ($this->db->transStatus() === false) {
|
||||
throw new \RuntimeException('Voucher transaction failed.');
|
||||
}
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'Voucher application failed: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
|
||||
return redirect()->back()->with('error', 'Voucher application failed. Transaction rolled back.');
|
||||
}
|
||||
|
||||
@@ -287,6 +349,10 @@ class DiscountController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pendingEvents as [$eventData, $studentData]) {
|
||||
Events::trigger('paymentReceived', $eventData, $studentData);
|
||||
}
|
||||
|
||||
// ✅ AFTER COMMIT: recalculate invoice totals/balance/paid/discount/refund
|
||||
foreach (array_unique($touchedInvoiceIds) as $iid) {
|
||||
try {
|
||||
@@ -632,6 +698,17 @@ class DiscountController extends BaseController
|
||||
return 999;
|
||||
}
|
||||
|
||||
private function nextDiscountApplicationOrder(int $invoiceId): int
|
||||
{
|
||||
$row = $this->db->table('discount_usages')
|
||||
->select('COALESCE(MAX(application_order),0) + 1 AS next_order', false)
|
||||
->where('invoice_id', $invoiceId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return max(1, (int)($row['next_order'] ?? 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect parent/invoice/payment data to trigger handlePaymentReceived().
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user