merge prod to main

This commit is contained in:
root
2026-05-16 13:44:12 -04:00
parent 663c0cdbda
commit b32fb853f6
901 changed files with 11241 additions and 1340 deletions
+125 -29
View File
@@ -381,18 +381,24 @@ class InvoiceController extends ResourceController
return false;
}
public function generateInvoice(string $parentId = null)
public function generateInvoice(
string $parentId = null,
?string $schoolYearOverride = null,
?string $semesterOverride = null,
bool $recalculateDiscounts = true
)
{
$isAjax = $this->request->isAJAX() || str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json');
if ($parentId == null) {
$parentId = (int)$this->request->getPost('parent_id');
}
$schoolYear = (string) $this->schoolYear;
$schoolYear = (string) ($schoolYearOverride ?: $this->schoolYear);
$semester = (string) ($semesterOverride ?: $this->semester);
// Fetch enrolled + withdrawn students
$enrollments = $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $this->schoolYear)
->where('school_year', $schoolYear)
->findAll();
if (empty($enrollments)) {
@@ -444,21 +450,23 @@ class InvoiceController extends ResourceController
$tuitionFee = $fees['tuition_fee'];
// ✅ Fetch event charges
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $this->schoolYear);
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
$eventchargeTotal = array_sum(array_column($eventsList, 'charged'));
$totalDiscount = $this->recalculateAndUpdateDiscount(
$parentId,
$this->schoolYear,
$tuitionFee,
$enrollments
);
$totalDiscount = 0.0;
if ($recalculateDiscounts) {
$totalDiscount = $this->recalculateAndUpdateDiscount(
$parentId,
$schoolYear,
$tuitionFee,
$enrollments
);
}
$semester = $this->semester;
// ✅ Refunds PAID to the parent for this year (Partial/Paid)
$refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $this->schoolYear);
$refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $schoolYear);
$totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $this->schoolYear);
$totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
$discountedTuition = max(0, $tuitionFee);
$totalAmount = $discountedTuition + $eventchargeTotal;
@@ -469,17 +477,22 @@ class InvoiceController extends ResourceController
- $refundPaid // approved refunds paid to parent
- $totalPaid; // payments received
// Your invoice fetch
$existingInvoices = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear);
// Business rule: single invoice per parent per school year.
// If legacy duplicates exist, prefer the invoice that already has a discount applied,
// otherwise use the latest invoice for the parent/year.
$invoice = $this->selectActiveInvoiceForParentYear((int)$parentId, $schoolYear);
$updated = false;
$updatedIds = [];
if (!empty($existingInvoices)) {
foreach ($existingInvoices as $invoice) {
if (!isset($invoice['id'])) {
continue;
}
if (!empty($invoice) && isset($invoice['id'])) {
$paymentExclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$paymentsHasStatus = false;
$paymentsHasVoid = false;
try {
$paymentsHasStatus = $this->db->fieldExists('status', 'payments');
$paymentsHasVoid = $this->db->fieldExists('is_void', 'payments');
} catch (\Throwable $e) {
}
// Preserve applied additional charges and recalc this invoice only
$extrasSum = 0.0;
@@ -487,7 +500,7 @@ class InvoiceController extends ResourceController
$rows = $this->db->table('additional_charges')
->select('charge_type, amount')
->where('invoice_id', (int)$invoice['id'])
->where('school_year', $this->schoolYear)
->where('school_year', $schoolYear)
->where('status', 'applied')
->get()->getResultArray();
foreach ($rows as $r) {
@@ -520,7 +533,7 @@ class InvoiceController extends ResourceController
$r = $this->db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
->where('invoice_id', (int)$invoice['id'])
->where('school_year', $this->schoolYear)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial','Paid'])
->get()->getRowArray();
$invRefunds = (float)($r['tot'] ?? 0.0);
@@ -528,8 +541,33 @@ class InvoiceController extends ResourceController
log_message('error', 'refund sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
}
// Payments recorded on this invoice (denormalized field kept in sync by PaymentController)
$paidOnInv = (float)($invoice['paid_amount'] ?? 0.0);
// Payments recorded on this invoice (sum payments to avoid stale invoice.paid_amount)
$paidOnInv = 0.0;
try {
$qb = $this->db->table('payments')
->select('COALESCE(SUM(paid_amount),0) AS tot')
->where('invoice_id', (int)$invoice['id'])
->where('paid_amount >', 0);
if ($paymentsHasStatus) {
$qb->groupStart()
->whereNotIn('status', $paymentExclude)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($paymentsHasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$row = $qb->get()->getRowArray();
$paidOnInv = (float)($row['tot'] ?? 0.0);
} catch (\Throwable $e) {
log_message('error', 'payment sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
$paidOnInv = (float)($invoice['paid_amount'] ?? 0.0);
}
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
$newStatus = ($newBalance <= 0.00001)
@@ -547,7 +585,6 @@ class InvoiceController extends ResourceController
$updatedIds[] = (int)$invoice['id'];
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
$updated = true;
}
} else {
// Generate invoice number
$schoolId = $this->userModel->getSchoolIdByUserId($parentId);
@@ -578,7 +615,7 @@ class InvoiceController extends ResourceController
// Initial balance equals the created total; discounts/refunds/payments will adjust later
'balance' => $totalAmount,
'status' => 'Unpaid',
'school_year' => $this->schoolYear,
'school_year' => $schoolYear,
'semester' => $semester,
'issue_date' => $issueUtc,
'due_date' => $dueUtc,
@@ -615,6 +652,47 @@ class InvoiceController extends ResourceController
->with('success', $updated ? 'Invoice updated.' : 'Invoice created.');
}
private function selectActiveInvoiceForParentYear(int $parentId, string $schoolYear): ?array
{
if ($parentId <= 0 || $schoolYear === '') {
return null;
}
// Prefer invoices flagged as having discounts.
$invoice = $this->invoiceModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('has_discount', 1)
->orderBy('id', 'DESC')
->first();
if (!empty($invoice)) {
return $invoice;
}
// Fallback: prefer invoices with discount_usages rows.
try {
$row = $this->db->table('invoices i')
->select('i.*')
->join('discount_usages du', 'du.invoice_id = i.id', 'inner')
->where('i.parent_id', $parentId)
->where('i.school_year', $schoolYear)
->orderBy('i.id', 'DESC')
->get()
->getRowArray();
if (!empty($row)) {
return $row;
}
} catch (\Throwable $e) {
}
// Final fallback: latest invoice for parent/year.
return $this->invoiceModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->first();
}
private function recalculateAndUpdateDiscount(
int $parentId,
string $schoolYear,
@@ -678,7 +756,7 @@ class InvoiceController extends ResourceController
log_message('error', 'additional_charges sum failed for discount recalculation invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
}
$baseTotal = round($tuitionFee + $eventchargeTotal + $extrasSum, 2);
$baseTotal = round($tuitionFee + $extrasSum, 2);
// Recalculate discount
if ($discountUsage['discount_type'] === 'percent') {
@@ -1266,6 +1344,12 @@ class InvoiceController extends ResourceController
}
}
}
if ($studentName === 'N/A') {
$externalName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? ''));
if ($externalName !== '') {
$studentName = $externalName . ' (external)';
}
}
$dt = $toLocal($event['created_at'] ?? null, false);
$amount = (float)($event['charged'] ?? 0.0);
@@ -1612,12 +1696,24 @@ private function getGradeLevel($grade): array
$invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null;
}
$invoiceEventCharges = [];
foreach ($invoices as $invoice) {
$year = $invoice['school_year'] ?? $this->schoolYear;
$sem = $invoice['semester'] ?? $this->semester;
$invoiceEventCharges[(int)$invoice['id']] = $this->chargesModel->getChargesWithEventInfo(
$invoice['parent_id'],
$year,
$sem
);
}
return view('/parent/invoice_payment', [
'invoices' => $invoices,
'schoolYears' => $schoolYears,
'selectedYear' => $selectedYear,
'currentSchoolYear' => $currentSchoolYear,
'dueDate' => $this->dueDate
'dueDate' => $this->dueDate,
'invoiceEventCharges' => $invoiceEventCharges,
]);
}