Files
alrahma_sunday_school_api/app/Http/Controllers/Api/PaymentController.php
T
2026-03-05 12:29:37 -05:00

1446 lines
54 KiB
PHP
Executable File

<?php
namespace App\Http\Controllers\Api;
use App\Models\AdditionalCharge;
use App\Models\ClassSection;
use App\Models\Configuration;
use App\Models\DiscountUsage;
use App\Models\Enrollment;
use App\Models\EventCharges;
use App\Models\Invoice;
use App\Models\ManualPayment;
use App\Models\PaymentError;
use App\Models\Payment;
use App\Models\StudentClass;
use App\Models\Student;
use App\Models\TeacherClass;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
class PaymentController extends BaseApiController
{
protected Payment $payment;
protected Configuration $config;
protected ManualPayment $manualPayment;
protected EventCharges $eventCharges;
protected User $user;
protected Enrollment $enrollment;
protected Student $student;
protected StudentClass $studentClass;
protected PaymentError $paymentError;
protected TeacherClass $teacherClass;
protected DiscountUsage $discountUsage;
protected AdditionalCharge $additionalCharge;
protected ClassSection $classSection;
protected Invoice $invoice;
protected string $schoolYear;
protected string $semester;
protected ?string $installmentDate;
public function __construct()
{
parent::__construct();
$this->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];
}
}