fix enrollment, invoice, payment and financila aid
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m19s

This commit is contained in:
root
2026-08-24 21:20:58 -04:00
parent 576da3bd78
commit 608aca79b8
30 changed files with 3327 additions and 774 deletions
@@ -233,7 +233,6 @@ class EnrollmentAdminController extends BaseController
$ruleCodes = $this->ruleCodesForFlag((string) $flag['flag_type']);
$now = date('Y-m-d H:i:s');
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
$this->db->transStart();
@@ -260,7 +259,7 @@ class EnrollmentAdminController extends BaseController
'created_by' => $this->userId(),
'approved_by' => $this->userId(),
'starts_at' => $now,
'expires_at' => $expiresAt,
'expires_at' => null,
'updated_at' => $now,
];
@@ -314,7 +313,6 @@ class EnrollmentAdminController extends BaseController
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? ''));
$reasonCode = trim((string) ($this->request->getPost('reason_code') ?? ''));
$reasonNote = trim((string) ($this->request->getPost('reason_note') ?? ''));
$expiresAt = trim((string) ($this->request->getPost('expires_at') ?? ''));
$postedCodesByStudent = $this->request->getPost('bypassed_rule_codes_by_student') ?? [];
$postedCodesByStudent = is_array($postedCodesByStudent) ? $postedCodesByStudent : [];
@@ -327,16 +325,6 @@ class EnrollmentAdminController extends BaseController
return redirect()->back()->withInput()->with('error', 'Unable to determine the source school year.');
}
if ($expiresAt === '') {
$expiresAt = date('Y-m-d H:i:s', strtotime('+30 days'));
} else {
try {
$expiresAt = (new \DateTimeImmutable($expiresAt))->format('Y-m-d H:i:s');
} catch (\Throwable) {
return redirect()->back()->withInput()->with('error', 'Expiration date is invalid.');
}
}
$now = date('Y-m-d H:i:s');
$transitionService = service('enrollmentTransition');
$saved = 0;
@@ -360,7 +348,7 @@ class EnrollmentAdminController extends BaseController
$failedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $failedCodes)));
$ruleCodes = $postedCodes !== [] ? array_values(array_intersect($postedCodes, $failedCodes)) : $failedCodes;
$ruleCodes = array_values(array_filter($ruleCodes, static fn (string $code): bool => $code !== ''));
$nonOverridable = array_values(array_intersect($ruleCodes, ['STUDENT_NOT_LINKED', 'SOURCE_YEAR_NOT_FOUND', 'TARGET_YEAR_NOT_FOUND', 'ALREADY_ENROLLED']));
$nonOverridable = array_values(array_intersect($ruleCodes, ['ADULT_STUDENT_PARENT_BLOCKED']));
if ($nonOverridable !== []) {
$this->db->transRollback();
return redirect()->back()->withInput()->with('error', 'These rule(s) cannot be bypassed: ' . implode(', ', $nonOverridable));
@@ -393,7 +381,7 @@ class EnrollmentAdminController extends BaseController
'created_by' => $this->userId(),
'approved_by' => $this->userId(),
'starts_at' => $now,
'expires_at' => $expiresAt,
'expires_at' => null,
'updated_at' => $now,
];
@@ -496,8 +484,13 @@ class EnrollmentAdminController extends BaseController
if (is_numeric($assignedTo) && (int) $assignedTo > 0) {
$builder->where('ef.assigned_to', (int) $assignedTo);
}
$builder->where('ef.flag_type !=', 'CLASS_CAPACITY_EXCEPTION_REQUIRED');
$rows = $builder->get()->getResultArray();
$rows = array_values(array_filter(
$rows,
static fn (array $row): bool => (string) ($row['flag_type'] ?? '') !== 'CLASS_CAPACITY_EXCEPTION_REQUIRED'
));
foreach ($rows as &$row) {
$row['student_name'] = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? '')) ?: 'Student #' . (int) ($row['student_id'] ?? 0);
$row['assignee_name'] = trim((string) ($row['assignee_firstname'] ?? '') . ' ' . (string) ($row['assignee_lastname'] ?? ''));
@@ -671,7 +664,6 @@ class EnrollmentAdminController extends BaseController
$followupTypes = [
'PENDING_MAKE_UP_EXAM_PROMOTION' => 'temporary_same_grade',
'CLASS_REASSIGNMENT_REQUIRED' => 'manual_class_required',
'CLASS_CAPACITY_EXCEPTION_REQUIRED' => 'manual_class_required',
'COMPLETION_OR_EXIT_PROCESS_REQUIRED' => 'exit_required',
];
@@ -707,7 +699,6 @@ class EnrollmentAdminController extends BaseController
'AGE_EXCEPTION_REQUIRED',
'LATE_REGISTRATION_EXCEPTION',
'FINANCIAL_REVIEW_REQUIRED',
'CLASS_CAPACITY_EXCEPTION_REQUIRED',
'SIBLING_LAST_NAME_MISMATCH',
'DEFERRED_DELIBERATION',
'WITHDRAWAL_REVIEW_REQUIRED',
@@ -802,7 +793,7 @@ class EnrollmentAdminController extends BaseController
throw new \RuntimeException('Selected class section was not found.');
}
$originalEnrollment = $this->latestEnrollment($studentId, $schoolYear);
$originalEnrollment = service('enrollmentStatus')->controllingEnrollment($studentId, $schoolYear);
$payload = [
'class_section_id' => $sectionId,
'assigned_class_section_id' => $sectionId,
@@ -838,6 +829,26 @@ class EnrollmentAdminController extends BaseController
}
$this->audit($studentId, $schoolYear, (string) ($originalEnrollment['source_school_year'] ?? ''), $auditAction, $originalEnrollment, $payload, 'Class section assigned by administrator.');
$parentId = (int) ($originalEnrollment['parent_id'] ?? 0);
if ($parentId <= 0) {
$student = $this->db->table('students')->select('parent_id')->where('id', $studentId)->limit(1)->get()->getRowArray();
$parentId = (int) ($student['parent_id'] ?? 0);
}
if ($parentId > 0) {
try {
(new InvoiceController())->generateInvoice(
(string) $parentId,
$schoolYear,
(string) ($originalEnrollment['semester'] ?? getSemester())
);
} catch (\Throwable $e) {
log_message('error', 'Invoice refresh after enrollment admin class assignment failed for parent {parent_id}: {error}', [
'parent_id' => $parentId,
'error' => $e->getMessage(),
]);
}
}
}
private function updateEnrollmentPlacementStatus(int $studentId, string $schoolYear, string $placementStatus): void
@@ -853,14 +864,7 @@ class EnrollmentAdminController extends BaseController
private function latestEnrollment(int $studentId, string $schoolYear): ?array
{
return $this->db->table('enrollments')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray() ?: null;
return service('enrollmentStatus')->controllingEnrollment($studentId, $schoolYear);
}
private function audit(int $studentId, string $schoolYear, string $sourceSchoolYear, string $action, ?array $original, array $new, string $reason): void
@@ -1126,7 +1130,6 @@ class EnrollmentAdminController extends BaseController
'AGE_EXCEPTION_REQUIRED' => ['AGE_RULE_BLOCKED'],
'LATE_REGISTRATION_EXCEPTION' => ['REGISTRATION_CLOSED'],
'FINANCIAL_REVIEW_REQUIRED' => ['OUTSTANDING_BALANCE_BLOCKED', 'FINANCE_APPROVAL_REQUIRED'],
'CLASS_CAPACITY_EXCEPTION_REQUIRED' => ['CLASS_CAPACITY_EXCEPTION_REQUIRED'],
'RESTRICTED_ADMINISTRATIVE_REVIEW' => ['EXPELLED'],
'WITHDRAWAL_REVIEW_REQUIRED' => ['WITHDRAWN'],
'DEFERRED_DELIBERATION' => ['NO_FINAL_DECISION', 'UNRECOGNIZED_DECISION', 'DEFERRED_DECISION'],
@@ -1246,7 +1249,9 @@ class EnrollmentAdminController extends BaseController
return 0;
}
$builder = $this->db->table('enrollment_flags')->where('status', 'open');
$builder = $this->db->table('enrollment_flags')
->where('status', 'open')
->where('flag_type !=', 'CLASS_CAPACITY_EXCEPTION_REQUIRED');
if ($schoolYear !== '') {
$builder->where('school_year', $schoolYear);
}
@@ -1260,7 +1265,18 @@ class EnrollmentAdminController extends BaseController
return [];
}
return array_column($this->db->table('enrollment_flags')->select('flag_type')->distinct()->orderBy('flag_type')->get()->getResultArray(), 'flag_type');
$types = array_column(
$this->db->table('enrollment_flags')
->select('flag_type')
->where('flag_type !=', 'CLASS_CAPACITY_EXCEPTION_REQUIRED')
->distinct()
->orderBy('flag_type')
->get()
->getResultArray(),
'flag_type'
);
return array_values(array_filter($types, static fn ($type): bool => (string) $type !== 'CLASS_CAPACITY_EXCEPTION_REQUIRED'));
}
private function schoolYears(): array
+103 -15
View File
@@ -145,6 +145,10 @@ class InventoryController extends BaseController
}
if (($item['type'] ?? '') === 'book') {
$this->saveBookClassAssignments($id);
$gradeError = $this->syncBookCategoryGradeRange($data['category_id'] ?? null);
if ($gradeError !== null) {
return redirect()->back()->withInput()->with('error', $gradeError);
}
}
return redirect()->to(site_url('inventory/' . $item['type']))->with('success', 'Item updated.');
@@ -161,6 +165,10 @@ class InventoryController extends BaseController
$initialQty = (int) ($itemData['quantity'] ?? 0);
if (($itemData['type'] ?? '') === 'book') {
$this->saveBookClassAssignments((int) $itemId);
$gradeError = $this->syncBookCategoryGradeRange($itemData['category_id'] ?? null);
if ($gradeError !== null) {
return redirect()->back()->withInput()->with('error', $gradeError);
}
if ($initialQty !== 0) {
$this->recordMovement($itemId, $initialQty, 'initial', 'Initial stock');
} else {
@@ -229,20 +237,12 @@ class InventoryController extends BaseController
// Only books have grade range
if ($data['type'] === 'book') {
$gmin = $this->request->getPost('grade_min');
$gmax = $this->request->getPost('grade_max');
$gmin = ($gmin === '' || $gmin === null) ? null : max(0, (int)$gmin);
$gmax = ($gmax === '' || $gmax === null) ? null : max(0, (int)$gmax);
if ($gmin !== null && $gmax !== null && $gmin > $gmax) {
return redirect()->back()->withInput()->with('error', 'Grade Min cannot be greater than Grade Max.');
$normalized = $this->normalizeGradeRangePost();
if (is_string($normalized)) {
return redirect()->back()->withInput()->with('error', $normalized);
}
if ($gmin !== null && $gmin > 13) $gmin = 13;
if ($gmax !== null && $gmax > 13) $gmax = 13;
$data['grade_min'] = $gmin;
$data['grade_max'] = $gmax;
$data['grade_min'] = $normalized['grade_min'];
$data['grade_max'] = $normalized['grade_max'];
} else {
$data['grade_min'] = null;
$data['grade_max'] = null;
@@ -264,7 +264,14 @@ class InventoryController extends BaseController
'A category with this Type & Name already exists. Please choose a different name or edit the existing one.'
);
}
$ok = $this->catModel->update((int)$id, $data);
$current = $this->db->table('inventory_categories')->where('id', (int) $id)->get()->getRowArray();
if (!$current) {
return redirect()->back()->withInput()->with('error', 'Category not found.');
}
if (!empty($current['school_year'])) {
$data['school_year'] = $current['school_year'];
}
$ok = $this->writeCategoryRow((int) $id, $data);
} else {
// Creating: block if already exists
if ($existing) {
@@ -295,7 +302,9 @@ class InventoryController extends BaseController
}
if (!$ok) {
return redirect()->back()->withInput()->with('error', 'Failed to save category.');
$errors = $this->catModel->errors();
$message = $errors !== [] ? implode(', ', $errors) : 'Failed to save category.';
return redirect()->back()->withInput()->with('error', $message);
}
return redirect()->back()->with('success', 'Category saved.');
@@ -531,6 +540,85 @@ class InventoryController extends BaseController
}
}
/**
* Persist grade_min/grade_max onto the selected book category.
* Returns an error message string on failure, or null on success/no-op.
*/
private function syncBookCategoryGradeRange($categoryId): ?string
{
$categoryId = (int) ($categoryId ?? 0);
if ($categoryId <= 0) {
return null;
}
// Disabled inputs are omitted from POST; do not wipe an existing category range.
$post = $this->request->getPost();
if (! is_array($post)
|| (! array_key_exists('grade_min', $post) && ! array_key_exists('grade_max', $post))
) {
return null;
}
$normalized = $this->normalizeGradeRangePost();
if (is_string($normalized)) {
return $normalized;
}
$category = $this->db->table('inventory_categories')->where('id', $categoryId)->get()->getRowArray();
if (!$category || ($category['type'] ?? '') !== 'book') {
return 'Selected category was not found or is not a book category.';
}
$payload = [
'grade_min' => $normalized['grade_min'],
'grade_max' => $normalized['grade_max'],
];
if (!empty($category['school_year'])) {
$payload['school_year'] = $category['school_year'];
}
if (!$this->writeCategoryRow($categoryId, $payload)) {
return 'Failed to update category grade range.';
}
return null;
}
private function writeCategoryRow(int $id, array $data): bool
{
$data['updated_at'] = date('Y-m-d H:i:s');
return (bool) $this->db->table('inventory_categories')->where('id', $id)->update($data);
}
/**
* @return array{grade_min: ?int, grade_max: ?int}|string
*/
private function normalizeGradeRangePost()
{
$gmin = $this->request->getPost('grade_min');
$gmax = $this->request->getPost('grade_max');
$gmin = ($gmin === '' || $gmin === null) ? null : max(0, (int) $gmin);
$gmax = ($gmax === '' || $gmax === null) ? null : max(0, (int) $gmax);
if ($gmin !== null && $gmin > 13) {
$gmin = 13;
}
if ($gmax !== null && $gmax > 13) {
$gmax = 13;
}
if ($gmin !== null && $gmax !== null && $gmin > $gmax) {
return 'Grade Min cannot be greater than Grade Max.';
}
return [
'grade_min' => $gmin,
'grade_max' => $gmax,
];
}
private function moneyValueToCents($value): int
{
$raw = trim((string) $value);
+438 -230
View File
@@ -105,104 +105,11 @@ class InvoiceController extends ResourceController
log_message('info', "Selected school year for invoice retrieval: $schoolYear");
$invoiceData = [];
$parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $schoolYear);
$parents = $this->invoiceManagementParents($schoolYear);
foreach ($parents as $parent) {
$students = $this->studentModel->where('parent_id', $parent['id'])->findAll();
$parentData = [
'parent_name' => $parent['firstname'] . ' ' . $parent['lastname'],
'parent_id' => $parent['id'],
'enrolledKids' => [],
'withdrawnKids' => [],
'invoice_amount' => 0,
'refund_amount' => 0, // default
'last_updated' => null,
'invoice_date' => null
];
// Fetch most recent invoice
$invoices = $this->invoiceModel->getInvoicesByParentId($parent['id'], $schoolYear);
foreach ($invoices as $invoice) {
if ($invoice) {
$parentData['invoice_amount'] = $invoice['total_amount'];
$parentData['last_updated'] = $invoice['updated_at'];
// Prefer issue_date (UTC) and render in configured/user local time for display
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$parentData['invoice_date'] = !empty($invoice['issue_date'])
? (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC')))
->setTimezone(new \DateTimeZone($tzName))
->format('Y-m-d H:i:s')
: ($invoice['updated_at'] ?? null);
$parentData['invoice_id'] = $invoice['id'];
$refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear);
$parentData['refund_amount'] = $refundSummary['amount'];
$parentData['refund_details'] = $refundSummary['details'];
log_message('info', "Latest invoice for parent {$parent['firstname']} {$parent['lastname']} in school year $schoolYear: Amount = {$invoice['total_amount']}, Updated at = {$invoice['updated_at']}");
} else {
log_message('error', "No invoice found for parent {$parent['firstname']} {$parent['lastname']} in school year $schoolYear.");
}
}
foreach ($students as $student) {
$studentClass = $this->db->table('student_class')
->where('student_id', $student['id'])
->where('school_year', $schoolYear)
->get()->getRowArray();
$grade = 'N/A';
if ($studentClass && isset($studentClass['class_section_id'])) {
$classSection = $this->db->table('classSection')
->where('class_section_id', $studentClass['class_section_id'])
->get()->getRowArray();
if ($classSection && isset($classSection['class_section_name'])) {
$grade = $classSection['class_section_name'];
}
}
$enrollments = $this->enrollmentModel
->where('student_id', $student['id'])
->where('school_year', $schoolYear)
->findAll();
foreach ($enrollments as $enrollment) {
$kidData = [
'name' => $student['firstname'] . ' ' . $student['lastname'],
'grade' => $grade,
'tuition_fee' => $enrollment['tuition_fee'] ?? 0
];
switch ($enrollment['enrollment_status']) {
case 'payment pending':
case 'enrolled':
$parentData['enrolledKids'][] = $kidData;
break;
case 'withdraw under review':
case 'withdrawn':
case 'refund pending':
$parentData['withdrawnKids'][] = $kidData;
break;
case 'admission under review':
log_message('info', "Student ID {$student['id']} is under admission review and not included in the invoice.");
break;
case 'waitlist':
log_message('info', "Student ID {$student['id']} is in waitlist and not included in the invoice.");
break;
case 'denied':
log_message('info', "Student ID {$student['id']} is denied and not included in the invoice.");
break;
default:
log_message('error', "Unexpected enrollment status '{$enrollment['enrollment_status']}' for student ID {$student['id']}.");
break;
}
}
}
if (!empty($parentData['enrolledKids']) || !empty($parentData['withdrawnKids'])) {
$invoiceData[] = $parentData;
foreach ($this->invoiceManagementRowsForParent($parent, $schoolYear) as $row) {
$invoiceData[] = $row;
}
}
@@ -370,94 +277,11 @@ class InvoiceController extends ResourceController
$schoolYears = [$this->schoolYear];
}
$parents = $this->userModel->getUsersByRoleAndSchoolYear('parent', $schoolYear);
$parents = $this->invoiceManagementParents($schoolYear);
foreach ($parents as $parent) {
$students = $this->studentModel->where('parent_id', $parent['id'])->findAll();
$parentData = [
'parent_name' => trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')),
'parent_id' => (int)$parent['id'],
'enrolledKids' => [],
'withdrawnKids' => [],
'invoice_amount'=> 0,
'refund_amount' => 0,
'last_updated' => null,
'invoice_date' => null,
'invoice_id' => null,
];
// Latest invoice
$invoices = $this->invoiceModel->getInvoicesByParentId($parent['id'], $schoolYear);
foreach ($invoices as $invoice) {
if ($invoice) {
$parentData['invoice_amount'] = (float)($invoice['total_amount'] ?? 0);
$parentData['last_updated'] = $invoice['updated_at'] ?? null;
// Prefer issue_date (UTC) -> local; fall back to updated_at/created_at
if (!empty($invoice['issue_date'])) {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$parentData['invoice_date'] = (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC')))
->setTimezone(new \DateTimeZone($tzName))
->format('Y-m-d H:i:s');
} else {
$parentData['invoice_date'] = date('Y-m-d H:i:s', strtotime($invoice['updated_at'] ?? $invoice['created_at'] ?? 'now'));
}
$parentData['invoice_id'] = $invoice['id'] ?? null;
$refundSummary = $this->paidRefundSummaryForParentYear((int) $parent['id'], (string) $schoolYear);
$parentData['refund_amount'] = $refundSummary['amount'];
$parentData['refund_details'] = $refundSummary['details'];
break; // only most recent as before
}
}
// Build kids lists based on enrollment statuses
foreach ($students as $student) {
$studentClass = $this->db->table('student_class')
->where('student_id', $student['id'])
->where('school_year', $schoolYear)
->get()->getRowArray();
$grade = 'N/A';
if ($studentClass && isset($studentClass['class_section_id'])) {
$classSection = $this->db->table('classSection')
->where('class_section_id', $studentClass['class_section_id'])
->get()->getRowArray();
if ($classSection && isset($classSection['class_section_name'])) {
$grade = $classSection['class_section_name'];
}
}
$enrollments = $this->enrollmentModel
->where('student_id', $student['id'])
->where('school_year', $schoolYear)
->findAll();
foreach ($enrollments as $enrollment) {
$kid = [
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
'grade' => $grade,
'tuition_fee' => (float)($enrollment['tuition_fee'] ?? 0),
];
switch ($enrollment['enrollment_status']) {
case 'payment pending':
case 'enrolled':
$parentData['enrolledKids'][] = $kid;
break;
case 'withdraw under review':
case 'withdrawn':
case 'refund pending':
$parentData['withdrawnKids'][] = $kid;
break;
default:
// ignore others for invoice summary
break;
}
}
}
if (!empty($parentData['enrolledKids']) || !empty($parentData['withdrawnKids'])) {
$invoiceData[] = $parentData;
foreach ($this->invoiceManagementRowsForParent($parent, $schoolYear) as $row) {
$invoiceData[] = $row;
}
}
@@ -610,7 +434,9 @@ class InvoiceController extends ResourceController
$updated = false;
$updatedIds = [];
if (!empty($invoice) && isset($invoice['id'])) {
$ledger = $this->invoiceLedgerService->recalculate((int) $invoice['id']);
$invoiceId = (int) $invoice['id'];
$this->invoiceLedgerService->syncTuitionLines($invoiceId);
$ledger = $this->invoiceLedgerService->recalculate($invoiceId);
$updatedIds[] = (int) $ledger['invoice_id'];
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
$updated = true;
@@ -767,39 +593,273 @@ class InvoiceController extends ResourceController
return null;
}
// Prefer invoices flagged as having discounts.
$invoice = $this->invoiceModel
$invoices = $this->invoiceModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('has_discount', 1)
->orderBy('id', 'DESC')
->first();
if (!empty($invoice)) {
return $invoice;
->findAll();
$tuitionInvoices = [];
foreach ($invoices as $invoice) {
if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) {
continue;
}
$tuitionInvoices[] = $invoice;
}
if ($tuitionInvoices === []) {
return null;
}
foreach ($tuitionInvoices as $invoice) {
if ((int) ($invoice['has_discount'] ?? 0) === 1) {
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;
$tuitionInvoiceIds = array_values(array_filter(array_map(
static fn (array $row): int => (int) ($row['id'] ?? 0),
$tuitionInvoices
)));
if ($tuitionInvoiceIds !== []) {
$row = $this->db->table('invoices i')
->select('i.*')
->join('discount_usages du', 'du.invoice_id = i.id', 'inner')
->whereIn('i.id', $tuitionInvoiceIds)
->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)
return $tuitionInvoices[0];
}
/**
* @return list<array<string, mixed>>
*/
private function invoiceManagementParents(string $schoolYear): array
{
$byId = [];
foreach ($this->userModel->getUsersByRoleAndSchoolYear('parent', $schoolYear) as $parent) {
$byId[(int) ($parent['id'] ?? 0)] = $parent;
}
$invoiceParentRows = $this->invoiceModel
->select('parent_id')
->distinct()
->where('school_year', $schoolYear)
->orderBy('id', 'DESC')
->first();
->findAll();
foreach ($invoiceParentRows as $row) {
$parentId = (int) ($row['parent_id'] ?? 0);
if ($parentId <= 0 || isset($byId[$parentId])) {
continue;
}
$parent = $this->userModel->find($parentId);
if ($parent !== null) {
$byId[$parentId] = $parent;
}
}
return array_values($byId);
}
/**
* @return list<array<string, mixed>>
*/
private function invoiceManagementRowsForParent(array $parent, string $schoolYear): array
{
$parentId = (int) ($parent['id'] ?? 0);
if ($parentId <= 0 || $schoolYear === '') {
return [];
}
$kids = $this->invoiceManagementKidsForParent($parentId, $schoolYear);
$enrolledKids = $kids['enrolledKids'];
$withdrawnKids = $kids['withdrawnKids'];
$rows = [];
foreach ($this->carryForwardInvoicesForParentYear($parentId, $schoolYear) as $carryForwardInvoice) {
$rows[] = $this->buildInvoiceManagementRow(
$parent,
$carryForwardInvoice,
true,
[],
[],
$this->paidRefundSummaryForInvoice((int) ($carryForwardInvoice['id'] ?? 0))
);
}
$tuitionInvoice = $this->selectActiveInvoiceForParentYear($parentId, $schoolYear);
if ($enrolledKids !== [] || $withdrawnKids !== [] || $tuitionInvoice !== null) {
$rows[] = $this->buildInvoiceManagementRow(
$parent,
$tuitionInvoice,
false,
$enrolledKids,
$withdrawnKids,
$this->paidRefundSummaryForParentYear($parentId, $schoolYear)
);
}
return $rows;
}
/**
* @return array{enrolledKids: list<array<string, mixed>>, withdrawnKids: list<array<string, mixed>>}
*/
private function invoiceManagementKidsForParent(int $parentId, string $schoolYear): array
{
$enrolledKids = [];
$withdrawnKids = [];
$students = $this->studentModel->where('parent_id', $parentId)->findAll();
foreach ($students as $student) {
$studentClass = $this->db->table('student_class')
->where('student_id', $student['id'])
->where('school_year', $schoolYear)
->get()
->getRowArray();
$grade = 'N/A';
if ($studentClass && isset($studentClass['class_section_id'])) {
$classSection = $this->db->table('classSection')
->where('class_section_id', $studentClass['class_section_id'])
->get()
->getRowArray();
if ($classSection && isset($classSection['class_section_name'])) {
$grade = $classSection['class_section_name'];
}
}
$enrollments = $this->enrollmentModel
->where('student_id', $student['id'])
->where('school_year', $schoolYear)
->findAll();
foreach ($enrollments as $enrollment) {
$kid = [
'name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')),
'grade' => $grade,
'tuition_fee' => (float) ($enrollment['tuition_fee'] ?? 0),
];
switch ($enrollment['enrollment_status']) {
case 'payment pending':
case 'enrolled':
$enrolledKids[] = $kid;
break;
case 'withdraw under review':
case 'withdrawn':
case 'refund pending':
$withdrawnKids[] = $kid;
break;
}
}
}
return [
'enrolledKids' => $enrolledKids,
'withdrawnKids' => $withdrawnKids,
];
}
/**
* @return list<array<string, mixed>>
*/
private function carryForwardInvoicesForParentYear(int $parentId, string $schoolYear): array
{
if ($parentId <= 0 || $schoolYear === '') {
return [];
}
return array_values(array_filter(
$this->invoiceModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->orderBy('id', 'ASC')
->findAll(),
fn (array $invoice): bool => $this->invoiceLedgerService->invoiceIsCarryForward($invoice)
));
}
/**
* @param list<array<string, mixed>> $enrolledKids
* @param list<array<string, mixed>> $withdrawnKids
* @param array{amount: float, details: list<array<string, mixed>>} $refundSummary
* @return array<string, mixed>
*/
private function buildInvoiceManagementRow(
array $parent,
?array $invoice,
bool $isCarryForward,
array $enrolledKids,
array $withdrawnKids,
array $refundSummary
): array {
$parentId = (int) ($parent['id'] ?? 0);
$invoiceId = $invoice !== null ? (int) ($invoice['id'] ?? 0) : null;
$invoiceDate = null;
if ($invoice !== null) {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$invoiceDate = ! empty($invoice['issue_date'])
? (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC')))
->setTimezone(new \DateTimeZone($tzName))
->format('Y-m-d H:i:s')
: ($invoice['updated_at'] ?? null);
}
$description = '';
if ($isCarryForward && $invoice !== null) {
$description = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice);
}
return [
'parent_name' => trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')),
'parent_id' => $parentId,
'enrolledKids' => $enrolledKids,
'withdrawnKids' => $withdrawnKids,
'invoice_amount' => $invoice !== null ? (float) ($invoice['total_amount'] ?? 0) : 0.0,
'invoice_balance' => $invoice !== null ? (float) ($invoice['balance'] ?? 0) : 0.0,
'refund_amount' => (float) ($refundSummary['amount'] ?? 0.0),
'refund_details' => $refundSummary['details'] ?? [],
'last_updated' => $invoice['updated_at'] ?? null,
'invoice_date' => $invoiceDate,
'invoice_id' => $invoiceId,
'invoice_number' => $invoice !== null ? (string) ($invoice['invoice_number'] ?? '') : '',
'invoice_description' => $description,
'invoice_status' => $invoice !== null ? (string) ($invoice['status'] ?? '') : '',
'is_carry_forward' => $isCarryForward,
];
}
/**
* @return array{amount: float, details: list<array<string, mixed>>}
*/
private function paidRefundSummaryForInvoice(int $invoiceId): array
{
if ($invoiceId <= 0) {
return ['amount' => 0.0, 'details' => []];
}
$details = $this->paidRefundDetailsForInvoice($invoiceId);
$amount = 0.0;
foreach ($details as $detail) {
$amount += (float) ($detail['amount'] ?? 0.0);
}
return [
'amount' => round($amount, 2),
'details' => $details,
];
}
private function recalculateAndUpdateDiscount(
@@ -854,11 +914,15 @@ class InvoiceController extends ResourceController
private function calculateTotalTuitionFee(array $students): float
{
$schoolYear = (string) ($students[0]['school_year'] ?? $this->schoolYear);
// 1) Normalize each student's grade name (once)
foreach ($students as &$student) {
$gradeName = $this->classSectionModel
->getClassSectionNameBySectionId($student['class_section_id']);
$student['grade'] = strtoupper(trim($gradeName));
$student['grade'] = $this->resolveStudentGradeName(
(int) ($student['student_id'] ?? 0),
$schoolYear,
$student['class_section_id'] ?? null
);
}
unset($student); // break reference
@@ -876,6 +940,29 @@ class InvoiceController extends ResourceController
return $total;
}
private function resolveStudentGradeName(int $studentId, string $schoolYear, $classSectionId = null): string
{
$sectionId = $classSectionId;
if (empty($sectionId) && $studentId > 0 && $schoolYear !== '') {
$row = $this->studentClassModel
->select('class_section_id')
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('is_event_only', 0)
->orderBy('updated_at', 'DESC')
->first();
$sectionId = $row['class_section_id'] ?? null;
}
if (empty($sectionId)) {
return 'N/A';
}
$gradeName = $this->classSectionModel->getClassSectionNameBySectionId($sectionId);
return strtoupper(trim((string) $gradeName));
}
// Method to check and generate an invoice when enrollment status changes
public function checkAndGenerateInvoice($parentId, $status)
@@ -922,6 +1009,10 @@ class InvoiceController extends ResourceController
return ['error' => "No invoice was generated. Please contact the school administration."];
}
if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) {
return $this->prepareCarryForwardInvoiceData($invoice);
}
$parentId = $invoice['parent_id'];
$schoolYear = $invoice['school_year'];
@@ -1141,6 +1232,8 @@ class InvoiceController extends ResourceController
return [
'invoice' => $invoice,
'parent' => $parent,
'isCarryForwardInvoice' => $this->invoiceLedgerService->invoiceIsCarryForward($invoice),
'carryForwardDescription'=> $this->invoiceLedgerService->carryForwardDisplayDescription($invoice),
'registeredKids' => $registeredKids,
'withdrawnKids' => $withdrawnKids,
'studentCharges' => $studentCharges,
@@ -1157,6 +1250,85 @@ class InvoiceController extends ResourceController
];
}
private function prepareCarryForwardInvoiceData(array $invoice): array
{
$invoiceId = (int) ($invoice['id'] ?? 0);
$parentId = (int) ($invoice['parent_id'] ?? 0);
$schoolYear = (string) ($invoice['school_year'] ?? '');
$parent = $this->userModel->find($parentId);
if (! $parent) {
return ['error' => 'Parent associated with the invoice was not found.'];
}
$ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId);
$carryForwardDescription = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice);
$invoice['description'] = $carryForwardDescription;
$carryForwardAmount = (float) ($ledger['total_amount'] ?? $invoice['total_amount'] ?? 0);
if (abs($carryForwardAmount) >= 0.01) {
try {
$this->invoiceLedgerService->issueCarryForwardInvoiceLine(
$invoiceId,
$carryForwardAmount,
$carryForwardDescription
);
$ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId);
} catch (\Throwable $e) {
log_message('warning', 'Unable to normalize carry-forward invoice line for invoice ' . $invoiceId . ': ' . $e->getMessage());
}
}
$db = $this->db;
$table = $this->paymentModel->table;
$hasStatus = $db->fieldExists('status', $table);
$hasVoid = $db->fieldExists('is_void', $table);
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$paymentQuery = $this->paymentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('invoice_id', $invoiceId);
if ($hasStatus) {
$paymentQuery->groupStart()
->whereNotIn('status', $exclude)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($hasVoid) {
$paymentQuery->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$payments = $paymentQuery->findAll();
$refundsPaidTotal = (float) ($ledger['refund_paid_total'] ?? 0.0);
$refundDetails = $this->paidRefundDetailsForInvoice($invoiceId);
return [
'invoice' => $invoice,
'parent' => $parent,
'isCarryForwardInvoice' => true,
'carryForwardDescription' => $carryForwardDescription,
'registeredKids' => [],
'withdrawnKids' => [],
'studentCharges' => [],
'events' => [],
'students' => [],
'payments' => $payments,
'discounts' => [],
'additionalChargesTotal' => 0.0,
'additionalChargeLines' => [],
'invoiceLines' => [],
'refundsPaidTotal' => $refundsPaidTotal,
'refundDetails' => $refundDetails,
'ledger' => $ledger,
];
}
/**
* Treat common KG spellings as Kindergarten.
@@ -1339,25 +1511,30 @@ class InvoiceController extends ResourceController
}
$studentTuitionRows = [];
foreach (array_merge($registeredKids ?? [], $withdrawnKids ?? []) as $student) {
$sid = (int)($student['student_id'] ?? 0);
$charge = $studentCharges[$sid] ?? null;
$amount = (float)($charge['unit_fee'] ?? 0.0);
if ($sid <= 0 || abs($amount) < 0.00001) {
continue;
}
$isCarryForwardInvoice = (bool) ($data['isCarryForwardInvoice'] ?? $this->invoiceLedgerService->invoiceIsCarryForward($invoice));
$carryForwardDescription = (string) ($data['carryForwardDescription'] ?? $this->invoiceLedgerService->carryForwardDisplayDescription($invoice));
$name = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? ''));
$grade = trim((string)($student['grade'] ?? ''));
$desc = 'Tuition - ' . ($name !== '' ? $name : 'Student #' . $sid);
if ($grade !== '' && strtoupper($grade) !== 'N/A') {
$desc .= ' (' . $grade . ')';
}
if (! $isCarryForwardInvoice) {
foreach (array_merge($registeredKids ?? [], $withdrawnKids ?? []) as $student) {
$sid = (int)($student['student_id'] ?? 0);
$charge = $studentCharges[$sid] ?? null;
$amount = (float)($charge['unit_fee'] ?? 0.0);
if ($sid <= 0 || abs($amount) < 0.00001) {
continue;
}
$studentTuitionRows[] = [
'description' => $desc,
'amount' => $amount,
];
$name = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? ''));
$grade = trim((string)($student['grade'] ?? ''));
$desc = 'Tuition - ' . ($name !== '' ? $name : 'Student #' . $sid);
if ($grade !== '' && strtoupper($grade) !== 'N/A') {
$desc .= ' (' . $grade . ')';
}
$studentTuitionRows[] = [
'description' => $desc,
'amount' => $amount,
];
}
}
$eventRows = [];
@@ -1390,9 +1567,24 @@ class InvoiceController extends ResourceController
$dt = $toLocal($line['created_at'] ?? ($invoice['created_at'] ?? null), true);
$amount = ((int)($line['line_amount_cents'] ?? 0)) / 100;
$type = (string)($line['line_type'] ?? 'other');
$sourceType = (string)($line['source_type'] ?? '');
$category = str_contains($type, 'event') ? 'event'
: (str_contains($type, 'additional') ? 'additional' : 'registration');
if ($sourceType === 'carry_forward_invoice' || $sourceType === 'carry_forward_opening_balance') {
$lineDescription = trim((string)($line['description'] ?? ''));
if ($lineDescription === '') {
$lineDescription = $carryForwardDescription;
}
$push($dt, $lineDescription, $amount, 'additional');
continue;
}
if ($isCarryForwardInvoice) {
$push($dt, $carryForwardDescription, $amount, 'additional');
continue;
}
if (!str_contains($type, 'additional') && !str_contains($type, 'event') && !empty($studentTuitionRows)) {
$expandedTotal = 0.0;
foreach ($studentTuitionRows as $row) {
@@ -1426,11 +1618,18 @@ class InvoiceController extends ResourceController
if (empty($data['invoiceLines'] ?? [])) {
$fallbackDt = $toLocal($invoice['created_at'] ?? ($invoice['issue_date'] ?? null), true);
foreach ($studentTuitionRows as $row) {
$push($fallbackDt, $row['description'], (float)$row['amount'], 'registration');
}
foreach ($eventRows as $row) {
$push($fallbackDt, $row['description'], (float)$row['amount'], 'event');
if ($isCarryForwardInvoice) {
$carryForwardAmount = (float)($ledger['total_amount'] ?? $invoice['total_amount'] ?? 0);
if (abs($carryForwardAmount) >= 0.01) {
$push($fallbackDt, $carryForwardDescription, $carryForwardAmount, 'additional');
}
} else {
foreach ($studentTuitionRows as $row) {
$push($fallbackDt, $row['description'], (float)$row['amount'], 'registration');
}
foreach ($eventRows as $row) {
$push($fallbackDt, $row['description'], (float)$row['amount'], 'event');
}
}
}
@@ -1737,13 +1936,22 @@ private function getGradeLevel($grade): array
// Attach refund amount and last payment data to each invoice
foreach ($invoices as &$invoice) {
if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) {
$invoice['description'] = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice);
}
$invoice['refund_amount'] = $refunds[$invoice['id']] ?? 0.00;
$invoice['last_paid_amount'] = $lastPayments[$invoice['id']]['last_paid_amount'] ?? 0.00;
$invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null;
}
unset($invoice);
$invoiceEventCharges = [];
foreach ($invoices as $invoice) {
if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) {
continue;
}
$year = $invoice['school_year'] ?? $this->schoolYear;
$sem = $invoice['semester'] ?? $this->semester;
$invoiceEventCharges[(int)$invoice['id']] = $this->chargesModel->getChargesWithEventInfo(
+271 -119
View File
@@ -317,13 +317,8 @@ class ParentController extends BaseController
$student['class_section'] = !empty($classSections)
? implode(', ', $classSections)
: 'Class not Assigned';
$isArabicClass = !empty($classSections) && array_reduce(
$classSections,
static fn($carry, $name) => $carry || (is_string($name) && stripos($name, 'arabic') === 0),
false
);
// Get enrollment status AND admission status
// Get enrollment status AND admission status
$enrollment = $this->db->table('enrollments')
->select('enrollment_status, admission_status')
->where('student_id', $studentId)
@@ -350,10 +345,7 @@ class ParentController extends BaseController
$student['enrollment_status'] = 'not enrolled';
}
// If assigned to Arabic class without an enrollment record, display as enrolled.
if ($student['enrollment_status'] === 'not enrolled' && $isArabicClass) {
$student['enrollment_status'] = 'enrolled';
}
// No enrollment record fabrication from class assignment.
// ✅ Updated disable logic to include denied status
$student['disable_enroll'] = in_array(
@@ -411,6 +403,15 @@ class ParentController extends BaseController
)));
}
if ($previousSchoolYear !== null) {
service('enrollmentTransition')->syncParentFinancialReviewFlags(
(int) $parentId,
$previousSchoolYear,
$selectedYear,
array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students))
);
}
// Render view
return view('/parent/enroll_classes', [
'students' => $students,
@@ -528,6 +529,7 @@ class ParentController extends BaseController
$studentName = trim((string) ($studentInfo['firstname'] ?? '') . ' ' . (string) ($studentInfo['lastname'] ?? '')) ?: 'Student ID ' . $studentId;
if (empty($evaluation['can_enroll'])) {
$transitionService->logEnrollmentBlock($evaluation, 'parent_enroll_submit', (int) $parentId, (int) $parentId);
$messages = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? []))));
$codes = array_values(array_filter(array_map('strval', array_merge($evaluation['blocking_rule_codes'] ?? [], $evaluation['review_rule_codes'] ?? []))));
$errors[] = $studentName . ': ' . ($messages !== [] ? implode(' ', $messages) : 'Enrollment is not currently allowed' . ($codes !== [] ? ' (' . implode(', ', $codes) . ')' : '') . '.');
@@ -891,7 +893,12 @@ class ParentController extends BaseController
private function updateEnrollmentParentContact(int $parentId): array
{
$fields = $this->request->getPost('parent_contact');
$normalized = $this->normalizeEnrollmentParentContact(is_array($fields) ? $fields : []);
$currentState = '';
$user = $this->userModel->find($parentId);
if (is_array($user)) {
$currentState = strtoupper(trim((string) ($user['state'] ?? '')));
}
$normalized = $this->normalizeEnrollmentParentContact(is_array($fields) ? $fields : [], $currentState);
if ($normalized['errors'] !== []) {
return $normalized['errors'];
}
@@ -907,33 +914,38 @@ class ParentController extends BaseController
* @param array<string, mixed> $fields
* @return array{errors: list<string>, data: array<string, string>}
*/
private function normalizeEnrollmentParentContact(array $fields): array
private function normalizeEnrollmentParentContact(array $fields, string $existingState = ''): array
{
$phoneDigits = preg_replace('/\D/', '', (string) ($fields['cellphone'] ?? '')) ?? '';
$street = trim((string) ($fields['address_street'] ?? ''));
$apt = trim((string) ($fields['apt'] ?? ''));
$city = trim((string) ($fields['city'] ?? ''));
$street = $this->collapseContactWhitespace((string) ($fields['address_street'] ?? ''));
$apt = $this->collapseContactWhitespace((string) ($fields['apt'] ?? ''));
$city = $this->collapseContactWhitespace((string) ($fields['city'] ?? ''));
$state = strtoupper(trim((string) ($fields['state'] ?? '')));
$zip = trim((string) ($fields['zip'] ?? ''));
$zip = preg_replace('/\D/', '', (string) ($fields['zip'] ?? '')) ?? '';
$errors = [];
$allowedStates = ['CT', 'ME', 'MA', 'NH', 'NY', 'RI', 'VT'];
$existingState = strtoupper(trim($existingState));
if (preg_match('/^[A-Z]{2}$/', $existingState) === 1) {
$allowedStates[] = $existingState;
}
if (strlen($phoneDigits) !== 10) {
$errors[] = 'A valid 10-digit home/cell phone number is required.';
}
if (strlen($street) < 5 || strlen($street) > 255) {
$errors[] = 'Home street address is required.';
if ($street === '' || strlen($street) < 2 || strlen($street) > 50 || ! preg_match('/^[A-Za-z0-9.\s\-]+$/', $street)) {
$errors[] = 'Home street address must be 250 characters and may contain only letters, numbers, spaces, periods, and hyphens.';
}
if ($apt !== '' && strlen($apt) > 15) {
$errors[] = 'Apartment or unit must be 15 characters or fewer.';
if ($apt !== '' && (strlen($apt) > 15 || ! preg_match('/^[A-Za-z0-9.\s\-]+$/', $apt))) {
$errors[] = 'Apartment or unit must be 15 characters or fewer and may contain only letters, numbers, spaces, periods, and hyphens.';
}
if (strlen($city) < 2 || strlen($city) > 100) {
$errors[] = 'City is required.';
if (strlen($city) < 2 || strlen($city) > 30 || ! preg_match('/^[A-Za-z\s.\'\-]+$/', $city)) {
$errors[] = 'City must be 230 characters and may contain only letters, spaces, periods, apostrophes, and hyphens.';
}
if (! preg_match('/^[A-Z]{2}$/', $state)) {
if (! preg_match('/^[A-Z]{2}$/', $state) || ! in_array($state, $allowedStates, true)) {
$errors[] = 'State is required.';
}
@@ -951,9 +963,9 @@ class ParentController extends BaseController
'errors' => [],
'data' => [
'cellphone' => $formattedPhone ?: $phoneDigits,
'address_street' => $street,
'apt' => $apt,
'city' => ucfirst(strtolower($city)),
'address_street' => ucwords(strtolower($street)),
'apt' => strtoupper($apt),
'city' => ucwords(strtolower($city), " -'"),
'state' => $state,
'zip' => $zip,
'updated_at' => utc_now(),
@@ -961,6 +973,11 @@ class ParentController extends BaseController
];
}
private function collapseContactWhitespace(string $value): string
{
return trim(preg_replace('/\s+/', ' ', $value) ?? '');
}
/**
* @param mixed $selected
* @return list<string>
@@ -1211,51 +1228,6 @@ class ParentController extends BaseController
}
}
private function studentPassedPreviousYear(int $studentId, string $targetSchoolYear): bool
{
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
if ($studentId <= 0 || $previousSchoolYear === null) {
return false;
}
try {
if ($this->db->tableExists('promotion_queue')) {
$queuedPromotion = $this->db->table('promotion_queue')
->select('id')
->where('student_id', $studentId)
->where('school_year_from', $previousSchoolYear)
->where('school_year_to', $targetSchoolYear)
->whereIn('status', ['queued', 'assigned', 'applied'])
->limit(1)
->get()
->getRowArray();
if ($queuedPromotion !== null) {
return true;
}
}
if ($this->db->tableExists('student_decisions')) {
$decisionRow = $this->db->table('student_decisions')
->select('decision')
->where('student_id', $studentId)
->where('school_year', $previousSchoolYear)
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
->limit(1)
->get()
->getRowArray();
return DeliberationDecision::normalize($decisionRow['decision'] ?? null) === DeliberationDecision::PASSED;
}
} catch (\Throwable $e) {
log_message('error', 'studentPassedPreviousYear failed: ' . $e->getMessage());
}
return false;
}
private function isReturningReEnrollmentStudent(int $studentId, string $targetSchoolYear): bool
{
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
@@ -1704,6 +1676,18 @@ class ParentController extends BaseController
private function eligibilityMessageFromTransition(array $student, ?array $evaluation, ?string $fallMakeupExamOn): array
{
if ($this->hasSettledParentEnrollmentStatus(
(string) ($student['enrollment_status'] ?? ''),
(string) ($student['admission_status'] ?? '')
)) {
return [
'message' => EnrollmentEligibility::alreadyEnrolledMessage((string) ($student['enrollment_status'] ?? '')),
'blocking' => true,
'level' => 'info',
'primary_block_reason' => 'ALREADY_ENROLLED',
];
}
if ($evaluation === null) {
return $this->enrollmentEligibilityMessageForStudent(
$student,
@@ -1721,6 +1705,15 @@ class ParentController extends BaseController
];
}
if (($evaluation['can_enroll'] ?? false) === false && ! empty($evaluation['primary_parent_message'])) {
return [
'message' => (string) $evaluation['primary_parent_message'],
'blocking' => true,
'level' => 'danger',
'primary_block_reason' => $evaluation['primary_block_reason'] ?? null,
];
}
$blockers = array_values(array_filter(array_map('trim', array_map('strval', $evaluation['blockers'] ?? []))));
if ($blockers !== []) {
$name = $this->studentNameFromRow($student);
@@ -1830,14 +1823,7 @@ class ParentController extends BaseController
private function parentEnrollmentState(array $student): string
{
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
if (in_array($status, [
'admission under review',
'review & decision',
'payment pending',
'enrolled',
'waitlist',
'withdraw under review',
], true)) {
if ($this->hasSettledParentEnrollmentStatus($status, (string) ($student['admission_status'] ?? ''))) {
return 'Already submitted';
}
@@ -1889,29 +1875,131 @@ class ParentController extends BaseController
return 'Contact administration';
}
private function hasSettledParentEnrollmentStatus(string $status, string $admissionStatus = ''): bool
{
if (strtolower(trim($admissionStatus)) === 'accepted') {
return true;
}
return in_array(strtolower(trim($status)), [
'admission under review',
'review & decision',
'payment pending',
'enrolled',
'waitlist',
'withdraw under review',
'refund pending',
], true);
}
private function familyFinancialSummary(int $parentId, ?string $previousSchoolYear, string $selectedYear, array $students = []): array
{
$schoolYearConfig = $this->schoolYearConfig($selectedYear);
$carryOver = $previousSchoolYear !== null ? $this->invoiceBalanceForParent($parentId, $previousSchoolYear) : 0.0;
$currentBalance = $this->invoiceBalanceForParent($parentId, $selectedYear);
$registrationFee = round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2);
$tuitionDue = $this->enrollmentTuitionDue($students);
$mandatoryFees = round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2);
$behavior = (string) ($schoolYearConfig['carry_over_balance_behavior'] ?? 'submission_blocked_until_payment');
$amountDue = max(0.0, $carryOver) + max(0.0, $currentBalance) + $registrationFee + $tuitionDue + $mandatoryFees;
$summary = service('enrollmentTransition')->getEnrollmentFinancialSummary(
$parentId,
$previousSchoolYear ?? '',
$selectedYear,
$tuitionDue
);
$behavior = (string) ($summary['balance_behavior'] ?? 'submission_blocked_until_payment');
$schoolYearConfig = $this->schoolYearConfig($selectedYear);
$summary['policy_message'] = $this->financialPolicyMessage(
$behavior,
(string) ($schoolYearConfig['financial_policy_message'] ?? '')
);
return [
'currency' => '$',
'carry_over_balance' => round($carryOver, 2),
'current_balance' => round($currentBalance, 2),
'registration_fee' => $registrationFee,
'tuition_due_at_registration' => $tuitionDue,
'mandatory_fees' => $mandatoryFees,
'amount_due' => round($amountDue, 2),
'balance_behavior' => $behavior,
'payment_plan_available' => (bool) ($schoolYearConfig['payment_plan_available'] ?? false),
'policy_message' => $this->financialPolicyMessage($behavior, (string) ($schoolYearConfig['financial_policy_message'] ?? '')),
];
return $summary;
}
public function enrollmentEligibilityRefresh()
{
$parentId = (int) session()->get('user_id');
if ($parentId <= 0) {
return $this->response->setStatusCode(401)->setJSON(['error' => 'Unauthorized']);
}
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
if ($previousSchoolYear === null) {
return $this->response->setJSON(['students' => []]);
}
$students = $this->db->table('students')
->select('id, firstname, lastname, dob')
->where('parent_id', $parentId)
->get()
->getResultArray();
$transitionService = service('enrollmentTransition');
$payload = [];
foreach ($students as $student) {
$studentId = (int) ($student['id'] ?? 0);
if ($studentId <= 0) {
continue;
}
$existingEnrollment = $this->db->table('enrollments')
->select('enrollment_status, admission_status')
->where('student_id', $studentId)
->where('school_year', $selectedYear)
->orderBy('id', 'DESC')
->get()
->getRowArray();
$existingStatus = is_array($existingEnrollment) ? (string) ($existingEnrollment['enrollment_status'] ?? '') : '';
$existingAdmissionStatus = is_array($existingEnrollment) ? (string) ($existingEnrollment['admission_status'] ?? '') : '';
if ($this->hasSettledParentEnrollmentStatus($existingStatus, $existingAdmissionStatus)) {
$payload[] = [
'student_id' => $studentId,
'can_enroll' => false,
'primary_block_reason' => 'ALREADY_ENROLLED',
'primary_parent_message' => EnrollmentEligibility::alreadyEnrolledMessage($existingStatus),
'block_title' => EnrollmentEligibility::alreadyEnrolledTitle($existingStatus),
'decision' => 'ALREADY_ENROLLED',
'blocking_rule_codes' => ['ALREADY_ENROLLED'],
];
continue;
}
$evaluation = $transitionService->evaluateForParent(
$parentId,
$studentId,
$previousSchoolYear,
$selectedYear
);
$payload[] = [
'student_id' => $studentId,
'can_enroll' => (bool) ($evaluation['can_enroll'] ?? false),
'primary_block_reason' => $evaluation['primary_block_reason'] ?? null,
'primary_parent_message' => $evaluation['primary_parent_message'] ?? null,
'blocking_rule_codes' => array_values(array_map('strval', $evaluation['blocking_rule_codes'] ?? [])),
'decision' => $evaluation['decision'] ?? null,
];
}
$financialSummary = $transitionService->getEnrollmentFinancialSummary(
$parentId,
(string) $previousSchoolYear,
$selectedYear
);
if ($previousSchoolYear !== null) {
$transitionService->syncParentFinancialReviewFlags(
$parentId,
$previousSchoolYear,
$selectedYear,
array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students))
);
}
return $this->response->setJSON([
'students' => $payload,
'financial_summary' => [
'carry_forward_balance' => (float) ($financialSummary['carry_forward_balance'] ?? 0),
'current_year_balance' => (float) ($financialSummary['current_year_balance'] ?? 0),
'total_enrollment_due' => (float) ($financialSummary['total_enrollment_due'] ?? 0),
],
]);
}
private function enrollmentTuitionDue(array $students): float
@@ -1919,16 +2007,11 @@ class ParentController extends BaseController
$tuitionStudents = [];
foreach ($students as $student) {
$status = strtolower(trim((string) ($student['enrollment_status'] ?? '')));
$eligibilityMessage = is_array($student['enrollment_eligibility_message'] ?? null)
? $student['enrollment_eligibility_message']
: ['blocking' => false];
if ($status !== 'not enrolled' || ! empty($eligibilityMessage['blocking'])) {
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
if (($evaluation['can_enroll'] ?? false) !== true) {
continue;
}
$evaluation = is_array($student['transition_evaluation'] ?? null) ? $student['transition_evaluation'] : [];
$tuitionStudents[] = [
'student_id' => (int) ($student['id'] ?? $student['student_id'] ?? 0),
'class_section_id' => (int) (
@@ -1954,9 +2037,7 @@ class ParentController extends BaseController
'currency' => '$',
'first_student_fee' => round((float) ($this->configModel->getConfig('first_student_fee') ?? 380), 2),
'second_student_fee' => round((float) ($this->configModel->getConfig('second_student_fee') ?? 280), 2),
'registration_fee' => round((float) ($schoolYearConfig['registration_fee'] ?? 0), 2),
'tuition_due_at_registration' => round((float) ($schoolYearConfig['tuition_due_at_registration'] ?? 0), 2),
'mandatory_fees' => round((float) ($schoolYearConfig['mandatory_fees'] ?? 0), 2),
];
}
@@ -1976,20 +2057,79 @@ class ParentController extends BaseController
};
}
private function invoiceBalanceForParent(int $parentId, string $schoolYear): float
private function auditParentStudentFieldChanges(array $original, array $updated, int $parentId, string $source): void
{
if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('invoices')) {
return 0.0;
if (! $this->db->tableExists('enrollment_transition_audits')) {
return;
}
$row = $this->db->table('invoices')
->select('COALESCE(SUM(balance), 0) AS balance', false)
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->get()
->getRowArray();
$studentId = (int) ($original['id'] ?? 0);
if ($studentId <= 0) {
return;
}
return round((float) ($row['balance'] ?? 0), 2);
$fields = ['firstname', 'lastname', 'dob'];
$changes = [];
foreach ($fields as $field) {
$oldValue = trim((string) ($original[$field] ?? ''));
$newValue = trim((string) ($updated[$field] ?? ''));
if ($oldValue !== $newValue) {
$changes[$field] = [
'old_value' => $oldValue,
'new_value' => $newValue,
'changed' => true,
'changed_by' => $parentId,
'changed_at' => date('Y-m-d H:i:s'),
'source' => $source,
];
}
}
if ($changes === []) {
return;
}
$this->db->table('enrollment_transition_audits')->insert([
'student_id' => $studentId,
'school_year' => (string) ($updated['school_year'] ?? $original['school_year'] ?? date('Y')),
'source_school_year' => null,
'action' => 'parent_student_field_edit',
'performed_by' => $parentId,
'original_values_json' => json_encode($original, JSON_UNESCAPED_SLASHES),
'new_values_json' => json_encode(['changes' => $changes, 'updated' => $updated], JSON_UNESCAPED_SLASHES),
'reason' => $source,
'created_at' => date('Y-m-d H:i:s'),
]);
}
private function parentEditAffectsEligibility(array $original, array $updated): bool
{
return trim((string) ($original['dob'] ?? '')) !== trim((string) ($updated['dob'] ?? ''))
|| trim((string) ($original['lastname'] ?? '')) !== trim((string) ($updated['lastname'] ?? ''));
}
private function recheckEligibilityAfterParentEdit(int $studentId, int $parentId, string $targetSchoolYear): void
{
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
if ($previousSchoolYear === null) {
return;
}
$evaluation = service('enrollmentTransition')->evaluateForParent(
$parentId,
$studentId,
$previousSchoolYear,
$targetSchoolYear,
'parent'
);
if (($evaluation['can_enroll'] ?? false) === true) {
return;
}
$message = (string) ($evaluation['primary_parent_message'] ?? 'Updated student information affects enrollment eligibility.');
service('enrollmentTransition')->logEnrollmentBlock($evaluation, 'parent_student_edit', $parentId, $parentId);
session()->setFlashdata('warning', $message);
}
private function schoolYearConfig(string $schoolYear): array
@@ -2548,7 +2688,7 @@ class ParentController extends BaseController
}
// ✅ 8. Pass $isNew to the student save function
$result = $this->validateAndSaveOrUpdateStudent($idx, $parentId, $this->semester, $this->schoolYear, $schoolIdService, $isNew, null, null);
$result = $this->validateAndSaveOrUpdateStudent($idx, $parentId, $this->semester, $this->schoolYear, $schoolIdService, $isNew, null);
if ($result) {
$studentAdded = true;
@@ -2775,7 +2915,19 @@ $existing = $this->studentModel
// ---------- UPDATE OR INSERT ----------
if ($studentId) {
$existing = $this->studentModel->find((int) $studentId);
if (! is_array($existing) || (int) ($existing['parent_id'] ?? 0) !== (int) $parentId) {
session()->setFlashdata('error', 'Student record was not found for this parent account.');
return false;
}
$this->auditParentStudentFieldChanges($existing, $studentData, (int) $parentId, 'parent_student_edit');
$this->studentModel->update($studentId, $studentData);
if ($this->parentEditAffectsEligibility($existing, $studentData)) {
$this->recheckEligibilityAfterParentEdit((int) $studentId, (int) $parentId, (string) $schoolYear);
}
} else {
$studentData['registration_date'] = utc_now();
$studentData['tuition_paid'] = 0;
@@ -3148,7 +3300,7 @@ $existing = $this->studentModel
$this->request->setGlobal('post', $formData);
// Save/update
$this->validateAndSaveOrUpdateStudent(0, $parentId, $this->semester, $this->schoolYear, $schoolIdService, $id, null, null);
$this->validateAndSaveOrUpdateStudent(0, $parentId, $this->semester, $this->schoolYear, $schoolIdService, null, (int) $id);
return redirect()->to('/parent/child_register')->with('success', 'Student updated!');
}
@@ -24,15 +24,8 @@ class ParentFinancialAidController extends BaseController
->orderBy('id', 'DESC')
->findAll();
$students = (new StudentModel())
->where('parent_id', $parentId)
->orderBy('lastname', 'ASC')
->orderBy('firstname', 'ASC')
->findAll();
return view('parent/financial_aid', [
'schoolYear' => $schoolYear,
'students' => $students,
'requests' => $requests,
'openRequest' => $model->openRequestForParent($parentId, $schoolYear),
'existingRequest' => $model->requestForParentYear($parentId, $schoolYear),
@@ -52,14 +45,27 @@ class ParentFinancialAidController extends BaseController
return redirect()->back()->with('error', 'You can submit only one financial aid application per school year.');
}
$studentIds = array_values(array_unique(array_filter(array_map('intval', (array) $this->request->getPost('student_ids')))));
$linkedIds = array_map('intval', array_column(
$studentIds = array_map('intval', array_column(
(new StudentModel())->select('id')->where('parent_id', $parentId)->findAll(),
'id'
));
$studentIds = array_values(array_intersect($studentIds, $linkedIds));
if ($studentIds === []) {
return redirect()->back()->withInput()->with('error', 'Select at least one of your students.');
return redirect()->back()->withInput()->with('error', 'No students are linked to your account.');
}
$householdIncomeRaw = trim((string) $this->request->getPost('household_income'));
if ($householdIncomeRaw === '' || ! is_numeric($householdIncomeRaw) || (float) $householdIncomeRaw < 0) {
return redirect()->back()->withInput()->with('error', 'Enter your household income.');
}
$householdSize = (int) $this->request->getPost('household_size');
if ($householdSize < 1) {
return redirect()->back()->withInput()->with('error', 'Enter your household size.');
}
$requestedAmountRaw = trim((string) $this->request->getPost('requested_amount'));
if ($requestedAmountRaw === '' || ! is_numeric($requestedAmountRaw) || (float) $requestedAmountRaw <= 0) {
return redirect()->back()->withInput()->with('error', 'Enter the amount you are requesting.');
}
$needStatement = trim((string) $this->request->getPost('need_statement'));
@@ -67,16 +73,14 @@ class ParentFinancialAidController extends BaseController
return redirect()->back()->withInput()->with('error', 'Please describe why you are requesting financial aid.');
}
$householdSize = (int) $this->request->getPost('household_size');
$requestedAmount = trim((string) $this->request->getPost('requested_amount'));
$model->insert([
'parent_id' => $parentId,
'school_year' => $schoolYear,
'student_ids_json' => json_encode($studentIds),
'household_size' => $householdSize > 0 ? $householdSize : null,
'student_ids_json' => json_encode(array_values($studentIds)),
'household_size' => $householdSize,
'household_income' => round((float) $householdIncomeRaw, 2),
'need_statement' => $needStatement,
'requested_amount' => $requestedAmount !== '' ? (float) $requestedAmount : null,
'requested_amount' => round((float) $requestedAmountRaw, 2),
'status' => 'submitted',
]);
+5 -6
View File
@@ -525,16 +525,15 @@ class PaymentController extends ResourceController
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]);
// 2) Payment check: enrollment transitions only after the invoice is fully paid
$currentBal = $this->getCurrentInvoiceBalance($invoiceId);
if ($currentBal > 0.00001) {
log_message('info', 'Invoice not fully paid. Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]);
return 0;
}
$parentId = (int) ($invoice['parent_id'] ?? 0);
$schoolYear = (string) $this->schoolYear;
$schoolYear = (string) ($invoice['school_year'] ?? $this->schoolYear);
$semester = isset($this->semester) && $this->semester !== '' ? (string)$this->semester : null;
if ($parentId <= 0 || $schoolYear === '') {
+73 -12
View File
@@ -214,22 +214,41 @@ class StudentController extends BaseController
$attnStats = [];
$scoreStats = [];
$invoiceParentId = 0;
if (!$isEventOnly) {
// Update enrollment for current term (if exists)
$enroll = $this->enrollmentModel
->where('student_id', $studentId)
->where('school_year', (string)$this->schoolYear)
->where('semester', (string)$this->semester)
->first();
$enroll = \Config\Services::enrollmentStatus(false)
->controllingEnrollment($studentId, (string) $this->schoolYear);
if ($enroll) {
$invoiceParentId = (int) ($enroll['parent_id'] ?? $student['parent_id'] ?? 0);
$parentId = $invoiceParentId;
if ($parentId > 0) {
$advance = service('enrollmentTransition')->evaluateEnrollmentAdvance(
$parentId,
$studentId,
(string) $this->schoolYear,
'admin'
);
if (! service('enrollmentTransition')->adminMayAdvanceEnrollment($advance)) {
service('enrollmentTransition')->logEnrollmentBlock(
$advance,
'assign_class_student',
$parentId,
$userId > 0 ? $userId : null
);
$msg = (string) ($advance['primary_parent_message'] ?? 'Enrollment eligibility check failed.');
throw new \RuntimeException($msg);
}
}
$enPk = $this->enrollmentModel->primaryKey ?? 'id';
$result = \Config\Services::enrollmentStatus(false)->upsertStatus([
'id' => (int) $enroll[$enPk],
'student_id' => $studentId,
'parent_id' => (int) ($enroll['parent_id'] ?? 0),
'school_year' => (string) $this->schoolYear,
'semester' => (string) $this->semester,
'semester' => (string) ($enroll['semester'] ?? $this->semester),
'class_section_id' => $primarySectionId,
'enrollment_status' => 'payment pending',
// Ensure admission is marked accepted once moved out of review
@@ -267,6 +286,21 @@ class StudentController extends BaseController
$this->db->transCommit();
if (! $isEventOnly && $invoiceParentId > 0) {
try {
(new InvoiceController())->generateInvoice(
(string) $invoiceParentId,
(string) $this->schoolYear,
(string) $this->semester
);
} catch (\Throwable $e) {
log_message('error', 'Invoice refresh after class assignment failed for parent {parent_id}: {error}', [
'parent_id' => $invoiceParentId,
'error' => $e->getMessage(),
]);
}
}
$resp = [
'ok' => true,
'student_id' => $studentId,
@@ -680,7 +714,7 @@ class StudentController extends BaseController
$cands = $this->distributionCandidates($classId, $year);
if (empty($cands)) {
$msg = 'No promoted students found to distribute for selected class/year.';
$msg = 'No students found to distribute for the selected class/year.';
return $isAjax ? $json(['ok' => false, 'message' => $msg], 200) : redirect()->back()->with('info', $msg);
}
@@ -1592,6 +1626,9 @@ class StudentController extends BaseController
$scoreField = $this->studentDecisionScoreField();
$select = 'sd.id AS decision_id, sd.student_id, sd.class_section_name, sd.decision, students.firstname, students.lastname, students.gender, students.age, students.dob';
if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) {
$select .= ', sd.deliberation_decision_standard';
}
if ($scoreField !== null) {
$select .= ', sd.' . $scoreField . ' AS year_score';
}
@@ -1616,13 +1653,15 @@ class StudentController extends BaseController
continue;
}
$seen[$studentId] = true;
if (DeliberationDecision::normalize($row['decision'] ?? null) !== DeliberationDecision::PASSED) {
$normalizedDecision = $this->normalizedDecisionFromRow($row);
if ($normalizedDecision !== DeliberationDecision::PASSED
&& $normalizedDecision !== DeliberationDecision::REPEAT_CLASS) {
continue;
}
$targetClassId = $this->targetClassIdFromDecision(
(string)($row['class_section_name'] ?? ''),
(string)($row['decision'] ?? ''),
$normalizedDecision ?? (string)($row['decision'] ?? ''),
$targetSchoolYear
);
$ageAtReference = $this->distributionAgeAtReference($row['dob'] ?? null, $targetSchoolYear);
@@ -1760,8 +1799,13 @@ class StudentController extends BaseController
return $this->distributionExcludedDecisionCache[$previousSchoolYear];
}
$select = 'student_id, decision';
if ($this->db->fieldExists('deliberation_decision_standard', 'student_decisions')) {
$select .= ', deliberation_decision_standard';
}
$rows = $this->db->table('student_decisions')
->select('student_id, decision')
->select($select)
->where('school_year', $previousSchoolYear)
->orderBy('updated_at', 'DESC')
->orderBy('id', 'DESC')
@@ -1777,7 +1821,8 @@ class StudentController extends BaseController
}
$seen[$studentId] = true;
if ($this->isDistributionExcludedDecision((string)($row['decision'] ?? ''))) {
$normalizedDecision = $this->normalizedDecisionFromRow($row);
if ($normalizedDecision !== null && $this->isDistributionExcludedDecision($normalizedDecision)) {
$excluded[$studentId] = true;
}
}
@@ -1787,9 +1832,25 @@ class StudentController extends BaseController
return $excluded;
}
private function normalizedDecisionFromRow(array $row): ?string
{
return DeliberationDecision::normalize($row['deliberation_decision_standard'] ?? null)
?? DeliberationDecision::normalize($row['decision'] ?? null);
}
private function isDistributionExcludedDecision(string $decision): bool
{
return DeliberationDecision::normalize($decision) !== DeliberationDecision::PASSED;
$normalized = DeliberationDecision::normalize($decision);
if ($normalized === null) {
return true;
}
return in_array($normalized, [
DeliberationDecision::EXPELLED,
DeliberationDecision::WITHDRAWN,
DeliberationDecision::DEFERRED_DECISION,
DeliberationDecision::MAKE_UP_EXAM,
], true);
}
private function distributionPreviousClassSectionName(int $studentId, string $targetSchoolYear, ?string $sourceSchoolYear = null): string