fix financials
Tests / PHPUnit (push) Failing after 1m21s

This commit is contained in:
root
2026-07-18 22:57:40 -04:00
parent 068e739408
commit a30c1398a1
61 changed files with 10908 additions and 1775 deletions
+89 -12
View File
@@ -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().
*