pdo = $pdo; } /** * Process a payment transaction * * @param array $params Payment parameters * @return array Result with success status, message, and payment details * @throws Exception on validation or processing errors */ public function processPayment(array $params) { // Extract and validate parameters $studentId = $params['student_id'] ?? ''; $studentCode = $params['student_code'] ?? ''; $selectedFees = $params['selected_fees'] ?? []; $tellerNumber = $params['teller_number'] ?? ''; $paymentDate = $params['payment_date'] ?? ''; $amountToUse = floatval($params['amount_to_use'] ?? 0); $source = $params['source'] ?? 'web'; // Track payment source (web/api) // Validate required fields if ( empty($studentId) || empty($studentCode) || empty($selectedFees) || empty($tellerNumber) || empty($paymentDate) || $amountToUse <= 0 ) { throw new Exception('Missing required fields'); } // Validate selected fees is an array if (!is_array($selectedFees) || count($selectedFees) === 0) { throw new Exception('No fees selected'); } // Sort fees by academic_session ASC, term_of_session ASC (oldest to newest) usort($selectedFees, function ($a, $b) { if ($a['academic_session'] != $b['academic_session']) { return $a['academic_session'] - $b['academic_session']; } return $a['term_of_session'] - $b['term_of_session']; }); // Re-fetch bank statement to verify $bankStatement = $this->getBankStatement($tellerNumber); if (!$bankStatement) { throw new Exception('Bank statement not found for teller number: ' . $tellerNumber); } // Verify unreconciled amount if ($amountToUse > $bankStatement['unreconciled_amount']) { throw new Exception('Amount exceeds unreconciled amount on teller'); } // Extract teller name and number from description $descParts = explode(' ', $bankStatement['description']); $tellerNo = array_pop($descParts); $tellerName = implode(' ', $descParts); // Use the oldest fee's session/term for transaction_id $dominantSession = $selectedFees[0]['academic_session']; $dominantTerm = $selectedFees[0]['term_of_session']; // Generate transaction_id $transactionId = $studentId . $dominantSession . $paymentDate; // STEP 1: Guard check - prevent duplicate payments on same date $this->checkDuplicatePayment($studentId, $paymentDate); // STEP 2: Allocate payment across fees (oldest to newest) $feeAllocations = $this->allocatePaymentToFees($selectedFees, $amountToUse); if (count($feeAllocations) === 0) { throw new Exception('No fees could be allocated'); } // Calculate total paid $totalPaid = array_sum(array_column($feeAllocations, 'amount')); // Generate receipt_no (used across all fee records) $receiptNo = $studentCode . $paymentDate; // STEP 3: Execute database transaction $this->pdo->beginTransaction(); try { // 3a) INSERT into tb_account_school_fee_payments (per fee) $this->insertSchoolFeePayments( $feeAllocations, $studentId, $studentCode, $transactionId, $tellerNo, $tellerName, $paymentDate ); // 3b) INSERT into tb_account_school_fee_sum_payments (single record) $this->insertSumPayment( $studentId, $totalPaid, $paymentDate, $transactionId, $dominantSession, $dominantTerm ); // 3c) INSERT into tb_account_student_payments (per fee) $this->insertStudentPayments( $feeAllocations, $studentId, $studentCode, $transactionId, $paymentDate ); // 3d) INSERT into tb_account_payment_registers (per fee) $this->insertPaymentRegisters( $feeAllocations, $studentId, $studentCode, $receiptNo, $paymentDate, $transactionId ); // 3e) UPDATE tb_student_logistics $this->updateStudentLogistics($feeAllocations, $studentId); // Commit transaction $this->pdo->commit(); // Fetch fee descriptions for display $feeDescriptions = $this->getFeeDescriptions($feeAllocations); // Prepare payment details for display foreach ($feeAllocations as $key => $allocation) { $feeAllocations[$key]['description'] = $feeDescriptions[$allocation['fee_id']] ?? 'Unknown Fee'; $feeAllocations[$key]['total_paid_to_date'] = $allocation['previous_paid'] + $allocation['amount']; $feeAllocations[$key]['balance'] = $allocation['amount_billed'] - $feeAllocations[$key]['total_paid_to_date']; } return [ 'success' => true, 'message' => 'Payment processed successfully', 'data' => [ 'student_id' => $studentId, 'student_code' => $studentCode, 'payment_date' => $paymentDate, 'teller_no' => $tellerNo, 'teller_name' => $tellerName, 'total_paid' => $totalPaid, 'receipt_no' => $receiptNo, 'transaction_id' => $transactionId, 'allocations' => $feeAllocations, 'remaining_unreconciled' => $bankStatement['unreconciled_amount'] - $totalPaid, 'source' => $source ] ]; } catch (Exception $e) { $this->pdo->rollBack(); throw new Exception('Transaction failed: ' . $e->getMessage()); } } /** * Get bank statement by teller number */ private function getBankStatement($tellerNumber) { $sql = "SELECT bs.id, bs.description, bs.amount_paid, bs.payment_date, COALESCE(fp.total_registered_fee, 0.00) AS registered_amount, (bs.amount_paid - COALESCE(fp.total_registered_fee, 0.00)) AS unreconciled_amount FROM tb_account_bank_statements bs LEFT JOIN ( SELECT teller_no, SUM(amount_paid) AS total_registered_fee FROM tb_account_school_fee_payments GROUP BY teller_no ) fp ON SUBSTRING_INDEX(bs.description, ' ', -1) = fp.teller_no WHERE SUBSTRING_INDEX(bs.description, ' ', -1) = :teller_number LIMIT 1"; $stmt = $this->pdo->prepare($sql); $stmt->execute(['teller_number' => $tellerNumber]); return $stmt->fetch(); } /** * Check for duplicate payment on the same date */ private function checkDuplicatePayment($studentId, $paymentDate) { $sql = "SELECT COUNT(*) AS cnt FROM tb_account_school_fee_sum_payments WHERE student_id = :student_id AND payment_date = :payment_date"; $stmt = $this->pdo->prepare($sql); $stmt->execute([ 'student_id' => $studentId, 'payment_date' => $paymentDate ]); $duplicateCheck = $stmt->fetch(); if ($duplicateCheck['cnt'] > 0) { throw new Exception('A payment for this student has already been registered on this date (' . $paymentDate . ')'); } } /** * Allocate payment amount across fees (oldest to newest) */ private function allocatePaymentToFees($selectedFees, $amountToUse) { $feeAllocations = []; $remainingAmount = $amountToUse; foreach ($selectedFees as $fee) { if ($remainingAmount <= 0) { break; } $outstandingAmount = floatval($fee['outstanding_amount']); if ($remainingAmount >= $outstandingAmount) { // Fully settle this fee $amountForThisFee = $outstandingAmount; $remainingAmount -= $outstandingAmount; } else { // Partially settle this fee $amountForThisFee = $remainingAmount; $remainingAmount = 0; } $feeAllocations[] = [ 'fee_id' => $fee['fee_id'], 'academic_session' => $fee['academic_session'], 'term_of_session' => $fee['term_of_session'], 'amount' => $amountForThisFee, 'amount_billed' => floatval($fee['amount_billed'] ?? 0), 'previous_paid' => floatval($fee['amount_paid'] ?? 0) ]; } return $feeAllocations; } /** * Insert records into tb_account_school_fee_payments */ private function insertSchoolFeePayments( $feeAllocations, $studentId, $studentCode, $transactionId, $tellerNo, $tellerName, $paymentDate ) { foreach ($feeAllocations as $allocation) { $schoolFeePaymentId = $studentCode . $allocation['fee_id'] . $paymentDate; $sql = "INSERT INTO tb_account_school_fee_payments ( id, fee_id, student_id, transaction_id, amount_paid, teller_no, teller_name, credit_bank_id, debit_bank_id, paymode_id, payment_status, payment_date, recipient_id, academic_session, term_of_session, payment_by, payment_on ) VALUES ( :id, :fee_id, :student_id, :transaction_id, :amount_paid, :teller_no, :teller_name, :credit_bank_id, :debit_bank_id, :paymode_id, :payment_status, :payment_date, :recipient_id, :academic_session, :term_of_session, :payment_by, NOW() )"; $stmt = $this->pdo->prepare($sql); $stmt->execute([ 'id' => $schoolFeePaymentId, 'fee_id' => $allocation['fee_id'], 'student_id' => $studentId, 'transaction_id' => $transactionId, 'amount_paid' => $allocation['amount'], 'teller_no' => $tellerNo, 'teller_name' => $tellerName, 'credit_bank_id' => self::CREDIT_BANK_ID, 'debit_bank_id' => self::DEBIT_BANK_ID, 'paymode_id' => self::PAYMODE_ID, 'payment_status' => self::PAYMENT_STATUS, 'payment_date' => $paymentDate, 'recipient_id' => self::RECIPIENT_ID, 'academic_session' => $allocation['academic_session'], 'term_of_session' => $allocation['term_of_session'], 'payment_by' => self::PAYMENT_BY ]); } } /** * Insert record into tb_account_school_fee_sum_payments */ private function insertSumPayment( $studentId, $totalPaid, $paymentDate, $transactionId, $dominantSession, $dominantTerm ) { $sumPaymentsId = $studentId . $dominantSession . $paymentDate; $sql = "INSERT INTO tb_account_school_fee_sum_payments ( id, student_id, total_paid, paymode_id, payment_date, credit_bank_id, debit_bank_id, transaction_id, status, academic_session, term_of_session, registered_by, registered_on ) VALUES ( :id, :student_id, :total_paid, :paymode_id, :payment_date, :credit_bank_id, :debit_bank_id, :transaction_id, :status, :academic_session, :term_of_session, :registered_by, NOW() )"; $stmt = $this->pdo->prepare($sql); $stmt->execute([ 'id' => $sumPaymentsId, 'student_id' => $studentId, 'total_paid' => $totalPaid, 'paymode_id' => self::PAYMODE_ID, 'payment_date' => $paymentDate, 'credit_bank_id' => self::CREDIT_BANK_ID, 'debit_bank_id' => self::DEBIT_BANK_ID, 'transaction_id' => $transactionId, 'status' => self::PAYMENT_STATUS, 'academic_session' => $dominantSession, 'term_of_session' => $dominantTerm, 'registered_by' => self::CREATED_BY ]); } /** * Insert records into tb_account_student_payments */ private function insertStudentPayments( $feeAllocations, $studentId, $studentCode, $transactionId, $paymentDate ) { foreach ($feeAllocations as $allocation) { // Get current payment_to_date (sum of all previous payments for this fee/session/term) $sql = "SELECT SUM(payment_to_date) AS current_total FROM tb_account_student_payments WHERE student_id = :student_id AND fee_id = :fee_id AND academic_session = :academic_session AND term_of_session = :term_of_session"; $stmt = $this->pdo->prepare($sql); $stmt->execute([ 'student_id' => $studentId, 'fee_id' => $allocation['fee_id'], 'academic_session' => $allocation['academic_session'], 'term_of_session' => $allocation['term_of_session'] ]); $currentPayment = $stmt->fetch(); $newPaymentToDate = $allocation['amount']; $studentPaymentId = $studentCode . $allocation['fee_id'] . $paymentDate; $sql = "INSERT INTO tb_account_student_payments ( id, fee_id, student_id, payment_to_date, transaction_id, academic_session, term_of_session, created_by, created_as, created_on ) VALUES ( :id, :fee_id, :student_id, :payment_to_date, :transaction_id, :academic_session, :term_of_session, :created_by, :created_as, NOW() )"; $stmt = $this->pdo->prepare($sql); $stmt->execute([ 'id' => $studentPaymentId, 'fee_id' => $allocation['fee_id'], 'student_id' => $studentId, 'payment_to_date' => $newPaymentToDate, 'transaction_id' => $transactionId, 'academic_session' => $allocation['academic_session'], 'term_of_session' => $allocation['term_of_session'], 'created_by' => self::CREATED_BY, 'created_as' => self::CREATED_AS ]); } } /** * Insert records into tb_account_payment_registers */ private function insertPaymentRegisters( $feeAllocations, $studentId, $studentCode, $receiptNo, $paymentDate, $transactionId ) { foreach ($feeAllocations as $allocation) { $paymentRegisterId = $studentCode . $allocation['fee_id'] . $paymentDate; $sql = "INSERT INTO tb_account_payment_registers ( id, fee_id, student_id, amount_paid, amount_due, receipt_no, recipient_id, payment_date, paymode_category, transaction_id, academic_session, term_of_session, created_by, created_as, created_on ) VALUES ( :id, :fee_id, :student_id, :amount_paid, :amount_due, :receipt_no, :recipient_id, :payment_date, :paymode_category, :transaction_id, :academic_session, :term_of_session, :created_by, :created_as, NOW() )"; $stmt = $this->pdo->prepare($sql); $stmt->execute([ 'id' => $paymentRegisterId, 'fee_id' => $allocation['fee_id'], 'student_id' => $studentId, 'amount_paid' => $allocation['amount'], 'amount_due' => $allocation['amount'], 'receipt_no' => $receiptNo, 'recipient_id' => self::RECIPIENT_ID, 'payment_date' => $paymentDate, 'paymode_category' => self::PAYMODE_CATEGORY, 'transaction_id' => $transactionId, 'academic_session' => $allocation['academic_session'], 'term_of_session' => $allocation['term_of_session'], 'created_by' => self::CREATED_BY, 'created_as' => self::CREATED_AS ]); } } /** * Update tb_student_logistics to reduce outstanding fees */ private function updateStudentLogistics($feeAllocations, $studentId) { // Group allocations by session/term to update specific records $sessionTermTotals = []; foreach ($feeAllocations as $allocation) { $key = $allocation['academic_session'] . '-' . $allocation['term_of_session']; if (!isset($sessionTermTotals[$key])) { $sessionTermTotals[$key] = [ 'academic_session' => $allocation['academic_session'], 'term_of_session' => $allocation['term_of_session'], 'total' => 0 ]; } $sessionTermTotals[$key]['total'] += $allocation['amount']; } // Update each session/term record foreach ($sessionTermTotals as $st) { $sql = "UPDATE tb_student_logistics SET fees_outstanding = GREATEST(0, fees_outstanding - :total_paid) WHERE student_id = :student_id AND academic_session = :academic_session AND term_of_session = :term_of_session"; $stmt = $this->pdo->prepare($sql); $stmt->execute([ 'total_paid' => $st['total'], 'student_id' => $studentId, 'academic_session' => $st['academic_session'], 'term_of_session' => $st['term_of_session'] ]); } } /** * Get fee descriptions for display */ private function getFeeDescriptions($feeAllocations) { $feeIds = array_column($feeAllocations, 'fee_id'); $placeholders = implode(',', array_fill(0, count($feeIds), '?')); $sql = "SELECT id, description FROM tb_account_school_fees WHERE id IN ($placeholders)"; $stmt = $this->pdo->prepare($sql); $stmt->execute($feeIds); return $stmt->fetchAll(PDO::FETCH_KEY_PAIR); } /** * Validate student exists */ public function validateStudent($studentId) { $sql = "SELECT id, student_code, CONCAT(last_name, ' ', first_name, ' ', COALESCE(other_name, '')) AS full_name FROM tb_student_registrations WHERE id = :student_id AND admission_status = 'Active'"; $stmt = $this->pdo->prepare($sql); $stmt->execute(['student_id' => $studentId]); return $stmt->fetch(); } /** * Get outstanding fees for a student */ public function getOutstandingFees($studentId) { $sql = "SELECT ar.fee_id, sf.description AS fee_description, ar.academic_session, ar.term_of_session, ar.actual_value AS amount_billed, COALESCE(SUM(sp.payment_to_date), 0) AS amount_paid, (ar.actual_value - COALESCE(SUM(sp.payment_to_date), 0)) AS outstanding_amount FROM tb_account_receivables ar INNER JOIN tb_account_school_fees sf ON ar.fee_id = sf.id LEFT JOIN tb_account_student_payments sp ON ar.student_id = sp.student_id AND ar.fee_id = sp.fee_id AND ar.academic_session = sp.academic_session AND ar.term_of_session = sp.term_of_session WHERE ar.student_id = :student_id GROUP BY ar.fee_id, ar.academic_session, ar.term_of_session, sf.description, ar.actual_value HAVING outstanding_amount > 0 ORDER BY ar.academic_session ASC, ar.term_of_session ASC"; $stmt = $this->pdo->prepare($sql); $stmt->execute(['student_id' => $studentId]); return $stmt->fetchAll(); } /** * Get total outstanding balance with breakdown * * @param string $studentId * @return array Total and breakdown */ public function getTotalOutstandingBalance($studentId) { // Reuse getOutstandingFees as it already provides the breakdown of unpaid items $breakdown = $this->getOutstandingFees($studentId); $totalOutstanding = 0; foreach ($breakdown as $item) { $totalOutstanding += floatval($item['outstanding_amount']); } return [ 'student_id' => $studentId, 'currency' => 'NGN', 'total_outstanding' => $totalOutstanding, 'breakdown' => $breakdown ]; } /** * Get invoice (fee breakdown) for a specific term * Shows all fees billed, paid, and balance * * @param string $studentId * @param string $session * @param string $term * @return array List of fees */ public function getTermInvoice($studentId, $session, $term) { $sql = "SELECT ar.fee_id, sf.description, ar.actual_value AS amount_billed, COALESCE(SUM(sp.payment_to_date), 0) AS amount_paid, (ar.actual_value - COALESCE(SUM(sp.payment_to_date), 0)) AS balance FROM tb_account_receivables ar INNER JOIN tb_account_school_fees sf ON ar.fee_id = sf.id LEFT JOIN tb_account_student_payments sp ON ar.student_id = sp.student_id AND ar.fee_id = sp.fee_id AND ar.academic_session = sp.academic_session AND ar.term_of_session = sp.term_of_session WHERE ar.student_id = :student_id AND ar.academic_session = :session AND ar.term_of_session = :term GROUP BY ar.fee_id, sf.description, ar.actual_value"; $stmt = $this->pdo->prepare($sql); $stmt->execute([ 'student_id' => $studentId, 'session' => $session, 'term' => $term ]); return $stmt->fetchAll(); } /** * Get payment status summary and transactions for a term * * @param string $studentId * @param string $session * @param string $term * @return array Summary and transactions */ public function getTermPaymentStatus($studentId, $session, $term) { // 1. Get Summary (Billed vs Paid) // We can use getTermInvoice to aggregate this $invoiceItems = $this->getTermInvoice($studentId, $session, $term); $totalBilled = 0; $totalPaid = 0; $totalBalance = 0; foreach ($invoiceItems as $item) { $totalBilled += floatval($item['amount_billed']); $totalPaid += floatval($item['amount_paid']); $totalBalance += floatval($item['balance']); } // 2. Get Transactions (Payments made for this term) // We look at the sum_payments table or specific fee payments // Using sum_payments is better for receipts, but it tracks 'dominant' session/term. // A better approach for "transactions" might be to look at where the money went. // However, usually we want to see "Receipts issued for this term". // Let's query tb_account_payment_registers which tracks receipts per fee, // or tb_account_school_fee_sum_payments which tracks the main transaction. // Let's use tb_account_school_fee_sum_payments for the main "Transactions" list // filtering by the session/term assigned to the payment. $sql = "SELECT payment_date, total_paid AS amount, transaction_id, id AS payment_ref FROM tb_account_school_fee_sum_payments WHERE student_id = :student_id AND academic_session = :session AND term_of_session = :term ORDER BY payment_date DESC"; $stmt = $this->pdo->prepare($sql); $stmt->execute([ 'student_id' => $studentId, 'session' => $session, 'term' => $term ]); $transactions = $stmt->fetchAll(); return [ 'summary' => [ 'total_billed' => $totalBilled, 'total_paid' => $totalPaid, 'outstanding' => $totalBalance ], 'transactions' => $transactions ]; } }