fix financial issues

This commit is contained in:
root
2026-06-01 02:08:27 -04:00
parent 090cb88573
commit 6444b61416
56 changed files with 4196 additions and 1824 deletions
+13 -225
View File
@@ -2,6 +2,7 @@
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Libraries\InvoiceLedgerService;
use App\Models\DiscountVoucherModel;
use App\Models\DiscountUsageModel;
use App\Models\InvoiceModel;
@@ -28,6 +29,7 @@ class DiscountController extends BaseController
protected $eventChargesModel;
protected $additionalChargeModel;
protected $classSectionModel;
protected $invoiceLedgerService;
public function __construct()
{
@@ -41,6 +43,7 @@ class DiscountController extends BaseController
$this->eventChargesModel = new EventChargesModel();
$this->additionalChargeModel = new AdditionalChargeModel();
$this->classSectionModel = new ClassSectionModel();
$this->invoiceLedgerService = new InvoiceLedgerService();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
@@ -121,6 +124,8 @@ class DiscountController extends BaseController
foreach ($invoices as $invoice) {
if ($remainingUses <= 0) break 2; // out of parentIds loop too
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $invoice['id']]);
// Snapshot current balance BEFORE applying
$initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
if ($initialPreBalance <= 0) {
@@ -201,22 +206,9 @@ class DiscountController extends BaseController
'updated_at' => $now,
]);
// Update invoice balance based on pre-discount snapshot (supports multiple discounts)
$newBalance = max(0.0, round($initialPreBalance - $discount, 2));
$this->db->table('invoices')
->where('id', $invoice['id'])
->update([
'balance' => $newBalance,
'has_discount' => 1,
'updated_at' => $now,
]);
// Compute post-balance based on pre-snapshot (more stable than $invoice['balance'])
$postBalance = round($initialPreBalance - $discount, 2);
if ($postBalance < 0) $postBalance = 0.0;
// (Optional) current balance re-check (in case of concurrent writes)
$currentBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
$ledger = $this->invoiceLedgerService->recalculateInvoice((int) $invoice['id']);
$postBalance = (float) ($ledger['balance'] ?? 0.0);
$currentBalance = $postBalance;
// Increment voucher usage
$this->db->table('discount_vouchers')
@@ -596,53 +588,11 @@ class DiscountController extends BaseController
*/
private function getCurrentInvoiceBalance($invoiceId, $schoolYear)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) return 0.0;
// Payments (exclude void/refund/failed, honor year)
$qb = $this->paymentModel
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear);
$table = $this->paymentModel->table;
$hasStatus = $this->db->fieldExists('status', $table);
$hasVoid = $this->db->fieldExists('is_void', $table);
if ($hasStatus) {
$qb->groupStart()
->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
->orWhere('status IS NULL', null, false)
->groupEnd();
try {
return (float) ($this->invoiceLedgerService->calculateInvoice((int) $invoiceId)['balance'] ?? 0.0);
} catch (\Throwable $e) {
return 0.0;
}
if ($hasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$rowPaid = $qb->first();
$totalPaid = (float)($rowPaid['total_paid'] ?? 0);
// Discounts for this invoice in this year
$rowDisc = $this->db->table('discount_usages du')
->select('COALESCE(SUM(du.discount_amount),0) AS total_disc')
->join('invoices i', 'i.id = du.invoice_id')
->where('du.invoice_id', $invoiceId)
->where('i.school_year', $schoolYear)
->get()->getRowArray();
$totalDisc = (float)($rowDisc['total_disc'] ?? 0);
// Refunds PAID for this invoice in this year
$rowRefund = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->get()->getRowArray();
$totalRefundPaid = (float)($rowRefund['total_refund_paid'] ?? 0);
$total = (float)($invoice['total_amount'] ?? 0);
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
}
/**
@@ -650,169 +600,7 @@ class DiscountController extends BaseController
*/
private function recalculateInvoice($invoiceId, $schoolYear): void
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) return;
$parentId = (int)($invoice['parent_id'] ?? 0);
if ($parentId <= 0) return;
// ---- Tuition (recompute from enrollments) ----
$enrollments = $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->findAll();
$registered = [];
$withdrawn = [];
foreach ($enrollments as $e) {
$row = [
'student_id' => (int)($e['student_id'] ?? 0),
'class_section_id' => $e['class_section_id'] ?? null,
'enrollment_status'=> (string)($e['enrollment_status'] ?? ''),
];
if (in_array($row['enrollment_status'], ['enrolled','payment pending'], true)) {
$registered[] = $row;
} elseif (in_array($row['enrollment_status'], ['withdrawn','refund pending','withdraw under review'], true)) {
$withdrawn[] = $row;
}
}
// Refund window check if after deadline, withdrawn still billed
$refundDeadline = (string)($this->configModel->getConfig('refund_deadline') ?? '');
$refundAllowed = true;
try {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$tz = new \DateTimeZone($tzName);
$today = new \DateTimeImmutable('today', $tz);
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
$refundAllowed = $today <= $deadline;
} catch (\Throwable $e) {
$refundAllowed = true;
}
$tuitionStudents = $registered;
if (!$refundAllowed) {
$tuitionStudents = array_merge($tuitionStudents, $withdrawn);
}
// Grade threshold and fees
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
$firstStudentFee = (float)($this->configModel->getConfig('first_student_fee') ?? 350);
$secondStudentFee = (float)($this->configModel->getConfig('second_student_fee') ?? 200);
$youthFee = (float)($this->configModel->getConfig('youth_fee') ?? 180);
// Normalize grades for tuition students
foreach ($tuitionStudents as &$s) {
$name = null;
if (!empty($s['class_section_id'])) {
$name = $this->classSectionModel->getClassSectionNameBySectionId($s['class_section_id']);
}
$s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A';
}
unset($s);
// Count regular vs youth and compute tuition
$regularCount = 0;
$youthCount = 0;
foreach ($tuitionStudents as $s) {
$lvl = $this->parseGradeLevel($s['grade_name']);
if ($lvl > $gradeFee) $youthCount++; else $regularCount++;
}
$tuitionSubtotal = 0.0;
$tuitionSubtotal += $youthCount * $youthFee;
if ($regularCount >= 2) {
$tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee;
} elseif ($regularCount === 1) {
$tuitionSubtotal += $firstStudentFee;
}
// ---- Event charges (parent-year) ----
$eventSubtotal = 0.0;
try {
$events = $this->eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear) ?? [];
foreach ($events as $ev) { $eventSubtotal += (float)($ev['charged'] ?? 0.0); }
} catch (\Throwable $e) {}
// ---- Additional charges (per-invoice) ----
$additionalSubtotal = 0.0;
try {
$rows = $this->additionalChargeModel
->select('charge_type, amount')
->where('invoice_id', $invoiceId)
->where('status', 'applied')
->findAll();
foreach ($rows as $r) {
$amt = (float)($r['amount'] ?? 0);
$typ = strtolower((string)($r['charge_type'] ?? 'add'));
if ($typ === 'deduct') $amt = -abs($amt); else $amt = abs($amt);
$additionalSubtotal += $amt;
}
} catch (\Throwable $e) {}
$discountableTotal = $tuitionSubtotal + $additionalSubtotal;
$nonDiscountableTotal = $eventSubtotal;
$newTotal = round($discountableTotal + $nonDiscountableTotal, 2);
// ---- Payments / Discounts / Refunds ----
$db = $this->db;
$table = $this->paymentModel->table;
$hasStatus = $db->fieldExists('status', $table);
$hasVoid = $db->fieldExists('is_void', $table);
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$qb = $this->paymentModel
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear);
if ($hasStatus) {
$qb->groupStart()
->whereNotIn('status', $exclude)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($hasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$payments = $qb->findAll();
$totalPaid = 0.0;
foreach ($payments as $p) { $totalPaid += (float)($p['paid_amount'] ?? 0); }
$discRow = $this->db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
->where('invoice_id', $invoiceId)
->get()->getRowArray();
$totalDisc = (float)($discRow['total_disc'] ?? 0);
$refundRow = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
$appliedDiscount = min($totalDisc, $discountableTotal);
$newBalance = max(0.0, $newTotal - $appliedDiscount - $totalPaid - $totalRefundPaid);
$newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid');
$updateData = [
'total_amount' => $newTotal,
'paid_amount' => $totalPaid,
'balance' => $newBalance,
'status' => $newStatus,
'has_discount' => ($totalDisc > 0.0) ? 1 : 0,
];
if ($this->db->fieldExists('discount', $this->invoiceModel->table)) {
$updateData['discount'] = $totalDisc;
}
$this->invoiceModel->update($invoiceId, $updateData);
$this->invoiceLedgerService->recalculateInvoice((int) $invoiceId);
}
/**