fix financial issues
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use App\Models\DiscountVoucherModel;
|
||||
use App\Models\DiscountUsageModel;
|
||||
use App\Models\InvoiceModel;
|
||||
@@ -28,6 +29,7 @@ class DiscountController extends BaseController
|
||||
protected $eventChargesModel;
|
||||
protected $additionalChargeModel;
|
||||
protected $classSectionModel;
|
||||
protected $invoiceLedgerService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -41,6 +43,7 @@ class DiscountController extends BaseController
|
||||
$this->eventChargesModel = new EventChargesModel();
|
||||
$this->additionalChargeModel = new AdditionalChargeModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
@@ -121,6 +124,8 @@ class DiscountController extends BaseController
|
||||
foreach ($invoices as $invoice) {
|
||||
if ($remainingUses <= 0) break 2; // out of parentIds loop too
|
||||
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $invoice['id']]);
|
||||
|
||||
// Snapshot current balance BEFORE applying
|
||||
$initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
||||
if ($initialPreBalance <= 0) {
|
||||
@@ -201,22 +206,9 @@ class DiscountController extends BaseController
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Update invoice balance based on pre-discount snapshot (supports multiple discounts)
|
||||
$newBalance = max(0.0, round($initialPreBalance - $discount, 2));
|
||||
$this->db->table('invoices')
|
||||
->where('id', $invoice['id'])
|
||||
->update([
|
||||
'balance' => $newBalance,
|
||||
'has_discount' => 1,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Compute post-balance based on pre-snapshot (more stable than $invoice['balance'])
|
||||
$postBalance = round($initialPreBalance - $discount, 2);
|
||||
if ($postBalance < 0) $postBalance = 0.0;
|
||||
|
||||
// (Optional) current balance re-check (in case of concurrent writes)
|
||||
$currentBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice((int) $invoice['id']);
|
||||
$postBalance = (float) ($ledger['balance'] ?? 0.0);
|
||||
$currentBalance = $postBalance;
|
||||
|
||||
// Increment voucher usage
|
||||
$this->db->table('discount_vouchers')
|
||||
@@ -596,53 +588,11 @@ class DiscountController extends BaseController
|
||||
*/
|
||||
private function getCurrentInvoiceBalance($invoiceId, $schoolYear)
|
||||
{
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) return 0.0;
|
||||
|
||||
// Payments (exclude void/refund/failed, honor year)
|
||||
$qb = $this->paymentModel
|
||||
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
$table = $this->paymentModel->table;
|
||||
$hasStatus = $this->db->fieldExists('status', $table);
|
||||
$hasVoid = $this->db->fieldExists('is_void', $table);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
try {
|
||||
return (float) ($this->invoiceLedgerService->calculateInvoice((int) $invoiceId)['balance'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
return 0.0;
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$rowPaid = $qb->first();
|
||||
$totalPaid = (float)($rowPaid['total_paid'] ?? 0);
|
||||
|
||||
// Discounts for this invoice in this year
|
||||
$rowDisc = $this->db->table('discount_usages du')
|
||||
->select('COALESCE(SUM(du.discount_amount),0) AS total_disc')
|
||||
->join('invoices i', 'i.id = du.invoice_id')
|
||||
->where('du.invoice_id', $invoiceId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->get()->getRowArray();
|
||||
$totalDisc = (float)($rowDisc['total_disc'] ?? 0);
|
||||
|
||||
// Refunds PAID for this invoice in this year
|
||||
$rowRefund = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->get()->getRowArray();
|
||||
$totalRefundPaid = (float)($rowRefund['total_refund_paid'] ?? 0);
|
||||
|
||||
$total = (float)($invoice['total_amount'] ?? 0);
|
||||
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -650,169 +600,7 @@ class DiscountController extends BaseController
|
||||
*/
|
||||
private function recalculateInvoice($invoiceId, $schoolYear): void
|
||||
{
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) return;
|
||||
|
||||
$parentId = (int)($invoice['parent_id'] ?? 0);
|
||||
if ($parentId <= 0) return;
|
||||
|
||||
// ---- Tuition (recompute from enrollments) ----
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$registered = [];
|
||||
$withdrawn = [];
|
||||
foreach ($enrollments as $e) {
|
||||
$row = [
|
||||
'student_id' => (int)($e['student_id'] ?? 0),
|
||||
'class_section_id' => $e['class_section_id'] ?? null,
|
||||
'enrollment_status'=> (string)($e['enrollment_status'] ?? ''),
|
||||
];
|
||||
if (in_array($row['enrollment_status'], ['enrolled','payment pending'], true)) {
|
||||
$registered[] = $row;
|
||||
} elseif (in_array($row['enrollment_status'], ['withdrawn','refund pending','withdraw under review'], true)) {
|
||||
$withdrawn[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
// Refund window check – if after deadline, withdrawn still billed
|
||||
$refundDeadline = (string)($this->configModel->getConfig('refund_deadline') ?? '');
|
||||
$refundAllowed = true;
|
||||
try {
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
|
||||
$refundAllowed = $today <= $deadline;
|
||||
} catch (\Throwable $e) {
|
||||
$refundAllowed = true;
|
||||
}
|
||||
|
||||
$tuitionStudents = $registered;
|
||||
if (!$refundAllowed) {
|
||||
$tuitionStudents = array_merge($tuitionStudents, $withdrawn);
|
||||
}
|
||||
|
||||
// Grade threshold and fees
|
||||
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
|
||||
$firstStudentFee = (float)($this->configModel->getConfig('first_student_fee') ?? 350);
|
||||
$secondStudentFee = (float)($this->configModel->getConfig('second_student_fee') ?? 200);
|
||||
$youthFee = (float)($this->configModel->getConfig('youth_fee') ?? 180);
|
||||
|
||||
// Normalize grades for tuition students
|
||||
foreach ($tuitionStudents as &$s) {
|
||||
$name = null;
|
||||
if (!empty($s['class_section_id'])) {
|
||||
$name = $this->classSectionModel->getClassSectionNameBySectionId($s['class_section_id']);
|
||||
}
|
||||
$s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A';
|
||||
}
|
||||
unset($s);
|
||||
|
||||
// Count regular vs youth and compute tuition
|
||||
$regularCount = 0;
|
||||
$youthCount = 0;
|
||||
foreach ($tuitionStudents as $s) {
|
||||
$lvl = $this->parseGradeLevel($s['grade_name']);
|
||||
if ($lvl > $gradeFee) $youthCount++; else $regularCount++;
|
||||
}
|
||||
|
||||
$tuitionSubtotal = 0.0;
|
||||
$tuitionSubtotal += $youthCount * $youthFee;
|
||||
if ($regularCount >= 2) {
|
||||
$tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee;
|
||||
} elseif ($regularCount === 1) {
|
||||
$tuitionSubtotal += $firstStudentFee;
|
||||
}
|
||||
|
||||
// ---- Event charges (parent-year) ----
|
||||
$eventSubtotal = 0.0;
|
||||
try {
|
||||
$events = $this->eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear) ?? [];
|
||||
foreach ($events as $ev) { $eventSubtotal += (float)($ev['charged'] ?? 0.0); }
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// ---- Additional charges (per-invoice) ----
|
||||
$additionalSubtotal = 0.0;
|
||||
try {
|
||||
$rows = $this->additionalChargeModel
|
||||
->select('charge_type, amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('status', 'applied')
|
||||
->findAll();
|
||||
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);
|
||||
$additionalSubtotal += $amt;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
$discountableTotal = $tuitionSubtotal + $additionalSubtotal;
|
||||
$nonDiscountableTotal = $eventSubtotal;
|
||||
$newTotal = round($discountableTotal + $nonDiscountableTotal, 2);
|
||||
|
||||
// ---- Payments / Discounts / Refunds ----
|
||||
$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('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
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();
|
||||
$totalPaid = 0.0;
|
||||
foreach ($payments as $p) { $totalPaid += (float)($p['paid_amount'] ?? 0); }
|
||||
|
||||
$discRow = $this->db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->get()->getRowArray();
|
||||
$totalDisc = (float)($discRow['total_disc'] ?? 0);
|
||||
|
||||
$refundRow = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
|
||||
|
||||
$appliedDiscount = min($totalDisc, $discountableTotal);
|
||||
$newBalance = max(0.0, $newTotal - $appliedDiscount - $totalPaid - $totalRefundPaid);
|
||||
$newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
$updateData = [
|
||||
'total_amount' => $newTotal,
|
||||
'paid_amount' => $totalPaid,
|
||||
'balance' => $newBalance,
|
||||
'status' => $newStatus,
|
||||
'has_discount' => ($totalDisc > 0.0) ? 1 : 0,
|
||||
];
|
||||
|
||||
if ($this->db->fieldExists('discount', $this->invoiceModel->table)) {
|
||||
$updateData['discount'] = $totalDisc;
|
||||
}
|
||||
|
||||
$this->invoiceModel->update($invoiceId, $updateData);
|
||||
$this->invoiceLedgerService->recalculateInvoice((int) $invoiceId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use CodeIgniter\Controller;
|
||||
use App\Models\UserModel;
|
||||
@@ -28,6 +30,7 @@ class ExtraChargesController extends BaseController
|
||||
protected $studentClassModel;
|
||||
protected $enableAttendance;
|
||||
protected $attendanceDayModel;
|
||||
protected $invoiceLedgerService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -39,6 +42,7 @@ class ExtraChargesController extends BaseController
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -247,13 +251,9 @@ class ExtraChargesController extends BaseController
|
||||
// keep status as-is
|
||||
]);
|
||||
|
||||
// If it’s already applied on an invoice and the amount changed, reflect the delta
|
||||
if ($row['status'] === 'applied' && !empty($row['invoice_id']) && abs($delta) > 0.00001) {
|
||||
if ($delta > 0) {
|
||||
$this->invoiceModel->applyAdditionalCharge((int)$row['invoice_id'], $delta);
|
||||
} else {
|
||||
$this->invoiceModel->reverseAdditionalCharge((int)$row['invoice_id'], -$delta);
|
||||
}
|
||||
if (($row['status'] ?? '') === FinancialStatus::ADDITIONAL_CHARGE_APPLIED && !empty($row['invoice_id'])) {
|
||||
$db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $row['invoice_id']]);
|
||||
$this->invoiceLedgerService->recalculateInvoice((int) $row['invoice_id']);
|
||||
}
|
||||
|
||||
$db->transComplete();
|
||||
@@ -343,7 +343,7 @@ class ExtraChargesController extends BaseController
|
||||
'description' => trim($data['description'] ?? ''),
|
||||
'amount' => $signedAmount,
|
||||
'due_date' => !empty($data['due_date']) ? $data['due_date'] : null,
|
||||
'status' => $invoiceId ? 'applied' : 'pending',
|
||||
'status' => $invoiceId ? FinancialStatus::ADDITIONAL_CHARGE_APPLIED : FinancialStatus::ADDITIONAL_CHARGE_PENDING,
|
||||
'created_by' => (int)(session()->get('user_id') ?? 0),
|
||||
'created_at' => \CodeIgniter\I18n\Time::now('UTC')->toDateTimeString(), // store UTC
|
||||
];
|
||||
@@ -357,17 +357,9 @@ class ExtraChargesController extends BaseController
|
||||
$this->additionalChargeModel->insert($payload);
|
||||
$chargeId = (int)$this->additionalChargeModel->getInsertID();
|
||||
|
||||
// Apply to invoice if present
|
||||
if ($invoiceId) {
|
||||
try {
|
||||
if ($chargeType === 'add') {
|
||||
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
$this->invoiceModel->deductAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'apply/deductAdditionalCharge failed: ' . $e->getMessage());
|
||||
}
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
}
|
||||
|
||||
// AFTER
|
||||
@@ -470,23 +462,15 @@ class ExtraChargesController extends BaseController
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
if ($status === 'applied' && $invoiceId > 0 && $amountAbs > 0) {
|
||||
try {
|
||||
if ($chargeType === 'add') {
|
||||
$this->invoiceModel->reverseAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
// voiding a deduction -> add back
|
||||
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'void(): invoice adjust failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->additionalChargeModel->update((int)$id, [
|
||||
'status' => 'void',
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_VOIDED,
|
||||
]);
|
||||
|
||||
if ($status === FinancialStatus::ADDITIONAL_CHARGE_APPLIED && $invoiceId > 0) {
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
@@ -514,26 +498,19 @@ class ExtraChargesController extends BaseController
|
||||
$chargeType = (string)($row['charge_type'] ?? 'add');
|
||||
$status = (string)($row['status'] ?? 'pending');
|
||||
|
||||
if ($status !== 'applied' || $invoiceId <= 0 || $amountAbs <= 0) {
|
||||
if ($status !== FinancialStatus::ADDITIONAL_CHARGE_APPLIED || $invoiceId <= 0 || $amountAbs <= 0) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Nothing to reverse']);
|
||||
return redirect()->back()->with('error', 'Nothing to reverse.');
|
||||
}
|
||||
|
||||
$this->db->transStart();
|
||||
try {
|
||||
if ($chargeType === 'add') {
|
||||
$this->invoiceModel->reverseAdditionalCharge($invoiceId, $amountAbs);
|
||||
} else {
|
||||
$this->invoiceModel->applyAdditionalCharge($invoiceId, $amountAbs);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'reverse(): invoice adjust failed: ' . $e->getMessage());
|
||||
}
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
|
||||
|
||||
$this->additionalChargeModel->update((int)$id, [
|
||||
'status' => 'pending',
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_PENDING,
|
||||
'invoice_id' => null,
|
||||
]);
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Controllers\View;
|
||||
|
||||
use CodeIgniter\Controller;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
use Config\Database;
|
||||
|
||||
class FilesController extends Controller
|
||||
{
|
||||
@@ -22,7 +21,11 @@ class FilesController extends Controller
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 3) Build path under writable
|
||||
$expense = $this->expenseRecordForFile($name);
|
||||
if ($expense === null || !$this->canViewExpenseFile($expense)) {
|
||||
return $this->response->setStatusCode(403, 'You are not allowed to access this file.');
|
||||
}
|
||||
|
||||
$path = WRITEPATH . 'uploads/receipts/' . $name;
|
||||
if (!is_file($path)) {
|
||||
throw PageNotFoundException::forPageNotFound();
|
||||
@@ -79,7 +82,11 @@ class FilesController extends Controller
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
}
|
||||
|
||||
// 3) Build path under writable (REIMBURSEMENTS)
|
||||
$reimbursement = $this->reimbursementRecordForFile($name);
|
||||
if ($reimbursement === null || !$this->canViewReimbursementFile($reimbursement)) {
|
||||
return $this->response->setStatusCode(403, 'You are not allowed to access this file.');
|
||||
}
|
||||
|
||||
$path = WRITEPATH . 'uploads/reimbursements/' . $name;
|
||||
if (!is_file($path)) {
|
||||
throw \CodeIgniter\Exceptions\PageNotFoundException::forPageNotFound();
|
||||
@@ -425,4 +432,69 @@ class FilesController extends Controller
|
||||
|
||||
return $draftSemester === '' || $currentSemester === '' || $draftSemester === $currentSemester;
|
||||
}
|
||||
|
||||
private function expenseRecordForFile(string $name): ?array
|
||||
{
|
||||
return \Config\Database::connect()
|
||||
->table('expenses')
|
||||
->where('receipt_path', $name)
|
||||
->get()
|
||||
->getRowArray();
|
||||
}
|
||||
|
||||
private function reimbursementRecordForFile(string $name): ?array
|
||||
{
|
||||
return \Config\Database::connect()
|
||||
->table('reimbursements')
|
||||
->where('receipt_path', $name)
|
||||
->get()
|
||||
->getRowArray();
|
||||
}
|
||||
|
||||
private function canViewExpenseFile(array $expense): bool
|
||||
{
|
||||
if ($this->hasFinancialStaffAccess()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
return $userId > 0 && in_array($userId, [
|
||||
(int) ($expense['purchased_by'] ?? 0),
|
||||
(int) ($expense['added_by'] ?? 0),
|
||||
(int) ($expense['approved_by'] ?? 0),
|
||||
], true);
|
||||
}
|
||||
|
||||
private function canViewReimbursementFile(array $reimbursement): bool
|
||||
{
|
||||
if ($this->hasFinancialStaffAccess()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
return $userId > 0 && in_array($userId, [
|
||||
(int) ($reimbursement['reimbursed_to'] ?? 0),
|
||||
(int) ($reimbursement['approved_by'] ?? 0),
|
||||
(int) ($reimbursement['added_by'] ?? 0),
|
||||
], true);
|
||||
}
|
||||
|
||||
private function hasFinancialStaffAccess(): bool
|
||||
{
|
||||
$roles = array_map('strtolower', (array) (session()->get('roles') ?? []));
|
||||
$activeRole = strtolower((string) (session()->get('role') ?? ''));
|
||||
if ($activeRole !== '' && !in_array($activeRole, $roles, true)) {
|
||||
$roles[] = $activeRole;
|
||||
}
|
||||
|
||||
foreach (['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant'] as $role) {
|
||||
if (in_array($role, $roles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -701,8 +701,7 @@ public function financialReport()
|
||||
]
|
||||
];
|
||||
|
||||
$url = 'https://quickchart.io/chart?c=' . urlencode(json_encode($chartData));
|
||||
file_put_contents(WRITEPATH . 'reports/bar_chart.png', file_get_contents($url));
|
||||
$this->writeChartImage('bar_chart.png', $chartData);
|
||||
}
|
||||
|
||||
private function generatePieChart(array $data)
|
||||
@@ -721,8 +720,28 @@ public function financialReport()
|
||||
]
|
||||
];
|
||||
|
||||
$this->writeChartImage('pie_chart.png', $chartData);
|
||||
}
|
||||
|
||||
private function writeChartImage(string $filename, array $chartData): void
|
||||
{
|
||||
$reportDir = WRITEPATH . 'reports';
|
||||
if (!is_dir($reportDir) && !mkdir($reportDir, 0775, true) && !is_dir($reportDir)) {
|
||||
log_message('error', 'Unable to create financial report chart directory: {dir}', ['dir' => $reportDir]);
|
||||
return;
|
||||
}
|
||||
|
||||
$url = 'https://quickchart.io/chart?c=' . urlencode(json_encode($chartData));
|
||||
file_put_contents(WRITEPATH . 'reports/pie_chart.png', file_get_contents($url));
|
||||
$image = @file_get_contents($url);
|
||||
if ($image === false || $image === '') {
|
||||
log_message('warning', 'Unable to download financial report chart from QuickChart.');
|
||||
return;
|
||||
}
|
||||
|
||||
$path = $reportDir . DIRECTORY_SEPARATOR . $filename;
|
||||
if (@file_put_contents($path, $image) === false) {
|
||||
log_message('error', 'Unable to write financial report chart image: {path}', ['path' => $path]);
|
||||
}
|
||||
}
|
||||
|
||||
private function getFinancialSummary(?string $dateFrom = null, ?string $dateTo = null, ?string $schoolYear = null): array
|
||||
|
||||
@@ -2357,6 +2357,7 @@ public function belowSixty()
|
||||
's.id AS student_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.age',
|
||||
's.school_id',
|
||||
'cs.class_section_name',
|
||||
'LOWER(TRIM(ss.semester)) AS sem_key',
|
||||
@@ -2389,6 +2390,7 @@ public function belowSixty()
|
||||
'school_id' => $row['school_id'] ?? '',
|
||||
'firstname' => $row['firstname'] ?? '',
|
||||
'lastname' => $row['lastname'] ?? '',
|
||||
'age' => $row['age'] ?? null,
|
||||
'class_section_name' => $row['class_section_name'] ?? '',
|
||||
'fall_score' => null,
|
||||
'spring_score' => null,
|
||||
@@ -3397,6 +3399,8 @@ public function allDecisions()
|
||||
's.school_id',
|
||||
's.firstname',
|
||||
's.lastname',
|
||||
's.gender',
|
||||
'ss.class_section_id',
|
||||
'cs.class_section_name',
|
||||
'LOWER(TRIM(ss.semester)) AS sem_key',
|
||||
'ss.semester_score',
|
||||
@@ -3428,6 +3432,8 @@ public function allDecisions()
|
||||
'school_id' => $sr['school_id'] ?? '',
|
||||
'firstname' => $sr['firstname'] ?? '',
|
||||
'lastname' => $sr['lastname'] ?? '',
|
||||
'gender' => $sr['gender'] ?? '',
|
||||
'class_section_id' => (int)($sr['class_section_id'] ?? 0),
|
||||
'class_section_name' => $sr['class_section_name'] ?? '',
|
||||
'fall_score' => null,
|
||||
'spring_score' => null,
|
||||
@@ -3513,6 +3519,8 @@ public function allDecisions()
|
||||
'school_id' => $info['school_id'],
|
||||
'firstname' => $info['firstname'],
|
||||
'lastname' => $info['lastname'],
|
||||
'gender' => $info['gender'] ?? '',
|
||||
'class_section_id' => (int)($info['class_section_id'] ?? 0),
|
||||
'class_section_name' => $info['class_section_name'],
|
||||
'fall_score' => $fall,
|
||||
'spring_score' => $spring,
|
||||
@@ -3521,9 +3529,47 @@ public function allDecisions()
|
||||
'source' => $source,
|
||||
'notes' => $notes,
|
||||
'saved' => isset($savedMap[$sid]),
|
||||
'is_trophy' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$rowsByClass = [];
|
||||
|
||||
foreach ($rows as $index => $row) {
|
||||
$classSectionId = (int)($row['class_section_id'] ?? 0);
|
||||
|
||||
if ($classSectionId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rowsByClass[$classSectionId][] = $index;
|
||||
}
|
||||
|
||||
foreach ($rowsByClass as $classIndexes) {
|
||||
$scores = [];
|
||||
|
||||
foreach ($classIndexes as $rowIndex) {
|
||||
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
|
||||
|
||||
if (is_numeric($yearScore)) {
|
||||
$scores[] = (float)$yearScore;
|
||||
}
|
||||
}
|
||||
|
||||
$thresholdInfo = $this->calculateTrophyThreshold($scores, 75.0);
|
||||
$threshold = $thresholdInfo['threshold'];
|
||||
|
||||
if ($threshold === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($classIndexes as $rowIndex) {
|
||||
$yearScore = $rows[$rowIndex]['year_score'] ?? null;
|
||||
|
||||
$rows[$rowIndex]['is_trophy'] = is_numeric($yearScore) && (float)$yearScore >= $threshold;
|
||||
}
|
||||
}
|
||||
|
||||
$generated = !empty($saved);
|
||||
|
||||
return view('grading/all_decisions', [
|
||||
@@ -3696,6 +3742,108 @@ public function generateAllDecisions()
|
||||
->with('status', "Decisions generated for {$savedCount} students.");
|
||||
}
|
||||
|
||||
private function calculateTrophyThreshold(array $scores, float $percentile = 75.0): array
|
||||
{
|
||||
$scores = array_values(array_filter(
|
||||
$scores,
|
||||
static fn ($value): bool => is_numeric($value) && $value !== null
|
||||
));
|
||||
$scores = array_map('floatval', $scores);
|
||||
sort($scores);
|
||||
|
||||
$count = count($scores);
|
||||
|
||||
if ($count === 0) {
|
||||
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
|
||||
}
|
||||
|
||||
$minWinners = 3;
|
||||
$maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100)));
|
||||
|
||||
$threshold = $this->empiricalTrophyPercentile($scores, $percentile);
|
||||
$winners = $this->countScoresAtOrAbove($scores, $threshold);
|
||||
|
||||
if ($winners < $minWinners) {
|
||||
$target = min($minWinners, $count);
|
||||
$descending = array_reverse($scores);
|
||||
$threshold = $descending[$target - 1];
|
||||
$winners = $this->countScoresAtOrAbove($scores, $threshold);
|
||||
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
|
||||
}
|
||||
|
||||
if ($winners <= $maxWinners) {
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
|
||||
}
|
||||
|
||||
$result = $this->capTrophyThresholdByRank($scores, $maxWinners);
|
||||
|
||||
if ($result['winners'] < $minWinners) {
|
||||
$target = min($minWinners, $count);
|
||||
$descending = array_reverse($scores);
|
||||
$threshold = $descending[$target - 1];
|
||||
$winners = $this->countScoresAtOrAbove($scores, $threshold);
|
||||
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function capTrophyThresholdByRank(array $sortedScores, int $max): array
|
||||
{
|
||||
$descending = array_reverse($sortedScores);
|
||||
$threshold = $descending[$max - 1];
|
||||
$winners = $this->countScoresAtOrAbove($sortedScores, $threshold);
|
||||
|
||||
if ($winners <= $max) {
|
||||
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
|
||||
}
|
||||
|
||||
$uniqueHigherScores = array_values(array_unique(array_filter(
|
||||
$sortedScores,
|
||||
static fn ($score): bool => $score > $threshold
|
||||
)));
|
||||
sort($uniqueHigherScores);
|
||||
|
||||
foreach ($uniqueHigherScores as $candidate) {
|
||||
$winnerCount = $this->countScoresAtOrAbove($sortedScores, $candidate);
|
||||
|
||||
if ($winnerCount <= $max) {
|
||||
return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
|
||||
}
|
||||
|
||||
private function empiricalTrophyPercentile(array $sortedScores, float $percentile): float
|
||||
{
|
||||
$count = count($sortedScores);
|
||||
|
||||
if ($count === 0) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$index = ($percentile / 100.0) * ($count - 1);
|
||||
$lower = (int) floor($index);
|
||||
$upper = (int) ceil($index);
|
||||
|
||||
if ($lower === $upper) {
|
||||
return $sortedScores[$lower];
|
||||
}
|
||||
|
||||
return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]);
|
||||
}
|
||||
|
||||
private function countScoresAtOrAbove(array $scores, float $threshold): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$scores,
|
||||
static fn ($score): bool => $score >= $threshold
|
||||
));
|
||||
}
|
||||
|
||||
public function getScoreComment()
|
||||
{
|
||||
// Get all students for the current semester and school year
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Libraries\FinancialAttachmentService;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
@@ -16,23 +19,14 @@ use App\Models\PaymentErrorModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\TeacherClassModel;
|
||||
use App\Models\DiscountUsageModel;
|
||||
use Config\PaypalConfig;
|
||||
use PayPal\Api\Amount;
|
||||
use PayPal\Api\Payment;
|
||||
use PayPal\Api\PaymentExecution;
|
||||
use PayPal\Api\Payer;
|
||||
use PayPal\Api\Transaction;
|
||||
use PayPal\Rest\ApiContext;
|
||||
use PayPal\Auth\OAuthTokenCredential;
|
||||
|
||||
use CodeIgniter\RESTful\ResourceController;
|
||||
use CodeIgniter\Events\Events;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
|
||||
class PaymentController extends ResourceController
|
||||
{
|
||||
protected $paymentModel;
|
||||
protected $paypalConfig;
|
||||
protected $apiContext;
|
||||
protected $request;
|
||||
protected $db;
|
||||
protected $invoiceModel;
|
||||
@@ -51,6 +45,8 @@ class PaymentController extends ResourceController
|
||||
protected $discountUsageModel;
|
||||
protected $additionalChargeModel;
|
||||
protected $classSectionModel;
|
||||
protected $invoiceLedgerService;
|
||||
protected $financialAttachmentService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -60,7 +56,6 @@ class PaymentController extends ResourceController
|
||||
$this->eventChargesModel = new EventChargesModel();
|
||||
$this->manualPaymentModel = new ManualPaymentModel();
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->paypalConfig = new PaypalConfig();
|
||||
$this->request = \Config\Services::request();
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
@@ -73,20 +68,9 @@ class PaymentController extends ResourceController
|
||||
$this->installmentDate = $this->configModel->getConfig('installment_date');
|
||||
$this->discountUsageModel = new DiscountUsageModel();
|
||||
$this->additionalChargeModel = new AdditionalChargeModel();
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
|
||||
// Set up PayPal API context
|
||||
$this->apiContext = new ApiContext(
|
||||
new OAuthTokenCredential(
|
||||
$this->paypalConfig->paypalClientId,
|
||||
$this->paypalConfig->paypalSecret
|
||||
)
|
||||
);
|
||||
|
||||
$this->apiContext->setConfig([
|
||||
'mode' => $this->paypalConfig->paypalMode, // 'sandbox' or 'live'
|
||||
'http.headers' => ['Connection' => 'Close']
|
||||
]);
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
$this->financialAttachmentService = new FinancialAttachmentService();
|
||||
}
|
||||
|
||||
// API: Create a new payment plan
|
||||
@@ -185,135 +169,11 @@ class PaymentController extends ResourceController
|
||||
}
|
||||
|
||||
|
||||
// Create a PayPal payment
|
||||
public function createPaypalPayment($paymentId)
|
||||
{
|
||||
// Fetch the payment details from the database
|
||||
$payment = $this->paymentModel->find($paymentId);
|
||||
|
||||
// Create the payer object (who is making the payment)
|
||||
$payer = new Payer();
|
||||
$payer->setPaymentMethod('paypal');
|
||||
|
||||
// Set up the payment amount
|
||||
$amount = new Amount();
|
||||
$amount->setCurrency('USD')
|
||||
->setTotal($payment['balance_amount']); // The balance amount to be paid
|
||||
|
||||
// Set up the transaction details
|
||||
$transaction = new Transaction();
|
||||
$transaction->setAmount($amount)
|
||||
->setDescription('Payment for school fees')
|
||||
->setInvoiceNumber(uniqid());
|
||||
|
||||
// Create the payment and set the redirect URLs
|
||||
$payment = new Payment();
|
||||
$payment->setIntent('sale')
|
||||
->setPayer($payer)
|
||||
->setTransactions([$transaction]);
|
||||
|
||||
// Set the approval URL for the payment
|
||||
$redirectUrls = new \PayPal\Api\RedirectUrls();
|
||||
$redirectUrls->setReturnUrl(base_url('payments/executePaypalPayment')) // Set this to where the user will be redirected after approval
|
||||
->setCancelUrl(base_url('payments/cancelPaypalPayment'));
|
||||
|
||||
$payment->setRedirectUrls($redirectUrls);
|
||||
|
||||
// Create the payment and get the approval URL
|
||||
try {
|
||||
$payment->create($this->apiContext);
|
||||
// Store the payment ID in the session to retrieve it later
|
||||
session()->set('paypalPaymentId', $payment->getId());
|
||||
session()->set('paymentId', $paymentId);
|
||||
$approvalUrl = $payment->getApprovalLink();
|
||||
|
||||
return redirect()->to($approvalUrl); // Redirect the user to PayPal's approval page
|
||||
} catch (\PayPal\Exception\PayPalConnectionException $ex) {
|
||||
// Handle errors
|
||||
echo $ex->getData();
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute the PayPal payment after user approval
|
||||
public function executePaypalPayment()
|
||||
{
|
||||
// Get the payment ID and Payer ID from the request
|
||||
$paymentId = session()->get('paypalPaymentId');
|
||||
$payerId = $this->request->getGet('PayerID');
|
||||
|
||||
// Get the payment object using the payment ID
|
||||
$payment = Payment::get($paymentId, $this->apiContext);
|
||||
|
||||
// Create an execution object to execute the payment
|
||||
$execution = new PaymentExecution();
|
||||
$execution->setPayerId($payerId);
|
||||
|
||||
// Execute the payment
|
||||
try {
|
||||
$result = $payment->execute($execution, $this->apiContext);
|
||||
|
||||
// Payment successful, update the payment status in the database
|
||||
$paymentId = session()->get('paymentId');
|
||||
$this->paymentModel->update($paymentId, ['status' => 'Completed']);
|
||||
|
||||
return redirect()->to('/payments'); // Redirect to the payments page after success
|
||||
} catch (\PayPal\Exception\PayPalConnectionException $ex) {
|
||||
// Handle payment execution failure
|
||||
echo $ex->getData();
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel the PayPal payment
|
||||
public function cancelPaypalPayment()
|
||||
{
|
||||
// Payment was canceled by the user
|
||||
return redirect()->to('/payments')->with('error', 'Payment was canceled.');
|
||||
}
|
||||
|
||||
|
||||
public function redirectPage()
|
||||
{
|
||||
$modeCheck = $this->configModel->getConfig('paypal_mode');
|
||||
|
||||
$parentId = session()->get('user_id'); // assuming this is the logged-in parent
|
||||
|
||||
// Get parent name and school ID
|
||||
$parent = $this->db->table('users')
|
||||
->select('firstname, lastname, school_id')
|
||||
->where('id', $parentId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$parentName = isset($parent['firstname'], $parent['lastname'])
|
||||
? $parent['firstname'] . ' ' . $parent['lastname']
|
||||
: 'Parent';
|
||||
|
||||
$schoolId = $parent['school_id'] ?? null;
|
||||
|
||||
// Get latest invoice total_amount
|
||||
$latestInvoice = $this->invoiceModel->where('parent_id', $parentId)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->select('total_amount')
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$totalAmount = $latestInvoice['total_amount'] ?? 0;
|
||||
|
||||
return view('payment/payment_redirect', [
|
||||
'parentName' => $parentName,
|
||||
'totalAmount' => $totalAmount,
|
||||
'schoolId' => $schoolId,
|
||||
'modeCheck' => $modeCheck,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
public function paypal()
|
||||
{
|
||||
// Redirect to PayPal API or show instructions
|
||||
return redirect()->to('https://www.paypal.com/ncp/payment/87FJL3EV8C7NE');
|
||||
return redirect()
|
||||
->to(site_url('parent/invoice_payment'))
|
||||
->with('info', 'Online payment is currently unavailable. Please contact the school office to complete payment.');
|
||||
}
|
||||
|
||||
public function manual()
|
||||
@@ -329,11 +189,7 @@ class PaymentController extends ResourceController
|
||||
|
||||
// Handle file upload if present
|
||||
$proof = $this->request->getFile('proof');
|
||||
if ($proof && $proof->isValid() && !$proof->hasMoved()) {
|
||||
$filename = $proof->getRandomName();
|
||||
$proof->move(WRITEPATH . 'uploads/payments/', $filename);
|
||||
$data['proof_path'] = $filename;
|
||||
}
|
||||
$data['proof_path'] = $this->financialAttachmentService->saveUploadedFile($proof, 'payments');
|
||||
|
||||
$this->manualPaymentModel->insert($data);
|
||||
|
||||
@@ -1057,26 +913,13 @@ class PaymentController extends ResourceController
|
||||
// Optional receipt upload
|
||||
$checkFile = null;
|
||||
$paymentFile = $this->request->getFile('payment_file');
|
||||
if ($paymentFile && $paymentFile->isValid() && !$paymentFile->hasMoved()) {
|
||||
$okTypes = ['image/jpeg', 'image/png', 'application/pdf'];
|
||||
$okExts = ['jpg', 'jpeg', 'png', 'pdf'];
|
||||
|
||||
$mime = $paymentFile->getMimeType();
|
||||
$ext = strtolower($paymentFile->getClientExtension());
|
||||
|
||||
if (!in_array($mime, $okTypes, true) || !in_array($ext, $okExts, true)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Unsupported file type. Use JPG, PNG, or PDF.');
|
||||
}
|
||||
if ($paymentFile->getSize() > 5 * 1024 * 1024) {
|
||||
return redirect()->back()->withInput()->with('error', 'File too large. Max 5MB.');
|
||||
}
|
||||
|
||||
$fileName = $paymentFile->getRandomName();
|
||||
$subdir = ($paymentMethod === 'check') ? 'checks' : (($paymentMethod === 'card') ? 'cards' : 'misc');
|
||||
$targetDir = WRITEPATH . 'uploads/' . $subdir . '/';
|
||||
if (!is_dir($targetDir)) @mkdir($targetDir, 0775, true);
|
||||
$paymentFile->move($targetDir, $fileName);
|
||||
$checkFile = $fileName;
|
||||
try {
|
||||
$checkFile = $this->financialAttachmentService->saveUploadedFile(
|
||||
$paymentFile,
|
||||
$paymentMethod === 'check' ? 'checks' : ($paymentMethod === 'card' ? 'cards' : 'misc')
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
$this->db->transBegin();
|
||||
|
||||
@@ -1096,10 +939,7 @@ class PaymentController extends ResourceController
|
||||
$invYear = (string)($row['school_year'] ?? $this->schoolYear);
|
||||
|
||||
// Recompute invoice totals from tuition + events + additional charges
|
||||
$this->recalculateInvoice($invoiceId, $invYear);
|
||||
|
||||
// Authoritative balance
|
||||
$currentBalance = $this->getCurrentInvoiceBalance($invoiceId);
|
||||
$currentBalance = (float) $this->invoiceLedgerService->recalculateInvoice($invoiceId)['balance'];
|
||||
|
||||
if ($amount > $currentBalance + 0.00001) {
|
||||
$this->db->transRollback();
|
||||
@@ -1135,7 +975,9 @@ class PaymentController extends ResourceController
|
||||
$this->schoolYear,
|
||||
$this->semester,
|
||||
$checkNumber,
|
||||
$installmentSeq
|
||||
$installmentSeq,
|
||||
(array) $this->invoiceModel->find($invoiceId),
|
||||
$currentBalance
|
||||
);
|
||||
|
||||
if (!$ok) {
|
||||
@@ -1144,11 +986,10 @@ class PaymentController extends ResourceController
|
||||
}
|
||||
|
||||
// Ensure invoice totals/balance reflect discounts and this payment
|
||||
$this->recalculateInvoice($invoiceId, $this->schoolYear);
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
|
||||
// Post-payment balance from snapshot
|
||||
$postBalance = (float)round($initialPreBalance - $amount, 2);
|
||||
if ($postBalance < 0) $postBalance = 0.0;
|
||||
$postBalance = (float) ($ledger['balance'] ?? max(0.0, round($initialPreBalance - $amount, 2)));
|
||||
|
||||
// Optional enrollment update
|
||||
$enrollmentupdated = $this->updateEnrollmentStatusIfPaid($invoiceId);
|
||||
@@ -1235,20 +1076,21 @@ class PaymentController extends ResourceController
|
||||
$checkFile = $payment['check_file']; // default keep old file
|
||||
|
||||
// Handle optional check or card payment receipt upload
|
||||
if (strtolower($paymentMethod) === 'check') {
|
||||
$paymentFile = $this->request->getFile('payment_file'); // ✅ Updated to payment_file
|
||||
if ($paymentFile && $paymentFile->isValid() && !$paymentFile->hasMoved()) {
|
||||
$fileName = $paymentFile->getRandomName();
|
||||
$paymentFile->move(WRITEPATH . 'uploads/checks/', $fileName);
|
||||
$checkFile = $fileName;
|
||||
}
|
||||
} elseif (strtolower($paymentMethod) === 'card') {
|
||||
$paymentFile = $this->request->getFile('payment_file'); // ✅ Updated to payment_file
|
||||
if ($paymentFile && $paymentFile->isValid() && !$paymentFile->hasMoved()) {
|
||||
$fileName = $paymentFile->getRandomName();
|
||||
$paymentFile->move(WRITEPATH . 'uploads/cards/', $fileName);
|
||||
$checkFile = $fileName; // reuse for compatibility
|
||||
try {
|
||||
$paymentFile = $this->request->getFile('payment_file');
|
||||
if (strtolower($paymentMethod) === 'check') {
|
||||
$uploaded = $this->financialAttachmentService->saveUploadedFile($paymentFile, 'checks');
|
||||
if ($uploaded !== null) {
|
||||
$checkFile = $uploaded;
|
||||
}
|
||||
} elseif (strtolower($paymentMethod) === 'card') {
|
||||
$uploaded = $this->financialAttachmentService->saveUploadedFile($paymentFile, 'cards');
|
||||
if ($uploaded !== null) {
|
||||
$checkFile = $uploaded;
|
||||
}
|
||||
}
|
||||
} catch (\RuntimeException $e) {
|
||||
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
// ❌ Validate amount - negative or zero
|
||||
@@ -1259,41 +1101,51 @@ class PaymentController extends ResourceController
|
||||
);
|
||||
}
|
||||
|
||||
// 🔄 Recalculate invoice first to ensure totals reflect tuition + events + additional
|
||||
$this->recalculateInvoice($payment['invoice_id'], $this->schoolYear);
|
||||
$this->db->transBegin();
|
||||
|
||||
// 🔄 Get current balance based on actual payments (with school_year filter)
|
||||
// We need to calculate what the balance would be WITHOUT the current payment being edited
|
||||
$currentBalance = $this->getCurrentInvoiceBalanceExcludingPayment($payment['invoice_id'], $paymentId);
|
||||
try {
|
||||
$invoiceId = (int) ($payment['invoice_id'] ?? 0);
|
||||
$lockedInvoice = $this->db->query(
|
||||
'SELECT id FROM invoices WHERE id = ? FOR UPDATE',
|
||||
[$invoiceId]
|
||||
)->getRowArray();
|
||||
|
||||
if ($paidAmount > $currentBalance) {
|
||||
return redirect()->back()->with(
|
||||
'error',
|
||||
'Entered amount (' . number_format($paidAmount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').'
|
||||
);
|
||||
if (!$lockedInvoice) {
|
||||
$this->db->transRollback();
|
||||
return redirect()->back()->with('error', 'Linked invoice not found.');
|
||||
}
|
||||
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
$currentBalance = $this->getCurrentInvoiceBalanceExcludingPayment($invoiceId, $paymentId);
|
||||
|
||||
if ($paidAmount > $currentBalance + 0.00001) {
|
||||
$this->db->transRollback();
|
||||
return redirect()->back()->with(
|
||||
'error',
|
||||
'Entered amount (' . number_format($paidAmount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').'
|
||||
);
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'paid_amount' => $paidAmount,
|
||||
'payment_method' => strtolower($paymentMethod),
|
||||
'check_file' => $checkFile,
|
||||
'check_number' => strtolower($paymentMethod) === 'check' ? $checkNumber : null,
|
||||
'balance' => max(0.0, round($currentBalance - $paidAmount, 2)),
|
||||
'status' => FinancialStatus::PAYMENT_RECORDED,
|
||||
'updated_by' => session()->get('user_id'),
|
||||
];
|
||||
|
||||
$this->paymentModel->update($paymentId, $updateData);
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
$this->db->transCommit();
|
||||
|
||||
return redirect()->back()->with('success', 'Payment updated successfully.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', '[manualPayEdit] ' . $e->getMessage());
|
||||
return redirect()->back()->withInput()->with('error', 'Unexpected error while updating payment.');
|
||||
}
|
||||
|
||||
// ✅ Update edited payment with check_number
|
||||
$updateData = [
|
||||
'paid_amount' => $paidAmount,
|
||||
'payment_method' => strtolower($paymentMethod),
|
||||
'check_file' => $checkFile,
|
||||
'updated_by' => session()->get('user_id')
|
||||
];
|
||||
|
||||
// ✅ Only add check_number if payment method is check
|
||||
if (strtolower($paymentMethod) === 'check') {
|
||||
$updateData['check_number'] = $checkNumber;
|
||||
} else {
|
||||
$updateData['check_number'] = null; // Clear check number for non-check payments
|
||||
}
|
||||
|
||||
$this->paymentModel->update($paymentId, $updateData);
|
||||
|
||||
// 🔄 Always recalc invoice after change
|
||||
$this->recalculateInvoice($payment['invoice_id'], $this->schoolYear);
|
||||
|
||||
return redirect()->back()->with('success', 'Payment updated successfully.');
|
||||
}
|
||||
|
||||
|
||||
@@ -1302,164 +1154,7 @@ class PaymentController extends ResourceController
|
||||
*/
|
||||
private function recalculateInvoice($invoiceId, $schoolYear)
|
||||
{
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) return;
|
||||
|
||||
$parentId = (int)($invoice['parent_id'] ?? 0);
|
||||
if ($parentId <= 0) return;
|
||||
|
||||
// ---- Tuition (recompute from enrollments) ----
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
$registered = [];
|
||||
$withdrawn = [];
|
||||
foreach ($enrollments as $e) {
|
||||
$row = [
|
||||
'student_id' => (int)($e['student_id'] ?? 0),
|
||||
'class_section_id' => $e['class_section_id'] ?? null,
|
||||
'enrollment_status'=> (string)($e['enrollment_status'] ?? ''),
|
||||
];
|
||||
if (in_array($row['enrollment_status'], ['enrolled','payment pending'], true)) {
|
||||
$registered[] = $row;
|
||||
} elseif (in_array($row['enrollment_status'], ['withdrawn','refund pending','withdraw under review'], true)) {
|
||||
$withdrawn[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
// Refund window check – if after deadline, withdrawn still billed
|
||||
$refundDeadline = (string)($this->configModel->getConfig('refund_deadline') ?? '');
|
||||
$refundAllowed = true;
|
||||
try {
|
||||
$tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
|
||||
$tz = new \DateTimeZone($tzName);
|
||||
$today = new \DateTimeImmutable('today', $tz);
|
||||
$deadline = new \DateTimeImmutable($refundDeadline, $tz);
|
||||
$refundAllowed = $today <= $deadline;
|
||||
} catch (\Throwable $e) {
|
||||
$refundAllowed = true;
|
||||
}
|
||||
|
||||
$tuitionStudents = $registered;
|
||||
if (!$refundAllowed) {
|
||||
$tuitionStudents = array_merge($tuitionStudents, $withdrawn);
|
||||
}
|
||||
|
||||
$tuitionStudents = array_values(array_filter($tuitionStudents, function ($student) use ($schoolYear) {
|
||||
$sid = (int)($student['student_id'] ?? 0);
|
||||
return $sid > 0 && $this->studentClassModel->hasNonEventAssignment($sid, $schoolYear);
|
||||
}));
|
||||
|
||||
// Grade threshold and fees
|
||||
$gradeFee = (int)($this->configModel->getConfig('grade_fee') ?? 9);
|
||||
$firstStudentFee = (float)($this->configModel->getConfig('first_student_fee') ?? 350);
|
||||
$secondStudentFee = (float)($this->configModel->getConfig('second_student_fee') ?? 200);
|
||||
$youthFee = (float)($this->configModel->getConfig('youth_fee') ?? 180);
|
||||
|
||||
// Normalize grades for tuition students
|
||||
foreach ($tuitionStudents as &$s) {
|
||||
$name = null;
|
||||
if (!empty($s['class_section_id'])) {
|
||||
$name = $this->classSectionModel->getClassSectionNameBySectionId($s['class_section_id']);
|
||||
}
|
||||
$s['grade_name'] = is_string($name) ? strtoupper(trim($name)) : 'N/A';
|
||||
}
|
||||
unset($s);
|
||||
|
||||
// Count regular vs youth and compute tuition
|
||||
$regularCount = 0;
|
||||
$youthCount = 0;
|
||||
foreach ($tuitionStudents as $s) {
|
||||
$lvl = $this->parseGradeLevel($s['grade_name']);
|
||||
if ($lvl > $gradeFee) $youthCount++; else $regularCount++;
|
||||
}
|
||||
|
||||
$tuitionSubtotal = 0.0;
|
||||
$tuitionSubtotal += $youthCount * $youthFee;
|
||||
if ($regularCount >= 2) {
|
||||
$tuitionSubtotal += $firstStudentFee + ($regularCount - 1) * $secondStudentFee;
|
||||
} elseif ($regularCount === 1) {
|
||||
$tuitionSubtotal += $firstStudentFee;
|
||||
}
|
||||
|
||||
// ---- Event charges (parent-year) ----
|
||||
$eventSubtotal = 0.0;
|
||||
try {
|
||||
$events = $this->eventChargesModel->getChargesWithEventInfo($parentId, $schoolYear) ?? [];
|
||||
foreach ($events as $ev) { $eventSubtotal += (float)($ev['charged'] ?? 0.0); }
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
// ---- Additional charges (per-invoice) ----
|
||||
$additionalSubtotal = 0.0;
|
||||
try {
|
||||
$rows = $this->additionalChargeModel
|
||||
->select('charge_type, amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('status', 'applied')
|
||||
->findAll();
|
||||
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);
|
||||
$additionalSubtotal += $amt;
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
|
||||
$newTotal = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
|
||||
|
||||
// ---- Payments / Discounts / Refunds ----
|
||||
$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('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
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();
|
||||
$totalPaid = 0.0;
|
||||
foreach ($payments as $p) { $totalPaid += (float)($p['paid_amount'] ?? 0); }
|
||||
|
||||
$discRow = $this->db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->get()->getRowArray();
|
||||
$totalDisc = (float)($discRow['total_disc'] ?? 0);
|
||||
|
||||
$refundRow = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
|
||||
|
||||
$newBalance = max(0.0, $newTotal - $totalDisc - $totalPaid - $totalRefundPaid);
|
||||
$newStatus = ($newBalance <= 0.00001) ? 'Paid' : (($totalPaid > 0) ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
$this->invoiceModel->update($invoiceId, [
|
||||
'total_amount' => $newTotal,
|
||||
'paid_amount' => $totalPaid,
|
||||
'balance' => $newBalance,
|
||||
'status' => $newStatus,
|
||||
]);
|
||||
$this->invoiceLedgerService->recalculateInvoice((int) $invoiceId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1521,106 +1216,28 @@ class PaymentController extends ResourceController
|
||||
/** Get current invoice balance = total - payments - discounts - refundsPaid (no school_year filter). */
|
||||
private function getCurrentInvoiceBalance(int $invoiceId): float
|
||||
{
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) return 0.0;
|
||||
|
||||
$db = $this->db;
|
||||
$table = $this->paymentModel->table; // usually 'payments'
|
||||
$hasStatus = $db->fieldExists('status', $table);
|
||||
$hasVoid = $db->fieldExists('is_void', $table);
|
||||
|
||||
$qb = $this->paymentModel
|
||||
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->where('invoice_id', $invoiceId);
|
||||
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
try {
|
||||
return (float) ($this->invoiceLedgerService->calculateInvoice($invoiceId)['balance'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$row = $qb->first();
|
||||
$totalPaid = (float)($row['total_paid'] ?? 0);
|
||||
|
||||
// Discount sum on this invoice
|
||||
$discRow = $this->db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->get()->getRowArray();
|
||||
$totalDisc = (float)($discRow['total_disc'] ?? 0);
|
||||
|
||||
// Refunds paid
|
||||
$refundRow = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
|
||||
|
||||
$total = (float)($invoice['total_amount'] ?? 0);
|
||||
|
||||
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
|
||||
}
|
||||
|
||||
/** Balance excluding a specific payment (for edit scenarios) and subtracting discounts/refunds. */
|
||||
private function getCurrentInvoiceBalanceExcludingPayment(int $invoiceId, int $excludePaymentId): float
|
||||
{
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) return 0.0;
|
||||
|
||||
$db = $this->db;
|
||||
$table = $this->paymentModel->table;
|
||||
$hasStatus = $db->fieldExists('status', $table);
|
||||
$hasVoid = $db->fieldExists('is_void', $table);
|
||||
|
||||
$qb = $this->paymentModel
|
||||
->select('COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('id !=', $excludePaymentId);
|
||||
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
$payment = $this->paymentModel->find($excludePaymentId);
|
||||
if (!$payment) {
|
||||
return $this->getCurrentInvoiceBalance($invoiceId);
|
||||
}
|
||||
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
$currentBalance = $this->getCurrentInvoiceBalance($invoiceId);
|
||||
$status = FinancialStatus::normalizePaymentStatus($payment['status'] ?? null);
|
||||
if (in_array($status, FinancialStatus::EXCLUDED_PAYMENT_STATUSES, true)) {
|
||||
return $currentBalance;
|
||||
}
|
||||
|
||||
$row = $qb->first();
|
||||
$totalPaid = (float)($row['total_paid'] ?? 0);
|
||||
|
||||
// Discount sum on this invoice
|
||||
$discRow = $this->db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->get()->getRowArray();
|
||||
$totalDisc = (float)($discRow['total_disc'] ?? 0);
|
||||
|
||||
// Refunds paid
|
||||
$refundRow = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$totalRefundPaid = (float)($refundRow['total_refund_paid'] ?? 0);
|
||||
|
||||
$total = (float)($invoice['total_amount'] ?? 0);
|
||||
|
||||
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
|
||||
return max(0.0, round($currentBalance + (float) ($payment['paid_amount'] ?? 0), 2));
|
||||
}
|
||||
|
||||
|
||||
@@ -1655,9 +1272,11 @@ class PaymentController extends ResourceController
|
||||
$schoolYear = null,
|
||||
$semester = null,
|
||||
$checkNumber = null,
|
||||
?int $installmentSeq = null // <-- NOW: the installment sequence (1,2,3,...) for this invoice
|
||||
?int $installmentSeq = null,
|
||||
?array $invoice = null,
|
||||
?float $currentBalance = null
|
||||
) {
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
$invoice = $invoice ?? $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return false;
|
||||
}
|
||||
@@ -1678,49 +1297,26 @@ class PaymentController extends ResourceController
|
||||
}
|
||||
|
||||
|
||||
// Compute new totals
|
||||
$newPaid = (float) $invoice['paid_amount'] + (float) $amount;
|
||||
$newBalance = (float) $invoice['balance'] - (float) $amount;
|
||||
if ($newBalance == $invoice['total_amount']) {
|
||||
$paymentStatus = 'Unpaid';
|
||||
} elseif ($newBalance > 0 && $newBalance < $invoice['total_amount']) {
|
||||
$paymentStatus = 'Partially Paid';
|
||||
} elseif ($newBalance <= 0.00001) {
|
||||
$paymentStatus = 'Paid';
|
||||
} else {
|
||||
$paymentStatus = $invoice['status']; // fallback
|
||||
}
|
||||
$preBalance = $currentBalance ?? $this->getCurrentInvoiceBalance((int) $invoiceId);
|
||||
$newBalance = max(0.0, round($preBalance - (float) $amount, 2));
|
||||
|
||||
// Update invoice
|
||||
$invoiceUpdateData = [
|
||||
'paid_amount' => $newPaid,
|
||||
'balance' => $newBalance,
|
||||
'status' => $paymentStatus,
|
||||
'updated_by' => session()->get('user_id'),
|
||||
];
|
||||
if (!$this->invoiceModel->update($invoiceId, $invoiceUpdateData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store the *sequence* in number_of_installments (kept for schema compatibility)
|
||||
// Consider renaming the column to `installment_seq` in a future migration.
|
||||
$paymentData = [
|
||||
'parent_id' => $invoice['parent_id'],
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => $invoice['total_amount'],
|
||||
'paid_amount' => $amount,
|
||||
'balance' => $newBalance,
|
||||
'parent_id' => $invoice['parent_id'],
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => $invoice['total_amount'],
|
||||
'paid_amount' => $amount,
|
||||
'balance' => $newBalance,
|
||||
'number_of_installments' => $installmentSeq, // <-- installment sequence (1,2,3,...)
|
||||
'transaction_id' => $transactionId,
|
||||
'payment_method' => strtolower($paymentMethod),
|
||||
'payment_date' => $paymentDate,
|
||||
'status' => $paymentStatus,
|
||||
'check_file' => $checkFile,
|
||||
'check_number' => (strtolower($paymentMethod) === 'check') ? $checkNumber : null,
|
||||
'updated_by' => session()->get('user_id'),
|
||||
'school_year' => $schoolYear ?? $this->schoolYear,
|
||||
'semester' => $semester ?? $this->semester,
|
||||
// 'installment_index' => $installmentSeq, // use if you add a dedicated column later
|
||||
'installment_seq' => $installmentSeq,
|
||||
'transaction_id' => $transactionId,
|
||||
'payment_method' => strtolower($paymentMethod),
|
||||
'payment_date' => $paymentDate,
|
||||
'status' => FinancialStatus::PAYMENT_RECORDED,
|
||||
'check_file' => $checkFile,
|
||||
'check_number' => (strtolower($paymentMethod) === 'check') ? $checkNumber : null,
|
||||
'updated_by' => session()->get('user_id'),
|
||||
'school_year' => $schoolYear ?? $this->schoolYear,
|
||||
'semester' => $semester ?? $this->semester,
|
||||
];
|
||||
|
||||
if (!$this->paymentModel->insert($paymentData)) {
|
||||
@@ -1741,35 +1337,53 @@ class PaymentController extends ResourceController
|
||||
->countAllResults();
|
||||
}
|
||||
|
||||
public function serveCheckFile($filename, $mode = 'download')
|
||||
public function servePaymentFile(int $paymentId, string $mode = 'download')
|
||||
{
|
||||
$filename = basename($filename);
|
||||
$roots = [
|
||||
WRITEPATH . 'uploads/checks/' . $filename,
|
||||
WRITEPATH . 'uploads/cards/' . $filename,
|
||||
WRITEPATH . 'uploads/misc/' . $filename,
|
||||
WRITEPATH . 'uploads/' . $filename, // final fallback
|
||||
];
|
||||
|
||||
$path = null;
|
||||
foreach ($roots as $candidate) {
|
||||
if (is_file($candidate)) {
|
||||
$path = $candidate;
|
||||
break;
|
||||
}
|
||||
$payment = $this->paymentModel->find($paymentId);
|
||||
if (!$payment || empty($payment['check_file'])) {
|
||||
throw PageNotFoundException::forPageNotFound('Payment file not found.');
|
||||
}
|
||||
|
||||
if (!$path) {
|
||||
throw new \CodeIgniter\Exceptions\PageNotFoundException('Payment file not found: ' . esc($filename));
|
||||
if (!$this->canViewPayment($payment)) {
|
||||
return $this->response->setStatusCode(403);
|
||||
}
|
||||
|
||||
$subdir = match (strtolower((string) ($payment['payment_method'] ?? ''))) {
|
||||
'check' => 'checks',
|
||||
'card', 'debit/credit card' => 'cards',
|
||||
default => 'misc',
|
||||
};
|
||||
|
||||
$path = $this->financialAttachmentService->resolvePath($subdir, (string) $payment['check_file']);
|
||||
if ($path === null) {
|
||||
throw PageNotFoundException::forPageNotFound('Payment file not found.');
|
||||
}
|
||||
|
||||
if ($mode === 'inline') {
|
||||
return $this->response
|
||||
->setHeader('Content-Type', mime_content_type($path))
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . $filename . '"')
|
||||
->setHeader('Content-Type', $this->financialAttachmentService->detectMime($path))
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . basename($path) . '"')
|
||||
->setBody(file_get_contents($path));
|
||||
}
|
||||
|
||||
return $this->response->download($path, null);
|
||||
}
|
||||
|
||||
private function canViewPayment(array $payment): bool
|
||||
{
|
||||
$roles = array_map('strtolower', (array) (session()->get('roles') ?? []));
|
||||
$activeRole = strtolower((string) (session()->get('role') ?? ''));
|
||||
if ($activeRole !== '' && !in_array($activeRole, $roles, true)) {
|
||||
$roles[] = $activeRole;
|
||||
}
|
||||
|
||||
$staffRoles = ['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant'];
|
||||
foreach ($staffRoles as $role) {
|
||||
if (in_array($role, $roles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return in_array('parent', $roles, true) && (int) ($payment['parent_id'] ?? 0) === (int) session()->get('user_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\PayPalPaymentModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
|
||||
class PaypalTransactionsController extends BaseController
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$model = new PayPalPaymentModel();
|
||||
|
||||
$keyword = $this->request->getGet('q');
|
||||
$perPage = 10;
|
||||
|
||||
if ($keyword) {
|
||||
$transactions = $model
|
||||
->groupStart()
|
||||
->like('transaction_id', $keyword)
|
||||
->orLike('payer_email', $keyword)
|
||||
->orLike('event_type', $keyword)
|
||||
->orLike('order_id', $keyword)
|
||||
->orLike('parent_school_id', $keyword)
|
||||
->groupEnd()
|
||||
->orderBy('created_at', 'DESC')
|
||||
->paginate($perPage);
|
||||
} else {
|
||||
$transactions = $model
|
||||
->orderBy('created_at', 'DESC')
|
||||
->paginate($perPage);
|
||||
}
|
||||
|
||||
return view('administrator/paypal_transactions', [
|
||||
'transactions' => $transactions,
|
||||
'pager' => $model->pager,
|
||||
'keyword' => $keyword
|
||||
]);
|
||||
}
|
||||
|
||||
public function exportCsv()
|
||||
{
|
||||
$model = new PayPalPaymentModel();
|
||||
$keyword = $this->request->getGet('q');
|
||||
|
||||
if ($keyword) {
|
||||
$transactions = $model
|
||||
->groupStart()
|
||||
->like('transaction_id', $keyword)
|
||||
->orLike('payer_email', $keyword)
|
||||
->orLike('event_type', $keyword)
|
||||
->orLike('order_id', $keyword)
|
||||
->orLike('parent_school_id', $keyword)
|
||||
->groupEnd()
|
||||
->orderBy('created_at', 'DESC')
|
||||
->findAll();
|
||||
} else {
|
||||
$transactions = $model->orderBy('created_at', 'DESC')->findAll();
|
||||
}
|
||||
|
||||
$filename = 'paypal_transactions_' . date('Ymd_His') . '.csv';
|
||||
|
||||
header('Content-Type: text/csv');
|
||||
header("Content-Disposition: attachment; filename=\"$filename\"");
|
||||
|
||||
$output = fopen('php://output', 'w');
|
||||
|
||||
// CSV headers
|
||||
fputcsv($output, [
|
||||
'ID', 'Transaction ID', 'Order ID', 'Parent School ID',
|
||||
'Email', 'Amount', 'Net Amount', 'Currency',
|
||||
'Status', 'Event Type', 'Created At'
|
||||
]);
|
||||
|
||||
foreach ($transactions as $t) {
|
||||
fputcsv($output, [
|
||||
$t['id'],
|
||||
$t['transaction_id'],
|
||||
$t['order_id'],
|
||||
$t['parent_school_id'],
|
||||
$t['payer_email'],
|
||||
$t['amount'],
|
||||
$t['net_amount'],
|
||||
$t['currency'],
|
||||
$t['status'],
|
||||
$t['event_type'],
|
||||
$t['created_at'],
|
||||
]);
|
||||
}
|
||||
|
||||
fclose($output);
|
||||
exit;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,12 +3,15 @@
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\FinancialAttachmentService;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use App\Models\RefundModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
|
||||
class RefundController extends BaseController
|
||||
{
|
||||
@@ -18,6 +21,8 @@ class RefundController extends BaseController
|
||||
protected ConfigurationModel $configModel;
|
||||
protected InvoiceModel $invoiceModel;
|
||||
protected EnrollmentModel $enrollmentModel;
|
||||
protected InvoiceLedgerService $invoiceLedgerService;
|
||||
protected FinancialAttachmentService $financialAttachmentService;
|
||||
protected $db;
|
||||
|
||||
// Allowed request types (mapped to your `refunds.request` column)
|
||||
@@ -33,6 +38,8 @@ class RefundController extends BaseController
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->enrollmentModel = new EnrollmentModel();
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
$this->financialAttachmentService = new FinancialAttachmentService();
|
||||
$this->db = \Config\Database::connect();
|
||||
}
|
||||
|
||||
@@ -445,6 +452,14 @@ class RefundController extends BaseController
|
||||
return $this->response->setJSON(['error' => 'Failed to create refund request.']);
|
||||
}
|
||||
|
||||
if (!empty($invoiceId)) {
|
||||
try {
|
||||
$this->invoiceLedgerService->recalculateInvoice((int) $invoiceId);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'requestRefund recalc failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Fire refundPending notification/event for newly created refunds
|
||||
try {
|
||||
$user = $this->userModel->select('id, email, firstname, lastname')->find($parentId) ?: [];
|
||||
@@ -467,13 +482,36 @@ class RefundController extends BaseController
|
||||
// Approve refund (no money movement)
|
||||
public function approveRefund(int $refundId)
|
||||
{
|
||||
$ok = $this->refundModel->update($refundId, [
|
||||
'status' => 'Approved',
|
||||
'approved_at' => utc_now(),
|
||||
'approved_by' => session()->get('user_id'),
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
$refund = $this->refundModel->find($refundId);
|
||||
if (!$refund) {
|
||||
return $this->response->setJSON(['error' => 'Refund not found']);
|
||||
}
|
||||
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
if (!empty($refund['invoice_id'])) {
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $refund['invoice_id']]);
|
||||
}
|
||||
|
||||
$ok = $this->refundModel->update($refundId, [
|
||||
'status' => 'Approved',
|
||||
'approved_at' => utc_now(),
|
||||
'approved_by' => session()->get('user_id'),
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
|
||||
if (!empty($refund['invoice_id'])) {
|
||||
$this->invoiceLedgerService->recalculateInvoice((int) $refund['invoice_id']);
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'approveRefund failed: ' . $e->getMessage());
|
||||
$ok = false;
|
||||
}
|
||||
|
||||
return $this->response->setJSON($ok ? ['success' => 'Refund approved'] : ['error' => 'Approve failed']);
|
||||
}
|
||||
@@ -481,13 +519,36 @@ class RefundController extends BaseController
|
||||
// Reject refund
|
||||
public function rejectRefund(int $refundId)
|
||||
{
|
||||
$ok = $this->refundModel->update($refundId, [
|
||||
'status' => 'Rejected',
|
||||
'approved_at' => utc_now(),
|
||||
'approved_by' => session()->get('user_id'),
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
$refund = $this->refundModel->find($refundId);
|
||||
if (!$refund) {
|
||||
return $this->response->setJSON(['error' => 'Refund not found']);
|
||||
}
|
||||
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
if (!empty($refund['invoice_id'])) {
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $refund['invoice_id']]);
|
||||
}
|
||||
|
||||
$ok = $this->refundModel->update($refundId, [
|
||||
'status' => 'Rejected',
|
||||
'approved_at' => utc_now(),
|
||||
'approved_by' => session()->get('user_id'),
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
|
||||
if (!empty($refund['invoice_id'])) {
|
||||
$this->invoiceLedgerService->recalculateInvoice((int) $refund['invoice_id']);
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'rejectRefund failed: ' . $e->getMessage());
|
||||
$ok = false;
|
||||
}
|
||||
|
||||
return $this->response->setJSON($ok ? ['success' => 'Refund rejected'] : ['error' => 'Reject failed']);
|
||||
}
|
||||
@@ -526,17 +587,21 @@ class RefundController extends BaseController
|
||||
// Optional file upload for Check
|
||||
$checkFileName = $refund['check_file'] ?? null;
|
||||
if ($refundMethod === 'Check') {
|
||||
$checkFile = $this->request->getFile('check_file');
|
||||
if ($checkFile && $checkFile->isValid() && !$checkFile->hasMoved()) {
|
||||
$checkFileName = $checkFile->getRandomName();
|
||||
$checkFile->move(WRITEPATH . 'uploads/checks/', $checkFileName);
|
||||
try {
|
||||
$checkFile = $this->request->getFile('check_file');
|
||||
$uploaded = $this->financialAttachmentService->saveUploadedFile($checkFile, 'checks');
|
||||
if ($uploaded !== null) {
|
||||
$checkFileName = $uploaded;
|
||||
}
|
||||
} catch (\RuntimeException $e) {
|
||||
return $this->response->setJSON(['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
$newStatus = ($total < $target) ? 'Partial' : 'Paid';
|
||||
|
||||
$db = db_connect();
|
||||
$db->transStart();
|
||||
$db->transBegin();
|
||||
|
||||
// 0) If this refund is not tied to an invoice yet, assign it to the most overpaid invoice
|
||||
$assignInvoiceId = null;
|
||||
@@ -595,26 +660,32 @@ class RefundController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
// 1) Update refund row
|
||||
$this->refundModel->update($refundId, [
|
||||
'refund_paid_amount' => $total,
|
||||
'status' => $newStatus,
|
||||
'refunded_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
'refund_method' => $refundMethod,
|
||||
'check_nbr' => $checkNbr,
|
||||
'check_file' => $checkFileName,
|
||||
// tie to invoice if determined
|
||||
'invoice_id' => $assignInvoiceId ?? $refund['invoice_id'],
|
||||
]);
|
||||
try {
|
||||
$affectedInvoiceId = (int) ($assignInvoiceId ?? $refund['invoice_id'] ?? 0);
|
||||
if ($affectedInvoiceId > 0) {
|
||||
$db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$affectedInvoiceId]);
|
||||
}
|
||||
|
||||
// 2) Optional: If you want an accounting journal entry for payouts, write to a dedicated table.
|
||||
// We no longer insert a negative row into payments to avoid schema/validation conflicts.
|
||||
$this->refundModel->update($refundId, [
|
||||
'refund_paid_amount' => $total,
|
||||
'status' => $newStatus,
|
||||
'refunded_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
'refund_method' => $refundMethod,
|
||||
'check_nbr' => $checkNbr,
|
||||
'check_file' => $checkFileName,
|
||||
'invoice_id' => $affectedInvoiceId ?: null,
|
||||
]);
|
||||
|
||||
$db->transComplete();
|
||||
if ($affectedInvoiceId > 0) {
|
||||
$this->invoiceLedgerService->recalculateInvoice($affectedInvoiceId);
|
||||
}
|
||||
|
||||
if ($db->transStatus() === false) {
|
||||
$db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->transRollback();
|
||||
log_message('error', 'updatePayment failed: ' . $e->getMessage());
|
||||
return $this->response->setJSON(['error' => 'Failed to update payment.']);
|
||||
}
|
||||
|
||||
@@ -770,16 +841,77 @@ class RefundController extends BaseController
|
||||
return $this->response->setJSON(['error' => 'Refund not found.']);
|
||||
}
|
||||
|
||||
$ok = $this->refundModel->update($refundId, [
|
||||
'status' => $status,
|
||||
'reason' => $reason,
|
||||
'approved_at' => utc_now(),
|
||||
'approved_by' => session()->get('user_id'),
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
if (!empty($refund['invoice_id'])) {
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $refund['invoice_id']]);
|
||||
}
|
||||
|
||||
$ok = $this->refundModel->update($refundId, [
|
||||
'status' => $status,
|
||||
'reason' => $reason,
|
||||
'approved_at' => utc_now(),
|
||||
'approved_by' => session()->get('user_id'),
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
]);
|
||||
|
||||
if (!empty($refund['invoice_id'])) {
|
||||
$this->invoiceLedgerService->recalculateInvoice((int) $refund['invoice_id']);
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'updateStatus failed: ' . $e->getMessage());
|
||||
$ok = false;
|
||||
}
|
||||
|
||||
return $this->response->setJSON($ok ? ['success' => 'Refund status updated successfully.']
|
||||
: ['error' => 'Failed to update refund status.']);
|
||||
}
|
||||
|
||||
public function serveRefundFile(int $refundId, string $mode = 'download')
|
||||
{
|
||||
$refund = $this->refundModel->find($refundId);
|
||||
if (!$refund || empty($refund['check_file'])) {
|
||||
throw PageNotFoundException::forPageNotFound('Refund file not found.');
|
||||
}
|
||||
|
||||
if (!$this->canViewRefund($refund)) {
|
||||
return $this->response->setStatusCode(403);
|
||||
}
|
||||
|
||||
$path = $this->financialAttachmentService->resolvePath('checks', (string) $refund['check_file']);
|
||||
if ($path === null) {
|
||||
throw PageNotFoundException::forPageNotFound('Refund file not found.');
|
||||
}
|
||||
|
||||
if ($mode === 'inline') {
|
||||
return $this->response
|
||||
->setHeader('Content-Type', $this->financialAttachmentService->detectMime($path))
|
||||
->setHeader('Content-Disposition', 'inline; filename="' . basename($path) . '"')
|
||||
->setBody(file_get_contents($path));
|
||||
}
|
||||
|
||||
return $this->response->download($path, null);
|
||||
}
|
||||
|
||||
private function canViewRefund(array $refund): bool
|
||||
{
|
||||
$roles = array_map('strtolower', (array) (session()->get('roles') ?? []));
|
||||
$activeRole = strtolower((string) (session()->get('role') ?? ''));
|
||||
if ($activeRole !== '' && !in_array($activeRole, $roles, true)) {
|
||||
$roles[] = $activeRole;
|
||||
}
|
||||
|
||||
foreach (['administrator', 'administrative staff', 'principal', 'teacher', 'teacher_assistant'] as $role) {
|
||||
if (in_array($role, $roles, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return in_array('parent', $roles, true) && (int) ($refund['parent_id'] ?? 0) === (int) session()->get('user_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1849,15 +1849,42 @@ private function calculateTermRanking(
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Rank must be limited to the actual selected class roster.
|
||||
* Do NOT rank by every semester_scores row that happens to share class_section_id,
|
||||
* because old/mismatched score rows can inflate the denominator (for example 18 students
|
||||
* in 3-A showing as "out of 32"). The roster is the source of truth for class size.
|
||||
*/
|
||||
$rosterRows = $this->fetchStudentsByClass($sectionCode, $schoolYear);
|
||||
if (empty($rosterRows) && $sectionId && $sectionId !== $sectionCode) {
|
||||
$rosterRows = $this->fetchStudentsByClass($sectionId, $schoolYear);
|
||||
}
|
||||
|
||||
$rosterByStudent = [];
|
||||
foreach ($rosterRows as $row) {
|
||||
$sid = (int)($row['id'] ?? 0);
|
||||
if ($sid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$rosterByStudent[$sid] = [
|
||||
'firstname' => trim((string)($row['firstname'] ?? '')),
|
||||
'lastname' => trim((string)($row['lastname'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($rosterByStudent) || !isset($rosterByStudent[$studentId])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rosterStudentIds = array_keys($rosterByStudent);
|
||||
|
||||
$semesterForRank = trim((string)$semester);
|
||||
$rankByFinalScore = $this->normalizeSemester($semesterForRank) === 'spring';
|
||||
|
||||
$builder = $this->db->table('semester_scores ss')
|
||||
->select('ss.student_id, ss.semester, ss.semester_score, ss.updated_at, ss.id, s.firstname, s.lastname')
|
||||
->join('students s', 's.id = ss.student_id', 'inner')
|
||||
->where('s.is_active', 1)
|
||||
->select('ss.student_id, ss.semester, ss.semester_score, ss.updated_at, ss.id')
|
||||
->where('ss.school_year', $schoolYear)
|
||||
->whereIn('ss.class_section_id', $sectionIds)
|
||||
->whereIn('ss.student_id', $rosterStudentIds)
|
||||
->orderBy('ss.updated_at', 'DESC')
|
||||
->orderBy('ss.id', 'DESC');
|
||||
|
||||
@@ -1876,7 +1903,7 @@ private function calculateTermRanking(
|
||||
foreach ($rows as $row) {
|
||||
$sid = (int)($row['student_id'] ?? 0);
|
||||
|
||||
if ($sid <= 0 || isset($scoresByStudent[$sid])) {
|
||||
if ($sid <= 0 || !isset($rosterByStudent[$sid]) || isset($scoresByStudent[$sid])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1892,8 +1919,8 @@ private function calculateTermRanking(
|
||||
'student_id' => $sid,
|
||||
'score' => $rawScore,
|
||||
'rank_score' => round($rawScore, 1),
|
||||
'firstname' => trim((string)($row['firstname'] ?? '')),
|
||||
'lastname' => trim((string)($row['lastname'] ?? '')),
|
||||
'firstname' => $rosterByStudent[$sid]['firstname'],
|
||||
'lastname' => $rosterByStudent[$sid]['lastname'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1910,7 +1937,6 @@ private function calculateTermRanking(
|
||||
->select('ss.student_id, ss.semester, ss.semester_score, ss.updated_at, ss.id')
|
||||
->where('ss.school_year', $schoolYear)
|
||||
->whereIn('ss.student_id', $studentIds)
|
||||
->whereIn('ss.class_section_id', $sectionIds)
|
||||
->orderBy('ss.updated_at', 'DESC')
|
||||
->orderBy('ss.id', 'DESC');
|
||||
|
||||
@@ -1925,7 +1951,7 @@ private function calculateTermRanking(
|
||||
foreach ($firstRows as $row) {
|
||||
$sid = (int)($row['student_id'] ?? 0);
|
||||
|
||||
if ($sid <= 0 || isset($firstScoresByStudent[$sid])) {
|
||||
if ($sid <= 0 || !isset($rosterByStudent[$sid]) || isset($firstScoresByStudent[$sid])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\Tuition\TuitionForecastService;
|
||||
|
||||
class TuitionForecastController extends BaseController
|
||||
{
|
||||
protected TuitionForecastService $forecastService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->forecastService = new TuitionForecastService();
|
||||
helper(['url']);
|
||||
}
|
||||
|
||||
public function index()
|
||||
{
|
||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
|
||||
$semester = trim((string) ($this->request->getGet('semester') ?? ''));
|
||||
$mode = (string) ($this->request->getGet('calculator_mode') ?? 'compare');
|
||||
$filters = $this->buildOptionsFromRequest('get');
|
||||
$result = $this->forecastService->calculate($schoolYear, $semester, $mode, $filters);
|
||||
|
||||
return view('administrator/tuition_forecast', [
|
||||
'title' => 'Tuition Collection Forecast',
|
||||
'schoolYears' => $this->forecastService->getAvailableSchoolYears(),
|
||||
'semesters' => $this->forecastService->getAvailableSemesters(),
|
||||
'filters' => [
|
||||
'school_year' => $result['school_year'],
|
||||
'semester' => $result['semester'],
|
||||
'calculator_mode' => $result['calculator_mode'],
|
||||
] + $result['options'],
|
||||
'result' => $result,
|
||||
]);
|
||||
}
|
||||
|
||||
public function calculate()
|
||||
{
|
||||
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? ''));
|
||||
$semester = trim((string) ($this->request->getPost('semester') ?? ''));
|
||||
$mode = (string) ($this->request->getPost('calculator_mode') ?? 'compare');
|
||||
|
||||
return $this->response->setJSON(
|
||||
$this->forecastService->calculate($schoolYear, $semester, $mode, $this->buildOptionsFromRequest('post'))
|
||||
);
|
||||
}
|
||||
|
||||
public function exportCsv()
|
||||
{
|
||||
$schoolYear = trim((string) ($this->request->getGet('school_year') ?? ''));
|
||||
$semester = trim((string) ($this->request->getGet('semester') ?? ''));
|
||||
$mode = (string) ($this->request->getGet('calculator_mode') ?? 'compare');
|
||||
$result = $this->forecastService->calculate($schoolYear, $semester, $mode, $this->buildOptionsFromRequest('get'));
|
||||
|
||||
$filename = sprintf(
|
||||
'tuition_forecast_%s_%s_%s.csv',
|
||||
preg_replace('/[^A-Za-z0-9_-]+/', '-', $result['school_year']) ?: 'school-year',
|
||||
preg_replace('/[^A-Za-z0-9_-]+/', '-', $result['semester']) ?: 'all-year',
|
||||
$result['calculator_mode']
|
||||
);
|
||||
|
||||
$handle = fopen('php://temp', 'w+');
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('Unable to create CSV export.');
|
||||
}
|
||||
|
||||
fputcsv($handle, ['School Year', $result['school_year']]);
|
||||
fputcsv($handle, ['Semester', $result['semester'] !== '' ? $result['semester'] : 'All Year']);
|
||||
fputcsv($handle, ['Calculator Mode', $result['calculator_mode']]);
|
||||
fputcsv($handle, []);
|
||||
fputcsv($handle, ['Summary']);
|
||||
fputcsv($handle, ['Families', 'Students', 'Billable Students', 'Unit Price', 'Old Projected Income', 'New Projected Income', 'Difference']);
|
||||
fputcsv($handle, [
|
||||
$result['summary']['family_count'],
|
||||
$result['summary']['student_count'],
|
||||
$result['summary']['billable_student_count'],
|
||||
$result['summary']['unit_price'],
|
||||
$result['summary']['old_projected_income'] ?? $result['summary']['old_projected_tuition'],
|
||||
$result['summary']['new_projected_income'] ?? $result['summary']['new_projected_tuition'],
|
||||
$result['summary']['difference'],
|
||||
]);
|
||||
fputcsv($handle, []);
|
||||
fputcsv($handle, ['Families']);
|
||||
fputcsv($handle, ['Parent/Family', 'Student Count', 'Billable Student Count', 'Old Tuition Total', 'New Tuition Total', 'Difference', 'Warnings']);
|
||||
|
||||
foreach ($result['families'] as $family) {
|
||||
fputcsv($handle, [
|
||||
$family['parent_name'] ?? '',
|
||||
$family['student_count'] ?? 0,
|
||||
$family['billable_student_count'] ?? 0,
|
||||
$family['old_total'] ?? '0.00',
|
||||
$family['new_total'] ?? '0.00',
|
||||
$family['difference'] ?? '0.00',
|
||||
implode(' | ', $family['warnings'] ?? []),
|
||||
]);
|
||||
|
||||
fputcsv($handle, ['Student Name', 'Grade', 'Billable', 'Excluded Reason', 'Old Rule', 'Old Amount', 'New Rule', 'New Amount']);
|
||||
foreach ($family['student_details'] as $detail) {
|
||||
fputcsv($handle, [
|
||||
$detail['student_name'] ?? '',
|
||||
$detail['grade_level'] ?? '',
|
||||
!empty($detail['billable']) ? 'Yes' : 'No',
|
||||
$detail['excluded_reason'] ?? '',
|
||||
$detail['old_rule'] ?? '',
|
||||
$detail['old_amount'] ?? '0.00',
|
||||
$detail['new_rule'] ?? '',
|
||||
$detail['new_amount'] ?? '0.00',
|
||||
]);
|
||||
}
|
||||
fputcsv($handle, []);
|
||||
}
|
||||
|
||||
rewind($handle);
|
||||
$csv = stream_get_contents($handle) ?: '';
|
||||
fclose($handle);
|
||||
|
||||
return $this->response
|
||||
->setHeader('Content-Type', 'text/csv; charset=UTF-8')
|
||||
->setHeader('Content-Disposition', 'attachment; filename="' . $filename . '"')
|
||||
->setBody($csv);
|
||||
}
|
||||
|
||||
protected function buildOptionsFromRequest(string $method): array
|
||||
{
|
||||
$source = $method === 'post' ? 'getPost' : 'getGet';
|
||||
|
||||
return [
|
||||
'include_withdrawn_mode' => (string) ($this->request->{$source}('include_withdrawn_mode') ?? 'refund_deadline'),
|
||||
'include_payment_pending' => $this->request->{$source}('include_payment_pending') ?? '0',
|
||||
'include_event_only' => $this->request->{$source}('include_event_only') ?? '0',
|
||||
'include_paid_invoices' => $this->request->{$source}('include_paid_invoices') ?? '0',
|
||||
'unit_price' => $this->request->{$source}('unit_price') ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user