fix enrollment for the new school-year
Tests / PHPUnit (push) Successful in 1m26s

This commit is contained in:
root
2026-08-07 23:43:31 -04:00
parent b6f3b14e7b
commit 1ef2800f12
66 changed files with 8997 additions and 498 deletions
+147 -10
View File
@@ -81,7 +81,8 @@ class InvoiceController extends ResourceController
$this->gradeFee = $this->configModel->getConfig('grade_fee');
$this->schoolYear = $this->currentSchoolYearName();
$this->semester = $this->configModel->getConfig('semester');
$this->dueDate = $this->configModel->getConfig('due_date');
$this->dueDate = $this->configModel->getConfig('first_day_of_school')
?: $this->configModel->getConfig('due_date');
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350);
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200);
$this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200);
@@ -230,6 +231,13 @@ class InvoiceController extends ResourceController
}
}
private function assertSchoolYearNameWritable(string $schoolYear): void
{
service('schoolYearWriteGuard')->assertWritable(
service('schoolYearContext')->forYearName($schoolYear)
);
}
/**
* API: Invoice management composite data (used by invoice_management view)
* Returns the same structure previously rendered server-side in index().
@@ -891,7 +899,7 @@ class InvoiceController extends ResourceController
}
}
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear, $invoice['semester'] ?? null);
// Attach SCHOOL IDs
$allKids = array_merge($registeredKids, $withdrawnKids);
@@ -1073,18 +1081,40 @@ class InvoiceController extends ResourceController
$pdf->SetFont('Arial', 'B', 12);
$pdf->Cell(40, 6, 'Due Date:', 0, 0, 'L');
$pdf->SetFont('Arial', '', 12);
$dueLocal = null;
$formatCalendarDate = static function ($raw): ?string {
if ($raw instanceof \DateTimeInterface) {
return $raw->format('m-d-Y');
}
$value = trim((string)($raw ?? ''));
if ($value === '' || preg_match('/^0{4}-0{2}-0{2}/', $value)) {
return null;
}
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $value, $matches)) {
return $matches[2] . '-' . $matches[3] . '-' . $matches[1];
}
if (preg_match('/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})/', $value, $matches)) {
return sprintf('%02d-%02d-%04d', (int)$matches[1], (int)$matches[2], (int)$matches[3]);
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('m-d-Y', $timestamp);
};
$dueDisplay = $formatCalendarDate($invoice['due_date'] ?? null);
try {
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
if (!empty($invoice['due_date'])) {
$dueLocal = (new \DateTimeImmutable($invoice['due_date'], new \DateTimeZone('UTC')))
->setTimezone(new \DateTimeZone($tzName));
} elseif (!empty($invoice['created_at'])) {
if ($dueDisplay === null && !empty($invoice['created_at'])) {
$dueLocal = new \DateTimeImmutable($invoice['created_at'], new \DateTimeZone($tzName));
$dueDisplay = $dueLocal->format('m-d-Y');
}
} catch (\Throwable $e) {}
if (!$dueLocal) { $dueLocal = new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')); }
$pdf->Cell(0, 6, $dueLocal->format('m-d-Y'), 0, 1, 'L');
if ($dueDisplay === null) {
$dueDisplay = (new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')))->format('m-d-Y');
}
$pdf->Cell(0, 6, $dueDisplay, 0, 1, 'L');
$pdf->Ln(5);
$pdf->SetFont('Arial', '', 9);
@@ -1145,16 +1175,111 @@ class InvoiceController extends ResourceController
];
};
// --- Frozen invoice charge lines. Do not rebuild issued charges from current enrollment/events.
$studentById = [];
foreach (($data['students'] ?? []) as $student) {
$sid = (int)($student['student_id'] ?? 0);
if ($sid <= 0) {
continue;
}
$studentById[$sid] = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? ''));
}
$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;
}
$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 = [];
foreach (($events ?? []) as $event) {
$amount = (float)($event['charged'] ?? 0.0);
if (abs($amount) < 0.00001) {
continue;
}
$sid = (int)($event['student_id'] ?? 0);
$studentName = $sid > 0 ? ($studentById[$sid] ?? '') : '';
if ($studentName === '') {
$studentName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? ''));
}
$desc = trim((string)($event['event_name'] ?? 'Event charge'));
if ($studentName !== '') {
$desc .= ' - ' . $studentName;
}
$eventRows[] = [
'description' => $desc,
'amount' => $amount,
];
}
// --- Frozen invoice charge lines remain authoritative for totals.
// Aggregate tuition/event lines are expanded for display when invoice details are available.
foreach (($data['invoiceLines'] ?? []) as $line) {
$dt = $toLocal($line['created_at'] ?? ($invoice['created_at'] ?? null), true);
$amount = ((int)($line['line_amount_cents'] ?? 0)) / 100;
$type = (string)($line['line_type'] ?? 'other');
$category = str_contains($type, 'event') ? 'event'
: (str_contains($type, 'additional') ? 'additional' : 'registration');
if (!str_contains($type, 'additional') && !str_contains($type, 'event') && !empty($studentTuitionRows)) {
$expandedTotal = 0.0;
foreach ($studentTuitionRows as $row) {
$expandedTotal += (float)$row['amount'];
$push($dt, $row['description'], (float)$row['amount'], 'registration');
}
$delta = round($amount - $expandedTotal, 2);
if (abs($delta) >= 0.01) {
$push($dt, 'Tuition charges adjustment', $delta, 'registration');
}
continue;
}
if (str_contains($type, 'event') && !empty($eventRows)) {
$expandedTotal = 0.0;
foreach ($eventRows as $row) {
$expandedTotal += (float)$row['amount'];
$push($dt, $row['description'], (float)$row['amount'], 'event');
}
$delta = round($amount - $expandedTotal, 2);
if (abs($delta) >= 0.01) {
$push($dt, 'Event charges adjustment', $delta, 'event');
}
continue;
}
$push($dt, (string)($line['description'] ?? 'Invoice line'), $amount, $category);
}
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');
}
}
// --- Payments (negative) — stored in local time
foreach ($payments as $payment) {
$dt = $toLocal($payment['payment_date'] ?? null, false /* local */);
@@ -1513,6 +1638,12 @@ private function getGradeLevel($grade): array
// API: Update invoice status
public function updateStatusAPI($invoiceId)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) {
return $this->failNotFound('Invoice not found.');
}
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ''));
$status = $this->request->getPost('status');
if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) {
return $this->respond(['status' => 'success']);
@@ -1524,6 +1655,12 @@ private function getGradeLevel($grade): array
// View: Update invoice status
public function updateStatus($invoiceId)
{
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice) {
return redirect()->back()->with('error', 'Invoice not found.');
}
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ''));
$status = $this->request->getPost('status');
if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) {
return redirect()->to('/invoices');