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
@@ -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();
}
}