1776 lines
69 KiB
PHP
Executable File
1776 lines
69 KiB
PHP
Executable File
<?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 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);
|
||
}
|
||
}
|