fix event charge and invoice update
This commit is contained in:
@@ -13,6 +13,7 @@ use App\Models\InvoiceModel;
|
||||
use App\Models\InvoiceEventModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\PaymentModel;
|
||||
use Config\Database;
|
||||
#use ???
|
||||
use App\Controllers\View\InvoiceController;
|
||||
use CodeIgniter\RESTful\ResourceController;
|
||||
@@ -34,6 +35,7 @@ class EventController extends ResourceController
|
||||
protected $semester;
|
||||
protected $categories;
|
||||
protected $enrollmentModel;
|
||||
private ?bool $eventChargesHasCreatedBy = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -60,6 +62,22 @@ class EventController extends ResourceController
|
||||
];
|
||||
}
|
||||
|
||||
private function eventChargesSupportsCreatedBy(): bool
|
||||
{
|
||||
if ($this->eventChargesHasCreatedBy !== null) {
|
||||
return $this->eventChargesHasCreatedBy;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::connect();
|
||||
$this->eventChargesHasCreatedBy = $db->fieldExists('created_by', 'event_charges');
|
||||
} catch (\Throwable $e) {
|
||||
$this->eventChargesHasCreatedBy = false;
|
||||
}
|
||||
|
||||
return $this->eventChargesHasCreatedBy;
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$eventModel = new EventModel();
|
||||
@@ -216,7 +234,7 @@ class EventController extends ResourceController
|
||||
$semester = $this->request->getGet('semester') ?? $this->semester;
|
||||
|
||||
$parents = $this->userModel->getParents();
|
||||
$events = $this->eventModel->getActiveEvents($this->schoolYear);
|
||||
$events = $this->eventModel->getActiveEvents($schoolYear);
|
||||
$filterEventId = (int) ($this->request->getGet('event_id') ?? 0);
|
||||
$filterParentId = (int) ($this->request->getGet('parent_id') ?? 0);
|
||||
|
||||
@@ -324,13 +342,24 @@ class EventController extends ResourceController
|
||||
$parentId = $this->request->getPost('parent_id');
|
||||
$eventId = $this->request->getPost('event_id');
|
||||
$participations = $this->request->getPost('participation') ?? [];
|
||||
$externalParticipants = $this->request->getPost('external_participants') ?? [];
|
||||
|
||||
if (!$parentId || !$eventId || empty($participations)) {
|
||||
// Allow "external only" submissions (no enrolled students selected).
|
||||
if (!$eventId || (empty($participations) && empty($externalParticipants))) {
|
||||
return redirect()->back()->with('error', 'Missing required information.');
|
||||
}
|
||||
if (!empty($participations) && !$parentId) {
|
||||
return redirect()->back()->with('error', 'Select a parent when adding enrolled students.');
|
||||
}
|
||||
|
||||
$userId = session()->get('user_id');
|
||||
$event = $this->eventModel->getEvent($eventId, $schoolYear);
|
||||
if (!$event) {
|
||||
return redirect()->back()->with('error', 'Selected event not found for the chosen school year.');
|
||||
}
|
||||
|
||||
$parentsForInvoice = [];
|
||||
$supportsCreatedBy = $this->eventChargesSupportsCreatedBy();
|
||||
|
||||
foreach ($participations as $studentId => $value) {
|
||||
$existing = $this->eventChargesModel->where([
|
||||
@@ -363,7 +392,7 @@ class EventController extends ResourceController
|
||||
$this->eventChargesModel->update($existing['id'], $updateData);
|
||||
continue;
|
||||
} else {
|
||||
$this->eventChargesModel->insert([
|
||||
$insertData = [
|
||||
'parent_id' => $parentId,
|
||||
'student_id' => $studentId,
|
||||
'event_id' => $eventId,
|
||||
@@ -373,15 +402,113 @@ class EventController extends ResourceController
|
||||
'class_section_id' => $classSectionId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'created_by' => $userId,
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
];
|
||||
if ($supportsCreatedBy) {
|
||||
$insertData['created_by'] = $userId;
|
||||
}
|
||||
$this->eventChargesModel->insert($insertData);
|
||||
}
|
||||
}
|
||||
|
||||
$this->invoiceController->generateInvoice($parentId);
|
||||
foreach ($externalParticipants as $pieces) {
|
||||
$firstname = $this->normalizeExternalName($pieces['firstname'] ?? '');
|
||||
$lastname = $this->normalizeExternalName($pieces['lastname'] ?? '');
|
||||
$note = trim($pieces['note'] ?? '');
|
||||
$parentFirst = $this->normalizeExternalName($pieces['parent_firstname'] ?? '');
|
||||
$parentLast = $this->normalizeExternalName($pieces['parent_lastname'] ?? '');
|
||||
$parentPhone = trim($pieces['parent_phone'] ?? '');
|
||||
$parentEmail = trim((string)($pieces['parent_email'] ?? ''));
|
||||
$markPaid = (string)($pieces['paid'] ?? '0') === '1';
|
||||
|
||||
$this->fixInvoiceStatusAfterCharge($parentId, $schoolYear);
|
||||
if (!$firstname && !$lastname) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matchedParentId = $this->findExistingParentIdByExternalContact(
|
||||
$parentFirst,
|
||||
$parentLast,
|
||||
$parentEmail,
|
||||
$parentPhone
|
||||
);
|
||||
|
||||
// Match external participants by kid name + event context.
|
||||
// Parent contact info can change (or be fixed later) and should be updated, not used for matching.
|
||||
$matchConditions = [
|
||||
'event_id' => $eventId,
|
||||
'external_firstname' => $firstname,
|
||||
'external_lastname' => $lastname,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
];
|
||||
if ($matchedParentId) {
|
||||
$matchConditions['parent_id'] = $matchedParentId;
|
||||
} else {
|
||||
// External-only parent: match by external parent contact to avoid duplicates.
|
||||
$matchConditions['parent_id'] = null;
|
||||
$matchConditions['external_parent_firstname'] = $parentFirst ?: null;
|
||||
$matchConditions['external_parent_lastname'] = $parentLast ?: null;
|
||||
$matchConditions['external_parent_phone'] = $parentPhone ?: null;
|
||||
$matchConditions['external_parent_email'] = $parentEmail ?: null;
|
||||
}
|
||||
$existingExternal = $this->eventChargesModel->where($matchConditions)->first();
|
||||
|
||||
if ($existingExternal) {
|
||||
$this->eventChargesModel->update($existingExternal['id'], [
|
||||
'participation' => 'yes',
|
||||
'charged' => $event['amount'],
|
||||
'updated_by' => $userId,
|
||||
// Preserve existing payment status; only update contact fields when provided.
|
||||
'external_note' => $note !== '' ? $note : ($existingExternal['external_note'] ?? null),
|
||||
'external_parent_firstname' => $parentFirst !== '' ? $parentFirst : ($existingExternal['external_parent_firstname'] ?? null),
|
||||
'external_parent_lastname' => $parentLast !== '' ? $parentLast : ($existingExternal['external_parent_lastname'] ?? null),
|
||||
'external_parent_phone' => $parentPhone !== '' ? $parentPhone : ($existingExternal['external_parent_phone'] ?? null),
|
||||
'external_parent_email' => $parentEmail !== '' ? $parentEmail : ($existingExternal['external_parent_email'] ?? null),
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$insertData = [
|
||||
'parent_id' => $matchedParentId ?: null,
|
||||
'student_id' => null,
|
||||
'event_id' => $eventId,
|
||||
'participation' => 'yes',
|
||||
'charged' => $event['amount'],
|
||||
'event_paid' => $markPaid ? 1 : 0,
|
||||
'external_firstname' => $firstname,
|
||||
'external_lastname' => $lastname,
|
||||
'external_note' => $note,
|
||||
'external_parent_firstname' => $parentFirst,
|
||||
'external_parent_lastname' => $parentLast,
|
||||
'external_parent_phone' => $parentPhone,
|
||||
'external_parent_email' => $parentEmail,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'updated_by' => $userId,
|
||||
];
|
||||
if ($supportsCreatedBy) {
|
||||
$insertData['created_by'] = $userId;
|
||||
}
|
||||
$this->eventChargesModel->insert($insertData);
|
||||
|
||||
$newChargeId = (int)$this->eventChargesModel->getInsertID();
|
||||
if ($markPaid && $newChargeId > 0) {
|
||||
$this->applyEventPaymentStatus($newChargeId, true);
|
||||
}
|
||||
|
||||
if ($matchedParentId && $this->parentHasEnrollment($matchedParentId, (string)$schoolYear)) {
|
||||
$parentsForInvoice[$matchedParentId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($parentId && $this->parentHasEnrollment((int)$parentId, (string)$schoolYear)) {
|
||||
$parentsForInvoice[(int)$parentId] = true;
|
||||
}
|
||||
|
||||
foreach (array_keys($parentsForInvoice) as $pid) {
|
||||
$this->invoiceController->generateInvoice((string)$pid, (string)$schoolYear, (string)$semester, false);
|
||||
$this->fixInvoiceStatusAfterCharge((int)$pid, (string)$schoolYear);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Event charges updated successfully.');
|
||||
}
|
||||
@@ -403,7 +530,9 @@ class EventController extends ResourceController
|
||||
}
|
||||
|
||||
$this->eventChargesModel->delete($chargeId);
|
||||
$this->invoiceController->generateInvoice($charge['parent_id']);
|
||||
if (!empty($charge['parent_id']) && $this->parentHasEnrollment((int)$charge['parent_id'], (string)($charge['school_year'] ?? ''))) {
|
||||
$this->invoiceController->generateInvoice((string)$charge['parent_id'], (string)($charge['school_year'] ?? $this->schoolYear), (string)($charge['semester'] ?? $this->semester), false);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Participation removed, invoices updated.');
|
||||
}
|
||||
@@ -414,42 +543,209 @@ class EventController extends ResourceController
|
||||
return redirect()->back()->with('error', 'Invalid charge.');
|
||||
}
|
||||
|
||||
$charge = $this->eventChargesModel->find($chargeId);
|
||||
if (!$charge) {
|
||||
$isPaid = $this->request->getPost('paid') === '1';
|
||||
$meta = $this->applyEventPaymentStatus((int)$chargeId, $isPaid);
|
||||
if (!$meta) {
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
|
||||
$isPaid = $this->request->getPost('paid') === '1';
|
||||
if (!empty($meta['invoice_id'])) {
|
||||
$this->invoiceController->generateInvoice((string)$meta['parent_id'], (string)$meta['school_year'], (string)($meta['semester'] ?? $this->semester), false);
|
||||
$this->fixInvoiceStatusAfterCharge((int)$meta['parent_id'], (string)$meta['school_year']);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('success', 'Event payment status updated.');
|
||||
}
|
||||
|
||||
private function parentHasEnrollment(int $parentId, string $schoolYear): bool
|
||||
{
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->countAllResults() > 0;
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function findExistingParentIdByExternalContact(string $first, string $last, string $email, string $phone): ?int
|
||||
{
|
||||
$first = trim($first);
|
||||
$last = trim($last);
|
||||
$email = trim($email);
|
||||
$phoneDigits = preg_replace('/\D+/', '', (string)$phone);
|
||||
|
||||
if ($first === '' || $last === '' || $email === '' || $phoneDigits === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = Database::connect();
|
||||
$expr = "REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') =";
|
||||
|
||||
$row = $this->userModel
|
||||
->select('id')
|
||||
->where('LOWER(firstname)', strtolower($first))
|
||||
->where('LOWER(lastname)', strtolower($last))
|
||||
->where('LOWER(email)', strtolower($email))
|
||||
->where($expr, $db->escapeString($phoneDigits), false)
|
||||
->first();
|
||||
|
||||
$id = (int)($row['id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to match external parent to users: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function syncInvoicePaymentSummary(int $invoiceId): void
|
||||
{
|
||||
if ($invoiceId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
|
||||
$paidSum = 0.0;
|
||||
try {
|
||||
$qb = $db->table('payments')
|
||||
->select('COALESCE(SUM(paid_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('paid_amount >', 0);
|
||||
|
||||
if ($db->fieldExists('status', 'payments')) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', $exclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($db->fieldExists('is_void', 'payments')) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$row = $qb->get()->getRowArray();
|
||||
$paidSum = (float)($row['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to sum payments for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$discountSum = 0.0;
|
||||
try {
|
||||
$row = $db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
$discountSum = (float)($row['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to sum discounts for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$refundSum = 0.0;
|
||||
try {
|
||||
$row = $db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->get()
|
||||
->getRowArray();
|
||||
$refundSum = (float)($row['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to sum refunds for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$total = (float)($invoice['total_amount'] ?? 0.0);
|
||||
$newBalance = round($total - $discountSum - $refundSum - $paidSum, 2);
|
||||
$newStatus = ($newBalance <= 0.00001)
|
||||
? 'Paid'
|
||||
: (($paidSum > 0) ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
$this->invoiceModel->update($invoiceId, [
|
||||
'paid_amount' => $paidSum,
|
||||
'balance' => $newBalance,
|
||||
'status' => $newStatus,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function applyEventPaymentStatus(int $chargeId, bool $isPaid): ?array
|
||||
{
|
||||
if ($chargeId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$charge = $this->eventChargesModel->find($chargeId);
|
||||
if (!$charge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$event = $this->eventModel->find($charge['event_id']);
|
||||
$eventAmount = max(0.0, (float)($event['amount'] ?? 0));
|
||||
$paymentId = (int)($charge['event_payment_id'] ?? 0);
|
||||
|
||||
$parentId = $charge['parent_id'];
|
||||
$schoolYear = $charge['school_year'] ?? $this->schoolYear;
|
||||
$parentId = (int)($charge['parent_id'] ?? 0);
|
||||
$schoolYear = (string)($charge['school_year'] ?? $this->schoolYear);
|
||||
$semester = (string)($charge['semester'] ?? $this->semester);
|
||||
|
||||
if (!empty($eventAmount)) {
|
||||
$invoice = $this->invoiceModel
|
||||
if ($parentId > 0 && $eventAmount > 0) {
|
||||
$hasEnrollment = $this->parentHasEnrollment($parentId, $schoolYear);
|
||||
$invoiceQuery = $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
->orderBy('id', 'DESC');
|
||||
$invoice = $invoiceQuery->first();
|
||||
|
||||
if (!$invoice) {
|
||||
$this->invoiceController->generateInvoice($parentId);
|
||||
$invoice = $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
if (!$invoice && $hasEnrollment) {
|
||||
$this->invoiceController->generateInvoice((string)$parentId, $schoolYear, $semester, false);
|
||||
$invoice = $invoiceQuery->first();
|
||||
}
|
||||
|
||||
if ($invoice) {
|
||||
$invoiceId = (int)($invoice['id'] ?? 0);
|
||||
if ($invoiceId > 0) {
|
||||
$this->syncInvoicePaymentSummary($invoiceId);
|
||||
$invoice = $this->invoiceModel->find($invoiceId) ?? $invoice;
|
||||
}
|
||||
|
||||
if ($isPaid && !$paymentId) {
|
||||
$paymentId = $this->createEventPayment($parentId, (int)$invoice['id'], $eventAmount, $schoolYear, $charge['semester'] ?? $this->semester);
|
||||
$paymentSchoolYear = (string)($invoice['school_year'] ?? $schoolYear);
|
||||
$paymentSemester = (string)($invoice['semester'] ?? $semester);
|
||||
$paymentId = (int)($this->createEventPayment(
|
||||
$parentId,
|
||||
(int)$invoice['id'],
|
||||
$eventAmount,
|
||||
$paymentSchoolYear,
|
||||
$paymentSemester,
|
||||
(float)($invoice['total_amount'] ?? 0.0),
|
||||
(float)($invoice['paid_amount'] ?? 0.0),
|
||||
(float)($invoice['balance'] ?? 0.0)
|
||||
) ?? 0);
|
||||
} elseif (!$isPaid && $paymentId > 0) {
|
||||
$this->paymentModel->delete($paymentId);
|
||||
$paymentId = 0;
|
||||
}
|
||||
|
||||
if (!empty($invoiceId)) {
|
||||
$this->syncInvoicePaymentSummary($invoiceId);
|
||||
}
|
||||
} elseif (!$hasEnrollment) {
|
||||
// External-only parent: do not create an invoice/payment row; just persist the paid flag on the charge.
|
||||
$paymentId = 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,10 +755,12 @@ class EventController extends ResourceController
|
||||
'event_payment_id' => $paymentId > 0 ? $paymentId : null,
|
||||
]);
|
||||
|
||||
$this->invoiceController->generateInvoice($parentId);
|
||||
$this->fixInvoiceStatusAfterCharge($parentId, $schoolYear);
|
||||
|
||||
return redirect()->back()->with('success', 'Event payment status updated.');
|
||||
return [
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'invoice_id' => isset($invoiceId) && $invoiceId > 0 ? $invoiceId : null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -506,25 +804,48 @@ class EventController extends ResourceController
|
||||
return $this->response->setJSON($data);
|
||||
}
|
||||
|
||||
private function createEventPayment(int $parentId, int $invoiceId, float $amount, string $schoolYear, ?string $semester): ?int
|
||||
private function createEventPayment(
|
||||
int $parentId,
|
||||
int $invoiceId,
|
||||
float $amount,
|
||||
string $schoolYear,
|
||||
?string $semester,
|
||||
float $invoiceTotal = 0.0,
|
||||
float $invoicePaid = 0.0,
|
||||
float $invoiceBalance = 0.0
|
||||
): ?int
|
||||
{
|
||||
if ($amount <= 0 || $invoiceId <= 0 || $parentId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$monthSemester = $semester ?: $this->semester;
|
||||
$newPaid = (float)$invoicePaid + $amount;
|
||||
$newBalance = (float)$invoiceBalance - $amount;
|
||||
$paymentStatus = ($newBalance <= 0.00001)
|
||||
? 'Paid'
|
||||
: (($newPaid > 0) ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
$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;
|
||||
|
||||
$data = [
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => $amount,
|
||||
'total_amount' => $invoiceTotal > 0 ? $invoiceTotal : $amount,
|
||||
'paid_amount' => $amount,
|
||||
'balance' => 0.0,
|
||||
'number_of_installments' => 1,
|
||||
'payment_method' => 'Cash',
|
||||
'balance' => $newBalance,
|
||||
'number_of_installments' => $installmentSeq,
|
||||
'payment_method' => 'cash',
|
||||
'payment_date' => utc_now(),
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $monthSemester,
|
||||
'status' => 'Paid',
|
||||
'status' => $paymentStatus,
|
||||
'transaction_id' => $this->paymentModel->generateNewTransactionId(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
];
|
||||
@@ -537,6 +858,23 @@ class EventController extends ResourceController
|
||||
return (int)$this->paymentModel->getInsertID();
|
||||
}
|
||||
|
||||
private function normalizeExternalName(?string $value): string
|
||||
{
|
||||
$raw = trim((string)$value);
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
$parts = preg_split('/\s+/', $raw);
|
||||
$formatted = array_map(function ($part) {
|
||||
$part = trim($part);
|
||||
if ($part === '') {
|
||||
return '';
|
||||
}
|
||||
return mb_strtoupper(mb_substr($part, 0, 1)) . mb_strtolower(mb_substr($part, 1));
|
||||
}, $parts);
|
||||
return implode(' ', array_filter($formatted, fn($p) => $p !== ''));
|
||||
}
|
||||
|
||||
private function fixInvoiceStatusAfterCharge($parentId, $schoolYear)
|
||||
{
|
||||
if (!$parentId || !$schoolYear) {
|
||||
|
||||
@@ -68,17 +68,45 @@ public function financialReport()
|
||||
}
|
||||
|
||||
// === Invoices ===
|
||||
$invoices = $invoiceModel
|
||||
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
|
||||
->join('users', 'users.id = invoices.parent_id')
|
||||
->findAll();
|
||||
// Business rule: one active invoice per parent per school year.
|
||||
// If legacy data has multiple invoices, show only the latest one to avoid reporting older invoices as "Unpaid"
|
||||
// after new charges/payments update the current invoice.
|
||||
$db = \Config\Database::connect();
|
||||
if (!empty($schoolYear)) {
|
||||
$latestSub = $db->table('invoices')
|
||||
->select('parent_id, MAX(id) AS max_id')
|
||||
->where('school_year', $schoolYear)
|
||||
->groupBy('parent_id')
|
||||
->getCompiledSelect();
|
||||
|
||||
$invBuilder = $db->table('invoices')
|
||||
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name", false)
|
||||
->join("($latestSub) latest", 'latest.max_id = invoices.id', 'inner', false)
|
||||
->join('users', 'users.id = invoices.parent_id', 'left');
|
||||
|
||||
if ($hasFrom) {
|
||||
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >=', $dateFrom);
|
||||
}
|
||||
if ($hasTo) {
|
||||
$invBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $dateTo);
|
||||
}
|
||||
|
||||
$invoices = $invBuilder->get()->getResultArray();
|
||||
} else {
|
||||
$invoices = $invoiceModel
|
||||
->select("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
|
||||
->join('users', 'users.id = invoices.parent_id')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
$invoiceIds = array_values(array_unique(array_filter(array_map(static function ($inv) {
|
||||
$id = (int)($inv['id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
}, $invoices))));
|
||||
|
||||
// Helper to build a fresh, filtered PaymentModel each time (so filters don't get lost between queries)
|
||||
$buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) {
|
||||
$pm = new PaymentModel();
|
||||
if ($schoolYear) {
|
||||
$pm->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($dateFrom)) {
|
||||
$pm->where('DATE(payment_date) >=', $dateFrom);
|
||||
}
|
||||
@@ -114,7 +142,13 @@ public function financialReport()
|
||||
};
|
||||
|
||||
// === Payments aggregated by invoice_id (for the "Paid" column) ===
|
||||
$payments = $applyPaymentFilters($buildPaymentModel())
|
||||
$paymentsQuery = $applyPaymentFilters($buildPaymentModel());
|
||||
if (!empty($invoiceIds)) {
|
||||
$paymentsQuery->whereIn('invoice_id', $invoiceIds);
|
||||
} else {
|
||||
$paymentsQuery->where('invoice_id', -1);
|
||||
}
|
||||
$payments = $paymentsQuery
|
||||
->select('invoice_id, SUM(paid_amount) AS paid_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy('invoice_id')
|
||||
@@ -138,6 +172,7 @@ public function financialReport()
|
||||
SUM(paid_amount) AS amount
|
||||
")
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->whereIn('invoice_id', $invoiceIds ?: [-1])
|
||||
->groupBy('invoice_id, method')
|
||||
->findAll();
|
||||
|
||||
@@ -161,6 +196,7 @@ public function financialReport()
|
||||
SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN paid_amount ELSE 0 END) AS total_credit
|
||||
")
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->whereIn('invoice_id', $invoiceIds ?: [-1])
|
||||
->first() ?? [];
|
||||
|
||||
$paymentTotals = [
|
||||
|
||||
@@ -381,18 +381,24 @@ class InvoiceController extends ResourceController
|
||||
return false;
|
||||
}
|
||||
|
||||
public function generateInvoice(string $parentId = null)
|
||||
public function generateInvoice(
|
||||
string $parentId = null,
|
||||
?string $schoolYearOverride = null,
|
||||
?string $semesterOverride = null,
|
||||
bool $recalculateDiscounts = true
|
||||
)
|
||||
{
|
||||
$isAjax = $this->request->isAJAX() || str_contains(strtolower($this->request->getHeaderLine('Accept')), 'application/json');
|
||||
if ($parentId == null) {
|
||||
$parentId = (int)$this->request->getPost('parent_id');
|
||||
}
|
||||
$schoolYear = (string) $this->schoolYear;
|
||||
$schoolYear = (string) ($schoolYearOverride ?: $this->schoolYear);
|
||||
$semester = (string) ($semesterOverride ?: $this->semester);
|
||||
|
||||
// Fetch enrolled + withdrawn students
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
if (empty($enrollments)) {
|
||||
@@ -444,21 +450,23 @@ class InvoiceController extends ResourceController
|
||||
$tuitionFee = $fees['tuition_fee'];
|
||||
|
||||
// ✅ Fetch event charges
|
||||
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $this->schoolYear);
|
||||
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
|
||||
$eventchargeTotal = array_sum(array_column($eventsList, 'charged'));
|
||||
|
||||
$totalDiscount = $this->recalculateAndUpdateDiscount(
|
||||
$parentId,
|
||||
$this->schoolYear,
|
||||
$tuitionFee,
|
||||
$enrollments
|
||||
);
|
||||
$totalDiscount = 0.0;
|
||||
if ($recalculateDiscounts) {
|
||||
$totalDiscount = $this->recalculateAndUpdateDiscount(
|
||||
$parentId,
|
||||
$schoolYear,
|
||||
$tuitionFee,
|
||||
$enrollments
|
||||
);
|
||||
}
|
||||
|
||||
$semester = $this->semester;
|
||||
// ✅ Refunds PAID to the parent for this year (Partial/Paid)
|
||||
$refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $this->schoolYear);
|
||||
$refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $schoolYear);
|
||||
|
||||
$totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $this->schoolYear);
|
||||
$totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
|
||||
|
||||
$discountedTuition = max(0, $tuitionFee);
|
||||
$totalAmount = $discountedTuition + $eventchargeTotal;
|
||||
@@ -469,17 +477,22 @@ class InvoiceController extends ResourceController
|
||||
- $refundPaid // approved refunds paid to parent
|
||||
- $totalPaid; // payments received
|
||||
|
||||
// Your invoice fetch
|
||||
$existingInvoices = $this->invoiceModel->getInvoicesByParentId($parentId, $this->schoolYear);
|
||||
// Business rule: single invoice per parent per school year.
|
||||
// If legacy duplicates exist, prefer the invoice that already has a discount applied,
|
||||
// otherwise use the latest invoice for the parent/year.
|
||||
$invoice = $this->selectActiveInvoiceForParentYear((int)$parentId, $schoolYear);
|
||||
|
||||
$updated = false;
|
||||
|
||||
$updatedIds = [];
|
||||
if (!empty($existingInvoices)) {
|
||||
foreach ($existingInvoices as $invoice) {
|
||||
if (!isset($invoice['id'])) {
|
||||
continue;
|
||||
}
|
||||
if (!empty($invoice) && isset($invoice['id'])) {
|
||||
$paymentExclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
$paymentsHasStatus = false;
|
||||
$paymentsHasVoid = false;
|
||||
try {
|
||||
$paymentsHasStatus = $this->db->fieldExists('status', 'payments');
|
||||
$paymentsHasVoid = $this->db->fieldExists('is_void', 'payments');
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
// Preserve applied additional charges and recalc this invoice only
|
||||
$extrasSum = 0.0;
|
||||
@@ -487,7 +500,7 @@ class InvoiceController extends ResourceController
|
||||
$rows = $this->db->table('additional_charges')
|
||||
->select('charge_type, amount')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('status', 'applied')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
@@ -520,7 +533,7 @@ class InvoiceController extends ResourceController
|
||||
$r = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('school_year', $this->schoolYear)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$invRefunds = (float)($r['tot'] ?? 0.0);
|
||||
@@ -528,8 +541,33 @@ class InvoiceController extends ResourceController
|
||||
log_message('error', 'refund sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Payments recorded on this invoice (denormalized field kept in sync by PaymentController)
|
||||
$paidOnInv = (float)($invoice['paid_amount'] ?? 0.0);
|
||||
// Payments recorded on this invoice (sum payments to avoid stale invoice.paid_amount)
|
||||
$paidOnInv = 0.0;
|
||||
try {
|
||||
$qb = $this->db->table('payments')
|
||||
->select('COALESCE(SUM(paid_amount),0) AS tot')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('paid_amount >', 0);
|
||||
|
||||
if ($paymentsHasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', $paymentExclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($paymentsHasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$row = $qb->get()->getRowArray();
|
||||
$paidOnInv = (float)($row['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'payment sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
$paidOnInv = (float)($invoice['paid_amount'] ?? 0.0);
|
||||
}
|
||||
|
||||
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
|
||||
$newStatus = ($newBalance <= 0.00001)
|
||||
@@ -547,7 +585,6 @@ class InvoiceController extends ResourceController
|
||||
$updatedIds[] = (int)$invoice['id'];
|
||||
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
|
||||
$updated = true;
|
||||
}
|
||||
} else {
|
||||
// Generate invoice number
|
||||
$schoolId = $this->userModel->getSchoolIdByUserId($parentId);
|
||||
@@ -578,7 +615,7 @@ class InvoiceController extends ResourceController
|
||||
// Initial balance equals the created total; discounts/refunds/payments will adjust later
|
||||
'balance' => $totalAmount,
|
||||
'status' => 'Unpaid',
|
||||
'school_year' => $this->schoolYear,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'issue_date' => $issueUtc,
|
||||
'due_date' => $dueUtc,
|
||||
@@ -615,6 +652,47 @@ class InvoiceController extends ResourceController
|
||||
->with('success', $updated ? 'Invoice updated.' : 'Invoice created.');
|
||||
}
|
||||
|
||||
private function selectActiveInvoiceForParentYear(int $parentId, string $schoolYear): ?array
|
||||
{
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer invoices flagged as having discounts.
|
||||
$invoice = $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('has_discount', 1)
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
if (!empty($invoice)) {
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
// Fallback: prefer invoices with discount_usages rows.
|
||||
try {
|
||||
$row = $this->db->table('invoices i')
|
||||
->select('i.*')
|
||||
->join('discount_usages du', 'du.invoice_id = i.id', 'inner')
|
||||
->where('i.parent_id', $parentId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->orderBy('i.id', 'DESC')
|
||||
->get()
|
||||
->getRowArray();
|
||||
if (!empty($row)) {
|
||||
return $row;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
// Final fallback: latest invoice for parent/year.
|
||||
return $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
}
|
||||
|
||||
private function recalculateAndUpdateDiscount(
|
||||
int $parentId,
|
||||
string $schoolYear,
|
||||
@@ -1266,6 +1344,12 @@ class InvoiceController extends ResourceController
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($studentName === 'N/A') {
|
||||
$externalName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? ''));
|
||||
if ($externalName !== '') {
|
||||
$studentName = $externalName . ' (external)';
|
||||
}
|
||||
}
|
||||
|
||||
$dt = $toLocal($event['created_at'] ?? null, false);
|
||||
$amount = (float)($event['charged'] ?? 0.0);
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddExternalParticipantFieldsToEventCharges extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$fields = [
|
||||
'external_firstname' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 150,
|
||||
'null' => true,
|
||||
'after' => 'event_payment_id',
|
||||
],
|
||||
'external_lastname' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 150,
|
||||
'null' => true,
|
||||
],
|
||||
'external_note' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 254,
|
||||
'null' => true,
|
||||
],
|
||||
];
|
||||
|
||||
if (! $this->db->fieldExists('external_firstname', 'event_charges')) {
|
||||
$this->forge->addColumn('event_charges', $fields);
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
foreach (['external_firstname', 'external_lastname', 'external_note'] as $field) {
|
||||
if ($this->db->fieldExists($field, 'event_charges')) {
|
||||
$this->forge->dropColumn('event_charges', $field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddExternalParentInfoToEventCharges extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$fields = [
|
||||
'external_parent_firstname' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 150,
|
||||
'null' => true,
|
||||
'after' => 'external_note',
|
||||
],
|
||||
'external_parent_lastname' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 150,
|
||||
'null' => true,
|
||||
],
|
||||
'external_parent_phone' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 50,
|
||||
'null' => true,
|
||||
],
|
||||
];
|
||||
|
||||
if (! $this->db->fieldExists('external_parent_firstname', 'event_charges')) {
|
||||
$this->forge->addColumn('event_charges', $fields);
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
foreach (['external_parent_firstname', 'external_parent_lastname', 'external_parent_phone'] as $field) {
|
||||
if ($this->db->fieldExists($field, 'event_charges')) {
|
||||
$this->forge->dropColumn('event_charges', $field);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddExternalParentEmailToEventCharges extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$fields = [
|
||||
'external_parent_email' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 254,
|
||||
'null' => true,
|
||||
'after' => 'external_parent_phone',
|
||||
],
|
||||
];
|
||||
|
||||
if (! $this->db->fieldExists('external_parent_email', 'event_charges')) {
|
||||
$this->forge->addColumn('event_charges', $fields);
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if ($this->db->fieldExists('external_parent_email', 'event_charges')) {
|
||||
$this->forge->dropColumn('event_charges', 'external_parent_email');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,16 @@ class EventChargesModel extends Model
|
||||
'event_paid',
|
||||
'event_payment_id',
|
||||
'class_section_id',
|
||||
'external_firstname',
|
||||
'external_lastname',
|
||||
'external_note',
|
||||
'external_parent_firstname',
|
||||
'external_parent_lastname',
|
||||
'external_parent_phone',
|
||||
'external_parent_email',
|
||||
'semester',
|
||||
'school_year',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
'created_at',
|
||||
'updated_at'
|
||||
|
||||
@@ -73,13 +73,15 @@
|
||||
<div class="card-body">
|
||||
<form id="event-participant-form" action="<?= site_url('payment/event_charges') ?>" method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="school_year" value="<?= esc($school_year) ?>">
|
||||
<input type="hidden" name="semester" value="<?= esc($semester) ?>">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="event_id" class="form-label">Select Event</label>
|
||||
<select name="event_id" id="event_id" class="form-select" required>
|
||||
<option value="">-- All events --</option>
|
||||
<?php foreach ($events as $event): ?>
|
||||
<option value="<?= esc($event['id']) ?>" <?= isset($filterEventId) && $filterEventId == $event['id'] ? 'selected' : '' ?>>
|
||||
<option value="<?= esc($event['id']) ?>" data-amount="<?= esc(number_format($event['amount'] ?? 0, 2, '.', '')) ?>" <?= isset($filterEventId) && $filterEventId == $event['id'] ? 'selected' : '' ?>>
|
||||
<?= esc($event['event_name']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
@@ -109,7 +111,7 @@
|
||||
|
||||
<div class="col-md-4">
|
||||
<label for="parent_id" class="form-label">Parents List</label>
|
||||
<select id="parent_id" name="parent_id" class="form-select" required>
|
||||
<select id="parent_id" name="parent_id" class="form-select">
|
||||
<option value="">-- Select Parent --</option>
|
||||
<?php foreach ($parents as $parent): ?>
|
||||
<option value="<?= esc($parent['id']) ?>" <?= $filterParentId && $filterParentId == $parent['id'] ? 'selected' : '' ?>>
|
||||
@@ -125,6 +127,14 @@
|
||||
<div id="studentList" class="row g-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button type="button" class="btn btn-outline-primary" data-bs-toggle="modal" data-bs-target="#externalParticipantModal">
|
||||
Add non-school participant
|
||||
</button>
|
||||
<div class="form-text">After adding participants, click Submit to save them to the database.</div>
|
||||
<div id="externalParticipantsHidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
<a href="<?= site_url('administrator/events') ?>" class="btn btn-success">Event List</a>
|
||||
@@ -136,16 +146,24 @@
|
||||
<?php
|
||||
$grouped = [];
|
||||
foreach ($charges as $charge) {
|
||||
$label = $charge['event_name'] ?? 'N/A';
|
||||
$grouped[$label][] = $charge;
|
||||
$eventId = (int)($charge['event_id'] ?? 0);
|
||||
if (!isset($grouped[$eventId])) {
|
||||
$grouped[$eventId] = [
|
||||
'label' => $charge['event_name'] ?? 'N/A',
|
||||
'rows' => [],
|
||||
];
|
||||
}
|
||||
$grouped[$eventId]['rows'][] = $charge;
|
||||
}
|
||||
?>
|
||||
|
||||
<?php if (empty($grouped)): ?>
|
||||
<div class="alert alert-info">No charges found.</div>
|
||||
<?php else: ?>
|
||||
<?php foreach ($grouped as $eventLabel => $rows): ?>
|
||||
<div class="card mb-4">
|
||||
<?php foreach ($grouped as $eventId => $data): ?>
|
||||
<?php $eventLabel = $data['label']; ?>
|
||||
<?php $rows = $data['rows']; ?>
|
||||
<div class="card mb-4 event-card" data-event-id="<?= esc($eventId) ?>">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<?= esc($eventLabel) ?>
|
||||
</div>
|
||||
@@ -155,12 +173,13 @@
|
||||
$totalCharged = 0.0;
|
||||
?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-striped mb-0 no-mgmt-sticky">
|
||||
<table id="eventTable_<?= esc($eventId) ?>" class="table table-bordered table-striped mb-0 no-mgmt-sticky">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Parent Name</th>
|
||||
<th>Student Name</th>
|
||||
<th>External Info</th>
|
||||
<th>Class Section</th>
|
||||
<th>Charged Amount</th>
|
||||
<th>Created</th>
|
||||
@@ -177,11 +196,37 @@
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($charge['id']) ?></td>
|
||||
<td><?= esc($charge['parent_firstname'] . ' ' . $charge['parent_lastname']) ?></td>
|
||||
<?php
|
||||
$studentName = $charge['student_firstname']
|
||||
? trim($charge['student_firstname'] . ' ' . $charge['student_lastname'])
|
||||
: '';
|
||||
$externalName = trim(
|
||||
($charge['external_firstname'] ?? '') . ' ' .
|
||||
($charge['external_lastname'] ?? '')
|
||||
);
|
||||
$displayName = $studentName ?: ($externalName ?: '—');
|
||||
$externalNote = $charge['external_note'] ?? '';
|
||||
$externalParentLabel = trim(
|
||||
($charge['external_parent_firstname'] ?? '') . ' ' .
|
||||
($charge['external_parent_lastname'] ?? '')
|
||||
);
|
||||
$externalParentPhone = $charge['external_parent_phone'] ?? '';
|
||||
$standardParentName = trim($charge['parent_firstname'] . ' ' . $charge['parent_lastname']);
|
||||
$parentColumnName = $externalParentLabel ?: ($standardParentName ?: '—');
|
||||
?>
|
||||
<td><?= esc($parentColumnName) ?></td>
|
||||
<td>
|
||||
<?= $charge['student_firstname']
|
||||
? esc($charge['student_firstname'] . ' ' . $charge['student_lastname'])
|
||||
: '-' ?>
|
||||
<?= esc($displayName) ?>
|
||||
<?php if ($externalNote): ?>
|
||||
<small class="text-muted d-block"><?= esc($externalNote) ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($externalParentPhone): ?>
|
||||
<div class="text-muted small"><?= esc($externalParentPhone) ?></div>
|
||||
<?php else: ?>
|
||||
<span class="text-muted small">—</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?= esc($classSectionNames[$charge['class_section_id'] ?? ''] ?? '—') ?>
|
||||
@@ -246,8 +291,63 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="externalParticipantModal" tabindex="-1" aria-labelledby="externalParticipantModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form id="externalParticipantForm" class="needs-validation" novalidate>
|
||||
<?= csrf_field() ?>
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="externalParticipantModalLabel">Add Non-School Participant</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="small text-muted">Provide the student’s name and the accompanying guardian/contact details.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Student / Kid Info</label>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="modalExternalFirstName" placeholder="First name" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="modalExternalLastName" placeholder="Last name" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Parent / Contact Info</label>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="modalParentFirstName" placeholder="Parent first name" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="modalParentLastName" placeholder="Parent last name" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="text" class="form-control" id="modalParentPhone" placeholder="Parent phone or contact">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<input type="email" class="form-control" id="modalParentEmail" placeholder="Parent email" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="modalExternalNote">Note <span class="text-muted small">(optional)</span></label>
|
||||
<textarea rows="2" class="form-control" id="modalExternalNote" placeholder="Additional info (e.g., guest, sibling, etc.)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" id="externalParticipantSave">
|
||||
Add to list
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
@@ -295,7 +395,20 @@ function loadStudentsWithCharges() {
|
||||
}
|
||||
}
|
||||
|
||||
$(function() {
|
||||
const capitalizeName = (value) => {
|
||||
if (!value) return '';
|
||||
return value
|
||||
.split(' ')
|
||||
.map(part => {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed.charAt(0).toUpperCase() + trimmed.slice(1).toLowerCase();
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
$(function() {
|
||||
$('#parent_id').on('change', loadStudentsWithCharges);
|
||||
|
||||
$('#event_id').on('change', function() {
|
||||
@@ -312,6 +425,277 @@ $(function() {
|
||||
if ($('#event_id').val() && $('#parent_id').val()) {
|
||||
loadStudentsWithCharges();
|
||||
}
|
||||
|
||||
const $externalHidden = $('#externalParticipantsHidden');
|
||||
const storageKey = 'eventChargesExternalParticipants';
|
||||
let externalDrafts = [];
|
||||
let editingKey = null;
|
||||
|
||||
const escapeHtml = (value) => {
|
||||
const s = String(value ?? '');
|
||||
return s.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
function buildExternalRow(entry) {
|
||||
const key = entry.key;
|
||||
const isPaid = !!entry.paid;
|
||||
const firstEsc = escapeHtml(entry.firstname);
|
||||
const lastEsc = escapeHtml(entry.lastname);
|
||||
const noteEsc = escapeHtml(entry.note);
|
||||
const parentFirstEsc = escapeHtml(entry.parentFirstname);
|
||||
const parentLastEsc = escapeHtml(entry.parentLastname);
|
||||
const parentPhoneEsc = escapeHtml(entry.parentPhone);
|
||||
const parentEmailEsc = escapeHtml(entry.parentEmail);
|
||||
const label = (firstEsc || lastEsc)
|
||||
? `${firstEsc || ''} ${lastEsc || ''}`.trim()
|
||||
: 'Unnamed participant';
|
||||
const parentLabel = (parentFirstEsc || parentLastEsc)
|
||||
? `<div class="text-muted small">Parent: ${parentFirstEsc} ${parentLastEsc}</div>`
|
||||
: '';
|
||||
const phoneLabel = parentPhoneEsc
|
||||
? `<div class="text-muted small">${parentPhoneEsc}</div>`
|
||||
: '';
|
||||
const parentNameForRow = entry.parentDisplayName || '—';
|
||||
const amountDisplay = ((entry.eventAmount ?? 0) || 0.0).toFixed(2);
|
||||
|
||||
const $wrapper = $(`
|
||||
<div data-key="${key}">
|
||||
<input type="hidden" name="external_participants[${key}][firstname]" value="${firstEsc}">
|
||||
<input type="hidden" name="external_participants[${key}][lastname]" value="${lastEsc}">
|
||||
<input type="hidden" name="external_participants[${key}][note]" value="${noteEsc}">
|
||||
<input type="hidden" name="external_participants[${key}][parent_firstname]" value="${parentFirstEsc}">
|
||||
<input type="hidden" name="external_participants[${key}][parent_lastname]" value="${parentLastEsc}">
|
||||
<input type="hidden" name="external_participants[${key}][parent_phone]" value="${parentPhoneEsc}">
|
||||
<input type="hidden" name="external_participants[${key}][parent_email]" value="${parentEmailEsc}">
|
||||
<input type="hidden" name="external_participants[${key}][paid]" value="${isPaid ? '1' : '0'}">
|
||||
</div>
|
||||
`);
|
||||
$externalHidden.append($wrapper);
|
||||
|
||||
const $tableBody = $(`#eventTable_${entry.eventId} tbody`);
|
||||
if (!$tableBody.length) {
|
||||
return;
|
||||
}
|
||||
const createdDisplay = new Date().toLocaleString();
|
||||
const $previewRow = $(`
|
||||
<tr class="external-preview table-warning" data-key="${key}" data-event-id="${entry.eventId}">
|
||||
<td>—</td>
|
||||
<td>${escapeHtml(parentNameForRow)}</td>
|
||||
<td>
|
||||
<strong>${label}</strong>
|
||||
${noteEsc ? `<div class="text-muted small">${noteEsc}</div>` : ''}
|
||||
</td>
|
||||
<td>
|
||||
${phoneLabel}
|
||||
${phoneLabel ? '' : '<span class="text-muted small">—</span>'}
|
||||
</td>
|
||||
<td>External</td>
|
||||
<td>$${amountDisplay}</td>
|
||||
<td>${createdDisplay}</td>
|
||||
<td class="text-center">
|
||||
<span class="badge bg-warning text-dark">Pending</span>
|
||||
${isPaid ? '<span class="badge bg-success ms-1 external-paid-badge">Paid on save</span>' : '<span class="badge bg-secondary ms-1 external-paid-badge d-none">Paid on save</span>'}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="form-check d-inline-flex align-items-center gap-2 justify-content-center m-0">
|
||||
<input class="form-check-input external-paid-toggle" type="checkbox" data-key="${key}" ${isPaid ? 'checked' : ''}>
|
||||
<span class="small text-muted">On save</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-end d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm edit-external-participant" data-key="${key}">
|
||||
Edit
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm remove-external-participant" data-key="${key}">
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`);
|
||||
$tableBody.append($previewRow);
|
||||
}
|
||||
|
||||
function persistDrafts() {
|
||||
localStorage.setItem(storageKey, JSON.stringify(externalDrafts));
|
||||
}
|
||||
|
||||
function saveExternalDraft(entry) {
|
||||
entry.key = entry.key ?? `${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||
entry.paid = !!entry.paid;
|
||||
removeExternalEntryFromDom(entry.key);
|
||||
externalDrafts = externalDrafts.filter((existing) => existing.key !== entry.key);
|
||||
externalDrafts.push(entry);
|
||||
persistDrafts();
|
||||
buildExternalRow(entry);
|
||||
}
|
||||
|
||||
function removeExternalEntryFromDom(key) {
|
||||
$(`tr.external-preview[data-key="${key}"]`).remove();
|
||||
$externalHidden.find(`div[data-key="${key}"]`).remove();
|
||||
}
|
||||
|
||||
function removeDraft(key) {
|
||||
externalDrafts = externalDrafts.filter((entry) => entry.key !== key);
|
||||
persistDrafts();
|
||||
}
|
||||
|
||||
function loadDrafts() {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const saved = JSON.parse(raw);
|
||||
if (!Array.isArray(saved)) {
|
||||
return;
|
||||
}
|
||||
externalDrafts = saved;
|
||||
const currentEvent = $('#event_id').val();
|
||||
externalDrafts.forEach((entry) => {
|
||||
if (String(entry.eventId) === String(currentEvent)) {
|
||||
buildExternalRow(entry);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to load external drafts', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getDraftByKey(key) {
|
||||
return externalDrafts.find((entry) => entry.key === key);
|
||||
}
|
||||
|
||||
function openExternalModalForEntry(key) {
|
||||
const entry = getDraftByKey(key);
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
editingKey = key;
|
||||
$('#modalExternalFirstName').val(entry.firstname);
|
||||
$('#modalExternalLastName').val(entry.lastname);
|
||||
$('#modalExternalNote').val(entry.note);
|
||||
$('#modalParentFirstName').val(entry.parentFirstname);
|
||||
$('#modalParentLastName').val(entry.parentLastname);
|
||||
$('#modalParentPhone').val(entry.parentPhone);
|
||||
$('#modalParentEmail').val(entry.parentEmail);
|
||||
if (modalInstance) {
|
||||
modalInstance.show();
|
||||
}
|
||||
}
|
||||
|
||||
const modalElement = document.getElementById('externalParticipantModal');
|
||||
const modalInstance = modalElement ? new bootstrap.Modal(modalElement) : null;
|
||||
if (modalElement) {
|
||||
modalElement.addEventListener('hidden.bs.modal', () => {
|
||||
editingKey = null;
|
||||
});
|
||||
}
|
||||
function clearModalInputs() {
|
||||
$('#modalExternalFirstName').val('');
|
||||
$('#modalExternalLastName').val('');
|
||||
$('#modalExternalNote').val('');
|
||||
$('#modalParentFirstName').val('');
|
||||
$('#modalParentLastName').val('');
|
||||
$('#modalParentPhone').val('');
|
||||
$('#modalParentEmail').val('');
|
||||
}
|
||||
|
||||
$('#externalParticipantSave').on('click', function() {
|
||||
const firstName = capitalizeName($('#modalExternalFirstName').val().trim());
|
||||
const lastName = capitalizeName($('#modalExternalLastName').val().trim());
|
||||
const note = $('#modalExternalNote').val().trim();
|
||||
if (!firstName && !lastName) {
|
||||
alert('Please provide at least a first or last name for the non-school participant.');
|
||||
return;
|
||||
}
|
||||
const parentFirst = capitalizeName($('#modalParentFirstName').val().trim());
|
||||
const parentLast = capitalizeName($('#modalParentLastName').val().trim());
|
||||
const parentPhone = $('#modalParentPhone').val().trim();
|
||||
const parentEmail = $('#modalParentEmail').val().trim();
|
||||
const eventId = $('#event_id').val();
|
||||
if (!eventId) {
|
||||
alert('Select an event before adding a non-school participant.');
|
||||
return;
|
||||
}
|
||||
const eventAmount = parseFloat($('#event_id option:selected').data('amount')) || 0;
|
||||
const explicitParentName = `${parentFirst} ${parentLast}`.trim();
|
||||
if (!explicitParentName) {
|
||||
alert('Please enter the external kid parent name.');
|
||||
return;
|
||||
}
|
||||
if (!parentEmail) {
|
||||
alert('Please enter the external kid parent email.');
|
||||
return;
|
||||
}
|
||||
const parentName = explicitParentName;
|
||||
const draftEntry = editingKey ? getDraftByKey(editingKey) : null;
|
||||
const entry = {
|
||||
firstname: firstName,
|
||||
lastname: lastName,
|
||||
note: note,
|
||||
parentFirstname: parentFirst,
|
||||
parentLastname: parentLast,
|
||||
parentPhone: parentPhone,
|
||||
parentEmail: parentEmail,
|
||||
eventId: (draftEntry && draftEntry.eventId) ? draftEntry.eventId : eventId,
|
||||
eventAmount: (draftEntry && draftEntry.eventAmount) ? draftEntry.eventAmount : eventAmount,
|
||||
parentDisplayName: (draftEntry && draftEntry.parentDisplayName) ? draftEntry.parentDisplayName : parentName,
|
||||
paid: (draftEntry && typeof draftEntry.paid !== 'undefined') ? !!draftEntry.paid : false,
|
||||
};
|
||||
if (editingKey) {
|
||||
entry.key = editingKey;
|
||||
}
|
||||
saveExternalDraft(entry);
|
||||
editingKey = null;
|
||||
clearModalInputs();
|
||||
if (modalInstance) {
|
||||
modalInstance.hide();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('click', '.remove-external-participant', function() {
|
||||
const $entry = $(this).closest('[data-key]');
|
||||
const key = $entry.data('key');
|
||||
removeExternalEntryFromDom(key);
|
||||
if (key) {
|
||||
removeDraft(key);
|
||||
}
|
||||
});
|
||||
$(document).on('click', '.edit-external-participant', function() {
|
||||
const key = $(this).data('key');
|
||||
if (key) {
|
||||
openExternalModalForEntry(key);
|
||||
}
|
||||
});
|
||||
$(document).on('change', '.external-paid-toggle', function() {
|
||||
const key = $(this).data('key');
|
||||
const paid = !!this.checked;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const entry = getDraftByKey(key);
|
||||
if (entry) {
|
||||
entry.paid = paid;
|
||||
persistDrafts();
|
||||
}
|
||||
$externalHidden
|
||||
.find(`div[data-key="${key}"] input[name="external_participants[${key}][paid]"]`)
|
||||
.val(paid ? '1' : '0');
|
||||
const $badge = $(`tr.external-preview[data-key="${key}"] .external-paid-badge`);
|
||||
if (paid) {
|
||||
$badge.removeClass('d-none').addClass('bg-success').text('Paid on save');
|
||||
} else {
|
||||
$badge.addClass('d-none');
|
||||
}
|
||||
});
|
||||
$('#event-participant-form').on('submit', function() {
|
||||
localStorage.removeItem(storageKey);
|
||||
});
|
||||
loadDrafts();
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
@@ -168,6 +168,12 @@ $deadline = parseDbDateTime($dueDate, 'UTC', $displayTz); // format either DATE
|
||||
<?php foreach ($charges as $charge): ?>
|
||||
<?php
|
||||
$studentName = trim(($charge['student_firstname'] ?? '') . ' ' . ($charge['student_lastname'] ?? ''));
|
||||
if ($studentName === '') {
|
||||
$externalName = trim(($charge['external_firstname'] ?? '') . ' ' . ($charge['external_lastname'] ?? ''));
|
||||
if ($externalName !== '') {
|
||||
$studentName = $externalName . ' (external)';
|
||||
}
|
||||
}
|
||||
$eventName = $charge['event_name'] ?? '—';
|
||||
$paid = !empty($charge['event_paid']) || ((float)($charge['charged'] ?? 0) <= 0);
|
||||
$badgeClass = $paid ? 'success' : 'danger';
|
||||
|
||||
@@ -100,8 +100,16 @@
|
||||
$balanceCalc = (float)$totalAmount - (float)$discounted - (float)$paid - (float)$refunded;
|
||||
if ($balanceCalc < 0) $balanceCalc = 0.0;
|
||||
$balance = $balanceCalc;
|
||||
$status = ($balanceCalc === 0.0) ? 'Paid' : 'Unpaid';
|
||||
$statusClass = ($status === 'Paid') ? 'bg-success' : 'bg-danger';
|
||||
if ($balanceCalc <= 0.00001) {
|
||||
$status = 'Paid';
|
||||
$statusClass = 'bg-success';
|
||||
} elseif ($paid > 0.00001) {
|
||||
$status = 'Partially Paid';
|
||||
$statusClass = 'bg-warning text-dark';
|
||||
} else {
|
||||
$status = 'Unpaid';
|
||||
$statusClass = 'bg-danger';
|
||||
}
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($inv['invoice_number']) ?></td>
|
||||
@@ -376,8 +384,15 @@
|
||||
const total = Number(inv.total_amount || 0);
|
||||
let balance = total - paid - discounted - refunded;
|
||||
if (!Number.isFinite(balance) || balance < 0) balance = 0;
|
||||
const status = balance === 0 ? 'Paid' : 'Unpaid';
|
||||
const statusClass = balance === 0 ? 'bg-success' : 'bg-danger';
|
||||
let status = 'Unpaid';
|
||||
let statusClass = 'bg-danger';
|
||||
if (balance <= 0.00001) {
|
||||
status = 'Paid';
|
||||
statusClass = 'bg-success';
|
||||
} else if (paid > 0.00001) {
|
||||
status = 'Partially Paid';
|
||||
statusClass = 'bg-warning text-dark';
|
||||
}
|
||||
const tr = document.createElement('tr');
|
||||
const parentHtml = (inv.parent_id && Number(inv.parent_id) > 0) ?
|
||||
`<a href="<?= site_url('family') ?>?guardian_id=${encodeURIComponent(inv.parent_id)}" class="text-decoration-none" data-family-guardian-id="${inv.parent_id}">${inv.parent_name||''}</a>` :
|
||||
|
||||
Reference in New Issue
Block a user