Files
alrahma_sunday_school_api/docs/tuition_income_prediction_plan.md
T
2026-06-04 02:24:41 -04:00

26 KiB
Raw Blame History

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:

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:

  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

[
    '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 $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:

$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_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:

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:

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:

  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.