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

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