Files
alrahma_sunday_school/app/Controllers/View/PaymentController.php
T
2026-05-16 13:44:12 -04:00

1776 lines
69 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Controllers\View;
use App\Models\PaymentModel;
use App\Models\AdditionalChargeModel;
use App\Models\ClassSectionModel;
use App\Models\ManualPaymentModel;
use App\Models\EventChargesModel;
use App\Models\UserModel;
use App\Models\StudentModel;
use App\Models\EnrollmentModel;
use App\Models\ConfigurationModel;
use App\Models\StudentClassModel;
use App\Models\PaymentErrorModel;
use App\Models\InvoiceModel;
use App\Models\TeacherClassModel;
use App\Models\DiscountUsageModel;
use Config\PaypalConfig;
use PayPal\Api\Amount;
use PayPal\Api\Payment;
use PayPal\Api\PaymentExecution;
use PayPal\Api\Payer;
use PayPal\Api\Transaction;
use PayPal\Rest\ApiContext;
use PayPal\Auth\OAuthTokenCredential;
use CodeIgniter\RESTful\ResourceController;
use CodeIgniter\Events\Events;
class PaymentController extends ResourceController
{
protected $paymentModel;
protected $paypalConfig;
protected $apiContext;
protected $request;
protected $db;
protected $invoiceModel;
protected $configModel;
protected $manualPaymentModel;
protected $eventChargesModel;
protected $userModel;
protected $schoolYear;
protected $semester;
protected $enrollmentModel;
protected $studentModel;
protected $studentClassModel;
protected $paymentErrorModel;
protected $installmentDate;
protected $teacherClassModel;
protected $discountUsageModel;
protected $additionalChargeModel;
protected $classSectionModel;
public function __construct()
{
$this->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 its `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);
}
}