# 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.
