add projet

This commit is contained in:
root
2026-03-05 12:29:37 -05:00
parent 8d1eef8ba8
commit 23b7db1107
9109 changed files with 1106501 additions and 73 deletions
+446
View File
@@ -0,0 +1,446 @@
<?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;
}
}