configModel = new ConfigurationModel(); $this->voucherModel = new DiscountVoucherModel(); $this->invoiceModel = new InvoiceModel(); $this->userModel = new UserModel(); $this->db = \Config\Database::connect(); $this->paymentModel = new PaymentModel(); $this->enrollmentModel = new EnrollmentModel(); $this->eventChargesModel = new EventChargesModel(); $this->additionalChargeModel = new AdditionalChargeModel(); $this->classSectionModel = new ClassSectionModel(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->semester = $this->configModel->getConfig('semester'); } public function applyVoucher() { if (strtolower($this->request->getMethod()) === 'post') { $voucherId = $this->request->getPost('voucher_id'); $parentIds = $this->request->getPost('parent_ids') ?? []; $allowAdditional = (bool) $this->request->getPost('allow_additional'); if (empty($parentIds)) { return redirect()->back()->with('error', 'Please select at least one parent.'); } $voucher = $this->voucherModel->find($voucherId); if (!$voucher) { return redirect()->back()->with('error', 'Voucher not found.'); } // Normalize description from voucher (optional) $voucherDescription = trim((string) ($voucher['description'] ?? '')); $voucherDescription = preg_replace( '/^Auto-generated on \d{4}-\d{2}-\d{2}\s*[—-]\s*reason:\s*/i', '', $voucherDescription ); $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) { return redirect()->back()->with('error', 'This voucher has reached its maximum allowed uses.'); } // If not allowing additional discounts, filter out parents who already have discounts this school year. if (!$allowAdditional) { $rows = $this->db->table('discount_usages du') ->select('i.parent_id') ->join('invoices i', 'i.id = du.invoice_id') ->whereIn('i.parent_id', $parentIds) ->where('i.school_year', $this->schoolYear) ->groupBy('i.parent_id') ->get() ->getResultArray(); $blockedParentIds = array_map(static fn($r) => (int) $r['parent_id'], $rows); if (!empty($blockedParentIds)) { $parentIds = array_values(array_diff( array_map('intval', $parentIds), $blockedParentIds )); } if (empty($parentIds)) { return redirect()->back()->with('error', 'All selected parents already have discounts. Enable "Allow additional discounts" to apply another.'); } } $paymentDate = utc_now(); // Collect invoice IDs that end up fully covered by the voucher in this run $fullyCoveredInvoiceIds = []; $touchedInvoiceIds = []; $appliedCount = 0; $this->db->transStart(); foreach ($parentIds as $parentId) { // Fetch invoices for this parent & school year $invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear); if (empty($invoices)) { // Do NOT early-return inside a transaction; just continue session()->setFlashdata('error', 'A selected parent has no invoice in record.'); continue; } foreach ($invoices as $invoice) { if ($remainingUses <= 0) break 2; // out of parentIds loop too // Snapshot current balance BEFORE applying $initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear); if ($initialPreBalance <= 0) { log_message( 'error', 'applyVoucher skip: zero balance | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} balance={bal}', [ 'vid' => (int)$voucherId, 'pid' => (int)$parentId, 'iid' => (int)$invoice['id'], 'inum' => (string)($invoice['invoice_number'] ?? ''), 'bal' => $initialPreBalance, ] ); continue; } // Already used this voucher on this invoice? if (!$allowAdditional) { $exists = $this->db->table('discount_usages du') ->join('invoices i', 'du.invoice_id = i.id') ->where('du.voucher_id', $voucherId) ->where('du.invoice_id', $invoice['id']) ->where('i.school_year', $this->schoolYear) ->countAllResults(); if ($exists) { log_message( 'error', 'applyVoucher skip: voucher already used | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum}', [ 'vid' => (int)$voucherId, 'pid' => (int)$parentId, 'iid' => (int)$invoice['id'], 'inum' => (string)($invoice['invoice_number'] ?? ''), ] ); continue; } } // Calculate discount $rawDiscount = ($voucher['discount_type'] === 'percent') ? round(((float)$invoice['total_amount'] * (float)$voucher['discount_value']) / 100, 2) : (float) $voucher['discount_value']; // Cap by CURRENT invoice balance snapshot $discount = min($rawDiscount, $initialPreBalance); // Nothing to do if no discount if ($discount <= 0) { log_message( 'error', 'applyVoucher skip: discount <= 0 | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} raw_discount={raw} balance={bal}', [ 'vid' => (int)$voucherId, 'pid' => (int)$parentId, 'iid' => (int)$invoice['id'], 'inum' => (string)($invoice['invoice_number'] ?? ''), 'raw' => $rawDiscount, 'bal' => $initialPreBalance, ] ); continue; } // Insert discount usage $now = utc_now(); $this->db->table('discount_usages')->insert([ 'voucher_id' => $voucherId, 'invoice_id' => $invoice['id'], 'parent_id' => $parentId, 'discount_amount' => $discount, 'description' => $voucherDescription, 'school_year' => $this->schoolYear, 'updated_by' => session()->get('user_id'), 'used_at' => $now, 'created_at' => $now, '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); // Increment voucher usage $this->db->table('discount_vouchers') ->where('id', $voucherId) ->set('times_used', 'COALESCE(times_used,0) + 1', false) ->update(); // Prepare and trigger payment event [$eventData, $studentData] = $this->buildPaymentEventData( $invoice['id'], $voucherId, $discount, 'discount', $paymentDate, '', 0, $initialPreBalance, // pre-payment snapshot $postBalance // computed post-payment ); Events::trigger('paymentReceived', $eventData, $studentData); $touchedInvoiceIds[] = (int) $invoice['id']; $appliedCount++; // If voucher covered the entire current balance, mark to update enrollments // Use a small epsilon to tolerate cents rounding. $epsilon = 0.01; $fullyCovered = (abs($discount - $initialPreBalance) <= $epsilon) || ($postBalance <= $epsilon) || ($currentBalance <= $epsilon); if ($fullyCovered) { $fullyCoveredInvoiceIds[] = (int) $invoice['id']; } $remainingUses--; // Deactivate if we just hit the cap if ($remainingUses <= 0 && $maxUses !== null) { $this->db->table('discount_vouchers') ->where('id', $voucherId) ->update([ 'is_active' => 0, 'updated_at' => $now, ]); break 2; } } } $this->db->transComplete(); if ($this->db->transStatus() === false) { return redirect()->back()->with('error', 'Voucher application failed. Transaction rolled back.'); } if ($appliedCount === 0) { return redirect()->back()->with('error', 'No discounts were applied. The voucher may already be used for these invoices or balances are zero.'); } // ✅ Ensure voucher deactivates when max_uses reached (handles edge cases) if ($maxUses !== null) { $row = $this->db->table('discount_vouchers') ->select('times_used') ->where('id', $voucherId) ->get() ->getRowArray(); $usedNow = (int) ($row['times_used'] ?? 0); if ($usedNow >= $maxUses) { $this->db->table('discount_vouchers') ->where('id', $voucherId) ->update([ 'is_active' => 0, 'updated_at' => utc_now(), ]); } } // ✅ AFTER COMMIT: recalculate invoice totals/balance/paid/discount/refund foreach (array_unique($touchedInvoiceIds) as $iid) { try { $this->recalculateInvoice($iid, $this->schoolYear); } catch (\Throwable $e) { log_message('error', 'recalculateInvoice failed for invoice {iid}: {err}', [ 'iid' => $iid, 'err' => $e->getMessage(), ]); } } // ✅ AFTER COMMIT: update enrollments for fully-covered invoices // Avoid nested transactions by doing this post-commit. $updatedTotal = 0; foreach (array_unique($fullyCoveredInvoiceIds) as $iid) { try { $updatedTotal += (int) $this->updateEnrollmentStatusIfPaid($iid); } catch (\Throwable $e) { log_message('error', 'updateEnrollmentStatusIfPaid failed for invoice {iid}: {err}', [ 'iid' => $iid, 'err' => $e->getMessage(), ]); } } if ($updatedTotal > 0) { return redirect()->back()->with('success', 'Voucher applied successfully and enrollments updated.'); } return redirect()->back()->with('success', 'Voucher applied successfully.'); } // GET: load page $parents = $this->userModel->getParents(); foreach ($parents as &$parent) { $discount = $this->db->table('discount_usages') ->select('COALESCE(SUM(discount_amount),0) AS total_discount') ->where('parent_id', $parent['id']) ->where('school_year', $this->schoolYear) ->get() ->getRowArray(); $parent['total_discount'] = $discount['total_discount'] ?? 0; $parent['has_discount'] = ($parent['total_discount'] > 0) ? 1 : 0; } unset($parent); return view('discounts/apply_voucher', [ 'vouchers' => $this->voucherModel->where('is_active', 1)->findAll(), 'parents' => $parents, ]); } private function updateEnrollmentStatusIfPaid(int $invoiceId): int { // 1) Fetch invoice $invoice = $this->invoiceModel->find($invoiceId); if (!$invoice) { log_message('warning', 'Invoice not found: {id}', ['id' => $invoiceId]); return 0; } // 2) Payment check (any payment recorded: balance < total) $total = (float) ($invoice['total_amount'] ?? 0); $balance = (float) ($invoice['balance'] ?? 0); if (!($total > 0 && $balance < $total)) { log_message('info', 'No payment yet. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]); return 0; } $parentId = (int) ($invoice['parent_id'] ?? 0); $schoolYear = (string) $this->schoolYear; if ($parentId <= 0 || $schoolYear === '') { log_message('warning', 'updateEnrollmentStatusIfPaid: missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]); return 0; } // 3) Try to limit to enrollments actually present on the invoice (optional) // If invoice_items.enrollment_id doesn't exist, this silently falls back to parent/year scope. $paidEnrollmentIds = []; try { $rows = $this->db->table('invoice_items') ->select('enrollment_id') ->where('invoice_id', $invoiceId) ->where('enrollment_id IS NOT NULL', null, false) ->get()->getResultArray(); foreach ($rows as $r) { $eid = (int) ($r['enrollment_id'] ?? 0); if ($eid > 0) $paidEnrollmentIds[] = $eid; } $paidEnrollmentIds = array_values(array_unique($paidEnrollmentIds)); } catch (\Throwable $e) { // No-op: table/column might not exist in your schema } $db = $this->db; $db->transBegin(); try { // 4) Preview IDs that will be updated (good for debugging “partials”) $previewBuilder = $db->table('enrollments') ->select('id') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->groupStart() // tolerant match for 'payment pending' (case/space/nbsp) ->where('enrollment_status', 'payment pending') ->orWhere('enrollment_status', 'Payment pending') ->orWhere('enrollment_status', 'Payment Pending') ->orWhere('enrollment_status', 'PAYMENT PENDING') ->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false) ->groupEnd(); if (!empty($paidEnrollmentIds)) { $previewBuilder->whereIn('id', $paidEnrollmentIds); } $toUpdateIds = array_map( static fn($r) => (int)$r['id'], $previewBuilder->get()->getResultArray() ); if (empty($toUpdateIds)) { $db->transCommit(); log_message('info', 'No enrollments matched pending status for parent {p}, year {y}, sem {s}.', [ 'p' => $parentId, 'y' => $schoolYear, 's' => 'ALL' ]); return 0; } // 5) Perform update with same filters $builder = $db->table('enrollments'); $builder->set('enrollment_status', 'enrolled') // Use DB date if you prefer: ->set('enrollment_date', 'CURRENT_DATE()', false) ->set('enrollment_date', local_date(utc_now(), 'Y-m-d')) ->whereIn('id', $toUpdateIds); if ($builder->update() === false) { $err = $db->error(); throw new \RuntimeException('Enrollments update failed: ' . ($err['message'] ?? 'unknown DB error')); } $affected = $db->affectedRows(); $db->transCommit(); log_message( 'info', 'Enrollment status -> enrolled for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]', [ 'n' => $affected, 'p' => $parentId, 'y' => $schoolYear, 's' => 'ALL', 'ids' => implode(',', $toUpdateIds), ] ); return $affected; } catch (\Throwable $e) { $db->transRollback(); log_message('error', 'updateEnrollmentStatusIfPaid error: ' . $e->getMessage()); return 0; } } public function listVouchers() { $vouchers = $this->voucherModel->findAll(); return view('discounts/list', ['vouchers' => $vouchers]); } public function createVoucher() { if (strtolower($this->request->getMethod()) === 'post') { // -------- Gather & normalize inputs -------- $rawCode = (string) $this->request->getPost('code'); // Keep A–Z, 0–9 and dashes; uppercase for consistency $code = strtoupper(preg_replace('/[^A-Z0-9\-]/i', '', trim($rawCode))); $discountType = strtolower((string) $this->request->getPost('discount_type')); // 'percent' | 'fixed' $discountValue = (float) ($this->request->getPost('discount_value') ?? 0); $maxUsesRaw = $this->request->getPost('max_uses'); $maxUses = ($maxUsesRaw === '' || $maxUsesRaw === null) ? null : max(0, (int) $maxUsesRaw); $validFrom = trim((string) ($this->request->getPost('valid_from') ?? '')); $validUntil = trim((string) ($this->request->getPost('valid_until') ?? '')); $isActive = $this->request->getPost('is_active') ? 1 : 0; // NEW: Description / Reason $description = trim((string) ($this->request->getPost('description') ?? '')); // -------- Basic validations (before model rules) -------- $errors = []; if ($code === '') { $errors[] = 'Voucher code is required.'; } if (!in_array($discountType, ['percent', 'fixed'], true)) { $errors[] = 'Discount type must be "percent" or "fixed".'; } if ($discountType === 'percent') { if ($discountValue <= 0 || $discountValue > 100) { $errors[] = 'Percentage discount must be between 0 and 100.'; } } else { // fixed if ($discountValue < 0) { $errors[] = 'Fixed discount must be 0 or greater.'; } } // Dates come from as YYYY-MM-DD $validFrom = $validFrom !== '' ? $validFrom : null; $validUntil = $validUntil !== '' ? $validUntil : null; // Ensure date format is plausible $isDate = static function (?string $d): bool { if ($d === null) return true; return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $d); }; if (!$isDate($validFrom)) $errors[] = 'Valid From must be a date (YYYY-MM-DD).'; if (!$isDate($validUntil)) $errors[] = 'Valid Until must be a date (YYYY-MM-DD).'; if ($validFrom && $validUntil && $validFrom > $validUntil) { $errors[] = 'Valid Until must be the same as or after Valid From.'; } // Optional: require a reason when auto-generated codes are used // if ($description === '' && strlen($code) === 10) { // $errors[] = 'Please add a description/reason for this voucher.'; // } if (!empty($errors)) { return redirect()->back()->withInput()->with('error', implode(' ', $errors)); } // -------- Prepare payload for model -------- $data = [ 'code' => $code, 'discount_type' => $discountType, 'discount_value' => $discountValue, 'max_uses' => $maxUses, 'valid_from' => $validFrom, 'valid_until' => $validUntil, 'is_active' => $isActive, 'description' => $description, // <-- NEW ]; if ($this->voucherModel->save($data)) { return redirect()->to('/discounts/list')->with('success', 'Voucher created successfully.'); } // Model-level errors (unique code, etc.) return redirect()->back() ->withInput() ->with('error', 'Failed to save voucher: ' . implode('; ', (array) $this->voucherModel->errors())); } return view('discounts/create'); } public function editVoucher($id) { $voucher = $this->voucherModel->find($id); if (!$voucher) { return redirect()->to('discounts/list')->with('error', 'Voucher not found'); } if (strtolower($this->request->getMethod()) === 'post') { $data = [ 'id' => $id, 'code' => $this->request->getPost('code'), 'discount_type' => $this->request->getPost('discount_type'), 'discount_value' => $this->request->getPost('discount_value'), 'max_uses' => $this->request->getPost('max_uses'), 'valid_from' => $this->request->getPost('valid_from') ?: null, 'valid_until' => $this->request->getPost('valid_until') ?: null, 'is_active' => $this->request->getPost('is_active') ? 1 : 0, ]; $this->voucherModel->save($data); return redirect()->to('discounts/list')->with('success', 'Voucher updated successfully'); } return view('discounts/edit', ['voucher' => $voucher]); } /** * 🔄 Helper: Current invoice balance (school-year scoped) = total - payments - discounts - refundsPaid */ 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(); } 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)); } /** * 🔄 Recalculate invoice totals and status based on all payments for current school year */ 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) {} $newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 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); $newBalance = max(0.0, $newTotal - $totalDisc - $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); } /** * Parse a grade name into an integer level for tuition rules. * Kindergarten -> 1, Youth -> > 9, numeric grades passthrough. */ private function parseGradeLevel($grade): int { if (is_numeric($grade)) return (int)$grade; if (!is_string($grade)) return 999; $g = strtoupper(trim((string)$grade)); $g = preg_replace('/\s+/', ' ', $g); $g = str_replace(['.', '_', '-'], ['', '', ' '], $g); // KG/K/Kindergarten $kg = ['K','KG','K G','KINDER','KINDERGARTEN']; if (in_array($g, $kg, true)) return 1; // Pre-K -> treat below regular $pk = ['PK','P K','PREK','PRE K','PRE KINDER','PREKINDER']; if (in_array($g, $pk, true)) return -1; // Youth variants: Y, YOUTH, Y1, YOUTH2 -> map to 10+ if (preg_match('/^Y(?:OUTH)?\s*(\d+)?$/', $g, $m)) { $n = isset($m[1]) && $m[1] !== '' ? max(1, (int)$m[1]) : 1; $gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9); return $gradeFee + $n; } if (preg_match('/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/', $g, $m)) { return (int)$m[1]; } return 999; } /** * Collect parent/invoice/payment data to trigger handlePaymentReceived(). * * @return array [$data, $studentdata] */ private function buildPaymentEventData( int $invoiceId, string $transactionIdOrRef, float $amount, string $paymentMethod, string $paymentDate, ?string $checkNumber, int $installmentSeq, float $preBalance, // ✅ new param: initial (pre-payment) balance float $postBalance // ✅ new param: computed post-payment balance ): array { $invoice = $this->invoiceModel->find($invoiceId); if (!$invoice) { throw new \RuntimeException("Invoice {$invoiceId} not found"); } $parentRow = $this->db->table('users') ->select('id, firstname, lastname, email') ->where('id', $invoice['parent_id']) ->get() ->getRowArray(); if (!$parentRow) { throw new \RuntimeException("Parent not found for invoice {$invoiceId}"); } // Students (adjust joins to your schema) $studentRows = $this->db->table('students s') ->select('s.id, s.firstname, s.lastname, sc.class_section_id') ->join('student_class sc', 'sc.student_id = s.id', 'left') ->where('s.parent_id', $invoice['parent_id']) ->where('sc.school_year', $this->schoolYear) ->get() ->getResultArray(); $data = [ 'user_id' => (int) $parentRow['id'], 'email' => $parentRow['email'], 'firstname' => $parentRow['firstname'], 'lastname' => $parentRow['lastname'], 'school_year' => $this->schoolYear, 'semester' => $this->semester, 'invoice_id' => $invoiceId, 'transaction_id' => $transactionIdOrRef, 'payment_date' => $paymentDate, 'amount' => $amount, 'method' => $paymentMethod, 'check_number' => $checkNumber, 'installment_seq' => $installmentSeq, 'invoice_total' => (float) $invoice['total_amount'], 'pre_balance' => $preBalance, // ✅ the captured pre-payment balance 'post_balance' => $postBalance, // ✅ computed post-payment balance 'portalLink' => site_url('parent/invoices/' . $invoiceId), ]; return [$data, $studentRows]; } }