add school year and security fix
API CI/CD / Validate (composer + pint) (push) Successful in 3m14s
API CI/CD / Test (PHPUnit) (push) Failing after 3m28s
API CI/CD / Build frontend assets (push) Successful in 1m2s
API CI/CD / Security audit (push) Failing after 56s
API CI/CD / Deploy to shared hosting (PHP) (push) Has been skipped

This commit is contained in:
root
2026-07-06 00:55:01 -04:00
parent 99782c2a3c
commit 39228168c8
40 changed files with 20370 additions and 70 deletions
BIN
View File
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class EnsureAccountActive
{
public function handle(Request $request, Closure $next): Response
{
/** @var User|null $user */
$user = Auth::user() ?: $request->user();
if (! $user) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
$status = strtolower(trim((string) ($user->status ?? '')));
$isVerified = filter_var($user->is_verified ?? false, FILTER_VALIDATE_BOOLEAN);
$isSuspended = filter_var($user->is_suspended ?? false, FILTER_VALIDATE_BOOLEAN);
if (! $isVerified || $status !== 'active' || $isSuspended) {
return response()->json([
'message' => 'Account unavailable.',
], 403);
}
return $next($request);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\SchoolYears;
use App\Services\Auth\PermissionCheckService;
use Illuminate\Foundation\Http\FormRequest;
class ArchiveSchoolYearRequest extends FormRequest
{
public function authorize(): bool
{
$userId = (int) ($this->user()?->id ?? 0);
return app(PermissionCheckService::class)->hasPermission($userId, 'school_year.archive');
}
public function rules(): array
{
return [
'reason' => ['nullable', 'string', 'max:1000'],
];
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests\SchoolYears;
use App\Services\Auth\PermissionCheckService;
use Illuminate\Foundation\Http\FormRequest;
class ReopenSchoolYearRequest extends FormRequest
{
public function authorize(): bool
{
$userId = (int) ($this->user()?->id ?? 0);
return app(PermissionCheckService::class)->hasPermission($userId, 'school_year.reopen');
}
public function rules(): array
{
return [
'reason' => ['nullable', 'string', 'max:1000'],
];
}
}
BIN
View File
Binary file not shown.
@@ -628,51 +628,16 @@ class SchoolYearClosureService
private function buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear): array
{
$invoices = DB::table('invoices')
$invoiceBalances = DB::table('invoices')
->where('school_year', $fromSchoolYear)
->whereNotIn(DB::raw('LOWER(COALESCE(status, ""))'), ['void', 'voided', 'cancelled', 'canceled'])
->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled'])
->select('parent_id')
->selectRaw('ROUND(SUM(COALESCE(balance, 0)), 2) as net_balance')
->selectRaw('COUNT(CASE WHEN COALESCE(balance, 0) > 0.01 THEN 1 END) as unpaid_invoice_count')
->groupBy('parent_id')
->havingRaw('ABS(SUM(COALESCE(balance, 0))) > 0.01')
->get();
$byParent = [];
foreach ($invoices as $invoice) {
$parentId = (int) ($invoice->parent_id ?? 0);
if ($parentId <= 0) {
continue;
}
$balance = $invoice->balance !== null
? (float) $invoice->balance
: (float) ($invoice->total_amount ?? 0) - (float) ($invoice->paid_amount ?? 0);
if (! isset($byParent[$parentId])) {
$existing = ParentBalanceTransfer::query()
->where('parent_id', $parentId)
->where('from_school_year', $fromSchoolYear)
->where('to_school_year', $toSchoolYear)
->first();
$byParent[$parentId] = [
'parent_id' => $parentId,
'parent_name' => $this->resolveParentName($parentId),
'student_names' => $this->resolveStudentNames($parentId, $fromSchoolYear),
'unpaid_invoice_count' => 0,
'old_unpaid_balance' => 0.0,
'credit_balance' => 0.0,
'net_balance' => 0.0,
'amount_to_transfer' => 0.0,
'transfer_status' => $existing?->status,
'existing_transfer_id' => $existing?->id,
'source_invoice_ids' => [],
];
}
$byParent[$parentId]['source_invoice_ids'][] = (int) $invoice->id;
$byParent[$parentId]['net_balance'] += $balance;
if ($balance > 0) {
$byParent[$parentId]['unpaid_invoice_count']++;
}
}
$rows = [];
$summary = [
'parents_with_non_zero_balance' => 0,
@@ -683,16 +648,46 @@ class SchoolYearClosureService
'net_balance_to_transfer' => 0.0,
];
foreach ($byParent as $row) {
$net = round((float) $row['net_balance'], 2);
foreach ($invoiceBalances as $balanceRow) {
$parentId = (int) ($balanceRow->parent_id ?? 0);
if ($parentId <= 0) {
continue;
}
$net = round((float) ($balanceRow->net_balance ?? 0), 2);
if (abs($net) < 0.01) {
continue;
}
$row['old_unpaid_balance'] = $net > 0 ? $net : 0.0;
$row['credit_balance'] = $net < 0 ? abs($net) : 0.0;
$row['amount_to_transfer'] = $net;
$row['transfer_status'] = $row['transfer_status'] ?? ($net > 0 ? 'pending' : 'credit_pending');
$existing = ParentBalanceTransfer::query()
->where('parent_id', $parentId)
->where('from_school_year', $fromSchoolYear)
->where('to_school_year', $toSchoolYear)
->first();
$sourceInvoiceIds = DB::table('invoices')
->where('parent_id', $parentId)
->where('school_year', $fromSchoolYear)
->whereRaw('ABS(COALESCE(balance, 0)) > 0.01')
->whereNotIn(DB::raw("LOWER(COALESCE(status, ''))"), ['void', 'voided', 'cancelled', 'canceled'])
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
$row = [
'parent_id' => $parentId,
'parent_name' => $this->resolveParentName($parentId),
'student_names' => $this->resolveStudentNames($parentId, $fromSchoolYear),
'unpaid_invoice_count' => (int) ($balanceRow->unpaid_invoice_count ?? 0),
'old_unpaid_balance' => $net > 0 ? $net : 0.0,
'credit_balance' => $net < 0 ? abs($net) : 0.0,
'net_balance' => $net,
'amount_to_transfer' => $net,
'transfer_status' => $existing?->status ?? ($net > 0 ? 'pending' : 'credit_pending'),
'existing_transfer_id' => $existing?->id,
'source_invoice_ids' => $sourceInvoiceIds,
];
$rows[] = $row;
$summary['parents_with_non_zero_balance']++;
+5
View File
@@ -3,6 +3,7 @@
use App\Http\Middleware\ApiDocsAuth;
use App\Http\Middleware\ApiJwtAuth;
use App\Http\Middleware\CleanupScheduler;
use App\Http\Middleware\EnsureAccountActive;
use App\Http\Middleware\EnsureBadgeScanLogsAccess;
use App\Http\Middleware\EnsureClassProgressTeacherPortalAccess;
use App\Http\Middleware\EnsureParentProgressAccess;
@@ -33,6 +34,7 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void {
$middleware->append(SecurityHeaders::class);
$middleware->alias([
// JWT auth
'jwt.auth' => Authenticate::class,
@@ -43,6 +45,9 @@ return Application::configure(basePath: dirname(__DIR__))
'auth.multi' => MultiAuth::class,
'multi.auth' => MultiAuth::class,
// Account status
'account.active' => EnsureAccountActive::class,
// Permission / role gates
'perm' => RequirePermission::class,
'require.permission' => RequirePermission::class,
+12
View File
@@ -41,4 +41,16 @@
0 => 'Termwind\\Laravel\\TermwindServiceProvider',
),
),
'php-open-source-saver/jwt-auth' =>
array (
'aliases' =>
array (
'JWTAuth' => 'PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTAuth',
'JWTFactory' => 'PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTFactory',
),
'providers' =>
array (
0 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider',
),
),
);
+8 -6
View File
@@ -30,9 +30,10 @@
26 => 'Carbon\\Laravel\\ServiceProvider',
27 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
28 => 'Termwind\\Laravel\\TermwindServiceProvider',
29 => 'App\\Providers\\AppServiceProvider',
30 => 'App\\Providers\\EventServiceProvider',
31 => 'Laravel\\Sanctum\\SanctumServiceProvider',
29 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider',
30 => 'App\\Providers\\AppServiceProvider',
31 => 'App\\Providers\\EventServiceProvider',
32 => 'Laravel\\Sanctum\\SanctumServiceProvider',
),
'eager' =>
array (
@@ -51,9 +52,10 @@
12 => 'Carbon\\Laravel\\ServiceProvider',
13 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
14 => 'Termwind\\Laravel\\TermwindServiceProvider',
15 => 'App\\Providers\\AppServiceProvider',
16 => 'App\\Providers\\EventServiceProvider',
17 => 'Laravel\\Sanctum\\SanctumServiceProvider',
15 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider',
16 => 'App\\Providers\\AppServiceProvider',
17 => 'App\\Providers\\EventServiceProvider',
18 => 'Laravel\\Sanctum\\SanctumServiceProvider',
),
'deferred' =>
array (
+2 -1
View File
@@ -10,7 +10,8 @@
"chillerlan/php-qrcode": "^5.0",
"dompdf/dompdf": "^3.1",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.3"
"laravel/sanctum": "^4.3",
"php-open-source-saver/jwt-auth": "^2.9"
},
"require-dev": {
"fakerphp/faker": "^1.23",
Generated
+302 -1
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "bdb0090286bd5459f0600321c75dfaf7",
"content-hash": "030fdeff24a9b7f70514420d59d17283",
"packages": [
{
"name": "brick/math",
@@ -1780,6 +1780,79 @@
},
"time": "2026-04-16T14:03:50+00:00"
},
{
"name": "lcobucci/jwt",
"version": "5.6.0",
"source": {
"type": "git",
"url": "https://github.com/lcobucci/jwt.git",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-sodium": "*",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/clock": "^1.0"
},
"require-dev": {
"infection/infection": "^0.29",
"lcobucci/clock": "^3.2",
"lcobucci/coding-standard": "^11.0",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.10.7",
"phpstan/phpstan-deprecation-rules": "^1.1.3",
"phpstan/phpstan-phpunit": "^1.3.10",
"phpstan/phpstan-strict-rules": "^1.5.0",
"phpunit/phpunit": "^11.1"
},
"suggest": {
"lcobucci/clock": ">= 3.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Lcobucci\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Luís Cobucci",
"email": "lcobucci@gmail.com",
"role": "Developer"
}
],
"description": "A simple library to work with JSON Web Token and JSON Web Signature",
"keywords": [
"JWS",
"jwt"
],
"support": {
"issues": "https://github.com/lcobucci/jwt/issues",
"source": "https://github.com/lcobucci/jwt/tree/5.6.0"
},
"funding": [
{
"url": "https://github.com/lcobucci",
"type": "github"
},
{
"url": "https://www.patreon.com/lcobucci",
"type": "patreon"
}
],
"time": "2025-10-17T11:30:53+00:00"
},
{
"name": "league/commonmark",
"version": "2.8.2",
@@ -2509,6 +2582,73 @@
],
"time": "2026-01-02T08:56:05+00:00"
},
{
"name": "namshi/jose",
"version": "7.2.3",
"source": {
"type": "git",
"url": "https://github.com/namshi/jose.git",
"reference": "89a24d7eb3040e285dd5925fcad992378b82bcff"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/namshi/jose/zipball/89a24d7eb3040e285dd5925fcad992378b82bcff",
"reference": "89a24d7eb3040e285dd5925fcad992378b82bcff",
"shasum": ""
},
"require": {
"ext-date": "*",
"ext-hash": "*",
"ext-json": "*",
"ext-pcre": "*",
"ext-spl": "*",
"php": ">=5.5",
"symfony/polyfill-php56": "^1.0"
},
"require-dev": {
"phpseclib/phpseclib": "^2.0",
"phpunit/phpunit": "^4.5|^5.0",
"satooshi/php-coveralls": "^1.0"
},
"suggest": {
"ext-openssl": "Allows to use OpenSSL as crypto engine.",
"phpseclib/phpseclib": "Allows to use Phpseclib as crypto engine, use version ^2.0."
},
"type": "library",
"autoload": {
"psr-4": {
"Namshi\\JOSE\\": "src/Namshi/JOSE/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Nadalin",
"email": "alessandro.nadalin@gmail.com"
},
{
"name": "Alessandro Cinelli (cirpo)",
"email": "alessandro.cinelli@gmail.com"
}
],
"description": "JSON Object Signing and Encryption library for PHP.",
"keywords": [
"JSON Web Signature",
"JSON Web Token",
"JWS",
"json",
"jwt",
"token"
],
"support": {
"issues": "https://github.com/namshi/jose/issues",
"source": "https://github.com/namshi/jose/tree/master"
},
"time": "2016-12-05T07:27:31+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.13.0",
@@ -2859,6 +2999,99 @@
],
"time": "2026-02-16T23:10:27+00:00"
},
{
"name": "php-open-source-saver/jwt-auth",
"version": "2.9.2",
"source": {
"type": "git",
"url": "https://github.com/PHP-Open-Source-Saver/jwt-auth.git",
"reference": "ce08363a9986e5253efd3663ed4f75c976bec89a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHP-Open-Source-Saver/jwt-auth/zipball/ce08363a9986e5253efd3663ed4f75c976bec89a",
"reference": "ce08363a9986e5253efd3663ed4f75c976bec89a",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/auth": "^12|^13",
"illuminate/contracts": "^12|^13",
"illuminate/http": "^12|^13",
"illuminate/support": "^12|^13",
"lcobucci/jwt": "^5.4",
"namshi/jose": "^7.0",
"nesbot/carbon": "^2.0|^3.0",
"php": "^8.3"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3",
"illuminate/console": "^12|^13",
"illuminate/routing": "^12|^13",
"mockery/mockery": "^1.6",
"orchestra/testbench": "^10|^11",
"phpstan/phpstan": "^2",
"phpunit/phpunit": "^10.5|^11"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"JWTAuth": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTAuth",
"JWTFactory": "PHPOpenSourceSaver\\JWTAuth\\Facades\\JWTFactory"
},
"providers": [
"PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"PHPOpenSourceSaver\\JWTAuth\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sean Tymon",
"email": "tymon148@gmail.com",
"homepage": "https://tymon.xyz",
"role": "Forked package creator | Developer"
},
{
"name": "Eric Schricker",
"email": "eric.schricker@adiutabyte.de",
"role": "Developer"
},
{
"name": "Fabio William Conceição",
"email": "messhias@gmail.com",
"role": "Developer"
},
{
"name": "Max Snow",
"email": "contact@maxsnow.me",
"role": "Developer"
}
],
"description": "JSON Web Token Authentication for Laravel and Lumen",
"homepage": "https://github.com/PHP-Open-Source-Saver/jwt-auth",
"keywords": [
"Authentication",
"JSON Web Token",
"auth",
"jwt",
"laravel"
],
"support": {
"issues": "https://github.com/PHP-Open-Source-Saver/jwt-auth/issues",
"source": "https://github.com/PHP-Open-Source-Saver/jwt-auth"
},
"time": "2026-05-07T16:44:01+00:00"
},
{
"name": "phpoption/phpoption",
"version": "1.9.5",
@@ -5051,6 +5284,74 @@
],
"time": "2026-05-27T06:59:30+00:00"
},
{
"name": "symfony/polyfill-php56",
"version": "v1.20.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php56.git",
"reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php56/zipball/54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675",
"reference": "54b8cd7e6c1643d78d011f3be89f3ef1f9f4c675",
"shasum": ""
},
"require": {
"php": ">=7.1"
},
"type": "metapackage",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
},
"branch-alias": {
"dev-main": "1.20-dev"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill backporting some PHP 5.6+ features to lower PHP versions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php56/tree/v1.20.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2020-10-23T14:02:19+00:00"
},
{
"name": "symfony/polyfill-php80",
"version": "v1.37.0",
BIN
View File
Binary file not shown.
@@ -0,0 +1,162 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('school_years')) {
Schema::create('school_years', function (Blueprint $table): void {
$table->id();
$table->string('name', 50)->unique();
$table->date('start_date');
$table->date('end_date');
$table->string('status', 20)->default('draft')->index();
$table->boolean('is_current')->default(false)->index();
$table->timestamp('closed_at')->nullable();
$table->unsignedBigInteger('closed_by')->nullable();
$table->timestamp('reopened_at')->nullable();
$table->unsignedBigInteger('reopened_by')->nullable();
$table->timestamp('archived_at')->nullable();
$table->unsignedBigInteger('archived_by')->nullable();
$table->timestamps();
});
} else {
Schema::table('school_years', function (Blueprint $table): void {
if (! Schema::hasColumn('school_years', 'reopened_at')) {
$table->timestamp('reopened_at')->nullable()->after('closed_by');
}
if (! Schema::hasColumn('school_years', 'reopened_by')) {
$table->unsignedBigInteger('reopened_by')->nullable()->after('reopened_at');
}
if (! Schema::hasColumn('school_years', 'archived_at')) {
$table->timestamp('archived_at')->nullable()->after('reopened_by');
}
if (! Schema::hasColumn('school_years', 'archived_by')) {
$table->unsignedBigInteger('archived_by')->nullable()->after('archived_at');
}
});
}
if (! Schema::hasTable('student_enrollments')) {
Schema::create('student_enrollments', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('student_id');
$table->unsignedBigInteger('school_year_id');
$table->unsignedBigInteger('grade_id');
$table->unsignedBigInteger('class_id')->nullable();
$table->string('status', 30)->default('enrolled')->index();
$table->unsignedBigInteger('promoted_from_enrollment_id')->nullable();
$table->timestamps();
$table->unique(['student_id', 'school_year_id'], 'uniq_student_year');
$table->index('school_year_id', 'idx_enrollment_school_year');
$table->index('student_id', 'idx_enrollment_student');
});
}
if (! Schema::hasTable('parent_accounts')) {
Schema::create('parent_accounts', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('parent_id');
$table->string('school_year', 50)->nullable();
$table->unsignedBigInteger('school_year_id')->nullable();
$table->decimal('opening_balance', 12, 2)->default(0);
$table->decimal('current_balance', 12, 2)->default(0);
$table->timestamps();
$table->unique(['parent_id', 'school_year'], 'uniq_parent_school_year_name');
$table->index('parent_id', 'idx_parent_account_parent');
$table->index('school_year_id', 'idx_parent_account_school_year_id');
});
} else {
Schema::table('parent_accounts', function (Blueprint $table): void {
if (! Schema::hasColumn('parent_accounts', 'school_year_id')) {
$table->unsignedBigInteger('school_year_id')->nullable()->after('school_year');
}
});
}
if (! Schema::hasTable('parent_balance_transfers')) {
Schema::create('parent_balance_transfers', function (Blueprint $table): void {
$table->id();
$table->unsignedBigInteger('parent_id');
$table->string('from_school_year', 50)->nullable();
$table->string('to_school_year', 50)->nullable();
$table->unsignedBigInteger('from_school_year_id')->nullable();
$table->unsignedBigInteger('to_school_year_id')->nullable();
$table->decimal('amount', 12, 2);
$table->string('status', 30)->default('transferred');
$table->json('source_summary_json')->nullable();
$table->json('source_summary')->nullable();
$table->unsignedBigInteger('new_invoice_id')->nullable();
$table->unsignedBigInteger('old_balance_invoice_id')->nullable();
$table->timestamp('reversed_at')->nullable();
$table->unsignedBigInteger('reversed_by')->nullable();
$table->text('reversal_reason')->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->timestamps();
$table->unique(['parent_id', 'from_school_year', 'to_school_year'], 'uniq_parent_year_transfer_name');
$table->index('parent_id', 'idx_balance_transfer_parent');
$table->index('from_school_year_id', 'idx_balance_transfer_from_year_id');
$table->index('to_school_year_id', 'idx_balance_transfer_to_year_id');
});
} else {
Schema::table('parent_balance_transfers', function (Blueprint $table): void {
if (! Schema::hasColumn('parent_balance_transfers', 'from_school_year_id')) {
$table->unsignedBigInteger('from_school_year_id')->nullable()->after('to_school_year');
}
if (! Schema::hasColumn('parent_balance_transfers', 'to_school_year_id')) {
$table->unsignedBigInteger('to_school_year_id')->nullable()->after('from_school_year_id');
}
if (! Schema::hasColumn('parent_balance_transfers', 'source_summary')) {
$table->json('source_summary')->nullable()->after('source_summary_json');
}
if (! Schema::hasColumn('parent_balance_transfers', 'old_balance_invoice_id')) {
$table->unsignedBigInteger('old_balance_invoice_id')->nullable()->after('new_invoice_id');
}
if (! Schema::hasColumn('parent_balance_transfers', 'reversed_at')) {
$table->timestamp('reversed_at')->nullable();
}
if (! Schema::hasColumn('parent_balance_transfers', 'reversed_by')) {
$table->unsignedBigInteger('reversed_by')->nullable();
}
if (! Schema::hasColumn('parent_balance_transfers', 'reversal_reason')) {
$table->text('reversal_reason')->nullable();
}
});
}
if (Schema::hasTable('invoices')) {
Schema::table('invoices', function (Blueprint $table): void {
if (! Schema::hasColumn('invoices', 'school_year_id')) {
$table->unsignedBigInteger('school_year_id')->nullable()->after('school_year');
}
if (! Schema::hasColumn('invoices', 'parent_id')) {
$table->unsignedBigInteger('parent_id')->nullable();
}
if (! Schema::hasColumn('invoices', 'student_id')) {
$table->unsignedBigInteger('student_id')->nullable()->after('parent_id');
}
if (! Schema::hasColumn('invoices', 'invoice_type')) {
$table->string('invoice_type', 50)->nullable()->after('invoice_number');
}
if (! Schema::hasColumn('invoices', 'balance_transfer_id')) {
$table->unsignedBigInteger('balance_transfer_id')->nullable()->after('invoice_type');
}
if (! Schema::hasColumn('invoices', 'locked_at')) {
$table->timestamp('locked_at')->nullable();
}
if (! Schema::hasColumn('invoices', 'locked_by')) {
$table->unsignedBigInteger('locked_by')->nullable();
}
});
}
}
public function down(): void
{
Schema::dropIfExists('student_enrollments');
}
};
@@ -0,0 +1,151 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/** @var list<string> */
private array $permissions = [
'school_year.view',
'school_year.view_closed',
'school_year.create',
'school_year.close',
'school_year.reopen',
'school_year.archive',
'school_year.manage',
'school_year.select',
'student_enrollment.view',
'student_enrollment.manage',
'student_enrollment.promote',
'student_enrollment.transition',
'finance.view',
'finance.manage',
'finance.invoice.manage',
'finance.payment.manage',
'finance.balance_transfer.view',
'finance.balance_transfer.run',
'finance.balance_transfer.reverse',
'finance.report.view',
'reports.view',
'reports.export',
'audit.view',
];
public function up(): void
{
if (! Schema::hasTable('permissions')) {
return;
}
foreach ($this->permissions as $permission) {
DB::table('permissions')->updateOrInsert(
['name' => $permission],
$this->filterColumns('permissions', [
'description' => $this->describe($permission),
'created_at' => now(),
'updated_at' => now(),
])
);
}
$this->grantByRoleNames(['super admin', 'super_admin', 'administrator', 'admin'], $this->permissions);
$this->grantByRoleNames(['director', 'principal'], [
'school_year.view',
'school_year.view_closed',
'school_year.select',
'school_year.close',
'student_enrollment.view',
'student_enrollment.promote',
'finance.balance_transfer.view',
'reports.view',
]);
$this->grantByRoleNames(['finance admin', 'finance_admin', 'finance'], [
'school_year.view',
'school_year.select',
'finance.view',
'finance.manage',
'finance.invoice.manage',
'finance.payment.manage',
'finance.balance_transfer.view',
'finance.balance_transfer.run',
'finance.balance_transfer.reverse',
'finance.report.view',
'reports.view',
'reports.export',
]);
$this->grantByRoleNames(['teacher'], [
'school_year.view',
'school_year.view_closed',
'school_year.select',
'student_enrollment.view',
]);
$this->grantByRoleNames(['parent'], [
'school_year.view',
'school_year.select',
'finance.view',
]);
}
public function down(): void
{
if (! Schema::hasTable('permissions')) {
return;
}
DB::table('permissions')->whereIn('name', $this->permissions)->delete();
}
private function grantByRoleNames(array $roleNames, array $permissions): void
{
if (! Schema::hasTable('roles') || ! Schema::hasTable('role_permissions')) {
return;
}
$roleIds = DB::table('roles')
->whereIn(DB::raw('LOWER(name)'), array_map('strtolower', $roleNames))
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
if ($roleIds === []) {
return;
}
$permissionRows = DB::table('permissions')
->whereIn('name', $permissions)
->pluck('id', 'name');
foreach ($roleIds as $roleId) {
foreach ($permissionRows as $permissionId) {
DB::table('role_permissions')->updateOrInsert(
['role_id' => $roleId, 'permission_id' => (int) $permissionId],
$this->filterColumns('role_permissions', [
'can_create' => true,
'can_read' => true,
'can_update' => true,
'can_delete' => true,
'can_manage' => true,
'created_at' => now(),
'updated_at' => now(),
])
);
}
}
}
private function describe(string $permission): string
{
return 'Allows '.$permission.' for the school-year closure workflow.';
}
private function filterColumns(string $table, array $values): array
{
return array_filter(
$values,
static fn (string $column): bool => Schema::hasColumn($table, $column),
ARRAY_FILTER_USE_KEY
);
}
};
View File
View File
+14 -13
View File
@@ -480,19 +480,20 @@ Route::prefix('v1')->group(function () {
});
});
Route::middleware(['multi.auth', 'admin.access'])->prefix('school-years')->group(function () {
Route::get('/', [SchoolYearController::class, 'index']);
Route::get('current', [SchoolYearController::class, 'current']);
Route::get('options', [SchoolYearController::class, 'options']);
Route::post('/', [SchoolYearController::class, 'store']);
Route::patch('{schoolYear}', [SchoolYearController::class, 'update'])->whereNumber('schoolYear');
Route::get('{schoolYear}/summary', [SchoolYearController::class, 'summary'])->whereNumber('schoolYear');
Route::post('{schoolYear}/validate-close', [SchoolYearController::class, 'validateClose'])->whereNumber('schoolYear');
Route::post('{schoolYear}/preview-close', [SchoolYearController::class, 'previewClose'])->whereNumber('schoolYear');
Route::post('{schoolYear}/close', [SchoolYearController::class, 'close'])->whereNumber('schoolYear');
Route::post('{schoolYear}/reopen', [SchoolYearController::class, 'reopen'])->whereNumber('schoolYear');
Route::get('{schoolYear}/parent-balances', [SchoolYearController::class, 'parentBalances'])->whereNumber('schoolYear');
Route::get('{schoolYear}/promotion-preview', [SchoolYearController::class, 'promotionPreview'])->whereNumber('schoolYear');
Route::middleware(['multi.auth', 'account.active'])->prefix('school-years')->group(function () {
Route::get('/', [SchoolYearController::class, 'index'])->middleware('perm:school_year.view');
Route::get('current', [SchoolYearController::class, 'current'])->middleware('perm:school_year.select');
Route::get('options', [SchoolYearController::class, 'options'])->middleware('perm:school_year.select');
Route::post('/', [SchoolYearController::class, 'store'])->middleware('perm:school_year.create');
Route::patch('{schoolYear}', [SchoolYearController::class, 'update'])->middleware('perm:school_year.manage')->whereNumber('schoolYear');
Route::get('{schoolYear}/summary', [SchoolYearController::class, 'summary'])->middleware('perm:school_year.view')->whereNumber('schoolYear');
Route::post('{schoolYear}/validate-close', [SchoolYearController::class, 'validateClose'])->middleware('perm:school_year.close')->whereNumber('schoolYear');
Route::post('{schoolYear}/preview-close', [SchoolYearController::class, 'previewClose'])->middleware('perm:school_year.close')->whereNumber('schoolYear');
Route::post('{schoolYear}/close', [SchoolYearController::class, 'close'])->middleware('perm:school_year.close')->whereNumber('schoolYear');
Route::post('{schoolYear}/reopen', [SchoolYearController::class, 'reopen'])->middleware('perm:school_year.reopen')->whereNumber('schoolYear');
Route::post('{schoolYear}/archive', [SchoolYearController::class, 'archive'])->middleware('perm:school_year.archive')->whereNumber('schoolYear');
Route::get('{schoolYear}/parent-balances', [SchoolYearController::class, 'parentBalances'])->middleware('perm:finance.balance_transfer.view')->whereNumber('schoolYear');
Route::get('{schoolYear}/promotion-preview', [SchoolYearController::class, 'promotionPreview'])->middleware('perm:student_enrollment.promote')->whereNumber('schoolYear');
});
Route::middleware(['multi.auth', 'school_year.editable'])->prefix('attendance')->group(function () {
BIN
View File
Binary file not shown.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+78
View File
@@ -6,6 +6,8 @@ use App\Models\Configuration;
use App\Models\Role;
use App\Models\SchoolYear;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Laravel\Sanctum\Sanctum;
trait CreatesApiTestUsers
@@ -64,6 +66,7 @@ trait CreatesApiTestUsers
{
$roles = $this->seedApiRoles();
$this->seedApiTestConfig();
$this->seedSchoolYearClosurePermissionsForTests($roles);
$user = User::factory()->create(array_merge([
'status' => 'Active',
@@ -91,6 +94,81 @@ trait CreatesApiTestUsers
return $user;
}
/**
* Keep test fixtures aligned with explicit permission middleware.
* Production receives these through the closure permission migration.
*
* @param array<string, Role> $roles
*/
protected function seedSchoolYearClosurePermissionsForTests(array $roles): void
{
if (! Schema::hasTable('permissions') || ! Schema::hasTable('role_permissions')) {
return;
}
$permissions = [
'school_year.view',
'school_year.view_closed',
'school_year.create',
'school_year.close',
'school_year.reopen',
'school_year.archive',
'school_year.manage',
'school_year.select',
'student_enrollment.view',
'student_enrollment.promote',
'finance.view',
'finance.balance_transfer.view',
'reports.view',
];
foreach ($permissions as $permission) {
DB::table('permissions')->updateOrInsert(
['name' => $permission],
['description' => 'Test permission '.$permission, 'created_at' => now(), 'updated_at' => now()]
);
}
$adminRoleIds = collect([$roles['administrator'] ?? null, $roles['admin'] ?? null])->filter()->map(fn (Role $role) => (int) $role->id)->all();
$teacherRoleId = isset($roles['teacher']) ? (int) $roles['teacher']->id : null;
$parentRoleId = isset($roles['parent']) ? (int) $roles['parent']->id : null;
$this->grantTestPermissions($adminRoleIds, $permissions);
if ($teacherRoleId) {
$this->grantTestPermissions([$teacherRoleId], ['school_year.view', 'school_year.view_closed', 'school_year.select', 'student_enrollment.view']);
}
if ($parentRoleId) {
$this->grantTestPermissions([$parentRoleId], ['school_year.view', 'school_year.select', 'finance.view']);
}
}
/** @param list<int> $roleIds */
private function grantTestPermissions(array $roleIds, array $permissions): void
{
foreach ($roleIds as $roleId) {
foreach ($permissions as $permission) {
$permissionId = (int) DB::table('permissions')->where('name', $permission)->value('id');
if ($permissionId <= 0) {
continue;
}
DB::table('role_permissions')->updateOrInsert(
['role_id' => $roleId, 'permission_id' => $permissionId],
[
'can_create' => true,
'can_read' => true,
'can_update' => true,
'can_delete' => true,
'can_manage' => true,
'created_at' => now(),
'updated_at' => now(),
]
);
}
}
}
/**
* @return array<string, mixed>
*/
BIN
View File
Binary file not shown.
@@ -5,6 +5,7 @@ namespace Tests\Feature\Api\V1\SchoolYears;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class SchoolYearControllerTest extends TestCase
@@ -31,10 +32,122 @@ class SchoolYearControllerTest extends TestCase
->assertJsonPath('data.promotion_preview.summary.promote', 1)
->assertJsonPath('data.parent_balances.summary.total_old_unpaid_balance', 200);
$this->assertSame(200.0, (float) $response->json('data.parent_balances.rows.0.amount_to_transfer'));
$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) {
$middleware = $route->gatherMiddleware();
$hasPermission = 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_admin_can_create_draft_school_year_via_api(): void
{
$this->seedClosureData();
@@ -178,6 +291,68 @@ class SchoolYearControllerTest extends TestCase
->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([
@@ -238,6 +413,38 @@ class SchoolYearControllerTest extends TestCase
['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) {
$permissionId = DB::table('permissions')->insertGetId([
'name' => $permission,
'description' => 'Test permission '.$permission,
'created_at' => now(),
'updated_at' => now(),
]);
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',