# Tuition Income Prediction Plan: One Service With Old/New Calculation Flag ## Goal Create one dedicated tuition income prediction service that supports two calculation methods: 1. **Old calculation** 2. **New calculation** The selected calculation method is controlled by a flag. The service must: - Pull students from the `students` table. - Group students by `parent_id`. - Filter by `school_id`. - Filter by `school_year`. - Calculate tuition per family. - Support editable tuition settings. - Return family-level details. - Return family-size summary. - Return grand totals. - Allow the prediction page to select which calculation method to use. The existing `FeeCalculationService` should remain for refund logic only. --- ## 1. Service Design Create one new service: ```text App\Services\TuitionIncomePredictionService ``` This service contains both calculation methods: ```php calculateOldModel(int $studentCount, array $config): float calculateNewModel(int $studentCount, array $config): float ``` The service chooses which method to use based on: ```text calculation_method ``` Allowed values: ```text old new comparison ``` Recommended default: ```text comparison ``` --- ## 2. Calculation Method Flag Use this request parameter: ```text calculation_method ``` Allowed values: | Value | Meaning | |---|---| | `old` | Use old calculation only | | `new` | Use new calculation only | | `comparison` | Calculate both old and new side by side | Example request: ```text GET /reports/tuition-income-prediction?school_id=ABC&school_year=2025-2026&calculation_method=comparison ``` Do not use `selected_service`. This is not selecting a different service anymore. It is selecting a calculation method inside the same service. --- ## 3. Pricing Configuration All pricing inputs must be editable. Do not hardcode these values inside the calculation logic. Configuration keys: | Setting Key | Default | Description | |---|---:|---| | `tuition_prediction_base_tuition` | `350.00` | Full tuition amount | | `tuition_prediction_old_model_increase_percent` | `20.00` | Percent increase applied to old model | | `tuition_prediction_old_model_additional_student_discount` | `150.00` | Old model discount for 2nd student and above | | `tuition_prediction_new_model_second_third_discount` | `50.00` | New model discount for 2nd and 3rd students | | `tuition_prediction_new_model_fourth_plus_discount` | `100.00` | New model discount for 4th student and above | Use scoped config names. Generic names like `base_tuition` and `increase_percent` are future bug bait. --- ## 4. Calculation Rules ### 4.1 Old Calculation The old model applies a percentage increase to the old pricing structure. Formula: ```text 1st student = base_tuition × (1 + old_model_increase_percent / 100) 2nd student and above = (base_tuition - old_model_additional_student_discount) × (1 + old_model_increase_percent / 100) ``` Default example: ```text base_tuition = 350 old_model_additional_student_discount = 150 old_model_increase_percent = 20 ``` | Student Position | Fee | |---:|---:| | 1st | 420 | | 2nd+ | 240 | ### 4.2 New Calculation The new model uses the base tuition without the old model increase. Formula: ```text 1st student = base_tuition 2nd student = base_tuition - new_model_second_third_discount 3rd student = base_tuition - new_model_second_third_discount 4th student and above = base_tuition - new_model_fourth_plus_discount ``` Default example: ```text base_tuition = 350 new_model_second_third_discount = 50 new_model_fourth_plus_discount = 100 ``` | Student Position | Fee | |---:|---:| | 1st | 350 | | 2nd | 300 | | 3rd | 300 | | 4th+ | 250 | --- ## 5. Required Student Filters All prediction queries must filter students with: ```sql WHERE is_active = 1 AND school_id = :school_id AND school_year = :school_year AND parent_id IS NOT NULL AND parent_id > 0 ``` Optional filters: ```sql AND semester = :semester AND year_of_registration = :year_of_registration ``` Use optional filters only when provided. `school_year` is required. Do not replace `school_year` with `year_of_registration`. --- ## 6. Student Model Methods Add these methods to the student model. ### 6.1 Get Student Counts by Family ```php public function getStudentCountsByFamily( string $schoolId, string $schoolYear, ?string $semester = null, ?string $yearOfRegistration = null ): array ``` SQL: ```sql SELECT parent_id, COUNT(*) AS student_count, SUM(CASE WHEN tuition_paid = 1 THEN 1 ELSE 0 END) AS paid_student_count, SUM(CASE WHEN tuition_paid = 0 THEN 1 ELSE 0 END) AS unpaid_student_count FROM students WHERE is_active = 1 AND school_id = :school_id AND school_year = :school_year AND parent_id IS NOT NULL AND parent_id > 0 GROUP BY parent_id ORDER BY student_count DESC, parent_id ASC; ``` Append optional filters safely using bound parameters. ### 6.2 Count Eligible Students ```php public function countEligibleStudents( string $schoolId, string $schoolYear, ?string $semester = null, ?string $yearOfRegistration = null ): int ``` SQL: ```sql SELECT COUNT(*) AS raw_student_count FROM students WHERE is_active = 1 AND school_id = :school_id AND school_year = :school_year AND parent_id IS NOT NULL AND parent_id > 0; ``` Apply the same optional filters. ### 6.3 Count Students Missing Parent ID ```php public function countStudentsMissingParentId( string $schoolId, string $schoolYear, ?string $semester = null, ?string $yearOfRegistration = null ): int ``` SQL: ```sql SELECT COUNT(*) AS missing_parent_count FROM students WHERE is_active = 1 AND school_id = :school_id AND school_year = :school_year AND (parent_id IS NULL OR parent_id <= 0); ``` Apply optional filters. ### 6.4 Count Students Missing School Year ```php public function countStudentsMissingSchoolYear(string $schoolId): int ``` SQL: ```sql SELECT COUNT(*) AS missing_school_year_count FROM students WHERE is_active = 1 AND school_id = :school_id AND (school_year IS NULL OR school_year = ''); ``` --- ## 7. Prediction Service Structure Create: ```text app/Services/TuitionIncomePredictionService.php ``` Suggested methods: ```php public function generateReport( string $schoolId, string $schoolYear, string $calculationMethod = 'comparison', ?string $semester = null, ?string $yearOfRegistration = null ): array; private function getPredictionConfig(): array; private function validatePredictionConfig(array $config): void; private function validateCalculationMethod(string $calculationMethod): void; private function calculateOldModel(int $studentCount, array $config): float; private function calculateNewModel(int $studentCount, array $config): float; private function buildFamilyReport(array $familyCounts, array $config, string $calculationMethod): array; private function buildFamilySizeSummary(array $familyReport, string $calculationMethod): array; private function buildTotals(array $familyReport, string $calculationMethod): array; private function determineBetterMethod(float $difference): string; private function buildWarnings( int $rawStudentCount, int $groupedStudentCount, int $missingParentCount, int $missingSchoolYearCount ): array; ``` --- ## 8. Configuration Loading ```php private function getPredictionConfig(): array { return [ 'base_tuition' => (float) ($this->configModel->getConfig('tuition_prediction_base_tuition') ?? 350), 'old_model_increase_percent' => (float) ($this->configModel->getConfig('tuition_prediction_old_model_increase_percent') ?? 20), 'old_model_additional_student_discount' => (float) ($this->configModel->getConfig('tuition_prediction_old_model_additional_student_discount') ?? 150), 'new_model_second_third_discount' => (float) ($this->configModel->getConfig('tuition_prediction_new_model_second_third_discount') ?? 50), 'new_model_fourth_plus_discount' => (float) ($this->configModel->getConfig('tuition_prediction_new_model_fourth_plus_discount') ?? 100), ]; } ``` --- ## 9. Configuration Validation ```php private function validatePredictionConfig(array $config): void { $baseTuition = (float) $config['base_tuition']; if ($baseTuition <= 0) { throw new \InvalidArgumentException('Base tuition must be greater than zero.'); } if ((float) $config['old_model_increase_percent'] < 0) { throw new \InvalidArgumentException('Old model increase percent cannot be negative.'); } $discountKeys = [ 'old_model_additional_student_discount', 'new_model_second_third_discount', 'new_model_fourth_plus_discount', ]; foreach ($discountKeys as $key) { $discount = (float) $config[$key]; if ($discount < 0) { throw new \InvalidArgumentException("{$key} cannot be negative."); } if ($discount > $baseTuition) { throw new \InvalidArgumentException("{$key} cannot exceed base tuition."); } } } ``` --- ## 10. Calculation Method Validation ```php private function validateCalculationMethod(string $calculationMethod): void { $allowed = ['old', 'new', 'comparison']; if (!in_array($calculationMethod, $allowed, true)) { throw new \InvalidArgumentException('Invalid calculation_method.'); } } ``` --- ## 11. Old Calculation Method ```php private function calculateOldModel(int $studentCount, array $config): float { $baseTuition = (float) $config['base_tuition']; $increasePercent = (float) $config['old_model_increase_percent']; $oldModelDiscount = (float) $config['old_model_additional_student_discount']; $multiplier = 1 + ($increasePercent / 100); $firstStudentFee = $baseTuition * $multiplier; $additionalStudentFee = ($baseTuition - $oldModelDiscount) * $multiplier; $total = 0.0; for ($position = 1; $position <= $studentCount; $position++) { $fee = ($position === 1) ? $firstStudentFee : $additionalStudentFee; $total += max(0, $fee); } return round($total, 2); } ``` --- ## 12. New Calculation Method ```php private function calculateNewModel(int $studentCount, array $config): float { $baseTuition = (float) $config['base_tuition']; $secondThirdDiscount = (float) $config['new_model_second_third_discount']; $fourthPlusDiscount = (float) $config['new_model_fourth_plus_discount']; $total = 0.0; for ($position = 1; $position <= $studentCount; $position++) { if ($position === 1) { $fee = $baseTuition; } elseif ($position === 2 || $position === 3) { $fee = $baseTuition - $secondThirdDiscount; } else { $fee = $baseTuition - $fourthPlusDiscount; } $total += max(0, $fee); } return round($total, 2); } ``` --- ## 13. Family Report Logic ```php private function buildFamilyReport(array $familyCounts, array $config, string $calculationMethod): array { $report = []; foreach ($familyCounts as $family) { $studentCount = (int) $family['student_count']; $row = [ 'parent_id' => (int) $family['parent_id'], 'student_count' => $studentCount, 'paid_student_count' => (int) $family['paid_student_count'], 'unpaid_student_count' => (int) $family['unpaid_student_count'], ]; if ($calculationMethod === 'old' || $calculationMethod === 'comparison') { $row['old_model_total'] = $this->calculateOldModel($studentCount, $config); } if ($calculationMethod === 'new' || $calculationMethod === 'comparison') { $row['new_model_total'] = $this->calculateNewModel($studentCount, $config); } if ($calculationMethod === 'comparison') { $row['difference'] = round($row['new_model_total'] - $row['old_model_total'], 2); $row['better_method'] = $this->determineBetterMethod($row['difference']); } $report[] = $row; } return $report; } ``` --- ## 14. Better Method Logic ```php private function determineBetterMethod(float $difference): string { if ($difference > 0) { return 'new'; } if ($difference < 0) { return 'old'; } return 'equal'; } ``` --- ## 15. Family-Size Summary Logic Group rows by `student_count`. ```php private function buildFamilySizeSummary(array $familyReport, string $calculationMethod): array { $summary = []; foreach ($familyReport as $row) { $size = (int) $row['student_count']; if (!isset($summary[$size])) { $summary[$size] = [ 'family_size' => $size, 'family_count' => 0, 'total_students' => 0, 'paid_student_count' => 0, 'unpaid_student_count' => 0, ]; if ($calculationMethod === 'old' || $calculationMethod === 'comparison') { $summary[$size]['old_model_total'] = 0.0; } if ($calculationMethod === 'new' || $calculationMethod === 'comparison') { $summary[$size]['new_model_total'] = 0.0; } if ($calculationMethod === 'comparison') { $summary[$size]['difference'] = 0.0; $summary[$size]['better_method'] = 'equal'; } } $summary[$size]['family_count']++; $summary[$size]['total_students'] += $size; $summary[$size]['paid_student_count'] += (int) $row['paid_student_count']; $summary[$size]['unpaid_student_count'] += (int) $row['unpaid_student_count']; if (isset($row['old_model_total'])) { $summary[$size]['old_model_total'] += $row['old_model_total']; } if (isset($row['new_model_total'])) { $summary[$size]['new_model_total'] += $row['new_model_total']; } if ($calculationMethod === 'comparison') { $summary[$size]['difference'] = round( $summary[$size]['new_model_total'] - $summary[$size]['old_model_total'], 2 ); $summary[$size]['better_method'] = $this->determineBetterMethod($summary[$size]['difference']); } } ksort($summary); return array_values($summary); } ``` --- ## 16. Totals Logic ```php private function buildTotals(array $familyReport, string $calculationMethod): array { $totals = [ 'total_families' => count($familyReport), 'total_students' => 0, 'total_paid_students' => 0, 'total_unpaid_students' => 0, ]; if ($calculationMethod === 'old' || $calculationMethod === 'comparison') { $totals['old_model_total'] = 0.0; } if ($calculationMethod === 'new' || $calculationMethod === 'comparison') { $totals['new_model_total'] = 0.0; } foreach ($familyReport as $row) { $totals['total_students'] += (int) $row['student_count']; $totals['total_paid_students'] += (int) $row['paid_student_count']; $totals['total_unpaid_students'] += (int) $row['unpaid_student_count']; if (isset($row['old_model_total'])) { $totals['old_model_total'] += $row['old_model_total']; } if (isset($row['new_model_total'])) { $totals['new_model_total'] += $row['new_model_total']; } } if (isset($totals['old_model_total'])) { $totals['old_model_total'] = round($totals['old_model_total'], 2); } if (isset($totals['new_model_total'])) { $totals['new_model_total'] = round($totals['new_model_total'], 2); } if ($calculationMethod === 'comparison') { $totals['difference'] = round($totals['new_model_total'] - $totals['old_model_total'], 2); $totals['better_method'] = $this->determineBetterMethod($totals['difference']); } return $totals; } ``` --- ## 17. Generate Report Flow ```php public function generateReport( string $schoolId, string $schoolYear, string $calculationMethod = 'comparison', ?string $semester = null, ?string $yearOfRegistration = null ): array { $this->validateCalculationMethod($calculationMethod); $config = $this->getPredictionConfig(); $this->validatePredictionConfig($config); $familyCounts = $this->studentModel->getStudentCountsByFamily( $schoolId, $schoolYear, $semester, $yearOfRegistration ); $familyReport = $this->buildFamilyReport($familyCounts, $config, $calculationMethod); $familySizeSummary = $this->buildFamilySizeSummary($familyReport, $calculationMethod); $totals = $this->buildTotals($familyReport, $calculationMethod); $rawStudentCount = $this->studentModel->countEligibleStudents( $schoolId, $schoolYear, $semester, $yearOfRegistration ); $groupedStudentCount = (int) array_sum(array_column($familyCounts, 'student_count')); $missingParentCount = $this->studentModel->countStudentsMissingParentId( $schoolId, $schoolYear, $semester, $yearOfRegistration ); $missingSchoolYearCount = $this->studentModel->countStudentsMissingSchoolYear($schoolId); $warnings = $this->buildWarnings( $rawStudentCount, $groupedStudentCount, $missingParentCount, $missingSchoolYearCount ); return [ 'filters' => [ 'school_id' => $schoolId, 'school_year' => $schoolYear, 'semester' => $semester, 'year_of_registration' => $yearOfRegistration, 'calculation_method' => $calculationMethod, ], 'config' => $config, 'family_report' => $familyReport, 'family_size_summary' => $familySizeSummary, 'totals' => $totals, 'validation' => [ 'raw_student_count' => $rawStudentCount, 'grouped_student_count' => $groupedStudentCount, 'missing_parent_count' => $missingParentCount, 'missing_school_year_count' => $missingSchoolYearCount, ], 'warnings' => $warnings, ]; } ``` --- ## 18. Controller Endpoint Suggested route: ```text GET /reports/tuition-income-prediction ``` Required query parameters: ```text school_id school_year ``` Optional query parameters: ```text calculation_method semester year_of_registration ``` Default: ```text calculation_method = comparison ``` Controller example: ```php public function tuitionIncomePrediction() { $schoolId = $this->request->getGet('school_id'); $schoolYear = $this->request->getGet('school_year'); $calculationMethod = $this->request->getGet('calculation_method') ?? 'comparison'; $semester = $this->request->getGet('semester'); $yearOfRegistration = $this->request->getGet('year_of_registration'); if (!$schoolId) { return $this->response->setStatusCode(400)->setJSON([ 'error' => 'school_id is required', ]); } if (!$schoolYear) { return $this->response->setStatusCode(400)->setJSON([ 'error' => 'school_year is required', ]); } try { $report = $this->tuitionIncomePredictionService->generateReport( $schoolId, $schoolYear, $calculationMethod, $semester, $yearOfRegistration ); return $this->response->setJSON($report); } catch (\InvalidArgumentException $e) { return $this->response->setStatusCode(400)->setJSON([ 'error' => $e->getMessage(), ]); } catch (\Throwable $e) { log_message('error', 'Tuition prediction failed: ' . $e->getMessage()); return $this->response->setStatusCode(500)->setJSON([ 'error' => 'Unable to generate tuition prediction report.', ]); } } ``` --- ## 19. Prediction Page UI Add a dropdown: ```text Calculation Method ``` Options: | Label | Value | |---|---| | Compare Old vs New | `comparison` | | Old Calculation | `old` | | New Calculation | `new` | Default: ```text comparison ``` Required filters: ```text School School Year ``` Optional filters: ```text Semester Year of Registration ``` Display: 1. Configuration values used. 2. Selected calculation method. 3. Family-size summary. 4. Family-level report. 5. Totals. 6. Validation warnings. --- ## 20. Response Shape ### Comparison Mode ```php [ 'filters' => [ 'school_id' => 'ABC', 'school_year' => '2025-2026', 'semester' => null, 'year_of_registration' => null, 'calculation_method' => 'comparison', ], 'config' => [ 'base_tuition' => 350.00, 'old_model_increase_percent' => 20.00, 'old_model_additional_student_discount' => 150.00, 'new_model_second_third_discount' => 50.00, 'new_model_fourth_plus_discount' => 100.00, ], 'family_report' => [ [ 'parent_id' => 101, 'student_count' => 3, 'paid_student_count' => 2, 'unpaid_student_count' => 1, 'old_model_total' => 900.00, 'new_model_total' => 950.00, 'difference' => 50.00, 'better_method' => 'new', ], ], 'family_size_summary' => [...], 'totals' => [ 'total_families' => 102, 'total_students' => 200, 'total_paid_students' => 150, 'total_unpaid_students' => 50, 'old_model_total' => 66360.00, 'new_model_total' => 64650.00, 'difference' => -1710.00, 'better_method' => 'old', ], 'validation' => [ 'raw_student_count' => 200, 'grouped_student_count' => 200, 'missing_parent_count' => 0, 'missing_school_year_count' => 0, ], 'warnings' => [], ] ``` ### Old Mode ```php [ 'filters' => [ 'calculation_method' => 'old', ], 'family_report' => [ [ 'parent_id' => 101, 'student_count' => 3, 'paid_student_count' => 2, 'unpaid_student_count' => 1, 'old_model_total' => 900.00, ], ], 'totals' => [ 'total_families' => 102, 'total_students' => 200, 'old_model_total' => 66360.00, ], ] ``` ### New Mode ```php [ 'filters' => [ 'calculation_method' => 'new', ], 'family_report' => [ [ 'parent_id' => 101, 'student_count' => 3, 'paid_student_count' => 2, 'unpaid_student_count' => 1, 'new_model_total' => 950.00, ], ], 'totals' => [ 'total_families' => 102, 'total_students' => 200, 'new_model_total' => 64650.00, ], ] ``` --- ## 21. Old Refund Service Fix Keep the existing old refund service: ```text App\Services\FeeCalculationService ``` Do not delete it. Do not use it for prediction. Fix its known issues separately. Critical issue in the old refund service: - It calculates `$studentFee` while assigning fees. - It does not store the fee on the student. - Later it uses `$studentFee` in the refund loop. - That can use a stale value from a previous loop. Fix by storing: ```php $student['tuition_fee'] = $studentFee; ``` Then in the refund loop use: ```php $studentFee = (float) ($student['tuition_fee'] ?? 0); ``` Also fix the array-copy issue: - Do not assign `tuition_fee` to `$allStudents` and then calculate refunds from the older `$withdrawnStudents` array. - Either calculate refunds from `$allStudents`, or map fees back by student ID. Recommended: ```php foreach ($allStudents as $student) { if (!$this->isWithdrawnStatus($student['enrollment_status'] ?? '')) { continue; } $studentFee = (float) ($student['tuition_fee'] ?? 0); // refund calculation here } ``` Also fix: 1. Remove unused `InvoiceModel` unless needed. 2. Validate `refund_deadline`. 3. Validate `last_school_day`. 4. Validate `weeks_study > 0`. 5. Normalize enrollment/admission statuses to lowercase. 6. Handle missing `class_section_id`. 7. Use `student_id ?? id ?? 'unknown'` in logs. Do not change the public method signature: ```php public function calculateRefund(array $students, int $parentId): float ``` Existing callers should continue working. --- ## 22. Test Cases Using defaults: ```text base_tuition = 350 old_model_increase_percent = 20 old_model_additional_student_discount = 150 new_model_second_third_discount = 50 new_model_fourth_plus_discount = 100 ``` | Students | Old Model | New Model | Difference | Better | |---:|---:|---:|---:|---| | 1 | 420 | 350 | -70 | old | | 2 | 660 | 650 | -10 | old | | 3 | 900 | 950 | 50 | new | | 4 | 1140 | 1200 | 60 | new | | 5 | 1380 | 1450 | 70 | new | Also test: 1. `calculation_method = old` 2. `calculation_method = new` 3. `calculation_method = comparison` 4. Invalid `calculation_method` 5. Missing `school_id` 6. Missing `school_year` 7. Missing `parent_id` 8. Inactive students excluded 9. Different school year excluded 10. Discount greater than base tuition rejected 11. Negative increase percent rejected --- ## 23. Acceptance Criteria The task is complete when: 1. One new prediction service exists. 2. The prediction service contains both old and new calculation methods. 3. A `calculation_method` flag controls which method is used. 4. Valid calculation methods are `old`, `new`, and `comparison`. 5. Prediction page exposes this flag as a dropdown. 6. Report requires `school_id`. 7. Report requires `school_year`. 8. Student counts are grouped by `parent_id`. 9. Base tuition is editable. 10. Increase percent is editable. 11. Old discount is editable. 12. New 2nd/3rd student discount is editable. 13. New 4th+ discount is editable. 14. Report includes config values used. 15. Report includes filters used. 16. Report includes validation counts. 17. Report includes warnings. 18. Existing `FeeCalculationService` remains for refund logic. 19. Existing `FeeCalculationService` is fixed internally. 20. Prediction logic does not call refund logic.