fix invoice and enrollment fees
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Failing after 1m19s

This commit is contained in:
root
2026-08-27 18:34:36 -04:00
parent 0ae9993d82
commit 38644b32ae
29 changed files with 1836 additions and 1555 deletions
+359 -109
View File
@@ -244,6 +244,32 @@ class InvoiceController extends ResourceController
return $details;
}
/**
* @return list<array<string,mixed>>
*/
private function eventChargesForInvoice(array $invoice): array
{
if (! $this->db->tableExists('event_charges')) {
return [];
}
$parentId = (int) ($invoice['parent_id'] ?? 0);
$schoolYear = trim((string) ($invoice['school_year'] ?? ''));
if ($parentId <= 0 || $schoolYear === '') {
return [];
}
$builder = $this->db->table('event_charges ec')
->select('ec.*, e.event_name, e.amount AS event_amount, e.description AS event_description')
->join('events e', 'e.id = ec.event_id', 'left')
->where('ec.parent_id', $parentId)
->where('ec.school_year', $schoolYear)
->orderBy('COALESCE(ec.created_at, ec.updated_at)', 'ASC', false)
->orderBy('ec.id', 'ASC');
return $builder->get()->getResultArray();
}
private function assertSchoolYearNameWritable(string $schoolYear): void
{
service('schoolYearWriteGuard')->assertWritable(
@@ -378,7 +404,6 @@ class InvoiceController extends ResourceController
'class_section_id' => $enrollment['class_section_id'],
'enrollment_status' => $enrollment['enrollment_status'],
'school_year' => $enrollment['school_year'],
'semester' => $enrollment['semester'],
'admission_status' => $enrollment['admission_status'],
'is_withdrawn' => $enrollment['is_withdrawn']
];
@@ -408,6 +433,9 @@ class InvoiceController extends ResourceController
// ✅ Use your helper to calculate tuition fee
$fees = $this->calculateTuitionFee($registeredKids, $withdrawnKids);
$tuitionFee = $fees['tuition_fee'];
$tuitionFeeByStudentId = $this->studentTuitionFeeMap($registeredKids, $withdrawnKids);
$registeredKids = $this->applyStudentTuitionFees($registeredKids, $tuitionFeeByStudentId);
$withdrawnKids = $this->applyStudentTuitionFees($withdrawnKids, $tuitionFeeByStudentId);
// ✅ Fetch event charges
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
@@ -435,6 +463,11 @@ class InvoiceController extends ResourceController
$updatedIds = [];
if (!empty($invoice) && isset($invoice['id'])) {
$invoiceId = (int) $invoice['id'];
$this->syncInvoiceStudentSnapshot(
$invoiceId,
array_merge($registeredKids, $withdrawnKids),
$schoolYear
);
$this->invoiceLedgerService->syncTuitionLines($invoiceId);
$ledger = $this->invoiceLedgerService->recalculate($invoiceId);
$updatedIds[] = (int) $ledger['invoice_id'];
@@ -511,6 +544,12 @@ class InvoiceController extends ResourceController
);
}
$updated = false;
$this->syncInvoiceStudentSnapshot(
(int) $insertId,
array_merge($registeredKids, $withdrawnKids),
$schoolYear
);
}
$successPayload = [
@@ -640,6 +679,165 @@ class InvoiceController extends ResourceController
return $tuitionInvoices[0];
}
/**
* Persist the invoice's student roster as an append-only snapshot.
*
* Existing rows are never removed or changed here. That keeps already-issued
* invoices from losing a student's name after enrollment status changes.
*
* @param list<array<string, mixed>> $students
*/
private function syncInvoiceStudentSnapshot(int $invoiceId, array $students, string $schoolYear): void
{
if ($invoiceId <= 0 || $schoolYear === '' || $students === []) {
return;
}
$hasTuitionFeeColumn = $this->db->fieldExists('tuition_fee', 'invoice_students_list');
$existingRows = $this->invoicestudentModel
->select($hasTuitionFeeColumn ? 'id, student_id, tuition_fee' : 'id, student_id')
->where('invoice_id', $invoiceId)
->findAll();
$existingByStudentId = [];
foreach ($existingRows as $row) {
$sid = (int) ($row['student_id'] ?? 0);
if ($sid > 0) {
$existingByStudentId[$sid] = $row;
}
}
$studentIds = array_values(array_unique(array_filter(
array_map(static fn (array $student): int => (int) ($student['student_id'] ?? 0), $students),
static fn (int $studentId): bool => $studentId > 0
)));
if ($studentIds === []) {
return;
}
$this->invoicestudentModel
->where('invoice_id', $invoiceId)
->whereNotIn('student_id', $studentIds)
->delete();
$studentRows = $this->studentModel
->select('id, firstname, lastname, school_id')
->whereIn('id', $studentIds)
->findAll();
$studentsById = [];
foreach ($studentRows as $row) {
$studentsById[(int) ($row['id'] ?? 0)] = $row;
}
$now = utc_now();
foreach ($students as $student) {
$studentId = (int) ($student['student_id'] ?? 0);
if ($studentId <= 0) {
continue;
}
$studentRow = $studentsById[$studentId] ?? [];
$tuitionFee = round((float) ($student['tuition_fee'] ?? 0), 2);
if (isset($existingByStudentId[$studentId])) {
$existing = $existingByStudentId[$studentId];
$payload = [
'student_firstname' => (string) ($studentRow['firstname'] ?? $student['student_firstname'] ?? ''),
'student_lastname' => (string) ($studentRow['lastname'] ?? $student['student_lastname'] ?? ''),
'school_id' => (int) ($studentRow['school_id'] ?? $student['school_id'] ?? 0),
'enrolled' => in_array((string) ($student['enrollment_status'] ?? ''), ['enrolled', 'payment pending'], true) ? 1 : 0,
'school_year' => $schoolYear,
'updated_at' => $now,
];
if ($hasTuitionFeeColumn) {
$payload['tuition_fee'] = number_format($tuitionFee, 2, '.', '');
}
$this->invoicestudentModel->update((int) $existing['id'], $payload);
continue;
}
$payload = [
'invoice_id' => $invoiceId,
'student_id' => $studentId,
'student_firstname' => (string) ($studentRow['firstname'] ?? $student['student_firstname'] ?? ''),
'student_lastname' => (string) ($studentRow['lastname'] ?? $student['student_lastname'] ?? ''),
'school_id' => (int) ($studentRow['school_id'] ?? $student['school_id'] ?? 0),
'enrolled' => in_array((string) ($student['enrollment_status'] ?? ''), ['enrolled', 'payment pending'], true) ? 1 : 0,
'school_year' => $schoolYear,
'created_at' => $now,
'updated_at' => $now,
];
if ($hasTuitionFeeColumn) {
$payload['tuition_fee'] = number_format($tuitionFee, 2, '.', '');
}
$this->invoicestudentModel->insert($payload);
}
}
/**
* @return list<array<string, mixed>>
*/
private function invoiceStudentSnapshotRows(int $invoiceId, string $schoolYear): array
{
if ($invoiceId <= 0) {
return [];
}
$hasTuitionFeeColumn = $this->db->fieldExists('tuition_fee', 'invoice_students_list');
$select = 'id, invoice_id, student_id, student_firstname, student_lastname, school_id, enrolled, school_year';
if ($hasTuitionFeeColumn) {
$select .= ', tuition_fee';
}
$rows = $this->invoicestudentModel
->select($select)
->where('invoice_id', $invoiceId)
->orderBy('id', 'ASC')
->findAll();
if ($rows === []) {
return [];
}
$studentIds = array_values(array_unique(array_filter(
array_map(static fn (array $row): int => (int) ($row['student_id'] ?? 0), $rows),
static fn (int $studentId): bool => $studentId > 0
)));
$schoolIdMap = [];
if ($studentIds !== []) {
$studentRows = $this->studentModel
->select('id, school_id')
->whereIn('id', $studentIds)
->findAll();
foreach ($studentRows as $studentRow) {
$schoolIdMap[(int) ($studentRow['id'] ?? 0)] = $studentRow['school_id'] ?? 'N/A';
}
}
$snapshot = [];
foreach ($rows as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
if ($studentId <= 0) {
continue;
}
$grade = $this->resolveStudentGradeName($studentId, $schoolYear);
$snapshot[] = [
'student_id' => $studentId,
'student_firstname' => (string) ($row['student_firstname'] ?? ''),
'student_lastname' => (string) ($row['student_lastname'] ?? ''),
'student_school_id' => $row['school_id'] ?? $schoolIdMap[$studentId] ?? 'N/A',
'school_id' => $row['school_id'] ?? $schoolIdMap[$studentId] ?? 0,
'grade' => $grade,
'tuition_fee' => (float) ($row['tuition_fee'] ?? 0),
'enrollment_status' => ((int) ($row['enrolled'] ?? 1) === 1) ? 'enrolled' : 'withdrawn',
];
}
return $snapshot;
}
/**
* @return list<array<string, mixed>>
*/
@@ -698,6 +896,27 @@ class InvoiceController extends ResourceController
}
$tuitionInvoice = $this->selectActiveInvoiceForParentYear($parentId, $schoolYear);
if ($tuitionInvoice !== null) {
$snapshotKids = $this->invoiceStudentSnapshotRows((int) ($tuitionInvoice['id'] ?? 0), $schoolYear);
if ($snapshotKids !== []) {
$enrolledKids = [];
$withdrawnKids = [];
foreach ($snapshotKids as $snapshotKid) {
$kid = [
'name' => trim((string) ($snapshotKid['student_firstname'] ?? '') . ' ' . (string) ($snapshotKid['student_lastname'] ?? '')),
'grade' => $snapshotKid['grade'] ?? 'N/A',
'tuition_fee' => (float) ($snapshotKid['tuition_fee'] ?? 0),
];
if (in_array((string) ($snapshotKid['enrollment_status'] ?? ''), ['enrolled', 'payment pending'], true)) {
$enrolledKids[] = $kid;
} else {
$withdrawnKids[] = $kid;
}
}
}
}
if ($enrolledKids !== [] || $withdrawnKids !== [] || $tuitionInvoice !== null) {
$rows[] = $this->buildInvoiceManagementRow(
$parent,
@@ -755,7 +974,7 @@ class InvoiceController extends ResourceController
$kid = [
'name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')),
'grade' => $grade,
'tuition_fee' => (float) ($enrollment['tuition_fee'] ?? 0),
'tuition_fee' => 0.0,
];
switch ($enrollment['enrollment_status']) {
@@ -937,6 +1156,67 @@ class InvoiceController extends ResourceController
];
}
/**
* @param list<array<string,mixed>> $registeredKids
* @param list<array<string,mixed>> $withdrawnKids
* @return array<int,float>
*/
private function studentTuitionFeeMap(array $registeredKids, array $withdrawnKids): array
{
try {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$today = new \DateTimeImmutable('today', new \DateTimeZone($tzName));
$deadline = new \DateTimeImmutable($this->refundDeadline, new \DateTimeZone($tzName));
$refundOk = $today <= $deadline;
} catch (\Throwable $e) {
$refundOk = true;
}
$billableStudents = $refundOk ? $registeredKids : array_merge($registeredKids, $withdrawnKids);
$schoolYear = (string) ($billableStudents[0]['school_year'] ?? $this->schoolYear);
foreach ($billableStudents as &$student) {
$student['grade'] = $this->resolveStudentGradeName(
(int) ($student['student_id'] ?? 0),
$schoolYear,
$student['class_section_id'] ?? null
);
}
unset($student);
usort($billableStudents, fn (array $left, array $right): int => GradeLevelParser::parse($left['grade'] ?? null) <=> GradeLevelParser::parse($right['grade'] ?? null));
$fees = [];
$studentCount = 0;
foreach ($billableStudents as $student) {
$studentId = (int) ($student['student_id'] ?? 0);
if ($studentId <= 0) {
continue;
}
$fees[$studentId] = $studentCount === 0 ? $this->firstStudentFee : $this->secondStudentFee;
$studentCount++;
}
return $fees;
}
/**
* @param list<array<string,mixed>> $students
* @param array<int,float> $tuitionFeeByStudentId
* @return list<array<string,mixed>>
*/
private function applyStudentTuitionFees(array $students, array $tuitionFeeByStudentId): array
{
foreach ($students as &$student) {
$studentId = (int) ($student['student_id'] ?? 0);
$student['tuition_fee'] = $tuitionFeeByStudentId[$studentId] ?? 0.0;
}
unset($student);
return $students;
}
private function calculateTotalTuitionFee(array $students): float
{
$schoolYear = (string) ($students[0]['school_year'] ?? $this->schoolYear);
@@ -1074,100 +1354,29 @@ class InvoiceController extends ResourceController
return ['error' => "Parent associated with the invoice was not found."];
}
$ledger = $this->invoiceLedgerService->calculateInvoice((int) $invoiceId);
$invoiceLines = $this->db->table('invoice_lines')
->select('description, quantity, unit_amount_cents, line_amount_cents, line_type, source_type, source_id, created_at, metadata_json')
->where('invoice_id', (int)$invoiceId)
->where('voided_at IS NULL', null, false)
->orderBy('id', 'ASC')
->get()
->getResultArray();
$enrollments = $this->enrollmentModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->findAll();
if (empty($enrollments)) {
return ['error' => 'No enrollments found for this invoice.'];
}
$ledger = $this->invoiceLedgerService->storedInvoiceLedger((int) $invoiceId);
$invoiceLines = [];
$registeredKids = [];
$withdrawnKids = [];
$snapshotKids = $this->invoiceStudentSnapshotRows((int) $invoiceId, (string) $schoolYear);
foreach ($enrollments as $enrollment) {
$student = $this->studentModel->find($enrollment['student_id']);
if (!$student) {
log_message('error', "Student not found for ID: {$enrollment['student_id']}");
continue;
}
$grade = $this->studentClassModel->getStudentGrade($student['id']);
$studentData = [
'student_id' => $student['id'],
'student_firstname' => $student['firstname'],
'student_lastname' => $student['lastname'],
'grade' => $grade,
'tuition_fee' => $enrollment['tuition_fee'] ?? 0,
'enrollment_status' => $enrollment['enrollment_status'],
];
if (in_array($enrollment['enrollment_status'], ['enrolled', 'payment pending'], true)) {
$registeredKids[] = $studentData;
} elseif (in_array($enrollment['enrollment_status'], ['withdrawn', 'withdraw under review', 'refund pending'], true)) {
$withdrawnKids[] = $studentData;
} else {
log_message('info', "Skipping student ID {$student['id']} with status {$enrollment['enrollment_status']}");
if ($snapshotKids !== []) {
foreach ($snapshotKids as $studentData) {
if (in_array((string) ($studentData['enrollment_status'] ?? ''), ['enrolled', 'payment pending'], true)) {
$registeredKids[] = $studentData;
} else {
$withdrawnKids[] = $studentData;
}
}
}
$registeredKids = array_values(array_filter($registeredKids, function ($student) use ($schoolYear) {
$sid = (int)($student['student_id'] ?? 0);
return $sid > 0 && $this->studentClassModel->hasNonEventAssignment($sid, $schoolYear);
}));
$withdrawnKids = array_values(array_filter($withdrawnKids, function ($student) use ($schoolYear) {
$sid = (int)($student['student_id'] ?? 0);
return $sid > 0 && $this->studentClassModel->hasNonEventAssignment($sid, $schoolYear);
}));
usort($registeredKids, fn($a, $b) => $this->compareGrades($a['grade'], $b['grade']));
usort($withdrawnKids, fn($a, $b) => $this->compareGrades($a['grade'], $b['grade']));
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
$tz = new \DateTimeZone($tzName);
$currentDate = new \DateTimeImmutable('today', $tz);
$deadline = new \DateTimeImmutable($this->refundDeadline, $tz);
$refundAllowed = $currentDate <= $deadline;
$studentCharges = [];
$studentCount = 0;
/**
* First student pays the base fee; every additional student pays base minus $100.
*/
$computeCharge = function (array $student) use (&$studentCount) {
$fee = ($studentCount === 0) ? $this->firstStudentFee : $this->secondStudentFee;
$studentCount++;
return $fee;
};
// Registered kids -> pay unit fee
foreach ($registeredKids as $student) {
$unitFee = $computeCharge($student);
$studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0];
}
// Refund NOT allowed -> withdrawn students still owe unit fee
if (!$refundAllowed) {
foreach ($withdrawnKids as $student) {
$unitFee = $computeCharge($student);
$studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0];
}
}
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $invoice['semester'] ?? null);
$eventsList = $this->eventChargesForInvoice($invoice);
// Attach SCHOOL IDs
$allKids = array_merge($registeredKids, $withdrawnKids);
@@ -1261,7 +1470,7 @@ class InvoiceController extends ResourceController
'carryForwardDescription'=> $this->invoiceLedgerService->carryForwardDisplayDescription($invoice),
'registeredKids' => $registeredKids,
'withdrawnKids' => $withdrawnKids,
'studentCharges' => $studentCharges,
'studentCharges' => [],
'events' => $eventsList,
'students' => $students,
'payments' => $payments,
@@ -1286,23 +1495,11 @@ class InvoiceController extends ResourceController
return ['error' => 'Parent associated with the invoice was not found.'];
}
$ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId);
$ledger = $this->invoiceLedgerService->storedInvoiceLedger($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;
@@ -1542,8 +1739,7 @@ class InvoiceController extends ResourceController
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);
$amount = (float)($student['tuition_fee'] ?? 0.0);
if ($sid <= 0 || abs($amount) < 0.00001) {
continue;
}
@@ -1562,6 +1758,41 @@ class InvoiceController extends ResourceController
}
}
$snapshotTuitionRows = function (float $total) use ($registeredKids, $withdrawnKids): array {
$students = array_values(array_filter(
array_merge($registeredKids ?? [], $withdrawnKids ?? []),
static fn (array $student): bool => (int)($student['student_id'] ?? 0) > 0
));
if ($students === [] || abs($total) < 0.01) {
return [];
}
$count = count($students);
$baseAmount = floor(($total / $count) * 100) / 100;
$allocated = 0.0;
$rows = [];
foreach ($students as $index => $student) {
$sid = (int)($student['student_id'] ?? 0);
$amount = $index === $count - 1 ? round($total - $allocated, 2) : $baseAmount;
$allocated += $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 . ')';
}
$rows[] = [
'description' => $desc,
'amount' => $amount,
];
}
return $rows;
};
$eventRows = [];
foreach (($events ?? []) as $event) {
$amount = (float)($event['charged'] ?? 0.0);
@@ -1624,6 +1855,16 @@ class InvoiceController extends ResourceController
continue;
}
if (!str_contains($type, 'additional') && !str_contains($type, 'event') && $studentTuitionRows === []) {
$fallbackStudentRows = $snapshotTuitionRows($amount);
if ($fallbackStudentRows !== []) {
foreach ($fallbackStudentRows as $row) {
$push($dt, $row['description'], (float)$row['amount'], 'registration');
}
continue;
}
}
if (str_contains($type, 'event') && !empty($eventRows)) {
$expandedTotal = 0.0;
foreach ($eventRows as $row) {
@@ -1649,8 +1890,23 @@ class InvoiceController extends ResourceController
$push($fallbackDt, $carryForwardDescription, $carryForwardAmount, 'additional');
}
} else {
foreach ($studentTuitionRows as $row) {
$push($fallbackDt, $row['description'], (float)$row['amount'], 'registration');
if ($studentTuitionRows !== []) {
foreach ($studentTuitionRows as $row) {
$push($fallbackDt, $row['description'], (float)$row['amount'], 'registration');
}
} else {
$eventTotal = array_reduce($eventRows, static fn (float $sum, array $row): float => $sum + (float) ($row['amount'] ?? 0), 0.0);
$savedTuitionTotal = round((float)($ledger['total_amount'] ?? $invoice['total_amount'] ?? 0) - $eventTotal - $additionalChargesTotal, 2);
if (abs($savedTuitionTotal) >= 0.01) {
$fallbackStudentRows = $snapshotTuitionRows($savedTuitionTotal);
if ($fallbackStudentRows !== []) {
foreach ($fallbackStudentRows as $row) {
$push($fallbackDt, $row['description'], (float)$row['amount'], 'registration');
}
} else {
$push($fallbackDt, 'Tuition charges', $savedTuitionTotal, 'registration');
}
}
}
foreach ($eventRows as $row) {
$push($fallbackDt, $row['description'], (float)$row['amount'], 'event');
@@ -1977,13 +2233,7 @@ private function getGradeLevel($grade): array
continue;
}
$year = $invoice['school_year'] ?? $this->schoolYear;
$sem = $invoice['semester'] ?? $this->semester;
$invoiceEventCharges[(int)$invoice['id']] = $this->chargesModel->getChargesWithEventInfo(
$invoice['parent_id'],
$year,
$sem
);
$invoiceEventCharges[(int)$invoice['id']] = $this->eventChargesForInvoice($invoice);
}
return view('/parent/invoice_payment', [