1086 lines
41 KiB
PHP
Executable File
1086 lines
41 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\Payment;
|
|
use App\Models\Refund;
|
|
use App\Models\StudentClass;
|
|
use App\Models\Student;
|
|
use App\Models\User;
|
|
use Carbon\CarbonImmutable;
|
|
use DateTime;
|
|
use DateTimeZone;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
|
|
class InvoiceController extends BaseApiController
|
|
{
|
|
protected Invoice $invoice;
|
|
protected AdditionalCharge $additionalCharge;
|
|
protected Student $student;
|
|
protected Enrollment $enrollment;
|
|
protected Configuration $config;
|
|
protected User $user;
|
|
protected StudentClass $studentClass;
|
|
protected EventCharges $charges;
|
|
protected DiscountUsage $discountUsage;
|
|
protected Refund $refund;
|
|
protected Payment $payment;
|
|
protected ClassSection $classSection;
|
|
|
|
protected string $schoolYear;
|
|
protected string $semester;
|
|
protected ?string $dueDate;
|
|
protected float $firstStudentFee;
|
|
protected float $secondStudentFee;
|
|
protected float $youthFee;
|
|
protected string $refundDeadline;
|
|
protected int $gradeFee;
|
|
|
|
public function __construct()
|
|
{
|
|
parent::__construct();
|
|
$this->additionalCharge = model(AdditionalCharge::class);
|
|
$this->classSection = model(ClassSection::class);
|
|
$this->invoice = model(Invoice::class);
|
|
$this->student = model(Student::class);
|
|
$this->enrollment = model(Enrollment::class);
|
|
$this->config = model(Configuration::class);
|
|
$this->user = model(User::class);
|
|
$this->studentClass = model(StudentClass::class);
|
|
$this->charges = model(EventCharges::class);
|
|
$this->discountUsage = model(DiscountUsage::class);
|
|
$this->refund = model(Refund::class);
|
|
$this->payment = model(Payment::class);
|
|
|
|
$this->gradeFee = (int) ($this->config->getConfig('grade_fee') ?? 9);
|
|
$this->schoolYear = (string) ($this->config->getConfig('school_year') ?? '');
|
|
$this->semester = (string) ($this->config->getConfig('semester') ?? '');
|
|
$this->dueDate = $this->config->getConfig('due_date');
|
|
$this->firstStudentFee = (float) ($this->config->getConfig('first_student_fee') ?? 350);
|
|
$this->secondStudentFee = (float) ($this->config->getConfig('second_student_fee') ?? 200);
|
|
$this->youthFee = (float) ($this->config->getConfig('youth_fee') ?? 180);
|
|
$refundDeadlineRaw = $this->config->getConfig('refund_deadline');
|
|
$this->refundDeadline = $refundDeadlineRaw ? date('Y-m-d', strtotime($refundDeadlineRaw)) : date('Y-m-d');
|
|
}
|
|
|
|
/**
|
|
* GET /api/v1/invoices
|
|
* List invoices 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');
|
|
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
|
|
|
$query = $this->invoice->newQuery();
|
|
|
|
if ($parentId) {
|
|
$query->where('parent_id', (int) $parentId);
|
|
}
|
|
|
|
if ($status) {
|
|
$query->where('status', $status);
|
|
}
|
|
|
|
if ($schoolYear) {
|
|
$query->where('school_year', $schoolYear);
|
|
}
|
|
|
|
$result = $this->paginate($query, $page, $perPage);
|
|
|
|
return $this->success($result, 'Invoices retrieved successfully');
|
|
}
|
|
|
|
/**
|
|
* GET /api/v1/invoices/{id}
|
|
* Get a single invoice
|
|
*/
|
|
public function show($id = null): JsonResponse
|
|
{
|
|
$invoice = $this->invoice->find($id);
|
|
if (!$invoice) {
|
|
return $this->error('Invoice not found', Response::HTTP_NOT_FOUND);
|
|
}
|
|
|
|
return $this->success($invoice, 'Invoice retrieved successfully');
|
|
}
|
|
|
|
/**
|
|
* GET /api/v1/invoices/management-data
|
|
* Invoice management composite data (used by invoice_management view)
|
|
*/
|
|
public function managementData(): JsonResponse
|
|
{
|
|
$schoolYear = trim((string) ($this->request->getGet('schoolYear') ?? $this->request->getGet('year') ?? ''));
|
|
if ($schoolYear === '') {
|
|
$schoolYear = $this->schoolYear;
|
|
}
|
|
|
|
$invoiceData = [];
|
|
|
|
try {
|
|
// Distinct school years (for selector)
|
|
$yearsRows = $this->invoice
|
|
->select('school_year')
|
|
->distinct()
|
|
->orderBy('school_year', 'DESC')
|
|
->findAll();
|
|
|
|
$schoolYears = array_values(array_filter(array_map(static function ($r) {
|
|
return isset($r['school_year']) ? (string) $r['school_year'] : null;
|
|
}, $yearsRows)));
|
|
|
|
if (empty($schoolYears)) {
|
|
$schoolYears = [$this->schoolYear];
|
|
}
|
|
|
|
$parents = $this->user->getUsersByRoleAndSchoolYear('parent', $schoolYear);
|
|
|
|
foreach ($parents as $parent) {
|
|
$students = $this->student->where('parent_id', $parent['id'])->findAll();
|
|
|
|
$parentData = [
|
|
'parent_name' => trim(($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? '')),
|
|
'parent_id' => (int) $parent['id'],
|
|
'enrolledKids' => [],
|
|
'withdrawnKids' => [],
|
|
'invoice_amount' => 0,
|
|
'refund_amount' => 0,
|
|
'last_updated' => null,
|
|
'invoice_date' => null,
|
|
'invoice_id' => null,
|
|
];
|
|
|
|
// Latest invoice
|
|
$invoices = $this->invoice->getInvoicesByParentId($parent['id'], $schoolYear);
|
|
foreach ($invoices as $invoice) {
|
|
if ($invoice) {
|
|
$parentData['invoice_amount'] = (float) ($invoice['total_amount'] ?? 0);
|
|
$parentData['last_updated'] = $invoice['updated_at'] ?? null;
|
|
|
|
// Prefer issue_date (UTC) -> local; fall back to updated_at/created_at
|
|
if (!empty($invoice['issue_date'])) {
|
|
$tzName = user_timezone();
|
|
$parentData['invoice_date'] = (new CarbonImmutable($invoice['issue_date'], new DateTimeZone('UTC')))
|
|
->setTimezone(new DateTimeZone($tzName))
|
|
->format('Y-m-d H:i:s');
|
|
} else {
|
|
$parentData['invoice_date'] = date('Y-m-d H:i:s', strtotime($invoice['updated_at'] ?? $invoice['created_at'] ?? 'now'));
|
|
}
|
|
$parentData['invoice_id'] = $invoice['id'] ?? null;
|
|
|
|
// Refund total paid for parent/year (Partial/Paid)
|
|
$refund = DB::table('refunds')
|
|
->select(DB::raw('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount'))
|
|
->where('parent_id', $parent['id'])
|
|
->where('school_year', $schoolYear)
|
|
->whereIn('status', ['Partial', 'Paid'])
|
|
->first();
|
|
|
|
$parentData['refund_amount'] = (float) ($refund->refund_paid_amount ?? 0.0);
|
|
break; // only most recent as before
|
|
}
|
|
}
|
|
|
|
// Build kids lists based on enrollment statuses
|
|
foreach ($students as $student) {
|
|
$studentClass = DB::table('student_class')
|
|
->where('student_id', $student['id'])
|
|
->where('school_year', $schoolYear)
|
|
->first();
|
|
|
|
$grade = 'N/A';
|
|
if ($studentClass && isset($studentClass->class_section_id)) {
|
|
$classSection = DB::table('class_sections')
|
|
->where('class_section_id', $studentClass->class_section_id)
|
|
->first();
|
|
|
|
if ($classSection && isset($classSection->class_section_name)) {
|
|
$grade = $classSection->class_section_name;
|
|
}
|
|
}
|
|
|
|
$enrollments = $this->enrollment
|
|
->where('student_id', $student['id'])
|
|
->where('school_year', $schoolYear)
|
|
->findAll();
|
|
|
|
foreach ($enrollments as $enrollment) {
|
|
$kid = [
|
|
'name' => trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')),
|
|
'grade' => $grade,
|
|
'tuition_fee' => (float) ($enrollment['tuition_fee'] ?? 0),
|
|
];
|
|
|
|
switch ($enrollment['enrollment_status']) {
|
|
case 'payment pending':
|
|
case 'enrolled':
|
|
$parentData['enrolledKids'][] = $kid;
|
|
break;
|
|
case 'withdraw under review':
|
|
case 'withdrawn':
|
|
case 'refund pending':
|
|
$parentData['withdrawnKids'][] = $kid;
|
|
break;
|
|
default:
|
|
// ignore others for invoice summary
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!empty($parentData['enrolledKids']) || !empty($parentData['withdrawnKids'])) {
|
|
$invoiceData[] = $parentData;
|
|
}
|
|
}
|
|
|
|
return $this->success([
|
|
'ok' => true,
|
|
'schoolYear' => $schoolYear,
|
|
'schoolYears' => $schoolYears,
|
|
'invoices' => $invoiceData,
|
|
], 'Management data retrieved successfully');
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'managementData error: ' . $e->getMessage());
|
|
return $this->respondError('Server error: ' . $e->getMessage(), Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/v1/invoices/generate
|
|
* Generate or update invoice for a parent
|
|
*/
|
|
public function generateInvoice(): JsonResponse
|
|
{
|
|
$payload = $this->payloadData();
|
|
$parentId = (int) ($payload['parent_id'] ?? $this->request->getPost('parent_id') ?? 0);
|
|
|
|
if (!$parentId) {
|
|
return $this->respondError('Parent ID is required', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
// Fetch enrolled + withdrawn students
|
|
$enrollments = $this->enrollment
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $this->schoolYear)
|
|
->findAll();
|
|
|
|
if (empty($enrollments)) {
|
|
return $this->respondError('No enrollment records found.', Response::HTTP_NOT_FOUND);
|
|
}
|
|
|
|
$registeredKids = [];
|
|
$withdrawnKids = [];
|
|
|
|
foreach ($enrollments as $enrollment) {
|
|
$studentData = [
|
|
'student_id' => $enrollment['student_id'],
|
|
'parent_id' => $enrollment['parent_id'],
|
|
'class_section_id' => $enrollment['class_section_id'],
|
|
'enrollment_status' => $enrollment['enrollment_status'],
|
|
'school_year' => $enrollment['school_year'],
|
|
'semester' => $enrollment['semester'],
|
|
'admission_status' => $enrollment['admission_status'],
|
|
'is_withdrawn' => $enrollment['is_withdrawn'] ?? false,
|
|
];
|
|
|
|
if (in_array($enrollment['enrollment_status'], ['enrolled', 'payment pending'], true)) {
|
|
$registeredKids[] = $studentData;
|
|
} elseif (in_array($enrollment['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) {
|
|
$withdrawnKids[] = $studentData;
|
|
} else {
|
|
log_message('info', "Enrollment skipped for student_id {$enrollment['student_id']} with status: {$enrollment['enrollment_status']}");
|
|
}
|
|
}
|
|
|
|
// Calculate tuition fee
|
|
$fees = $this->calculateTuitionFee($registeredKids, $withdrawnKids);
|
|
$tuitionFee = $fees['tuition_fee'];
|
|
|
|
// Fetch event charges
|
|
$eventsList = $this->charges->getChargesWithEventInfo($parentId, $this->schoolYear);
|
|
$eventchargeTotal = array_sum(array_column($eventsList, 'charged'));
|
|
|
|
$totalDiscount = $this->recalculateAndUpdateDiscount(
|
|
$parentId,
|
|
$this->schoolYear,
|
|
$tuitionFee,
|
|
$enrollments
|
|
);
|
|
|
|
$semester = $this->semester;
|
|
|
|
// Refunds PAID to the parent for this year (Partial/Paid)
|
|
$refundPaid = (float) $this->refund->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $this->schoolYear);
|
|
|
|
$totalPaid = $this->payment->getTotalPaidByParentId($parentId, $this->schoolYear);
|
|
|
|
$discountedTuition = max(0, $tuitionFee);
|
|
$totalAmount = $discountedTuition + $eventchargeTotal;
|
|
|
|
// Your invoice fetch
|
|
$existingInvoices = $this->invoice->getInvoicesByParentId($parentId, $this->schoolYear);
|
|
|
|
$updated = false;
|
|
$updatedIds = [];
|
|
|
|
if (!empty($existingInvoices)) {
|
|
foreach ($existingInvoices as $invoice) {
|
|
if (!isset($invoice['id'])) {
|
|
continue;
|
|
}
|
|
|
|
// Preserve applied additional charges and recalc this invoice only
|
|
$extrasSum = 0.0;
|
|
try {
|
|
$rows = DB::table('additional_charges')
|
|
->select('charge_type', 'amount')
|
|
->where('invoice_id', (int) $invoice['id'])
|
|
->where('school_year', $this->schoolYear)
|
|
->where('status', 'applied')
|
|
->get();
|
|
|
|
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);
|
|
}
|
|
$extrasSum += $amt;
|
|
}
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'additional_charges sum failed for invoice ' . (int) $invoice['id'] . ': ' . $e->getMessage());
|
|
}
|
|
|
|
$newTotal = round($totalAmount + $extrasSum, 2);
|
|
|
|
// Per-invoice discount
|
|
$invDiscount = 0.0;
|
|
try {
|
|
$d = DB::table('discount_usages')
|
|
->select(DB::raw('COALESCE(SUM(discount_amount),0) AS tot'))
|
|
->where('invoice_id', (int) $invoice['id'])
|
|
->first();
|
|
$invDiscount = (float) ($d->tot ?? 0.0);
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'discount sum failed for invoice ' . (int) $invoice['id'] . ': ' . $e->getMessage());
|
|
}
|
|
|
|
// Per-invoice refunds paid
|
|
$invRefunds = 0.0;
|
|
try {
|
|
$r = DB::table('refunds')
|
|
->select(DB::raw('COALESCE(SUM(refund_paid_amount),0) AS tot'))
|
|
->where('invoice_id', (int) $invoice['id'])
|
|
->where('school_year', $this->schoolYear)
|
|
->whereIn('status', ['Partial', 'Paid'])
|
|
->first();
|
|
$invRefunds = (float) ($r->tot ?? 0.0);
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'refund sum failed for invoice ' . (int) $invoice['id'] . ': ' . $e->getMessage());
|
|
}
|
|
|
|
// Payments recorded on this invoice
|
|
$paidOnInv = (float) ($invoice['paid_amount'] ?? 0.0);
|
|
|
|
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
|
|
$newStatus = $newBalance <= 0.00001 ? 'paid' : 'unpaid';
|
|
|
|
$this->invoice->update($invoice['id'], [
|
|
'total_amount' => $newTotal,
|
|
'paid_amount' => $paidOnInv,
|
|
'balance' => $newBalance,
|
|
'status' => $newStatus,
|
|
'updated_at' => utc_now(),
|
|
]);
|
|
|
|
$updatedIds[] = (int) $invoice['id'];
|
|
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
|
|
$updated = true;
|
|
}
|
|
} else {
|
|
// Generate invoice number
|
|
$schoolId = $this->user->getSchoolIdByUserId($parentId);
|
|
if (!empty($schoolId)) {
|
|
$invoiceNumber = 'INV-' . $schoolId . '-' . uniqid();
|
|
} else {
|
|
log_message('warning', "No school ID found for parent_id {$parentId}, generating fallback invoice number.");
|
|
$invoiceNumber = uniqid('INV-');
|
|
}
|
|
|
|
$issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
|
|
|
|
// Due date: interpret the date in configured/user local TZ
|
|
$dueUtc = null;
|
|
if (!empty($this->dueDate)) {
|
|
$tzName = user_timezone();
|
|
$dueLocal = new DateTime($this->dueDate . ' 19:59:59', new DateTimeZone($tzName));
|
|
$dueLocal->setTimezone(new DateTimeZone('UTC'));
|
|
$dueUtc = $dueLocal->format('Y-m-d H:i:s');
|
|
}
|
|
|
|
$insertId = $this->invoice->insert([
|
|
'parent_id' => $parentId,
|
|
'invoice_number' => $invoiceNumber,
|
|
'total_amount' => $totalAmount,
|
|
'paid_amount' => 0,
|
|
'balance' => $totalAmount,
|
|
'status' => 'unpaid',
|
|
'school_year' => $this->schoolYear,
|
|
'semester' => $semester,
|
|
'issue_date' => $issueUtc,
|
|
'due_date' => $dueUtc,
|
|
'created_at' => utc_now(),
|
|
'updated_at' => utc_now(),
|
|
]);
|
|
|
|
if (!$insertId) {
|
|
log_message('error', 'Invoice insert failed: ' . json_encode($this->invoice->errors()));
|
|
return $this->respondError('Failed to create invoice.', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
} else {
|
|
log_message('info', "Invoice created successfully. Insert ID: {$insertId}");
|
|
}
|
|
$updated = false;
|
|
}
|
|
|
|
return $this->success([
|
|
'ok' => true,
|
|
'updated' => $updated,
|
|
'updated_ids' => $updatedIds,
|
|
'insert_id' => isset($insertId) ? (int) $insertId : null,
|
|
], $updated ? 'Invoice updated successfully' : 'Invoice created successfully');
|
|
}
|
|
|
|
/**
|
|
* GET /api/v1/invoices/{id}/pdf
|
|
* Generate PDF invoice
|
|
*/
|
|
public function generatePdfInvoice($invoiceId): JsonResponse
|
|
{
|
|
$data = $this->prepareInvoiceData($invoiceId);
|
|
if (isset($data['error'])) {
|
|
return $this->respondError($data['error'], Response::HTTP_NOT_FOUND);
|
|
}
|
|
|
|
// Fetch discount_amount from discount_usages
|
|
$discountResult = DB::table('discount_usages as du')
|
|
->select(DB::raw('SUM(du.discount_amount) as total_discount'))
|
|
->join('invoices as i', 'du.invoice_id', '=', 'i.id')
|
|
->where('du.invoice_id', $invoiceId)
|
|
->where('i.school_year', $this->schoolYear)
|
|
->first();
|
|
|
|
$discountAmount = isset($discountResult->total_discount) ? (float) $discountResult->total_discount : 0.00;
|
|
|
|
// Attach discount amount to data
|
|
$data['discount_amount'] = $discountAmount;
|
|
|
|
// Note: PDF generation would need to be handled separately or via a service
|
|
// For now, return the prepared data
|
|
return $this->success($data, 'Invoice data prepared for PDF generation');
|
|
}
|
|
|
|
/**
|
|
* GET /api/v1/invoices/parent/{parentId}
|
|
* Get invoices by parent ID
|
|
*/
|
|
public function getByParent($parentId): JsonResponse
|
|
{
|
|
$schoolYear = $this->request->getGet('school_year') ?? $this->schoolYear;
|
|
$invoices = $this->invoice->getInvoicesByParentId($parentId, $schoolYear);
|
|
|
|
return $this->success($invoices, 'Invoices retrieved successfully');
|
|
}
|
|
|
|
/**
|
|
* POST /api/v1/invoices
|
|
* Create a new invoice
|
|
*/
|
|
public function store(): JsonResponse
|
|
{
|
|
$payload = $this->payloadData();
|
|
if (empty($payload)) {
|
|
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
$invoiceData = array_intersect_key($payload, array_flip($this->invoice->getFillable()));
|
|
$invoiceData['school_year'] = $invoiceData['school_year'] ?? $this->schoolYear;
|
|
$invoiceData['semester'] = $invoiceData['semester'] ?? $this->semester;
|
|
|
|
try {
|
|
$invoice = $this->invoice->create($invoiceData);
|
|
return $this->respondCreated($invoice->toArray(), 'Invoice created successfully');
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'Invoice create error: ' . $e->getMessage());
|
|
return $this->respondError('Failed to create invoice', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/v1/invoices/{id}
|
|
* Update invoice
|
|
*/
|
|
public function update($id = null): JsonResponse
|
|
{
|
|
$invoice = $this->invoice->find($id);
|
|
if (!$invoice) {
|
|
return $this->error('Invoice not found', Response::HTTP_NOT_FOUND);
|
|
}
|
|
|
|
$payload = $this->payloadData();
|
|
if (empty($payload)) {
|
|
return $this->error('Invalid request data', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
$allowedFields = ['status', 'paid_amount', 'balance', 'description'];
|
|
$updateData = [];
|
|
foreach ($allowedFields as $field) {
|
|
if (array_key_exists($field, $payload)) {
|
|
$updateData[$field] = $payload[$field];
|
|
}
|
|
}
|
|
|
|
if (empty($updateData)) {
|
|
return $this->error('No valid fields to update', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
try {
|
|
$this->invoice->update($id, $updateData);
|
|
$updatedInvoice = $this->invoice->find($id);
|
|
return $this->success($updatedInvoice, 'Invoice updated successfully');
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'Invoice update error: ' . $e->getMessage());
|
|
return $this->respondError('Failed to update invoice', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* PATCH /api/v1/invoices/{id}/status
|
|
* Update invoice status
|
|
*/
|
|
public function updateStatus($id = null): JsonResponse
|
|
{
|
|
$invoice = $this->invoice->find($id);
|
|
if (!$invoice) {
|
|
return $this->error('Invoice not found', Response::HTTP_NOT_FOUND);
|
|
}
|
|
|
|
$payload = $this->payloadData();
|
|
$status = $payload['status'] ?? null;
|
|
|
|
if (!$status) {
|
|
return $this->respondError('Status is required', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
if ($this->invoice->updateInvoiceStatus($id, $status)) {
|
|
$updatedInvoice = $this->invoice->find($id);
|
|
return $this->success($updatedInvoice, 'Invoice status updated successfully');
|
|
} else {
|
|
return $this->respondError('Failed to update invoice status', Response::HTTP_INTERNAL_SERVER_ERROR);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/v1/invoices/unpaid
|
|
* Get unpaid invoices
|
|
*/
|
|
public function unpaidInvoices(): JsonResponse
|
|
{
|
|
$unpaidInvoices = $this->invoice->getUnpaidInvoices();
|
|
return $this->success($unpaidInvoices, 'Unpaid invoices retrieved successfully');
|
|
}
|
|
|
|
/**
|
|
* POST /api/v1/invoices/check-and-generate
|
|
* Check and generate invoice when enrollment status changes
|
|
*/
|
|
public function checkAndGenerateInvoice(): JsonResponse
|
|
{
|
|
$payload = $this->payloadData();
|
|
$parentId = (int) ($payload['parent_id'] ?? 0);
|
|
$status = $payload['status'] ?? '';
|
|
|
|
if (!$parentId) {
|
|
return $this->respondError('Parent ID is required', Response::HTTP_BAD_REQUEST);
|
|
}
|
|
|
|
if ($status === 'payment pending' || $status === 'withdrawn' || $status === 'enrolled') {
|
|
// Call generateInvoice logic
|
|
$this->generateInvoice();
|
|
return $this->success(null, 'Invoice checked and generated if needed');
|
|
}
|
|
|
|
return $this->success(null, 'No invoice generation needed for this status');
|
|
}
|
|
|
|
// ==================== Private Helper Methods ====================
|
|
|
|
private function recalculateAndUpdateDiscount(
|
|
int $parentId,
|
|
string $schoolYear,
|
|
float $tuitionFee,
|
|
array $enrollments
|
|
): float {
|
|
$totalDiscount = 0.00;
|
|
|
|
// Get all invoices for this parent and school year
|
|
$invoices = $this->invoice->getInvoicesByParentId($parentId, $schoolYear);
|
|
|
|
if (empty($invoices)) {
|
|
log_message('info', "No invoices found for parent ID $parentId in school year $schoolYear.");
|
|
return 0.00;
|
|
}
|
|
|
|
foreach ($invoices as $invoice) {
|
|
if (!isset($invoice['id'])) {
|
|
continue;
|
|
}
|
|
|
|
$invoiceId = $invoice['id'];
|
|
|
|
// Get discount usage + voucher details
|
|
$discountUsage = DB::table('discount_usages as du')
|
|
->select('du.id', 'dv.id as voucher_id', 'dv.discount_type', 'dv.discount_value')
|
|
->join('discount_vouchers as dv', 'du.voucher_id', '=', 'dv.id')
|
|
->join('invoices as i', 'du.invoice_id', '=', 'i.id')
|
|
->where('du.invoice_id', $invoiceId)
|
|
->where('i.school_year', $schoolYear)
|
|
->first();
|
|
|
|
if (!$discountUsage) {
|
|
log_message('info', "No discount applied to invoice ID $invoiceId.");
|
|
continue;
|
|
}
|
|
|
|
// Recalculate discount
|
|
if ($discountUsage->discount_type === 'percent') {
|
|
$discountAmount = round(($tuitionFee * $discountUsage->discount_value) / 100, 2);
|
|
} else {
|
|
$discountAmount = min($discountUsage->discount_value, $tuitionFee);
|
|
}
|
|
|
|
// Update discount usage
|
|
DB::table('discount_usages')
|
|
->where('id', $discountUsage->id)
|
|
->update([
|
|
'discount_amount' => $discountAmount,
|
|
'updated_at' => utc_now(),
|
|
'updated_by' => $this->getCurrentUserId(),
|
|
]);
|
|
|
|
$totalDiscount += $discountAmount;
|
|
|
|
// Log enrollment summary
|
|
$added = [];
|
|
$withdrawn = [];
|
|
foreach ($enrollments as $e) {
|
|
if (
|
|
in_array($e['enrollment_status'], ['enrolled', 'payment pending']) &&
|
|
($e['admission_status'] ?? '') === 'accepted'
|
|
) {
|
|
$added[] = $e['student_id'];
|
|
} elseif (in_array($e['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) {
|
|
$withdrawn[] = $e['student_id'];
|
|
}
|
|
}
|
|
|
|
log_message('info', "Recalculated discount for invoice ID $invoiceId: Added students [" . implode(',', $added) . "], Withdrawn students [" . implode(',', $withdrawn) . "]. Discount updated to $discountAmount.");
|
|
}
|
|
|
|
return $totalDiscount;
|
|
}
|
|
|
|
/**
|
|
* Returns an array:
|
|
* [
|
|
* 'tuition_fee' => <float>, // what the parent still owes after any withdrawals
|
|
* 'refund_amount' => <float>, // what the school should return (0 if none)
|
|
* ]
|
|
*/
|
|
private function calculateTuitionFee(array $registeredKids, array $withdrawnKids): array
|
|
{
|
|
// If we are within the refund window, exclude withdrawn kids from billing.
|
|
// After the deadline, treat withdrawn kids as billable (no refunds allowed).
|
|
try {
|
|
$tzName = user_timezone();
|
|
$tz = new DateTimeZone($tzName);
|
|
$today = new CarbonImmutable('today', $tz);
|
|
$deadline = new CarbonImmutable($this->refundDeadline, $tz);
|
|
$refundOk = $today <= $deadline;
|
|
} catch (\Throwable $e) {
|
|
$refundOk = true; // default conservative
|
|
}
|
|
|
|
$tuitionStudents = $refundOk ? $registeredKids : array_merge($registeredKids, $withdrawnKids);
|
|
|
|
$tuitionFee = $this->calculateTotalTuitionFee($tuitionStudents);
|
|
|
|
log_message('info', 'Total Tuition Fee (refund ' . ($refundOk ? 'allowed' : 'not allowed') . ") = $tuitionFee");
|
|
|
|
return [
|
|
'tuition_fee' => $tuitionFee,
|
|
];
|
|
}
|
|
|
|
private function calculateTotalTuitionFee(array $students): float
|
|
{
|
|
// 1) Normalize each student's grade name (once)
|
|
foreach ($students as &$student) {
|
|
$gradeName = $this->classSection
|
|
->getClassSectionNameBySectionId($student['class_section_id']);
|
|
$student['grade'] = strtoupper(trim($gradeName ?? ''));
|
|
}
|
|
unset($student); // break reference
|
|
|
|
// 2) Partition into regular (<= grade 9) vs youth (> grade 9)
|
|
$regularCount = 0;
|
|
$youthCount = 0;
|
|
|
|
foreach ($students as $student) {
|
|
$levelInfo = $this->getGradeLevel($student['grade']);
|
|
$level = (int) ($levelInfo['level'] ?? 999);
|
|
|
|
// Youth if level > $this->gradeFee (e.g., gradeFee = 9)
|
|
if ($level > $this->gradeFee) {
|
|
$youthCount++;
|
|
} else {
|
|
$regularCount++;
|
|
}
|
|
}
|
|
|
|
// 3) Calculate totals per your rules
|
|
$total = 0.0;
|
|
|
|
// Youth: flat youth fee per student
|
|
$total += $youthCount * $this->youthFee;
|
|
|
|
// Regulars: first student full price, others discounted — but only if 2+ regulars
|
|
if ($regularCount >= 2) {
|
|
$total += $this->firstStudentFee; // one full
|
|
$total += ($regularCount - 1) * $this->secondStudentFee; // rest discounted
|
|
} elseif ($regularCount === 1) {
|
|
$total += $this->firstStudentFee; // single regular: no discount even if there are youths
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
|
|
private function prepareInvoiceData($invoiceId): array
|
|
{
|
|
$invoice = $this->invoice->find($invoiceId);
|
|
if (!$invoice) {
|
|
return ['error' => "No invoice was generated. Please contact the school administration."];
|
|
}
|
|
|
|
$parentId = $invoice['parent_id'];
|
|
$schoolYear = $invoice['school_year'];
|
|
|
|
// Payments
|
|
$payments = $this->payment
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->findAll();
|
|
|
|
$parent = $this->user->find($parentId);
|
|
if (!$parent) {
|
|
return ['error' => "Parent associated with the invoice was not found."];
|
|
}
|
|
|
|
$enrollments = $this->enrollment
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->findAll();
|
|
|
|
if (empty($enrollments)) {
|
|
return ['error' => 'No enrollments found for this invoice.'];
|
|
}
|
|
|
|
$registeredKids = [];
|
|
$withdrawnKids = [];
|
|
|
|
foreach ($enrollments as $enrollment) {
|
|
$student = $this->student->find($enrollment['student_id']);
|
|
if (!$student) {
|
|
log_message('error', "Student not found for ID: {$enrollment['student_id']}");
|
|
continue;
|
|
}
|
|
|
|
$grade = $this->studentClass->getStudentGrade($student['id']);
|
|
|
|
$studentData = [
|
|
'student_id' => $student['id'],
|
|
'student_firstname' => $student['firstname'],
|
|
'student_lastname' => $student['lastname'],
|
|
'grade' => $grade,
|
|
'tuition_fee' => $enrollment['tuition_fee'] ?? 0,
|
|
'enrollment_status' => $enrollment['enrollment_status'],
|
|
];
|
|
|
|
if (in_array($enrollment['enrollment_status'], ['enrolled', 'payment pending'], true)) {
|
|
$registeredKids[] = $studentData;
|
|
} elseif (in_array($enrollment['enrollment_status'], ['withdrawn', 'withdraw under review', 'refund pending'], true)) {
|
|
$withdrawnKids[] = $studentData;
|
|
} else {
|
|
log_message('info', "Skipping student ID {$student['id']} with status {$enrollment['enrollment_status']}");
|
|
}
|
|
}
|
|
|
|
usort($registeredKids, fn($a, $b) => $this->compareGrades($a['grade'], $b['grade']));
|
|
usort($withdrawnKids, fn($a, $b) => $this->compareGrades($a['grade'], $b['grade']));
|
|
|
|
$tzName = user_timezone();
|
|
$tz = new DateTimeZone($tzName);
|
|
$currentDate = new CarbonImmutable('today', $tz);
|
|
$deadline = new CarbonImmutable($this->refundDeadline, $tz);
|
|
$refundAllowed = $currentDate <= $deadline;
|
|
|
|
$studentCharges = [];
|
|
$regularCount = 0;
|
|
|
|
/**
|
|
* Computes the fee for a student given numeric level and original grade name.
|
|
*/
|
|
$computeCharge = function (int $gradeLevel, string $gradeName) use (&$regularCount) {
|
|
$threshold = (int) $this->gradeFee;
|
|
|
|
// Force Kindergarten to be regular regardless of numeric mapping
|
|
$isRegular = $this->isKindergarten($gradeName) || ($gradeLevel > 0 && $gradeLevel <= $threshold);
|
|
|
|
if ($isRegular) {
|
|
$fee = ($regularCount === 0) ? $this->firstStudentFee : $this->secondStudentFee;
|
|
$regularCount++;
|
|
return $fee;
|
|
}
|
|
return $this->youthFee;
|
|
};
|
|
|
|
// Registered kids -> pay unit fee
|
|
foreach ($registeredKids as $student) {
|
|
$gradeName = (string) $student['grade'];
|
|
$gradeLevel = $this->gradeLevelInt($gradeName);
|
|
$unitFee = $computeCharge($gradeLevel, $gradeName);
|
|
$studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0];
|
|
}
|
|
|
|
// Refund NOT allowed -> withdrawn students still owe unit fee
|
|
if (!$refundAllowed) {
|
|
foreach ($withdrawnKids as $student) {
|
|
$gradeName = (string) $student['grade'];
|
|
$gradeLevel = $this->gradeLevelInt($gradeName);
|
|
$unitFee = $computeCharge($gradeLevel, $gradeName);
|
|
$studentCharges[$student['student_id']] = ['unit_fee' => $unitFee, 'refund' => 0];
|
|
}
|
|
}
|
|
|
|
$eventsList = $this->charges->getChargesWithEventInfo($parentId, $schoolYear);
|
|
|
|
// Attach SCHOOL IDs
|
|
$allKids = array_merge($registeredKids, $withdrawnKids);
|
|
$studentIds = array_column($allKids, 'student_id');
|
|
|
|
$schoolIdMap = [];
|
|
if ($studentIds) {
|
|
$rows = $this->student
|
|
->select('id, school_id')
|
|
->whereIn('id', $studentIds)
|
|
->findAll();
|
|
|
|
foreach ($rows as $row) {
|
|
$schoolIdMap[(int) $row['id']] = $row['school_id'] ?? 'N/A';
|
|
}
|
|
}
|
|
|
|
$students = $allKids;
|
|
foreach ($students as $i => $s) {
|
|
$sid = (int) $s['student_id'];
|
|
$students[$i]['student_school_id'] = $schoolIdMap[$sid] ?? 'N/A';
|
|
}
|
|
|
|
$discounts = $this->discountUsage
|
|
->where('invoice_id', $invoiceId)
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->findAll();
|
|
|
|
// Refunds PAID for this specific invoice (money returned to the parent)
|
|
$refundsPaidTotal = 0.0;
|
|
try {
|
|
$r = DB::table('refunds')
|
|
->select(DB::raw('COALESCE(SUM(refund_paid_amount),0) AS tot'))
|
|
->where('invoice_id', $invoiceId)
|
|
->where('school_year', $schoolYear)
|
|
->whereIn('status', ['Partial', 'Paid'])
|
|
->first();
|
|
$refundsPaidTotal = (float) ($r->tot ?? 0.0);
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'Failed to sum refunds for invoice ' . (int) $invoiceId . ': ' . $e->getMessage());
|
|
}
|
|
|
|
// Additional charges
|
|
$acRows = $this->additionalCharge
|
|
->select('id, charge_type, title, description, amount, due_date, status, created_at')
|
|
->where('invoice_id', $invoiceId)
|
|
->where('status', '!=', 'void')
|
|
->orderBy('created_at', 'ASC')
|
|
->orderBy('id', 'ASC')
|
|
->findAll();
|
|
|
|
$additionalChargeLines = [];
|
|
$additionalChargesTotal = 0.0;
|
|
|
|
foreach ($acRows as $ac) {
|
|
$signed = (float) ($ac['amount'] ?? 0);
|
|
$ctype = strtolower((string) ($ac['charge_type'] ?? ''));
|
|
|
|
if (in_array($ctype, ['deduct'], true) && $signed > 0) {
|
|
$signed = -$signed;
|
|
} elseif (in_array($ctype, ['add'], true) && $signed < 0) {
|
|
$signed = abs($signed);
|
|
}
|
|
|
|
$lineDate = !empty($ac['created_at'])
|
|
? date('Y-m-d', strtotime($ac['created_at']))
|
|
: (!empty($invoice['created_at']) ? local_date($invoice['created_at'], 'Y-m-d') : local_date(utc_now(), 'Y-m-d'));
|
|
|
|
$typeLabel = in_array($ctype, ['deduct'], true) ? 'Deduct' : 'Add';
|
|
$desc = $typeLabel . ': ';
|
|
|
|
if (!empty($ac['description'])) {
|
|
$desc = $ac['description'];
|
|
}
|
|
|
|
$additionalChargesTotal += $signed;
|
|
|
|
$additionalChargeLines[] = [
|
|
'date' => $lineDate,
|
|
'description' => $desc,
|
|
'amount' => $signed,
|
|
'meta' => [
|
|
'id' => (int) $ac['id'],
|
|
'due_date' => $ac['due_date'] ?? null,
|
|
'status' => (string) ($ac['status'] ?? ''),
|
|
'type' => $ctype,
|
|
],
|
|
];
|
|
}
|
|
|
|
$additionalChargesTotal = round($additionalChargesTotal, 2);
|
|
|
|
return [
|
|
'invoice' => $invoice,
|
|
'parent' => $parent,
|
|
'registeredKids' => $registeredKids,
|
|
'withdrawnKids' => $withdrawnKids,
|
|
'studentCharges' => $studentCharges,
|
|
'events' => $eventsList,
|
|
'students' => $students,
|
|
'payments' => $payments,
|
|
'discounts' => $discounts,
|
|
'additionalChargesTotal' => $additionalChargesTotal,
|
|
'additionalChargeLines' => $additionalChargeLines,
|
|
'refundsPaidTotal' => $refundsPaidTotal,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Treat common KG spellings as Kindergarten.
|
|
*/
|
|
private function isKindergarten(string $grade): bool
|
|
{
|
|
$g = strtolower(trim($grade));
|
|
return in_array($g, ['kg', 'k', 'kindergarten', 'k-g', 'k.g', 'k g'], true);
|
|
}
|
|
|
|
private function compareGrades($gradeA, $gradeB): int
|
|
{
|
|
$valA = $this->getGradeLevel($gradeA);
|
|
$valB = $this->getGradeLevel($gradeB);
|
|
|
|
// First compare numeric levels
|
|
if ($valA['level'] !== $valB['level']) {
|
|
return $valA['level'] <=> $valB['level'];
|
|
}
|
|
|
|
// Same level, compare suffix lexicographically (A < B)
|
|
return strcmp($valA['suffix'] ?? '', $valB['suffix'] ?? '');
|
|
}
|
|
|
|
private function gradeLevelInt($grade): int
|
|
{
|
|
$gl = $this->getGradeLevel($grade);
|
|
return (int) ($gl['level'] ?? 0);
|
|
}
|
|
|
|
/**
|
|
* Robust grade parser. Returns ['level' => int, 'suffix' => string]
|
|
*/
|
|
private function getGradeLevel($grade): array
|
|
{
|
|
// Normalize
|
|
if (is_numeric($grade)) {
|
|
$num = (int) $grade;
|
|
if ($num === 13) {
|
|
// KG special case
|
|
return ['level' => 1, 'suffix' => '', 'classId' => 13];
|
|
}
|
|
return ['level' => $num, 'suffix' => '', 'classId' => null];
|
|
}
|
|
|
|
if (!is_string($grade)) {
|
|
return ['level' => 999, 'suffix' => '', 'classId' => null];
|
|
}
|
|
|
|
$g = strtoupper(trim($grade));
|
|
$g = preg_replace('/\s+/', ' ', $g);
|
|
$g = str_replace(['.', '_'], '', $g);
|
|
$g = str_replace('-', ' ', $g);
|
|
|
|
// Kindergarten (string forms)
|
|
$kg = ['K', 'KG', 'K G', 'KINDER', 'KINDERGARTEN'];
|
|
if (in_array($g, $kg, true)) {
|
|
return ['level' => 1, 'suffix' => '', 'classId' => 13];
|
|
}
|
|
|
|
// Pre-K
|
|
$pk = ['PK', 'P K', 'PREK', 'PRE K', 'PRE-K', 'PRE KINDER', 'PREKINDER'];
|
|
if (in_array($g, $pk, true)) {
|
|
return ['level' => -1, 'suffix' => '', 'classId' => null];
|
|
}
|
|
|
|
// Youth: Y, YOUTH, Y1, YOUTH2, etc.
|
|
if (preg_match('/^Y(?:OUTH)?(?:\s*(\d+))?$/', $g, $m)) {
|
|
$n = isset($m[1]) && $m[1] !== '' ? max(1, (int) $m[1]) : 1;
|
|
return [
|
|
'level' => (int) $this->gradeFee + $n,
|
|
'suffix' => '',
|
|
'classId' => null,
|
|
];
|
|
}
|
|
|
|
// Numeric grades like "5", "GRADE 5", "G5", "5A"
|
|
if (preg_match('/^(?:GR?ADE\s*)?(\d{1,2})\s*([A-Z]*)$/', $g, $m)) {
|
|
return [
|
|
'level' => (int) $m[1],
|
|
'suffix' => $m[2] ?? '',
|
|
'classId' => null,
|
|
];
|
|
}
|
|
|
|
return ['level' => 999, 'suffix' => '', 'classId' => null];
|
|
}
|
|
}
|