1446 lines
54 KiB
PHP
Executable File
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];
|
|
}
|
|
}
|