diff --git a/FINANCE_PLAN_IMPLEMENTATION_NOTES.md b/FINANCE_PLAN_IMPLEMENTATION_NOTES.md new file mode 100644 index 00000000..6ab7e46f --- /dev/null +++ b/FINANCE_PLAN_IMPLEMENTATION_NOTES.md @@ -0,0 +1,46 @@ +# Finance Plan Implementation Notes + +This implementation keeps the existing financial endpoints and PayPal-related code intact, then adds the missing additive finance lifecycle pieces from the plan. + +## Added + +- Finance lifecycle migration: follow-up notes, balance carryforwards, installment plans/installments, payment-installment allocations, notification logs/preferences, receipt sequences, and carryforward payment allocations. +- Parent payment follow-up reporting with CSV export and write-only metadata actions for notes, contacted status, promise-to-pay, and resolution. +- Prior-year balance carryforward preview, draft creation, approval, posting to a new-year invoice, waiver, adjustment, report, and CSV export. +- Installment plan creation, activation, cancellation, overdue/due reporting, and payment allocation. +- Finance notification logging endpoints for receipts, statements, overdue reminders, installment reminders, and notification log search. +- Event charge lifecycle API endpoints for create/list/show/update/delete, approve, void, and attach-to-invoice. +- Finance config flags for legacy ledger mode, carryforward behavior, payment allocation policy, and tuition calculator version. + +## Safety posture + +- Existing finance routes were not removed. +- Existing PayPal models/controllers/routes were not removed. +- Existing invoice/payment math remains the source for read models. +- Carryforward posting creates a separate new-year invoice instead of mutating prior-year invoices. +- Follow-up notes do not modify invoice balances. +- Installment allocation writes to additive allocation tables and installment balances only. +- Notifications currently log database events instead of sending real external email/SMS, because inboxes are not test benches, despite humanity's tireless effort to prove otherwise. + +## Key files changed or added + +- `database/migrations/2026_06_04_230000_add_finance_lifecycle_tables.php` +- `config/finance.php` +- `app/Services/Finance/ParentPaymentFollowUpService.php` +- `app/Services/Finance/PriorYearBalanceCarryforwardService.php` +- `app/Services/Finance/InstallmentPlanService.php` +- `app/Services/Finance/FinanceNotificationLogService.php` +- `app/Http/Controllers/Api/Finance/BalanceCarryforwardController.php` +- `app/Http/Controllers/Api/Finance/InstallmentPlanController.php` +- `app/Http/Controllers/Api/Finance/FinanceNotificationController.php` +- `app/Http/Controllers/Api/Finance/EventChargeController.php` +- `app/Http/Controllers/Api/Finance/FinancialController.php` +- `routes/api.php` +- New finance request classes under `app/Http/Requests/Finance/` +- New finance lifecycle models under `app/Models/` + +## Not fully implemented by design + +- Centralized ledger replacement is still behind the future phase. That is intentional. Replacing accounting math without dry-runs is how systems become folklore. +- External mail/SMS sending is not activated. The implementation logs notification intent first. +- Parent-facing UI pages are not included because this package is the Laravel API. diff --git a/STRONG_GRADING_IMPLEMENTATION_NOTES.md b/STRONG_GRADING_IMPLEMENTATION_NOTES.md new file mode 100644 index 00000000..e20dc8a3 --- /dev/null +++ b/STRONG_GRADING_IMPLEMENTATION_NOTES.md @@ -0,0 +1,90 @@ +# Strong Grading Implementation Notes + +## Safety rule + +This patch is additive and backward-compatible. Existing historical semester scores remain displayed from stored `semester_scores` values and are labeled as legacy by metadata. The new strong-grading machinery is present, but strong-mode finalization only activates when `strong_grading_enabled` is enabled through configuration. + +## What changed + +### Legacy display protection + +`semester_scores` now supports: + +- `calculation_mode` +- `calculation_policy_version` +- `snapshot_id` + +Existing rows are backfilled as: + +- `calculation_mode = legacy` +- `calculation_policy_version = legacy_v1` + +No historical score is recalculated by this migration. + +### Score safety columns + +The score tables now support: + +- `max_points` +- `status` +- `excused_reason` +- `locked_at` +- `locked_by` + +Existing numeric score rows are marked `scored`. Existing blank rows are marked `pending`, not `missing`, so old data is not punished by the new policy. + +### Validation + +A central `ScoreValueValidator` rejects impossible scores before storage. The default max remains `100` to preserve old behavior until item-level max points are configured. + +### Attendance grace + +Attendance now names the one-absence grace policy explicitly instead of hiding it in `(total_days + 1 - absences) / total_days`. The result is intentionally equivalent. + +### Strong-mode lock validation + +`GradingLockService` now checks the policy mode before locking. In legacy mode, behavior remains compatible. In strong mode, pending scores and invalid score ranges block locking. + +### Snapshot infrastructure + +`semester_score_snapshots` and `SemesterScoreSnapshotService` were added. Strong-mode finalized scores can store calculation inputs and outputs for auditability. + +### Display resolver + +`GradeCalculationDisplayResolver` resolves whether a row should be shown as `Legacy Calculation` or `Strong Calculation`. + +## Configuration gates + +Strong mode is off by default. + +Suggested configuration values: + +```text +strong_grading_enabled = false +strong_grading_class_sections = * +``` + +When enabled: + +```text +strong_grading_enabled = true +strong_grading_class_sections = 12,15,20 +``` + +or all sections: + +```text +strong_grading_class_sections = * +``` + +## What is intentionally not done yet + +This patch does not globally replace the legacy PTAP formula. That would change grade math. The legacy formula remains the default until strong grading is intentionally activated for a class section. + +This patch does not silently recalculate historical records. That would be reckless, so naturally we avoided it. + +## Verification + +PHP syntax checks passed for the modified and new PHP files. + +PHPUnit could not run in this sandbox because the PHP runtime is missing required extensions: `dom`, `mbstring`, `xml`, and `xmlwriter`. diff --git a/app/Http/Controllers/Api/Administrator/AdministratorPromotionController.php b/app/Http/Controllers/Api/Administrator/AdministratorPromotionController.php index fd6f230a..320ae177 100644 --- a/app/Http/Controllers/Api/Administrator/AdministratorPromotionController.php +++ b/app/Http/Controllers/Api/Administrator/AdministratorPromotionController.php @@ -24,6 +24,9 @@ use App\Services\Promotions\PromotionEnrollmentService; use App\Services\Promotions\PromotionQueryService; use App\Services\Promotions\PromotionReminderService; use App\Services\Promotions\PromotionStatusService; +use App\Services\Promotions\Placement\SectionPlacementPreviewService; +use App\Models\SectionPlacementBatch; +use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -42,7 +45,8 @@ class AdministratorPromotionController extends BaseApiController private PromotionEnrollmentService $enrollmentService, private PromotionReminderService $reminders, private PromotionAuditService $audit, - private LevelProgressionService $progression + private LevelProgressionService $progression, + private SectionPlacementPreviewService $sectionPlacement ) { parent::__construct(); } @@ -133,6 +137,69 @@ class AdministratorPromotionController extends BaseApiController ]); } + + public function createPlacementPreview(Request $request): JsonResponse + { + $payload = $request->validate([ + 'from_school_year' => ['required', 'string', 'max:25'], + 'to_school_year' => ['required', 'string', 'max:25'], + 'from_grade_level_id' => ['nullable', 'integer', 'min:1'], + 'to_grade_level_id' => ['required', 'integer', 'min:1'], + ]); + + try { + $batch = $this->sectionPlacement->createDraft( + $payload['from_school_year'], + $payload['to_school_year'], + $payload['from_grade_level_id'] ?? null, + $payload['to_grade_level_id'] ?? null, + $this->getCurrentUserId() + ); + } catch (\Throwable $e) { + Log::error('Section placement preview generation failed', ['exception' => $e]); + return response()->json([ + 'ok' => false, + 'message' => $e->getMessage(), + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return response()->json([ + 'ok' => true, + 'data' => $this->sectionPlacement->previewPayload($batch), + ]); + } + + public function showPlacementBatch(int $batchId): JsonResponse + { + $batch = SectionPlacementBatch::query()->find($batchId); + if (!$batch) { + return response()->json(['ok' => false, 'message' => 'Placement batch not found.'], Response::HTTP_NOT_FOUND); + } + + return response()->json([ + 'ok' => true, + 'data' => $this->sectionPlacement->previewPayload($batch), + ]); + } + + public function finalizePlacementBatch(int $batchId): JsonResponse + { + try { + $batch = $this->sectionPlacement->finalize($batchId, $this->getCurrentUserId()); + } catch (\Throwable $e) { + Log::error('Section placement finalization failed', ['batch_id' => $batchId, 'exception' => $e]); + return response()->json([ + 'ok' => false, + 'message' => $e->getMessage(), + ], Response::HTTP_CONFLICT); + } + + return response()->json([ + 'ok' => true, + 'data' => $this->sectionPlacement->previewPayload($batch), + ]); + } + public function updateStatus(UpdatePromotionStatusRequest $request, int $promotionId): JsonResponse { $record = $this->findOrFail($promotionId); diff --git a/app/Http/Controllers/Api/Administrator/TrophyController.php b/app/Http/Controllers/Api/Administrator/TrophyController.php new file mode 100644 index 00000000..8ed0e5d1 --- /dev/null +++ b/app/Http/Controllers/Api/Administrator/TrophyController.php @@ -0,0 +1,55 @@ +validated(); + + return response()->json([ + 'ok' => true, + 'data' => $this->service->projection( + $payload['school_year'] ?? null, + isset($payload['percentile']) ? (float) $payload['percentile'] : null + ), + ]); + } + + public function winners(TrophyReportRequest $request): JsonResponse + { + $payload = $request->validated(); + + return response()->json([ + 'ok' => true, + 'data' => $this->service->winners( + $payload['school_year'] ?? null, + isset($payload['percentile']) ? (float) $payload['percentile'] : null + ), + ]); + } + + public function final(TrophyReportRequest $request): JsonResponse + { + $payload = $request->validated(); + + return response()->json([ + 'ok' => true, + 'data' => $this->service->final( + $payload['school_year'] ?? null, + isset($payload['percentile']) ? (float) $payload['percentile'] : null + ), + ]); + } +} diff --git a/app/Http/Controllers/Api/Certificates/CertificateController.php b/app/Http/Controllers/Api/Certificates/CertificateController.php index dc2f8458..479ab4cb 100644 --- a/app/Http/Controllers/Api/Certificates/CertificateController.php +++ b/app/Http/Controllers/Api/Certificates/CertificateController.php @@ -5,149 +5,141 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\Certificates; use App\Http\Controllers\Api\Core\BaseApiController; -use App\Models\Configuration; +use App\Services\Certificates\CertificateAdminService; use App\Services\Certificates\CertificatePdfService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class CertificateController extends BaseApiController { - public function __construct() - { + public function __construct( + private CertificateAdminService $service, + private CertificatePdfService $pdfService, + ) { parent::__construct(); } - /** - * GET /api/v1/administrator/certificates/form-options - */ - public function formOptions(Request $request): JsonResponse + public function dashboard(Request $request): JsonResponse { - $schoolYearParam = $request->query('school_year'); - if ($schoolYearParam !== null) { - $schoolYear = trim((string) $schoolYearParam); - } else { - try { - $schoolYear = trim((string) (Configuration::getConfig('school_year') ?? '')); - } catch (\Throwable) { - $schoolYear = ''; - } - } - $classSectionId = $request->query('class_section_id'); - try { - $classSections = DB::table('classSection') - ->select('class_section_id', 'class_section_name') - ->orderBy('class_section_name', 'ASC') - ->get() - ->map(fn ($r) => (array) $r) - ->all(); + return response()->json([ + 'ok' => true, + 'data' => $this->service->dashboard($request->query('school_year')), + ]); } catch (\Throwable $e) { - Log::error('Certificate formOptions class query failed: ' . $e->getMessage()); - return $this->error('Failed to load classes: ' . $e->getMessage(), 500); + Log::error('Certificate dashboard failed', ['exception' => $e]); + + return $this->error('Failed to load certificate dashboard.', 500); + } + } + + public function formOptions(Request $request): JsonResponse + { + return $this->dashboard($request); + } + + public function auditLog(Request $request): JsonResponse + { + try { + return response()->json([ + 'ok' => true, + 'data' => $this->service->auditLog($request->query('school_year')), + ]); + } catch (\Throwable $e) { + Log::error('Certificate audit log failed', ['exception' => $e]); + + return $this->error('Failed to load certificate audit log.', 500); } - - $students = []; - if ($classSectionId) { - try { - $studentQuery = DB::table('student_class as sc') - ->select('s.id', 's.firstname', 's.lastname', 'cs.class_section_name as grade') - ->join('students as s', 's.id', '=', 'sc.student_id') - ->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') - ->where('sc.class_section_id', (int) $classSectionId) - ->where('s.is_active', 1) - ->orderBy('s.lastname') - ->orderBy('s.firstname'); - - if ($schoolYear !== '') { - $studentQuery->where('sc.school_year', $schoolYear); - } - - $students = $studentQuery->get()->map(fn ($r) => (array) $r)->all(); - } catch (\Throwable $e) { - Log::error('Certificate formOptions student query failed: ' . $e->getMessage()); - return $this->error('Failed to load students: ' . $e->getMessage(), 500); - } - } - - return $this->success([ - 'class_sections' => $classSections, - 'students' => $students, - 'school_year' => $schoolYear, - 'selected_class_id' => $classSectionId, - ]); } - /** - * POST /api/v1/administrator/certificates/generate - */ public function generate(Request $request): Response|JsonResponse { - $studentIds = $request->input('student_ids', []); - $certDate = trim((string) ($request->input('cert_date') ?? date('m/d/Y'))); + $studentIds = $request->input('student_ids', []); + $certDate = trim((string) ($request->input('cert_date') ?? date('m/d/Y'))); $classSectionId = $request->input('class_section_id'); - - if (empty($studentIds) || !is_array($studentIds)) { - return $this->error('Please select at least one student.', 422); - } - - $studentIds = array_values(array_filter(array_map('intval', $studentIds))); - $certDate = preg_replace('/[^0-9\/\-]/', '', $certDate) ?? date('m/d/Y'); - - if (empty($studentIds)) { - return $this->error('Invalid student selection.', 422); - } - - $students = []; - foreach ($studentIds as $id) { - $row = null; - - if ($classSectionId) { - $row = DB::table('student_class as sc') - ->select('s.id', 's.firstname', 's.lastname', 'cs.class_section_name as grade') - ->join('students as s', 's.id', '=', 'sc.student_id') - ->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') - ->where('sc.class_section_id', (int) $classSectionId) - ->where('s.id', $id) - ->first(); - } - - if (!$row) { - $row = DB::table('students') - ->select('id', 'firstname', 'lastname', 'registration_grade as grade') - ->where('id', $id) - ->first(); - } - - if ($row) { - $students[] = (array) $row; - } - } - - if (empty($students)) { - return $this->error('No valid students found.', 422); - } + $schoolYear = $request->input('school_year'); try { - $pdfService = app(CertificatePdfService::class); - $pdfContent = $pdfService->generate($students, $certDate); + $payload = $this->service->issueCertificates( + is_array($studentIds) ? $studentIds : [], + $certDate, + $classSectionId !== null && $classSectionId !== '' ? (int) $classSectionId : null, + is_string($schoolYear) ? $schoolYear : null, + $this->getCurrentUserId() + ); + $pdfContent = $this->pdfService->generate( + $payload['students'], + $payload['cert_date_display'] + ); + } catch (\InvalidArgumentException $e) { + return $this->error($e->getMessage(), 422); + } catch (\RuntimeException $e) { + return $this->error($e->getMessage(), 422); } catch (\Throwable $e) { - Log::error('Certificate PDF generation failed: ' . $e->getMessage(), ['exception' => $e]); + Log::error('Certificate generation failed', ['exception' => $e]); + return $this->error('Failed to generate certificates.', 500); } - $filename = 'Certificates_' . date('Ymd_His') . '.pdf'; + $filename = 'Certificates_'.date('Ymd_His').'.pdf'; return response($pdfContent, 200, [ - 'Content-Type' => 'application/pdf', - 'Content-Disposition' => 'inline; filename="' . $filename . '"', + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$filename.'"', ]); } - private function currentSchoolYear(): string + public function reprint(string $certificateNumber): Response|JsonResponse { - return trim((string) (Configuration::getConfig('school_year') ?? '')); + try { + $payload = $this->service->reprintPayload($certificateNumber); + if ($payload === null) { + return $this->error('Certificate not found: '.$certificateNumber, 404); + } + + $pdfContent = $this->pdfService->generate( + [$payload['student']], + $payload['cert_date_display'] + ); + } catch (\Throwable $e) { + Log::error('Certificate reprint failed', [ + 'certificate_number' => $certificateNumber, + 'exception' => $e, + ]); + + return $this->error('Failed to reprint certificate.', 500); + } + + $filename = 'Certificate_'.strtoupper($certificateNumber).'.pdf'; + + return response($pdfContent, 200, [ + 'Content-Type' => 'application/pdf', + 'Content-Disposition' => 'inline; filename="'.$filename.'"', + ]); + } + + public function verify(string $token): JsonResponse + { + try { + $payload = $this->service->verifyPayload($token); + } catch (\Throwable $e) { + Log::error('Certificate verify failed', ['token' => $token, 'exception' => $e]); + + return $this->error('Failed to verify certificate.', 500); + } + + if ($payload === null) { + return response()->json([ + 'ok' => false, + 'message' => 'Certificate not found.', + ], 404); + } + + return response()->json([ + 'ok' => true, + 'data' => $payload, + ]); } } diff --git a/app/Http/Controllers/Api/Finance/BalanceCarryforwardController.php b/app/Http/Controllers/Api/Finance/BalanceCarryforwardController.php new file mode 100644 index 00000000..2a8af7d8 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/BalanceCarryforwardController.php @@ -0,0 +1,64 @@ +json(['ok' => true, 'preview' => $this->service->preview($request->validated())]); + } + + public function storeDraft(CarryforwardFinalizeRequest $request): JsonResponse + { + return response()->json(['ok' => true, 'result' => $this->service->storeDrafts($request->validated(), optional($request->user())->id)]); + } + + public function approve(Request $request, int $carryforward): JsonResponse + { + return response()->json(['ok' => true, 'carryforward' => $this->service->approve($carryforward, optional($request->user())->id)]); + } + + public function postToNewYear(Request $request, int $carryforward): JsonResponse + { + return response()->json(['ok' => true, 'carryforward' => $this->service->postToNewYear($carryforward, optional($request->user())->id)]); + } + + public function waive(CarryforwardAdjustmentRequest $request, int $carryforward): JsonResponse + { + $data = $request->validated(); + return response()->json(['ok' => true, 'carryforward' => $this->service->waive($carryforward, (float) $data['amount'], $data['reason'] ?? null, optional($request->user())->id)]); + } + + public function adjust(CarryforwardAdjustmentRequest $request, int $carryforward): JsonResponse + { + $data = $request->validated(); + return response()->json(['ok' => true, 'carryforward' => $this->service->adjust($carryforward, (float) $data['amount'], $data['reason'] ?? null)]); + } + + public function report(Request $request): JsonResponse + { + return response()->json(['ok' => true, 'report' => $this->service->report($request->query())]); + } + + public function reportCsv(Request $request): StreamedResponse + { + $rows = $this->service->csvRows($this->service->report($request->query())); + return response()->streamDownload(function () use ($rows) { + $out = fopen('php://output', 'w'); + foreach ($rows as $row) fputcsv($out, $row); + fclose($out); + }, 'carryforward_report_' . date('Ymd_His') . '.csv', ['Content-Type' => 'text/csv']); + } +} diff --git a/app/Http/Controllers/Api/Finance/EventChargeController.php b/app/Http/Controllers/Api/Finance/EventChargeController.php new file mode 100644 index 00000000..8d85d5e3 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/EventChargeController.php @@ -0,0 +1,93 @@ +filled($field)) $q->where($field, $request->query($field)); + } + if ($request->filled('status')) { + $status = $request->query('status'); + if (Schema::hasColumn('event_charges', 'status')) $q->where('status', $status); + elseif ($status === 'paid') $q->where('event_paid', 1); + elseif ($status === 'pending') $q->where(function ($qq) { $qq->whereNull('event_paid')->orWhere('event_paid', 0); }); + } + return response()->json(['ok' => true, 'event_charges' => $q->orderByDesc('id')->paginate((int) $request->query('per_page', 25))]); + } + + public function store(EventChargeLifecycleRequest $request): JsonResponse + { + $data = $this->normalizePayload($request->validated()); + $data['created_by'] = optional($request->user())->id; + if (Schema::hasColumn('event_charges', 'status')) $data['status'] = $data['status'] ?? 'draft'; + $charge = EventCharge::query()->create($data); + return response()->json(['ok' => true, 'event_charge' => $charge], 201); + } + + public function show(int $eventCharge): JsonResponse + { + return response()->json(['ok' => true, 'event_charge' => EventCharge::query()->findOrFail($eventCharge)]); + } + + public function update(EventChargeLifecycleRequest $request, int $eventCharge): JsonResponse + { + $charge = EventCharge::query()->findOrFail($eventCharge); + $charge->fill($this->normalizePayload($request->validated()))->save(); + return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]); + } + + public function destroy(int $eventCharge): JsonResponse + { + return $this->void($eventCharge); + } + + public function approve(int $eventCharge): JsonResponse + { + $charge = EventCharge::query()->findOrFail($eventCharge); + if (Schema::hasColumn('event_charges', 'status')) $charge->status = 'approved'; + if (Schema::hasColumn('event_charges', 'charged')) $charge->charged = 1; + $charge->save(); + return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]); + } + + public function void(int $eventCharge): JsonResponse + { + $charge = EventCharge::query()->findOrFail($eventCharge); + if (Schema::hasColumn('event_charges', 'status')) $charge->status = 'voided'; + if (Schema::hasColumn('event_charges', 'charged')) $charge->charged = 0; + $charge->save(); + return response()->json(['ok' => true, 'event_charge' => $charge->fresh()]); + } + + public function attachToInvoice(Request $request, int $eventCharge): JsonResponse + { + $request->validate(['invoice_id' => ['required','integer']]); + $charge = EventCharge::query()->findOrFail($eventCharge); + $invoiceId = (int) $request->input('invoice_id'); + abort_if(!DB::table('invoices')->where('id', $invoiceId)->exists(), 404, 'Invoice not found.'); + if (Schema::hasColumn('event_charges', 'invoice_id')) $charge->invoice_id = $invoiceId; + if (Schema::hasColumn('event_charges', 'status')) $charge->status = 'invoiced'; + if (Schema::hasColumn('event_charges', 'charged')) $charge->charged = 1; + $charge->save(); + return response()->json(['ok' => true, 'event_charge' => $charge->fresh(), 'warning' => Schema::hasColumn('event_charges','invoice_id') ? null : 'invoice_id column does not exist; charge marked as charged only.']); + } + + private function normalizePayload(array $data): array + { + if (isset($data['title']) && !isset($data['event_name'])) $data['event_name'] = $data['title']; + unset($data['title'], $data['invoice_id']); + return array_filter($data, fn($v) => $v !== null); + } +} diff --git a/app/Http/Controllers/Api/Finance/FinanceNotificationController.php b/app/Http/Controllers/Api/Finance/FinanceNotificationController.php new file mode 100644 index 00000000..6b17e1a6 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/FinanceNotificationController.php @@ -0,0 +1,63 @@ +where('id', $payment)->first(); + abort_if(!$p, 404, 'Payment not found.'); + $receipt = $this->service->nextReceiptNumber($p->school_year ?? date('Y')); + $log = $this->service->log([ + 'parent_id' => $p->parent_id ?? null, + 'invoice_id' => $p->invoice_id ?? null, + 'payment_id' => $payment, + 'notification_type' => 'payment_receipt', + 'channel' => 'database', + 'recipient' => (string) ($p->parent_id ?? ''), + 'subject' => 'Payment receipt ' . $receipt, + ], optional($request->user())->id); + return response()->json(['ok' => true, 'receipt_number' => $receipt, 'notification_log' => $log]); + } + + public function sendRefundReceipt(Request $request, int $refund): JsonResponse + { + $log = $this->service->log(['refund_id' => $refund, 'notification_type' => 'refund_receipt', 'channel' => 'database', 'subject' => 'Refund receipt'], optional($request->user())->id); + return response()->json(['ok' => true, 'notification_log' => $log]); + } + + public function sendInvoiceStatement(Request $request, int $invoice): JsonResponse + { + $inv = DB::table('invoices')->where('id', $invoice)->first(); + abort_if(!$inv, 404, 'Invoice not found.'); + $log = $this->service->log(['parent_id' => $inv->parent_id ?? null, 'invoice_id' => $invoice, 'notification_type' => 'statement_available', 'channel' => 'database', 'subject' => 'Statement available'], optional($request->user())->id); + return response()->json(['ok' => true, 'notification_log' => $log]); + } + + public function sendOverdueReminder(Request $request, int $parent): JsonResponse + { + $log = $this->service->log(['parent_id' => $parent, 'notification_type' => 'overdue_balance_reminder', 'channel' => 'database', 'subject' => 'Overdue balance reminder'], optional($request->user())->id); + return response()->json(['ok' => true, 'notification_log' => $log]); + } + + public function sendInstallmentReminder(Request $request, int $installment): JsonResponse + { + $log = $this->service->log(['installment_id' => $installment, 'notification_type' => 'installment_reminder', 'channel' => 'database', 'subject' => 'Installment reminder'], optional($request->user())->id); + return response()->json(['ok' => true, 'notification_log' => $log]); + } + + public function logs(FinanceNotificationLogRequest $request): JsonResponse + { + return response()->json(['ok' => true, 'logs' => $this->service->list($request->validated())]); + } +} diff --git a/app/Http/Controllers/Api/Finance/FinancialController.php b/app/Http/Controllers/Api/Finance/FinancialController.php index 6548199c..a78c8e36 100644 --- a/app/Http/Controllers/Api/Finance/FinancialController.php +++ b/app/Http/Controllers/Api/Finance/FinancialController.php @@ -5,7 +5,10 @@ namespace App\Http\Controllers\Api\Finance; use App\Http\Controllers\Api\Core\BaseApiController; use App\Http\Requests\Finance\FinancialReportRequest; use App\Http\Requests\Finance\FinancialSummaryRequest; +use App\Http\Requests\Finance\StakeholderFinancialAnalysisRequest; use App\Http\Requests\Finance\FinancialUnpaidParentsRequest; +use App\Http\Requests\Finance\ParentPaymentFollowUpRequest; +use App\Http\Requests\Finance\FollowUpNoteRequest; use App\Http\Resources\Finance\FinancialReportResource; use App\Http\Resources\Finance\FinancialSummaryResource; use App\Http\Resources\Finance\FinancialUnpaidParentResource; @@ -13,7 +16,9 @@ use App\Services\Finance\FinancialPdfReportService; use App\Services\Finance\FinancialReportService; use App\Services\Finance\FinancialSchoolYearService; use App\Services\Finance\FinancialSummaryService; +use App\Services\Finance\StakeholderFinancialAnalysisService; use App\Services\Finance\FinancialUnpaidParentsService; +use App\Services\Finance\ParentPaymentFollowUpService; use Illuminate\Http\JsonResponse; use Symfony\Component\HttpFoundation\StreamedResponse; @@ -24,7 +29,9 @@ class FinancialController extends BaseApiController private FinancialSummaryService $summaryService, private FinancialUnpaidParentsService $unpaidParentsService, private FinancialSchoolYearService $schoolYears, - private FinancialPdfReportService $pdfService + private FinancialPdfReportService $pdfService, + private StakeholderFinancialAnalysisService $stakeholderAnalysisService, + private ParentPaymentFollowUpService $parentPaymentFollowUpService ) { parent::__construct(); } @@ -75,6 +82,95 @@ class FinancialController extends BaseApiController ]); } + + + + public function parentPaymentFollowups(ParentPaymentFollowUpRequest $request): JsonResponse + { + return response()->json([ + 'ok' => true, + 'followups' => $this->parentPaymentFollowUpService->report($request->validated()), + ]); + } + + public function parentPaymentFollowupsCsv(ParentPaymentFollowUpRequest $request): StreamedResponse + { + $report = $this->parentPaymentFollowUpService->report($request->validated()); + $rows = $this->parentPaymentFollowUpService->csvRows($report); + + return response()->streamDownload(function () use ($rows) { + $out = fopen('php://output', 'w'); + foreach ($rows as $row) { + fputcsv($out, $row); + } + fclose($out); + }, 'parent_payment_followups_' . ($report['schoolYear'] ?? 'report') . '_' . date('Ymd_His') . '.csv', ['Content-Type' => 'text/csv']); + } + + public function storeParentFollowUpNote(FollowUpNoteRequest $request, int $parent): JsonResponse + { + return response()->json([ + 'ok' => true, + 'note' => $this->parentPaymentFollowUpService->storeNote($parent, $request->validated(), optional($request->user())->id), + ], 201); + } + + public function markParentContacted(FollowUpNoteRequest $request, int $parent): JsonResponse + { + $payload = array_merge($request->validated(), ['status' => $request->input('status', 'contacted')]); + return response()->json(['ok' => true, 'note' => $this->parentPaymentFollowUpService->storeNote($parent, $payload, optional($request->user())->id)], 201); + } + + public function storePromiseToPay(FollowUpNoteRequest $request, int $parent): JsonResponse + { + $payload = array_merge($request->validated(), ['status' => 'promise_to_pay']); + return response()->json(['ok' => true, 'note' => $this->parentPaymentFollowUpService->storeNote($parent, $payload, optional($request->user())->id)], 201); + } + + public function resolveParentFollowUp(FollowUpNoteRequest $request, int $parent): JsonResponse + { + $payload = array_merge($request->validated(), ['status' => 'resolved']); + return response()->json(['ok' => true, 'note' => $this->parentPaymentFollowUpService->storeNote($parent, $payload, optional($request->user())->id)], 201); + } + + public function stakeholderAnalysis(StakeholderFinancialAnalysisRequest $request): JsonResponse + { + $payload = $request->validated(); + + $analysis = $this->stakeholderAnalysisService->analyze( + $payload['date_from'] ?? null, + $payload['date_to'] ?? null, + $payload['school_year'] ?? null, + $payload['compare_school_year'] ?? null + ); + + return response()->json([ + 'ok' => true, + 'analysis' => $analysis, + ]); + } + + public function stakeholderAnalysisCsv(StakeholderFinancialAnalysisRequest $request): StreamedResponse + { + $payload = $request->validated(); + $analysis = $this->stakeholderAnalysisService->analyze( + $payload['date_from'] ?? null, + $payload['date_to'] ?? null, + $payload['school_year'] ?? null, + $payload['compare_school_year'] ?? null + ); + $rows = $this->stakeholderAnalysisService->csvRows($analysis); + $schoolYear = $analysis['schoolYear'] ?? 'report'; + + return response()->streamDownload(function () use ($rows) { + $out = fopen('php://output', 'w'); + foreach ($rows as $row) { + fputcsv($out, $row); + } + fclose($out); + }, 'stakeholder_financial_analysis_' . $schoolYear . '_' . date('Ymd_His') . '.csv', ['Content-Type' => 'text/csv']); + } + public function downloadCsv(FinancialReportRequest $request): StreamedResponse { $payload = $request->validated(); diff --git a/app/Http/Controllers/Api/Finance/InstallmentPlanController.php b/app/Http/Controllers/Api/Finance/InstallmentPlanController.php new file mode 100644 index 00000000..796c66e5 --- /dev/null +++ b/app/Http/Controllers/Api/Finance/InstallmentPlanController.php @@ -0,0 +1,29 @@ +json(['ok' => true, 'plans' => $this->service->list($request->query())]); } + public function store(InstallmentPlanRequest $request, int $invoice): JsonResponse { return response()->json(['ok' => true, 'plan' => $this->service->createForInvoice($invoice, $request->validated(), optional($request->user())->id)], 201); } + public function show(int $plan): JsonResponse { return response()->json(['ok' => true, 'plan' => $this->service->show($plan)]); } + public function activate(Request $request, int $plan): JsonResponse { return response()->json(['ok' => true, 'plan' => $this->service->activate($plan, optional($request->user())->id)]); } + public function cancel(int $plan): JsonResponse { return response()->json(['ok' => true, 'plan' => $this->service->cancel($plan)]); } + public function restructure(InstallmentPlanRequest $request, int $plan): JsonResponse + { + $old = $this->service->cancel($plan); + return response()->json(['ok' => true, 'old_plan' => $old, 'message' => 'Old plan cancelled. Create a replacement plan against the invoice to preserve audit history.']); + } + public function allocatePayment(PaymentAllocationRequest $request, int $payment): JsonResponse { return response()->json(['ok' => true, 'result' => $this->service->allocatePayment($payment, $request->validated())]); } + public function due(): JsonResponse { return response()->json(['ok' => true, 'installments' => $this->service->due(false)]); } + public function overdue(): JsonResponse { $this->service->markOverdue(); return response()->json(['ok' => true, 'installments' => $this->service->due(true)]); } +} diff --git a/app/Http/Controllers/Api/Finance/InvoiceController.php b/app/Http/Controllers/Api/Finance/InvoiceController.php index 348aff79..153ba53a 100644 --- a/app/Http/Controllers/Api/Finance/InvoiceController.php +++ b/app/Http/Controllers/Api/Finance/InvoiceController.php @@ -128,6 +128,23 @@ class InvoiceController extends BaseApiController ]); } + public function preview(int $invoiceId): JsonResponse + { + $data = $this->pdfService->previewData($invoiceId); + + if (isset($data['error'])) { + return response()->json([ + 'ok' => false, + 'message' => $data['error'], + ], 404); + } + + return response()->json([ + 'ok' => true, + ...$data, + ]); + } + public function pdf(int $invoiceId): StreamedResponse { $pdfBytes = $this->pdfService->buildPdf($invoiceId); diff --git a/app/Http/Controllers/Api/Finance/ReimbursementController.php b/app/Http/Controllers/Api/Finance/ReimbursementController.php index d6d34ffe..114b9fdb 100644 --- a/app/Http/Controllers/Api/Finance/ReimbursementController.php +++ b/app/Http/Controllers/Api/Finance/ReimbursementController.php @@ -35,6 +35,7 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; use Illuminate\Support\Facades\Validator; use RuntimeException; +use Symfony\Component\HttpFoundation\StreamedResponse; class ReimbursementController extends BaseApiController { @@ -287,7 +288,7 @@ class ReimbursementController extends BaseApiController return response()->json(['ok' => true, 'message' => 'Email sent successfully.']); } - public function export(ReimbursementExportRequest $request): Response + public function export(ReimbursementExportRequest $request): StreamedResponse { $payload = $request->validated(); $type = $payload['type'] ?? 'processed'; @@ -310,7 +311,7 @@ class ReimbursementController extends BaseApiController ]); } - public function exportBatch(ReimbursementExportBatchRequest $request): Response + public function exportBatch(ReimbursementExportBatchRequest $request): StreamedResponse { $payload = $request->validated(); $csv = $this->exportService->buildBatchCsv((int) $payload['batch_id']); diff --git a/app/Http/Requests/Administrator/TrophyReportRequest.php b/app/Http/Requests/Administrator/TrophyReportRequest.php new file mode 100644 index 00000000..7557721e --- /dev/null +++ b/app/Http/Requests/Administrator/TrophyReportRequest.php @@ -0,0 +1,21 @@ + ['nullable', 'string', 'max:50'], + 'percentile' => ['nullable', 'numeric', 'min:1', 'max:99'], + ]; + } +} diff --git a/app/Http/Requests/Finance/CarryforwardAdjustmentRequest.php b/app/Http/Requests/Finance/CarryforwardAdjustmentRequest.php new file mode 100644 index 00000000..251e49e9 --- /dev/null +++ b/app/Http/Requests/Finance/CarryforwardAdjustmentRequest.php @@ -0,0 +1,17 @@ + ['required','numeric','min:0'], + 'reason' => ['nullable','string','max:255'], + ]; + } +} diff --git a/app/Http/Requests/Finance/CarryforwardFinalizeRequest.php b/app/Http/Requests/Finance/CarryforwardFinalizeRequest.php new file mode 100644 index 00000000..2cd75e8f --- /dev/null +++ b/app/Http/Requests/Finance/CarryforwardFinalizeRequest.php @@ -0,0 +1,19 @@ + ['required_without:id','string','max:20'], + 'to_school_year' => ['required_without:id','string','max:20'], + 'parent_id' => ['nullable','integer'], + 'reason' => ['nullable','string','max:255'], + ]; + } +} diff --git a/app/Http/Requests/Finance/CarryforwardPreviewRequest.php b/app/Http/Requests/Finance/CarryforwardPreviewRequest.php new file mode 100644 index 00000000..7d660220 --- /dev/null +++ b/app/Http/Requests/Finance/CarryforwardPreviewRequest.php @@ -0,0 +1,19 @@ + ['required','string','max:20'], + 'to_school_year' => ['required','string','max:20'], + 'parent_id' => ['nullable','integer'], + 'minimum_balance' => ['nullable','numeric','min:0'], + ]; + } +} diff --git a/app/Http/Requests/Finance/EventChargeLifecycleRequest.php b/app/Http/Requests/Finance/EventChargeLifecycleRequest.php new file mode 100644 index 00000000..e2a4371c --- /dev/null +++ b/app/Http/Requests/Finance/EventChargeLifecycleRequest.php @@ -0,0 +1,25 @@ + ['nullable','integer'], + 'parent_id' => ['nullable','integer'], + 'student_id' => ['nullable','integer'], + 'school_year' => ['nullable','string','max:20'], + 'semester' => ['nullable','string','max:40'], + 'event_name' => ['nullable','string','max:255'], + 'title' => ['nullable','string','max:255'], + 'description' => ['nullable','string'], + 'amount' => ['nullable','numeric','min:0'], + 'invoice_id' => ['nullable','integer'], + ]; + } +} diff --git a/app/Http/Requests/Finance/FinanceNotificationLogRequest.php b/app/Http/Requests/Finance/FinanceNotificationLogRequest.php new file mode 100644 index 00000000..e5e38aff --- /dev/null +++ b/app/Http/Requests/Finance/FinanceNotificationLogRequest.php @@ -0,0 +1,20 @@ + ['nullable','string','max:40'], + 'notification_type' => ['nullable','string','max:80'], + 'parent_id' => ['nullable','integer'], + 'date_from' => ['nullable','date'], + 'date_to' => ['nullable','date'], + ]; + } +} diff --git a/app/Http/Requests/Finance/FollowUpNoteRequest.php b/app/Http/Requests/Finance/FollowUpNoteRequest.php new file mode 100644 index 00000000..23f0caa6 --- /dev/null +++ b/app/Http/Requests/Finance/FollowUpNoteRequest.php @@ -0,0 +1,24 @@ + ['nullable','integer'], + 'school_year' => ['nullable','string','max:20'], + 'status' => ['nullable','string','max:60'], + 'contact_method' => ['nullable','string','max:60'], + 'promise_to_pay_date' => ['nullable','date'], + 'promise_to_pay_amount' => ['nullable','numeric','min:0'], + 'next_follow_up_date' => ['nullable','date'], + 'escalation_level' => ['nullable','integer','min:0','max:10'], + 'note' => ['nullable','string','max:10000'], + ]; + } +} diff --git a/app/Http/Requests/Finance/InstallmentPlanRequest.php b/app/Http/Requests/Finance/InstallmentPlanRequest.php new file mode 100644 index 00000000..e53b74d9 --- /dev/null +++ b/app/Http/Requests/Finance/InstallmentPlanRequest.php @@ -0,0 +1,20 @@ + ['nullable','string','max:120'], + 'total_amount' => ['nullable','numeric','min:0'], + 'number_of_installments' => ['required','integer','min:1','max:24'], + 'first_due_date' => ['required','date'], + 'interval_months' => ['nullable','integer','min:1','max:12'], + ]; + } +} diff --git a/app/Http/Requests/Finance/ParentPaymentFollowUpRequest.php b/app/Http/Requests/Finance/ParentPaymentFollowUpRequest.php new file mode 100644 index 00000000..7309f768 --- /dev/null +++ b/app/Http/Requests/Finance/ParentPaymentFollowUpRequest.php @@ -0,0 +1,28 @@ + ['nullable','string','max:20'], + 'date_from' => ['nullable','date'], + 'date_to' => ['nullable','date'], + 'minimum_balance' => ['nullable','numeric','min:0'], + 'aging_bucket' => ['nullable','string','max:40'], + 'parent_id' => ['nullable','integer'], + 'student_id' => ['nullable','integer'], + 'follow_up_status' => ['nullable','string','max:60'], + 'last_contacted_before' => ['nullable','date'], + 'next_follow_up_due_before' => ['nullable','date'], + 'promise_to_pay_due_before' => ['nullable','date'], + 'include_paid_invoices' => ['nullable','boolean'], + 'include_inactive_students' => ['nullable','boolean'], + ]; + } +} diff --git a/app/Http/Requests/Finance/PaymentAllocationRequest.php b/app/Http/Requests/Finance/PaymentAllocationRequest.php new file mode 100644 index 00000000..36f714bf --- /dev/null +++ b/app/Http/Requests/Finance/PaymentAllocationRequest.php @@ -0,0 +1,18 @@ + ['nullable','array'], + 'installments.*.installment_id' => ['required_with:installments','integer'], + 'installments.*.amount' => ['required_with:installments','numeric','min:0.01'], + ]; + } +} diff --git a/app/Http/Requests/Finance/StakeholderFinancialAnalysisRequest.php b/app/Http/Requests/Finance/StakeholderFinancialAnalysisRequest.php new file mode 100644 index 00000000..5ae487ac --- /dev/null +++ b/app/Http/Requests/Finance/StakeholderFinancialAnalysisRequest.php @@ -0,0 +1,15 @@ + ['nullable', 'string', 'max:20'], + 'include_monthly_trend' => ['nullable', 'boolean'], + 'include_parent_risk' => ['nullable', 'boolean'], + ]); + } +} diff --git a/app/Http/Requests/Grading/GradingScoreUpdateRequest.php b/app/Http/Requests/Grading/GradingScoreUpdateRequest.php index 0ba665ab..74e320e6 100644 --- a/app/Http/Requests/Grading/GradingScoreUpdateRequest.php +++ b/app/Http/Requests/Grading/GradingScoreUpdateRequest.php @@ -22,7 +22,7 @@ class GradingScoreUpdateRequest extends ApiFormRequest 'score_ids' => ['nullable', 'array'], 'scores' => ['nullable', 'array'], 'comments' => ['nullable', 'array'], - 'score' => ['nullable', 'numeric'], + 'score' => ['nullable', 'numeric', 'min:0', 'max:100'], 'comment' => ['nullable', 'string'], ]; } diff --git a/app/Http/Resources/Expenses/ExpenseResource.php b/app/Http/Resources/Expenses/ExpenseResource.php index 5fd63293..9c2f823e 100644 --- a/app/Http/Resources/Expenses/ExpenseResource.php +++ b/app/Http/Resources/Expenses/ExpenseResource.php @@ -9,26 +9,26 @@ class ExpenseResource extends JsonResource public function toArray($request): array { return [ - 'id' => $this['id'] ?? $this->id, - 'category' => $this['category'] ?? $this->category, - 'amount' => $this['amount'] ?? $this->amount, - 'receipt_path' => $this['receipt_path'] ?? $this->receipt_path, - 'receipt_url' => $this['receipt_url'] ?? null, - 'description' => $this['description'] ?? $this->description, - 'retailor' => $this['retailor'] ?? $this->retailor, - 'date_of_purchase' => $this['date_of_purchase'] ?? $this->date_of_purchase, - 'purchased_by' => $this['purchased_by'] ?? $this->purchased_by, - 'added_by' => $this['added_by'] ?? $this->added_by, - 'school_year' => $this['school_year'] ?? $this->school_year, - 'semester' => $this['semester'] ?? $this->semester, - 'status' => $this['status'] ?? $this->status, - 'status_reason' => $this['status_reason'] ?? $this->status_reason, - 'approved_by' => $this['approved_by'] ?? $this->approved_by, - 'updated_by' => $this['updated_by'] ?? $this->updated_by, - 'purchaser_firstname' => $this['purchaser_firstname'] ?? null, - 'purchaser_lastname' => $this['purchaser_lastname'] ?? null, - 'approver_firstname' => $this['approver_firstname'] ?? null, - 'approver_lastname' => $this['approver_lastname'] ?? null, + 'id' => data_get($this->resource, 'id'), + 'category' => data_get($this->resource, 'category'), + 'amount' => data_get($this->resource, 'amount'), + 'receipt_path' => data_get($this->resource, 'receipt_path'), + 'receipt_url' => data_get($this->resource, 'receipt_url'), + 'description' => data_get($this->resource, 'description'), + 'retailor' => data_get($this->resource, 'retailor'), + 'date_of_purchase' => data_get($this->resource, 'date_of_purchase'), + 'purchased_by' => data_get($this->resource, 'purchased_by'), + 'added_by' => data_get($this->resource, 'added_by'), + 'school_year' => data_get($this->resource, 'school_year'), + 'semester' => data_get($this->resource, 'semester'), + 'status' => data_get($this->resource, 'status'), + 'status_reason' => data_get($this->resource, 'status_reason'), + 'approved_by' => data_get($this->resource, 'approved_by'), + 'updated_by' => data_get($this->resource, 'updated_by'), + 'purchaser_firstname' => data_get($this->resource, 'purchaser_firstname'), + 'purchaser_lastname' => data_get($this->resource, 'purchaser_lastname'), + 'approver_firstname' => data_get($this->resource, 'approver_firstname'), + 'approver_lastname' => data_get($this->resource, 'approver_lastname'), ]; } } diff --git a/app/Models/CertificateRecord.php b/app/Models/CertificateRecord.php new file mode 100644 index 00000000..0b1cf95e --- /dev/null +++ b/app/Models/CertificateRecord.php @@ -0,0 +1,35 @@ + 'integer', + 'class_section_id' => 'integer', + 'issued_by' => 'integer', + 'cert_date' => 'date', + 'issued_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; +} diff --git a/app/Models/FinalExam.php b/app/Models/FinalExam.php index 05b1f46b..499d7571 100644 --- a/app/Models/FinalExam.php +++ b/app/Models/FinalExam.php @@ -20,6 +20,11 @@ class FinalExam extends BaseModel 'class_section_id', 'updated_by', 'score', + 'max_points', + 'status', + 'excused_reason', + 'locked_at', + 'locked_by', 'comment', 'semester', 'school_year', @@ -35,6 +40,9 @@ class FinalExam extends BaseModel // score is often numeric; adjust if yours is integer 'score' => 'decimal:2', + 'max_points' => 'decimal:2', + 'locked_by' => 'integer', + 'locked_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', diff --git a/app/Models/FinanceBalanceCarryforward.php b/app/Models/FinanceBalanceCarryforward.php new file mode 100644 index 00000000..776b63b8 --- /dev/null +++ b/app/Models/FinanceBalanceCarryforward.php @@ -0,0 +1,10 @@ + 'array', 'metadata_json' => 'array']; +} diff --git a/app/Models/FinanceFollowUpNote.php b/app/Models/FinanceFollowUpNote.php new file mode 100644 index 00000000..bd292657 --- /dev/null +++ b/app/Models/FinanceFollowUpNote.php @@ -0,0 +1,9 @@ + 'decimal:2', + 'max_points' => 'decimal:2', + 'locked_by' => 'integer', + 'locked_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', diff --git a/app/Models/InvoiceInstallment.php b/app/Models/InvoiceInstallment.php new file mode 100644 index 00000000..e9c0bb25 --- /dev/null +++ b/app/Models/InvoiceInstallment.php @@ -0,0 +1,9 @@ + 'decimal:2', + 'max_points' => 'decimal:2', + 'locked_by' => 'integer', + 'locked_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', diff --git a/app/Models/NavItem.php b/app/Models/NavItem.php index 89846a42..deb66f32 100644 --- a/app/Models/NavItem.php +++ b/app/Models/NavItem.php @@ -50,7 +50,8 @@ class NavItem extends BaseModel return static::query() ->where('menu_parent_id', $parentId) ->where('is_enabled', 1) - ->orderBy('sort_order', 'asc') + ->orderBy('label', 'asc') + ->orderBy('id', 'asc') ->get() ->toArray(); } diff --git a/app/Models/Participation.php b/app/Models/Participation.php index 29c129e3..4abe7d62 100644 --- a/app/Models/Participation.php +++ b/app/Models/Participation.php @@ -20,6 +20,11 @@ class Participation extends BaseModel 'class_section_id', 'updated_by', 'score', + 'max_points', + 'status', + 'excused_reason', + 'locked_at', + 'locked_by', 'comment', 'semester', 'school_year', @@ -35,6 +40,9 @@ class Participation extends BaseModel // score is numeric; change to 'integer' if it is whole number 'score' => 'decimal:2', + 'max_points' => 'decimal:2', + 'locked_by' => 'integer', + 'locked_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', diff --git a/app/Models/PaymentCarryforwardAllocation.php b/app/Models/PaymentCarryforwardAllocation.php new file mode 100644 index 00000000..0c2118f8 --- /dev/null +++ b/app/Models/PaymentCarryforwardAllocation.php @@ -0,0 +1,9 @@ + 'integer', 'updated_by' => 'integer', 'project_index' => 'integer', - 'score' => 'decimal:2', // or 'float' if you prefer + 'score' => 'decimal:2', + 'max_points' => 'decimal:2', + 'locked_by' => 'integer', + 'locked_at' => 'datetime', // or 'float' if you prefer 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; diff --git a/app/Models/Quiz.php b/app/Models/Quiz.php index 353e00fe..6663fd33 100644 --- a/app/Models/Quiz.php +++ b/app/Models/Quiz.php @@ -17,6 +17,11 @@ class Quiz extends BaseModel 'updated_by', 'quiz_index', 'score', + 'max_points', + 'status', + 'excused_reason', + 'locked_at', + 'locked_by', 'comment', 'semester', 'school_year', @@ -31,6 +36,9 @@ class Quiz extends BaseModel 'updated_by' => 'integer', 'quiz_index' => 'integer', 'score' => 'decimal:2', + 'max_points' => 'decimal:2', + 'locked_by' => 'integer', + 'locked_at' => 'datetime', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; diff --git a/app/Models/SectionPlacementBatch.php b/app/Models/SectionPlacementBatch.php new file mode 100644 index 00000000..c4a22c14 --- /dev/null +++ b/app/Models/SectionPlacementBatch.php @@ -0,0 +1,48 @@ + 'integer', + 'to_grade_level_id' => 'integer', + 'section_capacity_used' => 'integer', + 'total_students' => 'integer', + 'section_count' => 'integer', + 'created_by' => 'integer', + 'finalized_by' => 'integer', + 'finalized_at' => 'datetime', + ]; + + public function students(): HasMany + { + return $this->hasMany(SectionPlacementBatchStudent::class, 'batch_id'); + } +} diff --git a/app/Models/SectionPlacementBatchStudent.php b/app/Models/SectionPlacementBatchStudent.php new file mode 100644 index 00000000..b6339da6 --- /dev/null +++ b/app/Models/SectionPlacementBatchStudent.php @@ -0,0 +1,49 @@ + 'integer', + 'student_id' => 'integer', + 'source_decision_id' => 'integer', + 'source_enrollment_id' => 'integer', + 'final_score' => 'float', + 'placement_score' => 'float', + 'planned_section_index' => 'integer', + 'assigned_section_id' => 'integer', + 'assignment_order' => 'integer', + 'was_override' => 'boolean', + 'created_at' => 'datetime', + ]; + + public function batch(): BelongsTo + { + return $this->belongsTo(SectionPlacementBatch::class, 'batch_id'); + } +} diff --git a/app/Models/SemesterScore.php b/app/Models/SemesterScore.php index 45198ec3..d232d307 100644 --- a/app/Models/SemesterScore.php +++ b/app/Models/SemesterScore.php @@ -25,6 +25,9 @@ class SemesterScore extends BaseModel 'ptap_score', 'test_avg', 'semester_score', + 'calculation_mode', + 'calculation_policy_version', + 'snapshot_id', 'semester', 'school_year', ]; @@ -52,6 +55,7 @@ class SemesterScore extends BaseModel 'ptap_score' => 'decimal:2', 'test_avg' => 'decimal:2', 'semester_score' => 'decimal:2', + 'snapshot_id' => 'integer', 'created_at' => 'datetime', 'updated_at' => 'datetime', @@ -169,6 +173,9 @@ class SemesterScore extends BaseModel 'ptap_score' => ['nullable', 'numeric', 'min:0'], 'test_avg' => ['nullable', 'numeric', 'min:0'], 'semester_score' => ['nullable', 'numeric', 'min:0'], + 'calculation_mode' => ['nullable', 'string', 'in:legacy,strong'], + 'calculation_policy_version' => ['nullable', 'string', 'max:50'], + 'snapshot_id' => ['nullable', 'integer', 'min:1'], ]; } } \ No newline at end of file diff --git a/app/Models/SemesterScoreSnapshot.php b/app/Models/SemesterScoreSnapshot.php new file mode 100644 index 00000000..46cc07f0 --- /dev/null +++ b/app/Models/SemesterScoreSnapshot.php @@ -0,0 +1,45 @@ + 'integer', + 'school_id' => 'integer', + 'class_section_id' => 'integer', + 'grading_profile_id' => 'integer', + 'input_json' => 'array', + 'calculation_json' => 'array', + 'semester_score' => 'decimal:2', + 'calculated_by' => 'integer', + 'calculated_at' => 'datetime', + ]; + + public function semesterScore(): BelongsTo + { + return $this->belongsTo(SemesterScore::class, 'snapshot_id'); + } +} diff --git a/app/Services/Administrator/Trophy/TrophyReportService.php b/app/Services/Administrator/Trophy/TrophyReportService.php new file mode 100644 index 00000000..84bb2825 --- /dev/null +++ b/app/Services/Administrator/Trophy/TrophyReportService.php @@ -0,0 +1,647 @@ +resolveSchoolYear($schoolYear); + $selectedPercentile = $this->sanitizePercentile($percentile); + $sections = $this->sectionMap($this->studentRows($selectedYear)); + + $classResults = []; + + foreach ($sections as $sectionId => $section) { + $students = $this->sortByScore($section['students'], 'fall_score'); + $scores = array_column($students, 'fall_score'); + $calc = $this->calculateThreshold($scores, $selectedPercentile); + $threshold = $calc['threshold']; + + $students = array_map(function (array $student) use ($threshold): array { + $student['projected_trophy'] = $threshold !== null + && $student['fall_score'] !== null + && $student['fall_score'] >= $threshold; + + return $student; + }, $students); + + $scoredCount = count(array_filter( + array_column($students, 'fall_score'), + static fn ($score): bool => $score !== null + )); + + [$boys, $girls] = $this->splitByBinaryGender($students); + $trophyBoys = array_values(array_filter($boys, static fn (array $student): bool => ! empty($student['projected_trophy']))); + $trophyGirls = array_values(array_filter($girls, static fn (array $student): bool => ! empty($student['projected_trophy']))); + $studentCount = count($students); + + $classResults[] = [ + 'section_id' => $sectionId, + 'section_name' => $section['name'], + 'students' => $students, + 'threshold' => $threshold, + 'trophy_count' => $calc['winners'], + 'student_count' => $studentCount, + 'scored_count' => $scoredCount, + 'method' => $calc['method'], + 'boys' => count($boys), + 'girls' => count($girls), + 'trophy_boys' => count($trophyBoys), + 'trophy_girls' => count($trophyGirls), + 'pct_boys' => $studentCount > 0 ? round(count($boys) / $studentCount * 100) : 0, + 'pct_girls' => $studentCount > 0 ? round(count($girls) / $studentCount * 100) : 0, + 'pct_trophy_boys' => count($boys) > 0 ? round(count($trophyBoys) / count($boys) * 100) : 0, + 'pct_trophy_girls' => count($girls) > 0 ? round(count($trophyGirls) / count($girls) * 100) : 0, + 'pct_trophy_total' => $studentCount > 0 ? round($calc['winners'] / $studentCount * 100) : 0, + ]; + } + + return [ + 'selected_year' => $selectedYear, + 'selected_percentile' => $selectedPercentile, + 'years' => $this->schoolYears(), + 'class_results' => $classResults, + 'summary' => $this->projectionSummary($classResults), + ]; + } + + public function winners(?string $schoolYear = null, ?float $percentile = null): array + { + $selectedYear = $this->resolveSchoolYear($schoolYear); + $selectedPercentile = $this->sanitizePercentile($percentile); + $sections = $this->sectionMap($this->studentRows($selectedYear)); + + $classResults = []; + + foreach ($sections as $sectionId => $section) { + $students = $this->sortByScore($section['students'], 'fall_score'); + $calc = $this->calculateThreshold(array_column($students, 'fall_score'), $selectedPercentile); + $threshold = $calc['threshold']; + + $winners = array_values(array_filter($students, static function (array $student) use ($threshold): bool { + return $threshold !== null + && $student['fall_score'] !== null + && $student['fall_score'] >= $threshold; + })); + + if ($winners === []) { + continue; + } + + [$boys, $girls] = $this->splitByBinaryGender($students); + [$winnerBoys, $winnerGirls] = $this->splitByBinaryGender($winners); + $studentCount = count($students); + + $classResults[] = [ + 'section_id' => $sectionId, + 'section_name' => $section['name'], + 'threshold' => $threshold, + 'winners' => $winners, + 'student_count' => $studentCount, + 'boys' => count($boys), + 'girls' => count($girls), + 'trophy_boys' => count($winnerBoys), + 'trophy_girls' => count($winnerGirls), + 'pct_boys' => $studentCount > 0 ? round(count($boys) / $studentCount * 100) : 0, + 'pct_girls' => $studentCount > 0 ? round(count($girls) / $studentCount * 100) : 0, + 'pct_trophy_boys' => count($boys) > 0 ? round(count($winnerBoys) / count($boys) * 100) : 0, + 'pct_trophy_girls' => count($girls) > 0 ? round(count($winnerGirls) / count($girls) * 100) : 0, + 'pct_trophy_total' => $studentCount > 0 ? round(count($winners) / $studentCount * 100) : 0, + ]; + } + + return [ + 'selected_year' => $selectedYear, + 'selected_percentile' => $selectedPercentile, + 'years' => $this->schoolYears(), + 'class_results' => $classResults, + 'summary' => $this->winnersSummary($classResults), + ]; + } + + public function final(?string $schoolYear = null, ?float $percentile = null): array + { + $selectedYear = $this->resolveSchoolYear($schoolYear); + $selectedPercentile = $this->sanitizePercentile($percentile); + $sections = $this->sectionMap($this->studentRows($selectedYear)); + + $classResults = []; + $allStudents = []; + $passStudents = []; + $winnerStudents = []; + $winnerStickers = []; + + foreach ($sections as $sectionId => $section) { + $students = $section['students']; + $fallCalc = $this->calculateThreshold(array_column($students, 'fall_score'), $selectedPercentile); + $yearCalc = $this->calculateThreshold(array_column($students, 'year_score'), $selectedPercentile); + $fallThreshold = $fallCalc['threshold']; + $yearThreshold = $yearCalc['threshold']; + + $annotated = array_map(function (array $student) use ($fallThreshold, $yearThreshold): array { + $predicted = $fallThreshold !== null + && $student['fall_score'] !== null + && $student['fall_score'] >= $fallThreshold; + $actual = $yearThreshold !== null + && $student['year_score'] !== null + && $student['year_score'] >= $yearThreshold; + $status = match (true) { + $predicted && $actual => 'confirmed', + ! $predicted && $actual => 'surprise', + $predicted && ! $actual => 'missed', + default => 'none', + }; + + return $student + compact('predicted', 'actual', 'status'); + }, $students); + + $annotated = $this->sortByScore($annotated, 'year_score'); + + foreach ($annotated as $student) { + $allStudents[] = $student; + + if ($student['year_score'] !== null && $student['year_score'] >= 60.0) { + $passStudents[] = $student; + } + + if (! empty($student['actual'])) { + $winnerStudents[] = $student; + $winnerStickers[] = [ + 'name' => $student['name'], + 'section' => $section['name'], + ]; + } + } + + $confirmed = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'confirmed')); + $surprises = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'surprise')); + $missed = count(array_filter($annotated, static fn (array $student): bool => $student['status'] === 'missed')); + $predictedCount = count(array_filter($annotated, static fn (array $student): bool => ! empty($student['predicted']))); + $actualCount = count(array_filter($annotated, static fn (array $student): bool => ! empty($student['actual']))); + $studentCount = count($annotated); + + $classResults[] = [ + 'section_id' => $sectionId, + 'section_name' => $section['name'], + 'students' => $annotated, + 'student_count' => $studentCount, + 'fall_threshold' => $fallThreshold, + 'year_threshold' => $yearThreshold, + 'predicted_count' => $predictedCount, + 'actual_count' => $actualCount, + 'confirmed' => $confirmed, + 'surprises' => $surprises, + 'missed' => $missed, + 'accuracy' => $predictedCount > 0 ? round($confirmed / $predictedCount * 100) : ($actualCount === 0 ? 100 : 0), + ]; + } + + return [ + 'selected_year' => $selectedYear, + 'selected_percentile' => $selectedPercentile, + 'years' => $this->schoolYears(), + 'class_results' => $classResults, + 'summary' => $this->finalSummary($classResults), + 'winner_gender_summary' => $this->genderBreakdown($winnerStudents), + 'all_gender_summary' => $this->genderBreakdown($allStudents), + 'pass_gender_summary' => $this->genderBreakdown($passStudents), + 'winner_stickers' => array_values(array_filter($winnerStickers, static fn (array $sticker): bool => trim((string) ($sticker['name'] ?? '')) !== '')), + ]; + } + + /** + * @param array $scores + * @return array{threshold: float|null, winners: int, method: string} + */ + public function calculateThreshold(array $scores, float $percentile = 75.0): array + { + $scores = array_values(array_filter( + $scores, + static fn ($value): bool => is_numeric($value) && $value !== null + )); + $scores = array_map('floatval', $scores); + sort($scores); + + $count = count($scores); + + if ($count === 0) { + return ['threshold' => null, 'winners' => 0, 'method' => 'empty']; + } + + $minWinners = 3; + $maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100))); + + $threshold = $this->empiricalPercentile($scores, $percentile); + $winners = $this->countAtOrAbove($scores, $threshold); + + if ($winners < $minWinners) { + $target = min($minWinners, $count); + $descending = array_reverse($scores); + $threshold = $descending[$target - 1]; + $winners = $this->countAtOrAbove($scores, $threshold); + + return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced']; + } + + if ($winners <= $maxWinners) { + return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile']; + } + + $result = $this->capByRank($scores, $maxWinners); + + if ($result['winners'] < $minWinners) { + $target = min($minWinners, $count); + $descending = array_reverse($scores); + $threshold = $descending[$target - 1]; + $winners = $this->countAtOrAbove($scores, $threshold); + + return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap']; + } + + return $result; + } + + private function resolveSchoolYear(?string $schoolYear): string + { + $selected = trim((string) $schoolYear); + + if ($selected !== '') { + return $selected; + } + + return (string) (Configuration::getConfig('school_year') ?? ''); + } + + private function sanitizePercentile(?float $percentile): float + { + $value = $percentile ?? 75.0; + + return max(1.0, min(99.0, $value)); + } + + /** + * @return list + */ + private function schoolYears(): array + { + return DB::table('student_class') + ->select('school_year') + ->distinct() + ->orderBy('school_year', 'DESC') + ->pluck('school_year') + ->filter(static fn ($value): bool => $value !== null && trim((string) $value) !== '') + ->map(static fn ($value): string => (string) $value) + ->values() + ->all(); + } + + /** + * @return \Illuminate\Support\Collection> + */ + private function studentRows(string $schoolYear): Collection + { + $fallScores = DB::table('semester_scores') + ->select( + 'student_id', + 'class_section_id', + DB::raw('MAX(semester_score) AS fall_score') + ) + ->where('school_year', $schoolYear) + ->whereRaw('LOWER(semester) = ?', ['fall']) + ->groupBy('student_id', 'class_section_id'); + + $springScores = DB::table('semester_scores') + ->select( + 'student_id', + 'class_section_id', + DB::raw('MAX(semester_score) AS spring_score') + ) + ->where('school_year', $schoolYear) + ->whereRaw('LOWER(semester) = ?', ['spring']) + ->groupBy('student_id', 'class_section_id'); + + return DB::table('student_class as sc') + ->select( + 'sc.student_id', + 'sc.class_section_id', + 'cs.class_section_name', + 's.firstname', + 's.lastname', + 's.school_id', + 's.gender', + DB::raw('MAX(sf.fall_score) AS fall_score'), + DB::raw('MAX(sp.spring_score) AS spring_score') + ) + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') + ->leftJoin('students as s', 's.id', '=', 'sc.student_id') + ->leftJoinSub($fallScores, 'sf', function ($join): void { + $join->on('sf.student_id', '=', 'sc.student_id') + ->on('sf.class_section_id', '=', 'sc.class_section_id'); + }) + ->leftJoinSub($springScores, 'sp', function ($join): void { + $join->on('sp.student_id', '=', 'sc.student_id') + ->on('sp.class_section_id', '=', 'sc.class_section_id'); + }) + ->where('sc.school_year', $schoolYear) + ->groupBy( + 'sc.student_id', + 'sc.class_section_id', + 'cs.class_section_name', + 's.firstname', + 's.lastname', + 's.school_id', + 's.gender' + ) + ->orderBy('cs.class_section_name', 'ASC') + ->orderBy('s.firstname', 'ASC') + ->orderBy('s.lastname', 'ASC') + ->get() + ->map(function (object $row): array { + $student = (array) $row; + $fall = is_numeric($student['fall_score'] ?? null) ? round((float) $student['fall_score'], 1) : null; + $spring = is_numeric($student['spring_score'] ?? null) ? round((float) $student['spring_score'], 1) : null; + $year = ($fall !== null && $spring !== null) + ? round(($fall + $spring) / 2, 1) + : ($fall ?? $spring); + $firstname = trim((string) ($student['firstname'] ?? '')); + $lastname = trim((string) ($student['lastname'] ?? '')); + $name = trim($firstname.' '.$lastname); + + return [ + 'student_id' => (int) ($student['student_id'] ?? 0), + 'class_section_id' => (int) ($student['class_section_id'] ?? 0), + 'section_name' => (string) ($student['class_section_name'] ?? ''), + 'name' => $name !== '' ? $name : 'Student #'.($student['student_id'] ?? ''), + 'firstname' => $firstname !== '' ? $firstname : null, + 'lastname' => $lastname !== '' ? $lastname : null, + 'school_id' => $student['school_id'] ?? null, + 'gender' => (string) ($student['gender'] ?? ''), + 'fall_score' => $fall, + 'spring_score' => $spring, + 'year_score' => $year, + ]; + }); + } + + /** + * @param \Illuminate\Support\Collection> $rows + * @return array>}> + */ + private function sectionMap(Collection $rows): array + { + $sections = []; + + foreach ($rows as $row) { + $sectionId = (int) ($row['class_section_id'] ?? 0); + $name = trim((string) ($row['section_name'] ?? '')) ?: 'Section '.$sectionId; + + if (! isset($sections[$sectionId])) { + $sections[$sectionId] = [ + 'name' => $name, + 'students' => [], + ]; + } + + unset($row['section_name']); + $sections[$sectionId]['students'][] = $row; + } + + return $sections; + } + + /** + * @param list> $students + * @return list> + */ + private function sortByScore(array $students, string $scoreKey): array + { + usort($students, static function (array $left, array $right) use ($scoreKey): int { + $leftScore = $left[$scoreKey] ?? null; + $rightScore = $right[$scoreKey] ?? null; + + if ($leftScore === null && $rightScore === null) { + return strcmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? '')); + } + + if ($leftScore === null) { + return 1; + } + + if ($rightScore === null) { + return -1; + } + + return $rightScore <=> $leftScore ?: strcmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? '')); + }); + + return $students; + } + + /** + * @param list> $students + * @return array{0: list>, 1: list>} + */ + private function splitByBinaryGender(array $students): array + { + $boys = array_values(array_filter($students, fn (array $student): bool => $this->genderKey($student['gender'] ?? '') === 'boys')); + $girls = array_values(array_filter($students, fn (array $student): bool => $this->genderKey($student['gender'] ?? '') === 'girls')); + + return [$boys, $girls]; + } + + /** + * @param list> $classResults + * @return array + */ + private function projectionSummary(array $classResults): array + { + $totalStudents = array_sum(array_column($classResults, 'student_count')); + $totalScored = array_sum(array_column($classResults, 'scored_count')); + $totalTrophies = array_sum(array_column($classResults, 'trophy_count')); + $totalBoys = array_sum(array_column($classResults, 'boys')); + $totalGirls = array_sum(array_column($classResults, 'girls')); + $totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys')); + $totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls')); + + return [ + 'classes' => count($classResults), + 'students' => $totalStudents, + 'scored' => $totalScored, + 'trophies' => $totalTrophies, + 'boys' => $totalBoys, + 'girls' => $totalGirls, + 'trophy_boys' => $totalTrophyBoys, + 'trophy_girls' => $totalTrophyGirls, + 'pct_scored' => $totalStudents > 0 ? round($totalScored / $totalStudents * 100) : 0, + 'pct_boys' => $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0, + 'pct_girls' => $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0, + 'pct_trophies' => $totalStudents > 0 ? round($totalTrophies / $totalStudents * 100) : 0, + 'pct_trophy_boys' => $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0, + 'pct_trophy_girls' => $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0, + ]; + } + + /** + * @param list> $classResults + * @return array + */ + private function winnersSummary(array $classResults): array + { + $totalWinners = array_sum(array_map(static fn (array $classResult): int => count($classResult['winners'] ?? []), $classResults)); + $totalStudents = array_sum(array_column($classResults, 'student_count')); + $totalBoys = array_sum(array_column($classResults, 'boys')); + $totalGirls = array_sum(array_column($classResults, 'girls')); + $totalTrophyBoys = array_sum(array_column($classResults, 'trophy_boys')); + $totalTrophyGirls = array_sum(array_column($classResults, 'trophy_girls')); + + return [ + 'classes' => count($classResults), + 'students' => $totalStudents, + 'winners' => $totalWinners, + 'boys' => $totalBoys, + 'girls' => $totalGirls, + 'trophy_boys' => $totalTrophyBoys, + 'trophy_girls' => $totalTrophyGirls, + 'pct_winners' => $totalStudents > 0 ? round($totalWinners / $totalStudents * 100) : 0, + 'pct_boys' => $totalStudents > 0 ? round($totalBoys / $totalStudents * 100) : 0, + 'pct_girls' => $totalStudents > 0 ? round($totalGirls / $totalStudents * 100) : 0, + 'pct_trophy_boys' => $totalBoys > 0 ? round($totalTrophyBoys / $totalBoys * 100) : 0, + 'pct_trophy_girls' => $totalGirls > 0 ? round($totalTrophyGirls / $totalGirls * 100) : 0, + ]; + } + + /** + * @param list> $classResults + * @return array + */ + private function finalSummary(array $classResults): array + { + $totalStudents = array_sum(array_column($classResults, 'student_count')); + $totalPredicted = array_sum(array_column($classResults, 'predicted_count')); + $totalActual = array_sum(array_column($classResults, 'actual_count')); + $totalConfirmed = array_sum(array_column($classResults, 'confirmed')); + $totalSurprises = array_sum(array_column($classResults, 'surprises')); + $totalMissed = array_sum(array_column($classResults, 'missed')); + $overallAccuracy = $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : ($totalActual === 0 ? 100 : 0); + + return [ + 'classes' => count($classResults), + 'students' => $totalStudents, + 'predicted' => $totalPredicted, + 'actual' => $totalActual, + 'confirmed' => $totalConfirmed, + 'surprises' => $totalSurprises, + 'missed' => $totalMissed, + 'accuracy' => $overallAccuracy, + 'pct_predicted' => $totalStudents > 0 ? round($totalPredicted / $totalStudents * 100) : 0, + 'pct_actual' => $totalStudents > 0 ? round($totalActual / $totalStudents * 100) : 0, + 'pct_confirmed_of_predicted' => $totalPredicted > 0 ? round($totalConfirmed / $totalPredicted * 100) : 0, + ]; + } + + /** + * @param list> $students + * @return array + */ + private function genderBreakdown(array $students): array + { + $stats = ['boys' => 0, 'girls' => 0, 'other' => 0]; + + foreach ($students as $student) { + $stats[$this->genderKey($student['gender'] ?? '')]++; + } + + $total = array_sum($stats); + + return [ + 'total' => $total, + 'boys' => $stats['boys'], + 'girls' => $stats['girls'], + 'other' => $stats['other'], + 'pct_boys' => $total > 0 ? round(($stats['boys'] / $total) * 100, 1) : 0.0, + 'pct_girls' => $total > 0 ? round(($stats['girls'] / $total) * 100, 1) : 0.0, + 'pct_other' => $total > 0 ? round(($stats['other'] / $total) * 100, 1) : 0.0, + ]; + } + + private function genderKey(?string $gender): string + { + $value = strtolower(trim((string) $gender)); + + return match (true) { + in_array($value, ['male', 'm', 'boy', 'boys'], true) => 'boys', + in_array($value, ['female', 'f', 'girl', 'girls'], true) => 'girls', + default => 'other', + }; + } + + /** + * @param list $sortedScores + * @return array{threshold: float, winners: int, method: string} + */ + private function capByRank(array $sortedScores, int $max): array + { + $descending = array_reverse($sortedScores); + $threshold = $descending[$max - 1]; + $winners = $this->countAtOrAbove($sortedScores, $threshold); + + if ($winners <= $max) { + return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct']; + } + + $uniqueCandidates = array_values(array_unique(array_filter( + $sortedScores, + static fn (float $score): bool => $score > $threshold + ))); + sort($uniqueCandidates); + + foreach ($uniqueCandidates as $candidate) { + $winnerCount = $this->countAtOrAbove($sortedScores, $candidate); + + if ($winnerCount <= $max) { + return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct']; + } + } + + return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal']; + } + + /** + * @param list $sortedScores + */ + private function empiricalPercentile(array $sortedScores, float $percentile): float + { + $count = count($sortedScores); + + if ($count === 0) { + return 0.0; + } + + $index = ($percentile / 100.0) * ($count - 1); + $lower = (int) floor($index); + $upper = (int) ceil($index); + + if ($lower === $upper) { + return $sortedScores[$lower]; + } + + return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]); + } + + /** + * @param list $scores + */ + private function countAtOrAbove(array $scores, float $threshold): int + { + return count(array_filter($scores, static fn (float $score): bool => $score >= $threshold)); + } +} diff --git a/app/Services/Certificates/CertificateAdminService.php b/app/Services/Certificates/CertificateAdminService.php new file mode 100644 index 00000000..52e8668d --- /dev/null +++ b/app/Services/Certificates/CertificateAdminService.php @@ -0,0 +1,682 @@ +hasVerificationTokenColumn = Schema::hasTable('certificate_records') + && Schema::hasColumn('certificate_records', 'verification_token'); + } + + public function dashboard(?string $schoolYear = null): array + { + $selectedYear = $this->resolveSchoolYear($schoolYear); + + $allEnrolled = DB::table('student_class as sc') + ->select( + 's.id as student_id', + 's.firstname', + 's.lastname', + 'sc.class_section_id', + 'cs.class_section_name' + ) + ->join('students as s', 's.id', '=', 'sc.student_id') + ->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') + ->where('s.is_active', 1) + ->where('sc.school_year', $selectedYear) + ->orderBy('s.firstname', 'ASC') + ->orderBy('s.lastname', 'ASC') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + $studentIds = array_values(array_unique(array_map( + static fn (array $row): int => (int) ($row['student_id'] ?? 0), + $allEnrolled + ))); + $studentIds = array_values(array_filter($studentIds, static fn (int $id): bool => $id > 0)); + + $decisionsByStudent = []; + if ($studentIds !== []) { + $decisionRows = StudentDecision::query() + ->whereIn('student_id', $studentIds) + ->where('school_year', $selectedYear) + ->get(); + + foreach ($decisionRows as $decisionRow) { + $sid = (int) ($decisionRow->student_id ?? 0); + if ($sid <= 0) { + continue; + } + + $decision = trim((string) ($decisionRow->decision ?? '')); + $source = trim((string) ($decisionRow->source ?? '')); + if ($source === '') { + $source = $decision === '' ? 'pending' : 'manual'; + } + + $decisionsByStudent[$sid][] = [ + 'decision' => $decision, + 'source' => $source, + 'notes' => (string) ($decisionRow->notes ?? ''), + 'year_score' => is_numeric($decisionRow->year_score) ? round((float) $decisionRow->year_score, 2) : null, + ]; + } + } + + $certsByStudent = []; + if ($studentIds !== []) { + $certRows = DB::table('certificate_records') + ->select('student_id', 'certificate_number', 'issued_at') + ->whereIn('student_id', $studentIds) + ->where('school_year', $selectedYear) + ->orderByDesc('issued_at') + ->get(); + + foreach ($certRows as $record) { + $sid = (int) ($record->student_id ?? 0); + if ($sid > 0 && ! isset($certsByStudent[$sid])) { + $certsByStudent[$sid] = (string) ($record->certificate_number ?? ''); + } + } + } + + $statsPerClass = []; + $studentsByClass = []; + + foreach ($allEnrolled as $row) { + $sid = (int) ($row['student_id'] ?? 0); + $classSectionId = (int) ($row['class_section_id'] ?? 0); + $classSectionName = (string) ($row['class_section_name'] ?? ('Section '.$classSectionId)); + + if (! isset($statsPerClass[$classSectionId])) { + $statsPerClass[$classSectionId] = [ + 'name' => $classSectionName, + 'total' => 0, + 'pass' => 0, + 'cert' => 0, + ]; + } + + $statsPerClass[$classSectionId]['total']++; + + $studentDecisionRows = $decisionsByStudent[$sid] ?? []; + $decisionState = $this->normalizeDecisionState($studentDecisionRows); + + if ($decisionState['eligible']) { + $statsPerClass[$classSectionId]['pass']++; + } + + $certificateNumber = $certsByStudent[$sid] ?? null; + if ($certificateNumber !== null && $certificateNumber !== '') { + $statsPerClass[$classSectionId]['cert']++; + } + + $studentsByClass[$classSectionId][] = [ + 'student_id' => $sid, + 'firstname' => (string) ($row['firstname'] ?? ''), + 'lastname' => (string) ($row['lastname'] ?? ''), + 'class_section_id' => $classSectionId, + 'class_section_name' => $classSectionName, + 'year_score' => $decisionState['year_score'], + 'eligible' => $decisionState['eligible'], + 'decision_state' => $decisionState['status'], + 'decision_labels' => $decisionState['display_decisions'], + 'decision_rows' => $studentDecisionRows, + 'certificate_number' => $certificateNumber, + ]; + } + + $gradeGroups = $this->buildGradeGroups($statsPerClass, $studentsByClass); + + return [ + 'school_year' => $selectedYear, + 'cert_date' => date('m/d/Y'), + 'grade_groups' => array_values($gradeGroups), + 'stats_per_class' => $statsPerClass, + 'default_group_key' => array_key_first($gradeGroups), + ]; + } + + public function auditLog(?string $schoolYear = null): array + { + $selectedYear = trim((string) ($schoolYear ?? '')); + + $recordsQuery = DB::table('certificate_records as cr') + ->select( + 'cr.*', + 'u.firstname as admin_firstname', + 'u.lastname as admin_lastname' + ) + ->leftJoin('users as u', 'u.id', '=', 'cr.issued_by') + ->orderByDesc('cr.issued_at'); + + if ($selectedYear !== '') { + $recordsQuery->where('cr.school_year', $selectedYear); + } + + $records = $recordsQuery->get()->map(fn ($row) => (array) $row)->all(); + + $yearSummary = DB::table('certificate_records') + ->select('school_year', DB::raw('COUNT(*) as total')) + ->groupBy('school_year') + ->orderBy('school_year', 'DESC') + ->get() + ->map(fn ($row) => (array) $row) + ->all(); + + return [ + 'school_year' => $selectedYear, + 'records' => $records, + 'year_summary' => $yearSummary, + ]; + } + + /** + * @param list $studentIds + * @return array{students: list>, cert_date_display: string, school_year: string} + */ + public function issueCertificates( + array $studentIds, + string $certDate, + ?int $classSectionId, + ?string $schoolYear, + ?int $issuedBy + ): array { + $studentIds = array_values(array_filter(array_map('intval', $studentIds), static fn (int $id): bool => $id > 0)); + if ($studentIds === []) { + throw new \InvalidArgumentException('Please select at least one student.'); + } + + $selectedYear = $this->resolveSchoolYear($schoolYear); + $certDateDisplay = $this->normalizeDisplayCertDate($certDate); + $certDateStorage = $this->parseCertDate($certDateDisplay); + $students = $this->fetchStudentsForIssuance($studentIds, $classSectionId, $selectedYear); + + if ($students === []) { + throw new \RuntimeException('No valid students found.'); + } + + $rosterMap = []; + foreach ($students as $student) { + $rosterMap[(int) $student['id']] = $student; + } + + $existingByStudent = DB::table('certificate_records') + ->whereIn('student_id', array_keys($rosterMap)) + ->where('school_year', $selectedYear) + ->orderByDesc('issued_at') + ->get() + ->map(fn ($row) => (array) $row) + ->groupBy('student_id') + ->map(fn ($rows) => $rows[0]) + ->all(); + + $baseCount = (int) DB::table('certificate_records') + ->where('school_year', $selectedYear) + ->count(); + $nextOffset = 0; + + DB::beginTransaction(); + + try { + foreach ($students as &$student) { + $sid = (int) ($student['id'] ?? 0); + $existing = $existingByStudent[$sid] ?? null; + + if (is_array($existing)) { + $student['cert_number'] = (string) ($existing['certificate_number'] ?? ''); + $student['verify_token'] = $this->ensureVerificationToken($existing); + } else { + $nextOffset++; + $certificateNumber = $this->formatCertificateNumber($selectedYear, $baseCount + $nextOffset); + $verificationToken = $this->hasVerificationTokenColumn ? $this->generateVerificationToken() : null; + $grade = $this->formatGrade((string) ($student['grade'] ?? '')); + $studentName = trim((string) ($student['firstname'] ?? '').' '.(string) ($student['lastname'] ?? '')); + + $payload = [ + 'certificate_number' => $certificateNumber, + 'student_id' => $sid, + 'student_name' => $studentName, + 'grade' => $grade, + 'cert_date' => $certDateStorage, + 'school_year' => $selectedYear, + 'class_section_id' => $classSectionId, + 'issued_by' => $issuedBy, + 'issued_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]; + + if ($this->hasVerificationTokenColumn) { + $payload['verification_token'] = $verificationToken; + } + + DB::table('certificate_records')->insert($payload); + + $student['cert_number'] = $certificateNumber; + $student['verify_token'] = $verificationToken; + } + + $student['grade'] = $this->formatGrade((string) ($student['grade'] ?? '')); + $student['verify_url'] = ! empty($student['verify_token']) + ? url('/api/certificates/verify/'.rawurlencode((string) $student['verify_token'])) + : null; + } + unset($student); + + DB::commit(); + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + + return [ + 'students' => $students, + 'cert_date_display' => $certDateDisplay, + 'school_year' => $selectedYear, + ]; + } + + /** + * @return array{student: array, cert_date_display: string}|null + */ + public function reprintPayload(string $certificateNumber): ?array + { + $record = DB::table('certificate_records') + ->where('certificate_number', strtoupper(trim($certificateNumber))) + ->first(); + + if (! $record) { + return null; + } + + $row = (array) $record; + $studentName = trim((string) ($row['student_name'] ?? '')); + [$firstname, $lastname] = $this->splitStudentName($studentName); + $verifyToken = $this->ensureVerificationToken($row); + + return [ + 'student' => [ + 'id' => (int) ($row['student_id'] ?? 0), + 'firstname' => $firstname, + 'lastname' => $lastname, + 'grade' => (string) ($row['grade'] ?? ''), + 'cert_number' => (string) ($row['certificate_number'] ?? ''), + 'verify_token' => $verifyToken, + 'verify_url' => $verifyToken !== '' ? url('/api/certificates/verify/'.rawurlencode($verifyToken)) : null, + ], + 'cert_date_display' => $this->displayDateFromStorage($row['cert_date'] ?? null), + ]; + } + + /** + * @return array|null + */ + public function verifyPayload(string $tokenOrNumber): ?array + { + $lookup = strtoupper(trim($tokenOrNumber)); + + $query = DB::table('certificate_records as cr') + ->select( + 'cr.certificate_number', + 'cr.student_name', + 'cr.grade', + 'cr.cert_date', + 'cr.school_year', + 'cr.issued_at', + 'u.firstname as admin_firstname', + 'u.lastname as admin_lastname' + ) + ->leftJoin('users as u', 'u.id', '=', 'cr.issued_by'); + + if ($this->hasVerificationTokenColumn) { + $query->where(function ($builder) use ($tokenOrNumber, $lookup): void { + $builder->where('cr.verification_token', trim($tokenOrNumber)) + ->orWhere('cr.certificate_number', $lookup); + }); + } else { + $query->where('cr.certificate_number', $lookup); + } + + $record = $query->first(); + if (! $record) { + return null; + } + + return (array) $record; + } + + private function resolveSchoolYear(?string $schoolYear): string + { + $selectedYear = trim((string) ($schoolYear ?? '')); + + if ($selectedYear !== '') { + return $selectedYear; + } + + return trim((string) (Configuration::getConfig('school_year') ?? '')); + } + + /** + * @param array $statsPerClass + * @param array>> $studentsByClass + * @return array> + */ + private function buildGradeGroups(array $statsPerClass, array $studentsByClass): array + { + $gradeGroups = []; + + foreach ($statsPerClass as $classSectionId => $stats) { + [$label, $sortKey, $slug] = $this->classGroupMeta((string) ($stats['name'] ?? '')); + + if (! isset($gradeGroups[$sortKey])) { + $gradeGroups[$sortKey] = [ + 'key' => $sortKey, + 'label' => $label, + 'slug' => $slug, + 'total' => 0, + 'pass' => 0, + 'cert' => 0, + 'sections' => [], + ]; + } + + $total = (int) ($stats['total'] ?? 0); + $pass = (int) ($stats['pass'] ?? 0); + $cert = (int) ($stats['cert'] ?? 0); + + $gradeGroups[$sortKey]['total'] += $total; + $gradeGroups[$sortKey]['pass'] += $pass; + $gradeGroups[$sortKey]['cert'] += $cert; + $gradeGroups[$sortKey]['sections'][] = [ + 'section_id' => (int) $classSectionId, + 'section_name' => (string) ($stats['name'] ?? ''), + 'student_count' => $total, + 'pass_count' => $pass, + 'cert_count' => $cert, + 'remaining_count' => max(0, $pass - $cert), + 'students' => $studentsByClass[$classSectionId] ?? [], + ]; + } + + ksort($gradeGroups); + + foreach ($gradeGroups as &$group) { + $group['fully_done'] = $group['pass'] > 0 && $group['cert'] >= $group['pass']; + $group['has_pass'] = $group['pass'] > 0; + } + unset($group); + + return $gradeGroups; + } + + /** + * @param list> $studentDecisionRows + * @return array{eligible: bool, status: string, display_decisions: list, year_score: float|null} + */ + private function normalizeDecisionState(array $studentDecisionRows): array + { + if ($studentDecisionRows === []) { + return [ + 'eligible' => false, + 'status' => 'pending', + 'display_decisions' => [], + 'year_score' => null, + ]; + } + + $allDecisions = array_map( + static fn (array $row): string => trim((string) ($row['decision'] ?? '')), + $studentDecisionRows + ); + + $hasPending = in_array('', $allDecisions, true); + $unique = array_values(array_unique(array_filter( + $allDecisions, + static fn (string $decision): bool => $decision !== '' + ))); + + $eligible = ! $hasPending && $unique === ['Pass']; + $displayDecisions = $eligible + ? ['Pass'] + : array_values(array_filter($unique, static fn (string $decision): bool => $decision !== 'Pass')); + + $yearScore = null; + foreach ($studentDecisionRows as $row) { + if (array_key_exists('year_score', $row) && $row['year_score'] !== null && $row['year_score'] !== '') { + $yearScore = round((float) $row['year_score'], 2); + break; + } + } + + return [ + 'eligible' => $eligible, + 'status' => ($hasPending || $displayDecisions === []) ? 'pending' : ($eligible ? 'pass' : 'decision'), + 'display_decisions' => $displayDecisions, + 'year_score' => $yearScore, + ]; + } + + /** + * @param list $studentIds + * @return list> + */ + private function fetchStudentsForIssuance(array $studentIds, ?int $classSectionId, string $schoolYear): array + { + $dashboard = $this->dashboard($schoolYear); + $eligibleMap = []; + + foreach ($dashboard['grade_groups'] as $group) { + foreach (($group['sections'] ?? []) as $section) { + foreach (($section['students'] ?? []) as $student) { + $sid = (int) ($student['student_id'] ?? 0); + if ($sid > 0) { + $eligibleMap[$sid] = $student; + } + } + } + } + + $filtered = []; + foreach ($studentIds as $studentId) { + $student = $eligibleMap[$studentId] ?? null; + if (! is_array($student) || empty($student['eligible'])) { + continue; + } + + if ($classSectionId !== null && (int) ($student['class_section_id'] ?? 0) !== $classSectionId) { + continue; + } + + $filtered[] = [ + 'id' => $studentId, + 'firstname' => (string) ($student['firstname'] ?? ''), + 'lastname' => (string) ($student['lastname'] ?? ''), + 'grade' => (string) ($student['class_section_name'] ?? ''), + ]; + } + + return $filtered; + } + + private function formatGrade(string $raw): string + { + $clean = trim($raw); + $lower = strtolower($clean); + + if (preg_match('/^grade\s*(\d+)/i', $clean, $matches)) { + return 'Grade '.(int) $matches[1]; + } + + if ($lower === 'youth') { + return 'Youth'; + } + + if ($lower === 'kg' || $lower === 'kindergarten') { + return 'Kindergarten'; + } + + if (preg_match('/^(\d+)([- ][A-Za-z0-9]+)?$/', $clean, $matches)) { + return 'Grade '.(int) $matches[1]; + } + + return $clean; + } + + private function normalizeDisplayCertDate(string $certDate): string + { + $clean = trim($certDate); + if ($clean === '') { + return date('m/d/Y'); + } + + if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $clean) === 1) { + $timestamp = strtotime($clean); + if ($timestamp !== false) { + return date('m/d/Y', $timestamp); + } + } + + if (preg_match('#^\d{2}/\d{2}/\d{4}$#', $clean) === 1) { + return $clean; + } + + return date('m/d/Y'); + } + + private function parseCertDate(string $certDate): ?string + { + if (preg_match('#^(\d{2})/(\d{2})/(\d{4})$#', $certDate, $matches) === 1) { + return $matches[3].'-'.$matches[1].'-'.$matches[2]; + } + + if (preg_match('#^\d{4}-\d{2}-\d{2}$#', $certDate) === 1) { + return $certDate; + } + + return null; + } + + private function displayDateFromStorage(mixed $value): string + { + if ($value === null || trim((string) $value) === '') { + return date('m/d/Y'); + } + + $timestamp = strtotime((string) $value); + + return $timestamp !== false ? date('m/d/Y', $timestamp) : date('m/d/Y'); + } + + private function formatCertificateNumber(string $schoolYear, int $sequence): string + { + return 'ARSS-'.$schoolYear.'-'.str_pad((string) $sequence, 4, '0', STR_PAD_LEFT); + } + + private function generateVerificationToken(): string + { + if (! $this->hasVerificationTokenColumn) { + return ''; + } + + do { + $token = bin2hex(random_bytes(16)); + $exists = DB::table('certificate_records') + ->where('verification_token', $token) + ->exists(); + } while ($exists); + + return $token; + } + + /** + * @param array $record + */ + private function ensureVerificationToken(array $record): string + { + if (! $this->hasVerificationTokenColumn) { + return ''; + } + + $token = trim((string) ($record['verification_token'] ?? '')); + if ($token !== '') { + return $token; + } + + $recordId = (int) ($record['id'] ?? 0); + if ($recordId <= 0) { + return ''; + } + + $token = $this->generateVerificationToken(); + DB::table('certificate_records') + ->where('id', $recordId) + ->update([ + 'verification_token' => $token, + 'updated_at' => now(), + ]); + + return $token; + } + + /** + * @return array{0: string, 1: string, 2: string} + */ + private function classGroupMeta(string $name): array + { + $lower = strtolower(trim($name)); + + if (preg_match('/grade\s*(\d+)/i', $name, $matches) === 1) { + $grade = (int) $matches[1]; + return [ + 'Grade '.$grade, + '1_'.str_pad((string) $grade, 3, '0', STR_PAD_LEFT), + 'grade'.$grade, + ]; + } + + if (str_contains($lower, 'kg') || str_contains($lower, 'kindergarten')) { + return ['KG', '0_kg', 'kg']; + } + + if (str_contains($lower, 'arabic')) { + return ['Arabic', '2_arabic', 'arabic']; + } + + if (str_contains($lower, 'youth')) { + return ['Youth', '5_youth', 'youth']; + } + + return [ + $name, + '3_'.$lower, + (string) preg_replace('/[^a-z0-9]+/', '-', $lower), + ]; + } + + /** + * @return array{0: string, 1: string} + */ + private function splitStudentName(string $studentName): array + { + $parts = preg_split('/\s+/', trim($studentName), 2) ?: []; + + return [ + $parts[0] ?? '', + $parts[1] ?? '', + ]; + } +} diff --git a/app/Services/Certificates/CertificatePdfService.php b/app/Services/Certificates/CertificatePdfService.php index a7e00507..8bc5cce2 100644 --- a/app/Services/Certificates/CertificatePdfService.php +++ b/app/Services/Certificates/CertificatePdfService.php @@ -16,7 +16,7 @@ class CertificatePdfService } /** - * @param array $students + * @param array> $students */ public function generate(array $students, string $certDate): string { @@ -41,7 +41,22 @@ class CertificatePdfService $pdf->AddPage(); $name = $student['firstname'] . ' ' . $student['lastname']; $grade = $this->formatGrade((string) ($student['grade'] ?? '')); - $this->drawCertificate($pdf, $W, $H, $name, $grade, $certDate, $edwardianFont, $garamondBold, $ebGaramond); + $certNumber = (string) ($student['cert_number'] ?? ''); + $verifyUrl = is_string($student['verify_url'] ?? null) ? $student['verify_url'] : null; + + $this->drawCertificate( + $pdf, + $W, + $H, + $name, + $grade, + $certDate, + $certNumber, + $verifyUrl, + $edwardianFont, + $garamondBold, + $ebGaramond + ); } return (string) $pdf->Output('', 'S'); @@ -54,28 +69,37 @@ class CertificatePdfService string $name, string $grade, string $certDate, + string $certNumber, + ?string $verifyUrl, string $edwardianFont, string $garamondBold, string $ebGaramond ): void { $pdf->Image($this->imgDir . 'title.png', 126, 0, 600); $pdf->Image($this->imgDir . 'background.png', 280, 176, 291, 340); - $pdf->Image($this->imgDir . 'signature.png', 140, 399, 90, 90); + $pdf->Image($this->imgDir . 'signature.png', 140, 410, 90, 80); $pdf->SetFont('times', 'B', 24); $pdf->SetTextColor(0, 0, 0); $pdf->SetXY(0, 151); $pdf->Cell($W, 24, 'Presented to:', 0, 0, 'C'); + if (! empty($verifyUrl)) { + $style = [ + 'border' => false, + 'padding' => 0, + 'fgcolor' => [0, 0, 0], + 'bgcolor' => false, + ]; + $pdf->write2DBarcode($verifyUrl, 'QRCODE,L', 120, 162, 42, 42, $style, 'N'); + } + $pdf->SetFont($edwardianFont, '', 38); - $pdf->SetDrawColor(0, 0, 0); - $pdf->setTextRenderingMode(0.4, true, false); - $pdf->SetXY(0, 219); - $pdf->Cell($W, 38, $name, 0, 0, 'C'); - $pdf->setTextRenderingMode(0, true, false); + $nameX = ($W - $pdf->GetStringWidth($name)) / 2; + $this->drawGradientText($pdf, $edwardianFont, 38, $name, $nameX, 221.5); $pdf->SetFont('times', '', 20); - $pdf->SetXY(0, 243); + $pdf->SetXY(0, 236); $pdf->Cell(600, 20, '___________________________________', 0, 0, 'R'); $pdf->SetFont($ebGaramond, '', 20); @@ -94,7 +118,7 @@ class CertificatePdfService $pdf->SetXY(0, 375); $pdf->Cell($W, 20, 'Al Rahma Sunday School', 0, 0, 'C'); - $this->drawGradientText($pdf, $edwardianFont, 26, $certDate, 598, 453); + $this->drawGradientText($pdf, $edwardianFont, 26, $certDate, 586, 456); $pdf->SetFont('times', '', 20); $pdf->SetXY(0, 463); @@ -106,6 +130,14 @@ class CertificatePdfService $pdf->Cell(200, 20, '_________________', 0, 0, 'L'); $pdf->SetXY(106, 492); $pdf->Cell(168, 20, 'Signature', 0, 0, 'C'); + + if ($certNumber !== '') { + $pdf->SetFont('helvetica', '', 8); + $pdf->SetTextColor(150, 150, 150); + $pdf->SetXY(70.86, $H - 39.68); + $pdf->Cell(200, 12, 'Certificate No. ' . $certNumber, 0, 0, 'L'); + $pdf->SetTextColor(0, 0, 0); + } } private function drawGradientText(\TCPDF $pdf, string $fontName, float $fontSize, string $text, float $x, float $y): void diff --git a/app/Services/Expenses/ExpenseQueryService.php b/app/Services/Expenses/ExpenseQueryService.php index 50804d96..799f4a53 100644 --- a/app/Services/Expenses/ExpenseQueryService.php +++ b/app/Services/Expenses/ExpenseQueryService.php @@ -3,7 +3,6 @@ namespace App\Services\Expenses; use App\Models\Expense; -use Illuminate\Support\Facades\DB; class ExpenseQueryService { @@ -12,7 +11,7 @@ class ExpenseQueryService return $this->baseQuery() ->orderByDesc('expenses.created_at') ->get() - ->map(fn ($row) => (array) $row) + ->map(fn ($row) => $row->toArray()) ->all(); } @@ -22,7 +21,7 @@ class ExpenseQueryService ->where('expenses.id', $id) ->first(); - return $row ? (array) $row : null; + return $row ? $row->toArray() : null; } private function baseQuery() diff --git a/app/Services/Finance/FinanceNotificationLogService.php b/app/Services/Finance/FinanceNotificationLogService.php new file mode 100644 index 00000000..33229701 --- /dev/null +++ b/app/Services/Finance/FinanceNotificationLogService.php @@ -0,0 +1,41 @@ +create(array_merge($payload, [ + 'status' => $status, + 'sent_at' => $status === 'sent' ? now() : null, + 'failed_at' => $status === 'failed' ? now() : null, + 'created_by' => $actorId, + ])); + return $log->toArray(); + } + + public function list(array $filters): array + { + $q = FinanceNotificationLog::query(); + foreach (['status','notification_type','parent_id'] as $field) if (!empty($filters[$field])) $q->where($field, $filters[$field]); + if (!empty($filters['date_from'])) $q->whereDate('created_at', '>=', $filters['date_from']); + if (!empty($filters['date_to'])) $q->whereDate('created_at', '<=', $filters['date_to']); + return ['rows' => $q->orderByDesc('id')->paginate((int)($filters['per_page'] ?? 25))]; + } + + public function nextReceiptNumber(string $schoolYear): string + { + return DB::transaction(function () use ($schoolYear) { + $seq = FinanceReceiptSequence::query()->lockForUpdate()->firstOrCreate(['school_year' => $schoolYear], ['prefix' => 'R', 'next_number' => 1]); + $number = $seq->prefix . '-' . $schoolYear . '-' . str_pad((string) $seq->next_number, 6, '0', STR_PAD_LEFT); + $seq->next_number = $seq->next_number + 1; + $seq->save(); + return $number; + }); + } +} diff --git a/app/Services/Finance/InstallmentPlanService.php b/app/Services/Finance/InstallmentPlanService.php new file mode 100644 index 00000000..aa949ca9 --- /dev/null +++ b/app/Services/Finance/InstallmentPlanService.php @@ -0,0 +1,136 @@ +where($field, $filters[$field]); + } + return ['rows' => $q->orderByDesc('id')->paginate((int)($filters['per_page'] ?? 25))]; + } + + public function createForInvoice(int $invoiceId, array $payload, ?int $actorId): array + { + return DB::transaction(function () use ($invoiceId, $payload, $actorId) { + $invoice = DB::table('invoices')->where('id', $invoiceId)->first(); + abort_if(!$invoice, 404, 'Invoice not found.'); + $total = (float) ($payload['total_amount'] ?? ($invoice->balance ?? $invoice->total_amount ?? 0)); + $count = (int) $payload['number_of_installments']; + $plan = InvoiceInstallmentPlan::query()->create([ + 'invoice_id' => $invoiceId, + 'parent_id' => $invoice->parent_id, + 'school_year' => $invoice->school_year ?? null, + 'plan_name' => $payload['plan_name'] ?? ('Invoice #' . $invoiceId . ' installment plan'), + 'total_amount' => $total, + 'number_of_installments' => $count, + 'status' => 'draft', + 'created_by' => $actorId, + ]); + + $firstDue = Carbon::parse($payload['first_due_date']); + $interval = (int) ($payload['interval_months'] ?? 1); + $base = floor(($total / $count) * 100) / 100; + $allocated = 0.0; + for ($i = 1; $i <= $count; $i++) { + $amount = $i === $count ? round($total - $allocated, 2) : $base; + $allocated += $amount; + InvoiceInstallment::query()->create([ + 'installment_plan_id' => $plan->id, + 'invoice_id' => $invoiceId, + 'sequence' => $i, + 'due_date' => $firstDue->copy()->addMonths(($i - 1) * $interval)->toDateString(), + 'amount_due' => $amount, + 'amount_paid' => 0, + 'balance' => $amount, + 'status' => 'pending', + ]); + } + + return $this->show($plan->id); + }); + } + + public function show(int $id): array + { + $plan = InvoiceInstallmentPlan::query()->findOrFail($id)->toArray(); + $plan['installments'] = InvoiceInstallment::query()->where('installment_plan_id', $id)->orderBy('sequence')->get()->toArray(); + return $plan; + } + + public function activate(int $id, ?int $actorId): array + { + $plan = InvoiceInstallmentPlan::query()->findOrFail($id); + $plan->fill(['status' => 'active', 'approved_by' => $actorId])->save(); + InvoiceInstallment::query()->where('installment_plan_id', $id)->where('status', 'pending')->update(['status' => 'due']); + return $this->show($id); + } + + public function cancel(int $id): array + { + InvoiceInstallmentPlan::query()->whereKey($id)->update(['status' => 'cancelled']); + InvoiceInstallment::query()->where('installment_plan_id', $id)->whereNotIn('status', ['paid'])->update(['status' => 'cancelled']); + return $this->show($id); + } + + public function allocatePayment(int $paymentId, array $payload): array + { + return DB::transaction(function () use ($paymentId, $payload) { + $payment = DB::table('payments')->where('id', $paymentId)->first(); + abort_if(!$payment, 404, 'Payment not found.'); + $allocations = $payload['installments'] ?? null; + if (!$allocations) { + $remaining = (float) ($payment->paid_amount ?? 0); + $installments = InvoiceInstallment::query()->where('invoice_id', $payment->invoice_id)->where('balance', '>', 0)->orderBy('due_date')->lockForUpdate()->get(); + $allocations = []; + foreach ($installments as $inst) { + if ($remaining <= 0) break; + $amount = min($remaining, (float) $inst->balance); + $allocations[] = ['installment_id' => $inst->id, 'amount' => $amount]; + $remaining -= $amount; + } + } + + $created = []; + foreach ($allocations as $a) { + $inst = InvoiceInstallment::query()->lockForUpdate()->findOrFail($a['installment_id']); + $amount = min((float) $a['amount'], (float) $inst->balance); + if ($amount <= 0) continue; + $created[] = PaymentInstallmentAllocation::query()->create([ + 'payment_id' => $paymentId, + 'invoice_id' => $inst->invoice_id, + 'installment_id' => $inst->id, + 'amount' => $amount, + ])->toArray(); + $inst->amount_paid = (float) $inst->amount_paid + $amount; + $inst->balance = max(0, (float) $inst->amount_due - (float) $inst->amount_paid); + $inst->status = $inst->balance <= 0 ? 'paid' : 'partially_paid'; + $inst->paid_at = $inst->balance <= 0 ? now() : null; + $inst->save(); + } + return ['allocations' => $created]; + }); + } + + public function due(bool $overdue = false): array + { + $q = InvoiceInstallment::query()->where('balance', '>', 0); + $overdue ? $q->whereDate('due_date', '<', now()->toDateString()) : $q->whereDate('due_date', '>=', now()->toDateString()); + return ['rows' => $q->orderBy('due_date')->get()->toArray()]; + } + + public function markOverdue(): int + { + return InvoiceInstallment::query()->where('balance', '>', 0)->whereDate('due_date', '<', now()->toDateString())->whereNotIn('status', ['paid','cancelled','waived'])->update(['status' => 'overdue']); + } +} diff --git a/app/Services/Finance/ParentPaymentFollowUpService.php b/app/Services/Finance/ParentPaymentFollowUpService.php new file mode 100644 index 00000000..fc084be1 --- /dev/null +++ b/app/Services/Finance/ParentPaymentFollowUpService.php @@ -0,0 +1,239 @@ +invoiceRows($schoolYear, $filters); + $paidByInvoice = $this->paymentTotalsByInvoice($schoolYear); + $discountByInvoice = $this->discountTotalsByInvoice(); + $refundByInvoice = $this->refundTotalsByInvoice(); + $latestNotes = $this->latestNotesByParent($schoolYear); + $students = $this->studentsByParent(); + + $parents = []; + foreach ($invoiceRows as $invoice) { + $invoiceId = (int) ($invoice->id ?? 0); + $parentId = (int) ($invoice->parent_id ?? 0); + if ($parentId <= 0) { + continue; + } + + $total = (float) ($invoice->total_amount ?? 0); + $paid = (float) ($paidByInvoice[$invoiceId] ?? 0); + $discounted = (float) ($discountByInvoice[$invoiceId] ?? 0); + $refunded = (float) ($refundByInvoice[$invoiceId] ?? 0); + $open = max(0, $total - $paid - $discounted - $refunded); + if (!$includePaid && $open <= 0) { + continue; + } + + if (!isset($parents[$parentId])) { + $parents[$parentId] = [ + 'parent_id' => $parentId, + 'parent_name' => $invoice->parent_name ?? null, + 'parent_email' => $invoice->parent_email ?? null, + 'parent_phone' => $invoice->parent_phone ?? null, + 'student_names' => $students[$parentId] ?? [], + 'school_year' => $schoolYear, + 'invoice_count' => 0, + 'open_invoice_count' => 0, + 'total_invoiced' => 0.0, + 'total_paid' => 0.0, + 'total_discounted' => 0.0, + 'total_refunded' => 0.0, + 'open_balance' => 0.0, + 'oldest_unpaid_invoice_date' => null, + 'days_overdue' => 0, + 'aging_bucket' => 'current', + 'last_payment_date' => null, + 'last_payment_amount' => 0.0, + 'last_contacted_at' => null, + 'last_contacted_by' => null, + 'follow_up_status' => 'not_contacted', + 'next_follow_up_date' => null, + 'promise_to_pay_date' => null, + 'promise_to_pay_amount' => null, + 'escalation_level' => 0, + 'notes_count' => 0, + 'risk_flags' => [], + ]; + } + + $row =& $parents[$parentId]; + $row['invoice_count']++; + $row['total_invoiced'] += $total; + $row['total_paid'] += $paid; + $row['total_discounted'] += $discounted; + $row['total_refunded'] += $refunded; + $row['open_balance'] += $open; + if ($open > 0) { + $row['open_invoice_count']++; + $due = $invoice->due_date ?? $invoice->issue_date ?? $invoice->created_at ?? null; + if ($due && ($row['oldest_unpaid_invoice_date'] === null || strtotime((string) $due) < strtotime((string) $row['oldest_unpaid_invoice_date']))) { + $row['oldest_unpaid_invoice_date'] = substr((string) $due, 0, 10); + } + } + unset($row); + } + + foreach ($parents as $parentId => &$row) { + $note = $latestNotes[$parentId] ?? null; + if ($note) { + $row['last_contacted_at'] = $note->created_at ? (string) $note->created_at : null; + $row['last_contacted_by'] = $note->created_by ?? null; + $row['follow_up_status'] = $note->status ?? 'not_contacted'; + $row['next_follow_up_date'] = $note->next_follow_up_date ?? null; + $row['promise_to_pay_date'] = $note->promise_to_pay_date ?? null; + $row['promise_to_pay_amount'] = $note->promise_to_pay_amount !== null ? (float) $note->promise_to_pay_amount : null; + $row['escalation_level'] = (int) ($note->escalation_level ?? 0); + } + $row['notes_count'] = FinanceFollowUpNote::query()->where('parent_id', $parentId)->when($schoolYear, fn($q) => $q->where('school_year', $schoolYear))->count(); + $row['days_overdue'] = $this->daysOverdue($row['oldest_unpaid_invoice_date']); + $row['aging_bucket'] = $this->agingBucket($row['days_overdue']); + $row['risk_flags'] = $this->riskFlags($row); + } + unset($row); + + $rows = array_values(array_filter($parents, function (array $row) use ($filters, $minimumBalance) { + if ($row['open_balance'] < $minimumBalance) return false; + if (!empty($filters['aging_bucket']) && $row['aging_bucket'] !== $filters['aging_bucket']) return false; + if (!empty($filters['follow_up_status']) && $row['follow_up_status'] !== $filters['follow_up_status']) return false; + if (!empty($filters['next_follow_up_due_before']) && (empty($row['next_follow_up_date']) || strtotime($row['next_follow_up_date']) > strtotime($filters['next_follow_up_due_before']))) return false; + if (!empty($filters['promise_to_pay_due_before']) && (empty($row['promise_to_pay_date']) || strtotime($row['promise_to_pay_date']) > strtotime($filters['promise_to_pay_due_before']))) return false; + return true; + })); + + usort($rows, fn($a, $b) => [$b['open_balance'], $b['days_overdue']] <=> [$a['open_balance'], $a['days_overdue']]); + + return ['schoolYear' => $schoolYear, 'generatedAt' => now()->toISOString(), 'results' => $rows]; + } + + public function storeNote(int $parentId, array $payload, ?int $actorId): array + { + $note = FinanceFollowUpNote::query()->create([ + 'parent_id' => $parentId, + 'invoice_id' => $payload['invoice_id'] ?? null, + 'school_year' => $payload['school_year'] ?? null, + 'status' => $payload['status'] ?? 'contacted', + 'contact_method' => $payload['contact_method'] ?? null, + 'promise_to_pay_date' => $payload['promise_to_pay_date'] ?? null, + 'promise_to_pay_amount' => $payload['promise_to_pay_amount'] ?? null, + 'next_follow_up_date' => $payload['next_follow_up_date'] ?? null, + 'escalation_level' => $payload['escalation_level'] ?? 0, + 'note' => $payload['note'] ?? null, + 'created_by' => $actorId, + ]); + + return $note->toArray(); + } + + public function csvRows(array $report): array + { + $rows = [['Parent ID','Parent','Email','Phone','Students','School Year','Invoices','Open Invoices','Total Invoiced','Total Paid','Open Balance','Oldest Unpaid','Days Overdue','Aging','Status','Next Follow-Up','Promise Date','Promise Amount','Risk Flags']]; + foreach ($report['results'] as $r) { + $rows[] = [ + $r['parent_id'], $r['parent_name'], $r['parent_email'], $r['parent_phone'], implode('; ', $r['student_names'] ?? []), $r['school_year'], $r['invoice_count'], $r['open_invoice_count'], $r['total_invoiced'], $r['total_paid'], $r['open_balance'], $r['oldest_unpaid_invoice_date'], $r['days_overdue'], $r['aging_bucket'], $r['follow_up_status'], $r['next_follow_up_date'], $r['promise_to_pay_date'], $r['promise_to_pay_amount'], implode('; ', $r['risk_flags'] ?? []), + ]; + } + return $rows; + } + + private function invoiceRows(string $schoolYear, array $filters) + { + $q = DB::table('invoices')->where('invoices.school_year', $schoolYear); + if (!empty($filters['parent_id'])) $q->where('invoices.parent_id', $filters['parent_id']); + if (!empty($filters['date_from'])) $q->whereDate('invoices.created_at', '>=', $filters['date_from']); + if (!empty($filters['date_to'])) $q->whereDate('invoices.created_at', '<=', $filters['date_to']); + if (Schema::hasTable('users')) { + $name = $this->nameExpression('users'); + $q->leftJoin('users', 'users.id', '=', 'invoices.parent_id') + ->addSelect('invoices.*') + ->addSelect(DB::raw($name . ' as parent_name')) + ->addSelect('users.email as parent_email'); + if (Schema::hasColumn('users', 'phone')) $q->addSelect('users.phone as parent_phone'); + else $q->addSelect(DB::raw('NULL as parent_phone')); + } else { + $q->select('invoices.*', DB::raw('NULL as parent_name'), DB::raw('NULL as parent_email'), DB::raw('NULL as parent_phone')); + } + return $q->get(); + } + + private function paymentTotalsByInvoice(string $schoolYear): array + { + if (!Schema::hasTable('payments')) return []; + return DB::table('payments')->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) total'), DB::raw('MAX(payment_date) last_payment_date')) + ->where('school_year', $schoolYear)->whereNotIn('status', ['void','voided','failed','cancelled','canceled','reversed']) + ->groupBy('invoice_id')->pluck('total', 'invoice_id')->map(fn($v) => (float) $v)->all(); + } + + private function discountTotalsByInvoice(): array + { + if (!Schema::hasTable('discount_usages')) return []; + $col = Schema::hasColumn('discount_usages', 'discount_amount') ? 'discount_amount' : 'amount'; + return DB::table('discount_usages')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all(); + } + + private function refundTotalsByInvoice(): array + { + if (!Schema::hasTable('refunds')) return []; + $col = Schema::hasColumn('refunds', 'refund_amount') ? 'refund_amount' : (Schema::hasColumn('refunds', 'amount') ? 'amount' : null); + if (!$col) return []; + return DB::table('refunds')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->whereNotIn('status', ['void','voided','cancelled','canceled'])->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all(); + } + + private function latestNotesByParent(string $schoolYear): array + { + return FinanceFollowUpNote::query()->where('school_year', $schoolYear)->orderByDesc('created_at')->get()->unique('parent_id')->keyBy('parent_id')->all(); + } + + private function studentsByParent(): array + { + if (!Schema::hasTable('students') || !Schema::hasColumn('students', 'parent_id')) return []; + $name = $this->nameExpression('students'); + return DB::table('students')->select('parent_id', DB::raw($name.' as student_name'))->whereNotNull('parent_id')->get()->groupBy('parent_id')->map(fn($rows) => $rows->pluck('student_name')->filter()->values()->all())->all(); + } + + private function nameExpression(string $table): string + { + if (Schema::hasColumn($table, 'name')) return $table.'.name'; + if (Schema::hasColumn($table, 'full_name')) return $table.'.full_name'; + $driver = DB::connection()->getDriverName(); + $concat = $driver === 'sqlite' ? '%s || \' \' || %s' : "CONCAT(%s, ' ', %s)"; + if (Schema::hasColumn($table, 'firstname') && Schema::hasColumn($table, 'lastname')) return sprintf($concat, $table.'.firstname', $table.'.lastname'); + if (Schema::hasColumn($table, 'first_name') && Schema::hasColumn($table, 'last_name')) return sprintf($concat, $table.'.first_name', $table.'.last_name'); + return "CAST($table.id AS CHAR)"; + } + + private function daysOverdue(?string $date): int + { + if (!$date) return 0; + return max(0, now()->startOfDay()->diffInDays(\Carbon\Carbon::parse($date)->startOfDay(), false) * -1); + } + + private function agingBucket(int $days): string + { + return match (true) { $days <= 0 => 'current', $days <= 30 => '1-30 days overdue', $days <= 60 => '31-60 days overdue', $days <= 90 => '61-90 days overdue', default => '90+ days overdue' }; + } + + private function riskFlags(array $row): array + { + $flags = []; + if ($row['days_overdue'] > 90) $flags[] = '90+ days overdue'; + if ($row['open_balance'] > 1000) $flags[] = 'high balance'; + if ($row['promise_to_pay_date'] && strtotime($row['promise_to_pay_date']) < strtotime(date('Y-m-d'))) $flags[] = 'missed promise-to-pay'; + if (!$row['parent_email'] && !$row['parent_phone']) $flags[] = 'missing contact info'; + return $flags; + } +} diff --git a/app/Services/Finance/PriorYearBalanceCarryforwardService.php b/app/Services/Finance/PriorYearBalanceCarryforwardService.php new file mode 100644 index 00000000..66b4a53b --- /dev/null +++ b/app/Services/Finance/PriorYearBalanceCarryforwardService.php @@ -0,0 +1,180 @@ +paymentTotals($from); + $discounts = $this->discountTotals(); + $refunds = $this->refundTotals(); + + $q = DB::table('invoices')->where('school_year', $from); + if ($parentId) $q->where('parent_id', $parentId); + $invoices = $q->get(); + + $byParent = []; + foreach ($invoices as $invoice) { + $invoiceId = (int) $invoice->id; + $balance = max(0, (float) ($invoice->total_amount ?? 0) - (float) ($payments[$invoiceId] ?? 0) - (float) ($discounts[$invoiceId] ?? 0) - (float) ($refunds[$invoiceId] ?? 0)); + if ($balance <= 0) continue; + $pid = (int) $invoice->parent_id; + if (!isset($byParent[$pid])) { + $existing = FinanceBalanceCarryforward::query()->where('parent_id', $pid)->where('from_school_year', $from)->where('to_school_year', $to)->first(); + $byParent[$pid] = [ + 'parent_id' => $pid, + 'from_school_year' => $from, + 'to_school_year' => $to, + 'source_invoice_ids' => [], + 'source_balance_amount' => 0.0, + 'proposed_carryforward_amount' => 0.0, + 'existing_carryforward_id' => $existing?->id, + 'warnings' => [], + ]; + if ($existing) $byParent[$pid]['warnings'][] = 'source invoice already carried forward'; + } + $byParent[$pid]['source_invoice_ids'][] = $invoiceId; + $byParent[$pid]['source_balance_amount'] += $balance; + $byParent[$pid]['proposed_carryforward_amount'] += $balance; + } + + $rows = array_values(array_filter($byParent, fn($r) => $r['source_balance_amount'] >= $minimum)); + usort($rows, fn($a, $b) => $b['source_balance_amount'] <=> $a['source_balance_amount']); + + return ['fromSchoolYear' => $from, 'toSchoolYear' => $to, 'generatedAt' => now()->toISOString(), 'rows' => $rows]; + } + + public function storeDrafts(array $filters, ?int $actorId): array + { + $preview = $this->preview($filters); + $created = []; + foreach ($preview['rows'] as $row) { + if (!empty($row['existing_carryforward_id'])) continue; + $created[] = FinanceBalanceCarryforward::query()->create([ + 'parent_id' => $row['parent_id'], + 'from_school_year' => $row['from_school_year'], + 'to_school_year' => $row['to_school_year'], + 'source_invoice_ids_json' => $row['source_invoice_ids'], + 'source_balance_amount' => $row['source_balance_amount'], + 'carryforward_amount' => $row['proposed_carryforward_amount'], + 'remaining_amount' => $row['proposed_carryforward_amount'], + 'status' => 'draft', + 'reason' => $filters['reason'] ?? null, + 'created_by' => $actorId, + 'metadata_json' => ['warnings' => $row['warnings']], + ])->toArray(); + } + return ['created' => $created, 'skipped' => count($preview['rows']) - count($created)]; + } + + public function approve(int $id, ?int $actorId): array + { + $cf = FinanceBalanceCarryforward::query()->findOrFail($id); + $cf->fill(['status' => 'approved', 'approved_by' => $actorId, 'approved_at' => now()])->save(); + return $cf->fresh()->toArray(); + } + + public function waive(int $id, float $amount, ?string $reason, ?int $actorId): array + { + $cf = FinanceBalanceCarryforward::query()->findOrFail($id); + $waived = min((float) $cf->remaining_amount, $amount); + $cf->waived_amount = (float) $cf->waived_amount + $waived; + $cf->remaining_amount = max(0, (float) $cf->remaining_amount - $waived); + $cf->status = $cf->remaining_amount <= 0 ? 'waived' : $cf->status; + $cf->reason = $reason ?: $cf->reason; + $cf->approved_by = $cf->approved_by ?: $actorId; + $cf->save(); + return $cf->fresh()->toArray(); + } + + public function adjust(int $id, float $amount, ?string $reason): array + { + $cf = FinanceBalanceCarryforward::query()->findOrFail($id); + $cf->adjusted_amount = $amount; + $cf->remaining_amount = max(0, (float) $cf->carryforward_amount - (float) $cf->waived_amount + $amount); + $cf->reason = $reason ?: $cf->reason; + $cf->save(); + return $cf->fresh()->toArray(); + } + + public function postToNewYear(int $id, ?int $actorId): array + { + return DB::transaction(function () use ($id, $actorId) { + $cf = FinanceBalanceCarryforward::query()->lockForUpdate()->findOrFail($id); + if (!in_array($cf->status, ['approved','draft','pending_review'], true)) { + return $cf->toArray() + ['warning' => 'Carryforward is not in a postable status.']; + } + + $amount = max(0, (float) $cf->carryforward_amount - (float) $cf->waived_amount + (float) $cf->adjusted_amount); + $invoiceId = null; + if (Schema::hasTable('invoices')) { + $invoiceId = DB::table('invoices')->insertGetId([ + 'parent_id' => $cf->parent_id, + 'invoice_number' => 'CF-' . $cf->to_school_year . '-' . $cf->id, + 'total_amount' => $amount, + 'balance' => $amount, + 'paid_amount' => 0, + 'school_year' => $cf->to_school_year, + 'issue_date' => now()->toDateString(), + 'status' => $amount > 0 ? 'unpaid' : 'paid', + 'description' => 'Previous year balance from ' . $cf->from_school_year, + 'created_at' => now(), + 'updated_at' => now(), + 'updated_by' => $actorId, + ]); + } + $cf->posted_invoice_id = $invoiceId; + $cf->status = 'posted_to_new_year'; + $cf->remaining_amount = $amount; + $cf->save(); + return $cf->fresh()->toArray(); + }); + } + + public function report(array $filters): array + { + $q = FinanceBalanceCarryforward::query(); + foreach (['from_school_year','to_school_year','parent_id','status'] as $field) { + if (!empty($filters[$field])) $q->where($field, $filters[$field]); + } + return ['generatedAt' => now()->toISOString(), 'rows' => $q->orderByDesc('remaining_amount')->get()->toArray()]; + } + + public function csvRows(array $report): array + { + $rows = [['ID','Parent ID','From Year','To Year','Source Invoices','Source Balance','Carryforward','Waived','Adjusted','Remaining','Status','Posted Invoice']]; + foreach ($report['rows'] as $r) { + $rows[] = [$r['id'],$r['parent_id'],$r['from_school_year'],$r['to_school_year'],json_encode($r['source_invoice_ids_json'] ?? []),$r['source_balance_amount'],$r['carryforward_amount'],$r['waived_amount'],$r['adjusted_amount'],$r['remaining_amount'],$r['status'],$r['posted_invoice_id'] ?? null]; + } + return $rows; + } + + private function paymentTotals(string $schoolYear): array + { + if (!Schema::hasTable('payments')) return []; + return DB::table('payments')->where('school_year', $schoolYear)->whereNotIn('status', ['void','voided','failed','cancelled','canceled','reversed'])->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all(); + } + private function discountTotals(): array + { + if (!Schema::hasTable('discount_usages')) return []; + $col = Schema::hasColumn('discount_usages','discount_amount') ? 'discount_amount' : 'amount'; + return DB::table('discount_usages')->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all(); + } + private function refundTotals(): array + { + if (!Schema::hasTable('refunds')) return []; + $col = Schema::hasColumn('refunds','refund_amount') ? 'refund_amount' : (Schema::hasColumn('refunds','amount') ? 'amount' : null); + if (!$col) return []; + return DB::table('refunds')->whereNotIn('status', ['void','voided','cancelled','canceled'])->select('invoice_id', DB::raw('COALESCE(SUM('.$col.'),0) total'))->groupBy('invoice_id')->pluck('total','invoice_id')->map(fn($v)=>(float)$v)->all(); + } +} diff --git a/app/Services/Finance/StakeholderFinancialAnalysisService.php b/app/Services/Finance/StakeholderFinancialAnalysisService.php new file mode 100644 index 00000000..ab9415ef --- /dev/null +++ b/app/Services/Finance/StakeholderFinancialAnalysisService.php @@ -0,0 +1,509 @@ +resolveDateRange($schoolYear, $dateFrom, $dateTo); + + $current = $this->periodMetrics($schoolYear, $rangeFrom, $rangeTo); + $comparison = null; + if (!empty($compareSchoolYear)) { + [$compareFrom, $compareTo] = $this->resolveDateRange($compareSchoolYear, null, null); + $comparison = $this->periodMetrics($compareSchoolYear, $compareFrom, $compareTo); + } + + return [ + 'schoolYear' => $schoolYear, + 'dateFrom' => $rangeFrom, + 'dateTo' => $rangeTo, + 'generatedAt' => now()->toISOString(), + 'audience' => 'stakeholders', + 'status' => 'read_only_report', + 'executiveSummary' => $this->executiveSummary($current, $comparison), + 'keyMetrics' => $current['keyMetrics'], + 'cashFlow' => $current['cashFlow'], + 'receivables' => $current['receivables'], + 'expenseAnalysis' => $current['expenseAnalysis'], + 'revenueAnalysis' => $current['revenueAnalysis'], + 'paymentMethodMix' => $current['paymentMethodMix'], + 'monthlyTrend' => $current['monthlyTrend'], + 'riskFlags' => $current['riskFlags'], + 'comparison' => $comparison ? [ + 'schoolYear' => $compareSchoolYear, + 'keyMetrics' => $comparison['keyMetrics'], + 'deltas' => $this->deltas($current['keyMetrics'], $comparison['keyMetrics']), + ] : null, + 'notes' => [ + 'This report is additive and read-only. It does not mutate invoices, payments, refunds, expenses, reimbursements, or legacy PayPal records.', + 'Revenue is based on invoice and charge records; cash collection is based on non-voided payment records.', + 'Ratios are directional management indicators, not audited financial statements.', + ], + ]; + } + + public function csvRows(array $analysis): array + { + $rows = []; + $rows[] = ['Section', 'Metric', 'Value']; + foreach (($analysis['keyMetrics'] ?? []) as $key => $value) { + $rows[] = ['Key Metrics', $key, is_scalar($value) ? $value : json_encode($value)]; + } + foreach (($analysis['cashFlow'] ?? []) as $key => $value) { + $rows[] = ['Cash Flow', $key, is_scalar($value) ? $value : json_encode($value)]; + } + foreach (($analysis['receivables'] ?? []) as $key => $value) { + if ($key === 'agingBuckets') { + foreach ($value as $bucket => $amount) { + $rows[] = ['Receivables Aging', $bucket, $amount]; + } + continue; + } + $rows[] = ['Receivables', $key, is_scalar($value) ? $value : json_encode($value)]; + } + foreach (($analysis['expenseAnalysis']['byCategory'] ?? []) as $category => $amount) { + $rows[] = ['Expense By Category', $category, $amount]; + } + foreach (($analysis['paymentMethodMix'] ?? []) as $method => $amount) { + $rows[] = ['Payment Method Mix', $method, $amount]; + } + foreach (($analysis['monthlyTrend'] ?? []) as $month => $data) { + $rows[] = ['Monthly Trend', $month . ' charges', $data['charges'] ?? 0]; + $rows[] = ['Monthly Trend', $month . ' collections', $data['collections'] ?? 0]; + $rows[] = ['Monthly Trend', $month . ' expenses', $data['expenses'] ?? 0]; + $rows[] = ['Monthly Trend', $month . ' netCash', $data['netCash'] ?? 0]; + } + foreach (($analysis['riskFlags'] ?? []) as $flag) { + $rows[] = ['Risk Flag', $flag['level'] ?? '', $flag['message'] ?? '']; + } + return $rows; + } + + private function periodMetrics(string $schoolYear, ?string $dateFrom, ?string $dateTo): array + { + $invoiceTotals = $this->invoiceTotals($schoolYear, $dateFrom, $dateTo); + $payments = $this->paymentTotals($schoolYear, $dateFrom, $dateTo); + $refunds = $this->refundTotals($schoolYear, $dateFrom, $dateTo); + $discounts = $this->discountTotals($schoolYear, $dateFrom, $dateTo); + $additionalCharges = $this->additionalChargeTotals($schoolYear, $dateFrom, $dateTo); + $eventCharges = $this->eventChargeTotals($schoolYear, $dateFrom, $dateTo); + $expenses = $this->expenseTotals($schoolYear, $dateFrom, $dateTo); + $reimbursements = $this->reimbursementTotals($schoolYear, $dateFrom, $dateTo); + + $grossRevenue = $invoiceTotals['total'] + $additionalCharges['total'] + $eventCharges['total']; + $netRevenue = max(0.0, $grossRevenue - $discounts['total'] - $refunds['total']); + $cashCollected = $payments['total']; + $cashOut = $expenses['total'] + $reimbursements['total'] + $refunds['total']; + $netCash = $cashCollected - $cashOut; + $receivables = max(0.0, $netRevenue - $cashCollected); + $collectionRate = $netRevenue > 0 ? ($cashCollected / $netRevenue) * 100 : 0.0; + $expenseRatio = $cashCollected > 0 ? (($expenses['total'] + $reimbursements['total']) / $cashCollected) * 100 : 0.0; + $operatingMargin = $netRevenue > 0 ? (($netRevenue - $expenses['total'] - $reimbursements['total']) / $netRevenue) * 100 : 0.0; + + $aging = $this->receivableAging($schoolYear, $dateFrom, $dateTo); + + $keyMetrics = [ + 'grossRevenue' => $this->money($grossRevenue), + 'netRevenue' => $this->money($netRevenue), + 'cashCollected' => $this->money($cashCollected), + 'refunds' => $this->money($refunds['total']), + 'discounts' => $this->money($discounts['total']), + 'expenses' => $this->money($expenses['total']), + 'reimbursements' => $this->money($reimbursements['total']), + 'netCash' => $this->money($netCash), + 'receivables' => $this->money($receivables), + 'collectionRatePct' => $this->percent($collectionRate), + 'expenseRatioPct' => $this->percent($expenseRatio), + 'operatingMarginPct' => $this->percent($operatingMargin), + 'invoiceCount' => $invoiceTotals['count'], + 'payingFamilyCount' => $payments['familyCount'], + ]; + + return [ + 'keyMetrics' => $keyMetrics, + 'cashFlow' => [ + 'cashIn' => $this->money($cashCollected), + 'cashOut' => $this->money($cashOut), + 'netCash' => $this->money($netCash), + 'cashOutBreakdown' => [ + 'expenses' => $this->money($expenses['total']), + 'reimbursements' => $this->money($reimbursements['total']), + 'refunds' => $this->money($refunds['total']), + ], + ], + 'receivables' => [ + 'totalOpenReceivables' => $this->money($aging['total']), + 'agingBuckets' => array_map(fn ($v) => $this->money($v), $aging['buckets']), + 'largestOpenInvoices' => $aging['largestOpenInvoices'], + ], + 'expenseAnalysis' => [ + 'totalExpenses' => $this->money($expenses['total']), + 'byCategory' => array_map(fn ($v) => $this->money($v), $expenses['byCategory']), + ], + 'revenueAnalysis' => [ + 'invoiceCharges' => $this->money($invoiceTotals['total']), + 'additionalCharges' => $this->money($additionalCharges['total']), + 'eventCharges' => $this->money($eventCharges['total']), + 'discounts' => $this->money($discounts['total']), + 'refunds' => $this->money($refunds['total']), + 'netRevenue' => $this->money($netRevenue), + ], + 'paymentMethodMix' => array_map(fn ($v) => $this->money($v), $payments['byMethod']), + 'monthlyTrend' => $this->monthlyTrend($schoolYear, $dateFrom, $dateTo), + 'riskFlags' => $this->riskFlags($collectionRate, $expenseRatio, $aging['buckets'], $netCash), + ]; + } + + private function invoiceTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array + { + if (!Schema::hasTable('invoices')) { + return ['total' => 0.0, 'count' => 0]; + } + $q = DB::table('invoices')->where('school_year', $schoolYear); + $this->applyDateRange($q, $this->dateColumn('invoices', ['issue_date', 'created_at']), $dateFrom, $dateTo); + $row = (array) $q->selectRaw('COALESCE(SUM(total_amount),0) as total, COUNT(*) as count')->first(); + return ['total' => (float) ($row['total'] ?? 0), 'count' => (int) ($row['count'] ?? 0)]; + } + + private function paymentTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array + { + if (!Schema::hasTable('payments')) { + return ['total' => 0.0, 'familyCount' => 0, 'byMethod' => []]; + } + $base = DB::table('payments')->where('school_year', $schoolYear); + $this->excludeStatuses($base, 'payments', $this->excludedPaymentStatuses); + $this->applyDateRange($base, $this->dateColumn('payments', ['payment_date', 'created_at']), $dateFrom, $dateTo); + $total = (float) ((array) (clone $base)->selectRaw('COALESCE(SUM(paid_amount),0) as total')->first())['total']; + $familyCount = (int) ((array) (clone $base)->selectRaw('COUNT(DISTINCT parent_id) as count')->first())['count']; + $methodRows = (clone $base) + ->selectRaw("COALESCE(NULLIF(payment_method,''), 'unknown') as method, COALESCE(SUM(paid_amount),0) as total") + ->groupBy('method') + ->orderByDesc('total') + ->get(); + $byMethod = []; + foreach ($methodRows as $row) { + $arr = (array) $row; + $byMethod[(string) ($arr['method'] ?? 'unknown')] = (float) ($arr['total'] ?? 0); + } + return ['total' => $total, 'familyCount' => $familyCount, 'byMethod' => $byMethod]; + } + + private function refundTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array + { + if (!Schema::hasTable('refunds')) { + return ['total' => 0.0]; + } + $q = DB::table('refunds')->where('school_year', $schoolYear); + if (Schema::hasColumn('refunds', 'status')) { + $q->whereIn('status', ['paid', 'partially_paid', 'approved', 'Paid', 'Partially Paid', 'Approved']); + } + $this->applyDateRange($q, $this->dateColumn('refunds', ['refunded_at', 'approved_at', 'created_at']), $dateFrom, $dateTo); + $amountColumn = Schema::hasColumn('refunds', 'refund_paid_amount') ? 'refund_paid_amount' : 'refund_amount'; + $row = (array) $q->selectRaw('COALESCE(SUM(' . $amountColumn . '),0) as total')->first(); + return ['total' => (float) ($row['total'] ?? 0)]; + } + + private function discountTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array + { + if (!Schema::hasTable('discount_usages')) { + return ['total' => 0.0]; + } + $q = DB::table('discount_usages as du'); + if (Schema::hasTable('invoices')) { + $q->leftJoin('invoices as i', 'i.id', '=', 'du.invoice_id')->where('i.school_year', $schoolYear); + } + $this->excludeStatuses($q, 'discount_usages', $this->excludedChargeStatuses, 'du.status'); + $this->applyDateRange($q, Schema::hasColumn('discount_usages', 'created_at') ? 'du.created_at' : null, $dateFrom, $dateTo); + $amountColumn = Schema::hasColumn('discount_usages', 'discount_amount') ? 'discount_amount' : 'amount'; + $row = (array) $q->selectRaw('COALESCE(SUM(du.' . $amountColumn . '),0) as total')->first(); + return ['total' => (float) ($row['total'] ?? 0)]; + } + + private function additionalChargeTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array + { + if (!Schema::hasTable('additional_charges')) { + return ['total' => 0.0]; + } + $q = DB::table('additional_charges')->where('school_year', $schoolYear); + $this->excludeStatuses($q, 'additional_charges', $this->excludedChargeStatuses); + $this->applyDateRange($q, $this->dateColumn('additional_charges', ['due_date', 'created_at']), $dateFrom, $dateTo); + $row = (array) $q->selectRaw('COALESCE(SUM(amount),0) as total')->first(); + return ['total' => (float) ($row['total'] ?? 0)]; + } + + private function eventChargeTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array + { + $table = Schema::hasTable('event_charges') ? 'event_charges' : (Schema::hasTable('event_charge') ? 'event_charge' : null); + if ($table === null) { + return ['total' => 0.0]; + } + $q = DB::table($table); + if (Schema::hasColumn($table, 'school_year')) { + $q->where('school_year', $schoolYear); + } + $this->excludeStatuses($q, $table, $this->excludedChargeStatuses); + $amountColumn = Schema::hasColumn($table, 'amount') ? 'amount' : (Schema::hasColumn($table, 'charge_amount') ? 'charge_amount' : null); + if ($amountColumn === null) { + return ['total' => 0.0]; + } + $this->applyDateRange($q, $this->dateColumn($table, ['charge_date', 'created_at']), $dateFrom, $dateTo); + $row = (array) $q->selectRaw('COALESCE(SUM(' . $amountColumn . '),0) as total')->first(); + return ['total' => (float) ($row['total'] ?? 0)]; + } + + private function expenseTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array + { + if (!Schema::hasTable('expenses')) { + return ['total' => 0.0, 'byCategory' => []]; + } + $q = DB::table('expenses')->where('school_year', $schoolYear); + $this->excludeStatuses($q, 'expenses', ['void', 'voided', 'rejected', 'cancelled', 'canceled']); + $this->applyDateRange($q, $this->dateColumn('expenses', ['expense_date', 'date', 'created_at']), $dateFrom, $dateTo); + $amountColumn = Schema::hasColumn('expenses', 'amount') ? 'amount' : 'total_amount'; + $total = (float) ((array) (clone $q)->selectRaw('COALESCE(SUM(' . $amountColumn . '),0) as total')->first())['total']; + $categoryColumn = Schema::hasColumn('expenses', 'category') ? 'category' : (Schema::hasColumn('expenses', 'expense_category') ? 'expense_category' : null); + $byCategory = []; + if ($categoryColumn) { + foreach ((clone $q)->selectRaw("COALESCE(NULLIF($categoryColumn,''), 'uncategorized') as category, COALESCE(SUM($amountColumn),0) as total")->groupBy('category')->orderByDesc('total')->get() as $row) { + $arr = (array) $row; + $byCategory[(string) ($arr['category'] ?? 'uncategorized')] = (float) ($arr['total'] ?? 0); + } + } + return ['total' => $total, 'byCategory' => $byCategory]; + } + + private function reimbursementTotals(string $schoolYear, ?string $dateFrom, ?string $dateTo): array + { + if (!Schema::hasTable('reimbursements')) { + return ['total' => 0.0]; + } + $q = DB::table('reimbursements')->where('school_year', $schoolYear); + if (Schema::hasColumn('reimbursements', 'status')) { + $q->whereNotIn('status', ['void', 'voided', 'rejected', 'cancelled', 'canceled']); + } + $this->applyDateRange($q, $this->dateColumn('reimbursements', ['reimbursed_at', 'created_at']), $dateFrom, $dateTo); + $amountColumn = Schema::hasColumn('reimbursements', 'amount') ? 'amount' : (Schema::hasColumn('reimbursements', 'total_amount') ? 'total_amount' : 'reimbursement_amount'); + $row = (array) $q->selectRaw('COALESCE(SUM(' . $amountColumn . '),0) as total')->first(); + return ['total' => (float) ($row['total'] ?? 0)]; + } + + private function receivableAging(string $schoolYear, ?string $dateFrom, ?string $dateTo): array + { + $buckets = ['current' => 0.0, '1_30_days' => 0.0, '31_60_days' => 0.0, '61_90_days' => 0.0, 'over_90_days' => 0.0]; + $largest = []; + if (!Schema::hasTable('invoices')) { + return ['total' => 0.0, 'buckets' => $buckets, 'largestOpenInvoices' => []]; + } + $q = DB::table('invoices')->where('school_year', $schoolYear); + $this->applyDateRange($q, $this->dateColumn('invoices', ['issue_date', 'created_at']), $dateFrom, $dateTo); + $rows = $q->select(['id', 'invoice_number', 'parent_id', 'total_amount', 'paid_amount', 'balance', 'due_date', 'created_at'])->get(); + $today = now()->startOfDay(); + $total = 0.0; + foreach ($rows as $row) { + $arr = (array) $row; + $balance = (float) ($arr['balance'] ?? 0); + if ($balance <= 0) { + $totalAmount = (float) ($arr['total_amount'] ?? 0); + $paidAmount = (float) ($arr['paid_amount'] ?? 0); + $balance = max(0.0, $totalAmount - $paidAmount); + } + if ($balance <= 0) { + continue; + } + $total += $balance; + $dueRaw = $arr['due_date'] ?? $arr['created_at'] ?? null; + $days = 0; + try { + if (!empty($dueRaw)) { + $days = max(0, (int) \Illuminate\Support\Carbon::parse($dueRaw)->startOfDay()->diffInDays($today, false)); + } + } catch (\Throwable $e) { + $days = 0; + } + $bucket = $days <= 0 ? 'current' : ($days <= 30 ? '1_30_days' : ($days <= 60 ? '31_60_days' : ($days <= 90 ? '61_90_days' : 'over_90_days'))); + $buckets[$bucket] += $balance; + $largest[] = [ + 'invoiceId' => (int) ($arr['id'] ?? 0), + 'invoiceNumber' => (string) ($arr['invoice_number'] ?? ''), + 'parentId' => (int) ($arr['parent_id'] ?? 0), + 'balance' => $this->money($balance), + 'daysPastDue' => $days, + ]; + } + usort($largest, fn ($a, $b) => (float) $b['balance'] <=> (float) $a['balance']); + return ['total' => $total, 'buckets' => $buckets, 'largestOpenInvoices' => array_slice($largest, 0, 10)]; + } + + private function monthlyTrend(string $schoolYear, ?string $dateFrom, ?string $dateTo): array + { + $months = []; + $invoiceMonth = $this->monthlySum('invoices', 'total_amount', $schoolYear, $this->dateColumn('invoices', ['issue_date', 'created_at']), $dateFrom, $dateTo); + $paymentMonth = $this->monthlySum('payments', 'paid_amount', $schoolYear, $this->dateColumn('payments', ['payment_date', 'created_at']), $dateFrom, $dateTo, $this->excludedPaymentStatuses); + $expenseColumn = Schema::hasTable('expenses') && Schema::hasColumn('expenses', 'amount') ? 'amount' : 'total_amount'; + $expenseMonth = $this->monthlySum('expenses', $expenseColumn, $schoolYear, $this->dateColumn('expenses', ['expense_date', 'date', 'created_at']), $dateFrom, $dateTo, ['void', 'voided', 'rejected', 'cancelled', 'canceled']); + + foreach (array_unique(array_merge(array_keys($invoiceMonth), array_keys($paymentMonth), array_keys($expenseMonth))) as $month) { + $charges = $invoiceMonth[$month] ?? 0.0; + $collections = $paymentMonth[$month] ?? 0.0; + $expenses = $expenseMonth[$month] ?? 0.0; + $months[$month] = [ + 'charges' => $this->money($charges), + 'collections' => $this->money($collections), + 'expenses' => $this->money($expenses), + 'netCash' => $this->money($collections - $expenses), + ]; + } + ksort($months); + return $months; + } + + private function monthlySum(string $table, string $amountColumn, string $schoolYear, ?string $dateColumn, ?string $dateFrom, ?string $dateTo, array $excludedStatuses = []): array + { + if (!Schema::hasTable($table) || !Schema::hasColumn($table, $amountColumn) || $dateColumn === null) { + return []; + } + $q = DB::table($table); + if (Schema::hasColumn($table, 'school_year')) { + $q->where('school_year', $schoolYear); + } + $this->excludeStatuses($q, $table, $excludedStatuses); + $this->applyDateRange($q, $dateColumn, $dateFrom, $dateTo); + $driver = DB::connection()->getDriverName(); + $monthExpr = $driver === 'sqlite' + ? "strftime('%Y-%m', $dateColumn)" + : "DATE_FORMAT($dateColumn, '%Y-%m')"; + $rows = $q->selectRaw($monthExpr . ' as month, COALESCE(SUM(' . $amountColumn . '),0) as total') + ->groupBy('month') + ->orderBy('month') + ->get(); + $out = []; + foreach ($rows as $row) { + $arr = (array) $row; + if (!empty($arr['month'])) { + $out[(string) $arr['month']] = (float) ($arr['total'] ?? 0); + } + } + return $out; + } + + private function executiveSummary(array $current, ?array $comparison): array + { + $summary = [ + 'headline' => 'Stakeholder financial analysis generated from existing Laravel finance tables.', + 'collectionRatePct' => $current['keyMetrics']['collectionRatePct'] ?? '0.00', + 'netCash' => $current['keyMetrics']['netCash'] ?? '0.00', + 'receivables' => $current['keyMetrics']['receivables'] ?? '0.00', + ]; + if ($comparison) { + $summary['comparison'] = $this->deltas($current['keyMetrics'], $comparison['keyMetrics']); + } + return $summary; + } + + private function riskFlags(float $collectionRate, float $expenseRatio, array $agingBuckets, float $netCash): array + { + $flags = []; + if ($collectionRate < 85.0) { + $flags[] = ['level' => 'warning', 'message' => 'Collection rate is below 85%. Review receivables and follow-up cadence.']; + } + if (($agingBuckets['over_90_days'] ?? 0.0) > 0) { + $flags[] = ['level' => 'warning', 'message' => 'There are receivables more than 90 days past due.']; + } + if ($expenseRatio > 80.0) { + $flags[] = ['level' => 'watch', 'message' => 'Expenses and reimbursements exceed 80% of collections.']; + } + if ($netCash < 0) { + $flags[] = ['level' => 'critical', 'message' => 'Net cash for the selected period is negative.']; + } + if (empty($flags)) { + $flags[] = ['level' => 'ok', 'message' => 'No automatic risk flags were triggered for the selected period.']; + } + return $flags; + } + + private function deltas(array $current, array $previous): array + { + $keys = ['grossRevenue', 'netRevenue', 'cashCollected', 'expenses', 'netCash', 'receivables']; + $out = []; + foreach ($keys as $key) { + $cur = (float) ($current[$key] ?? 0); + $prev = (float) ($previous[$key] ?? 0); + $out[$key] = [ + 'current' => $this->money($cur), + 'previous' => $this->money($prev), + 'change' => $this->money($cur - $prev), + 'changePct' => $prev != 0.0 ? $this->percent((($cur - $prev) / abs($prev)) * 100) : null, + ]; + } + return $out; + } + + private function resolveDateRange(string $schoolYear, ?string $dateFrom, ?string $dateTo): array + { + if ((!$dateFrom || !$dateTo) && preg_match('/^(\d{4})\D?(\d{4})$/', $schoolYear, $m)) { + $dateFrom = $dateFrom ?: $m[1] . '-01-01'; + $dateTo = $dateTo ?: $m[2] . '-12-31'; + } + return [$dateFrom, $dateTo]; + } + + private function applyDateRange($query, ?string $column, ?string $dateFrom, ?string $dateTo): void + { + if ($column === null) { + return; + } + if (!empty($dateFrom)) { + $query->whereRaw('DATE(' . $column . ') >= ?', [$dateFrom]); + } + if (!empty($dateTo)) { + $query->whereRaw('DATE(' . $column . ') <= ?', [$dateTo]); + } + } + + private function excludeStatuses($query, string $table, array $statuses, ?string $qualifiedColumn = null): void + { + if (empty($statuses) || !Schema::hasColumn($table, 'status')) { + return; + } + $column = $qualifiedColumn ?: 'status'; + $query->where(function ($q) use ($column, $statuses) { + $q->whereNotIn($column, $statuses)->orWhereNull($column); + }); + } + + private function dateColumn(string $table, array $candidates): ?string + { + if (!Schema::hasTable($table)) { + return null; + } + foreach ($candidates as $column) { + if (Schema::hasColumn($table, $column)) { + return $column; + } + } + return null; + } + + private function money(float $value): string + { + return number_format(round($value, 2), 2, '.', ''); + } + + private function percent(float $value): string + { + return number_format(round($value, 2), 2, '.', ''); + } +} diff --git a/app/Services/Grading/Display/GradeCalculationDisplayResolver.php b/app/Services/Grading/Display/GradeCalculationDisplayResolver.php new file mode 100644 index 00000000..ce414817 --- /dev/null +++ b/app/Services/Grading/Display/GradeCalculationDisplayResolver.php @@ -0,0 +1,39 @@ +calculation_mode ?: 'legacy'); + $version = (string) ($score->calculation_policy_version ?: 'legacy_v1'); + + if ($mode === 'strong' && $score->snapshot_id) { + $snapshot = SemesterScoreSnapshot::query()->find($score->snapshot_id); + if ($snapshot) { + return [ + 'mode' => 'strong', + 'policy_version' => $version, + 'label' => 'Strong Calculation', + 'score' => $score, + 'snapshot' => $snapshot, + 'inputs' => $snapshot->input_json, + 'calculation' => $snapshot->calculation_json, + ]; + } + } + + return [ + 'mode' => 'legacy', + 'policy_version' => $version ?: 'legacy_v1', + 'label' => 'Legacy Calculation', + 'score' => $score, + 'snapshot' => null, + 'legacy_note' => 'This score is displayed from stored legacy semester_scores values. Legacy averages ignored blank scores, PTAP used legacy dynamic weighting, and attendance includes one-absence grace.', + ]; + } +} diff --git a/app/Services/Grading/GradingLockService.php b/app/Services/Grading/GradingLockService.php index 9e5e1bbd..c5ba695e 100644 --- a/app/Services/Grading/GradingLockService.php +++ b/app/Services/Grading/GradingLockService.php @@ -5,9 +5,19 @@ namespace App\Services\Grading; use App\Models\GradingLock; use App\Models\ClassSection; use RuntimeException; +use App\Services\Grading\Policy\GradingPolicyResolver; +use App\Services\Grading\Validation\GradebookFinalizationValidator; class GradingLockService { + public function __construct( + private ?GradingPolicyResolver $policyResolver = null, + private ?GradebookFinalizationValidator $finalizationValidator = null + ) { + $this->policyResolver = $this->policyResolver ?? new GradingPolicyResolver(); + $this->finalizationValidator = $this->finalizationValidator ?? new GradebookFinalizationValidator(); + } + public function toggle(int $classSectionId, string $semester, string $schoolYear, ?int $userId): bool { if ($classSectionId <= 0 || $semester === '' || $schoolYear === '') { @@ -25,6 +35,8 @@ class GradingLockService return false; } + $this->assertCanLock($classSectionId, $semester, $schoolYear); + if ($existing) { $existing->update([ 'is_locked' => 1, @@ -83,6 +95,8 @@ class GradingLockService $count = 0; foreach ($sectionIds as $sid) { + $this->assertCanLock($sid, $semester, $schoolYear); + if (!empty($existingBySection[$sid])) { if ($existingBySection[$sid]->is_locked) { continue; @@ -115,4 +129,18 @@ class GradingLockService return $count; } + + private function assertCanLock(int $classSectionId, string $semester, string $schoolYear): void + { + $mode = $this->policyResolver->calculationMode($classSectionId, $semester, $schoolYear); + if ($mode !== GradingPolicyResolver::STRONG_MODE) { + return; + } + + $result = $this->finalizationValidator->validateClassSectionBeforeLock($classSectionId, $semester, $schoolYear); + if (!$result['valid']) { + $count = count($result['errors']); + throw new RuntimeException("Cannot lock class section {$classSectionId}; strong grading validation failed with {$count} error(s)."); + } + } } diff --git a/app/Services/Grading/GradingScoreService.php b/app/Services/Grading/GradingScoreService.php index 52b07c81..7896c093 100644 --- a/app/Services/Grading/GradingScoreService.php +++ b/app/Services/Grading/GradingScoreService.php @@ -12,6 +12,7 @@ use App\Models\ScoreComment; use App\Models\SemesterScore; use App\Models\Student; use App\Models\ClassSection; +use App\Services\Grading\Validation\ScoreValueValidator; use App\Services\Scores\SemesterScoreService; use RuntimeException; @@ -72,18 +73,26 @@ class GradingScoreService $scores = $payload['scores'] ?? []; $comments = $payload['comments'] ?? []; + $validator = new ScoreValueValidator(); foreach ($scoreIds as $i => $id) { + $normalizedScore = $validator->normalizeNullable($scores[$i] ?? null, ScoreValueValidator::DEFAULT_MAX_POINTS, ucfirst($type) . ' score'); $model->newQuery()->whereKey($id)->update([ - 'score' => $scores[$i] ?? null, + 'score' => $normalizedScore, + 'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS, + 'status' => $validator->inferStatus($normalizedScore, false), 'comment' => $comments[$i] ?? null, 'updated_at' => now(), ]); } } elseif (in_array($type, ['midterm', 'final', 'test'], true)) { $score = $payload['score'] ?? null; + $validator = new ScoreValueValidator(); + $normalizedScore = $validator->normalizeNullable($score, ScoreValueValidator::DEFAULT_MAX_POINTS, ucfirst($type) . ' score'); $data = [ - 'score' => $score, + 'score' => $normalizedScore, + 'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS, + 'status' => $validator->inferStatus($normalizedScore, false), 'updated_at' => now(), ]; diff --git a/app/Services/Grading/Policy/GradingPolicyResolver.php b/app/Services/Grading/Policy/GradingPolicyResolver.php new file mode 100644 index 00000000..a4b6501f --- /dev/null +++ b/app/Services/Grading/Policy/GradingPolicyResolver.php @@ -0,0 +1,36 @@ + $v !== ''); + return $classSectionId !== null && in_array((string) $classSectionId, $ids, true) + ? self::STRONG_MODE + : self::LEGACY_MODE; + } + + public function policyVersion(string $mode): string + { + return $mode === self::STRONG_MODE ? self::STRONG_VERSION : self::LEGACY_VERSION; + } +} diff --git a/app/Services/Grading/Snapshots/SemesterScoreSnapshotService.php b/app/Services/Grading/Snapshots/SemesterScoreSnapshotService.php new file mode 100644 index 00000000..8fd9c492 --- /dev/null +++ b/app/Services/Grading/Snapshots/SemesterScoreSnapshotService.php @@ -0,0 +1,29 @@ +create([ + 'student_id' => $score->student_id, + 'school_id' => $score->school_id, + 'class_section_id' => $score->class_section_id, + 'semester' => $score->semester, + 'school_year' => $score->school_year, + 'grading_profile_id' => null, + 'grading_profile_version' => $score->calculation_policy_version ?: 'strong_v1', + 'calculation_mode' => $score->calculation_mode ?: 'strong', + 'calculation_policy_version' => $score->calculation_policy_version ?: 'strong_v1', + 'input_json' => $input, + 'calculation_json' => $calculation, + 'semester_score' => $score->semester_score, + 'calculated_by' => $actorId, + 'calculated_at' => now(), + ]); + } +} diff --git a/app/Services/Grading/StrongCategoryAverageCalculator.php b/app/Services/Grading/StrongCategoryAverageCalculator.php new file mode 100644 index 00000000..4b390d29 --- /dev/null +++ b/app/Services/Grading/StrongCategoryAverageCalculator.php @@ -0,0 +1,60 @@ +where('student_id', $studentId) + ->where('semester', $semester) + ->where('school_year', $schoolYear); + + if ($classSectionId !== null) { + $query->where('class_section_id', $classSectionId); + } + + $rows = $query->get(); + $included = []; + $pending = []; + $excluded = []; + + foreach ($rows as $row) { + $status = (string) ($row->status ?? 'pending'); + $score = $row->score ?? null; + $maxPoints = isset($row->max_points) && is_numeric($row->max_points) && (float) $row->max_points > 0 + ? (float) $row->max_points + : 100.0; + + if ($status === 'pending' || $status === '') { + $pending[] = $row; + continue; + } + + if (in_array($status, ['excused', 'not_assigned'], true)) { + $excluded[] = $row; + continue; + } + + if ($status === 'missing') { + $included[] = 0.0; + continue; + } + + if ($status === 'scored' && $score !== null && is_numeric($score)) { + $included[] = ((float) $score / $maxPoints) * 100; + } + } + + return [ + 'average' => count($included) > 0 ? round(array_sum($included) / count($included), 2) : null, + 'included_count' => count($included), + 'excluded_count' => count($excluded), + 'pending_count' => count($pending), + 'has_pending' => count($pending) > 0, + ]; + } +} diff --git a/app/Services/Grading/Validation/GradebookFinalizationValidator.php b/app/Services/Grading/Validation/GradebookFinalizationValidator.php new file mode 100644 index 00000000..f9510a66 --- /dev/null +++ b/app/Services/Grading/Validation/GradebookFinalizationValidator.php @@ -0,0 +1,99 @@ +scoreTables() as $table => $meta) { + if (!DB::getSchemaBuilder()->hasTable($table)) { + continue; + } + + $query = DB::table($table) + ->where('class_section_id', $classSectionId) + ->where('semester', $semester) + ->where('school_year', $schoolYear); + + if (DB::getSchemaBuilder()->hasColumn($table, 'status')) { + $pending = (clone $query)->where(function ($q) { + $q->whereNull('status')->orWhere('status', 'pending'); + })->get(); + + foreach ($pending as $row) { + $errors[] = $this->error($table, $meta['category'], $row, 'pending_score', 'Score must be marked scored, missing, excused, or not_assigned before strong-mode lock.'); + } + } + + if (DB::getSchemaBuilder()->hasColumn($table, 'max_points')) { + $invalidMax = (clone $query)->where(function ($q) { + $q->whereNull('max_points')->orWhere('max_points', '<=', 0); + })->get(); + + foreach ($invalidMax as $row) { + $errors[] = $this->error($table, $meta['category'], $row, 'invalid_max_points', 'Max points must be greater than 0.'); + } + + $invalidScores = (clone $query) + ->whereNotNull('score') + ->where(function ($q) { + $q->where('score', '<', 0)->orWhereRaw('score > max_points'); + }) + ->get(); + + foreach ($invalidScores as $row) { + $errors[] = $this->error($table, $meta['category'], $row, 'score_out_of_range', 'Score must be between 0 and max_points.'); + } + } else { + $invalidScores = (clone $query) + ->whereNotNull('score') + ->where(function ($q) { + $q->where('score', '<', 0)->orWhere('score', '>', 100); + }) + ->get(); + + foreach ($invalidScores as $row) { + $errors[] = $this->error($table, $meta['category'], $row, 'score_out_of_range', 'Score must be between 0 and 100.'); + } + } + } + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + ]; + } + + private function scoreTables(): array + { + return [ + 'homework' => ['category' => 'homework'], + 'quiz' => ['category' => 'quiz'], + 'project' => ['category' => 'project'], + 'participation' => ['category' => 'participation'], + 'midterm_exam' => ['category' => 'midterm_exam'], + 'final_exam' => ['category' => 'final_exam'], + ]; + } + + private function error(string $table, string $category, object $row, string $code, string $message): array + { + return [ + 'table' => $table, + 'category' => $category, + 'student_id' => isset($row->student_id) ? (int) $row->student_id : null, + 'item_index' => $row->homework_index ?? $row->quiz_index ?? $row->project_index ?? null, + 'code' => $code, + 'message' => $message, + ]; + } +} diff --git a/app/Services/Grading/Validation/ScoreValueValidator.php b/app/Services/Grading/Validation/ScoreValueValidator.php new file mode 100644 index 00000000..2141fb16 --- /dev/null +++ b/app/Services/Grading/Validation/ScoreValueValidator.php @@ -0,0 +1,48 @@ +normalizeMaxPoints($maxPoints, $label); + + if (!is_numeric($value)) { + throw new InvalidArgumentException("{$label} must be numeric."); + } + + $score = (float) $value; + if ($score < 0 || $score > $max) { + throw new InvalidArgumentException("{$label} must be between 0 and {$max}."); + } + + return $score; + } + + public function normalizeMaxPoints(float|int|null $maxPoints = self::DEFAULT_MAX_POINTS, string $label = 'Score'): float + { + $max = $maxPoints === null ? self::DEFAULT_MAX_POINTS : (float) $maxPoints; + if ($max <= 0) { + throw new InvalidArgumentException("{$label} max points must be greater than 0."); + } + return $max; + } + + public function inferStatus(?float $score, bool $missingAllowed = false): string + { + if ($score !== null) { + return 'scored'; + } + + return $missingAllowed ? 'excused' : 'pending'; + } +} diff --git a/app/Services/Invoices/InvoicePdfService.php b/app/Services/Invoices/InvoicePdfService.php index 39c006e7..ab47d5d6 100644 --- a/app/Services/Invoices/InvoicePdfService.php +++ b/app/Services/Invoices/InvoicePdfService.php @@ -34,6 +34,11 @@ class InvoicePdfService return $this->renderPdfInvoice($data); } + public function previewData(int $invoiceId): array + { + return $this->prepareInvoiceData($invoiceId); + } + private function prepareInvoiceData(int $invoiceId): array { $invoice = Invoice::query()->find($invoiceId); diff --git a/app/Services/Navigation/NavBuilderService.php b/app/Services/Navigation/NavBuilderService.php index 24dda604..93617a4d 100644 --- a/app/Services/Navigation/NavBuilderService.php +++ b/app/Services/Navigation/NavBuilderService.php @@ -43,7 +43,11 @@ class NavBuilderService $this->sortTreeAlpha($tree); $flatAlpha = $all; - usort($flatAlpha, fn ($a, $b) => strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? ''))); + usort($flatAlpha, function ($a, $b) { + $result = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')); + + return $result !== 0 ? $result : ((int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0)); + }); $roleAssignments = $this->buildRoleAssignments(); $flattened = $this->flattenTreeForResponse($tree, $roleAssignments); @@ -109,6 +113,8 @@ class NavBuilderService 'nav_item_id' => $navItemId, ]); } + + $this->normalizeSortOrdersAlphabetically(); }); $this->navbarService->clearCache(); @@ -118,7 +124,16 @@ class NavBuilderService public function delete(int $id): bool { - $deleted = (bool) NavItem::query()->whereKey($id)->delete(); + $deleted = false; + + DB::transaction(function () use ($id, &$deleted): void { + $deleted = (bool) NavItem::query()->whereKey($id)->delete(); + + if ($deleted) { + $this->normalizeSortOrdersAlphabetically(); + } + }); + $this->navbarService->clearCache(); return $deleted; @@ -126,15 +141,39 @@ class NavBuilderService public function reorder(array $orders): void { - DB::transaction(function () use ($orders): void { - foreach ($orders as $id => $order) { - NavItem::query()->whereKey((int) $id)->update(['sort_order' => (int) $order]); - } + DB::transaction(function (): void { + $this->normalizeSortOrdersAlphabetically(); }); $this->navbarService->clearCache(); } + private function normalizeSortOrdersAlphabetically(): void + { + $rows = NavItem::query() + ->select(['id', 'menu_parent_id', 'label']) + ->orderBy('menu_parent_id', 'asc') + ->orderBy('label', 'asc') + ->orderBy('id', 'asc') + ->get() + ->groupBy(fn ($row) => $row->menu_parent_id === null ? 'root' : (string) $row->menu_parent_id); + + foreach ($rows as $siblings) { + $ordered = $siblings->values()->all(); + usort($ordered, function ($a, $b) { + $result = strnatcasecmp($this->labelKey((string) ($a->label ?? '')), $this->labelKey((string) ($b->label ?? ''))); + + return $result !== 0 ? $result : ((int) $a->id <=> (int) $b->id); + }); + + foreach ($ordered as $index => $item) { + NavItem::query() + ->whereKey((int) $item->id) + ->update(['sort_order' => ($index + 1) * 10]); + } + } + } + private function buildRoleAssignments(): array { $rows = RoleNavItem::query() @@ -205,7 +244,11 @@ class NavBuilderService private function sortTreeAlpha(array &$nodes): void { - usort($nodes, fn ($a, $b) => strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? ''))); + usort($nodes, function ($a, $b) { + $result = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')); + + return $result !== 0 ? $result : ((int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0)); + }); foreach ($nodes as &$node) { if (!empty($node['children'])) { $this->sortTreeAlpha($node['children']); diff --git a/app/Services/Navigation/NavbarService.php b/app/Services/Navigation/NavbarService.php index 6ee84f6c..cf9d6778 100644 --- a/app/Services/Navigation/NavbarService.php +++ b/app/Services/Navigation/NavbarService.php @@ -21,7 +21,9 @@ class NavbarService $rows = NavItem::query() ->where('is_enabled', 1) - ->orderBy('sort_order', 'asc') + ->orderBy('menu_parent_id', 'asc') + ->orderBy('label', 'asc') + ->orderBy('id', 'asc') ->get() ->toArray(); @@ -44,6 +46,8 @@ class NavbarService } unset($node); + $this->sortTreeAlpha($tree); + return $tree; }); } @@ -52,4 +56,29 @@ class NavbarService { Cache::flush(); } + + private function labelKey(string $s): string + { + $s = trim(preg_replace('/\s+/', ' ', $s)); + $s = preg_replace('/^[^[:alnum:]]+/u', '', $s); + $s = preg_replace('/^(?i)(the|an|a)\s+/', '', $s); + + return mb_strtolower($s, 'UTF-8'); + } + + private function sortTreeAlpha(array &$nodes): void + { + usort($nodes, function ($a, $b) { + $result = strnatcasecmp($this->labelKey($a['label'] ?? ''), $this->labelKey($b['label'] ?? '')); + + return $result !== 0 ? $result : ((int) ($a['id'] ?? 0) <=> (int) ($b['id'] ?? 0)); + }); + + foreach ($nodes as &$node) { + if (!empty($node['children'])) { + $this->sortTreeAlpha($node['children']); + } + } + unset($node); + } } diff --git a/app/Services/Promotions/Placement/BalancedSectionPlacementService.php b/app/Services/Promotions/Placement/BalancedSectionPlacementService.php new file mode 100644 index 00000000..9e193d74 --- /dev/null +++ b/app/Services/Promotions/Placement/BalancedSectionPlacementService.php @@ -0,0 +1,106 @@ + 0, + 'target_sizes' => [], + 'sections' => [], + 'assignments' => [], + ]; + } + + $sectionCount = $total <= $capacity ? 1 : (int) ceil($total / $capacity); + $targetSizes = $this->targetSizes($total, $sectionCount); + $sections = []; + for ($i = 1; $i <= $sectionCount; $i++) { + $sections[$i] = [ + 'section_index' => $i, + 'target_size' => $targetSizes[$i], + 'total' => 0, + 'bands' => ['A' => 0, 'B' => 0, 'C' => 0, 'D' => 0], + 'students' => [], + ]; + } + + $grouped = ['A' => [], 'B' => [], 'C' => [], 'D' => []]; + foreach ($students as $student) { + $band = $student['score_band'] ?? 'D'; + $grouped[$band][] = $student; + } + + foreach (array_keys($grouped) as $band) { + usort($grouped[$band], function (array $a, array $b): int { + $scoreA = $a['final_score'] ?? $a['placement_score'] ?? -1; + $scoreB = $b['final_score'] ?? $b['placement_score'] ?? -1; + if ($scoreA !== $scoreB) { + return $scoreB <=> $scoreA; + } + $name = strcmp((string) ($a['student_name'] ?? ''), (string) ($b['student_name'] ?? '')); + return $name !== 0 ? $name : ((int) $a['student_id'] <=> (int) $b['student_id']); + }); + } + + $assignments = []; + $order = 1; + foreach (['A', 'B', 'C', 'D'] as $band) { + foreach ($grouped[$band] as $student) { + $sectionIndex = $this->selectSection($sections, $band); + $sections[$sectionIndex]['total']++; + $sections[$sectionIndex]['bands'][$band]++; + $sections[$sectionIndex]['students'][] = (int) $student['student_id']; + $student['planned_section_index'] = $sectionIndex; + $student['assignment_order'] = $order++; + $assignments[] = $student; + } + } + + return [ + 'section_count' => $sectionCount, + 'target_sizes' => $targetSizes, + 'sections' => array_values($sections), + 'assignments' => $assignments, + ]; + } + + private function targetSizes(int $total, int $sectionCount): array + { + $base = intdiv($total, $sectionCount); + $remainder = $total % $sectionCount; + $sizes = []; + for ($i = 1; $i <= $sectionCount; $i++) { + $sizes[$i] = $base + ($i <= $remainder ? 1 : 0); + } + return $sizes; + } + + private function selectSection(array $sections, string $band): int + { + $eligible = array_filter($sections, fn ($s) => $s['total'] < $s['target_size']); + if (empty($eligible)) { + $eligible = $sections; + } + + uasort($eligible, function (array $a, array $b) use ($band): int { + $cmp = $a['total'] <=> $b['total']; + if ($cmp !== 0) { + return $cmp; + } + $cmp = ($a['bands'][$band] ?? 0) <=> ($b['bands'][$band] ?? 0); + if ($cmp !== 0) { + return $cmp; + } + return $a['section_index'] <=> $b['section_index']; + }); + + return (int) array_key_first($eligible); + } +} diff --git a/app/Services/Promotions/Placement/PlacementPoolBuilder.php b/app/Services/Promotions/Placement/PlacementPoolBuilder.php new file mode 100644 index 00000000..03755292 --- /dev/null +++ b/app/Services/Promotions/Placement/PlacementPoolBuilder.php @@ -0,0 +1,182 @@ +exceptionBuckets($fromSchoolYear, $toSchoolYear); + + $returning = StudentPromotionRecord::query() + ->from('student_promotion_records as r') + ->join('students as s', 's.id', '=', 'r.student_id') + ->leftJoin('student_decisions as d', function ($join) use ($fromSchoolYear) { + $join->on('d.student_id', '=', 'r.student_id') + ->where('d.school_year', '=', $fromSchoolYear); + }) + ->where('r.current_school_year', $fromSchoolYear) + ->where('r.next_school_year', $toSchoolYear) + ->where('r.enrollment_status', StudentPromotionRecord::ENROLLMENT_COMPLETED) + ->where('r.promotion_status', StudentPromotionRecord::STATUS_PROMOTED_AND_ENROLLED) + ->when($toGradeLevelId !== null, fn ($q) => $q->where('r.promoted_level_id', $toGradeLevelId)) + ->selectRaw('r.*, s.firstname, s.lastname, s.is_active, d.id as decision_id, d.decision, d.year_score') + ->orderBy('s.lastname') + ->orderBy('s.firstname') + ->orderBy('s.id') + ->get(); + + foreach ($returning as $row) { + $studentId = (int) $row->student_id; + if ((int) ($row->is_active ?? 1) === 0) { + $exceptions['withdrawn_or_inactive_student'][] = $studentId; + continue; + } + if ($this->alreadyPlaced($studentId, $toSchoolYear)) { + $exceptions['duplicate_target_year_placement'][] = $studentId; + continue; + } + if (!$this->isPromotedDecision((string) ($row->decision ?? ''))) { + $bucket = $row->decision === null ? 'missing_student_decision' : 'decision_conflicts_with_enrollment_request'; + $exceptions[$bucket][] = $studentId; + continue; + } + + $score = $row->year_score !== null ? (float) $row->year_score : ($row->final_average !== null ? (float) $row->final_average : null); + if ($score === null) { + $exceptions['missing_student_decision'][] = $studentId; + continue; + } + try { + $band = $this->bands->fromScore($score, true); + } catch (\RuntimeException) { + $exceptions['failed_retained'][] = $studentId; + continue; + } + + $pool[] = [ + 'student_id' => $studentId, + 'student_type' => 'returning', + 'student_name' => trim((string) $row->lastname . ', ' . (string) $row->firstname), + 'source_decision_id' => $row->decision_id ? (int) $row->decision_id : null, + 'source_enrollment_id' => $row->enrollment_id ? (int) $row->enrollment_id : null, + 'final_score' => $score, + 'placement_score' => null, + 'score_band' => $band, + 'placement_band_reason' => 'returning_student_final_score', + ]; + } + + $newStudents = DB::table('enrollments as e') + ->join('students as s', 's.id', '=', 'e.student_id') + ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'e.class_section_id') + ->where('e.school_year', $toSchoolYear) + ->where('s.is_new', 1) + ->where('e.admission_status', 'accepted') + ->whereIn('e.enrollment_status', ['enrolled', 'payment pending']) + ->when($toGradeLevelId !== null, function ($q) use ($toGradeLevelId) { + $q->where(function ($inner) use ($toGradeLevelId) { + $inner->whereNull('e.class_section_id') + ->orWhere('cs.class_id', $toGradeLevelId); + }); + }) + ->selectRaw('e.id as enrollment_id, e.student_id, s.firstname, s.lastname, s.is_active') + ->orderBy('s.lastname') + ->orderBy('s.firstname') + ->orderBy('s.id') + ->get(); + + foreach ($newStudents as $row) { + $studentId = (int) $row->student_id; + if ((int) ($row->is_active ?? 1) === 0) { + $exceptions['withdrawn_or_inactive_student'][] = $studentId; + continue; + } + if ($this->alreadyPlaced($studentId, $toSchoolYear)) { + $exceptions['duplicate_target_year_placement'][] = $studentId; + continue; + } + + $pool[] = [ + 'student_id' => $studentId, + 'student_type' => 'new', + 'student_name' => trim((string) $row->lastname . ', ' . (string) $row->firstname), + 'source_decision_id' => null, + 'source_enrollment_id' => (int) $row->enrollment_id, + 'final_score' => null, + 'placement_score' => null, + 'score_band' => 'D', + 'placement_band_reason' => 'new_student_default_band', + ]; + } + + return [ + 'pool' => $pool, + 'exceptions' => array_map(fn ($ids) => array_values(array_unique($ids)), $exceptions), + ]; + } + + private function exceptionBuckets(string $fromSchoolYear, string $toSchoolYear): array + { + $buckets = [ + 'passed_but_parent_enrollment_missing' => [], + 'passed_and_parent_enrollment_completed' => [], + 'failed_retained' => [], + 'missing_student_decision' => [], + 'pending_student_decision' => [], + 'decision_conflicts_with_enrollment_request' => [], + 'withdrawn_or_inactive_student' => [], + 'duplicate_target_year_placement' => [], + ]; + + $rows = DB::table('student_decisions as d') + ->leftJoin('student_promotion_records as r', function ($join) use ($toSchoolYear) { + $join->on('r.student_id', '=', 'd.student_id') + ->where('r.next_school_year', '=', $toSchoolYear); + }) + ->where('d.school_year', $fromSchoolYear) + ->select('d.student_id', 'd.decision', 'r.enrollment_status') + ->get(); + + foreach ($rows as $row) { + $decision = strtolower(trim((string) $row->decision)); + $studentId = (int) $row->student_id; + if ($this->isPromotedDecision($decision)) { + if ($row->enrollment_status === StudentPromotionRecord::ENROLLMENT_COMPLETED) { + $buckets['passed_and_parent_enrollment_completed'][] = $studentId; + } else { + $buckets['passed_but_parent_enrollment_missing'][] = $studentId; + } + } elseif (in_array($decision, ['failed', 'fail', 'retained', 'repeat', 'repeated_level'], true)) { + $buckets['failed_retained'][] = $studentId; + } elseif ($decision === '' || $decision === 'pending') { + $buckets['pending_student_decision'][] = $studentId; + } + } + + return $buckets; + } + + private function alreadyPlaced(int $studentId, string $schoolYear): bool + { + return DB::table('student_class') + ->where('student_id', $studentId) + ->where('school_year', $schoolYear) + ->where('is_event_only', 0) + ->exists(); + } + + private function isPromotedDecision(string $decision): bool + { + $normalized = strtolower(trim($decision)); + return in_array($normalized, ['passed', 'pass', 'promoted', 'promote', 'eligible_to_continue'], true); + } +} diff --git a/app/Services/Promotions/Placement/PromotionSectionCapacityService.php b/app/Services/Promotions/Placement/PromotionSectionCapacityService.php new file mode 100644 index 00000000..62770bd9 --- /dev/null +++ b/app/Services/Promotions/Placement/PromotionSectionCapacityService.php @@ -0,0 +1,43 @@ + self::CONFIG_KEY, + 'fallback' => self::DEFAULT_CAPACITY, + ]); + $value = self::DEFAULT_CAPACITY; + } elseif (!ctype_digit((string) $raw)) { + throw new RuntimeException('promotion.section_capacity must be a positive integer.'); + } else { + $value = (int) $raw; + } + + if ($value <= 0) { + throw new RuntimeException('promotion.section_capacity must be greater than zero.'); + } + + return [ + 'key' => self::CONFIG_KEY, + 'value' => $value, + 'used_default' => $usedDefault, + 'warnings' => $usedDefault ? ['promotion.section_capacity missing; fallback capacity 20 used.'] : [], + ]; + } +} diff --git a/app/Services/Promotions/Placement/ScoreBandClassifier.php b/app/Services/Promotions/Placement/ScoreBandClassifier.php new file mode 100644 index 00000000..ce02dd00 --- /dev/null +++ b/app/Services/Promotions/Placement/ScoreBandClassifier.php @@ -0,0 +1,29 @@ += 90.0 && $score <= 100.0) { + return 'A'; + } + if ($score >= 80.0) { + return 'B'; + } + if ($score >= 70.0) { + return 'C'; + } + if ($score >= 60.0) { + return 'D'; + } + + throw new RuntimeException('Scores below 60 are not eligible for automatic placement.'); + } +} diff --git a/app/Services/Promotions/Placement/SectionPlacementPreviewService.php b/app/Services/Promotions/Placement/SectionPlacementPreviewService.php new file mode 100644 index 00000000..daa402ac --- /dev/null +++ b/app/Services/Promotions/Placement/SectionPlacementPreviewService.php @@ -0,0 +1,204 @@ +capacityService->capacity(); + $poolResult = $this->poolBuilder->build($fromSchoolYear, $toSchoolYear, $toGradeLevelId); + $placement = $this->placementService->assign($poolResult['pool'], (int) $capacityInfo['value']); + + $snapshot = [ + 'capacity' => $capacityInfo, + 'target_section_sizes' => $placement['target_sizes'], + 'sections' => $placement['sections'], + 'excluded_students' => $poolResult['exceptions'], + 'warnings' => $capacityInfo['warnings'], + 'new_students_defaulted_to_d_band' => array_values(array_map( + fn ($s) => $s['student_id'], + array_filter($placement['assignments'], fn ($s) => ($s['placement_band_reason'] ?? null) === 'new_student_default_band') + )), + ]; + + return DB::transaction(function () use ($fromSchoolYear, $toSchoolYear, $fromGradeLevelId, $toGradeLevelId, $createdBy, $capacityInfo, $placement, $snapshot) { + $batch = SectionPlacementBatch::query()->create([ + 'from_school_year' => $fromSchoolYear, + 'to_school_year' => $toSchoolYear, + 'from_grade_level_id' => $fromGradeLevelId, + 'to_grade_level_id' => $toGradeLevelId, + 'section_capacity_used' => (int) $capacityInfo['value'], + 'total_students' => count($placement['assignments']), + 'section_count' => (int) $placement['section_count'], + 'algorithm' => BalancedSectionPlacementService::ALGORITHM, + 'configuration_snapshot_json' => json_encode([ + PromotionSectionCapacityService::CONFIG_KEY => (int) $capacityInfo['value'], + 'placement.algorithm' => BalancedSectionPlacementService::ALGORITHM, + ]), + 'preview_snapshot_json' => json_encode($snapshot), + 'created_by' => $createdBy, + 'status' => SectionPlacementBatch::STATUS_DRAFT, + ]); + + foreach ($placement['assignments'] as $assignment) { + SectionPlacementBatchStudent::query()->create([ + 'batch_id' => $batch->getKey(), + 'student_id' => $assignment['student_id'], + 'student_type' => $assignment['student_type'], + 'source_decision_id' => $assignment['source_decision_id'], + 'source_enrollment_id' => $assignment['source_enrollment_id'], + 'final_score' => $assignment['final_score'], + 'placement_score' => $assignment['placement_score'], + 'score_band' => $assignment['score_band'], + 'placement_band_reason' => $assignment['placement_band_reason'], + 'planned_section_index' => $assignment['planned_section_index'], + 'assignment_order' => $assignment['assignment_order'], + 'was_override' => false, + 'created_at' => now(), + ]); + } + + return $batch->refresh(); + }); + } + + public function previewPayload(SectionPlacementBatch $batch): array + { + return [ + 'batch' => $batch->toArray(), + 'preview' => $batch->preview_snapshot_json ? json_decode((string) $batch->preview_snapshot_json, true) : null, + 'students' => $batch->students()->orderBy('planned_section_index')->orderBy('assignment_order')->get()->toArray(), + ]; + } + + public function finalize(int $batchId, ?int $userId = null): SectionPlacementBatch + { + $batch = SectionPlacementBatch::query()->findOrFail($batchId); + if ($batch->status !== SectionPlacementBatch::STATUS_DRAFT) { + throw new RuntimeException('Only draft placement batches can be finalized.'); + } + if ((int) $batch->total_students !== $batch->students()->count()) { + throw new RuntimeException('Draft batch student count does not match batch total.'); + } + + return DB::transaction(function () use ($batch, $userId) { + $semester = Configuration::getConfigValueByKey('semester') ?: 'Fall'; + $sectionMap = $this->ensureSections($batch, $semester); + + $seen = []; + $students = $batch->students()->orderBy('assignment_order')->get(); + foreach ($students as $student) { + $studentId = (int) $student->student_id; + if (isset($seen[$studentId])) { + throw new RuntimeException('Student appears more than once in placement batch.'); + } + $seen[$studentId] = true; + + $duplicate = DB::table('student_class') + ->where('student_id', $studentId) + ->where('school_year', $batch->to_school_year) + ->where('is_event_only', 0) + ->exists(); + if ($duplicate) { + throw new RuntimeException('Student ' . $studentId . ' already has a target-year class placement.'); + } + + $sectionId = $sectionMap[(int) $student->planned_section_index] ?? null; + if ($sectionId === null) { + throw new RuntimeException('Missing target section for planned section ' . $student->planned_section_index . '.'); + } + + DB::table('student_class')->insert([ + 'student_id' => $studentId, + 'class_section_id' => $sectionId, + 'is_event_only' => 0, + 'semester' => $semester, + 'school_year' => $batch->to_school_year, + 'description' => 'Finalized from section placement batch #' . $batch->getKey(), + 'created_at' => now(), + 'updated_at' => now(), + 'updated_by' => $userId, + ]); + + $student->assigned_section_id = $sectionId; + $student->save(); + } + + $batch->status = SectionPlacementBatch::STATUS_FINALIZED; + $batch->finalized_by = $userId; + $batch->finalized_at = now(); + $batch->save(); + + return $batch->refresh(); + }); + } + + private function ensureSections(SectionPlacementBatch $batch, string $semester): array + { + if (!$batch->to_grade_level_id) { + throw new RuntimeException('to_grade_level_id is required to finalize placement sections.'); + } + + $map = []; + $classId = (int) $batch->to_grade_level_id; + $sectionCount = (int) $batch->section_count; + $maxSectionId = (int) DB::table('classSection')->max('class_section_id'); + $letters = range('A', 'Z'); + + for ($i = 1; $i <= $sectionCount; $i++) { + $name = $this->sectionName($classId, $letters[$i - 1] ?? (string) $i, $batch->to_school_year); + $existing = DB::table('classSection') + ->where('class_id', $classId) + ->where('class_section_name', $name) + ->where(function ($q) use ($batch) { + $q->where('school_year', $batch->to_school_year)->orWhereNull('school_year'); + }) + ->value('class_section_id'); + + if ($existing) { + $map[$i] = (int) $existing; + continue; + } + + $sectionId = ++$maxSectionId; + DB::table('classSection')->insert([ + 'class_id' => $classId, + 'class_section_id' => $sectionId, + 'class_section_name' => $name, + 'semester' => $semester, + 'school_year' => $batch->to_school_year, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $map[$i] = $sectionId; + } + + return $map; + } + + private function sectionName(int $classId, string $suffix, string $schoolYear): string + { + $className = DB::table('classes')->where('id', $classId)->value('class_name'); + $base = $className ? (string) $className : ('Class ' . $classId); + return $base . '-' . $suffix . ' ' . $schoolYear; + } +} diff --git a/app/Services/Promotions/PromotionEligibilityService.php b/app/Services/Promotions/PromotionEligibilityService.php index 1c777cda..29845c89 100644 --- a/app/Services/Promotions/PromotionEligibilityService.php +++ b/app/Services/Promotions/PromotionEligibilityService.php @@ -281,50 +281,59 @@ class PromotionEligibilityService */ private function loadAcademicResult(int $studentId, string $schoolYear): array { - $rows = DB::table('semester_scores') - ->select('semester', 'semester_score') + $decision = DB::table('student_decisions') ->where('student_id', $studentId) ->where('school_year', $schoolYear) - ->get(); + ->orderByDesc('updated_at') + ->orderByDesc('id') + ->first(); - $fall = null; - $spring = null; - foreach ($rows as $row) { - $semester = strtolower((string) ($row->semester ?? '')); - $score = isset($row->semester_score) ? (float) $row->semester_score : null; - if ($semester === 'fall') { - $fall = $score; - } elseif ($semester === 'spring') { - $spring = $score; - } - } - - $threshold = $this->passThreshold(); - - if ($fall === null && $spring === null) { + if (!$decision) { return [ 'passed' => null, 'final_average' => null, - 'notes' => 'Missing fall and spring scores', - 'has_data' => false, - ]; - } - if ($fall === null || $spring === null) { - $existing = $fall ?? $spring; - return [ - 'passed' => null, - 'final_average' => $existing, - 'notes' => 'Awaiting both fall and spring scores', + 'notes' => 'Missing student_decisions record for promotion eligibility', + 'has_data' => false, + ]; + } + + $rawDecision = strtolower(trim((string) ($decision->decision ?? ''))); + $score = $decision->year_score !== null ? round((float) $decision->year_score, 2) : null; + + if (in_array($rawDecision, ['passed', 'pass', 'promoted', 'promote', 'eligible_to_continue'], true)) { + return [ + 'passed' => true, + 'final_average' => $score, + 'notes' => $score !== null + ? sprintf('student_decisions marked promoted with score %.2f', $score) + : 'student_decisions marked promoted without a year_score', + 'has_data' => true, + ]; + } + + if (in_array($rawDecision, ['failed', 'fail', 'retained', 'repeat', 'repeated_level'], true)) { + return [ + 'passed' => false, + 'final_average' => $score, + 'notes' => 'student_decisions marked failed/retained', + 'has_data' => true, + ]; + } + + if ($rawDecision === '' || $rawDecision === 'pending' || $rawDecision === 'manual review' || $rawDecision === 'manual_review') { + return [ + 'passed' => null, + 'final_average' => $score, + 'notes' => 'student_decisions is pending or requires manual review', 'has_data' => false, ]; } - $avg = round(($fall + $spring) / 2.0, 2); return [ - 'passed' => $avg >= $threshold, - 'final_average' => $avg, - 'notes' => sprintf('Final average %.2f (threshold %.2f)', $avg, $threshold), - 'has_data' => true, + 'passed' => null, + 'final_average' => $score, + 'notes' => 'student_decisions has unrecognized decision: ' . $rawDecision, + 'has_data' => false, ]; } diff --git a/app/Services/Scores/AttendanceCalculator.php b/app/Services/Scores/AttendanceCalculator.php index 05579295..f97b3120 100644 --- a/app/Services/Scores/AttendanceCalculator.php +++ b/app/Services/Scores/AttendanceCalculator.php @@ -63,7 +63,10 @@ class AttendanceCalculator implements ScoreCalculatorInterface return ['attendance_score' => 100.0]; } - $attendanceRatio = ($totalDays + 1 - $absences) / $totalDays; + // School policy: one absence is excused by grace before attendance is reduced. + $graceAbsences = 1; + $effectiveAbsences = max(0, (int) $absences - $graceAbsences); + $attendanceRatio = ($totalDays - $effectiveAbsences) / $totalDays; $score = min(100, max(0, $attendanceRatio * 100)); return ['attendance_score' => round($score, 2)]; diff --git a/app/Services/Scores/ExamScoreService.php b/app/Services/Scores/ExamScoreService.php index b1b50a66..66746cda 100644 --- a/app/Services/Scores/ExamScoreService.php +++ b/app/Services/Scores/ExamScoreService.php @@ -7,6 +7,7 @@ use App\Models\MissingScoreOverride; use App\Models\Student; use App\Models\TeacherClass; use App\Models\ClassSection; +use App\Services\Grading\Validation\ScoreValueValidator; use Illuminate\Support\Facades\DB; class ExamScoreService @@ -131,8 +132,10 @@ class ExamScoreService if (!$student) { continue; } - $rawScore = $data['score'] ?? null; - $normalizedScore = is_numeric($rawScore) ? (float) $rawScore : null; + $rawScore = is_array($data) ? ($data['score'] ?? null) : $data; + $validator = new ScoreValueValidator(); + $normalizedScore = $validator->normalizeNullable($rawScore, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Exam score'); + $missingAllowed = !empty($missingOk[$studentId]); $existing = $builder ->where('student_id', $studentId) @@ -147,6 +150,8 @@ class ExamScoreService 'class_section_id' => $classSectionId, 'updated_by' => $updatedBy, 'score' => $normalizedScore, + 'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS, + 'status' => $validator->inferStatus($normalizedScore, $missingAllowed), 'semester' => $semester, 'school_year' => $schoolYear, 'updated_at' => now(), diff --git a/app/Services/Scores/HomeworkScoreService.php b/app/Services/Scores/HomeworkScoreService.php index d46a45ff..13475f69 100644 --- a/app/Services/Scores/HomeworkScoreService.php +++ b/app/Services/Scores/HomeworkScoreService.php @@ -9,6 +9,7 @@ use App\Models\Student; use App\Models\StudentClass; use App\Models\TeacherClass; use App\Models\ClassSection; +use App\Services\Grading\Validation\ScoreValueValidator; class HomeworkScoreService { @@ -248,8 +249,9 @@ class HomeworkScoreService continue; } $rawScore = $score ?? null; - $isBlank = $rawScore === null || (is_string($rawScore) && trim($rawScore) === ''); - $normalizedScore = (!$isBlank && is_numeric($rawScore)) ? (float) $rawScore : null; + $validator = new ScoreValueValidator(); + $normalizedScore = $validator->normalizeNullable($rawScore, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Homework score'); + $missingAllowed = !empty($missingOk[$studentId][$index]); $existing = Homework::query() ->where('student_id', $studentId) @@ -266,6 +268,8 @@ class HomeworkScoreService 'updated_by' => $updatedBy, 'homework_index' => (int) $index, 'score' => $normalizedScore, + 'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS, + 'status' => $validator->inferStatus($normalizedScore, $missingAllowed), 'semester' => $semester, 'school_year' => $schoolYear, 'updated_at' => now(), diff --git a/app/Services/Scores/ParticipationScoreService.php b/app/Services/Scores/ParticipationScoreService.php index b50a3c2a..ebcbf743 100644 --- a/app/Services/Scores/ParticipationScoreService.php +++ b/app/Services/Scores/ParticipationScoreService.php @@ -4,6 +4,7 @@ namespace App\Services\Scores; use App\Models\GradingLock; use App\Models\ClassSection; +use App\Services\Grading\Validation\ScoreValueValidator; use App\Models\MissingScoreOverride; use App\Models\Participation; use App\Models\Student; @@ -185,8 +186,9 @@ class ParticipationScoreService } $rawScore = $data['score'] ?? null; - $isBlank = $rawScore === null || (is_string($rawScore) && trim($rawScore) === ''); - $normalizedScore = (!$isBlank && is_numeric($rawScore)) ? (float) $rawScore : null; + $validator = new ScoreValueValidator(); + $normalizedScore = $validator->normalizeNullable($rawScore, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Participation score'); + $missingAllowed = !empty($missingOk[$studentId]); $existing = Participation::query() ->where('student_id', $studentId) @@ -201,6 +203,8 @@ class ParticipationScoreService 'class_section_id' => $classSectionId, 'updated_by' => $updatedBy, 'score' => $normalizedScore, + 'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS, + 'status' => $validator->inferStatus($normalizedScore, $missingAllowed), 'semester' => $semester, 'school_year' => $schoolYear, 'updated_at' => now(), diff --git a/app/Services/Scores/ProjectScoreService.php b/app/Services/Scores/ProjectScoreService.php index 45bb8957..6372d968 100644 --- a/app/Services/Scores/ProjectScoreService.php +++ b/app/Services/Scores/ProjectScoreService.php @@ -9,6 +9,7 @@ use App\Models\Student; use App\Models\StudentClass; use App\Models\TeacherClass; use App\Models\ClassSection; +use App\Services\Grading\Validation\ScoreValueValidator; use Illuminate\Support\Facades\DB; class ProjectScoreService @@ -218,7 +219,9 @@ class ProjectScoreService if (!is_numeric($index)) { continue; } - $normalizedScore = is_numeric($score) ? (float) $score : null; + $validator = new ScoreValueValidator(); + $normalizedScore = $validator->normalizeNullable($score, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Project score'); + $missingAllowed = !empty($missingOk[$studentId][$index]); $existing = Project::query() ->where('student_id', $studentId) @@ -235,6 +238,8 @@ class ProjectScoreService 'updated_by' => $updatedBy, 'project_index' => (int) $index, 'score' => $normalizedScore, + 'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS, + 'status' => $validator->inferStatus($normalizedScore, $missingAllowed), 'semester' => $semester, 'school_year' => $schoolYear, 'updated_at' => now(), diff --git a/app/Services/Scores/QuizScoreService.php b/app/Services/Scores/QuizScoreService.php index 072b2427..117ede14 100644 --- a/app/Services/Scores/QuizScoreService.php +++ b/app/Services/Scores/QuizScoreService.php @@ -9,6 +9,7 @@ use App\Models\Student; use App\Models\StudentClass; use App\Models\TeacherClass; use App\Models\ClassSection; +use App\Services\Grading\Validation\ScoreValueValidator; class QuizScoreService { @@ -219,8 +220,9 @@ class QuizScoreService if (!is_numeric($quizNumber)) { continue; } - $isBlank = $score === null || (is_string($score) && trim($score) === ''); - $normalizedScore = (!$isBlank && is_numeric($score)) ? (float) $score : null; + $validator = new ScoreValueValidator(); + $normalizedScore = $validator->normalizeNullable($score, ScoreValueValidator::DEFAULT_MAX_POINTS, 'Quiz score'); + $missingAllowed = !empty($missingOk[$studentId][$quizNumber]); $existing = Quiz::query() ->where('student_id', $studentId) @@ -237,6 +239,8 @@ class QuizScoreService 'updated_by' => $updatedBy, 'quiz_index' => (int) $quizNumber, 'score' => $normalizedScore, + 'max_points' => ScoreValueValidator::DEFAULT_MAX_POINTS, + 'status' => $validator->inferStatus($normalizedScore, $missingAllowed), 'semester' => $semester, 'school_year' => $schoolYear, 'updated_at' => now(), diff --git a/app/Services/Scores/SemesterScoreService.php b/app/Services/Scores/SemesterScoreService.php index ed3f35ed..5bd082f8 100644 --- a/app/Services/Scores/SemesterScoreService.php +++ b/app/Services/Scores/SemesterScoreService.php @@ -17,6 +17,7 @@ use App\Models\CalendarEvent; use App\Models\Homework; use App\Models\Project; use RuntimeException; +use App\Services\Grading\Policy\GradingPolicyResolver; class SemesterScoreService { @@ -26,6 +27,7 @@ class SemesterScoreService private string $semester; private string $schoolYear; private ?int $actorId; + private GradingPolicyResolver $policyResolver; public function __construct( ?AttendanceCalculator $attendanceCalculator = null, @@ -36,6 +38,7 @@ class SemesterScoreService $this->semester = (string) (Configuration::getConfig('semester') ?? ''); $this->schoolYear = (string) (Configuration::getConfig('school_year') ?? ''); $this->actorId = (int) (auth()->id() ?? 0) ?: null; + $this->policyResolver = new GradingPolicyResolver(); $attendanceCalculator = $attendanceCalculator ?? new AttendanceCalculator( new AttendanceRecord(), @@ -209,12 +212,16 @@ class SemesterScoreService string $schoolYear, array $scoreData ): void { + $calculationMode = $this->policyResolver->calculationMode($classSectionId, $semester, $schoolYear); + $payload = array_merge([ 'student_id' => $studentId, 'school_id' => $schoolId, 'class_section_id' => $classSectionId, 'semester' => $semester, 'school_year' => $schoolYear, + 'calculation_mode' => $calculationMode, + 'calculation_policy_version' => $this->policyResolver->policyVersion($calculationMode), 'updated_at' => now(), ], $scoreData); diff --git a/config/finance.php b/config/finance.php new file mode 100644 index 00000000..ebb3f9a5 --- /dev/null +++ b/config/finance.php @@ -0,0 +1,16 @@ + env('FINANCE_LEDGER_MODE', 'legacy'), + 'carryforward' => [ + 'enabled' => env('FINANCE_CARRYFORWARD_ENABLED', true), + 'auto_post_to_new_year' => env('FINANCE_CARRYFORWARD_AUTO_POST', false), + 'block_enrollment_threshold' => env('FINANCE_CARRYFORWARD_BLOCK_ENROLLMENT_THRESHOLD', null), + 'require_admin_review_threshold' => env('FINANCE_CARRYFORWARD_REVIEW_THRESHOLD', 0), + 'allow_installment_plan' => env('FINANCE_CARRYFORWARD_ALLOW_INSTALLMENT_PLAN', true), + ], + 'payment_allocation' => [ + 'default_policy' => env('FINANCE_PAYMENT_ALLOCATION_POLICY', 'oldest_debt_first'), + ], + 'tuition_calculator_version' => env('FINANCE_TUITION_CALCULATOR_VERSION', 'old'), +]; diff --git a/database/migrations/2026_06_04_140000_add_strong_lifecycle_and_section_placement_tables.php b/database/migrations/2026_06_04_140000_add_strong_lifecycle_and_section_placement_tables.php new file mode 100644 index 00000000..4748ce3c --- /dev/null +++ b/database/migrations/2026_06_04_140000_add_strong_lifecycle_and_section_placement_tables.php @@ -0,0 +1,87 @@ +string('lifecycle_mode', 20)->default('legacy'); + } + if (!Schema::hasColumn($table, 'lifecycle_policy_version')) { + $t->string('lifecycle_policy_version', 30)->default('legacy_v1'); + } + }); + } + + if (!Schema::hasTable('section_placement_batches')) { + Schema::create('section_placement_batches', function (Blueprint $table) { + $table->id(); + $table->string('from_school_year', 25); + $table->string('to_school_year', 25); + $table->unsignedInteger('from_grade_level_id')->nullable(); + $table->unsignedInteger('to_grade_level_id')->nullable(); + $table->unsignedInteger('section_capacity_used'); + $table->unsignedInteger('total_students')->default(0); + $table->unsignedInteger('section_count')->default(0); + $table->string('algorithm', 100)->default('score_band_balanced_v1'); + $table->json('configuration_snapshot_json')->nullable(); + $table->json('preview_snapshot_json')->nullable(); + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('finalized_by')->nullable(); + $table->dateTime('finalized_at')->nullable(); + $table->string('status', 30)->default('draft'); + $table->timestamps(); + $table->index(['from_school_year', 'to_school_year', 'status'], 'placement_batches_year_status_idx'); + }); + } + + if (!Schema::hasTable('section_placement_batch_students')) { + Schema::create('section_placement_batch_students', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('batch_id'); + $table->unsignedInteger('student_id'); + $table->string('student_type', 30); + $table->unsignedInteger('source_decision_id')->nullable(); + $table->unsignedInteger('source_enrollment_id')->nullable(); + $table->decimal('final_score', 6, 2)->nullable(); + $table->decimal('placement_score', 6, 2)->nullable(); + $table->string('score_band', 1); + $table->string('placement_band_reason', 60); + $table->unsignedInteger('planned_section_index')->nullable(); + $table->unsignedInteger('assigned_section_id')->nullable(); + $table->unsignedInteger('assignment_order')->default(0); + $table->boolean('was_override')->default(false); + $table->text('override_reason')->nullable(); + $table->dateTime('created_at')->nullable(); + $table->unique(['batch_id', 'student_id'], 'placement_batch_student_unique'); + $table->index(['student_id', 'assigned_section_id'], 'placement_batch_student_assignment_idx'); + }); + } + + if (Schema::hasTable('configuration')) { + $exists = DB::table('configuration')->where('config_key', 'promotion.section_capacity')->exists(); + if (!$exists) { + DB::table('configuration')->insert([ + 'config_key' => 'promotion.section_capacity', + 'config_value' => '20', + ]); + } + } + } + + public function down(): void + { + Schema::dropIfExists('section_placement_batch_students'); + Schema::dropIfExists('section_placement_batches'); + } +}; diff --git a/database/migrations/2026_06_04_210000_add_strong_grading_backward_compatibility.php b/database/migrations/2026_06_04_210000_add_strong_grading_backward_compatibility.php new file mode 100644 index 00000000..f54c8795 --- /dev/null +++ b/database/migrations/2026_06_04_210000_add_strong_grading_backward_compatibility.php @@ -0,0 +1,111 @@ +decimal('max_points', 8, 2)->default(100)->after('score'); + } + if (!Schema::hasColumn($table, 'status')) { + $tableBlueprint->string('status', 30)->nullable()->after('max_points'); + } + if (!Schema::hasColumn($table, 'excused_reason')) { + $tableBlueprint->text('excused_reason')->nullable()->after('status'); + } + if (!Schema::hasColumn($table, 'locked_at')) { + $tableBlueprint->timestamp('locked_at')->nullable()->after('excused_reason'); + } + if (!Schema::hasColumn($table, 'locked_by')) { + $tableBlueprint->unsignedBigInteger('locked_by')->nullable()->after('locked_at'); + } + }); + + DB::table($table) + ->whereNull('status') + ->update([ + 'status' => DB::raw("CASE WHEN score IS NULL THEN 'pending' ELSE 'scored' END"), + ]); + } + + if (Schema::hasTable('semester_scores')) { + Schema::table('semester_scores', function (Blueprint $table) { + if (!Schema::hasColumn('semester_scores', 'calculation_mode')) { + $table->string('calculation_mode', 30)->default('legacy')->after('semester_score'); + } + if (!Schema::hasColumn('semester_scores', 'calculation_policy_version')) { + $table->string('calculation_policy_version', 50)->default('legacy_v1')->after('calculation_mode'); + } + if (!Schema::hasColumn('semester_scores', 'snapshot_id')) { + $table->unsignedBigInteger('snapshot_id')->nullable()->after('calculation_policy_version'); + } + }); + + DB::table('semester_scores')->whereNull('calculation_mode')->update(['calculation_mode' => 'legacy']); + DB::table('semester_scores')->whereNull('calculation_policy_version')->update(['calculation_policy_version' => 'legacy_v1']); + } + + if (!Schema::hasTable('semester_score_snapshots')) { + Schema::create('semester_score_snapshots', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('student_id'); + $table->unsignedBigInteger('school_id')->nullable(); + $table->unsignedBigInteger('class_section_id'); + $table->string('semester', 50); + $table->string('school_year', 50); + $table->unsignedBigInteger('grading_profile_id')->nullable(); + $table->string('grading_profile_version', 50)->nullable(); + $table->string('calculation_mode', 30)->default('strong'); + $table->string('calculation_policy_version', 50)->default('strong_v1'); + $table->json('input_json')->nullable(); + $table->json('calculation_json')->nullable(); + $table->decimal('semester_score', 8, 2)->nullable(); + $table->unsignedBigInteger('calculated_by')->nullable(); + $table->timestamp('calculated_at')->nullable(); + $table->timestamps(); + + $table->index(['student_id', 'class_section_id', 'semester', 'school_year'], 'semester_snapshot_student_term_idx'); + $table->index(['calculation_mode', 'calculation_policy_version'], 'semester_snapshot_policy_idx'); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('semester_score_snapshots'); + + if (Schema::hasTable('semester_scores')) { + Schema::table('semester_scores', function (Blueprint $table) { + foreach (['snapshot_id', 'calculation_policy_version', 'calculation_mode'] as $column) { + if (Schema::hasColumn('semester_scores', $column)) { + $table->dropColumn($column); + } + } + }); + } + + foreach (['homework', 'quiz', 'project', 'participation', 'midterm_exam', 'final_exam'] as $tableName) { + if (!Schema::hasTable($tableName)) { + continue; + } + Schema::table($tableName, function (Blueprint $table) use ($tableName) { + foreach (['locked_by', 'locked_at', 'excused_reason', 'status', 'max_points'] as $column) { + if (Schema::hasColumn($tableName, $column)) { + $table->dropColumn($column); + } + } + }); + } + } +}; diff --git a/database/migrations/2026_06_04_230000_add_finance_lifecycle_tables.php b/database/migrations/2026_06_04_230000_add_finance_lifecycle_tables.php new file mode 100644 index 00000000..9bb7c9eb --- /dev/null +++ b/database/migrations/2026_06_04_230000_add_finance_lifecycle_tables.php @@ -0,0 +1,168 @@ +id(); + $table->unsignedBigInteger('parent_id')->index(); + $table->unsignedBigInteger('invoice_id')->nullable()->index(); + $table->string('school_year')->nullable()->index(); + $table->string('status')->default('not_contacted')->index(); + $table->string('contact_method')->nullable(); + $table->date('promise_to_pay_date')->nullable(); + $table->decimal('promise_to_pay_amount', 12, 2)->nullable(); + $table->date('next_follow_up_date')->nullable()->index(); + $table->unsignedTinyInteger('escalation_level')->default(0); + $table->text('note')->nullable(); + $table->unsignedBigInteger('created_by')->nullable()->index(); + $table->timestamps(); + }); + } + + if (!Schema::hasTable('finance_balance_carryforwards')) { + Schema::create('finance_balance_carryforwards', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('parent_id')->index(); + $table->unsignedBigInteger('student_id')->nullable()->index(); + $table->string('from_school_year')->index(); + $table->string('to_school_year')->index(); + $table->json('source_invoice_ids_json')->nullable(); + $table->decimal('source_balance_amount', 12, 2)->default(0); + $table->decimal('carryforward_amount', 12, 2)->default(0); + $table->decimal('waived_amount', 12, 2)->default(0); + $table->decimal('adjusted_amount', 12, 2)->default(0); + $table->decimal('remaining_amount', 12, 2)->default(0); + $table->string('status')->default('draft')->index(); + $table->string('reason')->nullable(); + $table->unsignedBigInteger('created_by')->nullable()->index(); + $table->unsignedBigInteger('approved_by')->nullable()->index(); + $table->timestamp('approved_at')->nullable(); + $table->unsignedBigInteger('posted_invoice_id')->nullable()->index(); + $table->unsignedBigInteger('posted_invoice_line_item_id')->nullable()->index(); + $table->json('metadata_json')->nullable(); + $table->timestamps(); + $table->unique(['parent_id', 'from_school_year', 'to_school_year'], 'finance_carryforward_unique_parent_years'); + }); + } + + if (!Schema::hasTable('invoice_installment_plans')) { + Schema::create('invoice_installment_plans', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('invoice_id')->index(); + $table->unsignedBigInteger('parent_id')->index(); + $table->string('school_year')->nullable()->index(); + $table->string('plan_name')->nullable(); + $table->decimal('total_amount', 12, 2)->default(0); + $table->unsignedInteger('number_of_installments')->default(1); + $table->string('status')->default('draft')->index(); + $table->unsignedBigInteger('created_by')->nullable()->index(); + $table->unsignedBigInteger('approved_by')->nullable()->index(); + $table->timestamps(); + }); + } + + if (!Schema::hasTable('invoice_installments')) { + Schema::create('invoice_installments', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('installment_plan_id')->index(); + $table->unsignedBigInteger('invoice_id')->index(); + $table->unsignedInteger('sequence'); + $table->date('due_date')->index(); + $table->decimal('amount_due', 12, 2)->default(0); + $table->decimal('amount_paid', 12, 2)->default(0); + $table->decimal('balance', 12, 2)->default(0); + $table->string('status')->default('pending')->index(); + $table->timestamp('paid_at')->nullable(); + $table->timestamps(); + $table->unique(['installment_plan_id', 'sequence'], 'invoice_installments_plan_sequence_unique'); + }); + } + + if (!Schema::hasTable('payment_installment_allocations')) { + Schema::create('payment_installment_allocations', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('payment_id')->index(); + $table->unsignedBigInteger('invoice_id')->index(); + $table->unsignedBigInteger('installment_id')->index(); + $table->decimal('amount', 12, 2)->default(0); + $table->timestamps(); + }); + } + + if (!Schema::hasTable('finance_notification_logs')) { + Schema::create('finance_notification_logs', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('parent_id')->nullable()->index(); + $table->unsignedBigInteger('student_id')->nullable()->index(); + $table->unsignedBigInteger('invoice_id')->nullable()->index(); + $table->unsignedBigInteger('payment_id')->nullable()->index(); + $table->unsignedBigInteger('refund_id')->nullable()->index(); + $table->unsignedBigInteger('event_charge_id')->nullable()->index(); + $table->unsignedBigInteger('installment_id')->nullable()->index(); + $table->string('notification_type')->index(); + $table->string('channel')->default('mail')->index(); + $table->string('recipient')->nullable(); + $table->string('subject')->nullable(); + $table->string('status')->default('queued')->index(); + $table->timestamp('sent_at')->nullable(); + $table->timestamp('failed_at')->nullable(); + $table->text('error_message')->nullable(); + $table->unsignedBigInteger('created_by')->nullable()->index(); + $table->timestamps(); + }); + } + + if (!Schema::hasTable('finance_notification_preferences')) { + Schema::create('finance_notification_preferences', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('parent_id')->unique(); + $table->boolean('email_receipts_enabled')->default(true); + $table->boolean('email_reminders_enabled')->default(true); + $table->boolean('sms_receipts_enabled')->default(false); + $table->boolean('sms_reminders_enabled')->default(false); + $table->boolean('statement_notifications_enabled')->default(true); + $table->timestamps(); + }); + } + + if (!Schema::hasTable('finance_receipt_sequences')) { + Schema::create('finance_receipt_sequences', function (Blueprint $table) { + $table->id(); + $table->string('school_year')->unique(); + $table->string('prefix')->default('R'); + $table->unsignedBigInteger('next_number')->default(1); + $table->timestamps(); + }); + } + + if (!Schema::hasTable('payment_carryforward_allocations')) { + Schema::create('payment_carryforward_allocations', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('payment_id')->index(); + $table->unsignedBigInteger('carryforward_id')->index(); + $table->decimal('amount', 12, 2)->default(0); + $table->timestamps(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('payment_carryforward_allocations'); + Schema::dropIfExists('finance_receipt_sequences'); + Schema::dropIfExists('finance_notification_preferences'); + Schema::dropIfExists('finance_notification_logs'); + Schema::dropIfExists('payment_installment_allocations'); + Schema::dropIfExists('invoice_installments'); + Schema::dropIfExists('invoice_installment_plans'); + Schema::dropIfExists('finance_balance_carryforwards'); + Schema::dropIfExists('finance_follow_up_notes'); + } +}; diff --git a/database/migrations/2026_06_05_000000_sort_nav_items_alphabetically.php b/database/migrations/2026_06_05_000000_sort_nav_items_alphabetically.php new file mode 100644 index 00000000..4672ca80 --- /dev/null +++ b/database/migrations/2026_06_05_000000_sort_nav_items_alphabetically.php @@ -0,0 +1,56 @@ +select(['id', 'menu_parent_id', 'label']) + ->whereNull('deleted_at') + ->orderBy('menu_parent_id') + ->orderBy('label') + ->orderBy('id') + ->get() + ->groupBy(fn ($row) => $row->menu_parent_id === null ? 'root' : (string) $row->menu_parent_id); + + foreach ($rows as $siblings) { + $ordered = $siblings->values()->all(); + usort($ordered, function ($a, $b) { + $result = strnatcasecmp($this->labelKey((string) ($a->label ?? '')), $this->labelKey((string) ($b->label ?? ''))); + + return $result !== 0 ? $result : ((int) $a->id <=> (int) $b->id); + }); + + foreach ($ordered as $index => $item) { + DB::table('nav_items') + ->where('id', (int) $item->id) + ->update([ + 'sort_order' => ($index + 1) * 10, + 'updated_at' => now(), + ]); + } + } + } + + public function down(): void + { + // Alphabetical sort order normalization is intentionally not reversible. + } + + private function labelKey(string $s): string + { + $s = trim(preg_replace('/\s+/', ' ', $s)); + $s = preg_replace('/^[^[:alnum:]]+/u', '', $s); + $s = preg_replace('/^(?i)(the|an|a)\s+/', '', $s); + + return mb_strtolower($s, 'UTF-8'); + } +}; diff --git a/database/migrations/2026_06_05_010000_add_verification_token_to_certificate_records.php b/database/migrations/2026_06_05_010000_add_verification_token_to_certificate_records.php new file mode 100644 index 00000000..b02f55df --- /dev/null +++ b/database/migrations/2026_06_05_010000_add_verification_token_to_certificate_records.php @@ -0,0 +1,32 @@ +string('verification_token', 64)->nullable()->after('certificate_number'); + $table->unique('verification_token'); + }); + } + + public function down(): void + { + if (! Schema::hasTable('certificate_records') || ! Schema::hasColumn('certificate_records', 'verification_token')) { + return; + } + + Schema::table('certificate_records', function (Blueprint $table): void { + $table->dropUnique(['verification_token']); + $table->dropColumn('verification_token'); + }); + } +}; diff --git a/docs/final_strong_enrollment_registration_promotion_plan.md b/docs/final_strong_enrollment_registration_promotion_plan.md new file mode 100644 index 00000000..3ce2f7d5 --- /dev/null +++ b/docs/final_strong_enrollment_registration_promotion_plan.md @@ -0,0 +1,925 @@ +# Strong Enrollment, Registration, Promotion, and Section Placement Plan + +## 1. Purpose + +This plan defines a stronger, safer lifecycle for student registration, enrollment, promotion, and next-class section placement. + +The goal is to separate concepts that are currently too easy to mix together: + +- Student identity +- New-student registration +- Returning-student enrollment +- Academic pass/fail decision +- Promotion eligibility +- Class/section placement +- Historical display and auditability + +A strong system must not treat all of these as one status field wearing too many hats. That is how school software becomes a haunted spreadsheet. + +--- + +## 2. Core Principle + +The system must distinguish between: + +| Concept | Meaning | Source of Truth | +|---|---|---| +| Student identity | The person/student record | `students` | +| New-student registration | A new family applies for admission | registration/application tables | +| New-student approval | Admin approves the new student | admin registration/enrollment flow | +| Returning-student enrollment | Parent confirms child is continuing next year | returning enrollment record | +| Academic decision | Student passed, failed, retained, or needs review | `student_decisions` | +| Promotion eligibility | Student may move to next class | derived from `student_decisions` | +| Section placement | Student is assigned to a class section | section placement batch | +| Historical display | Old records remain visible as originally stored | legacy records/tables | + +Promotion must not directly mean enrollment. Enrollment must not directly mean class placement. Class placement must not decide pass/fail. Each process gets its own job, because apparently databases behave better when we stop asking one column to run a school. + +--- + +## 3. Backward Compatibility Requirement + +The new system must be additive, not destructive. + +Existing registration, enrollment, assignment, and promotion data must remain displayable. Old rows must not be silently reinterpreted or recalculated under the new policy. + +### Required rules + +```text +Old records remain visible. +Old student/class assignments remain readable. +Historical enrollment state is not silently changed. +Historical promotion results are not silently recalculated. +New lifecycle rules apply only to future/new workflow records or explicitly migrated records. +``` + +### Recommended legacy metadata + +Where needed, add metadata such as: + +```text +lifecycle_mode = legacy | strong +lifecycle_policy_version = legacy_v1 | strong_v1 +``` + +Existing rows should default to: + +```text +lifecycle_mode = legacy +lifecycle_policy_version = legacy_v1 +``` + +This allows the UI to show old data clearly without pretending old rows followed the new workflow. + +--- + +## 4. Student Lifecycle Types + +The system must support two main student flows. + +## 4.1 New Student Flow + +A new student is not already part of the school’s prior-year enrollment. + +New-student flow: + +```text +Parent submits new-student registration +→ Admin reviews application +→ Admin approves or rejects +→ Approved student enters placement pool +→ Student is assigned to class section +→ Enrollment is completed +``` + +Admin approval is required for new students. + +### New-student states + +```text +application_draft +application_submitted +admin_review_pending +approved +rejected +waitlisted +placement_pending +placed_in_class +enrollment_completed +``` + +--- + +## 4.2 Returning Student Flow + +A returning student already exists and was enrolled in the previous school year/class. + +Returning-student flow: + +```text +Old class/year finalized +→ `student_decisions` records pass/fail/retain/review decision +→ If passed/promoted, student becomes eligible to continue +→ Parent completes returning-student enrollment +→ System validates eligibility +→ Student enters placement pool +→ Student is assigned to class section +→ Enrollment is completed +``` + +Admin approval is not required for normal returning students. + +Admin review is required only for exceptions. + +### Returning-student states + +```text +academic_not_reviewed +eligible_to_continue +retained +parent_enrollment_pending +parent_enrollment_completed +placement_pending +placed_in_class +enrollment_completed +exception_review_required +``` + +--- + +## 5. Pass/Fail Source of Truth + +Promotion eligibility must come from `student_decisions`. + +The placement system must not recalculate pass/fail. Its job is placement, not academic judgment. Otherwise it becomes judge, registrar, scheduler, and disaster generator. + +### Required rule + +```text +Promotion placement must read academic eligibility from `student_decisions`. +A student is eligible for next-class placement only when `student_decisions` marks the student as passed/promoted for the completed school year/class. +Failed, retained, pending, or missing-decision students must not enter the automatic next-class placement pool. +``` + +### Eligibility from `student_decisions` + +| Decision | Placement behavior | +|---|---| +| passed/promoted | Eligible for next-class placement after parent enrollment | +| failed/retained | Excluded from automatic next-class placement | +| pending | Exception report | +| missing | Exception report | +| manual review | Exception review | +| withdrawn/inactive | Excluded unless admin override | + +### Required exception buckets + +The system should report: + +```text +Passed but parent enrollment missing +Passed and parent enrollment completed +Failed/retained +Missing student decision +Pending student decision +Decision conflicts with enrollment request +Withdrawn/inactive student +Duplicate target-year enrollment +``` + +--- + +## 6. Parent Enrollment Requirement for Returning Students + +A returning student must not be placed into the next year/class until the parent completes returning-student enrollment. + +### Required rule + +```text +For returning students, promotion eligibility plus parent enrollment completion is sufficient to continue enrollment into the next school year, subject to system validation and class placement. +Admin approval is only required for exceptions. +``` + +### Returning enrollment should capture + +```text +student_id +parent_id +target_school_year +requested_grade_level_id +returning_student = true +required forms completed +emergency contact confirmation +medical/contact update confirmation +agreement/consent confirmations +submission timestamp +status +``` + +### Returning enrollment statuses + +```text +not_started +in_progress +submitted +completed +expired +exception_review_required +``` + +Normal returning-student continuation should use: + +```text +parent_enrollment_completed +``` + +not: + +```text +admin_approved +``` + +Admin approval is for new students and exceptions, not normal returning students. + +--- + +## 7. When Admin Review Is Required for Returning Students + +Returning students should flow automatically only when clean. + +Admin review is required when: + +```text +student failed but parent tries to enroll in next grade +student is retained and needs repeat placement +student has no linked parent/guardian +required forms are incomplete after deadline +requested class/grade conflicts with eligibility +capacity override is needed +manual promotion override is requested +duplicate enrollment exists +student was withdrawn or inactive +student_decisions is missing or pending +``` + +This keeps normal cases fast and sends only abnormal cases to humans, who are expensive, inconsistent, and sometimes looking for the wrong spreadsheet. + +--- + +## 8. Placement Pool Construction + +The final placement pool is built from two sources: + +```text +eligible returning students ++ approved new students +``` + +### 8.1 Returning student inclusion rule + +A returning student enters the placement pool only if: + +```text +`student_decisions` marks student as passed/promoted +parent completed returning-student enrollment +student is not already placed in the target year +target grade/class matches promotion path +student is active/not withdrawn +``` + +### 8.2 New student inclusion rule + +A new student enters the placement pool only if: + +```text +new-student registration/enrollment is admin-approved +student is not already placed in the target year +target grade/class is valid +``` + +--- + +## 9. Configurable Section Capacity + +The section capacity must not be hardcoded. + +The maximum number of students per generated section must be read from the `configuration` table as a key/value setting. + +### Required configuration key + +Recommended key: + +```text +promotion.section_capacity = 20 +``` + +### Configuration table example + +| key | value | type | description | +|---|---:|---|---| +| `promotion.section_capacity` | `20` | integer | Maximum number of students per generated promoted section | + +### Validation + +```text +promotion.section_capacity must be an integer +promotion.section_capacity must be greater than 0 +recommended minimum: 5 +recommended maximum: 40 unless admin override +``` + +### Fallback rule + +If the key is missing: + +```text +section_capacity = 20 +``` + +The fallback protects the system, but missing config should still be logged as a warning. Silent defaults are convenient right up until nobody knows why sections were created that way. + +### Snapshot rule + +The system must store the capacity used at generation time: + +```text +section_capacity_used +``` + +Optional but recommended: + +```text +configuration_snapshot_json +``` + +Example: + +```json +{ + "promotion.section_capacity": 20, + "placement.algorithm": "score_band_balanced_v1" +} +``` + +This prevents old placement batches from changing meaning if the capacity is later changed to 22, 25, or whatever fresh number policy invents. + +--- + +## 10. Section Count Rule + +Let: + +```text +section_capacity = configuration['promotion.section_capacity'] ?? 20 +total_students = count(placement_pool) +``` + +Then: + +```text +If total_students <= section_capacity: + create one section + +If total_students > section_capacity: + section_count = ceil(total_students / section_capacity) +``` + +Examples when `promotion.section_capacity = 20`: + +| Total students | Section count | +|---:|---:| +| 1-20 | 1 | +| 21-40 | 2 | +| 41-60 | 3 | +| 61-80 | 4 | + +--- + +## 11. Score Bands for Placement + +Students must be grouped into score bands for balanced section placement. + +### Bands + +| Band | Score range | +|---|---:| +| A | 90-100 | +| B | 80-89 | +| C | 70-79 | +| D | 60-69 | + +Students below 60 are not eligible for automatic next-class promotion unless an authorized override exists. + +### Score source by student type + +| Student type | Eligibility source | Band source | +|---|---|---| +| Returning student | `student_decisions` pass/promote | final promotion score | +| Failed/retained student | `student_decisions` fail/retain | excluded from next-class automatic placement | +| New student with placement assessment | approved new-student enrollment | assessment score band | +| New student without placement assessment | approved new-student enrollment | D band by default | + +--- + +## 12. New Student Default Band Rule + +New students do not always have prior school scores. If no approved placement/assessment score exists, they must be assigned to the D band for balancing purposes. + +### Required rule + +```text +New students approved for enrollment are included in the target class placement pool. +If a new student does not have an approved placement or assessment score, the system assigns the student to the D score band, 60-69, for section-balancing purposes only. +This default band assignment must not be stored as an academic grade. +It must be stored as a placement-band classification with the reason `new_student_default_band`. +``` + +Do not invent a fake numeric score like 65. That would contaminate reports later, because somebody will export it and believe it is real. Humans keep doing that. We design around it. + +### Recommended fields + +```text +placement_band = D +placement_score = null +placement_band_reason = new_student_default_band +``` + +If a new student has an approved placement score: + +```text +placement_band = derived from placement score +placement_score = approved assessment score +placement_band_reason = new_student_assessment +``` + +--- + +## 13. Balanced Section Placement Rule + +When there are multiple sections, the system must balance: + +1. Total number of students per section. +2. Score-band mix across sections. + +The system should not simply divide each score band and accidentally create sections with different total sizes. + +### Required rule + +```text +When distributing score bands across multiple sections, the system must prioritize equal total section size while keeping each score band as evenly distributed as possible. +Remainders from score bands should be assigned using a balancing algorithm that gives the next student to the section with the lowest current total, not simply to the next section in fixed order. +``` + +### Target section sizes + +Given: + +```text +total_students +section_count +``` + +Calculate: + +```text +base_size = floor(total_students / section_count) +remainder = total_students % section_count +``` + +Then: + +```text +first `remainder` sections may have base_size + 1 students +remaining sections have base_size students +``` + +Example: + +```text +32 students +2 sections +target size = 16 and 16 +``` + +Example: + +```text +43 students +3 sections +target size = 15, 14, 14 +``` + +--- + +## 14. Balanced Placement Algorithm + +Use a deterministic algorithm. + +### Algorithm: score_band_balanced_v1 + +```text +1. Load section_capacity from configuration. +2. Validate section_capacity. +3. Build placement pool: + - returning students passed/promoted in `student_decisions` + - returning parent enrollment completed + - approved new students +4. Exclude: + - failed/retained students + - missing/pending decision students + - parent enrollment missing for returning students + - duplicate target-year placements +5. Count placement pool. +6. If total_students <= section_capacity: + create one section and assign all students. +7. If total_students > section_capacity: + section_count = ceil(total_students / section_capacity). +8. Calculate target section sizes. +9. Assign each student a placement band: + returning students: final promotion score band + new students with assessment: assessment score band + new students without assessment: D band +10. Group students by band: + A: 90-100 + B: 80-89 + C: 70-79 + D: 60-69 +11. Sort bands from highest to lowest: A, B, C, D. +12. Within each band, sort students deterministically: + final/placement score descending when available + student name ascending or student_id ascending as tie-breaker +13. Assign students one by one to the eligible section with: + lowest current total count + then lowest count for that student's score band + then deterministic section order +14. Do not exceed target section size unless all target sizes are full or admin override exists. +15. Validate every student is assigned exactly once. +16. Save placement snapshot. +17. Show admin preview. +18. Finalize only after preview confirmation. +``` + +### Tie-break order + +The section assignment tie-break order must be: + +```text +lowest total section count +then lowest count in the student's score band +then deterministic section order +``` + +This order matters. + +If the system prioritizes band equality before total section size, totals can drift. If it prioritizes total size first and band count second, it produces more balanced sections. + +--- + +## 15. Example Balanced Distribution + +For 32 eligible students and capacity 20: + +```text +section_count = ceil(32 / 20) = 2 +target size = 16 and 16 +``` + +A valid balanced result: + +| Band | Section A | Section B | +|---|---:|---:| +| 90-100 | 4 | 4 | +| 80-89 | 5 | 5 | +| 70-79 | 4 | 5 | +| 60-69 | 3 | 2 | +| **Total** | **16** | **16** | + +This is the desired behavior: both sections have equal total size, and the band mix is as balanced as possible. + +--- + +## 16. Placement Preview Before Finalization + +The system must not immediately write final class assignments. + +It should first create a draft placement batch. + +### Preview should show + +```text +section count +capacity used +target section sizes +student count per section +score-band distribution per section +new students defaulted to D band +students excluded from placement +students needing exception review +capacity warnings +duplicate placement warnings +``` + +Admin should then finalize the preview. + +Only after finalization should the system write final class/section assignments. + +This avoids the classic software maneuver where a draft becomes reality because somebody clicked the wrong button and the database had no spine. + +--- + +## 17. Placement Snapshot and Audit Tables + +Create a placement batch snapshot so the school can explain how students were assigned. + +### Recommended table: `section_placement_batches` + +```text +id +from_school_year +to_school_year +from_grade_level_id +to_grade_level_id +section_capacity_used +total_students +section_count +algorithm +configuration_snapshot_json +created_by +created_at +finalized_by +finalized_at +status +``` + +Recommended statuses: + +```text +draft +finalized +cancelled +superseded +``` + +### Recommended table: `section_placement_batch_students` + +```text +id +batch_id +student_id +student_type +source_decision_id nullable +source_enrollment_id nullable +final_score nullable +placement_score nullable +score_band +placement_band_reason +assigned_section_id +assignment_order +was_override +override_reason +created_at +``` + +### Placement band reasons + +```text +returning_student_final_score +new_student_assessment +new_student_default_band +admin_override +``` + +--- + +## 18. Validation Before Placement Generation + +Before generating placement, validate: + +```text +target school year exists +target school year is open for enrollment/placement +target grade/class exists +section_capacity config exists or fallback is applied +source class/year is finalized where applicable +`student_decisions` exists for returning students +returning students have passed/promoted decisions +returning students completed parent enrollment +new students are admin-approved +students are not already placed in target year +target grade/class matches the promotion path +``` + +--- + +## 19. Validation Before Placement Finalization + +Before finalizing placement, validate: + +```text +all eligible students are assigned exactly once +no ineligible student is assigned +no student is already assigned to another target-year section +all sections belong to the target year and grade/class +no section exceeds target size unless override is recorded +score-band distribution report was generated +section_capacity_used is stored +placement snapshot is saved +admin finalized the preview +``` + +--- + +## 20. Edge Cases + +| Case | Rule | +|---|---| +| Score exactly 90 | Band A | +| Score exactly 80 | Band B | +| Score exactly 70 | Band C | +| Score exactly 60 | Band D | +| Score below 60 | Not automatic promotion unless override | +| Missing final score for returning student | Exception report | +| Missing `student_decisions` | Exception report | +| Pending `student_decisions` | Exception report | +| Parent did not complete returning enrollment | Do not place | +| New student without assessment | D band for balancing only | +| New student with assessment score | Band from assessment | +| Duplicate target-year placement | Block and report | +| Capacity exceeded | Admin override required | +| Uneven score bands | Balance total section size first, band mix second | +| Config changes after placement | Old placement uses stored `section_capacity_used` | + +--- + +## 21. Required Reports + +The admin should have reports for: + +```text +Passed but parent enrollment missing +Ready for placement +New students approved and ready for placement +Missing/pending student decisions +Failed/retained students +Exception review required +Draft placement batches +Finalized placement batches +Capacity and section distribution +New students defaulted to D band +Manual overrides +``` + +--- + +## 22. Service Design + +Recommended services: + +```text +StudentDecisionEligibilityService +ReturningEnrollmentStatusService +NewStudentAdmissionStatusService +PlacementPoolBuilder +PromotionSectionCapacityService +ScoreBandClassifier +BalancedSectionPlacementService +SectionPlacementPreviewService +SectionPlacementFinalizationService +SectionPlacementAuditService +``` + +### Responsibility split + +| Service | Responsibility | +|---|---| +| `StudentDecisionEligibilityService` | Reads `student_decisions`; determines pass/fail eligibility | +| `ReturningEnrollmentStatusService` | Checks parent returning enrollment completion | +| `NewStudentAdmissionStatusService` | Checks admin-approved new students | +| `PlacementPoolBuilder` | Builds final placement pool | +| `PromotionSectionCapacityService` | Reads and validates `promotion.section_capacity` | +| `ScoreBandClassifier` | Assigns A/B/C/D bands | +| `BalancedSectionPlacementService` | Generates balanced section assignments | +| `SectionPlacementPreviewService` | Creates draft placement preview | +| `SectionPlacementFinalizationService` | Writes final class assignments | +| `SectionPlacementAuditService` | Stores snapshots and reports | + +Controllers should orchestrate requests, not contain placement policy. Controllers are traffic cops, not the Ministry of Education. + +--- + +## 23. Implementation Priority + +### Phase 1: Audit and preserve current data + +```text +Document current tables and flows. +Add lifecycle/display metadata where needed. +Do not silently migrate old rows. +Keep old records displayable. +``` + +### Phase 2: Strengthen pass/fail eligibility + +```text +Use `student_decisions` as source of truth. +Add reports for missing/pending decisions. +Block automatic placement without passed/promoted decision. +``` + +### Phase 3: Strengthen returning enrollment + +```text +Require parent completed returning enrollment. +Separate returning enrollment from new-student registration. +Admin approval only for new students and exceptions. +``` + +### Phase 4: Add configurable section capacity + +```text +Add/use `promotion.section_capacity` in configuration table. +Validate it. +Default to 20 only if missing. +Store capacity used in placement snapshot. +``` + +### Phase 5: Build placement pool + +```text +Merge eligible returning students and approved new students. +Default new students without assessment to D band. +Exclude ineligible and incomplete records. +Generate exception report. +``` + +### Phase 6: Generate balanced placement preview + +```text +Create sections based on capacity. +Group students by score band. +Balance section totals first, band mix second. +Generate draft preview. +``` + +### Phase 7: Finalize placement + +```text +Validate draft. +Admin finalizes. +Write class section assignments. +Store placement snapshot. +``` + +### Phase 8: Add audit and reports + +```text +Placement batch history. +Distribution reports. +Override logs. +New-student D-band report. +Parent enrollment missing report. +``` + +--- + +## 24. Final Policy Summary + +The strong system must follow these rules: + +```text +1. New students require admin approval. +2. Returning students do not require admin approval unless there is an exception. +3. Returning students must pass according to `student_decisions`. +4. Returning students must have parent enrollment completed. +5. Promotion placement must not recalculate pass/fail. +6. Section capacity comes from the `configuration` table using `promotion.section_capacity`. +7. If total placement pool size is less than or equal to capacity, create one section. +8. If total placement pool size is greater than capacity, create ceil(total / capacity) sections. +9. Returning students are grouped by final promotion score bands. +10. New students without assessment are placed in D band for balancing only. +11. Multiple sections must be balanced by total section size first and score-band mix second. +12. Placement must be previewed before finalization. +13. Final placement must store a snapshot of the algorithm, capacity, distribution, and assigned students. +14. Old data remains displayable and must not be silently changed. +``` + +--- + +## 25. Bottom Line + +The strong model is: + +```text +student_decisions = academic eligibility +parent returning enrollment = family intent to continue +admin approval = new-student admission or exception handling +configuration = section capacity +placement algorithm = balanced section assignment +snapshot = audit trail +``` + +That separation is the whole point. + +Without it, the system will keep confusing pass/fail, enrollment, and placement. Then someone will ask why a failed student is in the next class, why a parent never enrolled but the child appears in attendance, or why one section has all the high scorers. And then everyone will stare at the database like it committed a moral crime on its own. + +It did not. The design did. diff --git a/docs/laravel_financial_system_compatibility_and_stakeholder_reporting_plan.md b/docs/laravel_financial_system_compatibility_and_stakeholder_reporting_plan.md new file mode 100644 index 00000000..bc625ed2 --- /dev/null +++ b/docs/laravel_financial_system_compatibility_and_stakeholder_reporting_plan.md @@ -0,0 +1,315 @@ +# Laravel-Compatible Financial System Plan With Stakeholder Reporting + +## 1. Purpose + +This document converts the uploaded financial-system remediation plan into a Laravel-compatible implementation plan for this project. + +The previous plan was written mostly in CodeIgniter terms: `app/Config/Routes.php`, Spark commands, `app/Libraries`, and CI-style controllers. This project is Laravel, so implementation must use Laravel routes, controllers, services, form requests, resources, middleware, policies, migrations, console commands, storage, and Eloquent/DB transactions. + +The goal is not to win a framework cosplay contest. The goal is to fix finance without breaking finance, which is a surprisingly radical position in software. + +## 2. Non-Negotiable Compatibility Rule + +The existing financial system must keep working while improvements are added. + +That means: + +```text +Do not delete existing finance routes in the first pass. +Do not remove existing PayPal records or controllers until a separate decommission is approved. +Do not rewrite payment, invoice, refund, discount, expense, or reimbursement flows destructively. +Do not mutate historical invoice math during reporting. +Do not silently reinterpret old statuses. +Add new services and reporting endpoints beside the old flow first. +``` + +The original plan says to remove PayPal completely. That may still be a valid future phase, but it is not compatible with the current instruction to keep the old financial system working. Therefore, in this Laravel-compatible version, PayPal removal becomes a later controlled decommission phase, not a first-pass code deletion. + +## 3. Laravel Mapping + +| Old plan concept | Laravel-compatible location | +|---|---| +| `app/Config/Routes.php` | `routes/api.php`, `routes/web.php` | +| `app/Libraries/*` | `app/Services/*` or `app/Support/*` | +| CI controller validation | `app/Http/Requests/*` FormRequest classes | +| CI controllers | `app/Http/Controllers/Api/*` | +| Spark command | Laravel Artisan command in `app/Console/Commands` | +| CI filters | Laravel middleware in `app/Http/Middleware` | +| CI models | Eloquent models in `app/Models` | +| CI views | API resources, frontend pages, or Blade/Inertia only if used | +| `WRITEPATH/uploads` | Laravel `storage/app` or `storage/app/private` | +| Direct route filters | `auth:api`, custom middleware, policies, and gates | + +## 4. First-Pass Implementation Strategy + +Use an additive approach. + +Phase 1 should add: + +```text +StakeholderFinancialAnalysisService +StakeholderFinancialAnalysisRequest +GET /api/v1/finance/stakeholder-analysis +GET /api/v1/finance/stakeholder-analysis/csv +administrator aliases for the same stakeholder analysis endpoints +Laravel-compatible documentation +``` + +Phase 1 must not remove: + +```text +existing payment routes +existing invoice routes +existing PayPal routes +existing PayPal transaction exports +existing payment sync code +existing invoice generation code +existing financial summary/report endpoints +``` + +This lets leadership inspect finance without detonating operations. Boring, safe, good. A rare trilogy. + +## 5. Stakeholder Financial Analysis And Reporting + +Stakeholders need management-level financial visibility, not raw operational dumps. + +The report should be read-only and expose: + +```text +gross revenue +net revenue +cash collected +refunds +discounts +expenses +reimbursements +net cash +open receivables +collection rate +expense ratio +operating margin +invoice count +paying family count +payment method mix +monthly charge/collection/expense trend +receivables aging +largest open invoices +risk flags +school-year comparison +CSV export +``` + +The stakeholder report must not mutate invoices or trigger recalculation. Reports that change accounting are not reports. They are traps with charts. + +## 6. Laravel Endpoint Design + +Add routes under the existing authenticated finance group: + +```php +Route::prefix('finance')->group(function () { + Route::get('stakeholder-analysis', [FinancialController::class, 'stakeholderAnalysis']); + Route::get('stakeholder-analysis/csv', [FinancialController::class, 'stakeholderAnalysisCsv']); +}); +``` + +Also add administrator aliases if the admin dashboard uses `/administrator/...` finance paths: + +```php +Route::prefix('reports')->group(function () { + Route::get('stakeholder-analysis', [FinancialController::class, 'stakeholderAnalysis']); + Route::get('stakeholder-analysis/csv', [FinancialController::class, 'stakeholderAnalysisCsv']); +}); +``` + +The routes should remain inside existing `auth:api` and `admin.access` protected groups where applicable. + +## 7. Request Validation + +Create: + +```text +app/Http/Requests/Finance/StakeholderFinancialAnalysisRequest.php +``` + +Allowed inputs: + +```text +date_from: nullable YYYY-MM-DD +date_to: nullable YYYY-MM-DD, after or equal to date_from +school_year: nullable string +compare_school_year: nullable string +include_monthly_trend: nullable boolean +include_parent_risk: nullable boolean +``` + +The request should extend the existing `FinancialReportRequest` to preserve behavior and avoid yet another tiny validation kingdom. + +## 8. Service Design + +Create: + +```text +app/Services/Finance/StakeholderFinancialAnalysisService.php +``` + +Responsibilities: + +```text +read existing invoices, payments, refunds, discounts, expenses, reimbursements, extra charges, and event charges +exclude voided/failed/canceled records from management totals +calculate management KPIs +build monthly trends +build receivables aging +build payment method mix +build expense category mix +compare selected school year against another school year +produce CSV rows +return JSON-ready arrays +``` + +The service must use `Schema::hasTable()` and `Schema::hasColumn()` guards where the legacy schema is inconsistent. This project has legacy compatibility layers, so brittle assumptions are how bugs sneak in wearing a school hoodie. + +## 9. Keep Old Financial System Working + +In the first pass, do not replace the existing `FinancialSummaryService`, `FinancialReportService`, `PaymentController`, `InvoiceController`, `RefundController`, or PayPal controllers. + +Instead: + +```text +Reuse existing financial tables. +Reuse existing auth groups. +Add read-only reporting beside the current operations. +Leave existing payment/invoice mutation paths untouched. +Do not force status normalization yet. +Do not force invoice recalculation yet. +``` + +Once stakeholder reporting is stable, a later phase can introduce a centralized Laravel ledger service behind feature flags. + +## 10. Later Laravel Ledger Phase + +The old CodeIgniter-style `InvoiceLedgerService` recommendation should become: + +```text +app/Services/Finance/InvoiceLedgerService.php +``` + +It should be introduced behind a config flag: + +```text +finance.ledger_mode = legacy|centralized +``` + +When `legacy`, existing controllers continue using current behavior. +When `centralized`, payment/refund/discount/additional-charge writes call the ledger service after database writes. + +Every write should eventually use: + +```php +DB::transaction(function () use ($invoiceId) { + Invoice::query()->whereKey($invoiceId)->lockForUpdate()->firstOrFail(); + // create/update financial record + $this->invoiceLedgerService->recalculate($invoiceId); +}); +``` + +But do not force that transition before tests exist. Accounting rewrites without tests are just gambling with extra syntax. + +## 11. Tuition Forecast Compatibility + +The tuition forecast idea from the uploaded plan is valid, but it should be Laravelized: + +```text +app/Services/Finance/Tuition/OldTuitionCalculatorService.php +app/Services/Finance/Tuition/NewTuitionCalculatorService.php +app/Services/Finance/Tuition/TuitionForecastService.php +app/Http/Requests/Finance/TuitionForecastRequest.php +app/Http/Controllers/Api/Finance/TuitionForecastController.php +``` + +The old calculator must remain the default until leadership approves switching invoice generation. + +Use: + +```text +tuition_calculator_version = old|new +``` + +stored in the existing `configuration` table through `Configuration::getConfigValueByKey()` / `setConfigValueByKey()`. + +## 12. PayPal Compatibility Position + +Because the current instruction is to keep the old financial system working, PayPal is not removed in this pass. + +Instead: + +```text +Keep existing PayPal routes/controllers/models active. +Keep historical PayPal reports available. +Do not drop or rename PayPal tables. +Document PayPal as legacy. +Plan separate decommission only after staff confirms no operational dependency remains. +``` + +Future PayPal decommission should be its own release with: + +```text +route inventory +frontend dependency scan +data archive migration +rollback plan +parent communication +post-release monitoring +``` + +## 13. Reporting Security + +Stakeholder financial reporting should be restricted to authenticated admin/finance users. + +Minimum: + +```text +Route inside auth:api group. +Route inside administrator/admin finance area when exposed to admin UI. +Add permission middleware later if the permission catalog has a finance-report permission. +``` + +Preferred future middleware: + +```php +->middleware(['auth:api', 'permission:view_financial_reports']) +``` + +Do not expose stakeholder reports to ordinary parent users. This should not need saying, but software history suggests otherwise. + +## 14. Acceptance Criteria + +This Laravel-compatible phase is complete when: + +```text +Existing finance endpoints still work. +Existing PayPal endpoints still exist. +New stakeholder analysis JSON endpoint works. +New stakeholder analysis CSV endpoint works. +No financial write flow is changed. +No invoice is recalculated by the stakeholder report. +The report handles missing optional legacy columns safely. +The code passes PHP lint. +The plan is stored in docs/ for future implementation work. +``` + +## 15. Next Hardening Steps + +After the additive reporting phase: + +```text +Add tests for stakeholder report calculations. +Add permission middleware for finance reports. +Add centralized InvoiceLedgerService behind a feature flag. +Add a dry-run invoice recalculation Artisan command. +Add tuition forecast services. +Normalize financial statuses through migrations only after database backup. +Design a separate PayPal decommission release. +``` + +The brutal truth: the uploaded plan had the right instincts, but it assumed the wrong framework and moved too aggressively on removal. In this project, compatibility wins first. Then hardening. Then cleanup. In that order, unless the goal is accounting chaos with a nicer namespace. diff --git a/docs/laravel_school_financial_system_full_plan(1).md b/docs/laravel_school_financial_system_full_plan(1).md new file mode 100644 index 00000000..ce5a555c --- /dev/null +++ b/docs/laravel_school_financial_system_full_plan(1).md @@ -0,0 +1,1816 @@ +# Laravel-Compatible School Financial System Plan With Carryforward Balances, Collections, Events, Receipts, Installments, Notifications, and Stakeholder Reporting + +## 1. Purpose + +This document updates the Laravel-compatible financial-system plan into a fuller school finance operating plan. + +The earlier Laravel compatibility plan correctly prioritized keeping the old financial system working while adding read-only stakeholder reporting. That remains the right baseline. The expanded plan now adds: + +```text +non-paying parent follow-up +event charges and event billing +receipt notification system +installment plans +tuition and fee forecasting +stakeholder financial reporting +parent-facing balances +previous-year balance carryforward +admin collection workflows +school-wide financial controls +audit trails +future ledger hardening +``` + +The purpose is to cover the financial lifecycle of a school without breaking the existing system. This is not a heroic rewrite. Heroic rewrites are how people discover backups are decorative. + +## 2. Non-Negotiable Compatibility Rule + +The existing financial system must keep working while improvements are added. + +Rules: + +```text +Do not delete existing finance routes in the first pass. +Do not remove existing PayPal records, routes, controllers, or reports until a separate decommission is approved. +Do not rewrite payment, invoice, refund, discount, expense, reimbursement, or event charge flows destructively. +Do not mutate historical invoice math during reporting. +Do not silently reinterpret old statuses. +Add new services and reporting endpoints beside the old flow first. +Use feature flags for new behavior that may affect money. +Prefer read-only visibility before write-path replacement. +``` + +The older financial plan recommended aggressive PayPal removal. That may still happen later, but not inside the compatibility phase. Keeping finance working wins first. Cleanup can wait its turn like an adult. + +## 3. Laravel Mapping + +| Plan concept | Laravel-compatible location | +|---|---| +| Routes | `routes/api.php`, `routes/web.php` | +| Controllers | `app/Http/Controllers/Api/Finance/*` | +| Services | `app/Services/Finance/*` | +| Form validation | `app/Http/Requests/Finance/*` | +| Authorization | middleware, policies, gates, permissions | +| Models | `app/Models/*` | +| Migrations | `database/migrations/*` | +| Jobs | `app/Jobs/*` | +| Notifications | `app/Notifications/*` | +| Mailables, if used | `app/Mail/*` | +| Console commands | `app/Console/Commands/*` | +| Exports | streamed CSV responses or export classes | +| Private financial files | `storage/app/private/*` | +| Config flags | `config/finance.php` and/or `configuration` table | + +## 4. Target School Finance Capabilities + +The complete school finance system should support: + +```text +student tuition billing +family-based tuition discounts +event charges +additional fees +manual payments +installment plans +previous-year balance carryforward +partial payments +refunds +discounts and waivers +expenses +reimbursements +receipts +parent notifications +non-paying parent follow-up +collection notes +promise-to-pay tracking +financial file uploads +audit logs +stakeholder reports +school-year financial comparison +tuition forecast +collection forecast +aging reports +CSV exports +future ledger recalculation +``` + +The system must distinguish between: + +| Concept | Meaning | +|---|---| +| Invoice | formal amount owed | +| Charge | a billable item on an invoice | +| Payment | money received | +| Refund | money returned or owed back | +| Discount | approved reduction of amount owed | +| Installment plan | schedule for expected payments | +| Carryforward balance | prior-year unpaid amount moved into the new-year financial view | +| Receipt | proof/payment acknowledgement | +| Follow-up | collection workflow for unpaid balances | +| Report | read-only financial analysis | +| Ledger | source of calculated financial truth | + +These cannot all be one `status` column having an identity crisis. + +## 5. Implementation Strategy + +Use phased implementation. + +## Phase 1: Additive Reporting and Visibility + +Keep all existing write flows untouched. + +Add: + +```text +StakeholderFinancialAnalysisService +ParentPaymentFollowUpService +EventChargeReportingService +InstallmentReportingService +PriorYearBalanceCarryforwardService +ReceiptNotificationReadinessService +CSV exports +administrator report endpoints +``` + +This phase is mostly read-only. + +## Phase 2: Follow-Up Workflow + +Add operational collection tools: + +```text +follow-up statuses +collection notes +promise-to-pay dates +last contacted tracking +next follow-up dates +manual reminder generation +bulk follow-up export +``` + +This phase writes follow-up metadata only. It must not change invoice balances. + +## Phase 2B: Previous-Year Balance Carryforward + +Add prior-year balance handling before full ledger replacement: + +```text +prior-year open balance report +parent-level carryforward preview +student-level balance detail +new-year carryforward charge creation +carryforward adjustment/waiver workflow +carryforward audit trail +parent statement display +collection queue inclusion +stakeholder report inclusion +``` + +This phase must not silently mutate old invoices. Prior-year invoices remain historically visible. The new year receives a separate carryforward record or charge that references the original unpaid invoices. That way the system can collect old debt without rewriting history like a tiny accounting dictatorship. + +## Phase 3: Notifications and Receipts + +Add: + +```text +payment receipt notifications +installment due reminders +overdue balance reminders +event charge notices +manual resend receipt action +notification logs +notification preferences +``` + +Start with manual/admin-triggered notifications. Then add scheduled reminders after balances are trusted. + +## Phase 4: Event Charges and Installment Hardening + +Add or standardize: + +```text +event charge lifecycle +event registration billing +invoice line items +installment schedules +installment payment allocation +overdue installment reporting +``` + +Do not overwrite legacy charge logic until mapping is proven. + +## Phase 5: Centralized Ledger + +Introduce: + +```text +InvoiceLedgerService +finance.ledger_mode = legacy|centralized +dry-run recalculation command +write-path transaction locks +normalized financial statuses +audit logs +``` + +This is the dangerous phase. It needs tests and dry-runs, because “we changed how balances are calculated” is not a sentence accounting enjoys. + +## 6. Previous-Year Balance Carryforward + +## 6.1 Purpose + +The financial system must carry unpaid balances from a completed school year into the new school year so staff can collect old balances while still preserving historical invoices. + +Required rule: + +```text +Previous-year unpaid balances must remain visible on the original year invoices. +The new school year may show and collect those balances through a separate carryforward record or charge. +The system must not silently edit, close, or recalculate old invoices just to make the new year look clean. +``` + +This is not optional. If prior-year balances disappear during new-year enrollment, the school has not simplified finance. It has misplaced money with a login screen. + +## 6.2 Carryforward Source + +The carryforward source should be the prior school year open balance at the parent/family level. + +Use: + +```text +all unpaid or partially paid invoices from the previous school year +minus valid payments already recorded +minus approved discounts/waivers +plus valid charges still owed +minus paid refunds only when they actually reduce collectible balance under the business rule +``` + +The first implementation may read the existing legacy invoice balance field if the old system is still the source of truth. In the later centralized ledger phase, the carryforward preview should use `InvoiceLedgerService` in dry-run mode. + +Supported carryforward scopes: + +```text +single parent/family +all parents in a school year +single student detail for display +full school-year rollover batch +``` + +## 6.3 Carryforward Record Design + +Create a separate carryforward table or use a dedicated invoice line item type. Recommended additive table: + +```text +finance_balance_carryforwards +``` + +Suggested fields: + +```text +id +parent_id +student_id nullable +from_school_year +to_school_year +source_invoice_ids_json +source_balance_amount +carryforward_amount +waived_amount default 0.00 +adjusted_amount default 0.00 +remaining_amount +status +reason nullable +created_by nullable +approved_by nullable +created_at +approved_at nullable +posted_invoice_id nullable +posted_invoice_line_item_id nullable +metadata_json nullable +``` + +Recommended statuses: + +```text +draft +pending_review +approved +posted_to_new_year +partially_paid +paid +waived +cancelled +superseded +``` + +The carryforward record should reference the source invoices. Do not copy vague totals with no traceability. Future humans will ask where the number came from, and “the system said so” is not an audit trail. + +## 6.4 Carryforward Charge Type + +When a carryforward is posted to the new year, create one explicit new-year charge or invoice line item: + +```text +charge_type = previous_year_balance +source_type = balance_carryforward +source_id = finance_balance_carryforwards.id +description = Previous year balance from {from_school_year} +amount = carryforward_amount - waived_amount + adjusted_amount +``` + +This allows the new-year parent statement to show: + +```text +New-year tuition +Event charges +Additional charges +Previous-year balance +Payments +Remaining balance +``` + +The old invoice stays untouched. The new charge makes the old balance collectible in the new year. See how easy life gets when history and current billing stop fighting over the same row. + +## 6.5 Parent Enrollment Rule + +During returning-student enrollment, prior-year balance should be visible to the parent and staff. + +Recommended display: + +```text +Previous-year balance: $X.XX +Current-year expected tuition: $Y.YY +Total expected amount due: $Z.ZZ +``` + +Business policy options: + +```text +allow enrollment with prior-year balance but flag for follow-up +allow enrollment only after minimum payment +require admin override if balance exceeds threshold +require payment plan before placement/final enrollment +``` + +Recommended default for compatibility: + +```text +Do not block enrollment in the first implementation. +Show the balance. +Add the parent to the collection queue. +Allow admin to require payment or installment plan later by configuration. +``` + +Suggested configuration keys: + +```text +finance.carryforward.enabled = true +finance.carryforward.auto_post_to_new_year = false +finance.carryforward.block_enrollment_threshold = null +finance.carryforward.require_admin_review_threshold = 0.00 +finance.carryforward.allow_installment_plan = true +``` + +## 6.6 Carryforward Preview Before Posting + +Carryforward must use a preview/finalize pattern. + +Preview should show: + +```text +parent/family +students linked to balance +source invoice IDs +source school year +target school year +source balance +proposed carryforward amount +payments recorded after preview date +existing carryforward records +warnings +``` + +Warnings: + +```text +source invoice already carried forward +negative or zero balance +parent has active installment plan +invoice balance changed since preview +parent has duplicate family account +student withdrawn/inactive +missing parent link +``` + +Finalize only after admin approval. + +## 6.7 Payments Against Carryforward + +Payments in the new year can be allocated to carryforward balance using one of these policies: + +```text +oldest debt first +current tuition first +manual allocation by staff +parent-selected allocation if allowed +``` + +Recommended default: + +```text +oldest debt first unless an active installment plan defines another allocation order +``` + +Payment receipts must show allocation clearly: + +```text +Payment received: $300.00 +Applied to previous-year balance: $150.00 +Applied to current-year tuition: $150.00 +Remaining previous-year balance: $0.00 +Remaining current-year balance: $500.00 +``` + +Do not hide payment allocation. Hidden allocation creates parent disputes, and parent disputes create meetings, which are how afternoons go to die. + +## 6.8 Follow-Up and Reporting Impact + +Carryforward balances must appear in: + +```text +Parent Payment Follow-Up Queue +Non-Paying Parent Aging Report +Stakeholder Financial Summary +Collection Forecast +Parent Statement +Parent Balance Page +Installment Plan Setup +Receipt Allocation Detail +School-Year Comparison +``` + +Reports must separate: + +```text +current-year charges +previous-year carried balances +total receivables +collected against previous-year balances +collected against current-year charges +waived previous-year balances +``` + +Stakeholder reporting should show prior-year carryforward explicitly so leadership can distinguish current operating revenue from old receivables collection. + +## 6.9 Laravel Services and Endpoints + +Suggested service: + +```text +app/Services/Finance/PriorYearBalanceCarryforwardService.php +``` + +Responsibilities: + +```text +find prior-year open balances +build carryforward preview +detect duplicate carryforward records +create draft carryforward records +finalize approved carryforwards +post carryforward charge to target year +calculate remaining carryforward balance +produce carryforward CSV export +``` + +Suggested requests: + +```text +app/Http/Requests/Finance/CarryforwardPreviewRequest.php +app/Http/Requests/Finance/CarryforwardFinalizeRequest.php +app/Http/Requests/Finance/CarryforwardAdjustmentRequest.php +``` + +Suggested routes: + +```php +Route::prefix('finance/carryforwards')->middleware(['auth:api'])->group(function () { + Route::get('preview', [BalanceCarryforwardController::class, 'preview']); + Route::post('draft', [BalanceCarryforwardController::class, 'storeDraft']); + Route::post('{carryforward}/approve', [BalanceCarryforwardController::class, 'approve']); + Route::post('{carryforward}/post', [BalanceCarryforwardController::class, 'postToNewYear']); + Route::post('{carryforward}/waive', [BalanceCarryforwardController::class, 'waive']); + Route::post('{carryforward}/adjust', [BalanceCarryforwardController::class, 'adjust']); + Route::get('report', [BalanceCarryforwardController::class, 'report']); + Route::get('report/csv', [BalanceCarryforwardController::class, 'reportCsv']); +}); +``` + +## 6.10 Carryforward Acceptance Rules + +Required tests: + +```text +prior-year unpaid invoice appears in carryforward preview +paid prior-year invoice is excluded +partially paid invoice carries only remaining balance +duplicate carryforward is blocked +carryforward posting creates a new-year charge without changing old invoice +parent statement separates prior-year balance from current-year tuition +payment allocation can reduce carryforward balance +receipt shows amount applied to carryforward +follow-up queue includes parents with carryforward balance +stakeholder report separates old receivables from current-year revenue +waived carryforward remains auditable +unauthorized users cannot preview or post carryforwards +``` + +## 7. Non-Paying Parent Follow-Up System + +## 7.1 Purpose + +The finance system must help staff identify and follow up with parents who have unpaid balances. + +This is not the same as stakeholder reporting. Stakeholder reporting answers, “How much money is outstanding?” Follow-up answers, “Who needs to be contacted, what happened last time, and what happens next?” + +## 7.2 Parent Follow-Up Report + +Create: + +```text +app/Services/Finance/ParentPaymentFollowUpService.php +app/Http/Requests/Finance/ParentPaymentFollowUpRequest.php +``` + +Suggested endpoints: + +```php +Route::prefix('finance')->group(function () { + Route::get('parent-payment-followups', [FinancialController::class, 'parentPaymentFollowups']); + Route::get('parent-payment-followups/csv', [FinancialController::class, 'parentPaymentFollowupsCsv']); +}); +``` + +Administrator aliases: + +```php +Route::prefix('administrator/reports')->group(function () { + Route::get('parent-payment-followups', [FinancialController::class, 'parentPaymentFollowups']); + Route::get('parent-payment-followups/csv', [FinancialController::class, 'parentPaymentFollowupsCsv']); +}); +``` + +## 7.3 Filters + +The follow-up report should support: + +```text +school_year +date_from +date_to +minimum_balance +aging_bucket +parent_id +student_id +class_id +grade_level_id +follow_up_status +last_contacted_before +next_follow_up_due_before +promise_to_pay_due_before +include_paid_invoices = false by default +include_inactive_students = false by default +``` + +## 7.4 Aging Buckets + +Use standard aging buckets: + +```text +current +1-30 days overdue +31-60 days overdue +61-90 days overdue +90+ days overdue +``` + +If invoice due dates are missing, bucket by invoice creation date and show a warning. Missing due dates are not a personality trait. They are bad data. + +## 7.5 Follow-Up Row Fields + +Each row should include: + +```text +parent_id +parent_name +parent_email +parent_phone +student_names +school_year +invoice_count +open_invoice_count +total_invoiced +total_paid +total_discounted +total_refunded +open_balance +oldest_unpaid_invoice_date +days_overdue +aging_bucket +last_payment_date +last_payment_amount +last_contacted_at +last_contacted_by +follow_up_status +next_follow_up_date +promise_to_pay_date +promise_to_pay_amount +escalation_level +notes_count +risk_flags +``` + +## 7.6 Follow-Up Statuses + +Create normalized statuses: + +```text +not_contacted +contacted +left_message +email_sent +sms_sent +promise_to_pay +partial_payment_received +disputed_balance +needs_admin_review +financial_aid_review +escalated +resolved +do_not_contact +``` + +## 7.7 Follow-Up Notes + +Add a table: + +```text +finance_follow_up_notes +``` + +Recommended fields: + +```text +id +parent_id +invoice_id nullable +school_year nullable +status +note +contact_method nullable +promise_to_pay_date nullable +promise_to_pay_amount nullable +next_follow_up_date nullable +escalation_level default 0 +created_by +created_at +updated_at +``` + +Laravel migration: + +```php +Schema::create('finance_follow_up_notes', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('parent_id')->index(); + $table->unsignedBigInteger('invoice_id')->nullable()->index(); + $table->string('school_year')->nullable()->index(); + $table->string('status')->default('not_contacted')->index(); + $table->string('contact_method')->nullable(); + $table->date('promise_to_pay_date')->nullable(); + $table->decimal('promise_to_pay_amount', 12, 2)->nullable(); + $table->date('next_follow_up_date')->nullable()->index(); + $table->unsignedTinyInteger('escalation_level')->default(0); + $table->text('note')->nullable(); + $table->unsignedBigInteger('created_by')->nullable()->index(); + $table->timestamps(); +}); +``` + +## 7.8 Follow-Up Write Endpoints + +Suggested endpoints: + +```php +Route::post('parent-payment-followups/{parent}/note', [FinancialController::class, 'storeParentFollowUpNote']); +Route::post('parent-payment-followups/{parent}/mark-contacted', [FinancialController::class, 'markParentContacted']); +Route::post('parent-payment-followups/{parent}/promise-to-pay', [FinancialController::class, 'storePromiseToPay']); +Route::post('parent-payment-followups/{parent}/resolve', [FinancialController::class, 'resolveParentFollowUp']); +``` + +These should update follow-up metadata only. They must not alter invoices or payments. + +## 7.9 Collection Queue + +Create a queue view ordered by: + +```text +highest overdue balance +then oldest unpaid invoice +then 90+ aging bucket +then promise-to-pay missed date +then next follow-up due date +``` + +Staff should not manually hunt through invoices. The system should tell them who needs attention. That is the entire point of having a system instead of a drawer full of vibes. + +## 8. Event Charges + +## 8.1 Purpose + +The school finance system must support event billing without confusing it with tuition. + +Examples: + +```text +field trips +fundraisers +graduation fees +exam fees +competition fees +special activities +supplies +lunch programs +after-school events +camp registrations +``` + +## 8.2 Event Charge Model + +If a compatible table already exists, reuse it. Otherwise add: + +```text +event_charges +``` + +Recommended fields: + +```text +id +event_id nullable +student_id nullable +parent_id nullable +invoice_id nullable +school_year nullable +title +description nullable +amount +quantity default 1 +total_amount +status +due_date nullable +created_by +approved_by nullable +created_at +updated_at +``` + +Statuses: + +```text +draft +approved +invoiced +paid +partially_paid +waived +voided +cancelled +refunded +``` + +## 8.3 Event Charge Service + +Create: + +```text +app/Services/Finance/EventChargeService.php +app/Services/Finance/EventChargeReportingService.php +app/Http/Requests/Finance/EventChargeRequest.php +``` + +Responsibilities: + +```text +create event charge +approve event charge +attach event charge to invoice +void event charge +report event revenue +report unpaid event charges +exclude voided/cancelled charges +``` + +## 8.4 Event Charge Endpoints + +Suggested endpoints: + +```php +Route::get('event-charges', [EventChargeController::class, 'index']); +Route::post('event-charges', [EventChargeController::class, 'store']); +Route::get('event-charges/{eventCharge}', [EventChargeController::class, 'show']); +Route::patch('event-charges/{eventCharge}', [EventChargeController::class, 'update']); +Route::post('event-charges/{eventCharge}/approve', [EventChargeController::class, 'approve']); +Route::post('event-charges/{eventCharge}/void', [EventChargeController::class, 'void']); +Route::post('event-charges/{eventCharge}/attach-to-invoice', [EventChargeController::class, 'attachToInvoice']); +``` + +## 8.5 Event Charge Rules + +```text +An event charge must belong to a parent, student, invoice, or event context. +Draft event charges must not affect invoice balances. +Approved but uninvoiced event charges appear in pending charge reports. +Invoiced event charges affect invoice balances only through invoice line items or ledger calculation. +Voided/cancelled event charges are excluded from active totals. +Refunded event charges are reflected through refund records, not negative mystery charges. +Event charge reports must distinguish invoiced versus not yet invoiced. +``` + +## 8.6 Event Charge Parent Notification + +When an event charge is approved or invoiced, the system should be able to notify the parent. + +Notification types: + +```text +event_charge_created +event_charge_due_soon +event_charge_overdue +event_charge_paid +event_charge_voided +``` + +Do not automatically notify on draft charges. Drafts are staff thoughts. Parents do not need to receive staff thoughts. + +## 9. Installment Plans + +## 9.1 Purpose + +Installments let families pay tuition or invoice balances over time. + +The system must distinguish: + +```text +invoice amount owed +installment schedule +payments received +installment due status +``` + +A payment is not an installment. A payment may satisfy one or more installments. + +## 9.2 Installment Tables + +Add: + +```text +invoice_installment_plans +invoice_installments +``` + +Recommended `invoice_installment_plans` fields: + +```text +id +invoice_id +parent_id +school_year +plan_name +total_amount +number_of_installments +status +created_by +approved_by nullable +created_at +updated_at +``` + +Statuses: + +```text +draft +active +completed +cancelled +defaulted +restructured +``` + +Recommended `invoice_installments` fields: + +```text +id +installment_plan_id +invoice_id +sequence +due_date +amount_due +amount_paid +balance +status +paid_at nullable +created_at +updated_at +``` + +Statuses: + +```text +pending +due +partially_paid +paid +overdue +waived +cancelled +``` + +## 9.3 Payment Allocation + +Add: + +```text +payment_installment_allocations +``` + +Fields: + +```text +id +payment_id +invoice_id +installment_id +amount +created_at +updated_at +``` + +Rules: + +```text +Payments can be manually allocated to installments. +If no allocation is provided, allocate oldest due installment first. +Do not allocate voided/failed payments. +Do not allow allocation above installment balance unless admin override is recorded. +Installment balance should be recalculated from allocations, not hand-edited casually like a whiteboard. +``` + +## 9.4 Installment Service + +Create: + +```text +app/Services/Finance/InstallmentPlanService.php +app/Services/Finance/InstallmentReportingService.php +app/Http/Requests/Finance/InstallmentPlanRequest.php +``` + +Responsibilities: + +```text +create installment plan +generate schedule +activate plan +allocate payment +recalculate installment balances +mark overdue installments +report due/overdue installments +cancel or restructure plan +``` + +## 9.5 Installment Endpoints + +Suggested endpoints: + +```php +Route::get('installment-plans', [InstallmentPlanController::class, 'index']); +Route::post('invoices/{invoice}/installment-plans', [InstallmentPlanController::class, 'store']); +Route::get('installment-plans/{plan}', [InstallmentPlanController::class, 'show']); +Route::post('installment-plans/{plan}/activate', [InstallmentPlanController::class, 'activate']); +Route::post('installment-plans/{plan}/cancel', [InstallmentPlanController::class, 'cancel']); +Route::post('installment-plans/{plan}/restructure', [InstallmentPlanController::class, 'restructure']); +Route::post('payments/{payment}/allocate-installments', [InstallmentPlanController::class, 'allocatePayment']); +Route::get('installments/due', [InstallmentPlanController::class, 'due']); +Route::get('installments/overdue', [InstallmentPlanController::class, 'overdue']); +``` + +## 9.6 Installment Notifications + +Notification types: + +```text +installment_plan_created +installment_due_soon +installment_due_today +installment_overdue +installment_payment_received +installment_plan_completed +installment_plan_cancelled +``` + +Start with admin-triggered notifications. Scheduled reminders can come later after staff confirms due dates and balances are reliable. + +## 10. Receipt and Financial Notification System + +## 10.1 Purpose + +Parents should receive clear financial communication: + +```text +payment receipt +refund receipt +invoice created +event charge added +installment plan created +installment due reminder +overdue balance reminder +discount applied +balance updated +statement available +``` + +The system must log notifications so staff can prove what was sent. + +## 10.2 Laravel Notifications + +Use Laravel notifications: + +```text +app/Notifications/Finance/PaymentReceiptNotification.php +app/Notifications/Finance/RefundReceiptNotification.php +app/Notifications/Finance/InvoiceCreatedNotification.php +app/Notifications/Finance/EventChargeNotification.php +app/Notifications/Finance/InstallmentReminderNotification.php +app/Notifications/Finance/OverdueBalanceReminderNotification.php +app/Notifications/Finance/StatementAvailableNotification.php +``` + +Channels: + +```text +mail +database +sms later if provider exists +``` + +Do not assume SMS exists. Human beings love asking for SMS after nobody budgeted for SMS. + +## 10.3 Notification Log + +Add: + +```text +finance_notification_logs +``` + +Fields: + +```text +id +parent_id nullable +student_id nullable +invoice_id nullable +payment_id nullable +refund_id nullable +event_charge_id nullable +installment_id nullable +notification_type +channel +recipient +subject nullable +status +sent_at nullable +failed_at nullable +error_message nullable +created_by nullable +created_at +updated_at +``` + +Statuses: + +```text +queued +sent +failed +skipped +cancelled +``` + +## 10.4 Notification Preferences + +Add optional parent preferences: + +```text +finance_notification_preferences +``` + +Fields: + +```text +id +parent_id +email_receipts_enabled default true +email_reminders_enabled default true +sms_receipts_enabled default false +sms_reminders_enabled default false +statement_notifications_enabled default true +created_at +updated_at +``` + +Required rule: + +```text +Receipts should remain available even if reminders are disabled. +``` + +Disabling reminders should not erase receipts. That would be like refusing a thermometer because you dislike weather. + +## 10.5 Receipt Rules + +A receipt should be generated or sent when: + +```text +payment is recorded +refund is paid +manual receipt resend is requested +payment is edited and receipt regeneration is approved +payment is voided and void notice is enabled +``` + +Receipt must include: + +```text +receipt number +payment date +parent name +student names if available +invoice number +school year +payment method +amount paid +remaining balance +staff member or system source +school contact information +``` + +## 10.6 Receipt Numbering + +Add receipt numbering if not already present: + +```text +finance_receipt_sequences +payment_carryforward_allocations optional if payments are split across old/new balances +``` + +Fields: + +```text +id +school_year +prefix +next_number +created_at +updated_at +``` + +Receipt format: + +```text +R-{school_year}-{sequence} +``` + +Example: + +```text +R-2025-2026-000123 +``` + +Never derive receipt numbers from payment IDs alone if parents see them. Internal database IDs are not receipt policy. They are plumbing. + +## 10.7 Notification Endpoints + +Suggested endpoints: + +```php +Route::post('payments/{payment}/send-receipt', [FinanceNotificationController::class, 'sendPaymentReceipt']); +Route::post('refunds/{refund}/send-receipt', [FinanceNotificationController::class, 'sendRefundReceipt']); +Route::post('invoices/{invoice}/send-statement', [FinanceNotificationController::class, 'sendInvoiceStatement']); +Route::post('parents/{parent}/send-overdue-reminder', [FinanceNotificationController::class, 'sendOverdueReminder']); +Route::post('installments/{installment}/send-reminder', [FinanceNotificationController::class, 'sendInstallmentReminder']); +Route::get('notification-logs', [FinanceNotificationController::class, 'logs']); +``` + +## 11. School Financial Matter Coverage + +A complete school finance system should group functions into domains. + +## 11.1 Billing + +```text +tuition invoices +registration fees +event charges +activity fees +supplies fees +late fees if approved by policy +additional charges +discounts +waivers +scholarships or financial aid +``` + +## 11.2 Collections + +```text +manual payments +partial payments +installments +payment allocation +receipts +parent statements +overdue reminders +collection notes +promise-to-pay tracking +escalation workflow +``` + +## 11.3 Adjustments + +```text +discounts +waivers +refunds +voided payments +reversed charges +write-offs +balance corrections +audit-approved adjustments +``` + +## 11.4 Spending + +```text +expenses +reimbursements +vendor payments +staff reimbursements +receipt attachments +approval statuses +expense category reporting +``` + +## 11.5 Reporting + +```text +stakeholder dashboard +cash collected +gross and net revenue +open receivables +aging report +event revenue +tuition forecast +collection forecast +expense report +reimbursement report +payment method mix +school-year comparison +class/grade financial summary +parent balance report +audit exports +``` + +## 11.6 Controls + +```text +authorization +parent ownership checks +financial file access control +transaction logging +notification logging +audit trails +dry-run recalculation +data export controls +role-based permissions +``` + +## 12. Stakeholder Financial Analysis and Reporting + +Stakeholders need management-level visibility, not operational noise. + +Report metrics: + +```text +gross revenue +net revenue +cash collected +refunds +discounts +expenses +reimbursements +net cash +open receivables +collection rate +expense ratio +operating margin +invoice count +paying family count +non-paying family count +payment method mix +event charge revenue +tuition revenue +additional charge revenue +installment receivables +monthly charge/collection/expense trend +receivables aging +largest open invoices +highest-risk parents +school-year comparison +CSV export +``` + +The stakeholder report must not mutate invoices or trigger recalculation. Reports that change accounting are booby traps with dashboards. + +## 13. Tuition Forecast and Collection Prediction + +Keep the old tuition calculator as default. + +Add services: + +```text +app/Services/Finance/Tuition/OldTuitionCalculatorService.php +app/Services/Finance/Tuition/NewTuitionCalculatorService.php +app/Services/Finance/Tuition/TuitionForecastService.php +``` + +Config: + +```text +tuition_calculator_version = old|new +``` + +New tuition rule: + +```text +first student: full amount +second student: full amount minus $50 +third student: full amount minus $50 +fourth and later students: full amount minus $100 each +``` + +Forecast must show: + +```text +old projected tuition +new projected tuition +difference +already collected +remaining collectible +family breakdown +student breakdown +warnings +CSV export +``` + +Forecast must be read-only. + +## 14. Authorization and Security + +Every finance route must be protected. + +Minimum: + +```text +auth:api +admin.access or equivalent admin/staff middleware +``` + +Preferred permissions: + +```text +view_financial_reports +view_parent_balances +manage_payments +manage_refunds +manage_discounts +manage_event_charges +manage_installments +send_finance_notifications +manage_expenses +manage_reimbursements +export_financial_reports +``` + +Parent-facing routes must enforce ownership: + +```text +A parent can only view their own invoices, payments, receipts, installment plans, statements, and event charges. +``` + +File access must use IDs, not raw filenames. + +## 15. Centralized Ledger Future Phase + +Add later: + +```text +app/Services/Finance/InvoiceLedgerService.php +``` + +Feature flag: + +```text +finance.ledger_mode = legacy|centralized +``` + +When centralized mode is enabled, financial writes eventually follow: + +```php +DB::transaction(function () use ($invoiceId) { + Invoice::query()->whereKey($invoiceId)->lockForUpdate()->firstOrFail(); + + // create/update payment, refund, discount, event charge, installment allocation, or adjustment + + $this->invoiceLedgerService->recalculate($invoiceId); +}); +``` + +Ledger must calculate: + +```text +tuition charges +event charges +additional charges +discounts +waivers +payments +refunds +installment allocations +write-offs +balance +status +``` + +Do not activate centralized mode until tests and dry-run reports exist. This is accounting, not a place to practice optimism. + +## 16. Database Additions Summary + +Potential new tables: + +```text +finance_follow_up_notes +finance_balance_carryforwards +event_charges +invoice_installment_plans +invoice_installments +payment_installment_allocations +finance_notification_logs +finance_notification_preferences +finance_receipt_sequences +payment_carryforward_allocations optional if payments are split across old/new balances +``` + +Optional later tables: + +```text +invoice_line_items +finance_adjustments +finance_audit_logs +tuition_forecasts +parent_statements +``` + +If equivalent tables already exist, map to them instead of creating duplicates. Duplicate finance tables are how systems become haunted. + +## 17. API Endpoint Summary + +Suggested route groups: + +```php +Route::prefix('finance')->middleware(['auth:api'])->group(function () { + Route::get('stakeholder-analysis', [FinancialController::class, 'stakeholderAnalysis']); + Route::get('stakeholder-analysis/csv', [FinancialController::class, 'stakeholderAnalysisCsv']); + + Route::get('parent-payment-followups', [FinancialController::class, 'parentPaymentFollowups']); + Route::get('parent-payment-followups/csv', [FinancialController::class, 'parentPaymentFollowupsCsv']); + Route::post('parent-payment-followups/{parent}/note', [FinancialController::class, 'storeParentFollowUpNote']); + Route::post('parent-payment-followups/{parent}/promise-to-pay', [FinancialController::class, 'storePromiseToPay']); + Route::post('parent-payment-followups/{parent}/resolve', [FinancialController::class, 'resolveParentFollowUp']); + + Route::get('carryforwards/preview', [BalanceCarryforwardController::class, 'preview']); + Route::post('carryforwards/draft', [BalanceCarryforwardController::class, 'storeDraft']); + Route::post('carryforwards/{carryforward}/approve', [BalanceCarryforwardController::class, 'approve']); + Route::post('carryforwards/{carryforward}/post', [BalanceCarryforwardController::class, 'postToNewYear']); + Route::post('carryforwards/{carryforward}/waive', [BalanceCarryforwardController::class, 'waive']); + Route::post('carryforwards/{carryforward}/adjust', [BalanceCarryforwardController::class, 'adjust']); + Route::get('carryforwards/report', [BalanceCarryforwardController::class, 'report']); + Route::get('carryforwards/report/csv', [BalanceCarryforwardController::class, 'reportCsv']); + + Route::apiResource('event-charges', EventChargeController::class); + Route::post('event-charges/{eventCharge}/approve', [EventChargeController::class, 'approve']); + Route::post('event-charges/{eventCharge}/void', [EventChargeController::class, 'void']); + Route::post('event-charges/{eventCharge}/attach-to-invoice', [EventChargeController::class, 'attachToInvoice']); + + Route::apiResource('installment-plans', InstallmentPlanController::class); + Route::post('invoices/{invoice}/installment-plans', [InstallmentPlanController::class, 'store']); + Route::post('installment-plans/{plan}/activate', [InstallmentPlanController::class, 'activate']); + Route::post('installment-plans/{plan}/cancel', [InstallmentPlanController::class, 'cancel']); + Route::post('installment-plans/{plan}/restructure', [InstallmentPlanController::class, 'restructure']); + Route::post('payments/{payment}/allocate-installments', [InstallmentPlanController::class, 'allocatePayment']); + Route::get('installments/due', [InstallmentPlanController::class, 'due']); + Route::get('installments/overdue', [InstallmentPlanController::class, 'overdue']); + + Route::post('payments/{payment}/send-receipt', [FinanceNotificationController::class, 'sendPaymentReceipt']); + Route::post('refunds/{refund}/send-receipt', [FinanceNotificationController::class, 'sendRefundReceipt']); + Route::post('invoices/{invoice}/send-statement', [FinanceNotificationController::class, 'sendInvoiceStatement']); + Route::post('parents/{parent}/send-overdue-reminder', [FinanceNotificationController::class, 'sendOverdueReminder']); + Route::post('installments/{installment}/send-reminder', [FinanceNotificationController::class, 'sendInstallmentReminder']); + Route::get('notification-logs', [FinanceNotificationController::class, 'logs']); +}); +``` + +Route names and controllers should be adjusted to match existing project conventions. + +## 18. Reports Required + +Admin/staff reports: + +```text +Stakeholder Financial Summary +Parent Payment Follow-Up Queue +Non-Paying Parent Aging Report +Promise-to-Pay Report +Missed Promise-to-Pay Report +Prior-Year Balance Carryforward Preview +Posted Carryforward Balance Report +Carryforward Payments and Waivers Report +Overdue Installment Report +Upcoming Installments Report +Event Charge Revenue Report +Uninvoiced Event Charges Report +Receipt Notification Log +Failed Notification Report +Payment Method Mix Report +Discount and Waiver Report +Refund Report +Expense Report +Reimbursement Report +Tuition Forecast +Collection Forecast +School-Year Comparison +Audit Export +``` + +Parent-facing views: + +```text +My Balance +Previous-Year Balance +My Invoices +My Payments +My Receipts +My Installment Plans +My Event Charges +My Statements +``` + +## 19. Notification Schedule + +Start with manual notifications. + +Later scheduled jobs: + +```text +daily: mark overdue installments +daily: generate overdue parent follow-up queue +daily: send installment due soon reminders +weekly: send overdue balance reminders +weekly: send previous-year balance reminders when enabled +monthly: generate parent statements +monthly: stakeholder financial digest +``` + +Laravel jobs/commands: + +```text +app/Console/Commands/Finance/MarkOverdueInstallmentsCommand.php +app/Console/Commands/Finance/SendInstallmentRemindersCommand.php +app/Console/Commands/Finance/SendOverdueBalanceRemindersCommand.php +app/Console/Commands/Finance/GenerateMonthlyStatementsCommand.php +``` + +Use dry-run mode for all scheduled financial notifications: + +```text +--dry-run +--commit +``` + +If a notification job cannot dry-run, it is not ready to email parents. The inbox is not a test environment, despite how many systems behave. + +## 20. Audit and Logging + +Every financial write should eventually produce an audit event. + +Audit targets: + +```text +invoice created +invoice updated +payment recorded +payment edited +payment voided +refund created +refund paid +discount applied +discount removed +event charge created +event charge approved +event charge voided +installment plan created +installment plan activated +installment plan restructured +follow-up note added +promise-to-pay recorded +carryforward created +carryforward approved +carryforward posted to new year +carryforward waived or adjusted +receipt sent +notification failed +expense approved +reimbursement paid +``` + +Audit fields: + +```text +actor_id +action +entity_type +entity_id +before_json nullable +after_json nullable +ip_address nullable +user_agent nullable +created_at +``` + +## 21. Testing Plan + +Required tests: + +```text +stakeholder report does not mutate invoices +non-paying parent report only includes parents with open balances +aging buckets calculate correctly +follow-up note stores without changing invoice balance +promise-to-pay appears in collection queue +prior-year balance appears in carryforward preview +posting carryforward creates a new-year charge without changing old invoice +parent statement separates current-year and prior-year balances +event charge draft does not affect invoice balance +approved event charge appears in pending charge report +invoiced event charge appears in invoice totals when ledger mode supports it +installment plan creates correct schedule +payment allocation applies oldest installment first +overdue installments are detected correctly +receipt notification logs sent status +failed notification logs failed status +parent cannot view another parent's receipt or invoice +unauthorized user cannot export financial reports +CSV totals match JSON totals +old tuition forecast remains unchanged +new tuition rule applies family discounts correctly +``` + +## 22. Acceptance Criteria + +This expanded Laravel financial plan is complete when: + +```text +Existing finance endpoints still work. +Existing PayPal endpoints remain untouched unless a separate decommission is approved. +Stakeholder reporting works. +Non-paying parent follow-up report works. +Follow-up notes and promise-to-pay tracking work. +Previous-year balances can be previewed, carried forward, reported, and collected without changing old invoices. +Event charges can be reported and later attached to invoices safely. +Installment plans can be created, tracked, and reported. +Payment receipts can be sent and logged. +Overdue reminders can be manually triggered and logged. +Notification preferences are respected. +Financial exports exist for admin use. +Parent-facing balances remain access-controlled. +No report mutates invoice balances. +New write workflows are transaction-safe. +Ledger replacement remains behind a feature flag. +Tests cover reporting, collections, event charges, installments, and notifications. +``` + +## 23. Final Target Flow + +Financial action flow: + +```text +Authenticated staff action + ↓ +Permission check + ↓ +Form request validation + ↓ +Policy/ownership check + ↓ +DB transaction + ↓ +Lock affected invoice when balance-impacting + ↓ +Create/update payment, refund, charge, installment, discount, or note + ↓ +Recalculate through ledger when centralized mode is enabled + ↓ +Write audit log + ↓ +Send/log notification if requested + ↓ +Return response +``` + +Follow-up flow: + +```text +System calculates open balances + ↓ +Aging buckets and risk flags are assigned + ↓ +Staff reviews collection queue + ↓ +Staff records contact note or promise-to-pay + ↓ +System updates next follow-up date + ↓ +Optional reminder is sent and logged + ↓ +Resolved only when balance is paid, waived, or admin-reviewed +``` + +Carryforward flow: + +```text +System previews previous-year open balances + ↓ +Admin reviews source invoices and warnings + ↓ +Draft carryforward records are created + ↓ +Admin approves carryforward + ↓ +Carryforward is posted as a separate new-year charge + ↓ +Payments are allocated and receipts show old/new balance split + ↓ +Reports show prior-year receivables separately from current-year charges +``` + +Event charge flow: + +```text +Staff creates draft event charge + ↓ +Admin approves charge + ↓ +Parent is notified if configured + ↓ +Charge is attached to invoice or reported as pending + ↓ +Payment/refund follows normal finance rules +``` + +Installment flow: + +```text +Staff creates installment plan + ↓ +Plan schedule is generated + ↓ +Parent receives plan notice + ↓ +Payments are allocated to installments + ↓ +System tracks due, overdue, paid, and completed states + ↓ +Receipts and reminders are logged +``` + +The brutal summary: school finance needs one disciplined lifecycle for money owed, money collected, money adjusted, money spent, and money chased. Anything less becomes a spreadsheet with login screens. diff --git a/docs/strong_grading_system_plan.md b/docs/strong_grading_system_plan.md new file mode 100644 index 00000000..02de143a --- /dev/null +++ b/docs/strong_grading_system_plan.md @@ -0,0 +1,764 @@ +# Strong Grading System Plan + +## Purpose + +This document defines a stronger grading-system plan for the Alrahma Sunday School API. It is intentionally a planning document, not an implementation patch. + +The goal is to move the grading system from a functional calculator into a fair, explainable, auditable, backward-compatible, and policy-driven gradebook. + +The current system already has a useful foundation: a centralized semester score service, separated score-entry flows, grading locks, and missing-score override tracking. However, the current scoring model still allows too much ambiguity around blank scores, missing work, validation, and finalization. + +The strongest first move is not to redesign every formula. The strongest first move is to kill ambiguity while keeping old data readable and old calculations explainable. + +--- + +## Core Principles + +A strong grading system must be: + +1. **Fair** + Missing work must not accidentally improve a student's grade. + +2. **Explainable** + Every final score must be traceable to category scores, weights, attendance, exam score, and policy rules. + +3. **Auditable** + Locked or finalized scores must preserve the exact inputs and formula used at the time. + +4. **Policy-driven** + Grading policy should live in services, configuration, and grading profiles, not scattered controller logic. + +5. **Safe** + Invalid scores must be rejected before they reach averages or semester calculations. + +6. **Backward-compatible** + Old data must remain readable, displayable, and explainable under the legacy policy that created it. + +7. **Additive, not destructive** + The new grading system must be introduced beside the old one first. Existing historical scores must not be silently recalculated, reclassified, hidden, or overwritten. + +--- + +## Non-Negotiable Backward Compatibility Requirement + +The system must preserve old data display. + +Historical grades must remain visible exactly as they were calculated under the legacy system. The new strong grading system must not reinterpret old blanks, old averages, or old semester scores as if they were created under the new policy. + +This is the hard rule: + +```text +Old data must remain readable, displayable, and explainable exactly as it was calculated under the legacy system. +``` + +The new system must support two modes: + +| Mode | Purpose | Formula Source | Data Behavior | +|---|---|---|---| +| Legacy display mode | Show old/historical grades | Existing stored semester values and legacy formula | Do not reinterpret blanks or statuses | +| Strong scoring mode | Calculate future grades under stronger rules | New scoring service/profile | Uses statuses, max points, snapshots, and finalization validation | + +Legacy records must show stored values from the existing `semester_scores` table, such as: + +```text +homework_avg +quiz_avg +project_avg +participation_score +attendance_score +ptap_score +midterm_exam_score +final_exam_score +semester_score +``` + +Legacy displays should include a visible label: + +```text +Legacy Calculation +``` + +And a short explanation: + +```text +This score was calculated under the legacy grading policy. Blank scores were ignored in category averages, PTAP used legacy dynamic weighting, attendance included one-absence grace, and the semester exam used the legacy 60% weighting. +``` + +This is not optional. Without this, the system risks showing old data through new rules and creating disputes that nobody can cleanly answer. Software should not gaslight the gradebook. Humans already do enough of that. + +--- + +## Historical Data Protection Rules + +Historical grades must not be automatically recalculated under the new scoring policy. + +Rules: + +1. Existing `semester_scores` rows stay in place. +2. Existing historical rows are marked as legacy. +3. Legacy rows are displayed from stored aggregate values. +4. Legacy rows are not forced into the new status model. +5. Legacy blanks are not automatically converted to `missing`. +6. Legacy scores are not recalculated unless an authorized admin explicitly starts a recalculation job. +7. If recalculation is ever allowed, the original legacy score must be preserved beside the new-policy result. + +Recommended display if a historical record is later recalculated for comparison: + +```text +legacy_semester_score: 91.4 +new_policy_semester_score: 87.2 +displayed_score: 91.4 unless admin explicitly chooses otherwise +``` + +The default behavior must protect the original displayed score. + +--- + +## Recommended Compatibility Metadata + +Do not replace the existing `semester_scores` table. Keep it. + +Add nullable metadata columns: + +```text +calculation_mode +calculation_policy_version +snapshot_id nullable +``` + +Suggested values: + +```text +calculation_mode = legacy | strong +calculation_policy_version = legacy_v1 | strong_v1 +``` + +Backfill existing rows as: + +```text +calculation_mode = legacy +calculation_policy_version = legacy_v1 +snapshot_id = null +``` + +New strong-system finalized rows should be saved with: + +```text +calculation_mode = strong +calculation_policy_version = strong_v1 +snapshot_id = related semester_score_snapshots row +``` + +This keeps old grade display stable while allowing new scores to become audit-proof. + +--- + +## Display Resolver Requirement + +Create a display resolver instead of forcing every grade display through the new scoring engine. + +Recommended service: + +```php +App\Services\Grading\GradeCalculationDisplayResolver +``` + +Responsibilities: + +```text +If calculation_mode is legacy: + Load and display existing semester_scores fields. + Show Legacy Calculation badge. + Do not apply new status or profile rules. + +If calculation_mode is strong: + Load snapshot and/or strong calculation breakdown. + Show Strong Calculation badge. + Display status-aware category breakdown. +``` + +The display layer must not recalculate legacy scores just to show them. It should show the stored values. Recalculating old scores on display is a terrible idea, and yet somehow tempting to developers who enjoy turning page loads into historical revisionism. + +--- + +## Important Policy Clarification: Attendance Grace + +The attendance grace rule is school policy. + +The current formula effectively gives students one absence before attendance points are reduced: + +```text +attendance_score = ((total_days + 1 - absences) / total_days) * 100 +``` + +This is policy-compliant, but the formula hides the intent. It should be rewritten more explicitly: + +```text +grace_absences = 1 +effective_absences = max(0, absences - grace_absences) +attendance_score = ((total_days - effective_absences) / total_days) * 100 +``` + +This keeps the same policy while making the rule clear. Future developers should not have to decode a `+1` and wonder whether it is a bug wearing a tiny policy hat. + +--- + +## Phase 0: Lock the Grading Policy Before Writing Code + +Before implementation, these policy decisions must be explicit. + +| Question | Recommended Decision | +|---|---| +| Are old/historical grades still displayed? | Yes. Always. | +| Are old/historical grades recalculated automatically? | No. Never silently. | +| Are old blanks converted to missing? | No. Old blanks remain legacy behavior unless explicitly migrated by admin workflow. | +| Are scores always out of 100? | No. Add `max_points`, then normalize to percentages for strong scoring. | +| What does a blank score mean in the new system? | Nothing final. It means pending or unresolved. | +| Does missing work count as zero in the new system? | Yes, unless explicitly excused. | +| Does excused work affect the average? | No. Excused work is excluded from the denominator. | +| Can a class be locked with pending scores in the new system? | No. | +| Can a class be locked with missing work in the new system? | Yes, but missing work counts as zero unless excused. | +| Does attendance have one absence grace? | Yes. This is school policy. | +| Should final scores keep an audit trail? | Yes. Store a calculation snapshot when locking/finalizing new strong scores. | + +These decisions are non-negotiable if the system needs to be defensible. + +--- + +## Phase 1: Stabilize the Current Legacy System + +Do not start with grading profiles. That is architecture candy. It looks mature, but it does not fix the most dangerous problem first. + +The first goal is to stop bad data, protect current behavior with tests, and keep legacy grade display working. + +### 1. Add Tests for Existing Legacy Formulas + +Before changing calculation behavior, freeze the current formulas in tests. + +Test coverage should include: + +```text +homework average ignores blanks +quiz average ignores blanks +project average ignores blanks +participation missing returns null +PTAP with no quiz/project +PTAP with quiz only +PTAP with project only +PTAP with all categories +Fall semester formula +Spring semester formula +attendance with 0 absences = 100 +attendance with 1 absence = 100 +attendance with 2 absences = reduced score +legacy semester_scores display from stored values +legacy rows are not recalculated on display +``` + +This creates a safety net. Without it, every refactor becomes a guessing game with grades, which is exactly as bad as it sounds. + +### 2. Add Central Score Validation + +Create one central validator used by every score write path. + +Validation rules: + +```text +score may be null +score must be numeric when present +score must be >= 0 +score must be <= max_points +max_points must be > 0 +``` + +For the first pass, use `max_points = 100` by default. + +Example service shape: + +```php +final class ScoreValueValidator +{ + public function normalizeOrFail(mixed $value, float $maxPoints = 100.0): ?float + { + if ($value === null || (is_string($value) && trim($value) === '')) { + return null; + } + + if (!is_numeric($value)) { + throw new InvalidArgumentException('Score must be numeric.'); + } + + $score = (float) $value; + + if ($score < 0 || $score > $maxPoints) { + throw new InvalidArgumentException("Score must be between 0 and {$maxPoints}."); + } + + return $score; + } +} +``` + +Use this validator in all score-entry paths: + +```text +HomeworkScoreService +QuizScoreService +ProjectScoreService +ParticipationScoreService +ExamScoreService +GradingScoreService +``` + +This should reject impossible new values without changing how existing historical rows are displayed. + +### 3. Make Attendance Grace Explicit + +Refactor the attendance calculation without changing its result. + +Replace hidden arithmetic: + +```text +(total_days + 1 - absences) / total_days +``` + +With named policy logic: + +```text +grace_absences = 1 +effective_absences = max(0, absences - grace_absences) +attendance_score = ((total_days - effective_absences) / total_days) * 100 +``` + +Add tests: + +| Total Days | Absences | Expected Attendance Score | +|---:|---:|---:| +| 10 | 0 | 100 | +| 10 | 1 | 100 | +| 10 | 2 | 90 | +| 10 | 10 | 10 | +| 10 | 11+ | 0 | + +### 4. Add Legacy Display Badge + +Before introducing strong scoring, make the UI able to label old records. + +For legacy rows, show: + +```text +Policy: Legacy Calculation +``` + +Example display: + +```text +Semester Score: 91.4 +Policy: Legacy Calculation +Attendance: 100.0 +PTAP: 88.5 +Midterm: 92.0 + +Note: This score was calculated under the legacy policy. Blank scores were ignored in averages. +``` + +This prevents users from assuming legacy and strong scores use the same rules. + +--- + +## Phase 2: Make Missing Scores Explicit for New/Strong Scoring + +This phase applies to the new strong scoring path. It must not automatically rewrite old historical records. + +Right now, a blank score can mean too many things: + +```text +not graded yet +student missing work +student excused +teacher forgot +assignment not applicable +``` + +A grading system cannot be fair if the data refuses to say what it means. + +### 1. Add Score Statuses + +Every new strong score-bearing record should have both a numeric score and a status. + +Minimum statuses: + +| Status | Meaning | Counts in Average? | Blocks Finalization? | +|---|---|---:|---:| +| `pending` | Teacher has not resolved the item | No | Yes | +| `scored` | Valid numeric score exists | Yes | No | +| `missing` | Student did not submit | Yes, as 0 | No | +| `excused` | Approved exclusion | No | No | +| `not_assigned` | Does not apply | No | No | + +Recommended columns for homework, quiz, project, participation, midterm, and final tables: + +```text +status +max_points +excused_reason +locked_at +locked_by +``` + +### 2. Migrate Existing Data Carefully + +Do not blindly turn old blanks into missing work. That would punish students for old system ambiguity. + +Initial migration mapping for active, not-yet-finalized records: + +| Existing Data | Initial Status | +|---|---| +| Numeric score exists | `scored` | +| Blank/null score | `pending` | +| Missing override exists and is allowed | `excused` or `not_assigned`, depending policy meaning | + +For historical finalized legacy records, do not require full status migration just to display them. Keep them as legacy display records. + +After migration, teachers/admins must resolve pending items before final lock for strong-scoring sections. + +--- + +## Phase 3: Change Calculation Semantics for Strong Scoring + +Once statuses exist, strong category calculations should stop relying on blank/null behavior. + +New score handling: + +| Status | Calculation Behavior | +|---|---| +| `scored` | Include normalized score | +| `missing` | Include 0 | +| `excused` | Exclude from denominator | +| `not_assigned` | Exclude from denominator | +| `pending` | Block finalization | + +Normalize every scored item: + +```text +normalized_score = (raw_score / max_points) * 100 +``` + +Then calculate the category average: + +```text +category_average = average(all included normalized scores) +``` + +This prevents missing work from silently improving a student's grade. + +Legacy records must still display legacy stored values. Do not apply this status-aware calculation to historical rows unless an admin explicitly opts into a recalculation workflow. + +--- + +## Phase 4: Add Finalization Validation + +Locking a strong-scoring class should not simply flip a flag. It should prove the gradebook is complete enough to finalize. + +Create a validator service: + +```php +App\Services\Grading\GradebookFinalizationValidator +``` + +It should validate: + +```text +No pending required scores +All numeric scores are within range +Required categories are present +Required exam score exists +Required participation score exists +Attendance can be calculated +Missing items are either counted as zero or excused +Every student has a calculable final score +``` + +If validation fails, return a structured report: + +```text +student +category +item +problem +required_action +``` + +Example: + +```text +Student: Ahmed Ali +Category: Homework +Item: Homework #4 +Problem: Pending score +Required action: Mark as scored, missing, excused, or not assigned before locking. +``` + +The lock button should become a gate, not a decorative suggestion. + +Legacy historical records do not need to pass the new finalization validator just to be displayed. + +--- + +## Phase 5: Add Calculation Snapshots for Strong Scores + +When strong grades are finalized or locked, store exactly how the final score was produced. + +Create table: + +```text +semester_score_snapshots +``` + +Recommended fields: + +```text +id +student_id +class_section_id +semester +school_year +grading_policy_version +input_json +calculation_json +semester_score +calculated_by +calculated_at +``` + +The snapshot should include: + +```text +raw scores used +score statuses used +max points used +normalized scores +category averages +category weights +attendance grace policy +exam score +final formula +final score +``` + +This protects the school when someone disputes a grade later. + +The current `semester_scores` table can show the latest aggregate. The snapshot proves what was true at finalization. + +Legacy historical grades may have no snapshot. That is acceptable as long as they are clearly labeled `legacy_v1` and displayed from stored values. + +--- + +## Phase 6: Replace Hardcoded PTAP With Grading Profiles + +Only after validation, statuses, legacy display protection, and snapshots are in place should hardcoded weighting be replaced. + +Current PTAP behavior changes based on data availability: + +```text +No tests and no projects +Tests but no projects +Projects but no tests +All categories exist +``` + +That is flexible, but dangerous. It lets missing or unentered data affect the formula. + +### Recommended Tables + +```text +grading_profiles +``` + +Fields: + +```text +id +name +school_year +semester +class_section_id nullable +is_default +version +created_by +created_at +updated_at +``` + +```text +grading_profile_categories +``` + +Fields: + +```text +id +grading_profile_id +category_key +weight +required +redistribute_if_missing +``` + +### Example Profile + +| Category | Weight | Required | Redistribute If Unused | +|---|---:|---:|---:| +| homework | 15 | Yes | No | +| quiz | 15 | Configurable | Yes | +| project | 15 | Configurable | Yes | +| participation | 15 | Yes | No | +| attendance | 10 | Yes | No | +| exam | 30 | Yes | No | + +Important distinction: + +```text +Category not used by profile -> redistribute its weight +Category assigned but scores pending -> block lock +Category assigned but student missing -> count as zero +Category excused -> exclude according to policy +``` + +Do not redistribute weight just because a teacher forgot to enter grades. That would be grading by accident, which is somehow worse than grading by spreadsheet. + +Legacy rows continue using legacy PTAP display and must not be forced into grading profiles. + +--- + +## Phase 7: Opt-In Activation Strategy + +Strong scoring should be activated by class section, semester, and school year. It should not globally replace legacy behavior in one switch. + +Recommended activation metadata: + +```text +class_section_id +school_year +semester +calculation_mode = legacy | strong +grading_profile_id nullable +activated_by +activated_at +``` + +Activation rules: + +1. Existing historical records remain `legacy`. +2. Current active sections may remain `legacy` until admins choose migration. +3. New future sections can default to `strong` once the system is ready. +4. A class section using `legacy` must continue calculating and displaying using the legacy service. +5. A class section using `strong` must use statuses, max points, finalization validation, and snapshots. +6. Switching a section from legacy to strong should require an admin confirmation and a preflight report. + +Preflight report should show: + +```text +number of numeric scores that will become scored +number of blanks that will become pending +number of overrides requiring review +students blocked from finalization until pending items are resolved +``` + +No quiet migrations. Quiet migrations are where data integrity goes to disappear. + +--- + +## Phase 8: Refactor Controllers Last + +Controllers should not contain grading policy. + +Final desired flow: + +```text +Controller receives request +Request validates input shape +Application service saves score/status +Scoring mode resolver selects legacy or strong path +Scoring service recalculates draft score +Finalization validator blocks or allows lock for strong mode +Snapshot service records locked strong calculation +Display resolver shows legacy or strong breakdown +``` + +Controllers should not know formulas. They should not decide whether missing counts. They should not know category redistribution rules. + +Controllers are traffic cops, not education philosophers. + +--- + +## Recommended Implementation Order + +| Step | Work | Risk Reduced | +|---:|---|---| +| 1 | Add tests for current legacy formulas and legacy display | Prevent accidental formula/display drift | +| 2 | Add compatibility metadata to `semester_scores` | Preserve old records clearly | +| 3 | Add legacy/strong display resolver | Keep old data visible | +| 4 | Add central score validation | Stop impossible new scores | +| 5 | Make attendance grace explicit | Preserve policy and improve clarity | +| 6 | Add `max_points` columns | Enable normalization | +| 7 | Add `status` columns | Remove blank-score ambiguity for strong scoring | +| 8 | Build category calculator using statuses | Fix missing-score fairness | +| 9 | Add finalization validator | Stop invalid strong locks | +| 10 | Add calculation snapshots | Audit-proof locked strong grades | +| 11 | Add grading profiles | Remove hardcoded weights for strong mode | +| 12 | Add opt-in activation by class/semester | Prevent disruptive rollout | +| 13 | Refactor controllers | Clean architecture after behavior is safe | + +--- + +## MVP Strong Upgrade + +The smallest serious upgrade is: + +```text +1. Keep current legacy formulas. +2. Keep old data display from stored semester_scores values. +3. Add calculation_mode and calculation_policy_version metadata. +4. Add legacy display badge. +5. Add central score validation for new writes. +6. Make attendance grace explicit without changing results. +7. Add score statuses for active/future strong-scoring sections. +8. Treat missing as zero, excused as excluded, and pending as blocking lock in strong mode only. +9. Add a lock validation report for strong mode. +10. Add calculation snapshots on strong-mode lock. +``` + +This improves fairness and auditability without breaking old records or immediately changing all weights. + +After that, replace PTAP branching with grading profiles. + +--- + +## Hard Safety Rules + +1. Do not change formulas and database semantics in the same step without tests. +2. Do not silently recalculate historical scores. +3. Do not convert old blanks to missing for historical records. +4. Do not hide legacy data because it lacks new statuses. +5. Do not activate strong scoring globally without class/semester-level opt-in. +6. Do not display a strong-policy explanation for a legacy-policy score. +7. Do not let a lock action become the first time the system discovers incomplete data. + +That is how a school ends up unable to explain whether grades changed because of policy, migration, code, or a null-handling goblin hiding in the service layer. + +--- + +## Final Recommendation + +Do not begin by building grading profiles. + +Begin by making legacy display safe and every new score state explicit. + +The strongest first milestone is: + +```text +Old grades remain visible under Legacy Calculation, and future strong-mode grades cannot be locked until every score is classified as scored, missing, excused, pending, or not assigned. +``` + +Once that is true, the grading system becomes defensible. Until that is true, every formula is built on fog. diff --git a/routes/api.php b/routes/api.php index d53be1ab..5262a04a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\Administrator\AdministratorAbsenceController; use App\Http\Controllers\Api\Administrator\AdministratorDashboardController; use App\Http\Controllers\Api\Administrator\AdministratorNotificationController; use App\Http\Controllers\Api\Administrator\AdministratorTeacherSubmissionController; +use App\Http\Controllers\Api\Administrator\TrophyController as AdministratorTrophyController; use App\Http\Controllers\Api\Administrator\EmergencyContactController as AdministratorEmergencyContactController; use App\Http\Controllers\Api\Administrator\TeacherClassAssignmentController; use App\Http\Controllers\Api\Badges\BadgeController; @@ -41,6 +42,10 @@ use App\Http\Controllers\Api\Reports\ReportCardsController; use App\Http\Controllers\Api\Reports\StickersController; use App\Http\Controllers\Api\Scores\FinalController; use App\Http\Controllers\Api\Finance\FinancialController; +use App\Http\Controllers\Api\Finance\BalanceCarryforwardController; +use App\Http\Controllers\Api\Finance\InstallmentPlanController; +use App\Http\Controllers\Api\Finance\FinanceNotificationController; +use App\Http\Controllers\Api\Finance\EventChargeController; use App\Http\Controllers\Api\System\FlagController; use App\Http\Controllers\Api\System\DashboardController; use App\Http\Controllers\Api\System\DatabaseHealthController; @@ -151,6 +156,9 @@ Route::get('docs/public', [ApiDocsAdminController::class, 'public'])->name('api- Route::get('access_denied', [AccessDeniedController::class, 'accessDenied'])->name('access_denied'); +Route::get('certificates/verify/{token}', [CertificateController::class, 'verify']) + ->where('token', '[^/]+'); + Route::get('timeoff/notify/{token}', [TimeOffNotificationController::class, 'notify']) ->where('token', '[^/]+') ->name('api.timeoff.notify'); @@ -302,6 +310,12 @@ Route::prefix('v1')->group(function () { Route::get('teacher-submissions', [AdministratorTeacherSubmissionController::class, 'index']); Route::post('teacher-submissions/notify', [AdministratorTeacherSubmissionController::class, 'notify']); + Route::prefix('trophy')->group(function () { + Route::get('/', [AdministratorTrophyController::class, 'index']); + Route::get('winners', [AdministratorTrophyController::class, 'winners']); + Route::get('final', [AdministratorTrophyController::class, 'final']); + }); + Route::get('teacher-class/assignments', [TeacherClassAssignmentController::class, 'index']); Route::post('teacher-class/assign', [TeacherClassAssignmentController::class, 'store']); Route::post('teacher-class/delete', [TeacherClassAssignmentController::class, 'destroy']); @@ -325,6 +339,9 @@ Route::prefix('v1')->group(function () { Route::post('reminders/dispatch', [AdministratorPromotionController::class, 'dispatchScheduledReminders']); Route::post('deadlines/expire', [AdministratorPromotionController::class, 'expireDeadlines']); Route::post('deadlines', [AdministratorPromotionController::class, 'setDeadline']); + Route::post('placement/preview', [AdministratorPromotionController::class, 'createPlacementPreview']); + Route::get('placement/batches/{batchId}', [AdministratorPromotionController::class, 'showPlacementBatch'])->whereNumber('batchId'); + Route::post('placement/batches/{batchId}/finalize', [AdministratorPromotionController::class, 'finalizePlacementBatch'])->whereNumber('batchId'); Route::prefix('levels')->group(function () { Route::get('/', [AdministratorPromotionController::class, 'levelProgressions']); @@ -382,6 +399,10 @@ Route::prefix('v1')->group(function () { Route::prefix('reports')->group(function () { Route::get('financial-detailed', [FinancialController::class, 'report']); + Route::get('stakeholder-analysis', [FinancialController::class, 'stakeholderAnalysis']); + Route::get('stakeholder-analysis/csv', [FinancialController::class, 'stakeholderAnalysisCsv']); + Route::get('parent-payment-followups', [FinancialController::class, 'parentPaymentFollowups']); + Route::get('parent-payment-followups/csv', [FinancialController::class, 'parentPaymentFollowupsCsv']); }); Route::prefix('invoices')->group(function () { @@ -421,8 +442,12 @@ Route::prefix('v1')->group(function () { }); Route::prefix('certificates')->group(function () { + Route::get('dashboard', [CertificateController::class, 'dashboard']); Route::get('form-options', [CertificateController::class, 'formOptions']); + Route::get('audit-log', [CertificateController::class, 'auditLog']); Route::post('generate', [CertificateController::class, 'generate']); + Route::get('reprint/{certificateNumber}', [CertificateController::class, 'reprint']) + ->where('certificateNumber', '[^/]+'); }); Route::middleware('admin.access')->prefix('role-permission')->group(function () { @@ -919,6 +944,44 @@ Route::prefix('v1')->group(function () { Route::prefix('finance')->group(function () { Route::get('financial-report', [FinancialController::class, 'report']); Route::get('financial-summary', [FinancialController::class, 'summary']); + Route::get('stakeholder-analysis', [FinancialController::class, 'stakeholderAnalysis']); + Route::get('stakeholder-analysis/csv', [FinancialController::class, 'stakeholderAnalysisCsv']); + Route::get('parent-payment-followups', [FinancialController::class, 'parentPaymentFollowups']); + Route::get('parent-payment-followups/csv', [FinancialController::class, 'parentPaymentFollowupsCsv']); + Route::post('parent-payment-followups/{parent}/note', [FinancialController::class, 'storeParentFollowUpNote'])->whereNumber('parent'); + Route::post('parent-payment-followups/{parent}/mark-contacted', [FinancialController::class, 'markParentContacted'])->whereNumber('parent'); + Route::post('parent-payment-followups/{parent}/promise-to-pay', [FinancialController::class, 'storePromiseToPay'])->whereNumber('parent'); + Route::post('parent-payment-followups/{parent}/resolve', [FinancialController::class, 'resolveParentFollowUp'])->whereNumber('parent'); + + Route::get('carryforwards/preview', [BalanceCarryforwardController::class, 'preview']); + Route::post('carryforwards/draft', [BalanceCarryforwardController::class, 'storeDraft']); + Route::post('carryforwards/{carryforward}/approve', [BalanceCarryforwardController::class, 'approve'])->whereNumber('carryforward'); + Route::post('carryforwards/{carryforward}/post', [BalanceCarryforwardController::class, 'postToNewYear'])->whereNumber('carryforward'); + Route::post('carryforwards/{carryforward}/waive', [BalanceCarryforwardController::class, 'waive'])->whereNumber('carryforward'); + Route::post('carryforwards/{carryforward}/adjust', [BalanceCarryforwardController::class, 'adjust'])->whereNumber('carryforward'); + Route::get('carryforwards/report', [BalanceCarryforwardController::class, 'report']); + Route::get('carryforwards/report/csv', [BalanceCarryforwardController::class, 'reportCsv']); + + Route::apiResource('event-charges', EventChargeController::class); + Route::post('event-charges/{eventCharge}/approve', [EventChargeController::class, 'approve'])->whereNumber('eventCharge'); + Route::post('event-charges/{eventCharge}/void', [EventChargeController::class, 'void'])->whereNumber('eventCharge'); + Route::post('event-charges/{eventCharge}/attach-to-invoice', [EventChargeController::class, 'attachToInvoice'])->whereNumber('eventCharge'); + + Route::apiResource('installment-plans', InstallmentPlanController::class)->only(['index','show']); + Route::post('invoices/{invoice}/installment-plans', [InstallmentPlanController::class, 'store'])->whereNumber('invoice'); + Route::post('installment-plans/{plan}/activate', [InstallmentPlanController::class, 'activate'])->whereNumber('plan'); + Route::post('installment-plans/{plan}/cancel', [InstallmentPlanController::class, 'cancel'])->whereNumber('plan'); + Route::post('installment-plans/{plan}/restructure', [InstallmentPlanController::class, 'restructure'])->whereNumber('plan'); + Route::post('payments/{payment}/allocate-installments', [InstallmentPlanController::class, 'allocatePayment'])->whereNumber('payment'); + Route::get('installments/due', [InstallmentPlanController::class, 'due']); + Route::get('installments/overdue', [InstallmentPlanController::class, 'overdue']); + + Route::post('payments/{payment}/send-receipt', [FinanceNotificationController::class, 'sendPaymentReceipt'])->whereNumber('payment'); + Route::post('refunds/{refund}/send-receipt', [FinanceNotificationController::class, 'sendRefundReceipt'])->whereNumber('refund'); + Route::post('invoices/{invoice}/send-statement', [FinanceNotificationController::class, 'sendInvoiceStatement'])->whereNumber('invoice'); + Route::post('parents/{parent}/send-overdue-reminder', [FinanceNotificationController::class, 'sendOverdueReminder'])->whereNumber('parent'); + Route::post('installments/{installment}/send-reminder', [FinanceNotificationController::class, 'sendInstallmentReminder'])->whereNumber('installment'); + Route::get('notification-logs', [FinanceNotificationController::class, 'logs']); Route::get('financial-report/csv', [FinancialController::class, 'downloadCsv']); Route::get('financial-report/pdf', [FinancialController::class, 'downloadPdf']); Route::get('unpaid-parents', [FinancialController::class, 'unpaidParents']); @@ -985,6 +1048,7 @@ Route::prefix('v1')->group(function () { Route::get('parent-payment', [InvoiceController::class, 'parentPayment']); Route::post('{invoiceId}/status', [InvoiceController::class, 'updateStatus']); Route::get('unpaid', [InvoiceController::class, 'unpaid']); + Route::get('{invoiceId}/preview', [InvoiceController::class, 'preview']); Route::get('{invoiceId}/pdf', [InvoiceController::class, 'pdf']); }); diff --git a/tests/Unit/Grading/ScoreValueValidatorTest.php b/tests/Unit/Grading/ScoreValueValidatorTest.php new file mode 100644 index 00000000..2caebf76 --- /dev/null +++ b/tests/Unit/Grading/ScoreValueValidatorTest.php @@ -0,0 +1,37 @@ +assertNull($validator->normalizeNullable('', 100)); + $this->assertNull($validator->normalizeNullable(null, 100)); + } + + public function test_score_must_be_inside_range(): void + { + $validator = new ScoreValueValidator(); + + $this->assertSame(95.0, $validator->normalizeNullable('95', 100)); + + $this->expectException(InvalidArgumentException::class); + $validator->normalizeNullable(101, 100); + } + + public function test_status_inference_preserves_legacy_pending_behavior(): void + { + $validator = new ScoreValueValidator(); + + $this->assertSame('scored', $validator->inferStatus(80.0)); + $this->assertSame('pending', $validator->inferStatus(null, false)); + $this->assertSame('excused', $validator->inferStatus(null, true)); + } +} diff --git a/tests/Unit/Services/CertificateAdminServiceTest.php b/tests/Unit/Services/CertificateAdminServiceTest.php new file mode 100644 index 00000000..8b8c7638 --- /dev/null +++ b/tests/Unit/Services/CertificateAdminServiceTest.php @@ -0,0 +1,219 @@ +create([ + 'config_key' => 'school_year', + 'config_value' => $this->schoolYear, + ]); + + $this->section = ClassSection::factory()->create([ + 'class_section_id' => 501, + 'class_section_name' => 'Grade 5-A', + ]); + + $this->service = app(CertificateAdminService::class); + } + + public function test_dashboard_groups_students_and_uses_saved_decisions_as_source_of_truth(): void + { + $passStudent = $this->enrollStudent('Amina', 'Pass', 'Female'); + $repeatStudent = $this->enrollStudent('Bilal', 'Repeat', 'Male'); + $pendingStudent = $this->enrollStudent('Celine', 'Pending', 'Female'); + + StudentDecision::query()->create([ + 'student_id' => $passStudent->id, + 'school_year' => $this->schoolYear, + 'class_section_name' => $this->section->class_section_name, + 'year_score' => 95.50, + 'decision' => 'Pass', + 'source' => 'manual', + ]); + + StudentDecision::query()->create([ + 'student_id' => $repeatStudent->id, + 'school_year' => $this->schoolYear, + 'class_section_name' => $this->section->class_section_name, + 'year_score' => 58.25, + 'decision' => 'Repeat Class', + 'source' => 'manual', + ]); + + CertificateRecord::query()->create([ + 'certificate_number' => 'ARSS-2025-2026-0001', + 'verification_token' => 'existingtoken', + 'student_id' => $passStudent->id, + 'student_name' => 'Amina Pass', + 'grade' => 'Grade 5', + 'cert_date' => '2026-06-05', + 'school_year' => $this->schoolYear, + 'class_section_id' => $this->section->class_section_id, + 'issued_at' => now(), + ]); + + $payload = $this->service->dashboard($this->schoolYear); + + $this->assertSame($this->schoolYear, $payload['school_year']); + $this->assertCount(1, $payload['grade_groups']); + $this->assertSame('Grade 5', $payload['grade_groups'][0]['label']); + $this->assertTrue($payload['grade_groups'][0]['has_pass']); + $this->assertTrue($payload['grade_groups'][0]['fully_done']); + + $section = $payload['grade_groups'][0]['sections'][0]; + $this->assertSame(3, $section['student_count']); + $this->assertSame(1, $section['pass_count']); + $this->assertSame(1, $section['cert_count']); + $this->assertSame(0, $section['remaining_count']); + + $students = collect($section['students'])->keyBy('student_id'); + + $this->assertTrue($students[$passStudent->id]['eligible']); + $this->assertSame(['Pass'], $students[$passStudent->id]['decision_labels']); + $this->assertSame('ARSS-2025-2026-0001', $students[$passStudent->id]['certificate_number']); + + $this->assertFalse($students[$repeatStudent->id]['eligible']); + $this->assertSame(['Repeat Class'], $students[$repeatStudent->id]['decision_labels']); + + $this->assertFalse($students[$pendingStudent->id]['eligible']); + $this->assertSame('pending', $students[$pendingStudent->id]['decision_state']); + } + + public function test_issue_certificates_reuses_existing_record_and_creates_new_one_for_unissued_pass_students(): void + { + $admin = User::factory()->create(); + $existingStudent = $this->enrollStudent('Adam', 'Existing', 'Male'); + $newStudent = $this->enrollStudent('Dina', 'New', 'Female'); + + foreach ([$existingStudent, $newStudent] as $student) { + StudentDecision::query()->create([ + 'student_id' => $student->id, + 'school_year' => $this->schoolYear, + 'class_section_name' => $this->section->class_section_name, + 'year_score' => 90.00, + 'decision' => 'Pass', + 'source' => 'manual', + ]); + } + + CertificateRecord::query()->create([ + 'certificate_number' => 'ARSS-2025-2026-0001', + 'verification_token' => 'persistedtoken', + 'student_id' => $existingStudent->id, + 'student_name' => 'Adam Existing', + 'grade' => 'Grade 5', + 'cert_date' => '2026-06-05', + 'school_year' => $this->schoolYear, + 'class_section_id' => $this->section->class_section_id, + 'issued_by' => $admin->id, + 'issued_at' => now(), + ]); + + $payload = $this->service->issueCertificates( + [$existingStudent->id, $newStudent->id], + '06/05/2026', + $this->section->class_section_id, + $this->schoolYear, + $admin->id + ); + + $this->assertSame('06/05/2026', $payload['cert_date_display']); + $this->assertCount(2, $payload['students']); + + $issued = collect($payload['students'])->keyBy('id'); + + $this->assertSame('ARSS-2025-2026-0001', $issued[$existingStudent->id]['cert_number']); + $this->assertNotSame('', $issued[$existingStudent->id]['verify_token']); + $this->assertSame('ARSS-2025-2026-0002', $issued[$newStudent->id]['cert_number']); + $this->assertNotSame('', $issued[$newStudent->id]['verify_token']); + + $this->assertSame(2, CertificateRecord::query()->where('school_year', $this->schoolYear)->count()); + + $newRecord = CertificateRecord::query() + ->where('student_id', $newStudent->id) + ->where('school_year', $this->schoolYear) + ->first(); + + $this->assertNotNull($newRecord); + $this->assertSame('Grade 5', $newRecord->grade); + $this->assertSame('2026-06-05', $newRecord->cert_date?->format('Y-m-d')); + $this->assertSame($admin->id, $newRecord->issued_by); + } + + public function test_audit_log_returns_year_summary_and_filtered_records(): void + { + CertificateRecord::query()->create([ + 'certificate_number' => 'ARSS-2025-2026-0001', + 'verification_token' => 'token-a', + 'student_id' => 1, + 'student_name' => 'Ali One', + 'grade' => 'Grade 5', + 'cert_date' => '2026-06-05', + 'school_year' => '2025-2026', + 'issued_at' => now(), + ]); + + CertificateRecord::query()->create([ + 'certificate_number' => 'ARSS-2024-2025-0001', + 'verification_token' => 'token-b', + 'student_id' => 2, + 'student_name' => 'Maya Two', + 'grade' => 'Grade 4', + 'cert_date' => '2025-06-01', + 'school_year' => '2024-2025', + 'issued_at' => now()->subDay(), + ]); + + $payload = $this->service->auditLog('2025-2026'); + + $this->assertSame('2025-2026', $payload['school_year']); + $this->assertCount(1, $payload['records']); + $this->assertSame('ARSS-2025-2026-0001', $payload['records'][0]['certificate_number']); + $this->assertCount(2, $payload['year_summary']); + } + + private function enrollStudent(string $firstname, string $lastname, string $gender): Student + { + $student = Student::factory()->create([ + 'firstname' => $firstname, + 'lastname' => $lastname, + 'gender' => $gender, + 'school_year' => $this->schoolYear, + 'is_active' => 1, + ]); + + StudentClass::query()->create([ + 'student_id' => $student->id, + 'class_section_id' => $this->section->class_section_id, + 'school_year' => $this->schoolYear, + 'semester' => 'Spring', + 'is_event_only' => 0, + ]); + + return $student; + } +} diff --git a/tests/Unit/Services/Promotions/PromotionEligibilityServiceTest.php b/tests/Unit/Services/Promotions/PromotionEligibilityServiceTest.php index ea0c1e05..3d34fb9c 100644 --- a/tests/Unit/Services/Promotions/PromotionEligibilityServiceTest.php +++ b/tests/Unit/Services/Promotions/PromotionEligibilityServiceTest.php @@ -21,7 +21,7 @@ class PromotionEligibilityServiceTest extends TestCase $studentId = $this->seedStudent(); $this->seedClassChain(); $this->seedAssignment($studentId, 701, '2025-2026'); - $this->seedScores($studentId, '2025-2026', 80.0, 75.0); + $this->seedDecision($studentId, '2025-2026', 77.5, 'promoted'); $service = $this->makeService(); $record = $service->evaluateStudent($studentId, '2025-2026', 1); @@ -41,7 +41,7 @@ class PromotionEligibilityServiceTest extends TestCase $studentId = $this->seedStudent(); $this->seedClassChain(); $this->seedAssignment($studentId, 701, '2025-2026'); - $this->seedScores($studentId, '2025-2026', 50.0, 55.0); + $this->seedDecision($studentId, '2025-2026', 52.5, 'retained'); $service = $this->makeService(); $record = $service->evaluateStudent($studentId, '2025-2026', 1); @@ -72,7 +72,7 @@ class PromotionEligibilityServiceTest extends TestCase $studentId = $this->seedStudent(); $this->seedClassChain(); $this->seedAssignment($studentId, 901, '2025-2026'); - $this->seedScores($studentId, '2025-2026', 90.0, 90.0); + $this->seedDecision($studentId, '2025-2026', 90.0, 'promoted'); // Mark Youth as terminal \App\Models\LevelProgression::query()->where('current_level_name', 'Youth')->update(['is_terminal' => 1]); @@ -90,7 +90,7 @@ class PromotionEligibilityServiceTest extends TestCase $studentId = $this->seedStudent(); $this->seedClassChain(); $this->seedAssignment($studentId, 701, '2025-2026'); - $this->seedScores($studentId, '2025-2026', 80.0, 80.0); + $this->seedDecision($studentId, '2025-2026', 80.0, 'promoted'); $service = $this->makeService(); $service->evaluateStudent($studentId, '2025-2026', 99); @@ -159,11 +159,18 @@ class PromotionEligibilityServiceTest extends TestCase ]); } - private function seedScores(int $studentId, string $schoolYear, float $fall, float $spring): void + private function seedDecision(int $studentId, string $schoolYear, float $score, string $decision): void { - DB::table('semester_scores')->insert([ - ['student_id' => $studentId, 'class_section_id' => 0, 'school_year' => $schoolYear, 'semester' => 'fall', 'semester_score' => $fall], - ['student_id' => $studentId, 'class_section_id' => 0, 'school_year' => $schoolYear, 'semester' => 'spring', 'semester_score' => $spring], + DB::table('student_decisions')->insert([ + 'student_id' => $studentId, + 'school_year' => $schoolYear, + 'class_section_name' => '3A', + 'year_score' => $score, + 'decision' => $decision, + 'source' => 'manual', + 'notes' => 'Seeded test decision', + 'created_at' => now(), + 'updated_at' => now(), ]); } -} +} \ No newline at end of file diff --git a/tests/Unit/Services/TrophyReportServiceTest.php b/tests/Unit/Services/TrophyReportServiceTest.php new file mode 100644 index 00000000..4c655aa7 --- /dev/null +++ b/tests/Unit/Services/TrophyReportServiceTest.php @@ -0,0 +1,174 @@ +service = app(TrophyReportService::class); + + Configuration::query()->create([ + 'config_key' => 'school_year', + 'config_value' => $this->schoolYear, + ]); + + $this->section = ClassSection::factory()->create([ + 'class_section_id' => 401, + 'class_section_name' => 'Grade 4A', + ]); + } + + public function test_calculate_threshold_enforces_minimum_of_three_winners(): void + { + $result = $this->service->calculateThreshold([50, 60, 70, 80, 90], 95.0); + + $this->assertSame('min3_reduced', $result['method']); + $this->assertSame(3, $result['winners']); + $this->assertSame(70.0, $result['threshold']); + } + + public function test_projection_marks_students_without_fall_scores_as_not_projected(): void + { + $first = $this->enrollStudent('Amina', 'Top', 'Female', 'S-1', 98.0); + $second = $this->enrollStudent('Bilal', 'Strong', 'Male', 'S-2', 95.0); + $third = $this->enrollStudent('Celine', 'Good', 'Female', 'S-3', 90.0); + $fourth = $this->enrollStudent('David', 'Missing', 'Male', 'S-4', null); + + $payload = $this->service->projection($this->schoolYear, 75.0); + + $this->assertSame($this->schoolYear, $payload['selected_year']); + $this->assertSame(75.0, $payload['selected_percentile']); + $this->assertSame([$this->schoolYear], $payload['years']); + $this->assertCount(1, $payload['class_results']); + + $classResult = $payload['class_results'][0]; + + $this->assertSame(4, $classResult['student_count']); + $this->assertSame(3, $classResult['scored_count']); + $this->assertSame(3, $classResult['trophy_count']); + $this->assertSame(90.0, $classResult['threshold']); + $this->assertSame(4, $payload['summary']['students']); + $this->assertSame(3, $payload['summary']['trophies']); + + $students = collect($classResult['students'])->keyBy('student_id'); + + $this->assertTrue($students[$first->id]['projected_trophy']); + $this->assertTrue($students[$second->id]['projected_trophy']); + $this->assertTrue($students[$third->id]['projected_trophy']); + $this->assertFalse($students[$fourth->id]['projected_trophy']); + $this->assertNull($students[$fourth->id]['fall_score']); + } + + public function test_final_report_classifies_confirmed_surprise_and_missed_winners(): void + { + $this->enrollStudent('Adam', 'Confirmed', 'Male', 'S-10', 99.0, 97.0); + $this->enrollStudent('Basma', 'Confirmed', 'Female', 'S-11', 95.0, 93.0); + $missed = $this->enrollStudent('Cyrus', 'Missed', 'Male', 'S-12', 92.0, 68.0); + $surprise = $this->enrollStudent('Dina', 'Surprise', 'Female', 'S-13', 88.0, 100.0); + $this->enrollStudent('Evan', 'None', 'Male', 'S-14', 70.0, 50.0); + + $payload = $this->service->final($this->schoolYear, 75.0); + + $this->assertCount(1, $payload['class_results']); + $this->assertSame(3, $payload['summary']['predicted']); + $this->assertSame(3, $payload['summary']['actual']); + $this->assertSame(2, $payload['summary']['confirmed']); + $this->assertSame(1, $payload['summary']['surprises']); + $this->assertSame(1, $payload['summary']['missed']); + $this->assertSame(67.0, $payload['summary']['accuracy']); + $this->assertSame(3, $payload['winner_gender_summary']['total']); + $this->assertSame(1, $payload['winner_gender_summary']['boys']); + $this->assertSame(2, $payload['winner_gender_summary']['girls']); + $this->assertCount(3, $payload['winner_stickers']); + + $classResult = $payload['class_results'][0]; + + $this->assertSame(92.0, $classResult['fall_threshold']); + $this->assertSame(94.0, $classResult['year_threshold']); + $this->assertSame(2, $classResult['confirmed']); + $this->assertSame(1, $classResult['surprises']); + $this->assertSame(1, $classResult['missed']); + $this->assertSame(67.0, $classResult['accuracy']); + + $students = collect($classResult['students'])->keyBy('student_id'); + + $this->assertSame('missed', $students[$missed->id]['status']); + $this->assertTrue($students[$missed->id]['predicted']); + $this->assertFalse($students[$missed->id]['actual']); + + $this->assertSame('surprise', $students[$surprise->id]['status']); + $this->assertFalse($students[$surprise->id]['predicted']); + $this->assertTrue($students[$surprise->id]['actual']); + } + + private function enrollStudent( + string $firstname, + string $lastname, + string $gender, + string $schoolId, + ?float $fallScore, + ?float $springScore = null + ): Student { + $student = Student::factory()->create([ + 'firstname' => $firstname, + 'lastname' => $lastname, + 'gender' => $gender, + 'school_id' => $schoolId, + 'school_year' => $this->schoolYear, + 'is_active' => 1, + ]); + + StudentClass::query()->create([ + 'student_id' => $student->id, + 'school_id' => null, + 'class_section_id' => $this->section->class_section_id, + 'school_year' => $this->schoolYear, + 'semester' => 'Fall', + 'is_event_only' => 0, + ]); + + if ($fallScore !== null) { + SemesterScore::query()->create([ + 'student_id' => $student->id, + 'school_id' => $schoolId, + 'class_section_id' => $this->section->class_section_id, + 'semester_score' => $fallScore, + 'semester' => 'Fall', + 'school_year' => $this->schoolYear, + ]); + } + + if ($springScore !== null) { + SemesterScore::query()->create([ + 'student_id' => $student->id, + 'school_id' => $schoolId, + 'class_section_id' => $this->section->class_section_id, + 'semester_score' => $springScore, + 'semester' => 'Spring', + 'school_year' => $this->schoolYear, + ]); + } + + return $student; + } +}