teacherClassModel = new TeacherClassModel(); $this->paymentErrorModel = new PaymentErrorModel(); $this->userModel = new UserModel(); $this->eventChargesModel = new EventChargesModel(); $this->manualPaymentModel = new ManualPaymentModel(); $this->paymentModel = new PaymentModel(); $this->paypalConfig = new PaypalConfig(); $this->request = \Config\Services::request(); $this->db = \Config\Database::connect(); $this->invoiceModel = new InvoiceModel(); $this->configModel = new ConfigurationModel(); $this->enrollmentModel = new EnrollmentModel(); $this->studentModel = new StudentModel(); $this->studentClassModel = new StudentClassModel(); $this->schoolYear = $this->configModel->getConfig('school_year'); $this->semester = $this->configModel->getConfig('semester'); //installment_date $this->installmentDate = $this->configModel->getConfig('installment_date'); $this->discountUsageModel = new DiscountUsageModel(); $this->additionalChargeModel = new AdditionalChargeModel(); $this->classSectionModel = new ClassSectionModel(); // Set up PayPal API context $this->apiContext = new ApiContext( new OAuthTokenCredential( $this->paypalConfig->paypalClientId, $this->paypalConfig->paypalSecret ) ); $this->apiContext->setConfig([ 'mode' => $this->paypalConfig->paypalMode, // 'sandbox' or 'live' 'http.headers' => ['Connection' => 'Close'] ]); } // API: Create a new payment plan public function createAPI() { $data = [ 'parent_id' => $this->request->getPost('parent_id'), 'total_amount' => $this->request->getPost('total_amount'), 'number_of_installments' => $this->request->getPost('number_of_installments'), 'paid_amount' => 0, 'balance_amount' => $this->request->getPost('total_amount'), 'payment_date' => $this->request->getPost('payment_date'), 'payment_type' => $this->request->getPost('payment_type'), 'status' => 'Pending', 'semester' => $this->request->getPost('semester'), 'school_year' => $this->request->getPost('school_year'), ]; if ($this->paymentModel->save($data)) { return $this->respondCreated($data); // Send successful response } else { return $this->failValidationErrors($this->paymentModel->errors()); // Return validation errors } } // API: Get payments by parent ID public function getByParentAPI($parentId) { $payments = $this->paymentModel->getPaymentsByParentId($parentId); if ($payments) { return $this->respond($payments); } else { return $this->failNotFound('Payments not found for the parent.'); } } // View: Get payments by parent ID (for web views) public function getByParent($parentId) { // Fetch payments with invoice number (if available) $payments = $this->paymentModel ->select('payments.*, invoices.invoice_number') ->join('invoices', 'invoices.id = payments.invoice_id', 'left') ->where('payments.parent_id', (int)$parentId) ->orderBy('payments.payment_date', 'DESC') ->findAll(); // Parent display name $parent = $this->userModel->select('firstname, lastname')->find((int)$parentId) ?: []; $parentName = trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')); // If requested for modal, return a fragment (no layout) $isModal = (string)($this->request->getGet('modal') ?? '') === '1'; if ($isModal) { return view('payment/_list_modal', [ 'payments' => $payments, 'parentId' => (int)$parentId, 'parentName' => $parentName, ], ['saveData' => true]); } // Full page fallback return view('payment_list', [ 'payments' => $payments, 'parentId' => (int)$parentId, 'parentName' => $parentName, ]); } // View: Create a new payment plan public function create() { return view('payment_create'); } // API: Update balance after payment public function updateBalanceAPI($paymentId) { $amountPaid = $this->request->getPost('paid_amount'); if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) { return $this->respond(['status' => 'success']); } else { return $this->failNotFound('Payment not found.'); } } // View: Update balance after payment public function updateBalance($paymentId) { $amountPaid = $this->request->getPost('paid_amount'); if ($this->paymentModel->updateBalance($paymentId, $amountPaid)) { return redirect()->to('/payments'); } else { return redirect()->back()->with('error', 'Failed to update balance.'); } } // Create a PayPal payment public function createPaypalPayment($paymentId) { // Fetch the payment details from the database $payment = $this->paymentModel->find($paymentId); // Create the payer object (who is making the payment) $payer = new Payer(); $payer->setPaymentMethod('paypal'); // Set up the payment amount $amount = new Amount(); $amount->setCurrency('USD') ->setTotal($payment['balance_amount']); // The balance amount to be paid // Set up the transaction details $transaction = new Transaction(); $transaction->setAmount($amount) ->setDescription('Payment for school fees') ->setInvoiceNumber(uniqid()); // Create the payment and set the redirect URLs $payment = new Payment(); $payment->setIntent('sale') ->setPayer($payer) ->setTransactions([$transaction]); // Set the approval URL for the payment $redirectUrls = new \PayPal\Api\RedirectUrls(); $redirectUrls->setReturnUrl(base_url('payments/executePaypalPayment')) // Set this to where the user will be redirected after approval ->setCancelUrl(base_url('payments/cancelPaypalPayment')); $payment->setRedirectUrls($redirectUrls); // Create the payment and get the approval URL try { $payment->create($this->apiContext); // Store the payment ID in the session to retrieve it later session()->set('paypalPaymentId', $payment->getId()); session()->set('paymentId', $paymentId); $approvalUrl = $payment->getApprovalLink(); return redirect()->to($approvalUrl); // Redirect the user to PayPal's approval page } catch (\PayPal\Exception\PayPalConnectionException $ex) { // Handle errors echo $ex->getData(); exit; } } // Execute the PayPal payment after user approval public function executePaypalPayment() { // Get the payment ID and Payer ID from the request $paymentId = session()->get('paypalPaymentId'); $payerId = $this->request->getGet('PayerID'); // Get the payment object using the payment ID $payment = Payment::get($paymentId, $this->apiContext); // Create an execution object to execute the payment $execution = new PaymentExecution(); $execution->setPayerId($payerId); // Execute the payment try { $result = $payment->execute($execution, $this->apiContext); // Payment successful, update the payment status in the database $paymentId = session()->get('paymentId'); $this->paymentModel->update($paymentId, ['status' => 'Completed']); return redirect()->to('/payments'); // Redirect to the payments page after success } catch (\PayPal\Exception\PayPalConnectionException $ex) { // Handle payment execution failure echo $ex->getData(); exit; } } // Cancel the PayPal payment public function cancelPaypalPayment() { // Payment was canceled by the user return redirect()->to('/payments')->with('error', 'Payment was canceled.'); } public function redirectPage() { $modeCheck = $this->configModel->getConfig('paypal_mode'); $parentId = session()->get('user_id'); // assuming this is the logged-in parent // Get parent name and school ID $parent = $this->db->table('users') ->select('firstname, lastname, school_id') ->where('id', $parentId) ->get() ->getRowArray(); $parentName = isset($parent['firstname'], $parent['lastname']) ? $parent['firstname'] . ' ' . $parent['lastname'] : 'Parent'; $schoolId = $parent['school_id'] ?? null; // Get latest invoice total_amount $latestInvoice = $this->invoiceModel->where('parent_id', $parentId) ->orderBy('created_at', 'DESC') ->select('total_amount') ->get() ->getRowArray(); $totalAmount = $latestInvoice['total_amount'] ?? 0; return view('payment/payment_redirect', [ 'parentName' => $parentName, 'totalAmount' => $totalAmount, 'schoolId' => $schoolId, 'modeCheck' => $modeCheck, ]); } public function paypal() { // Redirect to PayPal API or show instructions return redirect()->to('https://www.paypal.com/ncp/payment/87FJL3EV8C7NE'); } public function manual() { if (strtolower($this->request->getMethod()) === 'post') { $data = [ 'invoice_number' => $this->request->getPost('invoice_number'), 'amount' => $this->request->getPost('amount'), 'payment_method' => $this->request->getPost('payment_method'), 'reference' => $this->request->getPost('reference'), 'created_at' => utc_now(), ]; // Handle file upload if present $proof = $this->request->getFile('proof'); if ($proof && $proof->isValid() && !$proof->hasMoved()) { $filename = $proof->getRandomName(); $proof->move(WRITEPATH . 'uploads/payments/', $filename); $data['proof_path'] = $filename; } $this->manualPaymentModel->insert($data); return redirect()->back()->with('success', 'Payment info submitted successfully!'); } return view('payment/manual_payment'); } public function eventChargesShow() { $parents = $this->userModel->getParents(); $school_Year = $this->request->getGet('school_year') ?? $this->schoolYear; $seme_ster = $this->request->getGet('semester') ?? $this->semester; // Join with users and students for full info $charges = $this->eventChargesModel ->select('event_charges.*, users.firstname AS parent_firstname, users.lastname AS parent_lastname, students.firstname AS student_firstname, students.lastname AS student_lastname') ->join('users', 'users.id = event_charges.parent_id', 'left') ->join('students', 'students.id = event_charges.student_id', 'left') ->where('event_charges.school_year', $school_Year) ->where('event_charges.semester', $seme_ster) ->orderBy('event_charges.created_at', 'DESC') ->findAll(); return view('payment/event_charges', [ 'charges' => $charges, 'parents' => $parents, 'school_year' => $school_Year, 'semester' => $seme_ster, ]); } public function eventChargesUpdate() { $studentIds = $this->request->getPost('student_ids') ?? []; $userId = session()->get('user_id'); $commonData = [ 'parent_id' => $this->request->getPost('parent_id'), 'event_name' => $this->request->getPost('event_name'), 'description' => $this->request->getPost('description'), 'amount' => $this->request->getPost('amount'), 'semester' => $this->request->getPost('semester'), 'school_year' => $this->request->getPost('school_year'), 'charged' => 0, 'updated_by' => $userId, ]; $success = false; if (!empty($studentIds)) { foreach ($studentIds as $studentId) { $data = array_merge($commonData, ['student_id' => $studentId]); $this->eventChargesModel->insert($data); $success = true; } } elseif ($this->request->getPost('student_id')) { $data = array_merge($commonData, ['student_id' => $this->request->getPost('student_id')]); $this->eventChargesModel->insert($data); $success = true; } if (!$success) { return redirect()->back()->with('error', 'No student selected. Please select at least one.'); } return redirect()->back()->with('success', 'Event charge(s) added successfully.'); } public function getEnrolledStudents($parentId) { $enrollments = $this->enrollmentModel->getEnrolledStudents((int) $parentId, $this->schoolYear); $students = []; foreach ($enrollments as $row) { $students[] = [ 'id' => $row['id'], 'firstname' => $row['firstname'], 'lastname' => $row['lastname'], 'grade' => $this->studentClassModel->getStudentGrade($row['id']) ?? 'N/A', ]; } return $this->response->setJSON($students); } public function manualPaySearch() { helper('form'); $parentData = null; $students = []; $payments = []; $invoices = []; $pager = null; // Read the search term (email or phone) $searchTerm = trim((string) $this->request->getGet('search_term')); // --- Installment end date comes ONLY from config --- $installmentDateRaw = (string) ($this->installmentDate ?? ''); $installmentEndYmd = ''; if ($installmentDateRaw) { $ts = strtotime($installmentDateRaw); if ($ts !== false) { $installmentEndYmd = date('Y-m-d', $ts); // ensure 'YYYY-MM-DD' } } if ($searchTerm !== '') { $searchClean = preg_replace('/\D/', '', $searchTerm); $searchFormatted = $this->formatPhone($searchClean); $builder = $this->db->table('users'); $builder->select('*'); // Build search conditions $builder->groupStart(); $builder->where('email', $searchTerm); if (!empty($searchClean)) { if (strlen($searchClean) === 10) { $formattedPhone = substr($searchClean, 0, 3) . '-' . substr($searchClean, 3, 3) . '-' . substr($searchClean, 6, 4); $builder->orWhere('cellphone', $formattedPhone); } // compare stripped formatting $builder->orWhere( "REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') =", $this->db->escapeString($searchClean), false ); } if (!empty($searchFormatted)) { $builder->orWhere('cellphone', $searchFormatted); } $builder->groupEnd(); // Execute query $parent = $builder->get()->getRowArray(); if (is_array($parent) && array_key_exists('password', $parent)) { unset($parent['password']); } if ($parent && !empty($parent['id'])) { $parentData = $parent; $parentId = (int) $parent['id']; // Students $students = $this->studentModel ->where('parent_id', $parentId) ->findAll(); // Payments (paginated) $payments = $this->paymentModel ->where('parent_id', $parentId) ->orderBy('payment_date', 'DESC') ->paginate(10); $pager = $this->paymentModel->pager; // Invoices $rawInvoices = $this->invoiceModel ->where('parent_id', $parentId) // <- fixed (removed "value:") ->orderBy('issue_date', 'DESC') ->findAll(); // Preload sums of actual payments/discounts/refunds per invoice to avoid stale invoice.paid_amount/balance $paidByInvoice = []; $discountByInvoice = []; $refundByInvoice = []; if (!empty($rawInvoices)) { $invoiceIds = array_values(array_unique(array_map(static fn($r) => (int)($r['id'] ?? 0), $rawInvoices))); if (!empty($invoiceIds)) { $rows = $this->paymentModel ->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid') ->whereIn('invoice_id', $invoiceIds) ->groupBy('invoice_id') ->get()->getResultArray(); foreach ($rows as $r) { $iid = (int)($r['invoice_id'] ?? 0); $paidByInvoice[$iid] = (float)($r['total_paid'] ?? 0); } // Sum discounts per invoice_id $discRows = $this->db->table('discount_usages') ->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc') ->whereIn('invoice_id', $invoiceIds) ->groupBy('invoice_id') ->get()->getResultArray(); foreach ($discRows as $dr) { $iid = (int)($dr['invoice_id'] ?? 0); $discountByInvoice[$iid] = (float)($dr['total_disc'] ?? 0); } // Sum PAID refunds per invoice_id (only Partial/Paid reduce liability) $refRows = $this->db->table('refunds') ->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid') ->whereIn('invoice_id', $invoiceIds) ->whereIn('status', ['Partial','Paid']) ->groupBy('invoice_id') ->get()->getResultArray(); foreach ($refRows as $rr) { $iid = (int)($rr['invoice_id'] ?? 0); $refundByInvoice[$iid] = (float)($rr['total_refund_paid'] ?? 0); } } } // Normalize invoices for the view/JS; force due_ymd to the CONFIG date $invoices = array_map(function (array $inv) use ($installmentEndYmd, $paidByInvoice, $discountByInvoice, $refundByInvoice) { // Cast numeric fields we rely on $inv['total_amount'] = isset($inv['total_amount']) ? (float) $inv['total_amount'] : 0.0; // Prefer actual sum of payments for paid_amount $iid = (int)($inv['id'] ?? 0); $actualPaid = isset($paidByInvoice[$iid]) ? (float)$paidByInvoice[$iid] : null; $inv['paid_amount'] = is_numeric($actualPaid) ? (float)$actualPaid : (isset($inv['paid_amount']) ? (float)$inv['paid_amount'] : 0.0); // Per-invoice discount $inv['discount'] = isset($discountByInvoice[$iid]) ? (float)$discountByInvoice[$iid] : (float)($inv['discount'] ?? 0.0); // Per-invoice refunds (paid out) $inv['refund_paid'] = isset($refundByInvoice[$iid]) ? (float)$refundByInvoice[$iid] : 0.0; // Derive balance from total - paid - discount - refundsPaid (never below 0) $inv['balance'] = max(0.0, (float)$inv['total_amount'] - (float)$inv['paid_amount'] - (float)$inv['discount'] - (float)$inv['refund_paid']); // Always use the configured end date for installments $inv['due_ymd'] = $installmentEndYmd; // Optional: keep a start marker if you ever need it elsewhere $issueYmd = ''; foreach (['issue_date', 'start_date', 'created_at'] as $k) { if (!empty($inv[$k])) { $its = strtotime($inv[$k]); if ($its !== false) { $issueYmd = date('Y-m-d', $its); break; } } } $inv['issue_ymd'] = $issueYmd; return $inv; }, $rawInvoices); } } return view('payment/manual_pay', [ 'parent' => $parentData, 'students' => $students, 'payments' => $payments, 'invoices' => $invoices, 'pager' => $pager, 'searchTermUsedInSearch' => $searchTerm, 'todayYmd' => utc_now(), 'installmentEndYmd' => $installmentEndYmd, // SELECT will use this in data-end-date ]); } public function manualPaySuggest() { $q = trim((string)($this->request->getGet('q') ?? '')); if ($q === '') { return $this->response->setJSON(['items' => []]); } $db = $this->db; $qs = '%' . $db->escapeLikeString($q) . '%'; $digits = preg_replace('/\D/', '', $q); $sql = "SELECT id, firstname, lastname, email, cellphone FROM users WHERE ( CONCAT_WS(' ', firstname, lastname) LIKE ? OR email LIKE ? OR cellphone LIKE ?"; $params = [$qs, $qs, $qs]; if ($digits !== '') { $sql .= " OR REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') LIKE ?"; $params[] = '%' . $digits . '%'; } $sql .= ") ORDER BY lastname, firstname LIMIT 20"; $rows = $db->query($sql, $params)->getResultArray(); $items = []; foreach ($rows as $row) { $label = trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? '')); $email = trim((string)($row['email'] ?? '')); $phone = trim((string)($row['cellphone'] ?? '')); $sub = trim($email . ' ' . $phone); $value = $email !== '' ? $email : ($phone !== '' ? $phone : $label); $items[] = [ 'id' => (int)($row['id'] ?? 0), 'label' => $label, 'sub' => $sub, 'value' => $value, ]; } return $this->response->setJSON(['items' => $items]); } 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: recompute current balance from payments (authoritative) $total = (float) ($invoice['total_amount'] ?? 0); $currentBal = $this->getCurrentInvoiceBalance($invoiceId); // <-- uses the safe helper if (!($total > 0 && $currentBal < $total)) { log_message('info', 'No payment yet (or still full balance). Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]); return 0; } $parentId = (int) ($invoice['parent_id'] ?? 0); $schoolYear = (string) $this->schoolYear; $semester = isset($this->semester) && $this->semester !== '' ? (string)$this->semester : null; 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) $paidEnrollmentIds = []; try { $hasInvoiceItems = false; try { $hasInvoiceItems = (bool) $this->db->query("SHOW TABLES LIKE 'invoice_items'")->getNumRows(); } catch (\Throwable $e) { $hasInvoiceItems = false; } if ($hasInvoiceItems) { $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) { // proceed without narrowing $paidEnrollmentIds = []; } // 4) Preview IDs that will be updated (good for debugging “partials”) $previewBuilder = $this->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 ($semester !== null) { $previewBuilder->where('semester', $semester); } if (!empty($paidEnrollmentIds)) { $previewBuilder->whereIn('id', $paidEnrollmentIds); } $toUpdateIds = array_map( static fn($r) => (int)$r['id'], $previewBuilder->get()->getResultArray() ); if (empty($toUpdateIds)) { log_message('info', 'No enrollments matched pending status for parent {p}, year {y}, sem {s}.', [ 'p' => $parentId, 'y' => $schoolYear, 's' => $semester ?? 'ALL' ]); return 0; } // 5) Perform update only if we have rows to change $db = $this->db; $db->transBegin(); try { $builder = $db->table('enrollments'); $builder->set('enrollment_status', 'enrolled') ->set('enrollment_date', local_date(utc_now(), 'Y-m-d')) // or 'CURRENT_DATE()' with false flag ->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' => $semester ?? 'ALL', 'ids' => implode(',', $toUpdateIds), ] ); return $affected; } catch (\Throwable $e) { $db->transRollback(); log_message('error', 'updateEnrollmentStatusIfPaid error: ' . $e->getMessage()); return 0; } } private function buildStudentEnrolledEventData( int $parentId, array $studentIds, ?string $schoolYear = null, ?string $semester = null, ?string $portalLink = null ): array { $schoolYear = $schoolYear ?? $this->schoolYear; $semester = $semester ?? $this->semester; $portalLink = $portalLink ?? site_url('/login'); // --- Parent/User info --- $parent = $this->db->table('users') ->select('id, firstname, lastname, email') ->where('id', $parentId) ->get()->getRowArray(); if (!$parent) { throw new \RuntimeException("Parent user #{$parentId} not found"); } // Normalize student ids and guard against empty arrays $ids = array_values(array_unique(array_map('intval', $studentIds))); if (empty($ids)) { // If none provided, fetch all of this parent's students for the current term (optional behavior) $ids = array_map( fn($r) => (int)$r['id'], $this->db->table('students') ->select('id') ->where('parent_id', $parentId) ->get()->getResultArray() ); } // If still empty, return minimal payload if (empty($ids)) { $data = [ 'user_id' => (int) $parent['id'], 'email' => $parent['email'] ?? null, 'firstname' => $parent['firstname'] ?? '', 'lastname' => $parent['lastname'] ?? '', 'school_year' => $schoolYear, ]; return [$data, []]; } // --- One-shot enrichment query --- // NOTE on classSection PK: // If your classSection PK is `class_section_id` (common in your code), keep the join below. // If it’s `id`, change the ON to `cs.id = sc.class_section_id`. $qb = $this->db->table('students s'); $qb->select(" s.id AS student_id, s.firstname AS s_firstname, s.lastname AS s_lastname, s.registration_grade, sc.class_section_id, cs.class_section_name, tu.firstname AS t_firstname, tu.lastname AS t_lastname "); // current term assignment (left join so unassigned students still appear) $qb->join( 'student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $this->db->escape($schoolYear), 'left' ); // section name $qb->join( 'classSection cs', 'cs.class_section_id = sc.class_section_id', 'left' ); // main teacher for that section in the same term $qb->join( 'teacher_class tc', 'tc.class_section_id = sc.class_section_id AND tc.position = "main" AND tc.school_year = ' . $this->db->escape($schoolYear), 'left' ); // teacher user for name $qb->join('users tu', 'tu.id = tc.teacher_id', 'left'); $qb->where('s.parent_id', $parentId) ->whereIn('s.id', $ids); // If a student can have multiple sc rows for the same term, you may need a strategy to pick one. // The simplest is to prefer the most recently updated: $qb->orderBy('sc.updated_at', 'DESC'); $rows = $qb->get()->getResultArray(); // Fallback minimal data if nothing came back (should be rare) if (!$rows) { $rows = $this->db->table('students s') ->select('s.id AS student_id, s.firstname AS s_firstname, s.lastname AS s_lastname, s.registration_grade') ->whereIn('s.id', $ids) ->where('s.parent_id', $parentId) ->get()->getResultArray(); } // Build normalized student array $studentdata = []; $seen = []; // to avoid duplicates if joins produce multiple rows per student foreach ($rows as $r) { $sid = (int) ($r['student_id'] ?? 0); if ($sid <= 0 || isset($seen[$sid])) continue; $seen[$sid] = true; $teacherFull = trim( trim((string)($r['t_firstname'] ?? '')) . ' ' . trim((string)($r['t_lastname'] ?? '')) ); if ($teacherFull === '') { $teacherFull = null; // keep null if no teacher yet } $studentdata[] = [ 'id' => $sid, 'student_id' => $sid, 'firstname' => (string)($r['s_firstname'] ?? ''), 'lastname' => (string)($r['s_lastname'] ?? ''), 'name' => trim(((string)($r['s_firstname'] ?? '')) . ' ' . ((string)($r['s_lastname'] ?? ''))), 'registration_grade' => (string)($r['registration_grade'] ?? ''), // what they registered for 'class_section_id' => isset($r['class_section_id']) ? (int)$r['class_section_id'] : null, 'class_section_name' => $r['class_section_name'] ?? null, // actual placed section (if any) 'teacher_name' => $teacherFull, // main teacher for placed section/term 'start_date' => '', // fill if you have it elsewhere 'portal_link' => $portalLink, ]; } // Top-level envelope consumed by handleStudentEnrolled() $data = [ 'user_id' => (int) $parent['id'], 'email' => $parent['email'] ?? null, 'firstname' => $parent['firstname'] ?? '', 'lastname' => $parent['lastname'] ?? '', 'school_year' => $schoolYear, 'semester' => $semester, 'portal_link' => $portalLink, ]; return [$data, $studentdata]; } private function buildPaymentEventData( int $invoiceId, string $transactionId, float $amount, string $paymentMethod, string $paymentDate, ?string $checkNumber, int $installmentSeq, float $preBalance, float $postBalance, ?float $invoiceDiscount = null, ?float $parentYearDiscountTotal = null ): array { $invoice = $this->invoiceModel->find($invoiceId) ?: []; $parentId = (int) ($invoice['parent_id'] ?? 0); $parent = $parentId ? ($this->db->table('users')->select('id, firstname, lastname, email')->where('id', $parentId)->get()->getRowArray() ?: []) : []; $eventData = [ 'invoice_id' => $invoiceId, 'invoice_number' => $invoice['invoice_number'] ?? null, // both keys, to satisfy any template 'transaction_id' => $transactionId, 'transactionId' => $transactionId, // payment 'amount' => (float) $amount, 'payment_method' => $paymentMethod, 'method' => $paymentMethod, 'payment_date' => $paymentDate, 'check_number' => $checkNumber ?: null, 'installment_seq' => (int) $installmentSeq, // balances 'pre_balance' => (float) $preBalance, 'post_balance' => (float) $postBalance, // totals 'invoice_total' => (float) ($invoice['total_amount'] ?? 0), // parent 'user_id' => (int) ($parent['id'] ?? $parentId), 'email' => $parent['email'] ?? null, 'firstname' => $parent['firstname'] ?? null, 'lastname' => $parent['lastname'] ?? null, 'school_year' => $this->schoolYear, 'semester' => $this->semester, ]; if ($invoiceDiscount !== null) { $eventData['invoice_discount'] = (float) $invoiceDiscount; } if ($parentYearDiscountTotal !== null) { $eventData['parent_year_discount_total'] = (float) $parentYearDiscountTotal; } // lightweight student list $students = []; if ($parentId) { $studentRows = $this->db->table('students') ->select('id, firstname, lastname') ->where('parent_id', $parentId) ->orderBy('lastname', 'ASC') ->get()->getResultArray(); foreach ($studentRows as $r) { $students[] = [ 'id' => (int) $r['id'], 'firstname' => (string) ($r['firstname'] ?? ''), 'lastname' => (string) ($r['lastname'] ?? ''), 'name' => trim(($r['firstname'] ?? '') . ' ' . ($r['lastname'] ?? '')), ]; } } return [$eventData, $students]; } public function manualPayUpdate() { $invoiceId = (int) $this->request->getPost('invoice_id'); $amount = (float) $this->request->getPost('amount'); $paymentMethod = strtolower(trim((string) $this->request->getPost('payment_method'))); // cash|check|card $checkNumber = trim((string) $this->request->getPost('check_number')); $searchTerm = $this->request->getPost('search_term') ?? $this->request->getGet('search_term') ?? ''; // UI-only $paymentType = strtolower((string) ($this->request->getPost('payment_type') ?? 'full')); // full|installment // Accept user-entered date or now() $paymentDateStr = $this->request->getPost('payment_date'); $paymentDate = $paymentDateStr ? date('Y-m-d H:i:s', strtotime($paymentDateStr)) : utc_now(); // Basic validation if (!$invoiceId || !$amount || !$paymentMethod) { return redirect()->back()->withInput()->with('error', 'Missing required fields (invoice, amount, or method).'); } $allowedMethods = ['cash', 'check', 'card']; if (!in_array($paymentMethod, $allowedMethods, true)) { return redirect()->back()->withInput()->with('error', 'Invalid payment method.'); } if ($paymentMethod === 'check' && $checkNumber === '') { return redirect()->back()->withInput()->with('error', 'Check number is required for check payments.'); } if ($amount <= 0) { return redirect()->back()->withInput()->with('error', 'Invalid amount: ' . number_format($amount, 2)); } $invoice = $this->invoiceModel->find($invoiceId); if (!$invoice) { return redirect()->back()->with('error', 'Invoice not found.'); } // Snapshot pre-payment balance $initialPreBalance = $this->getCurrentInvoiceBalance($invoiceId); // Card must equal full balance if ($paymentMethod === 'card' && (float)round($amount, 2) !== (float)round($initialPreBalance, 2)) { return redirect()->back()->withInput()->with( 'error', 'Debit/Credit Card payments must equal the full remaining balance (' . number_format($initialPreBalance, 2) . ').' ); } // Optional receipt upload $checkFile = null; $paymentFile = $this->request->getFile('payment_file'); if ($paymentFile && $paymentFile->isValid() && !$paymentFile->hasMoved()) { $okTypes = ['image/jpeg', 'image/png', 'application/pdf']; $okExts = ['jpg', 'jpeg', 'png', 'pdf']; $mime = $paymentFile->getMimeType(); $ext = strtolower($paymentFile->getClientExtension()); if (!in_array($mime, $okTypes, true) || !in_array($ext, $okExts, true)) { return redirect()->back()->withInput()->with('error', 'Unsupported file type. Use JPG, PNG, or PDF.'); } if ($paymentFile->getSize() > 5 * 1024 * 1024) { return redirect()->back()->withInput()->with('error', 'File too large. Max 5MB.'); } $fileName = $paymentFile->getRandomName(); $subdir = ($paymentMethod === 'check') ? 'checks' : (($paymentMethod === 'card') ? 'cards' : 'misc'); $targetDir = WRITEPATH . 'uploads/' . $subdir . '/'; if (!is_dir($targetDir)) @mkdir($targetDir, 0775, true); $paymentFile->move($targetDir, $fileName); $checkFile = $fileName; } $this->db->transBegin(); try { // Lock invoice & get context (also ensure totals are up-to-date before validation) $row = $this->db->query( 'SELECT id, parent_id, total_amount, school_year FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId] )->getRowArray(); if (!$row) { $this->db->transRollback(); return redirect()->back()->with('error', 'Invoice not found.'); } $parentId = (int)($row['parent_id'] ?? 0); $invYear = (string)($row['school_year'] ?? $this->schoolYear); // Recompute invoice totals from tuition + events + additional charges $this->recalculateInvoice($invoiceId, $invYear); // Authoritative balance $currentBalance = $this->getCurrentInvoiceBalance($invoiceId); if ($amount > $currentBalance + 0.00001) { $this->db->transRollback(); return redirect()->back()->withInput()->with( 'error', 'Entered amount (' . number_format($amount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').' ); } if ($paymentMethod === 'card' && (float)round($amount, 2) !== (float)round($currentBalance, 2)) { $this->db->transRollback(); return redirect()->back()->withInput()->with( 'error', 'Debit/Credit Card payments must equal the full remaining balance (' . number_format($currentBalance, 2) . ').' ); } // Installment sequence $paidCount = $this->getSuccessfulPaymentCount($invoiceId); $installmentSeq = $paidCount + 1; // Generate a stable transaction id $transactionId = 'INV-' . $invoiceId . '-' . str_replace('.', '', (string)microtime(true)); // Record payment $ok = $this->processPayment( $invoiceId, $amount, $paymentMethod, $checkFile, $transactionId, $paymentDate, $this->schoolYear, $this->semester, $checkNumber, $installmentSeq ); if (!$ok) { $this->db->transRollback(); return redirect()->back()->with('error', 'Failed to record payment.'); } // Ensure invoice totals/balance reflect discounts and this payment $this->recalculateInvoice($invoiceId, $this->schoolYear); // Post-payment balance from snapshot $postBalance = (float)round($initialPreBalance - $amount, 2); if ($postBalance < 0) $postBalance = 0.0; // Optional enrollment update $enrollmentupdated = $this->updateEnrollmentStatusIfPaid($invoiceId); if ($enrollmentupdated != 0) { $studentIds = $this->studentModel->getStudentIdsByParentId($parentId); [$eventDataEnroll, $studentDataEnroll] = $this->buildStudentEnrolledEventData($parentId, $studentIds); Events::trigger('studentEnrolled', $eventDataEnroll, $studentDataEnroll); } // --- Discounts (keep payment and discount separate!) // Per-invoice discount: sum by invoice_id ONLY (no year filter so we never miss it) $invoiceDiscount = 0.0; $rowDisc = $this->db->table('discount_usages') ->selectSum('discount_amount', 'sum_disc') ->where('invoice_id', $invoiceId) ->get()->getRowArray(); if ($rowDisc && isset($rowDisc['sum_disc'])) { $invoiceDiscount = (float)$rowDisc['sum_disc']; } // Parent YTD (same school year) $parentYearDiscountTotal = 0.0; $rowParentDisc = $this->db->table('discount_usages') ->selectSum('discount_amount', 'sum_disc') ->where('parent_id', $parentId) ->where('school_year', $this->schoolYear) ->get()->getRowArray(); if ($rowParentDisc && isset($rowParentDisc['sum_disc'])) { $parentYearDiscountTotal = (float)$rowParentDisc['sum_disc']; } $this->db->transCommit(); // Build payload & notify [$eventData, $studentData] = $this->buildPaymentEventData( $invoiceId, $transactionId, $amount, $paymentMethod, $paymentDate, $checkNumber, $installmentSeq, $initialPreBalance, $postBalance, $invoiceDiscount, $parentYearDiscountTotal ); Events::trigger('paymentReceived', $eventData, $studentData); return redirect() ->to(site_url('payment/manual_pay?search_term=' . urlencode($searchTerm))) ->with('success', 'Payment recorded successfully (Installment #' . $installmentSeq . '). Transaction ID: ' . $transactionId); } catch (\Throwable $e) { if ($this->db->transStatus()) $this->db->transRollback(); log_message('error', '[manualPayUpdate] ' . $e->getMessage()); return redirect()->back()->withInput()->with('error', 'Unexpected error while recording payment.'); } } public function manualPayEdit() { $paymentId = (int) $this->request->getPost('payment_id'); $paidAmount = (float) $this->request->getPost('paid_amount'); $paymentMethod = $this->request->getPost('payment_method'); $checkNumber = $this->request->getPost('check_number'); // ✅ Added check_number if (!$paymentId || !$paidAmount || !$paymentMethod) { return redirect()->back()->with('error', 'Missing required fields.'); } $payment = $this->paymentModel->find($paymentId); if (!$payment) { return redirect()->back()->with('error', 'Original payment not found.'); } $invoice = $this->invoiceModel->find($payment['invoice_id']); if (!$invoice) { return redirect()->back()->with('error', 'Linked invoice not found.'); } //$schoolYear = (new ConfigurationModel())->getConfig('school_year'); $checkFile = $payment['check_file']; // default keep old file // Handle optional check or card payment receipt upload if (strtolower($paymentMethod) === 'check') { $paymentFile = $this->request->getFile('payment_file'); // ✅ Updated to payment_file if ($paymentFile && $paymentFile->isValid() && !$paymentFile->hasMoved()) { $fileName = $paymentFile->getRandomName(); $paymentFile->move(WRITEPATH . 'uploads/checks/', $fileName); $checkFile = $fileName; } } elseif (strtolower($paymentMethod) === 'card') { $paymentFile = $this->request->getFile('payment_file'); // ✅ Updated to payment_file if ($paymentFile && $paymentFile->isValid() && !$paymentFile->hasMoved()) { $fileName = $paymentFile->getRandomName(); $paymentFile->move(WRITEPATH . 'uploads/cards/', $fileName); $checkFile = $fileName; // reuse for compatibility } } // ❌ Validate amount - negative or zero if ($paidAmount <= 0) { return redirect()->back()->with( 'error', 'Invalid amount entered: ' . number_format($paidAmount, 2) . '. Amount must be greater than zero.' ); } // 🔄 Recalculate invoice first to ensure totals reflect tuition + events + additional $this->recalculateInvoice($payment['invoice_id'], $this->schoolYear); // 🔄 Get current balance based on actual payments (with school_year filter) // We need to calculate what the balance would be WITHOUT the current payment being edited $currentBalance = $this->getCurrentInvoiceBalanceExcludingPayment($payment['invoice_id'], $paymentId); if ($paidAmount > $currentBalance) { return redirect()->back()->with( 'error', 'Entered amount (' . number_format($paidAmount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').' ); } // ✅ Update edited payment with check_number $updateData = [ 'paid_amount' => $paidAmount, 'payment_method' => strtolower($paymentMethod), 'check_file' => $checkFile, 'updated_by' => session()->get('user_id') ]; // ✅ Only add check_number if payment method is check if (strtolower($paymentMethod) === 'check') { $updateData['check_number'] = $checkNumber; } else { $updateData['check_number'] = null; // Clear check number for non-check payments } $this->paymentModel->update($paymentId, $updateData); // 🔄 Always recalc invoice after change $this->recalculateInvoice($payment['invoice_id'], $this->schoolYear); return redirect()->back()->with('success', 'Payment updated successfully.'); } /** * 🔄 Helper: Recalculate invoice totals and status based on all payments for current school year */ private function recalculateInvoice($invoiceId, $schoolYear) { $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); } $tuitionStudents = array_values(array_filter($tuitionStudents, function ($student) use ($schoolYear) { $sid = (int)($student['student_id'] ?? 0); return $sid > 0 && $this->studentClassModel->hasNonEventAssignment($sid, $schoolYear); })); // 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'); $this->invoiceModel->update($invoiceId, [ 'total_amount' => $newTotal, 'paid_amount' => $totalPaid, 'balance' => $newBalance, 'status' => $newStatus, ]); } /** * Recalculate invoice totals by invoice number or ID (school-year only). */ public function recalculateInvoiceByNumber(string $invoiceNumber) { $invoiceNumber = trim($invoiceNumber); $invoice = null; if ($invoiceNumber !== '' && ctype_digit($invoiceNumber)) { $invoice = $this->invoiceModel->find((int)$invoiceNumber); } if (!$invoice) { $invoice = $this->invoiceModel ->where('invoice_number', $invoiceNumber) ->first(); } if (!$invoice) { return redirect()->back()->with('error', 'Invoice not found.'); } $schoolYear = (string)($invoice['school_year'] ?? $this->schoolYear); $this->recalculateInvoice((int)$invoice['id'], $schoolYear); return redirect()->back()->with('success', 'Invoice recalculated.'); } /** * 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; } /** Get current invoice balance = total - payments - discounts - refundsPaid (no school_year filter). */ private function getCurrentInvoiceBalance(int $invoiceId): float { $invoice = $this->invoiceModel->find($invoiceId); if (!$invoice) return 0.0; $db = $this->db; $table = $this->paymentModel->table; // usually 'payments' $hasStatus = $db->fieldExists('status', $table); $hasVoid = $db->fieldExists('is_void', $table); $qb = $this->paymentModel ->select('COALESCE(SUM(paid_amount),0) AS total_paid') ->where('invoice_id', $invoiceId); 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(); } $row = $qb->first(); $totalPaid = (float)($row['total_paid'] ?? 0); // Discount sum on this invoice $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); // Refunds paid $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); $total = (float)($invoice['total_amount'] ?? 0); return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2)); } /** Balance excluding a specific payment (for edit scenarios) and subtracting discounts/refunds. */ private function getCurrentInvoiceBalanceExcludingPayment(int $invoiceId, int $excludePaymentId): float { $invoice = $this->invoiceModel->find($invoiceId); if (!$invoice) return 0.0; $db = $this->db; $table = $this->paymentModel->table; $hasStatus = $db->fieldExists('status', $table); $hasVoid = $db->fieldExists('is_void', $table); $qb = $this->paymentModel ->select('COALESCE(SUM(paid_amount),0) AS total_paid') ->where('invoice_id', $invoiceId) ->where('id !=', $excludePaymentId); 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(); } $row = $qb->first(); $totalPaid = (float)($row['total_paid'] ?? 0); // Discount sum on this invoice $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); // Refunds paid $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); $total = (float)($invoice['total_amount'] ?? 0); return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2)); } /** * 🔄 Helper: Get current invoice balance based on actual payments */ function formatPhone($input) { // Remove any non-digits just in case $digits = preg_replace('/\D/', '', $input); if (strlen($digits) !== 10) { return $input; // Return original if not exactly 10 digits } return sprintf( '(%s)-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6, 4) ); } private function processPayment( $invoiceId, $amount, $paymentMethod, $checkFile = null, $transactionId = null, $paymentDate = null, $schoolYear = null, $semester = null, $checkNumber = null, ?int $installmentSeq = null // <-- NOW: the installment sequence (1,2,3,...) for this invoice ) { $invoice = $this->invoiceModel->find($invoiceId); if (!$invoice) { return false; } $transactionId = $transactionId ?? 'INV-' . $invoiceId . '-' . time(); $paymentDate = $paymentDate ?? utc_now(); // If sequence wasn't provided (legacy callers), compute it here. // NOTE: If you call this from inside a transaction (recommended), this will be consistent. if ($installmentSeq === null || $installmentSeq < 1) { $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; } // Compute new totals $newPaid = (float) $invoice['paid_amount'] + (float) $amount; $newBalance = (float) $invoice['balance'] - (float) $amount; if ($newBalance == $invoice['total_amount']) { $paymentStatus = 'Unpaid'; } elseif ($newBalance > 0 && $newBalance < $invoice['total_amount']) { $paymentStatus = 'Partially Paid'; } elseif ($newBalance <= 0.00001) { $paymentStatus = 'Paid'; } else { $paymentStatus = $invoice['status']; // fallback } // Update invoice $invoiceUpdateData = [ 'paid_amount' => $newPaid, 'balance' => $newBalance, 'status' => $paymentStatus, 'updated_by' => session()->get('user_id'), ]; if (!$this->invoiceModel->update($invoiceId, $invoiceUpdateData)) { return false; } // Store the *sequence* in number_of_installments (kept for schema compatibility) // Consider renaming the column to `installment_seq` in a future migration. $paymentData = [ 'parent_id' => $invoice['parent_id'], 'invoice_id' => $invoiceId, 'total_amount' => $invoice['total_amount'], 'paid_amount' => $amount, 'balance' => $newBalance, 'number_of_installments' => $installmentSeq, // <-- installment sequence (1,2,3,...) 'transaction_id' => $transactionId, 'payment_method' => strtolower($paymentMethod), 'payment_date' => $paymentDate, 'status' => $paymentStatus, 'check_file' => $checkFile, 'check_number' => (strtolower($paymentMethod) === 'check') ? $checkNumber : null, 'updated_by' => session()->get('user_id'), 'school_year' => $schoolYear ?? $this->schoolYear, 'semester' => $semester ?? $this->semester, // 'installment_index' => $installmentSeq, // use if you add a dedicated column later ]; if (!$this->paymentModel->insert($paymentData)) { return false; } return true; } private function getSuccessfulPaymentCount(int $invoiceId): int { $exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']; return $this->paymentModel ->where('invoice_id', $invoiceId) ->where('paid_amount >', 0) ->whereNotIn('status', $exclude) ->countAllResults(); } public function serveCheckFile($filename, $mode = 'download') { $filename = basename($filename); $roots = [ WRITEPATH . 'uploads/checks/' . $filename, WRITEPATH . 'uploads/cards/' . $filename, WRITEPATH . 'uploads/misc/' . $filename, WRITEPATH . 'uploads/' . $filename, // final fallback ]; $path = null; foreach ($roots as $candidate) { if (is_file($candidate)) { $path = $candidate; break; } } if (!$path) { throw new \CodeIgniter\Exceptions\PageNotFoundException('Payment file not found: ' . esc($filename)); } if ($mode === 'inline') { return $this->response ->setHeader('Content-Type', mime_content_type($path)) ->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"') ->setBody(file_get_contents($path)); } return $this->response->download($path, null); } }