payment = model(Payment::class); $this->config = model(Configuration::class); $this->manualPayment = model(ManualPayment::class); $this->eventCharges = model(EventCharges::class); $this->user = model(User::class); $this->enrollment = model(Enrollment::class); $this->student = model(Student::class); $this->studentClass = model(StudentClass::class); $this->paymentError = model(PaymentError::class); $this->teacherClass = model(TeacherClass::class); $this->discountUsage = model(DiscountUsage::class); $this->additionalCharge = model(AdditionalCharge::class); $this->classSection = model(ClassSection::class); $this->invoice = model(Invoice::class); $this->schoolYear = (string) ($this->config->getConfig('school_year') ?? ''); $this->semester = (string) ($this->config->getConfig('semester') ?? ''); $this->installmentDate = $this->config->getConfig('installment_date'); } /** * GET /api/v1/payments * List payments with optional filters */ public function index(): JsonResponse { $page = max(1, (int) ($this->request->getGet('page') ?? 1)); $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); $parentId = $this->request->getGet('parent_id'); $status = $this->request->getGet('status'); $query = $this->payment->newQuery(); if ($parentId) { $query->where('parent_id', $parentId); } if ($status) { $query->where('status', $status); } $result = $this->paginate($query, $page, $perPage); return $this->success($result, 'Payments retrieved successfully'); } /** * GET /api/v1/payments/{id} * Get a single payment */ public function show($id = null): JsonResponse { $payment = $this->payment->find($id); if (!$payment) { return $this->error('Payment not found', Response::HTTP_NOT_FOUND); } return $this->success($payment, 'Payment retrieved successfully'); } /** * GET /api/v1/payments/parent/{id} * Get payments by parent ID */ public function getByParent($id = null): JsonResponse { $payments = $this->payment->newQuery() ->select('payments.*', 'invoices.invoice_number') ->leftJoin('invoices', 'invoices.id', '=', 'payments.invoice_id') ->where('payments.parent_id', $id) ->orderBy('payments.payment_date', 'DESC') ->get() ->toArray(); // Get parent name $parent = $this->user->select('firstname', 'lastname')->find($id); $parentName = trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')); return $this->success([ 'payments' => $payments, 'parent_id' => (int) $id, 'parent_name' => $parentName, ], 'Payments retrieved successfully'); } /** * POST /api/v1/payments * Create a new payment plan */ public function store(): JsonResponse { $data = $this->payloadData(); if (empty($data)) { return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); } $rules = [ 'parent_id' => 'required|integer', 'total_amount' => 'required|numeric', 'number_of_installments' => 'nullable|integer', 'payment_date' => 'required|date', 'payment_type' => 'nullable|string', 'semester' => 'nullable|string', 'school_year' => 'nullable|string', ]; $errors = $this->validateRequest($data, $rules); if (!empty($errors)) { return $this->respondValidationError($errors); } $paymentData = [ 'parent_id' => $data['parent_id'], 'total_amount' => $data['total_amount'], 'number_of_installments' => $data['number_of_installments'] ?? 1, 'paid_amount' => 0, 'balance_amount' => $data['total_amount'], 'payment_date' => $data['payment_date'], 'payment_type' => $data['payment_type'] ?? 'installment', 'status' => 'Pending', 'semester' => $data['semester'] ?? $this->semester, 'school_year' => $data['school_year'] ?? $this->schoolYear, ]; try { $payment = $this->payment->create($paymentData); return $this->success($payment->toArray(), 'Payment plan created successfully', Response::HTTP_CREATED); } catch (\Throwable $e) { log_message('error', 'Payment creation error: ' . $e->getMessage()); return $this->respondError('Failed to create payment plan', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * PATCH /api/v1/payments/{id} * Update a payment */ public function update($id = null): JsonResponse { $payment = $this->payment->find($id); if (!$payment) { return $this->error('Payment not found', Response::HTTP_NOT_FOUND); } $user = $this->getCurrentUser(); if (!$user) { return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); } $data = $this->payloadData(); if (empty($data)) { return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST); } $allowedFields = ['paid_amount', 'balance', 'status', 'payment_method', 'payment_date', 'check_number']; $updateData = []; foreach ($allowedFields as $field) { if (array_key_exists($field, $data)) { $updateData[$field] = $data[$field]; } } if (empty($updateData)) { return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST); } $updateData['updated_by'] = $user->id; try { $this->payment->update($id, $updateData); $updatedPayment = $this->payment->find($id); return $this->success($updatedPayment, 'Payment updated successfully'); } catch (\Throwable $e) { log_message('error', 'Payment update error: ' . $e->getMessage()); return $this->respondError('Failed to update payment', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * DELETE /api/v1/payments/{id} * Delete a payment */ public function destroy($id = null): JsonResponse { $row = $this->payment->find($id); if (!$row) { return $this->error('Payment not found', Response::HTTP_NOT_FOUND); } try { $this->payment->delete($id); return $this->success(null, 'Payment deleted successfully'); } catch (\Throwable $e) { log_message('error', 'Payment delete error: ' . $e->getMessage()); return $this->respondError('Failed to delete payment', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * POST /api/v1/payments/{id}/update-balance * Update balance after payment */ public function updateBalance($paymentId): JsonResponse { $data = $this->payloadData(); $amountPaid = (float) ($data['paid_amount'] ?? 0); if ($amountPaid <= 0) { return $this->respondError('Invalid amount', Response::HTTP_BAD_REQUEST); } if ($this->payment->updateBalance($paymentId, $amountPaid)) { return $this->success(['status' => 'success'], 'Balance updated successfully'); } else { return $this->error('Payment not found', Response::HTTP_NOT_FOUND); } } /** * GET /api/v1/payments/enrolled-students/{parentId} * Get enrolled students for a parent */ public function getEnrolledStudents($parentId): JsonResponse { $enrollments = $this->enrollment->getEnrolledStudents((int) $parentId, $this->schoolYear); $students = []; foreach ($enrollments as $row) { $students[] = [ 'id' => $row['id'], 'firstname' => $row['firstname'], 'lastname' => $row['lastname'], 'grade' => $this->studentClass->getStudentGrade($row['id']) ?? 'N/A', ]; } return $this->success($students, 'Enrolled students retrieved successfully'); } /** * GET /api/v1/payments/manual-pay-search * Search for parent and get payment data */ public function manualPaySearch(): JsonResponse { $searchTerm = trim((string) $this->request->getGet('search_term')); $installmentDateRaw = (string) ($this->installmentDate ?? ''); $installmentEndYmd = ''; if ($installmentDateRaw) { $ts = strtotime($installmentDateRaw); if ($ts !== false) { $installmentEndYmd = date('Y-m-d', $ts); } } $parentData = null; $students = []; $payments = []; $invoices = []; if ($searchTerm !== '') { $searchClean = preg_replace('/\D/', '', $searchTerm); $searchFormatted = $this->formatPhone($searchClean); $parent = DB::table('users') ->where(function ($query) use ($searchTerm, $searchClean, $searchFormatted) { $query->where('email', $searchTerm); if (!empty($searchClean) && strlen($searchClean) === 10) { $formattedPhone = substr($searchClean, 0, 3) . '-' . substr($searchClean, 3, 3) . '-' . substr($searchClean, 6, 4); $query->orWhere('cellphone', $formattedPhone); } if (!empty($searchClean)) { $query->orWhereRaw("REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') = ?", [$searchClean]); } if (!empty($searchFormatted)) { $query->orWhere('cellphone', $searchFormatted); } }) ->first(); if ($parent) { $parentArray = (array) $parent; unset($parentArray['password']); $parentData = $parentArray; $parentId = (int) $parent['id']; // Students $students = $this->student ->where('parent_id', $parentId) ->findAll(); // Payments (paginated) $payments = $this->payment ->where('parent_id', $parentId) ->orderBy('payment_date', 'DESC') ->paginate(10); // Invoices $rawInvoices = $this->invoice ->where('parent_id', $parentId) ->orderBy('issue_date', 'DESC') ->findAll(); // Calculate invoice balances $invoiceIds = array_values(array_unique(array_map(static fn($r) => (int)($r['id'] ?? 0), $rawInvoices))); $paidByInvoice = []; $discountByInvoice = []; $refundByInvoice = []; if (!empty($invoiceIds)) { $paidRows = DB::table('payments') ->select('invoice_id', DB::raw('COALESCE(SUM(paid_amount),0) AS total_paid')) ->whereIn('invoice_id', $invoiceIds) ->groupBy('invoice_id') ->get(); foreach ($paidRows as $r) { $iid = (int)($r->invoice_id ?? 0); $paidByInvoice[$iid] = (float)($r->total_paid ?? 0); } $discRows = DB::table('discount_usages') ->select('invoice_id', DB::raw('COALESCE(SUM(discount_amount),0) AS total_disc')) ->whereIn('invoice_id', $invoiceIds) ->groupBy('invoice_id') ->get(); foreach ($discRows as $dr) { $iid = (int)($dr->invoice_id ?? 0); $discountByInvoice[$iid] = (float)($dr->total_disc ?? 0); } $refRows = DB::table('refunds') ->select('invoice_id', DB::raw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')) ->whereIn('invoice_id', $invoiceIds) ->whereIn('status', ['Partial', 'Paid']) ->groupBy('invoice_id') ->get(); foreach ($refRows as $rr) { $iid = (int)($rr->invoice_id ?? 0); $refundByInvoice[$iid] = (float)($rr->total_refund_paid ?? 0); } } // Normalize invoices $invoices = 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 = isset($paidByInvoice[$iid]) ? (float)$paidByInvoice[$iid] : null; $inv['paid_amount'] = is_numeric($actualPaid) ? (float)$actualPaid : (isset($inv['paid_amount']) ? (float)$inv['paid_amount'] : 0.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($inv[$k]); if ($its !== false) { $issueYmd = date('Y-m-d', $its); break; } } } $inv['issue_ymd'] = $issueYmd; return $inv; }, $rawInvoices); } } return $this->success([ 'parent' => $parentData, 'students' => $students, 'payments' => $payments, 'invoices' => $invoices, 'search_term' => $searchTerm, 'today_ymd' => date('Y-m-d'), 'installment_end_ymd' => $installmentEndYmd, ], 'Manual pay search completed'); } /** * POST /api/v1/payments/manual-pay-update * Update manual payment */ public function manualPayUpdate(): JsonResponse { $data = $this->payloadData(); $invoiceId = (int) ($data['invoice_id'] ?? 0); $amount = (float) ($data['amount'] ?? 0); $paymentMethod = strtolower(trim((string) ($data['payment_method'] ?? ''))); $checkNumber = trim((string) ($data['check_number'] ?? '')); $paymentDateStr = $data['payment_date'] ?? null; $paymentDate = $paymentDateStr ? date('Y-m-d H:i:s', strtotime($paymentDateStr)) : utc_now(); // Validation if (!$invoiceId || !$amount || !$paymentMethod) { return $this->respondError('Missing required fields (invoice, amount, or method).', Response::HTTP_BAD_REQUEST); } $allowedMethods = ['cash', 'check', 'card']; if (!in_array($paymentMethod, $allowedMethods, true)) { return $this->respondError('Invalid payment method.', Response::HTTP_BAD_REQUEST); } if ($paymentMethod === 'check' && $checkNumber === '') { return $this->respondError('Check number is required for check payments.', Response::HTTP_BAD_REQUEST); } if ($amount <= 0) { return $this->respondError('Invalid amount: ' . number_format($amount, 2), Response::HTTP_BAD_REQUEST); } $invoice = $this->invoice->find($invoiceId); if (!$invoice) { return $this->respondError('Invoice not found.', Response::HTTP_NOT_FOUND); } // Snapshot pre-payment balance $initialPreBalance = $this->getCurrentInvoiceBalance($invoiceId); // Card must equal full balance if ($paymentMethod === 'card' && (float)round($amount, 2) !== (float)round($initialPreBalance, 2)) { return $this->respondError( 'Debit/Credit Card payments must equal the full remaining balance (' . number_format($initialPreBalance, 2) . ').', Response::HTTP_BAD_REQUEST ); } // Handle file upload $checkFile = null; if ($this->laravelRequest->hasFile('payment_file')) { $paymentFile = $this->laravelRequest->file('payment_file'); if ($paymentFile->isValid()) { $okTypes = ['image/jpeg', 'image/png', 'application/pdf']; $okExts = ['jpg', 'jpeg', 'png', 'pdf']; $mime = $paymentFile->getMimeType(); $ext = strtolower($paymentFile->getClientExtension()); if (!in_array($mime, $okTypes, true) || !in_array($ext, $okExts, true)) { return $this->respondError('Unsupported file type. Use JPG, PNG, or PDF.', Response::HTTP_BAD_REQUEST); } if ($paymentFile->getSize() > 5 * 1024 * 1024) { return $this->respondError('File too large. Max 5MB.', Response::HTTP_BAD_REQUEST); } $subdir = ($paymentMethod === 'check') ? 'checks' : (($paymentMethod === 'card') ? 'cards' : 'misc'); $fileName = $paymentFile->store('payments/' . $subdir, 'public'); $checkFile = basename($fileName); } } DB::beginTransaction(); try { // Lock invoice $row = DB::selectOne('SELECT id, parent_id, total_amount, school_year FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]); if (!$row) { DB::rollBack(); return $this->respondError('Invoice not found.', Response::HTTP_NOT_FOUND); } $parentId = (int)($row->parent_id ?? 0); $invYear = (string)($row->school_year ?? $this->schoolYear); // Recalculate invoice $this->recalculateInvoice($invoiceId, $invYear); // Get current balance $currentBalance = $this->getCurrentInvoiceBalance($invoiceId); if ($amount > $currentBalance + 0.00001) { DB::rollBack(); return $this->respondError( 'Entered amount (' . number_format($amount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').', Response::HTTP_BAD_REQUEST ); } if ($paymentMethod === 'card' && (float)round($amount, 2) !== (float)round($currentBalance, 2)) { DB::rollBack(); return $this->respondError( 'Debit/Credit Card payments must equal the full remaining balance (' . number_format($currentBalance, 2) . ').', Response::HTTP_BAD_REQUEST ); } // Installment sequence $paidCount = $this->getSuccessfulPaymentCount($invoiceId); $installmentSeq = $paidCount + 1; // Generate transaction ID $transactionId = 'INV-' . $invoiceId . '-' . str_replace('.', '', (string)microtime(true)); // Process payment $ok = $this->processPayment( $invoiceId, $amount, $paymentMethod, $checkFile, $transactionId, $paymentDate, $this->schoolYear, $this->semester, $checkNumber, $installmentSeq ); if (!$ok) { DB::rollBack(); return $this->respondError('Failed to record payment.', Response::HTTP_INTERNAL_SERVER_ERROR); } // Recalculate invoice $this->recalculateInvoice($invoiceId, $this->schoolYear); // Post-payment balance $postBalance = (float)round($initialPreBalance - $amount, 2); if ($postBalance < 0) $postBalance = 0.0; // Update enrollment status if paid $enrollmentUpdated = $this->updateEnrollmentStatusIfPaid($invoiceId); if ($enrollmentUpdated != 0) { $studentIds = $this->student->getStudentIdsByParentId($parentId); [$eventDataEnroll, $studentDataEnroll] = $this->buildStudentEnrolledEventData($parentId, $studentIds); Event::dispatch('studentEnrolled', [$eventDataEnroll, $studentDataEnroll]); } // Calculate discounts $invoiceDiscount = 0.0; $rowDisc = DB::table('discount_usages') ->select(DB::raw('COALESCE(SUM(discount_amount),0) AS sum_disc')) ->where('invoice_id', $invoiceId) ->first(); if ($rowDisc) { $invoiceDiscount = (float)$rowDisc->sum_disc; } $parentYearDiscountTotal = 0.0; $rowParentDisc = DB::table('discount_usages') ->select(DB::raw('COALESCE(SUM(discount_amount),0) AS sum_disc')) ->where('parent_id', $parentId) ->where('school_year', $this->schoolYear) ->first(); if ($rowParentDisc) { $parentYearDiscountTotal = (float)$rowParentDisc->sum_disc; } DB::commit(); // Build event data [$eventData, $studentData] = $this->buildPaymentEventData( $invoiceId, $transactionId, $amount, $paymentMethod, $paymentDate, $checkNumber, $installmentSeq, $initialPreBalance, $postBalance, $invoiceDiscount, $parentYearDiscountTotal ); Event::dispatch('paymentReceived', [$eventData, $studentData]); return $this->success([ 'transaction_id' => $transactionId, 'installment_seq' => $installmentSeq, 'post_balance' => $postBalance, ], 'Payment recorded successfully (Installment #' . $installmentSeq . '). Transaction ID: ' . $transactionId); } catch (\Throwable $e) { DB::rollBack(); log_message('error', '[manualPayUpdate] ' . $e->getMessage()); return $this->respondError('Unexpected error while recording payment.', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * POST /api/v1/payments/manual-pay-edit * Edit a manual payment */ public function manualPayEdit(): JsonResponse { $data = $this->payloadData(); $paymentId = (int) ($data['payment_id'] ?? 0); $paidAmount = (float) ($data['paid_amount'] ?? 0); $paymentMethod = $data['payment_method'] ?? ''; $checkNumber = $data['check_number'] ?? ''; if (!$paymentId || !$paidAmount || !$paymentMethod) { return $this->respondError('Missing required fields.', Response::HTTP_BAD_REQUEST); } $payment = $this->payment->find($paymentId); if (!$payment) { return $this->respondError('Original payment not found.', Response::HTTP_NOT_FOUND); } $invoice = $this->invoice->find($payment['invoice_id']); if (!$invoice) { return $this->respondError('Linked invoice not found.', Response::HTTP_NOT_FOUND); } $checkFile = $payment['check_file'] ?? null; // Handle file upload if (in_array(strtolower($paymentMethod), ['check', 'card']) && $this->laravelRequest->hasFile('payment_file')) { $paymentFile = $this->laravelRequest->file('payment_file'); if ($paymentFile->isValid()) { $subdir = (strtolower($paymentMethod) === 'check') ? 'checks' : 'cards'; $fileName = $paymentFile->store('payments/' . $subdir, 'public'); $checkFile = basename($fileName); } } if ($paidAmount <= 0) { return $this->respondError( 'Invalid amount entered: ' . number_format($paidAmount, 2) . '. Amount must be greater than zero.', Response::HTTP_BAD_REQUEST ); } // Recalculate invoice $this->recalculateInvoice($payment['invoice_id'], $this->schoolYear); // Get current balance excluding this payment $currentBalance = $this->getCurrentInvoiceBalanceExcludingPayment($payment['invoice_id'], $paymentId); if ($paidAmount > $currentBalance) { return $this->respondError( 'Entered amount (' . number_format($paidAmount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').', Response::HTTP_BAD_REQUEST ); } // Update payment $updateData = [ 'paid_amount' => $paidAmount, 'payment_method' => strtolower($paymentMethod), 'check_file' => $checkFile, 'updated_by' => $this->getCurrentUserId(), ]; if (strtolower($paymentMethod) === 'check') { $updateData['check_number'] = $checkNumber; } else { $updateData['check_number'] = null; } $this->payment->update($paymentId, $updateData); // Recalculate invoice $this->recalculateInvoice($payment['invoice_id'], $this->schoolYear); return $this->success(null, 'Payment updated successfully'); } /** * GET /api/v1/payments/event-charges * Get event charges */ public function eventChargesShow(): JsonResponse { $schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear; $semester = $this->request->getGet('semester') ?? $this->semester; $charges = $this->eventCharges->newQuery() ->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) ->orderBy('event_charges.created_at', 'DESC') ->get() ->toArray(); $parents = $this->user->getParents(); return $this->success([ 'charges' => $charges, 'parents' => $parents, 'school_year' => $schoolYear, 'semester' => $semester, ], 'Event charges retrieved successfully'); } /** * POST /api/v1/payments/event-charges * Update event charges */ public function eventChargesUpdate(): JsonResponse { $data = $this->payloadData(); $studentIds = $data['student_ids'] ?? []; $userId = $this->getCurrentUserId() ?? 0; $commonData = [ 'parent_id' => $data['parent_id'], 'event_name' => $data['event_name'], 'description' => $data['description'] ?? null, 'amount' => $data['amount'], 'semester' => $data['semester'] ?? $this->semester, 'school_year' => $data['school_year'] ?? $this->schoolYear, 'charged' => 0, 'updated_by' => $userId, ]; $success = false; $created = []; if (!empty($studentIds)) { foreach ($studentIds as $studentId) { $chargeData = array_merge($commonData, ['student_id' => $studentId]); $created[] = $this->eventCharges->create($chargeData); $success = true; } } elseif (isset($data['student_id'])) { $chargeData = array_merge($commonData, ['student_id' => $data['student_id']]); $created[] = $this->eventCharges->create($chargeData); $success = true; } if (!$success) { return $this->respondError('No student selected. Please select at least one.', Response::HTTP_BAD_REQUEST); } return $this->success($created, 'Event charge(s) added successfully', Response::HTTP_CREATED); } /** * GET /api/v1/payments/redirect-page * Get payment redirect page data */ public function redirectPage(): JsonResponse { $modeCheck = $this->config->getConfig('paypal_mode'); $parentId = $this->getCurrentUserId(); $parent = DB::table('users') ->select('firstname', 'lastname', 'school_id') ->where('id', $parentId) ->first(); $parentName = isset($parent->firstname, $parent->lastname) ? $parent->firstname . ' ' . $parent->lastname : 'Parent'; $schoolId = $parent->school_id ?? null; $latestInvoice = $this->invoice->where('parent_id', $parentId) ->orderBy('created_at', 'DESC') ->select('total_amount') ->first(); $totalAmount = $latestInvoice['total_amount'] ?? 0; return $this->success([ 'parent_name' => $parentName, 'total_amount' => $totalAmount, 'school_id' => $schoolId, 'mode_check' => $modeCheck, ], 'Payment redirect data retrieved'); } /** * GET /api/v1/payments/check-file/{filename} * Serve check file */ public function serveCheckFile($filename, $mode = 'download'): \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse { $filename = basename($filename); $roots = [ storage_path('app/public/uploads/checks/' . $filename), storage_path('app/public/uploads/cards/' . $filename), storage_path('app/public/uploads/misc/' . $filename), storage_path('app/public/uploads/' . $filename), ]; $path = null; foreach ($roots as $candidate) { if (is_file($candidate)) { $path = $candidate; break; } } if (!$path) { return $this->respondError('Payment file not found: ' . $filename, Response::HTTP_NOT_FOUND); } if ($mode === 'inline') { return response()->file($path, [ 'Content-Type' => mime_content_type($path), 'Content-Disposition' => 'inline; filename="' . $filename . '"', ]); } return response()->download($path, $filename); } // Private helper methods private function formatPhone($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 getCurrentInvoiceBalance(int $invoiceId): float { $invoice = $this->invoice->find($invoiceId); if (!$invoice) return 0.0; $totalPaid = (float) DB::table('payments') ->where('invoice_id', $invoiceId) ->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']) ->sum('paid_amount'); $totalDisc = (float) DB::table('discount_usages') ->where('invoice_id', $invoiceId) ->sum('discount_amount'); $totalRefundPaid = (float) DB::table('refunds') ->where('invoice_id', $invoiceId) ->whereIn('status', ['Partial', 'Paid']) ->sum('refund_paid_amount'); $total = (float)($invoice['total_amount'] ?? 0); return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2)); } private function getCurrentInvoiceBalanceExcludingPayment(int $invoiceId, int $excludePaymentId): float { $invoice = $this->invoice->find($invoiceId); if (!$invoice) return 0.0; $totalPaid = (float) DB::table('payments') ->where('invoice_id', $invoiceId) ->where('id', '!=', $excludePaymentId) ->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']) ->sum('paid_amount'); $totalDisc = (float) DB::table('discount_usages') ->where('invoice_id', $invoiceId) ->sum('discount_amount'); $totalRefundPaid = (float) DB::table('refunds') ->where('invoice_id', $invoiceId) ->whereIn('status', ['Partial', 'Paid']) ->sum('refund_paid_amount'); $total = (float)($invoice['total_amount'] ?? 0); return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2)); } private function getSuccessfulPaymentCount(int $invoiceId): int { return DB::table('payments') ->where('invoice_id', $invoiceId) ->where('paid_amount', '>', 0) ->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']) ->count(); } private function processPayment( $invoiceId, $amount, $paymentMethod, $checkFile = null, $transactionId = null, $paymentDate = null, $schoolYear = null, $semester = null, $checkNumber = null, ?int $installmentSeq = null ): bool { $invoice = $this->invoice->find($invoiceId); if (!$invoice) { return false; } $transactionId = $transactionId ?? 'INV-' . $invoiceId . '-' . time(); $paymentDate = $paymentDate ?? utc_now(); if ($installmentSeq === null || $installmentSeq < 1) { $installmentSeq = $this->getSuccessfulPaymentCount($invoiceId) + 1; } $newPaid = (float) $invoice['paid_amount'] + (float) $amount; $newBalance = (float) $invoice['balance'] - (float) $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' => $this->getCurrentUserId(), ]; if (!$this->invoice->update($invoiceId, $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' => $this->getCurrentUserId(), 'school_year' => $schoolYear ?? $this->schoolYear, 'semester' => $semester ?? $this->semester, ]; return $this->payment->insert($paymentData) !== false; } private function recalculateInvoice($invoiceId, $schoolYear): void { $invoice = $this->invoice->find($invoiceId); if (!$invoice) return; $parentId = (int)($invoice['parent_id'] ?? 0); if ($parentId <= 0) return; // Get enrollments $enrollments = $this->enrollment ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->findAll(); $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; } } // Refund window check $refundDeadline = (string)($this->config->getConfig('refund_deadline') ?? ''); $refundAllowed = true; try { $tzName = (string) (config('app.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); } // Grade threshold and fees $gradeFee = (int)($this->config->getConfig('grade_fee') ?? 9); $firstStudentFee = (float)($this->config->getConfig('first_student_fee') ?? 350); $secondStudentFee = (float)($this->config->getConfig('second_student_fee') ?? 200); $youthFee = (float)($this->config->getConfig('youth_fee') ?? 180); // Normalize grades foreach ($tuitionStudents as &$s) { $name = null; if (!empty($s['class_section_id'])) { $name = $this->classSection->getClassSectionNameBySectionId($s['class_section_id']); } $s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A'; } unset($s); // Count regular vs youth $regularCount = 0; $youthCount = 0; foreach ($tuitionStudents as $s) { $lvl = $this->parseGradeLevel($s['grade_name']); 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; } // Event charges $eventSubtotal = 0.0; try { $events = $this->eventCharges->getChargesWithEventInfo($parentId, $schoolYear) ?? []; foreach ($events as $ev) { $eventSubtotal += (float)($ev['charged'] ?? 0.0); } } catch (\Throwable $e) { } // Additional charges $additionalSubtotal = 0.0; try { $rows = $this->additionalCharge ->select('charge_type', 'amount') ->where('invoice_id', $invoiceId) ->where('status', 'applied') ->findAll(); foreach ($rows as $r) { $amt = (float)($r['amount'] ?? 0); $typ = strtolower((string)($r['charge_type'] ?? 'add')); if ($typ === 'deduct') $amt = -abs($amt); else $amt = abs($amt); $additionalSubtotal += $amt; } } catch (\Throwable $e) { } $newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2); // Payments / Discounts / Refunds $payments = $this->payment ->where('invoice_id', $invoiceId) ->where('school_year', $schoolYear) ->findAll(); $totalPaid = 0.0; foreach ($payments as $p) { $totalPaid += (float)($p['paid_amount'] ?? 0); } $discRow = DB::table('discount_usages') ->select(DB::raw('COALESCE(SUM(discount_amount),0) AS total_disc')) ->where('invoice_id', $invoiceId) ->first(); $totalDisc = (float)($discRow->total_disc ?? 0); $refundRow = DB::table('refunds') ->select(DB::raw('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'); $this->invoice->update($invoiceId, [ 'total_amount' => $newTotal, 'paid_amount' => $totalPaid, 'balance' => $newBalance, 'status' => $newStatus, ]); } private function parseGradeLevel($grade): int { if (is_numeric($grade)) return (int)$grade; if (!is_string($grade)) return 999; $g = strtoupper(trim((string)$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; $gradeFee = (int)($this->config->getConfig('grade_fee') ?? 9); 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 updateEnrollmentStatusIfPaid(int $invoiceId): int { $invoice = $this->invoice->find($invoiceId); if (!$invoice) { log_message('warning', 'Invoice not found: {id}', ['id' => $invoiceId]); return 0; } $total = (float) ($invoice['total_amount'] ?? 0); $currentBal = $this->getCurrentInvoiceBalance($invoiceId); if (!($total > 0 && $currentBal < $total)) { log_message('info', 'No payment yet (or still full balance). Skipping enrollment update. Invoice #{id}', ['id' => $invoiceId]); return 0; } $parentId = (int) ($invoice['parent_id'] ?? 0); $schoolYear = (string) $this->schoolYear; $semester = isset($this->semester) && $this->semester !== '' ? (string)$this->semester : null; if ($parentId <= 0 || $schoolYear === '') { log_message('warning', 'updateEnrollmentStatusIfPaid: missing parent_id/school_year for invoice #{id}', ['id' => $invoiceId]); return 0; } $toUpdateIds = DB::table('enrollments') ->select('id') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->where(function ($query) { $query->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'"); }) ->when($semester !== null, function ($query) use ($semester) { $query->where('semester', $semester); }) ->pluck('id') ->toArray(); if (empty($toUpdateIds)) { log_message('info', 'No enrollments matched pending status for parent {p}, year {y}, sem {s}.', [ 'p' => $parentId, 'y' => $schoolYear, 's' => $semester ?? 'ALL' ]); return 0; } DB::beginTransaction(); try { $affected = DB::table('enrollments') ->whereIn('id', $toUpdateIds) ->update([ 'enrollment_status' => 'enrolled', 'enrollment_date' => date('Y-m-d'), ]); DB::commit(); log_message('info', 'Enrollment status -> enrolled for {n} row(s). parent={p}, year={y}, sem={s}, ids=[{ids}]', [ 'n' => $affected, 'p' => $parentId, 'y' => $schoolYear, 's' => $semester ?? 'ALL', 'ids' => implode(',', $toUpdateIds), ]); return $affected; } catch (\Throwable $e) { DB::rollBack(); log_message('error', 'updateEnrollmentStatusIfPaid error: ' . $e->getMessage()); return 0; } } private function buildStudentEnrolledEventData( int $parentId, array $studentIds, ?string $schoolYear = null, ?string $semester = null, ?string $portalLink = null ): array { $schoolYear = $schoolYear ?? $this->schoolYear; $semester = $semester ?? $this->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 = array_map( fn($r) => (int)$r->id, DB::table('students') ->select('id') ->where('parent_id', $parentId) ->get() ->toArray() ); } if (empty($ids)) { return [[ 'user_id' => (int) $parent->id, 'email' => $parent->email ?? null, 'firstname' => $parent->firstname ?? '', 'lastname' => $parent->lastname ?? '', 'school_year' => $schoolYear, ], []]; } $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, $semester) { $join->on('sc.student_id', '=', 's.id') ->where('sc.school_year', '=', $schoolYear) ->where('sc.semester', '=', $semester); }) ->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id') ->leftJoin('teacher_class as tc', function ($join) use ($schoolYear, $semester) { $join->on('tc.class_section_id', '=', 'sc.class_section_id') ->where('tc.position', '=', 'main') ->where('tc.school_year', '=', $schoolYear) ->where('tc.semester', '=', $semester); }) ->leftJoin('users as tu', 'tu.id', '=', 'tc.teacher_id') ->where('s.parent_id', $parentId) ->whereIn('s.id', $ids) ->orderBy('sc.updated_at', 'DESC') ->get() ->toArray(); if (!$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') ->whereIn('s.id', $ids) ->where('s.parent_id', $parentId) ->get() ->toArray(); } $studentdata = []; $seen = []; foreach ($rows as $r) { $sid = (int) ($r->student_id ?? 0); if ($sid <= 0 || isset($seen[$sid])) continue; $seen[$sid] = true; $teacherFull = trim( trim((string)($r->t_firstname ?? '')) . ' ' . trim((string)($r->t_lastname ?? '')) ); if ($teacherFull === '') { $teacherFull = null; } $studentdata[] = [ 'id' => $sid, 'student_id' => $sid, 'firstname' => (string)($r->s_firstname ?? ''), 'lastname' => (string)($r->s_lastname ?? ''), 'name' => trim(((string)($r->s_firstname ?? '')) . ' ' . ((string)($r->s_lastname ?? ''))), 'registration_grade' => (string)($r->registration_grade ?? ''), 'class_section_id' => isset($r->class_section_id) ? (int)$r->class_section_id : null, 'class_section_name' => $r->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, $studentdata]; } private function buildPaymentEventData( int $invoiceId, string $transactionId, float $amount, string $paymentMethod, string $paymentDate, ?string $checkNumber, int $installmentSeq, float $preBalance, float $postBalance, ?float $invoiceDiscount = null, ?float $parentYearDiscountTotal = null ): array { $invoice = $this->invoice->find($invoiceId) ?: []; $parentId = (int) ($invoice['parent_id'] ?? 0); $parent = $parentId ? DB::table('users')->select('id', 'firstname', 'lastname', 'email')->where('id', $parentId)->first() : null; $eventData = [ 'invoice_id' => $invoiceId, 'invoice_number' => $invoice['invoice_number'] ?? null, 'transaction_id' => $transactionId, 'transactionId' => $transactionId, 'amount' => (float) $amount, 'payment_method' => $paymentMethod, 'method' => $paymentMethod, 'payment_date' => $paymentDate, 'check_number' => $checkNumber ?: null, 'installment_seq' => (int) $installmentSeq, 'pre_balance' => (float) $preBalance, 'post_balance' => (float) $postBalance, 'invoice_total' => (float) ($invoice['total_amount'] ?? 0), 'user_id' => (int) ($parent->id ?? $parentId), 'email' => $parent->email ?? null, 'firstname' => $parent->firstname ?? null, 'lastname' => $parent->lastname ?? null, 'school_year' => $this->schoolYear, 'semester' => $this->semester, ]; if ($invoiceDiscount !== null) { $eventData['invoice_discount'] = (float) $invoiceDiscount; } if ($parentYearDiscountTotal !== null) { $eventData['parent_year_discount_total'] = (float) $parentYearDiscountTotal; } $students = []; if ($parentId) { $studentRows = DB::table('students') ->select('id', 'firstname', 'lastname') ->where('parent_id', $parentId) ->orderBy('lastname', 'ASC') ->get(); foreach ($studentRows as $r) { $students[] = [ 'id' => (int) $r->id, 'firstname' => (string) ($r->firstname ?? ''), 'lastname' => (string) ($r->lastname ?? ''), 'name' => trim(($r->firstname ?? '') . ' ' . ($r->lastname ?? '')), ]; } } return [$eventData, $students]; } }