fix financial issues
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View {
|
||||
if (!function_exists(__NAMESPACE__ . '\view')) {
|
||||
function view($name, array $data = [], $options = [])
|
||||
{
|
||||
return ['view' => $name, 'data' => $data, 'options' => $options];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace Tests\App\Controllers\View {
|
||||
|
||||
use App\Controllers\View\PaymentController;
|
||||
use CodeIgniter\HTTP\RedirectResponse;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class PaymentRequestStub
|
||||
{
|
||||
public function __construct(
|
||||
private string $method = 'get',
|
||||
private array $post = [],
|
||||
private $file = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function getMethod(): string
|
||||
{
|
||||
return $this->method;
|
||||
}
|
||||
|
||||
public function getPost(?string $key = null)
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->post;
|
||||
}
|
||||
|
||||
return $this->post[$key] ?? null;
|
||||
}
|
||||
|
||||
public function getFile(string $name)
|
||||
{
|
||||
return $name === 'proof' ? $this->file : null;
|
||||
}
|
||||
}
|
||||
|
||||
class ManualPaymentModelSpy
|
||||
{
|
||||
public array $inserted = [];
|
||||
|
||||
public function insert(array $data)
|
||||
{
|
||||
$this->inserted[] = $data;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class AttachmentServiceStub
|
||||
{
|
||||
public function __construct(private ?string $path = null)
|
||||
{
|
||||
}
|
||||
|
||||
public function saveUploadedFile($file, string $subdir): ?string
|
||||
{
|
||||
return $this->path;
|
||||
}
|
||||
}
|
||||
|
||||
class TestablePaymentController extends PaymentController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function setRequestObject($request): self
|
||||
{
|
||||
$this->request = $request;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setManualPaymentModel($model): self
|
||||
{
|
||||
$this->manualPaymentModel = $model;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setAttachmentService($service): self
|
||||
{
|
||||
$this->financialAttachmentService = $service;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
class PaymentControllerRegressionTest extends CIUnitTestCase
|
||||
{
|
||||
public function testRedirectPageSendsUsersBackToInvoicePaymentWithInfoMessage(): void
|
||||
{
|
||||
$controller = new TestablePaymentController();
|
||||
|
||||
$response = $controller->redirectPage();
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertStringContainsString('parent/invoice_payment', $response->getHeaderLine('Location'));
|
||||
}
|
||||
|
||||
public function testManualGetReturnsManualPaymentView(): void
|
||||
{
|
||||
$controller = (new TestablePaymentController())
|
||||
->setRequestObject(new PaymentRequestStub('get'));
|
||||
|
||||
$result = $controller->manual();
|
||||
|
||||
$this->assertSame('payment/manual_payment', $result['view']);
|
||||
}
|
||||
|
||||
public function testManualPostStoresProofPathFromAttachmentService(): void
|
||||
{
|
||||
$model = new ManualPaymentModelSpy();
|
||||
$controller = (new TestablePaymentController())
|
||||
->setManualPaymentModel($model)
|
||||
->setAttachmentService(new AttachmentServiceStub('proofs/check-123.pdf'))
|
||||
->setRequestObject(new PaymentRequestStub('post', [
|
||||
'invoice_number' => 'INV-001',
|
||||
'amount' => '125.00',
|
||||
'payment_method' => 'check',
|
||||
'reference' => 'CHK123',
|
||||
], new \stdClass()));
|
||||
|
||||
$response = $controller->manual();
|
||||
|
||||
$this->assertInstanceOf(RedirectResponse::class, $response);
|
||||
$this->assertCount(1, $model->inserted);
|
||||
$this->assertSame('proofs/check-123.pdf', $model->inserted[0]['proof_path']);
|
||||
$this->assertSame('INV-001', $model->inserted[0]['invoice_number']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View {
|
||||
if (!function_exists(__NAMESPACE__ . '\view')) {
|
||||
function view($name, array $data = [], $options = [])
|
||||
{
|
||||
return ['view' => $name, 'data' => $data, 'options' => $options];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace Tests\App\Controllers\View {
|
||||
|
||||
use App\Controllers\View\TuitionForecastController;
|
||||
use App\Libraries\Tuition\TuitionForecastService;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class ForecastRequestStub
|
||||
{
|
||||
public function __construct(
|
||||
private array $get = [],
|
||||
private array $post = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function getGet(?string $key = null)
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->get;
|
||||
}
|
||||
|
||||
return $this->get[$key] ?? null;
|
||||
}
|
||||
|
||||
public function getPost(?string $key = null)
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->post;
|
||||
}
|
||||
|
||||
return $this->post[$key] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeForecastService extends TuitionForecastService
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function calculate(string $schoolYear = '', string $semester = '', string $mode = 'compare', array $options = []): array
|
||||
{
|
||||
$this->calls[] = compact('schoolYear', 'semester', 'mode', 'options');
|
||||
|
||||
return [
|
||||
'school_year' => $schoolYear !== '' ? $schoolYear : '2025-2026',
|
||||
'semester' => $semester,
|
||||
'calculator_mode' => $mode,
|
||||
'options' => [
|
||||
'include_withdrawn_mode' => $options['include_withdrawn_mode'] ?? 'refund_deadline',
|
||||
'include_payment_pending' => $options['include_payment_pending'] ?? '0',
|
||||
'include_event_only' => $options['include_event_only'] ?? '0',
|
||||
'include_paid_invoices' => $options['include_paid_invoices'] ?? '0',
|
||||
'unit_price' => $options['unit_price'] ?? '370.00',
|
||||
],
|
||||
'summary' => [
|
||||
'family_count' => 1,
|
||||
'student_count' => 2,
|
||||
'billable_student_count' => 2,
|
||||
'old_projected_tuition' => '550.00',
|
||||
'new_projected_tuition' => '650.00',
|
||||
'old_projected_income' => '550.00',
|
||||
'new_projected_income' => '650.00',
|
||||
'unit_price' => '370.00',
|
||||
'difference' => '100.00',
|
||||
],
|
||||
'families' => [[
|
||||
'parent_name' => 'Layla Yusuf',
|
||||
'student_count' => 2,
|
||||
'billable_student_count' => 2,
|
||||
'old_total' => '550.00',
|
||||
'new_total' => '650.00',
|
||||
'difference' => '100.00',
|
||||
'warnings' => ['warning'],
|
||||
'student_details' => [[
|
||||
'student_name' => 'Student One',
|
||||
'grade_level' => '1',
|
||||
'billable' => true,
|
||||
'excluded_reason' => '',
|
||||
'old_rule' => 'full',
|
||||
'old_amount' => '350.00',
|
||||
'new_rule' => 'full',
|
||||
'new_amount' => '370.00',
|
||||
]],
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
public function getAvailableSchoolYears(): array
|
||||
{
|
||||
return ['2025-2026'];
|
||||
}
|
||||
|
||||
public function getAvailableSemesters(): array
|
||||
{
|
||||
return ['Fall', 'Spring'];
|
||||
}
|
||||
}
|
||||
|
||||
class TestableTuitionForecastController extends TuitionForecastController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function setForecastService(TuitionForecastService $service): self
|
||||
{
|
||||
$this->forecastService = $service;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setRequestObject($request): self
|
||||
{
|
||||
$this->request = $request;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setResponseObject(ResponseInterface $response): self
|
||||
{
|
||||
$this->response = $response;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
class TuitionForecastControllerTest extends CIUnitTestCase
|
||||
{
|
||||
private TestableTuitionForecastController $controller;
|
||||
private FakeForecastService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new FakeForecastService();
|
||||
$this->controller = (new TestableTuitionForecastController())
|
||||
->setForecastService($this->service)
|
||||
->setResponseObject(service('response'));
|
||||
}
|
||||
|
||||
public function testIndexBuildsForecastViewDataFromQueryParameters(): void
|
||||
{
|
||||
$request = new ForecastRequestStub([
|
||||
'school_year' => '2026-2027',
|
||||
'semester' => 'Spring',
|
||||
'calculator_mode' => 'new',
|
||||
'include_withdrawn_mode' => 'always',
|
||||
'include_payment_pending' => '1',
|
||||
'include_event_only' => '1',
|
||||
'include_paid_invoices' => '1',
|
||||
'unit_price' => '395.00',
|
||||
]);
|
||||
|
||||
$result = $this->controller->setRequestObject($request)->index();
|
||||
|
||||
$this->assertSame('administrator/tuition_forecast', $result['view']);
|
||||
$this->assertSame('2026-2027', $result['data']['filters']['school_year']);
|
||||
$this->assertSame('Spring', $result['data']['filters']['semester']);
|
||||
$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('always', $this->service->calls[0]['options']['include_withdrawn_mode']);
|
||||
}
|
||||
|
||||
public function testCalculateReturnsJsonPayload(): void
|
||||
{
|
||||
$request = new ForecastRequestStub([], [
|
||||
'school_year' => '2025-2026',
|
||||
'semester' => 'Fall',
|
||||
'calculator_mode' => 'compare',
|
||||
'include_withdrawn_mode' => 'refund_deadline',
|
||||
'include_payment_pending' => '1',
|
||||
'include_event_only' => '0',
|
||||
'include_paid_invoices' => '1',
|
||||
'unit_price' => '390.00',
|
||||
]);
|
||||
|
||||
$response = $this->controller->setRequestObject($request)->calculate();
|
||||
$payload = json_decode((string) $response->getBody(), true);
|
||||
|
||||
$this->assertSame('2025-2026', $payload['school_year']);
|
||||
$this->assertSame('Fall', $payload['semester']);
|
||||
$this->assertSame('compare', $payload['calculator_mode']);
|
||||
$this->assertSame('390.00', $payload['options']['unit_price']);
|
||||
}
|
||||
|
||||
public function testExportCsvBuildsDownloadWithSummaryAndFamilyRows(): void
|
||||
{
|
||||
$request = new ForecastRequestStub([
|
||||
'school_year' => '2025-2026',
|
||||
'calculator_mode' => 'compare',
|
||||
]);
|
||||
|
||||
$response = $this->controller->setRequestObject($request)->exportCsv();
|
||||
$body = (string) $response->getBody();
|
||||
|
||||
$this->assertStringContainsString('text/csv', $response->getHeaderLine('Content-Type'));
|
||||
$this->assertStringContainsString('tuition_forecast_2025-2026_all-year_compare.csv', $response->getHeaderLine('Content-Disposition'));
|
||||
$this->assertStringContainsString('Summary', $body);
|
||||
$this->assertStringContainsString('All Year', $body);
|
||||
$this->assertStringContainsString('Old Projected Income', $body);
|
||||
$this->assertStringContainsString('Layla Yusuf', $body);
|
||||
$this->assertStringContainsString('Student One', $body);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Database\Migrations;
|
||||
|
||||
require_once APPPATH . 'Database/Migrations/2026-05-30-000001_FinancialSystemLedgerCleanup.php';
|
||||
|
||||
use App\Database\Migrations\FinancialSystemLedgerCleanup;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class TestableFinancialSystemLedgerCleanup extends FinancialSystemLedgerCleanup
|
||||
{
|
||||
public array $calls = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
protected function addInstallmentSequenceColumn(): void
|
||||
{
|
||||
$this->calls[] = 'addInstallmentSequenceColumn';
|
||||
}
|
||||
|
||||
protected function ensureIndexes(): void
|
||||
{
|
||||
$this->calls[] = 'ensureIndexes';
|
||||
}
|
||||
|
||||
protected function ensureConfigurationDefaults(): void
|
||||
{
|
||||
$this->calls[] = 'ensureConfigurationDefaults';
|
||||
}
|
||||
|
||||
protected function archivePaypalTables(): void
|
||||
{
|
||||
$this->calls[] = 'archivePaypalTables';
|
||||
}
|
||||
|
||||
protected function refreshFinancialNavItems(): void
|
||||
{
|
||||
$this->calls[] = 'refreshFinancialNavItems';
|
||||
}
|
||||
|
||||
protected function restorePaypalTables(): void
|
||||
{
|
||||
$this->calls[] = 'restorePaypalTables';
|
||||
}
|
||||
|
||||
protected function dropIndexIfExists(string $table, string $indexName): void
|
||||
{
|
||||
$this->calls[] = "dropIndexIfExists:$table:$indexName";
|
||||
}
|
||||
}
|
||||
|
||||
class FinancialSystemLedgerCleanupTest extends CIUnitTestCase
|
||||
{
|
||||
public function testUpRunsCleanupStepsInExpectedOrder(): void
|
||||
{
|
||||
$migration = new TestableFinancialSystemLedgerCleanup();
|
||||
|
||||
$migration->up();
|
||||
|
||||
$this->assertSame([
|
||||
'addInstallmentSequenceColumn',
|
||||
'ensureIndexes',
|
||||
'ensureConfigurationDefaults',
|
||||
'archivePaypalTables',
|
||||
'refreshFinancialNavItems',
|
||||
], $migration->calls);
|
||||
}
|
||||
|
||||
public function testDownRestoresPaypalAndDropsFinancialIndexes(): void
|
||||
{
|
||||
$migration = new TestableFinancialSystemLedgerCleanup();
|
||||
$fakeDb = new class () {
|
||||
public function tableExists(string $name): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function fieldExists(string $field, string $table): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
};
|
||||
$fakeForge = new class () {
|
||||
public array $dropped = [];
|
||||
|
||||
public function dropColumn(string $table, string $column): void
|
||||
{
|
||||
$this->dropped[] = [$table, $column];
|
||||
}
|
||||
};
|
||||
|
||||
self::setPrivateProperty($migration, 'db', $fakeDb);
|
||||
self::setPrivateProperty($migration, 'forge', $fakeForge);
|
||||
|
||||
$migration->down();
|
||||
|
||||
$this->assertSame('restorePaypalTables', $migration->calls[0]);
|
||||
$this->assertContains('dropIndexIfExists:payments:uniq_payments_transaction_id', $migration->calls);
|
||||
$this->assertContains('dropIndexIfExists:invoice_event:idx_invoice_event_invoice_id', $migration->calls);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Helpers;
|
||||
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class AttendanceCommentHelperTest extends CIUnitTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
helper('attendance_comment');
|
||||
}
|
||||
|
||||
public function testTemplateMatchReturnsDirectRangeHit(): void
|
||||
{
|
||||
$templates = [
|
||||
['min_score' => 60, 'max_score' => 69, 'template_text' => 'below average'],
|
||||
['min_score' => 70, 'max_score' => 79, 'template_text' => 'fair'],
|
||||
];
|
||||
|
||||
$match = attendance_comment_template_match($templates, 70.0);
|
||||
|
||||
$this->assertNotNull($match);
|
||||
$this->assertSame('fair', $match['template_text']);
|
||||
}
|
||||
|
||||
public function testTemplateMatchFallsBackToNearestLowerBandForDecimalGap(): void
|
||||
{
|
||||
$templates = [
|
||||
['min_score' => 60, 'max_score' => 69, 'template_text' => 'below average'],
|
||||
['min_score' => 70, 'max_score' => 79, 'template_text' => 'fair'],
|
||||
];
|
||||
|
||||
$match = attendance_comment_template_match($templates, 69.23);
|
||||
|
||||
$this->assertNotNull($match);
|
||||
$this->assertSame('below average', $match['template_text']);
|
||||
}
|
||||
|
||||
public function testCommentUsesResolvedTemplateForDecimalGap(): void
|
||||
{
|
||||
$templates = [
|
||||
['min_score' => 60, 'max_score' => 69, 'template_text' => 'attendance has been below average.'],
|
||||
['min_score' => 70, 'max_score' => 79, 'template_text' => 'attendance has been fair.'],
|
||||
];
|
||||
|
||||
$match = attendance_comment_template_match($templates, 69.23);
|
||||
|
||||
$this->assertNotNull($match);
|
||||
$this->assertSame('attendance has been below average.', $match['template_text']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Libraries;
|
||||
|
||||
use App\Libraries\FinancialAttachmentService;
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class FinancialAttachmentServiceTest extends CIUnitTestCase
|
||||
{
|
||||
private FinancialAttachmentService $service;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->service = new FinancialAttachmentService();
|
||||
}
|
||||
|
||||
public function testSaveUploadedFileReturnsNullForInvalidInput(): void
|
||||
{
|
||||
$this->assertNull($this->service->saveUploadedFile(null, 'payments'));
|
||||
|
||||
$file = $this->createMock(UploadedFile::class);
|
||||
$file->method('isValid')->willReturn(false);
|
||||
|
||||
$this->assertNull($this->service->saveUploadedFile($file, 'payments'));
|
||||
}
|
||||
|
||||
public function testSaveUploadedFileRejectsUnsupportedMimeType(): void
|
||||
{
|
||||
$file = $this->createMock(UploadedFile::class);
|
||||
$file->method('isValid')->willReturn(true);
|
||||
$file->method('hasMoved')->willReturn(false);
|
||||
$file->method('getSize')->willReturn(1000);
|
||||
$file->method('getMimeType')->willReturn('text/plain');
|
||||
$file->method('getClientExtension')->willReturn('txt');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Unsupported file type.');
|
||||
|
||||
$this->service->saveUploadedFile($file, 'payments');
|
||||
}
|
||||
|
||||
public function testSaveUploadedFileRejectsOversizedFiles(): void
|
||||
{
|
||||
$file = $this->createMock(UploadedFile::class);
|
||||
$file->method('isValid')->willReturn(true);
|
||||
$file->method('hasMoved')->willReturn(false);
|
||||
$file->method('getSize')->willReturn(5242881);
|
||||
$file->method('getMimeType')->willReturn('application/pdf');
|
||||
$file->method('getClientExtension')->willReturn('pdf');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('File too large.');
|
||||
|
||||
$this->service->saveUploadedFile($file, 'payments');
|
||||
}
|
||||
|
||||
public function testSaveUploadedFileMovesValidFilesAndReturnsRandomName(): void
|
||||
{
|
||||
$file = $this->createMock(UploadedFile::class);
|
||||
$file->method('isValid')->willReturn(true);
|
||||
$file->method('hasMoved')->willReturn(false);
|
||||
$file->method('getSize')->willReturn(1024);
|
||||
$file->method('getMimeType')->willReturn('application/pdf');
|
||||
$file->method('getClientExtension')->willReturn('pdf');
|
||||
$file->method('getRandomName')->willReturn('proof.pdf');
|
||||
|
||||
$file->expects($this->once())
|
||||
->method('move')
|
||||
->with($this->stringContains('writable/uploads/payments'), 'proof.pdf');
|
||||
|
||||
$this->assertSame('proof.pdf', $this->service->saveUploadedFile($file, 'payments'));
|
||||
}
|
||||
|
||||
public function testResolvePathUsesBasenameAndReturnsNullForMissingFiles(): void
|
||||
{
|
||||
$dir = $this->service->ensureSubdir('receipts');
|
||||
$path = $dir . DIRECTORY_SEPARATOR . 'receipt.pdf';
|
||||
file_put_contents($path, 'pdf');
|
||||
|
||||
$this->assertSame($path, $this->service->resolvePath('receipts', '../receipt.pdf'));
|
||||
$this->assertNull($this->service->resolvePath('receipts', 'missing.pdf'));
|
||||
}
|
||||
|
||||
public function testDetectMimeReturnsKnownMimeForExistingFile(): void
|
||||
{
|
||||
$dir = $this->service->ensureSubdir('mime-tests');
|
||||
$path = $dir . DIRECTORY_SEPARATOR . 'image.png';
|
||||
file_put_contents(
|
||||
$path,
|
||||
base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aap8AAAAASUVORK5CYII=')
|
||||
);
|
||||
|
||||
$mime = $this->service->detectMime($path);
|
||||
|
||||
$this->assertStringContainsString('image/', $mime);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Libraries;
|
||||
|
||||
use App\Libraries\Tuition\NewTuitionCalculatorService;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class NewTuitionCalculatorServiceTest extends CIUnitTestCase
|
||||
{
|
||||
public function testFamilyDiscountsApplyByStudentPosition(): void
|
||||
{
|
||||
$service = new NewTuitionCalculatorService();
|
||||
$result = $service->calculateFamilyTuition([
|
||||
['student_id' => 4, 'student_name' => 'Fourth', 'grade_level' => '4'],
|
||||
['student_id' => 1, 'student_name' => 'First', 'grade_level' => '1'],
|
||||
['student_id' => 3, 'student_name' => 'Third', 'grade_level' => '3'],
|
||||
['student_id' => 2, 'student_name' => 'Second', 'grade_level' => '2'],
|
||||
], [
|
||||
'grade_fee' => 9,
|
||||
'new_tuition_full_amount' => '350.00',
|
||||
'new_tuition_second_student_discount' => '50.00',
|
||||
'new_tuition_third_student_discount' => '50.00',
|
||||
'new_tuition_fourth_plus_discount' => '100.00',
|
||||
]);
|
||||
|
||||
$this->assertSame('1200.00', $result['total']);
|
||||
$this->assertSame('350.00', $result['details'][0]['amount']);
|
||||
$this->assertSame('300.00', $result['details'][1]['amount']);
|
||||
$this->assertSame('300.00', $result['details'][2]['amount']);
|
||||
$this->assertSame('250.00', $result['details'][3]['amount']);
|
||||
}
|
||||
|
||||
public function testDiscountCannotDriveAmountBelowZero(): void
|
||||
{
|
||||
$service = new NewTuitionCalculatorService();
|
||||
$result = $service->calculateFamilyTuition([
|
||||
['student_id' => 1, 'student_name' => 'One', 'grade_level' => '1'],
|
||||
['student_id' => 2, 'student_name' => 'Two', 'grade_level' => '2'],
|
||||
['student_id' => 3, 'student_name' => 'Three', 'grade_level' => '3'],
|
||||
['student_id' => 4, 'student_name' => 'Four', 'grade_level' => '4'],
|
||||
['student_id' => 5, 'student_name' => 'Five', 'grade_level' => '5'],
|
||||
], [
|
||||
'grade_fee' => 9,
|
||||
'new_tuition_full_amount' => '75.00',
|
||||
'new_tuition_second_student_discount' => '50.00',
|
||||
'new_tuition_third_student_discount' => '50.00',
|
||||
'new_tuition_fourth_plus_discount' => '100.00',
|
||||
]);
|
||||
|
||||
$this->assertSame('125.00', $result['total']);
|
||||
$this->assertSame('0.00', $result['details'][3]['amount']);
|
||||
$this->assertSame('0.00', $result['details'][4]['amount']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Libraries;
|
||||
|
||||
use App\Libraries\Tuition\OldTuitionCalculatorService;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class OldTuitionCalculatorServiceTest extends CIUnitTestCase
|
||||
{
|
||||
public function testRegularAndYouthStudentsUseLegacyRules(): void
|
||||
{
|
||||
$service = new OldTuitionCalculatorService();
|
||||
$result = $service->calculateFamilyTuition([
|
||||
['student_id' => 3, 'student_name' => 'Older Youth', 'grade_level' => 'Youth 1'],
|
||||
['student_id' => 1, 'student_name' => 'First Regular', 'grade_level' => '3'],
|
||||
['student_id' => 2, 'student_name' => 'Second Regular', 'grade_level' => '5'],
|
||||
], [
|
||||
'grade_fee' => 9,
|
||||
'first_student_fee' => '350.00',
|
||||
'second_student_fee' => '200.00',
|
||||
'youth_fee' => '180.00',
|
||||
]);
|
||||
|
||||
$this->assertSame('730.00', $result['total']);
|
||||
$this->assertSame('old_first_student_fee', $result['details'][0]['rule']);
|
||||
$this->assertSame('old_second_student_fee', $result['details'][1]['rule']);
|
||||
$this->assertSame('old_youth_fee', $result['details'][2]['rule']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Libraries;
|
||||
|
||||
use App\Libraries\Tuition\TuitionForecastService;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class TuitionForecastServiceTest extends CIUnitTestCase
|
||||
{
|
||||
public function testForecastSubtractsAlreadyCollectedAndKeepsEventOnlyStudentsNonBillable(): void
|
||||
{
|
||||
$service = new class () extends TuitionForecastService {
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function getDefaultSchoolYear(): string
|
||||
{
|
||||
return '2025-2026';
|
||||
}
|
||||
|
||||
protected function getDefaultSemester(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
protected function loadFamilies(string $schoolYear, string $semester): array
|
||||
{
|
||||
return [
|
||||
['parent_id' => 10, 'parent_name' => 'Yusuf, Layla'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function loadFamilyStudents(int $parentId, string $schoolYear, string $semester, array $options): array
|
||||
{
|
||||
return [
|
||||
'students' => [
|
||||
['student_id' => 1, 'student_name' => 'Mariam', 'grade_level' => '1'],
|
||||
['student_id' => 2, 'student_name' => 'Yahya', 'grade_level' => '2'],
|
||||
],
|
||||
'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' => 3, 'student_name' => 'Event Only', 'grade_level' => '3', 'billable' => false, 'excluded_reason' => 'event_only'],
|
||||
],
|
||||
'warnings' => ['1 event-only student(s) excluded from tuition.'],
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTuitionConfig(): array
|
||||
{
|
||||
return [
|
||||
'grade_fee' => 9,
|
||||
'first_student_fee' => '350.00',
|
||||
'second_student_fee' => '200.00',
|
||||
'youth_fee' => '180.00',
|
||||
'new_tuition_full_amount' => $this->unitPriceOverride ?? '350.00',
|
||||
'new_tuition_second_student_discount' => '50.00',
|
||||
'new_tuition_third_student_discount' => '50.00',
|
||||
'new_tuition_fourth_plus_discount' => '100.00',
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
$result = $service->calculate('2025-2026', 'Fall', 'compare', [
|
||||
'include_payment_pending' => true,
|
||||
'include_withdrawn_mode' => 'refund_deadline',
|
||||
'unit_price' => '400.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('400.00', $result['summary']['unit_price']);
|
||||
$this->assertSame('event_only', $result['families'][0]['student_details'][2]['excluded_reason']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Models;
|
||||
|
||||
use App\Models\PaymentModel;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class PaymentModelMetadataTest extends CIUnitTestCase
|
||||
{
|
||||
public function testAllowedFieldsIncludeInstallmentSequence(): void
|
||||
{
|
||||
$model = new PaymentModel();
|
||||
$fields = self::getPrivateProperty($model, 'allowedFields');
|
||||
|
||||
$this->assertContains('installment_seq', $fields);
|
||||
$this->assertContains('transaction_id', $fields);
|
||||
$this->assertContains('check_file', $fields);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Models;
|
||||
|
||||
use Tests\Support\DBReset;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
use App\Models\PaypalTransactionModel;
|
||||
|
||||
class PaypalTransactionModelTest extends CIUnitTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
DBReset::resetDatabase();
|
||||
}
|
||||
|
||||
public function testCanInsertAndRetrieve()
|
||||
{
|
||||
$record = fake(PaypalTransactionModel::class);
|
||||
$this->assertNotNull($record->id);
|
||||
$fetched = (new PaypalTransactionModel())->find($record->id);
|
||||
$this->assertEquals($record->id, $fetched->id);
|
||||
}
|
||||
|
||||
public function testCanUpdate()
|
||||
{
|
||||
$record = fake(PaypalTransactionModel::class);
|
||||
$model = new PaypalTransactionModel();
|
||||
$model->update($record->id, ['updated_at' => date('Y-m-d H:i:s')]);
|
||||
$this->assertTrue(true); // simple test to verify no exception thrown
|
||||
}
|
||||
|
||||
public function testCanDelete()
|
||||
{
|
||||
$record = fake(PaypalTransactionModel::class);
|
||||
$model = new PaypalTransactionModel();
|
||||
$model->delete($record->id);
|
||||
$this->assertNull($model->find($record->id));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user