diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 00000000..7228a961 Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/Http/.DS_Store b/app/Http/.DS_Store new file mode 100644 index 00000000..1466b0ac Binary files /dev/null and b/app/Http/.DS_Store differ diff --git a/app/Http/Controllers/.DS_Store b/app/Http/Controllers/.DS_Store new file mode 100644 index 00000000..67deef52 Binary files /dev/null and b/app/Http/Controllers/.DS_Store differ diff --git a/app/Http/Controllers/Api/.DS_Store b/app/Http/Controllers/Api/.DS_Store new file mode 100644 index 00000000..bf2b2d0a Binary files /dev/null and b/app/Http/Controllers/Api/.DS_Store differ diff --git a/app/Services/.DS_Store b/app/Services/.DS_Store new file mode 100644 index 00000000..abf0a397 Binary files /dev/null and b/app/Services/.DS_Store differ diff --git a/app/Services/Badges/BadgePdfService.php b/app/Services/Badges/BadgePdfService.php index d8022028..20e7111f 100644 --- a/app/Services/Badges/BadgePdfService.php +++ b/app/Services/Badges/BadgePdfService.php @@ -392,10 +392,11 @@ class BadgePdfService $barcodeValue = $barcodeValue !== '' ? $barcodeValue : 'ID0000'; $barcodeText = $this->escapeHtml(str_repeat($barcodeValue, 4)); $showGrade = (bool) ($row['show_grade'] ?? ($kind === 'student')); - $schoolLevel = trim((string) config('badges.header_subtitle', '')); + $headerMainTitle = $this->headerMainTitle($schoolName); + $schoolLevel = $this->headerSubtitle($schoolName); $headerMotto = trim($motto) !== '' ? trim($motto) - : trim((string) config('badges.motto_fallback', '')); + : trim((string) config('badges.motto_fallback', 'Learn • Lead • Succeed')); $infoRows = ''; if ($showGrade) { @@ -436,7 +437,7 @@ class BadgePdfService : 'AS') . '
-
' . $this->escapeHtml($schoolName) . '
+
' . $this->escapeHtml($headerMainTitle) . '
' . ($schoolLevel !== '' ? '
' . $this->escapeHtml($schoolLevel) . '
' : '') . ' @@ -508,7 +509,7 @@ class BadgePdfService body { margin: 0; font-family: DejaVu Sans, Arial, Helvetica, sans-serif; - color: #111; + color: #111111; } .page { @@ -536,149 +537,178 @@ body { .badge { width: 2.18in; height: 3.26in; - border: 1.5px solid #166833; + border: 1.2px solid #d8d8d8; border-radius: 0.18in; overflow: hidden; position: relative; margin: 0 auto; background: #ffffff; - box-shadow: 0 0.03in 0.10in rgba(0, 0, 0, 0.10); + box-shadow: 0 0.03in 0.10in rgba(0, 0, 0, 0.16); } .header-wrap { position: relative; - height: 0.80in; + height: 0.96in; } .header { - background: linear-gradient(135deg, #0d6b32 0%, #13763a 45%, #0d5a29 100%); + background: linear-gradient(135deg, #063979 0%, #082f68 55%, #05285a 100%); color: #ffffff; - padding: 0.06in 0.08in 0.04in; - height: 0.58in; + padding: 0.07in 0.08in 0.04in; + height: 0.70in; border-radius: 0.18in 0.18in 0 0; } +.header:after { + content: ""; + position: absolute; + left: 0; + right: 0; + top: 0.64in; + height: 0.08in; + background: #8db7e8; + clip-path: polygon(0 0, 50% 100%, 100% 0, 100% 25%, 50% 100%, 0 25%); +} + .header-accent { position: absolute; - left: 0.36in; - right: 0.36in; - top: 0.45in; - height: 0.012in; - background: rgba(255, 211, 74, 0.45); + left: 0; + right: 0; + top: 0.67in; + height: 0.20in; + background: #07336f; + clip-path: polygon(0 0, 50% 100%, 100% 0); } .header-tail { - width: 1.14in; - height: 0.10in; - margin: -0.01in auto 0; - background: #13763a; - border-radius: 0 0 0.04in 0.04in; + display: none; } .header-mark { float: left; - width: 0.30in; - height: 0.30in; + width: 0.39in; + height: 0.39in; border: 2px solid #ffffff; - border-radius: 50%; + border-radius: 0.02in; text-align: center; overflow: hidden; background: transparent; + margin-top: 0.02in; } .header-mark img { width: 100%; height: 100%; object-fit: cover; - border-radius: 50%; display: block; } .header-mark span { display: block; - line-height: 0.36in; - font-size: 12pt; + line-height: 0.38in; + font-size: 11pt; font-weight: 800; } .header-copy { - margin-left: 0.38in; + margin-left: 0.45in; text-align: center; } .school-name { - font-size: 7.2pt; - font-weight: 800; - line-height: 1.1; + font-size: 17.5pt; + font-weight: 900; + line-height: 0.90; margin: 0; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 1px; + color: #ffffff; } .sub-school { - font-size: 5.0pt; - font-weight: 800; + font-size: 8.6pt; + font-weight: 900; text-transform: uppercase; - letter-spacing: 1.9px; + letter-spacing: 1.7px; margin-top: 0.03in; + color: #ffffff; +} + +.sub-school:before, +.sub-school:after { + content: ""; + display: inline-block; + width: 0.22in; + border-top: 1px solid #ffffff; + margin: 0 0.05in 0.025in; } .motto { - font-size: 4.4pt; - font-weight: 700; + font-size: 6.5pt; + font-weight: 900; margin-top: 0.03in; - letter-spacing: 0.4px; + letter-spacing: 0.8px; min-height: 0.08in; text-transform: uppercase; - color: #f4d34c; + color: #ffd44a; +} + +.motto:before, +.motto:after { + content: ""; + display: inline-block; + width: 0.20in; + border-top: 1px solid #ffd44a; + margin: 0 0.05in 0.02in; } .body { position: relative; - height: 2.18in; - padding: 0.02in 0.09in 0.42in; + height: 1.94in; + padding: 0.09in 0.09in 0.39in; } .student-name { text-align: center; - color: #15512c; - font-size: 9.6pt; - font-weight: 800; + color: #052d6f; + font-size: 14.0pt; + font-weight: 900; line-height: 1.05; text-transform: uppercase; - margin: 0.03in 0 0.02in; + margin: 0.01in 0 0.03in; + letter-spacing: 1px; } .name-divider { text-align: center; - margin-bottom: 0.02in; + margin-bottom: 0.03in; } .divider-line { display: inline-block; - width: 0.48in; - border-top: 1px solid #cfd6d1; + width: 0.65in; + border-top: 1px solid #5b92d1; vertical-align: middle; } .divider-dot { display: inline-block; - width: 0.04in; - height: 0.04in; - margin: 0 0.04in; - background: #1f6b3a; + width: 0.045in; + height: 0.045in; + margin: 0 0.045in; + background: #064297; transform: rotate(45deg); vertical-align: middle; } .role { text-align: center; - color: #1f48b3; - font-size: 5.5pt; - font-weight: 800; + color: #063979; + font-size: 7.4pt; + font-weight: 900; text-transform: uppercase; - letter-spacing: 1.1px; - margin-bottom: 0.05in; + letter-spacing: 1.5px; + margin-bottom: 0.07in; } .content { @@ -693,14 +723,15 @@ body { } .info { - width: 58%; - padding-right: 0.04in; - border-right: 1px solid #d6d6d6; + width: 52%; + padding-right: 0.07in; + border-right: 1px solid #cfcfcf; } .info-row { - margin-bottom: 0.06in; - padding-bottom: 0.00in; + margin-bottom: 0.07in; + padding-bottom: 0.04in; + border-bottom: 1px solid #d7d7d7; } .info-row:last-child { @@ -711,55 +742,55 @@ body { .icon-badge { display: inline-block; - width: 0.18in; - height: 0.18in; - line-height: 0.18in; - margin-right: 0.03in; + width: 0.22in; + height: 0.22in; + line-height: 0.22in; + margin-right: 0.05in; border-radius: 50%; - background: #156733; + background: #064297; color: #ffffff; text-align: center; - font-size: 4.8pt; - font-weight: 800; + font-size: 5.0pt; + font-weight: 900; vertical-align: top; } .info-copy { display: inline-block; - width: 0.78in; + width: 0.70in; } .label { display: block; - font-size: 4.5pt; - font-weight: 700; - color: #222222; + font-size: 4.7pt; + font-weight: 900; + color: #111111; margin-bottom: 0.01in; + text-transform: uppercase; } .value { display: block; - font-size: 7.0pt; - font-weight: 800; - color: #111111; + font-size: 8.2pt; + font-weight: 900; + color: #064297; word-wrap: break-word; } .qr-col { - width: 42%; + width: 48%; text-align: center; - padding-left: 0.04in; + padding-left: 0.08in; } .qr-box { display: inline-block; - border: 1px solid #1f6b3a; - border-radius: 0.08in; - padding: 0.03in; + border: 1.5px solid #064297; + border-radius: 0.06in; + padding: 0.04in; background: #ffffff; - min-width: 0.76in; - min-height: 0.76in; - box-shadow: inset 0 0 0 1px #dce8df; + min-width: 0.78in; + min-height: 0.78in; } .qr-box img { @@ -769,15 +800,15 @@ body { } .scan-pill { - margin-top: 0.025in; + margin-top: 0.035in; display: inline-block; - background: #156733; + background: #064297; color: #ffffff; - border-radius: 999px; - font-size: 4.5pt; - font-weight: 700; + border-radius: 0.03in; + font-size: 4.8pt; + font-weight: 900; text-transform: uppercase; - padding: 0.025in 0.06in 0.02in; + padding: 0.035in 0.07in 0.025in; } .barcode-wrap { @@ -786,8 +817,8 @@ body { right: 0.10in; bottom: 0.06in; text-align: center; - border-top: 1px solid #d6ddd8; - padding-top: 0.06in; + border-top: 1px solid #3d7fc6; + padding-top: 0.05in; } .barcode-bars { @@ -806,11 +837,7 @@ body { } .barcode-text { - margin-top: 0.03in; - text-align: center; - font-size: 5.8pt; - font-weight: 700; - color: #111111; + display: none; } .footer { @@ -818,25 +845,25 @@ body { left: 0; right: 0; bottom: 0; - background: linear-gradient(135deg, #0d6b32 0%, #0b5c2b 100%); + background: linear-gradient(135deg, #064297 0%, #052b64 100%); color: #ffffff; - padding: 0.05in 0.06in 0.05in; - font-size: 4.3pt; + padding: 0.055in 0.07in 0.05in; + font-size: 5.2pt; font-weight: 700; line-height: 1.2; } .footer-mark { display: inline-block; - width: 0.15in; - height: 0.15in; - line-height: 0.15in; - margin-right: 0.05in; + width: 0.25in; + height: 0.25in; + line-height: 0.25in; + margin-right: 0.07in; border: 1.5px solid #ffffff; border-radius: 50%; text-align: center; vertical-align: top; - font-size: 6pt; + font-size: 7pt; overflow: hidden; background: transparent; } @@ -849,13 +876,11 @@ body { display: block; } -.footer-mark span { - display: block; -} - .footer-copy { display: inline-block; - width: 1.72in; + width: 1.65in; + padding-left: 0.07in; + border-left: 1px solid rgba(255,255,255,0.8); vertical-align: top; } CSS; @@ -914,16 +939,271 @@ CSS; string $defaultStudentRoleLabel, string $footerBlock, ?string $logoPath + ): void { + $templatePath = $this->badgeTemplatePath($student); + + if ($templatePath !== null) { + $this->drawBadgeFromTemplate( + $pdf, + $student, + $x, + $y, + $w, + $h, + $defaultStudentRoleLabel, + $footerBlock, + $templatePath + ); + + return; + } + + // Safety fallback: if the template PNGs are not deployed yet, still render a usable badge. + $this->drawBadgeGeneratedFallback( + $pdf, + $student, + $x, + $y, + $w, + $h, + $schoolName, + $motto, + $defaultStudentRoleLabel, + $footerBlock, + $logoPath + ); + } + + /** + * Pick the static image template used as the badge background. + * + * Deploy these exact filenames to public/ or one of the supported public asset folders: + * - student_template.png + * - teacher_template.png + * - admin_template.png + */ + protected function badgeTemplatePath(array $row): ?string + { + $kind = strtolower((string) ($row['_badge_kind'] ?? 'student')); + $role = strtolower(trim((string) ($row['role_subline'] ?? $row['role_resolved'] ?? $row['roles'] ?? ''))); + + if ($kind === 'student') { + return $this->firstExistingPublicTemplate('student_template.png'); + } + + if (str_contains($role, 'admin')) { + return $this->firstExistingPublicTemplate('admin_template.png'); + } + + return $this->firstExistingPublicTemplate('teacher_template.png'); + } + + protected function firstExistingPublicTemplate(string $filename): ?string + { + return $this->formatter->firstExisting([ + public_path($filename), + public_path('badges/' . $filename), + public_path('assets/images/' . $filename), + public_path('assets/images/badges/' . $filename), + storage_path('app/public/' . $filename), + storage_path('app/public/badges/' . $filename), + ]); + } + + protected function drawBadgeFromTemplate( + \FPDF $pdf, + array $row, + float $x, + float $y, + float $w, + float $h, + string $defaultStudentRoleLabel, + string $footerBlock, + string $templatePath + ): void { + try { + $pdf->Image($templatePath, $x, $y, $w, $h); + } catch (Throwable) { + return; + } + + $kind = strtolower((string) ($row['_badge_kind'] ?? 'student')); + $roleProbe = strtolower((string) ($row['role_subline'] ?? '')); + $isAdmin = $kind !== 'student' && str_contains($roleProbe, 'admin'); + + $name = strtoupper($this->formatter->toPdf((string) ($row['fullname'] ?? ''))); + $firstValue = $this->formatter->toPdf((string) ($row['grade'] ?? '')); + $year = $this->formatter->toPdf((string) ($row['academic_year'] ?? '')); + $id = $this->formatter->toPdf((string) ($row['school_id'] ?? '')); + $qrPath = (string) ($row['_qr_tmp'] ?? ''); + $barcodeValue = preg_replace('/[^A-Za-z0-9]/', '', (string) ($row['school_id'] ?? '')); + $barcodeValue = $barcodeValue !== '' ? $barcodeValue : 'ID0000'; + + // Template coordinate map, in inches, relative to the badge's top-left corner. + // The PNG owns the visual design. PHP only overlays dynamic data. + $black = [18, 18, 18]; + $white = [255, 255, 255]; + + // The new template leaves more white space above the name block, so start the overlay lower. + $this->fitCenteredText($pdf, $name, $x + 0.15, $y + 1.24, $w - 0.30, 0.18, 15.0, 7.0, $black, 'B'); + + // Values beside/under the static labels baked into the template. + $valueX = $x + 0.48; + $valueW = 0.78; + $valueYOffset = 0.18; + $firstValueYOffset = 0.01; + $this->fitLeftText($pdf, $firstValue, $valueX, $y + 2.02 + $valueYOffset + $firstValueYOffset, $valueW, 0.08, 8.0, 5.0, $black, 'B'); + $this->fitLeftText($pdf, $year, $valueX, $y + 2.37 + $valueYOffset, $valueW, 0.08, 8.0, 5.0, $black, 'B'); + $this->fitLeftText($pdf, $id, $valueX, $y + 2.72 + $valueYOffset, $valueW, 0.08, 8.0, 4.8, $black, 'B'); + + // QR overlay, inside the template frame. + if ($qrPath !== '' && is_file($qrPath)) { + try { + $pdf->Image($qrPath, $x + 1.45, $y + 1.90, 0.66, 0.66, 'PNG'); + } catch (Throwable) { + } + } + + // Barcode overlay. Repeat seed so the barcode fills the long template box instead of becoming a sad tiny fence. + $pdf->SetFillColor(17, 17, 17); + $this->drawBarcodeBars($pdf, str_repeat($barcodeValue, 6), $x + 0.35, $y + 3.07, $w - 0.70, 0.13); + + // Footer dynamic text. The template already contains the logo and divider. + $pdf->SetTextColor($white[0], $white[1], $white[2]); + $pdf->SetFont('Helvetica', 'B', 4.2); + $lines = preg_split('/\r\n|\r|\n/', $footerBlock) ?: [$footerBlock]; + $footerY = $y + $h - 0.25; + foreach ($lines as $line) { + $line = trim((string) $line); + if ($line === '') { + continue; + } + $pdf->SetXY($x + 0.57, $footerY); + $pdf->Cell($w - 0.65, 0.05, $this->formatter->toPdf($line), 0, 1, 'L'); + $footerY += 0.055; + } + } + + /** + * Shrink text until it fits in the available width. FPDF, bravely, does not do this for us. + * + * @param array{0:int,1:int,2:int} $rgb + */ + protected function fitCenteredText( + \FPDF $pdf, + string $text, + float $x, + float $y, + float $w, + float $h, + float $maxPt, + float $minPt, + array $rgb, + string $style = '' + ): float { + $text = trim($text); + if ($text === '') { + return $y; + } + + $size = $maxPt; + $lines = $this->splitCenteredTextIntoTwoLines($text) ?? [$text]; + do { + $pdf->SetFont('Helvetica', $style, $size); + $maxLineWidth = 0.0; + foreach ($lines as $line) { + $maxLineWidth = max($maxLineWidth, $pdf->GetStringWidth($line)); + } + if ($maxLineWidth <= $w || $size <= $minPt) { + break; + } + $size -= 0.5; + } while ($size >= $minPt); + + $pdf->SetFont('Helvetica', $style, $size); + $pdf->SetTextColor($rgb[0], $rgb[1], $rgb[2]); + $lineSpacing = $h * 1.18; + + foreach ($lines as $index => $line) { + $pdf->SetXY($x, $y + ($index * $lineSpacing)); + $pdf->Cell($w, $h, $line, 0, 0, 'C'); + } + + return $y + ((count($lines) - 1) * $lineSpacing) + $h; + } + + /** + * @return array{0:string,1:string}|null + */ + protected function splitCenteredTextIntoTwoLines(string $text): ?array + { + $words = array_values(array_filter(explode(' ', preg_replace('/\s+/', ' ', trim($text)) ?: ''), 'strlen')); + if (count($words) < 2) { + return null; + } + + return [ + $words[0], + implode(' ', array_slice($words, 1)), + ]; + } + + /** + * @param array{0:int,1:int,2:int} $rgb + */ + protected function fitLeftText( + \FPDF $pdf, + string $text, + float $x, + float $y, + float $w, + float $h, + float $maxPt, + float $minPt, + array $rgb, + string $style = '' + ): void { + $text = trim($text); + if ($text === '') { + return; + } + + $size = $maxPt; + do { + $pdf->SetFont('Helvetica', $style, $size); + if ($pdf->GetStringWidth($text) <= $w || $size <= $minPt) { + break; + } + $size -= 0.5; + } while ($size >= $minPt); + + $pdf->SetTextColor($rgb[0], $rgb[1], $rgb[2]); + $pdf->SetXY($x, $y); + $pdf->Cell($w, $h, $text, 0, 0, 'L'); + } + + protected function drawBadgeGeneratedFallback( + \FPDF $pdf, + array $student, + float $x, + float $y, + float $w, + float $h, + string $schoolName, + string $motto, + string $defaultStudentRoleLabel, + string $footerBlock, + ?string $logoPath ): void { $kind = (string) ($student['_badge_kind'] ?? 'student'); $idLabel = $kind === 'staff' ? 'USER ID' : 'SCHOOL ID'; $showGrade = (bool) ($student['show_grade'] ?? ($kind === 'student')); $gradeLabel = (string) ($student['grade_label'] ?? ($kind === 'staff' ? 'CLASS / ASSIGNMENT' : 'GRADE')); - $green = [21, 103, 51]; - $greenDark = [13, 91, 43]; - $greenAccent = [244, 211, 74]; - $blueRole = [31, 72, 179]; + $green = [6, 66, 151]; + $greenDark = [5, 43, 100]; + $greenAccent = [255, 212, 74]; + $blueRole = [6, 57, 121]; $qrPath = (string) ($student['_qr_tmp'] ?? ''); $fullName = $this->formatter->toPdf((string) ($student['fullname'] ?? '')); @@ -934,8 +1214,8 @@ CSS; $barcodeValue = $barcodeValue !== '' ? $barcodeValue : 'ID0000'; $barcodeText = $this->formatter->toPdf(str_repeat($barcodeValue, 4)); - $schoolNamePdf = $this->formatter->toPdf($schoolName); - $headerSubtitlePdf = $this->formatter->toPdf((string) config('badges.header_subtitle', 'High School')); + $schoolNamePdf = $this->formatter->toPdf($this->headerMainTitle($schoolName)); + $headerSubtitlePdf = $this->formatter->toPdf($this->headerSubtitle($schoolName)); $mottoValue = trim($motto) !== '' ? $motto : (string) config('badges.motto_fallback', 'Learn • Lead • Succeed'); @@ -949,11 +1229,12 @@ CSS; $headerH = 0.66; $footerH = 0.36; - $pdf->SetFillColor($green[0], $green[1], $green[2]); - $pdf->Rect($x, $y, $w, $headerH, 'F'); $pdf->SetFillColor($greenDark[0], $greenDark[1], $greenDark[2]); - $pdf->Rect($x, $y + 0.18, $w, 0.18, 'F'); - $pdf->Rect($x + 0.22, $y + $headerH - 0.03, $w - 0.44, 0.10, 'F'); + $pdf->Rect($x, $y, $w, $headerH, 'F'); + $pdf->SetFillColor($green[0], $green[1], $green[2]); + $pdf->Rect($x, $y + 0.04, $w, 0.20, 'F'); + $pdf->SetFillColor(141, 183, 232); + $pdf->Rect($x, $y + $headerH - 0.03, $w, 0.03, 'F'); $sealSize = 0.33; $sealX = $x + 0.09; @@ -970,33 +1251,42 @@ CSS; } $pdf->SetTextColor(255, 255, 255); - $pdf->SetFont('Helvetica', 'B', 8.2); + $pdf->SetFont('Helvetica', 'B', 13.0); $pdf->SetXY($x + 0.45, $y + 0.10); - $pdf->Cell($w - 0.55, 0.08, $schoolNamePdf, 0, 1, 'C'); + $pdf->Cell($w - 0.55, 0.12, strtoupper($schoolNamePdf), 0, 1, 'C'); - $pdf->SetFont('Helvetica', 'B', 5.8); + $pdf->SetFont('Helvetica', 'B', 7.2); $pdf->SetXY($x + 0.45, $y + 0.27); $pdf->Cell($w - 0.55, 0.06, strtoupper($headerSubtitlePdf), 0, 1, 'C'); if ($mottoPdf !== '') { - $pdf->SetFont('Helvetica', 'B', 5.2); + $pdf->SetFont('Helvetica', 'B', 5.6); $pdf->SetTextColor($greenAccent[0], $greenAccent[1], $greenAccent[2]); $pdf->SetXY($x + 0.45, $y + 0.40); $pdf->Cell($w - 0.55, 0.06, strtoupper($mottoPdf), 0, 1, 'C'); } $bodyTop = $y + $headerH + 0.02; - $pdf->SetTextColor(21, 81, 44); - $pdf->SetFont('Helvetica', 'B', 10.2); - $pdf->SetXY($x + 0.07, $bodyTop); - $pdf->Cell($w - 0.14, 0.11, strtoupper($fullName), 0, 1, 'C'); + $nameBottomY = $this->fitCenteredText( + $pdf, + strtoupper($fullName), + $x + 0.07, + $bodyTop, + $w - 0.14, + 0.11, + 13.2, + 7.0, + [18, 18, 18], + 'B' + ); - $pdf->SetTextColor($blueRole[0], $blueRole[1], $blueRole[2]); + $pdf->SetTextColor(18, 18, 18); $pdf->SetFont('Helvetica', 'B', 6); - $pdf->SetX($x + 0.07); + $roleTop = $nameBottomY + 0.01; + $pdf->SetXY($x + 0.07, $roleTop); $pdf->Cell($w - 0.14, 0.07, strtoupper($rolePdf), 0, 1, 'C'); - $ruleY = $bodyTop + 0.20; + $ruleY = $roleTop + 0.10; $pdf->SetDrawColor(210, 214, 210); $pdf->Line($x + 0.10, $ruleY, $x + 0.58, $ruleY); $pdf->Line($x + $w - 0.58, $ruleY, $x + $w - 0.10, $ruleY); @@ -1080,7 +1370,7 @@ CSS; $pdf->Cell($barsW, 0.05, $barcodeText, 0, 0, 'C'); $fy = $y + $h - $footerH; - $pdf->SetFillColor($greenDark[0], $greenDark[1], $greenDark[2]); + $pdf->SetFillColor($green[0], $green[1], $green[2]); $pdf->Rect($x, $fy, $w, $footerH, 'F'); if ($logoPath !== null && is_readable($logoPath)) { @@ -1119,8 +1409,9 @@ CSS; float $height ): void { $seed = $value !== '' ? strtoupper($value) : 'ID0000'; - $cursor = $x; $gap = 0.01; + $segments = []; + $usedWidth = 0.0; foreach (str_split($seed) as $char) { $width = match (ord($char) % 4) { @@ -1130,20 +1421,38 @@ CSS; default => 0.025, }; - if (($cursor + $width) > ($x + $maxWidth)) { + $barWidth = $width; + $thinBarWidth = 0.01; + $nextWidth = ($segments === [] ? 0.0 : $gap) + $barWidth + $gap + $thinBarWidth; + + if (($usedWidth + $nextWidth) > $maxWidth) { break; } + if ($segments !== []) { + $segments[] = ['gap', $gap]; + $usedWidth += $gap; + } + + $segments[] = ['bar', $barWidth]; + $usedWidth += $barWidth; + $segments[] = ['gap', $gap]; + $usedWidth += $gap; + $segments[] = ['bar', $thinBarWidth]; + $usedWidth += $thinBarWidth; + } + + $cursor = $x + max(($maxWidth - $usedWidth) / 2, 0.0); + + foreach ($segments as [$type, $segmentWidth]) { + if ($type === 'gap') { + $cursor += $segmentWidth; + continue; + } + $pdf->SetFillColor(17, 17, 17); - $pdf->Rect($cursor, $y, $width, $height, 'F'); - $cursor += $width + $gap; - - if (($cursor + 0.01) > ($x + $maxWidth)) { - break; - } - - $pdf->Rect($cursor, $y, 0.01, $height, 'F'); - $cursor += 0.01 + $gap; + $pdf->Rect($cursor, $y, $segmentWidth, $height, 'F'); + $cursor += $segmentWidth; } } @@ -1238,6 +1547,34 @@ CSS; return 'data:image/png;base64,' . base64_encode($bytes); } + + protected function headerMainTitle(string $schoolName): string + { + $name = trim($schoolName) !== '' ? trim($schoolName) : 'Al Rahma Sunday School'; + $normalized = preg_replace('/\s+/', ' ', $name) ?: $name; + + if (preg_match('/^(.*?)(\s+Sunday\s+School)$/i', $normalized, $matches)) { + return trim($matches[1]); + } + + return $normalized; + } + + protected function headerSubtitle(string $schoolName): string + { + $configured = trim((string) config('badges.header_subtitle', '')); + if ($configured !== '') { + return $configured; + } + + $name = trim($schoolName) !== '' ? trim($schoolName) : 'Al Rahma Sunday School'; + if (preg_match('/Sunday\s+School/i', $name)) { + return 'Sunday School'; + } + + return 'High School'; + } + protected function footerBlock(string $schoolName): string { $address = trim((string) config('badges.footer_address', '')); diff --git a/docs/school_year_closure_plan.md b/docs/school_year_closure_plan.md new file mode 100644 index 00000000..c7b70502 --- /dev/null +++ b/docs/school_year_closure_plan.md @@ -0,0 +1,1261 @@ +# School Year Closure and New Year Rollover Implementation Plan + +## 1. Objective + +Build a controlled school-year closure feature that allows an authorized administrator to close the current school year, make it read-only, create a new active school year, promote students, and transfer unpaid balances to the new school year based on `parent_id`. + +The old school year must remain viewable through a school-year selector, but it must not allow editing, deleting, importing, payment posting, attendance changes, grade changes, enrollment changes, or invoice modifications. + +Unpaid balances must be transferred at the parent level, not the student level, because one parent may be responsible for multiple students. + +--- + +## 2. Core Business Rules + +1. Only one school year can be active at a time. +2. Closed school years are read-only. +3. Every academic and financial record must be linked to a `school_year_id`. +4. Historical records must not be overwritten during year closure. +5. Student promotion must create new enrollments for the new year. +6. Old enrollments must remain unchanged. +7. Unpaid balances must be calculated by `parent_id`. +8. Transferred balances must be traceable to the original year and original invoices. +9. The closure process must run inside a database transaction. +10. If one closure step fails, the entire operation must roll back. +11. Only authorized users can close, reopen, or archive school years. +12. Duplicate balance transfers must be prevented. +13. Payments after closure should be posted in the new active year against the transferred balance invoice. + +--- + +## 3. Required Data Model + +### 3.1 `school_years` + +```sql +CREATE TABLE school_years ( + id BIGINT PRIMARY KEY, + name VARCHAR(50) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + status VARCHAR(20) NOT NULL, + is_current BOOLEAN DEFAULT FALSE, + closed_at TIMESTAMP NULL, + closed_by BIGINT NULL, + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +Recommended statuses: + +```text +draft = created but not active yet +active = current editable school year +closed = read-only historical school year +archived = old read-only year, hidden from default views if needed +``` + +There must be a database or application-level rule preventing more than one active/current school year. + +--- + +### 3.2 `student_enrollments` + +Students should not store their current grade/class as the only source of truth. Enrollment must be year-based. + +```sql +CREATE TABLE student_enrollments ( + id BIGINT PRIMARY KEY, + student_id BIGINT NOT NULL, + school_year_id BIGINT NOT NULL, + grade_id BIGINT NOT NULL, + class_id BIGINT NULL, + status VARCHAR(30) NOT NULL, + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +Recommended enrollment statuses: + +```text +enrolled +promoted +repeated +graduated +transferred +withdrawn +``` + +This allows the same student to have a clean academic history across multiple school years. + +--- + +### 3.3 `parent_accounts` + +Each parent should have one account per school year. + +```sql +CREATE TABLE parent_accounts ( + id BIGINT PRIMARY KEY, + parent_id BIGINT NOT NULL, + school_year_id BIGINT NOT NULL, + opening_balance DECIMAL(12,2) DEFAULT 0, + current_balance DECIMAL(12,2) DEFAULT 0, + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +Recommended unique constraint: + +```sql +ALTER TABLE parent_accounts +ADD CONSTRAINT unique_parent_account_per_year +UNIQUE (parent_id, school_year_id); +``` + +--- + +### 3.4 `invoices` + +Invoices must belong to a school year and a parent. `student_id` should be nullable for parent-level balances. + +```sql +CREATE TABLE invoices ( + id BIGINT PRIMARY KEY, + parent_id BIGINT NOT NULL, + student_id BIGINT NULL, + school_year_id BIGINT NOT NULL, + amount DECIMAL(12,2) NOT NULL, + paid_amount DECIMAL(12,2) DEFAULT 0, + balance_due DECIMAL(12,2) NOT NULL, + status VARCHAR(30) NOT NULL, + invoice_type VARCHAR(50), + description TEXT NULL, + balance_transfer_id BIGINT NULL, + created_at TIMESTAMP, + updated_at TIMESTAMP +); +``` + +Recommended invoice statuses: + +```text +unpaid +partially_paid +paid +cancelled +void +``` + +Recommended invoice types: + +```text +tuition +transport +books +registration +old_balance +adjustment +``` + +--- + +### 3.5 `parent_balance_transfers` + +This table records unpaid balances transferred from one school year to another. + +```sql +CREATE TABLE parent_balance_transfers ( + id BIGINT PRIMARY KEY, + parent_id BIGINT NOT NULL, + from_school_year_id BIGINT NOT NULL, + to_school_year_id BIGINT NOT NULL, + amount DECIMAL(12,2) NOT NULL, + status VARCHAR(30) NOT NULL, + source_summary JSON NULL, + created_by BIGINT NOT NULL, + created_at TIMESTAMP +); +``` + +Recommended statuses: + +```text +pending +transferred +reversed +``` + +Prevent duplicate transfers: + +```sql +ALTER TABLE parent_balance_transfers +ADD CONSTRAINT unique_parent_year_balance_transfer +UNIQUE (parent_id, from_school_year_id, to_school_year_id); +``` + +Example `source_summary`: + +```json +{ + "unpaid_invoice_ids": [101, 102, 140], + "total_due": 1200.00, + "total_paid": 800.00, + "remaining_balance": 400.00 +} +``` + +--- + +### 3.6 `audit_logs` + +```sql +CREATE TABLE audit_logs ( + id BIGINT PRIMARY KEY, + user_id BIGINT NOT NULL, + action VARCHAR(100) NOT NULL, + table_name VARCHAR(100) NULL, + record_id BIGINT NULL, + old_value JSON NULL, + new_value JSON NULL, + metadata JSON NULL, + created_at TIMESTAMP +); +``` + +Audit logs must record year closure, balance transfers, reopening, student promotion, and old-balance invoice creation. + +--- + +## 4. Close-Year Workflow + +The administrator should close a school year through a guided wizard. + +### Step 1: Select Current Year + +The system identifies the active school year. + +Example: + +```text +Current school year: 2024-2025 +Status: Active +``` + +Only an active school year can be closed. + +--- + +### Step 2: Enter New School Year Details + +Admin enters: + +```text +New school year name +Start date +End date +``` + +Example: + +```text +Name: 2025-2026 +Start date: 2025-09-01 +End date: 2026-06-30 +``` + +Validation rules: + +1. Name must be unique. +2. Start date must be before end date. +3. New year must not overlap an existing year. +4. There must not already be another active year. + +--- + +### Step 3: Student Promotion Preview + +The system previews what will happen to students. + +Possible actions: + +```text +Promote to next grade +Repeat same grade +Graduate +Transfer out +Withdraw +Hold for manual review +``` + +The preview should show: + +```text +Student name +Current grade +Current class +Promotion action +Target grade +Target class +Warnings +``` + +The system must block closure if students are missing required promotion decisions. + +--- + +### Step 4: Financial Preview + +The system calculates unpaid balances grouped by `parent_id`. + +Preview columns: + +```text +Parent ID +Parent name +Students under parent +Unpaid invoice count +Old unpaid balance +Credit balance, if any +Amount to transfer +Transfer status +``` + +Example: + +```text +Parent ID: P-102 +Parent: Mohamed Ali +Students: Sara Ali, Adam Ali +Old balance: 450.00 +Transfer to new year: Yes +``` + +This preview is critical. Closing a school year without a financial preview is how systems create accounting disasters with a user interface. + +--- + +### Step 5: Final Confirmation + +Require strong confirmation text. + +Example: + +```text +Type CLOSE 2024-2025 to confirm. +``` + +The confirmation screen should clearly state: + +```text +The old school year will become read-only. +A new school year will become active. +Students will be promoted based on the preview. +Unpaid parent balances will be transferred to the new year. +This action will be logged. +``` + +--- + +## 5. Pre-Closure Validation + +Before closure, validate the following: + +```text +No unposted payments +No unresolved payment allocations +No invalid or duplicate parent accounts +No active year conflict +No overlapping new school year dates +No students missing promotion decisions +No draft invoices that should be posted +No financial inconsistencies between invoice amount, paid amount, and balance due +Closing user has the required permission +``` + +Example validation response: + +```json +{ + "can_close": false, + "errors": [ + "12 payments are not allocated.", + "3 students have no promotion decision.", + "New school year overlaps with 2024-2025." + ] +} +``` + +Closure must be blocked if critical errors exist. + +--- + +## 6. Read-Only Enforcement + +Closed years must be protected in both the frontend and backend. + +### 6.1 Backend Guard + +Every create, update, or delete endpoint touching year-based records must check the school-year status. + +```js +async function assertSchoolYearEditable(schoolYearId) { + const schoolYear = await SchoolYear.findById(schoolYearId); + + if (!schoolYear) { + throw new Error("School year not found."); + } + + if (schoolYear.status !== "active") { + throw new Error("This school year is closed and read-only."); + } +} +``` + +Apply the guard to: + +```text +attendance +grades +exams +invoices +payments +payment allocations +student enrollments +class assignments +teacher assignments +fee structures +discipline records +imports +bulk updates +``` + +Backend enforcement is mandatory. Frontend-only protection is not security. + +--- + +### 6.2 Frontend Read-Only Mode + +When the selected school year is not active: + +```js +const isReadOnly = selectedSchoolYear.status !== "active"; +``` + +Disable: + +```text +Add +Edit +Delete +Import +Submit +Generate invoices +Record payments +Change class +Promote student +Bulk update +``` + +Show a clear banner: + +```text +Viewing school year 2024-2025. Read-only mode. +``` + +--- + +## 7. School-Year Selector + +Add a global school-year selector in the application header. + +Example: + +```text +School Year: [2025-2026 Current ▼] +``` + +Dropdown options: + +```text +2025-2026 Current +2024-2025 Closed +2023-2024 Archived +``` + +Every page must filter by the selected `school_year_id`. + +Example: + +```sql +SELECT * FROM student_enrollments +WHERE school_year_id = :selectedSchoolYearId; +``` + +Never mix data across years unless the report specifically requires it. + +--- + +## 8. Parent Balance Transfer Logic + +### 8.1 Rule + +At closure, unpaid balances must be calculated by `parent_id`. + +Formula: + +```text +Parent unpaid balance = SUM(invoice.balance_due) +WHERE invoice.parent_id = parent_id +AND invoice.school_year_id = old_school_year_id +AND invoice.status IN ('unpaid', 'partially_paid') +AND invoice.balance_due > 0 +``` + +SQL: + +```sql +SELECT + parent_id, + SUM(balance_due) AS unpaid_balance +FROM invoices +WHERE school_year_id = :oldSchoolYearId + AND status IN ('unpaid', 'partially_paid') + AND balance_due > 0 +GROUP BY parent_id; +``` + +--- + +### 8.2 Transfer Process + +For each parent with unpaid balance: + +1. Calculate total unpaid balance by `parent_id`. +2. Collect source unpaid invoice IDs. +3. Create or update the new-year parent account. +4. Set the transferred amount as the new-year opening balance. +5. Create a new-year invoice with `invoice_type = 'old_balance'`. +6. Create a `parent_balance_transfers` record. +7. Link the new old-balance invoice to the transfer record. +8. Keep old invoices unchanged and locked in the closed year. + +--- + +### 8.3 Recommended Handling + +Use both: + +```text +parent_accounts.opening_balance +``` + +and: + +```text +invoices.invoice_type = old_balance +``` + +But do not double-count them. + +Recommended rule: + +```text +opening_balance = informational and audit value +old_balance invoice = collectible amount +current_balance = sum(new-year invoices) - sum(new-year payments) +``` + +The old-balance invoice is what parents pay against in the new year. + +--- + +### 8.4 Old-Balance Invoice Example + +```sql +INSERT INTO invoices ( + parent_id, + student_id, + school_year_id, + amount, + paid_amount, + balance_due, + status, + invoice_type, + description, + balance_transfer_id, + created_at, + updated_at +) +VALUES ( + :parentId, + NULL, + :newSchoolYearId, + :unpaidBalance, + 0, + :unpaidBalance, + 'unpaid', + 'old_balance', + 'Previous year unpaid balance transferred to new school year', + :balanceTransferId, + NOW(), + NOW() +); +``` + +`student_id` should be `NULL` because this is a parent-level debt. + +--- + +## 9. Handling Credit Balances + +If a parent overpaid in the old year, the system should transfer the credit to the new year. + +Example: + +```text +Old year balance: -100.00 +New year opening credit: -100.00 +``` + +The credit can reduce future invoices. + +Credit transfer logic should be handled separately from unpaid balance transfer. + +Recommended behavior: + +```text +Positive balance = amount parent owes +Negative balance = credit parent owns +Zero balance = no transfer +``` + +--- + +## 10. Closure Transaction + +The entire close-year process must be atomic. + +Pseudo-code: + +```js +async function closeSchoolYear({ + oldSchoolYearId, + newSchoolYearData, + promotionRules, + adminUserId +}) { + return await db.transaction(async (trx) => { + const oldYear = await SchoolYear.findById(oldSchoolYearId, trx); + + if (!oldYear || oldYear.status !== "active") { + throw new Error("Only the active school year can be closed."); + } + + await validateYearCanBeClosed(oldSchoolYearId, trx); + + const newYear = await SchoolYear.create({ + name: newSchoolYearData.name, + start_date: newSchoolYearData.startDate, + end_date: newSchoolYearData.endDate, + status: "active", + is_current: true + }, trx); + + await SchoolYear.update(oldSchoolYearId, { + status: "closed", + is_current: false, + closed_at: new Date(), + closed_by: adminUserId + }, trx); + + await promoteStudents({ + fromSchoolYearId: oldSchoolYearId, + toSchoolYearId: newYear.id, + promotionRules, + trx + }); + + await transferParentBalances({ + fromSchoolYearId: oldSchoolYearId, + toSchoolYearId: newYear.id, + adminUserId, + trx + }); + + await copyReusableAcademicSettings({ + fromSchoolYearId: oldSchoolYearId, + toSchoolYearId: newYear.id, + trx + }); + + await AuditLog.create({ + user_id: adminUserId, + action: "CLOSE_SCHOOL_YEAR", + record_id: oldSchoolYearId, + metadata: { + from_school_year_id: oldSchoolYearId, + to_school_year_id: newYear.id + } + }, trx); + + return newYear; + }); +} +``` + +If anything fails, rollback everything. + +--- + +## 11. Parent Balance Transfer Function + +Example using a Knex-like query builder: + +```js +async function transferParentBalances({ + fromSchoolYearId, + toSchoolYearId, + adminUserId, + trx +}) { + const balances = await trx("invoices") + .select("parent_id") + .sum("balance_due as unpaid_balance") + .where("school_year_id", fromSchoolYearId) + .whereIn("status", ["unpaid", "partially_paid"]) + .where("balance_due", ">", 0) + .groupBy("parent_id"); + + for (const row of balances) { + const parentId = row.parent_id; + const unpaidBalance = Number(row.unpaid_balance); + + if (unpaidBalance <= 0) continue; + + const existingTransfer = await trx("parent_balance_transfers") + .where({ + parent_id: parentId, + from_school_year_id: fromSchoolYearId, + to_school_year_id: toSchoolYearId + }) + .first(); + + if (existingTransfer) { + throw new Error(`Balance already transferred for parent ${parentId}.`); + } + + const sourceInvoices = await trx("invoices") + .select("id", "amount", "paid_amount", "balance_due") + .where("school_year_id", fromSchoolYearId) + .where("parent_id", parentId) + .whereIn("status", ["unpaid", "partially_paid"]) + .where("balance_due", ">", 0); + + let parentAccount = await trx("parent_accounts") + .where({ + parent_id: parentId, + school_year_id: toSchoolYearId + }) + .first(); + + if (!parentAccount) { + const insertedAccounts = await trx("parent_accounts") + .insert({ + parent_id: parentId, + school_year_id: toSchoolYearId, + opening_balance: unpaidBalance, + current_balance: unpaidBalance, + created_at: new Date(), + updated_at: new Date() + }) + .returning("*"); + + parentAccount = insertedAccounts[0]; + } else { + await trx("parent_accounts") + .where({ id: parentAccount.id }) + .update({ + opening_balance: trx.raw("opening_balance + ?", [unpaidBalance]), + current_balance: trx.raw("current_balance + ?", [unpaidBalance]), + updated_at: new Date() + }); + } + + const insertedTransfers = await trx("parent_balance_transfers") + .insert({ + parent_id: parentId, + from_school_year_id: fromSchoolYearId, + to_school_year_id: toSchoolYearId, + amount: unpaidBalance, + status: "transferred", + source_summary: JSON.stringify({ + unpaid_invoice_ids: sourceInvoices.map(invoice => invoice.id), + invoices: sourceInvoices, + remaining_balance: unpaidBalance + }), + created_by: adminUserId, + created_at: new Date() + }) + .returning("*"); + + const transfer = insertedTransfers[0]; + + await trx("invoices").insert({ + parent_id: parentId, + student_id: null, + school_year_id: toSchoolYearId, + amount: unpaidBalance, + paid_amount: 0, + balance_due: unpaidBalance, + status: "unpaid", + invoice_type: "old_balance", + description: `Previous year unpaid balance transferred from school year ${fromSchoolYearId}`, + balance_transfer_id: transfer.id, + created_at: new Date(), + updated_at: new Date() + }); + } +} +``` + +--- + +## 12. Payments After Closure + +Recommended rule: + +```text +Payments for closed years cannot be posted directly to the closed year. +Payments must be posted in the active year against the transferred old-balance invoice. +``` + +Example: + +```text +Old year unpaid balance: 400.00 +New year old-balance invoice: 400.00 +Parent pays: 100.00 +Remaining old-balance invoice: 300.00 +``` + +The old year remains historically accurate and read-only. + +--- + +## 13. API Endpoints + +Recommended endpoints: + +```text +GET /api/school-years +GET /api/school-years/:id/summary +POST /api/school-years/:id/validate-close +POST /api/school-years/:id/preview-close +POST /api/school-years/:id/close +POST /api/school-years/:id/reopen +GET /api/school-years/:id/parent-balances +GET /api/school-years/:id/promotion-preview +``` + +Example close request: + +```json +{ + "new_school_year": { + "name": "2025-2026", + "start_date": "2025-09-01", + "end_date": "2026-06-30" + }, + "promotion_rules": { + "default_action": "promote", + "exceptions": [ + { + "student_id": 104, + "action": "repeat", + "target_grade_id": 3 + }, + { + "student_id": 220, + "action": "graduate" + } + ] + }, + "transfer_unpaid_balances": true +} +``` + +--- + +## 14. Permissions + +Recommended permissions: + +```text +school_year.view +school_year.close +school_year.reopen +school_year.archive +school_year.view_closed +finance.transfer_balances +finance.view_parent_balances +``` + +Recommended roles allowed to close years: + +```text +Super Admin +Director +Principal +Finance Admin +``` + +Teachers and normal staff should be allowed to view closed years only if their role permits it. + +--- + +## 15. Reports + +### 15.1 Closing Report + +Should include: + +```text +Closed school year +New school year +Closed by +Closed at +Total students promoted +Total students repeated +Total students graduated +Total students withdrawn/transferred +Total unpaid balance transferred +Total credit transferred +Number of parents with transferred balances +``` + +--- + +### 15.2 Parent Balance Transfer Report + +Should include: + +```text +Parent ID +Parent name +Old school year +New school year +Old unpaid amount +New old-balance invoice ID +Transfer date +Transfer status +Source invoice IDs +``` + +--- + +### 15.3 Parent Statement + +When viewing a parent in the new year, show: + +```text +Opening balance from previous school year +New year invoices +Payments +Credits +Current balance +``` + +Example: + +```text +Opening balance from 2024-2025: 400.00 +New year tuition: 1,000.00 +Payments: 250.00 +Current balance: 1,150.00 +``` + +--- + +## 16. Edge Cases + +### Parent Has Multiple Students + +Group all unpaid balances by parent. + +Correct: + +```text +Student A unpaid: 200.00 +Student B unpaid: 300.00 +Parent transferred balance: 500.00 +``` + +--- + +### Parent Has Credit Balance + +Transfer credit to the new year. + +```text +Old year balance: -100.00 +New year opening credit: -100.00 +``` + +--- + +### Parent Has Zero Balance + +Do not create a transfer. + +--- + +### Student Is Not Continuing + +The parent balance still transfers if the parent owes money. + +The debt belongs to the parent account, not to whether the child continues enrollment. + +--- + +### Payment Happens During Closure + +The system should temporarily block payment posting during the closure transaction. + +Recommended protected operations during closure: + +```text +payment posting +invoice generation +enrollment changes +student promotion changes +fee changes +bulk imports +``` + +--- + +### Closure Fails Halfway + +Rollback everything. + +There must be no partial new year, half-promoted students, or duplicated transferred invoices. + +--- + +## 17. Implementation Phases + +### Phase 1: Data Model Preparation + +Deliverables: + +```text +school_years table +student_enrollments table +parent_accounts table +parent_balance_transfers table +balance_transfer_id on invoices +school_year_id added to all academic and financial records +``` + +Acceptance criteria: + +```text +All year-based records can be filtered by school_year_id +Only one active year exists +Parent balances can be calculated by parent_id and school_year_id +``` + +--- + +### Phase 2: Read-Only Enforcement + +Deliverables: + +```text +Backend school-year edit guard +Frontend read-only mode +Global school-year selector +Closed-year banner +Permission checks +``` + +Acceptance criteria: + +```text +Users can view closed years +Users cannot modify closed-year records +API rejects closed-year write requests +UI disables editing for closed years +``` + +--- + +### Phase 3: Close-Year Preview + +Deliverables: + +```text +Close-year validation endpoint +Promotion preview +Parent unpaid balance preview +Warnings and blockers +``` + +Acceptance criteria: + +```text +Admin sees students to promote, repeat, graduate, or withdraw +Admin sees unpaid balances grouped by parent_id +System blocks closure if critical validation fails +``` + +--- + +### Phase 4: Closure Execution + +Deliverables: + +```text +Transactional close-year service +New-year creation +Student promotion +Reusable settings copy +Parent balance transfer +Old-balance invoice creation +Audit logs +``` + +Acceptance criteria: + +```text +Old year becomes closed/read-only +New year becomes active +Student enrollments are created for the new year +Parent unpaid balances appear in the new year +Old-balance invoices are traceable to transfer records +Duplicate transfers are blocked +``` + +--- + +### Phase 5: Reports and Reconciliation + +Deliverables: + +```text +Closing report +Parent balance transfer report +Parent statement update +Finance dashboard update +``` + +Acceptance criteria: + +```text +Finance can reconcile old unpaid balances against transferred balances +Parent accounts show previous-year balances clearly +No double-counting occurs +No duplicate transfer records exist +``` + +--- + +## 18. Testing Plan + +### 18.1 Unit Tests + +Test: + +```text +Only one active year is allowed +Closed year rejects edits +Unpaid invoices are grouped by parent_id +Old-balance invoice is created correctly +Duplicate transfers are blocked +Credit balances are handled correctly +Zero balances are ignored +Student promotion creates new enrollments +Old enrollments remain unchanged +``` + +--- + +### 18.2 Integration Tests + +Test full closure: + +```text +Create active school year +Create students and parents +Create invoices +Add partial payments +Close school year +Verify old year is read-only +Verify new year is active +Verify students are promoted +Verify parent balances are transferred +Verify old-balance invoices are created +Verify audit logs are created +``` + +--- + +### 18.3 Financial Test Example + +Before closure: + +```text +Parent 1: +Invoice A: 500.00, paid 300.00, balance 200.00 +Invoice B: 400.00, paid 0.00, balance 400.00 + +Parent 2: +Invoice C: 600.00, paid 600.00, balance 0.00 + +Parent 3: +Invoice D: 300.00, paid 350.00, balance -50.00 +``` + +Expected after closure: + +```text +Parent 1 new year old_balance invoice: 600.00 +Parent 2 no transfer +Parent 3 new year credit: -50.00 +``` + +--- + +## 19. Final Expected Behavior + +When the school year is closed: + +```text +Old school year: +- status becomes closed +- all records become read-only +- unpaid invoices remain historically visible +- financial records are locked + +New school year: +- status becomes active +- promoted students receive new enrollments +- parent accounts are created or updated +- unpaid balances from the old year are transferred by parent_id +- old-balance invoices are created for collection +- transferred balances are traceable to the original invoices +``` + +The key architecture rule is simple: + +```text +Academic history belongs to school_year_id. +Financial responsibility belongs to parent_id. +Debt transfer must be parent-level, traceable, and protected against duplication. +``` + +This gives the school a clean, auditable, and safe year-closure process instead of the usual manual spreadsheet ritual, which is basically accounting with a blindfold. diff --git a/public/admin_template.png b/public/admin_template.png new file mode 100644 index 00000000..7605f2b4 Binary files /dev/null and b/public/admin_template.png differ diff --git a/public/student_template.png b/public/student_template.png new file mode 100644 index 00000000..aefa8b5f Binary files /dev/null and b/public/student_template.png differ diff --git a/public/teacher_template.png b/public/teacher_template.png new file mode 100644 index 00000000..455f1da9 Binary files /dev/null and b/public/teacher_template.png differ