diff --git a/app.zip b/app.zip deleted file mode 100644 index 8c567bc..0000000 Binary files a/app.zip and /dev/null differ diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php index 077571c..f5e79ac 100644 --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -74,7 +74,7 @@ class InvoiceController extends ResourceController $this->dueDate = $this->configModel->getConfig('due_date'); $this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350); $this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200); - $this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 180); + $this->youthFee = (float) ($this->configModel->getConfig('youth_fee') ?? 200); $this->refundDeadline = date('Y-m-d', strtotime($this->configModel->getConfig('refund_deadline'))); $this->db = \Config\Database::connect(); $this->request = \Config\Services::request(); diff --git a/app/Controllers/View/TuitionForecastController.php b/app/Controllers/View/TuitionForecastController.php index a2060a2..e73b653 100644 --- a/app/Controllers/View/TuitionForecastController.php +++ b/app/Controllers/View/TuitionForecastController.php @@ -71,12 +71,13 @@ class TuitionForecastController extends BaseController fputcsv($handle, ['Calculator Mode', $result['calculator_mode']]); fputcsv($handle, []); fputcsv($handle, ['Summary']); - fputcsv($handle, ['Families', 'Students', 'Billable Students', 'Unit Price', 'Old Projected Income', 'New Projected Income', 'Difference']); + fputcsv($handle, ['Families', 'Students', 'Billable Students', 'Grade Unit Price', 'Youth Unit Price', 'Old Projected Income', 'New Projected Income', 'Difference']); fputcsv($handle, [ $result['summary']['family_count'], $result['summary']['student_count'], $result['summary']['billable_student_count'], $result['summary']['unit_price'], + $result['summary']['youth_unit_price'], $result['summary']['old_projected_income'] ?? $result['summary']['old_projected_tuition'], $result['summary']['new_projected_income'] ?? $result['summary']['new_projected_tuition'], $result['summary']['difference'], @@ -132,6 +133,7 @@ class TuitionForecastController extends BaseController 'include_event_only' => $this->request->{$source}('include_event_only') ?? '0', 'include_paid_invoices' => $this->request->{$source}('include_paid_invoices') ?? '0', 'unit_price' => $this->request->{$source}('unit_price') ?? '', + 'youth_unit_price' => $this->request->{$source}('youth_unit_price') ?? '', ]; } } diff --git a/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php b/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php index e919be0..e5bc09f 100644 --- a/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php +++ b/app/Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php @@ -94,7 +94,9 @@ class FinancialSystemLedgerCleanup extends Migration $defaults = [ 'tuition_calculator_version' => 'old', + 'youth_fee' => '200.00', 'new_tuition_full_amount' => '370.00', + 'new_tuition_youth_amount' => '200.00', 'new_tuition_second_student_discount' => '50.00', 'new_tuition_third_student_discount' => '50.00', 'new_tuition_fourth_plus_discount' => '100.00', diff --git a/app/Database/Migrations/2026-06-06-000001_UpdateYouthTuitionDefaults.php b/app/Database/Migrations/2026-06-06-000001_UpdateYouthTuitionDefaults.php new file mode 100644 index 0000000..7dd0987 --- /dev/null +++ b/app/Database/Migrations/2026-06-06-000001_UpdateYouthTuitionDefaults.php @@ -0,0 +1,61 @@ +db->tableExists('configuration')) { + return; + } + + $this->upsertConfig('youth_fee', '200.00', ['180', '180.00']); + $this->upsertConfig('new_tuition_youth_amount', '200.00', ['180', '180.00']); + } + + public function down() + { + if (!$this->db->tableExists('configuration')) { + return; + } + + $this->rollbackConfig('youth_fee', '180.00'); + $this->rollbackConfig('new_tuition_youth_amount', '180.00'); + } + + protected function upsertConfig(string $key, string $value, array $legacyValues = []): void + { + $row = $this->db->table('configuration') + ->select('id, config_value') + ->where('config_key', $key) + ->orderBy('id', 'ASC') + ->get() + ->getRowArray(); + + if (!$row) { + $this->db->table('configuration')->insert([ + 'config_key' => $key, + 'config_value' => $value, + ]); + return; + } + + $current = trim((string) ($row['config_value'] ?? '')); + if ($current === '' || in_array($current, $legacyValues, true)) { + $this->db->table('configuration') + ->where('config_key', $key) + ->update(['config_value' => $value]); + } + } + + protected function rollbackConfig(string $key, string $value): void + { + $this->db->table('configuration') + ->where('config_key', $key) + ->where('config_value', '200.00') + ->update(['config_value' => $value]); + } +} diff --git a/app/Libraries/InvoiceLedgerService.php b/app/Libraries/InvoiceLedgerService.php index e5f19d9..e401cb5 100644 --- a/app/Libraries/InvoiceLedgerService.php +++ b/app/Libraries/InvoiceLedgerService.php @@ -307,6 +307,7 @@ class InvoiceLedgerService 'second_student_fee' => $this->configurationModel->getConfig('second_student_fee'), 'youth_fee' => $this->configurationModel->getConfig('youth_fee'), 'new_tuition_full_amount' => $this->configurationModel->getConfig('new_tuition_full_amount'), + 'new_tuition_youth_amount' => $this->configurationModel->getConfig('new_tuition_youth_amount'), 'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_discount'), 'new_tuition_third_student_discount' => $this->configurationModel->getConfig('new_tuition_third_student_discount'), 'new_tuition_fourth_plus_discount' => $this->configurationModel->getConfig('new_tuition_fourth_plus_discount'), diff --git a/app/Libraries/Tuition/NewTuitionCalculatorService.php b/app/Libraries/Tuition/NewTuitionCalculatorService.php index 299869a..defb35d 100644 --- a/app/Libraries/Tuition/NewTuitionCalculatorService.php +++ b/app/Libraries/Tuition/NewTuitionCalculatorService.php @@ -10,6 +10,7 @@ final class NewTuitionCalculatorService implements TuitionCalculatorInterface { $gradeFee = (int) ($config['grade_fee'] ?? 9); $fullAmountCents = $this->toCents($config['new_tuition_full_amount'] ?? 370); + $youthAmountCents = $this->toCents($config['new_tuition_youth_amount'] ?? $config['youth_fee'] ?? 200); $secondDiscountCents = $this->toCents($config['new_tuition_second_student_discount'] ?? 50); $thirdDiscountCents = $this->toCents($config['new_tuition_third_student_discount'] ?? 50); $fourthPlusDiscountCents = $this->toCents($config['new_tuition_fourth_plus_discount'] ?? 100); @@ -22,32 +23,45 @@ final class NewTuitionCalculatorService implements TuitionCalculatorInterface }); $details = []; + $regularPosition = 0; - foreach (array_values($students) as $index => $student) { - $position = $index + 1; + foreach (array_values($students) as $student) { + $level = GradeLevelParser::parse($student['grade_level'] ?? null, $gradeFee); - if ($position === 1) { + if ($level > $gradeFee) { + $position = null; $discountCents = 0; - $rule = 'new_first_student_full_amount'; - } elseif ($position === 2) { - $discountCents = $secondDiscountCents; - $rule = 'new_second_student_discount'; - } elseif ($position === 3) { - $discountCents = $thirdDiscountCents; - $rule = 'new_third_student_discount'; + $rule = 'new_youth_unit_price'; + $baseAmountCents = $youthAmountCents; } else { - $discountCents = $fourthPlusDiscountCents; - $rule = 'new_fourth_plus_student_discount'; + $regularPosition++; + $position = $regularPosition; + + if ($position === 1) { + $discountCents = 0; + $rule = 'new_first_student_full_amount'; + } elseif ($position === 2) { + $discountCents = $secondDiscountCents; + $rule = 'new_second_student_discount'; + } elseif ($position === 3) { + $discountCents = $thirdDiscountCents; + $rule = 'new_third_student_discount'; + } else { + $discountCents = $fourthPlusDiscountCents; + $rule = 'new_fourth_plus_student_discount'; + } + + $baseAmountCents = $fullAmountCents; } - $amountCents = max(0, $fullAmountCents - $discountCents); + $amountCents = max(0, $baseAmountCents - $discountCents); $details[] = [ 'student_id' => (int) ($student['student_id'] ?? 0), 'student_name' => (string) ($student['student_name'] ?? ''), 'grade_level' => $student['grade_level'] ?? null, 'family_position' => $position, - 'full_amount' => $this->fromCents($fullAmountCents), + 'full_amount' => $this->fromCents($baseAmountCents), 'discount' => $this->fromCents($discountCents), 'rule' => $rule, 'amount' => $this->fromCents($amountCents), diff --git a/app/Libraries/Tuition/OldTuitionCalculatorService.php b/app/Libraries/Tuition/OldTuitionCalculatorService.php index 7136f45..7049d84 100644 --- a/app/Libraries/Tuition/OldTuitionCalculatorService.php +++ b/app/Libraries/Tuition/OldTuitionCalculatorService.php @@ -11,7 +11,7 @@ final class OldTuitionCalculatorService implements TuitionCalculatorInterface $gradeFee = (int) ($config['grade_fee'] ?? 9); $firstStudentFee = $this->toCents($config['first_student_fee'] ?? 370); $secondStudentFee = $this->toCents($config['second_student_fee'] ?? 200); - $youthFee = $this->toCents($config['youth_fee'] ?? 180); + $youthFee = $this->toCents($config['youth_fee'] ?? 200); usort($students, function (array $left, array $right) use ($gradeFee): int { $leftLevel = GradeLevelParser::parse($left['grade_level'] ?? null, $gradeFee); diff --git a/app/Libraries/Tuition/TuitionForecastService.php b/app/Libraries/Tuition/TuitionForecastService.php index 46b381c..f9ce8d4 100644 --- a/app/Libraries/Tuition/TuitionForecastService.php +++ b/app/Libraries/Tuition/TuitionForecastService.php @@ -43,6 +43,7 @@ class TuitionForecastService $mode = $this->normalizeMode($mode); $options = $this->normalizeOptions($options); $this->unitPriceOverride = $options['unit_price']; + $this->youthUnitPriceOverride = $options['youth_unit_price']; $tuitionConfig = $this->getTuitionConfig(); $familyRows = []; $summary = [ @@ -57,6 +58,7 @@ class TuitionForecastService 'projected_tuition' => '0.00', 'projected_income' => '0.00', 'unit_price' => '0.00', + 'youth_unit_price' => '0.00', ]; $oldProjectedCents = 0; @@ -117,6 +119,7 @@ class TuitionForecastService $summary['projected_tuition'] = $mode === 'old' ? $summary['old_projected_tuition'] : $summary['new_projected_tuition']; $summary['projected_income'] = $mode === 'old' ? $summary['old_projected_income'] : $summary['new_projected_income']; $summary['unit_price'] = number_format((float) ($tuitionConfig['new_tuition_full_amount'] ?? 0), 2, '.', ''); + $summary['youth_unit_price'] = number_format((float) ($tuitionConfig['new_tuition_youth_amount'] ?? 0), 2, '.', ''); return [ 'school_year' => $schoolYear, @@ -232,6 +235,7 @@ class TuitionForecastService 'include_event_only' => $this->toBool($options['include_event_only'] ?? false), 'include_paid_invoices' => $this->toBool($options['include_paid_invoices'] ?? true), 'unit_price' => $this->normalizeMoney($options['unit_price'] ?? null), + 'youth_unit_price' => $this->normalizeMoney($options['youth_unit_price'] ?? null), ]; } @@ -541,8 +545,9 @@ class TuitionForecastService 'grade_fee' => $this->configurationModel->getConfig('grade_fee'), 'first_student_fee' => $this->configurationModel->getConfig('first_student_fee'), 'second_student_fee' => $this->configurationModel->getConfig('second_student_fee'), - 'youth_fee' => $this->configurationModel->getConfig('youth_fee'), + 'youth_fee' => $this->configurationModel->getConfig('youth_fee') ?? '200.00', 'new_tuition_full_amount' => $this->normalizedUnitPriceOverride(), + 'new_tuition_youth_amount' => $this->normalizedYouthUnitPriceOverride(), 'new_tuition_second_student_discount' => $this->configurationModel->getConfig('new_tuition_second_student_discount'), 'new_tuition_third_student_discount' => $this->configurationModel->getConfig('new_tuition_third_student_discount'), 'new_tuition_fourth_plus_discount' => $this->configurationModel->getConfig('new_tuition_fourth_plus_discount'), @@ -550,6 +555,7 @@ class TuitionForecastService } protected ?string $unitPriceOverride = null; + protected ?string $youthUnitPriceOverride = null; protected function normalizedUnitPriceOverride(): string { @@ -557,6 +563,16 @@ class TuitionForecastService ?? (string) ($this->configurationModel->getConfig('new_tuition_full_amount') ?? '0.00'); } + protected function normalizedYouthUnitPriceOverride(): string + { + return $this->youthUnitPriceOverride + ?? (string) ( + $this->configurationModel->getConfig('new_tuition_youth_amount') + ?? $this->configurationModel->getConfig('youth_fee') + ?? '200.00' + ); + } + protected function normalizeMoney($value): ?string { if ($value === null || trim((string) $value) === '') { diff --git a/app/Services/FeeCalculationService.php b/app/Services/FeeCalculationService.php index f5540f3..876ee8d 100644 --- a/app/Services/FeeCalculationService.php +++ b/app/Services/FeeCalculationService.php @@ -61,7 +61,7 @@ class FeeCalculationService // Retrieve fee configs $firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350); $secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200); - $youthFee = (float) ($configModel->getConfig('youth_fee') ?? 180); + $youthFee = (float) ($configModel->getConfig('youth_fee') ?? 200); // Assign tuition_fee to all students (before filtering refunds) $regularCount = 0; @@ -148,7 +148,7 @@ class FeeCalculationService $firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350); $secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200); - $youthFee = (float) ($configModel->getConfig('youth_fee') ?? 180); + $youthFee = (float) ($configModel->getConfig('youth_fee') ?? 200); // ✅ Pre-fetch and assign grade/class section names before sorting foreach ($students as &$student) { diff --git a/app/Views/administrator/tuition_forecast.php b/app/Views/administrator/tuition_forecast.php index 184c15b..ae6b0d3 100644 --- a/app/Views/administrator/tuition_forecast.php +++ b/app/Views/administrator/tuition_forecast.php @@ -30,6 +30,7 @@ $warningText = static function (array $warnings): string { 'include_event_only' => !empty($filters['include_event_only']) ? '1' : '0', 'include_paid_invoices' => !empty($filters['include_paid_invoices']) ? '1' : '0', 'unit_price' => $filters['unit_price'] ?? '', + 'youth_unit_price' => $filters['youth_unit_price'] ?? '', ]); ?> @@ -79,7 +80,7 @@ $warningText = static function (array $warnings): string {
- + + placeholder="New tuition unit price for grades"> +
+
+ +
@@ -109,7 +122,7 @@ $warningText = static function (array $warnings): string {
-
+
Total Parents
@@ -117,7 +130,7 @@ $warningText = static function (array $warnings): string {
-
+
Total Students
@@ -126,7 +139,7 @@ $warningText = static function (array $warnings): string {
-
+
Old Projected Income
@@ -134,7 +147,7 @@ $warningText = static function (array $warnings): string {
-
+
New Projected Income
@@ -142,15 +155,23 @@ $warningText = static function (array $warnings): string {
-
+
-
Unit Price
+
Grades Unit Price
-
+
+
+
+
Youth Unit Price
+
+
+
+
+
Difference
diff --git a/tests/app/Controllers/View/TuitionForecastControllerTest.php b/tests/app/Controllers/View/TuitionForecastControllerTest.php index 0cb09f7..8d6a652 100644 --- a/tests/app/Controllers/View/TuitionForecastControllerTest.php +++ b/tests/app/Controllers/View/TuitionForecastControllerTest.php @@ -65,6 +65,7 @@ class FakeForecastService extends TuitionForecastService 'include_event_only' => $options['include_event_only'] ?? '0', 'include_paid_invoices' => $options['include_paid_invoices'] ?? '0', 'unit_price' => $options['unit_price'] ?? '370.00', + 'youth_unit_price' => $options['youth_unit_price'] ?? '200.00', ], 'summary' => [ 'family_count' => 1, @@ -75,6 +76,7 @@ class FakeForecastService extends TuitionForecastService 'old_projected_income' => '550.00', 'new_projected_income' => '650.00', 'unit_price' => '370.00', + 'youth_unit_price' => '200.00', 'difference' => '100.00', ], 'families' => [[ @@ -160,6 +162,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase 'include_event_only' => '1', 'include_paid_invoices' => '1', 'unit_price' => '395.00', + 'youth_unit_price' => '200.00', ]); $result = $this->controller->setRequestObject($request)->index(); @@ -170,6 +173,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase $this->assertSame('new', $result['data']['filters']['calculator_mode']); $this->assertSame('1', $result['data']['filters']['include_payment_pending']); $this->assertSame('395.00', $result['data']['filters']['unit_price']); + $this->assertSame('200.00', $result['data']['filters']['youth_unit_price']); $this->assertSame('always', $this->service->calls[0]['options']['include_withdrawn_mode']); } @@ -184,6 +188,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase 'include_event_only' => '0', 'include_paid_invoices' => '1', 'unit_price' => '390.00', + 'youth_unit_price' => '210.00', ]); $response = $this->controller->setRequestObject($request)->calculate(); @@ -193,6 +198,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase $this->assertSame('Fall', $payload['semester']); $this->assertSame('compare', $payload['calculator_mode']); $this->assertSame('390.00', $payload['options']['unit_price']); + $this->assertSame('210.00', $payload['options']['youth_unit_price']); } public function testExportCsvBuildsDownloadWithSummaryAndFamilyRows(): void @@ -210,6 +216,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase $this->assertStringContainsString('Summary', $body); $this->assertStringContainsString('All Year', $body); $this->assertStringContainsString('Old Projected Income', $body); + $this->assertStringContainsString('Youth Unit Price', $body); $this->assertStringContainsString('Layla Yusuf', $body); $this->assertStringContainsString('Student One', $body); } diff --git a/tests/app/Libraries/NewTuitionCalculatorServiceTest.php b/tests/app/Libraries/NewTuitionCalculatorServiceTest.php index 415c11d..b365a10 100644 --- a/tests/app/Libraries/NewTuitionCalculatorServiceTest.php +++ b/tests/app/Libraries/NewTuitionCalculatorServiceTest.php @@ -51,4 +51,29 @@ class NewTuitionCalculatorServiceTest extends CIUnitTestCase $this->assertSame('0.00', $result['details'][3]['amount']); $this->assertSame('0.00', $result['details'][4]['amount']); } + + public function testYouthStudentsUseSeparateUnitPriceWithoutAffectingRegularDiscountOrder(): void + { + $service = new NewTuitionCalculatorService(); + $result = $service->calculateFamilyTuition([ + ['student_id' => 3, 'student_name' => 'Youth', 'grade_level' => 'Youth 1'], + ['student_id' => 1, 'student_name' => 'Regular One', 'grade_level' => '2'], + ['student_id' => 2, 'student_name' => 'Regular Two', 'grade_level' => '5'], + ], [ + 'grade_fee' => 9, + 'new_tuition_full_amount' => '370.00', + 'new_tuition_youth_amount' => '200.00', + 'new_tuition_second_student_discount' => '150.00', + 'new_tuition_third_student_discount' => '150.00', + 'new_tuition_fourth_plus_discount' => '150.00', + ]); + + $this->assertSame('790.00', $result['total']); + $this->assertSame('new_first_student_full_amount', $result['details'][0]['rule']); + $this->assertSame('370.00', $result['details'][0]['amount']); + $this->assertSame('new_second_student_discount', $result['details'][1]['rule']); + $this->assertSame('220.00', $result['details'][1]['amount']); + $this->assertSame('new_youth_unit_price', $result['details'][2]['rule']); + $this->assertSame('200.00', $result['details'][2]['amount']); + } } diff --git a/tests/app/Libraries/TuitionForecastServiceTest.php b/tests/app/Libraries/TuitionForecastServiceTest.php index c4505dc..7dd294c 100644 --- a/tests/app/Libraries/TuitionForecastServiceTest.php +++ b/tests/app/Libraries/TuitionForecastServiceTest.php @@ -38,10 +38,12 @@ class TuitionForecastServiceTest extends CIUnitTestCase 'students' => [ ['student_id' => 1, 'student_name' => 'Mariam', 'grade_level' => '1'], ['student_id' => 2, 'student_name' => 'Yahya', 'grade_level' => '2'], + ['student_id' => 4, 'student_name' => 'Youth Student', 'grade_level' => 'Youth 1'], ], 'all_students' => [ ['student_id' => 1, 'student_name' => 'Mariam', 'grade_level' => '1', 'billable' => true, 'excluded_reason' => null], ['student_id' => 2, 'student_name' => 'Yahya', 'grade_level' => '2', 'billable' => true, 'excluded_reason' => null], + ['student_id' => 4, 'student_name' => 'Youth Student', 'grade_level' => 'Youth 1', 'billable' => true, 'excluded_reason' => null], ['student_id' => 3, 'student_name' => 'Event Only', 'grade_level' => '3', 'billable' => false, 'excluded_reason' => 'event_only'], ], 'warnings' => ['1 event-only student(s) excluded from tuition.'], @@ -54,8 +56,9 @@ class TuitionForecastServiceTest extends CIUnitTestCase 'grade_fee' => 9, 'first_student_fee' => '350.00', 'second_student_fee' => '200.00', - 'youth_fee' => '180.00', + 'youth_fee' => '200.00', 'new_tuition_full_amount' => $this->unitPriceOverride ?? '350.00', + 'new_tuition_youth_amount' => $this->youthUnitPriceOverride ?? '200.00', 'new_tuition_second_student_discount' => '50.00', 'new_tuition_third_student_discount' => '50.00', 'new_tuition_fourth_plus_discount' => '100.00', @@ -67,16 +70,18 @@ class TuitionForecastServiceTest extends CIUnitTestCase 'include_payment_pending' => true, 'include_withdrawn_mode' => 'refund_deadline', 'unit_price' => '400.00', + 'youth_unit_price' => '200.00', ]); $this->assertSame(1, $result['summary']['family_count']); - $this->assertSame(3, $result['summary']['student_count']); - $this->assertSame(2, $result['summary']['billable_student_count']); - $this->assertSame('550.00', $result['summary']['old_projected_tuition']); - $this->assertSame('750.00', $result['summary']['new_projected_tuition']); - $this->assertSame('550.00', $result['summary']['old_projected_income']); - $this->assertSame('750.00', $result['summary']['new_projected_income']); + $this->assertSame(4, $result['summary']['student_count']); + $this->assertSame(3, $result['summary']['billable_student_count']); + $this->assertSame('750.00', $result['summary']['old_projected_tuition']); + $this->assertSame('950.00', $result['summary']['new_projected_tuition']); + $this->assertSame('750.00', $result['summary']['old_projected_income']); + $this->assertSame('950.00', $result['summary']['new_projected_income']); $this->assertSame('400.00', $result['summary']['unit_price']); - $this->assertSame('event_only', $result['families'][0]['student_details'][2]['excluded_reason']); + $this->assertSame('200.00', $result['summary']['youth_unit_price']); + $this->assertSame('event_only', $result['families'][0]['student_details'][3]['excluded_reason']); } }