fix financial issues
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user