additionalCharge = model(AdditionalCharge::class); $this->user = model(User::class); $this->invoice = model(Invoice::class); $this->config = model(Configuration::class); $this->schoolYear = $this->config->getConfig('school_year'); $this->semester = $this->config->getConfig('semester'); } public function index() { $year = (string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? ''); $sem = (string) ($this->request->getGet('semester') ?? $this->semester ?? ''); $status = $this->request->getGet('status') ?: null; $q = trim((string) ($this->request->getGet('q') ?? '')) ?: null; $per = (int) ($this->request->getGet('per_page') ?? 50); $paginator = $this->additionalCharge->listAllForTerm($year, $sem, $status, $q, $per); $meta = [ 'perPage' => $paginator->perPage(), 'page' => $paginator->currentPage(), 'total' => $paginator->total(), 'pageCount' => $paginator->lastPage(), ]; return $this->success([ 'school_year' => $year, 'semester' => $sem, 'rows' => $paginator->items(), 'pager' => $meta, ], 'Extra charges retrieved successfully'); } public function show($id = null) { $charge = $this->additionalCharge->find($id); if (!$charge) { return $this->respondError('Extra charge not found', Response::HTTP_NOT_FOUND); } return $this->success($charge->toArray(), 'Extra charge retrieved successfully'); } /** * Get parent options for Select2 (with search) */ public function parentOptions() { $q = trim((string) ($this->request->getGet('q') ?? '')); $limit = 20; $query = $this->user ->select('users.id, users.firstname, users.lastname, users.email') ->join('user_roles', 'user_roles.user_id = users.id') ->join('roles', 'roles.id = user_roles.role_id') ->whereRaw('LOWER(roles.name) = ?', ['parent']) ->whereNull('user_roles.deleted_at'); if ($q !== '') { $query->where(function ($builder) use ($q) { $builder->where('users.firstname', 'like', "%{$q}%") ->orWhere('users.lastname', 'like', "%{$q}%") ->orWhere('users.email', 'like', "%{$q}%"); }); } $rows = $query ->orderBy('users.lastname', 'ASC') ->orderBy('users.firstname', 'ASC') ->limit($limit) ->findAll(); $results = array_map(function ($r) { $name = trim(($r['lastname'] ?? '') . ', ' . ($r['firstname'] ?? '')); $email = $r['email'] ?? ''; $label = $name !== ',' ? $name : ('User #' . $r['id']); if ($email) $label .= ' — ' . $email; $label .= ' (ID: ' . $r['id'] . ')'; return ['id' => (int) $r['id'], 'text' => $label]; }, $rows); return $this->success(['results' => $results], 'Parent options retrieved successfully'); } /** * Get invoices for a parent (for Select2) */ public function invoicesForParent() { $parentId = (int) ($this->request->getGet('parent_id') ?? 0); $schoolYear = (string) ($this->request->getGet('school_year') ?? $this->schoolYear ?? ''); $rows = ($parentId > 0) ? ($this->invoice->getInvoicesByUserId($parentId, $schoolYear) ?? []) : []; $results = array_map(function ($inv) { $id = (int) ($inv['id'] ?? 0); $num = (string) ($inv['invoice_number'] ?? ('INV-' . $id)); $status = strtolower((string) ($inv['status'] ?? '')); $balance = (float) ($inv['balance'] ?? 0); $issue = $inv['issue_date'] ?? null; return [ 'id' => $id, 'invoice_number' => $num, 'status' => $status, 'balance' => $balance, 'issue_date' => $issue, 'text' => $num . ($status ? ' — ' . ucfirst($status) : '') . ' (Bal: $' . number_format($balance, 2) . ')', ]; }, $rows); return $this->success(['results' => $results], 'Invoices retrieved successfully'); } public function store() { $user = $this->getCurrentUser(); if (!$user) { return $this->respondError('Authentication required', Response::HTTP_UNAUTHORIZED); } $data = $this->payloadData(); if (empty($data)) { return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); } $rules = [ 'parent_id' => 'required|integer', 'title' => 'required|string|min:2', 'amount' => 'required|numeric', 'charge_type' => 'required|in:add,deduct', 'due_date' => 'nullable|date', 'invoice_id' => 'nullable|integer', ]; $errors = $this->validateRequest($data, $rules); if (!empty($errors)) { return $this->respondValidationError($errors); } $invoiceId = !empty($data['invoice_id']) ? (int) $data['invoice_id'] : null; $chargeType = (string) $data['charge_type']; $amountAbs = round(abs((float) $data['amount']), 2); $signedAmount = ($chargeType === 'add') ? $amountAbs : -$amountAbs; $payload = [ 'parent_id' => (int) $data['parent_id'], 'invoice_id' => $invoiceId, 'school_year' => $data['school_year'] ?? $this->schoolYear, 'semester' => $data['semester'] ?? $this->semester, 'charge_type' => $chargeType, 'title' => trim($data['title']), 'description' => trim($data['description'] ?? ''), 'amount' => $signedAmount, 'due_date' => !empty($data['due_date']) ? $data['due_date'] : null, 'status' => $invoiceId ? 'applied' : 'pending', 'created_by' => $user->id, 'created_at' => Carbon::now('UTC')->toDateTimeString(), ]; try { DB::beginTransaction(); // Get invoice before $invoiceBefore = $this->normalizeRow( $this->invoice->getInvoicesByParentId($data['parent_id'], $this->schoolYear) ); // Insert charge $chargeId = $this->additionalCharge->insert($payload); if (!$chargeId) { throw new \Exception('Failed to insert charge'); } // Apply to invoice if present if ($invoiceId) { try { if ($chargeType === 'add') { $this->invoice->applyAdditionalCharge($invoiceId, $amountAbs); } else { $this->invoice->deductAdditionalCharge($invoiceId, $amountAbs); } } catch (\Throwable $e) { log_message('error', 'apply/deductAdditionalCharge failed: ' . $e->getMessage()); throw $e; } } // Get invoice after $invoiceAfter = $this->normalizeRow( $this->invoice->getInvoicesByParentId($data['parent_id'], $this->schoolYear) ); // Get parent user info $parentUser = $this->user->getUserInfoById($data['parent_id']); DB::commit(); // Build event payload $first = $parentUser['first_name'] ?? $parentUser['firstname'] ?? ''; $last = $parentUser['last_name'] ?? $parentUser['lastname'] ?? ''; $parentName = trim($first . ' ' . $last); $invoiceTotal = $invoiceAfter ? (float) ($invoiceAfter['total_amount'] ?? 0) : 0.0; $preBalance = $invoiceBefore ? (float) ($invoiceBefore['balance'] ?? 0) : 0.0; $postBalance = $invoiceAfter ? (float) ($invoiceAfter['balance'] ?? 0) : 0.0; $eventData = [ 'user_id' => (int) ($parentUser['id'] ?? 0), 'email' => $parentUser['email'] ?? null, 'firstname' => $first, 'lastname' => $last, 'parentName' => $parentName, 'school_year' => $payload['school_year'], 'semester' => $payload['semester'], 'invoice_id' => $invoiceId, 'invoice_number' => $invoiceAfter['invoice_number'] ?? ($invoiceId ? 'INV-' . $invoiceId : null), 'charge_id' => $chargeId, 'charge_title' => $payload['title'], 'charge_desc' => $payload['description'], 'charge_type' => $payload['charge_type'], 'amount_signed' => $signedAmount, 'amount_abs' => $amountAbs, 'due_date' => $payload['due_date'], 'created_at' => $payload['created_at'], 'pre_balance' => $preBalance, 'post_balance' => $postBalance, 'invoice_total' => $invoiceTotal, 'portal_link' => url('/login'), 'invoice_link' => $invoiceId ? url('parent/invoices/view/' . $invoiceId) : url('parent/invoices'), ]; // Dispatch event Event::dispatch('extraCharge', [$eventData]); return $this->respondCreated([ 'id' => $chargeId, 'invoice_id' => $invoiceId, 'parent_id' => (int) $data['parent_id'], ], 'Charge recorded successfully'); } catch (\Throwable $e) { DB::rollBack(); log_message('error', 'Transaction failed in ExtraChargesController::store: ' . $e->getMessage()); return $this->respondError('Failed to save charge: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR); } } public function update($id = null) { $row = $this->additionalCharge->find($id); if (!$row) { return $this->respondError('Charge not found', Response::HTTP_NOT_FOUND); } $data = $this->payloadData(); if (empty($data)) { return $this->respondError('Invalid request data', Response::HTTP_BAD_REQUEST); } $newAmount = isset($data['amount']) ? (float) $data['amount'] : (float) $row['amount']; $delta = $newAmount - (float) $row['amount']; try { DB::beginTransaction(); // Update the charge $this->additionalCharge->update($id, [ 'title' => trim($data['title'] ?? $row['title']), 'description' => trim($data['description'] ?? $row['description']), 'amount' => $newAmount, 'due_date' => $data['due_date'] ?? $row['due_date'], 'charge_type' => $data['charge_type'] ?? $row['charge_type'], ]); // If it's already applied on an invoice and the amount changed, reflect the delta if ($row['status'] === 'applied' && !empty($row['invoice_id']) && abs($delta) > 0.00001) { if ($delta > 0) { $this->invoice->applyAdditionalCharge((int) $row['invoice_id'], $delta); } else { $this->invoice->reverseAdditionalCharge((int) $row['invoice_id'], -$delta); } } DB::commit(); $updated = $this->additionalCharge->find($id); return $this->success($updated->toArray(), 'Charge updated successfully'); } catch (\Throwable $e) { DB::rollBack(); log_message('error', 'Failed to update charge: ' . $e->getMessage()); return $this->respondError('Failed to update charge', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Mark a charge as void and roll back its impact on the invoice if applied. */ public function void($id = null) { $row = $this->additionalCharge->find((int) $id); if (!$row) { return $this->respondError('Charge not found', Response::HTTP_NOT_FOUND); } $invoiceId = (int) ($row['invoice_id'] ?? 0); $amountAbs = round(abs((float) ($row['amount'] ?? 0)), 2); $chargeType = (string) ($row['charge_type'] ?? 'add'); $status = (string) ($row['status'] ?? 'pending'); try { DB::beginTransaction(); if ($status === 'applied' && $invoiceId > 0 && $amountAbs > 0) { try { if ($chargeType === 'add') { $this->invoice->reverseAdditionalCharge($invoiceId, $amountAbs); } else { // voiding a deduction -> add back $this->invoice->applyAdditionalCharge($invoiceId, $amountAbs); } } catch (\Throwable $e) { log_message('error', 'void(): invoice adjust failed: ' . $e->getMessage()); throw $e; } } $this->additionalCharge->update((int) $id, [ 'status' => 'void', ]); DB::commit(); return $this->success(null, 'Charge voided successfully'); } catch (\Throwable $e) { DB::rollBack(); log_message('error', 'Failed to void charge: ' . $e->getMessage()); return $this->respondError('Failed to void charge', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Reverse a previously applied charge: undo invoice impact and return to pending state. */ public function reverse($id = null) { $row = $this->additionalCharge->find((int) $id); if (!$row) { return $this->respondError('Charge not found', Response::HTTP_NOT_FOUND); } $invoiceId = (int) ($row['invoice_id'] ?? 0); $amountAbs = round(abs((float) ($row['amount'] ?? 0)), 2); $chargeType = (string) ($row['charge_type'] ?? 'add'); $status = (string) ($row['status'] ?? 'pending'); if ($status !== 'applied' || $invoiceId <= 0 || $amountAbs <= 0) { return $this->respondError('Nothing to reverse', Response::HTTP_BAD_REQUEST); } try { DB::beginTransaction(); try { if ($chargeType === 'add') { $this->invoice->reverseAdditionalCharge($invoiceId, $amountAbs); } else { $this->invoice->applyAdditionalCharge($invoiceId, $amountAbs); } } catch (\Throwable $e) { log_message('error', 'reverse(): invoice adjust failed: ' . $e->getMessage()); throw $e; } $this->additionalCharge->update((int) $id, [ 'status' => 'pending', 'invoice_id' => null, ]); DB::commit(); return $this->success(null, 'Charge reversed to pending successfully'); } catch (\Throwable $e) { DB::rollBack(); log_message('error', 'Failed to reverse charge: ' . $e->getMessage()); return $this->respondError('Failed to reverse charge', Response::HTTP_INTERNAL_SERVER_ERROR); } } public function delete($id = null) { $charge = $this->additionalCharge->find($id); if (!$charge) { return $this->respondError('Extra charge not found', Response::HTTP_NOT_FOUND); } try { $charge->delete(); return $this->success(null, 'Extra charge deleted successfully'); } catch (\Throwable $e) { log_message('error', 'Failed to delete charge: ' . $e->getMessage()); return $this->respondError('Failed to delete charge', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * Normalize query result into a single associative row */ private function normalizeRow($row): ?array { if (!$row) return null; if (is_array($row) && isset($row[0]) && is_array($row[0])) { return $row[0]; // unwrap first row from a result set } return is_array($row) ? $row : null; } }