2355 lines
91 KiB
PHP
2355 lines
91 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers\View;
|
|
|
|
require_once APPPATH . 'ThirdParty/fpdf/fpdf.php';
|
|
|
|
use App\Models\InvoiceModel;
|
|
use App\Models\InvoiceStudentListModel;
|
|
use App\Models\StudentModel;
|
|
use App\Models\EnrollmentModel;
|
|
use App\Models\StudentClassModel;
|
|
use App\Models\ConfigurationModel;
|
|
use App\Models\EventChargesModel;
|
|
use App\Models\UserModel;
|
|
use App\Models\AdditionalChargeModel;
|
|
use App\Models\PaymentModel;
|
|
use App\Models\InvoiceEventModel;
|
|
use App\Models\ClassSectionModel;
|
|
use App\Models\DiscountUsageModel;
|
|
use App\Models\RefundModel;
|
|
use App\Libraries\FinancialStatus;
|
|
use App\Libraries\IssueInvoiceCommand;
|
|
use App\Libraries\InvoiceIssuanceService;
|
|
use App\Libraries\InvoiceLedgerService;
|
|
use App\Libraries\Tuition\GradeLevelParser;
|
|
use DateTime;
|
|
use DateTimeZone;
|
|
|
|
|
|
use CodeIgniter\RESTful\ResourceController;
|
|
|
|
class InvoiceController extends ResourceController
|
|
{
|
|
protected $invoiceModel;
|
|
protected $additionalChargeModel;
|
|
protected $studentModel;
|
|
protected $enrollmentModel;
|
|
protected $invoicestudentModel;
|
|
protected $schoolYear; // Declare global variable for the controller
|
|
protected $semester;
|
|
protected $dueDate;
|
|
protected $db;
|
|
protected $configModel;
|
|
protected $userModel;
|
|
protected $studentClassModel;
|
|
protected $firstStudentFee;
|
|
protected $secondStudentFee;
|
|
protected $refundDeadline;
|
|
protected $invoiceEventModel;
|
|
protected $paymentModel;
|
|
protected $chargesModel;
|
|
protected $discountUsageModel;
|
|
protected $refundModel;
|
|
protected $request;
|
|
protected $gradeFee;
|
|
protected $classSectionModel;
|
|
protected $invoiceLedgerService;
|
|
protected InvoiceIssuanceService $invoiceIssuanceService;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->additionalChargeModel = new AdditionalChargeModel();
|
|
$this->classSectionModel = new ClassSectionModel();
|
|
$this->invoiceModel = new InvoiceModel();
|
|
$this->invoicestudentModel = new InvoiceStudentListModel();
|
|
$this->studentModel = new StudentModel();
|
|
$this->enrollmentModel = new EnrollmentModel();
|
|
$this->configModel = new ConfigurationModel();
|
|
$this->userModel = new UserModel();
|
|
$this->studentClassModel = new StudentClassModel();
|
|
$this->invoiceEventModel = new InvoiceEventModel();
|
|
$this->paymentModel = new PaymentModel();
|
|
$this->chargesModel = new EventChargesModel();
|
|
$this->discountUsageModel = new DiscountUsageModel();
|
|
$this->refundModel = new RefundModel();
|
|
$this->invoiceLedgerService = new InvoiceLedgerService();
|
|
$this->db = \Config\Database::connect();
|
|
$this->invoiceIssuanceService = new InvoiceIssuanceService($this->db, $this->invoiceModel, null, $this->invoiceLedgerService);
|
|
$this->request = \Config\Services::request();
|
|
|
|
$this->gradeFee = $this->configModel->getConfig('grade_fee');
|
|
$this->schoolYear = $this->currentSchoolYearName();
|
|
$this->semester = getSemester();
|
|
$this->dueDate = $this->configModel->getConfig('first_day_of_school')
|
|
?: $this->configModel->getConfig('due_date');
|
|
$this->firstStudentFee = (float) ($this->configModel->getConfig('first_student_fee') ?? 380);
|
|
$this->secondStudentFee = (float) ($this->configModel->getConfig('second_student_fee') ?? 280);
|
|
$this->refundDeadline = date('Y-m-d', strtotime($this->configModel->getConfig('refund_deadline')));
|
|
}
|
|
|
|
public function index($schoolYear = null)
|
|
{
|
|
// Get school years from invoices
|
|
$schoolYears = $this->invoiceModel
|
|
->select('school_year')
|
|
->distinct()
|
|
->orderBy('school_year', 'DESC')
|
|
->findAll();
|
|
|
|
// Default school year
|
|
if (!$schoolYear) {
|
|
$schoolYear = $this->schoolYear;
|
|
}
|
|
|
|
log_message('info', "Selected school year for invoice retrieval: $schoolYear");
|
|
|
|
$invoiceData = [];
|
|
$parents = $this->invoiceManagementParents($schoolYear);
|
|
|
|
foreach ($parents as $parent) {
|
|
foreach ($this->invoiceManagementRowsForParent($parent, $schoolYear) as $row) {
|
|
$invoiceData[] = $row;
|
|
}
|
|
}
|
|
|
|
return view('invoice_payment/invoice_management', [
|
|
'invoices' => $invoiceData,
|
|
'schoolYears' => $schoolYears,
|
|
'selectedYear' => $schoolYear,
|
|
'parents' => array_column($invoiceData, 'parent_name')
|
|
]);
|
|
}
|
|
|
|
private function currentSchoolYearName(): string
|
|
{
|
|
try {
|
|
return service('schoolYearContext')->resolve($this->request)->yearName();
|
|
} catch (\Throwable $e) {
|
|
return (string) ($this->configModel->getConfig('school_year') ?? '');
|
|
}
|
|
}
|
|
|
|
private function paidRefundSummaryForParentYear(int $parentId, string $schoolYear): array
|
|
{
|
|
$refund = $this->db->table('refunds')
|
|
->select('COALESCE(SUM(refund_paid_amount),0) AS refund_paid_amount')
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->whereIn('status', ['Partial', 'Paid'])
|
|
->get()
|
|
->getRowArray();
|
|
|
|
$details = [];
|
|
if ($this->db->tableExists('refund_payouts')) {
|
|
$details = $this->db->table('refund_payouts rp')
|
|
->select('rp.amount_cents, rp.payment_method, rp.check_number, rp.processed_at, rp.created_at')
|
|
->join('refunds r', 'r.id = rp.refund_id', 'inner')
|
|
->where('r.parent_id', $parentId)
|
|
->where('r.school_year', $schoolYear)
|
|
->where('rp.payout_type', 'cash_out')
|
|
->whereIn('rp.status', ['completed', 'processing'])
|
|
->orderBy('COALESCE(rp.processed_at, rp.created_at)', 'DESC', false)
|
|
->get()
|
|
->getResultArray();
|
|
|
|
$details = array_map(static function (array $row): array {
|
|
return [
|
|
'amount' => ((int) ($row['amount_cents'] ?? 0)) / 100,
|
|
'date' => $row['processed_at'] ?? $row['created_at'] ?? null,
|
|
'method' => $row['payment_method'] ?? '',
|
|
'check_number' => $row['check_number'] ?? '',
|
|
];
|
|
}, $details);
|
|
}
|
|
|
|
if (empty($details)) {
|
|
$legacyRows = $this->db->table('refunds')
|
|
->select('refund_paid_amount, refund_method, check_nbr, refunded_at, updated_at')
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->whereIn('status', ['Partial', 'Paid'])
|
|
->where('refund_paid_amount >', 0)
|
|
->orderBy('COALESCE(refunded_at, updated_at)', 'DESC', false)
|
|
->get()
|
|
->getResultArray();
|
|
|
|
$details = array_map(static function (array $row): array {
|
|
return [
|
|
'amount' => (float) ($row['refund_paid_amount'] ?? 0),
|
|
'date' => $row['refunded_at'] ?? $row['updated_at'] ?? null,
|
|
'method' => $row['refund_method'] ?? '',
|
|
'check_number' => $row['check_nbr'] ?? '',
|
|
];
|
|
}, $legacyRows);
|
|
}
|
|
|
|
return [
|
|
'amount' => (float) ($refund['refund_paid_amount'] ?? 0.0),
|
|
'details' => $details,
|
|
];
|
|
}
|
|
|
|
private function paidRefundDetailsForInvoice(int $invoiceId): array
|
|
{
|
|
$details = [];
|
|
if ($this->db->tableExists('refund_payouts')) {
|
|
$rows = $this->db->table('refund_payouts rp')
|
|
->select('rp.amount_cents, rp.payment_method, rp.check_number, rp.processed_at, rp.created_at, rp.payout_type')
|
|
->join('refunds r', 'r.id = rp.refund_id', 'inner')
|
|
->where('r.invoice_id', $invoiceId)
|
|
->whereIn('rp.payout_type', ['cash_out', 'reversal'])
|
|
->where('rp.status', 'completed')
|
|
->orderBy('COALESCE(rp.processed_at, rp.created_at)', 'ASC', false)
|
|
->get()
|
|
->getResultArray();
|
|
|
|
foreach ($rows as $row) {
|
|
$amount = ((int) ($row['amount_cents'] ?? 0)) / 100;
|
|
if (($row['payout_type'] ?? '') === 'reversal') {
|
|
$amount *= -1;
|
|
}
|
|
|
|
$details[] = [
|
|
'amount' => $amount,
|
|
'date' => $row['processed_at'] ?? $row['created_at'] ?? null,
|
|
'method' => $row['payment_method'] ?? '',
|
|
'check_number' => $row['check_number'] ?? '',
|
|
'type' => $row['payout_type'] ?? 'cash_out',
|
|
];
|
|
}
|
|
}
|
|
|
|
if (empty($details)) {
|
|
$rows = $this->db->table('refunds')
|
|
->select('refund_paid_amount, refund_method, check_nbr, refunded_at, updated_at')
|
|
->where('invoice_id', $invoiceId)
|
|
->whereIn('status', ['Partial', 'Paid'])
|
|
->where('refund_paid_amount >', 0)
|
|
->orderBy('COALESCE(refunded_at, updated_at)', 'ASC', false)
|
|
->get()
|
|
->getResultArray();
|
|
|
|
foreach ($rows as $row) {
|
|
$details[] = [
|
|
'amount' => (float) ($row['refund_paid_amount'] ?? 0),
|
|
'date' => $row['refunded_at'] ?? $row['updated_at'] ?? null,
|
|
'method' => $row['refund_method'] ?? '',
|
|
'check_number' => $row['check_nbr'] ?? '',
|
|
'type' => 'cash_out',
|
|
];
|
|
}
|
|
}
|
|
|
|
return $details;
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string,mixed>>
|
|
*/
|
|
private function eventChargesForInvoice(array $invoice): array
|
|
{
|
|
if (! $this->db->tableExists('event_charges')) {
|
|
return [];
|
|
}
|
|
|
|
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
|
$schoolYear = trim((string) ($invoice['school_year'] ?? ''));
|
|
if ($parentId <= 0 || $schoolYear === '') {
|
|
return [];
|
|
}
|
|
|
|
$builder = $this->db->table('event_charges ec')
|
|
->select('ec.*, e.event_name, e.amount AS event_amount, e.description AS event_description')
|
|
->join('events e', 'e.id = ec.event_id', 'left')
|
|
->where('ec.parent_id', $parentId)
|
|
->where('ec.school_year', $schoolYear)
|
|
->orderBy('COALESCE(ec.created_at, ec.updated_at)', 'ASC', false)
|
|
->orderBy('ec.id', 'ASC');
|
|
|
|
return $builder->get()->getResultArray();
|
|
}
|
|
|
|
private function assertSchoolYearNameWritable(string $schoolYear): void
|
|
{
|
|
service('schoolYearWriteGuard')->assertWritable(
|
|
service('schoolYearContext')->forYearName($schoolYear)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* API: Invoice management composite data (used by invoice_management view)
|
|
* Returns the same structure previously rendered server-side in index().
|
|
*/
|
|
public function managementData()
|
|
{
|
|
$schoolYear = trim((string)($this->request->getGet('schoolYear') ?? $this->request->getGet('year') ?? ''));
|
|
if ($schoolYear === '') {
|
|
$schoolYear = $this->currentSchoolYearName();
|
|
}
|
|
|
|
$invoiceData = [];
|
|
try {
|
|
// Distinct school years (for selector)
|
|
$yearsRows = $this->invoiceModel
|
|
->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->invoiceManagementParents($schoolYear);
|
|
|
|
foreach ($parents as $parent) {
|
|
foreach ($this->invoiceManagementRowsForParent($parent, $schoolYear) as $row) {
|
|
$invoiceData[] = $row;
|
|
}
|
|
}
|
|
|
|
return $this->response->setJSON([
|
|
'ok' => true,
|
|
'schoolYear' => $schoolYear,
|
|
'schoolYears' => $schoolYears,
|
|
'invoices' => $invoiceData,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'managementData error: ' . $e->getMessage());
|
|
return $this->response->setStatusCode(500)->setJSON(['ok' => false, 'error' => 'Server error']);
|
|
}
|
|
}
|
|
|
|
|
|
private function hasClassAssignment(string $schoolYear, string $parentId): bool
|
|
{
|
|
//$parentId = session()->get('user_id');
|
|
|
|
if (!$parentId) {
|
|
return false;
|
|
}
|
|
|
|
|
|
// Get all students for the parent
|
|
$students = $this->studentModel->where('parent_id', $parentId)->findAll();
|
|
|
|
if (empty($students)) {
|
|
return false;
|
|
}
|
|
|
|
// Check if at least one student has a class assignment
|
|
foreach ($students as $student) {
|
|
$studentId = $student['id'];
|
|
|
|
$exists = $this->studentClassModel
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $schoolYear)
|
|
->first();
|
|
|
|
if ($exists) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public function generateInvoice(
|
|
?string $parentId = null,
|
|
?string $schoolYearOverride = null,
|
|
?string $semesterOverride = null,
|
|
bool $recalculateDiscounts = true
|
|
)
|
|
{
|
|
$request = $this->request ?? service('request');
|
|
$isAjax = $request !== null && (
|
|
$request->isAJAX()
|
|
|| str_contains(strtolower($request->getHeaderLine('Accept')), 'application/json')
|
|
);
|
|
// Programmatic callers (new InvoiceController() without initController) have no response object.
|
|
$hasHttpResponse = $this->response !== null;
|
|
|
|
if ($parentId == null) {
|
|
$parentId = (int) ($request?->getPost('parent_id') ?? 0);
|
|
}
|
|
$schoolYear = (string) ($schoolYearOverride ?: $this->schoolYear);
|
|
$semester = (string) ($semesterOverride ?: $this->semester);
|
|
|
|
// Fetch enrolled + withdrawn students
|
|
$enrollments = $this->enrollmentModel
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->findAll();
|
|
|
|
if (empty($enrollments)) {
|
|
return $this->invoiceGenerationResult(
|
|
$hasHttpResponse,
|
|
$isAjax,
|
|
['ok' => false, 'message' => 'No enrollment records found.'],
|
|
422,
|
|
'No enrollment records 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'],
|
|
'admission_status' => $enrollment['admission_status'],
|
|
'is_withdrawn' => $enrollment['is_withdrawn']
|
|
];
|
|
|
|
// Treat 'payment pending' and 'enrolled' as billable regardless of admission_status,
|
|
// since admin mapping should have already normalized this to 'accepted'.
|
|
// This makes the calculation resilient to legacy data.
|
|
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']} and admission_status: {$enrollment['admission_status']}");
|
|
}
|
|
}
|
|
|
|
$registeredKids = array_values(array_filter($registeredKids, function ($student) use ($schoolYear) {
|
|
$sid = (int)($student['student_id'] ?? 0);
|
|
return $sid > 0 && $this->studentClassModel->hasNonEventAssignment($sid, $schoolYear);
|
|
}));
|
|
|
|
$withdrawnKids = array_values(array_filter($withdrawnKids, function ($student) use ($schoolYear) {
|
|
$sid = (int)($student['student_id'] ?? 0);
|
|
return $sid > 0 && $this->studentClassModel->hasNonEventAssignment($sid, $schoolYear);
|
|
}));
|
|
|
|
// ✅ Use your helper to calculate tuition fee
|
|
$fees = $this->calculateTuitionFee($registeredKids, $withdrawnKids);
|
|
$tuitionFee = $fees['tuition_fee'];
|
|
$tuitionFeeByStudentId = $this->studentTuitionFeeMap($registeredKids, $withdrawnKids);
|
|
$registeredKids = $this->applyStudentTuitionFees($registeredKids, $tuitionFeeByStudentId);
|
|
$withdrawnKids = $this->applyStudentTuitionFees($withdrawnKids, $tuitionFeeByStudentId);
|
|
|
|
// ✅ Fetch event charges
|
|
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
|
|
$eventchargeTotal = array_sum(array_column($eventsList, 'charged'));
|
|
|
|
$totalDiscount = 0.0;
|
|
if ($recalculateDiscounts) {
|
|
$totalDiscount = $this->recalculateAndUpdateDiscount(
|
|
$parentId,
|
|
$schoolYear,
|
|
$tuitionFee,
|
|
$enrollments
|
|
);
|
|
}
|
|
|
|
$discountedTuition = max(0, $tuitionFee);
|
|
$totalAmount = $discountedTuition + $eventchargeTotal;
|
|
|
|
// Business rule: single invoice per parent per school year.
|
|
// If legacy duplicates exist, prefer the invoice that already has a discount applied,
|
|
// otherwise use the latest invoice for the parent/year.
|
|
$invoice = $this->selectActiveInvoiceForParentYear((int)$parentId, $schoolYear);
|
|
|
|
$updated = false;
|
|
$updatedIds = [];
|
|
if (!empty($invoice) && isset($invoice['id'])) {
|
|
$invoiceId = (int) $invoice['id'];
|
|
$this->syncInvoiceStudentSnapshot(
|
|
$invoiceId,
|
|
array_merge($registeredKids, $withdrawnKids),
|
|
$schoolYear
|
|
);
|
|
$this->invoiceLedgerService->syncTuitionLines($invoiceId);
|
|
$ledger = $this->invoiceLedgerService->recalculate($invoiceId);
|
|
$updatedIds[] = (int) $ledger['invoice_id'];
|
|
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
|
|
$updated = true;
|
|
} else {
|
|
$hasNonZeroTuitionOrEvents = abs((float) $tuitionFee) > 0.00001
|
|
|| abs((float) $eventchargeTotal) > 0.00001;
|
|
$hasApprovedAdjustments = $this->parentHasApprovedInvoiceAdjustments(
|
|
(int) $parentId,
|
|
$schoolYear,
|
|
$semester
|
|
);
|
|
|
|
if (! $hasNonZeroTuitionOrEvents && ! $hasApprovedAdjustments) {
|
|
return $this->invoiceGenerationResult(
|
|
$hasHttpResponse,
|
|
$isAjax,
|
|
['ok' => false, 'message' => 'Invoice requires at least one non-zero line.'],
|
|
422,
|
|
'Invoice requires at least one non-zero line.'
|
|
);
|
|
}
|
|
|
|
$issueUtc = (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d H:i:s');
|
|
|
|
// Due date: interpret the date in configured/user local TZ,
|
|
// set a default time (noon to avoid DST edge cases), then convert to UTC.
|
|
$dueUtc = null;
|
|
if (!empty($this->dueDate)) {
|
|
$tzName = (string) (config('School')->attendance['timezone'] ?? 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');
|
|
}
|
|
|
|
try {
|
|
$issueResult = $this->invoiceIssuanceService->issueInvoice(new IssueInvoiceCommand([
|
|
'parent_id' => $parentId,
|
|
'invoice_number' => $this->invoiceIssuanceService->generateInvoiceNumber($schoolYear, (int)$parentId),
|
|
'total_amount' => $totalAmount,
|
|
'paid_amount' => 0,
|
|
'balance' => $totalAmount,
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
'issue_date' => $issueUtc,
|
|
'due_date' => $dueUtc,
|
|
'created_at' => utc_now(),
|
|
'updated_at' => utc_now()
|
|
], (float) $tuitionFee, (float) $eventchargeTotal, [
|
|
'parent_id' => (int) $parentId,
|
|
'school_year' => $schoolYear,
|
|
'semester' => $semester,
|
|
'registered_student_count' => count($registeredKids),
|
|
'withdrawn_student_count' => count($withdrawnKids),
|
|
]));
|
|
$insertId = $issueResult->invoiceId;
|
|
$ledger = $issueResult->ledger;
|
|
log_message('info', "Invoice created successfully. Insert ID: {$insertId}");
|
|
} catch (\Throwable $e) {
|
|
log_message('error', 'Invoice issuance failed: ' . $e->getMessage() . ' errors=' . json_encode($this->invoiceModel->errors()));
|
|
$message = str_contains($e->getMessage(), 'non-zero invoice line')
|
|
? 'Invoice requires at least one non-zero line.'
|
|
: 'Failed to create invoice.';
|
|
|
|
return $this->invoiceGenerationResult(
|
|
$hasHttpResponse,
|
|
$isAjax,
|
|
['ok' => false, 'message' => $message],
|
|
422,
|
|
$message === 'Invoice requires at least one non-zero line.'
|
|
? $message
|
|
: 'Failed to create invoice. Please check input values.'
|
|
);
|
|
}
|
|
$updated = false;
|
|
|
|
$this->syncInvoiceStudentSnapshot(
|
|
(int) $insertId,
|
|
array_merge($registeredKids, $withdrawnKids),
|
|
$schoolYear
|
|
);
|
|
}
|
|
|
|
$successPayload = [
|
|
'ok' => true,
|
|
'updated' => $updated,
|
|
'updated_ids' => $updatedIds,
|
|
'insert_id' => isset($insertId) ? (int)$insertId : null,
|
|
csrf_token() => csrf_hash(),
|
|
'csrfTokenName' => csrf_token(),
|
|
'csrfHash' => csrf_hash(),
|
|
];
|
|
|
|
return $this->invoiceGenerationResult(
|
|
$hasHttpResponse,
|
|
$isAjax,
|
|
$successPayload,
|
|
200,
|
|
null,
|
|
$updated ? 'Invoice updated.' : 'Invoice created.'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Safe invoice response helper for both HTTP and programmatic callers.
|
|
*
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
private function invoiceGenerationResult(
|
|
bool $hasHttpResponse,
|
|
bool $isAjax,
|
|
array $payload,
|
|
int $statusCode = 200,
|
|
?string $errorFlash = null,
|
|
?string $successFlash = null
|
|
) {
|
|
if (! $hasHttpResponse || $this->response === null) {
|
|
return $payload;
|
|
}
|
|
|
|
if ($isAjax) {
|
|
return $this->response
|
|
->setStatusCode($statusCode)
|
|
->setJSON($payload);
|
|
}
|
|
|
|
if ($errorFlash !== null) {
|
|
return redirect()->back()->with('error', $errorFlash);
|
|
}
|
|
|
|
return redirect()->to(route_to('InvoiceController::index'))
|
|
->with('success', $successFlash ?? 'Invoice saved.');
|
|
}
|
|
|
|
private function parentHasApprovedInvoiceAdjustments(int $parentId, string $schoolYear, string $semester): bool
|
|
{
|
|
if ($parentId <= 0 || $schoolYear === '' || $semester === '') {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return $this->additionalChargeModel
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->where('semester', $semester)
|
|
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPROVED)
|
|
->where('amount !=', 0)
|
|
->countAllResults() > 0;
|
|
} catch (\Throwable $e) {
|
|
log_message('warning', 'Unable to check approved invoice adjustments: {message}', [
|
|
'message' => $e->getMessage(),
|
|
]);
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private function selectActiveInvoiceForParentYear(int $parentId, string $schoolYear): ?array
|
|
{
|
|
if ($parentId <= 0 || $schoolYear === '') {
|
|
return null;
|
|
}
|
|
|
|
$invoices = $this->invoiceModel
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->orderBy('id', 'DESC')
|
|
->findAll();
|
|
|
|
$tuitionInvoices = [];
|
|
foreach ($invoices as $invoice) {
|
|
if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) {
|
|
continue;
|
|
}
|
|
$tuitionInvoices[] = $invoice;
|
|
}
|
|
|
|
if ($tuitionInvoices === []) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($tuitionInvoices as $invoice) {
|
|
if ((int) ($invoice['has_discount'] ?? 0) === 1) {
|
|
return $invoice;
|
|
}
|
|
}
|
|
|
|
try {
|
|
$tuitionInvoiceIds = array_values(array_filter(array_map(
|
|
static fn (array $row): int => (int) ($row['id'] ?? 0),
|
|
$tuitionInvoices
|
|
)));
|
|
if ($tuitionInvoiceIds !== []) {
|
|
$row = $this->db->table('invoices i')
|
|
->select('i.*')
|
|
->join('discount_usages du', 'du.invoice_id = i.id', 'inner')
|
|
->whereIn('i.id', $tuitionInvoiceIds)
|
|
->orderBy('i.id', 'DESC')
|
|
->get()
|
|
->getRowArray();
|
|
if (! empty($row)) {
|
|
return $row;
|
|
}
|
|
}
|
|
} catch (\Throwable $e) {
|
|
}
|
|
|
|
return $tuitionInvoices[0];
|
|
}
|
|
|
|
/**
|
|
* Persist the invoice's student roster as an append-only snapshot.
|
|
*
|
|
* Existing rows are never removed or changed here. That keeps already-issued
|
|
* invoices from losing a student's name after enrollment status changes.
|
|
*
|
|
* @param list<array<string, mixed>> $students
|
|
*/
|
|
private function syncInvoiceStudentSnapshot(int $invoiceId, array $students, string $schoolYear): void
|
|
{
|
|
if ($invoiceId <= 0 || $schoolYear === '' || $students === []) {
|
|
return;
|
|
}
|
|
|
|
$hasTuitionFeeColumn = $this->db->fieldExists('tuition_fee', 'invoice_students_list');
|
|
$existingRows = $this->invoicestudentModel
|
|
->select($hasTuitionFeeColumn ? 'id, student_id, tuition_fee' : 'id, student_id')
|
|
->where('invoice_id', $invoiceId)
|
|
->findAll();
|
|
$existingByStudentId = [];
|
|
foreach ($existingRows as $row) {
|
|
$sid = (int) ($row['student_id'] ?? 0);
|
|
if ($sid > 0) {
|
|
$existingByStudentId[$sid] = $row;
|
|
}
|
|
}
|
|
|
|
$studentIds = array_values(array_unique(array_filter(
|
|
array_map(static fn (array $student): int => (int) ($student['student_id'] ?? 0), $students),
|
|
static fn (int $studentId): bool => $studentId > 0
|
|
)));
|
|
if ($studentIds === []) {
|
|
return;
|
|
}
|
|
|
|
$this->invoicestudentModel
|
|
->where('invoice_id', $invoiceId)
|
|
->whereNotIn('student_id', $studentIds)
|
|
->delete();
|
|
|
|
$studentRows = $this->studentModel
|
|
->select('id, firstname, lastname, school_id')
|
|
->whereIn('id', $studentIds)
|
|
->findAll();
|
|
$studentsById = [];
|
|
foreach ($studentRows as $row) {
|
|
$studentsById[(int) ($row['id'] ?? 0)] = $row;
|
|
}
|
|
|
|
$now = utc_now();
|
|
foreach ($students as $student) {
|
|
$studentId = (int) ($student['student_id'] ?? 0);
|
|
if ($studentId <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$studentRow = $studentsById[$studentId] ?? [];
|
|
$tuitionFee = round((float) ($student['tuition_fee'] ?? 0), 2);
|
|
if (isset($existingByStudentId[$studentId])) {
|
|
$existing = $existingByStudentId[$studentId];
|
|
$payload = [
|
|
'student_firstname' => (string) ($studentRow['firstname'] ?? $student['student_firstname'] ?? ''),
|
|
'student_lastname' => (string) ($studentRow['lastname'] ?? $student['student_lastname'] ?? ''),
|
|
'school_id' => (int) ($studentRow['school_id'] ?? $student['school_id'] ?? 0),
|
|
'enrolled' => in_array((string) ($student['enrollment_status'] ?? ''), ['enrolled', 'payment pending'], true) ? 1 : 0,
|
|
'school_year' => $schoolYear,
|
|
'updated_at' => $now,
|
|
];
|
|
if ($hasTuitionFeeColumn) {
|
|
$payload['tuition_fee'] = number_format($tuitionFee, 2, '.', '');
|
|
}
|
|
|
|
$this->invoicestudentModel->update((int) $existing['id'], $payload);
|
|
continue;
|
|
}
|
|
|
|
$payload = [
|
|
'invoice_id' => $invoiceId,
|
|
'student_id' => $studentId,
|
|
'student_firstname' => (string) ($studentRow['firstname'] ?? $student['student_firstname'] ?? ''),
|
|
'student_lastname' => (string) ($studentRow['lastname'] ?? $student['student_lastname'] ?? ''),
|
|
'school_id' => (int) ($studentRow['school_id'] ?? $student['school_id'] ?? 0),
|
|
'enrolled' => in_array((string) ($student['enrollment_status'] ?? ''), ['enrolled', 'payment pending'], true) ? 1 : 0,
|
|
'school_year' => $schoolYear,
|
|
'created_at' => $now,
|
|
'updated_at' => $now,
|
|
];
|
|
if ($hasTuitionFeeColumn) {
|
|
$payload['tuition_fee'] = number_format($tuitionFee, 2, '.', '');
|
|
}
|
|
|
|
$this->invoicestudentModel->insert($payload);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function invoiceStudentSnapshotRows(int $invoiceId, string $schoolYear): array
|
|
{
|
|
if ($invoiceId <= 0) {
|
|
return [];
|
|
}
|
|
|
|
$hasTuitionFeeColumn = $this->db->fieldExists('tuition_fee', 'invoice_students_list');
|
|
$select = 'id, invoice_id, student_id, student_firstname, student_lastname, school_id, enrolled, school_year';
|
|
if ($hasTuitionFeeColumn) {
|
|
$select .= ', tuition_fee';
|
|
}
|
|
|
|
$rows = $this->invoicestudentModel
|
|
->select($select)
|
|
->where('invoice_id', $invoiceId)
|
|
->orderBy('id', 'ASC')
|
|
->findAll();
|
|
|
|
if ($rows === []) {
|
|
return [];
|
|
}
|
|
|
|
$studentIds = array_values(array_unique(array_filter(
|
|
array_map(static fn (array $row): int => (int) ($row['student_id'] ?? 0), $rows),
|
|
static fn (int $studentId): bool => $studentId > 0
|
|
)));
|
|
|
|
$schoolIdMap = [];
|
|
if ($studentIds !== []) {
|
|
$studentRows = $this->studentModel
|
|
->select('id, school_id')
|
|
->whereIn('id', $studentIds)
|
|
->findAll();
|
|
foreach ($studentRows as $studentRow) {
|
|
$schoolIdMap[(int) ($studentRow['id'] ?? 0)] = $studentRow['school_id'] ?? 'N/A';
|
|
}
|
|
}
|
|
|
|
$snapshot = [];
|
|
foreach ($rows as $row) {
|
|
$studentId = (int) ($row['student_id'] ?? 0);
|
|
if ($studentId <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$grade = $this->resolveStudentGradeName($studentId, $schoolYear);
|
|
$snapshot[] = [
|
|
'student_id' => $studentId,
|
|
'student_firstname' => (string) ($row['student_firstname'] ?? ''),
|
|
'student_lastname' => (string) ($row['student_lastname'] ?? ''),
|
|
'student_school_id' => $row['school_id'] ?? $schoolIdMap[$studentId] ?? 'N/A',
|
|
'school_id' => $row['school_id'] ?? $schoolIdMap[$studentId] ?? 0,
|
|
'grade' => $grade,
|
|
'tuition_fee' => (float) ($row['tuition_fee'] ?? 0),
|
|
'enrollment_status' => ((int) ($row['enrolled'] ?? 1) === 1) ? 'enrolled' : 'withdrawn',
|
|
];
|
|
}
|
|
|
|
return $snapshot;
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function invoiceManagementParents(string $schoolYear): array
|
|
{
|
|
$byId = [];
|
|
foreach ($this->userModel->getUsersByRoleAndSchoolYear('parent', $schoolYear) as $parent) {
|
|
$byId[(int) ($parent['id'] ?? 0)] = $parent;
|
|
}
|
|
|
|
$invoiceParentRows = $this->invoiceModel
|
|
->select('parent_id')
|
|
->distinct()
|
|
->where('school_year', $schoolYear)
|
|
->findAll();
|
|
|
|
foreach ($invoiceParentRows as $row) {
|
|
$parentId = (int) ($row['parent_id'] ?? 0);
|
|
if ($parentId <= 0 || isset($byId[$parentId])) {
|
|
continue;
|
|
}
|
|
|
|
$parent = $this->userModel->find($parentId);
|
|
if ($parent !== null) {
|
|
$byId[$parentId] = $parent;
|
|
}
|
|
}
|
|
|
|
return array_values($byId);
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function invoiceManagementRowsForParent(array $parent, string $schoolYear): array
|
|
{
|
|
$parentId = (int) ($parent['id'] ?? 0);
|
|
if ($parentId <= 0 || $schoolYear === '') {
|
|
return [];
|
|
}
|
|
|
|
$kids = $this->invoiceManagementKidsForParent($parentId, $schoolYear);
|
|
$enrolledKids = $kids['enrolledKids'];
|
|
$withdrawnKids = $kids['withdrawnKids'];
|
|
$rows = [];
|
|
|
|
foreach ($this->carryForwardInvoicesForParentYear($parentId, $schoolYear) as $carryForwardInvoice) {
|
|
$rows[] = $this->buildInvoiceManagementRow(
|
|
$parent,
|
|
$carryForwardInvoice,
|
|
true,
|
|
[],
|
|
[],
|
|
$this->paidRefundSummaryForInvoice((int) ($carryForwardInvoice['id'] ?? 0))
|
|
);
|
|
}
|
|
|
|
$tuitionInvoice = $this->selectActiveInvoiceForParentYear($parentId, $schoolYear);
|
|
if ($tuitionInvoice !== null) {
|
|
$snapshotKids = $this->invoiceStudentSnapshotRows((int) ($tuitionInvoice['id'] ?? 0), $schoolYear);
|
|
if ($snapshotKids !== []) {
|
|
$enrolledKids = [];
|
|
$withdrawnKids = [];
|
|
foreach ($snapshotKids as $snapshotKid) {
|
|
$kid = [
|
|
'name' => trim((string) ($snapshotKid['student_firstname'] ?? '') . ' ' . (string) ($snapshotKid['student_lastname'] ?? '')),
|
|
'grade' => $snapshotKid['grade'] ?? 'N/A',
|
|
'tuition_fee' => (float) ($snapshotKid['tuition_fee'] ?? 0),
|
|
];
|
|
|
|
if (in_array((string) ($snapshotKid['enrollment_status'] ?? ''), ['enrolled', 'payment pending'], true)) {
|
|
$enrolledKids[] = $kid;
|
|
} else {
|
|
$withdrawnKids[] = $kid;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($enrolledKids !== [] || $withdrawnKids !== [] || $tuitionInvoice !== null) {
|
|
$rows[] = $this->buildInvoiceManagementRow(
|
|
$parent,
|
|
$tuitionInvoice,
|
|
false,
|
|
$enrolledKids,
|
|
$withdrawnKids,
|
|
$this->paidRefundSummaryForParentYear($parentId, $schoolYear)
|
|
);
|
|
}
|
|
|
|
return $rows;
|
|
}
|
|
|
|
/**
|
|
* @return array{enrolledKids: list<array<string, mixed>>, withdrawnKids: list<array<string, mixed>>}
|
|
*/
|
|
private function invoiceManagementKidsForParent(int $parentId, string $schoolYear): array
|
|
{
|
|
$enrolledKids = [];
|
|
$withdrawnKids = [];
|
|
$students = $this->studentModel->where('parent_id', $parentId)->findAll();
|
|
|
|
foreach ($students as $student) {
|
|
$studentClass = $this->db->table('student_class')
|
|
->where('student_id', $student['id'])
|
|
->where('school_year', $schoolYear)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
$grade = 'N/A';
|
|
if ($studentClass && isset($studentClass['class_section_id'])) {
|
|
$classSectionBuilder = $this->db->table('classSection')
|
|
->select('classSection.class_section_id, classSection.class_section_name, classSection.class_id, classes.class_name')
|
|
->join('classes', 'classes.id = classSection.class_id', 'left')
|
|
->where('classSection.class_section_id', $studentClass['class_section_id']);
|
|
if ($this->db->fieldExists('school_year', 'classSection')) {
|
|
$classSectionBuilder->orderBy('classSection.school_year = ' . $this->db->escape($schoolYear), 'DESC', false);
|
|
}
|
|
$classSectionBuilder->orderBy('classSection.id', 'DESC');
|
|
$classSection = $classSectionBuilder->get()->getRowArray();
|
|
if ($classSection) {
|
|
$grade = $this->invoiceManagementGradeLabel($classSection, $student);
|
|
} elseif ($this->isKindergarten((string) ($student['registration_grade'] ?? ''))) {
|
|
$grade = 'KG';
|
|
}
|
|
}
|
|
|
|
$enrollments = $this->enrollmentModel
|
|
->where('student_id', $student['id'])
|
|
->where('school_year', $schoolYear)
|
|
->findAll();
|
|
|
|
foreach ($enrollments as $enrollment) {
|
|
$kid = [
|
|
'name' => trim((string) ($student['firstname'] ?? '') . ' ' . (string) ($student['lastname'] ?? '')),
|
|
'grade' => $grade,
|
|
'tuition_fee' => 0.0,
|
|
];
|
|
|
|
switch ($enrollment['enrollment_status']) {
|
|
case 'payment pending':
|
|
case 'enrolled':
|
|
$enrolledKids[] = $kid;
|
|
break;
|
|
case 'withdraw under review':
|
|
case 'withdrawn':
|
|
case 'refund pending':
|
|
$withdrawnKids[] = $kid;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return [
|
|
'enrolledKids' => $enrolledKids,
|
|
'withdrawnKids' => $withdrawnKids,
|
|
];
|
|
}
|
|
|
|
private function invoiceManagementGradeLabel(array $classSection, array $student): string
|
|
{
|
|
$registrationGrade = trim((string) ($student['registration_grade'] ?? ''));
|
|
$classId = (int) ($classSection['class_id'] ?? 0);
|
|
$className = trim((string) ($classSection['class_name'] ?? ''));
|
|
$sectionName = trim((string) ($classSection['class_section_name'] ?? ''));
|
|
|
|
if ($classId === 13 || $this->isKindergarten($className) || $this->isKindergarten($registrationGrade)) {
|
|
return 'KG';
|
|
}
|
|
|
|
if ($sectionName === '13') {
|
|
return 'KG';
|
|
}
|
|
|
|
return $sectionName !== '' ? $sectionName : ($className !== '' ? $className : 'N/A');
|
|
}
|
|
|
|
/**
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
private function carryForwardInvoicesForParentYear(int $parentId, string $schoolYear): array
|
|
{
|
|
if ($parentId <= 0 || $schoolYear === '') {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_filter(
|
|
$this->invoiceModel
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->orderBy('id', 'ASC')
|
|
->findAll(),
|
|
fn (array $invoice): bool => $this->invoiceLedgerService->invoiceIsCarryForward($invoice)
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $enrolledKids
|
|
* @param list<array<string, mixed>> $withdrawnKids
|
|
* @param array{amount: float, details: list<array<string, mixed>>} $refundSummary
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function buildInvoiceManagementRow(
|
|
array $parent,
|
|
?array $invoice,
|
|
bool $isCarryForward,
|
|
array $enrolledKids,
|
|
array $withdrawnKids,
|
|
array $refundSummary
|
|
): array {
|
|
$parentId = (int) ($parent['id'] ?? 0);
|
|
$invoiceId = $invoice !== null ? (int) ($invoice['id'] ?? 0) : null;
|
|
$invoiceDate = null;
|
|
|
|
if ($invoice !== null) {
|
|
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
|
$invoiceDate = ! empty($invoice['issue_date'])
|
|
? (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC')))
|
|
->setTimezone(new \DateTimeZone($tzName))
|
|
->format('Y-m-d H:i:s')
|
|
: ($invoice['updated_at'] ?? null);
|
|
}
|
|
|
|
$description = '';
|
|
if ($isCarryForward && $invoice !== null) {
|
|
$description = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice);
|
|
}
|
|
|
|
return [
|
|
'parent_name' => trim((string) ($parent['firstname'] ?? '') . ' ' . (string) ($parent['lastname'] ?? '')),
|
|
'parent_id' => $parentId,
|
|
'enrolledKids' => $enrolledKids,
|
|
'withdrawnKids' => $withdrawnKids,
|
|
'invoice_amount' => $invoice !== null ? (float) ($invoice['total_amount'] ?? 0) : 0.0,
|
|
'invoice_balance' => $invoice !== null ? (float) ($invoice['balance'] ?? 0) : 0.0,
|
|
'refund_amount' => (float) ($refundSummary['amount'] ?? 0.0),
|
|
'refund_details' => $refundSummary['details'] ?? [],
|
|
'last_updated' => $invoice['updated_at'] ?? null,
|
|
'invoice_date' => $invoiceDate,
|
|
'invoice_id' => $invoiceId,
|
|
'invoice_number' => $invoice !== null ? (string) ($invoice['invoice_number'] ?? '') : '',
|
|
'invoice_description' => $description,
|
|
'invoice_status' => $invoice !== null ? (string) ($invoice['status'] ?? '') : '',
|
|
'is_carry_forward' => $isCarryForward,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{amount: float, details: list<array<string, mixed>>}
|
|
*/
|
|
private function paidRefundSummaryForInvoice(int $invoiceId): array
|
|
{
|
|
if ($invoiceId <= 0) {
|
|
return ['amount' => 0.0, 'details' => []];
|
|
}
|
|
|
|
$details = $this->paidRefundDetailsForInvoice($invoiceId);
|
|
$amount = 0.0;
|
|
foreach ($details as $detail) {
|
|
$amount += (float) ($detail['amount'] ?? 0.0);
|
|
}
|
|
|
|
return [
|
|
'amount' => round($amount, 2),
|
|
'details' => $details,
|
|
];
|
|
}
|
|
|
|
private function recalculateAndUpdateDiscount(
|
|
int $parentId,
|
|
string $schoolYear,
|
|
float $tuitionFee,
|
|
array $enrollments
|
|
): float {
|
|
$invoices = $this->invoiceModel->getInvoicesByParentId($parentId, $schoolYear);
|
|
$totalDiscount = 0.0;
|
|
|
|
foreach ($invoices as $invoice) {
|
|
$ledger = $this->invoiceLedgerService->recalculateInvoice((int)$invoice['id']);
|
|
$totalDiscount += (float)($ledger['discount_total'] ?? 0.0);
|
|
}
|
|
|
|
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 = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
|
$tz = new \DateTimeZone($tzName);
|
|
$today = new \DateTimeImmutable('today', $tz);
|
|
$deadline = new \DateTimeImmutable($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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string,mixed>> $registeredKids
|
|
* @param list<array<string,mixed>> $withdrawnKids
|
|
* @return array<int,float>
|
|
*/
|
|
private function studentTuitionFeeMap(array $registeredKids, array $withdrawnKids): array
|
|
{
|
|
try {
|
|
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
|
$today = new \DateTimeImmutable('today', new \DateTimeZone($tzName));
|
|
$deadline = new \DateTimeImmutable($this->refundDeadline, new \DateTimeZone($tzName));
|
|
$refundOk = $today <= $deadline;
|
|
} catch (\Throwable $e) {
|
|
$refundOk = true;
|
|
}
|
|
|
|
$billableStudents = $refundOk ? $registeredKids : array_merge($registeredKids, $withdrawnKids);
|
|
$schoolYear = (string) ($billableStudents[0]['school_year'] ?? $this->schoolYear);
|
|
|
|
foreach ($billableStudents as &$student) {
|
|
$student['grade'] = $this->resolveStudentGradeName(
|
|
(int) ($student['student_id'] ?? 0),
|
|
$schoolYear,
|
|
$student['class_section_id'] ?? null
|
|
);
|
|
}
|
|
unset($student);
|
|
|
|
usort($billableStudents, fn (array $left, array $right): int => GradeLevelParser::parse($left['grade'] ?? null) <=> GradeLevelParser::parse($right['grade'] ?? null));
|
|
|
|
$fees = [];
|
|
$studentCount = 0;
|
|
foreach ($billableStudents as $student) {
|
|
$studentId = (int) ($student['student_id'] ?? 0);
|
|
if ($studentId <= 0) {
|
|
continue;
|
|
}
|
|
|
|
$fees[$studentId] = $studentCount === 0 ? $this->firstStudentFee : $this->secondStudentFee;
|
|
$studentCount++;
|
|
}
|
|
|
|
return $fees;
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string,mixed>> $students
|
|
* @param array<int,float> $tuitionFeeByStudentId
|
|
* @return list<array<string,mixed>>
|
|
*/
|
|
private function applyStudentTuitionFees(array $students, array $tuitionFeeByStudentId): array
|
|
{
|
|
foreach ($students as &$student) {
|
|
$studentId = (int) ($student['student_id'] ?? 0);
|
|
$student['tuition_fee'] = $tuitionFeeByStudentId[$studentId] ?? 0.0;
|
|
}
|
|
unset($student);
|
|
|
|
return $students;
|
|
}
|
|
|
|
private function calculateTotalTuitionFee(array $students): float
|
|
{
|
|
$schoolYear = (string) ($students[0]['school_year'] ?? $this->schoolYear);
|
|
|
|
// 1) Normalize each student's grade name (once)
|
|
foreach ($students as &$student) {
|
|
$student['grade'] = $this->resolveStudentGradeName(
|
|
(int) ($student['student_id'] ?? 0),
|
|
$schoolYear,
|
|
$student['class_section_id'] ?? null
|
|
);
|
|
}
|
|
unset($student); // break reference
|
|
|
|
// 2) First student pays the base fee; every additional student pays base minus $100.
|
|
usort($students, fn (array $left, array $right): int => GradeLevelParser::parse($left['grade'] ?? null) <=> GradeLevelParser::parse($right['grade'] ?? null));
|
|
|
|
$studentCount = 0;
|
|
$total = 0.0;
|
|
|
|
foreach ($students as $student) {
|
|
$total += ($studentCount === 0) ? $this->firstStudentFee : $this->secondStudentFee;
|
|
$studentCount++;
|
|
}
|
|
|
|
return $total;
|
|
}
|
|
|
|
private function resolveStudentGradeName(int $studentId, string $schoolYear, $classSectionId = null): string
|
|
{
|
|
$sectionId = $classSectionId;
|
|
if (empty($sectionId) && $studentId > 0 && $schoolYear !== '') {
|
|
$row = $this->studentClassModel
|
|
->select('class_section_id')
|
|
->where('student_id', $studentId)
|
|
->where('school_year', $schoolYear)
|
|
->where('is_event_only', 0)
|
|
->orderBy('updated_at', 'DESC')
|
|
->first();
|
|
$sectionId = $row['class_section_id'] ?? null;
|
|
}
|
|
|
|
if (empty($sectionId)) {
|
|
return 'N/A';
|
|
}
|
|
|
|
$gradeName = $this->classSectionModel->getClassSectionNameBySectionId($sectionId);
|
|
|
|
return strtoupper(trim((string) $gradeName));
|
|
}
|
|
|
|
|
|
// Method to check and generate an invoice when enrollment status changes
|
|
public function checkAndGenerateInvoice($parentId, $status)
|
|
{
|
|
if ($status == 'payment pending') {
|
|
$this->generateInvoice($parentId);
|
|
} elseif ($status == 'withdrawn' || $status == 'enrolled') {
|
|
$this->generateInvoice($parentId);
|
|
}
|
|
}
|
|
|
|
public function generatePdfInvoice($invoiceId)
|
|
{
|
|
$data = $this->prepareInvoiceData($invoiceId);
|
|
if (isset($data['error'])) {
|
|
return $this->generateErrorPdf($data['error']);
|
|
}
|
|
|
|
// Fetch discount_amount from discount_usages
|
|
$discountResult = $this->db->table('discount_usages du')
|
|
->selectSum('du.discount_amount', 'total_discount')
|
|
->join('invoices i', 'du.invoice_id = i.id')
|
|
->where('du.invoice_id', $invoiceId)
|
|
->where('i.school_year', $this->schoolYear)
|
|
->get()
|
|
->getRowArray();
|
|
|
|
|
|
$discountAmount = isset($discountResult['total_discount']) ? (float)$discountResult['total_discount'] : 0.00;
|
|
|
|
// Attach discount amount to data
|
|
$data['discount_amount'] = $discountAmount;
|
|
|
|
$this->renderPdfInvoice($data);
|
|
}
|
|
|
|
// Add this helper method in the same class (once)
|
|
|
|
|
|
private function prepareInvoiceData($invoiceId)
|
|
{
|
|
$invoice = $this->invoiceModel->find($invoiceId);
|
|
if (!$invoice) {
|
|
return ['error' => "No invoice was generated. Please contact the school administration."];
|
|
}
|
|
|
|
if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) {
|
|
return $this->prepareCarryForwardInvoiceData($invoice);
|
|
}
|
|
|
|
$parentId = $invoice['parent_id'];
|
|
$schoolYear = $invoice['school_year'];
|
|
|
|
// --- Payments ---
|
|
$db = $this->db;
|
|
$table = $this->paymentModel->table;
|
|
$hasStatus = $db->fieldExists('status', $table);
|
|
$hasVoid = $db->fieldExists('is_void', $table);
|
|
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
|
|
|
$qb = $this->paymentModel
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->where('invoice_id', $invoiceId);
|
|
|
|
if ($hasStatus) {
|
|
$qb->groupStart()
|
|
->whereNotIn('status', $exclude)
|
|
->orWhere('status IS NULL', null, false)
|
|
->groupEnd();
|
|
}
|
|
|
|
if ($hasVoid) {
|
|
$qb->groupStart()
|
|
->where('is_void', 0)
|
|
->orWhere('is_void IS NULL', null, false)
|
|
->groupEnd();
|
|
}
|
|
|
|
$payments = $qb->findAll();
|
|
|
|
$parent = $this->userModel->find($parentId);
|
|
if (!$parent) {
|
|
return ['error' => "Parent associated with the invoice was not found."];
|
|
}
|
|
|
|
$ledger = $this->invoiceLedgerService->storedInvoiceLedger((int) $invoiceId);
|
|
$invoiceLines = [];
|
|
|
|
$registeredKids = [];
|
|
$withdrawnKids = [];
|
|
$snapshotKids = $this->invoiceStudentSnapshotRows((int) $invoiceId, (string) $schoolYear);
|
|
|
|
if ($snapshotKids !== []) {
|
|
foreach ($snapshotKids as $studentData) {
|
|
if (in_array((string) ($studentData['enrollment_status'] ?? ''), ['enrolled', 'payment pending'], true)) {
|
|
$registeredKids[] = $studentData;
|
|
} else {
|
|
$withdrawnKids[] = $studentData;
|
|
}
|
|
}
|
|
}
|
|
|
|
usort($registeredKids, fn($a, $b) => $this->compareGrades($a['grade'], $b['grade']));
|
|
usort($withdrawnKids, fn($a, $b) => $this->compareGrades($a['grade'], $b['grade']));
|
|
|
|
$studentCharges = [];
|
|
|
|
$eventsList = $this->eventChargesForInvoice($invoice);
|
|
|
|
// Attach SCHOOL IDs
|
|
$allKids = array_merge($registeredKids, $withdrawnKids);
|
|
$studentIds = array_column($allKids, 'student_id');
|
|
|
|
$schoolIdMap = [];
|
|
if ($studentIds) {
|
|
$rows = $this->studentModel
|
|
->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->discountUsageModel
|
|
->where('invoice_id', $invoiceId)
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->findAll();
|
|
|
|
$refundsPaidTotal = (float) ($ledger['refund_paid_total'] ?? 0.0);
|
|
$refundDetails = $this->paidRefundDetailsForInvoice((int) $invoiceId);
|
|
|
|
/* ============================================================
|
|
* ADDITIONAL CHARGES (itemized) for this invoice
|
|
* - uses the additional_charges table for line items
|
|
* - uses invoice.additional_charge as the authoritative total (Strategy B)
|
|
* ============================================================ */
|
|
$acRows = $this->additionalChargeModel
|
|
->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';
|
|
$title = ''; //trim((string)($ac['title'] ?? 'Additional Charge'));
|
|
$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,
|
|
'isCarryForwardInvoice' => $this->invoiceLedgerService->invoiceIsCarryForward($invoice),
|
|
'carryForwardDescription'=> $this->invoiceLedgerService->carryForwardDisplayDescription($invoice),
|
|
'registeredKids' => $registeredKids,
|
|
'withdrawnKids' => $withdrawnKids,
|
|
'studentCharges' => [],
|
|
'events' => $eventsList,
|
|
'students' => $students,
|
|
'payments' => $payments,
|
|
'discounts' => $discounts,
|
|
'additionalChargesTotal' => $additionalChargesTotal,
|
|
'additionalChargeLines' => $additionalChargeLines,
|
|
'invoiceLines' => $invoiceLines,
|
|
'refundsPaidTotal' => $refundsPaidTotal,
|
|
'refundDetails' => $refundDetails,
|
|
'ledger' => $ledger,
|
|
];
|
|
}
|
|
|
|
private function prepareCarryForwardInvoiceData(array $invoice): array
|
|
{
|
|
$invoiceId = (int) ($invoice['id'] ?? 0);
|
|
$parentId = (int) ($invoice['parent_id'] ?? 0);
|
|
$schoolYear = (string) ($invoice['school_year'] ?? '');
|
|
|
|
$parent = $this->userModel->find($parentId);
|
|
if (! $parent) {
|
|
return ['error' => 'Parent associated with the invoice was not found.'];
|
|
}
|
|
|
|
$ledger = $this->invoiceLedgerService->storedInvoiceLedger($invoiceId);
|
|
$carryForwardDescription = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice);
|
|
$invoice['description'] = $carryForwardDescription;
|
|
|
|
$carryForwardAmount = (float) ($ledger['total_amount'] ?? $invoice['total_amount'] ?? 0);
|
|
|
|
$db = $this->db;
|
|
$table = $this->paymentModel->table;
|
|
$hasStatus = $db->fieldExists('status', $table);
|
|
$hasVoid = $db->fieldExists('is_void', $table);
|
|
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
|
|
|
$paymentQuery = $this->paymentModel
|
|
->where('parent_id', $parentId)
|
|
->where('school_year', $schoolYear)
|
|
->where('invoice_id', $invoiceId);
|
|
|
|
if ($hasStatus) {
|
|
$paymentQuery->groupStart()
|
|
->whereNotIn('status', $exclude)
|
|
->orWhere('status IS NULL', null, false)
|
|
->groupEnd();
|
|
}
|
|
|
|
if ($hasVoid) {
|
|
$paymentQuery->groupStart()
|
|
->where('is_void', 0)
|
|
->orWhere('is_void IS NULL', null, false)
|
|
->groupEnd();
|
|
}
|
|
|
|
$payments = $paymentQuery->findAll();
|
|
$refundsPaidTotal = (float) ($ledger['refund_paid_total'] ?? 0.0);
|
|
$refundDetails = $this->paidRefundDetailsForInvoice($invoiceId);
|
|
|
|
return [
|
|
'invoice' => $invoice,
|
|
'parent' => $parent,
|
|
'isCarryForwardInvoice' => true,
|
|
'carryForwardDescription' => $carryForwardDescription,
|
|
'registeredKids' => [],
|
|
'withdrawnKids' => [],
|
|
'studentCharges' => [],
|
|
'events' => [],
|
|
'students' => [],
|
|
'payments' => $payments,
|
|
'discounts' => [],
|
|
'additionalChargesTotal' => 0.0,
|
|
'additionalChargeLines' => [],
|
|
'invoiceLines' => [],
|
|
'refundsPaidTotal' => $refundsPaidTotal,
|
|
'refundDetails' => $refundDetails,
|
|
'ledger' => $ledger,
|
|
];
|
|
}
|
|
|
|
|
|
/**
|
|
* Treat common KG spellings as Kindergarten.
|
|
*/
|
|
private function isKindergarten(string $grade): bool
|
|
{
|
|
$g = strtolower(trim($grade));
|
|
return in_array($g, ['KG', 'kg', 'k', 'kindergarten', 'k-g', 'k.g', 'k g'], true);
|
|
}
|
|
|
|
|
|
private function renderPdfInvoice($data)
|
|
{
|
|
// Unpack prepared data
|
|
extract($data);
|
|
|
|
// $data keys expected from prepareInvoiceData():
|
|
// - invoice, parent, registeredKids, withdrawnKids, studentCharges, events, students, payments, discounts
|
|
// - additionalChargeLines, additionalChargesTotal
|
|
|
|
$pdf = new class extends \FPDF {
|
|
function Footer()
|
|
{
|
|
$this->SetY(-15);
|
|
$this->SetFont('Arial', 'I', 8);
|
|
$this->Cell(0, 10, 'Page ' . $this->PageNo() . '/{nb}', 0, 0, 'C');
|
|
}
|
|
};
|
|
|
|
$pdf->AliasNbPages();
|
|
$pdf->AddPage();
|
|
|
|
// --- Column widths
|
|
$dateCell = 30;
|
|
$descripCell = 130;
|
|
$amountCell = 30;
|
|
|
|
// --- Header section
|
|
if (defined('FCPATH')) {
|
|
@$pdf->Image(FCPATH . 'images/logo.png', 170, 8, 30);
|
|
@$pdf->Image(FCPATH . 'images/Isgl_logo.png', 10, 8, 30);
|
|
}
|
|
$pdf->Ln(25);
|
|
$pdf->SetFont('Arial', 'B', 18);
|
|
$pdf->Cell(0, 10, 'Al Rahma Sunday School', 0, 1, 'C');
|
|
$pdf->SetFont('Arial', 'B', 18);
|
|
$pdf->Cell(0, 10, 'Invoice', 0, 1, 'C');
|
|
$pdf->Ln(15);
|
|
|
|
$pdf->SetFont('Arial', 'B', 12);
|
|
$pdf->Cell(40, 6, 'Parent Name:', 0, 0, 'L');
|
|
$pdf->SetFont('Arial', '', 12);
|
|
$pdf->Cell(0, 6, ($parent['firstname'] ?? '') . ' ' . ($parent['lastname'] ?? ''), 0, 1, 'L');
|
|
|
|
$pdf->SetFont('Arial', 'B', 12);
|
|
$pdf->Cell(40, 6, 'Invoice Date:', 0, 0, 'L');
|
|
$pdf->SetFont('Arial', '', 12);
|
|
$issueLocal = null;
|
|
try {
|
|
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
|
if (!empty($invoice['issue_date'])) {
|
|
$issueLocal = (new \DateTimeImmutable($invoice['issue_date'], new \DateTimeZone('UTC')))
|
|
->setTimezone(new \DateTimeZone($tzName));
|
|
} elseif (!empty($invoice['created_at'])) {
|
|
$issueLocal = new \DateTimeImmutable($invoice['created_at'], new \DateTimeZone($tzName));
|
|
}
|
|
} catch (\Throwable $e) {}
|
|
if (!$issueLocal) { $issueLocal = new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')); }
|
|
$pdf->Cell(0, 6, $issueLocal->format('m-d-Y'), 0, 1, 'L');
|
|
|
|
$pdf->SetFont('Arial', 'B', 12);
|
|
$pdf->Cell(40, 6, 'Invoice Number:', 0, 0, 'L');
|
|
$pdf->SetFont('Arial', '', 12);
|
|
$pdf->Cell(0, 6, (string)($invoice['invoice_number'] ?? ''), 0, 1, 'L');
|
|
|
|
$pdf->SetFont('Arial', 'B', 12);
|
|
$pdf->Cell(40, 6, 'Due Date:', 0, 0, 'L');
|
|
$pdf->SetFont('Arial', '', 12);
|
|
$formatCalendarDate = static function ($raw): ?string {
|
|
if ($raw instanceof \DateTimeInterface) {
|
|
return $raw->format('m-d-Y');
|
|
}
|
|
|
|
$value = trim((string)($raw ?? ''));
|
|
if ($value === '' || preg_match('/^0{4}-0{2}-0{2}/', $value)) {
|
|
return null;
|
|
}
|
|
|
|
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})/', $value, $matches)) {
|
|
return $matches[2] . '-' . $matches[3] . '-' . $matches[1];
|
|
}
|
|
|
|
if (preg_match('/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{4})/', $value, $matches)) {
|
|
return sprintf('%02d-%02d-%04d', (int)$matches[1], (int)$matches[2], (int)$matches[3]);
|
|
}
|
|
|
|
$timestamp = strtotime($value);
|
|
return $timestamp === false ? null : date('m-d-Y', $timestamp);
|
|
};
|
|
|
|
$dueDisplay = $formatCalendarDate($invoice['due_date'] ?? null);
|
|
try {
|
|
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
|
if ($dueDisplay === null && !empty($invoice['created_at'])) {
|
|
$dueLocal = new \DateTimeImmutable($invoice['created_at'], new \DateTimeZone($tzName));
|
|
$dueDisplay = $dueLocal->format('m-d-Y');
|
|
}
|
|
} catch (\Throwable $e) {}
|
|
if ($dueDisplay === null) {
|
|
$dueDisplay = (new \DateTimeImmutable('now', new \DateTimeZone($tzName ?? 'UTC')))->format('m-d-Y');
|
|
}
|
|
$pdf->Cell(0, 6, $dueDisplay, 0, 1, 'L');
|
|
|
|
$pdf->Ln(5);
|
|
$pdf->SetFont('Arial', '', 9);
|
|
$pdf->MultiCell(0, 5, "Please make the payment by the due date mentioned above. For any queries regarding this invoice, contact the school administration at alrahma.isgl@gmail.com or call +1 978-364-0219.");
|
|
$pdf->Ln(4);
|
|
|
|
// --- Table header
|
|
$pdf->SetFont('Arial', 'B', 12);
|
|
$pdf->Cell($dateCell, 7, 'Date', 1, 0);
|
|
$pdf->Cell($descripCell, 7, 'Description', 1);
|
|
$pdf->Cell($amountCell, 7, 'Amount', 1, 1, 'R');
|
|
|
|
// --- Additional charges data presence
|
|
$additionalChargeLines = $data['additionalChargeLines'] ?? [];
|
|
$additionalChargesTotal = (float)($data['additionalChargesTotal'] ?? 0.0);
|
|
$hasAdditional = abs($additionalChargesTotal) > 0.00001;
|
|
|
|
// --- Helpers for robust date handling & row pushing ---
|
|
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
|
$tz = new \DateTimeZone($tzName);
|
|
|
|
/**
|
|
* Normalize a raw date string to configured/user local timezone.
|
|
* @param ?string $raw
|
|
* @param bool $assumeUtc If true, treat $raw as UTC and convert; if false, parse as local.
|
|
*/
|
|
$toLocal = function (?string $raw, bool $assumeUtc = false) use ($tz): \DateTimeImmutable {
|
|
$fallback = new \DateTimeImmutable('now', $tz);
|
|
if (!$raw) return $fallback;
|
|
|
|
try {
|
|
if ($assumeUtc) {
|
|
$dt = new \DateTimeImmutable($raw, new \DateTimeZone('UTC'));
|
|
return $dt->setTimezone($tz);
|
|
}
|
|
// Parse directly with local tz; PHP will interpret Y-m-d and Y-m-d H:i:s fine here
|
|
$dt = new \DateTimeImmutable($raw, $tz);
|
|
return $dt;
|
|
} catch (\Throwable $e) {
|
|
$ts = @strtotime($raw);
|
|
if ($ts === false) return $fallback;
|
|
return (new \DateTimeImmutable('@' . $ts))->setTimezone($tz);
|
|
}
|
|
};
|
|
|
|
$transactions = [];
|
|
$totalPaid = 0.0;
|
|
$totalDiscount = 0.0;
|
|
$seq = 0; // stable tie-breaker to preserve insertion order for identical timestamps
|
|
|
|
$push = function (\DateTimeImmutable $dt, string $desc, float $amount, string $category = 'other') use (&$transactions, &$seq) {
|
|
$transactions[] = [
|
|
'dt' => $dt,
|
|
'description' => $desc,
|
|
'amount' => $amount,
|
|
'cat' => $category,
|
|
'seq' => $seq++,
|
|
];
|
|
};
|
|
|
|
$studentById = [];
|
|
foreach (($data['students'] ?? []) as $student) {
|
|
$sid = (int)($student['student_id'] ?? 0);
|
|
if ($sid <= 0) {
|
|
continue;
|
|
}
|
|
$studentById[$sid] = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? ''));
|
|
}
|
|
|
|
$studentTuitionRows = [];
|
|
$isCarryForwardInvoice = (bool) ($data['isCarryForwardInvoice'] ?? $this->invoiceLedgerService->invoiceIsCarryForward($invoice));
|
|
$carryForwardDescription = (string) ($data['carryForwardDescription'] ?? $this->invoiceLedgerService->carryForwardDisplayDescription($invoice));
|
|
|
|
if (! $isCarryForwardInvoice) {
|
|
foreach (array_merge($registeredKids ?? [], $withdrawnKids ?? []) as $student) {
|
|
$sid = (int)($student['student_id'] ?? 0);
|
|
$amount = (float)($student['tuition_fee'] ?? 0.0);
|
|
if ($sid <= 0 || abs($amount) < 0.00001) {
|
|
continue;
|
|
}
|
|
|
|
$name = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? ''));
|
|
$grade = trim((string)($student['grade'] ?? ''));
|
|
$desc = 'Tuition - ' . ($name !== '' ? $name : 'Student #' . $sid);
|
|
if ($grade !== '' && strtoupper($grade) !== 'N/A') {
|
|
$desc .= ' (' . $grade . ')';
|
|
}
|
|
|
|
$studentTuitionRows[] = [
|
|
'description' => $desc,
|
|
'amount' => $amount,
|
|
];
|
|
}
|
|
}
|
|
|
|
$snapshotTuitionRows = function (float $total) use ($registeredKids, $withdrawnKids): array {
|
|
$students = array_values(array_filter(
|
|
array_merge($registeredKids ?? [], $withdrawnKids ?? []),
|
|
static fn (array $student): bool => (int)($student['student_id'] ?? 0) > 0
|
|
));
|
|
if ($students === [] || abs($total) < 0.01) {
|
|
return [];
|
|
}
|
|
|
|
$count = count($students);
|
|
$baseAmount = floor(($total / $count) * 100) / 100;
|
|
$allocated = 0.0;
|
|
$rows = [];
|
|
|
|
foreach ($students as $index => $student) {
|
|
$sid = (int)($student['student_id'] ?? 0);
|
|
$amount = $index === $count - 1 ? round($total - $allocated, 2) : $baseAmount;
|
|
$allocated += $amount;
|
|
|
|
$name = trim((string)($student['student_firstname'] ?? '') . ' ' . (string)($student['student_lastname'] ?? ''));
|
|
$grade = trim((string)($student['grade'] ?? ''));
|
|
$desc = 'Tuition - ' . ($name !== '' ? $name : 'Student #' . $sid);
|
|
if ($grade !== '' && strtoupper($grade) !== 'N/A') {
|
|
$desc .= ' (' . $grade . ')';
|
|
}
|
|
|
|
$rows[] = [
|
|
'description' => $desc,
|
|
'amount' => $amount,
|
|
];
|
|
}
|
|
|
|
return $rows;
|
|
};
|
|
|
|
$eventRows = [];
|
|
foreach (($events ?? []) as $event) {
|
|
$amount = (float)($event['charged'] ?? 0.0);
|
|
if (abs($amount) < 0.00001) {
|
|
continue;
|
|
}
|
|
|
|
$sid = (int)($event['student_id'] ?? 0);
|
|
$studentName = $sid > 0 ? ($studentById[$sid] ?? '') : '';
|
|
if ($studentName === '') {
|
|
$studentName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? ''));
|
|
}
|
|
|
|
$desc = trim((string)($event['event_name'] ?? 'Event charge'));
|
|
if ($studentName !== '') {
|
|
$desc .= ' - ' . $studentName;
|
|
}
|
|
|
|
$eventRows[] = [
|
|
'description' => $desc,
|
|
'amount' => $amount,
|
|
];
|
|
}
|
|
|
|
// --- Frozen invoice charge lines remain authoritative for totals.
|
|
// Aggregate tuition/event lines are expanded for display when invoice details are available.
|
|
foreach (($data['invoiceLines'] ?? []) as $line) {
|
|
$dt = $toLocal($line['created_at'] ?? ($invoice['created_at'] ?? null), true);
|
|
$amount = ((int)($line['line_amount_cents'] ?? 0)) / 100;
|
|
$type = (string)($line['line_type'] ?? 'other');
|
|
$sourceType = (string)($line['source_type'] ?? '');
|
|
$category = str_contains($type, 'event') ? 'event'
|
|
: (str_contains($type, 'additional') ? 'additional' : 'registration');
|
|
|
|
if ($sourceType === 'carry_forward_invoice' || $sourceType === 'carry_forward_opening_balance') {
|
|
$lineDescription = trim((string)($line['description'] ?? ''));
|
|
if ($lineDescription === '') {
|
|
$lineDescription = $carryForwardDescription;
|
|
}
|
|
$push($dt, $lineDescription, $amount, 'additional');
|
|
continue;
|
|
}
|
|
|
|
if ($isCarryForwardInvoice) {
|
|
$push($dt, $carryForwardDescription, $amount, 'additional');
|
|
continue;
|
|
}
|
|
|
|
if (!str_contains($type, 'additional') && !str_contains($type, 'event') && !empty($studentTuitionRows)) {
|
|
$expandedTotal = 0.0;
|
|
foreach ($studentTuitionRows as $row) {
|
|
$expandedTotal += (float)$row['amount'];
|
|
$push($dt, $row['description'], (float)$row['amount'], 'registration');
|
|
}
|
|
|
|
$delta = round($amount - $expandedTotal, 2);
|
|
if (abs($delta) >= 0.01) {
|
|
$push($dt, 'Tuition charges adjustment', $delta, 'registration');
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!str_contains($type, 'additional') && !str_contains($type, 'event') && $studentTuitionRows === []) {
|
|
$fallbackStudentRows = $snapshotTuitionRows($amount);
|
|
if ($fallbackStudentRows !== []) {
|
|
foreach ($fallbackStudentRows as $row) {
|
|
$push($dt, $row['description'], (float)$row['amount'], 'registration');
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (str_contains($type, 'event') && !empty($eventRows)) {
|
|
$expandedTotal = 0.0;
|
|
foreach ($eventRows as $row) {
|
|
$expandedTotal += (float)$row['amount'];
|
|
$push($dt, $row['description'], (float)$row['amount'], 'event');
|
|
}
|
|
|
|
$delta = round($amount - $expandedTotal, 2);
|
|
if (abs($delta) >= 0.01) {
|
|
$push($dt, 'Event charges adjustment', $delta, 'event');
|
|
}
|
|
continue;
|
|
}
|
|
|
|
$push($dt, (string)($line['description'] ?? 'Invoice line'), $amount, $category);
|
|
}
|
|
|
|
if (empty($data['invoiceLines'] ?? [])) {
|
|
$fallbackDt = $toLocal($invoice['created_at'] ?? ($invoice['issue_date'] ?? null), true);
|
|
if ($isCarryForwardInvoice) {
|
|
$carryForwardAmount = (float)($ledger['total_amount'] ?? $invoice['total_amount'] ?? 0);
|
|
if (abs($carryForwardAmount) >= 0.01) {
|
|
$push($fallbackDt, $carryForwardDescription, $carryForwardAmount, 'additional');
|
|
}
|
|
} else {
|
|
if ($studentTuitionRows !== []) {
|
|
foreach ($studentTuitionRows as $row) {
|
|
$push($fallbackDt, $row['description'], (float)$row['amount'], 'registration');
|
|
}
|
|
} else {
|
|
$eventTotal = array_reduce($eventRows, static fn (float $sum, array $row): float => $sum + (float) ($row['amount'] ?? 0), 0.0);
|
|
$savedTuitionTotal = round((float)($ledger['total_amount'] ?? $invoice['total_amount'] ?? 0) - $eventTotal - $additionalChargesTotal, 2);
|
|
if (abs($savedTuitionTotal) >= 0.01) {
|
|
$fallbackStudentRows = $snapshotTuitionRows($savedTuitionTotal);
|
|
if ($fallbackStudentRows !== []) {
|
|
foreach ($fallbackStudentRows as $row) {
|
|
$push($fallbackDt, $row['description'], (float)$row['amount'], 'registration');
|
|
}
|
|
} else {
|
|
$push($fallbackDt, 'Tuition charges', $savedTuitionTotal, 'registration');
|
|
}
|
|
}
|
|
}
|
|
foreach ($eventRows as $row) {
|
|
$push($fallbackDt, $row['description'], (float)$row['amount'], 'event');
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Payments (negative) — stored in local time
|
|
foreach ($payments as $payment) {
|
|
$dt = $toLocal($payment['payment_date'] ?? null, false /* local */);
|
|
$amount = (float)($payment['paid_amount'] ?? 0.0);
|
|
$totalPaid += $amount;
|
|
$push($dt, 'Payment (' . ($payment['payment_method'] ?? 'Payment') . ')', -1 * $amount, 'payment');
|
|
}
|
|
|
|
// --- Discounts (negative) integrated into the timeline
|
|
foreach (($discounts ?? []) as $discount) {
|
|
$amt = isset($discount['applied_discount_cents']) && $discount['applied_discount_cents'] !== null
|
|
? ((int)$discount['applied_discount_cents']) / 100
|
|
: (float)($discount['discount_amount'] ?? 0.0);
|
|
$totalDiscount += $amt;
|
|
|
|
$dt = $toLocal($discount['used_at'] ?? ($invoice['created_at'] ?? null), false);
|
|
$desc = "Discount applied (Reason: {$discount['description']})";
|
|
$push($dt, $desc, -1 * $amt, 'discount');
|
|
}
|
|
|
|
// --- Refund payouts (positive) integrated into the timeline
|
|
foreach (($refundDetails ?? []) as $refund) {
|
|
$amount = (float)($refund['amount'] ?? 0.0);
|
|
if (abs($amount) < 0.00001) {
|
|
continue;
|
|
}
|
|
|
|
$dt = $toLocal($refund['date'] ?? null, true);
|
|
$method = trim((string)($refund['method'] ?? ''));
|
|
$checkNumber = trim((string)($refund['check_number'] ?? ''));
|
|
$isReversal = (string)($refund['type'] ?? 'cash_out') === 'reversal';
|
|
$desc = $isReversal ? 'Refund reversal' : 'Refund paid';
|
|
if ($method !== '') {
|
|
$desc .= ' (' . $method . ')';
|
|
}
|
|
if ($checkNumber !== '') {
|
|
$desc .= ' - Check #' . $checkNumber;
|
|
}
|
|
|
|
$push($dt, $desc, $amount, 'refund');
|
|
}
|
|
|
|
// --- Sort by exact timestamp, then by insertion sequence for stability
|
|
usort($transactions, function ($a, $b) {
|
|
// Different days: keep chronological by timestamp
|
|
$dayA = $a['dt']->format('Y-m-d');
|
|
$dayB = $b['dt']->format('Y-m-d');
|
|
if ($dayA !== $dayB) {
|
|
return $a['dt'] <=> $b['dt'];
|
|
}
|
|
|
|
// Same day: ensure desired category priority (registration before payment)
|
|
$pri = [
|
|
'registration' => 10,
|
|
'event' => 20,
|
|
'additional' => 25,
|
|
'discount' => 30,
|
|
'refund' => 40,
|
|
'payment' => 90,
|
|
'other' => 50,
|
|
];
|
|
$ca = $a['cat'] ?? 'other';
|
|
$cb = $b['cat'] ?? 'other';
|
|
$pa = $pri[$ca] ?? 50;
|
|
$pb = $pri[$cb] ?? 50;
|
|
if ($pa !== $pb) {
|
|
return $pa <=> $pb;
|
|
}
|
|
|
|
// Same category: fall back to exact timestamp, then insertion order
|
|
$cmp = $a['dt'] <=> $b['dt'];
|
|
return $cmp !== 0 ? $cmp : ($a['seq'] <=> $b['seq']);
|
|
});
|
|
|
|
// --- Render transactions (strict chronological order)
|
|
$pdf->SetFont('Arial', '', 11);
|
|
foreach ($transactions as $t) {
|
|
$pdf->Cell($dateCell, 7, $t['dt']->format('m-d-Y'), 1);
|
|
$pdf->Cell($descripCell, 7, $t['description'], 1);
|
|
$pdf->Cell($amountCell, 7, ($t['amount'] < 0 ? '-$' : '$') . number_format(abs($t['amount']), 2), 1, 1, 'R');
|
|
}
|
|
|
|
// ======== SUMMARY (bottom) ========
|
|
$ledger = $data['ledger'] ?? [];
|
|
$totalAmount = (float) ($ledger['total_amount'] ?? 0.0);
|
|
$totalDiscount = (float) ($ledger['discount_total'] ?? $totalDiscount);
|
|
$totalPaid = (float) ($ledger['paid_amount'] ?? $totalPaid);
|
|
$totalRefund = (float) ($ledger['refund_paid_total'] ?? 0.0);
|
|
$displayBalance = (float) ($ledger['balance'] ?? 0.0);
|
|
$creditOverpay = (float) ($ledger['customer_credit'] ?? 0.0);
|
|
|
|
$pdf->Ln(5);
|
|
$labelWidth = 165;
|
|
$amountWidth = 25;
|
|
|
|
$pdf->SetFont('Arial', 'B', 12);
|
|
$pdf->Cell($labelWidth, 7, 'Total Charges:', 0, 0, 'R');
|
|
$pdf->SetFont('Arial', '', 12);
|
|
$pdf->Cell($amountWidth, 7, '$' . number_format($totalAmount, 2), 0, 1, 'R');
|
|
|
|
// Show Additional Charges subtotal if any (informational)
|
|
if ($hasAdditional) {
|
|
$pdf->SetFont('Arial', 'B', 12);
|
|
$pdf->Cell($labelWidth, 7, 'Additional Charges (included):', 0, 0, 'R');
|
|
$pdf->SetFont('Arial', '', 12);
|
|
$pdf->Cell($amountWidth, 7, '$' . number_format($additionalChargesTotal, 2), 0, 1, 'R');
|
|
}
|
|
|
|
if ($totalDiscount > 0.0) {
|
|
$pdf->SetFont('Arial', 'B', 12);
|
|
$pdf->Cell($labelWidth, 7, 'Total Discount:', 0, 0, 'R');
|
|
$pdf->SetFont('Arial', '', 12);
|
|
$pdf->Cell($amountWidth, 7, '$' . number_format($totalDiscount, 2), 0, 1, 'R');
|
|
}
|
|
|
|
if ($totalRefund > 0.0) {
|
|
$pdf->SetFont('Arial', 'B', 12);
|
|
$pdf->Cell($labelWidth, 7, 'Refunds Paid:', 0, 0, 'R');
|
|
$pdf->SetFont('Arial', '', 12);
|
|
$pdf->Cell($amountWidth, 7, '$' . number_format($totalRefund, 2), 0, 1, 'R');
|
|
}
|
|
|
|
// Show credit if overpaid
|
|
if ($creditOverpay > 0.0) {
|
|
$pdf->SetFont('Arial', 'B', 12);
|
|
$pdf->Cell($labelWidth, 7, 'Credit (Overpayment):', 0, 0, 'R');
|
|
$pdf->SetFont('Arial', '', 12);
|
|
$pdf->Cell($amountWidth, 7, '$' . number_format($creditOverpay, 2), 0, 1, 'R');
|
|
}
|
|
|
|
$pdf->SetFont('Arial', 'B', 12);
|
|
$pdf->Cell($labelWidth, 7, 'Total Paid:', 0, 0, 'R');
|
|
$pdf->SetFont('Arial', '', 12);
|
|
$pdf->Cell($amountWidth, 7, '$' . number_format($totalPaid, 2), 0, 1, 'R');
|
|
|
|
$pdf->SetFont('Arial', 'B', 12);
|
|
$pdf->Cell($labelWidth, 7, 'Balance Due:', 0, 0, 'R');
|
|
$pdf->SetFont('Arial', '', 12);
|
|
$pdf->Cell($amountWidth, 7, '$' . number_format($displayBalance, 2), 0, 1, 'R');
|
|
|
|
// --- Output
|
|
header('Content-Type: application/pdf');
|
|
header('Content-Disposition: inline; filename="invoice_' . ($invoice['invoice_number'] ?? 'N/A') . '.pdf"');
|
|
header('Cache-Control: private, max-age=0, must-revalidate');
|
|
|
|
$pdf->Output('I', 'invoice_' . ($invoice['invoice_number'] ?? 'N/A') . '.pdf');
|
|
exit;
|
|
}
|
|
|
|
// --- Grade helpers ---
|
|
private function compareGrades($gradeA, $gradeB)
|
|
{
|
|
$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]
|
|
* Examples:
|
|
* - 'PK' => -1, 'K'/'Kindergarten' => 0
|
|
* - '1', '2A', 'Grade 5', 'G5' => 1,2,5
|
|
* - 'Y', 'Youth', 'Y1', 'Youth 2' => map to > 9 (10, 11, 12 ...)
|
|
*/
|
|
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];
|
|
}
|
|
// If you want, you could also handle Pre-K or Youth by number here
|
|
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];
|
|
}
|
|
|
|
private function generateErrorPdf($message)
|
|
{
|
|
// Create a new instance of FPDF
|
|
$pdf = new \FPDF();
|
|
$pdf->AddPage();
|
|
|
|
// Set title
|
|
$pdf->SetFont('Arial', 'B', 16);
|
|
$pdf->Cell(0, 10, 'Error', 0, 1, 'C'); // Add the error title centered
|
|
|
|
// Set message font and size
|
|
$pdf->SetFont('Arial', '', 12);
|
|
$pdf->Ln(10); // Add a line break
|
|
|
|
// Add the message to the PDF
|
|
$pdf->MultiCell(0, 10, $message); // Allow the message to wrap in multiple lines
|
|
|
|
// Output the PDF
|
|
$pdf->Output('I', 'error.pdf'); // Output PDF inline (browser)
|
|
}
|
|
|
|
public function invoicePayment()
|
|
{
|
|
$parentId = session()->get('user_id');
|
|
|
|
if (!$parentId) {
|
|
return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.');
|
|
}
|
|
|
|
$currentSchoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
|
$selectedYear = $currentSchoolYear;
|
|
|
|
// Fetch invoices for the selected year
|
|
$invoices = [];
|
|
if ($selectedYear) {
|
|
$invoices = $this->invoiceModel->getInvoicesByParentId($parentId, $selectedYear);
|
|
}
|
|
|
|
// Fetch refund amounts PAID for these invoices
|
|
$invoiceIds = array_column($invoices, 'id');
|
|
$refunds = [];
|
|
if (!empty($invoiceIds)) {
|
|
$refundResults = $this->db->table('refunds')
|
|
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) as refund_paid_amount')
|
|
->whereIn('invoice_id', $invoiceIds)
|
|
->whereIn('status', ['Partial','Paid'])
|
|
->groupBy('invoice_id')
|
|
->get()
|
|
->getResultArray();
|
|
|
|
foreach ($refundResults as $row) {
|
|
$refunds[$row['invoice_id']] = $row['refund_paid_amount'];
|
|
}
|
|
}
|
|
|
|
// Fetch last payment data for these invoices
|
|
$lastPayments = [];
|
|
if (!empty($invoiceIds)) {
|
|
$paymentResults = $this->paymentModel->getPaymentsByInvoice($invoiceIds);
|
|
|
|
foreach ($paymentResults as $payment) {
|
|
$lastPayments[$payment['invoice_id']] = [
|
|
'last_paid_amount' => $payment['last_paid_amount'],
|
|
'last_payment_date' => $payment['last_payment_date']
|
|
];
|
|
}
|
|
}
|
|
|
|
// Attach refund amount and last payment data to each invoice
|
|
foreach ($invoices as &$invoice) {
|
|
if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) {
|
|
$invoice['description'] = $this->invoiceLedgerService->carryForwardDisplayDescription($invoice);
|
|
}
|
|
|
|
$invoice['refund_amount'] = $refunds[$invoice['id']] ?? 0.00;
|
|
$invoice['last_paid_amount'] = $lastPayments[$invoice['id']]['last_paid_amount'] ?? 0.00;
|
|
$invoice['last_payment_date'] = $lastPayments[$invoice['id']]['last_payment_date'] ?? null;
|
|
}
|
|
unset($invoice);
|
|
|
|
$invoiceEventCharges = [];
|
|
foreach ($invoices as $invoice) {
|
|
if ($this->invoiceLedgerService->invoiceIsCarryForward($invoice)) {
|
|
continue;
|
|
}
|
|
|
|
$invoiceEventCharges[(int)$invoice['id']] = $this->eventChargesForInvoice($invoice);
|
|
}
|
|
|
|
return view('/parent/invoice_payment', [
|
|
'invoices' => $invoices,
|
|
'selectedYear' => $selectedYear,
|
|
'currentSchoolYear' => $currentSchoolYear,
|
|
'dueDate' => $this->dueDate,
|
|
'invoiceEventCharges' => $invoiceEventCharges,
|
|
]);
|
|
}
|
|
|
|
// API: Create a new invoice
|
|
public function createAPI()
|
|
{
|
|
$data = [
|
|
'parent_id' => $this->request->getPost('parent_id'),
|
|
'registeredKids' => $this->request->getPost('registeredKids'),
|
|
'invoice_number' => $this->request->getPost('invoice_number'),
|
|
'total_amount' => $this->request->getPost('total_amount'),
|
|
'refund_amount' => $this->request->getPost('refund_amount'),
|
|
'balance' => $this->request->getPost('balance'),
|
|
'school_year' => $this->request->getPost('school_year'),
|
|
'issue_date' => $this->request->getPost('issue_date'),
|
|
'refund_issue_date' => $this->request->getPost('refund_issue_date'),
|
|
'due_date' => $this->request->getPost('due_date'),
|
|
'status' => FinancialStatus::INVOICE_UNPAID,
|
|
'description' => $this->request->getPost('description'),
|
|
];
|
|
|
|
if ($this->invoiceModel->save($data)) {
|
|
return $this->respondCreated($data); // Send successful response
|
|
} else {
|
|
return $this->failValidationErrors($this->invoiceModel->errors()); // Return validation errors
|
|
}
|
|
}
|
|
|
|
// API: Get invoices by parent ID
|
|
public function getByParentAPI($parentId)
|
|
{
|
|
$invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear);
|
|
if ($invoices) {
|
|
return $this->respond($invoices); // Send the invoices in the response
|
|
} else {
|
|
return $this->failNotFound('Invoices not found for the parent.');
|
|
}
|
|
}
|
|
|
|
// View: Get invoices by parent ID (for web views)
|
|
public function getByParent($parentId)
|
|
{
|
|
$parentId = (int) $parentId;
|
|
$userId = (int) (session()->get('user_id') ?? 0);
|
|
$roles = array_map(
|
|
static fn ($role): string => strtolower(trim((string) $role)),
|
|
array_filter(array_merge((array) session()->get('roles'), [session()->get('role')]))
|
|
);
|
|
$isStaff = (bool) array_intersect($roles, [
|
|
'administrator',
|
|
'administrative staff',
|
|
'principal',
|
|
'admin',
|
|
]);
|
|
|
|
if ($userId <= 0 || (! $isStaff && $userId !== $parentId)) {
|
|
return redirect()->to('/access_denied');
|
|
}
|
|
|
|
$invoices = $this->invoiceModel->getInvoicesByUserId($parentId, $this->schoolYear);
|
|
return view('invoice_list', ['invoices' => $invoices]);
|
|
}
|
|
|
|
// View: Create a new invoice
|
|
public function create()
|
|
{
|
|
return view('invoice_create');
|
|
}
|
|
|
|
// API: Update invoice status
|
|
public function updateStatusAPI($invoiceId)
|
|
{
|
|
$invoice = $this->invoiceModel->find($invoiceId);
|
|
if (!$invoice) {
|
|
return $this->failNotFound('Invoice not found.');
|
|
}
|
|
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ''));
|
|
|
|
$status = $this->request->getPost('status');
|
|
if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) {
|
|
return $this->respond(['status' => 'success']);
|
|
} else {
|
|
return $this->failNotFound('Invoice not found.');
|
|
}
|
|
}
|
|
|
|
// View: Update invoice status
|
|
public function updateStatus($invoiceId)
|
|
{
|
|
$invoice = $this->invoiceModel->find($invoiceId);
|
|
if (!$invoice) {
|
|
return redirect()->back()->with('error', 'Invoice not found.');
|
|
}
|
|
$this->assertSchoolYearNameWritable((string)($invoice['school_year'] ?? ''));
|
|
|
|
$status = $this->request->getPost('status');
|
|
if ($this->invoiceModel->updateInvoiceStatus($invoiceId, $status)) {
|
|
return redirect()->to('/invoices');
|
|
} else {
|
|
return redirect()->back()->with('error', 'Failed to update status.');
|
|
}
|
|
}
|
|
|
|
// View: Get unpaid invoices
|
|
public function unpaidInvoices()
|
|
{
|
|
$unpaidInvoices = $this->invoiceModel->getUnpaidInvoices();
|
|
return view('unpaid_invoices', ['invoices' => $unpaidInvoices]);
|
|
}
|
|
}
|