26 KiB
Tuition Income Prediction Plan: One Service With Old/New Calculation Flag
Goal
Create one dedicated tuition income prediction service that supports two calculation methods:
- Old calculation
- New calculation
The selected calculation method is controlled by a flag.
The service must:
- Pull students from the
studentstable. - 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:
App\Services\TuitionIncomePredictionService
This service contains both calculation methods:
calculateOldModel(int $studentCount, array $config): float
calculateNewModel(int $studentCount, array $config): float
The service chooses which method to use based on:
calculation_method
Allowed values:
old
new
comparison
Recommended default:
comparison
2. Calculation Method Flag
Use this request parameter:
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:
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:
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:
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:
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:
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:
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:
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
public function getStudentCountsByFamily(
string $schoolId,
string $schoolYear,
?string $semester = null,
?string $yearOfRegistration = null
): array
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
public function countEligibleStudents(
string $schoolId,
string $schoolYear,
?string $semester = null,
?string $yearOfRegistration = null
): int
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
public function countStudentsMissingParentId(
string $schoolId,
string $schoolYear,
?string $semester = null,
?string $yearOfRegistration = null
): int
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
public function countStudentsMissingSchoolYear(string $schoolId): int
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:
app/Services/TuitionIncomePredictionService.php
Suggested methods:
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
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
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
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
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
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
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
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.
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
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
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:
GET /reports/tuition-income-prediction
Required query parameters:
school_id
school_year
Optional query parameters:
calculation_method
semester
year_of_registration
Default:
calculation_method = comparison
Controller example:
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:
Calculation Method
Options:
| Label | Value |
|---|---|
| Compare Old vs New | comparison |
| Old Calculation | old |
| New Calculation | new |
Default:
comparison
Required filters:
School
School Year
Optional filters:
Semester
Year of Registration
Display:
- Configuration values used.
- Selected calculation method.
- Family-size summary.
- Family-level report.
- Totals.
- Validation warnings.
20. Response Shape
Comparison Mode
[
'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
[
'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
[
'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:
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
$studentFeewhile assigning fees. - It does not store the fee on the student.
- Later it uses
$studentFeein the refund loop. - That can use a stale value from a previous loop.
Fix by storing:
$student['tuition_fee'] = $studentFee;
Then in the refund loop use:
$studentFee = (float) ($student['tuition_fee'] ?? 0);
Also fix the array-copy issue:
- Do not assign
tuition_feeto$allStudentsand then calculate refunds from the older$withdrawnStudentsarray. - Either calculate refunds from
$allStudents, or map fees back by student ID.
Recommended:
foreach ($allStudents as $student) {
if (!$this->isWithdrawnStatus($student['enrollment_status'] ?? '')) {
continue;
}
$studentFee = (float) ($student['tuition_fee'] ?? 0);
// refund calculation here
}
Also fix:
- Remove unused
InvoiceModelunless needed. - Validate
refund_deadline. - Validate
last_school_day. - Validate
weeks_study > 0. - Normalize enrollment/admission statuses to lowercase.
- Handle missing
class_section_id. - Use
student_id ?? id ?? 'unknown'in logs.
Do not change the public method signature:
public function calculateRefund(array $students, int $parentId): float
Existing callers should continue working.
22. Test Cases
Using defaults:
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:
calculation_method = oldcalculation_method = newcalculation_method = comparison- Invalid
calculation_method - Missing
school_id - Missing
school_year - Missing
parent_id - Inactive students excluded
- Different school year excluded
- Discount greater than base tuition rejected
- Negative increase percent rejected
23. Acceptance Criteria
The task is complete when:
- One new prediction service exists.
- The prediction service contains both old and new calculation methods.
- A
calculation_methodflag controls which method is used. - Valid calculation methods are
old,new, andcomparison. - Prediction page exposes this flag as a dropdown.
- Report requires
school_id. - Report requires
school_year. - Student counts are grouped by
parent_id. - Base tuition is editable.
- Increase percent is editable.
- Old discount is editable.
- New 2nd/3rd student discount is editable.
- New 4th+ discount is editable.
- Report includes config values used.
- Report includes filters used.
- Report includes validation counts.
- Report includes warnings.
- Existing
FeeCalculationServiceremains for refund logic. - Existing
FeeCalculationServiceis fixed internally. - Prediction logic does not call refund logic.