Files
2026-06-01 02:08:27 -04:00

1390 lines
54 KiB
PHP
Raw Permalink 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\Libraries\FinancialAttachmentService;
use App\Libraries\FinancialStatus;
use App\Libraries\InvoiceLedgerService;
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 CodeIgniter\RESTful\ResourceController;
use CodeIgniter\Events\Events;
use CodeIgniter\Exceptions\PageNotFoundException;
class PaymentController extends ResourceController
{
protected $paymentModel;
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;
protected $invoiceLedgerService;
protected $financialAttachmentService;
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->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();
$this->invoiceLedgerService = new InvoiceLedgerService();
$this->financialAttachmentService = new FinancialAttachmentService();
}
// 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.');
}
}
public function redirectPage()
{
return redirect()
->to(site_url('parent/invoice_payment'))
->with('info', 'Online payment is currently unavailable. Please contact the school office to complete payment.');
}
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');
$data['proof_path'] = $this->financialAttachmentService->saveUploadedFile($proof, 'payments');
$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');
try {
$checkFile = $this->financialAttachmentService->saveUploadedFile(
$paymentFile,
$paymentMethod === 'check' ? 'checks' : ($paymentMethod === 'card' ? 'cards' : 'misc')
);
} catch (\RuntimeException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
$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
$currentBalance = (float) $this->invoiceLedgerService->recalculateInvoice($invoiceId)['balance'];
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,
(array) $this->invoiceModel->find($invoiceId),
$currentBalance
);
if (!$ok) {
$this->db->transRollback();
return redirect()->back()->with('error', 'Failed to record payment.');
}
// Ensure invoice totals/balance reflect discounts and this payment
$ledger = $this->invoiceLedgerService->recalculateInvoice($invoiceId);
// Post-payment balance from snapshot
$postBalance = (float) ($ledger['balance'] ?? max(0.0, round($initialPreBalance - $amount, 2)));
// 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
try {
$paymentFile = $this->request->getFile('payment_file');
if (strtolower($paymentMethod) === 'check') {
$uploaded = $this->financialAttachmentService->saveUploadedFile($paymentFile, 'checks');
if ($uploaded !== null) {
$checkFile = $uploaded;
}
} elseif (strtolower($paymentMethod) === 'card') {
$uploaded = $this->financialAttachmentService->saveUploadedFile($paymentFile, 'cards');
if ($uploaded !== null) {
$checkFile = $uploaded;
}
}
} catch (\RuntimeException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
// ❌ 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.'
);
}
$this->db->transBegin();
try {
$invoiceId = (int) ($payment['invoice_id'] ?? 0);
$lockedInvoice = $this->db->query(
'SELECT id FROM invoices WHERE id = ? FOR UPDATE',
[$invoiceId]
)->getRowArray();
if (!$lockedInvoice) {
$this->db->transRollback();
return redirect()->back()->with('error', 'Linked invoice not found.');
}
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
$currentBalance = $this->getCurrentInvoiceBalanceExcludingPayment($invoiceId, $paymentId);
if ($paidAmount > $currentBalance + 0.00001) {
$this->db->transRollback();
return redirect()->back()->with(
'error',
'Entered amount (' . number_format($paidAmount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').'
);
}
$updateData = [
'paid_amount' => $paidAmount,
'payment_method' => strtolower($paymentMethod),
'check_file' => $checkFile,
'check_number' => strtolower($paymentMethod) === 'check' ? $checkNumber : null,
'balance' => max(0.0, round($currentBalance - $paidAmount, 2)),
'status' => FinancialStatus::PAYMENT_RECORDED,
'updated_by' => session()->get('user_id'),
];
$this->paymentModel->update($paymentId, $updateData);
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
$this->db->transCommit();
return redirect()->back()->with('success', 'Payment updated successfully.');
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', '[manualPayEdit] ' . $e->getMessage());
return redirect()->back()->withInput()->with('error', 'Unexpected error while updating payment.');
}
}
/**
* 🔄 Helper: Recalculate invoice totals and status based on all payments for current school year
*/
private function recalculateInvoice($invoiceId, $schoolYear)
{
$this->invoiceLedgerService->recalculateInvoice((int) $invoiceId);
}
/**
* 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
{
try {
return (float) ($this->invoiceLedgerService->calculateInvoice($invoiceId)['balance'] ?? 0.0);
} catch (\Throwable $e) {
return 0.0;
}
}
/** Balance excluding a specific payment (for edit scenarios) and subtracting discounts/refunds. */
private function getCurrentInvoiceBalanceExcludingPayment(int $invoiceId, int $excludePaymentId): float
{
$payment = $this->paymentModel->find($excludePaymentId);
if (!$payment) {
return $this->getCurrentInvoiceBalance($invoiceId);
}
$currentBalance = $this->getCurrentInvoiceBalance($invoiceId);
$status = FinancialStatus::normalizePaymentStatus($payment['status'] ?? null);
if (in_array($status, FinancialStatus::EXCLUDED_PAYMENT_STATUSES, true)) {
return $currentBalance;
}
return max(0.0, round($currentBalance + (float) ($payment['paid_amount'] ?? 0), 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,
?array $invoice = null,
?float $currentBalance = null
) {
$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;
}
$preBalance = $currentBalance ?? $this->getCurrentInvoiceBalance((int) $invoiceId);
$newBalance = max(0.0, round($preBalance - (float) $amount, 2));
$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,...)
'installment_seq' => $installmentSeq,
'transaction_id' => $transactionId,
'payment_method' => strtolower($paymentMethod),
'payment_date' => $paymentDate,
'status' => FinancialStatus::PAYMENT_RECORDED,
'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,
];
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 servePaymentFile(int $paymentId, string $mode = 'download')
{
$payment = $this->paymentModel->find($paymentId);
if (!$payment || empty($payment['check_file'])) {
throw PageNotFoundException::forPageNotFound('Payment file not found.');
}
if (!$this->canViewPayment($payment)) {
return $this->response->setStatusCode(403);
}
$subdir = match (strtolower((string) ($payment['payment_method'] ?? ''))) {
'check' => 'checks',
'card', 'debit/credit card' => 'cards',
default => 'misc',
};
$path = $this->financialAttachmentService->resolvePath($subdir, (string) $payment['check_file']);
if ($path === null) {
throw PageNotFoundException::forPageNotFound('Payment file not found.');
}
if ($mode === 'inline') {
return $this->response
->setHeader('Content-Type', $this->financialAttachmentService->detectMime($path))
->setHeader('Content-Disposition', 'inline; filename="' . basename($path) . '"')
->setBody(file_get_contents($path));
}
return $this->response->download($path, null);
}
private function canViewPayment(array $payment): bool
{
$roles = array_map('strtolower', (array) (session()->get('roles') ?? []));
$activeRole = strtolower((string) (session()->get('role') ?? ''));
if ($activeRole !== '' && !in_array($activeRole, $roles, true)) {
$roles[] = $activeRole;
}
$staffRoles = ['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant'];
foreach ($staffRoles as $role) {
if (in_array($role, $roles, true)) {
return true;
}
}
return in_array('parent', $roles, true) && (int) ($payment['parent_id'] ?? 0) === (int) session()->get('user_id');
}
}