fix invoice and enrollment fees
This commit is contained in:
@@ -34,7 +34,7 @@ class ParentReportCardController extends BaseController
|
||||
|
||||
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||
$schoolYear = trim($schoolYearContext->yearName());
|
||||
$semester = trim((string) ($this->request->getGet('semester') ?? getSemester() ?? ''));
|
||||
$semesterOptions = $this->semesterOptions($schoolYear, '');
|
||||
|
||||
$builder = $this->db->table('students s')
|
||||
->select('s.id, s.firstname, s.lastname, cs.class_section_name')
|
||||
@@ -48,29 +48,42 @@ class ParentReportCardController extends BaseController
|
||||
$builder->where('sc.school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$students = $builder->get()->getResultArray();
|
||||
$students = $this->uniqueStudentRows($builder->get()->getResultArray());
|
||||
$studentIds = array_values(array_filter(array_map(static fn ($s) => (int) ($s['id'] ?? 0), $students)));
|
||||
|
||||
$ackMap = [];
|
||||
$reportAvailableMap = $this->reportAvailabilityMap($studentIds, $schoolYear, $semester);
|
||||
$reportAvailableMap = $this->reportAvailabilityMap($studentIds, $schoolYear);
|
||||
if (! empty($studentIds)) {
|
||||
$rows = $this->ackModel
|
||||
$ackQuery = $this->ackModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->whereIn('student_id', $studentIds)
|
||||
->findAll();
|
||||
->whereIn('student_id', $studentIds);
|
||||
|
||||
$rows = $ackQuery->findAll();
|
||||
foreach ($rows as $row) {
|
||||
$ackMap[(int) $row['student_id']] = $row;
|
||||
$semester = $this->selectedSemester($row['semester'] ?? '');
|
||||
$ackMap[$this->semesterStudentKey((int) $row['student_id'], $semester)] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$reportRows = [];
|
||||
foreach ($students as $student) {
|
||||
foreach ($semesterOptions as $semester) {
|
||||
$reportRows[] = [
|
||||
'student' => $student,
|
||||
'semester' => $semester,
|
||||
'key' => $this->semesterStudentKey((int) ($student['id'] ?? 0), $semester),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return view('parent/report_cards', [
|
||||
'students' => $students,
|
||||
'reportRows' => $reportRows,
|
||||
'ackMap' => $ackMap,
|
||||
'reportAvailableMap' => $reportAvailableMap,
|
||||
'schoolYear' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'semesterOptions' => $semesterOptions,
|
||||
'isEditable' => ! $schoolYearContext->isReadonly(),
|
||||
]);
|
||||
}
|
||||
@@ -89,7 +102,7 @@ class ParentReportCardController extends BaseController
|
||||
|
||||
$schoolYearContext = $this->resolveSchoolYearContext();
|
||||
$schoolYear = trim($schoolYearContext->yearName());
|
||||
$semester = trim((string) ($this->request->getGet('semester') ?? getSemester() ?? ''));
|
||||
$semester = $this->selectedSemester($this->request->getGet('semester'));
|
||||
|
||||
if (! $this->reportExists((int) $studentId, $schoolYear, $semester)) {
|
||||
return redirect()->to(site_url('parent/report-cards'))
|
||||
@@ -142,7 +155,7 @@ class ParentReportCardController extends BaseController
|
||||
}
|
||||
|
||||
$schoolYear = trim($schoolYearContext->yearName());
|
||||
$semester = trim((string) (getSemester() ?? ''));
|
||||
$semester = $this->selectedSemester($this->request->getPost('semester'));
|
||||
|
||||
if (! $this->reportExists((int) $studentId, $schoolYear, $semester)) {
|
||||
return redirect()->to(site_url('parent/report-cards'))
|
||||
@@ -160,6 +173,78 @@ class ParentReportCardController extends BaseController
|
||||
return redirect()->to(site_url('parent/report-cards'))->with('success', 'Report card acknowledged.');
|
||||
}
|
||||
|
||||
protected function selectedSemester($semester): string
|
||||
{
|
||||
$selected = trim((string) ($semester ?? ''));
|
||||
if ($selected === '') {
|
||||
$selected = trim((string) (getSemester() ?? ''));
|
||||
}
|
||||
|
||||
$normalized = strtolower($selected);
|
||||
if ($normalized === 'fall' || $normalized === 'first' || str_contains($normalized, 'fall') || str_contains($normalized, '1')) {
|
||||
return 'Fall';
|
||||
}
|
||||
if ($normalized === 'spring' || $normalized === 'second' || str_contains($normalized, 'spring') || str_contains($normalized, '2')) {
|
||||
return 'Spring';
|
||||
}
|
||||
|
||||
return $selected !== '' ? $selected : 'Fall';
|
||||
}
|
||||
|
||||
protected function uniqueStudentRows(array $students): array
|
||||
{
|
||||
$unique = [];
|
||||
foreach ($students as $student) {
|
||||
$studentId = (int) ($student['id'] ?? 0);
|
||||
if ($studentId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($unique[$studentId])) {
|
||||
$unique[$studentId] = $student;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
empty($unique[$studentId]['class_section_name'])
|
||||
&& ! empty($student['class_section_name'])
|
||||
) {
|
||||
$unique[$studentId]['class_section_name'] = $student['class_section_name'];
|
||||
}
|
||||
}
|
||||
|
||||
return array_values($unique);
|
||||
}
|
||||
|
||||
protected function semesterOptions(string $schoolYear, string $selectedSemester): array
|
||||
{
|
||||
$options = ['Fall', 'Spring'];
|
||||
|
||||
if ($schoolYear !== '') {
|
||||
$rows = $this->db->table('semester_scores')
|
||||
->select('DISTINCT semester', false)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester IS NOT NULL', null, false)
|
||||
->where('semester !=', '')
|
||||
->orderBy('semester', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$semester = $this->selectedSemester($row['semester'] ?? '');
|
||||
if ($semester !== '' && ! in_array($semester, $options, true)) {
|
||||
$options[] = $semester;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($selectedSemester !== '' && ! in_array($selectedSemester, $options, true)) {
|
||||
$options[] = $selectedSemester;
|
||||
}
|
||||
|
||||
return array_values(array_unique($options));
|
||||
}
|
||||
|
||||
protected function resolvePrimaryParentId(): ?int
|
||||
{
|
||||
$parentId = (int) (session()->get('user_id') ?? 0);
|
||||
@@ -186,7 +271,7 @@ class ParentReportCardController extends BaseController
|
||||
return $parentId ?: null;
|
||||
}
|
||||
|
||||
protected function reportAvailabilityMap(array $studentIds, string $schoolYear, string $semester): array
|
||||
protected function reportAvailabilityMap(array $studentIds, string $schoolYear): array
|
||||
{
|
||||
$studentIds = array_values(array_filter(array_map('intval', $studentIds), static fn ($id) => $id > 0));
|
||||
if (empty($studentIds) || $schoolYear === '') {
|
||||
@@ -194,17 +279,12 @@ class ParentReportCardController extends BaseController
|
||||
}
|
||||
|
||||
$builder = $this->db->table('semester_scores')
|
||||
->select('student_id')
|
||||
->select('student_id, semester')
|
||||
->whereIn('student_id', $studentIds)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
$semesterVariants = $this->semesterVariants($semester);
|
||||
if (! empty($semesterVariants)) {
|
||||
$builder->whereIn('semester', $semesterVariants);
|
||||
}
|
||||
|
||||
$rows = $builder
|
||||
->groupBy('student_id')
|
||||
->groupBy('student_id, semester')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
@@ -212,13 +292,19 @@ class ParentReportCardController extends BaseController
|
||||
foreach ($rows as $row) {
|
||||
$sid = (int) ($row['student_id'] ?? 0);
|
||||
if ($sid > 0) {
|
||||
$map[$sid] = true;
|
||||
$semester = $this->selectedSemester($row['semester'] ?? '');
|
||||
$map[$this->semesterStudentKey($sid, $semester)] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
protected function semesterStudentKey(int $studentId, string $semester): string
|
||||
{
|
||||
return $studentId . '|' . $this->selectedSemester($semester);
|
||||
}
|
||||
|
||||
protected function reportExists(int $studentId, string $schoolYear, string $semester): bool
|
||||
{
|
||||
if ($studentId <= 0 || $schoolYear === '') {
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -31,7 +31,6 @@ class PaymentsLogicAndDataRepairSupport extends Migration
|
||||
'payment_repair_transition_review',
|
||||
'payment_repair_invoice_review',
|
||||
'payment_repair_analysis',
|
||||
'invoice_opening_paid_balances',
|
||||
'invoice_adjustments',
|
||||
'payments_backup_20260718',
|
||||
] as $table) {
|
||||
@@ -259,20 +258,6 @@ class PaymentsLogicAndDataRepairSupport extends Migration
|
||||
) ENGINE=InnoDB"
|
||||
);
|
||||
|
||||
$this->db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `invoice_opening_paid_balances` (
|
||||
`invoice_id` INT UNSIGNED NOT NULL,
|
||||
`amount` DECIMAL(10,2) NOT NULL,
|
||||
`effective_before_payment_id` INT UNSIGNED DEFAULT NULL,
|
||||
`school_year` VARCHAR(9) NOT NULL,
|
||||
`evidence_reference` VARCHAR(255) NOT NULL,
|
||||
`approved_by` INT UNSIGNED NOT NULL,
|
||||
`approved_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`notes` TEXT,
|
||||
PRIMARY KEY (`invoice_id`)
|
||||
) ENGINE=InnoDB"
|
||||
);
|
||||
|
||||
$this->db->query(
|
||||
"CREATE TABLE IF NOT EXISTS `payment_repair_decisions` (
|
||||
`payment_id` INT UNSIGNED NOT NULL,
|
||||
|
||||
@@ -8,106 +8,8 @@ class CreateInvoiceLines extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (!$this->db->tableExists('invoice_lines')) {
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true,
|
||||
],
|
||||
'invoice_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'null' => false,
|
||||
],
|
||||
'school_year' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 9,
|
||||
'null' => false,
|
||||
],
|
||||
'line_type' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 50,
|
||||
'null' => false,
|
||||
],
|
||||
'source_type' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 50,
|
||||
'null' => true,
|
||||
],
|
||||
'source_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'null' => true,
|
||||
],
|
||||
'active_source_key' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 120,
|
||||
'null' => true,
|
||||
],
|
||||
'description' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => false,
|
||||
],
|
||||
'quantity' => [
|
||||
'type' => 'DECIMAL',
|
||||
'constraint' => '10,2',
|
||||
'null' => false,
|
||||
'default' => '1.00',
|
||||
],
|
||||
'unit_amount_cents' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'null' => false,
|
||||
'default' => 0,
|
||||
],
|
||||
'line_amount_cents' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'null' => false,
|
||||
'default' => 0,
|
||||
],
|
||||
'discount_eligible' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'null' => false,
|
||||
'default' => 1,
|
||||
],
|
||||
'calculation_version' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 50,
|
||||
'null' => true,
|
||||
],
|
||||
'metadata_json' => [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => false,
|
||||
],
|
||||
'updated_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => false,
|
||||
],
|
||||
'voided_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('invoice_id');
|
||||
$this->forge->addKey(['source_type', 'source_id']);
|
||||
$this->forge->addUniqueKey('active_source_key', 'uniq_invoice_lines_active_source_key');
|
||||
$this->forge->createTable('invoice_lines', true);
|
||||
}
|
||||
|
||||
$this->ensureSchoolYearColumn();
|
||||
$this->backfillLegacyInvoiceLines();
|
||||
// invoice_lines was removed from the financial model. Keep this
|
||||
// migration class as a no-op so older migration histories remain valid.
|
||||
}
|
||||
|
||||
public function down()
|
||||
@@ -116,88 +18,4 @@ class CreateInvoiceLines extends Migration
|
||||
$this->forge->dropTable('invoice_lines', true);
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillLegacyInvoiceLines(): void
|
||||
{
|
||||
if (!$this->db->tableExists('invoices') || !$this->db->tableExists('invoice_lines')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existingRows = $this->db->table('invoice_lines')
|
||||
->select('invoice_id')
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
$existing = array_fill_keys(array_map(static fn ($row) => (int) ($row['invoice_id'] ?? 0), $existingRows), true);
|
||||
|
||||
$invoices = $this->db->table('invoices')
|
||||
->select('id, total_amount, invoice_number, school_year, created_at, updated_at')
|
||||
->orderBy('id', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
foreach ($invoices as $invoice) {
|
||||
$invoiceId = (int) ($invoice['id'] ?? 0);
|
||||
if ($invoiceId <= 0 || isset($existing[$invoiceId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amountCents = (int) round(((float) ($invoice['total_amount'] ?? 0)) * 100);
|
||||
$createdAt = $invoice['created_at'] ?? $now;
|
||||
$updatedAt = $invoice['updated_at'] ?? $createdAt;
|
||||
|
||||
$this->db->table('invoice_lines')->insert([
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => (string)($invoice['school_year'] ?? ''),
|
||||
'line_type' => 'legacy_invoice_total',
|
||||
'source_type' => 'legacy_invoice',
|
||||
'source_id' => $invoiceId,
|
||||
'active_source_key' => null,
|
||||
'description' => 'Legacy invoice total preserved from invoice ' . (string) ($invoice['invoice_number'] ?? $invoiceId),
|
||||
'quantity' => '1.00',
|
||||
'unit_amount_cents' => $amountCents,
|
||||
'line_amount_cents' => $amountCents,
|
||||
'discount_eligible' => 0,
|
||||
'calculation_version' => 'legacy_import',
|
||||
'metadata_json' => json_encode([
|
||||
'source' => 'invoices.total_amount',
|
||||
'legacy_discount_eligible_base_cents' => 0,
|
||||
'reconciliation_required' => true,
|
||||
], JSON_UNESCAPED_SLASHES),
|
||||
'created_at' => $createdAt ?: $now,
|
||||
'updated_at' => $updatedAt ?: $now,
|
||||
'voided_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureSchoolYearColumn(): void
|
||||
{
|
||||
if (!$this->db->tableExists('invoice_lines')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->db->fieldExists('school_year', 'invoice_lines')) {
|
||||
$this->forge->addColumn('invoice_lines', [
|
||||
'school_year' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 9,
|
||||
'null' => true,
|
||||
'after' => 'invoice_id',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('invoices')) {
|
||||
$this->db->query(
|
||||
"UPDATE invoice_lines il
|
||||
INNER JOIN invoices i ON i.id = il.invoice_id
|
||||
SET il.school_year = i.school_year
|
||||
WHERE il.school_year IS NULL OR TRIM(il.school_year) = ''"
|
||||
);
|
||||
}
|
||||
|
||||
$this->db->query("ALTER TABLE invoice_lines MODIFY school_year VARCHAR(9) NOT NULL");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
final class RemoveSemesterFromInvoiceStudentsList extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! $this->db->tableExists('invoice_students_list')
|
||||
|| ! $this->db->fieldExists('semester', 'invoice_students_list')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->indexesContainingColumn('invoice_students_list', 'semester') as $indexName) {
|
||||
$this->dropIndexIfExists('invoice_students_list', $indexName);
|
||||
}
|
||||
|
||||
$this->forge->dropColumn('invoice_students_list', 'semester');
|
||||
$this->db->resetDataCache();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! $this->db->tableExists('invoice_students_list')
|
||||
|| $this->db->fieldExists('semester', 'invoice_students_list')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addColumn('invoice_students_list', [
|
||||
'semester' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 10,
|
||||
'null' => true,
|
||||
'after' => 'school_year',
|
||||
],
|
||||
]);
|
||||
$this->db->resetDataCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function indexesContainingColumn(string $table, string $column): array
|
||||
{
|
||||
$rows = $this->db->query(
|
||||
'SELECT DISTINCT INDEX_NAME
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = ?
|
||||
AND COLUMN_NAME = ?
|
||||
AND INDEX_NAME <> \'PRIMARY\'',
|
||||
[$table, $column]
|
||||
)->getResultArray();
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (array $row): string => (string) ($row['INDEX_NAME'] ?? ''),
|
||||
$rows
|
||||
)));
|
||||
}
|
||||
|
||||
private function dropIndexIfExists(string $table, string $index): void
|
||||
{
|
||||
if ($index === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$exists = $this->db->query(
|
||||
'SELECT COUNT(*) AS aggregate
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = ?
|
||||
AND INDEX_NAME = ?',
|
||||
[$table, $index]
|
||||
)->getRow();
|
||||
|
||||
if ((int) ($exists->aggregate ?? 0) === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->query(sprintf(
|
||||
'ALTER TABLE %s DROP INDEX %s',
|
||||
$this->db->escapeIdentifiers($table),
|
||||
$this->db->escapeIdentifiers($index)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
final class DropUnusedInvoiceOpeningPaidBalances extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if ($this->db->tableExists('invoice_opening_paid_balances')) {
|
||||
$this->forge->dropTable('invoice_opening_paid_balances', true);
|
||||
$this->db->resetDataCache();
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if ($this->db->tableExists('invoice_opening_paid_balances')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->query(
|
||||
"CREATE TABLE `invoice_opening_paid_balances` (
|
||||
`invoice_id` INT UNSIGNED NOT NULL,
|
||||
`amount` DECIMAL(10,2) NOT NULL,
|
||||
`effective_before_payment_id` INT UNSIGNED DEFAULT NULL,
|
||||
`school_year` VARCHAR(9) NOT NULL,
|
||||
`evidence_reference` VARCHAR(255) NOT NULL,
|
||||
`approved_by` INT UNSIGNED NOT NULL,
|
||||
`approved_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`notes` TEXT,
|
||||
PRIMARY KEY (`invoice_id`)
|
||||
) ENGINE=InnoDB"
|
||||
);
|
||||
$this->db->resetDataCache();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
final class DropUnusedInvoiceLines extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if ($this->db->tableExists('invoice_lines')) {
|
||||
$this->forge->dropTable('invoice_lines', true);
|
||||
$this->db->resetDataCache();
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// invoice_lines was intentionally removed from the financial model.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
final class AddTuitionFeeToInvoiceStudentsList extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! $this->db->tableExists('invoice_students_list')
|
||||
|| $this->db->fieldExists('tuition_fee', 'invoice_students_list')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addColumn('invoice_students_list', [
|
||||
'tuition_fee' => [
|
||||
'type' => 'DECIMAL',
|
||||
'constraint' => '10,2',
|
||||
'null' => false,
|
||||
'default' => '0.00',
|
||||
'after' => 'enrolled',
|
||||
],
|
||||
]);
|
||||
$this->db->resetDataCache();
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if ($this->db->tableExists('invoice_students_list')
|
||||
&& $this->db->fieldExists('tuition_fee', 'invoice_students_list')) {
|
||||
$this->forge->dropColumn('invoice_students_list', 'tuition_fee');
|
||||
$this->db->resetDataCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
final class BackfillInvoiceStudentsList extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
foreach (['invoice_students_list', 'invoices', 'enrollments', 'students', 'student_class'] as $table) {
|
||||
if (! $this->db->tableExists($table)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$invoiceFilters = [];
|
||||
if ($this->db->fieldExists('invoice_number', 'invoices')) {
|
||||
$invoiceFilters[] = "COALESCE(i.invoice_number, '') NOT LIKE 'CF-%'";
|
||||
}
|
||||
if ($this->db->fieldExists('semester', 'invoices')) {
|
||||
$invoiceFilters[] = "LOWER(TRIM(COALESCE(i.semester, ''))) <> 'opening balance'";
|
||||
}
|
||||
if ($this->db->fieldExists('description', 'invoices')) {
|
||||
$invoiceFilters[] = "LOWER(COALESCE(i.description, '')) NOT LIKE '%carried over%'";
|
||||
$invoiceFilters[] = "LOWER(COALESCE(i.description, '')) NOT LIKE '%carry-forward%'";
|
||||
$invoiceFilters[] = "LOWER(COALESCE(i.description, '')) NOT LIKE '%carry over%'";
|
||||
$invoiceFilters[] = "LOWER(COALESCE(i.description, '')) NOT LIKE '%previous school year%'";
|
||||
}
|
||||
|
||||
$nonEventFilter = $this->db->fieldExists('is_event_only', 'student_class')
|
||||
? 'AND COALESCE(sc.is_event_only, 0) = 0'
|
||||
: '';
|
||||
|
||||
$where = $invoiceFilters === [] ? '' : 'AND ' . implode("\n AND ", $invoiceFilters);
|
||||
|
||||
$this->db->query(
|
||||
"INSERT INTO invoice_students_list (
|
||||
invoice_id,
|
||||
student_id,
|
||||
student_firstname,
|
||||
student_lastname,
|
||||
school_id,
|
||||
enrolled,
|
||||
tuition_fee,
|
||||
school_year,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
i.id AS invoice_id,
|
||||
e.student_id,
|
||||
MIN(COALESCE(s.firstname, '')) AS student_firstname,
|
||||
MIN(COALESCE(s.lastname, '')) AS student_lastname,
|
||||
MIN(COALESCE(s.school_id, 0)) AS school_id,
|
||||
MAX(CASE
|
||||
WHEN LOWER(TRIM(COALESCE(e.enrollment_status, ''))) IN ('enrolled', 'payment pending') THEN 1
|
||||
ELSE 0
|
||||
END) AS enrolled,
|
||||
0.00 AS tuition_fee,
|
||||
i.school_year,
|
||||
UTC_TIMESTAMP(),
|
||||
UTC_TIMESTAMP()
|
||||
FROM invoices i
|
||||
INNER JOIN enrollments e
|
||||
ON e.parent_id = i.parent_id
|
||||
AND e.school_year = i.school_year
|
||||
INNER JOIN students s
|
||||
ON s.id = e.student_id
|
||||
WHERE LOWER(TRIM(COALESCE(e.enrollment_status, ''))) IN (
|
||||
'enrolled',
|
||||
'payment pending',
|
||||
'withdrawn',
|
||||
'refund pending',
|
||||
'withdraw under review'
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM student_class sc
|
||||
WHERE sc.student_id = e.student_id
|
||||
AND sc.school_year = i.school_year
|
||||
{$nonEventFilter}
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM invoice_students_list isl
|
||||
WHERE isl.invoice_id = i.id
|
||||
AND isl.student_id = e.student_id
|
||||
)
|
||||
{$where}
|
||||
GROUP BY i.id, e.student_id, i.school_year"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Backfilled invoice snapshots are intentionally retained.
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,21 @@
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use App\Models\InvoiceLineModel;
|
||||
|
||||
class InvoiceAdjustmentService
|
||||
{
|
||||
private \CodeIgniter\Database\BaseConnection $db;
|
||||
private AdditionalChargeModel $additionalChargeModel;
|
||||
private InvoiceLineModel $invoiceLineModel;
|
||||
private InvoiceLedgerService $invoiceLedgerService;
|
||||
|
||||
public function __construct(
|
||||
?\CodeIgniter\Database\BaseConnection $db = null,
|
||||
?AdditionalChargeModel $additionalChargeModel = null,
|
||||
?InvoiceLineModel $invoiceLineModel = null,
|
||||
$invoiceLineModel = null,
|
||||
?InvoiceLedgerService $invoiceLedgerService = null
|
||||
) {
|
||||
$this->db = $db ?? db_connect();
|
||||
$this->additionalChargeModel = $additionalChargeModel ?? new AdditionalChargeModel();
|
||||
$this->invoiceLineModel = $invoiceLineModel ?? new InvoiceLineModel();
|
||||
$this->invoiceLedgerService = $invoiceLedgerService ?? new InvoiceLedgerService();
|
||||
}
|
||||
|
||||
@@ -43,47 +40,14 @@ class InvoiceAdjustmentService
|
||||
throw new \RuntimeException('Zero amount additional charges cannot be applied.');
|
||||
}
|
||||
|
||||
$activeSourceKey = $this->activeSourceKey($chargeId);
|
||||
$existing = $this->db->table('invoice_lines')
|
||||
->where('active_source_key', $activeSourceKey)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if ($existing) {
|
||||
throw new \RuntimeException('Additional charge already has an active invoice line.');
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
$lineId = $this->invoiceLineModel->insert([
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => (string)$invoice['school_year'],
|
||||
'line_type' => $amountCents > 0 ? 'additional_charge' : 'additional_deduction',
|
||||
'source_type' => 'additional_charge',
|
||||
'source_id' => $chargeId,
|
||||
'active_source_key' => $activeSourceKey,
|
||||
'description' => $this->chargeDescription($charge),
|
||||
'quantity' => '1.00',
|
||||
'unit_amount_cents' => $amountCents,
|
||||
'line_amount_cents' => $amountCents,
|
||||
'discount_eligible' => 0,
|
||||
'calculation_version' => 'invoice_adjustment_v1',
|
||||
'metadata_json' => json_encode([
|
||||
'charge_type' => (string)($charge['charge_type'] ?? ''),
|
||||
'applied_by' => $actorId,
|
||||
], JSON_UNESCAPED_SLASHES),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
'voided_at' => null,
|
||||
]);
|
||||
$this->requireWrite($lineId, 'INVOICE_ADJUSTMENT_LINE_INSERT_FAILED', $this->invoiceLineModel);
|
||||
|
||||
$this->requireWrite($this->additionalChargeModel->update($chargeId, [
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_APPLIED,
|
||||
'invoice_id' => $invoiceId,
|
||||
'parent_id' => (int)$invoice['parent_id'],
|
||||
'school_year' => (string)$invoice['school_year'],
|
||||
'semester' => (string)($invoice['semester'] ?? ''),
|
||||
'applied_invoice_line_id' => (int)$lineId,
|
||||
'applied_invoice_line_id' => null,
|
||||
'applied_by' => $actorId > 0 ? $actorId : null,
|
||||
'applied_at' => $now,
|
||||
]), 'ADDITIONAL_CHARGE_APPLY_UPDATE_FAILED', $this->additionalChargeModel);
|
||||
@@ -92,7 +56,7 @@ class InvoiceAdjustmentService
|
||||
$this->requireTransactionStatus();
|
||||
$this->db->transCommit();
|
||||
|
||||
return new InvoiceLedgerResult($invoiceId, (int)$lineId, $ledger);
|
||||
return new InvoiceLedgerResult($invoiceId, 0, $ledger);
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
@@ -118,50 +82,11 @@ class InvoiceAdjustmentService
|
||||
$invoice = $this->lockInvoice($invoiceId);
|
||||
$this->assertInvoiceAcceptsAdjustment($invoice);
|
||||
|
||||
$originalLineId = (int)($charge['applied_invoice_line_id'] ?? 0);
|
||||
$originalLine = $originalLineId > 0
|
||||
? $this->db->query('SELECT * FROM invoice_lines WHERE id = ? FOR UPDATE', [$originalLineId])->getRowArray()
|
||||
: null;
|
||||
if (!$originalLine) {
|
||||
$originalLine = $this->db->query(
|
||||
'SELECT * FROM invoice_lines WHERE source_type = ? AND source_id = ? AND voided_at IS NULL ORDER BY id ASC LIMIT 1 FOR UPDATE',
|
||||
['additional_charge', $chargeId]
|
||||
)->getRowArray();
|
||||
}
|
||||
if (!$originalLine) {
|
||||
throw new \RuntimeException('Original invoice line not found.');
|
||||
}
|
||||
|
||||
$reverseAmountCents = -1 * (int)($originalLine['line_amount_cents'] ?? 0);
|
||||
if ($reverseAmountCents === 0) {
|
||||
if ($this->signedChargeAmountCents($charge) === 0) {
|
||||
throw new \RuntimeException('Zero amount additional charges cannot be reversed.');
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
$lineId = $this->invoiceLineModel->insert([
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => (string)$invoice['school_year'],
|
||||
'line_type' => $reverseAmountCents > 0 ? 'additional_charge_reversal' : 'additional_deduction_reversal',
|
||||
'source_type' => 'additional_charge_reversal',
|
||||
'source_id' => $chargeId,
|
||||
'active_source_key' => null,
|
||||
'description' => 'Reversal: ' . $this->chargeDescription($charge),
|
||||
'quantity' => '1.00',
|
||||
'unit_amount_cents' => $reverseAmountCents,
|
||||
'line_amount_cents' => $reverseAmountCents,
|
||||
'discount_eligible' => 0,
|
||||
'calculation_version' => 'invoice_adjustment_v1',
|
||||
'metadata_json' => json_encode([
|
||||
'original_invoice_line_id' => (int)($originalLine['id'] ?? 0),
|
||||
'reason' => $reason,
|
||||
'reversed_by' => $actorId,
|
||||
], JSON_UNESCAPED_SLASHES),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
'voided_at' => null,
|
||||
]);
|
||||
$this->requireWrite($lineId, 'INVOICE_ADJUSTMENT_REVERSAL_LINE_INSERT_FAILED', $this->invoiceLineModel);
|
||||
|
||||
$this->requireWrite($this->additionalChargeModel->update($chargeId, [
|
||||
'status' => 'reversed',
|
||||
'voided_by' => $actorId > 0 ? $actorId : null,
|
||||
@@ -173,7 +98,7 @@ class InvoiceAdjustmentService
|
||||
$this->requireTransactionStatus();
|
||||
$this->db->transCommit();
|
||||
|
||||
return new InvoiceLedgerResult($invoiceId, (int)$lineId, $ledger);
|
||||
return new InvoiceLedgerResult($invoiceId, 0, $ledger);
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
@@ -226,11 +151,6 @@ class InvoiceAdjustmentService
|
||||
return (string)($charge['charge_type'] ?? 'add') === 'deduct' ? -1 * $amount : $amount;
|
||||
}
|
||||
|
||||
private function activeSourceKey(int $chargeId): string
|
||||
{
|
||||
return 'additional_charge:' . $chargeId;
|
||||
}
|
||||
|
||||
private function chargeDescription(array $charge): string
|
||||
{
|
||||
$title = trim((string)($charge['title'] ?? 'Additional charge'));
|
||||
|
||||
@@ -2,25 +2,22 @@
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\InvoiceLineModel;
|
||||
use App\Models\InvoiceModel;
|
||||
|
||||
class InvoiceIssuanceService
|
||||
{
|
||||
private \CodeIgniter\Database\BaseConnection $db;
|
||||
private InvoiceModel $invoiceModel;
|
||||
private InvoiceLineModel $invoiceLineModel;
|
||||
private InvoiceLedgerService $invoiceLedgerService;
|
||||
|
||||
public function __construct(
|
||||
?\CodeIgniter\Database\BaseConnection $db = null,
|
||||
?InvoiceModel $invoiceModel = null,
|
||||
?InvoiceLineModel $invoiceLineModel = null,
|
||||
$invoiceLineModel = null,
|
||||
?InvoiceLedgerService $invoiceLedgerService = null
|
||||
) {
|
||||
$this->db = $db ?? db_connect();
|
||||
$this->invoiceModel = $invoiceModel ?? new InvoiceModel();
|
||||
$this->invoiceLineModel = $invoiceLineModel ?? new InvoiceLineModel();
|
||||
$this->invoiceLedgerService = $invoiceLedgerService ?? new InvoiceLedgerService();
|
||||
}
|
||||
|
||||
@@ -45,29 +42,6 @@ class InvoiceIssuanceService
|
||||
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int)$invoiceId]);
|
||||
|
||||
$inserted = $this->invoiceLedgerService->issueInitialInvoiceLines(
|
||||
(int)$invoiceId,
|
||||
$command->tuitionAmount,
|
||||
$command->eventAmount,
|
||||
$command->metadata
|
||||
);
|
||||
if ($inserted <= 0) {
|
||||
throw new FinancialPersistenceException('INVOICE_ISSUE_NO_LINES');
|
||||
}
|
||||
|
||||
$lineTotals = $this->db->table('invoice_lines')
|
||||
->select('COUNT(*) AS line_count, COALESCE(SUM(line_amount_cents),0) AS total_cents')
|
||||
->where('invoice_id', (int)$invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if ((int)($lineTotals['line_count'] ?? 0) !== $inserted) {
|
||||
throw new FinancialPersistenceException('INVOICE_ISSUE_LINE_COUNT_MISMATCH');
|
||||
}
|
||||
if ((int)($lineTotals['total_cents'] ?? 0) === 0) {
|
||||
throw new FinancialPersistenceException('INVOICE_ISSUE_ZERO_LINE_TOTAL');
|
||||
}
|
||||
|
||||
$this->requireWrite($this->invoiceModel->update((int)$invoiceId, [
|
||||
'status' => FinancialStatus::INVOICE_ISSUED,
|
||||
'updated_at' => utc_now(),
|
||||
|
||||
@@ -12,7 +12,7 @@ use App\Models\DiscountUsageModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Models\EventChargesModel;
|
||||
use App\Models\InvoiceEventModel;
|
||||
use App\Models\InvoiceLineModel;
|
||||
use App\Models\InvoiceStudentListModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\RefundModel;
|
||||
@@ -34,7 +34,7 @@ class InvoiceLedgerService
|
||||
protected ClassSectionModel $classSectionModel;
|
||||
protected EventChargesModel $eventChargesModel;
|
||||
protected InvoiceEventModel $invoiceEventModel;
|
||||
protected ?InvoiceLineModel $invoiceLineModel = null;
|
||||
protected InvoiceStudentListModel $invoiceStudentListModel;
|
||||
protected StudentModel $studentModel;
|
||||
protected TuitionCalculatorInterface $oldCalculator;
|
||||
protected TuitionCalculatorInterface $newCalculator;
|
||||
@@ -53,7 +53,7 @@ class InvoiceLedgerService
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->eventChargesModel = new EventChargesModel();
|
||||
$this->invoiceEventModel = new InvoiceEventModel();
|
||||
$this->invoiceLineModel = new InvoiceLineModel();
|
||||
$this->invoiceStudentListModel = new InvoiceStudentListModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->oldCalculator = new OldTuitionCalculatorService();
|
||||
$this->newCalculator = new NewTuitionCalculatorService();
|
||||
@@ -145,8 +145,78 @@ class InvoiceLedgerService
|
||||
];
|
||||
}
|
||||
|
||||
public function storedInvoiceLedger(int $invoiceId): array
|
||||
{
|
||||
$invoice = $this->loadInvoice($invoiceId);
|
||||
if ($invoice === null) {
|
||||
throw new \RuntimeException('Invoice not found.');
|
||||
}
|
||||
|
||||
$totalAmountCents = $this->toCents((float) ($invoice['total_amount'] ?? 0));
|
||||
$paidCents = $this->toCents((float) ($invoice['paid_amount'] ?? $this->calculateValidPayments($invoiceId)));
|
||||
$balanceCents = $this->toCents((float) ($invoice['balance'] ?? 0));
|
||||
$refundPaidCents = $this->toCents($this->calculatePaidRefunds($invoiceId));
|
||||
|
||||
$discountCents = 0;
|
||||
if ($this->invoiceModel->db->fieldExists('discount', $this->invoiceModel->table)) {
|
||||
$discountCents = $this->toCents((float) ($invoice['discount'] ?? 0));
|
||||
}
|
||||
if ($discountCents === 0) {
|
||||
$discountCents = $this->toCents($this->calculateDiscounts($invoiceId));
|
||||
}
|
||||
|
||||
$netChargeCents = max(0, $totalAmountCents - $discountCents);
|
||||
$rawBalanceCents = $balanceCents;
|
||||
$customerCreditCents = max(0, -1 * $balanceCents);
|
||||
$balanceDueCents = max(0, $balanceCents);
|
||||
$status = (string) ($invoice['status'] ?? '');
|
||||
if ($status === '') {
|
||||
if ($balanceDueCents === 0) {
|
||||
$status = FinancialStatus::INVOICE_PAID;
|
||||
} elseif ($paidCents > 0 || $discountCents > 0 || $refundPaidCents > 0) {
|
||||
$status = FinancialStatus::INVOICE_PARTIALLY_PAID;
|
||||
} else {
|
||||
$status = FinancialStatus::INVOICE_UNPAID;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'invoice_id' => $invoiceId,
|
||||
'gross_charge_cents' => $totalAmountCents,
|
||||
'discount_eligible_base_cents' => $totalAmountCents,
|
||||
'requested_discount_cents' => $discountCents,
|
||||
'applied_discount_cents' => $discountCents,
|
||||
'net_charge_cents' => $netChargeCents,
|
||||
'totalAmountCents' => $totalAmountCents,
|
||||
'discountCents' => $discountCents,
|
||||
'paidCents' => $paidCents,
|
||||
'completedRefundCents' => $refundPaidCents,
|
||||
'rawBalanceCents' => $rawBalanceCents,
|
||||
'balanceDueCents' => $balanceDueCents,
|
||||
'customerCreditCents' => $customerCreditCents,
|
||||
'tuition_total' => '0.00',
|
||||
'event_total' => '0.00',
|
||||
'additional_total' => '0.00',
|
||||
'discount_total' => $this->fromCents($discountCents),
|
||||
'discount_raw_total' => $this->fromCents($discountCents),
|
||||
'paid_amount' => $this->fromCents($paidCents),
|
||||
'refund_paid_total' => $this->fromCents($refundPaidCents),
|
||||
'total_amount' => $this->fromCents($totalAmountCents),
|
||||
'customer_credit' => $this->fromCents($customerCreditCents),
|
||||
'balance' => $this->fromCents($balanceDueCents),
|
||||
'status' => $status,
|
||||
'has_discount' => $discountCents > 0 ? 1 : (int) ($invoice['has_discount'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function recalculateInvoice(int $invoiceId): array
|
||||
{
|
||||
$invoice = $this->loadInvoice($invoiceId);
|
||||
if ($invoice === null) {
|
||||
throw new \RuntimeException('Invoice not found.');
|
||||
}
|
||||
|
||||
$this->syncInvoiceStudentsList($invoice);
|
||||
$calculation = $this->calculateInvoice($invoiceId);
|
||||
$payload = [
|
||||
'total_amount' => $calculation['total_amount'],
|
||||
@@ -168,6 +238,99 @@ class InvoiceLedgerService
|
||||
return $calculation;
|
||||
}
|
||||
|
||||
public function syncInvoiceStudentsList(array $invoice): void
|
||||
{
|
||||
if (! $this->invoiceStudentListModel->db->tableExists('invoice_students_list')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isCarryForwardInvoice($invoice)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$invoiceId = (int) ($invoice['id'] ?? 0);
|
||||
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
||||
$schoolYear = trim((string) ($invoice['school_year'] ?? ''));
|
||||
if ($invoiceId <= 0 || $parentId <= 0 || $schoolYear === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$hasTuitionFeeColumn = $this->invoiceStudentListModel->db->fieldExists('tuition_fee', 'invoice_students_list');
|
||||
$existingRows = $this->invoiceStudentListModel
|
||||
->select('student_id')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->findAll();
|
||||
$existingStudentIds = [];
|
||||
foreach ($existingRows as $row) {
|
||||
$studentId = (int) ($row['student_id'] ?? 0);
|
||||
if ($studentId > 0) {
|
||||
$existingStudentIds[$studentId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$eligibleStatuses = ['enrolled', 'payment pending', 'withdrawn', 'refund pending', 'withdraw under review'];
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$snapshotRows = [];
|
||||
$studentIds = [];
|
||||
foreach ($enrollments as $enrollment) {
|
||||
$status = strtolower(trim((string) ($enrollment['enrollment_status'] ?? '')));
|
||||
$studentId = (int) ($enrollment['student_id'] ?? 0);
|
||||
if ($studentId <= 0 || isset($existingStudentIds[$studentId]) || ! in_array($status, $eligibleStatuses, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->studentClassModel->hasNonEventAssignment($studentId, $schoolYear)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$snapshotRows[] = [
|
||||
'student_id' => $studentId,
|
||||
'enrolled' => in_array($status, ['enrolled', 'payment pending'], true) ? 1 : 0,
|
||||
];
|
||||
$studentIds[] = $studentId;
|
||||
$existingStudentIds[$studentId] = true;
|
||||
}
|
||||
|
||||
$studentIds = array_values(array_unique($studentIds));
|
||||
if ($studentIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$studentRows = $this->studentModel
|
||||
->select('id, firstname, lastname, school_id')
|
||||
->whereIn('id', $studentIds)
|
||||
->findAll();
|
||||
$studentsById = [];
|
||||
foreach ($studentRows as $studentRow) {
|
||||
$studentsById[(int) ($studentRow['id'] ?? 0)] = $studentRow;
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
foreach ($snapshotRows as $snapshotRow) {
|
||||
$studentId = (int) ($snapshotRow['student_id'] ?? 0);
|
||||
$student = $studentsById[$studentId] ?? null;
|
||||
if ($student === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->invoiceStudentListModel->insert([
|
||||
'invoice_id' => $invoiceId,
|
||||
'student_id' => $studentId,
|
||||
'student_firstname' => (string) ($student['firstname'] ?? ''),
|
||||
'student_lastname' => (string) ($student['lastname'] ?? ''),
|
||||
'school_id' => (int) ($student['school_id'] ?? 0),
|
||||
'enrolled' => (int) ($snapshotRow['enrolled'] ?? 0),
|
||||
'school_year' => $schoolYear,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
] + ($hasTuitionFeeColumn ? ['tuition_fee' => '0.00'] : []));
|
||||
}
|
||||
}
|
||||
|
||||
public function recalculate(int $invoiceId): array
|
||||
{
|
||||
return $this->recalculateInvoice($invoiceId);
|
||||
@@ -179,68 +342,7 @@ class InvoiceLedgerService
|
||||
*/
|
||||
public function syncTuitionLines(int $invoiceId): bool
|
||||
{
|
||||
$invoice = $this->loadInvoice($invoiceId);
|
||||
if ($invoice === null || $this->isCarryForwardInvoice($invoice) || ! $this->invoiceLinesAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->invoiceHasLines($invoiceId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentTuitionCents = $this->toCents($this->calculateTuitionTotal($invoice));
|
||||
$frozen = $this->calculateFrozenLineTotals($invoiceId);
|
||||
$frozenTuitionCents = (int) ($frozen['tuition_cents'] ?? 0);
|
||||
|
||||
if ($currentTuitionCents === $frozenTuitionCents) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
$this->invoiceLineModel()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->groupStart()
|
||||
->where('line_type', 'tuition')
|
||||
->orWhere('source_type', 'tuition_calculation')
|
||||
->groupEnd()
|
||||
->set([
|
||||
'voided_at' => $now,
|
||||
'updated_at' => $now,
|
||||
])
|
||||
->update();
|
||||
|
||||
if ($currentTuitionCents === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$metadata = [
|
||||
'parent_id' => (int) ($invoice['parent_id'] ?? 0),
|
||||
'school_year' => (string) ($invoice['school_year'] ?? ''),
|
||||
'semester' => (string) ($invoice['semester'] ?? ''),
|
||||
'synced_at' => $now,
|
||||
];
|
||||
|
||||
$lineId = $this->invoiceLineModel()->insert(
|
||||
$this->buildInvoiceLineRow(
|
||||
$invoiceId,
|
||||
'tuition',
|
||||
'tuition_calculation',
|
||||
null,
|
||||
'Tuition charges',
|
||||
$currentTuitionCents,
|
||||
1,
|
||||
$this->getCalculationVersion(),
|
||||
$metadata,
|
||||
$now
|
||||
)
|
||||
);
|
||||
|
||||
if (! $lineId) {
|
||||
throw new FinancialPersistenceException('INVOICE_TUITION_SYNC_FAILED', $this->invoiceLineModel()->errors());
|
||||
}
|
||||
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getValidPaymentTotalCents(int $invoiceId): int
|
||||
@@ -257,118 +359,7 @@ class InvoiceLedgerService
|
||||
if ($invoiceId <= 0) {
|
||||
throw new \RuntimeException('Invoice ID is required to issue invoice lines.');
|
||||
}
|
||||
if (!$this->invoiceLinesAvailable()) {
|
||||
throw new \RuntimeException('Invoice lines table is not available.');
|
||||
}
|
||||
if ($this->invoiceHasLines($invoiceId)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
$version = (string) ($metadata['calculation_version'] ?? $this->getCalculationVersion());
|
||||
$rows = [];
|
||||
|
||||
$tuitionCents = $this->toCents($tuitionAmount);
|
||||
if ($tuitionCents !== 0) {
|
||||
$rows[] = $this->buildInvoiceLineRow(
|
||||
$invoiceId,
|
||||
'tuition',
|
||||
'tuition_calculation',
|
||||
null,
|
||||
'Tuition charges',
|
||||
$tuitionCents,
|
||||
1,
|
||||
$version,
|
||||
$metadata,
|
||||
$now
|
||||
);
|
||||
}
|
||||
|
||||
$eventCents = $this->toCents($eventAmount);
|
||||
if ($eventCents !== 0) {
|
||||
$rows[] = $this->buildInvoiceLineRow(
|
||||
$invoiceId,
|
||||
'event_fee',
|
||||
'event_charges',
|
||||
null,
|
||||
'Event charges',
|
||||
$eventCents,
|
||||
0,
|
||||
$version,
|
||||
$metadata,
|
||||
$now
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->loadApprovedAdjustmentRowsForIssuance($invoiceId, $metadata) as $charge) {
|
||||
$adjustmentCents = (string)($charge['charge_type'] ?? 'add') === 'deduct'
|
||||
? -1 * $this->toCents(abs((float)($charge['amount'] ?? 0)))
|
||||
: $this->toCents(abs((float)($charge['amount'] ?? 0)));
|
||||
if ($adjustmentCents === 0) {
|
||||
throw new \RuntimeException('Zero amount approved additional charges cannot be issued.');
|
||||
}
|
||||
|
||||
$row = $this->buildInvoiceLineRow(
|
||||
$invoiceId,
|
||||
$adjustmentCents > 0 ? 'additional_charge' : 'additional_deduction',
|
||||
'additional_charge',
|
||||
(int)$charge['id'],
|
||||
trim((string)($charge['title'] ?? 'Additional charge')) ?: 'Additional charge',
|
||||
$adjustmentCents,
|
||||
0,
|
||||
$version,
|
||||
$metadata + ['charge_type' => (string)($charge['charge_type'] ?? '')],
|
||||
$now
|
||||
);
|
||||
$row['active_source_key'] = 'additional_charge:' . (int)$charge['id'];
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
throw new \RuntimeException('Invoice issuance requires at least one non-zero invoice line.');
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
foreach ($rows as $row) {
|
||||
$lineId = $this->invoiceLineModel()->insert($row);
|
||||
if (!$lineId) {
|
||||
throw new FinancialPersistenceException('INVOICE_LINE_INSERT_FAILED', $this->invoiceLineModel()->errors());
|
||||
}
|
||||
$inserted++;
|
||||
|
||||
if (($row['source_type'] ?? null) === 'additional_charge' && !empty($row['source_id'])) {
|
||||
$this->additionalChargeModel->update((int)$row['source_id'], [
|
||||
'invoice_id' => $invoiceId,
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_APPLIED,
|
||||
'applied_invoice_line_id' => (int)$lineId,
|
||||
'applied_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
protected function loadApprovedAdjustmentRowsForIssuance(int $invoiceId, array $metadata): array
|
||||
{
|
||||
$parentId = (int)($metadata['parent_id'] ?? 0);
|
||||
$schoolYear = (string)($metadata['school_year'] ?? '');
|
||||
$semester = (string)($metadata['semester'] ?? '');
|
||||
if ($parentId <= 0 || $schoolYear === '' || $semester === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->additionalChargeModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPROVED)
|
||||
->groupStart()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->orWhere('invoice_id IS NULL', null, false)
|
||||
->groupEnd()
|
||||
->orderBy('id', 'ASC')
|
||||
->findAll();
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function loadInvoice(int $invoiceId): ?array
|
||||
@@ -378,109 +369,17 @@ class InvoiceLedgerService
|
||||
|
||||
protected function calculateFrozenLineTotals(int $invoiceId): ?array
|
||||
{
|
||||
if (!$this->invoiceLinesAvailable() || !$this->invoiceHasLines($invoiceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = $this->invoiceLineModel()
|
||||
->select('line_type, line_amount_cents, discount_eligible')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->findAll();
|
||||
|
||||
$totals = [
|
||||
'tuition_cents' => 0,
|
||||
'event_cents' => 0,
|
||||
'additional_cents' => 0,
|
||||
'total_cents' => 0,
|
||||
'discount_eligible_base_cents' => 0,
|
||||
];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$amount = (int) ($row['line_amount_cents'] ?? 0);
|
||||
$type = (string) ($row['line_type'] ?? '');
|
||||
$totals['total_cents'] += $amount;
|
||||
if ((int) ($row['discount_eligible'] ?? 0) === 1) {
|
||||
$totals['discount_eligible_base_cents'] += $amount;
|
||||
}
|
||||
|
||||
if (str_contains($type, 'event')) {
|
||||
$totals['event_cents'] += $amount;
|
||||
} elseif (str_contains($type, 'additional') || str_contains($type, 'adjustment') || str_contains($type, 'charge')) {
|
||||
$totals['additional_cents'] += $amount;
|
||||
} else {
|
||||
$totals['tuition_cents'] += $amount;
|
||||
}
|
||||
}
|
||||
|
||||
return $totals;
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function invoiceHasLines(int $invoiceId): bool
|
||||
{
|
||||
if (!$this->invoiceLinesAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->invoiceLineModel()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->countAllResults() > 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function invoiceLinesAvailable(): bool
|
||||
{
|
||||
try {
|
||||
return $this->invoiceLineModel()->db->tableExists('invoice_lines');
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function invoiceLineModel(): InvoiceLineModel
|
||||
{
|
||||
if ($this->invoiceLineModel === null) {
|
||||
$this->invoiceLineModel = new InvoiceLineModel();
|
||||
}
|
||||
|
||||
return $this->invoiceLineModel;
|
||||
}
|
||||
|
||||
protected function buildInvoiceLineRow(
|
||||
int $invoiceId,
|
||||
string $lineType,
|
||||
?string $sourceType,
|
||||
?int $sourceId,
|
||||
string $description,
|
||||
int $amountCents,
|
||||
int $discountEligible,
|
||||
string $version,
|
||||
array $metadata,
|
||||
string $timestamp
|
||||
): array {
|
||||
return [
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => (string)($metadata['school_year'] ?? ''),
|
||||
'line_type' => $lineType,
|
||||
'source_type' => $sourceType,
|
||||
'source_id' => $sourceId,
|
||||
'active_source_key' => null,
|
||||
'description' => $description,
|
||||
'quantity' => '1.00',
|
||||
'unit_amount_cents' => $amountCents,
|
||||
'line_amount_cents' => $amountCents,
|
||||
'discount_eligible' => $discountEligible,
|
||||
'calculation_version' => $version,
|
||||
'metadata_json' => json_encode($metadata, JSON_UNESCAPED_SLASHES),
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
'voided_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getCalculationVersion(): string
|
||||
{
|
||||
return 'invoice_lines_v1:' . get_class($this->resolveActiveCalculator());
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function isCarryForwardInvoice(array $invoice): bool
|
||||
@@ -522,77 +421,7 @@ class InvoiceLedgerService
|
||||
*/
|
||||
public function issueCarryForwardInvoiceLine(int $invoiceId, float $amount, string $description): int
|
||||
{
|
||||
if ($invoiceId <= 0 || ! $this->invoiceLinesAvailable()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$invoice = $this->loadInvoice($invoiceId);
|
||||
if ($invoice === null || ! $this->isCarryForwardInvoice($invoice)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$amountCents = $this->toCents($amount);
|
||||
if ($amountCents === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$description = trim($description);
|
||||
if ($description === '') {
|
||||
$description = $this->carryForwardDisplayDescription($invoice);
|
||||
}
|
||||
|
||||
$existingLine = $this->invoiceLineModel()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('source_type', 'carry_forward_opening_balance')
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->first();
|
||||
|
||||
$now = utc_now();
|
||||
|
||||
$this->invoiceLineModel()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->groupStart()
|
||||
->where('line_type', 'tuition')
|
||||
->orWhere('source_type', 'tuition_calculation')
|
||||
->groupEnd()
|
||||
->set([
|
||||
'voided_at' => $now,
|
||||
'updated_at' => $now,
|
||||
])
|
||||
->update();
|
||||
|
||||
if ($existingLine !== null) {
|
||||
$this->invoiceLineModel()->update((int) $existingLine['id'], [
|
||||
'description' => $description,
|
||||
'unit_amount_cents' => $amountCents,
|
||||
'line_amount_cents' => $amountCents,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
return (int) $existingLine['id'];
|
||||
}
|
||||
|
||||
$lineId = $this->invoiceLineModel()->insert(
|
||||
$this->buildInvoiceLineRow(
|
||||
$invoiceId,
|
||||
'additional_charge',
|
||||
'carry_forward_opening_balance',
|
||||
$invoiceId,
|
||||
$description,
|
||||
$amountCents,
|
||||
0,
|
||||
$this->getCalculationVersion(),
|
||||
['carry_forward' => true],
|
||||
$now
|
||||
)
|
||||
);
|
||||
|
||||
if (! $lineId) {
|
||||
throw new FinancialPersistenceException('INVOICE_CARRY_FORWARD_LINE_FAILED', $this->invoiceLineModel()->errors());
|
||||
}
|
||||
|
||||
return (int) $lineId;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function carryForwardSourceSchoolYear(array $invoice): string
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
use App\Models\Concerns\SchoolYearAutoFillTrait;
|
||||
|
||||
class InvoiceLineModel extends Model
|
||||
{
|
||||
use SchoolYearAutoFillTrait;
|
||||
|
||||
protected $table = 'invoice_lines';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = false;
|
||||
|
||||
protected $allowedFields = [
|
||||
'invoice_id',
|
||||
'school_year',
|
||||
'line_type',
|
||||
'source_type',
|
||||
'source_id',
|
||||
'active_source_key',
|
||||
'description',
|
||||
'quantity',
|
||||
'unit_amount_cents',
|
||||
'line_amount_cents',
|
||||
'discount_eligible',
|
||||
'calculation_version',
|
||||
'metadata_json',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'voided_at',
|
||||
];
|
||||
|
||||
protected $validationRules = [
|
||||
'invoice_id' => 'required|integer',
|
||||
'school_year' => 'required|string|max_length[9]',
|
||||
'line_type' => 'required|max_length[50]',
|
||||
'description' => 'required|max_length[255]',
|
||||
'quantity' => 'required|decimal',
|
||||
'unit_amount_cents' => 'required|integer',
|
||||
'line_amount_cents' => 'required|integer',
|
||||
'discount_eligible' => 'required|in_list[0,1]',
|
||||
];
|
||||
}
|
||||
@@ -23,6 +23,7 @@ class InvoiceStudentListModel extends Model
|
||||
'student_lastname',
|
||||
'school_id',
|
||||
'enrolled',
|
||||
'tuition_fee',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'school_year',
|
||||
|
||||
@@ -416,14 +416,17 @@ final class WithdrawalFinancialService
|
||||
if ($invoice !== null) {
|
||||
$this->lockInvoice((int) $invoice['id']);
|
||||
$ledger = $this->invoiceLedger->calculateInvoice((int) $invoice['id']);
|
||||
$existingTargetAdjustment = $this->db->table('invoice_lines il')
|
||||
->selectSum('il.line_amount_cents', 'total')
|
||||
->select("COALESCE(SUM(CASE WHEN il.discount_eligible = 1 THEN il.line_amount_cents ELSE 0 END), 0) AS eligible_total", false)
|
||||
->join('withdrawal_financial_calculations wfc', 'wfc.id = il.source_id AND il.source_type = \'withdrawal_calculation\'', 'inner')
|
||||
->where('il.invoice_id', (int) $invoice['id'])
|
||||
->where('wfc.enrollment_id', (int) $enrollment['id'])
|
||||
->where('il.voided_at', null)
|
||||
->get()->getRowArray();
|
||||
$existingTargetAdjustment = ['total' => 0, 'eligible_total' => 0];
|
||||
if ($this->invoiceLinesAvailable()) {
|
||||
$existingTargetAdjustment = $this->db->table('invoice_lines il')
|
||||
->selectSum('il.line_amount_cents', 'total')
|
||||
->select("COALESCE(SUM(CASE WHEN il.discount_eligible = 1 THEN il.line_amount_cents ELSE 0 END), 0) AS eligible_total", false)
|
||||
->join('withdrawal_financial_calculations wfc', 'wfc.id = il.source_id AND il.source_type = \'withdrawal_calculation\'', 'inner')
|
||||
->where('il.invoice_id', (int) $invoice['id'])
|
||||
->where('wfc.enrollment_id', (int) $enrollment['id'])
|
||||
->where('il.voided_at', null)
|
||||
->get()->getRowArray();
|
||||
}
|
||||
$baseGrossCharge = (int) ($ledger['gross_charge_cents'] ?? 0) - (int) ($existingTargetAdjustment['total'] ?? 0);
|
||||
$baseDiscountEligible = max(0, (int) ($ledger['discount_eligible_base_cents'] ?? 0) - (int) ($existingTargetAdjustment['eligible_total'] ?? 0));
|
||||
$requestedDiscount = max(0, (int) ($ledger['requested_discount_cents'] ?? 0));
|
||||
@@ -596,6 +599,10 @@ final class WithdrawalFinancialService
|
||||
/** @return list<string> */
|
||||
private function invoiceReconstructionBlockers(int $invoiceId, int $expectedFamilyTuition, int $expectedStudentCount, bool $legacyConfirmed): array
|
||||
{
|
||||
if (! $this->invoiceLinesAvailable()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$lines = $this->db->table('invoice_lines')
|
||||
->select('line_type, source_type, line_amount_cents')
|
||||
->where('invoice_id', $invoiceId)
|
||||
@@ -635,6 +642,10 @@ final class WithdrawalFinancialService
|
||||
/** @return list<array<string,mixed>> */
|
||||
private function invoiceTuitionStudentDetails(int $invoiceId): array
|
||||
{
|
||||
if (! $this->invoiceLinesAvailable()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$line = $this->db->table('invoice_lines')
|
||||
->select('metadata_json')
|
||||
->where('invoice_id', $invoiceId)
|
||||
@@ -654,6 +665,10 @@ final class WithdrawalFinancialService
|
||||
|
||||
private function appendInvoiceLines(array $invoice, array $calculation, array $books): void
|
||||
{
|
||||
if (! $this->invoiceLinesAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$invoiceId = (int) $invoice['id'];
|
||||
$calculationId = (int) $calculation['id'];
|
||||
$enrollmentId = (int) $calculation['enrollment_id'];
|
||||
@@ -790,11 +805,13 @@ final class WithdrawalFinancialService
|
||||
private function supersedePostedCalculation(int $oldId, int $newId): void
|
||||
{
|
||||
$now = utc_now();
|
||||
$this->db->table('invoice_lines')->where('source_type', 'withdrawal_calculation')->where('source_id', $oldId)->where('voided_at', null)->update([
|
||||
'active_source_key' => null,
|
||||
'voided_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
if ($this->invoiceLinesAvailable()) {
|
||||
$this->db->table('invoice_lines')->where('source_type', 'withdrawal_calculation')->where('source_id', $oldId)->where('voided_at', null)->update([
|
||||
'active_source_key' => null,
|
||||
'voided_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
$this->db->table('withdrawal_financial_calculations')->where('id', $oldId)->update([
|
||||
'status' => 'superseded',
|
||||
'active_posted_key' => null,
|
||||
@@ -927,10 +944,15 @@ final class WithdrawalFinancialService
|
||||
|
||||
private function assertTables(): void
|
||||
{
|
||||
foreach (['withdrawal_financial_calculations', 'student_book_issues', 'invoice_lines', 'refunds', 'school_years'] as $table) {
|
||||
foreach (['withdrawal_financial_calculations', 'student_book_issues', 'refunds', 'school_years'] as $table) {
|
||||
if (! $this->db->tableExists($table)) {
|
||||
throw new RuntimeException('Required table is missing: ' . $table . '. Run migrations first.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function invoiceLinesAvailable(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="generateInvoiceConfirmModal" tabindex="-1" aria-labelledby="generateInvoiceConfirmModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="generateInvoiceConfirmModalLabel">Generate Invoice?</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-2">Generate Invoice will recalculate this invoice using the current tuition settings and current student enrollment.</p>
|
||||
<p class="mb-0 text-muted small">This will update the saved invoice student list.</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" id="generateInvoiceNoButton" data-bs-dismiss="modal">No</button>
|
||||
<button type="button" class="btn btn-primary" id="generateInvoiceYesButton">Yes, Generate Invoice</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('scripts') ?>
|
||||
@@ -80,6 +99,7 @@
|
||||
|
||||
const fmtMoney = (v) => '$' + Number(v || 0).toFixed(2);
|
||||
const esc = (s) => $('<div>').text(String(s ?? '')).html();
|
||||
const escAttr = (s) => esc(s).replace(/`/g, '`');
|
||||
const pad2 = (num) => String(num).padStart(2, '0');
|
||||
const formatDateTime = (ts) => {
|
||||
const date = new Date(ts);
|
||||
@@ -130,7 +150,7 @@
|
||||
const ts = Date.parse(r.invoice_date || new Date().toISOString());
|
||||
const genBtn = isCarryForward
|
||||
? '<span class="text-muted small">Audit only</span>'
|
||||
: `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${esc(r.parent_id)}\" onclick=\"return window.__genInvoice && window.__genInvoice(this)\">Generate Invoice</button>`;
|
||||
: `<button type=\"button\" class=\"btn btn-primary btn-sm gen-invoice\" data-parent-id=\"${escAttr(r.parent_id)}\" data-parent-name=\"${escAttr(r.parent_name || '')}\">Generate Invoice</button>`;
|
||||
const invoiceLabel = isCarryForward
|
||||
? `<div><span class="badge bg-secondary me-1">Carry-over</span>${esc(r.invoice_description || 'Carry over balance')}</div>`
|
||||
+ (r.invoice_number ? `<div class="text-muted small">${esc(r.invoice_number)}</div>` : '')
|
||||
@@ -169,6 +189,47 @@
|
||||
return json || { ok: true };
|
||||
}
|
||||
|
||||
function confirmGenerateInvoice(parentName) {
|
||||
return new Promise((resolve) => {
|
||||
const modalEl = document.getElementById('generateInvoiceConfirmModal');
|
||||
const yesButton = document.getElementById('generateInvoiceYesButton');
|
||||
if (!modalEl || !yesButton || typeof bootstrap === 'undefined') {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const body = modalEl.querySelector('.modal-body');
|
||||
if (body) {
|
||||
const name = parentName ? `<strong>${esc(parentName)}</strong>` : 'this parent';
|
||||
body.innerHTML = '<p class="mb-2">Generate Invoice for ' + name + '?</p>'
|
||||
+ '<p class="mb-2">This will recalculate the invoice using the current tuition settings and current student enrollment.</p>'
|
||||
+ '<p class="mb-0 text-muted small">This will update the saved invoice student list.</p>';
|
||||
}
|
||||
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
yesButton.removeEventListener('click', onYes);
|
||||
modalEl.removeEventListener('hidden.bs.modal', onHidden);
|
||||
};
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
const onYes = () => {
|
||||
modal.hide();
|
||||
finish(true);
|
||||
};
|
||||
const onHidden = () => finish(false);
|
||||
|
||||
yesButton.addEventListener('click', onYes);
|
||||
modalEl.addEventListener('hidden.bs.modal', onHidden, { once: true });
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadInvoices(year) {
|
||||
const url = year ? `${API_URL}?schoolYear=${encodeURIComponent(year)}` : API_URL;
|
||||
const res = await fetch(url, { credentials: 'same-origin' });
|
||||
@@ -188,27 +249,33 @@
|
||||
const selected = resp.schoolYear || (years[0] || '');
|
||||
$year.innerHTML = years.map(y => `<option value="${esc(y)}" ${y===selected?'selected':''}>${esc(y)}</option>`).join('');
|
||||
};
|
||||
// Global fallback handler for inline onclick
|
||||
window.__genInvoice = async function(btn){
|
||||
|
||||
const runGenerateInvoice = async function(btn) {
|
||||
const confirmed = await confirmGenerateInvoice(btn.getAttribute('data-parent-name') || '');
|
||||
if (!confirmed) return false;
|
||||
|
||||
try {
|
||||
btn.disabled = true;
|
||||
await generateInvoice(btn.getAttribute('data-parent-id'));
|
||||
const resp = await loadInvoices(selectedSchoolYear());
|
||||
syncSchoolYearSelect(resp);
|
||||
const resp = await loadInvoices(selectedSchoolYear());
|
||||
syncSchoolYearSelect(resp);
|
||||
|
||||
const data = (resp.invoices || []).map(renderInvoiceRow);
|
||||
const data = (resp.invoices || []).map(renderInvoiceRow);
|
||||
if ($.fn.DataTable.isDataTable($tbl)) {
|
||||
const dti = $tbl.DataTable();
|
||||
dti.clear();
|
||||
dti.rows.add(data).draw(false);
|
||||
}
|
||||
showToast('Invoice generated');
|
||||
} catch (err) {
|
||||
showToast(err?.message || 'Failed to generate invoice', false);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
window.__genInvoice = runGenerateInvoice;
|
||||
|
||||
try {
|
||||
const resp = await loadInvoices();
|
||||
syncSchoolYearSelect(resp);
|
||||
@@ -233,47 +300,11 @@
|
||||
}
|
||||
|
||||
// Delegate generate invoice
|
||||
// Native delegation
|
||||
document.addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('.gen-invoice');
|
||||
if (!btn) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await generateInvoice(btn.getAttribute('data-parent-id'));
|
||||
// Refresh table minimally: reload data
|
||||
const resp = await loadInvoices(selectedSchoolYear());
|
||||
const data = (resp.invoices || []).map(renderInvoiceRow);
|
||||
if ($.fn.DataTable.isDataTable($tbl)) {
|
||||
const dti = $tbl.DataTable();
|
||||
dti.clear();
|
||||
dti.rows.add(data).draw(false);
|
||||
}
|
||||
showToast('Invoice generated');
|
||||
} catch (err) {
|
||||
showToast(err?.message || 'Failed to generate invoice', false);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// jQuery delegated fallback
|
||||
$(document).on('click', '.gen-invoice', async function (e) {
|
||||
try {
|
||||
this.disabled = true;
|
||||
await generateInvoice(this.getAttribute('data-parent-id'));
|
||||
const resp = await loadInvoices(selectedSchoolYear());
|
||||
const data = (resp.invoices || []).map(renderInvoiceRow);
|
||||
if ($.fn.DataTable.isDataTable($tbl)) {
|
||||
const dti = $tbl.DataTable();
|
||||
dti.clear();
|
||||
dti.rows.add(data).draw(false);
|
||||
}
|
||||
showToast('Invoice generated');
|
||||
} catch (err) {
|
||||
showToast(err?.message || 'Failed to generate invoice', false);
|
||||
} finally {
|
||||
this.disabled = false;
|
||||
}
|
||||
e.preventDefault();
|
||||
await runGenerateInvoice(btn);
|
||||
});
|
||||
|
||||
// Explicit delegated handler as a safety net (in addition to inline onclick)
|
||||
|
||||
@@ -74,11 +74,70 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack {
|
||||
align-items: flex-start;
|
||||
background: #fff8e1;
|
||||
border: 2px solid #ffc107;
|
||||
border-radius: 8px;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -.35rem 1rem rgba(33, 37, 41, .08);
|
||||
display: flex;
|
||||
gap: .85rem;
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack.is-accepted {
|
||||
background: #eef8f0;
|
||||
border-color: #198754;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack .form-check-input {
|
||||
border: 2px solid #212529;
|
||||
flex: 0 0 auto;
|
||||
height: 1.6rem;
|
||||
margin: .1rem 0 0;
|
||||
width: 1.6rem;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack .form-check-input:focus {
|
||||
box-shadow: 0 0 0 .25rem rgba(255, 193, 7, .35);
|
||||
}
|
||||
|
||||
.enrollment-policy-ack .form-check-input:checked {
|
||||
background-color: #198754;
|
||||
border-color: #198754;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack-label {
|
||||
color: #212529;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack-label strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack-label span {
|
||||
color: #6c757d;
|
||||
display: block;
|
||||
font-size: .875rem;
|
||||
margin-top: .15rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.enrollment-policy-frame {
|
||||
height: 72vh;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.enrollment-policy-ack {
|
||||
padding: .85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.enrollment-withdraw-control {
|
||||
@@ -578,6 +637,7 @@ $studentCount = count($students ?? []);
|
||||
data-required-action="<?= esc($student['required_action_label'] ?? 'Contact administration') ?>"
|
||||
data-expected-placement="<?= esc($student['expected_placement_label'] ?? $gradeLabel) ?>"
|
||||
data-is-new="<?= (string) ($student['is_new'] ?? '1') === '1' ? '1' : '0' ?>"
|
||||
data-counts-tuition="<?= $hasSettledEnrollment ? '1' : '0' ?>"
|
||||
data-selectable="<?= $canEnroll ? '1' : '0' ?>"
|
||||
data-already-enrolled="<?= $hasSettledEnrollment ? '1' : '0' ?>"
|
||||
data-block-title="<?= esc($blockTitle) ?>"
|
||||
@@ -781,10 +841,11 @@ $studentCount = count($students ?? []);
|
||||
<h6 class="fw-semibold">Acknowledge school policies</h6>
|
||||
<div class="text-muted small mb-3">Review the current school policies before submitting enrollment.</div>
|
||||
<iframe src="<?= base_url('policy/school_policy') ?>" class="enrollment-policy-frame" frameborder="0" title="School Policies"></iframe>
|
||||
<div class="form-check mt-3">
|
||||
<div class="enrollment-policy-ack <?= $hasAcceptedSchoolPolicy ? 'is-accepted' : '' ?>" id="schoolPolicyAcceptedPanel">
|
||||
<input class="form-check-input" type="checkbox" value="1" id="schoolPolicyAcceptedCheckbox" <?= $hasAcceptedSchoolPolicy ? 'checked disabled' : '' ?>>
|
||||
<label class="form-check-label" for="schoolPolicyAcceptedCheckbox">
|
||||
I have read and accept all school policies.
|
||||
<label class="enrollment-policy-ack-label" for="schoolPolicyAcceptedCheckbox">
|
||||
<strong>I have read and accept all school policies.</strong>
|
||||
<span>This acknowledgement is required before enrollment can continue.</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -932,6 +993,7 @@ $studentCount = count($students ?? []);
|
||||
let latestEligibility = {};
|
||||
const policyAcceptedInput = document.getElementById('accept_school_policy_input');
|
||||
const policyAcceptedCheckbox = document.getElementById('schoolPolicyAcceptedCheckbox');
|
||||
const policyAcceptedPanel = document.getElementById('schoolPolicyAcceptedPanel');
|
||||
const backButton = document.getElementById('enrollmentBackButton');
|
||||
const nextButton = document.getElementById('enrollmentNextButton');
|
||||
const submitButton = document.getElementById('enrollmentSubmitButton');
|
||||
@@ -987,10 +1049,62 @@ $studentCount = count($students ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
function parseGradeRank(value) {
|
||||
let text = String(value || '').trim().toUpperCase().replace(/\s+/g, ' ');
|
||||
text = text.replace(/[._-]/g, ' ');
|
||||
if (['PK', 'P K', 'PREK', 'PRE K', 'PRE KINDER', 'PREKINDER'].includes(text)) return -1;
|
||||
if (['K', 'KG', 'K G', 'KINDER', 'KINDERGARTEN'].includes(text)) return 1;
|
||||
const youth = text.match(/^Y(?:OUTH)?\s*(\d+)?$/);
|
||||
if (youth) return 9 + (youth[1] ? Math.max(1, Number(youth[1])) : 1);
|
||||
const grade = text.match(/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/);
|
||||
if (grade) return Number(grade[1]);
|
||||
const match = text.match(/\d+/);
|
||||
return match ? Number(match[0]) : 999;
|
||||
}
|
||||
|
||||
function cardFeeGrade(card) {
|
||||
return card?.dataset.expectedPlacement || card?.dataset.currentGrade || '';
|
||||
}
|
||||
|
||||
function billableTuitionCards() {
|
||||
const cardsByStudentId = new Map();
|
||||
|
||||
studentCards().forEach(card => {
|
||||
if (card.dataset.countsTuition === '1') {
|
||||
cardsByStudentId.set(String(card.dataset.studentId || ''), card);
|
||||
}
|
||||
});
|
||||
|
||||
selectedEnrollInputs().forEach(input => {
|
||||
const card = input.closest('[data-student-card]');
|
||||
if (card) {
|
||||
cardsByStudentId.set(String(card.dataset.studentId || ''), card);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(cardsByStudentId.values()).sort((left, right) => {
|
||||
const rankDiff = parseGradeRank(cardFeeGrade(left)) - parseGradeRank(cardFeeGrade(right));
|
||||
if (rankDiff !== 0) return rankDiff;
|
||||
return String(left.dataset.studentName || '').localeCompare(String(right.dataset.studentName || ''));
|
||||
});
|
||||
}
|
||||
|
||||
function tuitionFeeByStudentId() {
|
||||
const fees = new Map();
|
||||
billableTuitionCards().forEach((card, index) => {
|
||||
fees.set(
|
||||
String(card.dataset.studentId || ''),
|
||||
index === 0 ? Number(feeSchedule.firstStudentFee || 0) : Number(feeSchedule.secondStudentFee || 0)
|
||||
);
|
||||
});
|
||||
return fees;
|
||||
}
|
||||
|
||||
function calculateSelectedTuition() {
|
||||
return selectedEnrollInputs().reduce((total, input, index) => {
|
||||
const familyPosition = Number(familyFinancial.currentYearTuitionStudentCount || 0) + index;
|
||||
return total + (familyPosition === 0 ? Number(feeSchedule.firstStudentFee || 0) : Number(feeSchedule.secondStudentFee || 0));
|
||||
const fees = tuitionFeeByStudentId();
|
||||
return selectedEnrollInputs().reduce((total, input) => {
|
||||
const card = input.closest('[data-student-card]');
|
||||
return total + Number(fees.get(String(card?.dataset.studentId || '')) || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
@@ -1030,6 +1144,7 @@ $studentCount = count($students ?? []);
|
||||
const isNewStudent = card?.dataset.isNew === '1';
|
||||
const name = (firstName + ' ' + lastName).trim() || card?.querySelector('.fw-semibold')?.textContent?.trim() || 'Student';
|
||||
return {
|
||||
studentId: card?.dataset.studentId || '',
|
||||
name,
|
||||
schoolId,
|
||||
dob,
|
||||
@@ -1051,9 +1166,9 @@ $studentCount = count($students ?? []);
|
||||
return;
|
||||
}
|
||||
|
||||
feeReviewList.innerHTML = cards.map((student, index) => {
|
||||
const familyPosition = Number(familyFinancial.currentYearTuitionStudentCount || 0) + index;
|
||||
const tuitionFee = familyPosition === 0 ? feeSchedule.firstStudentFee : feeSchedule.secondStudentFee;
|
||||
const fees = tuitionFeeByStudentId();
|
||||
feeReviewList.innerHTML = cards.map((student) => {
|
||||
const tuitionFee = fees.get(String(student.studentId || '')) || 0;
|
||||
const lines = [
|
||||
['Student', student.name],
|
||||
['School ID', student.schoolId || 'N/A'],
|
||||
@@ -1286,6 +1401,9 @@ $studentCount = count($students ?? []);
|
||||
if (policyAcceptedInput) {
|
||||
policyAcceptedInput.value = hasAcceptedSchoolPolicy ? '1' : '0';
|
||||
}
|
||||
if (policyAcceptedPanel) {
|
||||
policyAcceptedPanel.classList.toggle('is-accepted', hasAcceptedSchoolPolicy);
|
||||
}
|
||||
}
|
||||
|
||||
function setStudentFieldsEnabled(card, enabled) {
|
||||
|
||||
@@ -80,13 +80,14 @@
|
||||
<?php
|
||||
$isEditable = (bool) ($isEditable ?? true);
|
||||
$disabledAttr = $isEditable ? '' : ' disabled';
|
||||
$reportRows = $reportRows ?? [];
|
||||
?>
|
||||
<div class="container my-5">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-3">
|
||||
<div>
|
||||
<h3 class="text-success mb-1">Report Cards</h3>
|
||||
<div class="text-muted small">
|
||||
<?= esc($schoolYear ?: 'N/A') ?> <?= $semester ? '• ' . esc($semester) . ' Semester' : '' ?>
|
||||
<?= esc($schoolYear ?: 'N/A') ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,7 +104,7 @@
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($students)): ?>
|
||||
<?php if (empty($reportRows)): ?>
|
||||
<div class="alert alert-info">No students available for report cards.</div>
|
||||
<?php else: ?>
|
||||
<div class="table-responsive">
|
||||
@@ -112,24 +113,29 @@
|
||||
<tr>
|
||||
<th>Student</th>
|
||||
<th>Class Section</th>
|
||||
<th>Semester</th>
|
||||
<th>Viewed</th>
|
||||
<th>Signature</th>
|
||||
<th class="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($students as $student): ?>
|
||||
<?php foreach ($reportRows as $reportRow): ?>
|
||||
<?php
|
||||
$student = $reportRow['student'] ?? [];
|
||||
$sid = (int) ($student['id'] ?? 0);
|
||||
$ack = $ackMap[$sid] ?? null;
|
||||
$rowSemester = (string) ($reportRow['semester'] ?? '');
|
||||
$rowKey = (string) ($reportRow['key'] ?? ($sid . '|' . $rowSemester));
|
||||
$ack = $ackMap[$rowKey] ?? null;
|
||||
$viewedAt = $ack['viewed_at'] ?? '';
|
||||
$signedAt = $ack['signed_at'] ?? '';
|
||||
$signedName = $ack['signed_name'] ?? '';
|
||||
$hasReport = !empty(($reportAvailableMap ?? [])[$sid]);
|
||||
$hasReport = !empty(($reportAvailableMap ?? [])[$rowKey]);
|
||||
?>
|
||||
<tr>
|
||||
<td data-label="Student"><?= esc(trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? ''))) ?></td>
|
||||
<td data-label="Class Section"><?= esc($student['class_section_name'] ?? 'N/A') ?></td>
|
||||
<td data-label="Semester"><?= esc($rowSemester) ?></td>
|
||||
<td data-label="Viewed"><?= $viewedAt ? esc(local_datetime($viewedAt, 'm-d-Y H:i')) : 'Not viewed' ?></td>
|
||||
<td data-label="Signature">
|
||||
<?php if ($signedAt): ?>
|
||||
@@ -141,13 +147,14 @@
|
||||
</td>
|
||||
<td class="text-end" data-label="Action">
|
||||
<?php if ($hasReport): ?>
|
||||
<a class="btn btn-sm btn-outline-primary" target="_blank" href="<?= base_url('parent/report-cards/view/' . $sid) ?>">View Report</a>
|
||||
<a class="btn btn-sm btn-outline-primary" target="_blank" href="<?= site_url('parent/report-cards/view/' . $sid) . '?' . http_build_query(['semester' => $rowSemester]) ?>">View Report</a>
|
||||
<?php else: ?>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" disabled>No report available</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($hasReport && ! $signedAt): ?>
|
||||
<form class="d-inline-flex align-items-center gap-2 ms-2" method="post" action="<?= base_url('parent/report-cards/sign/' . $sid) ?>">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="semester" value="<?= esc($rowSemester) ?>">
|
||||
<input type="text" name="signed_name" class="form-control form-control-sm" placeholder="Full name" required<?= $disabledAttr ?>>
|
||||
<button class="btn btn-sm btn-success" type="submit"<?= $disabledAttr ?>>Sign</button>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
0 2 * * * php /path/to/your/project/public/index.php notifications:cleanup >> /path/to/your/project/writable/logs/cron_cleanup.log 2>&1
|
||||
|
||||
|
||||
|
||||
@@ -22,3 +22,7 @@ CI_ENVIRONMENT=production
|
||||
50 9 * * 7 /usr/bin/php /home/u280815660/domains/alrahmaisgl.org/alrahma/spark config:update -t enable_attendance_on --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1
|
||||
0 13 * * 7 /usr/bin/php /home/u280815660/domains/alrahmaisgl.org/alrahma/spark config:update -t enable_attendance_off --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1
|
||||
0 0 1 7 * /usr/bin/php /home/u280815660/domains/alrahmaisgl.org/alrahma/spark config:update -t update_date_age_reference --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1
|
||||
0 2 * * * php /path/to/your/project/public/index.php notifications:cleanup >> /path/to/your/project/writable/logs/cron_cleanup.log 2>&1
|
||||
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
<IfModule authz_core_module>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !authz_core_module>
|
||||
Deny from all
|
||||
</IfModule>
|
||||
Reference in New Issue
Block a user