fix event charge and invoice update

This commit is contained in:
root
2026-04-18 09:54:29 -04:00
parent a448288878
commit dd1d492c30
16 changed files with 1078 additions and 89 deletions
+373 -35
View File
@@ -13,6 +13,7 @@ use App\Models\InvoiceModel;
use App\Models\InvoiceEventModel;
use App\Models\ClassSectionModel;
use App\Models\PaymentModel;
use Config\Database;
#use ???
use App\Controllers\View\InvoiceController;
use CodeIgniter\RESTful\ResourceController;
@@ -34,6 +35,7 @@ class EventController extends ResourceController
protected $semester;
protected $categories;
protected $enrollmentModel;
private ?bool $eventChargesHasCreatedBy = null;
public function __construct()
{
@@ -60,6 +62,22 @@ class EventController extends ResourceController
];
}
private function eventChargesSupportsCreatedBy(): bool
{
if ($this->eventChargesHasCreatedBy !== null) {
return $this->eventChargesHasCreatedBy;
}
try {
$db = Database::connect();
$this->eventChargesHasCreatedBy = $db->fieldExists('created_by', 'event_charges');
} catch (\Throwable $e) {
$this->eventChargesHasCreatedBy = false;
}
return $this->eventChargesHasCreatedBy;
}
public function index()
{
$eventModel = new EventModel();
@@ -216,7 +234,7 @@ class EventController extends ResourceController
$semester = $this->request->getGet('semester') ?? $this->semester;
$parents = $this->userModel->getParents();
$events = $this->eventModel->getActiveEvents($this->schoolYear);
$events = $this->eventModel->getActiveEvents($schoolYear);
$filterEventId = (int) ($this->request->getGet('event_id') ?? 0);
$filterParentId = (int) ($this->request->getGet('parent_id') ?? 0);
@@ -324,13 +342,24 @@ class EventController extends ResourceController
$parentId = $this->request->getPost('parent_id');
$eventId = $this->request->getPost('event_id');
$participations = $this->request->getPost('participation') ?? [];
$externalParticipants = $this->request->getPost('external_participants') ?? [];
if (!$parentId || !$eventId || empty($participations)) {
// Allow "external only" submissions (no enrolled students selected).
if (!$eventId || (empty($participations) && empty($externalParticipants))) {
return redirect()->back()->with('error', 'Missing required information.');
}
if (!empty($participations) && !$parentId) {
return redirect()->back()->with('error', 'Select a parent when adding enrolled students.');
}
$userId = session()->get('user_id');
$event = $this->eventModel->getEvent($eventId, $schoolYear);
if (!$event) {
return redirect()->back()->with('error', 'Selected event not found for the chosen school year.');
}
$parentsForInvoice = [];
$supportsCreatedBy = $this->eventChargesSupportsCreatedBy();
foreach ($participations as $studentId => $value) {
$existing = $this->eventChargesModel->where([
@@ -363,7 +392,7 @@ class EventController extends ResourceController
$this->eventChargesModel->update($existing['id'], $updateData);
continue;
} else {
$this->eventChargesModel->insert([
$insertData = [
'parent_id' => $parentId,
'student_id' => $studentId,
'event_id' => $eventId,
@@ -373,15 +402,113 @@ class EventController extends ResourceController
'class_section_id' => $classSectionId,
'school_year' => $schoolYear,
'semester' => $semester,
'created_by' => $userId,
'updated_by' => $userId
]);
];
if ($supportsCreatedBy) {
$insertData['created_by'] = $userId;
}
$this->eventChargesModel->insert($insertData);
}
}
$this->invoiceController->generateInvoice($parentId);
foreach ($externalParticipants as $pieces) {
$firstname = $this->normalizeExternalName($pieces['firstname'] ?? '');
$lastname = $this->normalizeExternalName($pieces['lastname'] ?? '');
$note = trim($pieces['note'] ?? '');
$parentFirst = $this->normalizeExternalName($pieces['parent_firstname'] ?? '');
$parentLast = $this->normalizeExternalName($pieces['parent_lastname'] ?? '');
$parentPhone = trim($pieces['parent_phone'] ?? '');
$parentEmail = trim((string)($pieces['parent_email'] ?? ''));
$markPaid = (string)($pieces['paid'] ?? '0') === '1';
$this->fixInvoiceStatusAfterCharge($parentId, $schoolYear);
if (!$firstname && !$lastname) {
continue;
}
$matchedParentId = $this->findExistingParentIdByExternalContact(
$parentFirst,
$parentLast,
$parentEmail,
$parentPhone
);
// Match external participants by kid name + event context.
// Parent contact info can change (or be fixed later) and should be updated, not used for matching.
$matchConditions = [
'event_id' => $eventId,
'external_firstname' => $firstname,
'external_lastname' => $lastname,
'school_year' => $schoolYear,
'semester' => $semester,
];
if ($matchedParentId) {
$matchConditions['parent_id'] = $matchedParentId;
} else {
// External-only parent: match by external parent contact to avoid duplicates.
$matchConditions['parent_id'] = null;
$matchConditions['external_parent_firstname'] = $parentFirst ?: null;
$matchConditions['external_parent_lastname'] = $parentLast ?: null;
$matchConditions['external_parent_phone'] = $parentPhone ?: null;
$matchConditions['external_parent_email'] = $parentEmail ?: null;
}
$existingExternal = $this->eventChargesModel->where($matchConditions)->first();
if ($existingExternal) {
$this->eventChargesModel->update($existingExternal['id'], [
'participation' => 'yes',
'charged' => $event['amount'],
'updated_by' => $userId,
// Preserve existing payment status; only update contact fields when provided.
'external_note' => $note !== '' ? $note : ($existingExternal['external_note'] ?? null),
'external_parent_firstname' => $parentFirst !== '' ? $parentFirst : ($existingExternal['external_parent_firstname'] ?? null),
'external_parent_lastname' => $parentLast !== '' ? $parentLast : ($existingExternal['external_parent_lastname'] ?? null),
'external_parent_phone' => $parentPhone !== '' ? $parentPhone : ($existingExternal['external_parent_phone'] ?? null),
'external_parent_email' => $parentEmail !== '' ? $parentEmail : ($existingExternal['external_parent_email'] ?? null),
]);
continue;
}
$insertData = [
'parent_id' => $matchedParentId ?: null,
'student_id' => null,
'event_id' => $eventId,
'participation' => 'yes',
'charged' => $event['amount'],
'event_paid' => $markPaid ? 1 : 0,
'external_firstname' => $firstname,
'external_lastname' => $lastname,
'external_note' => $note,
'external_parent_firstname' => $parentFirst,
'external_parent_lastname' => $parentLast,
'external_parent_phone' => $parentPhone,
'external_parent_email' => $parentEmail,
'school_year' => $schoolYear,
'semester' => $semester,
'updated_by' => $userId,
];
if ($supportsCreatedBy) {
$insertData['created_by'] = $userId;
}
$this->eventChargesModel->insert($insertData);
$newChargeId = (int)$this->eventChargesModel->getInsertID();
if ($markPaid && $newChargeId > 0) {
$this->applyEventPaymentStatus($newChargeId, true);
}
if ($matchedParentId && $this->parentHasEnrollment($matchedParentId, (string)$schoolYear)) {
$parentsForInvoice[$matchedParentId] = true;
}
}
if ($parentId && $this->parentHasEnrollment((int)$parentId, (string)$schoolYear)) {
$parentsForInvoice[(int)$parentId] = true;
}
foreach (array_keys($parentsForInvoice) as $pid) {
$this->invoiceController->generateInvoice((string)$pid, (string)$schoolYear, (string)$semester, false);
$this->fixInvoiceStatusAfterCharge((int)$pid, (string)$schoolYear);
}
return redirect()->back()->with('success', 'Event charges updated successfully.');
}
@@ -403,7 +530,9 @@ class EventController extends ResourceController
}
$this->eventChargesModel->delete($chargeId);
$this->invoiceController->generateInvoice($charge['parent_id']);
if (!empty($charge['parent_id']) && $this->parentHasEnrollment((int)$charge['parent_id'], (string)($charge['school_year'] ?? ''))) {
$this->invoiceController->generateInvoice((string)$charge['parent_id'], (string)($charge['school_year'] ?? $this->schoolYear), (string)($charge['semester'] ?? $this->semester), false);
}
return redirect()->back()->with('success', 'Participation removed, invoices updated.');
}
@@ -414,42 +543,209 @@ class EventController extends ResourceController
return redirect()->back()->with('error', 'Invalid charge.');
}
$charge = $this->eventChargesModel->find($chargeId);
if (!$charge) {
$isPaid = $this->request->getPost('paid') === '1';
$meta = $this->applyEventPaymentStatus((int)$chargeId, $isPaid);
if (!$meta) {
return redirect()->back()->with('error', 'Charge not found.');
}
$isPaid = $this->request->getPost('paid') === '1';
if (!empty($meta['invoice_id'])) {
$this->invoiceController->generateInvoice((string)$meta['parent_id'], (string)$meta['school_year'], (string)($meta['semester'] ?? $this->semester), false);
$this->fixInvoiceStatusAfterCharge((int)$meta['parent_id'], (string)$meta['school_year']);
}
return redirect()->back()->with('success', 'Event payment status updated.');
}
private function parentHasEnrollment(int $parentId, string $schoolYear): bool
{
if ($parentId <= 0 || $schoolYear === '') {
return false;
}
try {
return $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->countAllResults() > 0;
} catch (\Throwable $e) {
return false;
}
}
private function findExistingParentIdByExternalContact(string $first, string $last, string $email, string $phone): ?int
{
$first = trim($first);
$last = trim($last);
$email = trim($email);
$phoneDigits = preg_replace('/\D+/', '', (string)$phone);
if ($first === '' || $last === '' || $email === '' || $phoneDigits === '') {
return null;
}
try {
$db = Database::connect();
$expr = "REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') =";
$row = $this->userModel
->select('id')
->where('LOWER(firstname)', strtolower($first))
->where('LOWER(lastname)', strtolower($last))
->where('LOWER(email)', strtolower($email))
->where($expr, $db->escapeString($phoneDigits), false)
->first();
$id = (int)($row['id'] ?? 0);
return $id > 0 ? $id : null;
} catch (\Throwable $e) {
log_message('error', 'Failed to match external parent to users: ' . $e->getMessage());
return null;
}
}
private function syncInvoicePaymentSummary(int $invoiceId): void
{
if ($invoiceId <= 0) {
return;
}
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) {
return;
}
$db = \Config\Database::connect();
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$paidSum = 0.0;
try {
$qb = $db->table('payments')
->select('COALESCE(SUM(paid_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->where('paid_amount >', 0);
if ($db->fieldExists('status', 'payments')) {
$qb->groupStart()
->whereNotIn('status', $exclude)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($db->fieldExists('is_void', 'payments')) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$row = $qb->get()->getRowArray();
$paidSum = (float)($row['tot'] ?? 0.0);
} catch (\Throwable $e) {
log_message('error', 'Failed to sum payments for invoice ' . $invoiceId . ': ' . $e->getMessage());
}
$discountSum = 0.0;
try {
$row = $db->table('discount_usages')
->select('COALESCE(SUM(discount_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->get()
->getRowArray();
$discountSum = (float)($row['tot'] ?? 0.0);
} catch (\Throwable $e) {
log_message('error', 'Failed to sum discounts for invoice ' . $invoiceId . ': ' . $e->getMessage());
}
$refundSum = 0.0;
try {
$row = $db->table('refunds')
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial', 'Paid'])
->get()
->getRowArray();
$refundSum = (float)($row['tot'] ?? 0.0);
} catch (\Throwable $e) {
log_message('error', 'Failed to sum refunds for invoice ' . $invoiceId . ': ' . $e->getMessage());
}
$total = (float)($invoice['total_amount'] ?? 0.0);
$newBalance = round($total - $discountSum - $refundSum - $paidSum, 2);
$newStatus = ($newBalance <= 0.00001)
? 'Paid'
: (($paidSum > 0) ? 'Partially Paid' : 'Unpaid');
$this->invoiceModel->update($invoiceId, [
'paid_amount' => $paidSum,
'balance' => $newBalance,
'status' => $newStatus,
'updated_at' => utc_now(),
]);
}
private function applyEventPaymentStatus(int $chargeId, bool $isPaid): ?array
{
if ($chargeId <= 0) {
return null;
}
$charge = $this->eventChargesModel->find($chargeId);
if (!$charge) {
return null;
}
$event = $this->eventModel->find($charge['event_id']);
$eventAmount = max(0.0, (float)($event['amount'] ?? 0));
$paymentId = (int)($charge['event_payment_id'] ?? 0);
$parentId = $charge['parent_id'];
$schoolYear = $charge['school_year'] ?? $this->schoolYear;
$parentId = (int)($charge['parent_id'] ?? 0);
$schoolYear = (string)($charge['school_year'] ?? $this->schoolYear);
$semester = (string)($charge['semester'] ?? $this->semester);
if (!empty($eventAmount)) {
$invoice = $this->invoiceModel
if ($parentId > 0 && $eventAmount > 0) {
$hasEnrollment = $this->parentHasEnrollment($parentId, $schoolYear);
$invoiceQuery = $this->invoiceModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->first();
->orderBy('id', 'DESC');
$invoice = $invoiceQuery->first();
if (!$invoice) {
$this->invoiceController->generateInvoice($parentId);
$invoice = $this->invoiceModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->first();
if (!$invoice && $hasEnrollment) {
$this->invoiceController->generateInvoice((string)$parentId, $schoolYear, $semester, false);
$invoice = $invoiceQuery->first();
}
if ($invoice) {
$invoiceId = (int)($invoice['id'] ?? 0);
if ($invoiceId > 0) {
$this->syncInvoicePaymentSummary($invoiceId);
$invoice = $this->invoiceModel->find($invoiceId) ?? $invoice;
}
if ($isPaid && !$paymentId) {
$paymentId = $this->createEventPayment($parentId, (int)$invoice['id'], $eventAmount, $schoolYear, $charge['semester'] ?? $this->semester);
$paymentSchoolYear = (string)($invoice['school_year'] ?? $schoolYear);
$paymentSemester = (string)($invoice['semester'] ?? $semester);
$paymentId = (int)($this->createEventPayment(
$parentId,
(int)$invoice['id'],
$eventAmount,
$paymentSchoolYear,
$paymentSemester,
(float)($invoice['total_amount'] ?? 0.0),
(float)($invoice['paid_amount'] ?? 0.0),
(float)($invoice['balance'] ?? 0.0)
) ?? 0);
} elseif (!$isPaid && $paymentId > 0) {
$this->paymentModel->delete($paymentId);
$paymentId = 0;
}
if (!empty($invoiceId)) {
$this->syncInvoicePaymentSummary($invoiceId);
}
} elseif (!$hasEnrollment) {
// External-only parent: do not create an invoice/payment row; just persist the paid flag on the charge.
$paymentId = 0;
}
}
@@ -459,10 +755,12 @@ class EventController extends ResourceController
'event_payment_id' => $paymentId > 0 ? $paymentId : null,
]);
$this->invoiceController->generateInvoice($parentId);
$this->fixInvoiceStatusAfterCharge($parentId, $schoolYear);
return redirect()->back()->with('success', 'Event payment status updated.');
return [
'parent_id' => $parentId,
'school_year' => $schoolYear,
'semester' => $semester,
'invoice_id' => isset($invoiceId) && $invoiceId > 0 ? $invoiceId : null,
];
}
@@ -506,25 +804,48 @@ class EventController extends ResourceController
return $this->response->setJSON($data);
}
private function createEventPayment(int $parentId, int $invoiceId, float $amount, string $schoolYear, ?string $semester): ?int
private function createEventPayment(
int $parentId,
int $invoiceId,
float $amount,
string $schoolYear,
?string $semester,
float $invoiceTotal = 0.0,
float $invoicePaid = 0.0,
float $invoiceBalance = 0.0
): ?int
{
if ($amount <= 0 || $invoiceId <= 0 || $parentId <= 0) {
return null;
}
$monthSemester = $semester ?: $this->semester;
$newPaid = (float)$invoicePaid + $amount;
$newBalance = (float)$invoiceBalance - $amount;
$paymentStatus = ($newBalance <= 0.00001)
? 'Paid'
: (($newPaid > 0) ? 'Partially Paid' : 'Unpaid');
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$priorCount = $this->paymentModel
->where('invoice_id', $invoiceId)
->where('paid_amount >', 0)
->whereNotIn('status', $exclude)
->countAllResults();
$installmentSeq = $priorCount + 1;
$data = [
'parent_id' => $parentId,
'invoice_id' => $invoiceId,
'total_amount' => $amount,
'total_amount' => $invoiceTotal > 0 ? $invoiceTotal : $amount,
'paid_amount' => $amount,
'balance' => 0.0,
'number_of_installments' => 1,
'payment_method' => 'Cash',
'balance' => $newBalance,
'number_of_installments' => $installmentSeq,
'payment_method' => 'cash',
'payment_date' => utc_now(),
'school_year' => $schoolYear,
'semester' => $monthSemester,
'status' => 'Paid',
'status' => $paymentStatus,
'transaction_id' => $this->paymentModel->generateNewTransactionId(),
'updated_by' => session()->get('user_id'),
];
@@ -537,6 +858,23 @@ class EventController extends ResourceController
return (int)$this->paymentModel->getInsertID();
}
private function normalizeExternalName(?string $value): string
{
$raw = trim((string)$value);
if ($raw === '') {
return '';
}
$parts = preg_split('/\s+/', $raw);
$formatted = array_map(function ($part) {
$part = trim($part);
if ($part === '') {
return '';
}
return mb_strtoupper(mb_substr($part, 0, 1)) . mb_strtolower(mb_substr($part, 1));
}, $parts);
return implode(' ', array_filter($formatted, fn($p) => $p !== ''));
}
private function fixInvoiceStatusAfterCharge($parentId, $schoolYear)
{
if (!$parentId || !$schoolYear) {
+44 -8
View File
@@ -68,17 +68,45 @@ public function financialReport()
}
// === Invoices ===
$invoices = $invoiceModel
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
->join('users', 'users.id = invoices.parent_id')
->findAll();
// Business rule: one active invoice per parent per school year.
// If legacy data has multiple invoices, show only the latest one to avoid reporting older invoices as "Unpaid"
// after new charges/payments update the current invoice.
$db = \Config\Database::connect();
if (!empty($schoolYear)) {
$latestSub = $db->table('invoices')
->select('parent_id, MAX(id) AS max_id')
->where('school_year', $schoolYear)
->groupBy('parent_id')
->getCompiledSelect();
$invBuilder = $db->table('invoices')
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name", false)
->join("($latestSub) latest", 'latest.max_id = invoices.id', 'inner', false)
->join('users', 'users.id = invoices.parent_id', 'left');
if ($hasFrom) {
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom);
}
if ($hasTo) {
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo);
}
$invoices = $invBuilder->get()->getResultArray();
} else {
$invoices = $invoiceModel
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
->join('users', 'users.id = invoices.parent_id')
->findAll();
}
$invoiceIds = array_values(array_unique(array_filter(array_map(static function ($inv) {
$id = (int)($inv['id'] ?? 0);
return $id > 0 ? $id : null;
}, $invoices))));
// Helper to build a fresh, filtered PaymentModel each time (so filters don't get lost between queries)
$buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) {
$pm = new PaymentModel();
if ($schoolYear) {
$pm->where('school_year', $schoolYear);
}
if (!empty($dateFrom)) {
$pm->where('DATE(payment_date) >=', $dateFrom);
}
@@ -114,7 +142,13 @@ public function financialReport()
};
// === Payments aggregated by invoice_id (for the "Paid" column) ===
$payments = $applyPaymentFilters($buildPaymentModel())
$paymentsQuery = $applyPaymentFilters($buildPaymentModel());
if (!empty($invoiceIds)) {
$paymentsQuery->whereIn('invoice_id', $invoiceIds);
} else {
$paymentsQuery->where('invoice_id', -1);
}
$payments = $paymentsQuery
->select('invoice_id, SUM(paid_amount) AS paid_amount')
->where('invoice_id IS NOT NULL')
->groupBy('invoice_id')
@@ -138,6 +172,7 @@ public function financialReport()
SUM(paid_amount) AS amount
")
->where('invoice_id IS NOT NULL')
->whereIn('invoice_id', $invoiceIds ?: [-1])
->groupBy('invoice_id, method')
->findAll();
@@ -161,6 +196,7 @@ public function financialReport()
SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN paid_amount ELSE 0 END) AS total_credit
")
->where('invoice_id IS NOT NULL')
->whereIn('invoice_id', $invoiceIds ?: [-1])
->first() ?? [];
$paymentTotals = [
+111 -27
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,
@@ -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);