add controllers, servoices

This commit is contained in:
root
2026-03-09 02:52:13 -04:00
parent c8de5f7edc
commit d76c871cb7
501 changed files with 34439 additions and 21843 deletions
@@ -23,8 +23,6 @@ class AdministratorEnrollmentStatusService
$schoolYear = $this->shared->getSchoolYear();
$semester = $this->shared->getSemester();
DB::beginTransaction();
try {
if (empty($enrollmentStatuses)) {
return [
@@ -34,6 +32,8 @@ class AdministratorEnrollmentStatusService
];
}
DB::beginTransaction();
$errors = [];
$groupsByParentStatus = [];
$parentInfo = [];
@@ -270,4 +270,4 @@ class AdministratorEnrollmentStatusService
'lastname' => $parentData['lastname'] ?? '',
];
}
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Services\Assignment;
use App\Models\Configuration;
class AssignmentContextService
{
public function __construct(private Configuration $configuration) {}
public function getCurrentSemester(): string
{
return (string) ($this->getConfigValue('semester') ?? '');
}
public function getCurrentSchoolYear(): string
{
return (string) ($this->getConfigValue('school_year') ?? '');
}
private function getConfigValue(string $key): mixed
{
return $this->configuration
->newQuery()
->where('config_key', $key)
->value('config_value');
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Services\Assignment;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use Illuminate\Support\Collection;
class AssignmentDataLoaderService
{
public function __construct(
private TeacherClass $teacherClass,
private StudentClass $studentClass
) {}
public function loadTeacherClasses(?string $schoolYear = null): Collection
{
return $this->teacherClass
->newQuery()
->with([
'teacher:id,firstname,lastname',
])
->when(filled($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
->get()
->map(function ($row) {
$row->teacher_full_name = trim(
((string) optional($row->teacher)->firstname) . ' ' .
((string) optional($row->teacher)->lastname)
);
return $row;
});
}
public function loadStudentClasses(?string $schoolYear = null): Collection
{
return $this->studentClass
->newQuery()
->with([
'student:id,firstname,lastname,age,gender,registration_grade,photo_consent,tuition_paid,school_id,is_active',
])
->whereHas('student', fn ($q) => $q->where('is_active', 1))
->when(filled($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
->get();
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Services\Assignment;
use Illuminate\Support\Facades\DB;
class AssignmentMetaService
{
public function getSchoolYears(string $fallbackYear = ''): array
{
$years = DB::table('teacher_class')
->selectRaw('DISTINCT school_year')
->whereNotNull('school_year')
->orderByDesc('school_year')
->pluck('school_year')
->map(fn ($year) => (string) $year)
->filter()
->values()
->all();
if (empty($years) && $fallbackYear !== '') {
$years[] = $fallbackYear;
}
return $years;
}
public function firstNonEmpty(array $values): mixed
{
foreach ($values as $value) {
if (filled($value)) {
return $value;
}
}
return null;
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Services\Assignment;
use App\Models\ClassSection;
class AssignmentSectionService
{
public function __construct(private ClassSection $classSection) {}
public function getSectionNamesMap(array $sectionIds): array
{
if (empty($sectionIds)) {
return [];
}
return $this->classSection
->newQuery()
->whereIn('id', $sectionIds)
->get()
->mapWithKeys(function ($section) {
$name = $section->class_section_name ?? $section->section_name ?? $section->name ?? '';
return [(int) $section->id => (string) $name];
})
->all();
}
}
+19 -109
View File
@@ -2,38 +2,26 @@
namespace App\Services\Assignment;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\StudentClass;
use App\Models\TeacherClass;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class AssignmentService
{
public function __construct(
protected Configuration $configurationModel,
protected TeacherClass $teacherClassModel,
protected StudentClass $studentClassModel,
protected ClassSection $classSectionModel,
protected AssignmentContextService $contextService,
protected AssignmentDataLoaderService $dataLoader,
protected AssignmentSectionService $sectionService,
protected AssignmentMetaService $metaService
) {}
public function getCurrentSemester(): string
{
return (string) ($this->getConfigValue('semester') ?? '');
return $this->contextService->getCurrentSemester();
}
public function getCurrentSchoolYear(): string
{
return (string) ($this->getConfigValue('school_year') ?? '');
}
protected function getConfigValue(string $key): mixed
{
return $this->configurationModel
->newQuery()
->where('config_key', $key)
->value('config_value');
return $this->contextService->getCurrentSchoolYear();
}
public function getAssignmentsOverview(?string $schoolYear = null, ?string $semester = null): array
@@ -44,8 +32,8 @@ class AssignmentService
$selectedSemester = (string) ($semester ?? $currentSemester);
$selectedYear = (string) ($schoolYear ?? $currentSchoolYear);
$teacherClasses = $this->loadTeacherClasses($selectedYear);
$studentClasses = $this->loadStudentClasses($selectedYear);
$teacherClasses = $this->dataLoader->loadTeacherClasses($selectedYear);
$studentClasses = $this->dataLoader->loadStudentClasses($selectedYear);
$teacherBySection = $teacherClasses->groupBy('class_section_id');
$studentBySection = $studentClasses->groupBy('class_section_id');
@@ -56,7 +44,7 @@ class AssignmentService
->filter()
->values();
$sectionNames = $this->getSectionNamesMap($allSectionIds->all());
$sectionNames = $this->sectionService->getSectionNamesMap($allSectionIds->all());
$classSections = $allSectionIds->map(function ($sectionId) use (
$teacherBySection,
@@ -86,19 +74,19 @@ class AssignmentService
->unique()
->values();
$semesterMeta = $this->firstNonEmpty([
$semesterMeta = $this->metaService->firstNonEmpty([
$teacherRows->pluck('semester')->first(fn ($v) => filled($v)),
$studentRows->pluck('semester')->first(fn ($v) => filled($v)),
$currentSemester,
]);
$schoolYearMeta = $this->firstNonEmpty([
$schoolYearMeta = $this->metaService->firstNonEmpty([
$teacherRows->pluck('school_year')->first(fn ($v) => filled($v)),
$studentRows->pluck('school_year')->first(fn ($v) => filled($v)),
$currentSchoolYear,
]);
$descriptionMeta = $this->firstNonEmpty([
$descriptionMeta = $this->metaService->firstNonEmpty([
$teacherRows->pluck('description')->first(fn ($v) => filled($v)),
$studentRows->pluck('description')->first(fn ($v) => filled($v)),
'',
@@ -141,7 +129,7 @@ class AssignmentService
return [
'classSections' => $classSections,
'schoolYears' => $this->getSchoolYears($currentSchoolYear),
'schoolYears' => $this->metaService->getSchoolYears($currentSchoolYear),
'schoolYear' => $selectedYear,
'selectedYear' => $selectedYear,
'selectedSemester' => $selectedSemester,
@@ -171,8 +159,8 @@ class AssignmentService
$currentSemester = $this->getCurrentSemester();
$currentSchoolYear = $this->getCurrentSchoolYear();
$teacherClasses = $this->loadTeacherClasses();
$studentClasses = $this->loadStudentClasses();
$teacherClasses = $this->dataLoader->loadTeacherClasses();
$studentClasses = $this->dataLoader->loadStudentClasses();
$teacherBySection = $teacherClasses->groupBy('class_section_id');
$studentBySection = $studentClasses->groupBy('class_section_id');
@@ -183,7 +171,7 @@ class AssignmentService
->filter()
->values();
$sectionNames = $this->getSectionNamesMap($allSectionIds->all());
$sectionNames = $this->sectionService->getSectionNamesMap($allSectionIds->all());
$classSections = $allSectionIds->map(function ($sectionId) use (
$teacherBySection,
@@ -213,19 +201,19 @@ class AssignmentService
->unique()
->values();
$semesterMeta = $this->firstNonEmpty([
$semesterMeta = $this->metaService->firstNonEmpty([
$teacherRows->pluck('semester')->first(fn ($v) => filled($v)),
$studentRows->pluck('semester')->first(fn ($v) => filled($v)),
$currentSemester,
]);
$schoolYearMeta = $this->firstNonEmpty([
$schoolYearMeta = $this->metaService->firstNonEmpty([
$teacherRows->pluck('school_year')->first(fn ($v) => filled($v)),
$studentRows->pluck('school_year')->first(fn ($v) => filled($v)),
$currentSchoolYear,
]);
$descriptionMeta = $this->firstNonEmpty([
$descriptionMeta = $this->metaService->firstNonEmpty([
$teacherRows->pluck('description')->first(fn ($v) => filled($v)),
$studentRows->pluck('description')->first(fn ($v) => filled($v)),
'',
@@ -272,82 +260,4 @@ class AssignmentService
'school_year' => $currentSchoolYear,
];
}
protected function loadTeacherClasses(?string $schoolYear = null): Collection
{
return $this->teacherClassModel
->newQuery()
->with([
'teacher:id,firstname,lastname',
])
->when(filled($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
->get()
->map(function ($row) {
$row->teacher_full_name = trim(
((string) optional($row->teacher)->firstname) . ' ' .
((string) optional($row->teacher)->lastname)
);
return $row;
});
}
protected function loadStudentClasses(?string $schoolYear = null): Collection
{
return $this->studentClassModel
->newQuery()
->with([
'student:id,firstname,lastname,age,gender,registration_grade,photo_consent,tuition_paid,school_id,is_active',
])
->whereHas('student', fn ($q) => $q->where('is_active', 1))
->when(filled($schoolYear), fn ($q) => $q->where('school_year', $schoolYear))
->get();
}
protected function getSectionNamesMap(array $sectionIds): array
{
if (empty($sectionIds)) {
return [];
}
return $this->classSectionModel
->newQuery()
->whereIn('id', $sectionIds)
->get()
->mapWithKeys(function ($section) {
$name = $section->section_name ?? $section->name ?? '';
return [(int) $section->id => (string) $name];
})
->all();
}
protected function getSchoolYears(string $fallbackYear = ''): array
{
$years = DB::table('teacher_class')
->selectRaw('DISTINCT school_year')
->whereNotNull('school_year')
->orderByDesc('school_year')
->pluck('school_year')
->map(fn ($year) => (string) $year)
->filter()
->values()
->all();
if (empty($years) && $fallbackYear !== '') {
$years[] = $fallbackYear;
}
return $years;
}
protected function firstNonEmpty(array $values): mixed
{
foreach ($values as $value) {
if (filled($value)) {
return $value;
}
}
return null;
}
}
@@ -2,179 +2,6 @@
namespace App\Services\AttendanceTracking;
use App\Models\AttendanceData;
use App\Models\AttendanceTracking;
use App\Models\Configuration;
use App\Models\Student;
use App\Models\StudentClass;
use Carbon\Carbon;
class AttendanceTrackingService
{
protected string $semester;
protected string $schoolYear;
public function __construct(
protected Student $studentModel,
protected StudentClass $studentClassModel,
protected AttendanceData $attendanceDataModel,
protected AttendanceTracking $attendanceTrackingModel,
protected Configuration $configModel,
protected AttendanceMailerService $attendanceMailerService,
protected ViolationRuleEngineService $violationRuleEngine,
protected AttendanceParentLookupService $parentLookupService,
protected AttendanceEmailComposerService $emailComposerService,
protected AttendanceNotificationLogService $notificationLogService,
protected AttendanceViolationStudentResolverService $studentResolverService,
protected AttendanceCaseQueryService $attendanceCaseQueryService,
protected AttendanceNotificationWorkflowService $attendanceNotificationWorkflowService,
protected AttendanceCommunicationSupportService $attendanceCommunicationSupportService,
protected AttendancePendingViolationService $attendancePendingViolationService
) {
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
}
public function getPendingViolations(?string $schoolYearParam = null, ?string $semesterParam = null): array
{
return $this->attendancePendingViolationService->getPendingViolations(
$this->schoolYear,
$schoolYearParam,
$semesterParam
);
}
public function getStudentCase(
int $studentId,
?string $codeParam = null,
?string $incidentDate = null,
bool $includeNotified = false,
bool $pendingOnly = false,
?string $schoolYearFilter = null,
?string $semesterFilter = null
): array {
return $this->attendanceCaseQueryService->getStudentCase(
$studentId,
$codeParam,
$incidentDate,
$includeNotified,
$pendingOnly,
$schoolYearFilter,
$semesterFilter,
$this->schoolYear,
$this->semester
);
}
public function getNotifiedViolations(?string $schoolYearParam = null, ?string $semesterParam = null): array
{
return $this->attendanceCaseQueryService->getNotifiedViolations(
$schoolYearParam,
$semesterParam,
$this->schoolYear,
$this->semester
);
}
public function compose(
int $studentId,
string $code = 'ABS_1',
string $variant = 'default',
?string $incidentDate = null
): array {
return $this->attendanceCommunicationSupportService->compose(
$studentId,
$code,
$variant,
$incidentDate
);
}
public function parentsInfo(int $studentId): array
{
return $this->attendanceCommunicationSupportService->parentsInfo($studentId);
}
public function record(array $data): array
{
return $this->attendanceNotificationWorkflowService->record($data);
}
public function sendAutoEmails(?string $variantOverride = null): array
{
$classStudentsQuery = $this->studentClassModel->query();
if (method_exists($this->studentClassModel, 'scopeActive')) {
$classStudentsQuery->active();
}
$classStudents = $classStudentsQuery
->where('school_year', $this->schoolYear)
->get()
->toArray();
if (empty($classStudents)) {
return [
'success' => true,
'sent' => 0,
'skipped' => 0,
'errors' => 0,
'details' => [],
];
}
$studentIds = array_column($classStudents, 'student_id');
$students = $this->studentModel->query()
->whereIn('id', $studentIds)
->where('is_active', 1)
->get()
->toArray();
$attendanceQuery = $this->attendanceDataModel->query()
->whereIn('student_id', $studentIds)
->where('school_year', $this->schoolYear)
->where('date', '>=', Carbon::today()->subWeeks(5)->format('Y-m-d'));
$this->violationRuleEngine->applyUnreportedAttendanceFilter(
$attendanceQuery,
$this->attendanceDataModel->getTable()
);
$attendanceData = $attendanceQuery
->whereRaw("LOWER(TRIM(status)) IN ('absent','late')")
->orderByDesc('date')
->get()
->toArray();
$violations = $this->violationRuleEngine->computeViolations(
$students,
$attendanceData,
$this->schoolYear,
null,
$attendanceData
);
return $this->attendanceNotificationWorkflowService->sendAutoEmails($violations, $variantOverride);
}
public function sendEmailManual(array $data): array
{
$sid = (int) $data['student_id'];
$code = (string) $data['code'];
$ymdRaw = (string) ($data['incident_date'] ?? '');
$ymd = $ymdRaw !== '' ? substr($ymdRaw, 0, 10) : '';
$violationDates = $this->attendanceCommunicationSupportService->getViolationDatesForStudent(
$sid,
$code,
$ymd
);
return $this->attendanceNotificationWorkflowService->sendEmailManual($data, $violationDates);
}
public function saveNotificationNote(array $data): array
{
return $this->attendanceNotificationWorkflowService->saveNotificationNote($data);
}
}
}
@@ -1,9 +0,0 @@
<?php
namespace App\Services;
use App\Services\AttendanceTracking\AttendanceTrackingService as CoreService;
class AttendanceTrackingService extends CoreService
{
}
@@ -0,0 +1,36 @@
<?php
namespace App\Services\Auth;
class RegistrationCaptchaService
{
public function generate(?int $length = null): string
{
$length = $length ?? random_int(4, 8);
$characters = 'ABCDEFGHJKLMNOPQRSTUVWXYZabcdefghjkmnopqrstuvwxyz0123456789';
$captchaText = '';
for ($i = 0; $i < $length; $i++) {
$captchaText .= $characters[random_int(0, strlen($characters) - 1)];
}
session()->put('captcha_answer', $captchaText);
return $captchaText;
}
public function verify(string $input): bool
{
$expected = (string) session()->get('captcha_answer', '');
if ($expected === '') {
return false;
}
return hash_equals($expected, $input);
}
public function clear(): void
{
session()->forget('captcha_answer');
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Services\Auth;
use App\Services\PhoneFormatterService;
class RegistrationFormatterService
{
public function __construct(private PhoneFormatterService $phoneFormatter)
{
}
public function format(array $payload): array
{
$g = static function (string $key) use ($payload): string {
return trim((string) ($payload[$key] ?? ''));
};
$data = [
'firstname' => $this->formatName($g('firstname')),
'lastname' => $this->formatName($g('lastname')),
'email' => $this->formatEmail($g('email')),
'gender' => $g('gender'),
'city' => $this->formatName($g('city')),
'cellphone' => $this->phoneFormatter->formatPhoneNumber($g('cellphone')),
'address_street' => $this->formatAddress($g('address_street')),
'apt' => strtoupper($g('apt')),
'state' => strtoupper($g('state')),
'zip' => $g('zip'),
'accept_school_policy' => (int) ($payload['accept_school_policy'] ?? 0),
];
$noSecondInfo = !empty($payload['no_second_parent_info']);
$secondFirstname = $this->formatName($g('second_firstname'));
$secondLastname = $this->formatName($g('second_lastname'));
$secondGender = $g('second_gender');
$secondEmail = $this->formatEmail($g('second_email'));
$secondCellphone = $this->phoneFormatter->formatPhoneNumber($g('second_cellphone'));
$secondProvided = !$noSecondInfo && (
$secondFirstname !== '' ||
$secondLastname !== '' ||
$secondGender !== '' ||
$secondEmail !== '' ||
preg_replace('/\D/', '', (string) $secondCellphone) !== ''
);
if ($secondProvided) {
$data['second_firstname'] = $secondFirstname;
$data['second_lastname'] = $secondLastname;
$data['second_gender'] = $secondGender;
$data['second_email'] = $secondEmail;
$data['second_cellphone'] = $secondCellphone;
}
return $data;
}
private function formatName(string $name): string
{
$name = trim($name);
if ($name === '') {
return '';
}
return ucwords(strtolower($name), " -");
}
private function formatEmail(string $email): string
{
return strtolower(trim($email));
}
private function formatAddress(string $address): string
{
$address = trim($address);
if ($address === '') {
return '';
}
return ucwords(strtolower($address));
}
}
+159
View File
@@ -0,0 +1,159 @@
<?php
namespace App\Services\Auth;
use App\Models\Configuration;
use App\Models\ParentModel;
use App\Models\Role;
use App\Models\User;
use App\Models\UserRole;
use App\Services\EmailService;
use App\Services\SchoolIdService;
use Illuminate\Support\Facades\DB;
class RegistrationService
{
public function __construct(
private EmailService $emailService,
private SchoolIdService $schoolIdService,
private RegistrationFormatterService $formatter,
private RegistrationCaptchaService $captchaService
) {
}
public function register(array $payload): array
{
$captcha = (string) ($payload['captcha'] ?? '');
if (!$this->captchaService->verify($captcha)) {
return [
'ok' => false,
'code' => 'captcha_invalid',
'message' => 'Incorrect CAPTCHA answer. Please try again.',
];
}
$isParent = !empty($payload['is_parent']);
$noSecondInfo = !empty($payload['no_second_parent_info']);
$formatted = $this->formatter->format($payload);
$email = (string) ($formatted['email'] ?? '');
$existing = User::query()->where('email', $email)->first();
if ($existing) {
$pending = !empty($existing->token) && (int) ($existing->is_verified ?? 0) === 0;
if ($pending) {
return [
'ok' => false,
'code' => 'pending_activation',
'message' => 'This email address is already registered and pending activation.',
];
}
return [
'ok' => false,
'code' => 'email_exists',
'message' => 'The email address you entered is already in use.',
];
}
$roleName = $isParent ? 'parent' : 'guest';
$role = Role::query()->where('name', $roleName)->first();
if (!$role) {
return [
'ok' => false,
'code' => 'role_missing',
'message' => 'Role not found.',
];
}
$token = bin2hex(random_bytes(48));
$schoolYear = Configuration::getConfig('school_year');
$semester = Configuration::getConfig('semester');
$userData = [
'firstname' => (string) ($formatted['firstname'] ?? ''),
'lastname' => (string) ($formatted['lastname'] ?? ''),
'gender' => (string) ($formatted['gender'] ?? ''),
'email' => $email,
'cellphone' => (string) ($formatted['cellphone'] ?? ($payload['cellphone'] ?? '')),
'address_street' => (string) ($formatted['address_street'] ?? ''),
'apt' => $formatted['apt'] ?? null,
'city' => (string) ($formatted['city'] ?? ''),
'state' => (string) ($formatted['state'] ?? ''),
'zip' => (string) ($formatted['zip'] ?? ''),
'token' => $token,
'is_verified' => 0,
'accept_school_policy' => (int) ($formatted['accept_school_policy'] ?? 0),
'status' => 'Inactive',
'school_id' => $this->schoolIdService->generateUserSchoolId(),
'semester' => (string) ($semester ?? ''),
'school_year' => $schoolYear,
'password' => pbkdf2_hash(bin2hex(random_bytes(8))),
];
$user = null;
DB::transaction(function () use (
$userData,
$role,
$isParent,
$noSecondInfo,
$formatted,
$schoolYear,
$semester,
&$user
) {
$user = User::query()->create($userData);
UserRole::query()->create([
'user_id' => (int) $user->id,
'role_id' => (int) $role->id,
'created_at' => now(),
]);
if ($isParent && !$noSecondInfo && !empty($formatted['second_firstname'])) {
ParentModel::query()->create([
'secondparent_firstname' => $formatted['second_firstname'] ?? null,
'secondparent_lastname' => $formatted['second_lastname'] ?? null,
'secondparent_gender' => $formatted['second_gender'] ?? null,
'secondparent_email' => $formatted['second_email'] ?? null,
'secondparent_phone' => $formatted['second_cellphone'] ?? null,
'firstparent_id' => (int) $user->id,
'semester' => $semester,
'school_year' => $schoolYear,
]);
}
});
$activationLink = url('/user/confirm/' . $token);
$recipientName = trim((string) ($formatted['firstname'] ?? '') . ' ' . (string) ($formatted['lastname'] ?? ''));
$emailData = [
'recipientName' => $recipientName,
'activationLink' => $activationLink,
'orgName' => 'Al Rahma Sunday School',
'contactInfo' => 'alrahma.isgl@gmail.com',
'logoUrl' => 'https://alrahmaisgl.org/assets/images/alrahma_logo.png',
'subject' => 'Email Confirmation',
];
if (view()->exists($isParent ? 'emails.welcome_parent' : 'emails.welcome_staff')) {
$html = view($isParent ? 'emails.welcome_parent' : 'emails.welcome_staff', $emailData);
} else {
$html = '<p>Hello ' . e($recipientName !== '' ? $recipientName : 'there') . ',</p>'
. '<p>Please confirm your email by clicking the link below:</p>'
. '<p><a href="' . e($activationLink) . '">Activate your account</a></p>'
. '<p>Thank you.</p>';
}
$sent = $this->emailService->send($email, 'Email Confirmation', $html, 'general');
$this->captchaService->clear();
return [
'ok' => true,
'user' => $user,
'role' => $roleName,
'activation_sent' => $sent,
];
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Services\BroadcastEmail;
use Illuminate\Support\Facades\View;
class BroadcastEmailComposerService
{
public function sanitizeHtml(string $html): string
{
$html = preg_replace('#<(script|iframe)[^>]*>.*?</\\1>#is', '', $html);
$html = preg_replace('/\\son\\w+="[^"]*"/i', '', $html);
$html = preg_replace("/\\son\\w+='[^']*'/i", '', $html);
return $html ?? '';
}
public function compose(
bool $wrapLayout,
string $subject,
string $body,
string $recipientName,
string $preheader = '',
string $ctaText = '',
string $ctaUrl = '',
bool $personalize = true
): string {
$content = $personalize ? str_replace('{{name}}', $recipientName, $body) : $body;
if (!$wrapLayout) {
return $content;
}
if (!View::exists('emails.broadcast_wrapper')) {
return $content;
}
return view('emails.broadcast_wrapper', [
'subject' => $subject,
'content' => $content,
'preheader' => $preheader,
'cta_text' => $ctaText,
'cta_url' => $ctaUrl,
])->render();
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Services\BroadcastEmail;
use App\Services\EmailService;
class BroadcastEmailDispatchService
{
private EmailService $mailer;
private BroadcastEmailComposerService $composer;
public function __construct(EmailService $mailer, BroadcastEmailComposerService $composer)
{
$this->mailer = $mailer;
$this->composer = $composer;
}
public function sendTest(array $payload): array
{
$html = $this->composer->compose(
(bool) $payload['wrap_layout'],
$payload['subject'],
$payload['body_html'],
$payload['recipient_name'],
$payload['preheader'],
$payload['cta_text'],
$payload['cta_url'],
(bool) $payload['personalize']
);
$ok = $this->mailer->send(
$payload['test_email'],
'[TEST] ' . $payload['subject'],
$html,
$payload['from_key']
);
return [
'ok' => $ok,
'message' => $ok
? "Test email sent to {$payload['test_email']}."
: 'Test email failed (mailer->send() returned false). Check logs.',
];
}
public function sendBroadcast(array $payload, array $recipients): array
{
$stats = [
'attempted' => 0,
'sent' => 0,
'failed' => 0,
'mode' => $payload['mode'],
];
foreach ($recipients as $recipient) {
$stats['attempted']++;
$recipientName = $recipient['name'] ?: 'Parent';
$html = $this->composer->compose(
(bool) $payload['wrap_layout'],
$payload['subject'],
$payload['body_html'],
$recipientName,
$payload['preheader'],
$payload['cta_text'],
$payload['cta_url'],
(bool) $payload['personalize']
);
$ok = $this->mailer->send(
(string) $recipient['email'],
$payload['subject'],
$html,
$payload['from_key']
);
$ok ? $stats['sent']++ : $stats['failed']++;
}
return $stats;
}
}
@@ -0,0 +1,51 @@
<?php
namespace App\Services\BroadcastEmail;
use Illuminate\Http\UploadedFile;
class BroadcastEmailImageService
{
private const MAX_SIZE_BYTES = 5242880;
private array $allowedTypes = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
public function store(UploadedFile $file): array
{
$mime = strtolower((string) $file->getMimeType());
if (!isset($this->allowedTypes[$mime])) {
return [
'ok' => false,
'status' => 415,
'error' => 'Unsupported image type',
];
}
if ($file->getSize() > self::MAX_SIZE_BYTES) {
return [
'ok' => false,
'status' => 413,
'error' => 'Image too large (max 5MB)',
];
}
$targetDir = public_path('uploads/email');
if (!is_dir($targetDir)) {
@mkdir($targetDir, 0755, true);
}
$ext = $this->allowedTypes[$mime];
$newName = uniqid('em_', true) . '.' . $ext;
$file->move($targetDir, $newName);
return [
'ok' => true,
'url' => asset('uploads/email/' . $newName),
];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Services\BroadcastEmail;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class BroadcastEmailRecipientService
{
public function parentsWithEmails(): array
{
$parents = User::getParents();
return array_values(array_filter($parents, static function ($parent) {
$email = $parent['email'] ?? '';
return is_string($email) && trim($email) !== '';
}));
}
public function recipientsByIds(array $ids): array
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
if (empty($ids)) {
return [];
}
return DB::table('users')
->select('users.id', 'users.email', DB::raw('CONCAT(users.firstname, " ", users.lastname) AS name'))
->whereIn('users.id', $ids)
->whereNotNull('users.email')
->where('users.email', '!=', '')
->get()
->map(fn ($row) => (array) $row)
->all();
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Services\BroadcastEmail;
class BroadcastEmailSenderOptionsService
{
public function listOptions(): array
{
$json = env('MAIL_SENDERS', '{}');
$arr = json_decode($json, true);
if (!is_array($arr) || empty($arr)) {
$smtpUser = getenv('SMTP_USER') ?: '';
$name = 'Al Rahma Sunday School';
return [[
'key' => 'general',
'label' => $name . ($smtpUser ? " <{$smtpUser}>" : ''),
]];
}
$out = [];
foreach ($arr as $key => $info) {
$name = $info['name'] ?? 'Sender';
$email = $info['email'] ?? '';
$out[] = [
'key' => (string) $key,
'label' => trim($name . ($email ? " <{$email}>" : '')),
];
}
return $out;
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Services\ClassPrep;
use App\Models\Configuration;
use Illuminate\Support\Facades\DB;
class ClassRosterService
{
public function listStudentsByClass(int $classSectionId, ?string $schoolYear = null): array
{
$schoolYear = $schoolYear && trim($schoolYear) !== ''
? $schoolYear
: (string) Configuration::getConfig('school_year');
return DB::table('students')
->select(
'students.id',
'students.firstname',
'students.lastname',
'students.gender',
'cs.class_section_name AS registration_grade'
)
->join('student_class as sc', 'sc.student_id', '=', 'students.id')
->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->where('sc.school_year', $schoolYear)
->where('sc.class_section_id', $classSectionId)
->groupBy(
'students.id',
'students.firstname',
'students.lastname',
'students.gender',
'cs.class_section_name'
)
->orderBy('students.firstname', 'ASC')
->orderBy('students.lastname', 'ASC')
->limit(1000)
->get()
->map(fn ($row) => (array) $row)
->toArray();
}
}
@@ -0,0 +1,161 @@
<?php
namespace App\Services\ClassPrep;
use App\Models\Configuration;
use Illuminate\Support\Facades\DB;
class StickerCountService
{
public function listAll(?string $schoolYear = null, ?string $semester = null): array
{
$schoolYear = $this->resolveSchoolYear($schoolYear);
$semester = $this->resolveSemester($semester);
return $this->buildStickerCounts($schoolYear, $semester, null, $this->specialMapWithKg());
}
public function listForClass(?string $schoolYear, ?string $semester, int $classSectionId): array
{
$schoolYear = $this->resolveSchoolYear($schoolYear);
$semester = $this->resolveSemester($semester);
return $this->buildStickerCounts($schoolYear, $semester, $classSectionId, $this->specialMapWithoutKg());
}
private function resolveSchoolYear(?string $schoolYear): string
{
return $schoolYear && trim($schoolYear) !== ''
? $schoolYear
: (string) Configuration::getConfig('school_year');
}
private function resolveSemester(?string $semester): string
{
return $semester && trim($semester) !== ''
? $semester
: (string) Configuration::getConfig('semester');
}
private function buildStickerCounts(string $schoolYear, string $semester, ?int $classSectionId, array $specialMap): array
{
$builder = DB::table('student_class as sc')
->select(
'sc.student_id',
's.firstname',
's.lastname',
's.is_new',
'cs.class_section_name AS grade_label'
)
->join('students as s', 's.id', '=', 'sc.student_id')
->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->join('classes as c', 'c.id', '=', 'cs.class_id')
->where('sc.school_year', $schoolYear);
if ($classSectionId !== null && $classSectionId > 0) {
$builder->where('sc.class_section_id', $classSectionId);
}
$rows = $builder->get()->toArray();
$students = [];
$totalPrimary = 0;
$totalSecondary = 0;
foreach ($rows as $row) {
$grade = trim((string) ($row->grade_label ?? ''));
if ($grade === '' || $this->isYouth($grade)) {
continue;
}
$isNew = (int) ($row->is_new ?? 0) === 1;
[$primary, $secondary] = $this->calculateStickerCounts($grade, $isNew, $specialMap);
$totalPrimary += $primary;
$totalSecondary += $secondary;
$students[] = [
'student_id' => (int) ($row->student_id ?? 0),
'firstname' => trim((string) ($row->firstname ?? '')),
'lastname' => trim((string) ($row->lastname ?? '')),
'grade_label' => $grade,
'primary_count' => $primary,
'secondary_count' => $secondary,
];
}
return [
'schoolYear' => $schoolYear,
'semester' => $semester,
'classSectionId' => $classSectionId,
'students' => $students,
'totals' => [
'primary' => $totalPrimary,
'secondary' => $totalSecondary,
'students' => count($students),
],
];
}
private function calculateStickerCounts(string $grade, bool $isNew, array $specialMap): array
{
$primary = 0;
$secondary = 0;
if (array_key_exists($grade, $specialMap)) {
$primary = (int) $specialMap[$grade];
$secondary = 0;
} elseif ($this->isUpperBlock($grade)) {
if ($isNew) {
$primary = 4;
$secondary = 0;
} else {
$primary = $this->isGrade5($grade) ? 3 : 2;
$secondary = max(0, 4 - $primary);
}
} else {
$primary = 4;
$secondary = 0;
}
return [$primary, $secondary];
}
private function isUpperBlock(string $grade): bool
{
return (bool) preg_match('/^(3|4|5|6|7|8)(-[AB])?$/', trim($grade));
}
private function isGrade5(string $grade): bool
{
return (bool) preg_match('/^5(\-|$)/', trim($grade));
}
private function isYouth(string $grade): bool
{
return (bool) preg_match('/^youth(?:\b|-)/i', trim($grade));
}
private function specialMapWithKg(): array
{
return [
'KG' => 1,
'1-A' => 3,
'1-B' => 4,
'2-A' => 5,
'2-B' => 5,
'9' => 2,
];
}
private function specialMapWithoutKg(): array
{
return [
'1-A' => 3,
'1-B' => 4,
'2-A' => 5,
'2-B' => 5,
'9' => 2,
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Services\ClassPreparation;
use App\Models\ClassPrepAdjustment;
class ClassPreparationAdjustmentService
{
public function applyAdjustments(array $baseItems, string $classSectionId, string $schoolYear): array
{
$rawAdjustments = ClassPrepAdjustment::query()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->where('adjustable', 1)
->get()
->toArray();
$adjMap = ['Large Table' => 0, 'Small Table' => 0];
foreach ($rawAdjustments as $adjustment) {
$item = $adjustment['item_name'] ?? '';
$delta = (int) ($adjustment['adjustment'] ?? 0);
if (array_key_exists($item, $adjMap)) {
$adjMap[$item] = $delta;
}
if (isset($baseItems[$item])) {
$baseItems[$item] = max(0, (int) $baseItems[$item] + $delta);
}
}
return [$baseItems, $adjMap];
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Services\ClassPreparation;
use App\Models\ClassSection;
use App\Models\Configuration;
use Illuminate\Support\Facades\DB;
class ClassPreparationCalculatorService
{
private array $allowedPrepCategories = [
'Grade Box',
'Large Table',
'Regular Chair',
'Small Chair',
'Small Table',
'Teacher Chair',
'Trash Bin',
'White Board',
];
public function getAllowedCategories(): array
{
return $this->allowedPrepCategories;
}
public function getClassLevelBySection(string $classSectionId): int
{
$sectionName = ClassSection::getClassSectionNameBySectionId($classSectionId);
$label = $sectionName !== null && $sectionName !== '' ? (string) $sectionName : $classSectionId;
if (preg_match('/^(kg|k)(\\b|[^a-z0-9])/i', $label)) {
return 1;
}
$numValue = (int) preg_replace('/\\D/', '', $label);
if ($numValue > 0 && $numValue < 30) {
$firstDigit = (int) substr((string) $numValue, 0, 1);
return $firstDigit === 1 ? 1 : ($firstDigit === 2 ? 2 : 3);
}
return 3;
}
public function calculatePrepItems(int $students, int $classLevel, string $classSectionId): array
{
$allowed = $this->allowedPrepCategories;
$items = array_fill_keys($allowed, 0);
$categories = DB::table('inventory_categories')
->select('name', 'grade_min', 'grade_max')
->where('type', 'classroom')
->whereIn('name', $allowed)
->orderBy('name')
->get()
->toArray();
$isLowerGrades = in_array($classLevel, [1, 2], true);
$teacherCount = $this->getTeacherCountForSection(
$classSectionId,
(string) Configuration::getConfig('school_year')
);
foreach ($categories as $cat) {
$name = (string) $cat->name;
$gradeMin = $cat->grade_min ?? null;
$gradeMax = $cat->grade_max ?? null;
if ($gradeMin !== null && $classLevel < (int) $gradeMin) {
$items[$name] = 0;
continue;
}
if ($gradeMax !== null && $classLevel > (int) $gradeMax) {
$items[$name] = 0;
continue;
}
$qty = 0;
switch (strtolower($name)) {
case 'small table':
$qty = $isLowerGrades ? (int) ceil($students / 3) : 0;
break;
case 'large table':
$qty = $isLowerGrades ? 0 : (int) ceil($students / 4);
break;
case 'small chair':
$qty = $isLowerGrades ? $students : 0;
break;
case 'regular chair':
$qty = $isLowerGrades ? 0 : $students;
break;
case 'teacher chair':
$qty = (int) $teacherCount;
break;
case 'trash bin':
case 'white board':
case 'grade box':
$qty = 1;
break;
default:
$qty = 0;
}
if (array_key_exists($name, $items)) {
$items[$name] = $qty;
}
}
return $items;
}
private function getTeacherCountForSection(string $classSectionId, ?string $schoolYear = null): int
{
$schoolYear = $schoolYear ?: (string) Configuration::getConfig('school_year');
$row = DB::table('teacher_class')
->select('COUNT(*) AS cnt')
->where('class_section_id', $classSectionId)
->whereIn('position', ['main', 'ta'])
->where('school_year', $schoolYear)
->first();
return (int) ($row->cnt ?? 0);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Services\ClassPreparation;
use App\Models\Configuration;
use Illuminate\Support\Facades\DB;
class ClassPreparationContextService
{
/** cache for roster presence per term */
private array $rosterPresenceCache = [];
public function getSchoolYear(): string
{
return (string) (Configuration::getConfig('school_year') ?? '');
}
public function getSemester(): string
{
return (string) (Configuration::getConfig('semester') ?? '');
}
public function hasRosterForSemester(string $schoolYear, string $semester): bool
{
$year = trim((string) $schoolYear);
$sem = trim((string) $semester);
if ($year === '' || $sem === '') {
return false;
}
$key = sprintf('sem:%s:%s', $year, $sem);
if (array_key_exists($key, $this->rosterPresenceCache)) {
return $this->rosterPresenceCache[$key];
}
$cnt = DB::table('student_class')
->where('school_year', $year)
->where('semester', $sem)
->count();
$this->rosterPresenceCache[$key] = $cnt > 0;
return $this->rosterPresenceCache[$key];
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Services\ClassPreparation;
use Illuminate\Support\Facades\DB;
class ClassPreparationInventoryService
{
public function buildAvailability(string $schoolYear, string $semester, bool $limitToSemester, array $allowed): array
{
$inventoryMap = array_fill_keys($allowed, 0);
$joinRows = DB::table('inventory_items as ii')
->select('ic.name AS item_name, COALESCE(SUM(CASE WHEN ii.good_qty IS NOT NULL THEN ii.good_qty WHEN ii.`condition`=\"good\" THEN ii.quantity ELSE 0 END),0) AS available')
->join('inventory_categories as ic', 'ic.id', '=', 'ii.category_id')
->where('ii.type', 'classroom')
->where('ii.school_year', $schoolYear)
->whereIn('ic.name', $allowed);
if ($limitToSemester && $semester !== '') {
$joinRows->where('ii.semester', $semester);
}
$joinRows = $joinRows->groupBy('ic.name')->get()->toArray();
foreach ($joinRows as $r) {
$name = (string) ($r->item_name ?? '');
if ($name !== '' && isset($inventoryMap[$name])) {
$inventoryMap[$name] = max($inventoryMap[$name], (int) ($r->available ?? 0));
}
}
$nameRows = DB::table('inventory_items')
->select('name AS item_name, COALESCE(SUM(CASE WHEN good_qty IS NOT NULL THEN good_qty WHEN `condition`=\"good\" THEN quantity ELSE 0 END),0) AS available')
->where('type', 'classroom')
->where('school_year', $schoolYear)
->whereIn('name', $allowed);
if ($limitToSemester && $semester !== '') {
$nameRows->where('semester', $semester);
}
$nameRows = $nameRows->groupBy('name')->get()->toArray();
foreach ($nameRows as $r) {
$name = (string) ($r->item_name ?? '');
if ($name !== '' && isset($inventoryMap[$name])) {
$inventoryMap[$name] = max($inventoryMap[$name], (int) ($r->available ?? 0));
}
}
return $inventoryMap;
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Services\ClassPreparation;
use App\Models\ClassPreparationLog;
class ClassPreparationLogService
{
public function getLatestLog(string $classSectionId, string $schoolYear): ?ClassPreparationLog
{
return ClassPreparationLog::query()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')
->first();
}
public function hasPrepChanged(array $new, array $old): bool
{
ksort($new);
ksort($old);
return json_encode($new) !== json_encode($old);
}
public function createLog(string $classSectionId, string $className, string $schoolYear, array $items, string $createdAt): bool
{
try {
ClassPreparationLog::query()->create([
'class_section_id' => $classSectionId,
'class_section' => $className,
'school_year' => $schoolYear,
'prep_data' => json_encode($items),
'created_at' => $createdAt,
]);
return true;
} catch (\Throwable $e) {
return false;
}
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Services\ClassPreparation;
use Illuminate\Support\Facades\DB;
class ClassPreparationRosterService
{
public function getClassSectionStudentCounts(string $schoolYear, string $semester, bool $limitToSemester): array
{
$query = DB::table('student_class as sc')
->select('sc.class_section_id, COUNT(DISTINCT sc.student_id) AS student_count', false)
->join('students as s', 's.id', '=', 'sc.student_id')
->where('s.is_active', 1)
->where('sc.school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$query->where('sc.semester', $semester);
}
return $query->groupBy('sc.class_section_id')->get()->toArray();
}
public function getStudentCountForSection(string $schoolYear, string $semester, bool $limitToSemester, string $classSectionId): int
{
$query = DB::table('student_class as sc')
->select('COUNT(DISTINCT sc.student_id) AS cnt', false)
->join('students as s', 's.id', '=', 'sc.student_id')
->where('s.is_active', 1)
->where('sc.class_section_id', $classSectionId)
->where('sc.school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$query->where('sc.semester', $semester);
}
$row = $query->first();
return (int) ($row->cnt ?? 0);
}
}
@@ -0,0 +1,121 @@
<?php
namespace App\Services\ClassPreparation;
use App\Models\ClassSection;
class ClassPreparationService
{
private ClassPreparationContextService $context;
private ClassPreparationRosterService $roster;
private ClassPreparationInventoryService $inventory;
private ClassPreparationCalculatorService $calculator;
private ClassPreparationAdjustmentService $adjustments;
private ClassPreparationLogService $logs;
public function __construct(
ClassPreparationContextService $context,
ClassPreparationRosterService $roster,
ClassPreparationInventoryService $inventory,
ClassPreparationCalculatorService $calculator,
ClassPreparationAdjustmentService $adjustments,
ClassPreparationLogService $logs
) {
$this->context = $context;
$this->roster = $roster;
$this->inventory = $inventory;
$this->calculator = $calculator;
$this->adjustments = $adjustments;
$this->logs = $logs;
}
public function listPrep(?string $schoolYear = null, ?string $semester = null): array
{
$schoolYear = $schoolYear ?: $this->context->getSchoolYear();
$semester = $semester ?: $this->context->getSemester();
$allowed = $this->calculator->getAllowedCategories();
$limitToSemester = $this->context->hasRosterForSemester($schoolYear, $semester);
$classSections = $this->roster->getClassSectionStudentCounts($schoolYear, $semester, $limitToSemester);
$inventoryMap = $this->inventory->buildAvailability($schoolYear, $semester, $limitToSemester, $allowed);
$requiredTotals = array_fill_keys($allowed, 0);
$results = [];
foreach ($classSections as $row) {
$classSectionId = (string) $row->class_section_id;
$studentCount = (int) $row->student_count;
$classLevel = $this->calculator->getClassLevelBySection($classSectionId);
$className = ClassSection::getClassSectionNameBySectionId($classSectionId);
$baseItems = $this->calculator->calculatePrepItems($studentCount, $classLevel, $classSectionId);
[$baseItems, $adjMap] = $this->adjustments->applyAdjustments($baseItems, $classSectionId, $schoolYear);
foreach ($allowed as $cat) {
$requiredTotals[$cat] += (int) ($baseItems[$cat] ?? 0);
}
$oldSnap = $this->logs->getLatestLog($classSectionId, $schoolYear);
$oldPrep = $oldSnap ? (array) ($oldSnap->prep_data ?? []) : [];
$hasChanged = $this->logs->hasPrepChanged($baseItems, $oldPrep);
$results[] = [
'class_section' => $className,
'class_section_id' => $classSectionId,
'student_count' => $studentCount,
'class_level' => $classLevel,
'prep_items' => $baseItems,
'needs_print' => $hasChanged,
'last_printed_at' => $oldSnap?->created_at,
'adjustments' => $adjMap,
];
}
$shortages = [];
foreach ($requiredTotals as $item => $reqQty) {
$have = (int) ($inventoryMap[$item] ?? 0);
if ($have < (int) $reqQty) {
$shortages[$item] = (int) $reqQty - $have;
}
}
return [
'schoolYear' => $schoolYear,
'semester' => $semester,
'results' => $results,
'totals' => $requiredTotals,
'shortages' => $shortages,
];
}
public function markPrinted(string $schoolYear, string $semester, array $classSectionIds): int
{
$limitToSemester = $this->context->hasRosterForSemester($schoolYear, $semester);
$ids = array_values(array_unique(array_filter(array_map('strval', $classSectionIds))));
$now = $this->utcNow();
$count = 0;
foreach ($ids as $classSectionId) {
$studentCount = $this->roster->getStudentCountForSection($schoolYear, $semester, $limitToSemester, $classSectionId);
$classLevel = $this->calculator->getClassLevelBySection($classSectionId);
$className = ClassSection::getClassSectionNameBySectionId($classSectionId) ?? $classSectionId;
$items = $this->calculator->calculatePrepItems($studentCount, $classLevel, $classSectionId);
[$items] = $this->adjustments->applyAdjustments($items, $classSectionId, $schoolYear);
if ($this->logs->createLog($classSectionId, $className, $schoolYear, $items, $now)) {
$count++;
}
}
return $count;
}
private function utcNow(): string
{
if (function_exists('utc_now')) {
return (string) utc_now();
}
return now('UTC')->toDateTimeString();
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Services\Communication;
use App\Models\FamilyStudent;
use Illuminate\Support\Facades\DB;
class CommunicationFamilyService
{
public function familiesForStudent(int $studentId): array
{
return FamilyStudent::getFamiliesForStudent($studentId);
}
public function guardiansForFamily(int $familyId): array
{
return DB::table('family_guardians as fg')
->join('users as u', 'u.id', '=', 'fg.user_id')
->where('fg.family_id', $familyId)
->select(
'u.id as user_id',
'u.firstname',
'u.lastname',
'u.email',
'fg.relation',
'fg.is_primary',
'fg.receive_emails',
'fg.receive_sms'
)
->get()
->map(fn ($row) => (array) $row)
->all();
}
public function guardianSalutation(int $familyId): string
{
$rows = DB::table('family_guardians as fg')
->join('users as u', 'u.id', '=', 'fg.user_id')
->where('fg.family_id', $familyId)
->where('fg.receive_emails', 1)
->orderByDesc('fg.is_primary')
->orderBy('u.lastname')
->orderBy('u.firstname')
->select('u.firstname', 'u.lastname')
->get()
->map(fn ($row) => (array) $row)
->all();
if (empty($rows)) {
return 'Parent/Guardian';
}
$names = array_map(static function ($row) {
return trim(($row['firstname'] ?? '') . ' ' . ($row['lastname'] ?? ''));
}, $rows);
return implode(' & ', $names);
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Services\Communication;
use App\Models\CommunicationLog;
use Illuminate\Support\Facades\Schema;
class CommunicationLogService
{
public function log(array $payload): void
{
if (!Schema::hasTable('communication_logs')) {
return;
}
CommunicationLog::query()->create($payload);
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Services\Communication;
class CommunicationPreviewService
{
private CommunicationTemplateService $templates;
private CommunicationStudentService $students;
private CommunicationFamilyService $families;
public function __construct(
CommunicationTemplateService $templates,
CommunicationStudentService $students,
CommunicationFamilyService $families
) {
$this->templates = $templates;
$this->students = $students;
$this->families = $families;
}
public function buildPreview(
string $templateKey,
int $studentId,
int $familyId,
array $vars,
string $teacherName
): array {
$template = $this->templates->findTemplateByKey($templateKey);
if (!$template) {
return [
'ok' => false,
'status' => 404,
'message' => 'Template not found',
];
}
$student = $this->students->getStudentBasic($studentId);
if (!$student) {
return [
'ok' => false,
'status' => 404,
'message' => 'Student not found',
];
}
$salutation = $this->families->guardianSalutation($familyId);
$autoVars = [
'student_fullname' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
'student_grade' => $student['grade'] ?? '',
'parent_salutation' => $salutation,
'date' => $this->formatLocalDate('Y-m-d'),
'school_name' => 'Al Rahma Sunday School',
'teacher_name' => $teacherName !== '' ? $teacherName : 'Teacher',
];
$allVars = array_merge($autoVars, $vars);
$subject = $this->renderTwig($template['subject'], $allVars);
$body = $this->renderTwig($template['body'], $allVars);
return [
'ok' => true,
'subject' => $subject,
'html' => nl2br($body),
];
}
private function renderTwig(string $template, array $vars): string
{
return (string) preg_replace_callback('/\\{\\{\\s*([a-zA-Z0-9_\\.]+)\\s*\\}\\}/', function ($m) use ($vars) {
$key = $m[1];
return htmlspecialchars((string) ($vars[$key] ?? ''), ENT_QUOTES, 'UTF-8');
}, $template);
}
private function formatLocalDate(string $format): string
{
if (function_exists('local_date') && function_exists('utc_now')) {
return (string) local_date(utc_now(), $format);
}
return now()->format($format);
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services\Communication;
use App\Services\EmailService;
class CommunicationSendService
{
private EmailService $mailer;
private CommunicationLogService $logService;
public function __construct(EmailService $mailer, CommunicationLogService $logService)
{
$this->mailer = $mailer;
$this->logService = $logService;
}
public function send(array $payload): array
{
$recipients = $this->normalizeEmails($payload['recipients'] ?? []);
$cc = $this->normalizeEmails($payload['cc'] ?? []);
$bcc = $this->normalizeEmails($payload['bcc'] ?? []);
if (empty($recipients)) {
$this->logService->log([
'student_id' => (int) $payload['student_id'],
'family_id' => (int) $payload['family_id'],
'student_name' => (string) $payload['student_name'],
'template_key' => (string) $payload['template_key'],
'subject' => (string) $payload['subject'],
'body' => (string) $payload['body'],
'recipients' => [],
'cc' => array_values($cc),
'bcc' => array_values($bcc),
'status' => 'failed',
'error_message' => 'No recipients provided',
'sent_by' => (int) ($payload['sent_by'] ?? 0),
'metadata' => null,
]);
return [
'ok' => false,
'error' => 'No recipients provided',
];
}
$sendOk = false;
$error = null;
try {
$sendOk = $this->mailer->send(
$recipients,
(string) $payload['subject'],
(string) $payload['body'],
'general',
$cc,
$bcc
);
} catch (\Throwable $t) {
$error = $t->getMessage();
$sendOk = false;
}
if (!$sendOk && $error === null) {
$error = 'Mailer returned false';
}
$this->logService->log([
'student_id' => (int) $payload['student_id'],
'family_id' => (int) $payload['family_id'],
'student_name' => (string) $payload['student_name'],
'template_key' => (string) $payload['template_key'],
'subject' => (string) $payload['subject'],
'body' => (string) $payload['body'],
'recipients' => array_values($recipients),
'cc' => array_values($cc),
'bcc' => array_values($bcc),
'status' => $sendOk ? 'sent' : 'failed',
'error_message' => $error,
'sent_by' => (int) ($payload['sent_by'] ?? 0),
'metadata' => null,
]);
return [
'ok' => $sendOk,
'error' => $error,
];
}
private function normalizeEmails(array $values): array
{
$out = array_values(array_unique(array_filter(array_map('strval', $values))));
return array_values(array_filter($out, static function ($email) {
return $email !== '';
}));
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Services\Communication;
use Illuminate\Support\Facades\DB;
class CommunicationStudentService
{
public function listStudents(): array
{
return DB::table('students')
->select('id', 'firstname', 'lastname', 'registration_grade')
->orderBy('lastname', 'asc')
->orderBy('firstname', 'asc')
->get()
->map(fn ($row) => (array) $row)
->all();
}
public function getStudentBasic(int $studentId): ?array
{
$row = DB::table('students as s')
->leftJoin('student_class as sc', 'sc.student_id', '=', 's.id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->select(
's.id',
's.firstname',
's.lastname',
's.registration_grade',
'cs.class_section_name as class_section_name'
)
->where('s.id', $studentId)
->orderBy('sc.id', 'desc')
->first();
if (!$row) {
return null;
}
$row = (array) $row;
$grade = $row['registration_grade'] ?? '';
if (!$grade && !empty($row['class_section_name'])) {
$grade = $row['class_section_name'];
}
return [
'id' => (int) $row['id'],
'firstname' => (string) ($row['firstname'] ?? ''),
'lastname' => (string) ($row['lastname'] ?? ''),
'grade' => (string) ($grade ?? ''),
];
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Services\Communication;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class CommunicationTemplateService
{
public function listActiveTemplates(): array
{
[$keyColumn, $bodyColumn] = $this->resolveTemplateColumns();
return DB::table('email_templates')
->where('is_active', 1)
->orderBy($keyColumn, 'asc')
->get()
->map(function ($row) use ($keyColumn, $bodyColumn) {
$row = (array) $row;
return [
'template_key' => (string) ($row[$keyColumn] ?? ''),
'subject' => (string) ($row['subject'] ?? ''),
'body' => (string) ($row[$bodyColumn] ?? ''),
];
})
->all();
}
public function findTemplateByKey(string $key): ?array
{
[$keyColumn, $bodyColumn] = $this->resolveTemplateColumns();
$row = DB::table('email_templates')
->where($keyColumn, $key)
->where('is_active', 1)
->first();
if (!$row) {
return null;
}
$row = (array) $row;
return [
'template_key' => (string) ($row[$keyColumn] ?? ''),
'subject' => (string) ($row['subject'] ?? ''),
'body' => (string) ($row[$bodyColumn] ?? ''),
];
}
private function resolveTemplateColumns(): array
{
$keyColumn = Schema::hasColumn('email_templates', 'template_key') ? 'template_key' : 'code';
$bodyColumn = Schema::hasColumn('email_templates', 'body') ? 'body' : 'body_html';
return [$keyColumn, $bodyColumn];
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Services\CompetitionScores;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\TeacherClass;
class CompetitionScoresContextService
{
public function getTeacherContext(int $userId): array
{
$schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
$semester = (string) (Configuration::getConfig('semester') ?? '');
$assignments = TeacherClass::getClassAssignmentsByUserId($userId, $schoolYear, $semester);
return [$assignments, $schoolYear, $semester];
}
public function getAllowedClassIds(array $assignments): array
{
$ids = array_map(static function ($row) {
return (int) ($row['class_section_id'] ?? 0);
}, $assignments);
return array_values(array_filter(array_unique($ids)));
}
public function resolveActiveClassId(array $assignments, int $requestedClassId = 0): int
{
$allowed = $this->getAllowedClassIds($assignments);
if ($requestedClassId > 0 && in_array($requestedClassId, $allowed, true)) {
return $requestedClassId;
}
return (int) ($allowed[0] ?? 0);
}
public function getActiveClassName(array $assignments, int $classSectionId): ?string
{
if ($classSectionId <= 0) {
return null;
}
foreach ($assignments as $row) {
if ((int) ($row['class_section_id'] ?? 0) === $classSectionId) {
return $row['class_section_name'] ?? null;
}
}
return ClassSection::getClassSectionNameBySectionId($classSectionId);
}
public function getClassSectionMap(): array
{
$sections = ClassSection::query()
->select('class_section_id', 'class_section_name')
->orderBy('class_section_name', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
$map = [];
foreach ($sections as $section) {
$id = (int) ($section['class_section_id'] ?? 0);
if ($id > 0) {
$map[$id] = $section['class_section_name'] ?? (string) $id;
}
}
return $map;
}
}
@@ -0,0 +1,118 @@
<?php
namespace App\Services\CompetitionScores;
use App\Models\Competition;
use App\Models\CompetitionClassWinner;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class CompetitionScoresQueryService
{
public function getCompetitionsForClass(int $classSectionId, string $schoolYear, string $semester): array
{
$builder = Competition::query()->orderBy('id', 'DESC');
if ($classSectionId > 0) {
$builder->where(function ($q) use ($classSectionId) {
$q->where('class_section_id', $classSectionId)
->orWhere('class_section_id', 0)
->orWhereNull('class_section_id');
});
}
if ($schoolYear !== '') {
$builder->where(function ($q) use ($schoolYear) {
$q->where('school_year', $schoolYear)
->orWhere('school_year', '')
->orWhereNull('school_year');
});
}
if ($semester !== '') {
$builder->where(function ($q) use ($semester) {
$q->where('semester', $semester)
->orWhere('semester', '')
->orWhereNull('semester');
});
}
return $builder->get()->map(fn ($row) => (array) $row)->all();
}
public function getQuestionCounts(array $competitionIds, int $classSectionId): array
{
if (!$this->hasQuestionCount() || empty($competitionIds)) {
return [];
}
$rows = CompetitionClassWinner::query()
->select('competition_id', 'question_count')
->where('class_section_id', $classSectionId)
->whereIn('competition_id', $competitionIds)
->get()
->map(fn ($row) => (array) $row)
->all();
$out = [];
foreach ($rows as $row) {
$compId = (int) ($row['competition_id'] ?? 0);
if ($compId > 0 && $row['question_count'] !== null) {
$out[$compId] = (int) $row['question_count'];
}
}
return $out;
}
public function getScoreCounts(array $competitionIds, int $classSectionId): array
{
if (empty($competitionIds)) {
return [];
}
$rows = DB::table('competition_scores')
->selectRaw('competition_id, COUNT(*) AS total')
->where('class_section_id', $classSectionId)
->whereIn('competition_id', $competitionIds)
->groupBy('competition_id')
->get()
->map(fn ($row) => (array) $row)
->all();
$out = [];
foreach ($rows as $row) {
$compId = (int) ($row['competition_id'] ?? 0);
if ($compId > 0) {
$out[$compId] = (int) ($row['total'] ?? 0);
}
}
return $out;
}
public function getQuestionCountForCompetition(int $competitionId, int $classSectionId): ?int
{
if (!$this->hasQuestionCount()) {
return null;
}
$row = CompetitionClassWinner::query()
->select('question_count')
->where('competition_id', $competitionId)
->where('class_section_id', $classSectionId)
->first();
if ($row && $row->question_count !== null) {
return (int) $row->question_count;
}
return null;
}
private function hasQuestionCount(): bool
{
return Schema::hasTable('competition_class_winners')
&& Schema::hasColumn('competition_class_winners', 'question_count');
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Services\CompetitionScores;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class CompetitionScoresRosterService
{
private string $classStudentTable;
public function __construct()
{
if (Schema::hasTable('class_student')) {
$this->classStudentTable = 'class_student';
} elseif (Schema::hasTable('student_class')) {
$this->classStudentTable = 'student_class';
} else {
$this->classStudentTable = '';
}
}
public function getClassStudentCount(int $classSectionId, ?string $schoolYear): int
{
if ($classSectionId <= 0 || $this->classStudentTable === '') {
return 0;
}
$builder = DB::table($this->classStudentTable)
->selectRaw('COUNT(*) AS total')
->where('class_section_id', $classSectionId);
if ($schoolYear && Schema::hasColumn($this->classStudentTable, 'school_year')) {
$builder->where('school_year', $schoolYear);
}
$row = (array) $builder->first();
return (int) ($row['total'] ?? 0);
}
public function getStudentsForCompetition(array $competition, int $classSectionId): array
{
if ($classSectionId <= 0 || $this->classStudentTable === '') {
return [];
}
$builder = DB::table('students as s')
->select('s.id', 's.school_id', 's.firstname', 's.lastname')
->join($this->classStudentTable . ' as cs', 'cs.student_id', '=', 's.id')
->where('cs.class_section_id', $classSectionId);
if (Schema::hasColumn($this->classStudentTable, 'school_year')) {
$schoolYear = (string) ($competition['school_year'] ?? '');
if ($schoolYear !== '') {
$builder->where('cs.school_year', $schoolYear);
}
}
return $builder
->distinct()
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Services\CompetitionScores;
use App\Models\CompetitionScore;
class CompetitionScoresSaveService
{
public function filterScores(array $scores): array
{
$cleanScores = [];
$invalidScores = [];
foreach ($scores as $studentId => $scoreValue) {
$scoreValue = trim((string) $scoreValue);
if ($scoreValue === '') {
continue;
}
if (!preg_match('/^\\d+$/', $scoreValue)) {
$invalidScores[] = (int) $studentId;
continue;
}
$cleanScores[(int) $studentId] = (int) $scoreValue;
}
return [$cleanScores, $invalidScores];
}
public function saveScores(int $competitionId, int $classSectionId, array $scores): void
{
foreach ($scores as $studentId => $scoreValue) {
CompetitionScore::query()->updateOrCreate(
[
'competition_id' => $competitionId,
'student_id' => (int) $studentId,
'class_section_id' => $classSectionId,
],
[
'score' => (int) $scoreValue,
'class_section_id' => $classSectionId,
]
);
}
}
public function getScoreMap(int $competitionId, int $classSectionId): array
{
$rows = CompetitionScore::query()
->select('student_id', 'score')
->where('competition_id', $competitionId)
->where('class_section_id', $classSectionId)
->get()
->map(fn ($row) => (array) $row)
->all();
$map = [];
foreach ($rows as $row) {
$map[(int) $row['student_id']] = $row['score'];
}
return $map;
}
}
@@ -0,0 +1,294 @@
<?php
namespace App\Services\Discounts;
use App\Models\DiscountVoucher;
use App\Models\Invoice;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class DiscountApplyService
{
private DiscountContextService $context;
private DiscountInvoiceService $invoiceService;
public function __construct(DiscountContextService $context, DiscountInvoiceService $invoiceService)
{
$this->context = $context;
$this->invoiceService = $invoiceService;
}
public function applyVoucher(int $voucherId, array $parentIds, bool $allowAdditional, int $actorId): array
{
$schoolYear = $this->context->getSchoolYear();
$semester = $this->context->getSemester();
$voucher = DiscountVoucher::query()->find($voucherId);
if (!$voucher) {
return ['ok' => false, 'message' => 'Voucher not found.'];
}
$voucherDescription = trim((string) ($voucher->description ?? ''));
$voucherDescription = preg_replace(
'/^Auto-generated on \\d{4}-\\d{2}-\\d{2}\\s*[—-]\\s*reason:\\s*/i',
'',
$voucherDescription
);
$maxUsesRaw = $voucher->max_uses;
$maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw;
$timesUsed = (int) ($voucher->times_used ?? 0);
$remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed);
if ($remainingUses <= 0) {
return ['ok' => false, 'message' => 'This voucher has reached its maximum allowed uses.'];
}
if (!$allowAdditional) {
$rows = DB::table('discount_usages as du')
->select('i.parent_id')
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
->whereIn('i.parent_id', $parentIds)
->where('i.school_year', $schoolYear)
->groupBy('i.parent_id')
->get()
->map(fn ($row) => (array) $row)
->all();
$blockedParentIds = array_map(static fn ($r) => (int) ($r['parent_id'] ?? 0), $rows);
if (!empty($blockedParentIds)) {
$parentIds = array_values(array_diff(array_map('intval', $parentIds), $blockedParentIds));
}
if (empty($parentIds)) {
return [
'ok' => false,
'message' => 'All selected parents already have discounts. Enable "Allow additional discounts" to apply another.',
];
}
}
$paymentDate = function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString();
$fullyCoveredInvoiceIds = [];
$touchedInvoiceIds = [];
$appliedCount = 0;
DB::beginTransaction();
try {
foreach ($parentIds as $parentId) {
$invoices = Invoice::getInvoicesByUserId((int) $parentId, $schoolYear);
if (empty($invoices)) {
Log::warning('Discount apply: parent has no invoices', [
'parent_id' => (int) $parentId,
'voucher_id' => $voucherId,
]);
continue;
}
foreach ($invoices as $invoice) {
if ($remainingUses <= 0) {
break 2;
}
$invoiceId = (int) ($invoice['id'] ?? 0);
if ($invoiceId <= 0) {
continue;
}
$initialPreBalance = (float) $this->invoiceService->getCurrentInvoiceBalance($invoiceId, $schoolYear);
if ($initialPreBalance <= 0) {
continue;
}
if (!$allowAdditional) {
$exists = DB::table('discount_usages as du')
->join('invoices as i', 'du.invoice_id', '=', 'i.id')
->where('du.voucher_id', $voucherId)
->where('du.invoice_id', $invoiceId)
->where('i.school_year', $schoolYear)
->count();
if ($exists) {
continue;
}
}
$rawDiscount = $voucher->discount_type === 'percent'
? round(((float) ($invoice['total_amount'] ?? 0) * (float) $voucher->discount_value) / 100, 2)
: (float) $voucher->discount_value;
$discount = min($rawDiscount, $initialPreBalance);
if ($discount <= 0) {
continue;
}
$now = function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString();
DB::table('discount_usages')->insert([
'voucher_id' => $voucherId,
'invoice_id' => $invoiceId,
'parent_id' => (int) $parentId,
'discount_amount' => $discount,
'description' => $voucherDescription,
'school_year' => $schoolYear,
'updated_by' => $actorId,
'used_at' => $now,
'created_at' => $now,
'updated_at' => $now,
'semester' => $semester,
]);
$newBalance = max(0.0, round($initialPreBalance - $discount, 2));
DB::table('invoices')
->where('id', $invoiceId)
->update([
'balance' => $newBalance,
'has_discount' => 1,
'updated_at' => $now,
]);
$postBalance = round($initialPreBalance - $discount, 2);
if ($postBalance < 0) {
$postBalance = 0.0;
}
$currentBalance = (float) $this->invoiceService->getCurrentInvoiceBalance($invoiceId, $schoolYear);
DB::table('discount_vouchers')
->where('id', $voucherId)
->update([
'times_used' => DB::raw('COALESCE(times_used,0) + 1'),
]);
$this->triggerPaymentReceived(
$invoiceId,
(string) $voucherId,
$discount,
$paymentDate,
$initialPreBalance,
$postBalance,
$schoolYear,
$semester
);
$touchedInvoiceIds[] = $invoiceId;
$appliedCount++;
$epsilon = 0.01;
$fullyCovered = (abs($discount - $initialPreBalance) <= $epsilon)
|| ($postBalance <= $epsilon)
|| ($currentBalance <= $epsilon);
if ($fullyCovered) {
$fullyCoveredInvoiceIds[] = $invoiceId;
}
$remainingUses--;
if ($remainingUses <= 0 && $maxUses !== null) {
DB::table('discount_vouchers')
->where('id', $voucherId)
->update([
'is_active' => 0,
'updated_at' => $now,
]);
break 2;
}
}
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
return ['ok' => false, 'message' => 'Voucher application failed. Transaction rolled back.'];
}
if ($appliedCount === 0) {
return [
'ok' => false,
'message' => 'No discounts were applied. The voucher may already be used for these invoices or balances are zero.',
];
}
if ($maxUses !== null) {
$row = (array) DB::table('discount_vouchers')
->select('times_used')
->where('id', $voucherId)
->first();
$usedNow = (int) ($row['times_used'] ?? 0);
if ($usedNow >= $maxUses) {
DB::table('discount_vouchers')
->where('id', $voucherId)
->update([
'is_active' => 0,
'updated_at' => function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString(),
]);
}
}
foreach (array_unique($touchedInvoiceIds) as $iid) {
try {
$this->invoiceService->recalculateInvoice($iid, $schoolYear);
} catch (\Throwable $e) {
Log::error('recalculateInvoice failed for invoice {id}: {err}', [
'id' => $iid,
'err' => $e->getMessage(),
]);
}
}
$updatedTotal = 0;
foreach (array_unique($fullyCoveredInvoiceIds) as $iid) {
try {
$updatedTotal += $this->invoiceService->updateEnrollmentStatusIfPaid($iid, $schoolYear);
} catch (\Throwable $e) {
Log::error('updateEnrollmentStatusIfPaid failed for invoice {id}: {err}', [
'id' => $iid,
'err' => $e->getMessage(),
]);
}
}
return [
'ok' => true,
'message' => $updatedTotal > 0
? 'Voucher applied successfully and enrollments updated.'
: 'Voucher applied successfully.',
'appliedCount' => $appliedCount,
'updatedEnrollments' => $updatedTotal,
'touchedInvoices' => array_values(array_unique($touchedInvoiceIds)),
];
}
private function triggerPaymentReceived(
int $invoiceId,
string $voucherId,
float $amount,
string $paymentDate,
float $preBalance,
float $postBalance,
string $schoolYear,
string $semester
): void {
try {
[$eventData, $studentData] = $this->invoiceService->buildPaymentEventData(
$invoiceId,
$voucherId,
$amount,
'discount',
$paymentDate,
'',
0,
$preBalance,
$postBalance,
$schoolYear,
$semester
);
if (class_exists(\CodeIgniter\Events\Events::class)) {
\CodeIgniter\Events\Events::trigger('paymentReceived', $eventData, $studentData);
} elseif (function_exists('event')) {
event('paymentReceived', [$eventData, $studentData]);
}
} catch (\Throwable $e) {
Log::warning('paymentReceived hook failed: ' . $e->getMessage());
}
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Services\Discounts;
use App\Models\Configuration;
class DiscountContextService
{
public function getSchoolYear(): string
{
return (string) (Configuration::getConfig('school_year') ?? '');
}
public function getSemester(): string
{
return (string) (Configuration::getConfig('semester') ?? '');
}
}
@@ -0,0 +1,394 @@
<?php
namespace App\Services\Discounts;
use App\Models\AdditionalCharge;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\EventCharges;
use App\Models\Invoice;
use App\Models\Payment;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class DiscountInvoiceService
{
public function getCurrentInvoiceBalance(int $invoiceId, string $schoolYear): float
{
$invoice = Invoice::query()->find($invoiceId);
if (!$invoice) {
return 0.0;
}
$paymentQuery = Payment::query()
->selectRaw('COALESCE(SUM(paid_amount),0) AS total_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear);
if (Schema::hasColumn('payments', 'status')) {
$paymentQuery->where(function ($q) {
$q->whereNotIn('status', [
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
])->orWhereNull('status');
});
}
if (Schema::hasColumn('payments', 'is_void')) {
$paymentQuery->where(function ($q) {
$q->where('is_void', 0)->orWhereNull('is_void');
});
}
$rowPaid = (array) $paymentQuery->first();
$totalPaid = (float) ($rowPaid['total_paid'] ?? 0);
$rowDisc = (array) DB::table('discount_usages as du')
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
->where('du.invoice_id', $invoiceId)
->where('i.school_year', $schoolYear)
->selectRaw('COALESCE(SUM(du.discount_amount),0) AS total_disc')
->first();
$totalDisc = (float) ($rowDisc['total_disc'] ?? 0);
$rowRefund = (array) DB::table('refunds')
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->first();
$totalRefundPaid = (float) ($rowRefund['total_refund_paid'] ?? 0);
$total = (float) ($invoice->total_amount ?? 0);
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
}
public function recalculateInvoice(int $invoiceId, string $schoolYear): void
{
$invoice = Invoice::query()->find($invoiceId);
if (!$invoice) {
return;
}
$parentId = (int) ($invoice->parent_id ?? 0);
if ($parentId <= 0) {
return;
}
$enrollments = Enrollment::query()
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->get()
->map(fn ($row) => (array) $row)
->all();
$registered = [];
$withdrawn = [];
foreach ($enrollments as $e) {
$row = [
'student_id' => (int) ($e['student_id'] ?? 0),
'class_section_id' => $e['class_section_id'] ?? null,
'enrollment_status' => (string) ($e['enrollment_status'] ?? ''),
];
if (in_array($row['enrollment_status'], ['enrolled', 'payment pending'], true)) {
$registered[] = $row;
} elseif (in_array($row['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
$withdrawn[] = $row;
}
}
$refundDeadline = (string) (Configuration::getConfig('refund_deadline') ?? '');
$refundAllowed = true;
if ($refundDeadline !== '') {
try {
$tzName = function_exists('user_timezone') ? (string) user_timezone() : 'UTC';
$tz = new \DateTimeZone($tzName);
$today = new \DateTimeImmutable('today', $tz);
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
$refundAllowed = $today <= $deadline;
} catch (\Throwable $e) {
$refundAllowed = true;
}
}
$tuitionStudents = $registered;
if (!$refundAllowed) {
$tuitionStudents = array_merge($tuitionStudents, $withdrawn);
}
$gradeFee = (int) (Configuration::getConfig('grade_fee') ?? 9);
$firstStudentFee = (float) (Configuration::getConfig('first_student_fee') ?? 350);
$secondStudentFee = (float) (Configuration::getConfig('second_student_fee') ?? 200);
$youthFee = (float) (Configuration::getConfig('youth_fee') ?? 180);
foreach ($tuitionStudents as &$s) {
$name = null;
if (!empty($s['class_section_id'])) {
$name = ClassSection::getClassSectionNameBySectionId($s['class_section_id']);
}
$s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A';
}
unset($s);
$regularCount = 0;
$youthCount = 0;
foreach ($tuitionStudents as $s) {
$lvl = $this->parseGradeLevel((string) ($s['grade_name'] ?? ''), $gradeFee);
if ($lvl > $gradeFee) {
$youthCount++;
} else {
$regularCount++;
}
}
$tuitionSubtotal = 0.0;
$tuitionSubtotal += $youthCount * $youthFee;
if ($regularCount >= 2) {
$tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee;
} elseif ($regularCount === 1) {
$tuitionSubtotal += $firstStudentFee;
}
$eventSubtotal = 0.0;
$events = EventCharges::getChargesWithEventInfo($parentId, $schoolYear) ?? [];
foreach ($events as $ev) {
$eventSubtotal += (float) ($ev['charged'] ?? 0.0);
}
$additionalSubtotal = 0.0;
$rows = AdditionalCharge::query()
->select('charge_type', 'amount')
->where('invoice_id', $invoiceId)
->where('status', 'applied')
->get()
->map(fn ($row) => (array) $row)
->all();
foreach ($rows as $r) {
$amt = (float) ($r['amount'] ?? 0);
$typ = strtolower((string) ($r['charge_type'] ?? 'add'));
$amt = $typ === 'deduct' ? -abs($amt) : abs($amt);
$additionalSubtotal += $amt;
}
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
$paymentQuery = Payment::query()
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear);
if (Schema::hasColumn('payments', 'status')) {
$paymentQuery->where(function ($q) {
$q->whereNotIn('status', [
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
])->orWhereNull('status');
});
}
if (Schema::hasColumn('payments', 'is_void')) {
$paymentQuery->where(function ($q) {
$q->where('is_void', 0)->orWhereNull('is_void');
});
}
$payments = $paymentQuery->get()->map(fn ($row) => (array) $row)->all();
$totalPaid = 0.0;
foreach ($payments as $p) {
$totalPaid += (float) ($p['paid_amount'] ?? 0);
}
$discRow = (array) DB::table('discount_usages')
->selectRaw('COALESCE(SUM(discount_amount),0) AS total_disc')
->where('invoice_id', $invoiceId)
->first();
$totalDisc = (float) ($discRow['total_disc'] ?? 0);
$refundRow = (array) DB::table('refunds')
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial', 'Paid'])
->first();
$totalRefundPaid = (float) ($refundRow['total_refund_paid'] ?? 0);
$newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid);
$newStatus = $newBalance <= 0.00001 ? 'Paid' : ($totalPaid > 0 ? 'Partially Paid' : 'Unpaid');
$updateData = [
'total_amount' => $newTotal,
'paid_amount' => $totalPaid,
'balance' => $newBalance,
'status' => $newStatus,
'has_discount' => $totalDisc > 0.0 ? 1 : 0,
];
if (Schema::hasColumn('invoices', 'discount')) {
$updateData['discount'] = $totalDisc;
}
Invoice::query()->whereKey($invoiceId)->update($updateData);
}
public function updateEnrollmentStatusIfPaid(int $invoiceId, string $schoolYear): int
{
$invoice = Invoice::query()->find($invoiceId);
if (!$invoice) {
return 0;
}
$total = (float) ($invoice->total_amount ?? 0);
$balance = (float) ($invoice->balance ?? 0);
if (!($total > 0 && $balance < $total)) {
return 0;
}
$parentId = (int) ($invoice->parent_id ?? 0);
if ($parentId <= 0 || $schoolYear === '') {
return 0;
}
$paidEnrollmentIds = [];
if (Schema::hasTable('invoice_items') && Schema::hasColumn('invoice_items', 'enrollment_id')) {
$rows = DB::table('invoice_items')
->select('enrollment_id')
->where('invoice_id', $invoiceId)
->whereNotNull('enrollment_id')
->get()
->map(fn ($row) => (array) $row)
->all();
foreach ($rows as $r) {
$eid = (int) ($r['enrollment_id'] ?? 0);
if ($eid > 0) {
$paidEnrollmentIds[] = $eid;
}
}
}
$query = Enrollment::query()
->select('id')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where(function ($q) {
$q->where('enrollment_status', 'payment pending')
->orWhere('enrollment_status', 'Payment pending')
->orWhere('enrollment_status', 'Payment Pending')
->orWhere('enrollment_status', 'PAYMENT PENDING')
->orWhereRaw("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'");
});
if (!empty($paidEnrollmentIds)) {
$query->whereIn('id', array_values(array_unique($paidEnrollmentIds)));
}
$toUpdateIds = $query->get()->pluck('id')->map(fn ($id) => (int) $id)->all();
if (empty($toUpdateIds)) {
return 0;
}
$affected = Enrollment::query()
->whereIn('id', $toUpdateIds)
->update([
'enrollment_status' => 'enrolled',
'enrollment_date' => $this->formatLocalDate('Y-m-d'),
]);
return (int) $affected;
}
public function buildPaymentEventData(
int $invoiceId,
string $transactionIdOrRef,
float $amount,
string $paymentMethod,
string $paymentDate,
?string $checkNumber,
int $installmentSeq,
float $preBalance,
float $postBalance,
string $schoolYear,
string $semester
): array {
$invoice = Invoice::query()->find($invoiceId);
if (!$invoice) {
throw new \RuntimeException("Invoice {$invoiceId} not found");
}
$parentRow = (array) DB::table('users')
->select('id', 'firstname', 'lastname', 'email')
->where('id', $invoice->parent_id)
->first();
if (empty($parentRow)) {
throw new \RuntimeException("Parent not found for invoice {$invoiceId}");
}
$studentRows = DB::table('students as s')
->select('s.id', 's.firstname', 's.lastname', 'sc.class_section_id')
->leftJoin('student_class as sc', 'sc.student_id', '=', 's.id')
->where('s.parent_id', $invoice->parent_id)
->where('sc.school_year', $schoolYear)
->get()
->map(fn ($row) => (array) $row)
->all();
$data = [
'user_id' => (int) $parentRow['id'],
'email' => $parentRow['email'],
'firstname' => $parentRow['firstname'],
'lastname' => $parentRow['lastname'],
'school_year' => $schoolYear,
'semester' => $semester,
'invoice_id' => $invoiceId,
'transaction_id' => $transactionIdOrRef,
'payment_date' => $paymentDate,
'amount' => $amount,
'method' => $paymentMethod,
'check_number' => $checkNumber,
'installment_seq' => $installmentSeq,
'invoice_total' => (float) $invoice->total_amount,
'pre_balance' => $preBalance,
'post_balance' => $postBalance,
'portalLink' => url('parent/invoices/' . $invoiceId),
];
return [$data, $studentRows];
}
private function parseGradeLevel(string $grade, int $gradeFee): int
{
if (is_numeric($grade)) {
return (int) $grade;
}
$g = strtoupper(trim($grade));
$g = preg_replace('/\\s+/', ' ', $g);
$g = str_replace(['.', '_', '-'], ['', '', ' '], $g);
$kg = ['K', 'KG', 'K G', 'KINDER', 'KINDERGARTEN'];
if (in_array($g, $kg, true)) {
return 1;
}
$pk = ['PK', 'P K', 'PREK', 'PRE K', 'PRE KINDER', 'PREKINDER'];
if (in_array($g, $pk, true)) {
return -1;
}
if (preg_match('/^Y(?:OUTH)?\\s*(\\d+)?$/', $g, $m)) {
$n = isset($m[1]) && $m[1] !== '' ? max(1, (int) $m[1]) : 1;
return $gradeFee + $n;
}
if (preg_match('/^(?:GR?ADE\\s*)?(\\d{1,2})\\s*([A-Z]*)$/', $g, $m)) {
return (int) $m[1];
}
return 999;
}
private function formatLocalDate(string $format): string
{
if (function_exists('local_date') && function_exists('utc_now')) {
return (string) local_date(utc_now(), $format);
}
return now()->format($format);
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Services\Discounts;
use App\Models\DiscountUsage;
use App\Models\User;
class DiscountParentService
{
public function listParentsWithDiscounts(string $schoolYear): array
{
$parents = User::getParents();
foreach ($parents as &$parent) {
$total = DiscountUsage::getTotalDiscountByParentIdAndSchoolYear(
(int) ($parent['id'] ?? 0),
$schoolYear
);
$parent['total_discount'] = $total;
$parent['has_discount'] = $total > 0 ? 1 : 0;
}
unset($parent);
return $parents;
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Services\Discounts;
use App\Models\DiscountVoucher;
class DiscountVoucherService
{
public function listAll(): array
{
return DiscountVoucher::query()
->orderByDesc('id')
->get()
->all();
}
public function listActive(): array
{
return DiscountVoucher::query()
->where('is_active', 1)
->orderByDesc('id')
->get()
->all();
}
public function create(array $payload): DiscountVoucher
{
return DiscountVoucher::query()->create($payload);
}
public function update(int $id, array $payload): DiscountVoucher
{
$voucher = DiscountVoucher::query()->findOrFail($id);
$voucher->fill($payload);
$voucher->save();
return $voucher;
}
public function find(int $id): DiscountVoucher
{
return DiscountVoucher::query()->findOrFail($id);
}
public function delete(int $id): void
{
DiscountVoucher::query()->whereKey($id)->delete();
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class EmailService
{
public function send(
array|string $to,
string $subject,
string $html,
string $fromKey = 'general',
array $cc = [],
array $bcc = []
): bool
{
$sender = $this->resolveSender($fromKey);
try {
Mail::html($html, function ($message) use ($to, $subject, $sender, $cc, $bcc) {
$message->to($to)->subject($subject);
if ($sender['email'] !== '') {
$message->from($sender['email'], $sender['name']);
}
if (!empty($cc)) {
$message->cc($cc);
}
if (!empty($bcc)) {
$message->bcc($bcc);
}
});
return true;
} catch (\Throwable $e) {
Log::error('EmailService send failed: ' . $e->getMessage());
return false;
}
}
private function resolveSender(string $fromKey): array
{
$fromKey = $fromKey !== '' ? $fromKey : 'general';
$json = env('MAIL_SENDERS', '{}');
$senders = json_decode($json, true);
if (is_array($senders) && isset($senders[$fromKey])) {
$info = $senders[$fromKey];
return [
'name' => (string) ($info['name'] ?? 'Sender'),
'email' => (string) ($info['email'] ?? ''),
];
}
$fallbackAddress = (string) (config('mail.from.address') ?: (getenv('SMTP_USER') ?: ''));
$fallbackName = (string) (config('mail.from.name') ?: 'Al Rahma Sunday School');
return [
'name' => $fallbackName,
'email' => $fallbackAddress,
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Services\Expenses;
use App\Models\Configuration;
class ExpenseContextService
{
public function getSchoolYear(): string
{
return (string) (Configuration::getConfig('school_year') ?? '');
}
public function getSemester(): string
{
return (string) (Configuration::getConfig('semester') ?? '');
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Services\Expenses;
use App\Models\Expense;
use Illuminate\Support\Facades\DB;
class ExpenseQueryService
{
public function listAll(): array
{
return $this->baseQuery()
->orderByDesc('expenses.created_at')
->get()
->map(fn ($row) => (array) $row)
->all();
}
public function findById(int $id): ?array
{
$row = $this->baseQuery()
->where('expenses.id', $id)
->first();
return $row ? (array) $row : null;
}
private function baseQuery()
{
return Expense::query()
->selectRaw('
expenses.*,
u.firstname AS purchaser_firstname,
u.lastname AS purchaser_lastname,
approver.firstname AS approver_firstname,
approver.lastname AS approver_lastname
')
->leftJoin('users as u', 'u.id', '=', 'expenses.purchased_by')
->leftJoin('users as approver', 'approver.id', '=', 'expenses.approved_by');
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Services\Expenses;
use Illuminate\Http\UploadedFile;
class ExpenseReceiptService
{
public function storeReceipt(UploadedFile $file): string
{
$stored = $file->store('receipts');
return basename($stored);
}
public function receiptUrl(?string $filename): ?string
{
if (!$filename) {
return null;
}
return url('receipts/' . $filename);
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Services\Expenses;
class ExpenseRetailorService
{
public function listRetailors(): array
{
return [
'Amazon',
'Walmart',
'Costco',
"BJ's",
'Market Basket',
'Aldi',
'Hannaford',
"Sam's Club",
'HomeGoods',
'Hostinger',
'Wicked Cheesy',
'Shatila',
'Brothers Pizzeria',
'Paradise Biryani Pointe',
'Emad Leiman',
'Nova Trampoline Park',
"Lubin's Awards",
'Dollar Tree',
'Stop & Shop',
"Dunkin' Donuts",
"Giovanni's Pizza",
'Trader Joes',
];
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Services\Expenses;
use Illuminate\Support\Facades\DB;
class ExpenseStaffService
{
public function listStaffUsers(): array
{
$rows = DB::table('users')
->select('users.id', 'users.firstname', 'users.lastname', 'roles.name AS role_name')
->leftJoin('user_roles', 'user_roles.user_id', '=', 'users.id')
->leftJoin('roles', 'roles.id', '=', 'user_roles.role_id')
->whereNotNull('roles.name')
->get()
->map(fn ($row) => (array) $row)
->all();
$excludedRoles = array_map('strtolower', ['parent', 'student', 'guest']);
$staff = [];
foreach ($rows as $row) {
$roleName = strtolower((string) ($row['role_name'] ?? ''));
$id = (int) ($row['id'] ?? 0);
if ($id <= 0 || in_array($roleName, $excludedRoles, true)) {
continue;
}
if (!isset($staff[$id])) {
$staff[$id] = [
'id' => $id,
'firstname' => $row['firstname'] ?? '',
'lastname' => $row['lastname'] ?? '',
];
}
}
uasort($staff, static function ($a, $b) {
$nameA = trim(($a['firstname'] ?? '') . ' ' . ($a['lastname'] ?? ''));
$nameB = trim(($b['firstname'] ?? '') . ' ' . ($b['lastname'] ?? ''));
return strcasecmp($nameA, $nameB);
});
return array_values($staff);
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Services\Expenses;
use App\Models\Expense;
class ExpenseStatusService
{
public function updateStatus(Expense $expense, string $status, string $reason, int $userId): Expense
{
$expense->status = $status;
$expense->status_reason = $reason;
$expense->approved_by = $userId;
$expense->updated_by = $userId;
$expense->save();
return $expense;
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Services\Expenses;
use App\Models\Expense;
class ExpenseWriteService
{
public function __construct(private ExpenseContextService $context) {}
public function store(array $payload): Expense
{
$schoolYear = $payload['school_year'] ?? $this->context->getSchoolYear();
$semester = $payload['semester'] ?? $this->context->getSemester();
$data = [
'category' => $payload['category'],
'amount' => $payload['amount'],
'receipt_path' => $payload['receipt_path'] ?? null,
'description' => $payload['description'] ?? null,
'retailor' => $payload['retailor'] ?? null,
'date_of_purchase' => $payload['date_of_purchase'],
'purchased_by' => $payload['purchased_by'],
'added_by' => $payload['added_by'],
'status' => $payload['status'],
'status_reason' => $payload['status_reason'] ?? null,
'approved_by' => $payload['approved_by'] ?? null,
'school_year' => $schoolYear,
'semester' => $semester,
];
return Expense::query()->create($data);
}
public function update(Expense $expense, array $payload): Expense
{
$expense->fill($payload);
$expense->save();
return $expense;
}
}
@@ -0,0 +1,235 @@
<?php
namespace App\Services\ExtraCharges;
use App\Models\AdditionalCharge;
use App\Models\Invoice;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ExtraChargesChargeService
{
public function __construct(
private ExtraChargesContextService $context,
private ExtraChargesInvoiceService $invoiceService
) {}
public function createCharge(array $payload): array
{
$invoiceId = $payload['invoice_id'] ?? null;
$chargeType = $payload['charge_type'];
$amountAbs = round(abs((float) $payload['amount']), 2);
$signedAmount = $chargeType === 'add' ? $amountAbs : -$amountAbs;
$schoolYear = $payload['school_year'] ?? $this->context->getSchoolYear();
$semester = $payload['semester'] ?? $this->context->getSemester();
$data = [
'parent_id' => (int) $payload['parent_id'],
'invoice_id' => $invoiceId,
'school_year' => $schoolYear,
'semester' => $semester,
'charge_type' => $chargeType,
'title' => trim($payload['title']),
'description' => trim((string) ($payload['description'] ?? '')),
'amount' => $signedAmount,
'due_date' => $payload['due_date'] ?? null,
'status' => $invoiceId ? 'applied' : 'pending',
'created_by' => (int) ($payload['created_by'] ?? 0),
'created_at' => $payload['created_at'] ?? $this->utcNow(),
];
$invoiceBefore = $invoiceId ? Invoice::getInvoicesByParentId((int) $payload['parent_id'], $schoolYear) : [];
$invoiceAfter = [];
DB::beginTransaction();
try {
$chargeId = AdditionalCharge::query()->create($data)->id;
if ($invoiceId) {
$this->invoiceService->applyToInvoice((int) $invoiceId, $chargeType, $amountAbs);
}
$invoiceAfter = $invoiceId ? Invoice::getInvoicesByParentId((int) $payload['parent_id'], $schoolYear) : [];
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Extra charge create failed: ' . $e->getMessage());
return ['ok' => false, 'message' => 'Failed to save charge.'];
}
$parentUser = User::getUserInfoById((int) $payload['parent_id']);
$this->triggerExtraChargeEvent($parentUser, $data, (int) $chargeId, $invoiceId, $invoiceBefore, $invoiceAfter);
return [
'ok' => true,
'id' => (int) $chargeId,
'invoice_id' => $invoiceId ? (int) $invoiceId : null,
'parent_id' => (int) $payload['parent_id'],
];
}
public function updateCharge(AdditionalCharge $charge, array $payload): bool
{
$chargeType = $payload['charge_type'] ?? $charge->charge_type ?? 'add';
$amountRaw = isset($payload['amount']) ? (float) $payload['amount'] : (float) $charge->amount;
$newAmount = $chargeType === 'add' ? abs($amountRaw) : -abs($amountRaw);
$delta = $newAmount - (float) $charge->amount;
DB::beginTransaction();
try {
$charge->title = trim($payload['title'] ?? $charge->title);
$charge->description = trim($payload['description'] ?? $charge->description);
$charge->amount = $newAmount;
$charge->due_date = $payload['due_date'] ?? $charge->due_date;
$charge->charge_type = $chargeType;
if (array_key_exists('updated_by', $payload)) {
$charge->updated_by = $payload['updated_by'];
}
$charge->save();
if ($charge->status === 'applied' && !empty($charge->invoice_id) && abs($delta) > 0.00001) {
$this->invoiceService->adjustDelta((int) $charge->invoice_id, $delta);
}
DB::commit();
return true;
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Extra charge update failed: ' . $e->getMessage());
return false;
}
}
public function voidCharge(AdditionalCharge $charge): bool
{
DB::beginTransaction();
try {
$invoiceId = (int) ($charge->invoice_id ?? 0);
$amountAbs = round(abs((float) ($charge->amount ?? 0)), 2);
$chargeType = (string) ($charge->charge_type ?? 'add');
$status = (string) ($charge->status ?? 'pending');
if ($status === 'applied' && $invoiceId > 0 && $amountAbs > 0) {
$this->invoiceService->reverseOnInvoice($invoiceId, $chargeType, $amountAbs);
}
$charge->status = 'void';
$charge->save();
DB::commit();
return true;
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Extra charge void failed: ' . $e->getMessage());
return false;
}
}
public function reverseCharge(AdditionalCharge $charge): bool
{
$invoiceId = (int) ($charge->invoice_id ?? 0);
$amountAbs = round(abs((float) ($charge->amount ?? 0)), 2);
$chargeType = (string) ($charge->charge_type ?? 'add');
$status = (string) ($charge->status ?? 'pending');
if ($status !== 'applied' || $invoiceId <= 0 || $amountAbs <= 0) {
return false;
}
DB::beginTransaction();
try {
$this->invoiceService->reverseOnInvoice($invoiceId, $chargeType, $amountAbs);
$charge->status = 'pending';
$charge->invoice_id = null;
$charge->save();
DB::commit();
return true;
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Extra charge reverse failed: ' . $e->getMessage());
return false;
}
}
private function triggerExtraChargeEvent(?array $parentUser, array $payload, int $chargeId, ?int $invoiceId, array $invoiceBefore, array $invoiceAfter): void
{
if (!$parentUser) {
return;
}
$first = $parentUser['first_name'] ?? $parentUser['firstname'] ?? '';
$last = $parentUser['last_name'] ?? $parentUser['lastname'] ?? '';
$parentName = trim($first . ' ' . $last);
$before = $this->normalizeRow($invoiceBefore);
$after = $this->normalizeRow($invoiceAfter);
$invoiceTotal = $after ? (float) ($after['total_amount'] ?? 0) : 0.0;
$preBalance = $before ? (float) ($before['balance'] ?? 0) : 0.0;
$postBalance = $after ? (float) ($after['balance'] ?? 0) : 0.0;
$eventData = [
'user_id' => (int) ($parentUser['id'] ?? 0),
'email' => $parentUser['email'] ?? null,
'firstname' => $first,
'lastname' => $last,
'parentName' => $parentName,
'school_year' => $payload['school_year'],
'semester' => $payload['semester'],
'invoice_id' => $invoiceId,
'invoice_number' => $after['invoice_number'] ?? ($invoiceId ? 'INV-' . $invoiceId : null),
'charge_id' => $chargeId,
'charge_title' => $payload['title'],
'charge_desc' => $payload['description'],
'charge_type' => $payload['charge_type'],
'amount_signed' => $payload['amount'],
'amount_abs' => abs((float) $payload['amount']),
'due_date' => $payload['due_date'],
'created_at' => $payload['created_at'],
'pre_balance' => $preBalance,
'post_balance' => $postBalance,
'invoice_total' => $invoiceTotal,
'portal_link' => url('/login'),
'invoice_link' => $invoiceId ? url('parent/invoices/view/' . $invoiceId) : url('parent/invoices'),
];
$students = [];
try {
if (class_exists(\CodeIgniter\Events\Events::class)) {
\CodeIgniter\Events\Events::trigger('extraCharge', $eventData, $students);
} elseif (function_exists('event')) {
event('extraCharge', [$eventData, $students]);
}
} catch (\Throwable $e) {
Log::warning('extraCharge event failed: ' . $e->getMessage());
}
}
private function normalizeRow($row): ?array
{
if (!$row) {
return null;
}
if (is_array($row) && isset($row[0]) && is_array($row[0])) {
return $row[0];
}
return is_array($row) ? $row : null;
}
private function utcNow(): string
{
if (function_exists('utc_now')) {
return (string) utc_now();
}
return now('UTC')->toDateTimeString();
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Services\ExtraCharges;
use App\Models\Configuration;
class ExtraChargesContextService
{
public function getSchoolYear(): string
{
return (string) (Configuration::getConfig('school_year') ?? '');
}
public function getSemester(): string
{
return (string) (Configuration::getConfig('semester') ?? '');
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Services\ExtraCharges;
use App\Models\Invoice;
class ExtraChargesInvoiceService
{
public function listInvoicesForParent(int $parentId, string $schoolYear): array
{
if ($parentId <= 0) {
return [];
}
$rows = Invoice::getInvoicesByParentId($parentId, $schoolYear) ?? [];
return array_map(function ($inv) {
$id = (int) ($inv['id'] ?? 0);
$num = (string) ($inv['invoice_number'] ?? ('INV-' . $id));
$status = strtolower((string) ($inv['status'] ?? ''));
$balance = (float) ($inv['balance'] ?? 0);
$issue = $inv['issue_date'] ?? null;
return [
'id' => $id,
'invoice_number' => $num,
'status' => $status,
'balance' => $balance,
'issue_date' => $issue,
'text' => $num
. ($status ? ' — ' . ucfirst($status) : '')
. ' (Bal: $' . number_format($balance, 2) . ')',
];
}, $rows);
}
public function applyToInvoice(int $invoiceId, string $chargeType, float $amountAbs): void
{
if ($invoiceId <= 0 || $amountAbs <= 0) {
return;
}
if ($chargeType === 'add') {
Invoice::applyAdditionalCharge($invoiceId, $amountAbs);
} else {
Invoice::deductAdditionalCharge($invoiceId, $amountAbs);
}
}
public function reverseOnInvoice(int $invoiceId, string $chargeType, float $amountAbs): void
{
if ($invoiceId <= 0 || $amountAbs <= 0) {
return;
}
if ($chargeType === 'add') {
Invoice::reverseAdditionalCharge($invoiceId, $amountAbs);
} else {
Invoice::applyAdditionalCharge($invoiceId, $amountAbs);
}
}
public function adjustDelta(int $invoiceId, float $delta): void
{
if ($invoiceId <= 0 || abs($delta) <= 0.00001) {
return;
}
if ($delta > 0) {
Invoice::applyAdditionalCharge($invoiceId, $delta);
} else {
Invoice::reverseAdditionalCharge($invoiceId, abs($delta));
}
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\ExtraCharges;
use App\Models\AdditionalCharge;
class ExtraChargesListService
{
public function listForTerm(string $schoolYear, string $semester, ?string $status, ?string $q, int $perPage): array
{
$rows = AdditionalCharge::listAllForTerm($schoolYear, $semester, $status, $q, $perPage);
$meta = [
'perPage' => method_exists($rows, 'perPage') ? $rows->perPage() : $perPage,
'page' => method_exists($rows, 'currentPage') ? $rows->currentPage() : null,
'total' => method_exists($rows, 'total') ? $rows->total() : null,
'pageCount' => method_exists($rows, 'lastPage') ? $rows->lastPage() : null,
];
$items = method_exists($rows, 'items') ? $rows->items() : (array) $rows;
return [$items, $meta];
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Services\ExtraCharges;
use Illuminate\Support\Facades\DB;
class ExtraChargesMetaService
{
public function getSchoolYears(string $fallbackYear = ''): array
{
$schoolYears = [];
try {
$rows = DB::table('additional_charges')
->selectRaw('DISTINCT school_year')
->whereNotNull('school_year')
->orderByDesc('school_year')
->get()
->map(fn ($row) => (array) $row)
->all();
foreach ($rows as $row) {
$val = (string) ($row['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYears, true)) {
$schoolYears[] = $val;
}
}
} catch (\Throwable $e) {
}
try {
$rows = DB::table('invoices')
->selectRaw('DISTINCT school_year')
->whereNotNull('school_year')
->orderByDesc('school_year')
->get()
->map(fn ($row) => (array) $row)
->all();
foreach ($rows as $row) {
$val = (string) ($row['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYears, true)) {
$schoolYears[] = $val;
}
}
} catch (\Throwable $e) {
}
if (empty($schoolYears) && $fallbackYear !== '') {
$schoolYears[] = $fallbackYear;
if (str_contains($fallbackYear, '-')) {
[$start] = explode('-', $fallbackYear) + [0 => ''];
$start = (int) $start;
if ($start > 0) {
for ($i = 1; $i <= 3; $i++) {
$schoolYears[] = ($start - $i) . '-' . (($start - $i) + 1);
}
}
}
}
rsort($schoolYears);
return array_values(array_unique($schoolYears));
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Services\ExtraCharges;
use Illuminate\Support\Facades\DB;
class ExtraChargesParentService
{
public function searchParents(string $q = '', int $limit = 20): array
{
$builder = DB::table('users')
->select('users.id', 'users.firstname', 'users.lastname', 'users.email')
->join('user_roles', 'user_roles.user_id', '=', 'users.id')
->join('roles', 'roles.id', '=', 'user_roles.role_id')
->where('roles.name', 'parent')
->whereNull('user_roles.deleted_at');
if ($q !== '') {
$builder->where(function ($query) use ($q) {
$query->where('users.firstname', 'like', "%{$q}%")
->orWhere('users.lastname', 'like', "%{$q}%")
->orWhere('users.email', 'like', "%{$q}%");
});
}
$rows = $builder
->orderBy('users.lastname', 'ASC')
->orderBy('users.firstname', 'ASC')
->limit($limit)
->get()
->map(fn ($row) => (array) $row)
->all();
return array_map(function ($row) {
$name = trim(($row['lastname'] ?? '') . ', ' . ($row['firstname'] ?? ''));
$email = $row['email'] ?? '';
$label = $name !== ',' ? $name : ('User #' . $row['id']);
if ($email) {
$label .= ' — ' . $email;
}
$label .= ' (ID: ' . $row['id'] . ')';
return [
'id' => (int) $row['id'],
'text' => $label,
];
}, $rows);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Services\Files;
use Illuminate\Support\Facades\DB;
class ExamDraftDownloadNameService
{
public function build(string $filename, string $subdir): string
{
$column = $subdir === 'finals' ? 'final_file' : 'teacher_file';
$row = DB::table('exam_drafts as ed')
->select('ed.version', 'ed.exam_type', 'ed.class_section_id', 'cs.class_section_name')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ed.class_section_id')
->where('ed.' . $column, $filename)
->limit(1)
->first();
$row = $row ? (array) $row : [];
$classLabel = trim((string) ($row['class_section_name'] ?? ('Class' . ($row['class_section_id'] ?? '0'))));
$typeLabel = trim((string) ($row['exam_type'] ?? 'Exam'));
$version = 'v' . max(1, (int) ($row['version'] ?? 1));
$parts = array_filter([
$this->slugify($classLabel),
$this->slugify($typeLabel),
$version,
]);
return implode('_', $parts);
}
private function slugify(string $value): string
{
$value = trim($value);
$value = preg_replace('/[^\p{L}\p{N}]+/u', '_', $value);
$value = trim($value, '_');
if ($value === '') {
return 'Exam';
}
return mb_strtolower($value);
}
}
+112
View File
@@ -0,0 +1,112 @@
<?php
namespace App\Services\Files;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\HttpException;
class FileServeService
{
public function meta(string $baseDir, string $name, array $allowedExtensions, ?string $downloadName = null): array
{
return $this->resolveFile($baseDir, $name, $allowedExtensions, $downloadName);
}
public function serveInline(
string $baseDir,
string $name,
array $allowedExtensions,
Request $request,
?string $downloadName = null,
bool $nosniff = false
): Response {
$meta = $this->resolveFile($baseDir, $name, $allowedExtensions, $downloadName);
$ifNoneMatch = trim((string) $request->headers->get('If-None-Match', ''), '"');
$ifModifiedSince = (string) $request->headers->get('If-Modified-Since', '');
$imsTime = $ifModifiedSince !== '' ? strtotime($ifModifiedSince) : false;
if (($ifNoneMatch !== '' && $ifNoneMatch === $meta['etag']) ||
($imsTime !== false && $imsTime >= $meta['mtime'])
) {
return response('', 304, $this->notModifiedHeaders($meta));
}
$headers = [
'Content-Type' => $meta['mime'],
'Content-Disposition' => 'inline; filename="' . $meta['download_name'] . '"',
'Content-Length' => (string) $meta['size'],
'ETag' => $meta['etag'],
'Last-Modified' => $meta['last_modified'],
'Cache-Control' => 'public, max-age=86400',
];
if ($nosniff) {
$headers['X-Content-Type-Options'] = 'nosniff';
}
return response(file_get_contents($meta['path']), 200, $headers);
}
private function resolveFile(string $baseDir, string $name, array $allowedExtensions, ?string $downloadName): array
{
if ($name !== basename($name)) {
throw new HttpException(400, 'Invalid filename');
}
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if (!in_array($ext, $allowedExtensions, true)) {
throw new HttpException(404, 'File not found');
}
$path = rtrim($baseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $name;
if (!is_file($path)) {
throw new HttpException(404, 'File not found');
}
$mime = $this->detectMime($path);
$mtime = filemtime($path) ?: time();
$size = filesize($path) ?: 0;
$etag = md5($name . '|' . $mtime . '|' . $size);
$downloadName = $downloadName ? $downloadName . '.' . $ext : $name;
return [
'name' => $name,
'path' => $path,
'mime' => $mime,
'mtime' => $mtime,
'size' => $size,
'etag' => $etag,
'download_name' => $downloadName,
'last_modified' => gmdate('D, d M Y H:i:s', $mtime) . ' GMT',
];
}
private function detectMime(string $path): string
{
$mime = 'application/octet-stream';
if (function_exists('finfo_open')) {
$fi = finfo_open(FILEINFO_MIME_TYPE);
if ($fi) {
$detected = finfo_file($fi, $path);
if ($detected) {
$mime = $detected;
}
finfo_close($fi);
}
} elseif (function_exists('mime_content_type')) {
$mime = mime_content_type($path) ?: $mime;
}
return $mime;
}
private function notModifiedHeaders(array $meta): array
{
return [
'ETag' => $meta['etag'],
'Last-Modified' => $meta['last_modified'],
];
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Services\Finance;
class FinancialChartService
{
public function generateBarChart(array $summary): ?string
{
if ($this->skipRemoteCharts()) {
return null;
}
$chartData = [
'type' => 'bar',
'data' => [
'labels' => ['Charges', 'Paid', 'Unpaid', 'Discounts', 'Refunds', 'Expenses', 'Reimbursements', 'Net'],
'datasets' => [[
'label' => 'Amount (USD)',
'data' => [
$summary['totalCharges'] ?? 0,
$summary['amountCollected'] ?? 0,
$summary['totalUnpaid'] ?? 0,
$summary['totalDiscounts'] ?? 0,
$summary['totalRefunds'] ?? 0,
$summary['totalExpenses'] ?? 0,
$summary['totalReimbursements'] ?? 0,
$summary['netAmount'] ?? 0,
],
'backgroundColor' => 'rgba(54, 162, 235, 0.6)',
]],
],
];
return $this->downloadChart($chartData, 'bar_chart.png');
}
public function generatePieChart(array $summary): ?string
{
if ($this->skipRemoteCharts()) {
return null;
}
$chartData = [
'type' => 'pie',
'data' => [
'labels' => ['Expenses', 'Reimbursements'],
'datasets' => [[
'data' => [
$summary['totalExpenses'] ?? 0,
$summary['totalReimbursements'] ?? 0,
],
'backgroundColor' => ['#e74c3c', '#8e44ad'],
]],
],
];
return $this->downloadChart($chartData, 'pie_chart.png');
}
private function downloadChart(array $chartData, string $filename): ?string
{
$baseUrl = 'https://quickchart.io/chart?c=';
$url = $baseUrl . urlencode(json_encode($chartData));
$targetDir = storage_path('app/reports');
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
return null;
}
try {
$contents = @file_get_contents($url);
if ($contents === false) {
return null;
}
$path = $targetDir . DIRECTORY_SEPARATOR . $filename;
file_put_contents($path, $contents);
return $path;
} catch (\Throwable $e) {
return null;
}
}
private function skipRemoteCharts(): bool
{
if (app()->environment('testing')) {
return true;
}
return (bool) config('services.quickchart.disabled', false);
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Services\Finance;
class FinancialDonationRecipientService
{
public const SPECIAL_RECIPIENTS = [
990001 => 'Masjid',
990002 => 'Donation',
];
public function recipientIds(): array
{
return array_map('intval', array_keys(self::SPECIAL_RECIPIENTS));
}
}
@@ -0,0 +1,119 @@
<?php
namespace App\Services\Finance;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class FinancialPaymentService
{
public function paymentsByInvoice(?string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
$query = $this->applyPaymentFilters($this->buildPaymentQuery($schoolYear, $dateFrom, $dateTo));
return $query
->selectRaw('invoice_id, SUM(paid_amount) AS paid_amount')
->whereNotNull('invoice_id')
->groupBy('invoice_id')
->get()
->map(fn ($row) => (array) $row)
->all();
}
public function paymentBreakdown(?string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
$query = $this->applyPaymentFilters($this->buildPaymentQuery($schoolYear, $dateFrom, $dateTo));
$rows = $query
->selectRaw("
invoice_id,
CASE
WHEN LOWER(TRIM(payment_method)) = 'cash' THEN 'cash'
WHEN LOWER(TRIM(payment_method)) IN ('check','cheque') THEN 'check'
WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN 'credit'
ELSE 'other'
END AS method,
SUM(paid_amount) AS amount
")
->whereNotNull('invoice_id')
->groupBy('invoice_id', 'method')
->get()
->map(fn ($row) => (array) $row)
->all();
$breakdown = [];
foreach ($rows as $row) {
$invoiceId = (int) ($row['invoice_id'] ?? 0);
if ($invoiceId <= 0) {
continue;
}
$method = (string) ($row['method'] ?? '');
$amount = (float) ($row['amount'] ?? 0);
if (!isset($breakdown[$invoiceId])) {
$breakdown[$invoiceId] = [];
}
$breakdown[$invoiceId][$method] = $amount;
}
return $breakdown;
}
public function paymentTotals(?string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
$query = $this->applyPaymentFilters($this->buildPaymentQuery($schoolYear, $dateFrom, $dateTo));
$row = (array) $query
->selectRaw("
SUM(paid_amount) AS total_all,
SUM(CASE WHEN LOWER(TRIM(payment_method)) = 'cash' THEN paid_amount ELSE 0 END) AS total_cash,
SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('check','cheque') THEN paid_amount ELSE 0 END) AS total_check,
SUM(CASE WHEN LOWER(TRIM(payment_method)) IN ('credit','card','credit card','debit','debit card','visa','mastercard') THEN paid_amount ELSE 0 END) AS total_credit
")
->whereNotNull('invoice_id')
->first();
return [
'total_all' => (float) ($row['total_all'] ?? 0),
'total_cash' => (float) ($row['total_cash'] ?? 0),
'total_check' => (float) ($row['total_check'] ?? 0),
'total_credit' => (float) ($row['total_credit'] ?? 0),
];
}
private function buildPaymentQuery(?string $schoolYear, ?string $dateFrom, ?string $dateTo): Builder
{
$query = DB::table('payments');
if (!empty($schoolYear)) {
$query->where('school_year', $schoolYear);
}
if (!empty($dateFrom)) {
$query->whereRaw('DATE(payment_date) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$query->whereRaw('DATE(payment_date) <= ?', [$dateTo]);
}
return $query;
}
private function applyPaymentFilters(Builder $query): Builder
{
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
if (Schema::hasColumn('payments', 'status')) {
$query->where(function ($q) use ($exclude) {
$q->whereNotIn('status', $exclude)->orWhereNull('status');
});
}
if (Schema::hasColumn('payments', 'is_void')) {
$query->where(function ($q) {
$q->where('is_void', 0)->orWhereNull('is_void');
});
}
return $query;
}
}
@@ -0,0 +1,62 @@
<?php
namespace App\Services\Finance;
class FinancialPdfReportService
{
public function __construct(private FinancialChartService $charts)
{
}
public function buildPdf(array $summary): string
{
$pdf = new \FPDF();
$pdf->AddPage();
$pdf->SetFont('Arial', 'B', 16);
$pdf->Cell(0, 10, 'Financial Report for School Year: ' . ($summary['schoolYear'] ?? ''), 0, 1, 'C');
$pdf->Ln(10);
$pdf->SetFont('Arial', 'B', 12);
$pdf->Cell(130, 10, 'Description', 1);
$pdf->Cell(60, 10, 'Amount (USD)', 1);
$pdf->Ln();
$rows = [
'Total Charges' => $summary['totalCharges'] ?? 0,
'Total Extra Charges' => $summary['totalExtraCharges'] ?? 0,
'Total Discounts' => $summary['totalDiscounts'] ?? 0,
'Total Refunds' => $summary['totalRefunds'] ?? 0,
'Total Expenses' => $summary['totalExpenses'] ?? 0,
'Total Reimbursements' => $summary['totalReimbursements'] ?? 0,
'Donation to School (Masjid/Donation reimbursements)' => $summary['donationToSchool'] ?? 0,
'Net Amount (Earned Income)' => $summary['netAmount'] ?? 0,
'Amount Collected (Paid)' => $summary['amountCollected'] ?? 0,
'Amount Unpaid (Outstanding)' => $summary['totalUnpaid'] ?? 0,
];
$pdf->SetFont('Arial', '', 11);
foreach ($rows as $label => $amount) {
$pdf->Cell(130, 10, $label, 1);
$pdf->Cell(60, 10, '$' . number_format((float) $amount, 2), 1);
$pdf->Ln();
}
$barChart = $this->charts->generateBarChart($summary);
if (!empty($barChart) && file_exists($barChart)) {
$pdf->Ln(10);
$pdf->SetFont('Arial', 'B', 12);
$pdf->Cell(0, 10, 'Summary Graph', 0, 1);
$pdf->Image($barChart, null, null, 180);
}
$pieChart = $this->charts->generatePieChart($summary);
if (!empty($pieChart) && file_exists($pieChart)) {
$pdf->Ln(10);
$pdf->SetFont('Arial', 'B', 12);
$pdf->Cell(0, 10, 'Expense Breakdown', 0, 1);
$pdf->Image($pieChart, null, null, 120);
}
return $pdf->Output('S');
}
}
@@ -0,0 +1,104 @@
<?php
namespace App\Services\Finance;
use Illuminate\Support\Facades\DB;
class FinancialReportService
{
public function __construct(
private FinancialPaymentService $payments,
private FinancialSchoolYearService $schoolYears
) {
}
public function getReport(?string $dateFrom, ?string $dateTo, ?string $schoolYear): array
{
$invoiceQuery = DB::table('invoices')
->selectRaw("invoices.*, CONCAT(users.firstname, ' ', users.lastname) AS parent_name")
->join('users', 'users.id', '=', 'invoices.parent_id');
$refundQuery = DB::table('refunds');
$discountQuery = DB::table('discount_usages');
$expenseQuery = DB::table('expenses');
$reimbQuery = DB::table('reimbursements');
if (!empty($schoolYear)) {
$invoiceQuery->where('invoices.school_year', $schoolYear);
$refundQuery->where('school_year', $schoolYear);
$discountQuery->where('school_year', $schoolYear);
$expenseQuery->where('school_year', $schoolYear);
$reimbQuery->where('school_year', $schoolYear);
}
if (!empty($dateFrom)) {
$invoiceQuery->whereRaw('DATE(COALESCE(invoices.issue_date, invoices.created_at)) >= ?', [$dateFrom]);
$refundQuery->whereRaw('DATE(COALESCE(refunded_at, created_at)) >= ?', [$dateFrom]);
$discountQuery->whereRaw('DATE(COALESCE(used_at, created_at)) >= ?', [$dateFrom]);
$expenseQuery->whereRaw('DATE(created_at) >= ?', [$dateFrom]);
$reimbQuery->whereRaw('DATE(created_at) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$invoiceQuery->whereRaw('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <= ?', [$dateTo]);
$refundQuery->whereRaw('DATE(COALESCE(refunded_at, created_at)) <= ?', [$dateTo]);
$discountQuery->whereRaw('DATE(COALESCE(used_at, created_at)) <= ?', [$dateTo]);
$expenseQuery->whereRaw('DATE(created_at) <= ?', [$dateTo]);
$reimbQuery->whereRaw('DATE(created_at) <= ?', [$dateTo]);
}
$invoices = $invoiceQuery->get()->map(fn ($row) => (array) $row)->all();
$payments = $this->payments->paymentsByInvoice($schoolYear, $dateFrom, $dateTo);
$paymentBreakdown = $this->payments->paymentBreakdown($schoolYear, $dateFrom, $dateTo);
$paymentTotals = $this->payments->paymentTotals($schoolYear, $dateFrom, $dateTo);
$refunds = $refundQuery
->selectRaw('invoice_id, school_year, SUM(refund_paid_amount) AS total_refunded')
->whereNotNull('invoice_id')
->whereIn('status', ['Partial', 'Paid'])
->groupBy('invoice_id', 'school_year')
->get()
->map(fn ($row) => (array) $row)
->all();
$discounts = $discountQuery
->selectRaw('invoice_id, school_year, SUM(discount_amount) AS discount_amount')
->whereNotNull('invoice_id')
->groupBy('invoice_id', 'school_year')
->get()
->map(fn ($row) => (array) $row)
->all();
$expenses = $expenseQuery
->selectRaw('category, SUM(amount) AS total_amount')
->groupBy('category')
->get()
->map(fn ($row) => (array) $row)
->all();
$reimbursements = $reimbQuery
->selectRaw('status, SUM(amount) AS total_amount')
->groupBy('status')
->get()
->map(fn ($row) => (array) $row)
->all();
$schoolYears = $this->schoolYears->listYears($schoolYear);
return [
'selectedYear' => $schoolYear,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'schoolYears' => $schoolYears,
'invoices' => $invoices,
'payments' => $payments,
'paymentBreakdown' => $paymentBreakdown,
'paymentTotals' => $paymentTotals,
'refunds' => $refunds,
'expenses' => $expenses,
'reimbursements' => $reimbursements,
'discounts' => $discounts,
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Services\Finance;
use Illuminate\Support\Facades\DB;
class FinancialSchoolYearService
{
public function listYears(?string $fallbackYear = null): array
{
$years = DB::table('invoices')
->select('school_year')
->whereNotNull('school_year')
->distinct()
->orderBy('school_year', 'DESC')
->pluck('school_year')
->map(static fn ($val) => (string) $val)
->filter(static fn ($val) => $val !== '')
->values()
->all();
if (empty($years) && !empty($fallbackYear)) {
$years[] = (string) $fallbackYear;
}
return $years;
}
}
@@ -0,0 +1,479 @@
<?php
namespace App\Services\Finance;
use App\Models\Configuration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class FinancialSummaryService
{
public function __construct(
private FinancialDonationRecipientService $donationRecipients
) {
}
public function getSummary(?string $dateFrom, ?string $dateTo, ?string $schoolYear): array
{
$schoolYear = $schoolYear ?: (Configuration::getConfig('school_year') ?? date('Y'));
$derivedDateFrom = null;
$derivedDateTo = null;
if (!empty($schoolYear) && empty($dateFrom) && empty($dateTo)) {
if (preg_match('/^(\\d{4})[^\\d]?(\\d{4})$/', $schoolYear, $m)) {
$derivedDateFrom = $m[1] . '-01-01';
$derivedDateTo = $m[2] . '-12-31';
}
}
$invoiceDateFrom = $dateFrom ?: $derivedDateFrom;
$invoiceDateTo = $dateTo ?: $derivedDateTo;
$invoiceQuery = DB::table('invoices')->where('school_year', $schoolYear);
if (!empty($invoiceDateFrom)) {
$invoiceQuery->whereRaw('DATE(COALESCE(issue_date, created_at)) >= ?', [$invoiceDateFrom]);
}
if (!empty($invoiceDateTo)) {
$invoiceQuery->whereRaw('DATE(COALESCE(issue_date, created_at)) <= ?', [$invoiceDateTo]);
}
$invoices = $invoiceQuery->get()->map(fn ($row) => (array) $row)->all();
$totalCharges = array_sum(array_map(static fn ($row) => (float) ($row['total_amount'] ?? 0), $invoices));
$invoiceIds = array_values(array_unique(array_filter(array_map(static function ($row) {
$id = (int) ($row['id'] ?? 0);
return $id > 0 ? $id : null;
}, $invoices))));
$totalExtraCharges = $this->sumAdditionalCharges($schoolYear, $dateFrom, $dateTo);
$extraByParent = $this->extraChargesByParent($schoolYear, $dateFrom, $dateTo);
$extraChargesUnapplied = array_sum($extraByParent);
$totalCharges += $extraChargesUnapplied;
$totalPaid = $this->sumPayments($schoolYear, $invoiceDateFrom, $invoiceDateTo);
$paidByInvoice = [];
$discountByInvoice = [];
$refundByInvoice = [];
if (!empty($invoiceIds)) {
$paidByInvoice = $this->paymentsByInvoiceIds($invoiceIds, $schoolYear, $invoiceDateFrom, $invoiceDateTo);
$discountByInvoice = $this->discountsByInvoiceIds($invoiceIds, $dateFrom, $dateTo);
$refundByInvoice = $this->refundsByInvoiceIds($invoiceIds, $dateFrom, $dateTo);
}
$totalExpenses = $this->sumExpenses($schoolYear, $invoiceDateFrom, $invoiceDateTo);
$totalReimbursements = $this->sumReimbursements($schoolYear, $invoiceDateFrom, $invoiceDateTo);
$donationToSchool = $this->sumDonationToSchool($schoolYear, $invoiceDateFrom, $invoiceDateTo);
$totalReimbursements = max(0.0, $totalReimbursements - $donationToSchool['donationReimb']);
$totalRefunds = $this->sumRefunds($schoolYear, $dateFrom, $dateTo);
$totalDiscounts = $this->sumDiscounts($schoolYear, $dateFrom, $dateTo);
$totalUnpaid = $this->sumUnpaidBalances($invoices, $paidByInvoice, $discountByInvoice, $refundByInvoice, $extraByParent);
$amountCollected = $totalPaid;
$netAmount = $totalCharges - $totalDiscounts - $totalRefunds;
return [
'schoolYear' => $schoolYear,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'totalCharges' => $totalCharges,
'totalExtraCharges' => $totalExtraCharges,
'totalDiscounts' => $totalDiscounts,
'totalRefunds' => $totalRefunds,
'totalExpenses' => $totalExpenses,
'totalReimbursements' => $totalReimbursements,
'donationToSchool' => $donationToSchool['donationToSchool'],
'totalPaid' => $totalPaid,
'amountCollected' => $amountCollected,
'totalUnpaid' => $totalUnpaid,
'netAmount' => $netAmount,
];
}
private function sumAdditionalCharges(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
{
$query = DB::table('additional_charges as ac')
->selectRaw('COALESCE(SUM(ac.amount), 0) AS amount')
->where('ac.school_year', $schoolYear)
->where('ac.status !=', 'void');
if (!empty($dateFrom)) {
$query->whereRaw('DATE(ac.due_date) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$query->whereRaw('DATE(ac.due_date) <= ?', [$dateTo]);
}
$row = (array) $query->first();
return (float) ($row['amount'] ?? 0);
}
private function extraChargesByParent(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
$query = DB::table('additional_charges as ac')
->selectRaw('ac.parent_id, COALESCE(SUM(ac.amount), 0) AS amount')
->where('ac.school_year', $schoolYear)
->where('ac.status !=', 'void')
->where(function ($q) {
$q->whereNull('ac.invoice_id')
->orWhere('ac.invoice_id', 0)
->orWhere('ac.status !=', 'applied');
})
->groupBy('ac.parent_id');
if (!empty($dateFrom)) {
$query->whereRaw('DATE(ac.due_date) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$query->whereRaw('DATE(ac.due_date) <= ?', [$dateTo]);
}
$rows = $query->get()->map(fn ($row) => (array) $row)->all();
$extraByParent = [];
foreach ($rows as $row) {
$parentId = (int) ($row['parent_id'] ?? 0);
$amount = (float) ($row['amount'] ?? 0);
if ($parentId > 0 && abs($amount) > 0.00001) {
$extraByParent[$parentId] = $amount;
}
}
return $extraByParent;
}
private function sumPayments(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
{
$query = DB::table('payments')->where('school_year', $schoolYear);
if (!empty($dateFrom)) {
$query->whereRaw('DATE(payment_date) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$query->whereRaw('DATE(payment_date) <= ?', [$dateTo]);
}
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
if (Schema::hasColumn('payments', 'status')) {
$query->where(function ($q) use ($exclude) {
$q->whereNotIn('status', $exclude)->orWhereNull('status');
});
}
if (Schema::hasColumn('payments', 'is_void')) {
$query->where(function ($q) {
$q->where('is_void', 0)->orWhereNull('is_void');
});
}
$row = (array) $query->selectRaw('COALESCE(SUM(paid_amount), 0) AS paid_amount')->first();
return (float) ($row['paid_amount'] ?? 0);
}
private function paymentsByInvoiceIds(array $invoiceIds, string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
$query = DB::table('payments')
->selectRaw('invoice_id, COALESCE(SUM(paid_amount), 0) AS total_paid')
->whereIn('invoice_id', $invoiceIds)
->where('school_year', $schoolYear);
if (!empty($dateFrom)) {
$query->whereRaw('DATE(payment_date) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$query->whereRaw('DATE(payment_date) <= ?', [$dateTo]);
}
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
if (Schema::hasColumn('payments', 'status')) {
$query->where(function ($q) use ($exclude) {
$q->whereNotIn('status', $exclude)->orWhereNull('status');
});
}
if (Schema::hasColumn('payments', 'is_void')) {
$query->where(function ($q) {
$q->where('is_void', 0)->orWhereNull('is_void');
});
}
$rows = $query->groupBy('invoice_id')->get()->map(fn ($row) => (array) $row)->all();
$map = [];
foreach ($rows as $row) {
$invoiceId = (int) ($row['invoice_id'] ?? 0);
if ($invoiceId > 0) {
$map[$invoiceId] = (float) ($row['total_paid'] ?? 0);
}
}
return $map;
}
private function discountsByInvoiceIds(array $invoiceIds, ?string $dateFrom, ?string $dateTo): array
{
$query = DB::table('discount_usages')
->selectRaw('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
->whereIn('invoice_id', $invoiceIds);
if (!empty($dateFrom)) {
$query->whereRaw('DATE(COALESCE(used_at, created_at)) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$query->whereRaw('DATE(COALESCE(used_at, created_at)) <= ?', [$dateTo]);
}
$rows = $query->groupBy('invoice_id')->get()->map(fn ($row) => (array) $row)->all();
$map = [];
foreach ($rows as $row) {
$invoiceId = (int) ($row['invoice_id'] ?? 0);
if ($invoiceId > 0) {
$map[$invoiceId] = (float) ($row['total_disc'] ?? 0);
}
}
return $map;
}
private function refundsByInvoiceIds(array $invoiceIds, ?string $dateFrom, ?string $dateTo): array
{
$query = DB::table('refunds')
->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund')
->whereIn('invoice_id', $invoiceIds)
->whereIn('status', ['Partial', 'Paid']);
if (!empty($dateFrom)) {
$query->whereRaw('DATE(COALESCE(refunded_at, created_at)) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$query->whereRaw('DATE(COALESCE(refunded_at, created_at)) <= ?', [$dateTo]);
}
$rows = $query->groupBy('invoice_id')->get()->map(fn ($row) => (array) $row)->all();
$map = [];
foreach ($rows as $row) {
$invoiceId = (int) ($row['invoice_id'] ?? 0);
if ($invoiceId > 0) {
$map[$invoiceId] = (float) ($row['total_refund'] ?? 0);
}
}
return $map;
}
private function sumExpenses(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
{
$query = DB::table('expenses')->where('school_year', $schoolYear);
if (!empty($dateFrom)) {
$query->whereRaw('DATE(created_at) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$query->whereRaw('DATE(created_at) <= ?', [$dateTo]);
}
$row = (array) $query->selectRaw('COALESCE(SUM(amount), 0) AS amount')->first();
return (float) ($row['amount'] ?? 0);
}
private function sumReimbursements(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
{
$normalizedYear = preg_replace('/[^0-9]/', '', (string) $schoolYear);
$query = DB::table('reimbursements as r')
->selectRaw('COALESCE(SUM(r.amount),0) AS amount')
->leftJoin('expenses as e', 'e.id', '=', 'r.expense_id');
if (!empty($schoolYear)) {
$query->where(function ($q) use ($schoolYear, $normalizedYear) {
$q->where(function ($q2) use ($schoolYear, $normalizedYear) {
$q2->where('r.school_year', $schoolYear)
->orWhereRaw(
"REPLACE(REPLACE(REPLACE(REPLACE(r.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '') = ?",
[$normalizedYear]
);
})->orWhere(function ($q2) use ($schoolYear, $normalizedYear) {
$q2->whereNull('r.school_year')
->where(function ($q3) use ($schoolYear, $normalizedYear) {
$q3->where('e.school_year', $schoolYear)
->orWhereRaw(
"REPLACE(REPLACE(REPLACE(REPLACE(e.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '') = ?",
[$normalizedYear]
);
});
});
});
}
if (!empty($dateFrom)) {
$query->whereRaw('DATE(COALESCE(r.created_at, e.created_at)) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$query->whereRaw('DATE(COALESCE(r.created_at, e.created_at)) <= ?', [$dateTo]);
}
$row = (array) $query->first();
$total = (float) ($row['amount'] ?? 0);
$batchFallback = DB::table('reimbursement_batch_items as bi')
->selectRaw('COALESCE(SUM(e.amount),0) AS amount')
->join('reimbursement_batches as b', 'b.id', '=', 'bi.batch_id')
->join('expenses as e', 'e.id', '=', 'bi.expense_id')
->leftJoin('reimbursements as r', 'r.expense_id', '=', 'e.id')
->where('b.status', 'closed')
->whereNull('bi.unassigned_at')
->whereNull('r.id');
if (!empty($schoolYear)) {
$batchFallback->where(function ($q) use ($schoolYear, $normalizedYear) {
$q->where('e.school_year', $schoolYear)
->orWhereRaw(
"REPLACE(REPLACE(REPLACE(REPLACE(e.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '') = ?",
[$normalizedYear]
);
});
}
if (!empty($dateFrom)) {
$batchFallback->whereRaw('DATE(COALESCE(e.created_at, b.closed_at)) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$batchFallback->whereRaw('DATE(COALESCE(e.created_at, b.closed_at)) <= ?', [$dateTo]);
}
$fallbackRow = (array) $batchFallback->first();
$total += (float) ($fallbackRow['amount'] ?? 0);
return $total;
}
private function sumDonationToSchool(string $schoolYear, ?string $dateFrom, ?string $dateTo): array
{
$expenseQuery = DB::table('expenses')
->where('school_year', $schoolYear)
->where('category', 'Donation');
if (!empty($dateFrom)) {
$expenseQuery->whereRaw('DATE(created_at) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$expenseQuery->whereRaw('DATE(created_at) <= ?', [$dateTo]);
}
$expenseRow = (array) $expenseQuery->selectRaw('COALESCE(SUM(amount),0) AS amount')->first();
$donationExpense = (float) ($expenseRow['amount'] ?? 0);
$donationReimb = 0.0;
$recipientIds = $this->donationRecipients->recipientIds();
if (!empty($recipientIds)) {
$normalizedYear = preg_replace('/[^0-9]/', '', (string) $schoolYear);
$donationQuery = DB::table('reimbursements as r')
->selectRaw('COALESCE(SUM(r.amount),0) AS amount')
->leftJoin('expenses as e', 'e.id', '=', 'r.expense_id')
->whereIn('r.reimbursed_to', $recipientIds);
if (!empty($schoolYear)) {
$donationQuery->where(function ($q) use ($schoolYear, $normalizedYear) {
$q->where(function ($q2) use ($schoolYear, $normalizedYear) {
$q2->where('r.school_year', $schoolYear)
->orWhereRaw(
"REPLACE(REPLACE(REPLACE(REPLACE(r.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '') = ?",
[$normalizedYear]
);
})->orWhere(function ($q2) use ($schoolYear, $normalizedYear) {
$q2->whereNull('r.school_year')
->where(function ($q3) use ($schoolYear, $normalizedYear) {
$q3->where('e.school_year', $schoolYear)
->orWhereRaw(
"REPLACE(REPLACE(REPLACE(REPLACE(e.school_year, ' ', ''), '-', ''), '/', ''), '\\\\', '') = ?",
[$normalizedYear]
);
});
});
});
}
if (!empty($dateFrom)) {
$donationQuery->whereRaw('DATE(COALESCE(r.created_at, e.created_at)) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$donationQuery->whereRaw('DATE(COALESCE(r.created_at, e.created_at)) <= ?', [$dateTo]);
}
$donationRow = (array) $donationQuery->first();
$donationReimb = (float) ($donationRow['amount'] ?? 0);
}
return [
'donationToSchool' => $donationExpense + $donationReimb,
'donationReimb' => $donationReimb,
];
}
private function sumRefunds(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
{
$query = DB::table('refunds')
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->whereNotNull('refund_paid_amount');
if (!empty($dateFrom)) {
$query->whereRaw('DATE(COALESCE(refunded_at, created_at)) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$query->whereRaw('DATE(COALESCE(refunded_at, created_at)) <= ?', [$dateTo]);
}
$row = (array) $query->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')->first();
return (float) ($row['refund_paid_amount'] ?? 0);
}
private function sumDiscounts(string $schoolYear, ?string $dateFrom, ?string $dateTo): float
{
$query = DB::table('discount_usages as du')
->join('invoices as i', 'i.id', '=', 'du.invoice_id')
->where('i.school_year', $schoolYear);
if (!empty($dateFrom)) {
$query->whereRaw('DATE(COALESCE(du.used_at, du.created_at)) >= ?', [$dateFrom]);
}
if (!empty($dateTo)) {
$query->whereRaw('DATE(COALESCE(du.used_at, du.created_at)) <= ?', [$dateTo]);
}
$row = (array) $query->selectRaw('COALESCE(SUM(du.discount_amount),0) AS discount_amount')->first();
return (float) ($row['discount_amount'] ?? 0);
}
private function sumUnpaidBalances(array $invoices, array $paidByInvoice, array $discountByInvoice, array $refundByInvoice, array $extraByParent): float
{
$parentBalances = [];
foreach ($invoices as $inv) {
$invoiceId = (int) ($inv['id'] ?? 0);
$parentId = (int) ($inv['parent_id'] ?? 0);
if ($invoiceId <= 0 || $parentId <= 0) {
continue;
}
$total = (float) ($inv['total_amount'] ?? 0);
$paid = (float) ($paidByInvoice[$invoiceId] ?? 0);
$disc = (float) ($discountByInvoice[$invoiceId] ?? 0);
$ref = (float) ($refundByInvoice[$invoiceId] ?? 0);
$balance = max(0.0, round($total - $disc - $paid - $ref, 2));
if (!isset($parentBalances[$parentId])) {
$parentBalances[$parentId] = 0.0;
}
$parentBalances[$parentId] += $balance;
}
foreach ($extraByParent as $parentId => $extra) {
if (!isset($parentBalances[$parentId])) {
$parentBalances[$parentId] = 0.0;
}
$parentBalances[$parentId] += (float) $extra;
}
$totalUnpaid = 0.0;
foreach ($parentBalances as $balance) {
if ($balance > 0.00001) {
$totalUnpaid += $balance;
}
}
return $totalUnpaid;
}
}
@@ -0,0 +1,247 @@
<?php
namespace App\Services\Finance;
use App\Models\Configuration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class FinancialUnpaidParentsService
{
public function __construct(private FinancialSchoolYearService $schoolYears)
{
}
public function getUnpaidParents(?string $schoolYear): array
{
$schoolYear = trim((string) ($schoolYear ?? ''));
if ($schoolYear === '') {
$schoolYear = (string) (Configuration::getConfig('school_year') ?? date('Y'));
}
$schoolYears = $this->schoolYears->listYears($schoolYear);
$invRows = DB::table('invoices as i')
->select('i.id', 'i.parent_id', 'i.total_amount', 'u.firstname', 'u.lastname', 'u.email')
->join('users as u', 'u.id', '=', 'i.parent_id')
->where('i.school_year', $schoolYear)
->orderBy('i.parent_id', 'ASC')
->orderBy('i.id', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
$byParent = [];
$hasStatus = Schema::hasColumn('payments', 'status');
$hasVoid = Schema::hasColumn('payments', 'is_void');
foreach ($invRows as $row) {
$invoiceId = (int) ($row['id'] ?? 0);
$parentId = (int) ($row['parent_id'] ?? 0);
if ($invoiceId <= 0 || $parentId <= 0) {
continue;
}
$paidQuery = DB::table('payments')
->selectRaw('COALESCE(SUM(paid_amount),0) AS tot')
->where('invoice_id', $invoiceId);
if ($hasStatus) {
$paidQuery->where(function ($q) {
$q->whereNotIn('status', [
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
])->orWhereNull('status');
});
}
if ($hasVoid) {
$paidQuery->where(function ($q) {
$q->where('is_void', 0)->orWhereNull('is_void');
});
}
$paidRow = (array) $paidQuery->first();
$paidSum = (float) ($paidRow['tot'] ?? 0);
$discRow = (array) DB::table('discount_usages')
->selectRaw('COALESCE(SUM(discount_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->first();
$discSum = (float) ($discRow['tot'] ?? 0);
$refRow = (array) DB::table('refunds')
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial', 'Paid'])
->first();
$refSum = (float) ($refRow['tot'] ?? 0);
$total = (float) ($row['total_amount'] ?? 0);
$balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2));
if (!isset($byParent[$parentId])) {
$byParent[$parentId] = [
'parent_id' => $parentId,
'firstname' => (string) ($row['firstname'] ?? ''),
'lastname' => (string) ($row['lastname'] ?? ''),
'email' => (string) ($row['email'] ?? ''),
'total_invoice' => 0.0,
'total_balance' => 0.0,
'total_paid' => 0.0,
'total_discount' => 0.0,
];
}
$byParent[$parentId]['total_invoice'] += $total;
$byParent[$parentId]['total_balance'] += $balance;
$byParent[$parentId]['total_paid'] += $paidSum;
$byParent[$parentId]['total_discount'] += $discSum;
}
$extraRows = DB::table('additional_charges as ac')
->selectRaw(
"ac.parent_id, u.firstname, u.lastname, u.email, COALESCE(SUM(ac.amount), 0) AS total_extra",
false
)
->leftJoin('users as u', 'u.id', '=', 'ac.parent_id')
->where('ac.school_year', $schoolYear)
->where('ac.status !=', 'void')
->where(function ($q) {
$q->whereNull('ac.invoice_id')
->orWhere('ac.invoice_id', 0)
->orWhere('ac.status !=', 'applied');
})
->groupBy('ac.parent_id')
->get()
->map(fn ($row) => (array) $row)
->all();
foreach ($extraRows as $row) {
$parentId = (int) ($row['parent_id'] ?? 0);
if ($parentId <= 0) {
continue;
}
$extra = (float) ($row['total_extra'] ?? 0);
if (abs($extra) < 0.00001) {
continue;
}
if (!isset($byParent[$parentId])) {
$byParent[$parentId] = [
'parent_id' => $parentId,
'firstname' => (string) ($row['firstname'] ?? ''),
'lastname' => (string) ($row['lastname'] ?? ''),
'email' => (string) ($row['email'] ?? ''),
'total_invoice' => 0.0,
'total_balance' => 0.0,
'total_paid' => 0.0,
'total_discount' => 0.0,
];
}
$byParent[$parentId]['total_invoice'] += $extra;
$byParent[$parentId]['total_balance'] += $extra;
}
$rows = [];
$installmentEndRaw = (string) (Configuration::getConfig('installment_date') ?? '');
$tzName = function_exists('user_timezone') ? (string) user_timezone() : config('app.timezone', 'UTC');
$tz = new \DateTimeZone($tzName);
$today = new \DateTimeImmutable('today', $tz);
$endDate = null;
if ($installmentEndRaw !== '') {
try {
$endDate = new \DateTimeImmutable($installmentEndRaw, $tz);
} catch (\Throwable $e) {
$endDate = null;
}
}
$monthsUntil = static function (? \DateTimeImmutable $end) use ($today): int {
if (!$end) {
return 0;
}
$y = (int) $end->format('Y') - (int) $today->format('Y');
$m = (int) $end->format('n') - (int) $today->format('n');
$months = $y * 12 + $m;
if ((int) $end->format('j') > (int) $today->format('j')) {
$months += 1;
}
return max(0, $months);
};
foreach ($byParent as $agg) {
$balance = (float) ($agg['total_balance'] ?? 0);
if ($balance <= 0.00001) {
continue;
}
$remMonths = $monthsUntil($endDate);
$remainingInstallments = max(1, $remMonths);
$instAmount = round($balance / $remainingInstallments, 2);
$rows[] = [
'parent_id' => (int) ($agg['parent_id'] ?? 0),
'firstname' => (string) ($agg['firstname'] ?? ''),
'lastname' => (string) ($agg['lastname'] ?? ''),
'email' => (string) ($agg['email'] ?? ''),
'total_invoice' => (float) ($agg['total_invoice'] ?? 0),
'total_balance' => $balance,
'total_discount' => (float) ($agg['total_discount'] ?? 0),
'remaining_installments' => $remainingInstallments,
'installment_amount' => $instAmount,
];
}
usort($rows, static fn ($a, $b) => $b['total_balance'] <=> $a['total_balance']);
$nextInstallment = (new \DateTime('now', $tz))
->modify('first day of next month')
->format('Y-m-d');
$parentIds = array_values(array_unique(array_map(static fn ($row) => (int) ($row['parent_id'] ?? 0), $rows)));
$hasPayments = [];
$paidTotals = [];
$paymentCounts = [];
if (!empty($parentIds)) {
$pRows = DB::table('payments')
->selectRaw('parent_id, SUM(paid_amount) AS total_paid, COUNT(*) AS payment_count')
->whereIn('parent_id', $parentIds)
->where('school_year', $schoolYear)
->groupBy('parent_id')
->get()
->map(fn ($row) => (array) $row)
->all();
foreach ($pRows as $row) {
$parentId = (int) ($row['parent_id'] ?? 0);
if ($parentId > 0) {
$hasPayments[$parentId] = true;
$paidTotals[$parentId] = (float) ($row['total_paid'] ?? 0);
$paymentCounts[$parentId] = (int) ($row['payment_count'] ?? 0);
}
}
}
$results = array_map(function (array $row) use ($hasPayments, $paidTotals, $paymentCounts, $nextInstallment) {
$parentId = (int) ($row['parent_id'] ?? 0);
$name = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
return [
'parent_id' => $parentId,
'parent_name' => $name,
'email' => (string) ($row['email'] ?? ''),
'total_invoice' => (float) ($row['total_invoice'] ?? 0),
'total_balance' => (float) ($row['total_balance'] ?? 0),
'total_discount' => (float) ($row['total_discount'] ?? 0),
'remaining_installments' => (int) ($row['remaining_installments'] ?? 0),
'installment_amount' => (float) ($row['installment_amount'] ?? 0),
'type' => isset($hasPayments[$parentId]) ? 'installment' : 'no_payment',
'total_paid' => isset($paidTotals[$parentId]) ? (float) $paidTotals[$parentId] : 0.0,
'payment_count' => isset($paymentCounts[$parentId]) ? (int) $paymentCounts[$parentId] : 0,
'has_installment' => isset($hasPayments[$parentId]) ? 1 : 0,
'next_installment' => $nextInstallment,
];
}, $rows);
return [
'schoolYear' => $schoolYear,
'schoolYears' => $schoolYears,
'results' => $results,
];
}
}
@@ -0,0 +1,403 @@
<?php
namespace App\Services\Grading;
use App\Models\CurrentFlag;
use App\Models\ParentMeetingSchedule;
use App\Models\ScoreComment;
use App\Models\Student;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use RuntimeException;
class GradingBelowSixtyService
{
public function listRows(string $schoolYear, string $semester): array
{
$semesterKey = strtolower(trim($semester));
$rows = DB::table('semester_scores as ss')
->select([
's.id as student_id',
's.school_id',
's.firstname',
's.lastname',
'cs.class_section_name',
'ss.homework_avg',
'ss.project_avg',
'ss.participation_score',
DB::raw('COALESCE(ss.test_avg, ss.quiz_avg) as test_avg'),
'ss.ptap_score',
'ss.attendance_score',
'ss.midterm_exam_score',
'ss.final_exam_score',
'ss.semester_score',
])
->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('s.is_active', 1)
->where('ss.school_year', $schoolYear)
->whereRaw('LOWER(TRIM(ss.semester)) = ?', [$semesterKey])
->whereNotNull('ss.semester_score')
->where('ss.semester_score', '<', 60)
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
if (empty($rows)) {
return [];
}
$studentIds = array_values(array_unique(array_map(
static fn ($r) => (int) ($r['student_id'] ?? 0),
$rows
)));
$studentIds = array_values(array_filter($studentIds, static fn ($id) => $id > 0));
$commentMap = [];
if (!empty($studentIds)) {
$commentRows = ScoreComment::query()
->select('student_id', 'comment', 'created_at')
->where('score_type', 'general')
->where('school_year', $schoolYear)
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
->whereIn('student_id', $studentIds)
->orderBy('created_at', 'DESC')
->get();
foreach ($commentRows as $row) {
$sid = (int) $row->student_id;
if ($sid > 0 && !isset($commentMap[$sid])) {
$commentMap[$sid] = (string) ($row->comment ?? '');
}
}
}
$statusMap = [];
if (!empty($studentIds)) {
$flagRows = CurrentFlag::query()
->select('student_id', 'flag_state')
->where('flag', 'grade')
->where('school_year', $schoolYear)
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
->whereIn('student_id', $studentIds)
->get();
foreach ($flagRows as $row) {
$sid = (int) $row->student_id;
if ($sid > 0) {
$statusMap[$sid] = (string) ($row->flag_state ?? '');
}
}
}
foreach ($rows as &$row) {
$sid = (int) ($row['student_id'] ?? 0);
$row['comment'] = $commentMap[$sid] ?? '';
$flagState = strtolower(trim((string) ($statusMap[$sid] ?? '')));
$row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open';
}
unset($row);
return $rows;
}
public function emailContext(int $studentId, string $schoolYear, string $semester): array
{
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
if (empty($row)) {
throw new RuntimeException('Student record not found for the selected term.');
}
$studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
$subject = $this->buildBelowSixtySubject($studentName, $semester, $schoolYear);
$parentName = $this->fetchBelowSixtyParentName($studentId);
$scores = [
'homework_avg' => $row['homework_avg'] ?? null,
'project_avg' => $row['project_avg'] ?? null,
'participation_score' => $row['participation_score'] ?? null,
'test_avg' => $row['test_avg'] ?? null,
'ptap_score' => $row['ptap_score'] ?? null,
'attendance_score' => $row['attendance_score'] ?? null,
'midterm_exam_score' => $row['midterm_exam_score'] ?? null,
'semester_score' => $row['semester_score'] ?? null,
];
return [
'student_id' => $studentId,
'student_name' => $studentName !== '' ? $studentName : 'Student',
'parent_name' => $parentName,
'class_section_name' => $row['class_section_name'] ?? '',
'semester' => $semester,
'school_year' => $schoolYear,
'scores' => $scores,
'comment' => $row['comment'] ?? '',
'subject' => $subject,
];
}
public function sendEmail(array $payload): void
{
Event::dispatch('below60.email', $payload);
}
public function updateStatus(int $studentId, string $semester, string $schoolYear, string $status, string $note, ?int $userId): void
{
$status = ucfirst(strtolower($status));
if (!in_array($status, ['Open', 'Closed'], true)) {
throw new RuntimeException('Invalid status.');
}
$semesterKey = strtolower(trim($semester));
$existing = CurrentFlag::query()
->where('student_id', $studentId)
->where('flag', 'grade')
->where('school_year', $schoolYear)
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
->first();
$now = now();
if ($existing) {
$data = [
'flag_state' => $status,
'flag_datetime' => $now,
'updated_at' => $now,
];
if ($status === 'Open') {
$data['updated_by_open'] = $userId;
if ($note !== '') {
$prev = (string) ($existing->open_description ?? '');
$data['open_description'] = trim($prev . PHP_EOL . $note);
}
} else {
$data['updated_by_closed'] = $userId;
if ($note !== '') {
$prev = (string) ($existing->close_description ?? '');
$data['close_description'] = trim($prev . PHP_EOL . $note);
}
}
$existing->update($data);
return;
}
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
$studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
$grade = (string) ($row['class_section_name'] ?? '');
$data = [
'student_id' => $studentId,
'student_name' => $studentName !== '' ? $studentName : 'Student',
'grade' => $grade,
'flag' => 'grade',
'flag_datetime' => $now,
'flag_state' => $status,
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => $now,
];
if ($status === 'Open') {
$data['updated_by_open'] = $userId;
if ($note !== '') {
$data['open_description'] = $note;
}
} else {
$data['updated_by_closed'] = $userId;
if ($note !== '') {
$data['close_description'] = $note;
}
}
CurrentFlag::query()->create($data);
}
public function meetingContext(int $studentId, string $schoolYear, string $semester): array
{
$context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester);
if (empty($context)) {
throw new RuntimeException('Student record not found for the selected term.');
}
return $context + [
'student_id' => $studentId,
'semester' => $semester,
'school_year' => $schoolYear,
];
}
public function saveMeeting(array $payload, ?int $userId): void
{
$studentId = (int) ($payload['student_id'] ?? 0);
$semester = (string) ($payload['semester'] ?? '');
$schoolYear = (string) ($payload['school_year'] ?? '');
$date = trim((string) ($payload['date'] ?? ''));
$time = trim((string) ($payload['time'] ?? ''));
$notes = trim((string) ($payload['notes'] ?? ''));
if ($studentId <= 0 || $semester === '' || $schoolYear === '' || $date === '') {
throw new RuntimeException('Missing required fields.');
}
$context = $this->fetchBelowSixtyMeetingContext($studentId, $schoolYear, $semester);
if (empty($context)) {
throw new RuntimeException('Student record not found for the selected term.');
}
ParentMeetingSchedule::query()->create([
'student_id' => $studentId,
'parent_user_id' => $context['parent_user_id'] ?? null,
'parent_name' => $context['parent_name'],
'student_name' => $context['student_name'],
'class_section_name' => $context['class_section_name'],
'date' => $date,
'time' => $time !== '' ? $time : null,
'notes' => $notes !== '' ? $notes : null,
'semester' => $semester,
'school_year' => $schoolYear,
'status' => 'scheduled',
'created_by' => $userId,
]);
}
private function fetchBelowSixtyEmailRow(int $studentId, string $schoolYear, string $semester): array
{
$semesterKey = strtolower(trim($semester));
$row = DB::table('semester_scores as ss')
->select([
's.id as student_id',
's.firstname',
's.lastname',
'cs.class_section_name',
'ss.homework_avg',
'ss.project_avg',
'ss.participation_score',
DB::raw('COALESCE(ss.test_avg, ss.quiz_avg) as test_avg'),
'ss.ptap_score',
'ss.attendance_score',
'ss.midterm_exam_score',
'ss.semester_score',
])
->join('students as s', 's.id', '=', 'ss.student_id')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'ss.class_section_id')
->where('ss.school_year', $schoolYear)
->where('ss.student_id', $studentId)
->where('s.is_active', 1)
->whereRaw('LOWER(TRIM(ss.semester)) = ?', [$semesterKey])
->first();
if (!$row) {
return [];
}
$commentRow = ScoreComment::query()
->select('comment')
->where('score_type', 'general')
->where('school_year', $schoolYear)
->whereRaw('LOWER(TRIM(semester)) = ?', [$semesterKey])
->where('student_id', $studentId)
->orderBy('created_at', 'DESC')
->first();
$row = (array) $row;
$row['comment'] = (string) ($commentRow?->comment ?? '');
return $row;
}
private function fetchBelowSixtyParentName(int $studentId): string
{
$parentName = 'Parent/Guardian';
try {
$rows = DB::select(
"SELECT u.firstname, u.lastname
FROM family_students fs
JOIN family_guardians fg ON fg.family_id = fs.family_id
JOIN users u ON u.id = fg.user_id
WHERE fs.student_id = ?
ORDER BY fg.is_primary DESC, u.lastname, u.firstname
LIMIT 1",
[$studentId]
);
if (!empty($rows[0])) {
$candidate = trim((string) ($rows[0]->firstname ?? '') . ' ' . (string) ($rows[0]->lastname ?? ''));
if ($candidate !== '') {
$parentName = $candidate;
}
}
} catch (\Throwable $e) {
}
return $parentName;
}
private function buildBelowSixtySubject(string $studentName, string $semester, string $schoolYear): string
{
$subject = 'Student Performance Alert';
if ($studentName !== '') {
$subject .= ' — ' . $studentName;
}
if ($semester !== '' || $schoolYear !== '') {
$subject .= ' (' . trim($semester . ' ' . $schoolYear) . ')';
}
return $subject;
}
private function fetchBelowSixtyMeetingContext(int $studentId, string $schoolYear, string $semester): array
{
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
if (empty($row)) {
return [];
}
$parentName = 'Parent/Guardian';
$parentUserId = null;
try {
$pRows = DB::select(
"SELECT u.id AS user_id, u.firstname, u.lastname
FROM family_students fs
JOIN family_guardians fg ON fg.family_id = fs.family_id
JOIN users u ON u.id = fg.user_id
WHERE fs.student_id = ?
ORDER BY fg.is_primary DESC, u.lastname, u.firstname
LIMIT 1",
[$studentId]
);
if (!empty($pRows[0])) {
$parentUserId = (int) ($pRows[0]->user_id ?? 0) ?: null;
$parentName = trim((string) ($pRows[0]->firstname ?? '') . ' ' . (string) ($pRows[0]->lastname ?? ''));
if ($parentName === '') {
$parentName = 'Parent/Guardian';
}
}
} catch (\Throwable $e) {
}
if ($parentUserId === null) {
try {
$srow = DB::selectOne(
"SELECT s.parent_id, u.firstname, u.lastname
FROM students s
LEFT JOIN users u ON u.id = s.parent_id
WHERE s.id = ?
LIMIT 1",
[$studentId]
);
if (!empty($srow)) {
$parentUserId = (int) ($srow->parent_id ?? 0) ?: null;
$fallbackName = trim((string) ($srow->firstname ?? '') . ' ' . (string) ($srow->lastname ?? ''));
if ($fallbackName !== '') {
$parentName = $fallbackName;
}
}
} catch (\Throwable $e) {
}
}
$studentName = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
return [
'student_name' => $studentName !== '' ? $studentName : 'Student',
'parent_name' => $parentName,
'parent_user_id' => $parentUserId,
'class_section_name' => (string) ($row['class_section_name'] ?? ''),
];
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace App\Services\Grading;
use App\Models\GradingLock;
use App\Models\ClassSection;
use RuntimeException;
class GradingLockService
{
public function toggle(int $classSectionId, string $semester, string $schoolYear, ?int $userId): bool
{
if ($classSectionId <= 0 || $semester === '' || $schoolYear === '') {
throw new RuntimeException('Missing class section or term.');
}
$existing = GradingLock::getLock($classSectionId, $semester, $schoolYear);
if ($existing && $existing->is_locked) {
$existing->update([
'is_locked' => 0,
'locked_by' => null,
'locked_at' => null,
]);
return false;
}
if ($existing) {
$existing->update([
'is_locked' => 1,
'locked_by' => $userId ?: null,
'locked_at' => now(),
]);
} else {
GradingLock::query()->create([
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear,
'is_locked' => 1,
'locked_by' => $userId ?: null,
'locked_at' => now(),
]);
}
return true;
}
public function lockAll(string $semester, string $schoolYear, ?int $userId): int
{
if ($semester === '' || $schoolYear === '') {
throw new RuntimeException('Missing semester or school year.');
}
$sectionIds = ClassSection::query()
->select('class_section_id')
->groupBy('class_section_id')
->pluck('class_section_id')
->map(fn ($v) => (int) $v)
->filter(fn ($v) => $v > 0)
->values()
->all();
if (empty($sectionIds)) {
return 0;
}
$existingLocks = GradingLock::query()
->whereIn('class_section_id', $sectionIds)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
$existingBySection = [];
foreach ($existingLocks as $row) {
$sid = (int) $row->class_section_id;
if ($sid > 0) {
$existingBySection[$sid] = $row;
}
}
$now = now();
$insertRows = [];
$count = 0;
foreach ($sectionIds as $sid) {
if (!empty($existingBySection[$sid])) {
if ($existingBySection[$sid]->is_locked) {
continue;
}
$existingBySection[$sid]->update([
'is_locked' => 1,
'locked_by' => $userId ?: null,
'locked_at' => $now,
]);
$count++;
continue;
}
$insertRows[] = [
'class_section_id' => $sid,
'semester' => $semester,
'school_year' => $schoolYear,
'is_locked' => 1,
'locked_by' => $userId ?: null,
'locked_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
$count++;
}
if (!empty($insertRows)) {
GradingLock::query()->insert($insertRows);
}
return $count;
}
}
@@ -0,0 +1,439 @@
<?php
namespace App\Services\Grading;
use App\Models\AttendanceRecord;
use App\Models\CalendarEvent;
use App\Models\Configuration;
use App\Models\ClassSection;
use App\Models\GradingLock;
use App\Models\Student;
use App\Services\Scores\AttendanceCalculator;
use App\Services\Scores\SemesterScoreService;
use Illuminate\Support\Facades\DB;
class GradingOverviewService
{
private AttendanceCalculator $attendanceCalculator;
public function __construct(private SemesterScoreService $semesterScoreService)
{
$this->attendanceCalculator = new AttendanceCalculator(
new AttendanceRecord(),
new Configuration(),
new CalendarEvent()
);
}
public function overview(?int $classId, ?string $semester, ?string $schoolYear): array
{
$schoolYear = $schoolYear ?: (string) (Configuration::getConfig('school_year') ?? '');
$configuredSemester = (string) (Configuration::getConfig('semester') ?? '');
$semesterOptions = $this->getSemestersForSchoolYear($schoolYear, $configuredSemester);
$semester = $this->resolveSemesterSelection($semester, $semesterOptions, $configuredSemester);
$this->ensureParentReleaseKeyExists('Fall');
$this->ensureParentReleaseKeyExists('Spring');
$scoresReleased = $this->getParentScoresReleasedForSemester($semester);
$scoresReleasedFall = $this->getParentScoresReleasedForSemester('Fall');
$scoresReleasedSpring = $this->getParentScoresReleasedForSemester('Spring');
if ($classId && $this->semesterScoreService) {
$sectionIds = ClassSection::query()
->select('class_section_id')
->where('class_id', $classId)
->pluck('class_section_id')
->map(fn ($v) => (int) $v)
->all();
foreach ($sectionIds as $sectionId) {
$studentTeacherInfo = Student::getStudentInfoByClassSectionId(
$sectionId,
$semester,
$schoolYear
);
if (empty($studentTeacherInfo)) {
continue;
}
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
} catch (\Throwable $e) {
}
}
}
$rows = $this->buildGradingRows($semester, $schoolYear);
$counts = $this->loadScoreCounts($rows, $semester, $schoolYear);
$sectionsNeedingRefresh = [];
foreach ($rows as $row) {
$sectionId = (int) ($row['section_id'] ?? 0);
if ($sectionId <= 0) {
continue;
}
if ($row['ss_ptap_score'] === null || $row['ss_semester_score'] === null) {
$sectionsNeedingRefresh[$sectionId] = true;
}
}
if (!empty($sectionsNeedingRefresh)) {
foreach (array_keys($sectionsNeedingRefresh) as $sectionId) {
$studentTeacherInfo = Student::getStudentInfoByClassSectionId(
$sectionId,
$semester,
$schoolYear
);
if (empty($studentTeacherInfo)) {
continue;
}
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
} catch (\Throwable $e) {
}
}
$rows = $this->buildGradingRows($semester, $schoolYear);
}
$grades = [];
$studentsBySection = [];
foreach ($rows as $row) {
$sectionId = (int) ($row['section_id'] ?? 0);
$classIdValue = (int) ($row['class_id'] ?? 0);
$sectionName = (string) ($row['class_section_name'] ?? '');
if ($sectionId <= 0 || $classIdValue <= 0) {
continue;
}
if (!isset($grades[$classIdValue])) {
$grades[$classIdValue] = [];
}
$exists = false;
foreach ($grades[$classIdValue] as $section) {
if ((int) $section['class_section_id'] === $sectionId) {
$exists = true;
break;
}
}
if (!$exists) {
$grades[$classIdValue][] = [
'class_section_id' => $sectionId,
'class_section_name' => $sectionName,
];
}
$studentId = (int) ($row['student_id'] ?? 0);
if ($studentId <= 0) {
continue;
}
$attendanceScore = $this->calculateAttendanceScoreForStudent(
$studentId,
$semester,
$schoolYear,
$sectionId
);
if ($attendanceScore === null) {
$rawAttendance = $row['ss_attendance_score'] ?? null;
if ($rawAttendance !== null && $rawAttendance !== '') {
$attendanceScore = round((float) $rawAttendance, 2);
}
}
$studentRow = [
'id' => $studentId,
'school_id' => $row['school_id'] ?? null,
'firstname' => $row['firstname'] ?? null,
'lastname' => $row['lastname'] ?? null,
'class_id' => $classIdValue,
'ptap' => $this->normalizeScore($row['ss_ptap_score'] ?? null),
'semester_score' => $this->normalizeScore($row['ss_semester_score'] ?? null),
'attendance' => $attendanceScore,
'homework_avg' => $this->normalizeCountedScore($row['ss_homework_avg'] ?? null, $counts['homework'] ?? [], $sectionId, $studentId),
'project_avg' => $this->normalizeCountedScore($row['ss_project_avg'] ?? null, $counts['project'] ?? [], $sectionId, $studentId),
'quiz_avg' => $this->normalizeCountedScore($row['ss_quiz_avg'] ?? null, $counts['quiz'] ?? [], $sectionId, $studentId),
'participation' => $this->normalizeCountedScore($row['ss_participation_score'] ?? null, $counts['participation'] ?? [], $sectionId, $studentId),
'midterm_exam' => $this->normalizeCountedScore($row['ss_midterm_exam_score'] ?? null, $counts['midterm'] ?? [], $sectionId, $studentId),
'final_exam' => $this->normalizeScore($row['ss_final_exam_score'] ?? null),
'matched_biz_csid' => $row['matched_biz_csid'] ?? null,
'matched_pk_csid' => $row['matched_pk_csid'] ?? null,
'placement_level' => $row['placement_level'] ?? null,
];
$studentsBySection[$sectionId][] = $studentRow;
}
$scoreLocks = $this->loadScoreLocks($grades, $semester, $schoolYear);
return [
'grades' => $grades,
'students_by_section' => $studentsBySection,
'semester' => $semester,
'school_year' => $schoolYear,
'requested_class_id' => $classId,
'semester_options' => $semesterOptions,
'scores_released' => $scoresReleased,
'scores_released_fall' => $scoresReleasedFall,
'scores_released_spring' => $scoresReleasedSpring,
'score_locks' => $scoreLocks,
];
}
private function buildGradingRows(string $semester, string $schoolYear): array
{
$semLower = strtolower(trim($semester));
return DB::table('student_class as sc')
->select([
'cs.id as section_pk',
'cs.class_section_id as section_id',
'cs.class_id as class_id',
'cs.class_section_name',
's.id as student_id',
's.school_id',
's.firstname',
's.lastname',
'pl.level as placement_level',
DB::raw('COALESCE(ss_b.ptap_score, ss_p.ptap_score) as ss_ptap_score'),
DB::raw('COALESCE(ss_b.semester_score, ss_p.semester_score) as ss_semester_score'),
DB::raw('COALESCE(ss_b.attendance_score, ss_p.attendance_score) as ss_attendance_score'),
DB::raw('COALESCE(ss_b.homework_avg, ss_p.homework_avg) as ss_homework_avg'),
DB::raw('COALESCE(ss_b.project_avg, ss_p.project_avg) as ss_project_avg'),
DB::raw('COALESCE(ss_b.quiz_avg, ss_p.quiz_avg) as ss_quiz_avg'),
DB::raw('COALESCE(ss_b.participation_score, ss_p.participation_score) as ss_participation_score'),
DB::raw('COALESCE(ss_b.midterm_exam_score, ss_p.midterm_exam_score) as ss_midterm_exam_score'),
DB::raw('COALESCE(ss_b.final_exam_score, ss_p.final_exam_score) as ss_final_exam_score'),
'ss_b.class_section_id as matched_biz_csid',
'ss_p.class_section_id as matched_pk_csid',
])
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->join('students as s', 's.id', '=', 'sc.student_id')
->leftJoin('placement_levels as pl', function ($join) use ($schoolYear) {
$join->on('pl.student_id', '=', 's.id')
->where('pl.school_year', '=', $schoolYear);
})
->leftJoin('semester_scores as ss_b', function ($join) use ($semLower, $schoolYear) {
$join->on('ss_b.student_id', '=', 's.id')
->on('ss_b.class_section_id', '=', 'sc.class_section_id')
->whereRaw('LOWER(TRIM(ss_b.semester)) = ?', [$semLower])
->where('ss_b.school_year', '=', $schoolYear);
})
->leftJoin('semester_scores as ss_p', function ($join) use ($semLower, $schoolYear) {
$join->on('ss_p.student_id', '=', 's.id')
->on('ss_p.class_section_id', '=', 'cs.id')
->whereRaw('LOWER(TRIM(ss_p.semester)) = ?', [$semLower])
->where('ss_p.school_year', '=', $schoolYear);
})
->where('sc.school_year', $schoolYear)
->where('s.is_active', 1)
->orderBy('cs.class_id', 'ASC')
->orderBy('cs.class_section_name', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
}
private function loadScoreCounts(array $rows, string $semester, string $schoolYear): array
{
$sectionIds = [];
$studentIds = [];
foreach ($rows as $row) {
$studentId = (int) ($row['student_id'] ?? 0);
$sectionId = (int) ($row['section_id'] ?? 0);
if ($studentId > 0) {
$studentIds[$studentId] = true;
}
if ($sectionId > 0) {
$sectionIds[$sectionId] = true;
}
}
$sectionIds = array_keys($sectionIds);
$studentIds = array_keys($studentIds);
if (empty($sectionIds) || empty($studentIds)) {
return [
'quiz' => [],
'homework' => [],
'project' => [],
'participation' => [],
'midterm' => [],
];
}
return [
'quiz' => $this->countScores('quiz', $sectionIds, $studentIds, $semester, $schoolYear),
'homework' => $this->countScores('homework', $sectionIds, $studentIds, $semester, $schoolYear),
'project' => $this->countScores('project', $sectionIds, $studentIds, $semester, $schoolYear),
'participation' => $this->countScores('participation', $sectionIds, $studentIds, $semester, $schoolYear),
'midterm' => $this->countScores('midterm_exam', $sectionIds, $studentIds, $semester, $schoolYear),
];
}
private function countScores(string $table, array $sectionIds, array $studentIds, string $semester, string $schoolYear): array
{
$rows = DB::table($table)
->select('student_id', 'class_section_id', DB::raw('COUNT(*) as cnt'))
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('class_section_id', $sectionIds)
->whereIn('student_id', $studentIds)
->whereNotNull('score')
->groupBy('student_id', 'class_section_id')
->get();
$counts = [];
foreach ($rows as $row) {
$counts[(int) $row->class_section_id][(int) $row->student_id] = (int) $row->cnt;
}
return $counts;
}
private function loadScoreLocks(array $grades, string $semester, string $schoolYear): array
{
$sectionIds = [];
foreach ($grades as $sections) {
foreach ($sections as $section) {
$sid = (int) ($section['class_section_id'] ?? 0);
if ($sid > 0) {
$sectionIds[$sid] = true;
}
}
}
$sectionIds = array_keys($sectionIds);
if (empty($sectionIds)) {
return [];
}
$locks = GradingLock::query()
->whereIn('class_section_id', $sectionIds)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
$map = [];
foreach ($locks as $lock) {
$map[(int) $lock->class_section_id] = (bool) $lock->is_locked;
}
return $map;
}
private function calculateAttendanceScoreForStudent(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): ?float
{
try {
$result = $this->attendanceCalculator->calculate($studentId, $semester, $schoolYear, $classSectionId);
$score = $result['attendance_score'] ?? null;
if ($score === null || $score === '') {
return null;
}
return round((float) $score, 2);
} catch (\Throwable $e) {
}
return null;
}
private function normalizeScore($value): ?float
{
if ($value === null || $value === '') {
return null;
}
return round((float) $value, 2);
}
private function normalizeCountedScore($value, array $countMap, int $sectionId, int $studentId): ?float
{
$score = $this->normalizeScore($value);
if ($score !== null && (float) $score === 0.0) {
$count = (int) ($countMap[$sectionId][$studentId] ?? 0);
if ($count === 0) {
return null;
}
}
return $score;
}
private function resolveSemesterSelection(?string $requestedSemester, array $semesterOptions, ?string $fallbackSemester): string
{
if ($requestedSemester !== null && $requestedSemester !== '') {
foreach ($semesterOptions as $option) {
if (strcasecmp($option, $requestedSemester) === 0) {
return $option;
}
}
return $requestedSemester;
}
if ($fallbackSemester !== null && $fallbackSemester !== '') {
foreach ($semesterOptions as $option) {
if (strcasecmp($option, $fallbackSemester) === 0) {
return $option;
}
}
return $fallbackSemester;
}
return $semesterOptions[0] ?? '';
}
private function getSemestersForSchoolYear(string $schoolYear, ?string $fallbackSemester = null): array
{
$rows = DB::table('semester_scores')
->select(DB::raw('DISTINCT semester'))
->whereNotNull('semester')
->where('semester', '!=', '')
->where('school_year', $schoolYear)
->orderBy('semester', 'ASC')
->get();
$semesters = [];
foreach ($rows as $row) {
$value = trim((string) $row->semester);
if ($value !== '') {
$semesters[] = $value;
}
}
if (empty($semesters)) {
$semesters = ['Fall', 'Spring'];
}
if ($fallbackSemester !== null && $fallbackSemester !== '' && !in_array($fallbackSemester, $semesters, true)) {
array_unshift($semesters, $fallbackSemester);
}
return array_values(array_unique($semesters));
}
private function getParentReleaseKey(string $semester): ?string
{
$norm = strtolower(trim($semester));
if ($norm === 'fall') {
return 'parent_scores_released_fall';
}
if ($norm === 'spring') {
return 'parent_scores_released_spring';
}
return null;
}
private function getParentScoresReleasedForSemester(string $semester): bool
{
$key = $this->getParentReleaseKey($semester);
$raw = $key ? Configuration::getConfig($key) : null;
$raw = (string) ($raw ?? '');
return in_array(strtolower(trim($raw)), ['1', 'true', 'yes', 'y', 'on'], true);
}
private function ensureParentReleaseKeyExists(string $semester): void
{
$key = $this->getParentReleaseKey($semester);
if (!$key) {
return;
}
if (Configuration::getConfigValueByKey($key) === null) {
Configuration::setConfigValueByKey($key, '0');
}
}
}
@@ -0,0 +1,372 @@
<?php
namespace App\Services\Grading;
use App\Models\ClassSection;
use App\Models\PlacementBatch;
use App\Models\PlacementLevel;
use App\Models\PlacementScore;
use App\Models\Student;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class GradingPlacementService
{
public function placementContext(?int $classSectionId, string $schoolYear, ?string $placementTest, ?string $open): array
{
if (!$classSectionId) {
$showStudents = ($placementTest !== '' && $open === '1');
$students = $showStudents ? $this->fetchActiveStudentsWithSection($schoolYear) : [];
$batches = $this->fetchPlacementBatches($schoolYear);
$batchDetails = $this->fetchPlacementBatchDetails($batches, $schoolYear);
return [
'school_year' => $schoolYear,
'students' => $students,
'batches' => $batches,
'batch_details' => $batchDetails,
'placement_test' => $placementTest,
'show_students' => $showStudents,
];
}
$sectionName = ClassSection::query()->where('class_section_id', $classSectionId)->value('class_section_name');
$classId = ClassSection::query()->where('class_section_id', $classSectionId)->value('class_id');
$students = Student::getStudentInfoByClassSectionId($classSectionId, null, $schoolYear);
$studentIds = array_values(array_filter(array_map(
static fn ($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn ($id) => $id > 0));
$levels = [];
if (!empty($studentIds)) {
$rows = PlacementLevel::query()
->whereIn('student_id', $studentIds)
->where('school_year', $schoolYear)
->get();
foreach ($rows as $row) {
$levels[(int) $row->student_id] = $row->level ?? null;
}
}
return [
'class_section_id' => $classSectionId,
'class_section_name' => $sectionName ?? '',
'class_id' => $classId,
'school_year' => $schoolYear,
'students' => $students,
'levels' => $levels,
];
}
public function updatePlacementLevel(int $studentId, string $schoolYear, $level, ?int $userId): void
{
if ($studentId <= 0 || $schoolYear === '') {
throw new RuntimeException('Missing student or school year.');
}
$level = $level === '' || $level === null ? null : (int) $level;
if ($level !== null && !in_array($level, [1, 2, 3], true)) {
throw new RuntimeException('Invalid placement level.');
}
$existing = PlacementLevel::query()
->where('student_id', $studentId)
->where('school_year', $schoolYear)
->first();
if ($level === null) {
if ($existing) {
$existing->delete();
}
return;
}
$payload = [
'student_id' => $studentId,
'school_year' => $schoolYear,
'level' => $level,
'updated_by' => $userId,
];
if ($existing) {
$existing->update($payload);
} else {
$payload['created_by'] = $userId;
PlacementLevel::query()->create($payload);
}
}
public function updatePlacementLevels(int $classSectionId, string $schoolYear, array $levels, ?int $userId): void
{
if ($classSectionId <= 0 || $schoolYear === '') {
throw new RuntimeException('Missing class section or school year.');
}
$students = Student::getStudentInfoByClassSectionId($classSectionId, null, $schoolYear);
$validIds = array_values(array_filter(array_map(
static fn ($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn ($id) => $id > 0));
$validSet = array_flip($validIds);
$existingRows = [];
if (!empty($validIds)) {
$rows = PlacementLevel::query()
->whereIn('student_id', $validIds)
->where('school_year', $schoolYear)
->get();
foreach ($rows as $row) {
$existingRows[(int) $row->student_id] = $row;
}
}
foreach ($levels as $studentIdRaw => $levelRaw) {
$studentId = (int) $studentIdRaw;
if (!isset($validSet[$studentId])) {
continue;
}
$levelRaw = trim((string) $levelRaw);
$level = $levelRaw === '' ? null : (int) $levelRaw;
if ($level !== null && !in_array($level, [1, 2, 3], true)) {
continue;
}
if ($level === null) {
if (isset($existingRows[$studentId])) {
$existingRows[$studentId]->delete();
}
continue;
}
$payload = [
'student_id' => $studentId,
'school_year' => $schoolYear,
'level' => $level,
'updated_by' => $userId,
];
if (isset($existingRows[$studentId])) {
$existingRows[$studentId]->update($payload);
} else {
$payload['created_by'] = $userId;
PlacementLevel::query()->create($payload);
}
}
}
public function createPlacementBatch(string $schoolYear, string $placementTest, array $levels, ?int $userId): int
{
if ($schoolYear === '' || $placementTest === '') {
throw new RuntimeException('Missing placement test or school year.');
}
$students = $this->fetchActiveStudentsWithSection($schoolYear);
$validIds = array_values(array_filter(array_map(
static fn ($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn ($id) => $id > 0));
$validSet = array_flip($validIds);
$batch = PlacementBatch::query()->create([
'placement_test' => $placementTest,
'school_year' => $schoolYear,
'created_by' => $userId,
'updated_by' => $userId,
]);
$savedCount = 0;
foreach ($levels as $studentIdRaw => $levelRaw) {
$studentId = (int) $studentIdRaw;
if (!isset($validSet[$studentId])) {
continue;
}
$levelRaw = trim((string) $levelRaw);
if ($levelRaw === '') {
continue;
}
$score = (int) $levelRaw;
if ($score < 0 || $score > 100) {
continue;
}
PlacementScore::query()->create([
'batch_id' => (int) $batch->id,
'student_id' => $studentId,
'score' => $score,
'created_by' => $userId,
'updated_by' => $userId,
]);
$savedCount++;
}
if ($savedCount === 0) {
$batch->delete();
throw new RuntimeException('No scores entered. Batch not saved.');
}
return (int) $batch->id;
}
public function getPlacementBatch(int $batchId): array
{
$batch = PlacementBatch::query()->find($batchId);
if (!$batch) {
throw new RuntimeException('Placement batch not found.');
}
$schoolYear = (string) ($batch->school_year ?? '');
$students = $this->fetchActiveStudentsWithSection($schoolYear);
$scores = $this->fetchPlacementScoresForBatch($batchId);
return [
'batch' => $batch,
'students' => $students,
'scores' => $scores,
];
}
public function updatePlacementBatch(int $batchId, string $schoolYear, array $levels, ?int $userId): void
{
$batch = PlacementBatch::query()->find($batchId);
if (!$batch) {
throw new RuntimeException('Placement batch not found.');
}
$students = $this->fetchActiveStudentsWithSection($schoolYear);
$validIds = array_values(array_filter(array_map(
static fn ($row) => (int) ($row['student_id'] ?? 0),
$students
), static fn ($id) => $id > 0));
$validSet = array_flip($validIds);
$existing = $this->fetchPlacementScoresForBatch($batchId);
foreach ($levels as $studentIdRaw => $scoreRaw) {
$studentId = (int) $studentIdRaw;
if (!isset($validSet[$studentId])) {
continue;
}
$scoreRaw = trim((string) $scoreRaw);
if ($scoreRaw === '') {
if (isset($existing[$studentId])) {
PlacementScore::query()->whereKey($existing[$studentId]['id'])->delete();
}
continue;
}
$score = (int) $scoreRaw;
if ($score < 0 || $score > 100) {
continue;
}
if (isset($existing[$studentId])) {
PlacementScore::query()->whereKey($existing[$studentId]['id'])->update([
'score' => $score,
'updated_by' => $userId,
]);
} else {
PlacementScore::query()->create([
'batch_id' => $batchId,
'student_id' => $studentId,
'score' => $score,
'created_by' => $userId,
'updated_by' => $userId,
]);
}
}
$batch->update([
'updated_by' => $userId,
]);
}
private function fetchActiveStudentsWithSection(string $schoolYear): array
{
return DB::table('students as s')
->select('s.id as student_id', 's.school_id', 's.firstname', 's.lastname', 'sc.class_section_id', 'cs.class_section_name', 'c.class_name')
->leftJoin('student_class as sc', function ($join) use ($schoolYear) {
$join->on('sc.student_id', '=', 's.id')
->where('sc.school_year', '=', $schoolYear);
})
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->leftJoin('classes as c', 'c.id', '=', 'cs.class_id')
->where('s.is_active', 1)
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
}
private function fetchPlacementBatches(string $schoolYear): array
{
return PlacementBatch::query()
->where('school_year', $schoolYear)
->orderBy('created_at', 'DESC')
->get()
->map(fn ($row) => (array) $row)
->all();
}
private function fetchPlacementBatchDetails(array $batches, string $schoolYear): array
{
if (empty($batches)) {
return [];
}
$batchIds = array_values(array_filter(array_map(
static fn ($row) => (int) ($row['id'] ?? 0),
$batches
), static fn ($id) => $id > 0));
if (empty($batchIds)) {
return [];
}
$rows = DB::table('placement_scores as ps')
->select('ps.batch_id', 'ps.score', 's.school_id', 's.firstname', 's.lastname', 'cs.class_section_name', 'c.class_name')
->join('students as s', 's.id', '=', 'ps.student_id')
->leftJoin('student_class as sc', function ($join) use ($schoolYear) {
$join->on('sc.student_id', '=', 's.id')
->where('sc.school_year', '=', $schoolYear);
})
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->leftJoin('classes as c', 'c.id', '=', 'cs.class_id')
->whereIn('ps.batch_id', $batchIds)
->where('s.is_active', 1)
->orderBy('ps.batch_id', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
$details = [];
foreach ($rows as $row) {
$bid = (int) ($row['batch_id'] ?? 0);
if ($bid <= 0) {
continue;
}
$details[$bid][] = $row;
}
return $details;
}
private function fetchPlacementScoresForBatch(int $batchId): array
{
$rows = PlacementScore::query()
->where('batch_id', $batchId)
->get()
->map(fn ($row) => (array) $row)
->all();
$scores = [];
foreach ($rows as $row) {
$scores[(int) $row['student_id']] = $row;
}
return $scores;
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Services\Grading;
use App\Models\ScoreComment;
use App\Models\SemesterScore;
use App\Models\Student;
use App\Services\Scores\SemesterScoreService;
use RuntimeException;
class GradingRefreshService
{
public function __construct(private SemesterScoreService $semesterScoreService)
{
}
public function refreshSemesterScores(int $classSectionId, string $semester, string $schoolYear): void
{
if ($classSectionId <= 0) {
throw new RuntimeException('Missing class section.');
}
$studentTeacherInfo = Student::getStudentInfoByClassSectionId(
$classSectionId,
$semester,
$schoolYear
);
if (empty($studentTeacherInfo)) {
throw new RuntimeException('No students found for this class/term.');
}
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
$this->refreshAttendanceComments($classSectionId, $semester, $schoolYear);
}
private function refreshAttendanceComments(int $classSectionId, string $semester, string $schoolYear): void
{
if (!function_exists('attendance_comment_from_score')) {
return;
}
$scoreRows = SemesterScore::query()
->select(['student_id', 'attendance_score'])
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
if ($scoreRows->isEmpty()) {
return;
}
$studentIds = $scoreRows->pluck('student_id')->map(fn ($v) => (int) $v)->unique()->values()->all();
if (empty($studentIds)) {
return;
}
$students = Student::query()
->select(['id', 'firstname'])
->whereIn('id', $studentIds)
->get();
$nameMap = [];
foreach ($students as $student) {
$nameMap[(int) $student->id] = (string) ($student->firstname ?? '');
}
$existing = ScoreComment::query()
->where('score_type', 'attendance')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('student_id', $studentIds)
->get();
$existingByStudent = [];
foreach ($existing as $row) {
$existingByStudent[(int) $row->student_id] = $row;
}
foreach ($scoreRows as $row) {
$studentId = (int) $row->student_id;
if ($studentId <= 0) {
continue;
}
$score = $row->attendance_score !== null ? (float) $row->attendance_score : null;
if ($score === null) {
continue;
}
$auto = attendance_comment_from_score($score, $nameMap[$studentId] ?? '');
if ($auto === null) {
continue;
}
if (isset($existingByStudent[$studentId])) {
$existingByStudent[$studentId]->update([
'comment' => $auto,
]);
} else {
ScoreComment::query()->create([
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'score_type' => 'attendance',
'semester' => $semester,
'school_year' => $schoolYear,
'comment' => $auto,
'commented_by' => null,
'created_at' => now(),
]);
}
}
}
}
@@ -0,0 +1,189 @@
<?php
namespace App\Services\Grading;
use App\Models\FinalExam;
use App\Models\GradingLock;
use App\Models\Homework;
use App\Models\MidtermExam;
use App\Models\Project;
use App\Models\Quiz;
use App\Models\ScoreComment;
use App\Models\SemesterScore;
use App\Models\Student;
use App\Services\Scores\SemesterScoreService;
use RuntimeException;
class GradingScoreService
{
public function __construct(private SemesterScoreService $semesterScoreService)
{
}
public function show(string $type, int $classSectionId, int $studentId, string $semester, string $schoolYear): array
{
$model = $this->resolveModel($type);
$student = Student::query()->find($studentId);
if (!$student) {
throw new RuntimeException('Student not found.');
}
$scores = $model->newQuery()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
$scoresLocked = $classSectionId > 0
? GradingLock::isLocked($classSectionId, $semester, $schoolYear)
: false;
return [
'student' => $student,
'scores' => $scores,
'type' => $type,
'class_section_id' => $classSectionId,
'semester' => $semester,
'scores_locked' => $scoresLocked,
];
}
public function update(array $payload, int $updatedBy): void
{
$type = (string) ($payload['type'] ?? '');
$studentId = (int) ($payload['student_id'] ?? 0);
$classSectionId = (int) ($payload['class_section_id'] ?? 0);
$semester = (string) ($payload['semester'] ?? '');
$schoolYear = (string) ($payload['school_year'] ?? '');
if ($classSectionId > 0 && GradingLock::isLocked($classSectionId, $semester, $schoolYear)) {
throw new RuntimeException('Scores are locked for this class. Unlock to edit.');
}
$model = $this->resolveModel($type);
if (in_array($type, ['homework', 'quiz', 'project'], true)) {
$scoreIds = $payload['score_ids'] ?? [];
$scores = $payload['scores'] ?? [];
$comments = $payload['comments'] ?? [];
foreach ($scoreIds as $i => $id) {
$model->newQuery()->whereKey($id)->update([
'score' => $scores[$i] ?? null,
'comment' => $comments[$i] ?? null,
'updated_at' => now(),
]);
}
} elseif (in_array($type, ['midterm', 'final', 'test'], true)) {
$score = $payload['score'] ?? null;
$data = [
'score' => $score,
'updated_at' => now(),
];
$existing = $model->newQuery()
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
if ($existing) {
$existing->update($data);
} else {
$model->newQuery()->create($data + [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear,
'created_at' => now(),
]);
}
} elseif ($type === 'comments') {
$comment = (string) ($payload['comment'] ?? '');
$model->newQuery()
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->delete();
$model->newQuery()->create([
'student_id' => $studentId,
'score_type' => 'general',
'semester' => $semester,
'school_year' => $schoolYear,
'comment' => $comment,
'commented_by' => $updatedBy ?: null,
'created_at' => now(),
]);
}
$studentTeacherInfo = Student::getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear, $updatedBy);
if (!empty($studentTeacherInfo)) {
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
} catch (RuntimeException $e) {
}
}
}
public function getScoreComments(string $semester, string $schoolYear): array
{
$studentIds = Student::query()
->select('students.id')
->join('student_class', 'student_class.student_id', '=', 'students.id')
->where('student_class.semester', $semester)
->where('student_class.school_year', $schoolYear)
->pluck('students.id')
->map(fn ($v) => (int) $v)
->all();
if (empty($studentIds)) {
return [];
}
$scores = [];
$students = Student::query()
->whereIn('id', $studentIds)
->where('school_year', $schoolYear)
->get();
foreach ($students as $student) {
$scores[$student->id] = [
'school_id' => $student->school_id,
'firstname' => $student->firstname,
'lastname' => $student->lastname,
'comments' => [],
];
}
$commentRows = ScoreComment::query()
->whereIn('student_id', $studentIds)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get();
foreach ($commentRows as $comment) {
$scores[$comment->student_id]['comments'][] = $comment;
}
return $scores;
}
private function resolveModel(string $type)
{
return match ($type) {
'homework' => new Homework(),
'quiz' => new Quiz(),
'project' => new Project(),
'midterm' => new MidtermExam(),
'final' => new FinalExam(),
'test' => new SemesterScore(),
'comments' => new ScoreComment(),
default => throw new RuntimeException("Invalid type: {$type}"),
};
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Services\Grading;
use App\Models\CalendarEvent;
use DateTime;
class HomeworkTrackingCalendarService
{
public function buildDateRange(string $schoolYear, string $semester): array
{
$startYear = null;
if (preg_match('/\b(20\d{2})\b/', $schoolYear, $m)) {
$startYear = (int) $m[1];
}
if ($startYear === null) {
$today = new DateTime('today');
return [$today, $today];
}
$nextYear = $startYear + 1;
$sem = strtolower(trim($semester));
try {
if ($sem === 'spring') {
$start = new DateTime("{$nextYear}-01-25");
$end = new DateTime("{$nextYear}-05-31");
} else {
$start = new DateTime("{$startYear}-09-21");
$end = new DateTime("{$nextYear}-01-18");
}
} catch (\Throwable $e) {
$today = new DateTime('today');
return [$today, $today];
}
return [$start, $end];
}
public function buildSundays(DateTime $start, DateTime $end): array
{
if ((int) $start->format('w') !== 0) {
$start = (clone $start)->modify('next sunday');
}
$sundays = [];
$d = clone $start;
while ($d <= $end) {
$sundays[] = $d->format('Y-m-d');
$d->modify('+7 days');
}
return $sundays;
}
public function eventDays(string $schoolYear): array
{
$events = CalendarEvent::getEventsBySchoolYear($schoolYear) ?? [];
$eventDays = [];
foreach ($events as $event) {
$ymd = substr((string) ($event['date'] ?? ''), 0, 10);
$no = (int) ($event['no_school'] ?? 0);
if ($ymd && $no === 1) {
$eventDays[$ymd] = true;
}
}
return $eventDays;
}
public function dateToIndex(array $sundays, array $eventDays): array
{
$dateToIndex = [];
$idx = 0;
foreach ($sundays as $ymd) {
if (!isset($eventDays[$ymd])) {
$idx++;
$dateToIndex[$ymd] = $idx;
} else {
$dateToIndex[$ymd] = null;
}
}
return $dateToIndex;
}
}
@@ -0,0 +1,219 @@
<?php
namespace App\Services\Grading;
use App\Models\Configuration;
use Illuminate\Support\Facades\DB;
class HomeworkTrackingService
{
private array $teacherAssignmentCache = [];
public function __construct(private HomeworkTrackingCalendarService $calendarService)
{
}
public function report(?string $semester = null, ?string $schoolYear = null, int $page = 1): array
{
$semester = $semester ?: (string) (Configuration::getConfig('semester') ?? '');
$schoolYear = $schoolYear ?: (string) (Configuration::getConfig('school_year') ?? '');
[$start, $end] = $this->calendarService->buildDateRange($schoolYear, $semester);
$sundays = $this->calendarService->buildSundays($start, $end);
$eventDays = $this->calendarService->eventDays($schoolYear);
$dateToIndex = $this->calendarService->dateToIndex($sundays, $eventDays);
$limitToSemester = $this->hasTeacherAssignments($schoolYear);
$homeworkByIndex = $this->loadHomeworkByIndex($schoolYear, $semester, $limitToSemester);
$homeworkByDate = $this->loadHomeworkByDate($schoolYear, $semester, $limitToSemester, $sundays, $eventDays);
$teachers = $this->loadTeachers($schoolYear);
$perPage = 8;
$totalRows = count($teachers);
$totalPages = max(1, (int) ceil($totalRows / $perPage));
$page = max(1, min($page, $totalPages));
$offset = ($page - 1) * $perPage;
$teachersPage = array_slice($teachers, $offset, $perPage);
return [
'semester' => $semester,
'school_year' => $schoolYear,
'sundays' => $sundays,
'event_days' => $eventDays,
'date_to_index' => $dateToIndex,
'teachers' => $teachersPage,
'has_homework' => $homeworkByIndex['has_homework'],
'hw_entered_at' => $homeworkByIndex['entered_at'],
'has_homework_by_date' => $homeworkByDate['has_homework'],
'hw_entered_at_by_date' => $homeworkByDate['entered_at'],
'page' => $page,
'total_pages' => $totalPages,
'per_page' => $perPage,
'total_rows' => $totalRows,
];
}
private function hasTeacherAssignments(string $schoolYear): bool
{
$year = trim((string) $schoolYear);
if ($year === '') {
return false;
}
if (array_key_exists($year, $this->teacherAssignmentCache)) {
return $this->teacherAssignmentCache[$year];
}
$cnt = DB::table('teacher_class')->where('school_year', $year)->count();
$this->teacherAssignmentCache[$year] = $cnt > 0;
return $this->teacherAssignmentCache[$year];
}
private function loadHomeworkByIndex(string $schoolYear, string $semester, bool $limitToSemester): array
{
$query = DB::table('homework')
->select('class_section_id', 'homework_index', DB::raw('MIN(created_at) as first_created'), DB::raw('COUNT(*) as cnt'))
->where('school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$query->where('semester', $semester);
}
$rows = $query->groupBy('class_section_id', 'homework_index')->get();
$hasHomework = [];
$enteredAt = [];
foreach ($rows as $row) {
$csid = (int) $row->class_section_id;
$hi = (int) $row->homework_index;
$cnt = (int) $row->cnt;
if ($csid > 0 && $hi > 0 && $cnt > 0) {
$hasHomework[$csid][$hi] = true;
$dateStr = substr((string) ($row->first_created ?? ''), 0, 10);
$enteredAt[$csid][$hi] = $dateStr ?: null;
}
}
return ['has_homework' => $hasHomework, 'entered_at' => $enteredAt];
}
private function loadHomeworkByDate(string $schoolYear, string $semester, bool $limitToSemester, array $sundays, array $eventDays): array
{
$query = DB::table('homework')
->select(DB::raw('class_section_id, DATE(created_at) as hw_date, MIN(created_at) as first_created, COUNT(*) as cnt'))
->where('school_year', $schoolYear);
if ($limitToSemester && $semester !== '') {
$query->where('semester', $semester);
}
$rows = $query
->groupBy('class_section_id', DB::raw('DATE(created_at)'))
->orderBy('hw_date', 'ASC')
->get();
$hasHomework = [];
$enteredAt = [];
foreach ($rows as $row) {
$csid = (int) $row->class_section_id;
$date = substr((string) ($row->hw_date ?? ''), 0, 10);
$cnt = (int) $row->cnt;
$firstCreated = substr((string) ($row->first_created ?? ''), 0, 10);
if ($csid <= 0 || !$date || $cnt <= 0) {
continue;
}
$baseIndex = -1;
for ($i = count($sundays) - 1; $i >= 0; $i--) {
if ($sundays[$i] <= $date) {
$baseIndex = $i;
break;
}
}
if ($baseIndex < 0) {
continue;
}
$j = $baseIndex;
while ($j >= 0 && !empty($eventDays[$sundays[$j]])) {
$j--;
}
if ($j < 0) {
continue;
}
$sunday = $sundays[$j];
$hasHomework[$csid][$sunday] = true;
$candidate = $firstCreated ?: $date;
if (empty($enteredAt[$csid][$sunday]) || ($candidate && $candidate < $enteredAt[$csid][$sunday])) {
$enteredAt[$csid][$sunday] = $candidate;
}
}
return ['has_homework' => $hasHomework, 'entered_at' => $enteredAt];
}
private function loadTeachers(string $schoolYear): array
{
$rows = DB::table('teacher_class as tc')
->select('tc.class_section_id', 'tc.position', 'cs.class_section_name', 'cs.class_id', 'u.firstname', 'u.lastname')
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'tc.class_section_id')
->leftJoin('users as u', 'u.id', '=', 'tc.teacher_id')
->where('tc.school_year', $schoolYear)
->whereNotNull('tc.class_section_id')
->whereIn('tc.position', ['main', 'ta'])
->orderBy('cs.class_id', 'ASC')
->orderBy('cs.class_section_name', 'ASC')
->orderByRaw("FIELD(tc.position, 'main','ta')")
->orderBy('u.firstname', 'ASC')
->get();
$bySection = [];
foreach ($rows as $row) {
$sid = (int) $row->class_section_id;
if ($sid <= 0) {
continue;
}
if (!isset($bySection[$sid])) {
$bySection[$sid] = [
'class_section_id' => $sid,
'class_id' => (int) ($row->class_id ?? 0),
'class_section_name' => (string) ($row->class_section_name ?? ''),
'teachers' => [],
'tas' => [],
];
}
$name = trim((string) ($row->firstname ?? '') . ' ' . (string) ($row->lastname ?? ''));
$pos = strtolower((string) ($row->position ?? ''));
if ($name !== '') {
if ($pos === 'main') {
$bySection[$sid]['teachers'][] = $name;
} elseif ($pos === 'ta') {
$bySection[$sid]['tas'][] = $name;
}
}
}
$teachers = array_values($bySection);
usort($teachers, function ($a, $b) {
$ai = (int) ($a['class_id'] ?? 0);
$bi = (int) ($b['class_id'] ?? 0);
$order = function (int $cid): int {
if ($cid === 13) return 0;
if ($cid >= 1 && $cid <= 11) return $cid;
if ($cid === 12) return 99;
return 200 + $cid;
};
$cmp = $order($ai) <=> $order($bi);
if ($cmp !== 0) {
return $cmp;
}
return strnatcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? '');
});
return $teachers;
}
}
@@ -0,0 +1,233 @@
<?php
namespace App\Services\Incidents;
use App\Models\Configuration;
use App\Models\CurrentIncident;
use App\Models\Incident;
use App\Models\Student;
use App\Models\StudentClass;
use Illuminate\Support\Facades\DB;
class CurrentIncidentService
{
public function __construct(private IncidentLookupService $lookup)
{
}
public function listCurrent(): array
{
$incidents = CurrentIncident::query()->get()->map(fn ($row) => $row->toArray())->all();
$schoolYear = Configuration::getConfig('school_year');
$updaterIds = [];
foreach ($incidents as $incident) {
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
if (!empty($incident[$key])) {
$updaterIds[] = (int) $incident[$key];
}
}
}
$updaterMap = $this->lookup->updaterNameMap($updaterIds);
foreach ($incidents as &$incident) {
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
$id = (int) ($incident[$key] ?? 0);
$incident[$key . '_name'] = $id > 0 ? ($updaterMap[$id] ?? null) : null;
}
}
unset($incident);
return [
'incidents' => $incidents,
'grades' => $this->lookup->gradeOptionsWithActiveStudents($schoolYear),
];
}
public function studentsByGrade(int $gradeId): array
{
$studentIds = StudentClass::query()
->active()
->where('student_class.class_section_id', $gradeId)
->pluck('student_id')
->all();
if (empty($studentIds)) {
return [];
}
return Student::query()
->whereIn('id', $studentIds)
->where('is_active', 1)
->get()
->map(fn ($student) => [
'id' => (int) $student->id,
'name' => trim((string) $student->firstname . ' ' . (string) $student->lastname),
])
->all();
}
public function addIncident(array $payload, int $userId): array
{
$studentId = (int) $payload['student_id'];
$incidentType = (string) $payload['incident'];
$description = (string) ($payload['description'] ?? '');
$incidentState = (string) ($payload['incident_state'] ?? 'Open');
$stateDescription = (string) ($payload['state_description'] ?? '');
$grade = (string) $payload['grade'];
$student = Student::query()->find($studentId);
if (!$student) {
return [
'ok' => false,
'message' => 'Student not found.',
];
}
$semester = Configuration::getConfig('semester');
$schoolYear = Configuration::getConfig('school_year');
$currentDateTime = function_exists('utc_now') ? utc_now() : now('UTC')->toDateTimeString();
$existingIncident = CurrentIncident::query()
->where('student_id', $studentId)
->where('incident', $incidentType)
->first();
if ($existingIncident) {
$data = [
'incident_datetime' => $currentDateTime,
'updated_by_open' => $userId,
];
if ($incidentState === 'Closed') {
$data['incident_state'] = 'Closed';
$data['close_description'] = $this->appendLine($existingIncident->close_description ?? '', $stateDescription);
} elseif ($incidentState === 'Canceled') {
$data['incident_state'] = 'Canceled';
$data['cancel_description'] = $this->appendLine($existingIncident->cancel_description ?? '', $stateDescription);
} else {
$data['open_description'] = $this->appendLine($existingIncident->open_description ?? '', $description);
}
$existingIncident->fill($data);
$existingIncident->save();
return [
'ok' => true,
'created' => false,
'incident_id' => (int) $existingIncident->id,
];
}
$incident = CurrentIncident::query()->create([
'student_id' => $student->id,
'student_name' => trim((string) $student->firstname . ' ' . (string) $student->lastname),
'grade' => $grade,
'incident' => $incidentType,
'incident_datetime' => $currentDateTime,
'incident_state' => 'Open',
'updated_by_open' => $userId,
'open_description' => $description,
'semester' => $semester,
'school_year' => $schoolYear,
]);
return [
'ok' => true,
'created' => true,
'incident_id' => (int) $incident->id,
];
}
public function updateState(int $incidentId, string $newState): bool
{
return CurrentIncident::query()->whereKey($incidentId)->update([
'incident_state' => $newState,
]) > 0;
}
public function closeIncident(int $incidentId, string $closeDescription, ?string $actionTaken, int $userId): array
{
$incident = CurrentIncident::query()->find($incidentId);
if (!$incident) {
return ['ok' => false, 'message' => 'Incident not found.'];
}
if ($incident->incident_state === 'Closed') {
return ['ok' => false, 'message' => 'Incident is already closed.'];
}
$incident->fill([
'incident_state' => 'Closed',
'updated_by_closed' => $userId,
'close_description' => $closeDescription,
'action_taken' => $actionTaken,
]);
$incident->save();
return $this->moveToHistory($incident);
}
public function cancelIncident(int $incidentId, ?string $cancelDescription, ?string $actionTaken, int $userId): array
{
$incident = CurrentIncident::query()->find($incidentId);
if (!$incident) {
return ['ok' => false, 'message' => 'Incident not found.'];
}
if ($incident->incident_state === 'Canceled') {
return ['ok' => false, 'message' => 'Incident is already canceled.'];
}
$incident->fill([
'incident_state' => 'Canceled',
'updated_by_canceled' => $userId,
'cancel_description' => $cancelDescription,
'action_taken' => $actionTaken,
]);
$incident->save();
return $this->moveToHistory($incident);
}
private function moveToHistory(CurrentIncident $incident): array
{
return DB::transaction(function () use ($incident) {
$history = Incident::query()->create([
'student_id' => $incident->student_id,
'student_name' => $incident->student_name,
'grade' => $incident->grade,
'incident' => $incident->incident,
'incident_datetime' => $incident->incident_datetime,
'incident_state' => $incident->incident_state,
'updated_by_open' => $incident->updated_by_open,
'open_description' => $incident->open_description,
'updated_by_closed' => $incident->updated_by_closed,
'close_description' => $incident->close_description,
'action_taken' => $incident->action_taken,
'updated_by_canceled' => $incident->updated_by_canceled,
'cancel_description' => $incident->cancel_description,
'semester' => $incident->semester,
'school_year' => $incident->school_year,
'created_at' => $incident->created_at,
'updated_at' => $incident->updated_at,
]);
$incident->delete();
return [
'ok' => true,
'incident_id' => (int) $history->id,
];
});
}
private function appendLine(string $existing, string $append): string
{
if ($existing === '') {
return $append;
}
return $existing . PHP_EOL . $append;
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Services\Incidents;
use App\Models\Incident;
class IncidentAnalysisService
{
public function __construct(private IncidentLookupService $lookup)
{
}
public function analyze(?string $schoolYear, ?string $semester): array
{
$builder = Incident::query()->orderBy('incident_datetime', 'DESC');
if ($schoolYear !== null && $schoolYear !== '') {
$builder->where('school_year', $schoolYear);
}
if ($semester !== null && $semester !== '') {
$builder->where('semester', $semester);
}
$incidents = $builder->get()->map(fn ($row) => $row->toArray())->all();
if (empty($incidents)) {
return [];
}
$gradeMap = $this->lookup->gradeNameMap(array_column($incidents, 'grade'));
$students = [];
foreach ($incidents as $incident) {
$studentId = (string) ($incident['student_id'] ?? '');
$studentName = (string) ($incident['student_name'] ?? '');
$studentKey = $studentId !== '' ? 'id:' . $studentId : 'name:' . strtolower(trim($studentName));
$gradeValue = $incident['grade'] ?? '';
$gradeLabel = $gradeValue;
if (is_numeric($gradeValue)) {
$gradeLabel = $gradeMap[(int) $gradeValue] ?? (string) $gradeValue;
}
if (!isset($students[$studentKey])) {
$students[$studentKey] = [
'student_id' => $studentId,
'student_name' => $studentName,
'grade' => (string) $gradeLabel,
'total' => 0,
'open' => 0,
'closed' => 0,
'canceled' => 0,
'last_incident' => null,
'logs' => [],
];
}
$students[$studentKey]['total'] += 1;
$state = (string) ($incident['incident_state'] ?? '');
if ($state === 'Open') {
$students[$studentKey]['open'] += 1;
} elseif ($state === 'Closed') {
$students[$studentKey]['closed'] += 1;
} elseif ($state === 'Canceled') {
$students[$studentKey]['canceled'] += 1;
}
if ($students[$studentKey]['last_incident'] === null) {
$students[$studentKey]['last_incident'] = $incident['incident_datetime'] ?? null;
}
$students[$studentKey]['logs'][] = [
'incident' => $incident['incident'] ?? '',
'incident_state' => $state,
'incident_datetime' => $incident['incident_datetime'] ?? null,
'open_description' => $incident['open_description'] ?? null,
'close_description' => $incident['close_description'] ?? null,
'cancel_description' => $incident['cancel_description'] ?? null,
'action_taken' => $incident['action_taken'] ?? null,
];
}
$studentRows = array_values($students);
usort($studentRows, function ($a, $b) {
if ($a['total'] === $b['total']) {
return strcasecmp($a['student_name'], $b['student_name']);
}
return $b['total'] <=> $a['total'];
});
return $studentRows;
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Services\Incidents;
use App\Models\Incident;
class IncidentHistoryService
{
public function __construct(private IncidentLookupService $lookup)
{
}
public function history(?string $schoolYear, ?string $semester): array
{
$builder = Incident::query()->orderBy('incident_datetime', 'DESC');
if ($schoolYear !== null && $schoolYear !== '') {
$builder->where('school_year', $schoolYear);
}
if ($semester !== null && $semester !== '') {
$builder->where('semester', $semester);
}
return $builder->get()->map(fn ($row) => $row->toArray())->all();
}
public function processed(?string $schoolYear, ?string $semester): array
{
$incidents = $this->history($schoolYear, $semester);
if (empty($incidents)) {
return [];
}
$gradeMap = $this->lookup->gradeNameMap(array_column($incidents, 'grade'));
$updaterIds = [];
foreach ($incidents as $incident) {
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
if (!empty($incident[$key])) {
$updaterIds[] = (int) $incident[$key];
}
}
}
$updaterMap = $this->lookup->updaterNameMap($updaterIds);
foreach ($incidents as &$incident) {
foreach (['updated_by_open', 'updated_by_closed', 'updated_by_canceled'] as $key) {
$id = (int) ($incident[$key] ?? 0);
$incident[$key . '_name'] = $id > 0 ? ($updaterMap[$id] ?? null) : null;
}
$gradeValue = $incident['grade'] ?? '';
if (is_numeric($gradeValue)) {
$incident['grade'] = $gradeMap[(int) $gradeValue] ?? (string) $gradeValue;
}
}
unset($incident);
return $incidents;
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Services\Incidents;
use App\Models\ClassSection;
use App\Models\StudentClass;
use Illuminate\Support\Facades\DB;
class IncidentLookupService
{
public function gradeOptionsWithActiveStudents(?string $schoolYear = null): array
{
$studentCounts = StudentClass::getStudentCountsBySection($schoolYear);
if (empty($studentCounts)) {
return [];
}
$classSections = ClassSection::query()
->orderBy('class_section_name', 'ASC')
->whereIn('class_section_id', array_keys($studentCounts))
->get()
->map(fn ($row) => $row->toArray())
->all();
$grades = [];
foreach ($classSections as $section) {
$grades[] = [
'id' => $section['class_section_id'],
'name' => $section['class_section_name'],
];
}
return $grades;
}
public function gradeNameMap(array $gradeValues): array
{
$gradeIds = [];
foreach ($gradeValues as $gradeValue) {
if (is_numeric($gradeValue)) {
$gradeIds[(int) $gradeValue] = true;
}
}
if (empty($gradeIds)) {
return [];
}
$rows = ClassSection::query()
->select('class_section_id', 'class_section_name')
->whereIn('class_section_id', array_keys($gradeIds))
->get()
->map(fn ($row) => $row->toArray())
->all();
$gradeMap = [];
foreach ($rows as $row) {
$gradeMap[(int) $row['class_section_id']] = (string) ($row['class_section_name'] ?? '');
}
return $gradeMap;
}
public function updaterNameMap(array $userIds): array
{
$userIds = array_values(array_filter(array_unique(array_map('intval', $userIds))));
if (empty($userIds)) {
return [];
}
$rows = DB::table('users')
->select('id, firstname, lastname')
->whereIn('id', $userIds)
->get()
->map(fn ($row) => (array) $row)
->all();
$map = [];
foreach ($rows as $row) {
$name = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
$map[(int) $row['id']] = $name !== '' ? $name : ('User #' . $row['id']);
}
return $map;
}
}
@@ -0,0 +1,63 @@
<?php
namespace App\Services\Invoices;
use App\Models\Configuration;
class InvoiceConfigService
{
public function getSchoolYear(): string
{
return (string) (Configuration::getConfig('school_year') ?? date('Y'));
}
public function getSemester(): string
{
return (string) (Configuration::getConfig('semester') ?? 'Fall');
}
public function getDueDate(): ?string
{
$value = Configuration::getConfig('due_date');
return $value ? (string) $value : null;
}
public function getGradeFee(): int
{
return (int) (Configuration::getConfig('grade_fee') ?? 9);
}
public function getFirstStudentFee(): float
{
return (float) (Configuration::getConfig('first_student_fee') ?? 350);
}
public function getSecondStudentFee(): float
{
return (float) (Configuration::getConfig('second_student_fee') ?? 200);
}
public function getYouthFee(): float
{
return (float) (Configuration::getConfig('youth_fee') ?? 180);
}
public function getRefundDeadline(): ?string
{
$value = Configuration::getConfig('refund_deadline');
if (!$value) {
return null;
}
try {
return date('Y-m-d', strtotime((string) $value));
} catch (\Throwable $e) {
return null;
}
}
public function getTimezone(): string
{
return (string) (config('School')->attendance['timezone'] ?? user_timezone());
}
}
@@ -0,0 +1,264 @@
<?php
namespace App\Services\Invoices;
use App\Models\Enrollment;
use App\Models\EventCharges;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Refund;
use App\Models\StudentClass;
use App\Models\User;
use DateTime;
use DateTimeZone;
use Illuminate\Support\Facades\DB;
class InvoiceGenerationService
{
public function __construct(
private InvoiceConfigService $config,
private InvoiceTuitionService $tuitionService
) {
}
public function generateInvoice(int $parentId, ?string $schoolYear = null): array
{
$schoolYear = $schoolYear ?: $this->config->getSchoolYear();
$semester = $this->config->getSemester();
$enrollments = Enrollment::query()
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->get()
->map(fn ($r) => (array) $r)
->all();
if (empty($enrollments)) {
return [
'ok' => false,
'message' => 'No enrollment records found.',
];
}
$registeredKids = [];
$withdrawnKids = [];
foreach ($enrollments as $enrollment) {
$student = [
'student_id' => $enrollment['student_id'],
'parent_id' => $enrollment['parent_id'],
'class_section_id' => $enrollment['class_section_id'],
'enrollment_status' => $enrollment['enrollment_status'],
'school_year' => $enrollment['school_year'],
'semester' => $enrollment['semester'],
'admission_status' => $enrollment['admission_status'],
'is_withdrawn' => $enrollment['is_withdrawn'],
];
if (in_array($enrollment['enrollment_status'], ['enrolled', 'payment pending'], true)) {
$registeredKids[] = $student;
} elseif (in_array($enrollment['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'], true)) {
$withdrawnKids[] = $student;
}
}
$registeredKids = array_values(array_filter($registeredKids, function ($student) use ($schoolYear) {
$sid = (int) ($student['student_id'] ?? 0);
return $sid > 0 && StudentClass::hasNonEventAssignment($sid, $schoolYear);
}));
$withdrawnKids = array_values(array_filter($withdrawnKids, function ($student) use ($schoolYear) {
$sid = (int) ($student['student_id'] ?? 0);
return $sid > 0 && StudentClass::hasNonEventAssignment($sid, $schoolYear);
}));
$tuitionFee = $this->tuitionService->calculateTuitionFee(
$registeredKids,
$withdrawnKids,
$this->config->getRefundDeadline()
);
$eventsList = EventCharges::getChargesWithEventInfo($parentId, $schoolYear);
$eventChargeTotal = array_sum(array_column($eventsList, 'charged'));
$this->recalculateAndUpdateDiscount($parentId, $schoolYear, $tuitionFee, $enrollments);
$refundPaid = (new Refund())->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $schoolYear);
$totalPaid = Payment::getTotalPaidByParentId($parentId, $schoolYear);
$totalAmount = max(0.0, (float) $tuitionFee) + (float) $eventChargeTotal;
$existingInvoices = Invoice::getInvoicesByParentId($parentId, $schoolYear);
$updated = false;
$updatedIds = [];
$insertId = null;
if (!empty($existingInvoices)) {
foreach ($existingInvoices as $invoice) {
if (!isset($invoice['id'])) {
continue;
}
$extrasSum = $this->sumInvoiceAdditionalCharges((int) $invoice['id'], $schoolYear);
$newTotal = round($totalAmount + $extrasSum, 2);
$invDiscount = $this->sumInvoiceDiscounts((int) $invoice['id']);
$invRefunds = $this->sumInvoiceRefunds((int) $invoice['id'], $schoolYear);
$paidOnInv = (float) ($invoice['paid_amount'] ?? 0.0);
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
$newStatus = $newBalance <= 0.00001
? 'Paid'
: ($paidOnInv > 0 ? 'Partially Paid' : 'Unpaid');
Invoice::query()
->whereKey($invoice['id'])
->update([
'total_amount' => $newTotal,
'paid_amount' => $paidOnInv,
'balance' => $newBalance,
'status' => $newStatus,
'updated_at' => utc_now(),
]);
$updatedIds[] = (int) $invoice['id'];
$updated = true;
}
} else {
$schoolId = User::getSchoolIdByUserId($parentId);
$invoiceNumber = $schoolId
? ('INV-' . $schoolId . '-' . uniqid())
: uniqid('INV-');
$issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
$dueUtc = null;
$dueDate = $this->config->getDueDate();
if (!empty($dueDate)) {
$dueLocal = new DateTime($dueDate . ' 19:59:59', new DateTimeZone($this->config->getTimezone()));
$dueLocal->setTimezone(new DateTimeZone('UTC'));
$dueUtc = $dueLocal->format('Y-m-d H:i:s');
}
$insertId = Invoice::query()->insertGetId([
'parent_id' => $parentId,
'invoice_number' => $invoiceNumber,
'total_amount' => $totalAmount,
'paid_amount' => 0,
'balance' => $totalAmount,
'status' => 'Unpaid',
'school_year' => $schoolYear,
'semester' => $semester,
'issue_date' => $issueUtc,
'due_date' => $dueUtc,
'created_at' => utc_now(),
'updated_at' => utc_now(),
]);
}
return [
'ok' => true,
'updated' => $updated,
'updated_ids' => $updatedIds,
'insert_id' => $insertId ? (int) $insertId : null,
'total_paid' => $totalPaid,
'refund_paid' => $refundPaid,
];
}
private function recalculateAndUpdateDiscount(int $parentId, string $schoolYear, float $tuitionFee, array $enrollments): void
{
$eventChargeTotal = 0.0;
try {
$eventsList = EventCharges::getChargesWithEventInfo($parentId, $schoolYear);
$eventChargeTotal = array_sum(array_column($eventsList, 'charged'));
} catch (\Throwable $e) {
}
$invoices = Invoice::getInvoicesByParentId($parentId, $schoolYear);
if (empty($invoices)) {
return;
}
foreach ($invoices as $invoice) {
if (!isset($invoice['id'])) {
continue;
}
$invoiceId = (int) $invoice['id'];
$discountUsage = DB::table('discount_usages as du')
->select('du.id', 'dv.id as voucher_id', 'dv.discount_type', 'dv.discount_value')
->join('discount_vouchers as dv', 'du.voucher_id', '=', 'dv.id')
->join('invoices as i', 'du.invoice_id', '=', 'i.id')
->where('du.invoice_id', $invoiceId)
->where('i.school_year', $schoolYear)
->first();
if (!$discountUsage) {
continue;
}
$extrasSum = $this->sumInvoiceAdditionalCharges($invoiceId, $schoolYear);
$baseTotal = round($tuitionFee + $eventChargeTotal + $extrasSum, 2);
if (($discountUsage->discount_type ?? '') === 'percent') {
$discountAmount = round(($baseTotal * (float) $discountUsage->discount_value) / 100, 2);
} else {
$discountAmount = min((float) $discountUsage->discount_value, $baseTotal);
}
DB::table('discount_usages')
->where('id', $discountUsage->id)
->update([
'discount_amount' => $discountAmount,
'updated_at' => utc_now(),
'updated_by' => auth()->id(),
]);
}
}
private function sumInvoiceAdditionalCharges(int $invoiceId, string $schoolYear): float
{
$extrasSum = 0.0;
$rows = DB::table('additional_charges')
->select('charge_type', 'amount')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear)
->where('status', 'applied')
->get();
foreach ($rows as $row) {
$amount = (float) ($row->amount ?? 0);
$type = strtolower((string) ($row->charge_type ?? 'add'));
if ($type === 'deduct') {
$amount = -abs($amount);
} else {
$amount = abs($amount);
}
$extrasSum += $amount;
}
return $extrasSum;
}
private function sumInvoiceDiscounts(int $invoiceId): float
{
$row = DB::table('discount_usages')
->selectRaw('COALESCE(SUM(discount_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->first();
return (float) ($row->tot ?? 0.0);
}
private function sumInvoiceRefunds(int $invoiceId, string $schoolYear): float
{
$row = DB::table('refunds')
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->first();
return (float) ($row->tot ?? 0.0);
}
}
@@ -0,0 +1,83 @@
<?php
namespace App\Services\Invoices;
class InvoiceGradeService
{
public function __construct(private int $gradeFee)
{
}
public function isKindergarten(string $grade): bool
{
$g = strtolower(trim($grade));
return in_array($g, ['KG', 'kg', 'k', 'kindergarten', 'k-g', 'k.g', 'k g'], true);
}
public function compareGrades(string $gradeA, string $gradeB): int
{
$valA = $this->getGradeLevel($gradeA);
$valB = $this->getGradeLevel($gradeB);
if ($valA['level'] !== $valB['level']) {
return $valA['level'] <=> $valB['level'];
}
return strcmp($valA['suffix'] ?? '', $valB['suffix'] ?? '');
}
public function gradeLevelInt(string $grade): int
{
$gl = $this->getGradeLevel($grade);
return (int) ($gl['level'] ?? 0);
}
public function getGradeLevel($grade): array
{
if (is_numeric($grade)) {
$num = (int) $grade;
if ($num === 13) {
return ['level' => 1, 'suffix' => '', 'classId' => 13];
}
return ['level' => $num, 'suffix' => '', 'classId' => null];
}
if (!is_string($grade)) {
return ['level' => 999, 'suffix' => '', 'classId' => null];
}
$g = strtoupper(trim($grade));
$g = preg_replace('/\s+/', ' ', $g);
$g = str_replace(['.', '_'], '', $g);
$g = str_replace('-', ' ', $g);
$kg = ['K', 'KG', 'K G', 'KINDER', 'KINDERGARTEN'];
if (in_array($g, $kg, true)) {
return ['level' => 1, 'suffix' => '', 'classId' => 13];
}
$pk = ['PK', 'P K', 'PREK', 'PRE K', 'PRE-K', 'PRE KINDER', 'PREKINDER'];
if (in_array($g, $pk, true)) {
return ['level' => -1, 'suffix' => '', 'classId' => null];
}
if (preg_match('/^Y(?:OUTH)?(?:\s*(\d+))?$/', $g, $m)) {
$n = isset($m[1]) && $m[1] !== '' ? max(1, (int) $m[1]) : 1;
return [
'level' => $this->gradeFee + $n,
'suffix' => '',
'classId' => null,
];
}
if (preg_match('/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/', $g, $m)) {
return [
'level' => (int) $m[1],
'suffix' => $m[2] ?? '',
'classId' => null,
];
}
return ['level' => 999, 'suffix' => '', 'classId' => null];
}
}
@@ -0,0 +1,146 @@
<?php
namespace App\Services\Invoices;
use App\Models\Enrollment;
use App\Models\Invoice;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class InvoiceManagementService
{
public function __construct(private InvoiceConfigService $config)
{
}
public function getManagementData(?string $schoolYear): array
{
$schoolYear = trim((string) ($schoolYear ?? ''));
if ($schoolYear === '') {
$schoolYear = $this->config->getSchoolYear();
}
$schoolYears = DB::table('invoices')
->select('school_year')
->distinct()
->orderBy('school_year', 'DESC')
->pluck('school_year')
->map(fn ($val) => (string) $val)
->filter(fn ($val) => $val !== '')
->values()
->all();
if (empty($schoolYears)) {
$schoolYears = [$schoolYear];
}
$invoiceData = [];
$parents = User::getUsersByRoleAndSchoolYear('parent', $schoolYear);
foreach ($parents as $parent) {
$students = Student::query()->where('parent_id', $parent['id'])->get()->map(fn ($r) => (array) $r)->all();
$parentData = [
'parent_name' => trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')),
'parent_id' => (int) $parent['id'],
'enrolledKids' => [],
'withdrawnKids' => [],
'invoice_amount' => 0,
'refund_amount' => 0,
'last_updated' => null,
'invoice_date' => null,
'invoice_id' => null,
];
$invoices = Invoice::getInvoicesByParentId((int) $parent['id'], $schoolYear);
foreach ($invoices as $invoice) {
if (!$invoice) {
continue;
}
$parentData['invoice_amount'] = (float) ($invoice['total_amount'] ?? 0);
$parentData['last_updated'] = $invoice['updated_at'] ?? null;
$parentData['invoice_id'] = $invoice['id'] ?? null;
if (!empty($invoice['issue_date'])) {
$tzName = $this->config->getTimezone();
$parentData['invoice_date'] = (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC')))
->setTimezone(new \DateTimeZone($tzName))
->format('Y-m-d H:i:s');
} else {
$parentData['invoice_date'] = !empty($invoice['updated_at'])
? date('Y-m-d H:i:s', strtotime($invoice['updated_at']))
: null;
}
$refund = DB::table('refunds')
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')
->where('parent_id', $parent['id'])
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->first();
$parentData['refund_amount'] = (float) ($refund->refund_paid_amount ?? 0.0);
break;
}
foreach ($students as $student) {
$grade = 'N/A';
$studentClass = DB::table('student_class')
->where('student_id', $student['id'])
->where('school_year', $schoolYear)
->first();
if ($studentClass && isset($studentClass->class_section_id)) {
$classSection = DB::table('classSection')
->where('class_section_id', $studentClass->class_section_id)
->first();
if ($classSection && isset($classSection->class_section_name)) {
$grade = $classSection->class_section_name;
}
}
$enrollments = Enrollment::query()
->where('student_id', $student['id'])
->where('school_year', $schoolYear)
->get()
->map(fn ($r) => (array) $r)
->all();
foreach ($enrollments as $enrollment) {
$kid = [
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
'grade' => $grade,
'tuition_fee' => (float) ($enrollment['tuition_fee'] ?? 0),
];
switch ($enrollment['enrollment_status']) {
case 'payment pending':
case 'enrolled':
$parentData['enrolledKids'][] = $kid;
break;
case 'withdraw under review':
case 'withdrawn':
case 'refund pending':
$parentData['withdrawnKids'][] = $kid;
break;
default:
break;
}
}
}
if (!empty($parentData['enrolledKids']) || !empty($parentData['withdrawnKids'])) {
$invoiceData[] = $parentData;
}
}
return [
'schoolYear' => $schoolYear,
'schoolYears' => $schoolYears,
'invoices' => $invoiceData,
];
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Services\Invoices;
use App\Models\Invoice;
use App\Models\Payment;
use Illuminate\Support\Facades\DB;
class InvoicePaymentService
{
public function __construct(private InvoiceConfigService $config)
{
}
public function getParentInvoiceSummary(int $parentId, ?string $schoolYear): array
{
$currentSchoolYear = $this->config->getSchoolYear();
$schoolYears = DB::table('invoices')
->select('school_year')
->distinct()
->where('parent_id', $parentId)
->orderBy('school_year', 'DESC')
->get()
->map(fn ($r) => (array) $r)
->all();
$selectedYear = $schoolYear ?: null;
if (!$selectedYear) {
$hasCurrentYear = in_array($currentSchoolYear, array_column($schoolYears, 'school_year'), true);
$selectedYear = $hasCurrentYear ? $currentSchoolYear : (!empty($schoolYears) ? $schoolYears[0]['school_year'] : null);
}
$invoices = [];
if ($selectedYear) {
$invoices = Invoice::getInvoicesByParentId($parentId, $selectedYear);
}
$invoiceIds = array_column($invoices, 'id');
$refunds = [];
if (!empty($invoiceIds)) {
$refundResults = DB::table('refunds')
->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) as refund_paid_amount')
->whereIn('invoice_id', $invoiceIds)
->whereIn('status', ['Partial', 'Paid'])
->groupBy('invoice_id')
->get()
->map(fn ($r) => (array) $r)
->all();
foreach ($refundResults as $row) {
$refunds[$row['invoice_id']] = (float) ($row['refund_paid_amount'] ?? 0);
}
}
$lastPayments = [];
if (!empty($invoiceIds)) {
$paymentResults = Payment::getPaymentsByInvoice($invoiceIds);
foreach ($paymentResults as $payment) {
$lastPayments[$payment['invoice_id']] = [
'last_paid_amount' => (float) ($payment['last_paid_amount'] ?? 0),
'last_payment_date' => $payment['last_payment_date'] ?? null,
];
}
}
foreach ($invoices as &$invoice) {
$invoice['refund_amount'] = $refunds[$invoice['id']] ?? 0.0;
$invoice['last_paid_amount'] = $lastPayments[$invoice['id']]['last_paid_amount'] ?? 0.0;
$invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null;
}
unset($invoice);
return [
'invoices' => $invoices,
'schoolYears' => $schoolYears,
'selectedYear' => $selectedYear,
'currentSchoolYear' => $currentSchoolYear,
'dueDate' => $this->config->getDueDate(),
];
}
}
+445
View File
@@ -0,0 +1,445 @@
<?php
namespace App\Services\Invoices;
use App\Models\AdditionalCharge;
use App\Models\ClassSection;
use App\Models\DiscountUsage;
use App\Models\Enrollment;
use App\Models\EventCharges;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Refund;
use App\Models\Student;
use App\Models\StudentClass;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class InvoicePdfService
{
public function __construct(
private InvoiceConfigService $config,
private InvoiceGradeService $grades
) {
}
public function buildPdf(int $invoiceId): string
{
$data = $this->prepareInvoiceData($invoiceId);
if (isset($data['error'])) {
return $this->renderErrorPdf($data['error']);
}
return $this->renderPdfInvoice($data);
}
private function prepareInvoiceData(int $invoiceId): array
{
$invoice = Invoice::query()->find($invoiceId);
if (!$invoice) {
return ['error' => 'No invoice was generated. Please contact the school administration.'];
}
$parentId = (int) ($invoice->parent_id ?? 0);
$schoolYear = (string) ($invoice->school_year ?? $this->config->getSchoolYear());
$paymentsQuery = Payment::query()
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('invoice_id', $invoiceId);
if (Schema::hasColumn('payments', 'status')) {
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
$paymentsQuery->where(function ($q) use ($exclude) {
$q->whereNotIn('status', $exclude)->orWhereNull('status');
});
}
if (Schema::hasColumn('payments', 'is_void')) {
$paymentsQuery->where(function ($q) {
$q->where('is_void', 0)->orWhereNull('is_void');
});
}
$payments = $paymentsQuery->get()->map(fn ($r) => $r->toArray())->all();
$parent = User::query()->find($parentId);
if (!$parent) {
return ['error' => 'Parent associated with the invoice was not found.'];
}
$enrollments = Enrollment::query()
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->get()
->map(fn ($r) => $r->toArray())
->all();
if (empty($enrollments)) {
return ['error' => 'No enrollments found for this invoice.'];
}
$registeredKids = [];
$withdrawnKids = [];
foreach ($enrollments as $enrollment) {
$student = Student::query()->find($enrollment['student_id']);
if (!$student) {
continue;
}
$grade = StudentClass::getStudentGrade((int) $student->id);
$studentData = [
'student_id' => (int) $student->id,
'student_firstname' => (string) ($student->firstname ?? ''),
'student_lastname' => (string) ($student->lastname ?? ''),
'grade' => $grade,
'tuition_fee' => $enrollment['tuition_fee'] ?? 0,
'enrollment_status' => $enrollment['enrollment_status'],
];
if (in_array($enrollment['enrollment_status'], ['enrolled', 'payment pending'], true)) {
$registeredKids[] = $studentData;
} elseif (in_array($enrollment['enrollment_status'], ['withdrawn', 'withdraw under review', 'refund pending'], true)) {
$withdrawnKids[] = $studentData;
}
}
$registeredKids = array_values(array_filter($registeredKids, function ($student) use ($schoolYear) {
$sid = (int) ($student['student_id'] ?? 0);
return $sid > 0 && StudentClass::hasNonEventAssignment($sid, $schoolYear);
}));
$withdrawnKids = array_values(array_filter($withdrawnKids, function ($student) use ($schoolYear) {
$sid = (int) ($student['student_id'] ?? 0);
return $sid > 0 && StudentClass::hasNonEventAssignment($sid, $schoolYear);
}));
usort($registeredKids, fn ($a, $b) => $this->grades->compareGrades((string) $a['grade'], (string) $b['grade']));
usort($withdrawnKids, fn ($a, $b) => $this->grades->compareGrades((string) $a['grade'], (string) $b['grade']));
$refundAllowed = true;
$refundDeadline = $this->config->getRefundDeadline();
if ($refundDeadline) {
try {
$tz = new \DateTimeZone($this->config->getTimezone());
$today = new \DateTimeImmutable('today', $tz);
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
$refundAllowed = $today <= $deadline;
} catch (\Throwable $e) {
$refundAllowed = true;
}
}
$studentCharges = [];
$regularCount = 0;
$gradeFee = $this->config->getGradeFee();
$firstFee = $this->config->getFirstStudentFee();
$secondFee = $this->config->getSecondStudentFee();
$youthFee = $this->config->getYouthFee();
$computeCharge = function (int $gradeLevel, string $gradeName) use (&$regularCount, $gradeFee, $firstFee, $secondFee, $youthFee) {
$isRegular = $this->grades->isKindergarten($gradeName) || ($gradeLevel > 0 && $gradeLevel <= $gradeFee);
if ($isRegular) {
$fee = $regularCount === 0 ? $firstFee : $secondFee;
$regularCount++;
return $fee;
}
return $youthFee;
};
foreach ($registeredKids as $student) {
$gradeName = (string) ($student['grade'] ?? '');
$gradeLevel = $this->grades->gradeLevelInt($gradeName);
$unitFee = $computeCharge($gradeLevel, $gradeName);
$studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0];
}
if (!$refundAllowed) {
foreach ($withdrawnKids as $student) {
$gradeName = (string) ($student['grade'] ?? '');
$gradeLevel = $this->grades->gradeLevelInt($gradeName);
$unitFee = $computeCharge($gradeLevel, $gradeName);
$studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0];
}
}
$eventsList = EventCharges::getChargesWithEventInfo($parentId, $schoolYear);
$studentIds = array_values(array_unique(array_map(static fn ($r) => (int) ($r['student_id'] ?? 0), array_merge($registeredKids, $withdrawnKids))));
$schoolIdMap = [];
if (!empty($studentIds)) {
$rows = Student::query()->whereIn('id', $studentIds)->get(['id', 'school_id'])->all();
foreach ($rows as $row) {
$schoolIdMap[(int) $row->id] = $row->school_id ?? 'N/A';
}
}
$students = array_merge($registeredKids, $withdrawnKids);
foreach ($students as $i => $s) {
$sid = (int) ($s['student_id'] ?? 0);
$students[$i]['student_school_id'] = $schoolIdMap[$sid] ?? 'N/A';
}
$discounts = DiscountUsage::query()
->where('invoice_id', $invoiceId)
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->get()
->map(fn ($r) => $r->toArray())
->all();
$refundsPaidTotal = (float) DB::table('refunds')
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS tot')
->where('invoice_id', $invoiceId)
->where('school_year', $schoolYear)
->whereIn('status', ['Partial', 'Paid'])
->value('tot');
$acRows = AdditionalCharge::query()
->select('id', 'charge_type', 'title', 'description', 'amount', 'due_date', 'status', 'created_at')
->where('invoice_id', $invoiceId)
->where('status', '!=', 'void')
->orderBy('created_at')
->orderBy('id')
->get()
->map(fn ($r) => $r->toArray())
->all();
$additionalChargeLines = [];
$additionalChargesTotal = 0.0;
foreach ($acRows as $ac) {
$signed = (float) ($ac['amount'] ?? 0);
$ctype = strtolower((string) ($ac['charge_type'] ?? ''));
if ($ctype === 'deduct' && $signed > 0) {
$signed = -$signed;
} elseif ($ctype === 'add' && $signed < 0) {
$signed = abs($signed);
}
$lineDate = !empty($ac['created_at'])
? date('Y-m-d', strtotime($ac['created_at']))
: (!empty($invoice->created_at) ? local_date($invoice->created_at, 'Y-m-d') : local_date(utc_now(), 'Y-m-d'));
$desc = $ac['description'] ?? '';
if ($desc === '') {
$desc = $ctype === 'deduct' ? 'Deduct' : 'Add';
}
$additionalChargesTotal += $signed;
$additionalChargeLines[] = [
'date' => $lineDate,
'description' => $desc,
'amount' => $signed,
];
}
return [
'invoice' => $invoice->toArray(),
'parent' => $parent->toArray(),
'registeredKids' => $registeredKids,
'withdrawnKids' => $withdrawnKids,
'studentCharges' => $studentCharges,
'events' => $eventsList,
'students' => $students,
'payments' => $payments,
'discounts' => $discounts,
'additionalChargeLines' => $additionalChargeLines,
'additionalChargesTotal' => round($additionalChargesTotal, 2),
'refundsPaidTotal' => $refundsPaidTotal,
];
}
private function renderPdfInvoice(array $data): string
{
$invoice = $data['invoice'];
$parent = $data['parent'];
$registeredKids = $data['registeredKids'] ?? [];
$withdrawnKids = $data['withdrawnKids'] ?? [];
$studentCharges = $data['studentCharges'] ?? [];
$events = $data['events'] ?? [];
$students = $data['students'] ?? [];
$payments = $data['payments'] ?? [];
$discounts = $data['discounts'] ?? [];
$additionalChargeLines = $data['additionalChargeLines'] ?? [];
$additionalChargesTotal = (float) ($data['additionalChargesTotal'] ?? 0);
$refundsPaidTotal = (float) ($data['refundsPaidTotal'] ?? 0);
$pdf = new \FPDF();
$pdf->AddPage();
$pdf->SetFont('Arial', 'B', 16);
$pdf->Cell(0, 10, 'Invoice', 0, 1, 'C');
$pdf->Ln(4);
$pdf->SetFont('Arial', 'B', 12);
$pdf->Cell(40, 6, 'Parent Name:', 0, 0, 'L');
$pdf->SetFont('Arial', '', 12);
$pdf->Cell(0, 6, ($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? ''), 0, 1, 'L');
$pdf->SetFont('Arial', 'B', 12);
$pdf->Cell(40, 6, 'Invoice Number:', 0, 0, 'L');
$pdf->SetFont('Arial', '', 12);
$pdf->Cell(0, 6, (string) ($invoice['invoice_number'] ?? ''), 0, 1, 'L');
$pdf->Ln(8);
$pdf->SetFont('Arial', 'B', 11);
$pdf->Cell(30, 7, 'Date', 1);
$pdf->Cell(130, 7, 'Description', 1);
$pdf->Cell(30, 7, 'Amount', 1, 1, 'R');
$transactions = [];
$seq = 0;
$push = function (\DateTimeImmutable $dt, string $desc, float $amount, string $cat) use (&$transactions, &$seq) {
$transactions[] = [
'dt' => $dt,
'description' => $desc,
'amount' => $amount,
'cat' => $cat,
'seq' => $seq++,
];
};
$tz = new \DateTimeZone($this->config->getTimezone());
$toLocal = function (?string $raw) use ($tz): \DateTimeImmutable {
if (!$raw) {
return new \DateTimeImmutable('now', $tz);
}
try {
return new \DateTimeImmutable($raw, $tz);
} catch (\Throwable $e) {
return new \DateTimeImmutable('now', $tz);
}
};
foreach ($registeredKids as $student) {
$id = $student['student_id'];
$unit = (float) ($studentCharges[$id]['unit_fee'] ?? 0.0);
$name = $student['student_firstname'] . ' ' . $student['student_lastname'];
$gradeName = ClassSection::getClassSectionNameByClassId($student['grade']) ?? $student['grade'];
$push($toLocal($invoice['created_at'] ?? null), 'Registration of "' . $name . '" in ' . $gradeName, $unit, 'registration');
}
foreach ($events as $event) {
$studentName = 'N/A';
if (!empty($event['student_id'])) {
foreach ($students as $st) {
if (($st['student_id'] ?? null) == $event['student_id']) {
$studentName = $st['student_firstname'] . ' ' . $st['student_lastname'];
break;
}
}
}
$amount = (float) ($event['charged'] ?? 0.0);
$eventName = $event['event_name'] ?? 'Event';
$push($toLocal($event['created_at'] ?? null), 'Event ' . $eventName . ' for "' . $studentName . '"', $amount, 'event');
}
foreach ($additionalChargeLines as $line) {
$push($toLocal($line['date'] ?? null), (string) ($line['description'] ?? 'Additional Charge'), (float) ($line['amount'] ?? 0), 'additional');
}
$totalPaid = 0.0;
foreach ($payments as $payment) {
$amount = (float) ($payment['paid_amount'] ?? 0.0);
$totalPaid += $amount;
$push($toLocal($payment['payment_date'] ?? null), 'Payment (' . ($payment['payment_method'] ?? 'Payment') . ')', -$amount, 'payment');
}
$totalDiscount = 0.0;
foreach ($discounts as $discount) {
$amount = (float) ($discount['discount_amount'] ?? 0.0);
$totalDiscount += $amount;
$push($toLocal($discount['used_at'] ?? null), 'Discount applied', -$amount, 'discount');
}
usort($transactions, function ($a, $b) {
$dayA = $a['dt']->format('Y-m-d');
$dayB = $b['dt']->format('Y-m-d');
if ($dayA !== $dayB) {
return $a['dt'] <=> $b['dt'];
}
$pri = [
'registration' => 10,
'event' => 20,
'additional' => 25,
'discount' => 30,
'payment' => 90,
'other' => 50,
];
$pa = $pri[$a['cat']] ?? 50;
$pb = $pri[$b['cat']] ?? 50;
if ($pa !== $pb) {
return $pa <=> $pb;
}
$cmp = $a['dt'] <=> $b['dt'];
return $cmp !== 0 ? $cmp : ($a['seq'] <=> $b['seq']);
});
$pdf->SetFont('Arial', '', 10);
foreach ($transactions as $t) {
$pdf->Cell(30, 7, $t['dt']->format('m-d-Y'), 1);
$pdf->Cell(130, 7, $t['description'], 1);
$pdf->Cell(30, 7, ($t['amount'] < 0 ? '-$' : '$') . number_format(abs($t['amount']), 2), 1, 1, 'R');
}
$tuitionSubtotal = 0.0;
foreach ($studentCharges as $sc) {
$tuitionSubtotal += (float) ($sc['unit_fee'] ?? 0.0);
}
$eventSubtotal = 0.0;
foreach ($events as $ev) {
$eventSubtotal += (float) ($ev['charged'] ?? 0.0);
}
$totalAmount = round($tuitionSubtotal + $eventSubtotal + $additionalChargesTotal, 2);
$balance = $totalAmount - $totalPaid - $totalDiscount - $refundsPaidTotal;
if ($balance < 0) {
$balance = 0.0;
}
$pdf->Ln(5);
$pdf->SetFont('Arial', 'B', 12);
$pdf->Cell(160, 7, 'Total Charges:', 0, 0, 'R');
$pdf->SetFont('Arial', '', 12);
$pdf->Cell(30, 7, '$' . number_format($totalAmount, 2), 0, 1, 'R');
$pdf->SetFont('Arial', 'B', 12);
$pdf->Cell(160, 7, 'Total Paid:', 0, 0, 'R');
$pdf->SetFont('Arial', '', 12);
$pdf->Cell(30, 7, '$' . number_format($totalPaid, 2), 0, 1, 'R');
if ($totalDiscount > 0) {
$pdf->SetFont('Arial', 'B', 12);
$pdf->Cell(160, 7, 'Total Discount:', 0, 0, 'R');
$pdf->SetFont('Arial', '', 12);
$pdf->Cell(30, 7, '$' . number_format($totalDiscount, 2), 0, 1, 'R');
}
if ($refundsPaidTotal > 0) {
$pdf->SetFont('Arial', 'B', 12);
$pdf->Cell(160, 7, 'Refunds Paid:', 0, 0, 'R');
$pdf->SetFont('Arial', '', 12);
$pdf->Cell(30, 7, '$' . number_format($refundsPaidTotal, 2), 0, 1, 'R');
}
$pdf->SetFont('Arial', 'B', 12);
$pdf->Cell(160, 7, 'Balance Due:', 0, 0, 'R');
$pdf->SetFont('Arial', '', 12);
$pdf->Cell(30, 7, '$' . number_format($balance, 2), 0, 1, 'R');
return $pdf->Output('S');
}
private function renderErrorPdf(string $message): string
{
$pdf = new \FPDF();
$pdf->AddPage();
$pdf->SetFont('Arial', 'B', 16);
$pdf->Cell(0, 10, 'Error', 0, 1, 'C');
$pdf->SetFont('Arial', '', 12);
$pdf->Ln(10);
$pdf->MultiCell(0, 10, $message);
return $pdf->Output('S');
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Services\Invoices;
use App\Models\ClassSection;
class InvoiceTuitionService
{
public function __construct(
private InvoiceGradeService $grades,
private int $gradeFee,
private float $firstStudentFee,
private float $secondStudentFee,
private float $youthFee,
private string $timezone
) {
}
public function calculateTuitionFee(array $registeredKids, array $withdrawnKids, ?string $refundDeadline): float
{
$refundOk = $this->isRefundAllowed($refundDeadline);
$tuitionStudents = $refundOk ? $registeredKids : array_merge($registeredKids, $withdrawnKids);
return $this->calculateTotalTuitionFee($tuitionStudents);
}
public function calculateTotalTuitionFee(array $students): float
{
foreach ($students as &$student) {
$gradeName = ClassSection::getClassSectionNameBySectionId($student['class_section_id'] ?? null) ?? '';
$student['grade'] = strtoupper(trim($gradeName));
}
unset($student);
$regularCount = 0;
$youthCount = 0;
foreach ($students as $student) {
$levelInfo = $this->grades->getGradeLevel($student['grade'] ?? '');
$level = (int) ($levelInfo['level'] ?? 999);
if ($level > $this->gradeFee) {
$youthCount++;
} else {
$regularCount++;
}
}
$total = 0.0;
$total += $youthCount * $this->youthFee;
if ($regularCount >= 2) {
$total += $this->firstStudentFee;
$total += ($regularCount - 1) * $this->secondStudentFee;
} elseif ($regularCount === 1) {
$total += $this->firstStudentFee;
}
return $total;
}
private function isRefundAllowed(?string $refundDeadline): bool
{
if (!$refundDeadline) {
return true;
}
try {
$tz = new \DateTimeZone($this->timezone);
$today = new \DateTimeImmutable('today', $tz);
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
return $today <= $deadline;
} catch (\Throwable $e) {
return true;
}
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Services\Payments;
use App\Models\Payment;
class PaymentBalanceService
{
public function updateBalance(int $paymentId, float $amountPaid): bool
{
return Payment::updateBalance($paymentId, $amountPaid);
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Services\Payments;
use App\Models\Configuration;
use Illuminate\Support\Facades\DB;
class PaymentEnrollmentEventService
{
public function buildEventData(
int $parentId,
array $studentIds,
?string $schoolYear = null,
?string $semester = null,
?string $portalLink = null
): array {
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
$semester = $semester ?: Configuration::getConfig('semester');
$portalLink = $portalLink ?: url('/login');
$parent = DB::table('users')
->select('id', 'firstname', 'lastname', 'email')
->where('id', $parentId)
->first();
if (!$parent) {
throw new \RuntimeException("Parent user #{$parentId} not found");
}
$ids = array_values(array_unique(array_map('intval', $studentIds)));
if (empty($ids)) {
$ids = DB::table('students')
->where('parent_id', $parentId)
->pluck('id')
->map(fn ($id) => (int) $id)
->all();
}
if (empty($ids)) {
$data = [
'user_id' => (int) $parent->id,
'email' => $parent->email ?? null,
'firstname' => $parent->firstname ?? '',
'lastname' => $parent->lastname ?? '',
'school_year' => $schoolYear,
'semester' => $semester,
'portal_link' => $portalLink,
];
return [$data, []];
}
$rows = DB::table('students as s')
->select([
's.id as student_id',
's.firstname as s_firstname',
's.lastname as s_lastname',
's.registration_grade',
'sc.class_section_id',
'cs.class_section_name',
'tu.firstname as t_firstname',
'tu.lastname as t_lastname',
])
->leftJoin('student_class as sc', function ($join) use ($schoolYear) {
$join->on('sc.student_id', '=', 's.id')
->where('sc.school_year', '=', $schoolYear);
})
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->leftJoin('teacher_class as tc', function ($join) use ($schoolYear) {
$join->on('tc.class_section_id', '=', 'sc.class_section_id')
->where('tc.position', '=', 'main')
->where('tc.school_year', '=', $schoolYear);
})
->leftJoin('users as tu', 'tu.id', '=', 'tc.teacher_id')
->where('s.parent_id', $parentId)
->whereIn('s.id', $ids)
->orderByDesc('sc.updated_at')
->get()
->map(fn ($row) => (array) $row)
->all();
if (empty($rows)) {
$rows = DB::table('students as s')
->select('s.id as student_id', 's.firstname as s_firstname', 's.lastname as s_lastname', 's.registration_grade')
->where('s.parent_id', $parentId)
->whereIn('s.id', $ids)
->get()
->map(fn ($row) => (array) $row)
->all();
}
$students = [];
$seen = [];
foreach ($rows as $row) {
$sid = (int) ($row['student_id'] ?? 0);
if ($sid <= 0 || isset($seen[$sid])) {
continue;
}
$seen[$sid] = true;
$teacherFull = trim(trim((string) ($row['t_firstname'] ?? '')) . ' ' . trim((string) ($row['t_lastname'] ?? '')));
$teacherFull = $teacherFull !== '' ? $teacherFull : null;
$students[] = [
'id' => $sid,
'student_id' => $sid,
'firstname' => (string) ($row['s_firstname'] ?? ''),
'lastname' => (string) ($row['s_lastname'] ?? ''),
'name' => trim(((string) ($row['s_firstname'] ?? '')) . ' ' . ((string) ($row['s_lastname'] ?? ''))),
'registration_grade' => (string) ($row['registration_grade'] ?? ''),
'class_section_id' => isset($row['class_section_id']) ? (int) $row['class_section_id'] : null,
'class_section_name' => $row['class_section_name'] ?? null,
'teacher_name' => $teacherFull,
'start_date' => '',
'portal_link' => $portalLink,
];
}
$data = [
'user_id' => (int) $parent->id,
'email' => $parent->email ?? null,
'firstname' => $parent->firstname ?? '',
'lastname' => $parent->lastname ?? '',
'school_year' => $schoolYear,
'semester' => $semester,
'portal_link' => $portalLink,
];
return [$data, $students];
}
}
@@ -0,0 +1,98 @@
<?php
namespace App\Services\Payments;
use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\StudentClass;
use App\Models\User;
use Illuminate\Support\Facades\DB;
class PaymentEventChargesService
{
public function listCharges(?string $schoolYear, ?string $semester): array
{
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
$semester = $semester ?: Configuration::getConfig('semester');
$charges = DB::table('event_charges')
->select(
'event_charges.*',
'users.firstname AS parent_firstname',
'users.lastname AS parent_lastname',
'students.firstname AS student_firstname',
'students.lastname AS student_lastname'
)
->leftJoin('users', 'users.id', '=', 'event_charges.parent_id')
->leftJoin('students', 'students.id', '=', 'event_charges.student_id')
->where('event_charges.school_year', $schoolYear)
->where('event_charges.semester', $semester)
->orderByDesc('event_charges.created_at')
->get()
->map(fn ($row) => (array) $row)
->all();
return [
'charges' => $charges,
'parents' => User::getParents(),
'school_year' => $schoolYear,
'semester' => $semester,
];
}
public function addCharges(array $payload, int $userId): int
{
$studentIds = $payload['student_ids'] ?? [];
$parentId = (int) $payload['parent_id'];
$commonData = [
'parent_id' => $parentId,
'event_name' => $payload['event_name'],
'description' => $payload['description'] ?? null,
'amount' => (float) $payload['amount'],
'semester' => $payload['semester'],
'school_year' => $payload['school_year'],
'charged' => 0,
'updated_by' => $userId,
'created_at' => now(),
'updated_at' => now(),
];
$count = 0;
if (!empty($studentIds)) {
foreach ($studentIds as $studentId) {
DB::table('event_charges')->insert(array_merge($commonData, [
'student_id' => (int) $studentId,
]));
$count++;
}
} elseif (!empty($payload['student_id'])) {
DB::table('event_charges')->insert(array_merge($commonData, [
'student_id' => (int) $payload['student_id'],
]));
$count++;
}
return $count;
}
public function getEnrolledStudents(int $parentId, ?string $schoolYear): array
{
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
$enrollments = Enrollment::getEnrolledStudents($parentId, $schoolYear);
$students = [];
foreach ($enrollments as $row) {
$students[] = [
'id' => (int) ($row->id ?? 0),
'firstname' => (string) ($row->firstname ?? ''),
'lastname' => (string) ($row->lastname ?? ''),
'grade' => StudentClass::getStudentGrade((int) ($row->id ?? 0)) ?: 'N/A',
];
}
return $students;
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Services\Payments;
use App\Models\Payment;
class PaymentLookupService
{
public function getByParent(int $parentId, ?string $schoolYear = null): array
{
$query = Payment::query()->where('parent_id', $parentId);
if ($schoolYear) {
$query->where('school_year', $schoolYear);
}
return $query->orderByDesc('payment_date')->get()->toArray();
}
}
@@ -0,0 +1,649 @@
<?php
namespace App\Services\Payments;
use App\Models\Configuration;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\Student;
use App\Services\Discounts\DiscountInvoiceService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
class PaymentManualService
{
public function __construct(
private DiscountInvoiceService $invoiceService,
private PaymentEnrollmentEventService $enrollmentEventService
) {
}
public function search(string $searchTerm, ?string $schoolYear = null): array
{
$searchTerm = trim($searchTerm);
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
$installmentDateRaw = (string) (Configuration::getConfig('installment_date') ?? '');
$installmentEndYmd = '';
if ($installmentDateRaw !== '') {
$ts = strtotime($installmentDateRaw);
if ($ts !== false) {
$installmentEndYmd = date('Y-m-d', $ts);
}
}
$parentData = null;
$students = [];
$payments = [];
$invoices = [];
$pagination = null;
if ($searchTerm !== '') {
$searchClean = preg_replace('/\D/', '', $searchTerm);
$searchFormatted = $this->formatPhone($searchClean);
$builder = DB::table('users')->select('*');
$builder->where(function ($q) use ($searchTerm, $searchClean, $searchFormatted) {
$q->where('email', $searchTerm);
if (!empty($searchClean)) {
if (strlen($searchClean) === 10) {
$formattedPhone = substr($searchClean, 0, 3) . '-' . substr($searchClean, 3, 3) . '-' . substr($searchClean, 6, 4);
$q->orWhere('cellphone', $formattedPhone);
}
$q->orWhereRaw(
"REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') = ?",
[$searchClean]
);
}
if (!empty($searchFormatted)) {
$q->orWhere('cellphone', $searchFormatted);
}
});
$parent = (array) $builder->first();
if (!empty($parent)) {
unset($parent['password']);
$parentData = $parent;
$parentId = (int) ($parent['id'] ?? 0);
if ($parentId > 0) {
$students = Student::query()->where('parent_id', $parentId)->get()->toArray();
$paymentPaginator = Payment::query()
->where('parent_id', $parentId)
->orderByDesc('payment_date')
->paginate(10);
$payments = $paymentPaginator->items();
$pagination = [
'current_page' => $paymentPaginator->currentPage(),
'per_page' => $paymentPaginator->perPage(),
'total' => $paymentPaginator->total(),
'last_page' => $paymentPaginator->lastPage(),
];
$rawInvoices = Invoice::query()
->where('parent_id', $parentId)
->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear))
->orderByDesc('issue_date')
->get()
->toArray();
$invoices = $this->normalizeInvoices($rawInvoices, $installmentEndYmd);
}
}
}
return [
'parent' => $parentData,
'students' => $students,
'payments' => $payments,
'invoices' => $invoices,
'pagination' => $pagination,
'search_term' => $searchTerm,
'today' => function_exists('utc_now') ? utc_now() : now()->toDateTimeString(),
'installment_end_ymd' => $installmentEndYmd,
];
}
public function suggest(string $query): array
{
$q = trim($query);
if ($q === '') {
return [];
}
$digits = preg_replace('/\D/', '', $q);
$sql = "SELECT id, firstname, lastname, email, cellphone
FROM users
WHERE (
CONCAT_WS(' ', firstname, lastname) LIKE ?
OR email LIKE ?
OR cellphone LIKE ?";
$params = ['%' . $q . '%', '%' . $q . '%', '%' . $q . '%'];
if ($digits !== '') {
$sql .= " OR REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') LIKE ?";
$params[] = '%' . $digits . '%';
}
$sql .= ") ORDER BY lastname, firstname LIMIT 20";
$rows = DB::select($sql, $params);
$items = [];
foreach ($rows as $row) {
$label = trim(($row->firstname ?? '') . ' ' . ($row->lastname ?? ''));
$email = trim((string) ($row->email ?? ''));
$phone = trim((string) ($row->cellphone ?? ''));
$sub = trim($email . ' ' . $phone);
$value = $email !== '' ? $email : ($phone !== '' ? $phone : $label);
$items[] = [
'id' => (int) ($row->id ?? 0),
'label' => $label,
'sub' => $sub,
'value' => $value,
];
}
return $items;
}
public function recordPayment(
int $invoiceId,
float $amount,
string $paymentMethod,
?string $paymentDate,
?string $checkNumber,
?string $paymentType,
?UploadedFile $paymentFile,
?string $schoolYear = null,
?string $semester = null,
?int $userId = null
): array {
$paymentMethod = strtolower(trim($paymentMethod));
$allowedMethods = ['cash', 'check', 'card'];
if (!in_array($paymentMethod, $allowedMethods, true)) {
throw new \InvalidArgumentException('Invalid payment method.');
}
if ($paymentMethod === 'check' && ($checkNumber === null || $checkNumber === '')) {
throw new \InvalidArgumentException('Check number is required for check payments.');
}
if ($amount <= 0) {
throw new \InvalidArgumentException('Invalid amount.');
}
$invoice = Invoice::query()->find($invoiceId);
if (!$invoice) {
throw new \RuntimeException('Invoice not found.');
}
$schoolYear = $schoolYear ?: (string) ($invoice->school_year ?? Configuration::getConfig('school_year'));
$semester = $semester ?: (string) ($invoice->semester ?? Configuration::getConfig('semester'));
$initialPreBalance = $this->getCurrentInvoiceBalance($invoiceId, $schoolYear);
if ($paymentMethod === 'card' && round($amount, 2) !== round($initialPreBalance, 2)) {
throw new \InvalidArgumentException('Card payments must equal the full remaining balance.');
}
$checkFile = null;
if ($paymentFile) {
$checkFile = $this->storePaymentFile($paymentFile, $paymentMethod);
}
$paymentDate = $paymentDate ? date('Y-m-d H:i:s', strtotime($paymentDate)) : (function_exists('utc_now') ? utc_now() : now()->toDateTimeString());
DB::beginTransaction();
try {
$locked = DB::table('invoices')->where('id', $invoiceId)->lockForUpdate()->first();
if (!$locked) {
DB::rollBack();
throw new \RuntimeException('Invoice not found.');
}
$this->invoiceService->recalculateInvoice($invoiceId, $schoolYear);
$currentBalance = $this->getCurrentInvoiceBalance($invoiceId, $schoolYear);
if ($amount > $currentBalance + 0.00001) {
DB::rollBack();
throw new \InvalidArgumentException('Entered amount exceeds remaining balance.');
}
if ($paymentMethod === 'card' && round($amount, 2) !== round($currentBalance, 2)) {
DB::rollBack();
throw new \InvalidArgumentException('Card payments must equal the full remaining balance.');
}
$paidCount = $this->getSuccessfulPaymentCount($invoiceId);
$installmentSeq = $paidCount + 1;
$transactionId = 'INV-' . $invoiceId . '-' . str_replace('.', '', (string) microtime(true));
$ok = $this->processPayment(
$invoiceId,
$amount,
$paymentMethod,
$checkFile,
$transactionId,
$paymentDate,
$schoolYear,
$semester,
$checkNumber,
$installmentSeq,
$userId
);
if (!$ok) {
DB::rollBack();
throw new \RuntimeException('Failed to record payment.');
}
$this->invoiceService->recalculateInvoice($invoiceId, $schoolYear);
$postBalance = max(0.0, round($initialPreBalance - $amount, 2));
$enrollmentUpdated = $this->invoiceService->updateEnrollmentStatusIfPaid($invoiceId, $schoolYear);
if ($enrollmentUpdated > 0) {
$studentIds = Student::query()->where('parent_id', $invoice->parent_id)->pluck('id')->all();
[$eventDataEnroll, $studentDataEnroll] = $this->enrollmentEventService->buildEventData(
(int) $invoice->parent_id,
$studentIds,
$schoolYear,
$semester
);
$this->triggerEvent('studentEnrolled', [$eventDataEnroll, $studentDataEnroll]);
}
$invoiceDiscount = (float) (DB::table('discount_usages')
->selectRaw('COALESCE(SUM(discount_amount),0) AS sum_disc')
->where('invoice_id', $invoiceId)
->value('sum_disc') ?? 0);
$parentYearDiscountTotal = (float) (DB::table('discount_usages')
->selectRaw('COALESCE(SUM(discount_amount),0) AS sum_disc')
->where('parent_id', $invoice->parent_id)
->where('school_year', $schoolYear)
->value('sum_disc') ?? 0);
DB::commit();
[$eventData, $studentData] = $this->invoiceService->buildPaymentEventData(
$invoiceId,
$transactionId,
$amount,
$paymentMethod,
$paymentDate,
$checkNumber,
$installmentSeq,
$initialPreBalance,
$postBalance,
$schoolYear,
$semester
);
$eventData['invoice_discount'] = $invoiceDiscount;
$eventData['parent_year_discount_total'] = $parentYearDiscountTotal;
$this->triggerEvent('paymentReceived', [$eventData, $studentData]);
return [
'transaction_id' => $transactionId,
'installment_seq' => $installmentSeq,
'pre_balance' => $initialPreBalance,
'post_balance' => $postBalance,
];
} catch (\Throwable $e) {
DB::rollBack();
Log::error('[manualPayUpdate] ' . $e->getMessage());
throw $e;
}
}
public function editPayment(
int $paymentId,
float $paidAmount,
string $paymentMethod,
?string $checkNumber,
?UploadedFile $paymentFile,
?int $userId = null
): void {
$paymentMethod = strtolower(trim($paymentMethod));
if ($paidAmount <= 0) {
throw new \InvalidArgumentException('Invalid amount entered.');
}
$payment = Payment::query()->find($paymentId);
if (!$payment) {
throw new \RuntimeException('Original payment not found.');
}
$invoice = Invoice::query()->find((int) $payment->invoice_id);
if (!$invoice) {
throw new \RuntimeException('Linked invoice not found.');
}
$checkFile = $payment->check_file;
if ($paymentFile) {
$checkFile = $this->storePaymentFile($paymentFile, $paymentMethod);
}
$this->invoiceService->recalculateInvoice((int) $payment->invoice_id, (string) ($invoice->school_year ?? Configuration::getConfig('school_year')));
$currentBalance = $this->getCurrentInvoiceBalanceExcludingPayment((int) $payment->invoice_id, $paymentId);
if ($paidAmount > $currentBalance) {
throw new \InvalidArgumentException('Entered amount exceeds remaining balance.');
}
$updateData = [
'paid_amount' => $paidAmount,
'payment_method' => $paymentMethod,
'check_file' => $checkFile,
'updated_by' => $userId,
'check_number' => $paymentMethod === 'check' ? $checkNumber : null,
];
Payment::query()->whereKey($paymentId)->update($updateData);
$this->invoiceService->recalculateInvoice((int) $payment->invoice_id, (string) ($invoice->school_year ?? Configuration::getConfig('school_year')));
}
public function serveCheckFile(string $filename, string $mode = 'download')
{
$filename = basename($filename);
$roots = [
storage_path('app/uploads/checks/' . $filename),
storage_path('app/uploads/cards/' . $filename),
storage_path('app/uploads/misc/' . $filename),
storage_path('app/uploads/' . $filename),
];
$path = null;
foreach ($roots as $candidate) {
if (is_file($candidate)) {
$path = $candidate;
break;
}
}
if (!$path) {
abort(404, 'Payment file not found.');
}
if ($mode === 'inline') {
return response()->file($path, [
'Content-Type' => mime_content_type($path) ?: 'application/octet-stream',
'Content-Disposition' => 'inline; filename="' . $filename . '"',
]);
}
return response()->download($path, $filename);
}
private function processPayment(
int $invoiceId,
float $amount,
string $paymentMethod,
?string $checkFile,
string $transactionId,
string $paymentDate,
?string $schoolYear,
?string $semester,
?string $checkNumber,
int $installmentSeq,
?int $userId
): bool {
$invoice = Invoice::query()->find($invoiceId);
if (!$invoice) {
return false;
}
$newPaid = (float) $invoice->paid_amount + $amount;
$newBalance = (float) $invoice->balance - $amount;
if ($newBalance == $invoice->total_amount) {
$paymentStatus = 'Unpaid';
} elseif ($newBalance > 0 && $newBalance < $invoice->total_amount) {
$paymentStatus = 'Partially Paid';
} elseif ($newBalance <= 0.00001) {
$paymentStatus = 'Paid';
} else {
$paymentStatus = $invoice->status;
}
$invoiceUpdateData = [
'paid_amount' => $newPaid,
'balance' => $newBalance,
'status' => $paymentStatus,
'updated_by' => $userId,
];
if (!Invoice::query()->whereKey($invoiceId)->update($invoiceUpdateData)) {
return false;
}
$paymentData = [
'parent_id' => $invoice->parent_id,
'invoice_id' => $invoiceId,
'total_amount' => $invoice->total_amount,
'paid_amount' => $amount,
'balance' => $newBalance,
'number_of_installments' => $installmentSeq,
'transaction_id' => $transactionId,
'payment_method' => strtolower($paymentMethod),
'payment_date' => $paymentDate,
'status' => $paymentStatus,
'check_file' => $checkFile,
'check_number' => strtolower($paymentMethod) === 'check' ? $checkNumber : null,
'updated_by' => $userId,
'school_year' => $schoolYear,
'semester' => $semester,
];
return (bool) Payment::query()->create($paymentData);
}
private function getCurrentInvoiceBalance(int $invoiceId, string $schoolYear): float
{
return $this->invoiceService->getCurrentInvoiceBalance($invoiceId, $schoolYear);
}
private function getCurrentInvoiceBalanceExcludingPayment(int $invoiceId, int $excludePaymentId): float
{
$invoice = Invoice::query()->find($invoiceId);
if (!$invoice) {
return 0.0;
}
$paymentQuery = Payment::query()
->selectRaw('COALESCE(SUM(paid_amount),0) AS total_paid')
->where('invoice_id', $invoiceId)
->where('id', '!=', $excludePaymentId);
if (Schema::hasColumn('payments', 'status')) {
$paymentQuery->where(function ($q) {
$q->whereNotIn('status', [
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
])->orWhereNull('status');
});
}
if (Schema::hasColumn('payments', 'is_void')) {
$paymentQuery->where(function ($q) {
$q->where('is_void', 0)->orWhereNull('is_void');
});
}
$row = (array) $paymentQuery->first();
$totalPaid = (float) ($row['total_paid'] ?? 0);
$totalDisc = (float) (DB::table('discount_usages')
->selectRaw('COALESCE(SUM(discount_amount),0) AS total_disc')
->where('invoice_id', $invoiceId)
->value('total_disc') ?? 0);
$totalRefundPaid = (float) (DB::table('refunds')
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial', 'Paid'])
->value('total_refund_paid') ?? 0);
$total = (float) ($invoice->total_amount ?? 0);
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
}
private function getSuccessfulPaymentCount(int $invoiceId): int
{
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
return Payment::query()
->where('invoice_id', $invoiceId)
->where('paid_amount', '>', 0)
->whereNotIn('status', $exclude)
->count();
}
private function normalizeInvoices(array $rawInvoices, string $installmentEndYmd): array
{
if (empty($rawInvoices)) {
return [];
}
$invoiceIds = array_values(array_unique(array_map(static fn ($r) => (int) ($r['id'] ?? 0), $rawInvoices)));
$paidByInvoice = [];
$discountByInvoice = [];
$refundByInvoice = [];
if (!empty($invoiceIds)) {
$rows = Payment::query()
->selectRaw('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id')
->get()
->map(fn ($row) => (array) $row)
->all();
foreach ($rows as $row) {
$paidByInvoice[(int) ($row['invoice_id'] ?? 0)] = (float) ($row['total_paid'] ?? 0);
}
$discRows = DB::table('discount_usages')
->selectRaw('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id')
->get()
->map(fn ($row) => (array) $row)
->all();
foreach ($discRows as $row) {
$discountByInvoice[(int) ($row['invoice_id'] ?? 0)] = (float) ($row['total_disc'] ?? 0);
}
$refundRows = DB::table('refunds')
->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->whereIn('invoice_id', $invoiceIds)
->whereIn('status', ['Partial', 'Paid'])
->groupBy('invoice_id')
->get()
->map(fn ($row) => (array) $row)
->all();
foreach ($refundRows as $row) {
$refundByInvoice[(int) ($row['invoice_id'] ?? 0)] = (float) ($row['total_refund_paid'] ?? 0);
}
}
return array_map(function (array $inv) use ($installmentEndYmd, $paidByInvoice, $discountByInvoice, $refundByInvoice) {
$inv['total_amount'] = isset($inv['total_amount']) ? (float) $inv['total_amount'] : 0.0;
$iid = (int) ($inv['id'] ?? 0);
$actualPaid = $paidByInvoice[$iid] ?? null;
$inv['paid_amount'] = is_numeric($actualPaid) ? (float) $actualPaid : (float) ($inv['paid_amount'] ?? 0);
$inv['discount'] = isset($discountByInvoice[$iid]) ? (float) $discountByInvoice[$iid] : (float) ($inv['discount'] ?? 0.0);
$inv['refund_paid'] = isset($refundByInvoice[$iid]) ? (float) $refundByInvoice[$iid] : 0.0;
$inv['balance'] = max(0.0, (float) $inv['total_amount'] - (float) $inv['paid_amount'] - (float) $inv['discount'] - (float) $inv['refund_paid']);
$inv['due_ymd'] = $installmentEndYmd;
$issueYmd = '';
foreach (['issue_date', 'start_date', 'created_at'] as $k) {
if (!empty($inv[$k])) {
$its = strtotime((string) $inv[$k]);
if ($its !== false) {
$issueYmd = date('Y-m-d', $its);
break;
}
}
}
$inv['issue_ymd'] = $issueYmd;
return $inv;
}, $rawInvoices);
}
private function formatPhone(string $input): string
{
$digits = preg_replace('/\D/', '', $input);
if (strlen($digits) !== 10) {
return $input;
}
return sprintf('(%s)-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6, 4));
}
private function storePaymentFile(UploadedFile $file, string $paymentMethod): string
{
$mime = $file->getMimeType();
$ext = strtolower($file->getClientOriginalExtension() ?: '');
$okTypes = ['image/jpeg', 'image/png', 'application/pdf'];
$okExts = ['jpg', 'jpeg', 'png', 'pdf'];
if (!in_array($mime, $okTypes, true) || !in_array($ext, $okExts, true)) {
throw new \InvalidArgumentException('Unsupported file type. Use JPG, PNG, or PDF.');
}
if ($file->getSize() > 5 * 1024 * 1024) {
throw new \InvalidArgumentException('File too large. Max 5MB.');
}
$fileName = $file->hashName();
$subdir = $paymentMethod === 'check' ? 'checks' : ($paymentMethod === 'card' ? 'cards' : 'misc');
$targetDir = storage_path('app/uploads/' . $subdir);
if (!is_dir($targetDir)) {
mkdir($targetDir, 0775, true);
}
$file->move($targetDir, $fileName);
return $fileName;
}
private function triggerEvent(string $name, array $payload): void
{
try {
if (class_exists(\CodeIgniter\Events\Events::class)) {
\CodeIgniter\Events\Events::trigger($name, ...$payload);
} elseif (function_exists('event')) {
event($name, $payload);
}
} catch (\Throwable $e) {
Log::warning($name . ' hook failed: ' . $e->getMessage());
}
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Services\Payments;
use App\Models\Notification;
use App\Models\UserNotification;
use App\Models\Configuration;
class PaymentNotificationDispatchService
{
public function notifyUser(int $userId, string $title, string $message, array $channels = ['in_app']): void
{
$schoolYear = Configuration::getConfig('school_year');
$semester = Configuration::getConfig('semester');
$notification = Notification::query()->create([
'title' => $title,
'message' => $message,
'target_group' => 'user',
'delivery_channels' => $channels,
'priority' => 'normal',
'status' => 'sent',
'scheduled_at' => now(),
'sent_at' => now(),
'school_year' => $schoolYear,
'semester' => $semester,
]);
UserNotification::query()->create([
'notification_id' => (int) $notification->id,
'user_id' => $userId,
'is_read' => 0,
'delivered' => 1,
'delivered_at' => now(),
'school_year' => $schoolYear,
'semester' => $semester,
]);
}
}
@@ -0,0 +1,396 @@
<?php
namespace App\Services\Payments;
use App\Models\Configuration;
use App\Models\PaymentNotificationLog;
use App\Models\User;
use App\Services\EmailService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class PaymentNotificationService
{
public function __construct(
private EmailService $emailService,
private PaymentNotificationDispatchService $dispatchService
) {
}
public function listLogs(?string $from, ?string $to, ?string $type): array
{
$query = PaymentNotificationLog::query()->with('parent')->orderByDesc('sent_at');
if (!empty($from)) {
$query->where('sent_at', '>=', $from);
}
if (!empty($to)) {
$query->where('sent_at', '<=', $to);
}
if (!empty($type)) {
$query->where('type', $type);
}
return $query->get()->all();
}
public function send(array $payload): array
{
$parentId = (int) ($payload['parent_id'] ?? 0);
$typeInput = (string) ($payload['type'] ?? '');
$force = (bool) ($payload['force'] ?? false);
$schoolYear = (string) ($payload['school_year'] ?? (Configuration::getConfig('school_year') ?? date('Y')));
$tzName = $this->getTimezone();
$tz = new \DateTimeZone($tzName);
$now = new \DateTime('now', $tz);
$year = (int) $now->format('Y');
$month = (int) $now->format('n');
$results = [
'sent' => 0,
'skipped' => 0,
'failed' => 0,
'details' => [],
];
if ($parentId > 0) {
$result = $this->sendToParent($parentId, $typeInput, $schoolYear, $year, $month, $now, $force, false);
$results = $this->mergeResults($results, $result);
} else {
$parentIds = $this->getParentsWithInvoices($schoolYear);
foreach ($parentIds as $pid) {
$result = $this->sendToParent($pid, $typeInput, $schoolYear, $year, $month, $now, $force, true);
$results = $this->mergeResults($results, $result);
}
}
return $results;
}
private function sendToParent(
int $parentId,
string $typeInput,
string $schoolYear,
int $year,
int $month,
\DateTime $now,
bool $force,
bool $notifyHeads
): array {
$results = ['sent' => 0, 'skipped' => 0, 'failed' => 0, 'details' => []];
[$balance, $latestInv] = $this->computeBalance($parentId, $schoolYear);
$type = $this->resolveType($parentId, $schoolYear, $typeInput);
if (!$force && PaymentNotificationLog::existsForPeriod($parentId, $year, $month, $type)) {
$results['skipped']++;
$results['details'][] = ['parent_id' => $parentId, 'result' => 'already_sent'];
return $results;
}
if ($balance <= 0 && !$force) {
$results['skipped']++;
$results['details'][] = ['parent_id' => $parentId, 'result' => 'no_balance'];
return $results;
}
[$subject, $body] = $this->composeMessage($parentId, $balance, $schoolYear, $now);
$installmentInfo = $this->computeInstallmentSuggestion($balance);
$toEmail = $this->getUserEmail($parentId);
$ccEmail = $this->getSecondaryGuardianEmail($parentId);
$ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false;
if ($ok && $ccEmail && strcasecmp($ccEmail, (string) $toEmail) !== 0) {
$this->emailService->send($ccEmail, $subject, $body, 'finance');
}
PaymentNotificationLog::query()->create([
'parent_id' => $parentId,
'invoice_id' => $latestInv,
'school_year' => $schoolYear,
'period_year' => $year,
'period_month' => $month,
'type' => $type,
'to_email' => $toEmail,
'cc_email' => $ccEmail,
'head_fa_notified' => $notifyHeads ? 1 : 0,
'subject' => $subject,
'body' => $body,
'status' => $ok ? 'sent' : 'failed',
'error_message' => $ok ? null : 'Missing/invalid email or send failed',
'balance_snapshot' => $balance,
'sent_at' => $now->format('Y-m-d H:i:s'),
]);
if ($ok) {
$results['sent']++;
$results['details'][] = [
'parent_id' => $parentId,
'result' => 'sent',
'balance' => $balance,
'installment_due' => $installmentInfo['installment_due'],
'remaining' => $installmentInfo['remaining_installments'],
];
} else {
$results['failed']++;
$results['details'][] = ['parent_id' => $parentId, 'result' => 'failed'];
}
if ($notifyHeads) {
$this->notifyHeadsOfFinance($type, $parentId, $balance);
}
return $results;
}
private function computeBalance(int $parentId, string $schoolYear): array
{
$rows = DB::table('invoices')
->select('id', 'total_amount')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->get()
->map(fn ($row) => (array) $row)
->all();
$hasStatus = DB::getSchemaBuilder()->hasColumn('payments', 'status');
$hasVoid = DB::getSchemaBuilder()->hasColumn('payments', 'is_void');
$sumBalance = 0.0;
$latestId = null;
foreach ($rows as $row) {
$invoiceId = (int) ($row['id'] ?? 0);
if ($invoiceId <= 0) {
continue;
}
if ($latestId === null) {
$latestId = $invoiceId;
}
$total = (float) ($row['total_amount'] ?? 0);
$qb = DB::table('payments')
->selectRaw('COALESCE(SUM(paid_amount),0) AS total')
->where('invoice_id', $invoiceId);
if ($hasStatus) {
$qb->where(function ($q) {
$q->whereNotIn('status', [
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
])->orWhereNull('status');
});
}
if ($hasVoid) {
$qb->where(function ($q) {
$q->where('is_void', 0)->orWhereNull('is_void');
});
}
$paid = (float) (optional($qb->first())->total ?? 0);
$disc = (float) (optional(DB::table('discount_usages')
->selectRaw('COALESCE(SUM(discount_amount),0) AS total')
->where('invoice_id', $invoiceId)
->first())->total ?? 0);
$rfnd = (float) (optional(DB::table('refunds')
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total')
->where('invoice_id', $invoiceId)
->whereIn('status', ['Partial', 'Paid'])
->first())->total ?? 0);
$sumBalance += max(0.0, round($total - $disc - $paid - $rfnd, 2));
}
return [$sumBalance, $latestId];
}
private function resolveType(int $parentId, string $schoolYear, string $typeInput): string
{
if (in_array($typeInput, ['no_payment', 'installment'], true)) {
return $typeInput;
}
$hasPayments = DB::table('payments')
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->count() > 0;
return $hasPayments ? 'installment' : 'no_payment';
}
private function composeMessage(int $parentId, float $balance, string $schoolYear, \DateTime $now): array
{
$user = User::query()->find($parentId);
$parentName = $user ? trim(($user->firstname ?? '') . ' ' . ($user->lastname ?? '')) : '';
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
$monthYear = $now->format('F Y');
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
$balanceFmt = '$' . number_format($balance, 2);
$intro = 'This is your monthly installment reminder for ' . $schoolYear . '.';
$installmentInfo = $this->computeInstallmentSuggestion($balance);
$bodyHtml = <<<HTML
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder</h2>
<p>{$greeting}</p>
<p>{$intro}</p>
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
<ul>
<li><strong>Remaining installments:</strong> {$installmentInfo['remaining_installments']}</li>
<li><strong>Suggested installment this month:</strong> {$installmentInfo['installment_due_fmt']}</li>
<li><strong>Maximum installments available:</strong> {$installmentInfo['max_installments']}</li>
</ul>
<p>As a friendly reminder, our tuition installments are due at the beginning of each month.</p>
<p>You can review your invoice and payment options by logging in to your parent portal.</p>
<p>Thank you for your prompt attention.</p>
<p><em>Reminder Period:</em> {$monthYear}</p>
</div>
HTML;
if (view()->exists('emails._wrap_layout')) {
$body = view('emails._wrap_layout', [
'title' => 'Monthly Tuition Reminder',
'body_html' => $bodyHtml,
], ['saveData' => true]);
} else {
$body = $bodyHtml;
}
return [$subject, $body];
}
private function computeInstallmentSuggestion(float $balance): array
{
$installmentEndRaw = (string) (Configuration::getConfig('installment_date') ?? '');
$tzName = $this->getTimezone();
$tz = new \DateTimeZone($tzName);
$today = new \DateTimeImmutable('today', $tz);
$end = null;
if ($installmentEndRaw !== '') {
try {
$end = new \DateTimeImmutable($installmentEndRaw, $tz);
} catch (\Throwable $e) {
$end = null;
}
}
$remMonths = 0;
if ($end) {
$y = (int) $end->format('Y') - (int) $today->format('Y');
$m = (int) $end->format('n') - (int) $today->format('n');
$remMonths = $y * 12 + $m;
if ((int) $end->format('j') > (int) $today->format('j')) {
$remMonths += 1;
}
if ($remMonths < 0) {
$remMonths = 0;
}
}
$remainingInstallments = max(1, $remMonths);
$maxInstallments = max($remMonths ?: 0, $balance > 0 ? 2 : 0);
$installmentDue = $remainingInstallments > 0 ? ($balance / $remainingInstallments) : 0;
return [
'remaining_installments' => $remainingInstallments,
'max_installments' => $maxInstallments,
'installment_due' => round($installmentDue, 2),
'installment_due_fmt' => '$' . number_format($installmentDue, 2),
];
}
private function getSecondaryGuardianEmail(int $primaryUserId): ?string
{
$row = DB::table('family_guardians')->where('user_id', $primaryUserId)->first();
if (!$row || empty($row->family_id)) {
return null;
}
$others = DB::table('family_guardians')
->where('family_id', (int) $row->family_id)
->where('user_id', '!=', $primaryUserId)
->where('receive_emails', 1)
->get()
->map(fn ($r) => (array) $r)
->all();
foreach ($others as $g) {
$email = (string) (DB::table('users')->where('id', (int) ($g['user_id'] ?? 0))->value('email') ?? '');
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $email;
}
}
return null;
}
private function getUserEmail(int $userId): ?string
{
$email = (string) (DB::table('users')->where('id', $userId)->value('email') ?? '');
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return null;
}
return $email;
}
private function notifyHeadsOfFinance(string $type, int $parentId, float $balance): void
{
try {
$heads = User::getUsersByRole('head of department (finance)');
if (empty($heads)) {
$heads = User::getUsersByRole('accountant');
}
foreach ($heads as $user) {
$this->dispatchService->notifyUser(
(int) ($user['id'] ?? 0),
'Payment Reminder Sent',
sprintf('A %s reminder was sent to parent #%d (balance: $%0.2f).', $type, $parentId, $balance),
['in_app']
);
}
} catch (\Throwable $e) {
Log::warning('Unable to notify heads of finance: ' . $e->getMessage());
}
}
private function mergeResults(array $base, array $update): array
{
$base['sent'] += $update['sent'] ?? 0;
$base['skipped'] += $update['skipped'] ?? 0;
$base['failed'] += $update['failed'] ?? 0;
if (!empty($update['details'])) {
$base['details'] = array_merge($base['details'], $update['details']);
}
return $base;
}
private function getParentsWithInvoices(string $schoolYear): array
{
$rows = DB::table('invoices')
->selectRaw('DISTINCT parent_id')
->where('school_year', $schoolYear)
->get()
->map(fn ($row) => (array) $row)
->all();
return array_values(array_unique(array_map(static fn ($r) => (int) ($r['parent_id'] ?? 0), $rows)));
}
private function getTimezone(): string
{
if (function_exists('user_timezone')) {
return (string) user_timezone();
}
return (string) (config('app.timezone') ?: 'UTC');
}
}
@@ -0,0 +1,39 @@
<?php
namespace App\Services\Payments;
use App\Models\Configuration;
use App\Models\Payment;
use Illuminate\Support\Facades\Schema;
class PaymentPlanService
{
public function createPlan(array $payload): Payment
{
$schoolYear = $payload['school_year'] ?? Configuration::getConfig('school_year');
$semester = $payload['semester'] ?? Configuration::getConfig('semester');
$data = [
'parent_id' => (int) $payload['parent_id'],
'total_amount' => (float) $payload['total_amount'],
'number_of_installments' => (int) $payload['number_of_installments'],
'paid_amount' => 0,
'balance' => (float) $payload['total_amount'],
'payment_date' => $payload['payment_date'] ?? null,
'payment_method' => $payload['payment_method'] ?? null,
'status' => $payload['status'] ?? 'Pending',
'semester' => $semester,
'school_year' => $schoolYear,
];
if (isset($payload['invoice_id']) && Schema::hasColumn('payments', 'invoice_id')) {
$data['invoice_id'] = (int) $payload['invoice_id'];
}
if (isset($payload['payment_type']) && Schema::hasColumn('payments', 'payment_type')) {
$data['payment_type'] = $payload['payment_type'];
}
return Payment::query()->create($data);
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Services\Payments;
use App\Models\PaymentTransaction;
class PaymentTransactionService
{
public function create(array $payload): PaymentTransaction
{
$data = [
'transaction_id' => $payload['transaction_id'],
'payment_id' => (int) $payload['payment_id'],
'transaction_date' => $payload['transaction_date'],
'amount' => (float) $payload['amount'],
'payment_method' => $payload['payment_method'],
'payment_status' => $payload['payment_status'] ?? 'Pending',
'transaction_fee' => $payload['transaction_fee'] ?? null,
'payment_reference' => $payload['payment_reference'] ?? null,
'is_full_payment' => !empty($payload['is_full_payment']),
'school_year' => $payload['school_year'] ?? null,
'semester' => $payload['semester'] ?? null,
];
return PaymentTransaction::query()->create($data);
}
public function getByPayment(int $paymentId): array
{
return PaymentTransaction::getTransactionsByPaymentId($paymentId);
}
public function updateStatus(string $transactionId, string $status): bool
{
return PaymentTransaction::updateTransactionStatus($transactionId, $status);
}
}
@@ -0,0 +1,123 @@
<?php
namespace App\Services\Payments;
use App\Models\Payment;
use Illuminate\Support\Facades\Log;
class PaypalPaymentService
{
public function getRedirectUrl(): string
{
return (string) (config('services.paypal.payment_url')
?: env('PAYPAL_PAYMENT_URL')
?: 'https://www.paypal.com/ncp/payment/87FJL3EV8C7NE');
}
public function createPayment(int $paymentId): array
{
$payment = Payment::query()->find($paymentId);
if (!$payment) {
throw new \RuntimeException('Payment not found.');
}
if (!$this->sdkAvailable()) {
return [
'redirect_url' => $this->getRedirectUrl(),
'mode' => 'hosted',
'message' => 'PayPal SDK not installed; using hosted redirect URL.',
];
}
try {
$apiContext = $this->buildApiContext();
$payer = new \PayPal\Api\Payer();
$payer->setPaymentMethod('paypal');
$amount = new \PayPal\Api\Amount();
$amount->setCurrency('USD')->setTotal($payment->balance ?? $payment->total_amount ?? 0);
$transaction = new \PayPal\Api\Transaction();
$transaction->setAmount($amount)
->setDescription('Payment for school fees')
->setInvoiceNumber(uniqid('inv_', true));
$paypalPayment = new \PayPal\Api\Payment();
$paypalPayment->setIntent('sale')
->setPayer($payer)
->setTransactions([$transaction]);
$redirectUrls = new \PayPal\Api\RedirectUrls();
$redirectUrls->setReturnUrl(url('/api/v1/finance/paypal/execute'))
->setCancelUrl(url('/api/v1/finance/paypal/cancel'));
$paypalPayment->setRedirectUrls($redirectUrls);
$paypalPayment->create($apiContext);
session()->put('paypalPaymentId', $paypalPayment->getId());
session()->put('paymentId', $paymentId);
return [
'redirect_url' => $paypalPayment->getApprovalLink(),
'mode' => 'sdk',
];
} catch (\Throwable $e) {
Log::error('PayPal create payment failed: ' . $e->getMessage());
throw new \RuntimeException('Unable to create PayPal payment.');
}
}
public function executePayment(string $payerId, ?string $paypalPaymentId = null): void
{
if (!$this->sdkAvailable()) {
throw new \RuntimeException('PayPal SDK not installed.');
}
$paymentId = $paypalPaymentId ?: (string) session()->get('paypalPaymentId');
if (!$paymentId) {
throw new \RuntimeException('Missing PayPal payment ID.');
}
$apiContext = $this->buildApiContext();
$payment = \PayPal\Api\Payment::get($paymentId, $apiContext);
$execution = new \PayPal\Api\PaymentExecution();
$execution->setPayerId($payerId);
$payment->execute($execution, $apiContext);
$internalPaymentId = (int) session()->get('paymentId');
if ($internalPaymentId) {
Payment::query()->whereKey($internalPaymentId)->update(['status' => 'Completed']);
}
}
public function cancelPayment(): void
{
// no-op (client handles redirect)
}
private function sdkAvailable(): bool
{
return class_exists(\PayPal\Rest\ApiContext::class)
&& class_exists(\PayPal\Auth\OAuthTokenCredential::class);
}
private function buildApiContext(): \PayPal\Rest\ApiContext
{
$clientId = (string) (config('services.paypal.client_id') ?: env('PAYPAL_CLIENT_ID'));
$secret = (string) (config('services.paypal.secret') ?: env('PAYPAL_SECRET'));
$mode = (string) (config('services.paypal.mode') ?: env('PAYPAL_MODE') ?: 'sandbox');
$apiContext = new \PayPal\Rest\ApiContext(
new \PayPal\Auth\OAuthTokenCredential($clientId, $secret)
);
$apiContext->setConfig([
'mode' => $mode,
'http.headers' => ['Connection' => 'Close'],
]);
return $apiContext;
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Services\Payments;
use App\Models\PayPalPayment;
class PaypalTransactionsService
{
public function list(?string $keyword, int $perPage = 10)
{
$query = PayPalPayment::query()->orderByDesc('created_at');
if ($keyword) {
$query->where(function ($q) use ($keyword) {
$q->where('transaction_id', 'like', '%' . $keyword . '%')
->orWhere('payer_email', 'like', '%' . $keyword . '%')
->orWhere('event_type', 'like', '%' . $keyword . '%')
->orWhere('order_id', 'like', '%' . $keyword . '%')
->orWhere('parent_school_id', 'like', '%' . $keyword . '%');
});
}
return $query->paginate($perPage);
}
public function listAll(?string $keyword): array
{
$query = PayPalPayment::query()->orderByDesc('created_at');
if ($keyword) {
$query->where(function ($q) use ($keyword) {
$q->where('transaction_id', 'like', '%' . $keyword . '%')
->orWhere('payer_email', 'like', '%' . $keyword . '%')
->orWhere('event_type', 'like', '%' . $keyword . '%')
->orWhere('order_id', 'like', '%' . $keyword . '%')
->orWhere('parent_school_id', 'like', '%' . $keyword . '%');
});
}
return $query->get()->map(fn ($row) => (array) $row)->all();
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Services;
class PhoneFormatterService
{
public function formatPhoneNumber(string $number): ?string
{
$digits = preg_replace('/\D/', '', $number);
if (strlen($digits) === 10) {
return sprintf(
'%s-%s-%s',
substr($digits, 0, 3),
substr($digits, 3, 3),
substr($digits, 6)
);
}
return null;
}
}
@@ -0,0 +1,121 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\ReimbursementBatch;
use App\Models\ReimbursementBatchItem;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class ReimbursementBatchAssignmentService
{
public function __construct(private ReimbursementLookupService $lookupService)
{
}
public function updateAssignment(
int $expenseId,
int $batchId,
?int $adminId,
?int $reimbursementId,
string $schoolYear,
string $semester
): array {
if ($expenseId <= 0) {
throw new RuntimeException('Invalid expense id.');
}
$now = now();
$activeItem = ReimbursementBatchItem::query()
->where('expense_id', $expenseId)
->whereNull('unassigned_at')
->first();
if ($batchId <= 0) {
if ($activeItem) {
$activeItem->update(['unassigned_at' => $now]);
if (!$reimbursementId && !empty($activeItem->reimbursement_id)) {
$reimbursementId = (int) $activeItem->reimbursement_id;
}
}
return [
'batch_id' => 0,
'admin_id' => null,
'reimbursement_id' => $reimbursementId,
];
}
$batch = ReimbursementBatch::query()->find($batchId);
if (!$batch || strtolower((string) $batch->status) !== ReimbursementBatch::STATUS_OPEN) {
throw new RuntimeException('Batch not found or already closed.');
}
$currentAdmin = $activeItem ? (int) ($activeItem->admin_id ?? 0) : null;
$activeSameBatch = $activeItem && (int) ($activeItem->batch_id ?? 0) === $batchId;
if ($activeSameBatch && ($currentAdmin === ($adminId ?? 0))) {
return [
'batch_id' => $batchId,
'admin_id' => $adminId,
'reimbursement_id' => $activeItem->reimbursement_id ?? null,
];
}
if ($activeSameBatch) {
if (!$reimbursementId) {
$reimbursementId = $activeItem->reimbursement_id
? (int) $activeItem->reimbursement_id
: $this->lookupService->findReimbursementIdForExpense($expenseId);
}
$activeItem->update([
'admin_id' => $adminId,
'assigned_at' => $now,
'reimbursement_id' => $reimbursementId,
'unassigned_at' => $adminId === null ? $now : null,
]);
return [
'batch_id' => $batchId,
'admin_id' => $adminId,
'reimbursement_id' => $reimbursementId,
];
}
if ($activeItem) {
$activeItem->update(['unassigned_at' => $now]);
if (!$reimbursementId && !empty($activeItem->reimbursement_id)) {
$reimbursementId = (int) $activeItem->reimbursement_id;
}
}
if (!$reimbursementId) {
$reimbursementId = $this->lookupService->findReimbursementIdForExpense($expenseId);
}
try {
ReimbursementBatchItem::query()->create([
'batch_id' => $batchId,
'expense_id' => $expenseId,
'reimbursement_id' => $reimbursementId,
'admin_id' => $adminId,
'assigned_at' => $now,
'school_year' => $schoolYear,
'semester' => $semester,
]);
} catch (\Throwable $e) {
Log::error('Failed to assign expense to batch: ' . $e->getMessage(), [
'expense_id' => $expenseId,
'batch_id' => $batchId,
]);
throw new RuntimeException('Unable to update batch assignment right now.');
}
return [
'batch_id' => $batchId,
'admin_id' => $adminId,
'reimbursement_id' => $reimbursementId,
];
}
}
@@ -0,0 +1,154 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\Expense;
use App\Models\Reimbursement;
use App\Models\ReimbursementBatch;
use App\Models\ReimbursementBatchAdminFile;
use App\Models\ReimbursementBatchItem;
use Illuminate\Support\Facades\DB;
use RuntimeException;
class ReimbursementBatchService
{
public function __construct(private ReimbursementLookupService $lookupService)
{
}
public function createBatch(?string $title, int $userId, string $schoolYear, string $semester): array
{
$sequence = $this->nextYearlyBatchNumber($schoolYear);
$now = now();
$batch = ReimbursementBatch::query()->create([
'title' => $title !== null && trim($title) !== '' ? trim($title) : null,
'status' => ReimbursementBatch::STATUS_OPEN,
'created_by' => $userId ?: null,
'opened_at' => $now,
'school_year' => $schoolYear,
'semester' => $semester,
'yearly_batch_number' => $sequence,
]);
$label = trim((string) $batch->title) !== '' ? (string) $batch->title : ('Batch #' . $sequence);
if (trim((string) $batch->title) === '') {
$batch->title = $label;
$batch->save();
}
return [
'batch_id' => (int) $batch->id,
'label' => $label,
'sequence' => $sequence,
];
}
public function lockBatch(int $batchId, int $userId, string $schoolYear, string $semester): void
{
$batch = ReimbursementBatch::query()->find($batchId);
if (!$batch || strtolower((string) $batch->status) !== ReimbursementBatch::STATUS_OPEN) {
throw new RuntimeException('Batch not found or already closed.');
}
$adminRows = ReimbursementBatchItem::query()
->selectRaw('DISTINCT COALESCE(admin_id, 0) AS admin_id')
->where('batch_id', $batchId)
->whereNull('unassigned_at')
->get();
$adminIds = array_values(array_unique($adminRows->pluck('admin_id')->map(fn ($id) => (int) $id)->all()));
$requiredAdmins = array_values(array_filter($adminIds, static fn ($adminId) => $adminId > 0));
if (!empty($requiredAdmins)) {
$uploadedAdminIds = ReimbursementBatchAdminFile::query()
->where('batch_id', $batchId)
->whereIn('admin_id', $requiredAdmins)
->pluck('admin_id')
->map(fn ($id) => (int) $id)
->unique()
->values()
->all();
foreach ($requiredAdmins as $adminId) {
if (!in_array($adminId, $uploadedAdminIds, true)) {
throw new RuntimeException('All admin sections must have a check file before submitting the batch.');
}
}
}
DB::transaction(function () use ($batchId, $userId, $schoolYear, $semester) {
$items = DB::table('reimbursement_batch_items as bi')
->selectRaw('bi.id as batch_item_id, bi.reimbursement_id as batch_reimb_id, bi.expense_id, e.amount, e.purchased_by, e.description, e.reimbursement_id as expense_reimb_id, e.school_year as expense_school_year, e.semester as expense_semester')
->join('expenses as e', 'e.id', '=', 'bi.expense_id')
->where('bi.batch_id', $batchId)
->whereNull('bi.unassigned_at')
->get();
foreach ($items as $item) {
$expenseId = (int) ($item->expense_id ?? 0);
$recipientId = (int) ($item->purchased_by ?? 0);
if ($expenseId <= 0 || $recipientId <= 0) {
continue;
}
$reimbId = $item->batch_reimb_id ?: ($item->expense_reimb_id ?: $this->lookupService->findReimbursementIdForExpense($expenseId));
if (!$reimbId) {
$payload = [
'expense_id' => $expenseId,
'amount' => (float) ($item->amount ?? 0),
'reimbursed_to' => $recipientId,
'approved_by' => $userId ?: null,
'description' => trim((string) ($item->description ?? '')),
'status' => 'Paid',
'added_by' => $userId ?: null,
'school_year' => $item->expense_school_year ?: $schoolYear,
'semester' => $item->expense_semester ?: $semester,
'reimbursement_method' => 'Check',
'batch_number' => $batchId,
'created_at' => now(),
'updated_at' => now(),
];
$reimbId = (int) Reimbursement::query()->create($payload)->id;
}
if ($reimbId) {
Reimbursement::query()->where('id', $reimbId)->update([
'batch_number' => $batchId,
'approved_by' => $userId ?: null,
'status' => 'Paid',
]);
Expense::query()->where('id', $expenseId)->update(['reimbursement_id' => $reimbId]);
if (!empty($item->batch_item_id)) {
ReimbursementBatchItem::query()->where('id', (int) $item->batch_item_id)->update([
'reimbursement_id' => $reimbId,
]);
}
}
}
ReimbursementBatch::query()->where('id', $batchId)->update([
'status' => ReimbursementBatch::STATUS_CLOSED,
'closed_at' => now(),
'closed_by' => $userId ?: null,
]);
});
}
private function nextYearlyBatchNumber(string $schoolYear): int
{
$year = trim($schoolYear);
if ($year === '') {
return 1;
}
$max = ReimbursementBatch::query()
->where('school_year', $year)
->max('yearly_batch_number');
return ((int) $max) + 1;
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\Configuration;
class ReimbursementContextService
{
public function getSchoolYear(): string
{
return (string) (Configuration::getConfig('school_year') ?? date('Y'));
}
public function getSemester(): string
{
return (string) (Configuration::getConfig('semester') ?? 'Fall');
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\Expense;
use App\Models\Reimbursement;
use RuntimeException;
class ReimbursementCrudService
{
public function store(array $payload, int $userId, string $schoolYear, string $semester, ?string $receiptName): Reimbursement
{
$expenseId = (int) ($payload['expense_id'] ?? 0);
if ($expenseId > 0) {
$expense = Expense::query()->find($expenseId);
if ($expense && strcasecmp((string) $expense->category, 'Donation') === 0) {
throw new RuntimeException('Donation expenses are tracked but should not be reimbursed.');
}
}
$methodRaw = (string) ($payload['reimbursement_method'] ?? '');
$method = ucfirst(strtolower($methodRaw));
$reimb = Reimbursement::query()->create([
'expense_id' => $expenseId ?: null,
'amount' => $payload['amount'],
'reimbursed_to' => (int) $payload['reimbursed_to'],
'description' => $payload['description'] ?? null,
'reimbursement_method' => $method,
'check_number' => $method === 'Check' ? ($payload['check_number'] ?? null) : null,
'receipt_path' => $receiptName,
'school_year' => $payload['school_year'] ?? $schoolYear,
'semester' => $payload['semester'] ?? $semester,
'added_by' => $userId ?: null,
'approved_by' => $userId ?: null,
'status' => 'Paid',
]);
if ($expenseId > 0) {
Expense::query()->where('id', $expenseId)->update(['reimbursement_id' => $reimb->id]);
}
return $reimb;
}
public function process(array $payload, int $userId, string $schoolYear, string $semester, ?string $receiptName): Reimbursement
{
$expenseId = (int) ($payload['expense_id'] ?? 0);
if ($expenseId > 0) {
$expense = Expense::query()->find($expenseId);
if ($expense && strcasecmp((string) $expense->category, 'Donation') === 0) {
throw new RuntimeException('Donation expenses are tracked but should not be reimbursed.');
}
}
$reimb = Reimbursement::query()->create([
'expense_id' => $expenseId ?: null,
'amount' => $payload['amount'],
'reimbursed_to' => (int) $payload['reimbursed_to'],
'approved_by' => $userId ?: null,
'receipt_path' => $receiptName,
'description' => $payload['description'] ?? 'Expense reimbursement',
'status' => 'Paid',
'added_by' => $userId ?: null,
'school_year' => $payload['school_year'] ?? $schoolYear,
'semester' => $payload['semester'] ?? $semester,
'check_number' => $payload['check_number'] ?? null,
'reimbursement_method' => $payload['reimbursement_method'] ?? null,
]);
if ($expenseId > 0) {
Expense::query()->where('id', $expenseId)->update(['reimbursement_id' => $reimb->id]);
}
return $reimb;
}
public function update(Reimbursement $reimb, array $payload, int $userId, ?string $receiptName): Reimbursement
{
$methodRaw = (string) ($payload['reimbursement_method'] ?? $reimb->reimbursement_method);
$method = ucfirst(strtolower($methodRaw));
$updateData = [
'amount' => $payload['amount'],
'reimbursed_to' => (int) $payload['reimbursed_to'],
'description' => $payload['description'] ?? null,
'reimbursement_method' => $method,
'check_number' => $method === 'Check' ? ($payload['check_number'] ?? null) : null,
'receipt_path' => $receiptName,
'updated_by' => $userId ?: null,
];
$reimb->update($updateData);
return $reimb->refresh();
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Services\Reimbursements;
use App\Models\Expense;
use App\Models\ReimbursementBatchItem;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class ReimbursementDonationService
{
public function markDonation(int $expenseId, int $userId): void
{
if ($expenseId <= 0) {
throw new RuntimeException('Invalid expense id.');
}
$expense = Expense::query()->find($expenseId);
if (!$expense) {
throw new RuntimeException('Expense not found.');
}
if (!empty($expense->reimbursement_id)) {
throw new RuntimeException('Expense is already tied to a reimbursement.');
}
$now = now();
DB::beginTransaction();
try {
ReimbursementBatchItem::query()
->where('expense_id', $expenseId)
->whereNull('unassigned_at')
->update(['unassigned_at' => $now]);
$expense->update([
'category' => 'Donation',
'status' => 'approved',
'status_reason' => 'Marked as Donation (non-reimbursable).',
'approved_by' => $userId ?: null,
'updated_by' => $userId ?: null,
'reimbursement_id' => null,
]);
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
Log::error('Failed to mark donation: ' . $e->getMessage(), ['expense_id' => $expenseId]);
throw new RuntimeException('Unable to mark donation right now.');
}
}
}

Some files were not shown because too many files have changed in this diff Show More