fix the forecast tuition

This commit is contained in:
root
2026-06-07 00:27:27 -04:00
parent 654453222f
commit 384ae8b719
14 changed files with 191 additions and 37 deletions
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -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();
@@ -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') ?? '',
];
}
}
@@ -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',
@@ -0,0 +1,61 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class UpdateYouthTuitionDefaults extends Migration
{
public function up()
{
if (!$this->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]);
}
}
+1
View File
@@ -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'),
@@ -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),
@@ -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);
@@ -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) === '') {
+2 -2
View File
@@ -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) {
+30 -9
View File
@@ -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'] ?? '',
]);
?>
<a href="<?= site_url('administrator/tuition-forecast/export?' . $exportQuery) ?>" class="btn btn-success">
@@ -79,7 +80,7 @@ $warningText = static function (array $warnings): string {
</select>
</div>
<div class="col-md-3">
<label for="unit_price" class="form-label">Unit Price</label>
<label for="unit_price" class="form-label">Grades Unit Price</label>
<input
type="number"
min="0"
@@ -88,7 +89,19 @@ $warningText = static function (array $warnings): string {
name="unit_price"
class="form-control"
value="<?= esc((string) ($filters['unit_price'] ?? ($summary['unit_price'] ?? ''))) ?>"
placeholder="New tuition unit price">
placeholder="New tuition unit price for grades">
</div>
<div class="col-md-3">
<label for="youth_unit_price" class="form-label">Youth Unit Price</label>
<input
type="number"
min="0"
step="0.01"
id="youth_unit_price"
name="youth_unit_price"
class="form-control"
value="<?= esc((string) ($filters['youth_unit_price'] ?? ($summary['youth_unit_price'] ?? ''))) ?>"
placeholder="New tuition unit price for youth">
</div>
<div class="col-md-2">
<div class="form-check">
@@ -109,7 +122,7 @@ $warningText = static function (array $warnings): string {
<?php if ($result): ?>
<div class="row g-3 mb-4">
<div class="col-md-2">
<div class="col-xl col-lg-3 col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="text-muted small">Total Parents</div>
@@ -117,7 +130,7 @@ $warningText = static function (array $warnings): string {
</div>
</div>
</div>
<div class="col-md-2">
<div class="col-xl col-lg-3 col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="text-muted small">Total Students</div>
@@ -126,7 +139,7 @@ $warningText = static function (array $warnings): string {
</div>
</div>
</div>
<div class="col-md-2">
<div class="col-xl col-lg-3 col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="text-muted small">Old Projected Income</div>
@@ -134,7 +147,7 @@ $warningText = static function (array $warnings): string {
</div>
</div>
</div>
<div class="col-md-2">
<div class="col-xl col-lg-3 col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="text-muted small">New Projected Income</div>
@@ -142,15 +155,23 @@ $warningText = static function (array $warnings): string {
</div>
</div>
</div>
<div class="col-md-2">
<div class="col-xl col-lg-3 col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="text-muted small">Unit Price</div>
<div class="text-muted small">Grades Unit Price</div>
<div class="fs-5 fw-semibold"><?= esc($fmtMoney($summary['unit_price'] ?? 0)) ?></div>
</div>
</div>
</div>
<div class="col-md-2">
<div class="col-xl col-lg-3 col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="text-muted small">Youth Unit Price</div>
<div class="fs-5 fw-semibold"><?= esc($fmtMoney($summary['youth_unit_price'] ?? 0)) ?></div>
</div>
</div>
</div>
<div class="col-xl col-lg-3 col-md-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="text-muted small">Difference</div>
@@ -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);
}
@@ -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']);
}
}
@@ -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']);
}
}