447 lines
17 KiB
PHP
Executable File
447 lines
17 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Models\AdditionalCharge;
|
|
use App\Models\Configuration;
|
|
use App\Models\User;
|
|
use App\Models\Invoice;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Event;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class ExtraChargesController extends BaseApiController
|
|
{
|
|
protected AdditionalCharge $additionalCharge;
|
|
protected Configuration $config;
|
|
protected User $user;
|
|
protected Invoice $invoice;
|
|
protected ?string $schoolYear;
|
|
protected ?string $semester;
|
|
|
|
public function __construct()
|
|
{
|
|
parent::__construct();
|
|
$this->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;
|
|
}
|
|
}
|