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

589 lines
24 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\AdditionalChargeModel;
use CodeIgniter\Controller;
use App\Models\UserModel;
use App\Models\ConfigurationModel;
use CodeIgniter\Events\Events;
use App\Models\InvoiceModel;
use CodeIgniter\I18n\Time;
class ExtraChargesController extends BaseController
{
protected $additionalChargeModel;
protected $db;
protected $configModel;
protected $semester;
protected $schoolYear;
protected $attendanceDataModel;
protected $studentModel;
protected $emergencyContactModel;
protected $userModel;
protected $teacherClassSection;
protected $invoiceModel;
protected $classSectionModel;
protected $studentClassModel;
protected $enableAttendance;
protected $attendanceDayModel;
public function __construct()
{
// Initialize the database connection in the constructor
$this->db = \Config\Database::connect();
$this->configModel = new ConfigurationModel();
$this->additionalChargeModel = new AdditionalChargeModel();
$this->userModel = new UserModel();
$this->invoiceModel = new InvoiceModel();
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
}
public function index()
{
// API-first: delegate to JSON list endpoint
return $this->apiList();
}
/** Render HTML management page */
public function page()
{
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
$status = $this->request->getGet('status') ?: null;
$yearSelect = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
$rows = [];
if ($parentId > 0) {
$rows = $this->additionalChargeModel
->byParentTerm($parentId, $this->schoolYear, $this->semester, $status);
}
// Load ALL parents for current view
$parents = $this->userModel->select('users.id, users.firstname, users.lastname')
->join('user_roles', 'user_roles.user_id = users.id')
->join('roles', 'roles.id = user_roles.role_id')
->where('LOWER(roles.name) =', 'parent')
->where('user_roles.deleted_at IS NULL', null, false)
->orderBy('users.lastname', 'ASC')
->orderBy('users.firstname', 'ASC')
->findAll();
// Map parent id -> "Lastname, Firstname" (no email/ID shown)
$parentMap = [];
foreach ($parents as $p) {
$label = trim(($p['lastname'] ?? '') . ', ' . ($p['firstname'] ?? ''));
$parentMap[(int)$p['id']] = $label !== '' && $label !== ',' ? $label : ('User #' . (int)$p['id']);
}
// 🔹 Get ALL invoices for ALL parents this year, group by parent_id
$parentIds = array_map(fn($r) => (int)$r['id'], $parents);
$invoicesByParent = [];
if (!empty($parentIds)) {
$all = $this->invoiceModel->getAllInvoicesByUserIds($parentIds, $yearSelect);
foreach ($all as $inv) {
$pid = (int)$inv['parent_id'];
$invoicesByParent[$pid][] = [
'id' => (int)$inv['id'],
'invoice_number' => (string)$inv['invoice_number'],
'status' => (string)($inv['status'] ?? ''),
'balance' => (float)($inv['balance'] ?? 0),
'issue_date' => $inv['issue_date'] ?? null,
];
}
}
// Label for preselect (if filtering by a parent)
$selectedParentLabel = ($parentId > 0 && isset($parentMap[$parentId])) ? $parentMap[$parentId] : '';
// Optional filters
$status = $this->request->getGet('status') ?: null;
$q = trim((string)($this->request->getGet('q') ?? ''));
// ✅ Always pull all charges for the selected year & current semester (all parents)
$rows = $this->additionalChargeModel->listAllForTerm(
$yearSelect,
$this->semester,
$status,
$q,
50 // per-page
);
$pager = $this->additionalChargeModel->pager;
// Build school year options from data (additional_charges + invoices)
$schoolYears = [];
try {
$q1 = $this->db->table('additional_charges')->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')->get()->getResultArray();
foreach ($q1 as $r) {
$val = (string)($r['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
}
} catch (\Throwable $e) {}
try {
$q2 = $this->db->table('invoices')->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->orderBy('school_year', 'DESC')->get()->getResultArray();
foreach ($q2 as $r) {
$val = (string)($r['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
}
} catch (\Throwable $e) {}
if (empty($schoolYears) && is_string($this->schoolYear) && $this->schoolYear !== '') {
// fallback: generate recent years around configured schoolYear
$schoolYears[] = $this->schoolYear;
// Optionally add previous/next
[$start, $end] = explode('-', $this->schoolYear) + [0 => date('Y'), 1 => date('Y')+1];
$start = (int)$start;
for ($i = 1; $i <= 3; $i++) {
$schoolYears[] = ($start - $i) . '-' . (($start - $i) + 1);
}
}
rsort($schoolYears);
return view('payment/extra_charges', [
'q' => $q,
'pager' => $pager,
'rows' => $rows,
'parents' => $parents,
'parentMap' => $parentMap,
'invoicesByParent' => $invoicesByParent, // 🔹 pass the grouped map
'parentId' => $parentId,
'selectedParentLabel' => $selectedParentLabel,
'status' => $status,
'schoolYear' => $yearSelect,
'schoolYears' => $schoolYears,
'semester' => $this->semester,
]);
}
private function wantsJson(): bool
{
$accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? ''));
return $this->request->isAJAX() || str_contains($accept, 'application/json');
}
/** Used by Select2 AJAX: returns {results:[{id,text}]} */
public function parentOptions()
{
$q = trim((string)($this->request->getGet('q') ?? ''));
$limit = 20;
$builder = $this->userModel
->select('users.id, users.firstname, users.lastname, users.email')
->join('user_roles', 'user_roles.user_id = users.id')
->join('roles', 'roles.id = user_roles.role_id')
// If your role name may have different case, use LOWER(...):
// ->where('LOWER(roles.name) =', 'parent')
->where('roles.name', 'parent')
// Make sure this produces IS NULL; if not, use the raw condition line below
->where('user_roles.deleted_at', null);
// Alternative guaranteed-IS-NULL:
// $builder->where('user_roles.deleted_at IS NULL', null, false);
if ($q !== '') {
$builder->groupStart()
->like('users.firstname', $q)
->orLike('users.lastname', $q)
->orLike('users.email', $q)
->groupEnd();
}
$rows = $builder
->orderBy('users.lastname', 'ASC')
->orderBy('users.firstname', 'ASC')
->limit($limit)
->find();
$results = array_map(function ($r) {
$name = trim(($r['lastname'] ?? '') . ', ' . ($r['firstname'] ?? ''));
$email = $r['email'] ?? '';
$label = $name !== ',' ? $name : ('User #' . $r['id']);
if ($email) $label .= ' — ' . $email;
$label .= ' (ID: ' . $r['id'] . ')';
return ['id' => (int)$r['id'], 'text' => $label];
}, $rows);
return $this->response->setJSON(['results' => $results]);
}
/** Helper for preselect label in the modal */
private function getParentLabel(int $id): string
{
$p = $this->userModel->select('firstname, lastname, email')->find($id);
if (!$p) return 'User #' . $id;
$name = trim(($p['lastname'] ?? '') . ', ' . ($p['firstname'] ?? ''));
$email = $p['email'] ?? '';
$label = $name !== ',' ? $name : ('User #' . $id);
if ($email) $label .= ' — ' . $email;
return $label . ' (ID: ' . $id . ')';
}
public function update($id)
{
$row = $this->additionalChargeModel->find($id);
if (!$row) {
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
return redirect()->back()->with('error', 'Charge not found.');
}
$data = $this->request->getPost();
$newAmount = isset($data['amount']) ? (float)$data['amount'] : (float)$row['amount'];
$delta = $newAmount - (float)$row['amount'];
$db = \Config\Database::connect();
$db->transStart();
// Update the charge first
$this->additionalChargeModel->update($id, [
'title' => trim($data['title'] ?? $row['title']),
'description' => trim($data['description'] ?? $row['description']),
'amount' => $newAmount,
'due_date' => $data['due_date'] ?? $row['due_date'],
'charge_type' => $data['charge_type'] ?? $row['charge_type'],
// keep status as-is
]);
// If its already applied on an invoice and the amount changed, reflect the delta
if ($row['status'] === 'applied' && !empty($row['invoice_id']) && abs($delta) > 0.00001) {
if ($delta > 0) {
$this->invoiceModel->applyAdditionalCharge((int)$row['invoice_id'], $delta);
} else {
$this->invoiceModel->reverseAdditionalCharge((int)$row['invoice_id'], -$delta);
}
}
$db->transComplete();
if (!$db->transStatus()) {
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to update charge', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
return redirect()->back()->with('error', 'Failed to update charge.');
}
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true, 'id' => (int)$id, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
return redirect()->back()->with('status', 'Charge updated.');
}
// (removed duplicate model-like listAllForTerm method; use AdditionalChargeModel::listAllForTerm)
// ExtraChargesController.php
public function invoicesForParent()
{
$parentId = (int)($this->request->getGet('parent_id') ?? 0);
$schoolYear = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
$rows = ($parentId > 0)
? ($this->invoiceModel->getInvoicesByUserId($parentId, $schoolYear) ?? [])
: [];
$results = array_map(function ($inv) {
$id = (int)($inv['id'] ?? 0);
$num = (string)($inv['invoice_number'] ?? ('INV-' . $id));
$status = strtolower((string)($inv['status'] ?? ''));
$balance = (float)($inv['balance'] ?? 0);
$issue = $inv['issue_date'] ?? null;
return [
'id' => $id,
'invoice_number' => $num,
'status' => $status,
'balance' => $balance,
'issue_date' => $issue,
'text' => $num
. ($status ? ' — ' . ucfirst($status) : '')
. ' (Bal: $' . number_format($balance, 2) . ')',
];
}, $rows);
return $this->response->setJSON(['results' => $results]);
}
public function store()
{
$data = $this->request->getPost();
$rules = [
'parent_id' => 'required|integer', // this is actually the user_id of the parent
'title' => 'required|string|min_length[2]',
'amount' => 'required|decimal',
'charge_type' => 'required|in_list[add,deduct]',
'due_date' => 'permit_empty|valid_date',
'invoice_id' => 'permit_empty|integer',
];
if (!$this->validate($rules)) {
$msg = implode(' ', $this->validator->getErrors());
if ($this->wantsJson()) {
return $this->response->setJSON([
'ok' => false,
'error' => $msg,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
return redirect()->back()->withInput()->with('error', $msg);
}
$invoiceId = !empty($data['invoice_id']) ? (int)$data['invoice_id'] : null;
$chargeType = (string)$data['charge_type'];
$amountAbs = round(abs((float)$data['amount']), 2);
$signedAmount = ($chargeType === 'add') ? $amountAbs : -$amountAbs;
$payload = [
'parent_id' => (int)$data['parent_id'], // ← users.id of the parent
'invoice_id' => $invoiceId,
'school_year' => $data['school_year'] ?? $this->schoolYear,
'semester' => $data['semester'] ?? $this->semester,
'charge_type' => $chargeType,
'title' => trim($data['title']),
'description' => trim($data['description'] ?? ''),
'amount' => $signedAmount,
'due_date' => !empty($data['due_date']) ? $data['due_date'] : null,
'status' => $invoiceId ? 'applied' : 'pending',
'created_by' => (int)(session()->get('user_id') ?? 0),
'created_at' => \CodeIgniter\I18n\Time::now('UTC')->toDateTimeString(), // store UTC
];
$this->db->transStart();
// BEFORE
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
// Insert charge
$this->additionalChargeModel->insert($payload);
$chargeId = (int)$this->additionalChargeModel->getInsertID();
// Apply to invoice if present
if ($invoiceId) {
try {
if ($chargeType === 'add') {
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
} else {
$this->invoiceModel->deductAdditionalCharge($invoiceId, $amountAbs);
}
} catch (\Throwable $e) {
log_message('error', 'apply/deductAdditionalCharge failed: ' . $e->getMessage());
}
}
// AFTER
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $this->schoolYear);
// Parent USER (not parent table)
$parentUser = $this->userModel->getUserInfoById($data['parent_id']);
$this->db->transComplete();
if (!$this->db->transStatus()) {
$err = $this->db->error();
log_message('error', 'Transaction failed in ExtraChargesController::store: ' . json_encode($err));
if ($this->wantsJson()) {
return $this->response->setJSON(['ok' => false, 'error' => 'Failed to save charge', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
}
return redirect()->back()->withInput()->with('error', 'Failed to save charge.');
}
// Build event payload (same style as your payment handler)
// allow either first_name/last_name OR firstname/lastname from users table
$first = $parentUser['first_name'] ?? $parentUser['firstname'] ?? '';
$last = $parentUser['last_name'] ?? $parentUser['lastname'] ?? '';
$parentName = trim($first . ' ' . $last);
$invoiceBefore = $this->normalizeRow($invoiceBefore);
$invoiceAfter = $this->normalizeRow($invoiceAfter);
$invoiceTotal = $invoiceAfter ? (float)($invoiceAfter['total_amount'] ?? 0) : 0.0;
$preBalance = $invoiceBefore ? (float)($invoiceBefore['balance'] ?? 0) : 0.0;
$postBalance = $invoiceAfter ? (float)($invoiceAfter['balance'] ?? 0) : 0.0;
$eventData = [
// user identity (parent user)
'user_id' => (int)($parentUser['id'] ?? 0),
'email' => $parentUser['email'] ?? null,
'firstname' => $first,
'lastname' => $last,
'parentName' => $parentName,
// context
'school_year' => $payload['school_year'],
'semester' => $payload['semester'],
// invoice
'invoice_id' => $invoiceId,
'invoice_number' => $invoiceAfter['invoice_number'] ?? ($invoiceId ? 'INV-' . $invoiceId : null),
// charge details
'charge_id' => $chargeId,
'charge_title' => $payload['title'],
'charge_desc' => $payload['description'],
'charge_type' => $payload['charge_type'], // add|deduct
'amount_signed' => $signedAmount,
'amount_abs' => $amountAbs,
'due_date' => $payload['due_date'],
'created_at' => $payload['created_at'],
// money snapshot
'pre_balance' => $preBalance,
'post_balance' => $postBalance,
'invoice_total' => $invoiceTotal,
// links
'portal_link' => base_url('/login'),
'invoice_link' => $invoiceId ? site_url('parent/invoices/view/' . $invoiceId) : site_url('parent/invoices'),
];
$students = []; // keep [] unless you want to include children under this user
Events::trigger('extraCharge', $eventData, $students);
if ($this->wantsJson()) {
return $this->response->setJSON([
'ok' => true,
'id' => $chargeId,
'invoice_id' => $invoiceId,
'parent_id' => (int)$data['parent_id'],
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
return redirect()->to(site_url('admin/charges'))->with('status', 'Charge recorded.');
}
/**
* Mark a charge as void and roll back its impact on the invoice if applied.
*/
public function void($id)
{
$row = $this->additionalChargeModel->find((int)$id);
if (!$row) {
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
return redirect()->back()->with('error', 'Charge not found.');
}
$invoiceId = (int)($row['invoice_id'] ?? 0);
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
$chargeType = (string)($row['charge_type'] ?? 'add');
$status = (string)($row['status'] ?? 'pending');
$this->db->transStart();
if ($status === 'applied' && $invoiceId > 0 && $amountAbs > 0) {
try {
if ($chargeType === 'add') {
$this->invoiceModel->reverseAdditionalCharge($invoiceId, $amountAbs);
} else {
// voiding a deduction -> add back
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
}
} catch (\Throwable $e) {
log_message('error', 'void(): invoice adjust failed: ' . $e->getMessage());
}
}
$this->additionalChargeModel->update((int)$id, [
'status' => 'void',
]);
$this->db->transComplete();
if (!$this->db->transStatus()) {
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to void charge']);
return redirect()->back()->with('error', 'Failed to void charge.');
}
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
return redirect()->back()->with('status', 'Charge voided.');
}
/**
* Reverse a previously applied charge: undo invoice impact and return to pending state.
*/
public function reverse($id)
{
$row = $this->additionalChargeModel->find((int)$id);
if (!$row) {
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
return redirect()->back()->with('error', 'Charge not found.');
}
$invoiceId = (int)($row['invoice_id'] ?? 0);
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
$chargeType = (string)($row['charge_type'] ?? 'add');
$status = (string)($row['status'] ?? 'pending');
if ($status !== 'applied' || $invoiceId <= 0 || $amountAbs <= 0) {
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Nothing to reverse']);
return redirect()->back()->with('error', 'Nothing to reverse.');
}
$this->db->transStart();
try {
if ($chargeType === 'add') {
$this->invoiceModel->reverseAdditionalCharge($invoiceId, $amountAbs);
} else {
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
}
} catch (\Throwable $e) {
log_message('error', 'reverse(): invoice adjust failed: ' . $e->getMessage());
}
$this->additionalChargeModel->update((int)$id, [
'status' => 'pending',
'invoice_id' => null,
]);
$this->db->transComplete();
if (!$this->db->transStatus()) {
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to reverse charge']);
return redirect()->back()->with('error', 'Failed to reverse charge.');
}
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
return redirect()->back()->with('status', 'Charge reversed to pending.');
}
/** JSON: list charges for the current term (with optional filters). */
public function apiList()
{
$year = (string)($this->request->getGet('school_year') ?? $this->schoolYear);
$sem = (string)($this->request->getGet('semester') ?? $this->semester);
$status = $this->request->getGet('status') ?: null;
$q = trim((string)($this->request->getGet('q') ?? '')) ?: null;
$per = (int)($this->request->getGet('per_page') ?? 50);
$rows = $this->additionalChargeModel->listAllForTerm($year, $sem, $status, $q, $per);
$pager = $this->additionalChargeModel->pager;
$meta = [
'perPage' => method_exists($pager, 'getPerPage') ? $pager->getPerPage() : $per,
'page' => method_exists($pager, 'getCurrentPage') ? $pager->getCurrentPage() : null,
'total' => method_exists($pager, 'getTotal') ? $pager->getTotal() : null,
'pageCount' => method_exists($pager, 'getPageCount') ? $pager->getPageCount() : null,
];
return $this->response->setJSON([
'ok' => true,
'school_year' => $year,
'semester' => $sem,
'rows' => $rows,
'pager' => $meta,
'csrf_token' => csrf_token(),
'csrf_hash' => csrf_hash(),
]);
}
// Normalize query result into a single associative row
private function normalizeRow($row): ?array
{
if (!$row) return null;
if (is_array($row) && isset($row[0]) && is_array($row[0])) {
return $row[0]; // unwrap first row from a result set
}
return is_array($row) ? $row : null;
}
}