Fix semester context, attendance rosters, and billing workflows
- load global semester helpers consistently and use date-based semester defaults - fix grading and daily attendance duplicate student/section rows - keep attendance violations scoped to the current semester by default - update invoice, refund, discount, payment, and financial aid flows - add configuration cleanup migrations for duplicate calendar/semester keys - refresh parent registration/report-card and print request handling - update related models, services, views, cron notes, and test coverage
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Controllers\Administrator;
|
||||
|
||||
use App\Controllers\Administrator\FinancialAidController;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class FinancialAidRequestStub
|
||||
{
|
||||
public function __construct(private array $post = [])
|
||||
{
|
||||
}
|
||||
|
||||
public function getPost(?string $key = null)
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->post;
|
||||
}
|
||||
|
||||
return $this->post[$key] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class TestableFinancialAidController extends FinancialAidController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function setRequestObject($request): self
|
||||
{
|
||||
$this->request = $request;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
class FinancialAidControllerTest extends CIUnitTestCase
|
||||
{
|
||||
public function testPostedAdminAmountWinsOverRequestedAmount(): void
|
||||
{
|
||||
$controller = (new TestableFinancialAidController())
|
||||
->setRequestObject(new FinancialAidRequestStub(['admin_amount' => '175.50']));
|
||||
|
||||
$this->assertSame(175.50, $this->approvalAmount($controller, [
|
||||
'requested_amount' => '250.00',
|
||||
]));
|
||||
}
|
||||
|
||||
public function testBlankAdminAmountFallsBackToRequestedAmount(): void
|
||||
{
|
||||
$controller = (new TestableFinancialAidController())
|
||||
->setRequestObject(new FinancialAidRequestStub(['admin_amount' => '']));
|
||||
|
||||
$this->assertSame(250.00, $this->approvalAmount($controller, [
|
||||
'requested_amount' => '250.00',
|
||||
]));
|
||||
}
|
||||
|
||||
private function approvalAmount(FinancialAidController $controller, array $request): float
|
||||
{
|
||||
$reflection = new \ReflectionMethod($controller, 'approvalAmount');
|
||||
$reflection->setAccessible(true);
|
||||
|
||||
return $reflection->invoke($controller, $request);
|
||||
}
|
||||
}
|
||||
@@ -241,7 +241,7 @@ class TestableAttendanceController extends AttendanceController
|
||||
$this->studentClassModel = $studentClassModel;
|
||||
$this->configModel = $configModel;
|
||||
$this->schoolYear = (string) $configModel->getConfig('school_year');
|
||||
$this->semester = (string) $configModel->getConfig('semester');
|
||||
$this->semester = (string) getSemester();
|
||||
}
|
||||
|
||||
public function setDatabaseConnection(StubDbConnection $db): void
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace {
|
||||
if (!function_exists('site_url')) {
|
||||
function site_url($uri = '')
|
||||
{
|
||||
return 'https://test.alrahmaisgl.org/' . ltrim((string) $uri, '/');
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('view')) {
|
||||
function view($name, $data = [], $options = [])
|
||||
{
|
||||
return ['view' => $name, 'data' => $data, 'options' => $options];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace Tests\App\Controllers\View {
|
||||
|
||||
use App\Controllers\View\ExpenseController;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use Config\Services;
|
||||
|
||||
class TestableExpenseController extends ExpenseController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
// Skip the real constructor so tests can inject fakes.
|
||||
}
|
||||
|
||||
public function setExpenseModel(object $model): self
|
||||
{
|
||||
$this->expenseModel = $model;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setConfigModel(object $model): self
|
||||
{
|
||||
$this->configModel = $model;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
class ExpenseIndexFakeModel
|
||||
{
|
||||
public array $whereCalls = [];
|
||||
|
||||
public function select(string $select): self
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function join(string $table, string $condition, string $type = ''): self
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function where(string $field, mixed $value): self
|
||||
{
|
||||
$this->whereCalls[] = [$field, $value];
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function orderBy(string $field, string $direction = ''): self
|
||||
{
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function findAll(): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => 10,
|
||||
'category' => 'Expense',
|
||||
'amount' => '12.50',
|
||||
'receipt_path' => 'receipt.pdf',
|
||||
'school_year' => '2025-2026',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
class ExpenseIndexFakeConfig
|
||||
{
|
||||
public function getConfig(string $key): ?string
|
||||
{
|
||||
return $key === 'school_year' ? '2025-2026' : null;
|
||||
}
|
||||
}
|
||||
|
||||
class ExpenseFakeRenderer
|
||||
{
|
||||
private array $data = [];
|
||||
private array $lastRender = [];
|
||||
|
||||
public function setData(?array $data = null): self
|
||||
{
|
||||
$this->data = $data ?? [];
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function render(string $view, array $data = [], $options = null)
|
||||
{
|
||||
$this->lastRender = [
|
||||
'view' => $view,
|
||||
'data' => $data ?: $this->data,
|
||||
'options' => $options,
|
||||
];
|
||||
|
||||
return 'fake-rendered:' . $view;
|
||||
}
|
||||
|
||||
public function getLastRender(): array
|
||||
{
|
||||
return $this->lastRender;
|
||||
}
|
||||
}
|
||||
|
||||
class ExpenseControllerTest extends CIUnitTestCase
|
||||
{
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Services::resetSingle('renderer');
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function testIndexScopesExpensesToActiveConfiguredSchoolYear(): void
|
||||
{
|
||||
$expenseModel = new ExpenseIndexFakeModel();
|
||||
$renderer = new ExpenseFakeRenderer();
|
||||
Services::injectMock('renderer', $renderer);
|
||||
|
||||
$controller = (new TestableExpenseController())
|
||||
->setExpenseModel($expenseModel)
|
||||
->setConfigModel(new ExpenseIndexFakeConfig());
|
||||
|
||||
$result = $controller->index();
|
||||
$this->assertSame('fake-rendered:expenses/index', $result);
|
||||
$rendered = $renderer->getLastRender();
|
||||
|
||||
$this->assertSame('expenses/index', $rendered['view']);
|
||||
$this->assertSame('2025-2026', $rendered['data']['schoolYear']);
|
||||
$this->assertSame([
|
||||
['expenses.school_year', '2025-2026'],
|
||||
], $expenseModel->whereCalls);
|
||||
$this->assertSame(
|
||||
site_url('receipts/receipt.pdf'),
|
||||
$rendered['data']['expenses'][0]['receipt_url']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
|
||||
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame('15.00', $calculation['customer_credit']);
|
||||
$this->assertSame(-1500, $calculation['rawBalanceCents']);
|
||||
$this->assertSame(-3500, $calculation['rawBalanceCents']);
|
||||
}
|
||||
|
||||
public function testFullyRefundedOverpaymentClearsCustomerCredit(): void
|
||||
@@ -177,7 +177,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
|
||||
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame('0.00', $calculation['customer_credit']);
|
||||
$this->assertSame(0, $calculation['rawBalanceCents']);
|
||||
$this->assertSame(-5000, $calculation['rawBalanceCents']);
|
||||
}
|
||||
|
||||
public function testPaymentAfterRefundCanRestorePaidStatus(): void
|
||||
@@ -392,7 +392,7 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testCashRefundCanRestoreBalanceAfterOverpaymentIsReturned(): void
|
||||
public function testCashRefundDoesNotCreateBalanceAfterOverpaymentIsReturned(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 12,
|
||||
@@ -405,8 +405,8 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
|
||||
$calculation = $service->calculateInvoice(12);
|
||||
|
||||
$this->assertSame('0.00', $calculation['customer_credit']);
|
||||
$this->assertSame('5.00', $calculation['balance']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PARTIALLY_PAID, $calculation['status']);
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testIssuedInvoiceUsesPaidRefundsAsCashOutInsteadOfAdditionalCredit(): void
|
||||
@@ -428,4 +428,25 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testRefundedWithdrawalInvoiceHasNoBalanceDue(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 14,
|
||||
'invoice_number' => 'INV-2026-00014',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
'description' => 'Current year tuition invoice.',
|
||||
], 178.0, 178.0, 380.0, 0.0, 0.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(14);
|
||||
|
||||
$this->assertSame('380.00', $calculation['total_amount']);
|
||||
$this->assertSame('100.00', $calculation['discount_total']);
|
||||
$this->assertSame('178.00', $calculation['paid_amount']);
|
||||
$this->assertSame('178.00', $calculation['refund_paid_total']);
|
||||
$this->assertSame('0.00', $calculation['customer_credit']);
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,20 @@ class RefundEligibilityServiceTest extends CIUnitTestCase
|
||||
$this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes);
|
||||
}
|
||||
|
||||
public function testTuitionWithdrawalAvailableCreditSubtractsCompletedPayoutsAndReservations(): void
|
||||
{
|
||||
$service = new RefundEligibilityServiceHarness(20000, 2500, 1500);
|
||||
|
||||
$result = $service->calculateAvailableCredit(10, 20, 'tuition_withdrawal', 20);
|
||||
|
||||
$this->assertSame(20000, $result->sourceCreditCents);
|
||||
$this->assertSame(2500, $result->completedPayoutCents);
|
||||
$this->assertSame(1500, $result->reservedAmountCents);
|
||||
$this->assertSame(16000, $result->availableAmountCents);
|
||||
$this->assertContains('HAS_COMPLETED_PAYOUTS', $result->reasonCodes);
|
||||
$this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes);
|
||||
}
|
||||
|
||||
public function testValidateRejectsAmountAboveAvailableCredit(): void
|
||||
{
|
||||
$service = new RefundEligibilityServiceHarness(10000, 7000, 1000);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Models;
|
||||
|
||||
use App\Models\ManualPaymentModel;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class ManualPaymentModelMetadataTest extends CIUnitTestCase
|
||||
{
|
||||
public function testAllowedFieldsOnlyIncludeExistingColumns(): void
|
||||
{
|
||||
$model = new ManualPaymentModel();
|
||||
$fields = self::getPrivateProperty($model, 'allowedFields');
|
||||
$columns = self::getPrivateProperty($model, 'manualPaymentColumns');
|
||||
|
||||
if ($columns === []) {
|
||||
$this->assertSame([], $fields);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$this->assertContains($field, $columns);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Services;
|
||||
|
||||
use App\Services\FeeCalculationService;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class FeeCalculationServiceTest extends CIUnitTestCase
|
||||
{
|
||||
public function testRefundReversesLatestTuitionTierFirst(): void
|
||||
{
|
||||
$fees = $this->refundFeeStack(3, 2, 380.0, 280.0);
|
||||
|
||||
$this->assertSame([280.0], $fees);
|
||||
}
|
||||
|
||||
public function testRefundKeepsReversingAdditionalFeesBeforeFirstStudentFee(): void
|
||||
{
|
||||
$fees = $this->refundFeeStack(3, 0, 380.0, 280.0);
|
||||
|
||||
$this->assertSame([280.0, 280.0, 380.0], $fees);
|
||||
}
|
||||
|
||||
public function testSingleStudentWithdrawalRefundsFirstStudentFee(): void
|
||||
{
|
||||
$fees = $this->refundFeeStack(1, 0, 380.0, 280.0);
|
||||
|
||||
$this->assertSame([380.0], $fees);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int,float>
|
||||
*/
|
||||
private function refundFeeStack(
|
||||
int $originalStudentCount,
|
||||
int $remainingStudentCount,
|
||||
float $firstStudentFee,
|
||||
float $additionalStudentFee
|
||||
): array {
|
||||
$method = new \ReflectionMethod(FeeCalculationService::class, 'reverseTuitionRefundFeeStack');
|
||||
$method->setAccessible(true);
|
||||
|
||||
return $method->invoke(
|
||||
new FeeCalculationService(),
|
||||
$originalStudentCount,
|
||||
$remainingStudentCount,
|
||||
$firstStudentFee,
|
||||
$additionalStudentFee
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,10 @@ final class SchoolYearManagementServiceCalendarTest extends CIUnitTestCase
|
||||
$this->assertSame('2026-08-01', $calendar['registration_starts_on']);
|
||||
$this->assertSame('2026-10-05', $calendar['registration_ends_on']);
|
||||
$this->assertSame('2026-09-20', $calendar['first_day_of_school']);
|
||||
$this->assertSame('2026-09-20', $calendar['1st_day_of_school']);
|
||||
$this->assertSame('2026-09-13', $calendar['fall_makeup_exam_on']);
|
||||
$this->assertSame('2026-09-06', $calendar['orientation_day']);
|
||||
$this->assertSame('2027-05-23', $calendar['final_exam_day']);
|
||||
$this->assertSame('2027-06-06', $calendar['last_day_of_school']);
|
||||
$this->assertSame('2027-06-06', $calendar['last_school_day']);
|
||||
$this->assertSame('2027-01-17', $calendar['midterm_exam_day']);
|
||||
$this->assertSame('2027-01-24', $calendar['spring_semester_start']);
|
||||
$this->assertSame('2027-03-01', $calendar['installment_date']);
|
||||
|
||||
Reference in New Issue
Block a user