seedClosureData(); $this->actingAs(User::query()->findOrFail(1), 'api'); $response = $this->postJson('/api/v1/school-years/1/preview-close', [ 'new_school_year' => [ 'name' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-06-30', ], 'transfer_unpaid_balances' => true, ]); $response->assertOk() ->assertJsonPath('data.validation.can_close', true) ->assertJsonPath('data.promotion_preview.summary.promote', 1) ->assertJsonPath('data.parent_balances.summary.total_old_unpaid_balance', 200); $balanceRows = collect($response->json('data.parent_balances.rows')); $parentRow = $balanceRows->first(fn (array $row): bool => (int) $row['parent_id'] === 10); $this->assertCount(1, $balanceRows); $this->assertNotNull($parentRow); $this->assertSame(200.0, (float) $parentRow['amount_to_transfer']); $this->assertSame('promote', $response->json('data.promotion_preview.rows.0.promotion_action')); } public function test_preview_close_excludes_zero_invoice_balance_parent_even_when_parent_account_is_stale(): void { $this->seedClosureData(); $this->seedZeroBalanceParentWithStaleAccount(); $this->actingAs(User::query()->findOrFail(1), 'api'); $response = $this->postJson('/api/v1/school-years/1/preview-close', [ 'new_school_year' => [ 'name' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-06-30', ], 'transfer_unpaid_balances' => true, ]); $response->assertOk() ->assertJsonPath('data.validation.can_close', true) ->assertJsonPath('data.parent_balances.summary.total_old_unpaid_balance', 200) ->assertJsonPath('data.parent_balances.summary.net_balance_to_transfer', 200); $balanceRows = collect($response->json('data.parent_balances.rows')); $this->assertTrue( $balanceRows->contains(fn (array $row): bool => (int) $row['parent_id'] === 10 && (float) $row['amount_to_transfer'] === 200.0) ); $this->assertFalse( $balanceRows->contains(fn (array $row): bool => (int) $row['parent_id'] === 11), 'Parent with invoice balance 0 must not appear in closure balance preview, even when parent_accounts.current_balance is stale.' ); } public function test_close_school_year_does_not_transfer_zero_invoice_balance_parent_even_when_parent_account_is_stale(): void { $this->seedClosureData(); $this->seedZeroBalanceParentWithStaleAccount(); $this->actingAs(User::query()->findOrFail(1), 'api'); $this->postJson('/api/v1/school-years/1/close', [ 'new_school_year' => [ 'name' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-06-30', ], 'transfer_unpaid_balances' => true, 'confirmation' => 'CLOSE 2025-2026', ])->assertOk(); $this->assertDatabaseHas('parent_balance_transfers', [ 'parent_id' => 10, 'from_school_year' => '2025-2026', 'to_school_year' => '2026-2027', 'amount' => 200.00, 'status' => 'transferred', ]); $this->assertDatabaseMissing('parent_balance_transfers', [ 'parent_id' => 11, 'from_school_year' => '2025-2026', 'to_school_year' => '2026-2027', ]); $this->assertDatabaseMissing('invoices', [ 'parent_id' => 11, 'school_year' => '2026-2027', 'description' => 'Opening balance transferred from 2025-2026', ]); } public function test_parent_cannot_close_school_year(): void { $this->seedClosureData(); $this->actingAs(User::query()->findOrFail(10), 'api'); $this->postJson('/api/v1/school-years/1/close', [ 'new_school_year' => [ 'name' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-06-30', ], 'transfer_unpaid_balances' => true, 'confirmation' => 'CLOSE 2025-2026', ])->assertForbidden(); } public function test_school_year_closure_routes_have_explicit_permission_middleware(): void { $routes = collect(app('router')->getRoutes()->getRoutes()) ->filter(fn ($route) => str_starts_with($route->uri(), 'api/v1/school-years')); $missing = []; foreach ($routes as $route) { $uri = $route->uri(); $middleware = $route->gatherMiddleware(); $isContextRoute = in_array($uri, [ 'api/v1/school-years', 'api/v1/school-years/current', 'api/v1/school-years/options', ], true); $hasPermission = $isContextRoute || collect($middleware)->contains(fn (string $middleware): bool => str_starts_with($middleware, 'perm:')); $hasAuth = collect($middleware)->contains(fn (string $middleware): bool => str_contains($middleware, 'multi.auth') || str_contains($middleware, 'auth')); $hasAccountGuard = in_array('account.active', $middleware, true); if (! $hasPermission || ! $hasAuth || ! $hasAccountGuard) { $missing[] = implode('|', $route->methods()).' '.$route->uri(); } } $this->assertSame([], $missing, 'School-year routes missing auth/account/permission middleware: '.implode(', ', $missing)); } public function test_active_user_can_load_school_year_context_list_without_school_year_permission(): void { $this->seedClosureData(); $this->actingAs(User::query()->findOrFail(10), 'api'); $response = $this->getJson('/api/v1/school-years'); $response->assertOk() ->assertJsonPath('ok', true) ->assertJsonPath('current_school_year', '2025-2026'); $this->postJson('/api/v1/school-years', [ 'name' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-06-30', ])->assertForbidden(); } public function test_admin_can_create_draft_school_year_via_api(): void { $this->seedClosureData(); $this->actingAs(User::query()->findOrFail(1), 'api'); $response = $this->postJson('/api/v1/school-years', [ 'name' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-06-30', ]); $response->assertCreated() ->assertJsonPath('data.name', '2026-2027') ->assertJsonPath('data.status', 'draft'); $this->assertDatabaseHas('school_years', [ 'name' => '2026-2027', 'status' => 'draft', 'is_current' => 0, ]); } public function test_admin_can_update_draft_school_year_via_api(): void { $this->seedClosureData(); DB::table('school_years')->insert([ 'id' => 2, 'name' => '2027-2028', 'start_date' => '2027-09-01', 'end_date' => '2028-06-30', 'status' => 'draft', 'is_current' => 0, 'created_at' => now(), 'updated_at' => now(), ]); $this->actingAs(User::query()->findOrFail(1), 'api'); $response = $this->patchJson('/api/v1/school-years/2', [ 'name' => '2028-2029', 'start_date' => '2028-09-01', 'end_date' => '2029-06-30', ]); $response->assertOk() ->assertJsonPath('data.name', '2028-2029') ->assertJsonPath('data.start_date', '2028-09-01'); $this->assertDatabaseHas('school_years', [ 'id' => 2, 'name' => '2028-2029', 'start_date' => '2028-09-01 00:00:00', 'end_date' => '2029-06-30 00:00:00', ]); } public function test_close_school_year_creates_new_active_year_enrollment_and_balance_transfer(): void { $this->seedClosureData(); $this->actingAs(User::query()->findOrFail(1), 'api'); $response = $this->postJson('/api/v1/school-years/1/close', [ 'new_school_year' => [ 'name' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-06-30', ], 'transfer_unpaid_balances' => true, 'confirmation' => 'CLOSE 2025-2026', ]); $response->assertOk() ->assertJsonPath('data.closed_school_year.status', 'closed') ->assertJsonPath('data.new_school_year.status', 'active'); $this->assertDatabaseHas('school_years', [ 'name' => '2025-2026', 'status' => 'closed', 'is_current' => 0, ]); $this->assertDatabaseHas('school_years', [ 'name' => '2026-2027', 'status' => 'active', 'is_current' => 1, ]); $this->assertDatabaseHas('configuration', [ 'config_key' => 'school_year', 'config_value' => '2026-2027', ]); $this->assertDatabaseHas('enrollments', [ 'student_id' => 100, 'parent_id' => 10, 'school_year' => '2026-2027', 'enrollment_status' => 'enrolled', ]); $this->assertDatabaseHas('parent_balance_transfers', [ 'parent_id' => 10, 'from_school_year' => '2025-2026', 'to_school_year' => '2026-2027', 'amount' => 200.00, 'status' => 'transferred', ]); $this->assertDatabaseHas('invoices', [ 'parent_id' => 10, 'school_year' => '2026-2027', 'total_amount' => 200.00, 'balance' => 200.00, 'description' => 'Opening balance transferred from 2025-2026', ]); } public function test_closed_school_year_blocks_legacy_invoice_generation(): void { $this->seedClosureData(); $this->actingAs(User::query()->findOrFail(1), 'api'); $this->postJson('/api/v1/school-years/1/close', [ 'new_school_year' => [ 'name' => '2026-2027', 'start_date' => '2026-09-01', 'end_date' => '2027-06-30', ], 'transfer_unpaid_balances' => true, 'confirmation' => 'CLOSE 2025-2026', ])->assertOk(); $response = $this->postJson('/api/v1/finance/invoices/generate', [ 'parent_id' => 10, 'school_year' => '2025-2026', ]); $response->assertStatus(409) ->assertJsonPath('message', 'School year 2025-2026 is closed and read-only.'); } private function seedZeroBalanceParentWithStaleAccount(): void { DB::table('users')->insert([ 'id' => 11, 'school_id' => 1, 'firstname' => 'Zero', 'lastname' => 'Balance', 'cellphone' => '5555555556', 'email' => 'zero@example.com', 'address_street' => '123 Main', 'city' => 'City', 'state' => 'ST', 'zip' => '12345', 'accept_school_policy' => 1, 'is_verified' => 1, 'status' => 'Active', 'is_suspended' => 0, 'failed_attempts' => 0, 'password' => bcrypt('secret'), 'semester' => 'Fall', 'school_year' => '2025-2026', ]); DB::table('user_roles')->insert([ 'user_id' => 11, 'role_id' => 2, ]); $parentAccount = [ 'parent_id' => 11, 'school_year' => '2025-2026', 'opening_balance' => 999, 'current_balance' => 999, ]; if (Schema::hasColumn('parent_accounts', 'created_at')) { $parentAccount['created_at'] = now(); } if (Schema::hasColumn('parent_accounts', 'updated_at')) { $parentAccount['updated_at'] = now(); } DB::table('parent_accounts')->insert($parentAccount); DB::table('invoices')->insert([ 'id' => 501, 'parent_id' => 11, 'invoice_number' => 'INV-501', 'total_amount' => 500, 'balance' => 0, 'paid_amount' => 500, 'has_discount' => 0, 'issue_date' => '2026-05-10', 'due_date' => '2026-06-10', 'status' => 'overdue', 'description' => 'Paid invoice with stale overdue status', 'school_year' => '2025-2026', 'created_at' => now(), 'updated_at' => now(), 'updated_by' => 1, 'semester' => 'Fall', ]); } private function seedClosureData(): void { DB::table('configuration')->insert([ ['config_key' => 'school_year', 'config_value' => '2025-2026'], ['config_key' => 'semester', 'config_value' => 'Fall'], ]); DB::table('roles')->insert([ ['id' => 1, 'name' => 'administrator', 'slug' => 'administrator', 'is_active' => 1], ['id' => 2, 'name' => 'parent', 'slug' => 'parent', 'is_active' => 1], ]); DB::table('users')->insert([ [ 'id' => 1, 'school_id' => 1, 'firstname' => 'Admin', 'lastname' => 'User', 'cellphone' => '5555555555', 'email' => 'admin@example.com', 'address_street' => '123 Main', 'city' => 'City', 'state' => 'ST', 'zip' => '12345', 'accept_school_policy' => 1, 'is_verified' => 1, 'status' => 'Active', 'is_suspended' => 0, 'failed_attempts' => 0, 'password' => bcrypt('secret'), 'semester' => 'Fall', 'school_year' => '2025-2026', ], [ 'id' => 10, 'school_id' => 1, 'firstname' => 'Parent', 'lastname' => 'User', 'cellphone' => '5555555555', 'email' => 'parent@example.com', 'address_street' => '123 Main', 'city' => 'City', 'state' => 'ST', 'zip' => '12345', 'accept_school_policy' => 1, 'is_verified' => 1, 'status' => 'Active', 'is_suspended' => 0, 'failed_attempts' => 0, 'password' => bcrypt('secret'), 'semester' => 'Fall', 'school_year' => '2025-2026', ], ]); DB::table('user_roles')->insert([ ['user_id' => 1, 'role_id' => 1], ['user_id' => 10, 'role_id' => 2], ]); $permissions = [ 'school_year.view', 'school_year.create', 'school_year.manage', 'school_year.close', 'school_year.reopen', 'school_year.archive', 'school_year.select', 'student_enrollment.promote', 'finance.balance_transfer.view', ]; foreach ($permissions as $permission) { DB::table('permissions')->updateOrInsert( ['name' => $permission], [ 'description' => 'Test permission '.$permission, 'created_at' => now(), 'updated_at' => now(), ] ); $permissionId = (int) DB::table('permissions')->where('name', $permission)->value('id'); DB::table('role_permissions')->insert([ 'role_id' => 1, 'permission_id' => $permissionId, 'can_create' => 1, 'can_read' => 1, 'can_update' => 1, 'can_delete' => 1, 'can_manage' => 1, 'created_at' => now(), 'updated_at' => now(), ]); } DB::table('school_years')->insert([ 'id' => 1, 'name' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-06-30', 'status' => 'active', 'is_current' => 1, 'created_at' => now(), 'updated_at' => now(), ]); DB::table('students')->insert([ 'id' => 100, 'parent_id' => 10, 'firstname' => 'Sara', 'lastname' => 'Student', 'school_id' => 'S-100', 'age' => 9, 'gender' => 'Female', 'is_active' => 1, 'photo_consent' => 1, 'year_of_registration' => '2025', 'school_year' => '2025-2026', 'semester' => 'Fall', ]); DB::table('classes')->insert([ ['id' => 3, 'class_name' => 'Grade 3', 'schedule' => 'Sunday 9:00', 'school_year' => '2025-2026', 'semester' => 'Fall', 'capacity' => 30], ['id' => 4, 'class_name' => 'Grade 4', 'schedule' => 'Sunday 10:00', 'school_year' => '2026-2027', 'semester' => 'Fall', 'capacity' => 30], ]); DB::table('classSection')->insert([ [ 'class_section_id' => 301, 'class_section_name' => '3A', 'class_id' => 3, 'semester' => 'Fall', 'school_year' => '2025-2026', ], [ 'class_section_id' => 401, 'class_section_name' => '4A', 'class_id' => 4, 'semester' => 'Fall', 'school_year' => '2026-2027', ], ]); DB::table('student_class')->insert([ 'student_id' => 100, 'class_section_id' => 301, 'school_year' => '2025-2026', 'semester' => 'Fall', 'is_event_only' => 0, ]); DB::table('enrollments')->insert([ 'student_id' => 100, 'class_section_id' => 301, 'parent_id' => 10, 'enrollment_date' => '2025-09-05', 'enrollment_status' => 'enrolled', 'withdrawal_date' => null, 'is_withdrawn' => 0, 'admission_status' => 'accepted', 'semester' => 'Fall', 'school_year' => '2025-2026', 'created_at' => now(), 'updated_at' => now(), ]); DB::table('student_decisions')->insert([ 'student_id' => 100, 'school_year' => '2025-2026', 'class_section_name' => '3A', 'year_score' => 85, 'decision' => 'promoted', 'source' => 'manual', 'notes' => 'Ready for promotion', 'created_at' => now(), 'updated_at' => now(), ]); DB::table('invoices')->insert([ 'id' => 500, 'parent_id' => 10, 'invoice_number' => 'INV-500', 'total_amount' => 500, 'balance' => 200, 'paid_amount' => 300, 'has_discount' => 0, 'issue_date' => '2026-05-10', 'due_date' => '2026-06-10', 'status' => 'unpaid', 'description' => 'Tuition balance', 'school_year' => '2025-2026', 'created_at' => now(), 'updated_at' => now(), 'updated_by' => 1, 'semester' => 'Fall', ]); } }