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
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:
Vendored
BIN
Binary file not shown.
Vendored
BIN
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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
@@ -628,51 +628,16 @@ class SchoolYearClosureService
|
|||||||
|
|
||||||
private function buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear): array
|
private function buildParentBalanceRows(string $fromSchoolYear, string $toSchoolYear): array
|
||||||
{
|
{
|
||||||
$invoices = DB::table('invoices')
|
$invoiceBalances = DB::table('invoices')
|
||||||
->where('school_year', $fromSchoolYear)
|
->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();
|
->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 = [];
|
$rows = [];
|
||||||
$summary = [
|
$summary = [
|
||||||
'parents_with_non_zero_balance' => 0,
|
'parents_with_non_zero_balance' => 0,
|
||||||
@@ -683,16 +648,46 @@ class SchoolYearClosureService
|
|||||||
'net_balance_to_transfer' => 0.0,
|
'net_balance_to_transfer' => 0.0,
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($byParent as $row) {
|
foreach ($invoiceBalances as $balanceRow) {
|
||||||
$net = round((float) $row['net_balance'], 2);
|
$parentId = (int) ($balanceRow->parent_id ?? 0);
|
||||||
|
if ($parentId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$net = round((float) ($balanceRow->net_balance ?? 0), 2);
|
||||||
if (abs($net) < 0.01) {
|
if (abs($net) < 0.01) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$row['old_unpaid_balance'] = $net > 0 ? $net : 0.0;
|
$existing = ParentBalanceTransfer::query()
|
||||||
$row['credit_balance'] = $net < 0 ? abs($net) : 0.0;
|
->where('parent_id', $parentId)
|
||||||
$row['amount_to_transfer'] = $net;
|
->where('from_school_year', $fromSchoolYear)
|
||||||
$row['transfer_status'] = $row['transfer_status'] ?? ($net > 0 ? 'pending' : 'credit_pending');
|
->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;
|
$rows[] = $row;
|
||||||
|
|
||||||
$summary['parents_with_non_zero_balance']++;
|
$summary['parents_with_non_zero_balance']++;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use App\Http\Middleware\ApiDocsAuth;
|
use App\Http\Middleware\ApiDocsAuth;
|
||||||
use App\Http\Middleware\ApiJwtAuth;
|
use App\Http\Middleware\ApiJwtAuth;
|
||||||
use App\Http\Middleware\CleanupScheduler;
|
use App\Http\Middleware\CleanupScheduler;
|
||||||
|
use App\Http\Middleware\EnsureAccountActive;
|
||||||
use App\Http\Middleware\EnsureBadgeScanLogsAccess;
|
use App\Http\Middleware\EnsureBadgeScanLogsAccess;
|
||||||
use App\Http\Middleware\EnsureClassProgressTeacherPortalAccess;
|
use App\Http\Middleware\EnsureClassProgressTeacherPortalAccess;
|
||||||
use App\Http\Middleware\EnsureParentProgressAccess;
|
use App\Http\Middleware\EnsureParentProgressAccess;
|
||||||
@@ -32,6 +33,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->append(SecurityHeaders::class);
|
$middleware->append(SecurityHeaders::class);
|
||||||
|
|
||||||
|
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
// JWT auth
|
// JWT auth
|
||||||
@@ -43,6 +45,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'auth.multi' => MultiAuth::class,
|
'auth.multi' => MultiAuth::class,
|
||||||
'multi.auth' => MultiAuth::class,
|
'multi.auth' => MultiAuth::class,
|
||||||
|
|
||||||
|
// Account status
|
||||||
|
'account.active' => EnsureAccountActive::class,
|
||||||
|
|
||||||
// Permission / role gates
|
// Permission / role gates
|
||||||
'perm' => RequirePermission::class,
|
'perm' => RequirePermission::class,
|
||||||
'require.permission' => RequirePermission::class,
|
'require.permission' => RequirePermission::class,
|
||||||
|
|||||||
Vendored
+12
@@ -41,4 +41,16 @@
|
|||||||
0 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
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',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
Vendored
+8
-6
@@ -30,9 +30,10 @@
|
|||||||
26 => 'Carbon\\Laravel\\ServiceProvider',
|
26 => 'Carbon\\Laravel\\ServiceProvider',
|
||||||
27 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
27 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||||
28 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
28 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||||
29 => 'App\\Providers\\AppServiceProvider',
|
29 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider',
|
||||||
30 => 'App\\Providers\\EventServiceProvider',
|
30 => 'App\\Providers\\AppServiceProvider',
|
||||||
31 => 'Laravel\\Sanctum\\SanctumServiceProvider',
|
31 => 'App\\Providers\\EventServiceProvider',
|
||||||
|
32 => 'Laravel\\Sanctum\\SanctumServiceProvider',
|
||||||
),
|
),
|
||||||
'eager' =>
|
'eager' =>
|
||||||
array (
|
array (
|
||||||
@@ -51,9 +52,10 @@
|
|||||||
12 => 'Carbon\\Laravel\\ServiceProvider',
|
12 => 'Carbon\\Laravel\\ServiceProvider',
|
||||||
13 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
13 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||||
14 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
14 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||||
15 => 'App\\Providers\\AppServiceProvider',
|
15 => 'PHPOpenSourceSaver\\JWTAuth\\Providers\\LaravelServiceProvider',
|
||||||
16 => 'App\\Providers\\EventServiceProvider',
|
16 => 'App\\Providers\\AppServiceProvider',
|
||||||
17 => 'Laravel\\Sanctum\\SanctumServiceProvider',
|
17 => 'App\\Providers\\EventServiceProvider',
|
||||||
|
18 => 'Laravel\\Sanctum\\SanctumServiceProvider',
|
||||||
),
|
),
|
||||||
'deferred' =>
|
'deferred' =>
|
||||||
array (
|
array (
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"chillerlan/php-qrcode": "^5.0",
|
"chillerlan/php-qrcode": "^5.0",
|
||||||
"dompdf/dompdf": "^3.1",
|
"dompdf/dompdf": "^3.1",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "^4.3"
|
"laravel/sanctum": "^4.3",
|
||||||
|
"php-open-source-saver/jwt-auth": "^2.9"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"fakerphp/faker": "^1.23",
|
||||||
|
|||||||
Generated
+302
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "bdb0090286bd5459f0600321c75dfaf7",
|
"content-hash": "030fdeff24a9b7f70514420d59d17283",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@@ -1780,6 +1780,79 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-04-16T14:03:50+00:00"
|
"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",
|
"name": "league/commonmark",
|
||||||
"version": "2.8.2",
|
"version": "2.8.2",
|
||||||
@@ -2509,6 +2582,73 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-01-02T08:56:05+00:00"
|
"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",
|
"name": "nesbot/carbon",
|
||||||
"version": "3.13.0",
|
"version": "3.13.0",
|
||||||
@@ -2859,6 +2999,99 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-02-16T23:10:27+00:00"
|
"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",
|
"name": "phpoption/phpoption",
|
||||||
"version": "1.9.5",
|
"version": "1.9.5",
|
||||||
@@ -5051,6 +5284,74 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-05-27T06:59:30+00:00"
|
"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",
|
"name": "symfony/polyfill-php80",
|
||||||
"version": "v1.37.0",
|
"version": "v1.37.0",
|
||||||
|
|||||||
Vendored
BIN
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
+14
-13
@@ -480,19 +480,20 @@ Route::prefix('v1')->group(function () {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['multi.auth', 'admin.access'])->prefix('school-years')->group(function () {
|
Route::middleware(['multi.auth', 'account.active'])->prefix('school-years')->group(function () {
|
||||||
Route::get('/', [SchoolYearController::class, 'index']);
|
Route::get('/', [SchoolYearController::class, 'index'])->middleware('perm:school_year.view');
|
||||||
Route::get('current', [SchoolYearController::class, 'current']);
|
Route::get('current', [SchoolYearController::class, 'current'])->middleware('perm:school_year.select');
|
||||||
Route::get('options', [SchoolYearController::class, 'options']);
|
Route::get('options', [SchoolYearController::class, 'options'])->middleware('perm:school_year.select');
|
||||||
Route::post('/', [SchoolYearController::class, 'store']);
|
Route::post('/', [SchoolYearController::class, 'store'])->middleware('perm:school_year.create');
|
||||||
Route::patch('{schoolYear}', [SchoolYearController::class, 'update'])->whereNumber('schoolYear');
|
Route::patch('{schoolYear}', [SchoolYearController::class, 'update'])->middleware('perm:school_year.manage')->whereNumber('schoolYear');
|
||||||
Route::get('{schoolYear}/summary', [SchoolYearController::class, 'summary'])->whereNumber('schoolYear');
|
Route::get('{schoolYear}/summary', [SchoolYearController::class, 'summary'])->middleware('perm:school_year.view')->whereNumber('schoolYear');
|
||||||
Route::post('{schoolYear}/validate-close', [SchoolYearController::class, 'validateClose'])->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'])->whereNumber('schoolYear');
|
Route::post('{schoolYear}/preview-close', [SchoolYearController::class, 'previewClose'])->middleware('perm:school_year.close')->whereNumber('schoolYear');
|
||||||
Route::post('{schoolYear}/close', [SchoolYearController::class, 'close'])->whereNumber('schoolYear');
|
Route::post('{schoolYear}/close', [SchoolYearController::class, 'close'])->middleware('perm:school_year.close')->whereNumber('schoolYear');
|
||||||
Route::post('{schoolYear}/reopen', [SchoolYearController::class, 'reopen'])->whereNumber('schoolYear');
|
Route::post('{schoolYear}/reopen', [SchoolYearController::class, 'reopen'])->middleware('perm:school_year.reopen')->whereNumber('schoolYear');
|
||||||
Route::get('{schoolYear}/parent-balances', [SchoolYearController::class, 'parentBalances'])->whereNumber('schoolYear');
|
Route::post('{schoolYear}/archive', [SchoolYearController::class, 'archive'])->middleware('perm:school_year.archive')->whereNumber('schoolYear');
|
||||||
Route::get('{schoolYear}/promotion-preview', [SchoolYearController::class, 'promotionPreview'])->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 () {
|
Route::middleware(['multi.auth', 'school_year.editable'])->prefix('attendance')->group(function () {
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -6,6 +6,8 @@ use App\Models\Configuration;
|
|||||||
use App\Models\Role;
|
use App\Models\Role;
|
||||||
use App\Models\SchoolYear;
|
use App\Models\SchoolYear;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
|
||||||
trait CreatesApiTestUsers
|
trait CreatesApiTestUsers
|
||||||
@@ -64,6 +66,7 @@ trait CreatesApiTestUsers
|
|||||||
{
|
{
|
||||||
$roles = $this->seedApiRoles();
|
$roles = $this->seedApiRoles();
|
||||||
$this->seedApiTestConfig();
|
$this->seedApiTestConfig();
|
||||||
|
$this->seedSchoolYearClosurePermissionsForTests($roles);
|
||||||
|
|
||||||
$user = User::factory()->create(array_merge([
|
$user = User::factory()->create(array_merge([
|
||||||
'status' => 'Active',
|
'status' => 'Active',
|
||||||
@@ -91,6 +94,81 @@ trait CreatesApiTestUsers
|
|||||||
return $user;
|
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>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
@@ -5,6 +5,7 @@ namespace Tests\Feature\Api\V1\SchoolYears;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class SchoolYearControllerTest extends TestCase
|
class SchoolYearControllerTest extends TestCase
|
||||||
@@ -31,10 +32,122 @@ class SchoolYearControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.promotion_preview.summary.promote', 1)
|
->assertJsonPath('data.promotion_preview.summary.promote', 1)
|
||||||
->assertJsonPath('data.parent_balances.summary.total_old_unpaid_balance', 200);
|
->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'));
|
$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
|
public function test_admin_can_create_draft_school_year_via_api(): void
|
||||||
{
|
{
|
||||||
$this->seedClosureData();
|
$this->seedClosureData();
|
||||||
@@ -178,6 +291,68 @@ class SchoolYearControllerTest extends TestCase
|
|||||||
->assertJsonPath('message', 'School year 2025-2026 is closed and read-only.');
|
->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
|
private function seedClosureData(): void
|
||||||
{
|
{
|
||||||
DB::table('configuration')->insert([
|
DB::table('configuration')->insert([
|
||||||
@@ -238,6 +413,38 @@ class SchoolYearControllerTest extends TestCase
|
|||||||
['user_id' => 10, 'role_id' => 2],
|
['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([
|
DB::table('school_years')->insert([
|
||||||
'id' => 1,
|
'id' => 1,
|
||||||
'name' => '2025-2026',
|
'name' => '2025-2026',
|
||||||
|
|||||||
Reference in New Issue
Block a user