Compare commits

...

2 Commits

Author SHA1 Message Date
root 6e8da3cc2c fix batch issue 2026-06-08 23:20:06 -04:00
root 384ae8b719 fix the forecast tuition 2026-06-07 00:27:27 -04:00
16 changed files with 401 additions and 133 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->dueDate = $this->configModel->getConfig('due_date');
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350); $this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 350);
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 200); $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->refundDeadline = date('Y-m-d', strtotime($this->configModel->getConfig('refund_deadline')));
$this->db = \Config\Database::connect(); $this->db = \Config\Database::connect();
$this->request = \Config\Services::request(); $this->request = \Config\Services::request();
+162 -96
View File
@@ -601,153 +601,219 @@ class ReimbursementController extends BaseController
]); ]);
} }
public function updateBatchAssignment() public function updateBatchAssignment()
{ {
if (strtolower($this->request->getMethod()) !== 'post') { if (strtolower($this->request->getMethod()) !== 'post') {
return $this->response->setStatusCode(405)->setJSON([ return $this->response->setStatusCode(405)->setJSON([
'success' => false, 'success' => false,
'error' => 'Method not allowed', 'error' => 'Method not allowed',
]); ]);
} }
$expenseId = (int) $this->request->getPost('expense_id'); $expenseId = (int) $this->request->getPost('expense_id');
$batchIdRaw = $this->request->getPost('batch_id');
$batchId = (int) $batchIdRaw;
$fallbackBatchNumber = $this->request->getPost('batch_number');
if ($batchId <= 0 && $fallbackBatchNumber !== null && trim((string) $fallbackBatchNumber) !== '') {
$batchId = (int) $fallbackBatchNumber;
}
$adminIdRaw = $this->request->getPost('admin_id');
$adminId = ($adminIdRaw === '' || $adminIdRaw === null) ? null : (int) $adminIdRaw;
$reimbursementId = (int) $this->request->getPost('reimbursement_id') ?: null;
if ($expenseId <= 0) { $batchIdRaw = $this->request->getPost('batch_id');
return $this->response->setStatusCode(422)->setJSON([ $batchId = (int) $batchIdRaw;
'success' => false,
'error' => 'Invalid expense id.',
]);
}
$now = date('Y-m-d H:i:s'); $fallbackBatchNumber = $this->request->getPost('batch_number');
if ($batchId <= 0 && $fallbackBatchNumber !== null && trim((string) $fallbackBatchNumber) !== '') {
$batchId = (int) $fallbackBatchNumber;
}
$adminIdRaw = $this->request->getPost('admin_id');
$adminId = ($adminIdRaw === '' || $adminIdRaw === null) ? null : (int) $adminIdRaw;
$reimbursementId = (int) $this->request->getPost('reimbursement_id') ?: null;
if ($expenseId <= 0) {
return $this->response->setStatusCode(422)->setJSON([
'success' => false,
'error' => 'Invalid expense id.',
]);
}
$now = date('Y-m-d H:i:s');
$newHashResponse = function (array $payload = []) {
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
return $this->response
->setHeader('X-CSRF-HASH', (string) $newHash)
->setJSON(array_merge($payload, [
'csrf_hash' => $newHash,
]));
};
$this->db->transBegin();
try {
$activeItem = $this->batchItemModel $activeItem = $this->batchItemModel
->where('expense_id', $expenseId) ->where('expense_id', $expenseId)
->where('unassigned_at IS NULL', null, false) ->where('unassigned_at IS NULL', null, false)
->first(); ->first();
/**
* If no batch is provided, remove the item from active batch processing.
* This means "unassigned from batch", not "unassigned admin".
*/
if ($batchId <= 0) { if ($batchId <= 0) {
if ($activeItem) { if ($activeItem) {
$this->batchItemModel->update((int) $activeItem['id'], ['unassigned_at' => $now]); $this->batchItemModel->update((int) $activeItem['id'], [
'unassigned_at' => $now,
]);
if (!$reimbursementId && !empty($activeItem['reimbursement_id'])) { if (!$reimbursementId && !empty($activeItem['reimbursement_id'])) {
$reimbursementId = (int) $activeItem['reimbursement_id']; $reimbursementId = (int) $activeItem['reimbursement_id'];
} }
} }
$newHash = function_exists('csrf_hash') ? csrf_hash() : null; if (!$this->db->transStatus()) {
return $this->response throw new \RuntimeException('Transaction failed while unassigning batch item.');
->setHeader('X-CSRF-HASH', (string) $newHash) }
->setJSON([
'success' => true, $this->db->transCommit();
'batch_id' => 0,
'admin_id' => null, return $newHashResponse([
'reimbursement_id' => $reimbursementId, 'success' => true,
'csrf_hash' => $newHash, 'batch_id' => 0,
]); 'admin_id' => null,
'reimbursement_id' => $reimbursementId,
]);
} }
$batch = $this->batchModel->find($batchId); $batch = $this->batchModel->find($batchId);
if (!$batch || strtolower((string) ($batch['status'] ?? 'open')) !== 'open') { if (!$batch || strtolower((string) ($batch['status'] ?? 'open')) !== 'open') {
$this->db->transRollback();
return $this->response->setStatusCode(404)->setJSON([ return $this->response->setStatusCode(404)->setJSON([
'success' => false, 'success' => false,
'error' => 'Batch not found or already closed.', 'error' => 'Batch not found or already closed.',
]); ]);
} }
$currentAdmin = $activeItem ? (int) ($activeItem['admin_id'] ?? 0) : null; if (!$reimbursementId) {
$activeSameBatch = $activeItem && (int) ($activeItem['batch_id'] ?? 0) === $batchId; if ($activeItem && !empty($activeItem['reimbursement_id'])) {
if ($activeSameBatch && ($currentAdmin === ($adminId ?? 0))) { $reimbursementId = (int) $activeItem['reimbursement_id'];
$newHash = function_exists('csrf_hash') ? csrf_hash() : null; } else {
return $this->response $reimbursementId = $this->lookupReimbursementId($expenseId);
->setHeader('X-CSRF-HASH', (string) $newHash) }
->setJSON([
'success' => true,
'batch_id' => $batchId,
'admin_id' => $adminId,
'reimbursement_id' => $activeItem['reimbursement_id'] ?? null,
'csrf_hash' => $newHash,
]);
} }
if ($activeSameBatch) { $activeSameBatch = $activeItem && (int) ($activeItem['batch_id'] ?? 0) === $batchId;
if (!$reimbursementId) { $currentAdmin = $activeItem ? (int) ($activeItem['admin_id'] ?? 0) : null;
$reimbursementId = $activeItem['reimbursement_id'] ? (int) $activeItem['reimbursement_id'] : $this->lookupReimbursementId($expenseId); $requestedAdmin = $adminId ?? 0;
/**
* If the item is already active in the same batch/admin slot, just return success.
*/
if ($activeSameBatch && $currentAdmin === $requestedAdmin) {
if (!$this->db->transStatus()) {
throw new \RuntimeException('Transaction failed while checking existing assignment.');
} }
$updatePayload = [
$this->db->transCommit();
return $newHashResponse([
'success' => true,
'batch_id' => $batchId,
'admin_id' => $adminId,
'reimbursement_id' => $activeItem['reimbursement_id'] ?? $reimbursementId,
]);
}
/**
* If same batch but different admin, update the existing active row.
*
* Important:
* admin_id = null means active in the batch but not assigned to an admin.
* unassigned_at != null means removed from active batch processing.
*/
if ($activeSameBatch) {
$this->batchItemModel->update((int) $activeItem['id'], [
'admin_id' => $adminId, 'admin_id' => $adminId,
'assigned_at' => $now, 'assigned_at' => $now,
'reimbursement_id' => $reimbursementId, 'reimbursement_id' => $reimbursementId,
'unassigned_at' => $adminId === null ? $now : null, 'unassigned_at' => null,
]; ]);
$this->batchItemModel->update((int) $activeItem['id'], $updatePayload);
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
return $this->response
->setHeader('X-CSRF-HASH', (string) $newHash)
->setJSON([
'success' => true,
'batch_id' => $batchId,
'admin_id' => $adminId,
'reimbursement_id' => $reimbursementId,
'csrf_hash' => $newHash,
]);
}
if ($activeItem) { if (!$this->db->transStatus()) {
$this->batchItemModel->update((int) $activeItem['id'], ['unassigned_at' => $now]); throw new \RuntimeException('Transaction failed while updating existing assignment.');
if (!$reimbursementId && !empty($activeItem['reimbursement_id'])) {
$reimbursementId = (int) $activeItem['reimbursement_id'];
} }
$this->db->transCommit();
return $newHashResponse([
'success' => true,
'batch_id' => $batchId,
'admin_id' => $adminId,
'reimbursement_id' => $reimbursementId,
]);
} }
if (!$reimbursementId) { /**
$reimbursementId = $this->lookupReimbursementId($expenseId); * If the item is active in another batch, soft-unassign that row first.
*/
if ($activeItem) {
$this->batchItemModel->update((int) $activeItem['id'], [
'unassigned_at' => $now,
]);
} }
$insertData = [ /**
* Critical fix:
* Check whether this expense was previously assigned to this batch.
* Because uq_batch_expense(batch_id, expense_id) blocks duplicate rows,
* we must reactivate/update the old row instead of inserting again.
*/
$existingBatchItem = $this->batchItemModel
->where('batch_id', $batchId)
->where('expense_id', $expenseId)
->first();
$assignmentData = [
'batch_id' => $batchId, 'batch_id' => $batchId,
'expense_id' => $expenseId, 'expense_id' => $expenseId,
'reimbursement_id' => $reimbursementId, 'reimbursement_id' => $reimbursementId,
'admin_id' => $adminId, 'admin_id' => $adminId,
'assigned_at' => $now, 'assigned_at' => $now,
'unassigned_at' => null,
'school_year' => $this->schoolYear, 'school_year' => $this->schoolYear,
'semester' => $this->semester, 'semester' => $this->semester,
]; ];
try { if ($existingBatchItem) {
$this->batchItemModel->insert($insertData); $this->batchItemModel->update((int) $existingBatchItem['id'], $assignmentData);
} catch (\Throwable $e) { } else {
log_message('error', 'Failed to assign expense #{expense} to batch #{batch}: {msg}', [ $this->batchItemModel->insert($assignmentData);
'expense' => $expenseId,
'batch' => $batchId,
'msg' => $e->getMessage(),
]);
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Unable to update batch assignment right now.',
]);
} }
$newHash = function_exists('csrf_hash') ? csrf_hash() : null; if (!$this->db->transStatus()) {
throw new \RuntimeException('Transaction failed while saving assignment.');
}
return $this->response $this->db->transCommit();
->setHeader('X-CSRF-HASH', (string) $newHash)
->setJSON([ return $newHashResponse([
'success' => true, 'success' => true,
'batch_id' => $batchId, 'batch_id' => $batchId,
'admin_id' => $adminId, 'admin_id' => $adminId,
'reimbursement_id' => $reimbursementId, 'reimbursement_id' => $reimbursementId,
'csrf_hash' => $newHash, ]);
]); } catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Failed to update batch assignment for expense #{expense}, batch #{batch}: {msg}', [
'expense' => $expenseId,
'batch' => $batchId,
'msg' => $e->getMessage(),
]);
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Unable to update batch assignment right now.',
]);
} }
}
public function lockBatch() public function lockBatch()
{ {
@@ -71,12 +71,13 @@ class TuitionForecastController extends BaseController
fputcsv($handle, ['Calculator Mode', $result['calculator_mode']]); fputcsv($handle, ['Calculator Mode', $result['calculator_mode']]);
fputcsv($handle, []); fputcsv($handle, []);
fputcsv($handle, ['Summary']); 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, [ fputcsv($handle, [
$result['summary']['family_count'], $result['summary']['family_count'],
$result['summary']['student_count'], $result['summary']['student_count'],
$result['summary']['billable_student_count'], $result['summary']['billable_student_count'],
$result['summary']['unit_price'], $result['summary']['unit_price'],
$result['summary']['youth_unit_price'],
$result['summary']['old_projected_income'] ?? $result['summary']['old_projected_tuition'], $result['summary']['old_projected_income'] ?? $result['summary']['old_projected_tuition'],
$result['summary']['new_projected_income'] ?? $result['summary']['new_projected_tuition'], $result['summary']['new_projected_income'] ?? $result['summary']['new_projected_tuition'],
$result['summary']['difference'], $result['summary']['difference'],
@@ -132,6 +133,7 @@ class TuitionForecastController extends BaseController
'include_event_only' => $this->request->{$source}('include_event_only') ?? '0', 'include_event_only' => $this->request->{$source}('include_event_only') ?? '0',
'include_paid_invoices' => $this->request->{$source}('include_paid_invoices') ?? '0', 'include_paid_invoices' => $this->request->{$source}('include_paid_invoices') ?? '0',
'unit_price' => $this->request->{$source}('unit_price') ?? '', '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 = [ $defaults = [
'tuition_calculator_version' => 'old', 'tuition_calculator_version' => 'old',
'youth_fee' => '200.00',
'new_tuition_full_amount' => '370.00', 'new_tuition_full_amount' => '370.00',
'new_tuition_youth_amount' => '200.00',
'new_tuition_second_student_discount' => '50.00', 'new_tuition_second_student_discount' => '50.00',
'new_tuition_third_student_discount' => '50.00', 'new_tuition_third_student_discount' => '50.00',
'new_tuition_fourth_plus_discount' => '100.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'), 'second_student_fee' => $this->configurationModel->getConfig('second_student_fee'),
'youth_fee' => $this->configurationModel->getConfig('youth_fee'), 'youth_fee' => $this->configurationModel->getConfig('youth_fee'),
'new_tuition_full_amount' => $this->configurationModel->getConfig('new_tuition_full_amount'), '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_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_third_student_discount' => $this->configurationModel->getConfig('new_tuition_third_student_discount'),
'new_tuition_fourth_plus_discount' => $this->configurationModel->getConfig('new_tuition_fourth_plus_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); $gradeFee = (int) ($config['grade_fee'] ?? 9);
$fullAmountCents = $this->toCents($config['new_tuition_full_amount'] ?? 370); $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); $secondDiscountCents = $this->toCents($config['new_tuition_second_student_discount'] ?? 50);
$thirdDiscountCents = $this->toCents($config['new_tuition_third_student_discount'] ?? 50); $thirdDiscountCents = $this->toCents($config['new_tuition_third_student_discount'] ?? 50);
$fourthPlusDiscountCents = $this->toCents($config['new_tuition_fourth_plus_discount'] ?? 100); $fourthPlusDiscountCents = $this->toCents($config['new_tuition_fourth_plus_discount'] ?? 100);
@@ -22,32 +23,45 @@ final class NewTuitionCalculatorService implements TuitionCalculatorInterface
}); });
$details = []; $details = [];
$regularPosition = 0;
foreach (array_values($students) as $index => $student) { foreach (array_values($students) as $student) {
$position = $index + 1; $level = GradeLevelParser::parse($student['grade_level'] ?? null, $gradeFee);
if ($position === 1) { if ($level > $gradeFee) {
$position = null;
$discountCents = 0; $discountCents = 0;
$rule = 'new_first_student_full_amount'; $rule = 'new_youth_unit_price';
} elseif ($position === 2) { $baseAmountCents = $youthAmountCents;
$discountCents = $secondDiscountCents;
$rule = 'new_second_student_discount';
} elseif ($position === 3) {
$discountCents = $thirdDiscountCents;
$rule = 'new_third_student_discount';
} else { } else {
$discountCents = $fourthPlusDiscountCents; $regularPosition++;
$rule = 'new_fourth_plus_student_discount'; $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[] = [ $details[] = [
'student_id' => (int) ($student['student_id'] ?? 0), 'student_id' => (int) ($student['student_id'] ?? 0),
'student_name' => (string) ($student['student_name'] ?? ''), 'student_name' => (string) ($student['student_name'] ?? ''),
'grade_level' => $student['grade_level'] ?? null, 'grade_level' => $student['grade_level'] ?? null,
'family_position' => $position, 'family_position' => $position,
'full_amount' => $this->fromCents($fullAmountCents), 'full_amount' => $this->fromCents($baseAmountCents),
'discount' => $this->fromCents($discountCents), 'discount' => $this->fromCents($discountCents),
'rule' => $rule, 'rule' => $rule,
'amount' => $this->fromCents($amountCents), 'amount' => $this->fromCents($amountCents),
@@ -11,7 +11,7 @@ final class OldTuitionCalculatorService implements TuitionCalculatorInterface
$gradeFee = (int) ($config['grade_fee'] ?? 9); $gradeFee = (int) ($config['grade_fee'] ?? 9);
$firstStudentFee = $this->toCents($config['first_student_fee'] ?? 370); $firstStudentFee = $this->toCents($config['first_student_fee'] ?? 370);
$secondStudentFee = $this->toCents($config['second_student_fee'] ?? 200); $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 { usort($students, function (array $left, array $right) use ($gradeFee): int {
$leftLevel = GradeLevelParser::parse($left['grade_level'] ?? null, $gradeFee); $leftLevel = GradeLevelParser::parse($left['grade_level'] ?? null, $gradeFee);
@@ -43,6 +43,7 @@ class TuitionForecastService
$mode = $this->normalizeMode($mode); $mode = $this->normalizeMode($mode);
$options = $this->normalizeOptions($options); $options = $this->normalizeOptions($options);
$this->unitPriceOverride = $options['unit_price']; $this->unitPriceOverride = $options['unit_price'];
$this->youthUnitPriceOverride = $options['youth_unit_price'];
$tuitionConfig = $this->getTuitionConfig(); $tuitionConfig = $this->getTuitionConfig();
$familyRows = []; $familyRows = [];
$summary = [ $summary = [
@@ -57,6 +58,7 @@ class TuitionForecastService
'projected_tuition' => '0.00', 'projected_tuition' => '0.00',
'projected_income' => '0.00', 'projected_income' => '0.00',
'unit_price' => '0.00', 'unit_price' => '0.00',
'youth_unit_price' => '0.00',
]; ];
$oldProjectedCents = 0; $oldProjectedCents = 0;
@@ -117,6 +119,7 @@ class TuitionForecastService
$summary['projected_tuition'] = $mode === 'old' ? $summary['old_projected_tuition'] : $summary['new_projected_tuition']; $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['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['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 [ return [
'school_year' => $schoolYear, 'school_year' => $schoolYear,
@@ -232,6 +235,7 @@ class TuitionForecastService
'include_event_only' => $this->toBool($options['include_event_only'] ?? false), 'include_event_only' => $this->toBool($options['include_event_only'] ?? false),
'include_paid_invoices' => $this->toBool($options['include_paid_invoices'] ?? true), 'include_paid_invoices' => $this->toBool($options['include_paid_invoices'] ?? true),
'unit_price' => $this->normalizeMoney($options['unit_price'] ?? null), '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'), 'grade_fee' => $this->configurationModel->getConfig('grade_fee'),
'first_student_fee' => $this->configurationModel->getConfig('first_student_fee'), 'first_student_fee' => $this->configurationModel->getConfig('first_student_fee'),
'second_student_fee' => $this->configurationModel->getConfig('second_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_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_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_third_student_discount' => $this->configurationModel->getConfig('new_tuition_third_student_discount'),
'new_tuition_fourth_plus_discount' => $this->configurationModel->getConfig('new_tuition_fourth_plus_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 $unitPriceOverride = null;
protected ?string $youthUnitPriceOverride = null;
protected function normalizedUnitPriceOverride(): string protected function normalizedUnitPriceOverride(): string
{ {
@@ -557,6 +563,16 @@ class TuitionForecastService
?? (string) ($this->configurationModel->getConfig('new_tuition_full_amount') ?? '0.00'); ?? (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 protected function normalizeMoney($value): ?string
{ {
if ($value === null || trim((string) $value) === '') { if ($value === null || trim((string) $value) === '') {
+2 -2
View File
@@ -61,7 +61,7 @@ class FeeCalculationService
// Retrieve fee configs // Retrieve fee configs
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350); $firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200); $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) // Assign tuition_fee to all students (before filtering refunds)
$regularCount = 0; $regularCount = 0;
@@ -148,7 +148,7 @@ class FeeCalculationService
$firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350); $firstStudentFee = (float) ($configModel->getConfig('first_student_fee') ?? 350);
$secondStudentFee = (float) ($configModel->getConfig('second_student_fee') ?? 200); $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 // ✅ Pre-fetch and assign grade/class section names before sorting
foreach ($students as &$student) { 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_event_only' => !empty($filters['include_event_only']) ? '1' : '0',
'include_paid_invoices' => !empty($filters['include_paid_invoices']) ? '1' : '0', 'include_paid_invoices' => !empty($filters['include_paid_invoices']) ? '1' : '0',
'unit_price' => $filters['unit_price'] ?? '', '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"> <a href="<?= site_url('administrator/tuition-forecast/export?' . $exportQuery) ?>" class="btn btn-success">
@@ -79,7 +80,7 @@ $warningText = static function (array $warnings): string {
</select> </select>
</div> </div>
<div class="col-md-3"> <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 <input
type="number" type="number"
min="0" min="0"
@@ -88,7 +89,19 @@ $warningText = static function (array $warnings): string {
name="unit_price" name="unit_price"
class="form-control" class="form-control"
value="<?= esc((string) ($filters['unit_price'] ?? ($summary['unit_price'] ?? ''))) ?>" 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>
<div class="col-md-2"> <div class="col-md-2">
<div class="form-check"> <div class="form-check">
@@ -109,7 +122,7 @@ $warningText = static function (array $warnings): string {
<?php if ($result): ?> <?php if ($result): ?>
<div class="row g-3 mb-4"> <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 border-0 shadow-sm h-100">
<div class="card-body"> <div class="card-body">
<div class="text-muted small">Total Parents</div> <div class="text-muted small">Total Parents</div>
@@ -117,7 +130,7 @@ $warningText = static function (array $warnings): string {
</div> </div>
</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 border-0 shadow-sm h-100">
<div class="card-body"> <div class="card-body">
<div class="text-muted small">Total Students</div> <div class="text-muted small">Total Students</div>
@@ -126,7 +139,7 @@ $warningText = static function (array $warnings): string {
</div> </div>
</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 border-0 shadow-sm h-100">
<div class="card-body"> <div class="card-body">
<div class="text-muted small">Old Projected Income</div> <div class="text-muted small">Old Projected Income</div>
@@ -134,7 +147,7 @@ $warningText = static function (array $warnings): string {
</div> </div>
</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 border-0 shadow-sm h-100">
<div class="card-body"> <div class="card-body">
<div class="text-muted small">New Projected Income</div> <div class="text-muted small">New Projected Income</div>
@@ -142,15 +155,23 @@ $warningText = static function (array $warnings): string {
</div> </div>
</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 border-0 shadow-sm h-100">
<div class="card-body"> <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 class="fs-5 fw-semibold"><?= esc($fmtMoney($summary['unit_price'] ?? 0)) ?></div>
</div> </div>
</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 border-0 shadow-sm h-100">
<div class="card-body"> <div class="card-body">
<div class="text-muted small">Difference</div> <div class="text-muted small">Difference</div>
+48
View File
@@ -0,0 +1,48 @@
{
"advisories": {
"phpoffice/math": [
{
"advisoryId": "PKSA-jw72-bn8m-h7rc",
"packageName": "phpoffice/math",
"affectedVersions": "<=0.2.0",
"title": "PHPOffice Math allows XXE when processing an XML file in the MathML format ",
"cve": "CVE-2025-48882",
"link": "https://github.com/advisories/GHSA-42hm-pq2f-3r7m",
"reportedAt": "2025-05-29T17:27:39+00:00",
"sources": [
{
"name": "GitHub",
"remoteId": "GHSA-42hm-pq2f-3r7m"
}
],
"severity": "high"
}
],
"phpunit/phpunit": [
{
"advisoryId": "PKSA-z3gr-8qht-p93v",
"packageName": "phpunit/phpunit",
"affectedVersions": ">=0,<8.5.52|>=9.0.0,<9.6.33|>=10.0.0,<10.5.62|>=11.0.0,<11.5.50|>=12.0.0,<12.5.8",
"title": "Unsafe Deserialization in PHPT Code Coverage Handling",
"cve": "CVE-2026-24765",
"link": "https://github.com/sebastianbergmann/phpunit/security/advisories/GHSA-vvj3-c3rp-c85p",
"reportedAt": "2026-01-27T05:21:14+00:00",
"sources": [
{
"name": "GitHub",
"remoteId": "GHSA-vvj3-c3rp-c85p"
},
{
"name": "FriendsOfPHP/security-advisories",
"remoteId": "phpunit/phpunit/CVE-2026-24765.yaml"
}
],
"severity": "high"
}
]
},
"abandoned": {
"paypal/rest-api-sdk-php": "paypal/paypal-server-sdk"
},
"filter": []
}
@@ -65,6 +65,7 @@ class FakeForecastService extends TuitionForecastService
'include_event_only' => $options['include_event_only'] ?? '0', 'include_event_only' => $options['include_event_only'] ?? '0',
'include_paid_invoices' => $options['include_paid_invoices'] ?? '0', 'include_paid_invoices' => $options['include_paid_invoices'] ?? '0',
'unit_price' => $options['unit_price'] ?? '370.00', 'unit_price' => $options['unit_price'] ?? '370.00',
'youth_unit_price' => $options['youth_unit_price'] ?? '200.00',
], ],
'summary' => [ 'summary' => [
'family_count' => 1, 'family_count' => 1,
@@ -75,6 +76,7 @@ class FakeForecastService extends TuitionForecastService
'old_projected_income' => '550.00', 'old_projected_income' => '550.00',
'new_projected_income' => '650.00', 'new_projected_income' => '650.00',
'unit_price' => '370.00', 'unit_price' => '370.00',
'youth_unit_price' => '200.00',
'difference' => '100.00', 'difference' => '100.00',
], ],
'families' => [[ 'families' => [[
@@ -160,6 +162,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase
'include_event_only' => '1', 'include_event_only' => '1',
'include_paid_invoices' => '1', 'include_paid_invoices' => '1',
'unit_price' => '395.00', 'unit_price' => '395.00',
'youth_unit_price' => '200.00',
]); ]);
$result = $this->controller->setRequestObject($request)->index(); $result = $this->controller->setRequestObject($request)->index();
@@ -170,6 +173,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase
$this->assertSame('new', $result['data']['filters']['calculator_mode']); $this->assertSame('new', $result['data']['filters']['calculator_mode']);
$this->assertSame('1', $result['data']['filters']['include_payment_pending']); $this->assertSame('1', $result['data']['filters']['include_payment_pending']);
$this->assertSame('395.00', $result['data']['filters']['unit_price']); $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']); $this->assertSame('always', $this->service->calls[0]['options']['include_withdrawn_mode']);
} }
@@ -184,6 +188,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase
'include_event_only' => '0', 'include_event_only' => '0',
'include_paid_invoices' => '1', 'include_paid_invoices' => '1',
'unit_price' => '390.00', 'unit_price' => '390.00',
'youth_unit_price' => '210.00',
]); ]);
$response = $this->controller->setRequestObject($request)->calculate(); $response = $this->controller->setRequestObject($request)->calculate();
@@ -193,6 +198,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase
$this->assertSame('Fall', $payload['semester']); $this->assertSame('Fall', $payload['semester']);
$this->assertSame('compare', $payload['calculator_mode']); $this->assertSame('compare', $payload['calculator_mode']);
$this->assertSame('390.00', $payload['options']['unit_price']); $this->assertSame('390.00', $payload['options']['unit_price']);
$this->assertSame('210.00', $payload['options']['youth_unit_price']);
} }
public function testExportCsvBuildsDownloadWithSummaryAndFamilyRows(): void public function testExportCsvBuildsDownloadWithSummaryAndFamilyRows(): void
@@ -210,6 +216,7 @@ class TuitionForecastControllerTest extends CIUnitTestCase
$this->assertStringContainsString('Summary', $body); $this->assertStringContainsString('Summary', $body);
$this->assertStringContainsString('All Year', $body); $this->assertStringContainsString('All Year', $body);
$this->assertStringContainsString('Old Projected Income', $body); $this->assertStringContainsString('Old Projected Income', $body);
$this->assertStringContainsString('Youth Unit Price', $body);
$this->assertStringContainsString('Layla Yusuf', $body); $this->assertStringContainsString('Layla Yusuf', $body);
$this->assertStringContainsString('Student One', $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'][3]['amount']);
$this->assertSame('0.00', $result['details'][4]['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' => [ 'students' => [
['student_id' => 1, 'student_name' => 'Mariam', 'grade_level' => '1'], ['student_id' => 1, 'student_name' => 'Mariam', 'grade_level' => '1'],
['student_id' => 2, 'student_name' => 'Yahya', 'grade_level' => '2'], ['student_id' => 2, 'student_name' => 'Yahya', 'grade_level' => '2'],
['student_id' => 4, 'student_name' => 'Youth Student', 'grade_level' => 'Youth 1'],
], ],
'all_students' => [ 'all_students' => [
['student_id' => 1, 'student_name' => 'Mariam', 'grade_level' => '1', 'billable' => true, 'excluded_reason' => null], ['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' => 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'], ['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.'], 'warnings' => ['1 event-only student(s) excluded from tuition.'],
@@ -54,8 +56,9 @@ class TuitionForecastServiceTest extends CIUnitTestCase
'grade_fee' => 9, 'grade_fee' => 9,
'first_student_fee' => '350.00', 'first_student_fee' => '350.00',
'second_student_fee' => '200.00', 'second_student_fee' => '200.00',
'youth_fee' => '180.00', 'youth_fee' => '200.00',
'new_tuition_full_amount' => $this->unitPriceOverride ?? '350.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_second_student_discount' => '50.00',
'new_tuition_third_student_discount' => '50.00', 'new_tuition_third_student_discount' => '50.00',
'new_tuition_fourth_plus_discount' => '100.00', 'new_tuition_fourth_plus_discount' => '100.00',
@@ -67,16 +70,18 @@ class TuitionForecastServiceTest extends CIUnitTestCase
'include_payment_pending' => true, 'include_payment_pending' => true,
'include_withdrawn_mode' => 'refund_deadline', 'include_withdrawn_mode' => 'refund_deadline',
'unit_price' => '400.00', 'unit_price' => '400.00',
'youth_unit_price' => '200.00',
]); ]);
$this->assertSame(1, $result['summary']['family_count']); $this->assertSame(1, $result['summary']['family_count']);
$this->assertSame(3, $result['summary']['student_count']); $this->assertSame(4, $result['summary']['student_count']);
$this->assertSame(2, $result['summary']['billable_student_count']); $this->assertSame(3, $result['summary']['billable_student_count']);
$this->assertSame('550.00', $result['summary']['old_projected_tuition']); $this->assertSame('750.00', $result['summary']['old_projected_tuition']);
$this->assertSame('750.00', $result['summary']['new_projected_tuition']); $this->assertSame('950.00', $result['summary']['new_projected_tuition']);
$this->assertSame('550.00', $result['summary']['old_projected_income']); $this->assertSame('750.00', $result['summary']['old_projected_income']);
$this->assertSame('750.00', $result['summary']['new_projected_income']); $this->assertSame('950.00', $result['summary']['new_projected_income']);
$this->assertSame('400.00', $result['summary']['unit_price']); $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']);
} }
} }