+43
-77
@@ -263,6 +263,7 @@ $routes->get('refunds/list', 'View\RefundController::listRefunds', ['filter' =>
|
||||
$routes->post('refunds/request', 'View\RefundController::requestRefund', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
|
||||
$routes->post('refunds/approve/(:num)', 'View\RefundController::approveRefund/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->post('refunds/pay/(:num)', 'View\RefundController::payRefund/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->post('refunds/reversePayout/(:num)', 'View\RefundController::reversePayout/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->post('refunds/updateStatus/(:num)', 'View\RefundController::updateStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->post('refunds/processRefunds', 'View\RefundController::processRefunds', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->post('refunds/updateStatus', 'View\RefundController::updateStatus', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
@@ -566,14 +567,14 @@ $routes->match(['get', 'post'], 'administrator/events/edit/(:num)', 'View\EventC
|
||||
|
||||
|
||||
$routes->post('administrator/events/delete/(:num)', 'View\EventController::delete/$1'); // handle delete
|
||||
$routes->post('payment/event_charges', 'View\EventController::eventUpdate');
|
||||
$routes->post('payment/event_charges', 'View\EventController::eventUpdate', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
|
||||
// Parent event participation
|
||||
$routes->get('administrator/event-charges', 'View\EventController::eventShow');
|
||||
$routes->get('administrator/event-charges/pdf', 'View\EventController::eventChargesPdf');
|
||||
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1');
|
||||
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1');
|
||||
$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1');
|
||||
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges');
|
||||
|
||||
$routes->get('parent/events', 'View\ParentController::parentEventPage', ['filter' => 'auth:parent']);
|
||||
@@ -584,7 +585,7 @@ $routes->post('parent/updateParticipation', 'View\ParentController::updatePartic
|
||||
// Web View Routes for Invoices
|
||||
$routes->get('invoices/getByParent/(:num)', 'View\InvoiceController::getByParent/$1');
|
||||
$routes->get('invoices/create', 'View\InvoiceController::create');
|
||||
$routes->post('invoices/updateStatus/(:num)', 'View\InvoiceController::updateStatus/$1');
|
||||
$routes->post('invoices/updateStatus/(:num)', 'View\InvoiceController::updateStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
|
||||
// app/Config/Routes.php
|
||||
|
||||
@@ -606,24 +607,6 @@ $routes->group('payment', ['filter' => 'auth'], static function ($routes) {
|
||||
$routes->get('file/(:num)', 'View\PaymentController::servePaymentFile/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Optional: if someone opens it in the browser via GET, redirect to the search page:
|
||||
$routes->get('payment/manual_pay_update', function () {
|
||||
return redirect()->to(site_url('payment/manual_pay'));
|
||||
});
|
||||
|
||||
/*
|
||||
$routes->group('admin/charges', ['filter' => 'auth'], static function($routes) {
|
||||
$routes->get('/', 'ExtraChargesController::index');
|
||||
$routes->get('parents', 'ExtraChargesController::parentOptions'); // <-- for Select2
|
||||
$routes->post('store', 'ExtraChargesController::store');
|
||||
$routes->post('update/(:num)', 'ExtraChargesController::update/$1');
|
||||
$routes->post('void/(:num)', 'ExtraChargesController::void/$1');
|
||||
});
|
||||
|
||||
*/
|
||||
|
||||
$routes->group('admin', ['filter' => 'auth'], static function ($routes) {
|
||||
$routes->group('charges', static function ($routes) {
|
||||
// Serve HTML page from ::page, keep ::index for API JSON
|
||||
@@ -631,10 +614,12 @@ $routes->group('admin', ['filter' => 'auth'], static function ($routes) {
|
||||
$routes->get('parents', [ExtraChargesController::class, 'parentOptions']);
|
||||
$routes->get('invoices', [ExtraChargesController::class, 'invoicesForParent']); // <-- AJAX endpoint
|
||||
$routes->get('list', [ExtraChargesController::class, 'apiList']);
|
||||
$routes->post('store', [ExtraChargesController::class, 'store']);
|
||||
$routes->post('update/(:num)', [ExtraChargesController::class, 'update/$1']);
|
||||
$routes->post('void/(:num)', [ExtraChargesController::class, 'void/$1']);
|
||||
$routes->post('reverse/(:num)', [ExtraChargesController::class, 'reverse/$1']);
|
||||
$routes->post('store', [ExtraChargesController::class, 'store'], ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
|
||||
$routes->post('update/(:num)', [ExtraChargesController::class, 'update/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->post('approve/(:num)', [ExtraChargesController::class, 'approve/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->post('apply/(:num)', [ExtraChargesController::class, 'apply/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->post('void/(:num)', [ExtraChargesController::class, 'void/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->post('reverse/(:num)', [ExtraChargesController::class, 'reverse/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -683,6 +668,7 @@ $routes->get('reimbursements/batch/admin-file/(:segment)/(:segment)', 'View\Reim
|
||||
$routes->post('reimbursements/batch/send', 'View\ReimbursementController::sendBatchEmail', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->get('reimbursements/batch/export', 'View\ReimbursementController::exportBatch', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
|
||||
$routes->post('reimbursements/process', 'View\ReimbursementController::process', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->post('reimbursements/reverse/(:num)', 'View\ReimbursementController::reverse/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->get('reimbursements/export', 'View\ReimbursementController::export', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
|
||||
$routes->get('reimbursements', 'View\ReimbursementController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
|
||||
|
||||
@@ -733,21 +719,21 @@ $routes->post(
|
||||
'View\InvoiceController::generateInvoice',
|
||||
['filter' => 'auth:create_invoice']
|
||||
);
|
||||
$routes->get('/parent/invoice_payment', 'View\InvoiceController::invoicePayment'); // Route for invoice payment page
|
||||
$routes->get('invoice/pay/(:num)', 'View\InvoiceController::payInvoice/$1'); // Route for paying the invoice
|
||||
$routes->get('invoices', 'View\InvoiceController::index'); // Default route for invoices
|
||||
$routes->get('invoices/(:any)', 'View\InvoiceController::index/$1'); // Route with school year filter
|
||||
$routes->get('invoices/recalculate/(:segment)', 'View\PaymentController::recalculateInvoiceByNumber/$1', ['filter' => 'auth:update_invoice']); // Recalculate invoice totals/balance
|
||||
$routes->get('/parent/invoice_payment', 'View\InvoiceController::invoicePayment', ['filter' => 'auth:parent']); // Route for invoice payment page
|
||||
$routes->get('invoice/pay/(:num)', 'View\InvoiceController::payInvoice/$1', ['filter' => 'auth:view_invoice|view_payment|administrator|administrative staff|principal|parent']); // Route for paying the invoice
|
||||
$routes->get('invoices', 'View\InvoiceController::index', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); // Default route for invoices
|
||||
$routes->get('invoices/(:any)', 'View\InvoiceController::index/$1', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); // Route with school year filter
|
||||
$routes->post('invoices/recalculate/(:segment)', 'View\PaymentController::recalculateInvoiceByNumber/$1', ['filter' => 'auth:update_invoice']); // Recalculate invoice totals/balance
|
||||
$routes->get('invoice/pdf/(:num)', 'View\InvoiceController::generatePdfInvoice/$1', ['filter' => 'auth:invoice_pdf,read']);
|
||||
|
||||
|
||||
// Web View Routes for Payments
|
||||
$routes->get('payments/getByParent/(:num)', 'View\PaymentController::getByParent/$1');
|
||||
$routes->get('payments/create', 'View\PaymentController::create');
|
||||
$routes->post('payments/updateBalance/(:num)', 'View\PaymentController::updateBalance/$1');
|
||||
$routes->get('payments/getByParent/(:num)', 'View\PaymentController::getByParent/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
|
||||
$routes->get('payments/create', 'View\PaymentController::create', ['filter' => 'auth:create_payment|create_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||
$routes->post('payments/updateBalance/(:num)', 'View\PaymentController::updateBalance/$1', ['filter' => 'auth:update_payment|update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
|
||||
// Routes for payment pages
|
||||
$routes->get('payments/view/(:num)', 'View\PaymentController::viewPayment/$1');
|
||||
$routes->get('payments/view/(:num)', 'View\PaymentController::viewPayment/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
|
||||
$routes->get('payment/get_enrolled_students/(:num)', 'View\PaymentController::getEnrolledStudents/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
|
||||
|
||||
// Payment Notification Management
|
||||
@@ -925,11 +911,11 @@ $routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
|
||||
$routes->group('movements', static function ($routes) {
|
||||
$routes->get('/', 'View\InventoryController::movementsIndex'); // /inventory/movements
|
||||
$routes->get('create', 'View\InventoryController::movementsCreate'); // /inventory/movements/create
|
||||
$routes->post('store', 'View\InventoryController::movementsStore'); // /inventory/movements/store
|
||||
$routes->post('store', 'View\InventoryController::movementsStore', ['filter' => 'auth:create_inventory|administrator|administrative staff|principal,create']); // /inventory/movements/store
|
||||
$routes->get('edit/(:num)', 'View\InventoryController::movementsEdit/$1'); // /inventory/movements/edit/123
|
||||
$routes->post('update/(:num)', 'View\InventoryController::movementsUpdate/$1'); // /inventory/movements/update/123
|
||||
$routes->post('delete/(:num)', 'View\InventoryController::movementsDelete/$1'); // /inventory/movements/delete/123
|
||||
$routes->post('bulk-delete', 'View\InventoryController::movementsBulkDelete');
|
||||
$routes->post('update/(:num)', 'View\InventoryController::movementsUpdate/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']); // /inventory/movements/update/123
|
||||
$routes->post('delete/(:num)', 'View\InventoryController::movementsDelete/$1', ['filter' => 'auth:delete_inventory|administrator|administrative staff|principal,delete']); // /inventory/movements/delete/123
|
||||
$routes->post('bulk-delete', 'View\InventoryController::movementsBulkDelete', ['filter' => 'auth:delete_inventory|administrator|administrative staff|principal,delete']);
|
||||
});
|
||||
|
||||
/** ---------------------------
|
||||
@@ -938,19 +924,21 @@ $routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
|
||||
// Summary & actions (keep these BEFORE the wildcard too)
|
||||
$routes->get('summary-all', 'View\InventoryController::summaryAll');
|
||||
$routes->get('adjust/(:num)', 'View\InventoryController::adjustForm/$1');
|
||||
$routes->post('adjust/(:num)', 'View\InventoryController::adjustStore/$1');
|
||||
$routes->post('adjust/(:num)', 'View\InventoryController::adjustStore/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||
$routes->get('books/distribute', 'View\InventoryController::teacherDistributeForm');
|
||||
$routes->post('books/distribute', 'View\InventoryController::teacherDistributeStore');
|
||||
$routes->post('books/distribute', 'View\InventoryController::teacherDistributeStore', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||
$routes->get('classroom/audit/(:num)', 'View\InventoryController::auditClassroomForm/$1');
|
||||
$routes->post('classroom/audit/(:num)', 'View\InventoryController::auditClassroomStore/$1');
|
||||
$routes->post('classroom/audit/(:num)', 'View\InventoryController::auditClassroomStore/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||
|
||||
// CRUD for items/categories
|
||||
$routes->get('create/(:alpha)', 'View\InventoryController::create/$1');
|
||||
$routes->post('store', 'View\InventoryController::store');
|
||||
$routes->post('store', 'View\InventoryController::store', ['filter' => 'auth:create_inventory|administrator|administrative staff|principal,create']);
|
||||
$routes->get('edit/(:num)', 'View\InventoryController::edit/$1');
|
||||
$routes->post('update/(:num)', 'View\InventoryController::update/$1');
|
||||
$routes->post('delete/(:num)', 'View\InventoryController::delete/$1');
|
||||
$routes->post('category/save', 'View\InventoryController::saveCategory');
|
||||
$routes->post('update/(:num)', 'View\InventoryController::update/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||
$routes->post('delete/(:num)', 'View\InventoryController::delete/$1', ['filter' => 'auth:delete_inventory|administrator|administrative staff|principal,delete']);
|
||||
$routes->post('category/save', 'View\InventoryController::saveCategory', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||
$routes->post('po/receive/(:num)', 'View\PurchaseOrderController::receive/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||
$routes->post('po/receipt/reverse/(:num)', 'View\PurchaseOrderController::reverseReceipt/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
|
||||
|
||||
/** ---------------------------
|
||||
* Index & section switcher
|
||||
@@ -1219,14 +1207,17 @@ $routes->get('/help_center', 'View\PageController::helpCenter');
|
||||
|
||||
//payment
|
||||
$routes->get('/payment', 'View\PaymentController::redirectPage', ['filter' => 'auth:parent']);
|
||||
$routes->get('/payment/manual', 'View\PaymentController::manual');
|
||||
$routes->post('/payment/manual', 'View\PaymentController::manual');
|
||||
$routes->get('/payment/manual', 'View\PaymentController::manual', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
|
||||
$routes->post('/payment/manual', 'View\PaymentController::manual', ['filter' => 'auth:create_payment|create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
|
||||
|
||||
// Voucher management
|
||||
$routes->get('discounts/list', 'View\DiscountController::listVouchers', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||
$routes->match(['get', 'post'], 'discount/create', 'View\DiscountController::createVoucher', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
|
||||
$routes->match(['get', 'post'], 'discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->match(['get', 'post'], 'discount/apply', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->get('discount/create', 'View\DiscountController::createVoucher', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||
$routes->post('discount/create', 'View\DiscountController::createVoucher', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
|
||||
$routes->get('discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||
$routes->post('discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
$routes->get('discount/voucher-form', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal']);
|
||||
$routes->post('discount/apply', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
|
||||
|
||||
|
||||
|
||||
@@ -1370,14 +1361,11 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
||||
$routes->get('payments', 'View\UserController::index');
|
||||
$routes->get('payments/(:num)', 'View\SupportController::show/$1');
|
||||
$routes->get('payments/parent/(:num)', 'View\SupportController::getByParent/$1');
|
||||
$routes->post('payments', 'View\TeacherController::create');
|
||||
$routes->put('payments/(:num)', 'View\TeacherController::update/$1');
|
||||
|
||||
// Invoices
|
||||
$routes->get('invoices', 'View\UserController::index');
|
||||
$routes->get('invoices/(:num)', 'View\SupportController::show/$1');
|
||||
$routes->get('invoices/parent/(:num)', 'View\SupportController::getByParent/$1');
|
||||
$routes->put('invoices/(:num)', 'View\TeacherController::update/$1');
|
||||
|
||||
// Notifications
|
||||
$routes->get('notifications', 'View\UserController::index');
|
||||
@@ -1398,14 +1386,10 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
||||
// Expenses
|
||||
$routes->get('expenses', 'View\UserController::index');
|
||||
$routes->get('expenses/(:num)', 'View\SupportController::show/$1');
|
||||
$routes->post('expenses', 'View\TeacherController::create');
|
||||
$routes->put('expenses/(:num)', 'View\TeacherController::update/$1');
|
||||
|
||||
// Reimbursements
|
||||
$routes->get('reimbursements', 'View\UserController::index');
|
||||
$routes->get('reimbursements/(:num)', 'View\SupportController::show/$1');
|
||||
$routes->post('reimbursements', 'View\TeacherController::create');
|
||||
$routes->put('reimbursements/(:num)', 'View\TeacherController::update/$1');
|
||||
|
||||
// Dashboard Data
|
||||
$routes->get('dashboard', 'DashboardController::index');
|
||||
@@ -1471,19 +1455,10 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
||||
// Refunds
|
||||
$routes->get('refunds', 'View\UserController::index');
|
||||
$routes->get('refunds/(:num)', 'View\SupportController::show/$1');
|
||||
$routes->post('refunds', 'View\TeacherController::create');
|
||||
$routes->put('refunds/(:num)', 'View\TeacherController::update/$1');
|
||||
|
||||
// Discounts
|
||||
$routes->get('discounts', 'View\UserController::index');
|
||||
$routes->get('discounts/code/(:segment)', 'View\SupportController::getByCode/$1');
|
||||
$routes->post('discounts/apply', 'View\SupportController::apply');
|
||||
|
||||
// Payment Transactions
|
||||
$routes->get('payment-transactions', 'View\UserController::index');
|
||||
$routes->get('payment-transactions/(:num)', 'View\SupportController::show/$1');
|
||||
$routes->get('payment-transactions/payment/(:num)', 'View\SupportController::getByPayment/$1');
|
||||
$routes->post('payment-transactions', 'View\TeacherController::create');
|
||||
|
||||
// Emergency Contacts
|
||||
$routes->get('emergency-contacts', 'View\UserController::index');
|
||||
@@ -1529,9 +1504,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
||||
// Extra Charges
|
||||
$routes->get('extra-charges', 'View\UserController::index');
|
||||
$routes->get('extra-charges/(:num)', 'View\SupportController::show/$1');
|
||||
$routes->post('extra-charges', 'View\TeacherController::create');
|
||||
$routes->put('extra-charges/(:num)', 'View\TeacherController::update/$1');
|
||||
$routes->post('extra-charges/(:num)/void', 'View\SupportController::void/$1');
|
||||
$routes->get('extra-charges/parents', 'View\SupportController::parentOptions');
|
||||
|
||||
// Flags
|
||||
@@ -1582,7 +1554,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
||||
|
||||
// Payment Notifications
|
||||
$routes->get('payment-notifications', 'View\UserController::index');
|
||||
$routes->post('payment-notifications/send', 'View\SupportController::send');
|
||||
|
||||
// RFID
|
||||
$routes->post('rfid/process', 'View\SupportController::process');
|
||||
@@ -1599,9 +1570,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
||||
// Inventory
|
||||
$routes->get('inventory', 'View\UserController::index');
|
||||
$routes->get('inventory/(:num)', 'View\SupportController::show/$1');
|
||||
$routes->post('inventory', 'View\TeacherController::create');
|
||||
$routes->put('inventory/(:num)', 'View\TeacherController::update/$1');
|
||||
$routes->delete('inventory/(:num)', 'View\TeacherController::delete/$1');
|
||||
$routes->get('inventory/movements', 'View\SupportController::movements');
|
||||
|
||||
// Preferences
|
||||
@@ -1653,8 +1621,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
|
||||
// Purchase Orders
|
||||
$routes->get('purchase-orders', 'View\UserController::index');
|
||||
$routes->get('purchase-orders/(:num)', 'View\SupportController::show/$1');
|
||||
$routes->post('purchase-orders', 'View\TeacherController::create');
|
||||
$routes->put('purchase-orders/(:num)', 'View\TeacherController::update/$1');
|
||||
|
||||
// Grading
|
||||
$routes->get('grading/(:segment)/(:num)/(:num)', 'View\SupportController::show/$1/$2/$3');
|
||||
|
||||
@@ -22,6 +22,7 @@ use App\Models\StudentClassModel;
|
||||
use App\Models\StudentSectionDistributionDraftModel;
|
||||
use App\Controllers\View\EmailController;
|
||||
use App\Controllers\View\InvoiceController;
|
||||
use App\Libraries\RefundEligibilityService;
|
||||
use App\Models\StaffAttendanceModel;
|
||||
use App\Libraries\StaffTimeOffLinkService;
|
||||
use App\Models\AttendanceDayModel;
|
||||
@@ -2855,19 +2856,51 @@ class AdministratorController extends BaseController
|
||||
$existingRefund = $this->refundModel->where('invoice_id', $invoice['id'])->first();
|
||||
|
||||
if ($existingRefund) {
|
||||
$this->refundModel->update($existingRefund['id'], [
|
||||
'refund_amount' => $refundAmount,
|
||||
'status' => 'Pending',
|
||||
$refundId = (int)$existingRefund['id'];
|
||||
$status = strtolower((string)($existingRefund['status'] ?? ''));
|
||||
$isApprovedState = in_array($status, ['approved', 'partial', 'paid', 'partially_paid'], true);
|
||||
$calculatedCents = max(0, (int)round($refundAmount * 100));
|
||||
$paidCents = (new RefundEligibilityService())->getCompletedPayoutTotalCentsForRefund($refundId);
|
||||
$targetCents = $isApprovedState ? max($calculatedCents, $paidCents) : $calculatedCents;
|
||||
$update = [
|
||||
'refund_amount' => $targetCents / 100,
|
||||
'updated_by' => session()->get('user_id') ?? null,
|
||||
]);
|
||||
];
|
||||
if ($isApprovedState) {
|
||||
$update['approved_amount_cents'] = $targetCents;
|
||||
} else {
|
||||
$update['status'] = 'Pending';
|
||||
$update['requested_amount_cents'] = $targetCents;
|
||||
}
|
||||
if ($isApprovedState && $paidCents > $calculatedCents) {
|
||||
$message = sprintf(
|
||||
'Completed payouts (%0.2f) exceed recalculated refundable credit (%0.2f).',
|
||||
$paidCents / 100,
|
||||
$calculatedCents / 100
|
||||
);
|
||||
$update['reconciliation_status'] = 'requires_review';
|
||||
$update['reconciliation_reason'] = $message;
|
||||
$update['reconciliation_required_at'] = utc_now();
|
||||
log_message('critical', 'Refund reconciliation required for refund #' . $refundId . ': ' . $message);
|
||||
} else {
|
||||
$update['reconciliation_status'] = null;
|
||||
$update['reconciliation_reason'] = null;
|
||||
$update['reconciliation_required_at'] = null;
|
||||
}
|
||||
$this->refundModel->update($refundId, $update);
|
||||
} else {
|
||||
$this->refundModel->insert([
|
||||
'parent_id' => $pid,
|
||||
'school_year' => $invoice['school_year'],
|
||||
'invoice_id' => $invoice['id'],
|
||||
'refund_amount' => $refundAmount,
|
||||
'requested_amount_cents' => (int)round($refundAmount * 100),
|
||||
'approved_amount_cents' => null,
|
||||
'currency' => 'USD',
|
||||
'refund_paid_amount' => 0.0,
|
||||
'status' => 'Pending',
|
||||
'source_type' => 'invoice_overpayment',
|
||||
'source_id' => (int)$invoice['id'],
|
||||
'requested_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id') ?? null,
|
||||
]);
|
||||
|
||||
@@ -108,9 +108,41 @@ class DiscountController extends BaseController
|
||||
// Collect invoice IDs that end up fully covered by the voucher in this run
|
||||
$fullyCoveredInvoiceIds = [];
|
||||
$touchedInvoiceIds = [];
|
||||
$pendingEvents = [];
|
||||
$appliedCount = 0;
|
||||
|
||||
$this->db->transStart();
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
$lockedVoucher = $this->db->query('SELECT * FROM discount_vouchers WHERE id = ? FOR UPDATE', [(int)$voucherId])->getRowArray();
|
||||
if (!$lockedVoucher) {
|
||||
throw new \RuntimeException('Voucher not found.');
|
||||
}
|
||||
$today = date('Y-m-d');
|
||||
if ((int)($lockedVoucher['is_active'] ?? 0) !== 1) {
|
||||
throw new \RuntimeException('Voucher is inactive.');
|
||||
}
|
||||
if (!empty($lockedVoucher['valid_from']) && (string)$lockedVoucher['valid_from'] > $today) {
|
||||
throw new \RuntimeException('Voucher is not active yet.');
|
||||
}
|
||||
if (!empty($lockedVoucher['valid_until']) && (string)$lockedVoucher['valid_until'] < $today) {
|
||||
throw new \RuntimeException('Voucher is expired.');
|
||||
}
|
||||
if (!empty($lockedVoucher['school_year']) && (string)$lockedVoucher['school_year'] !== (string)$this->schoolYear) {
|
||||
throw new \RuntimeException('Voucher is not valid for this school year.');
|
||||
}
|
||||
if (!empty($lockedVoucher['semester']) && (string)$lockedVoucher['semester'] !== (string)$this->semester) {
|
||||
throw new \RuntimeException('Voucher is not valid for this semester.');
|
||||
}
|
||||
|
||||
$voucher = $lockedVoucher;
|
||||
$maxUsesRaw = $voucher['max_uses'] ?? null;
|
||||
$maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw;
|
||||
$timesUsed = (int) ($voucher['times_used'] ?? 0);
|
||||
$remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed);
|
||||
if ($remainingUses <= 0) {
|
||||
throw new \RuntimeException('This voucher has reached its maximum allowed uses.');
|
||||
}
|
||||
|
||||
foreach ($parentIds as $parentId) {
|
||||
// Fetch invoices for this parent & school year
|
||||
@@ -125,10 +157,17 @@ class DiscountController extends BaseController
|
||||
if ($remainingUses <= 0) break 2; // out of parentIds loop too
|
||||
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $invoice['id']]);
|
||||
$this->db->query('SELECT id FROM discount_usages WHERE invoice_id = ? FOR UPDATE', [(int)$invoice['id']])->getResultArray();
|
||||
|
||||
// Snapshot current balance BEFORE applying
|
||||
$initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
||||
if ($initialPreBalance <= 0) {
|
||||
$ledgerBefore = $this->invoiceLedgerService->calculateInvoice((int)$invoice['id']);
|
||||
$initialPreBalance = (float)($ledgerBefore['balance'] ?? 0);
|
||||
$eligibleBaseCents = max(
|
||||
0,
|
||||
(int)($ledgerBefore['discount_eligible_base_cents'] ?? 0)
|
||||
- (int)($ledgerBefore['applied_discount_cents'] ?? 0)
|
||||
);
|
||||
if ($eligibleBaseCents <= 0) {
|
||||
log_message(
|
||||
'error',
|
||||
'applyVoucher skip: zero balance | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} balance={bal}',
|
||||
@@ -168,11 +207,14 @@ class DiscountController extends BaseController
|
||||
|
||||
// Calculate discount
|
||||
$rawDiscount = ($voucher['discount_type'] === 'percent')
|
||||
? round(((float)$invoice['total_amount'] * (float)$voucher['discount_value']) / 100, 2)
|
||||
? round(($eligibleBaseCents / 100 * (float)$voucher['discount_value']) / 100, 2)
|
||||
: (float) $voucher['discount_value'];
|
||||
|
||||
// Cap by CURRENT invoice balance snapshot
|
||||
$discount = min($rawDiscount, $initialPreBalance);
|
||||
$requestedDiscountCents = max(0, (int)round($rawDiscount * 100));
|
||||
$appliedDiscountCents = min($requestedDiscountCents, $eligibleBaseCents);
|
||||
$discount = $appliedDiscountCents / 100;
|
||||
|
||||
// Nothing to do if no discount
|
||||
if ($discount <= 0) {
|
||||
@@ -193,7 +235,7 @@ class DiscountController extends BaseController
|
||||
|
||||
// Insert discount usage
|
||||
$now = utc_now();
|
||||
$this->db->table('discount_usages')->insert([
|
||||
$usagePayload = [
|
||||
'voucher_id' => $voucherId,
|
||||
'invoice_id' => $invoice['id'],
|
||||
'parent_id' => $parentId,
|
||||
@@ -204,17 +246,30 @@ class DiscountController extends BaseController
|
||||
'used_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
];
|
||||
if ($this->db->fieldExists('requested_discount_cents', 'discount_usages')) {
|
||||
$usagePayload['requested_discount_cents'] = $requestedDiscountCents;
|
||||
$usagePayload['eligible_base_cents'] = $eligibleBaseCents;
|
||||
$usagePayload['eligible_base_before_cents'] = $eligibleBaseCents;
|
||||
$usagePayload['applied_discount_cents'] = $appliedDiscountCents;
|
||||
$usagePayload['application_order'] = $this->nextDiscountApplicationOrder((int)$invoice['id']);
|
||||
}
|
||||
if (!$this->db->table('discount_usages')->insert($usagePayload)) {
|
||||
throw new \RuntimeException('Discount usage could not be recorded.');
|
||||
}
|
||||
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice((int) $invoice['id']);
|
||||
$postBalance = (float) ($ledger['balance'] ?? 0.0);
|
||||
$currentBalance = $postBalance;
|
||||
|
||||
// Increment voucher usage
|
||||
$this->db->table('discount_vouchers')
|
||||
$updatedVoucher = $this->db->table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->set('times_used', 'COALESCE(times_used,0) + 1', false)
|
||||
->update();
|
||||
if (!$updatedVoucher) {
|
||||
throw new \RuntimeException('Voucher usage could not be updated.');
|
||||
}
|
||||
|
||||
// Prepare and trigger payment event
|
||||
[$eventData, $studentData] = $this->buildPaymentEventData(
|
||||
@@ -228,7 +283,7 @@ class DiscountController extends BaseController
|
||||
$initialPreBalance, // pre-payment snapshot
|
||||
$postBalance // computed post-payment
|
||||
);
|
||||
Events::trigger('paymentReceived', $eventData, $studentData);
|
||||
$pendingEvents[] = [$eventData, $studentData];
|
||||
|
||||
$touchedInvoiceIds[] = (int) $invoice['id'];
|
||||
$appliedCount++;
|
||||
@@ -248,20 +303,27 @@ class DiscountController extends BaseController
|
||||
|
||||
// Deactivate if we just hit the cap
|
||||
if ($remainingUses <= 0 && $maxUses !== null) {
|
||||
$this->db->table('discount_vouchers')
|
||||
$deactivated = $this->db->table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->update([
|
||||
'is_active' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
if (!$deactivated) {
|
||||
throw new \RuntimeException('Voucher could not be deactivated.');
|
||||
}
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if ($this->db->transStatus() === false) {
|
||||
if ($this->db->transStatus() === false) {
|
||||
throw new \RuntimeException('Voucher transaction failed.');
|
||||
}
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'Voucher application failed: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
|
||||
return redirect()->back()->with('error', 'Voucher application failed. Transaction rolled back.');
|
||||
}
|
||||
|
||||
@@ -287,6 +349,10 @@ class DiscountController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pendingEvents as [$eventData, $studentData]) {
|
||||
Events::trigger('paymentReceived', $eventData, $studentData);
|
||||
}
|
||||
|
||||
// ✅ AFTER COMMIT: recalculate invoice totals/balance/paid/discount/refund
|
||||
foreach (array_unique($touchedInvoiceIds) as $iid) {
|
||||
try {
|
||||
@@ -632,6 +698,17 @@ class DiscountController extends BaseController
|
||||
return 999;
|
||||
}
|
||||
|
||||
private function nextDiscountApplicationOrder(int $invoiceId): int
|
||||
{
|
||||
$row = $this->db->table('discount_usages')
|
||||
->select('COALESCE(MAX(application_order),0) + 1 AS next_order', false)
|
||||
->where('invoice_id', $invoiceId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return max(1, (int)($row['next_order'] ?? 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect parent/invoice/payment data to trigger handlePaymentReceived().
|
||||
*
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Models\ParentModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\CalendarModel;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use App\Services\EmailService;
|
||||
use Config\Database;
|
||||
use App\Controllers\View\InvoiceController;
|
||||
@@ -43,6 +44,7 @@ class EventController extends ResourceController
|
||||
protected $semester;
|
||||
protected $categories;
|
||||
protected $enrollmentModel;
|
||||
protected $invoiceLedgerService;
|
||||
private ?bool $eventChargesHasCreatedBy = null;
|
||||
private ?bool $eventChargesHasWaiverSigned = null;
|
||||
|
||||
@@ -62,6 +64,7 @@ class EventController extends ResourceController
|
||||
$this->enrollmentModel = new EnrollmentModel();
|
||||
$this->parentModel = new ParentModel();
|
||||
$this->emailService = new EmailService();
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
|
||||
$this->schoolYear = $this->currentSchoolYearName();
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
@@ -1050,77 +1053,11 @@ class EventController extends ResourceController
|
||||
return;
|
||||
}
|
||||
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
|
||||
$paidSum = 0.0;
|
||||
try {
|
||||
$qb = $db->table('payments')
|
||||
->select('COALESCE(SUM(paid_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('paid_amount >', 0);
|
||||
|
||||
if ($db->fieldExists('status', 'payments')) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', $exclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($db->fieldExists('is_void', 'payments')) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$row = $qb->get()->getRowArray();
|
||||
$paidSum = (float)($row['tot'] ?? 0.0);
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to sum payments for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
log_message('error', 'Failed to recalculate invoice ledger for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$discountSum = 0.0;
|
||||
try {
|
||||
$row = $db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
$discountSum = (float)($row['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to sum discounts for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$refundSum = 0.0;
|
||||
try {
|
||||
$row = $db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->get()
|
||||
->getRowArray();
|
||||
$refundSum = (float)($row['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to sum refunds for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$total = (float)($invoice['total_amount'] ?? 0.0);
|
||||
$newBalance = round($total - $discountSum - $refundSum - $paidSum, 2);
|
||||
$newStatus = ($newBalance <= 0.00001)
|
||||
? 'Paid'
|
||||
: (($paidSum > 0) ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
$this->invoiceModel->update($invoiceId, [
|
||||
'paid_amount' => $paidSum,
|
||||
'balance' => $newBalance,
|
||||
'status' => $newStatus,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function applyEventPaymentStatus(int $chargeId, bool $isPaid): ?array
|
||||
@@ -1171,9 +1108,7 @@ class EventController extends ResourceController
|
||||
$eventAmount,
|
||||
$paymentSchoolYear,
|
||||
$paymentSemester,
|
||||
(float)($invoice['total_amount'] ?? 0.0),
|
||||
(float)($invoice['paid_amount'] ?? 0.0),
|
||||
(float)($invoice['balance'] ?? 0.0)
|
||||
(float)($invoice['total_amount'] ?? 0.0)
|
||||
) ?? 0);
|
||||
} elseif (!$isPaid && $paymentId > 0) {
|
||||
$this->voidPayment($paymentId, 'Event charge marked unpaid.');
|
||||
@@ -1250,16 +1185,13 @@ class EventController extends ResourceController
|
||||
float $amount,
|
||||
string $schoolYear,
|
||||
?string $semester,
|
||||
float $invoiceTotal = 0.0,
|
||||
float $invoicePaid = 0.0,
|
||||
float $invoiceBalance = 0.0
|
||||
float $invoiceTotal = 0.0
|
||||
): ?int
|
||||
{
|
||||
if ($amount <= 0 || $invoiceId <= 0 || $parentId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$newBalance = max(0.0, round((float)$invoiceBalance - $amount, 2));
|
||||
$row = $this->paymentModel->db->table('payments')
|
||||
->select('COALESCE(MAX(installment_seq), 0) + 1 AS next_seq', false)
|
||||
->where('invoice_id', $invoiceId)
|
||||
@@ -1272,7 +1204,7 @@ class EventController extends ResourceController
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => $invoiceTotal > 0 ? $invoiceTotal : $amount,
|
||||
'paid_amount' => $amount,
|
||||
'balance' => $newBalance,
|
||||
'balance' => null,
|
||||
'number_of_installments' => $installmentSeq,
|
||||
'installment_seq' => $installmentSeq,
|
||||
'payment_method' => 'cash',
|
||||
@@ -1347,13 +1279,9 @@ class EventController extends ResourceController
|
||||
->findAll();
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
$status = strtolower(trim($invoice['status'] ?? ''));
|
||||
$balance = (float)($invoice['balance'] ?? 0.0);
|
||||
|
||||
if ($balance <= 0.00001 && $status !== 'paid') {
|
||||
$this->invoiceModel->update($invoice['id'], ['status' => 'Paid']);
|
||||
} elseif ($status === 'paid' && $balance > 0) {
|
||||
$this->invoiceModel->update($invoice['id'], ['status' => 'Unpaid']);
|
||||
$invoiceId = (int)($invoice['id'] ?? 0);
|
||||
if ($invoiceId > 0) {
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\FinancialAttachmentService;
|
||||
use App\Models\ExpenseModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
@@ -12,6 +13,7 @@ class ExpenseController extends BaseController
|
||||
protected $expenseModel;
|
||||
protected $userModel;
|
||||
protected $configModel;
|
||||
protected FinancialAttachmentService $financialAttachmentService;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $retailors;
|
||||
@@ -21,6 +23,7 @@ class ExpenseController extends BaseController
|
||||
$this->expenseModel = new ExpenseModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->financialAttachmentService = new FinancialAttachmentService();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
@@ -135,19 +138,19 @@ class ExpenseController extends BaseController
|
||||
// Optional extra fields
|
||||
'retailor' => 'permit_empty|max_length[255]',
|
||||
'date_of_purchase' => 'permit_empty',
|
||||
// allow JPG/JPEG/PNG/WEBP/GIF and PDF up to 2MB
|
||||
// allow JPG/JPEG/PNG and PDF up to 2MB
|
||||
'receipt' => 'uploaded[receipt]'
|
||||
. '|max_size[receipt,2048]'
|
||||
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
|
||||
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]',
|
||||
. '|max_size[receipt,5120]'
|
||||
. '|ext_in[receipt,jpg,jpeg,png,pdf]'
|
||||
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,application/pdf]',
|
||||
];
|
||||
|
||||
$messages = [
|
||||
'receipt' => [
|
||||
'uploaded' => 'Receipt file is required.',
|
||||
'max_size' => 'Maximum file size is 2MB.',
|
||||
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
||||
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
||||
'max_size' => 'Maximum file size is 5MB.',
|
||||
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, or PDF.',
|
||||
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, or PDF.',
|
||||
]
|
||||
];
|
||||
|
||||
@@ -170,24 +173,32 @@ class ExpenseController extends BaseController
|
||||
$purchasedById = (int) $purchasedById;
|
||||
|
||||
// School context
|
||||
$schoolYear = $this->schoolYear ?: date('Y');
|
||||
$schoolYear = (string)$this->schoolYear;
|
||||
if (!preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Invalid school year configuration. Expected YYYY-YYYY.');
|
||||
}
|
||||
$semester = $this->semester ?: 'Fall';
|
||||
|
||||
// Handle upload: store under writable/uploads/receipts and save only the filename
|
||||
$receiptName = null;
|
||||
$file = $this->request->getFile('receipt');
|
||||
if ($file && $file->isValid() && !$file->hasMoved()) {
|
||||
$stored = $file->store('receipts'); // -> writable/uploads/receipts/<randomname>.ext
|
||||
$receiptName = basename($stored);
|
||||
$stagedReceipt = null;
|
||||
try {
|
||||
$stagedReceipt = $this->financialAttachmentService->stageUploadedFile(
|
||||
$this->request->getFile('receipt'),
|
||||
'receipts'
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$status = $isDonation ? 'approved' : 'pending';
|
||||
$statusReason = $isDonation ? 'Marked as Donation (non-reimbursable).' : null;
|
||||
|
||||
$this->expenseModel->insert([
|
||||
$db = \Config\Database::connect();
|
||||
$db->transBegin();
|
||||
try {
|
||||
$expenseId = (int)$this->expenseModel->insert([
|
||||
'category' => $category,
|
||||
'amount' => $amount,
|
||||
'receipt_path' => $receiptName, // filename only
|
||||
'receipt_path' => null,
|
||||
'description' => $description,
|
||||
'retailor' => ($retailor !== '') ? $retailor : null,
|
||||
'date_of_purchase' => ($datePurchase !== '') ? $datePurchase : null,
|
||||
@@ -198,7 +209,33 @@ class ExpenseController extends BaseController
|
||||
'approved_by' => $isDonation ? $userId : null,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
]);
|
||||
if ($expenseId <= 0) {
|
||||
throw new \RuntimeException('Expense insert failed.');
|
||||
}
|
||||
if ($db->transStatus() === false) {
|
||||
throw new \RuntimeException('Expense transaction failed.');
|
||||
}
|
||||
$db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
|
||||
log_message('error', 'Expense creation failed: ' . $e->getMessage());
|
||||
return redirect()->back()->withInput()->with('error', 'Expense could not be saved.');
|
||||
}
|
||||
|
||||
if ($stagedReceipt !== null) {
|
||||
try {
|
||||
$receiptName = $this->financialAttachmentService->finalizeStagedFile($stagedReceipt);
|
||||
if (!$this->expenseModel->update($expenseId, ['receipt_path' => $receiptName])) {
|
||||
throw new \RuntimeException('Expense receipt update failed.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
|
||||
log_message('critical', 'Expense receipt incomplete for expense #' . $expenseId . ': ' . $e->getMessage());
|
||||
return redirect()->to('/expenses/index')->with('error', 'Expense saved, but receipt could not be finalized. Operations must review.');
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to('/expenses/index')->with('success', 'Record added successfully!');
|
||||
}
|
||||
@@ -217,18 +254,36 @@ class ExpenseController extends BaseController
|
||||
return $this->response->setJSON(['error' => 'Invalid data']);
|
||||
}
|
||||
|
||||
$expense = $this->expenseModel->find($id);
|
||||
if (!$expense) {
|
||||
log_message('error', 'Expense not found for ID ' . $id);
|
||||
return $this->response->setJSON(['error' => 'Expense not found']);
|
||||
}
|
||||
$db = \Config\Database::connect();
|
||||
$db->transBegin();
|
||||
try {
|
||||
$expense = $db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$id])->getRowArray();
|
||||
if (!$expense) {
|
||||
throw new \RuntimeException('Expense not found');
|
||||
}
|
||||
$db->query(
|
||||
"SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE",
|
||||
[$id]
|
||||
)->getResultArray();
|
||||
if ($this->hasActiveReimbursement($expense)) {
|
||||
throw new \RuntimeException('Expense status cannot change after reimbursement without reversal.');
|
||||
}
|
||||
|
||||
$success = $this->expenseModel->update($id, [
|
||||
'status' => $status,
|
||||
'status_reason' => $reason,
|
||||
'approved_by' => $userId,
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
$success = $this->expenseModel->update($id, [
|
||||
'status' => $status === 'denied' ? 'rejected' : $status,
|
||||
'status_reason' => $reason,
|
||||
'approved_by' => $status === 'approved' ? $userId : null,
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
if (!$success || $db->transStatus() === false) {
|
||||
throw new \RuntimeException('Update failed');
|
||||
}
|
||||
$db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->transRollback();
|
||||
log_message('error', 'Expense status update failed for ID ' . $id . ': ' . $e->getMessage());
|
||||
return $this->response->setJSON(['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
if (!$success) {
|
||||
log_message('error', 'Expense update failed for ID ' . $id);
|
||||
@@ -260,6 +315,10 @@ class ExpenseController extends BaseController
|
||||
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
||||
}
|
||||
|
||||
if ($this->hasActiveReimbursement($expense)) {
|
||||
return redirect()->back()->with('error', 'Reimbursed expenses are immutable. Reverse the reimbursement and create a replacement expense.');
|
||||
}
|
||||
|
||||
// same user list you use in create()
|
||||
$users = $this->staffUsers();
|
||||
|
||||
@@ -308,14 +367,13 @@ class ExpenseController extends BaseController
|
||||
$isDonation = ($category === 'Donation');
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
// Keep old receipt unless replaced or removed
|
||||
$receiptName = $expense['receipt_path'];
|
||||
$stagedReceipt = null;
|
||||
if ($file && $file->isValid() && !$file->hasMoved() && ($file->getSize() ?? 0) > 0) {
|
||||
$stored = $file->store('receipts');
|
||||
$receiptName = basename($stored);
|
||||
}
|
||||
if ($this->request->getPost('remove_receipt') === '1') {
|
||||
$receiptName = null;
|
||||
try {
|
||||
$stagedReceipt = $this->financialAttachmentService->stageUploadedFile($file, 'receipts');
|
||||
} catch (\RuntimeException $e) {
|
||||
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
@@ -325,9 +383,11 @@ class ExpenseController extends BaseController
|
||||
'retailor' => trim((string) $this->request->getPost('retailor')) ?: null,
|
||||
'date_of_purchase' => (string) $this->request->getPost('date_of_purchase') ?: null,
|
||||
'purchased_by' => $purchasedById,
|
||||
'receipt_path' => $receiptName,
|
||||
'updated_by' => $userId,
|
||||
];
|
||||
if ($this->request->getPost('remove_receipt') === '1') {
|
||||
$updateData['receipt_path'] = null;
|
||||
}
|
||||
|
||||
if ($isDonation) {
|
||||
$updateData['status'] = 'approved';
|
||||
@@ -335,14 +395,89 @@ class ExpenseController extends BaseController
|
||||
$updateData['approved_by'] = $userId ?: null;
|
||||
$updateData['reimbursement_id'] = null;
|
||||
} elseif (($expense['category'] ?? '') === 'Donation') {
|
||||
// Moving a donation back to a reimbursable category: clear the marker.
|
||||
// Moving a donation back to a reimbursable category must re-enter approval.
|
||||
$updateData['status_reason'] = null;
|
||||
$updateData['approved_by'] = $expense['approved_by'] ?? null;
|
||||
$updateData['status'] = $expense['status'] ?? 'pending';
|
||||
$updateData['approved_by'] = null;
|
||||
$updateData['status'] = 'pending';
|
||||
}
|
||||
|
||||
$this->expenseModel->update($id, $updateData);
|
||||
$db = \Config\Database::connect();
|
||||
$db->transBegin();
|
||||
try {
|
||||
$lockedExpense = $db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$id])->getRowArray();
|
||||
if (!$lockedExpense) {
|
||||
throw new \RuntimeException('Expense not found.');
|
||||
}
|
||||
$activeReimbursements = $db->query(
|
||||
"SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE",
|
||||
[$id]
|
||||
)->getResultArray();
|
||||
if ($activeReimbursements !== []) {
|
||||
$protectedChanged = (
|
||||
(float)$lockedExpense['amount'] !== (float)$updateData['amount']
|
||||
|| (string)$lockedExpense['category'] !== (string)$updateData['category']
|
||||
|| (int)$lockedExpense['purchased_by'] !== (int)$updateData['purchased_by']
|
||||
|| array_key_exists('receipt_path', $updateData)
|
||||
);
|
||||
if ($protectedChanged) {
|
||||
throw new \RuntimeException('Reimbursed expenses are immutable. Reverse the reimbursement and create a replacement expense.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->expenseModel->update($id, $updateData) || $db->transStatus() === false) {
|
||||
throw new \RuntimeException('Expense update failed.');
|
||||
}
|
||||
$db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
|
||||
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
if ($stagedReceipt !== null) {
|
||||
try {
|
||||
$receiptName = $this->financialAttachmentService->finalizeStagedFile($stagedReceipt);
|
||||
if (!$this->expenseModel->update($id, ['receipt_path' => $receiptName])) {
|
||||
throw new \RuntimeException('Expense receipt update failed.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
|
||||
log_message('critical', 'Expense receipt replacement incomplete for expense #' . $id . ': ' . $e->getMessage());
|
||||
return redirect()->to('/expenses/index')->with('error', 'Expense updated, but receipt could not be finalized. Operations must review.');
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to('/expenses/index')->with('success', 'Expense updated.');
|
||||
}
|
||||
|
||||
private function hasActiveReimbursement(array $expense): bool
|
||||
{
|
||||
$expenseId = (int) ($expense['id'] ?? 0);
|
||||
if ($expenseId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($expense['reimbursement_id'])) {
|
||||
$row = \Config\Database::connect()
|
||||
->table('reimbursements')
|
||||
->select('id')
|
||||
->where('id', (int) $expense['reimbursement_id'])
|
||||
->where("LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')", null, false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if ($row) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$row = \Config\Database::connect()
|
||||
->table('reimbursements')
|
||||
->select('id')
|
||||
->where('expense_id', $expenseId)
|
||||
->where("LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')", null, false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return $row !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Libraries\InvoiceAdjustmentService;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use CodeIgniter\Controller;
|
||||
@@ -31,6 +32,7 @@ class ExtraChargesController extends BaseController
|
||||
protected $enableAttendance;
|
||||
protected $attendanceDayModel;
|
||||
protected $invoiceLedgerService;
|
||||
protected InvoiceAdjustmentService $invoiceAdjustmentService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -43,6 +45,7 @@ class ExtraChargesController extends BaseController
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
$this->invoiceAdjustmentService = new InvoiceAdjustmentService($this->db);
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -216,16 +219,19 @@ class ExtraChargesController extends BaseController
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
if (($row['status'] ?? '') === FinancialStatus::ADDITIONAL_CHARGE_APPLIED) {
|
||||
$message = 'Applied charges are immutable. Void and create an adjustment instead.';
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $message, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->with('error', $message);
|
||||
}
|
||||
|
||||
$data = $this->request->getPost();
|
||||
$newAmount = isset($data['amount']) ? (float)$data['amount'] : (float)$row['amount'];
|
||||
$delta = $newAmount - (float)$row['amount'];
|
||||
$newAmount = isset($data['amount']) ? round(abs((float)$data['amount']), 2) : round(abs((float)$row['amount']), 2);
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$db->transStart();
|
||||
|
||||
// Update the charge first
|
||||
$this->additionalChargeModel->update($id, [
|
||||
$updated = $this->additionalChargeModel->update($id, [
|
||||
'title' => trim($data['title'] ?? $row['title']),
|
||||
'description' => trim($data['description'] ?? $row['description']),
|
||||
'amount' => $newAmount,
|
||||
@@ -233,6 +239,11 @@ class ExtraChargesController extends BaseController
|
||||
'charge_type' => $data['charge_type'] ?? $row['charge_type'],
|
||||
// keep status as-is
|
||||
]);
|
||||
if (!$updated) {
|
||||
$db->transRollback();
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to update charge', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->with('error', 'Failed to update charge.');
|
||||
}
|
||||
|
||||
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']]);
|
||||
@@ -314,22 +325,30 @@ class ExtraChargesController extends BaseController
|
||||
}
|
||||
|
||||
$invoiceId = !empty($data['invoice_id']) ? (int)$data['invoice_id'] : null;
|
||||
$invoice = null;
|
||||
if ($invoiceId !== null) {
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
$msg = 'Invoice not found for charge.';
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $msg, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->withInput()->with('error', $msg);
|
||||
}
|
||||
}
|
||||
$chargeType = (string)$data['charge_type'];
|
||||
|
||||
$amountAbs = round(abs((float)$data['amount']), 2);
|
||||
$signedAmount = ($chargeType === 'add') ? $amountAbs : -$amountAbs;
|
||||
|
||||
$payload = [
|
||||
'parent_id' => (int)$data['parent_id'], // ← users.id of the parent
|
||||
'parent_id' => $invoice ? (int)$invoice['parent_id'] : (int)$data['parent_id'],
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => (string)$this->semester,
|
||||
'school_year' => $invoice ? (string)$invoice['school_year'] : $schoolYear,
|
||||
'semester' => $invoice ? (string)($invoice['semester'] ?? $this->semester) : (string)$this->semester,
|
||||
'charge_type' => $chargeType,
|
||||
'title' => trim($data['title']),
|
||||
'description' => trim($data['description'] ?? ''),
|
||||
'amount' => $signedAmount,
|
||||
'amount' => $amountAbs,
|
||||
'due_date' => !empty($data['due_date']) ? $data['due_date'] : null,
|
||||
'status' => $invoiceId ? FinancialStatus::ADDITIONAL_CHARGE_APPLIED : FinancialStatus::ADDITIONAL_CHARGE_PENDING,
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_PENDING,
|
||||
'created_by' => (int)(session()->get('user_id') ?? 0),
|
||||
'created_at' => \CodeIgniter\I18n\Time::now('UTC')->toDateTimeString(), // store UTC
|
||||
];
|
||||
@@ -337,22 +356,22 @@ class ExtraChargesController extends BaseController
|
||||
$this->db->transStart();
|
||||
|
||||
// BEFORE
|
||||
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
|
||||
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($payload['parent_id'], $payload['school_year']);
|
||||
|
||||
// Insert charge
|
||||
$this->additionalChargeModel->insert($payload);
|
||||
$chargeId = (int)$this->additionalChargeModel->getInsertID();
|
||||
|
||||
if ($invoiceId) {
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
$chargeId = (int)$this->additionalChargeModel->insert($payload);
|
||||
if ($chargeId <= 0) {
|
||||
$this->db->transRollback();
|
||||
$msg = 'Failed to save charge.';
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $msg, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->withInput()->with('error', $msg);
|
||||
}
|
||||
|
||||
// AFTER
|
||||
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
|
||||
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($payload['parent_id'], $payload['school_year']);
|
||||
|
||||
// Parent USER (not parent table)
|
||||
$parentUser = $this->userModel->getUserInfoById($data['parent_id']);
|
||||
$parentUser = $this->userModel->getUserInfoById($payload['parent_id']);
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
@@ -399,7 +418,7 @@ class ExtraChargesController extends BaseController
|
||||
'charge_title' => $payload['title'],
|
||||
'charge_desc' => $payload['description'],
|
||||
'charge_type' => $payload['charge_type'], // add|deduct
|
||||
'amount_signed' => $signedAmount,
|
||||
'amount_signed' => $chargeType === 'add' ? $amountAbs : -$amountAbs,
|
||||
'amount_abs' => $amountAbs,
|
||||
'due_date' => $payload['due_date'],
|
||||
'created_at' => $payload['created_at'],
|
||||
@@ -422,7 +441,7 @@ class ExtraChargesController extends BaseController
|
||||
'ok' => true,
|
||||
'id' => $chargeId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'parent_id' => (int)$data['parent_id'],
|
||||
'parent_id' => $payload['parent_id'],
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
@@ -430,6 +449,61 @@ class ExtraChargesController extends BaseController
|
||||
return redirect()->to(site_url('admin/charges'))->with('status', 'Charge recorded.');
|
||||
}
|
||||
|
||||
public function approve($id)
|
||||
{
|
||||
$row = $this->additionalChargeModel->find((int)$id);
|
||||
if (!$row) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
if (($row['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_PENDING) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Only pending charges can be approved']);
|
||||
return redirect()->back()->with('error', 'Only pending charges can be approved.');
|
||||
}
|
||||
|
||||
if (!$this->additionalChargeModel->update((int)$id, ['status' => FinancialStatus::ADDITIONAL_CHARGE_APPROVED])) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to approve charge']);
|
||||
return redirect()->back()->with('error', 'Failed to approve charge.');
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
return redirect()->back()->with('status', 'Charge approved.');
|
||||
}
|
||||
|
||||
public function apply($id)
|
||||
{
|
||||
$row = $this->additionalChargeModel->find((int)$id);
|
||||
if (!$row) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
if ((string)($row['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_APPROVED) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Only approved charges can be applied']);
|
||||
return redirect()->back()->with('error', 'Only approved charges can be applied.');
|
||||
}
|
||||
|
||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
||||
if ($invoiceId <= 0) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge must reference an invoice before application']);
|
||||
return redirect()->back()->with('error', 'Charge must reference an invoice before application.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->invoiceAdjustmentService->applyAdditionalCharge(
|
||||
(int)$id,
|
||||
$invoiceId,
|
||||
(int)(session()->get('user_id') ?? 0)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Additional charge apply failed: ' . $e->getMessage());
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
return redirect()->back()->with('status', 'Charge applied.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a charge as void and roll back its impact on the invoice if applied.
|
||||
*/
|
||||
@@ -441,27 +515,36 @@ class ExtraChargesController extends BaseController
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
|
||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
||||
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
|
||||
$chargeType = (string)($row['charge_type'] ?? 'add');
|
||||
$status = (string)($row['status'] ?? 'pending');
|
||||
$reason = trim((string)($this->request->getPost('reason') ?? 'Voided by staff'));
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
$this->additionalChargeModel->update((int)$id, [
|
||||
'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()) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to void charge']);
|
||||
return redirect()->back()->with('error', 'Failed to void charge.');
|
||||
try {
|
||||
if ($status === FinancialStatus::ADDITIONAL_CHARGE_APPLIED) {
|
||||
$this->invoiceAdjustmentService->reverseAdditionalCharge(
|
||||
(int)$id,
|
||||
$reason,
|
||||
(int)(session()->get('user_id') ?? 0)
|
||||
);
|
||||
} else {
|
||||
$this->db->transBegin();
|
||||
$this->additionalChargeModel->update((int)$id, [
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_VOIDED,
|
||||
'voided_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||
'voided_at' => utc_now(),
|
||||
'void_reason' => $reason,
|
||||
]);
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('Failed to void charge.');
|
||||
}
|
||||
$this->db->transCommit();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->db->transStatus() === false) {
|
||||
$this->db->transRollback();
|
||||
}
|
||||
log_message('error', 'Additional charge void failed: ' . $e->getMessage());
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
@@ -469,7 +552,7 @@ class ExtraChargesController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse a previously applied charge: undo invoice impact and return to pending state.
|
||||
* Reverse a previously applied charge with an immutable reversing invoice line.
|
||||
*/
|
||||
public function reverse($id)
|
||||
{
|
||||
@@ -480,33 +563,28 @@ class ExtraChargesController extends BaseController
|
||||
}
|
||||
|
||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
||||
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
|
||||
$chargeType = (string)($row['charge_type'] ?? 'add');
|
||||
$status = (string)($row['status'] ?? 'pending');
|
||||
|
||||
if ($status !== FinancialStatus::ADDITIONAL_CHARGE_APPLIED || $invoiceId <= 0 || $amountAbs <= 0) {
|
||||
if ($status !== FinancialStatus::ADDITIONAL_CHARGE_APPLIED || $invoiceId <= 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();
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
|
||||
|
||||
$this->additionalChargeModel->update((int)$id, [
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_PENDING,
|
||||
'invoice_id' => null,
|
||||
]);
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to reverse charge']);
|
||||
return redirect()->back()->with('error', 'Failed to reverse charge.');
|
||||
$reason = trim((string)($this->request->getPost('reason') ?? 'Reversed by staff'));
|
||||
try {
|
||||
$this->invoiceAdjustmentService->reverseAdditionalCharge(
|
||||
(int)$id,
|
||||
$reason,
|
||||
(int)(session()->get('user_id') ?? 0)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Additional charge reverse failed: ' . $e->getMessage());
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
return redirect()->back()->with('status', 'Charge reversed to pending.');
|
||||
return redirect()->back()->with('status', 'Charge reversed.');
|
||||
}
|
||||
|
||||
/** JSON: list charges for the current term (with optional filters). */
|
||||
|
||||
@@ -9,12 +9,55 @@ use App\Models\RefundModel;
|
||||
use App\Models\ExpenseModel;
|
||||
use App\Models\ReimbursementModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
|
||||
require_once APPPATH . 'ThirdParty/fpdf/fpdf.php';
|
||||
use FPDF;
|
||||
|
||||
class FinancialController extends BaseController
|
||||
{
|
||||
private ?InvoiceLedgerService $invoiceLedgerService = null;
|
||||
|
||||
private function invoiceLedger(): InvoiceLedgerService
|
||||
{
|
||||
if ($this->invoiceLedgerService === null) {
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
}
|
||||
|
||||
return $this->invoiceLedgerService;
|
||||
}
|
||||
|
||||
private function ledgerProjectionForInvoice(int $invoiceId): array
|
||||
{
|
||||
try {
|
||||
return $this->invoiceLedger()->calculateInvoice($invoiceId);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Invoice ledger projection failed for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
return [
|
||||
'total_amount' => '0.00',
|
||||
'paid_amount' => '0.00',
|
||||
'discount_total' => '0.00',
|
||||
'refund_paid_total' => '0.00',
|
||||
'balance' => '0.00',
|
||||
'customer_credit' => '0.00',
|
||||
'status' => FinancialStatus::INVOICE_UNPAID,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function ledgerProjectionMap(array $invoiceIds): array
|
||||
{
|
||||
$map = [];
|
||||
foreach (array_values(array_unique(array_map('intval', $invoiceIds))) as $invoiceId) {
|
||||
if ($invoiceId > 0) {
|
||||
$map[$invoiceId] = $this->ledgerProjectionForInvoice($invoiceId);
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function wantsJson(): bool
|
||||
{
|
||||
$accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? ''));
|
||||
@@ -157,6 +200,7 @@ public function financialReport()
|
||||
$id = (int)($inv['id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
}, $invoices))));
|
||||
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIds);
|
||||
|
||||
// Helper to build a fresh, filtered PaymentModel each time (so filters don't get lost between queries)
|
||||
$buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) {
|
||||
@@ -195,18 +239,14 @@ public function financialReport()
|
||||
return $qb;
|
||||
};
|
||||
|
||||
// === Payments aggregated by invoice_id (for the "Paid" column) ===
|
||||
$paymentsQuery = $applyPaymentFilters($buildPaymentModel());
|
||||
if (!empty($invoiceIds)) {
|
||||
$paymentsQuery->whereIn('invoice_id', $invoiceIds);
|
||||
} else {
|
||||
$paymentsQuery->where('invoice_id', -1);
|
||||
// === Ledger-derived invoice totals (for Paid/Refund/Discount/Balance/Status columns) ===
|
||||
$payments = [];
|
||||
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||
$payments[] = [
|
||||
'invoice_id' => $invoiceId,
|
||||
'paid_amount' => (float) ($ledger['paid_amount'] ?? 0),
|
||||
];
|
||||
}
|
||||
$payments = $paymentsQuery
|
||||
->select('invoice_id, SUM(paid_amount) AS paid_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy('invoice_id')
|
||||
->findAll();
|
||||
|
||||
// === Per-invoice breakdown by normalized method (cash/credit/check) ===
|
||||
// Normalization rules:
|
||||
@@ -260,21 +300,44 @@ public function financialReport()
|
||||
'total_credit' => (float)($paymentTotalsRow['total_credit'] ?? 0),
|
||||
];
|
||||
|
||||
// === Refunds (grouped) ===
|
||||
$refunds = $refundModel
|
||||
->select('invoice_id, school_year, SUM(refund_paid_amount) AS total_refunded')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->groupBy(['invoice_id', 'school_year'])
|
||||
->findAll();
|
||||
// === Ledger-derived refunds and discounts ===
|
||||
$refunds = [];
|
||||
$discounts = [];
|
||||
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||
$invoiceSchoolYear = '';
|
||||
foreach ($invoices as $invoiceRow) {
|
||||
if ((int)($invoiceRow['id'] ?? 0) === (int)$invoiceId) {
|
||||
$invoiceSchoolYear = (string)($invoiceRow['school_year'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
$refunds[] = [
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $invoiceSchoolYear,
|
||||
'total_refunded' => (float) ($ledger['refund_paid_total'] ?? 0),
|
||||
];
|
||||
$discounts[] = [
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $invoiceSchoolYear,
|
||||
'discount_amount' => (float) ($ledger['discount_total'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
// === Discounts (grouped) ===
|
||||
$discounts = $discountModel
|
||||
->select('invoice_id, school_year, SUM(discount_amount) AS discount_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy(['invoice_id', 'school_year'])
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($invoices as &$invoiceRow) {
|
||||
$invoiceId = (int)($invoiceRow['id'] ?? 0);
|
||||
$ledger = $ledgerByInvoice[$invoiceId] ?? null;
|
||||
if ($ledger === null) {
|
||||
continue;
|
||||
}
|
||||
$invoiceRow['total_amount'] = (float)($ledger['total_amount'] ?? 0);
|
||||
$invoiceRow['paid_amount'] = (float)($ledger['paid_amount'] ?? 0);
|
||||
$invoiceRow['discount'] = (float)($ledger['discount_total'] ?? 0);
|
||||
$invoiceRow['refund_paid'] = (float)($ledger['refund_paid_total'] ?? 0);
|
||||
$invoiceRow['balance'] = (float)($ledger['balance'] ?? 0);
|
||||
$invoiceRow['customer_credit'] = (float)($ledger['customer_credit'] ?? 0);
|
||||
$invoiceRow['status'] = (string)($ledger['status'] ?? ($invoiceRow['status'] ?? ''));
|
||||
}
|
||||
unset($invoiceRow);
|
||||
|
||||
// === Expenses ===
|
||||
$expenses = $expenseModel
|
||||
@@ -583,77 +646,27 @@ public function financialReport()
|
||||
$invoiceRows = $invoiceRows->orderBy('invoices.id', 'ASC')->get()->getResultArray();
|
||||
|
||||
$invoiceIds = array_values(array_filter(array_map(static fn($row) => (int)($row['id'] ?? 0), $invoiceRows)));
|
||||
|
||||
$paidByInvoice = [];
|
||||
$discountByInvoice = [];
|
||||
$refundByInvoice = [];
|
||||
if (!empty($invoiceIds)) {
|
||||
$paidRows = $db->table('payments')
|
||||
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupStart()
|
||||
->whereNotIn('status', $paymentExclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
if (!empty($schoolYear)) {
|
||||
$paidRows->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
$paidRows->where('DATE(payment_date) >=', $invoiceDateFrom);
|
||||
}
|
||||
if (!empty($invoiceDateTo)) {
|
||||
$paidRows->where('DATE(payment_date) <=', $invoiceDateTo);
|
||||
}
|
||||
foreach ($paidRows->groupBy('invoice_id')->get()->getResultArray() as $row) {
|
||||
$paidByInvoice[(int)($row['invoice_id'] ?? 0)] = (float)($row['total_paid'] ?? 0);
|
||||
}
|
||||
|
||||
$discountRows = $db->table('discount_usages')
|
||||
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_discount')
|
||||
->whereIn('invoice_id', $invoiceIds);
|
||||
if (!empty($dateFrom)) {
|
||||
$discountRows->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$discountRows->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
foreach ($discountRows->groupBy('invoice_id')->get()->getResultArray() as $row) {
|
||||
$discountByInvoice[(int)($row['invoice_id'] ?? 0)] = (float)($row['total_discount'] ?? 0);
|
||||
}
|
||||
|
||||
$refundRows = $db->table('refunds')
|
||||
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial', 'Paid']);
|
||||
if (!empty($dateFrom)) {
|
||||
$refundRows->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$refundRows->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
foreach ($refundRows->groupBy('invoice_id')->get()->getResultArray() as $row) {
|
||||
$refundByInvoice[(int)($row['invoice_id'] ?? 0)] = (float)($row['total_refund'] ?? 0);
|
||||
}
|
||||
}
|
||||
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIds);
|
||||
|
||||
$invoices = [];
|
||||
foreach ($invoiceRows as $row) {
|
||||
$iid = (int)($row['id'] ?? 0);
|
||||
$paid = (float)($paidByInvoice[$iid] ?? 0);
|
||||
$disc = (float)($discountByInvoice[$iid] ?? 0);
|
||||
$refund = (float)($refundByInvoice[$iid] ?? 0);
|
||||
$balance = round((float)($row['total_amount'] ?? 0) - $disc - $refund - $paid, 2);
|
||||
$ledger = $ledgerByInvoice[$iid] ?? [];
|
||||
$paid = (float)($ledger['paid_amount'] ?? 0);
|
||||
$disc = (float)($ledger['discount_total'] ?? 0);
|
||||
$refund = (float)($ledger['refund_paid_total'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
$invoices[] = [
|
||||
'Invoice #' => (string)($row['invoice_number'] ?? ''),
|
||||
'Parent' => trim((string)($row['parent_name'] ?? '')),
|
||||
'Issue Date' => (string)($row['issue_date'] ?? ''),
|
||||
'Due Date' => (string)($row['due_date'] ?? ''),
|
||||
'Gross Charges' => (float)($row['total_amount'] ?? 0),
|
||||
'Gross Charges' => (float)($ledger['total_amount'] ?? 0),
|
||||
'Discounts' => $disc,
|
||||
'Refunds' => $refund,
|
||||
'Paid' => $paid,
|
||||
'Balance' => $balance,
|
||||
'Status' => (string)($row['status'] ?? ''),
|
||||
'Status' => (string)($ledger['status'] ?? ($row['status'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -992,10 +1005,6 @@ public function financialReport()
|
||||
->get()->getResultArray();
|
||||
|
||||
$byParent = [];
|
||||
$paymentsTbl = 'payments';
|
||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
||||
|
||||
foreach ($invRows as $r) {
|
||||
$iid = (int)($r['id'] ?? 0);
|
||||
$pid = (int)($r['parent_id'] ?? 0);
|
||||
@@ -1003,37 +1012,11 @@ public function financialReport()
|
||||
continue;
|
||||
}
|
||||
|
||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paidRow = $qb->get()->getRowArray();
|
||||
$paidSum = (float)($paidRow['tot'] ?? 0);
|
||||
|
||||
$discRow = $db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->get()->getRowArray();
|
||||
$discSum = (float)($discRow['tot'] ?? 0);
|
||||
|
||||
$refRow = $db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$refSum = (float)($refRow['tot'] ?? 0);
|
||||
|
||||
$total = (float)($r['total_amount'] ?? 0);
|
||||
$balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2));
|
||||
$ledger = $this->ledgerProjectionForInvoice($iid);
|
||||
$paidSum = (float)($ledger['paid_amount'] ?? 0);
|
||||
$discSum = (float)($ledger['discount_total'] ?? 0);
|
||||
$total = (float)($ledger['total_amount'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
|
||||
if (!isset($byParent[$pid])) {
|
||||
$byParent[$pid] = [
|
||||
@@ -1293,8 +1276,10 @@ public function financialReport()
|
||||
->findAll();
|
||||
|
||||
$paymentsMap = [];
|
||||
foreach ($payments as $payment) {
|
||||
$paymentsMap[(int)($payment['invoice_id'] ?? 0)] = (float)($payment['paid_amount'] ?? 0);
|
||||
$invoiceIdsForExport = array_values(array_filter(array_map(static fn($row) => (int)($row['id'] ?? 0), $invoices)));
|
||||
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIdsForExport);
|
||||
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||
$paymentsMap[(int)$invoiceId] = (float)($ledger['paid_amount'] ?? 0);
|
||||
}
|
||||
|
||||
$paymentBreakdownRows = $applyPaymentFilters($buildPaymentModel())
|
||||
@@ -1350,47 +1335,11 @@ public function financialReport()
|
||||
}
|
||||
$reimbursements = $reimbBuilder->groupBy('status')->findAll();
|
||||
|
||||
// Refunds (grouped)
|
||||
if (!empty($schoolYear)) {
|
||||
$refundModel->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($hasFrom) {
|
||||
$refundModel->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if ($hasTo) {
|
||||
$refundModel->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
$refundsData = $refundModel
|
||||
->select('invoice_id, SUM(refund_paid_amount) AS total_refunded')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->groupBy('invoice_id')
|
||||
->findAll();
|
||||
$refunds = [];
|
||||
foreach ($refundsData as $refund) {
|
||||
$refunds[$refund['invoice_id']] = $refund['total_refunded'];
|
||||
}
|
||||
|
||||
$discountBuilder = $discountModel;
|
||||
if (!empty($schoolYear)) {
|
||||
$discountBuilder->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($hasFrom) {
|
||||
$discountBuilder->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if ($hasTo) {
|
||||
$discountBuilder->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
$discountsData = $discountBuilder
|
||||
->select('invoice_id, SUM(discount_amount) AS discount_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$discounts = [];
|
||||
foreach ($discountsData as $disc) {
|
||||
$discounts[$disc['invoice_id']] = $disc['discount_amount'];
|
||||
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||
$refunds[(int)$invoiceId] = (float)($ledger['refund_paid_total'] ?? 0);
|
||||
$discounts[(int)$invoiceId] = (float)($ledger['discount_total'] ?? 0);
|
||||
}
|
||||
|
||||
$filename = 'financial_report_' . date('Ymd_His') . '.csv';
|
||||
@@ -1419,10 +1368,10 @@ public function financialReport()
|
||||
$check = (float)($bd['check'] ?? 0);
|
||||
$refunded = (float)($refunds[$invoiceId] ?? 0);
|
||||
$discount = (float)($discounts[$invoiceId] ?? 0);
|
||||
$total = (float)($inv['total_amount'] ?? 0);
|
||||
$balance = $total - $paid - $discount - $refunded;
|
||||
if ($balance < 0) $balance = 0.0;
|
||||
$status = ($balance === 0.0) ? 'Paid' : 'Unpaid';
|
||||
$ledger = $ledgerByInvoice[$invoiceId] ?? [];
|
||||
$total = (float)($ledger['total_amount'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
$status = (string)($ledger['status'] ?? ($inv['status'] ?? ''));
|
||||
|
||||
fputcsv($out, [
|
||||
$inv['invoice_number'],
|
||||
@@ -1660,11 +1609,21 @@ public function financialReport()
|
||||
$invoiceBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $invoiceDateTo);
|
||||
}
|
||||
$invoices = $invoiceBuilder->findAll();
|
||||
$totalCharges = array_sum(array_column($invoices, 'total_amount'));
|
||||
$invoiceIds = array_values(array_unique(array_filter(array_map(static function ($inv) {
|
||||
$id = (int)($inv['id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
}, $invoices))));
|
||||
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIds);
|
||||
$totalCharges = 0.0;
|
||||
$totalPaid = 0.0;
|
||||
$totalDiscounts = 0.0;
|
||||
$totalRefunds = 0.0;
|
||||
foreach ($ledgerByInvoice as $ledger) {
|
||||
$totalCharges += (float)($ledger['total_amount'] ?? 0);
|
||||
$totalPaid += (float)($ledger['paid_amount'] ?? 0);
|
||||
$totalDiscounts += (float)($ledger['discount_total'] ?? 0);
|
||||
$totalRefunds += (float)($ledger['refund_paid_total'] ?? 0);
|
||||
}
|
||||
|
||||
// === Additional Charges ===
|
||||
$hasExplicitDates = !empty($dateFrom) || !empty($dateTo);
|
||||
@@ -1714,98 +1673,6 @@ public function financialReport()
|
||||
|
||||
$totalCharges += $extraChargesUnapplied;
|
||||
|
||||
// === Payments: Total Paid ===
|
||||
$paymentBuilder = $paymentModel->where('school_year', $schoolYear);
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
$paymentBuilder->where('DATE(payment_date) >=', $invoiceDateFrom);
|
||||
}
|
||||
if (!empty($invoiceDateTo)) {
|
||||
$paymentBuilder->where('DATE(payment_date) <=', $invoiceDateTo);
|
||||
}
|
||||
$payHasStatus = $db->fieldExists('status', 'payments');
|
||||
$payHasVoid = $db->fieldExists('is_void', 'payments');
|
||||
if ($payHasStatus) {
|
||||
$paymentBuilder->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($payHasVoid) {
|
||||
$paymentBuilder->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paymentResult = $paymentBuilder->selectSum('paid_amount')->get()->getRowArray();
|
||||
$totalPaid = isset($paymentResult['paid_amount']) ? (float) $paymentResult['paid_amount'] : 0.00;
|
||||
|
||||
// === Per-invoice paid/discount/refund totals for outstanding balance ===
|
||||
$paidByInvoice = [];
|
||||
$discountByInvoice = [];
|
||||
$refundByInvoice = [];
|
||||
if (!empty($invoiceIds)) {
|
||||
$paidRows = $db->table('payments')
|
||||
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->whereIn('invoice_id', $invoiceIds);
|
||||
if (!empty($schoolYear)) {
|
||||
$paidRows->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
$paidRows->where('DATE(payment_date) >=', $invoiceDateFrom);
|
||||
}
|
||||
if (!empty($invoiceDateTo)) {
|
||||
$paidRows->where('DATE(payment_date) <=', $invoiceDateTo);
|
||||
}
|
||||
if ($payHasStatus) {
|
||||
$paidRows->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($payHasVoid) {
|
||||
$paidRows->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paidRows = $paidRows->groupBy('invoice_id')->get()->getResultArray();
|
||||
foreach ($paidRows as $r) {
|
||||
$iid = (int)($r['invoice_id'] ?? 0);
|
||||
if ($iid > 0) $paidByInvoice[$iid] = (float)($r['total_paid'] ?? 0);
|
||||
}
|
||||
|
||||
$discRows = $db->table('discount_usages')
|
||||
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->whereIn('invoice_id', $invoiceIds);
|
||||
if (!empty($dateFrom)) {
|
||||
$discRows->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$discRows->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
$discRows = $discRows->groupBy('invoice_id')->get()->getResultArray();
|
||||
foreach ($discRows as $r) {
|
||||
$iid = (int)($r['invoice_id'] ?? 0);
|
||||
if ($iid > 0) $discountByInvoice[$iid] = (float)($r['total_disc'] ?? 0);
|
||||
}
|
||||
|
||||
$refRows = $db->table('refunds')
|
||||
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial', 'Paid']);
|
||||
if (!empty($dateFrom)) {
|
||||
$refRows->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$refRows->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
$refRows = $refRows->groupBy('invoice_id')->get()->getResultArray();
|
||||
foreach ($refRows as $r) {
|
||||
$iid = (int)($r['invoice_id'] ?? 0);
|
||||
if ($iid > 0) $refundByInvoice[$iid] = (float)($r['total_refund'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// === Expenses ===
|
||||
$expenseBuilder = $expenseModel->where('school_year', $schoolYear);
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
@@ -1943,39 +1810,6 @@ public function financialReport()
|
||||
$donationToSchool = $donationExpense + $donationReimb;
|
||||
$totalReimbursements = max(0.0, $totalReimbursements - $donationReimb);
|
||||
|
||||
// === Refunds ===
|
||||
$refundBuilder = $refundModel
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->where('refund_paid_amount IS NOT NULL');
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
|
||||
$refundResult = $refundBuilder
|
||||
->selectSum('refund_paid_amount')
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$totalRefunds = isset($refundResult['refund_paid_amount']) ? (float) $refundResult['refund_paid_amount'] : 0.00;
|
||||
|
||||
// === Discounts ===
|
||||
$discountBuilder = $discountModel
|
||||
->join('invoices', 'invoices.id = discount_usages.invoice_id')
|
||||
->where('invoices.school_year', $schoolYear);
|
||||
if (!empty($dateFrom)) {
|
||||
$discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) <=', $dateTo);
|
||||
}
|
||||
$discountResult = $discountBuilder->selectSum('discount_amount')->get()->getRowArray();
|
||||
$totalDiscounts = isset($discountResult['discount_amount']) ? (float) $discountResult['discount_amount'] : 0.00;
|
||||
|
||||
// === Net, Outstanding & Overpayments ===
|
||||
$overpaymentDetails = [];
|
||||
$totalUnpaid = 0.0;
|
||||
@@ -1984,15 +1818,16 @@ public function financialReport()
|
||||
$iid = (int)($inv['id'] ?? 0);
|
||||
$pid = (int)($inv['parent_id'] ?? 0);
|
||||
if ($iid <= 0 || $pid <= 0) continue;
|
||||
$total = (float)($inv['total_amount'] ?? 0);
|
||||
$paid = (float)($paidByInvoice[$iid] ?? 0);
|
||||
$disc = (float)($discountByInvoice[$iid] ?? 0);
|
||||
$ref = (float)($refundByInvoice[$iid] ?? 0);
|
||||
$rawBal = round($total - $disc - $paid - $ref, 2);
|
||||
if ($rawBal > 0.00001) {
|
||||
$totalUnpaid += $rawBal;
|
||||
} elseif ($rawBal < -0.00001) {
|
||||
$credit = abs($rawBal);
|
||||
$ledger = $ledgerByInvoice[$iid] ?? [];
|
||||
$total = (float)($ledger['total_amount'] ?? 0);
|
||||
$paid = (float)($ledger['paid_amount'] ?? 0);
|
||||
$disc = (float)($ledger['discount_total'] ?? 0);
|
||||
$ref = (float)($ledger['refund_paid_total'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
$credit = (float)($ledger['customer_credit'] ?? 0);
|
||||
if ($balance > 0.00001) {
|
||||
$totalUnpaid += $balance;
|
||||
} elseif ($credit > 0.00001) {
|
||||
$totalOverpaid += $credit;
|
||||
$overpaymentDetails[] = [
|
||||
'type' => 'invoice',
|
||||
@@ -2005,7 +1840,7 @@ public function financialReport()
|
||||
'discount_amount' => $disc,
|
||||
'refund_amount' => $ref,
|
||||
'paid_amount' => $paid,
|
||||
'note' => 'Invoice payments/discounts exceed net invoice charges.',
|
||||
'note' => 'Invoice ledger shows customer credit.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2111,9 +1946,7 @@ public function financialReport()
|
||||
$schoolYears[] = (string)$schoolYear;
|
||||
}
|
||||
|
||||
// Aggregate balances by parent for selected school year
|
||||
// IMPORTANT: Compute current balance = total - payments - discounts - refundsPaid
|
||||
// rather than trusting invoices.balance which may become stale.
|
||||
// Aggregate balances by parent for selected school year from the canonical invoice ledger.
|
||||
$db = \Config\Database::connect();
|
||||
$invRows = $db->table('invoices i')
|
||||
->select('i.id, i.parent_id, i.total_amount, u.firstname, u.lastname, u.email')
|
||||
@@ -2123,51 +1956,18 @@ public function financialReport()
|
||||
->orderBy('i.id', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
// Group by parent and compute balances dynamically
|
||||
$byParent = [];
|
||||
$paymentsTbl = 'payments';
|
||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
||||
|
||||
foreach ($invRows as $r) {
|
||||
$iid = (int)($r['id'] ?? 0);
|
||||
$pid = (int)($r['parent_id'] ?? 0);
|
||||
if ($iid <= 0 || $pid <= 0) continue;
|
||||
|
||||
// Sum payments for this invoice (exclude void/failed if such columns exist)
|
||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paidRow = $qb->get()->getRowArray();
|
||||
$paidSum = (float)($paidRow['tot'] ?? 0);
|
||||
|
||||
// Sum discounts for this invoice
|
||||
$discRow = $db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->get()->getRowArray();
|
||||
$discSum = (float)($discRow['tot'] ?? 0);
|
||||
|
||||
// Sum refunds PAID for this invoice (Partial/Paid only)
|
||||
$refRow = $db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$refSum = (float)($refRow['tot'] ?? 0);
|
||||
|
||||
$total = (float)($r['total_amount'] ?? 0);
|
||||
$balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2));
|
||||
$ledger = $this->ledgerProjectionForInvoice($iid);
|
||||
$paidSum = (float)($ledger['paid_amount'] ?? 0);
|
||||
$discSum = (float)($ledger['discount_total'] ?? 0);
|
||||
$total = (float)($ledger['total_amount'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
|
||||
if (!isset($byParent[$pid])) {
|
||||
$byParent[$pid] = [
|
||||
|
||||
@@ -18,6 +18,10 @@ use App\Models\InvoiceEventModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\DiscountUsageModel;
|
||||
use App\Models\RefundModel;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Libraries\IssueInvoiceCommand;
|
||||
use App\Libraries\InvoiceIssuanceService;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
|
||||
@@ -50,6 +54,8 @@ class InvoiceController extends ResourceController
|
||||
protected $request;
|
||||
protected $gradeFee;
|
||||
protected $classSectionModel;
|
||||
protected $invoiceLedgerService;
|
||||
protected InvoiceIssuanceService $invoiceIssuanceService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -67,7 +73,9 @@ class InvoiceController extends ResourceController
|
||||
$this->chargesModel = new EventChargesModel();
|
||||
$this->discountUsageModel = new DiscountUsageModel();
|
||||
$this->refundModel = new RefundModel();
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->invoiceIssuanceService = new InvoiceIssuanceService($this->db, $this->invoiceModel, null, $this->invoiceLedgerService);
|
||||
$this->request = \Config\Services::request();
|
||||
|
||||
$this->gradeFee = $this->configModel->getConfig('grade_fee');
|
||||
@@ -391,7 +399,7 @@ class InvoiceController extends ResourceController
|
||||
}
|
||||
|
||||
public function generateInvoice(
|
||||
string $parentId = null,
|
||||
?string $parentId = null,
|
||||
?string $schoolYearOverride = null,
|
||||
?string $semesterOverride = null,
|
||||
bool $recalculateDiscounts = true
|
||||
@@ -472,20 +480,9 @@ class InvoiceController extends ResourceController
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Refunds PAID to the parent for this year (Partial/Paid)
|
||||
$refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $schoolYear);
|
||||
|
||||
$totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
|
||||
|
||||
$discountedTuition = max(0, $tuitionFee);
|
||||
$totalAmount = $discountedTuition + $eventchargeTotal;
|
||||
|
||||
// Parent-level balance (informational); we will recalc per invoice below
|
||||
$parentBalance = $totalAmount // original charges (tuition + events)
|
||||
- $totalDiscount // any applied discounts/vouchers
|
||||
- $refundPaid // approved refunds paid to parent
|
||||
- $totalPaid; // payments received
|
||||
|
||||
// Business rule: single invoice per parent per school year.
|
||||
// If legacy duplicates exist, prefer the invoice that already has a discount applied,
|
||||
// otherwise use the latest invoice for the parent/year.
|
||||
@@ -494,106 +491,10 @@ class InvoiceController extends ResourceController
|
||||
$updated = false;
|
||||
$updatedIds = [];
|
||||
if (!empty($invoice) && isset($invoice['id'])) {
|
||||
$paymentExclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
$paymentsHasStatus = false;
|
||||
$paymentsHasVoid = false;
|
||||
try {
|
||||
$paymentsHasStatus = $this->db->fieldExists('status', 'payments');
|
||||
$paymentsHasVoid = $this->db->fieldExists('is_void', 'payments');
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
// Preserve applied additional charges and recalc this invoice only
|
||||
$extrasSum = 0.0;
|
||||
try {
|
||||
$rows = $this->db->table('additional_charges')
|
||||
->select('charge_type, amount')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->where('status', 'applied')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$amt = (float)($r['amount'] ?? 0);
|
||||
$typ = strtolower((string)($r['charge_type'] ?? 'add'));
|
||||
if ($typ === 'deduct') $amt = -abs($amt); else $amt = abs($amt);
|
||||
$extrasSum += $amt;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'additional_charges sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$newTotal = round($totalAmount + $extrasSum, 2);
|
||||
|
||||
// Per-invoice discount
|
||||
$invDiscount = 0.0;
|
||||
try {
|
||||
$d = $this->db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->get()->getRowArray();
|
||||
$invDiscount = (float)($d['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'discount sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Per-invoice refunds paid
|
||||
$invRefunds = 0.0;
|
||||
try {
|
||||
$r = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$invRefunds = (float)($r['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'refund sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Payments recorded on this invoice (sum payments to avoid stale invoice.paid_amount)
|
||||
$paidOnInv = 0.0;
|
||||
try {
|
||||
$qb = $this->db->table('payments')
|
||||
->select('COALESCE(SUM(paid_amount),0) AS tot')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('paid_amount >', 0);
|
||||
|
||||
if ($paymentsHasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', $paymentExclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($paymentsHasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$row = $qb->get()->getRowArray();
|
||||
$paidOnInv = (float)($row['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'payment sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
$paidOnInv = (float)($invoice['paid_amount'] ?? 0.0);
|
||||
}
|
||||
|
||||
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
|
||||
$newStatus = ($newBalance <= 0.00001)
|
||||
? 'Paid'
|
||||
: (($paidOnInv > 0) ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
$this->invoiceModel->update($invoice['id'], [
|
||||
'total_amount' => $newTotal,
|
||||
'paid_amount' => $paidOnInv,
|
||||
'balance' => $newBalance,
|
||||
'status' => $newStatus,
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
|
||||
$updatedIds[] = (int)$invoice['id'];
|
||||
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
|
||||
$updated = true;
|
||||
$ledger = $this->invoiceLedgerService->recalculate((int) $invoice['id']);
|
||||
$updatedIds[] = (int) $ledger['invoice_id'];
|
||||
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
|
||||
$updated = true;
|
||||
} else {
|
||||
// Generate invoice number
|
||||
$schoolId = $this->userModel->getSchoolIdByUserId($parentId);
|
||||
@@ -616,30 +517,35 @@ class InvoiceController extends ResourceController
|
||||
$dueUtc = $dueLocal->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$insertId = $this->invoiceModel->insert([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'total_amount' => $totalAmount,
|
||||
'paid_amount' => 0,
|
||||
// Initial balance equals the created total; discounts/refunds/payments will adjust later
|
||||
'balance' => $totalAmount,
|
||||
'status' => 'Unpaid',
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'issue_date' => $issueUtc,
|
||||
'due_date' => $dueUtc,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
|
||||
if (!$insertId) {
|
||||
log_message('error', 'Invoice insert failed: ' . json_encode($this->invoiceModel->errors()));
|
||||
try {
|
||||
$issueResult = $this->invoiceIssuanceService->issueInvoice(new IssueInvoiceCommand([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'total_amount' => $totalAmount,
|
||||
'paid_amount' => 0,
|
||||
'balance' => $totalAmount,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'issue_date' => $issueUtc,
|
||||
'due_date' => $dueUtc,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
], (float) $tuitionFee, (float) $eventchargeTotal, [
|
||||
'parent_id' => (int) $parentId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'registered_student_count' => count($registeredKids),
|
||||
'withdrawn_student_count' => count($withdrawnKids),
|
||||
]));
|
||||
$insertId = $issueResult->invoiceId;
|
||||
$ledger = $issueResult->ledger;
|
||||
log_message('info', "Invoice created successfully. Insert ID: {$insertId}");
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Invoice issuance failed: ' . $e->getMessage() . ' errors=' . json_encode($this->invoiceModel->errors()));
|
||||
if ($isAjax) {
|
||||
return $this->response->setJSON(['ok' => false, 'message' => 'Failed to create invoice.']);
|
||||
}
|
||||
return redirect()->back()->with('error', 'Failed to create invoice. Please check input values.');
|
||||
} else {
|
||||
log_message('info', "Invoice created successfully. Insert ID: {$insertId}");
|
||||
}
|
||||
$updated = false;
|
||||
}
|
||||
@@ -708,98 +614,12 @@ class InvoiceController extends ResourceController
|
||||
float $tuitionFee,
|
||||
array $enrollments
|
||||
): float {
|
||||
$totalDiscount = 0.00;
|
||||
$eventchargeTotal = 0.0;
|
||||
try {
|
||||
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
|
||||
$eventchargeTotal = array_sum(array_column($eventsList, 'charged'));
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to load event charges for discount recalculation: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Get all invoices for this parent and school year
|
||||
$invoices = $this->invoiceModel->getInvoicesByParentId($parentId, $schoolYear);
|
||||
|
||||
if (empty($invoices)) {
|
||||
log_message('info', "No invoices found for parent ID $parentId in school year $schoolYear.");
|
||||
return 0.00;
|
||||
}
|
||||
$totalDiscount = 0.0;
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
if (!isset($invoice['id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$invoiceId = $invoice['id'];
|
||||
|
||||
// Get discount usage + voucher details
|
||||
$discountUsage = $this->db->table('discount_usages du')
|
||||
->select('du.id, dv.id as voucher_id, dv.discount_type, dv.discount_value')
|
||||
->join('discount_vouchers dv', 'du.voucher_id = dv.id')
|
||||
->join('invoices i', 'du.invoice_id = i.id')
|
||||
->where('du.invoice_id', $invoiceId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if (!$discountUsage) {
|
||||
log_message('info', "No discount applied to invoice ID $invoiceId.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$extrasSum = 0.0;
|
||||
try {
|
||||
$rows = $this->db->table('additional_charges')
|
||||
->select('charge_type, amount')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->where('status', 'applied')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$amt = (float)($r['amount'] ?? 0);
|
||||
$typ = strtolower((string)($r['charge_type'] ?? 'add'));
|
||||
if ($typ === 'deduct') $amt = -abs($amt); else $amt = abs($amt);
|
||||
$extrasSum += $amt;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'additional_charges sum failed for discount recalculation invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$baseTotal = round($tuitionFee + $extrasSum, 2);
|
||||
|
||||
// Recalculate discount
|
||||
if ($discountUsage['discount_type'] === 'percent') {
|
||||
$discountAmount = round(($baseTotal * $discountUsage['discount_value']) / 100, 2);
|
||||
} else {
|
||||
$discountAmount = min($discountUsage['discount_value'], $baseTotal);
|
||||
}
|
||||
|
||||
// Update discount usage
|
||||
$this->db->table('discount_usages')
|
||||
->where('id', $discountUsage['id'])
|
||||
->update([
|
||||
'discount_amount' => $discountAmount,
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id')
|
||||
]);
|
||||
|
||||
$totalDiscount += $discountAmount;
|
||||
|
||||
// Log enrollment summary
|
||||
$added = [];
|
||||
$withdrawn = [];
|
||||
foreach ($enrollments as $e) {
|
||||
if (
|
||||
in_array($e['enrollment_status'], ['enrolled', 'payment pending']) &&
|
||||
$e['admission_status'] === 'accepted'
|
||||
) {
|
||||
$added[] = $e['student_id'];
|
||||
} elseif (in_array($e['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) {
|
||||
$withdrawn[] = $e['student_id'];
|
||||
}
|
||||
}
|
||||
|
||||
log_message('info', "Recalculated discount for invoice ID $invoiceId: Added students [" . implode(',', $added) . "], Withdrawn students [" . implode(',', $withdrawn) . "]. Discount updated to $discountAmount.");
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice((int)$invoice['id']);
|
||||
$totalDiscount += (float)($ledger['discount_total'] ?? 0.0);
|
||||
}
|
||||
|
||||
return $totalDiscount;
|
||||
@@ -964,6 +784,15 @@ class InvoiceController extends ResourceController
|
||||
return ['error' => "Parent associated with the invoice was not found."];
|
||||
}
|
||||
|
||||
$ledger = $this->invoiceLedgerService->calculateInvoice((int) $invoiceId);
|
||||
$invoiceLines = $this->db->table('invoice_lines')
|
||||
->select('description, quantity, unit_amount_cents, line_amount_cents, line_type, source_type, source_id, created_at, metadata_json')
|
||||
->where('invoice_id', (int)$invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->orderBy('id', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
@@ -1091,19 +920,7 @@ class InvoiceController extends ResourceController
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
// Refunds PAID for this specific invoice (money returned to the parent)
|
||||
$refundsPaidTotal = 0.0;
|
||||
try {
|
||||
$r = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$refundsPaidTotal = (float)($r['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to sum refunds for invoice ' . (int)$invoiceId . ': ' . $e->getMessage());
|
||||
}
|
||||
$refundsPaidTotal = (float) ($ledger['refund_paid_total'] ?? 0.0);
|
||||
|
||||
/* ============================================================
|
||||
* ADDITIONAL CHARGES (itemized) for this invoice
|
||||
@@ -1172,7 +989,9 @@ class InvoiceController extends ResourceController
|
||||
'discounts' => $discounts,
|
||||
'additionalChargesTotal' => $additionalChargesTotal,
|
||||
'additionalChargeLines' => $additionalChargeLines,
|
||||
'invoiceLines' => $invoiceLines,
|
||||
'refundsPaidTotal' => $refundsPaidTotal,
|
||||
'ledger' => $ledger,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1326,56 +1145,14 @@ class InvoiceController extends ResourceController
|
||||
];
|
||||
};
|
||||
|
||||
// --- Tuition/registration lines (charges) ---
|
||||
foreach ($registeredKids as $student) {
|
||||
$id = $student['student_id'];
|
||||
$unit = (float)($studentCharges[$id]['unit_fee'] ?? 0.0);
|
||||
$name = $student['student_firstname'] . ' ' . $student['student_lastname'];
|
||||
|
||||
$classSectionName = $this->classSectionModel->getClassSectionNameByClassId($student['grade']);
|
||||
$lowerCaseName = strtolower((string)$classSectionName);
|
||||
$gradeName = ($lowerCaseName === 'kg')
|
||||
? 'in Kindergarten '
|
||||
: (($lowerCaseName === 'youth') ? 'in Youth ' : ('in Grade ' . $classSectionName));
|
||||
|
||||
$dt = $toLocal($invoice['created_at'] ?? null, false);
|
||||
$push($dt, 'Registration of student "' . $name . '" ' . $gradeName, $unit, 'registration');
|
||||
}
|
||||
|
||||
// --- Event charges (charges) ---
|
||||
foreach ($events as $event) {
|
||||
$studentName = 'N/A';
|
||||
if (!empty($event['student_id'])) {
|
||||
foreach ($students as $st) {
|
||||
if (($st['student_id'] ?? null) == $event['student_id']) {
|
||||
$studentName = $st['student_firstname'] . ' ' . $st['student_lastname'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($studentName === 'N/A') {
|
||||
$externalName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? ''));
|
||||
if ($externalName !== '') {
|
||||
$studentName = $externalName . ' (external)';
|
||||
}
|
||||
}
|
||||
|
||||
$dt = $toLocal($event['created_at'] ?? null, false);
|
||||
$amount = (float)($event['charged'] ?? 0.0);
|
||||
$eventName = !empty($event['event_name']) ? $event['event_name'] : 'with no name';
|
||||
|
||||
$push($dt, 'Event ' . $eventName . ' charge for "' . $studentName . '"', $amount, 'event');
|
||||
}
|
||||
|
||||
// --- Withdrawn refunds (negative) — only if a non-zero refund figure exists
|
||||
foreach ($withdrawnKids as $student) {
|
||||
$id = $student['student_id'];
|
||||
$ref = (float)($studentCharges[$id]['refund'] ?? 0.0);
|
||||
if ($ref <= 0) { continue; }
|
||||
$name = $student['student_firstname'] . ' ' . $student['student_lastname'];
|
||||
|
||||
$dt = $toLocal($invoice['created_at'] ?? null, false);
|
||||
$push($dt, 'Refund for student "' . $name . '"', -1 * $ref, 'refund');
|
||||
// --- Frozen invoice charge lines. Do not rebuild issued charges from current enrollment/events.
|
||||
foreach (($data['invoiceLines'] ?? []) as $line) {
|
||||
$dt = $toLocal($line['created_at'] ?? ($invoice['created_at'] ?? null), true);
|
||||
$amount = ((int)($line['line_amount_cents'] ?? 0)) / 100;
|
||||
$type = (string)($line['line_type'] ?? 'other');
|
||||
$category = str_contains($type, 'event') ? 'event'
|
||||
: (str_contains($type, 'additional') ? 'additional' : 'registration');
|
||||
$push($dt, (string)($line['description'] ?? 'Invoice line'), $amount, $category);
|
||||
}
|
||||
|
||||
// --- Payments (negative) — stored in local time
|
||||
@@ -1386,17 +1163,11 @@ class InvoiceController extends ResourceController
|
||||
$push($dt, 'Payment (' . ($payment['payment_method'] ?? 'Payment') . ')', -1 * $amount, 'payment');
|
||||
}
|
||||
|
||||
// --- Additional charges (already signed: deduct < 0, add > 0)
|
||||
foreach ($additionalChargeLines as $l) {
|
||||
$dt = $toLocal($l['date'] ?? null, false);
|
||||
$desc = (string)($l['description'] ?? 'Additional Charge');
|
||||
$amt = (float)($l['amount'] ?? 0.0);
|
||||
$push($dt, $desc, $amt, 'additional');
|
||||
}
|
||||
|
||||
// --- Discounts (negative) integrated into the timeline
|
||||
foreach (($discounts ?? []) as $discount) {
|
||||
$amt = (float)($discount['discount_amount'] ?? 0.0);
|
||||
$amt = isset($discount['applied_discount_cents']) && $discount['applied_discount_cents'] !== null
|
||||
? ((int)$discount['applied_discount_cents']) / 100
|
||||
: (float)($discount['discount_amount'] ?? 0.0);
|
||||
$totalDiscount += $amt;
|
||||
|
||||
$dt = $toLocal($discount['used_at'] ?? ($invoice['created_at'] ?? null), false);
|
||||
@@ -1445,33 +1216,13 @@ class InvoiceController extends ResourceController
|
||||
}
|
||||
|
||||
// ======== SUMMARY (bottom) ========
|
||||
// Compute total charges from components (tuition + event charges + additional charges)
|
||||
// to ensure the PDF always reflects all elements accurately.
|
||||
$tuitionSubtotal = 0.0;
|
||||
foreach (($studentCharges ?? []) as $sc) {
|
||||
$tuitionSubtotal += (float)($sc['unit_fee'] ?? 0.0);
|
||||
}
|
||||
|
||||
$eventSubtotal = 0.0;
|
||||
foreach (($events ?? []) as $ev) {
|
||||
$eventSubtotal += (float)($ev['charged'] ?? 0.0);
|
||||
}
|
||||
|
||||
$additionalSubtotal = (float)($additionalChargesTotal ?? 0.0);
|
||||
|
||||
$totalRefund = (float)($data['refundsPaidTotal'] ?? 0.0);
|
||||
$totalAmount = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
|
||||
$calcBalance = $totalAmount - $totalPaid - $totalDiscount - $totalRefund;
|
||||
// Prefer computed balance in PDF to avoid stale DB values
|
||||
$totalBalance = $calcBalance;
|
||||
|
||||
// Display rule: if negative, show as Credit (Overpayment) and clamp balance due to 0.00
|
||||
$displayBalance = $totalBalance;
|
||||
$creditOverpay = 0.0;
|
||||
if ($displayBalance < -0.00001) {
|
||||
$creditOverpay = abs($displayBalance);
|
||||
$displayBalance = 0.00;
|
||||
}
|
||||
$ledger = $data['ledger'] ?? [];
|
||||
$totalAmount = (float) ($ledger['total_amount'] ?? 0.0);
|
||||
$totalDiscount = (float) ($ledger['discount_total'] ?? $totalDiscount);
|
||||
$totalPaid = (float) ($ledger['paid_amount'] ?? $totalPaid);
|
||||
$totalRefund = (float) ($ledger['refund_paid_total'] ?? 0.0);
|
||||
$displayBalance = (float) ($ledger['balance'] ?? 0.0);
|
||||
$creditOverpay = (float) ($ledger['customer_credit'] ?? 0.0);
|
||||
|
||||
$pdf->Ln(5);
|
||||
$labelWidth = 165;
|
||||
@@ -1724,7 +1475,7 @@ private function getGradeLevel($grade): array
|
||||
'issue_date' => $this->request->getPost('issue_date'),
|
||||
'refund_issue_date' => $this->request->getPost('refund_issue_date'),
|
||||
'due_date' => $this->request->getPost('due_date'),
|
||||
'status' => 'Unpaid',
|
||||
'status' => FinancialStatus::INVOICE_UNPAID,
|
||||
'description' => $this->request->getPost('description'),
|
||||
];
|
||||
|
||||
|
||||
@@ -378,66 +378,22 @@ class PaymentController extends ResourceController
|
||||
->orderBy('issue_date', 'DESC')
|
||||
->findAll();
|
||||
|
||||
// Preload sums of actual payments/discounts/refunds per invoice to avoid stale invoice.paid_amount/balance
|
||||
$paidByInvoice = [];
|
||||
$discountByInvoice = [];
|
||||
$refundByInvoice = [];
|
||||
if (!empty($rawInvoices)) {
|
||||
$invoiceIds = array_values(array_unique(array_map(static fn($r) => (int)($r['id'] ?? 0), $rawInvoices)));
|
||||
if (!empty($invoiceIds)) {
|
||||
$rows = $this->paymentModel
|
||||
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$iid = (int)($r['invoice_id'] ?? 0);
|
||||
$paidByInvoice[$iid] = (float)($r['total_paid'] ?? 0);
|
||||
}
|
||||
|
||||
// Sum discounts per invoice_id
|
||||
$discRows = $this->db->table('discount_usages')
|
||||
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id')
|
||||
->get()->getResultArray();
|
||||
foreach ($discRows as $dr) {
|
||||
$iid = (int)($dr['invoice_id'] ?? 0);
|
||||
$discountByInvoice[$iid] = (float)($dr['total_disc'] ?? 0);
|
||||
}
|
||||
|
||||
// Sum PAID refunds per invoice_id (only Partial/Paid reduce liability)
|
||||
$refRows = $this->db->table('refunds')
|
||||
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->groupBy('invoice_id')
|
||||
->get()->getResultArray();
|
||||
foreach ($refRows as $rr) {
|
||||
$iid = (int)($rr['invoice_id'] ?? 0);
|
||||
$refundByInvoice[$iid] = (float)($rr['total_refund_paid'] ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize invoices for the view/JS; force due_ymd to the CONFIG date
|
||||
$invoices = array_map(function (array $inv) use ($installmentEndYmd, $paidByInvoice, $discountByInvoice, $refundByInvoice) {
|
||||
// Cast numeric fields we rely on
|
||||
$inv['total_amount'] = isset($inv['total_amount']) ? (float) $inv['total_amount'] : 0.0;
|
||||
// Prefer actual sum of payments for paid_amount
|
||||
// Normalize invoices for the view/JS; all accounting amounts come from the ledger.
|
||||
$invoices = array_map(function (array $inv) use ($installmentEndYmd) {
|
||||
$iid = (int)($inv['id'] ?? 0);
|
||||
$actualPaid = isset($paidByInvoice[$iid]) ? (float)$paidByInvoice[$iid] : null;
|
||||
$inv['paid_amount'] = is_numeric($actualPaid)
|
||||
? (float)$actualPaid
|
||||
: (isset($inv['paid_amount']) ? (float)$inv['paid_amount'] : 0.0);
|
||||
// Per-invoice discount
|
||||
$inv['discount'] = isset($discountByInvoice[$iid]) ? (float)$discountByInvoice[$iid] : (float)($inv['discount'] ?? 0.0);
|
||||
|
||||
// Per-invoice refunds (paid out)
|
||||
$inv['refund_paid'] = isset($refundByInvoice[$iid]) ? (float)$refundByInvoice[$iid] : 0.0;
|
||||
|
||||
// Derive balance from total - paid - discount - refundsPaid (never below 0)
|
||||
$inv['balance'] = max(0.0, (float)$inv['total_amount'] - (float)$inv['paid_amount'] - (float)$inv['discount'] - (float)$inv['refund_paid']);
|
||||
if ($iid > 0) {
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice($iid);
|
||||
$inv['display_total'] = (float) ($ledger['total_amount'] ?? 0);
|
||||
$inv['total_amount'] = $inv['display_total'];
|
||||
$inv['paid_amount'] = (float) ($ledger['paid_amount'] ?? 0);
|
||||
$inv['discount'] = (float) ($ledger['discount_total'] ?? 0);
|
||||
$inv['refund_paid'] = (float) ($ledger['refund_paid_total'] ?? 0);
|
||||
$inv['balance'] = (float) ($ledger['balance'] ?? 0);
|
||||
$inv['balance_due_cents'] = (int) ($ledger['balanceDueCents'] ?? 0);
|
||||
$inv['customer_credit_cents'] = (int) ($ledger['customerCreditCents'] ?? 0);
|
||||
$inv['customer_credit'] = (float) ($ledger['customer_credit'] ?? 0);
|
||||
$inv['status'] = (string) ($ledger['status'] ?? ($inv['status'] ?? ''));
|
||||
}
|
||||
|
||||
// Always use the configured end date for installments
|
||||
$inv['due_ymd'] = $installmentEndYmd;
|
||||
@@ -889,6 +845,10 @@ class PaymentController extends ResourceController
|
||||
$paymentMethod = strtolower(trim((string) $this->request->getPost('payment_method'))); // cash|check|card
|
||||
$checkNumber = trim((string) $this->request->getPost('check_number'));
|
||||
$searchTerm = $this->request->getPost('search_term') ?? $this->request->getGet('search_term') ?? '';
|
||||
$idempotencyKey = trim((string)($this->request->getPost('idempotency_key') ?? ''));
|
||||
if ($idempotencyKey === '') {
|
||||
$idempotencyKey = bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
// UI-only
|
||||
$paymentType = strtolower((string) ($this->request->getPost('payment_type') ?? 'full')); // full|installment
|
||||
@@ -932,10 +892,11 @@ class PaymentController extends ResourceController
|
||||
}
|
||||
|
||||
// Optional receipt upload
|
||||
$stagedEvidence = null;
|
||||
$checkFile = null;
|
||||
$paymentFile = $this->request->getFile('payment_file');
|
||||
try {
|
||||
$checkFile = $this->financialAttachmentService->saveUploadedFile(
|
||||
$stagedEvidence = $this->financialAttachmentService->stageUploadedFile(
|
||||
$paymentFile,
|
||||
$paymentMethod === 'check' ? 'checks' : ($paymentMethod === 'card' ? 'cards' : 'misc')
|
||||
);
|
||||
@@ -953,6 +914,7 @@ class PaymentController extends ResourceController
|
||||
|
||||
if (!$row) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->with('error', 'Invoice not found.');
|
||||
}
|
||||
|
||||
@@ -965,6 +927,7 @@ class PaymentController extends ResourceController
|
||||
$carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $invYear);
|
||||
if ($carryForwardPaymentRequired && $paymentType === 'installment') {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->withInput()->with(
|
||||
'error',
|
||||
'This parent has a balance carried over from a previous school year. Installments are not allowed; payment must be made in full.'
|
||||
@@ -973,6 +936,7 @@ class PaymentController extends ResourceController
|
||||
|
||||
if ($amount > $currentBalance + 0.00001) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->withInput()->with(
|
||||
'error',
|
||||
'Entered amount (' . number_format($amount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').'
|
||||
@@ -981,6 +945,7 @@ class PaymentController extends ResourceController
|
||||
|
||||
if ($carryForwardPaymentRequired && (float)round($amount, 2) !== (float)round($currentBalance, 2)) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->withInput()->with(
|
||||
'error',
|
||||
'This parent has a balance carried over from a previous school year. Payment must equal the full remaining balance (' . number_format($currentBalance, 2) . ').'
|
||||
@@ -989,6 +954,7 @@ class PaymentController extends ResourceController
|
||||
|
||||
if ($paymentMethod === 'card' && (float)round($amount, 2) !== (float)round($currentBalance, 2)) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->withInput()->with(
|
||||
'error',
|
||||
'Debit/Credit Card payments must equal the full remaining balance (' . number_format($currentBalance, 2) . ').'
|
||||
@@ -1010,13 +976,20 @@ class PaymentController extends ResourceController
|
||||
$checkNumber,
|
||||
null,
|
||||
(array) $row,
|
||||
$currentBalance
|
||||
$currentBalance,
|
||||
$idempotencyKey
|
||||
);
|
||||
|
||||
if ($paymentResult === false) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->with('error', 'Failed to record payment.');
|
||||
}
|
||||
if (!empty($paymentResult['conflict'])) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->withInput()->with('error', 'Payment idempotency key conflicts with a different request.');
|
||||
}
|
||||
$installmentSeq = (int) ($paymentResult['installment_seq'] ?? 1);
|
||||
|
||||
if (!$this->recordManualPayment(
|
||||
@@ -1029,6 +1002,7 @@ class PaymentController extends ResourceController
|
||||
$paymentDate
|
||||
)) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
log_message('error', '[manualPayUpdate] Failed to record manual payment audit row: ' . json_encode($this->manualPaymentModel->errors()));
|
||||
return redirect()->back()->with('error', 'Payment was not recorded because the manual payment audit row could not be saved.');
|
||||
}
|
||||
@@ -1071,6 +1045,25 @@ class PaymentController extends ResourceController
|
||||
|
||||
$this->db->transCommit();
|
||||
|
||||
$evidenceWarning = null;
|
||||
if ($stagedEvidence !== null && empty($paymentResult['duplicate'])) {
|
||||
try {
|
||||
$checkFile = $this->financialAttachmentService->finalizeStagedFile($stagedEvidence);
|
||||
$this->paymentModel->update((int)($paymentResult['payment_id'] ?? 0), [
|
||||
'check_file' => $checkFile,
|
||||
'evidence_status' => 'complete',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
$evidenceWarning = 'Payment recorded, but evidence could not be finalized.';
|
||||
log_message('critical', '[manualPayUpdate] evidence incomplete for payment #' . (int)($paymentResult['payment_id'] ?? 0) . ': ' . $e->getMessage());
|
||||
$this->paymentModel->update((int)($paymentResult['payment_id'] ?? 0), [
|
||||
'evidence_status' => 'incomplete',
|
||||
'evidence_failure_message' => $evidenceWarning,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Build payload & notify
|
||||
[$eventData, $studentData] = $this->buildPaymentEventData(
|
||||
$invoiceId,
|
||||
@@ -1090,9 +1083,10 @@ class PaymentController extends ResourceController
|
||||
|
||||
return redirect()
|
||||
->to(site_url('payment/manual_pay?search_term=' . urlencode($searchTerm)))
|
||||
->with('success', 'Payment recorded successfully (Installment #' . $installmentSeq . '). Transaction ID: ' . $transactionId);
|
||||
->with($evidenceWarning ? 'warning' : 'success', ($evidenceWarning ?? 'Payment recorded successfully') . ' (Installment #' . $installmentSeq . '). Transaction ID: ' . $transactionId);
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->db->transStatus()) $this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence ?? null);
|
||||
log_message('error', '[manualPayUpdate] ' . $e->getMessage());
|
||||
return redirect()->back()->withInput()->with('error', 'Unexpected error while recording payment.');
|
||||
}
|
||||
@@ -1402,8 +1396,22 @@ class PaymentController extends ResourceController
|
||||
}
|
||||
|
||||
if ($idempotencyKey !== null && $idempotencyKey !== '') {
|
||||
$fingerprint = $this->buildPaymentFingerprint(
|
||||
(int)$invoice['parent_id'],
|
||||
$invoiceId,
|
||||
(int)round($amount * 100),
|
||||
strtolower($paymentMethod),
|
||||
'USD'
|
||||
);
|
||||
$existing = $this->paymentModel->where('idempotency_key', $idempotencyKey)->first();
|
||||
if ($existing) {
|
||||
if ((string)($existing['request_fingerprint_hash'] ?? '') !== $fingerprint) {
|
||||
return [
|
||||
'conflict' => true,
|
||||
'duplicate' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'payment_id' => (int) ($existing['id'] ?? 0),
|
||||
'installment_seq' => (int) ($existing['installment_seq'] ?? $existing['number_of_installments'] ?? 1),
|
||||
@@ -1427,22 +1435,22 @@ class PaymentController extends ResourceController
|
||||
if ($amount > $preBalance + 0.00001) {
|
||||
return false;
|
||||
}
|
||||
$newBalance = max(0.0, round($preBalance - $amount, 2));
|
||||
|
||||
$paymentData = [
|
||||
'parent_id' => (int) $invoice['parent_id'],
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => $invoice['total_amount'],
|
||||
'paid_amount' => $amount,
|
||||
'balance' => $newBalance,
|
||||
'balance' => null,
|
||||
'number_of_installments' => $installmentSeq, // <-- installment sequence (1,2,3,...)
|
||||
'installment_seq' => $installmentSeq,
|
||||
'transaction_id' => $transactionId,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'request_fingerprint_hash' => $idempotencyKey ? ($fingerprint ?? null) : null,
|
||||
'payment_method' => strtolower($paymentMethod),
|
||||
'payment_date' => $paymentDate,
|
||||
'status' => FinancialStatus::PAYMENT_RECORDED,
|
||||
'check_file' => $checkFile,
|
||||
'evidence_status' => $checkFile ? 'complete' : null,
|
||||
'check_number' => (strtolower($paymentMethod) === 'check') ? $checkNumber : null,
|
||||
'updated_by' => session()->get('user_id'),
|
||||
'school_year' => $schoolYear ?? ($invoice['school_year'] ?? $this->schoolYear),
|
||||
@@ -1460,6 +1468,23 @@ class PaymentController extends ResourceController
|
||||
];
|
||||
}
|
||||
|
||||
private function buildPaymentFingerprint(
|
||||
int $parentId,
|
||||
int $invoiceId,
|
||||
int $amountCents,
|
||||
string $paymentMethod,
|
||||
string $currency
|
||||
): string {
|
||||
return hash('sha256', json_encode([
|
||||
'operation_type' => 'payment_creation',
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'amount_cents' => $amountCents,
|
||||
'payment_method' => strtolower($paymentMethod),
|
||||
'currency' => strtoupper($currency),
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
private function getSuccessfulPaymentCount(int $invoiceId): int
|
||||
{
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\FamilyGuardianModel;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use App\Services\EmailService;
|
||||
use App\Services\NotificationService;
|
||||
|
||||
@@ -20,6 +21,7 @@ class PaymentNotificationController extends BaseController
|
||||
protected PaymentModel $paymentModel;
|
||||
protected FamilyGuardianModel $familyGuardianModel;
|
||||
protected EmailService $emailService;
|
||||
protected InvoiceLedgerService $invoiceLedgerService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -30,6 +32,7 @@ class PaymentNotificationController extends BaseController
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->familyGuardianModel = new FamilyGuardianModel();
|
||||
$this->emailService = new EmailService();
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -87,43 +90,19 @@ class PaymentNotificationController extends BaseController
|
||||
$year = (int)$now->format('Y');
|
||||
$month= (int)$now->format('n');
|
||||
|
||||
// Helper: compute current total balance across invoices for this parent/year
|
||||
$computeBalance = function (int $pid) use ($schoolYear): array {
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->table('invoices')
|
||||
->select('id, total_amount')
|
||||
->select('id')
|
||||
->where('parent_id', $pid)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()->getResultArray();
|
||||
$paymentsTbl = 'payments';
|
||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
||||
|
||||
$sumBalance = 0.0; $latestId = null;
|
||||
foreach ($rows as $ir) {
|
||||
$iid = (int)$ir['id'];
|
||||
if ($latestId === null) $latestId = $iid;
|
||||
$total = (float)($ir['total_amount'] ?? 0);
|
||||
|
||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paid = (float)($qb->get()->getRowArray()['tot'] ?? 0);
|
||||
|
||||
$disc = (float)($db->table('discount_usages')->select('COALESCE(SUM(discount_amount),0) AS tot')->where('invoice_id', $iid)->get()->getRowArray()['tot'] ?? 0);
|
||||
$rfnd = (float)($db->table('refunds')->select('COALESCE(SUM(refund_paid_amount),0) AS tot')->where('invoice_id', $iid)->whereIn('status', ['Partial','Paid'])->get()->getRowArray()['tot'] ?? 0);
|
||||
|
||||
$sumBalance += max(0.0, round($total - $disc - $paid - $rfnd, 2));
|
||||
$sumBalance += (float)($this->invoiceLedgerService->calculateInvoice($iid)['balance'] ?? 0.0);
|
||||
}
|
||||
return [$sumBalance, $latestId];
|
||||
};
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\PaymentTransactionModel;
|
||||
use CodeIgniter\RESTful\ResourceController;
|
||||
|
||||
class PaymentTransactionController extends ResourceController
|
||||
{
|
||||
protected $paymentTransactionModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->paymentTransactionModel = new PaymentTransactionModel();
|
||||
}
|
||||
|
||||
// API: Create a new payment transaction (installment)
|
||||
public function createAPI()
|
||||
{
|
||||
$data = [
|
||||
'transaction_id' => $this->request->getPost('transaction_id'),
|
||||
'payment_id' => $this->request->getPost('payment_id'),
|
||||
'transaction_date' => $this->request->getPost('transaction_date'),
|
||||
'amount' => $this->request->getPost('amount'),
|
||||
'payment_method' => $this->request->getPost('payment_method'),
|
||||
'payment_status' => 'Pending',
|
||||
'transaction_fee' => $this->request->getPost('transaction_fee'),
|
||||
'payment_reference' => $this->request->getPost('payment_reference'),
|
||||
'is_full_payment' => $this->request->getPost('is_full_payment') ? 1 : 0,
|
||||
];
|
||||
|
||||
if ($this->paymentTransactionModel->save($data)) {
|
||||
return $this->respondCreated($data);
|
||||
} else {
|
||||
return $this->failValidationErrors($this->paymentTransactionModel->errors());
|
||||
}
|
||||
}
|
||||
|
||||
// API: Get all transactions for a specific payment
|
||||
public function getByPaymentAPI($paymentId)
|
||||
{
|
||||
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
|
||||
if ($transactions) {
|
||||
return $this->respond($transactions);
|
||||
} else {
|
||||
return $this->failNotFound('Transactions not found for the payment.');
|
||||
}
|
||||
}
|
||||
|
||||
// View: Get all transactions for a specific payment (for web views)
|
||||
public function getByPayment($paymentId)
|
||||
{
|
||||
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
|
||||
return view('payment_transaction_list', ['transactions' => $transactions]);
|
||||
}
|
||||
|
||||
// View: Create a new payment transaction
|
||||
public function create()
|
||||
{
|
||||
return view('payment_transaction_create');
|
||||
}
|
||||
|
||||
// API: Update payment status by transaction ID
|
||||
public function updateStatusAPI($transactionId)
|
||||
{
|
||||
$status = $this->request->getPost('status');
|
||||
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
|
||||
return $this->respond(['status' => 'success']);
|
||||
} else {
|
||||
return $this->failNotFound('Transaction not found.');
|
||||
}
|
||||
}
|
||||
|
||||
// View: Update payment status by transaction ID
|
||||
public function updateStatus($transactionId)
|
||||
{
|
||||
$status = $this->request->getPost('status');
|
||||
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
|
||||
return redirect()->to('/payment_transactions');
|
||||
} else {
|
||||
return redirect()->back()->with('error', 'Failed to update status.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,8 +79,11 @@ class PurchaseOrderController extends BaseController
|
||||
$subtotal = 0.0;
|
||||
$items = [];
|
||||
foreach ($supply_ids as $i => $sid) {
|
||||
$q = max(0, (int)($qtys[$i] ?? 0));
|
||||
$q = (int)($qtys[$i] ?? 0);
|
||||
$uc = (float)($unit_costs[$i] ?? 0);
|
||||
if ($sid && ($q <= 0 || $uc < 0)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Quantity must be greater than zero and unit cost cannot be negative.');
|
||||
}
|
||||
if ($sid && $q > 0) {
|
||||
$line = $q * $uc;
|
||||
$subtotal += $line;
|
||||
@@ -151,77 +154,329 @@ class PurchaseOrderController extends BaseController
|
||||
*/
|
||||
public function receive($id)
|
||||
{
|
||||
$po = $this->poModel->find($id);
|
||||
if (!$po || in_array($po['status'], ['canceled','received'], true)) {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'PO not receivable.');
|
||||
}
|
||||
|
||||
$received = $this->request->getPost('received') ?? []; // [itemId => qty]
|
||||
if (!$received) {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'No items to receive.');
|
||||
}
|
||||
$idempotencyKey = trim((string)($this->request->getPost('idempotency_key') ?? ''));
|
||||
if ($idempotencyKey === '') {
|
||||
$idempotencyKey = bin2hex(random_bytes(16));
|
||||
}
|
||||
$fingerprint = $this->buildReceiptFingerprint((int)$id, $received);
|
||||
|
||||
$issuedBy = (string) (session('user.email') ?? session('user.username') ?? 'system');
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
$completed = true;
|
||||
foreach ($received as $itemId => $qty) {
|
||||
$qty = (int)$qty;
|
||||
if ($qty <= 0) continue;
|
||||
|
||||
$item = $this->itemModel->where('purchase_order_id', $id)->find($itemId);
|
||||
if (!$item) { $completed = false; continue; }
|
||||
|
||||
$remaining = (int)$item['quantity'] - (int)$item['received_qty'];
|
||||
$toReceive = min($remaining, $qty);
|
||||
if ($toReceive <= 0) continue;
|
||||
|
||||
// Update item received qty
|
||||
$this->itemModel->update($itemId, [
|
||||
'received_qty' => (int)$item['received_qty'] + $toReceive
|
||||
]);
|
||||
|
||||
// Update supply on hand
|
||||
$supply = $this->supplyModel->find($item['supply_id']);
|
||||
if (!$supply) { $completed = false; continue; }
|
||||
$newQty = (int)$supply['qty_on_hand'] + $toReceive;
|
||||
$this->supplyModel->update($supply['id'], ['qty_on_hand' => $newQty]);
|
||||
|
||||
// Log transaction IN
|
||||
$this->txnModel->insert([
|
||||
'supply_id' => $supply['id'],
|
||||
'type' => 'in',
|
||||
'quantity' => $toReceive,
|
||||
'ref' => 'PO ' . $po['po_number'],
|
||||
'issued_to' => 'Inventory',
|
||||
'issued_by' => $issuedBy,
|
||||
'notes' => 'Received against PO',
|
||||
]);
|
||||
|
||||
if (($item['received_qty'] + $toReceive) < $item['quantity']) {
|
||||
$completed = false;
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$existingOperation = $this->db->query(
|
||||
'SELECT * FROM inventory_receipt_operations WHERE idempotency_key = ? FOR UPDATE',
|
||||
[$idempotencyKey]
|
||||
)->getRowArray();
|
||||
if ($existingOperation) {
|
||||
if ((int)$existingOperation['purchase_order_id'] !== (int)$id || (string)$existingOperation['request_fingerprint_hash'] !== $fingerprint) {
|
||||
$this->db->transCommit();
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Receipt idempotency key conflicts with a different request.');
|
||||
}
|
||||
$this->db->transCommit();
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('success', 'Receipt already recorded.');
|
||||
}
|
||||
}
|
||||
|
||||
// Set PO status
|
||||
$this->poModel->update($id, ['status' => $completed ? 'received' : 'ordered']);
|
||||
$po = $this->db->query('SELECT * FROM purchase_orders WHERE id = ? FOR UPDATE', [(int) $id])->getRowArray();
|
||||
if (!$po || in_array($po['status'], ['canceled','received'], true)) {
|
||||
throw new \RuntimeException('PO not receivable.');
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
if ($this->db->transStatus() === false) {
|
||||
$items = $this->db->query(
|
||||
'SELECT * FROM purchase_order_items WHERE purchase_order_id = ? FOR UPDATE',
|
||||
[(int) $id]
|
||||
)->getResultArray();
|
||||
if ($items === []) {
|
||||
throw new \RuntimeException('PO has no receivable lines.');
|
||||
}
|
||||
|
||||
$operationInserted = $this->db->table('inventory_receipt_operations')->insert([
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'purchase_order_id' => (int)$id,
|
||||
'request_fingerprint_hash' => $fingerprint,
|
||||
'status' => 'processing',
|
||||
'actor_id' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
$operationId = (int)$this->db->insertID();
|
||||
if (!$operationInserted) {
|
||||
$operationId = 0;
|
||||
}
|
||||
if ($operationId <= 0) {
|
||||
throw new \RuntimeException('Failed to create receipt operation.');
|
||||
}
|
||||
|
||||
$itemsById = [];
|
||||
foreach ($items as $item) {
|
||||
$ordered = (int) ($item['quantity'] ?? 0);
|
||||
$alreadyReceived = (int) ($item['received_qty'] ?? 0);
|
||||
if ($ordered <= 0 || $alreadyReceived < 0 || $alreadyReceived > $ordered) {
|
||||
throw new \RuntimeException('PO contains invalid received quantities.');
|
||||
}
|
||||
$itemsById[(int) $item['id']] = $item;
|
||||
}
|
||||
|
||||
foreach ($received as $itemId => $qty) {
|
||||
$itemId = (int) $itemId;
|
||||
$qty = (int)$qty;
|
||||
if ($qty <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($itemsById[$itemId])) {
|
||||
throw new \RuntimeException('Submitted item does not belong to this PO.');
|
||||
}
|
||||
|
||||
$item = $itemsById[$itemId];
|
||||
$remaining = (int)$item['quantity'] - (int)$item['received_qty'];
|
||||
if ($qty > $remaining) {
|
||||
throw new \RuntimeException('Received quantity exceeds ordered quantity.');
|
||||
}
|
||||
|
||||
$supply = $this->db->query('SELECT * FROM supplies WHERE id = ? FOR UPDATE', [(int) $item['supply_id']])->getRowArray();
|
||||
if (!$supply) {
|
||||
throw new \RuntimeException('Supply not found for PO line.');
|
||||
}
|
||||
|
||||
if (!$this->db->table('supplies')
|
||||
->where('id', (int) $supply['id'])
|
||||
->set('qty_on_hand', 'qty_on_hand + ' . $qty, false)
|
||||
->update()) {
|
||||
throw new \RuntimeException('Failed to update supply quantity.');
|
||||
}
|
||||
|
||||
$movementId = $this->txnModel->insert([
|
||||
'supply_id' => (int) $supply['id'],
|
||||
'type' => 'in',
|
||||
'quantity' => $qty,
|
||||
'ref' => 'PO ' . $po['po_number'],
|
||||
'issued_to' => 'Inventory',
|
||||
'issued_by' => $issuedBy,
|
||||
'notes' => 'Received against PO',
|
||||
]);
|
||||
if (!$movementId) {
|
||||
throw new \RuntimeException('Failed to record inventory transaction.');
|
||||
}
|
||||
if (!$this->db->table('inventory_receipt_lines')->insert([
|
||||
'operation_id' => $operationId,
|
||||
'purchase_order_item_id' => $itemId,
|
||||
'quantity' => $qty,
|
||||
'movement_id' => (int)$movementId,
|
||||
'reversed_quantity' => 0,
|
||||
'created_at' => utc_now(),
|
||||
])) {
|
||||
throw new \RuntimeException('Failed to record receipt line.');
|
||||
}
|
||||
|
||||
if (!$this->itemModel->update($itemId, [
|
||||
'received_qty' => (int)$item['received_qty'] + $qty
|
||||
])) {
|
||||
throw new \RuntimeException('Failed to update PO line quantity.');
|
||||
}
|
||||
|
||||
$itemsById[$itemId]['received_qty'] = (int)$item['received_qty'] + $qty;
|
||||
}
|
||||
|
||||
$completed = true;
|
||||
foreach ($itemsById as $item) {
|
||||
if ((int) $item['received_qty'] < (int) $item['quantity']) {
|
||||
$completed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->poModel->update($id, ['status' => $completed ? 'received' : 'ordered'])) {
|
||||
throw new \RuntimeException('Failed to update PO status.');
|
||||
}
|
||||
$this->db->table('inventory_receipt_operations')
|
||||
->where('id', $operationId)
|
||||
->update(['status' => 'completed', 'updated_at' => utc_now()]);
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('PO receive transaction failed.');
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'Failed to receive PO #{po}: {msg}', ['po' => $id, 'msg' => $e->getMessage()]);
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Failed to receive items.');
|
||||
}
|
||||
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('success', $completed ? 'PO fully received.' : 'PO partially received.');
|
||||
}
|
||||
|
||||
public function reverseReceipt(int $operationId)
|
||||
{
|
||||
$reason = trim((string)($this->request->getPost('reason') ?? ''));
|
||||
if ($reason === '') {
|
||||
return redirect()->back()->with('error', 'Receipt reversal reason is required.');
|
||||
}
|
||||
|
||||
$issuedBy = (string) (session('user.email') ?? session('user.username') ?? 'system');
|
||||
$poId = 0;
|
||||
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$operation = $this->db->query(
|
||||
'SELECT * FROM inventory_receipt_operations WHERE id = ? FOR UPDATE',
|
||||
[$operationId]
|
||||
)->getRowArray();
|
||||
if (!$operation || (string)($operation['status'] ?? '') !== 'completed') {
|
||||
throw new \RuntimeException('Receipt operation is not reversible.');
|
||||
}
|
||||
|
||||
$poId = (int)$operation['purchase_order_id'];
|
||||
$po = $this->db->query('SELECT * FROM purchase_orders WHERE id = ? FOR UPDATE', [$poId])->getRowArray();
|
||||
if (!$po) {
|
||||
throw new \RuntimeException('Purchase order not found.');
|
||||
}
|
||||
|
||||
$lines = $this->db->query(
|
||||
'SELECT rl.*, poi.supply_id, poi.received_qty, poi.quantity AS ordered_quantity
|
||||
FROM inventory_receipt_lines rl
|
||||
JOIN purchase_order_items poi ON poi.id = rl.purchase_order_item_id
|
||||
WHERE rl.operation_id = ?
|
||||
FOR UPDATE',
|
||||
[$operationId]
|
||||
)->getResultArray();
|
||||
if ($lines === []) {
|
||||
throw new \RuntimeException('Receipt operation has no lines.');
|
||||
}
|
||||
|
||||
$reversedAny = false;
|
||||
foreach ($lines as $line) {
|
||||
$remaining = (int)$line['quantity'] - (int)$line['reversed_quantity'];
|
||||
if ($remaining <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$supply = $this->db->query('SELECT * FROM supplies WHERE id = ? FOR UPDATE', [(int)$line['supply_id']])->getRowArray();
|
||||
if (!$supply) {
|
||||
throw new \RuntimeException('Supply not found for receipt line.');
|
||||
}
|
||||
if ((int)($supply['qty_on_hand'] ?? 0) < $remaining) {
|
||||
throw new \RuntimeException('Insufficient inventory for receipt reversal.');
|
||||
}
|
||||
if ((int)$line['received_qty'] < $remaining) {
|
||||
throw new \RuntimeException('PO line received quantity cannot cover reversal.');
|
||||
}
|
||||
|
||||
if (!$this->db->table('supplies')
|
||||
->where('id', (int)$supply['id'])
|
||||
->set('qty_on_hand', 'qty_on_hand - ' . $remaining, false)
|
||||
->update()) {
|
||||
throw new \RuntimeException('Failed to update supply quantity.');
|
||||
}
|
||||
|
||||
$movementId = $this->txnModel->insert([
|
||||
'supply_id' => (int)$supply['id'],
|
||||
'type' => 'out',
|
||||
'quantity' => $remaining,
|
||||
'ref' => 'PO ' . ($po['po_number'] ?? $poId),
|
||||
'issued_to' => 'Inventory',
|
||||
'issued_by' => $issuedBy,
|
||||
'notes' => 'Reversal of receipt operation #' . $operationId . ': ' . $reason,
|
||||
]);
|
||||
if (!$movementId) {
|
||||
throw new \RuntimeException('Failed to record inventory reversal transaction.');
|
||||
}
|
||||
|
||||
if (!$this->db->table('purchase_order_items')
|
||||
->where('id', (int)$line['purchase_order_item_id'])
|
||||
->set('received_qty', 'received_qty - ' . $remaining, false)
|
||||
->update()) {
|
||||
throw new \RuntimeException('Failed to update PO line received quantity.');
|
||||
}
|
||||
|
||||
if (!$this->db->table('inventory_receipt_lines')
|
||||
->where('id', (int)$line['id'])
|
||||
->update([
|
||||
'reversed_quantity' => (int)$line['reversed_quantity'] + $remaining,
|
||||
'reversal_movement_id' => (int)$movementId,
|
||||
])) {
|
||||
throw new \RuntimeException('Failed to mark receipt line reversed.');
|
||||
}
|
||||
|
||||
$reversedAny = true;
|
||||
}
|
||||
|
||||
if (!$reversedAny) {
|
||||
throw new \RuntimeException('Receipt operation is already fully reversed.');
|
||||
}
|
||||
|
||||
$items = $this->db->query(
|
||||
'SELECT quantity, received_qty FROM purchase_order_items WHERE purchase_order_id = ? FOR UPDATE',
|
||||
[$poId]
|
||||
)->getResultArray();
|
||||
$allReceived = $items !== [];
|
||||
foreach ($items as $item) {
|
||||
$allReceived = $allReceived && (int)($item['received_qty'] ?? 0) >= (int)($item['quantity'] ?? 0);
|
||||
}
|
||||
|
||||
if (!$this->poModel->update($poId, ['status' => $allReceived ? 'received' : 'ordered'])) {
|
||||
throw new \RuntimeException('Failed to update PO status.');
|
||||
}
|
||||
|
||||
$this->db->table('inventory_receipt_operations')
|
||||
->where('id', $operationId)
|
||||
->update(['status' => 'reversed', 'updated_at' => utc_now()]);
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('Receipt reversal transaction failed.');
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'Failed to reverse receipt operation #{operation}: {msg}', [
|
||||
'operation' => $operationId,
|
||||
'msg' => $e->getMessage(),
|
||||
]);
|
||||
return redirect()->to($poId > 0 ? 'inventory/po/show/' . $poId : 'inventory/po')->with('error', 'Failed to reverse receipt.');
|
||||
}
|
||||
|
||||
return redirect()->to('inventory/po/show/' . $poId)->with('success', 'Receipt reversed.');
|
||||
}
|
||||
|
||||
public function cancel($id)
|
||||
{
|
||||
$po = $this->poModel->find($id);
|
||||
if (!$po || $po['status'] === 'received') {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Cannot cancel this PO.');
|
||||
}
|
||||
$this->poModel->update($id, ['status' => 'canceled']);
|
||||
$received = $this->itemModel
|
||||
->where('purchase_order_id', (int) $id)
|
||||
->where('received_qty >', 0)
|
||||
->countAllResults();
|
||||
if ($received > 0) {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Cannot cancel a partially received PO without inventory reversal.');
|
||||
}
|
||||
if (!$this->poModel->update($id, ['status' => 'canceled'])) {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Failed to cancel PO.');
|
||||
}
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('success', 'PO canceled.');
|
||||
}
|
||||
|
||||
private function buildReceiptFingerprint(int $purchaseOrderId, array $received): string
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($received as $itemId => $qty) {
|
||||
$qty = (int)$qty;
|
||||
if ($qty > 0) {
|
||||
$normalized[(int)$itemId] = $qty;
|
||||
}
|
||||
}
|
||||
ksort($normalized);
|
||||
|
||||
return hash('sha256', json_encode([
|
||||
'operation_type' => 'inventory_receipt',
|
||||
'purchase_order_id' => $purchaseOrderId,
|
||||
'received' => $normalized,
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ use App\Models\ConfigurationModel;
|
||||
use App\Models\ReimbursementBatchModel;
|
||||
use App\Models\ReimbursementBatchItemModel;
|
||||
use App\Models\ReimbursementBatchAdminFileModel;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Services\EmailService;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
@@ -127,10 +128,13 @@ class ReimbursementController extends BaseController
|
||||
if ($year === '') {
|
||||
return 1;
|
||||
}
|
||||
$record = $this->batchModel
|
||||
->select('MAX(yearly_batch_number) AS max_number')
|
||||
->where('school_year', $year)
|
||||
->first();
|
||||
$record = $this->db->query(
|
||||
'SELECT COALESCE(MAX(yearly_batch_number), 0) AS max_number
|
||||
FROM reimbursement_batches
|
||||
WHERE school_year = ?
|
||||
FOR UPDATE',
|
||||
[$year]
|
||||
)->getRowArray();
|
||||
|
||||
$max = (int) ($record['max_number'] ?? 0);
|
||||
return $max + 1;
|
||||
@@ -553,22 +557,38 @@ class ReimbursementController extends BaseController
|
||||
$title = trim((string) ($this->request->getPost('title') ?? ''));
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$sequence = $this->nextYearlyBatchNumberForSchoolYear();
|
||||
|
||||
$data = [
|
||||
'title' => $title !== '' ? $title : null,
|
||||
'status' => 'open',
|
||||
'created_by' => $userId ?: null,
|
||||
'opened_at' => $now,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'yearly_batch_number' => $sequence,
|
||||
];
|
||||
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$sequence = $this->nextYearlyBatchNumberForSchoolYear();
|
||||
$data = [
|
||||
'title' => $title !== '' ? $title : null,
|
||||
'status' => 'open',
|
||||
'created_by' => $userId ?: null,
|
||||
'opened_at' => $now,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'yearly_batch_number' => $sequence,
|
||||
];
|
||||
|
||||
$this->batchModel->insert($data);
|
||||
$batchId = (int) $this->batchModel->getInsertID();
|
||||
if ($batchId <= 0) {
|
||||
throw new \RuntimeException('Batch insert failed.');
|
||||
}
|
||||
|
||||
$label = $title !== '' ? $title : 'Batch #' . $sequence;
|
||||
if ($title === '' && !$this->batchModel->update($batchId, ['title' => $label])) {
|
||||
throw new \RuntimeException('Batch title update failed.');
|
||||
}
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('Batch transaction failed.');
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'Failed to create reimbursement batch: {msg}', ['msg' => $e->getMessage()]);
|
||||
return $this->response->setStatusCode(500)->setJSON([
|
||||
'success' => false,
|
||||
@@ -576,18 +596,6 @@ class ReimbursementController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
if ($batchId <= 0) {
|
||||
return $this->response->setStatusCode(500)->setJSON([
|
||||
'success' => false,
|
||||
'error' => 'Failed to create batch.',
|
||||
]);
|
||||
}
|
||||
|
||||
$label = $title !== '' ? $title : 'Batch #' . $sequence;
|
||||
if ($title === '') {
|
||||
$this->batchModel->update($batchId, ['title' => $label]);
|
||||
}
|
||||
|
||||
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
|
||||
|
||||
return $this->response
|
||||
@@ -865,26 +873,55 @@ public function updateBatchAssignment()
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transBegin();
|
||||
|
||||
// Create reimbursement records for any batch items that don't yet have one
|
||||
$items = $this->db->table('reimbursement_batch_items bi')
|
||||
->select('bi.id AS batch_item_id, bi.reimbursement_id AS batch_reimb_id, bi.expense_id, e.amount, e.purchased_by, e.description, e.reimbursement_id AS expense_reimb_id, e.school_year AS expense_school_year, e.semester AS expense_semester')
|
||||
->join('expenses e', 'e.id = bi.expense_id', 'inner')
|
||||
->where('bi.batch_id', $batchId)
|
||||
->where('bi.unassigned_at IS NULL', null, false)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
$lockedBatch = $this->db->query('SELECT * FROM reimbursement_batches WHERE id = ? FOR UPDATE', [$batchId])->getRowArray();
|
||||
if (!$lockedBatch || strtolower((string) ($lockedBatch['status'] ?? '')) !== 'open') {
|
||||
throw new \RuntimeException('Batch is not open.');
|
||||
}
|
||||
|
||||
$items = $this->db->query(
|
||||
'SELECT bi.id AS batch_item_id,
|
||||
bi.reimbursement_id AS batch_reimb_id,
|
||||
bi.expense_id,
|
||||
e.amount,
|
||||
e.purchased_by,
|
||||
e.category,
|
||||
e.status AS expense_status,
|
||||
e.description,
|
||||
e.reimbursement_id AS expense_reimb_id,
|
||||
e.school_year AS expense_school_year,
|
||||
e.semester AS expense_semester
|
||||
FROM reimbursement_batch_items bi
|
||||
JOIN expenses e ON e.id = bi.expense_id
|
||||
WHERE bi.batch_id = ?
|
||||
AND bi.unassigned_at IS NULL
|
||||
FOR UPDATE',
|
||||
[$batchId]
|
||||
)->getResultArray();
|
||||
|
||||
if ($items === []) {
|
||||
throw new \RuntimeException('Batch has no active items.');
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
$expenseId = (int) ($item['expense_id'] ?? 0);
|
||||
$recipientId = (int) ($item['purchased_by'] ?? 0);
|
||||
if ($expenseId <= 0 || $recipientId <= 0) {
|
||||
continue;
|
||||
throw new \RuntimeException('Batch contains an invalid expense or recipient.');
|
||||
}
|
||||
if (FinancialStatus::normalize((string) ($item['expense_status'] ?? '')) !== 'approved') {
|
||||
throw new \RuntimeException('Every batch expense must be approved before closing.');
|
||||
}
|
||||
if ((float) ($item['amount'] ?? 0) <= 0) {
|
||||
throw new \RuntimeException('Every batch expense amount must be positive.');
|
||||
}
|
||||
if (strcasecmp((string) ($item['category'] ?? ''), 'Donation') === 0) {
|
||||
throw new \RuntimeException('Donation expenses cannot be reimbursed.');
|
||||
}
|
||||
|
||||
$reimbId = $item['batch_reimb_id'] ?: ($item['expense_reimb_id'] ?: $this->lookupReimbursementId($expenseId));
|
||||
@@ -895,28 +932,31 @@ public function updateBatchAssignment()
|
||||
'reimbursed_to' => $recipientId,
|
||||
'approved_by' => $userId ?: null,
|
||||
'description' => trim((string) ($item['description'] ?? '')),
|
||||
'status' => 'Paid',
|
||||
'status' => FinancialStatus::REIMBURSEMENT_PAID,
|
||||
'added_by' => $userId ?: null,
|
||||
'school_year' => $item['expense_school_year'] ?: $this->schoolYear,
|
||||
'semester' => $item['expense_semester'] ?: $this->semester,
|
||||
'reimbursement_method' => 'Check',
|
||||
'batch_number' => $batchId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$reimbId = $this->reimbModel->insert($payload);
|
||||
$reimbId = (int) $this->reimbModel->insert($payload);
|
||||
if ($reimbId <= 0) {
|
||||
throw new \RuntimeException('Failed to create reimbursement for batch item.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($reimbId) {
|
||||
$this->reimbModel->update($reimbId, [
|
||||
'batch_number' => $batchId,
|
||||
'approved_by' => $userId ?: null,
|
||||
'status' => 'Paid',
|
||||
]);
|
||||
$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbId]);
|
||||
if (!empty($item['batch_item_id'])) {
|
||||
$this->batchItemModel->update((int) $item['batch_item_id'], ['reimbursement_id' => $reimbId]);
|
||||
}
|
||||
if (!$this->reimbModel->update($reimbId, [
|
||||
'batch_number' => $batchId,
|
||||
'approved_by' => $userId ?: null,
|
||||
'status' => FinancialStatus::REIMBURSEMENT_PAID,
|
||||
])) {
|
||||
throw new \RuntimeException('Failed to update batch reimbursement.');
|
||||
}
|
||||
if (!$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbId])) {
|
||||
throw new \RuntimeException('Failed to link batch expense reimbursement.');
|
||||
}
|
||||
if (empty($item['batch_item_id']) || !$this->batchItemModel->update((int) $item['batch_item_id'], ['reimbursement_id' => $reimbId])) {
|
||||
throw new \RuntimeException('Failed to link batch item reimbursement.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -929,7 +969,9 @@ public function updateBatchAssignment()
|
||||
$update['closed_by'] = $userId;
|
||||
}
|
||||
|
||||
$this->batchModel->update($batchId, $update);
|
||||
if (!$this->batchModel->update($batchId, $update)) {
|
||||
throw new \RuntimeException('Failed to close reimbursement batch.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'Failed to lock reimbursement batch #{batch}: {msg}', [
|
||||
@@ -1979,15 +2021,6 @@ public function updateBatchAssignment()
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
if ($expenseId > 0) {
|
||||
$expense = $this->expenseModel->find($expenseId);
|
||||
if ($expense && strcasecmp($expense['category'] ?? '', 'Donation') === 0) {
|
||||
return redirect()->back()->withInput()->with('errors', [
|
||||
'expense_id' => 'Donation expenses are tracked but should not be reimbursed.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Store file only if one was actually uploaded
|
||||
try {
|
||||
$receiptName = $this->saveReimbReceipt($this->request->getFile('receipt'));
|
||||
@@ -2001,77 +2034,34 @@ public function updateBatchAssignment()
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
$recipientId = (int) $this->request->getPost('reimbursed_to');
|
||||
|
||||
// Mark reimbursement as Paid when recorded
|
||||
$data = [
|
||||
'expense_id' => $expenseId ?: null,
|
||||
'amount' => $this->request->getPost('amount'),
|
||||
'reimbursed_to' => $recipientId,
|
||||
'description' => $this->request->getPost('description'),
|
||||
'reimbursement_method' => $method,
|
||||
'check_number' => $method === 'Check' ? $this->request->getPost('check_number') : null,
|
||||
'receipt_path' => $receiptName, // may be null for Cash
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'added_by' => $userId,
|
||||
'approved_by' => $userId,
|
||||
'status' => 'Paid',
|
||||
];
|
||||
|
||||
$this->reimbModel->insert($data);
|
||||
$reimbursementId = $this->reimbModel->getInsertID();
|
||||
|
||||
if ($expenseId = $this->request->getPost('expense_id')) {
|
||||
$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbursementId]);
|
||||
try {
|
||||
$this->createPaidReimbursementForExpense(
|
||||
$expenseId,
|
||||
(float) $this->request->getPost('amount'),
|
||||
$recipientId,
|
||||
$method,
|
||||
$method === 'Check' ? (string) $this->request->getPost('check_number') : null,
|
||||
$receiptName,
|
||||
(string) $this->request->getPost('description'),
|
||||
$userId
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
if ($receiptName !== null) {
|
||||
@unlink(WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . 'reimbursements' . DIRECTORY_SEPARATOR . basename($receiptName));
|
||||
}
|
||||
log_message('error', 'Reimbursement creation failed: {msg}', ['msg' => $e->getMessage()]);
|
||||
return redirect()->back()->withInput()->with('errors', [
|
||||
'expense_id' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->to('/reimbursements')->with('success', 'Reimbursement recorded as Paid.');
|
||||
}
|
||||
|
||||
|
||||
// Optional old flow kept for compatibility (also sets Paid)
|
||||
public function process()
|
||||
{
|
||||
$expenseId = (int) ($this->request->getPost('expense_id') ?? 0);
|
||||
if ($expenseId > 0) {
|
||||
$expense = $this->expenseModel->find($expenseId);
|
||||
if ($expense && strcasecmp($expense['category'] ?? '', 'Donation') === 0) {
|
||||
return redirect()->back()->withInput()->with('errors', [
|
||||
'expense_id' => 'Donation expenses are tracked but should not be reimbursed.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$receiptName = $this->saveReimbReceipt($this->request->getFile('receipt'));
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to save reimbursement receipt in process(): {msg}', ['msg' => $e->getMessage()]);
|
||||
return redirect()->back()->withInput()->with('errors', [
|
||||
'receipt' => 'Failed to save uploaded file. Please try again or contact admin.'
|
||||
]);
|
||||
}
|
||||
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
$recipientId = (int) $this->request->getPost('reimbursed_to');
|
||||
|
||||
$reimbursementId = $this->reimbModel->insert([
|
||||
'amount' => $this->request->getPost('amount'),
|
||||
'reimbursed_to' => $recipientId,
|
||||
'approved_by' => $userId,
|
||||
'receipt_path' => $receiptName,
|
||||
'description' => 'Expense reimbursement',
|
||||
'status' => 'Paid',
|
||||
'added_by' => $userId,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'check_number' => $this->request->getPost('check_number'),
|
||||
'reimbursement_method' => $this->request->getPost('reimbursement_method')
|
||||
]);
|
||||
|
||||
$this->expenseModel->update($expenseId, [
|
||||
'reimbursement_id' => $reimbursementId
|
||||
]);
|
||||
|
||||
return redirect()->to('/reimbursements/under-processing')->with('success', 'Reimbursement processed!');
|
||||
return $this->store();
|
||||
}
|
||||
|
||||
public function reimbursedExpenses()
|
||||
@@ -2118,6 +2108,9 @@ public function updateBatchAssignment()
|
||||
if (!$reimb) {
|
||||
throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found");
|
||||
}
|
||||
if ($this->isPaidReimbursement($reimb)) {
|
||||
return redirect()->to('/reimbursements')->with('error', 'Paid reimbursements are immutable. Reverse and replace the transaction instead.');
|
||||
}
|
||||
|
||||
$users = $this->recipientOptions();
|
||||
|
||||
@@ -2136,6 +2129,9 @@ public function updateBatchAssignment()
|
||||
if (!$reimb) {
|
||||
throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found");
|
||||
}
|
||||
if ($this->isPaidReimbursement($reimb)) {
|
||||
return redirect()->to('/reimbursements')->with('error', 'Paid reimbursements are immutable. Reverse and replace the transaction instead.');
|
||||
}
|
||||
|
||||
$methodRaw = (string) $this->request->getPost('reimbursement_method');
|
||||
$method = ucfirst(strtolower($methodRaw));
|
||||
@@ -2198,6 +2194,162 @@ public function updateBatchAssignment()
|
||||
return redirect()->to('/reimbursements')->with('success', 'Reimbursement updated.');
|
||||
}
|
||||
|
||||
public function reverse(int $id)
|
||||
{
|
||||
$reason = trim((string)$this->request->getPost('reason'));
|
||||
if ($reason === '') {
|
||||
return redirect()->back()->with('error', 'Reversal reason is required.');
|
||||
}
|
||||
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$reimbursement = $this->db->query('SELECT * FROM reimbursements WHERE id = ? FOR UPDATE', [$id])->getRowArray();
|
||||
if (!$reimbursement) {
|
||||
throw new \RuntimeException('Reimbursement not found.');
|
||||
}
|
||||
if (FinancialStatus::normalizeReimbursementStatus($reimbursement['status'] ?? null) !== FinancialStatus::REIMBURSEMENT_PAID) {
|
||||
throw new \RuntimeException('Only paid reimbursements can be reversed.');
|
||||
}
|
||||
|
||||
$expenseId = (int)($reimbursement['expense_id'] ?? 0);
|
||||
if ($expenseId > 0) {
|
||||
$this->db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$expenseId])->getRowArray();
|
||||
}
|
||||
|
||||
$existingReversal = $this->db->table('reimbursement_reversals')
|
||||
->where('reimbursement_id', $id)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if ($existingReversal) {
|
||||
throw new \RuntimeException('Reimbursement has already been reversed.');
|
||||
}
|
||||
|
||||
$amountCents = (int)round(((float)($reimbursement['amount'] ?? 0)) * 100);
|
||||
$now = utc_now();
|
||||
if (!$this->db->table('reimbursement_reversals')->insert([
|
||||
'reimbursement_id' => $id,
|
||||
'amount_cents' => $amountCents,
|
||||
'reason' => $reason,
|
||||
'reversed_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||
'reversed_at' => $now,
|
||||
'created_at' => $now,
|
||||
])) {
|
||||
throw new \RuntimeException('Reimbursement reversal could not be recorded.');
|
||||
}
|
||||
|
||||
if (!$this->reimbModel->update($id, ['status' => FinancialStatus::REIMBURSEMENT_REVERSED])) {
|
||||
throw new \RuntimeException('Reimbursement status could not be updated.');
|
||||
}
|
||||
if ($expenseId > 0 && !$this->expenseModel->update($expenseId, ['reimbursement_id' => null])) {
|
||||
throw new \RuntimeException('Expense reimbursement link could not be cleared.');
|
||||
}
|
||||
if ($this->db->transStatus() === false) {
|
||||
throw new \RuntimeException('Reimbursement reversal transaction failed.');
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->to('/reimbursements')->with('success', 'Reimbursement reversed.');
|
||||
}
|
||||
|
||||
private function createPaidReimbursementForExpense(
|
||||
int $expenseId,
|
||||
float $amount,
|
||||
int $recipientId,
|
||||
string $method,
|
||||
?string $checkNumber,
|
||||
?string $receiptName,
|
||||
string $description,
|
||||
int $userId,
|
||||
?int $batchId = null
|
||||
): int {
|
||||
if ($expenseId <= 0) {
|
||||
throw new \RuntimeException('A valid approved expense is required.');
|
||||
}
|
||||
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$expense = $this->db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$expenseId])->getRowArray();
|
||||
if (!$expense) {
|
||||
throw new \RuntimeException('Expense not found.');
|
||||
}
|
||||
if (FinancialStatus::normalize((string) ($expense['status'] ?? '')) !== 'approved') {
|
||||
throw new \RuntimeException('Expense must be approved before reimbursement.');
|
||||
}
|
||||
if ((float) ($expense['amount'] ?? 0) <= 0 || $amount <= 0) {
|
||||
throw new \RuntimeException('Reimbursement amount must be positive.');
|
||||
}
|
||||
if (abs((float) ($expense['amount'] ?? 0) - $amount) > 0.005) {
|
||||
throw new \RuntimeException('Reimbursement amount must match the approved expense.');
|
||||
}
|
||||
if (strcasecmp((string) ($expense['category'] ?? ''), 'Donation') === 0) {
|
||||
throw new \RuntimeException('Donation expenses are tracked but should not be reimbursed.');
|
||||
}
|
||||
if ($recipientId !== (int) ($expense['purchased_by'] ?? 0)) {
|
||||
throw new \RuntimeException('Reimbursement recipient must match the expense purchaser.');
|
||||
}
|
||||
if ((string) ($expense['school_year'] ?? '') !== (string) $this->schoolYear) {
|
||||
throw new \RuntimeException('Expense is outside the active school year.');
|
||||
}
|
||||
if (!empty($expense['semester']) && (string) $expense['semester'] !== (string) $this->semester) {
|
||||
throw new \RuntimeException('Expense is outside the active semester.');
|
||||
}
|
||||
|
||||
$active = $this->db->query(
|
||||
"SELECT id FROM reimbursements
|
||||
WHERE expense_id = ?
|
||||
AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')
|
||||
FOR UPDATE",
|
||||
[$expenseId]
|
||||
)->getRowArray();
|
||||
if ($active || !empty($expense['reimbursement_id'])) {
|
||||
throw new \RuntimeException('Expense already has an active reimbursement.');
|
||||
}
|
||||
|
||||
$reimbursementId = (int) $this->reimbModel->insert([
|
||||
'expense_id' => $expenseId,
|
||||
'amount' => $amount,
|
||||
'reimbursed_to' => $recipientId,
|
||||
'description' => $description !== '' ? $description : (string) ($expense['description'] ?? ''),
|
||||
'reimbursement_method' => $method,
|
||||
'check_number' => $checkNumber,
|
||||
'receipt_path' => $receiptName,
|
||||
'school_year' => $expense['school_year'] ?: $this->schoolYear,
|
||||
'semester' => $expense['semester'] ?: $this->semester,
|
||||
'added_by' => $userId ?: null,
|
||||
'approved_by' => $userId ?: null,
|
||||
'status' => FinancialStatus::REIMBURSEMENT_PAID,
|
||||
'batch_number' => $batchId,
|
||||
]);
|
||||
if ($reimbursementId <= 0) {
|
||||
throw new \RuntimeException('Reimbursement insert failed.');
|
||||
}
|
||||
|
||||
if (!$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbursementId])) {
|
||||
throw new \RuntimeException('Expense reimbursement link failed.');
|
||||
}
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('Reimbursement transaction failed.');
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
return $reimbursementId;
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function isPaidReimbursement(array $reimbursement): bool
|
||||
{
|
||||
return FinancialStatus::normalizeReimbursementStatus((string) ($reimbursement['status'] ?? '')) === FinancialStatus::REIMBURSEMENT_PAID;
|
||||
}
|
||||
|
||||
private function lookupReimbursementId(int $expenseId): ?int
|
||||
{
|
||||
if ($expenseId <= 0) {
|
||||
|
||||
@@ -69,11 +69,28 @@ class PaymentsLogicAndDataRepairSupport extends Migration
|
||||
|
||||
if (!$this->db->fieldExists('idempotency_key', 'payments')) {
|
||||
$columns['idempotency_key'] = [
|
||||
'type' => 'CHAR',
|
||||
'constraint' => 36,
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 100,
|
||||
'null' => true,
|
||||
'after' => 'transaction_id',
|
||||
];
|
||||
} else {
|
||||
$this->forge->modifyColumn('payments', [
|
||||
'idempotency_key' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 100,
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
if (!$this->db->fieldExists('request_fingerprint_hash', 'payments')) {
|
||||
$columns['request_fingerprint_hash'] = [
|
||||
'type' => 'CHAR',
|
||||
'constraint' => 64,
|
||||
'null' => true,
|
||||
'after' => 'idempotency_key',
|
||||
];
|
||||
}
|
||||
|
||||
if (!$this->db->fieldExists('is_void', 'payments')) {
|
||||
@@ -86,6 +103,24 @@ class PaymentsLogicAndDataRepairSupport extends Migration
|
||||
];
|
||||
}
|
||||
|
||||
if (!$this->db->fieldExists('evidence_status', 'payments')) {
|
||||
$columns['evidence_status'] = [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 30,
|
||||
'null' => true,
|
||||
'after' => 'check_file',
|
||||
];
|
||||
}
|
||||
|
||||
if (!$this->db->fieldExists('evidence_failure_message', 'payments')) {
|
||||
$columns['evidence_failure_message'] = [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => true,
|
||||
'after' => 'evidence_status',
|
||||
];
|
||||
}
|
||||
|
||||
if (!$this->db->fieldExists('reversal_of_payment_id', 'payments')) {
|
||||
$columns['reversal_of_payment_id'] = [
|
||||
'type' => 'INT',
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateInvoiceLines extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (!$this->db->tableExists('invoice_lines')) {
|
||||
$this->forge->addField([
|
||||
'id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true,
|
||||
],
|
||||
'invoice_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'null' => false,
|
||||
],
|
||||
'line_type' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 50,
|
||||
'null' => false,
|
||||
],
|
||||
'source_type' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 50,
|
||||
'null' => true,
|
||||
],
|
||||
'source_id' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'unsigned' => true,
|
||||
'null' => true,
|
||||
],
|
||||
'active_source_key' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 120,
|
||||
'null' => true,
|
||||
],
|
||||
'description' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 255,
|
||||
'null' => false,
|
||||
],
|
||||
'quantity' => [
|
||||
'type' => 'DECIMAL',
|
||||
'constraint' => '10,2',
|
||||
'null' => false,
|
||||
'default' => '1.00',
|
||||
],
|
||||
'unit_amount_cents' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'null' => false,
|
||||
'default' => 0,
|
||||
],
|
||||
'line_amount_cents' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'null' => false,
|
||||
'default' => 0,
|
||||
],
|
||||
'discount_eligible' => [
|
||||
'type' => 'TINYINT',
|
||||
'constraint' => 1,
|
||||
'null' => false,
|
||||
'default' => 1,
|
||||
],
|
||||
'calculation_version' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 50,
|
||||
'null' => true,
|
||||
],
|
||||
'metadata_json' => [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
],
|
||||
'created_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => false,
|
||||
],
|
||||
'updated_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => false,
|
||||
],
|
||||
'voided_at' => [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('invoice_id');
|
||||
$this->forge->addKey(['source_type', 'source_id']);
|
||||
$this->forge->addUniqueKey('active_source_key', 'uniq_invoice_lines_active_source_key');
|
||||
$this->forge->createTable('invoice_lines', true);
|
||||
}
|
||||
|
||||
$this->backfillLegacyInvoiceLines();
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if ($this->db->tableExists('invoice_lines')) {
|
||||
$this->forge->dropTable('invoice_lines', true);
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillLegacyInvoiceLines(): void
|
||||
{
|
||||
if (!$this->db->tableExists('invoices') || !$this->db->tableExists('invoice_lines')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existingRows = $this->db->table('invoice_lines')
|
||||
->select('invoice_id')
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
$existing = array_fill_keys(array_map(static fn ($row) => (int) ($row['invoice_id'] ?? 0), $existingRows), true);
|
||||
|
||||
$invoices = $this->db->table('invoices')
|
||||
->select('id, total_amount, invoice_number, created_at, updated_at')
|
||||
->orderBy('id', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
foreach ($invoices as $invoice) {
|
||||
$invoiceId = (int) ($invoice['id'] ?? 0);
|
||||
if ($invoiceId <= 0 || isset($existing[$invoiceId])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amountCents = (int) round(((float) ($invoice['total_amount'] ?? 0)) * 100);
|
||||
$createdAt = $invoice['created_at'] ?? $now;
|
||||
$updatedAt = $invoice['updated_at'] ?? $createdAt;
|
||||
|
||||
$this->db->table('invoice_lines')->insert([
|
||||
'invoice_id' => $invoiceId,
|
||||
'line_type' => 'legacy_invoice_total',
|
||||
'source_type' => 'legacy_invoice',
|
||||
'source_id' => $invoiceId,
|
||||
'active_source_key' => null,
|
||||
'description' => 'Legacy invoice total preserved from invoice ' . (string) ($invoice['invoice_number'] ?? $invoiceId),
|
||||
'quantity' => '1.00',
|
||||
'unit_amount_cents' => $amountCents,
|
||||
'line_amount_cents' => $amountCents,
|
||||
'discount_eligible' => 0,
|
||||
'calculation_version' => 'legacy_import',
|
||||
'metadata_json' => json_encode([
|
||||
'source' => 'invoices.total_amount',
|
||||
'legacy_discount_eligible_base_cents' => 0,
|
||||
'reconciliation_required' => true,
|
||||
], JSON_UNESCAPED_SLASHES),
|
||||
'created_at' => $createdAt ?: $now,
|
||||
'updated_at' => $updatedAt ?: $now,
|
||||
'voided_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddRefundSourcesAndPayouts extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$this->ensureRefundSourceColumns();
|
||||
$this->ensureRefundPayoutsTable();
|
||||
$this->ensureRefundPayoutFingerprintColumns();
|
||||
$this->backfillRefundSources();
|
||||
$this->backfillLegacyPayouts();
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if ($this->db->tableExists('refund_payouts')) {
|
||||
$this->forge->dropTable('refund_payouts', true);
|
||||
}
|
||||
|
||||
if ($this->db->tableExists('refunds')) {
|
||||
foreach (['source_type', 'source_id', 'requested_amount_cents', 'approved_amount_cents', 'currency'] as $column) {
|
||||
if ($this->db->fieldExists($column, 'refunds')) {
|
||||
$this->forge->dropColumn('refunds', $column);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureRefundSourceColumns(): void
|
||||
{
|
||||
if (!$this->db->tableExists('refunds')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
if (!$this->db->fieldExists('source_type', 'refunds')) {
|
||||
$columns['source_type'] = ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true, 'after' => 'request'];
|
||||
}
|
||||
if (!$this->db->fieldExists('source_id', 'refunds')) {
|
||||
$columns['source_id'] = ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'source_type'];
|
||||
}
|
||||
if (!$this->db->fieldExists('requested_amount_cents', 'refunds')) {
|
||||
$columns['requested_amount_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'refund_amount'];
|
||||
}
|
||||
if (!$this->db->fieldExists('approved_amount_cents', 'refunds')) {
|
||||
$columns['approved_amount_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'requested_amount_cents'];
|
||||
}
|
||||
if (!$this->db->fieldExists('currency', 'refunds')) {
|
||||
$columns['currency'] = ['type' => 'CHAR', 'constraint' => 3, 'null' => false, 'default' => 'USD', 'after' => 'approved_amount_cents'];
|
||||
}
|
||||
|
||||
if ($columns !== []) {
|
||||
$this->forge->addColumn('refunds', $columns);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureRefundPayoutsTable(): void
|
||||
{
|
||||
if ($this->db->tableExists('refund_payouts')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'refund_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'amount_cents' => ['type' => 'INT', 'constraint' => 11, 'null' => false],
|
||||
'currency' => ['type' => 'CHAR', 'constraint' => 3, 'null' => false, 'default' => 'USD'],
|
||||
'payout_type' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false, 'default' => 'cash_out'],
|
||||
'payment_method' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true],
|
||||
'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false],
|
||||
'external_reference' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
|
||||
'check_number' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
|
||||
'check_date' => ['type' => 'DATE', 'null' => true],
|
||||
'evidence_path' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
|
||||
'idempotency_key' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => false],
|
||||
'operation_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
|
||||
'request_fingerprint_hash' => ['type' => 'CHAR', 'constraint' => 64, 'null' => true],
|
||||
'processed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'processed_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'reversed_payout_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'failure_code' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
|
||||
'failure_message' => ['type' => 'TEXT', 'null' => true],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => false],
|
||||
'updated_at' => ['type' => 'DATETIME', 'null' => false],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('refund_id');
|
||||
$this->forge->addKey('reversed_payout_id');
|
||||
$this->forge->addUniqueKey('idempotency_key', 'uniq_refund_payouts_idempotency_key');
|
||||
$this->forge->createTable('refund_payouts', true);
|
||||
}
|
||||
|
||||
private function ensureRefundPayoutFingerprintColumns(): void
|
||||
{
|
||||
if (!$this->db->tableExists('refund_payouts')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
if (!$this->db->fieldExists('operation_type', 'refund_payouts')) {
|
||||
$columns['operation_type'] = [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 50,
|
||||
'null' => true,
|
||||
'after' => 'idempotency_key',
|
||||
];
|
||||
}
|
||||
if (!$this->db->fieldExists('request_fingerprint_hash', 'refund_payouts')) {
|
||||
$columns['request_fingerprint_hash'] = [
|
||||
'type' => 'CHAR',
|
||||
'constraint' => 64,
|
||||
'null' => true,
|
||||
'after' => 'operation_type',
|
||||
];
|
||||
}
|
||||
|
||||
if ($columns !== []) {
|
||||
$this->forge->addColumn('refund_payouts', $columns);
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillRefundSources(): void
|
||||
{
|
||||
if (!$this->db->tableExists('refunds')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->db->query(
|
||||
"UPDATE `refunds`
|
||||
SET `requested_amount_cents` = ROUND(COALESCE(`refund_amount`, 0) * 100),
|
||||
`approved_amount_cents` = ROUND(COALESCE(`refund_amount`, 0) * 100),
|
||||
`currency` = COALESCE(NULLIF(`currency`, ''), 'USD')
|
||||
WHERE `requested_amount_cents` IS NULL OR `approved_amount_cents` IS NULL"
|
||||
);
|
||||
|
||||
if ($this->db->fieldExists('source_type', 'refunds')) {
|
||||
$this->db->query(
|
||||
"UPDATE `refunds`
|
||||
SET `source_type` = CASE
|
||||
WHEN `invoice_id` IS NOT NULL THEN 'invoice_overpayment'
|
||||
WHEN LOWER(COALESCE(`request`, '')) = 'duplicate' THEN 'payment_duplicate'
|
||||
ELSE 'administrative_credit'
|
||||
END,
|
||||
`source_id` = CASE
|
||||
WHEN `invoice_id` IS NOT NULL THEN `invoice_id`
|
||||
ELSE `source_id`
|
||||
END
|
||||
WHERE `source_type` IS NULL"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private function backfillLegacyPayouts(): void
|
||||
{
|
||||
if (!$this->db->tableExists('refunds') || !$this->db->tableExists('refund_payouts')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$refunds = $this->db->table('refunds')
|
||||
->select('id, refund_paid_amount, currency, refund_method, check_nbr, check_file, refunded_at, updated_by, created_at, updated_at')
|
||||
->where('refund_paid_amount >', 0)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
foreach ($refunds as $refund) {
|
||||
$refundId = (int) ($refund['id'] ?? 0);
|
||||
if ($refundId <= 0) {
|
||||
continue;
|
||||
}
|
||||
$exists = $this->db->table('refund_payouts')
|
||||
->where('refund_id', $refundId)
|
||||
->where('idempotency_key', 'legacy-refund-' . $refundId)
|
||||
->countAllResults();
|
||||
if ($exists > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->db->table('refund_payouts')->insert([
|
||||
'refund_id' => $refundId,
|
||||
'amount_cents' => (int) round(((float) ($refund['refund_paid_amount'] ?? 0)) * 100),
|
||||
'currency' => (string) ($refund['currency'] ?? 'USD') ?: 'USD',
|
||||
'payout_type' => 'cash_out',
|
||||
'payment_method' => $refund['refund_method'] ?? null,
|
||||
'status' => 'completed',
|
||||
'external_reference' => 'legacy_import',
|
||||
'check_number' => $refund['check_nbr'] ?? null,
|
||||
'check_date' => null,
|
||||
'evidence_path' => $refund['check_file'] ?? null,
|
||||
'idempotency_key' => 'legacy-refund-' . $refundId,
|
||||
'operation_type' => 'refund_payout',
|
||||
'request_fingerprint_hash' => hash('sha256', json_encode([
|
||||
'operation_type' => 'refund_payout',
|
||||
'refund_id' => $refundId,
|
||||
'amount_cents' => (int) round(((float) ($refund['refund_paid_amount'] ?? 0)) * 100),
|
||||
'payment_method' => $refund['refund_method'] ?? null,
|
||||
'currency' => (string) ($refund['currency'] ?? 'USD') ?: 'USD',
|
||||
'external_reference' => 'legacy_import',
|
||||
], JSON_UNESCAPED_SLASHES)),
|
||||
'processed_by' => $refund['updated_by'] ?? null,
|
||||
'processed_at' => $refund['refunded_at'] ?? $refund['updated_at'] ?? $now,
|
||||
'reversed_payout_id' => null,
|
||||
'failure_code' => null,
|
||||
'failure_message' => null,
|
||||
'created_at' => $refund['created_at'] ?? $now,
|
||||
'updated_at' => $refund['updated_at'] ?? $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddRefundReconciliationFields extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (!$this->db->tableExists('refunds')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
if (!$this->db->fieldExists('reconciliation_status', 'refunds')) {
|
||||
$columns['reconciliation_status'] = [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 30,
|
||||
'null' => true,
|
||||
'after' => 'approved_amount_cents',
|
||||
];
|
||||
}
|
||||
if (!$this->db->fieldExists('reconciliation_reason', 'refunds')) {
|
||||
$columns['reconciliation_reason'] = [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
'after' => 'reconciliation_status',
|
||||
];
|
||||
}
|
||||
if (!$this->db->fieldExists('reconciliation_required_at', 'refunds')) {
|
||||
$columns['reconciliation_required_at'] = [
|
||||
'type' => 'DATETIME',
|
||||
'null' => true,
|
||||
'after' => 'reconciliation_reason',
|
||||
];
|
||||
}
|
||||
|
||||
if ($columns !== []) {
|
||||
$this->forge->addColumn('refunds', $columns);
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if (!$this->db->tableExists('refunds')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (['reconciliation_status', 'reconciliation_reason', 'reconciliation_required_at'] as $column) {
|
||||
if ($this->db->fieldExists($column, 'refunds')) {
|
||||
$this->forge->dropColumn('refunds', $column);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class FinancialWorkflowHardening extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$this->ensureDiscountUsageColumns();
|
||||
$this->ensureDiscountUsageUniqueness();
|
||||
$this->ensureAdditionalChargeLedgerColumns();
|
||||
$this->ensureInvoiceLineActiveSourceKey();
|
||||
$this->expandAdditionalChargeStatus();
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if ($this->db->tableExists('discount_usages')) {
|
||||
foreach (['requested_discount_cents', 'eligible_base_cents', 'eligible_base_before_cents', 'applied_discount_cents', 'application_order'] as $column) {
|
||||
if ($this->db->fieldExists($column, 'discount_usages')) {
|
||||
$this->forge->dropColumn('discount_usages', $column);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureDiscountUsageColumns(): void
|
||||
{
|
||||
if (!$this->db->tableExists('discount_usages')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
if (!$this->db->fieldExists('requested_discount_cents', 'discount_usages')) {
|
||||
$columns['requested_discount_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'discount_amount'];
|
||||
}
|
||||
if (!$this->db->fieldExists('eligible_base_cents', 'discount_usages')) {
|
||||
$columns['eligible_base_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'requested_discount_cents'];
|
||||
}
|
||||
if (!$this->db->fieldExists('eligible_base_before_cents', 'discount_usages')) {
|
||||
$columns['eligible_base_before_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'eligible_base_cents'];
|
||||
}
|
||||
if (!$this->db->fieldExists('applied_discount_cents', 'discount_usages')) {
|
||||
$columns['applied_discount_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'eligible_base_before_cents'];
|
||||
}
|
||||
if (!$this->db->fieldExists('application_order', 'discount_usages')) {
|
||||
$columns['application_order'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'applied_discount_cents'];
|
||||
}
|
||||
|
||||
if ($columns !== []) {
|
||||
$this->forge->addColumn('discount_usages', $columns);
|
||||
}
|
||||
|
||||
$this->db->query(
|
||||
"UPDATE discount_usages
|
||||
SET requested_discount_cents = COALESCE(requested_discount_cents, ROUND(COALESCE(discount_amount, 0) * 100)),
|
||||
eligible_base_cents = COALESCE(eligible_base_cents, ROUND(COALESCE(discount_amount, 0) * 100)),
|
||||
eligible_base_before_cents = COALESCE(eligible_base_before_cents, eligible_base_cents, ROUND(COALESCE(discount_amount, 0) * 100)),
|
||||
applied_discount_cents = COALESCE(applied_discount_cents, ROUND(COALESCE(discount_amount, 0) * 100))
|
||||
WHERE requested_discount_cents IS NULL
|
||||
OR eligible_base_cents IS NULL
|
||||
OR eligible_base_before_cents IS NULL
|
||||
OR applied_discount_cents IS NULL"
|
||||
);
|
||||
|
||||
$this->db->query(
|
||||
"UPDATE discount_usages du
|
||||
JOIN (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY invoice_id ORDER BY COALESCE(used_at, created_at), id) AS rn
|
||||
FROM discount_usages
|
||||
) ordered ON ordered.id = du.id
|
||||
SET du.application_order = COALESCE(du.application_order, ordered.rn)
|
||||
WHERE du.application_order IS NULL"
|
||||
);
|
||||
}
|
||||
|
||||
private function ensureDiscountUsageUniqueness(): void
|
||||
{
|
||||
if (!$this->db->tableExists('discount_usages')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexes = $this->db->query('SHOW INDEX FROM discount_usages')->getResultArray();
|
||||
foreach ($indexes as $index) {
|
||||
if (($index['Key_name'] ?? '') === 'uniq_discount_usage_voucher_invoice') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->query(
|
||||
'CREATE UNIQUE INDEX uniq_discount_usage_voucher_invoice ON discount_usages (voucher_id, invoice_id)'
|
||||
);
|
||||
$this->assertIndexExists('discount_usages', 'uniq_discount_usage_voucher_invoice');
|
||||
}
|
||||
|
||||
private function ensureAdditionalChargeLedgerColumns(): void
|
||||
{
|
||||
if (!$this->db->tableExists('additional_charges')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$columns = [];
|
||||
if (!$this->db->fieldExists('applied_invoice_line_id', 'additional_charges')) {
|
||||
$columns['applied_invoice_line_id'] = ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'status'];
|
||||
}
|
||||
if (!$this->db->fieldExists('applied_by', 'additional_charges')) {
|
||||
$columns['applied_by'] = ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'applied_invoice_line_id'];
|
||||
}
|
||||
if (!$this->db->fieldExists('applied_at', 'additional_charges')) {
|
||||
$columns['applied_at'] = ['type' => 'DATETIME', 'null' => true, 'after' => 'applied_by'];
|
||||
}
|
||||
if (!$this->db->fieldExists('voided_by', 'additional_charges')) {
|
||||
$columns['voided_by'] = ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'applied_at'];
|
||||
}
|
||||
if (!$this->db->fieldExists('voided_at', 'additional_charges')) {
|
||||
$columns['voided_at'] = ['type' => 'DATETIME', 'null' => true, 'after' => 'voided_by'];
|
||||
}
|
||||
if (!$this->db->fieldExists('void_reason', 'additional_charges')) {
|
||||
$columns['void_reason'] = ['type' => 'TEXT', 'null' => true, 'after' => 'voided_at'];
|
||||
}
|
||||
|
||||
if ($columns !== []) {
|
||||
$this->forge->addColumn('additional_charges', $columns);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureInvoiceLineActiveSourceKey(): void
|
||||
{
|
||||
if (!$this->db->tableExists('invoice_lines')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->db->fieldExists('active_source_key', 'invoice_lines')) {
|
||||
$this->forge->addColumn('invoice_lines', [
|
||||
'active_source_key' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 120,
|
||||
'null' => true,
|
||||
'after' => 'source_id',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$indexes = $this->db->query('SHOW INDEX FROM invoice_lines')->getResultArray();
|
||||
foreach ($indexes as $index) {
|
||||
if (($index['Key_name'] ?? '') === 'uniq_invoice_lines_active_source_key') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->query(
|
||||
'CREATE UNIQUE INDEX uniq_invoice_lines_active_source_key ON invoice_lines (active_source_key)'
|
||||
);
|
||||
$this->assertIndexExists('invoice_lines', 'uniq_invoice_lines_active_source_key');
|
||||
}
|
||||
|
||||
private function expandAdditionalChargeStatus(): void
|
||||
{
|
||||
if (!$this->db->tableExists('additional_charges')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->forge->modifyColumn('additional_charges', [
|
||||
'status' => [
|
||||
'type' => 'ENUM',
|
||||
'constraint' => ['pending', 'approved', 'applied', 'rejected', 'voided', 'reversed'],
|
||||
'default' => 'pending',
|
||||
'null' => false,
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException('Could not expand additional charge statuses: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertIndexExists(string $table, string $indexName): void
|
||||
{
|
||||
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
|
||||
foreach ($indexes as $index) {
|
||||
if (($index['Key_name'] ?? '') === $indexName) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException("Required index {$indexName} was not created on {$table}.");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class FinancialReimbursementPoHardening extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$this->ensureReimbursementInvariants();
|
||||
$this->ensurePurchaseOrderInvariants();
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if ($this->db->tableExists('reimbursements')) {
|
||||
$this->dropIndexIfExists('reimbursements', 'uniq_reimbursement_active_expense');
|
||||
if ($this->db->fieldExists('active_expense_id', 'reimbursements')) {
|
||||
$this->forge->dropColumn('reimbursements', 'active_expense_id');
|
||||
}
|
||||
}
|
||||
$this->dropIndexIfExists('reimbursement_batches', 'uniq_reimbursement_batch_year_sequence');
|
||||
$this->dropIndexIfExists('reimbursement_batch_items', 'uniq_reimbursement_batch_item_active_expense');
|
||||
}
|
||||
|
||||
private function ensureReimbursementInvariants(): void
|
||||
{
|
||||
if (!$this->db->tableExists('reimbursements')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->forge->modifyColumn('reimbursements', [
|
||||
'status' => [
|
||||
'type' => 'ENUM',
|
||||
'constraint' => ['pending', 'approved', 'paid', 'rejected', 'reversed', 'Pending', 'Approved', 'Paid', 'Rejected', 'Reversed'],
|
||||
'default' => 'pending',
|
||||
'null' => false,
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException('Could not normalize reimbursement statuses: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
if (!$this->db->fieldExists('active_expense_id', 'reimbursements')) {
|
||||
try {
|
||||
$this->db->query(
|
||||
"ALTER TABLE reimbursements
|
||||
ADD active_expense_id INT
|
||||
GENERATED ALWAYS AS (
|
||||
CASE
|
||||
WHEN expense_id IS NOT NULL
|
||||
AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')
|
||||
THEN expense_id
|
||||
ELSE NULL
|
||||
END
|
||||
) STORED"
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException('Could not add reimbursement active_expense_id generated column: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
$this->createIndexIfMissing('reimbursements', 'uniq_reimbursement_active_expense', 'CREATE UNIQUE INDEX uniq_reimbursement_active_expense ON reimbursements (active_expense_id)');
|
||||
$this->createIndexIfMissing('reimbursement_batches', 'uniq_reimbursement_batch_year_sequence', 'CREATE UNIQUE INDEX uniq_reimbursement_batch_year_sequence ON reimbursement_batches (school_year, yearly_batch_number)');
|
||||
$this->createIndexIfMissing('reimbursement_batch_items', 'uniq_reimbursement_batch_item_active_expense', 'CREATE UNIQUE INDEX uniq_reimbursement_batch_item_active_expense ON reimbursement_batch_items (batch_id, expense_id)');
|
||||
}
|
||||
|
||||
private function ensurePurchaseOrderInvariants(): void
|
||||
{
|
||||
if (!$this->db->tableExists('purchase_order_items')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->forge->modifyColumn('purchase_order_items', [
|
||||
'quantity' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'null' => false,
|
||||
],
|
||||
'received_qty' => [
|
||||
'type' => 'INT',
|
||||
'constraint' => 11,
|
||||
'default' => 0,
|
||||
'null' => false,
|
||||
],
|
||||
'unit_cost' => [
|
||||
'type' => 'DECIMAL',
|
||||
'constraint' => '10,2',
|
||||
'default' => 0,
|
||||
'null' => false,
|
||||
],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException('Could not tighten purchase order item columns: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->db->query(
|
||||
'ALTER TABLE purchase_order_items
|
||||
ADD CONSTRAINT chk_po_item_quantities
|
||||
CHECK (quantity > 0 AND received_qty >= 0 AND received_qty <= quantity AND unit_cost >= 0)'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
throw new \RuntimeException('Could not add purchase order quantity check: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function createIndexIfMissing(string $table, string $indexName, string $sql): void
|
||||
{
|
||||
if (!$this->db->tableExists($table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
|
||||
foreach ($indexes as $index) {
|
||||
if (($index['Key_name'] ?? '') === $indexName) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->query($sql);
|
||||
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
|
||||
foreach ($indexes as $index) {
|
||||
if (($index['Key_name'] ?? '') === $indexName) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Required index ' . $indexName . ' was not created.');
|
||||
}
|
||||
|
||||
private function dropIndexIfExists(string $table, string $indexName): void
|
||||
{
|
||||
if (!$this->db->tableExists($table)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
|
||||
foreach ($indexes as $index) {
|
||||
if (($index['Key_name'] ?? '') === $indexName) {
|
||||
$this->db->query('DROP INDEX ' . $this->db->escapeIdentifiers($indexName) . ' ON ' . $this->db->escapeIdentifiers($table));
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('warning', 'Could not drop index ' . $indexName . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreatePaymentCorrections extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if ($this->db->tableExists('payment_corrections')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'payment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'invoice_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'parent_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'correction_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false],
|
||||
'approved_refundable_cents' => ['type' => 'INT', 'constraint' => 11, 'null' => false],
|
||||
'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false],
|
||||
'reason' => ['type' => 'TEXT', 'null' => true],
|
||||
'approved_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'approved_at' => ['type' => 'DATETIME', 'null' => true],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => false],
|
||||
'updated_at' => ['type' => 'DATETIME', 'null' => false],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->createTable('payment_corrections', true);
|
||||
|
||||
$this->db->query('CREATE INDEX idx_payment_corrections_payment_status ON payment_corrections (payment_id, status)');
|
||||
$this->db->query('CREATE INDEX idx_payment_corrections_invoice_status ON payment_corrections (invoice_id, status)');
|
||||
$this->assertIndexExists('payment_corrections', 'idx_payment_corrections_payment_status');
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if ($this->db->tableExists('payment_corrections')) {
|
||||
$this->forge->dropTable('payment_corrections', true);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertIndexExists(string $table, string $indexName): void
|
||||
{
|
||||
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
|
||||
foreach ($indexes as $index) {
|
||||
if (($index['Key_name'] ?? '') === $indexName) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException("Required index {$indexName} was not created on {$table}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateReimbursementReversals extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if ($this->db->tableExists('reimbursement_reversals')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'reimbursement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'amount_cents' => ['type' => 'INT', 'constraint' => 11, 'null' => false],
|
||||
'reason' => ['type' => 'TEXT', 'null' => false],
|
||||
'reversed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'reversed_at' => ['type' => 'DATETIME', 'null' => false],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => false],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('reimbursement_id');
|
||||
$this->forge->createTable('reimbursement_reversals', true);
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if ($this->db->tableExists('reimbursement_reversals')) {
|
||||
$this->forge->dropTable('reimbursement_reversals', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateInventoryReceiptOperations extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (!$this->db->tableExists('inventory_receipt_operations')) {
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'idempotency_key' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => false],
|
||||
'purchase_order_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'request_fingerprint_hash' => ['type' => 'CHAR', 'constraint' => 64, 'null' => false],
|
||||
'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false],
|
||||
'actor_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => false],
|
||||
'updated_at' => ['type' => 'DATETIME', 'null' => false],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addUniqueKey('idempotency_key', 'uniq_inventory_receipt_operation_key');
|
||||
$this->forge->createTable('inventory_receipt_operations', true);
|
||||
}
|
||||
|
||||
if (!$this->db->tableExists('inventory_receipt_lines')) {
|
||||
$this->forge->addField([
|
||||
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
|
||||
'operation_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'purchase_order_item_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
|
||||
'quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => false],
|
||||
'movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'reversal_movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||
'reversed_quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => false, 'default' => 0],
|
||||
'created_at' => ['type' => 'DATETIME', 'null' => false],
|
||||
]);
|
||||
$this->forge->addKey('id', true);
|
||||
$this->forge->addKey('operation_id');
|
||||
$this->forge->addKey('purchase_order_item_id');
|
||||
$this->forge->createTable('inventory_receipt_lines', true);
|
||||
}
|
||||
}
|
||||
|
||||
public function down()
|
||||
{
|
||||
if ($this->db->tableExists('inventory_receipt_lines')) {
|
||||
$this->forge->dropTable('inventory_receipt_lines', true);
|
||||
}
|
||||
if ($this->db->tableExists('inventory_receipt_operations')) {
|
||||
$this->forge->dropTable('inventory_receipt_operations', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,18 @@ final class FinancialAttachmentService
|
||||
];
|
||||
|
||||
public function saveUploadedFile($file, string $subdir): ?string
|
||||
{
|
||||
$staged = $this->stageUploadedFile($file, $subdir);
|
||||
if ($staged === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$this->finalizeStagedFile($staged);
|
||||
|
||||
return $staged['final_name'];
|
||||
}
|
||||
|
||||
public function stageUploadedFile($file, string $subdir): ?array
|
||||
{
|
||||
if (!$file instanceof UploadedFile) {
|
||||
return null;
|
||||
@@ -46,11 +58,50 @@ final class FinancialAttachmentService
|
||||
throw new \RuntimeException('File too large. Maximum size is 5 MB.');
|
||||
}
|
||||
|
||||
$dir = $this->ensureSubdir($subdir);
|
||||
$name = $file->getRandomName();
|
||||
$file->move($dir, $name);
|
||||
$tmpSubdir = '_tmp' . DIRECTORY_SEPARATOR . trim($subdir, '/');
|
||||
$tmpDir = $this->ensureSubdir($tmpSubdir);
|
||||
$tmpName = 'pending-' . bin2hex(random_bytes(8)) . '-' . $name;
|
||||
$file->move($tmpDir, $tmpName);
|
||||
|
||||
return $name;
|
||||
return [
|
||||
'subdir' => trim($subdir, '/'),
|
||||
'final_name' => $name,
|
||||
'temporary_subdir' => $tmpSubdir,
|
||||
'temporary_name' => $tmpName,
|
||||
'temporary_path' => $tmpDir . DIRECTORY_SEPARATOR . $tmpName,
|
||||
];
|
||||
}
|
||||
|
||||
public function finalizeStagedFile(array $staged): string
|
||||
{
|
||||
$tmpPath = (string)($staged['temporary_path'] ?? '');
|
||||
$finalName = basename((string)($staged['final_name'] ?? ''));
|
||||
$subdir = (string)($staged['subdir'] ?? '');
|
||||
|
||||
if ($tmpPath === '' || $finalName === '' || $subdir === '' || !is_file($tmpPath)) {
|
||||
throw new \RuntimeException('Temporary upload is missing.');
|
||||
}
|
||||
|
||||
$finalDir = $this->ensureSubdir($subdir);
|
||||
$finalPath = $finalDir . DIRECTORY_SEPARATOR . $finalName;
|
||||
if (!@rename($tmpPath, $finalPath)) {
|
||||
throw new \RuntimeException('Unable to finalize uploaded evidence.');
|
||||
}
|
||||
|
||||
return $finalName;
|
||||
}
|
||||
|
||||
public function discardStagedFile(?array $staged): void
|
||||
{
|
||||
if ($staged === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tmpPath = (string)($staged['temporary_path'] ?? '');
|
||||
if ($tmpPath !== '' && is_file($tmpPath)) {
|
||||
@unlink($tmpPath);
|
||||
}
|
||||
}
|
||||
|
||||
public function resolvePath(string $subdir, string $filename): ?string
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\InvoiceModel;
|
||||
|
||||
class FinancialCorrectionReportService
|
||||
{
|
||||
private InvoiceModel $invoiceModel;
|
||||
private FinancialReportProjectionService $projectionService;
|
||||
|
||||
public function __construct(?FinancialReportProjectionService $projectionService = null)
|
||||
{
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->projectionService = $projectionService ?? new FinancialReportProjectionService();
|
||||
}
|
||||
|
||||
public function invoiceReport(?string $schoolYear = null): array
|
||||
{
|
||||
$query = $this->invoiceModel->orderBy('id', 'ASC');
|
||||
if ($schoolYear !== null && $schoolYear !== '') {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($query->findAll() as $invoice) {
|
||||
$projection = $this->projectionService->invoiceProjection((int) $invoice['id']);
|
||||
$storedTotalCents = $this->toCents((float) ($invoice['total_amount'] ?? 0));
|
||||
$storedBalanceCents = $this->toCents((float) ($invoice['balance'] ?? 0));
|
||||
$flags = [];
|
||||
|
||||
if ($storedTotalCents !== $projection['invoice_gross_charges_cents']) {
|
||||
$flags[] = 'stored_total_mismatch';
|
||||
}
|
||||
if ($storedBalanceCents !== $projection['amount_due_cents']) {
|
||||
$flags[] = 'stored_balance_mismatch';
|
||||
}
|
||||
if ($projection['completed_cash_refunds_cents'] < 0) {
|
||||
$flags[] = 'wrong_refund_sign';
|
||||
}
|
||||
if ($projection['applied_discounts_cents'] > $projection['invoice_gross_charges_cents']) {
|
||||
$flags[] = 'discount_above_eligible_base';
|
||||
}
|
||||
|
||||
$rows[] = [
|
||||
'invoice_id' => (int) $invoice['id'],
|
||||
'invoice_number' => $invoice['invoice_number'] ?? null,
|
||||
'stored_total_cents' => $storedTotalCents,
|
||||
'canonical_frozen_charge_total_cents' => $projection['invoice_gross_charges_cents'],
|
||||
'valid_payment_total_cents' => $projection['valid_payments_cents'],
|
||||
'completed_refund_payout_total_cents' => $projection['completed_cash_refunds_cents'],
|
||||
'applied_discount_total_cents' => $projection['applied_discounts_cents'],
|
||||
'expected_balance_due_cents' => $projection['amount_due_cents'],
|
||||
'expected_customer_credit_cents' => $projection['customer_credit_cents'],
|
||||
'flags' => $flags,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function toCents(float $amount): int
|
||||
{
|
||||
return (int) round($amount * 100);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
class FinancialPersistenceException extends \RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $codeName,
|
||||
public readonly array $details = []
|
||||
) {
|
||||
parent::__construct($codeName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\InvoiceModel;
|
||||
|
||||
class FinancialReportProjectionService
|
||||
{
|
||||
private InvoiceLedgerService $invoiceLedgerService;
|
||||
private InvoiceModel $invoiceModel;
|
||||
|
||||
public function __construct(?InvoiceLedgerService $invoiceLedgerService = null)
|
||||
{
|
||||
$this->invoiceLedgerService = $invoiceLedgerService ?? new InvoiceLedgerService();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
}
|
||||
|
||||
public function invoiceProjection(int $invoiceId): array
|
||||
{
|
||||
$ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId);
|
||||
|
||||
return [
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_gross_charges_cents' => (int) ($ledger['totalAmountCents'] ?? 0),
|
||||
'applied_discounts_cents' => (int) ($ledger['discountCents'] ?? 0),
|
||||
'net_charges_cents' => max(0, (int) ($ledger['totalAmountCents'] ?? 0) - (int) ($ledger['discountCents'] ?? 0)),
|
||||
'valid_payments_cents' => (int) ($ledger['paidCents'] ?? 0),
|
||||
'completed_cash_refunds_cents' => (int) ($ledger['completedRefundCents'] ?? 0),
|
||||
'amount_due_cents' => (int) ($ledger['balanceDueCents'] ?? 0),
|
||||
'customer_credit_cents' => (int) ($ledger['customerCreditCents'] ?? 0),
|
||||
'approved_refund_reservations_cents' => (int) ($ledger['approvedRefundReservationCents'] ?? 0),
|
||||
'available_refundable_credit_cents' => (int) ($ledger['availableRefundableCreditCents'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
public function allInvoiceProjections(?string $schoolYear = null): array
|
||||
{
|
||||
$query = $this->invoiceModel->select('id')->orderBy('id', 'ASC');
|
||||
if ($schoolYear !== null && $schoolYear !== '') {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
$rows = $query->findAll();
|
||||
return array_map(fn (array $row): array => $this->invoiceProjection((int) $row['id']), $rows);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,13 @@ namespace App\Libraries;
|
||||
|
||||
final class FinancialStatus
|
||||
{
|
||||
public const INVOICE_DRAFT = 'draft';
|
||||
public const INVOICE_ISSUED = 'issued';
|
||||
public const INVOICE_UNPAID = 'unpaid';
|
||||
public const INVOICE_PARTIALLY_PAID = 'partially_paid';
|
||||
public const INVOICE_PAID = 'paid';
|
||||
public const INVOICE_CREDITED = 'credited';
|
||||
public const INVOICE_VOIDED = 'voided';
|
||||
public const INVOICE_OVERPAID = 'overpaid';
|
||||
public const INVOICE_CANCELLED = 'cancelled';
|
||||
|
||||
@@ -29,24 +33,59 @@ final class FinancialStatus
|
||||
self::PAYMENT_CHARGEBACK,
|
||||
];
|
||||
|
||||
public const VALID_PAYMENT_STATUSES = [
|
||||
self::PAYMENT_RECORDED,
|
||||
'successful',
|
||||
'completed',
|
||||
'paid',
|
||||
'Successful',
|
||||
'Completed',
|
||||
'Paid',
|
||||
];
|
||||
|
||||
public const INVOICE_STATUSES = [
|
||||
self::INVOICE_UNPAID,
|
||||
self::INVOICE_DRAFT,
|
||||
self::INVOICE_ISSUED,
|
||||
self::INVOICE_PARTIALLY_PAID,
|
||||
self::INVOICE_PAID,
|
||||
self::INVOICE_CREDITED,
|
||||
self::INVOICE_VOIDED,
|
||||
self::INVOICE_OVERPAID,
|
||||
self::INVOICE_CANCELLED,
|
||||
];
|
||||
|
||||
public const REFUND_REQUESTED = 'requested';
|
||||
public const REFUND_PENDING = 'pending';
|
||||
public const REFUND_APPROVED = 'approved';
|
||||
public const REFUND_REJECTED = 'rejected';
|
||||
public const REFUND_PARTIALLY_PAID = 'partially_paid';
|
||||
public const REFUND_PAID = 'paid';
|
||||
public const REFUND_CANCELLED = 'cancelled';
|
||||
public const REFUND_REVERSED = 'reversed';
|
||||
public const REFUND_EXCEPTION = 'exception';
|
||||
public const REFUND_VOIDED = 'voided';
|
||||
|
||||
public const REIMBURSEMENT_PENDING = 'pending';
|
||||
public const REIMBURSEMENT_APPROVED = 'approved';
|
||||
public const REIMBURSEMENT_PAID = 'paid';
|
||||
public const REIMBURSEMENT_REJECTED = 'rejected';
|
||||
public const REIMBURSEMENT_REVERSED = 'reversed';
|
||||
|
||||
public const REIMBURSEMENT_STATUSES = [
|
||||
self::REIMBURSEMENT_PENDING,
|
||||
self::REIMBURSEMENT_APPROVED,
|
||||
self::REIMBURSEMENT_PAID,
|
||||
self::REIMBURSEMENT_REJECTED,
|
||||
self::REIMBURSEMENT_REVERSED,
|
||||
];
|
||||
|
||||
public const ADDITIONAL_CHARGE_PENDING = 'pending';
|
||||
public const ADDITIONAL_CHARGE_APPROVED = 'approved';
|
||||
public const ADDITIONAL_CHARGE_APPLIED = 'applied';
|
||||
public const ADDITIONAL_CHARGE_REJECTED = 'rejected';
|
||||
public const ADDITIONAL_CHARGE_VOIDED = 'voided';
|
||||
public const ADDITIONAL_CHARGE_REVERSED = 'reversed';
|
||||
|
||||
public const EXCLUDED_PAYMENT_STATUSES = [
|
||||
self::PAYMENT_VOIDED,
|
||||
@@ -74,6 +113,10 @@ final class FinancialStatus
|
||||
return match (self::normalize($status)) {
|
||||
'paid', 'full' => self::INVOICE_PAID,
|
||||
'partially paid', 'partially_paid', 'partial' => self::INVOICE_PARTIALLY_PAID,
|
||||
'draft' => self::INVOICE_DRAFT,
|
||||
'issued' => self::INVOICE_ISSUED,
|
||||
'credited' => self::INVOICE_CREDITED,
|
||||
'void', 'voided' => self::INVOICE_VOIDED,
|
||||
'overpaid' => self::INVOICE_OVERPAID,
|
||||
'cancelled', 'canceled', 'cancelled invoice', 'canceled invoice' => self::INVOICE_CANCELLED,
|
||||
default => self::INVOICE_UNPAID,
|
||||
@@ -103,8 +146,23 @@ final class FinancialStatus
|
||||
'rejected' => self::REFUND_REJECTED,
|
||||
'partial', 'partially paid', 'partially_paid' => self::REFUND_PARTIALLY_PAID,
|
||||
'paid', 'full' => self::REFUND_PAID,
|
||||
'requested', 'pending' => self::REFUND_REQUESTED,
|
||||
'cancelled', 'canceled' => self::REFUND_CANCELLED,
|
||||
'reversed' => self::REFUND_REVERSED,
|
||||
'exception' => self::REFUND_EXCEPTION,
|
||||
'void', 'voided' => self::REFUND_VOIDED,
|
||||
default => self::REFUND_PENDING,
|
||||
default => self::REFUND_REQUESTED,
|
||||
};
|
||||
}
|
||||
|
||||
public static function normalizeReimbursementStatus(?string $status): string
|
||||
{
|
||||
return match (self::normalize($status)) {
|
||||
'approved' => self::REIMBURSEMENT_APPROVED,
|
||||
'paid' => self::REIMBURSEMENT_PAID,
|
||||
'rejected', 'denied' => self::REIMBURSEMENT_REJECTED,
|
||||
'reversed' => self::REIMBURSEMENT_REVERSED,
|
||||
default => self::REIMBURSEMENT_PENDING,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use App\Models\InvoiceLineModel;
|
||||
|
||||
class InvoiceAdjustmentService
|
||||
{
|
||||
private \CodeIgniter\Database\BaseConnection $db;
|
||||
private AdditionalChargeModel $additionalChargeModel;
|
||||
private InvoiceLineModel $invoiceLineModel;
|
||||
private InvoiceLedgerService $invoiceLedgerService;
|
||||
|
||||
public function __construct(
|
||||
?\CodeIgniter\Database\BaseConnection $db = null,
|
||||
?AdditionalChargeModel $additionalChargeModel = null,
|
||||
?InvoiceLineModel $invoiceLineModel = null,
|
||||
?InvoiceLedgerService $invoiceLedgerService = null
|
||||
) {
|
||||
$this->db = $db ?? db_connect();
|
||||
$this->additionalChargeModel = $additionalChargeModel ?? new AdditionalChargeModel();
|
||||
$this->invoiceLineModel = $invoiceLineModel ?? new InvoiceLineModel();
|
||||
$this->invoiceLedgerService = $invoiceLedgerService ?? new InvoiceLedgerService();
|
||||
}
|
||||
|
||||
public function applyAdditionalCharge(int $chargeId, int $invoiceId, int $actorId): InvoiceLedgerResult
|
||||
{
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
$charge = $this->lockCharge($chargeId);
|
||||
$invoice = $this->lockInvoice($invoiceId);
|
||||
|
||||
$this->assertInvoiceAcceptsAdjustment($invoice);
|
||||
$this->assertChargeMatchesInvoice($charge, $invoice);
|
||||
if ((string)($charge['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_APPROVED) {
|
||||
throw new \RuntimeException('Only approved charges can be applied.');
|
||||
}
|
||||
|
||||
$amountCents = $this->signedChargeAmountCents($charge);
|
||||
if ($amountCents === 0) {
|
||||
throw new \RuntimeException('Zero amount additional charges cannot be applied.');
|
||||
}
|
||||
|
||||
$activeSourceKey = $this->activeSourceKey($chargeId);
|
||||
$existing = $this->db->table('invoice_lines')
|
||||
->where('active_source_key', $activeSourceKey)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if ($existing) {
|
||||
throw new \RuntimeException('Additional charge already has an active invoice line.');
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
$lineId = $this->invoiceLineModel->insert([
|
||||
'invoice_id' => $invoiceId,
|
||||
'line_type' => $amountCents > 0 ? 'additional_charge' : 'additional_deduction',
|
||||
'source_type' => 'additional_charge',
|
||||
'source_id' => $chargeId,
|
||||
'active_source_key' => $activeSourceKey,
|
||||
'description' => $this->chargeDescription($charge),
|
||||
'quantity' => '1.00',
|
||||
'unit_amount_cents' => $amountCents,
|
||||
'line_amount_cents' => $amountCents,
|
||||
'discount_eligible' => 0,
|
||||
'calculation_version' => 'invoice_adjustment_v1',
|
||||
'metadata_json' => json_encode([
|
||||
'charge_type' => (string)($charge['charge_type'] ?? ''),
|
||||
'applied_by' => $actorId,
|
||||
], JSON_UNESCAPED_SLASHES),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
'voided_at' => null,
|
||||
]);
|
||||
$this->requireWrite($lineId, 'INVOICE_ADJUSTMENT_LINE_INSERT_FAILED', $this->invoiceLineModel);
|
||||
|
||||
$this->requireWrite($this->additionalChargeModel->update($chargeId, [
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_APPLIED,
|
||||
'invoice_id' => $invoiceId,
|
||||
'parent_id' => (int)$invoice['parent_id'],
|
||||
'school_year' => (string)$invoice['school_year'],
|
||||
'semester' => (string)($invoice['semester'] ?? ''),
|
||||
'applied_invoice_line_id' => (int)$lineId,
|
||||
'applied_by' => $actorId > 0 ? $actorId : null,
|
||||
'applied_at' => $now,
|
||||
]), 'ADDITIONAL_CHARGE_APPLY_UPDATE_FAILED', $this->additionalChargeModel);
|
||||
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
$this->requireTransactionStatus();
|
||||
$this->db->transCommit();
|
||||
|
||||
return new InvoiceLedgerResult($invoiceId, (int)$lineId, $ledger);
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function reverseAdditionalCharge(int $chargeId, string $reason, int $actorId): InvoiceLedgerResult
|
||||
{
|
||||
$reason = trim($reason);
|
||||
if ($reason === '') {
|
||||
throw new \RuntimeException('Reversal reason is required.');
|
||||
}
|
||||
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
$charge = $this->lockCharge($chargeId);
|
||||
if ((string)($charge['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_APPLIED) {
|
||||
throw new \RuntimeException('Only applied charges can be reversed.');
|
||||
}
|
||||
|
||||
$invoiceId = (int)($charge['invoice_id'] ?? 0);
|
||||
$invoice = $this->lockInvoice($invoiceId);
|
||||
$this->assertInvoiceAcceptsAdjustment($invoice);
|
||||
|
||||
$originalLineId = (int)($charge['applied_invoice_line_id'] ?? 0);
|
||||
$originalLine = $originalLineId > 0
|
||||
? $this->db->query('SELECT * FROM invoice_lines WHERE id = ? FOR UPDATE', [$originalLineId])->getRowArray()
|
||||
: null;
|
||||
if (!$originalLine) {
|
||||
$originalLine = $this->db->query(
|
||||
'SELECT * FROM invoice_lines WHERE source_type = ? AND source_id = ? AND voided_at IS NULL ORDER BY id ASC LIMIT 1 FOR UPDATE',
|
||||
['additional_charge', $chargeId]
|
||||
)->getRowArray();
|
||||
}
|
||||
if (!$originalLine) {
|
||||
throw new \RuntimeException('Original invoice line not found.');
|
||||
}
|
||||
|
||||
$reverseAmountCents = -1 * (int)($originalLine['line_amount_cents'] ?? 0);
|
||||
if ($reverseAmountCents === 0) {
|
||||
throw new \RuntimeException('Zero amount additional charges cannot be reversed.');
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
$lineId = $this->invoiceLineModel->insert([
|
||||
'invoice_id' => $invoiceId,
|
||||
'line_type' => $reverseAmountCents > 0 ? 'additional_charge_reversal' : 'additional_deduction_reversal',
|
||||
'source_type' => 'additional_charge_reversal',
|
||||
'source_id' => $chargeId,
|
||||
'active_source_key' => null,
|
||||
'description' => 'Reversal: ' . $this->chargeDescription($charge),
|
||||
'quantity' => '1.00',
|
||||
'unit_amount_cents' => $reverseAmountCents,
|
||||
'line_amount_cents' => $reverseAmountCents,
|
||||
'discount_eligible' => 0,
|
||||
'calculation_version' => 'invoice_adjustment_v1',
|
||||
'metadata_json' => json_encode([
|
||||
'original_invoice_line_id' => (int)($originalLine['id'] ?? 0),
|
||||
'reason' => $reason,
|
||||
'reversed_by' => $actorId,
|
||||
], JSON_UNESCAPED_SLASHES),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
'voided_at' => null,
|
||||
]);
|
||||
$this->requireWrite($lineId, 'INVOICE_ADJUSTMENT_REVERSAL_LINE_INSERT_FAILED', $this->invoiceLineModel);
|
||||
|
||||
$this->requireWrite($this->additionalChargeModel->update($chargeId, [
|
||||
'status' => 'reversed',
|
||||
'voided_by' => $actorId > 0 ? $actorId : null,
|
||||
'voided_at' => $now,
|
||||
'void_reason' => $reason,
|
||||
]), 'ADDITIONAL_CHARGE_REVERSE_UPDATE_FAILED', $this->additionalChargeModel);
|
||||
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
$this->requireTransactionStatus();
|
||||
$this->db->transCommit();
|
||||
|
||||
return new InvoiceLedgerResult($invoiceId, (int)$lineId, $ledger);
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function lockCharge(int $chargeId): array
|
||||
{
|
||||
$charge = $this->db->query('SELECT * FROM additional_charges WHERE id = ? FOR UPDATE', [$chargeId])->getRowArray();
|
||||
if (!$charge) {
|
||||
throw new \RuntimeException('Additional charge not found.');
|
||||
}
|
||||
|
||||
return $charge;
|
||||
}
|
||||
|
||||
private function lockInvoice(int $invoiceId): array
|
||||
{
|
||||
$invoice = $this->db->query('SELECT * FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId])->getRowArray();
|
||||
if (!$invoice) {
|
||||
throw new \RuntimeException('Invoice not found.');
|
||||
}
|
||||
|
||||
return $invoice;
|
||||
}
|
||||
|
||||
private function assertInvoiceAcceptsAdjustment(array $invoice): void
|
||||
{
|
||||
if (FinancialStatus::normalizeInvoiceStatus($invoice['status'] ?? null) === FinancialStatus::INVOICE_VOIDED) {
|
||||
throw new \RuntimeException('Voided invoices cannot be adjusted.');
|
||||
}
|
||||
}
|
||||
|
||||
private function assertChargeMatchesInvoice(array $charge, array $invoice): void
|
||||
{
|
||||
if ((int)($charge['parent_id'] ?? 0) !== (int)($invoice['parent_id'] ?? 0)) {
|
||||
throw new \RuntimeException('Charge parent does not match invoice parent.');
|
||||
}
|
||||
if ((string)($charge['school_year'] ?? '') !== (string)($invoice['school_year'] ?? '')) {
|
||||
throw new \RuntimeException('Charge school year does not match invoice school year.');
|
||||
}
|
||||
if ((string)($charge['semester'] ?? '') !== (string)($invoice['semester'] ?? '')) {
|
||||
throw new \RuntimeException('Charge semester does not match invoice semester.');
|
||||
}
|
||||
}
|
||||
|
||||
private function signedChargeAmountCents(array $charge): int
|
||||
{
|
||||
$amount = (int)round(abs((float)($charge['amount'] ?? 0)) * 100);
|
||||
return (string)($charge['charge_type'] ?? 'add') === 'deduct' ? -1 * $amount : $amount;
|
||||
}
|
||||
|
||||
private function activeSourceKey(int $chargeId): string
|
||||
{
|
||||
return 'additional_charge:' . $chargeId;
|
||||
}
|
||||
|
||||
private function chargeDescription(array $charge): string
|
||||
{
|
||||
$title = trim((string)($charge['title'] ?? 'Additional charge'));
|
||||
return $title !== '' ? $title : 'Additional charge';
|
||||
}
|
||||
|
||||
private function requireWrite($result, string $code, $model = null): void
|
||||
{
|
||||
if ($result === false || $result === null || $result === 0) {
|
||||
$details = is_object($model) && method_exists($model, 'errors') ? (array)$model->errors() : [];
|
||||
throw new FinancialPersistenceException($code, $details);
|
||||
}
|
||||
}
|
||||
|
||||
private function requireTransactionStatus(string $code = 'TRANSACTION_FAILED'): void
|
||||
{
|
||||
if (method_exists($this->db, 'transStatus') && $this->db->transStatus() === false) {
|
||||
throw new FinancialPersistenceException($code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\InvoiceLineModel;
|
||||
use App\Models\InvoiceModel;
|
||||
|
||||
class InvoiceIssuanceService
|
||||
{
|
||||
private \CodeIgniter\Database\BaseConnection $db;
|
||||
private InvoiceModel $invoiceModel;
|
||||
private InvoiceLineModel $invoiceLineModel;
|
||||
private InvoiceLedgerService $invoiceLedgerService;
|
||||
|
||||
public function __construct(
|
||||
?\CodeIgniter\Database\BaseConnection $db = null,
|
||||
?InvoiceModel $invoiceModel = null,
|
||||
?InvoiceLineModel $invoiceLineModel = null,
|
||||
?InvoiceLedgerService $invoiceLedgerService = null
|
||||
) {
|
||||
$this->db = $db ?? db_connect();
|
||||
$this->invoiceModel = $invoiceModel ?? new InvoiceModel();
|
||||
$this->invoiceLineModel = $invoiceLineModel ?? new InvoiceLineModel();
|
||||
$this->invoiceLedgerService = $invoiceLedgerService ?? new InvoiceLedgerService();
|
||||
}
|
||||
|
||||
public function issueInvoice(IssueInvoiceCommand $command): InvoiceLedgerResult
|
||||
{
|
||||
$invoiceData = $command->invoiceData;
|
||||
$invoiceData['status'] = FinancialStatus::INVOICE_DRAFT;
|
||||
$invoiceData['total_amount'] = $invoiceData['total_amount'] ?? number_format($command->tuitionAmount + $command->eventAmount, 2, '.', '');
|
||||
$invoiceData['balance'] = $invoiceData['balance'] ?? $invoiceData['total_amount'];
|
||||
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
$invoiceId = $this->invoiceModel->insert($invoiceData);
|
||||
$this->requireWrite($invoiceId, 'INVOICE_ISSUE_INSERT_FAILED', $this->invoiceModel);
|
||||
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int)$invoiceId]);
|
||||
|
||||
$inserted = $this->invoiceLedgerService->issueInitialInvoiceLines(
|
||||
(int)$invoiceId,
|
||||
$command->tuitionAmount,
|
||||
$command->eventAmount,
|
||||
$command->metadata
|
||||
);
|
||||
if ($inserted <= 0) {
|
||||
throw new FinancialPersistenceException('INVOICE_ISSUE_NO_LINES');
|
||||
}
|
||||
|
||||
$lineTotals = $this->db->table('invoice_lines')
|
||||
->select('COUNT(*) AS line_count, COALESCE(SUM(line_amount_cents),0) AS total_cents')
|
||||
->where('invoice_id', (int)$invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if ((int)($lineTotals['line_count'] ?? 0) !== $inserted) {
|
||||
throw new FinancialPersistenceException('INVOICE_ISSUE_LINE_COUNT_MISMATCH');
|
||||
}
|
||||
if ((int)($lineTotals['total_cents'] ?? 0) === 0) {
|
||||
throw new FinancialPersistenceException('INVOICE_ISSUE_ZERO_LINE_TOTAL');
|
||||
}
|
||||
|
||||
$this->requireWrite($this->invoiceModel->update((int)$invoiceId, [
|
||||
'status' => FinancialStatus::INVOICE_ISSUED,
|
||||
'updated_at' => utc_now(),
|
||||
]), 'INVOICE_ISSUE_STATUS_UPDATE_FAILED', $this->invoiceModel);
|
||||
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice((int)$invoiceId);
|
||||
$this->requireTransactionStatus();
|
||||
$this->db->transCommit();
|
||||
|
||||
return new InvoiceLedgerResult((int)$invoiceId, 0, $ledger);
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function requireWrite($result, string $code, $model = null): void
|
||||
{
|
||||
if ($result === false || $result === null || $result === 0) {
|
||||
$details = is_object($model) && method_exists($model, 'errors') ? (array)$model->errors() : [];
|
||||
throw new FinancialPersistenceException($code, $details);
|
||||
}
|
||||
}
|
||||
|
||||
private function requireTransactionStatus(string $code = 'TRANSACTION_FAILED'): void
|
||||
{
|
||||
if (method_exists($this->db, 'transStatus') && $this->db->transStatus() === false) {
|
||||
throw new FinancialPersistenceException($code);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
class InvoiceLedgerResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $invoiceId,
|
||||
public readonly int $invoiceLineId,
|
||||
public readonly array $ledger
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,11 @@ use App\Models\DiscountUsageModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Models\EventChargesModel;
|
||||
use App\Models\InvoiceEventModel;
|
||||
use App\Models\InvoiceLineModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\RefundModel;
|
||||
use App\Models\RefundPayoutModel;
|
||||
use App\Models\StudentClassModel;
|
||||
use App\Models\StudentModel;
|
||||
|
||||
@@ -23,6 +25,7 @@ class InvoiceLedgerService
|
||||
protected InvoiceModel $invoiceModel;
|
||||
protected PaymentModel $paymentModel;
|
||||
protected RefundModel $refundModel;
|
||||
protected ?RefundPayoutModel $refundPayoutModel = null;
|
||||
protected DiscountUsageModel $discountUsageModel;
|
||||
protected AdditionalChargeModel $additionalChargeModel;
|
||||
protected ConfigurationModel $configurationModel;
|
||||
@@ -31,6 +34,7 @@ class InvoiceLedgerService
|
||||
protected ClassSectionModel $classSectionModel;
|
||||
protected EventChargesModel $eventChargesModel;
|
||||
protected InvoiceEventModel $invoiceEventModel;
|
||||
protected ?InvoiceLineModel $invoiceLineModel = null;
|
||||
protected StudentModel $studentModel;
|
||||
protected TuitionCalculatorInterface $oldCalculator;
|
||||
protected TuitionCalculatorInterface $newCalculator;
|
||||
@@ -40,6 +44,7 @@ class InvoiceLedgerService
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->refundModel = new RefundModel();
|
||||
$this->refundPayoutModel = new RefundPayoutModel();
|
||||
$this->discountUsageModel = new DiscountUsageModel();
|
||||
$this->additionalChargeModel = new AdditionalChargeModel();
|
||||
$this->configurationModel = new ConfigurationModel();
|
||||
@@ -48,6 +53,7 @@ class InvoiceLedgerService
|
||||
$this->classSectionModel = new ClassSectionModel();
|
||||
$this->eventChargesModel = new EventChargesModel();
|
||||
$this->invoiceEventModel = new InvoiceEventModel();
|
||||
$this->invoiceLineModel = new InvoiceLineModel();
|
||||
$this->studentModel = new StudentModel();
|
||||
$this->oldCalculator = new OldTuitionCalculatorService();
|
||||
$this->newCalculator = new NewTuitionCalculatorService();
|
||||
@@ -60,9 +66,18 @@ class InvoiceLedgerService
|
||||
throw new \RuntimeException('Invoice not found.');
|
||||
}
|
||||
|
||||
$tuitionTotal = $this->calculateTuitionTotal($invoice);
|
||||
$eventTotal = $this->calculateEventTotal($invoice);
|
||||
$additionalTotal = $this->calculateAdditionalCharges($invoiceId);
|
||||
$frozenTotals = $this->calculateFrozenLineTotals($invoiceId);
|
||||
if ($frozenTotals !== null) {
|
||||
$tuitionTotal = $this->fromCentsNumber($frozenTotals['tuition_cents']);
|
||||
$eventTotal = $this->fromCentsNumber($frozenTotals['event_cents']);
|
||||
$additionalTotal = $this->fromCentsNumber($frozenTotals['additional_cents']);
|
||||
$frozenTotalCents = $frozenTotals['total_cents'];
|
||||
} else {
|
||||
$tuitionTotal = $this->calculateTuitionTotal($invoice);
|
||||
$eventTotal = $this->calculateEventTotal($invoice);
|
||||
$additionalTotal = $this->calculateAdditionalCharges($invoiceId);
|
||||
$frozenTotalCents = null;
|
||||
}
|
||||
$discountRawTotal = $this->calculateDiscounts($invoiceId);
|
||||
$paidTotal = $this->calculateValidPayments($invoiceId);
|
||||
$refundPaidTotal = $this->calculatePaidRefunds($invoiceId);
|
||||
@@ -74,8 +89,13 @@ class InvoiceLedgerService
|
||||
$paidCents = $this->toCents($paidTotal);
|
||||
$refundPaidCents = $this->toCents($refundPaidTotal);
|
||||
|
||||
if ($this->isCarryForwardInvoice($invoice)) {
|
||||
if ($frozenTotalCents !== null) {
|
||||
$totalAmountCents = $frozenTotalCents;
|
||||
$discountBaseCents = max(0, (int) ($frozenTotals['discount_eligible_base_cents'] ?? ($tuitionCents + $additionalCents)));
|
||||
$discountCents = $discountBaseCents > 0 ? min($discountRawCents, $discountBaseCents) : 0;
|
||||
} elseif ($this->isCarryForwardInvoice($invoice)) {
|
||||
$totalAmountCents = $this->toCents((float) ($invoice['total_amount'] ?? 0));
|
||||
$discountBaseCents = 0;
|
||||
$discountCents = 0;
|
||||
} else {
|
||||
$discountBaseCents = max(0, $tuitionCents + $additionalCents);
|
||||
@@ -83,7 +103,8 @@ class InvoiceLedgerService
|
||||
$totalAmountCents = $tuitionCents + $eventCents + $additionalCents;
|
||||
}
|
||||
|
||||
$balanceCents = max(0, $totalAmountCents - $discountCents - $paidCents - $refundPaidCents);
|
||||
$rawBalanceCents = $totalAmountCents - $discountCents - $paidCents + $refundPaidCents;
|
||||
$balanceCents = max(0, $rawBalanceCents);
|
||||
|
||||
if ($balanceCents === 0) {
|
||||
$status = FinancialStatus::INVOICE_PAID;
|
||||
@@ -95,6 +116,18 @@ class InvoiceLedgerService
|
||||
|
||||
return [
|
||||
'invoice_id' => $invoiceId,
|
||||
'gross_charge_cents' => $totalAmountCents,
|
||||
'discount_eligible_base_cents' => $discountBaseCents,
|
||||
'requested_discount_cents' => $discountRawCents,
|
||||
'applied_discount_cents' => $discountCents,
|
||||
'net_charge_cents' => $totalAmountCents - $discountCents,
|
||||
'totalAmountCents' => $totalAmountCents,
|
||||
'discountCents' => $discountCents,
|
||||
'paidCents' => $paidCents,
|
||||
'completedRefundCents' => $refundPaidCents,
|
||||
'rawBalanceCents' => $rawBalanceCents,
|
||||
'balanceDueCents' => $balanceCents,
|
||||
'customerCreditCents' => max(0, -$rawBalanceCents),
|
||||
'tuition_total' => $this->fromCents($tuitionCents),
|
||||
'event_total' => $this->fromCents($eventCents),
|
||||
'additional_total' => $this->fromCents($additionalCents),
|
||||
@@ -103,6 +136,7 @@ class InvoiceLedgerService
|
||||
'paid_amount' => $this->fromCents($paidCents),
|
||||
'refund_paid_total' => $this->fromCents($refundPaidCents),
|
||||
'total_amount' => $this->fromCents($totalAmountCents),
|
||||
'customer_credit' => $this->fromCents(max(0, -$rawBalanceCents)),
|
||||
'balance' => $this->fromCents($balanceCents),
|
||||
'status' => $status,
|
||||
'has_discount' => $discountCents > 0 ? 1 : 0,
|
||||
@@ -125,16 +159,257 @@ class InvoiceLedgerService
|
||||
$payload['discount'] = $calculation['discount_total'];
|
||||
}
|
||||
|
||||
$this->invoiceModel->update($invoiceId, $payload);
|
||||
if (!$this->invoiceModel->update($invoiceId, $payload)) {
|
||||
throw new FinancialPersistenceException('INVOICE_LEDGER_UPDATE_FAILED', $this->invoiceModel->errors());
|
||||
}
|
||||
|
||||
return $calculation;
|
||||
}
|
||||
|
||||
public function recalculate(int $invoiceId): array
|
||||
{
|
||||
return $this->recalculateInvoice($invoiceId);
|
||||
}
|
||||
|
||||
public function getValidPaymentTotalCents(int $invoiceId): int
|
||||
{
|
||||
return $this->toCents($this->calculateValidPayments($invoiceId));
|
||||
}
|
||||
|
||||
public function issueInitialInvoiceLines(
|
||||
int $invoiceId,
|
||||
float $tuitionAmount,
|
||||
float $eventAmount,
|
||||
array $metadata = []
|
||||
): int {
|
||||
if ($invoiceId <= 0) {
|
||||
throw new \RuntimeException('Invoice ID is required to issue invoice lines.');
|
||||
}
|
||||
if (!$this->invoiceLinesAvailable()) {
|
||||
throw new \RuntimeException('Invoice lines table is not available.');
|
||||
}
|
||||
if ($this->invoiceHasLines($invoiceId)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$now = utc_now();
|
||||
$version = (string) ($metadata['calculation_version'] ?? $this->getCalculationVersion());
|
||||
$rows = [];
|
||||
|
||||
$tuitionCents = $this->toCents($tuitionAmount);
|
||||
if ($tuitionCents !== 0) {
|
||||
$rows[] = $this->buildInvoiceLineRow(
|
||||
$invoiceId,
|
||||
'tuition',
|
||||
'tuition_calculation',
|
||||
null,
|
||||
'Tuition charges',
|
||||
$tuitionCents,
|
||||
1,
|
||||
$version,
|
||||
$metadata,
|
||||
$now
|
||||
);
|
||||
}
|
||||
|
||||
$eventCents = $this->toCents($eventAmount);
|
||||
if ($eventCents !== 0) {
|
||||
$rows[] = $this->buildInvoiceLineRow(
|
||||
$invoiceId,
|
||||
'event_fee',
|
||||
'event_charges',
|
||||
null,
|
||||
'Event charges',
|
||||
$eventCents,
|
||||
0,
|
||||
$version,
|
||||
$metadata,
|
||||
$now
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->loadApprovedAdjustmentRowsForIssuance($invoiceId, $metadata) as $charge) {
|
||||
$adjustmentCents = (string)($charge['charge_type'] ?? 'add') === 'deduct'
|
||||
? -1 * $this->toCents(abs((float)($charge['amount'] ?? 0)))
|
||||
: $this->toCents(abs((float)($charge['amount'] ?? 0)));
|
||||
if ($adjustmentCents === 0) {
|
||||
throw new \RuntimeException('Zero amount approved additional charges cannot be issued.');
|
||||
}
|
||||
|
||||
$row = $this->buildInvoiceLineRow(
|
||||
$invoiceId,
|
||||
$adjustmentCents > 0 ? 'additional_charge' : 'additional_deduction',
|
||||
'additional_charge',
|
||||
(int)$charge['id'],
|
||||
trim((string)($charge['title'] ?? 'Additional charge')) ?: 'Additional charge',
|
||||
$adjustmentCents,
|
||||
0,
|
||||
$version,
|
||||
$metadata + ['charge_type' => (string)($charge['charge_type'] ?? '')],
|
||||
$now
|
||||
);
|
||||
$row['active_source_key'] = 'additional_charge:' . (int)$charge['id'];
|
||||
$rows[] = $row;
|
||||
}
|
||||
|
||||
if ($rows === []) {
|
||||
throw new \RuntimeException('Invoice issuance requires at least one non-zero invoice line.');
|
||||
}
|
||||
|
||||
$inserted = 0;
|
||||
foreach ($rows as $row) {
|
||||
$lineId = $this->invoiceLineModel()->insert($row);
|
||||
if (!$lineId) {
|
||||
throw new FinancialPersistenceException('INVOICE_LINE_INSERT_FAILED', $this->invoiceLineModel()->errors());
|
||||
}
|
||||
$inserted++;
|
||||
|
||||
if (($row['source_type'] ?? null) === 'additional_charge' && !empty($row['source_id'])) {
|
||||
$this->additionalChargeModel->update((int)$row['source_id'], [
|
||||
'invoice_id' => $invoiceId,
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_APPLIED,
|
||||
'applied_invoice_line_id' => (int)$lineId,
|
||||
'applied_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $inserted;
|
||||
}
|
||||
|
||||
protected function loadApprovedAdjustmentRowsForIssuance(int $invoiceId, array $metadata): array
|
||||
{
|
||||
$parentId = (int)($metadata['parent_id'] ?? 0);
|
||||
$schoolYear = (string)($metadata['school_year'] ?? '');
|
||||
$semester = (string)($metadata['semester'] ?? '');
|
||||
if ($parentId <= 0 || $schoolYear === '' || $semester === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->additionalChargeModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->where('semester', $semester)
|
||||
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPROVED)
|
||||
->groupStart()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->orWhere('invoice_id IS NULL', null, false)
|
||||
->groupEnd()
|
||||
->orderBy('id', 'ASC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
protected function loadInvoice(int $invoiceId): ?array
|
||||
{
|
||||
return $this->invoiceModel->find($invoiceId);
|
||||
}
|
||||
|
||||
protected function calculateFrozenLineTotals(int $invoiceId): ?array
|
||||
{
|
||||
if (!$this->invoiceLinesAvailable() || !$this->invoiceHasLines($invoiceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = $this->invoiceLineModel()
|
||||
->select('line_type, line_amount_cents, discount_eligible')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->findAll();
|
||||
|
||||
$totals = [
|
||||
'tuition_cents' => 0,
|
||||
'event_cents' => 0,
|
||||
'additional_cents' => 0,
|
||||
'total_cents' => 0,
|
||||
'discount_eligible_base_cents' => 0,
|
||||
];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$amount = (int) ($row['line_amount_cents'] ?? 0);
|
||||
$type = (string) ($row['line_type'] ?? '');
|
||||
$totals['total_cents'] += $amount;
|
||||
if ((int) ($row['discount_eligible'] ?? 0) === 1) {
|
||||
$totals['discount_eligible_base_cents'] += $amount;
|
||||
}
|
||||
|
||||
if (str_contains($type, 'event')) {
|
||||
$totals['event_cents'] += $amount;
|
||||
} elseif (str_contains($type, 'additional') || str_contains($type, 'adjustment') || str_contains($type, 'charge')) {
|
||||
$totals['additional_cents'] += $amount;
|
||||
} else {
|
||||
$totals['tuition_cents'] += $amount;
|
||||
}
|
||||
}
|
||||
|
||||
return $totals;
|
||||
}
|
||||
|
||||
protected function invoiceHasLines(int $invoiceId): bool
|
||||
{
|
||||
if (!$this->invoiceLinesAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->invoiceLineModel()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->countAllResults() > 0;
|
||||
}
|
||||
|
||||
protected function invoiceLinesAvailable(): bool
|
||||
{
|
||||
try {
|
||||
return $this->invoiceLineModel()->db->tableExists('invoice_lines');
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function invoiceLineModel(): InvoiceLineModel
|
||||
{
|
||||
if ($this->invoiceLineModel === null) {
|
||||
$this->invoiceLineModel = new InvoiceLineModel();
|
||||
}
|
||||
|
||||
return $this->invoiceLineModel;
|
||||
}
|
||||
|
||||
protected function buildInvoiceLineRow(
|
||||
int $invoiceId,
|
||||
string $lineType,
|
||||
?string $sourceType,
|
||||
?int $sourceId,
|
||||
string $description,
|
||||
int $amountCents,
|
||||
int $discountEligible,
|
||||
string $version,
|
||||
array $metadata,
|
||||
string $timestamp
|
||||
): array {
|
||||
return [
|
||||
'invoice_id' => $invoiceId,
|
||||
'line_type' => $lineType,
|
||||
'source_type' => $sourceType,
|
||||
'source_id' => $sourceId,
|
||||
'active_source_key' => null,
|
||||
'description' => $description,
|
||||
'quantity' => '1.00',
|
||||
'unit_amount_cents' => $amountCents,
|
||||
'line_amount_cents' => $amountCents,
|
||||
'discount_eligible' => $discountEligible,
|
||||
'calculation_version' => $version,
|
||||
'metadata_json' => json_encode($metadata, JSON_UNESCAPED_SLASHES),
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
'voided_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getCalculationVersion(): string
|
||||
{
|
||||
return 'invoice_lines_v1:' . get_class($this->resolveActiveCalculator());
|
||||
}
|
||||
|
||||
protected function isCarryForwardInvoice(array $invoice): bool
|
||||
{
|
||||
$invoiceNumber = (string) ($invoice['invoice_number'] ?? '');
|
||||
@@ -196,9 +471,9 @@ class InvoiceLedgerService
|
||||
protected function calculateAdditionalCharges(int $invoiceId): float
|
||||
{
|
||||
$rows = $this->additionalChargeModel
|
||||
->select('COALESCE(SUM(amount),0) AS total_amount')
|
||||
->select("COALESCE(SUM(CASE WHEN charge_type = 'deduct' THEN -ABS(amount) ELSE ABS(amount) END),0) AS total_amount", false)
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereNotIn('status', ['void', FinancialStatus::ADDITIONAL_CHARGE_VOIDED, 'cancelled', 'canceled'])
|
||||
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED)
|
||||
->findAll();
|
||||
|
||||
return (float) ($rows[0]['total_amount'] ?? 0);
|
||||
@@ -206,6 +481,15 @@ class InvoiceLedgerService
|
||||
|
||||
protected function calculateDiscounts(int $invoiceId): float
|
||||
{
|
||||
if ($this->discountUsageModel->db->fieldExists('applied_discount_cents', $this->discountUsageModel->table)) {
|
||||
$row = $this->discountUsageModel
|
||||
->select('COALESCE(SUM(applied_discount_cents),0) AS total_cents')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->first();
|
||||
|
||||
return ((int) ($row['total_cents'] ?? 0)) / 100;
|
||||
}
|
||||
|
||||
$row = $this->discountUsageModel
|
||||
->select('COALESCE(SUM(discount_amount),0) AS total_amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
@@ -222,7 +506,7 @@ class InvoiceLedgerService
|
||||
|
||||
if ($this->paymentModel->db->fieldExists('status', $this->paymentModel->table)) {
|
||||
$query->groupStart()
|
||||
->whereNotIn('status', FinancialStatus::EXCLUDED_PAYMENT_STATUSES)
|
||||
->whereIn('status', FinancialStatus::VALID_PAYMENT_STATUSES)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
@@ -241,6 +525,20 @@ class InvoiceLedgerService
|
||||
|
||||
protected function calculatePaidRefunds(int $invoiceId): float
|
||||
{
|
||||
if ($this->refundPayoutsAvailable()) {
|
||||
$row = $this->refundPayoutModel()
|
||||
->select("COALESCE(SUM(CASE
|
||||
WHEN refund_payouts.payout_type = 'cash_out' AND refund_payouts.status = 'completed' THEN refund_payouts.amount_cents
|
||||
WHEN refund_payouts.payout_type = 'reversal' AND refund_payouts.status = 'completed' THEN -refund_payouts.amount_cents
|
||||
ELSE 0
|
||||
END),0) AS total_cents", false)
|
||||
->join('refunds', 'refunds.id = refund_payouts.refund_id', 'inner')
|
||||
->where('refunds.invoice_id', $invoiceId)
|
||||
->first();
|
||||
|
||||
return max(0, (float) ($row['total_cents'] ?? 0)) / 100;
|
||||
}
|
||||
|
||||
$row = $this->refundModel
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_amount')
|
||||
->where('invoice_id', $invoiceId)
|
||||
@@ -250,6 +548,24 @@ class InvoiceLedgerService
|
||||
return (float) ($row['total_amount'] ?? 0);
|
||||
}
|
||||
|
||||
protected function refundPayoutsAvailable(): bool
|
||||
{
|
||||
try {
|
||||
return $this->refundPayoutModel()->db->tableExists('refund_payouts');
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function refundPayoutModel(): RefundPayoutModel
|
||||
{
|
||||
if ($this->refundPayoutModel === null) {
|
||||
$this->refundPayoutModel = new RefundPayoutModel();
|
||||
}
|
||||
|
||||
return $this->refundPayoutModel;
|
||||
}
|
||||
|
||||
protected function loadTuitionStudents(int $parentId, string $schoolYear): array
|
||||
{
|
||||
$enrollments = $this->enrollmentModel
|
||||
@@ -364,4 +680,9 @@ class InvoiceLedgerService
|
||||
{
|
||||
return number_format($cents / 100, 2, '.', '');
|
||||
}
|
||||
|
||||
protected function fromCentsNumber(int $cents): float
|
||||
{
|
||||
return $cents / 100;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
class IssueInvoiceCommand
|
||||
{
|
||||
public function __construct(
|
||||
public readonly array $invoiceData,
|
||||
public readonly float $tuitionAmount,
|
||||
public readonly float $eventAmount,
|
||||
public readonly array $metadata = []
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\InvoiceModel;
|
||||
|
||||
class ParentLedgerService
|
||||
{
|
||||
protected InvoiceModel $invoiceModel;
|
||||
protected InvoiceLedgerService $invoiceLedgerService;
|
||||
protected RefundEligibilityService $refundEligibilityService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
$this->refundEligibilityService = new RefundEligibilityService();
|
||||
}
|
||||
|
||||
public function getParentProjection(int $parentId, string $schoolYear, ?string $semester = null): array
|
||||
{
|
||||
$invoices = $this->loadInvoices($parentId, $schoolYear, $semester);
|
||||
|
||||
$grossChargesCents = 0;
|
||||
$discountCents = 0;
|
||||
$netInvoiceChargesCents = 0;
|
||||
$validPaymentCents = 0;
|
||||
$completedRefundCents = 0;
|
||||
$balanceDueCents = 0;
|
||||
$customerCreditCents = 0;
|
||||
$approvedRefundReservationCents = 0;
|
||||
$availableRefundableCreditCents = 0;
|
||||
$invoiceProjections = [];
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
$invoiceId = (int)($invoice['id'] ?? 0);
|
||||
if ($invoiceId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$ledger = $this->ledgerForInvoice($invoiceId);
|
||||
$eligibility = $this->eligibilityForSource(
|
||||
$parentId,
|
||||
$invoiceId,
|
||||
'invoice_overpayment',
|
||||
$invoiceId
|
||||
);
|
||||
|
||||
$invoiceGrossCents = (int)($ledger['totalAmountCents'] ?? 0);
|
||||
$invoiceDiscountCents = (int)($ledger['discountCents'] ?? 0);
|
||||
$grossChargesCents += $invoiceGrossCents;
|
||||
$discountCents += $invoiceDiscountCents;
|
||||
$netInvoiceChargesCents += max(0, $invoiceGrossCents - $invoiceDiscountCents);
|
||||
$validPaymentCents += (int)($ledger['paidCents'] ?? 0);
|
||||
$completedRefundCents += (int)($ledger['completedRefundCents'] ?? 0);
|
||||
$balanceDueCents += (int)($ledger['balanceDueCents'] ?? 0);
|
||||
$customerCreditCents += (int)($ledger['customerCreditCents'] ?? 0);
|
||||
$approvedRefundReservationCents += $eligibility->reservedAmountCents;
|
||||
$availableRefundableCreditCents += $eligibility->availableAmountCents;
|
||||
|
||||
$invoiceProjections[] = [
|
||||
'invoice_id' => $invoiceId,
|
||||
'invoice_number' => (string)($invoice['invoice_number'] ?? ''),
|
||||
'grossChargesCents' => $invoiceGrossCents,
|
||||
'discountCents' => $invoiceDiscountCents,
|
||||
'netInvoiceChargesCents' => max(0, $invoiceGrossCents - $invoiceDiscountCents),
|
||||
'validPaymentCents' => (int)($ledger['paidCents'] ?? 0),
|
||||
'completedRefundCents' => (int)($ledger['completedRefundCents'] ?? 0),
|
||||
'balanceDueCents' => (int)($ledger['balanceDueCents'] ?? 0),
|
||||
'customerCreditCents' => (int)($ledger['customerCreditCents'] ?? 0),
|
||||
'approvedRefundReservationCents' => $eligibility->reservedAmountCents,
|
||||
'availableRefundableCreditCents' => $eligibility->availableAmountCents,
|
||||
'status' => (string)($ledger['status'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'parent_id' => $parentId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'grossChargesCents' => $grossChargesCents,
|
||||
'discountCents' => $discountCents,
|
||||
'netInvoiceChargesCents' => $netInvoiceChargesCents,
|
||||
'validPaymentCents' => $validPaymentCents,
|
||||
'completedRefundCents' => $completedRefundCents,
|
||||
'balanceDueCents' => $balanceDueCents,
|
||||
'customerCreditCents' => $customerCreditCents,
|
||||
'approvedRefundReservationCents' => $approvedRefundReservationCents,
|
||||
'availableRefundableCreditCents' => $availableRefundableCreditCents,
|
||||
'gross_charges' => $this->fromCents($grossChargesCents),
|
||||
'discounts' => $this->fromCents($discountCents),
|
||||
'net_invoice_charges' => $this->fromCents($netInvoiceChargesCents),
|
||||
'valid_payments' => $this->fromCents($validPaymentCents),
|
||||
'completed_refunds' => $this->fromCents($completedRefundCents),
|
||||
'balance_due' => $this->fromCents($balanceDueCents),
|
||||
'customer_credit' => $this->fromCents($customerCreditCents),
|
||||
'approved_refund_reservations' => $this->fromCents($approvedRefundReservationCents),
|
||||
'available_refundable_credit' => $this->fromCents($availableRefundableCreditCents),
|
||||
'invoices' => $invoiceProjections,
|
||||
];
|
||||
}
|
||||
|
||||
protected function loadInvoices(int $parentId, string $schoolYear, ?string $semester): array
|
||||
{
|
||||
$query = $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear);
|
||||
|
||||
if ($semester !== null && $semester !== '') {
|
||||
$query->where('semester', $semester);
|
||||
}
|
||||
|
||||
return $query->orderBy('id', 'ASC')->findAll();
|
||||
}
|
||||
|
||||
protected function ledgerForInvoice(int $invoiceId): array
|
||||
{
|
||||
return $this->invoiceLedgerService->calculateInvoice($invoiceId);
|
||||
}
|
||||
|
||||
protected function eligibilityForSource(
|
||||
int $parentId,
|
||||
?int $invoiceId,
|
||||
string $sourceType,
|
||||
int $sourceId
|
||||
): RefundEligibilityResult {
|
||||
return $this->refundEligibilityService->calculateAvailableCredit(
|
||||
$parentId,
|
||||
$invoiceId,
|
||||
$sourceType,
|
||||
$sourceId
|
||||
);
|
||||
}
|
||||
|
||||
private function fromCents(int $cents): string
|
||||
{
|
||||
return number_format($cents / 100, 2, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
class RefundEligibilityResult
|
||||
{
|
||||
public function __construct(
|
||||
public int $sourceCreditCents,
|
||||
public int $completedPayoutCents,
|
||||
public int $reservedAmountCents,
|
||||
public int $availableAmountCents,
|
||||
public array $reasonCodes = []
|
||||
) {
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'sourceCreditCents' => $this->sourceCreditCents,
|
||||
'completedPayoutCents' => $this->completedPayoutCents,
|
||||
'reservedAmountCents' => $this->reservedAmountCents,
|
||||
'availableAmountCents' => $this->availableAmountCents,
|
||||
'reasonCodes' => $this->reasonCodes,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries;
|
||||
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\PaymentCorrectionModel;
|
||||
use App\Models\RefundModel;
|
||||
use App\Models\RefundPayoutModel;
|
||||
|
||||
class RefundEligibilityService
|
||||
{
|
||||
protected InvoiceLedgerService $invoiceLedgerService;
|
||||
protected RefundModel $refundModel;
|
||||
protected RefundPayoutModel $refundPayoutModel;
|
||||
protected PaymentModel $paymentModel;
|
||||
protected PaymentCorrectionModel $paymentCorrectionModel;
|
||||
protected InvoiceModel $invoiceModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
$this->refundModel = new RefundModel();
|
||||
$this->refundPayoutModel = new RefundPayoutModel();
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->paymentCorrectionModel = new PaymentCorrectionModel();
|
||||
$this->invoiceModel = new InvoiceModel();
|
||||
}
|
||||
|
||||
public function calculateAvailableCredit(
|
||||
int $parentId,
|
||||
?int $invoiceId,
|
||||
string $sourceType,
|
||||
int $sourceId,
|
||||
?int $excludeRefundId = null
|
||||
): RefundEligibilityResult {
|
||||
$sourceCreditCents = $this->calculateSourceCreditCents($parentId, $invoiceId, $sourceType, $sourceId);
|
||||
$completedPayoutCents = $this->completedPayoutsAffectAvailability($sourceType)
|
||||
? $this->calculateCompletedPayoutCents($sourceType, $sourceId, $excludeRefundId)
|
||||
: 0;
|
||||
$reservedAmountCents = $this->calculateReservedAmountCents($sourceType, $sourceId, $excludeRefundId);
|
||||
$availableAmountCents = max(0, $sourceCreditCents - $completedPayoutCents - $reservedAmountCents);
|
||||
|
||||
$reasons = [];
|
||||
if ($sourceCreditCents <= 0) {
|
||||
$reasons[] = 'NO_SOURCE_CREDIT';
|
||||
}
|
||||
if ($completedPayoutCents > 0) {
|
||||
$reasons[] = 'HAS_COMPLETED_PAYOUTS';
|
||||
}
|
||||
if ($reservedAmountCents > 0) {
|
||||
$reasons[] = 'HAS_APPROVED_RESERVATIONS';
|
||||
}
|
||||
if ($availableAmountCents <= 0) {
|
||||
$reasons[] = 'NO_AVAILABLE_CREDIT';
|
||||
}
|
||||
|
||||
return new RefundEligibilityResult(
|
||||
$sourceCreditCents,
|
||||
$completedPayoutCents,
|
||||
$reservedAmountCents,
|
||||
$availableAmountCents,
|
||||
array_values(array_unique($reasons))
|
||||
);
|
||||
}
|
||||
|
||||
public function validateRequestedAmount(RefundEligibilityResult $eligibility, int $requestedAmountCents): void
|
||||
{
|
||||
if ($requestedAmountCents <= 0) {
|
||||
throw new \RuntimeException('Refund amount must be greater than zero.');
|
||||
}
|
||||
|
||||
if ($requestedAmountCents > $eligibility->availableAmountCents) {
|
||||
throw new \RuntimeException('Refund amount exceeds available source credit.');
|
||||
}
|
||||
}
|
||||
|
||||
public function getCompletedPayoutTotalCentsForRefund(int $refundId): int
|
||||
{
|
||||
return $this->getNetCompletedCashOutCentsForRefund($refundId);
|
||||
}
|
||||
|
||||
public function getNetCompletedCashOutCentsForRefund(int $refundId): int
|
||||
{
|
||||
if ($refundId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->refundPayoutsAvailable()) {
|
||||
$row = $this->refundPayoutModel->db->query(
|
||||
"SELECT
|
||||
COALESCE(SUM(CASE
|
||||
WHEN payout_type = 'cash_out' AND status = 'completed' THEN amount_cents
|
||||
WHEN payout_type = 'reversal' AND status = 'completed' THEN -amount_cents
|
||||
ELSE 0
|
||||
END), 0) AS total_cents
|
||||
FROM refund_payouts
|
||||
WHERE refund_id = ?",
|
||||
[$refundId]
|
||||
)->getRowArray();
|
||||
|
||||
return max(0, (int)($row['total_cents'] ?? 0));
|
||||
}
|
||||
|
||||
$refund = $this->refundModel->find($refundId);
|
||||
if (!$refund) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (int)round(((float)($refund['refund_paid_amount'] ?? 0)) * 100);
|
||||
}
|
||||
|
||||
public function getUnreversedPayoutAmountCents(int $payoutId): int
|
||||
{
|
||||
if ($payoutId <= 0 || !$this->refundPayoutsAvailable()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$row = $this->refundPayoutModel->db->query(
|
||||
"SELECT p.amount_cents
|
||||
- COALESCE(SUM(CASE
|
||||
WHEN r.payout_type = 'reversal' AND r.status = 'completed' THEN r.amount_cents
|
||||
ELSE 0
|
||||
END), 0) AS unreversed_cents
|
||||
FROM refund_payouts p
|
||||
LEFT JOIN refund_payouts r ON r.reversed_payout_id = p.id
|
||||
WHERE p.id = ?
|
||||
GROUP BY p.id, p.amount_cents",
|
||||
[$payoutId]
|
||||
)->getRowArray();
|
||||
|
||||
return max(0, (int)($row['unreversed_cents'] ?? 0));
|
||||
}
|
||||
|
||||
public function findPayoutsExceedingApprovedAmounts(): array
|
||||
{
|
||||
if ($this->refundPayoutsAvailable()) {
|
||||
return $this->refundModel->db->query(
|
||||
"SELECT r.id AS refund_id,
|
||||
COALESCE(r.approved_amount_cents, ROUND(COALESCE(r.refund_amount, 0) * 100)) AS approved_amount_cents,
|
||||
COALESCE(SUM(CASE
|
||||
WHEN p.payout_type = 'cash_out' AND p.status = 'completed' THEN p.amount_cents
|
||||
WHEN p.payout_type = 'reversal' AND p.status = 'completed' THEN -p.amount_cents
|
||||
ELSE 0
|
||||
END), 0) AS completed_payout_cents
|
||||
FROM refunds r
|
||||
LEFT JOIN refund_payouts p ON p.refund_id = r.id
|
||||
GROUP BY r.id, r.approved_amount_cents, r.refund_amount
|
||||
HAVING completed_payout_cents > approved_amount_cents"
|
||||
)->getResultArray();
|
||||
}
|
||||
|
||||
return $this->refundModel->db->query(
|
||||
"SELECT id AS refund_id,
|
||||
COALESCE(approved_amount_cents, ROUND(COALESCE(refund_amount, 0) * 100)) AS approved_amount_cents,
|
||||
ROUND(COALESCE(refund_paid_amount, 0) * 100) AS completed_payout_cents
|
||||
FROM refunds
|
||||
WHERE ROUND(COALESCE(refund_paid_amount, 0) * 100)
|
||||
> COALESCE(approved_amount_cents, ROUND(COALESCE(refund_amount, 0) * 100))"
|
||||
)->getResultArray();
|
||||
}
|
||||
|
||||
protected function calculateSourceCreditCents(int $parentId, ?int $invoiceId, string $sourceType, int $sourceId): int
|
||||
{
|
||||
return match ($sourceType) {
|
||||
'invoice_overpayment' => $this->invoiceCreditCents($parentId, $invoiceId ?: $sourceId),
|
||||
'payment_duplicate', 'payment_correction' => $this->paymentCreditCents($parentId, $invoiceId, $sourceId),
|
||||
'credit_memo', 'administrative_credit' => 0,
|
||||
default => 0,
|
||||
};
|
||||
}
|
||||
|
||||
protected function completedPayoutsAffectAvailability(string $sourceType): bool
|
||||
{
|
||||
return $sourceType !== 'invoice_overpayment';
|
||||
}
|
||||
|
||||
protected function invoiceCreditCents(int $parentId, int $invoiceId): int
|
||||
{
|
||||
if ($invoiceId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice || (int)($invoice['parent_id'] ?? 0) !== $parentId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId);
|
||||
|
||||
return (int)($ledger['customerCreditCents'] ?? 0);
|
||||
}
|
||||
|
||||
protected function paymentCreditCents(int $parentId, ?int $invoiceId, int $paymentId): int
|
||||
{
|
||||
if ($paymentId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$payment = $this->paymentModel->find($paymentId);
|
||||
if (!$payment || (int)($payment['parent_id'] ?? 0) !== $parentId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($invoiceId !== null && $invoiceId > 0 && (int)($payment['invoice_id'] ?? 0) !== $invoiceId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$status = FinancialStatus::normalizePaymentStatus($payment['status'] ?? null);
|
||||
if (!in_array($status, [FinancialStatus::PAYMENT_RECORDED], true)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!$this->paymentCorrectionsAvailable()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$correction = $this->paymentCorrectionModel
|
||||
->where('payment_id', $paymentId)
|
||||
->where('invoice_id', (int)($payment['invoice_id'] ?? 0))
|
||||
->where('parent_id', $parentId)
|
||||
->where('status', 'approved')
|
||||
->orderBy('approved_at', 'DESC')
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
if (!$correction) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return max(0, (int)($correction['approved_refundable_cents'] ?? 0));
|
||||
}
|
||||
|
||||
protected function paymentCorrectionsAvailable(): bool
|
||||
{
|
||||
try {
|
||||
return $this->paymentCorrectionModel->db->tableExists('payment_corrections');
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
protected function calculateCompletedPayoutCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int
|
||||
{
|
||||
$refundIds = $this->matchingRefundIds($sourceType, $sourceId, $excludeRefundId);
|
||||
if ($refundIds === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->refundPayoutsAvailable()) {
|
||||
$row = $this->refundPayoutModel->db->table('refund_payouts')
|
||||
->select("COALESCE(SUM(CASE
|
||||
WHEN payout_type = 'cash_out' AND status = 'completed' THEN amount_cents
|
||||
WHEN payout_type = 'reversal' AND status = 'completed' THEN -amount_cents
|
||||
ELSE 0
|
||||
END),0) AS total_cents", false)
|
||||
->whereIn('refund_id', $refundIds)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return max(0, (int)($row['total_cents'] ?? 0));
|
||||
}
|
||||
|
||||
$row = $this->refundModel
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS total_paid')
|
||||
->whereIn('id', $refundIds)
|
||||
->whereIn('status', ['Partial', 'Paid', 'partial', 'paid', 'partially_paid'])
|
||||
->first();
|
||||
|
||||
return (int)round(((float)($row['total_paid'] ?? 0)) * 100);
|
||||
}
|
||||
|
||||
protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int
|
||||
{
|
||||
$query = $this->refundModel
|
||||
->select('id, refund_amount, refund_paid_amount, approved_amount_cents')
|
||||
->where('source_type', $sourceType)
|
||||
->where('source_id', $sourceId)
|
||||
->whereIn('status', ['Approved', 'Partial', 'approved', 'partially_paid']);
|
||||
|
||||
if ($excludeRefundId !== null && $excludeRefundId > 0) {
|
||||
$query->where('id !=', $excludeRefundId);
|
||||
}
|
||||
|
||||
$reserved = 0;
|
||||
foreach ($query->findAll() as $refund) {
|
||||
$approved = isset($refund['approved_amount_cents']) && $refund['approved_amount_cents'] !== null
|
||||
? (int)$refund['approved_amount_cents']
|
||||
: (int)round(((float)($refund['refund_amount'] ?? 0)) * 100);
|
||||
$paid = $this->getCompletedPayoutTotalCentsForRefund((int)($refund['id'] ?? 0));
|
||||
$reserved += max(0, $approved - $paid);
|
||||
}
|
||||
|
||||
return $reserved;
|
||||
}
|
||||
|
||||
protected function matchingRefundIds(string $sourceType, int $sourceId, ?int $excludeRefundId): array
|
||||
{
|
||||
$query = $this->refundModel
|
||||
->select('id')
|
||||
->where('source_type', $sourceType)
|
||||
->where('source_id', $sourceId);
|
||||
|
||||
if ($excludeRefundId !== null && $excludeRefundId > 0) {
|
||||
$query->where('id !=', $excludeRefundId);
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn(array $row): int => (int)($row['id'] ?? 0),
|
||||
$query->findAll()
|
||||
)));
|
||||
}
|
||||
|
||||
protected function refundPayoutsAvailable(): bool
|
||||
{
|
||||
try {
|
||||
return $this->refundPayoutModel->db->tableExists('refund_payouts');
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,12 @@ class AdditionalChargeModel extends Model
|
||||
'amount',
|
||||
'due_date',
|
||||
'status',
|
||||
'applied_invoice_line_id',
|
||||
'applied_by',
|
||||
'applied_at',
|
||||
'voided_by',
|
||||
'voided_at',
|
||||
'void_reason',
|
||||
'created_by',
|
||||
];
|
||||
|
||||
@@ -36,9 +42,9 @@ class AdditionalChargeModel extends Model
|
||||
'charge_type' => 'required|in_list[add,deduct]',
|
||||
'title' => 'required|string|min_length[2]|max_length[255]',
|
||||
'description' => 'permit_empty|string',
|
||||
'amount' => 'required|decimal',
|
||||
'amount' => 'required|decimal|greater_than_equal_to[0]',
|
||||
'due_date' => 'permit_empty|valid_date',
|
||||
'status' => 'required|in_list[pending,applied,voided]',
|
||||
'status' => 'required|in_list[pending,approved,applied,rejected,voided,reversed]',
|
||||
'created_by' => 'permit_empty|integer',
|
||||
];
|
||||
|
||||
|
||||
@@ -16,10 +16,16 @@ class DiscountUsageModel extends Model
|
||||
'voucher_id',
|
||||
'invoice_id',
|
||||
'discount_amount',
|
||||
'requested_discount_cents',
|
||||
'eligible_base_cents',
|
||||
'eligible_base_before_cents',
|
||||
'applied_discount_cents',
|
||||
'application_order',
|
||||
'school_year',
|
||||
'semester',
|
||||
'updated_by',
|
||||
'parent_id',
|
||||
'description',
|
||||
'used_at'
|
||||
];
|
||||
|
||||
@@ -50,8 +56,12 @@ class DiscountUsageModel extends Model
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
$amountExpression = $db->fieldExists('applied_discount_cents', 'discount_usages')
|
||||
? 'COALESCE(SUM(du.applied_discount_cents),0) / 100'
|
||||
: 'COALESCE(SUM(du.discount_amount),0)';
|
||||
|
||||
$result = $db->table('discount_usages du')
|
||||
->selectSum('du.discount_amount', 'total_discount')
|
||||
->select($amountExpression . ' AS total_discount', false)
|
||||
->join('invoices i', 'du.invoice_id = i.id')
|
||||
->where('i.parent_id', $parentId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
@@ -62,4 +72,3 @@ class DiscountUsageModel extends Model
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class InvoiceLineModel extends Model
|
||||
{
|
||||
protected $table = 'invoice_lines';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = false;
|
||||
|
||||
protected $allowedFields = [
|
||||
'invoice_id',
|
||||
'line_type',
|
||||
'source_type',
|
||||
'source_id',
|
||||
'active_source_key',
|
||||
'description',
|
||||
'quantity',
|
||||
'unit_amount_cents',
|
||||
'line_amount_cents',
|
||||
'discount_eligible',
|
||||
'calculation_version',
|
||||
'metadata_json',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'voided_at',
|
||||
];
|
||||
|
||||
protected $validationRules = [
|
||||
'invoice_id' => 'required|integer',
|
||||
'line_type' => 'required|max_length[50]',
|
||||
'description' => 'required|max_length[255]',
|
||||
'quantity' => 'required|decimal',
|
||||
'unit_amount_cents' => 'required|integer',
|
||||
'line_amount_cents' => 'required|integer',
|
||||
'discount_eligible' => 'required|in_list[0,1]',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class PaymentCorrectionModel extends Model
|
||||
{
|
||||
protected $table = 'payment_corrections';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = false;
|
||||
|
||||
protected $allowedFields = [
|
||||
'payment_id',
|
||||
'invoice_id',
|
||||
'parent_id',
|
||||
'correction_type',
|
||||
'approved_refundable_cents',
|
||||
'status',
|
||||
'reason',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
}
|
||||
@@ -35,7 +35,10 @@ class PaymentModel extends Model
|
||||
'installment_seq',
|
||||
'transaction_id',
|
||||
'idempotency_key',
|
||||
'request_fingerprint_hash',
|
||||
'check_file',
|
||||
'evidence_status',
|
||||
'evidence_failure_message',
|
||||
'check_number',
|
||||
'payment_method',
|
||||
'payment_date',
|
||||
@@ -76,7 +79,8 @@ class PaymentModel extends Model
|
||||
'installment_seq' => 'permit_empty|integer|greater_than[0]',
|
||||
|
||||
'transaction_id' => 'required|max_length[100]',
|
||||
'idempotency_key' => 'permit_empty|max_length[36]',
|
||||
'idempotency_key' => 'permit_empty|max_length[100]',
|
||||
'request_fingerprint_hash' => 'permit_empty|exact_length[64]',
|
||||
'payment_method' => 'required|in_list[cash,check,card]',
|
||||
'payment_date' => 'required|valid_date',
|
||||
'school_year' => 'required|string|max_length[9]',
|
||||
@@ -87,6 +91,8 @@ class PaymentModel extends Model
|
||||
'voided_by' => 'permit_empty|integer',
|
||||
'void_reason' => 'permit_empty|max_length[255]',
|
||||
'check_file' => 'permit_empty|max_length[255]',
|
||||
'evidence_status' => 'permit_empty|max_length[30]',
|
||||
'evidence_failure_message' => 'permit_empty|max_length[255]',
|
||||
'check_number' => 'permit_empty|max_length[100]',
|
||||
'updated_by' => 'permit_empty|integer',
|
||||
];
|
||||
@@ -145,7 +151,7 @@ class PaymentModel extends Model
|
||||
'greater_than' => 'Installment sequence must be greater than zero.',
|
||||
],
|
||||
'idempotency_key' => [
|
||||
'max_length' => 'Idempotency key must not exceed 36 characters.',
|
||||
'max_length' => 'Idempotency key must not exceed 100 characters.',
|
||||
],
|
||||
'check_number' => [
|
||||
'max_length' => 'Check number must not exceed 100 characters.',
|
||||
|
||||
@@ -15,6 +15,7 @@ class PurchaseOrderItemModel extends Model
|
||||
'purchase_order_id' => 'required|is_natural_no_zero',
|
||||
'supply_id' => 'required|is_natural_no_zero',
|
||||
'quantity' => 'required|is_natural_no_zero',
|
||||
'unit_cost' => 'required|decimal',
|
||||
'received_qty' => 'if_exist|integer|greater_than_equal_to[0]',
|
||||
'unit_cost' => 'required|decimal|greater_than_equal_to[0]',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ class RefundModel extends Model
|
||||
'school_year',
|
||||
'invoice_id',
|
||||
'refund_amount',
|
||||
'requested_amount_cents',
|
||||
'approved_amount_cents',
|
||||
'reconciliation_status',
|
||||
'reconciliation_reason',
|
||||
'reconciliation_required_at',
|
||||
'currency',
|
||||
'requested_at',
|
||||
'approved_at',
|
||||
'refunded_at',
|
||||
@@ -24,6 +30,8 @@ class RefundModel extends Model
|
||||
'reason',
|
||||
'refund_paid_amount',
|
||||
'request',
|
||||
'source_type',
|
||||
'source_id',
|
||||
'note',
|
||||
'semester',
|
||||
'school_year',
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
class RefundPayoutModel extends Model
|
||||
{
|
||||
protected $table = 'refund_payouts';
|
||||
protected $primaryKey = 'id';
|
||||
protected $returnType = 'array';
|
||||
protected $useTimestamps = false;
|
||||
|
||||
protected $allowedFields = [
|
||||
'refund_id',
|
||||
'amount_cents',
|
||||
'currency',
|
||||
'payout_type',
|
||||
'payment_method',
|
||||
'status',
|
||||
'external_reference',
|
||||
'check_number',
|
||||
'check_date',
|
||||
'evidence_path',
|
||||
'idempotency_key',
|
||||
'operation_type',
|
||||
'request_fingerprint_hash',
|
||||
'processed_by',
|
||||
'processed_at',
|
||||
'reversed_payout_id',
|
||||
'failure_code',
|
||||
'failure_message',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
];
|
||||
|
||||
protected $validationRules = [
|
||||
'refund_id' => 'required|integer',
|
||||
'amount_cents' => 'required|integer|greater_than[0]',
|
||||
'currency' => 'required|exact_length[3]',
|
||||
'payout_type' => 'required|in_list[cash_out,account_credit,reversal]',
|
||||
'status' => 'required|max_length[30]',
|
||||
'idempotency_key' => 'required|max_length[100]',
|
||||
'operation_type' => 'permit_empty|max_length[50]',
|
||||
'request_fingerprint_hash' => 'permit_empty|exact_length[64]',
|
||||
];
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Models;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
use App\Models\Concerns\SchoolYearAutoFillTrait;
|
||||
use App\Libraries\FinancialStatus;
|
||||
|
||||
class ReimbursementModel extends Model
|
||||
{
|
||||
@@ -24,10 +25,13 @@ class ReimbursementModel extends Model
|
||||
'semester',
|
||||
'check_number',
|
||||
'reimbursement_method',
|
||||
'batch_number'
|
||||
'batch_number',
|
||||
'updated_by',
|
||||
];
|
||||
protected $validationRules = [
|
||||
'school_year' => 'required|string|max_length[9]',
|
||||
'amount' => 'if_exist|decimal|greater_than[0]',
|
||||
'status' => 'if_exist|in_list[' . FinancialStatus::REIMBURSEMENT_PENDING . ',' . FinancialStatus::REIMBURSEMENT_APPROVED . ',' . FinancialStatus::REIMBURSEMENT_PAID . ',' . FinancialStatus::REIMBURSEMENT_REJECTED . ',' . FinancialStatus::REIMBURSEMENT_REVERSED . ',Pending,Approved,Paid,Rejected,Reversed]',
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="d-flex gap-2 mb-3 justify-content-end">
|
||||
<div>
|
||||
<a href="/discount/create" class="btn btn-primary">Create New Voucher</a>
|
||||
<a href="/discount/apply" class="btn btn-success">Apply Voucher to Parents</a>
|
||||
<a href="/discount/voucher-form" class="btn btn-success">Apply Voucher to Parents</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
|
||||
@@ -89,20 +89,12 @@
|
||||
$disc = $discMatches ? reset($discMatches) : null;
|
||||
$discounted = (float)($disc['discount_amount'] ?? 0);
|
||||
$totalAmount = (float)($inv['total_amount'] ?? 0);
|
||||
// Recompute balance for display: total - discount - paid - refunded
|
||||
$balanceCalc = (float)$totalAmount - (float)$discounted - (float)$paid - (float)$refunded;
|
||||
if ($balanceCalc < 0) $balanceCalc = 0.0;
|
||||
$balance = $balanceCalc;
|
||||
if ($balanceCalc <= 0.00001) {
|
||||
$status = 'Paid';
|
||||
$statusClass = 'bg-success';
|
||||
} elseif ($paid > 0.00001) {
|
||||
$status = 'Partially Paid';
|
||||
$statusClass = 'bg-warning text-dark';
|
||||
} else {
|
||||
$status = 'Unpaid';
|
||||
$statusClass = 'bg-danger';
|
||||
}
|
||||
$balance = (float)($inv['balance'] ?? 0);
|
||||
$status = (string)($inv['status'] ?? 'unpaid');
|
||||
$statusClass = $status === 'paid'
|
||||
? 'bg-success'
|
||||
: ($status === 'partially_paid' ? 'bg-warning text-dark' : 'bg-danger');
|
||||
$statusLabel = str_replace('_', ' ', $status);
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($inv['invoice_number']) ?></td>
|
||||
@@ -125,7 +117,7 @@
|
||||
<td data-order="<?= $balance ?>">$<?= number_format($balance, 2) ?></td>
|
||||
<td data-order="<?= $refunded ?>">$<?= number_format($refunded, 2) ?></td>
|
||||
<td data-order="<?= $discounted ?>">$<?= number_format($discounted, 2) ?></td>
|
||||
<td data-order="<?= esc($status) ?>"><span class="badge <?= esc($statusClass) ?>"><?= esc($status) ?></span></td>
|
||||
<td data-order="<?= esc($status) ?>"><span class="badge <?= esc($statusClass) ?>"><?= esc($statusLabel) ?></span></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
@@ -375,17 +367,12 @@
|
||||
const refunded = refundsMap[inv.id] || 0;
|
||||
const discounted = discountsMap[inv.id] || 0;
|
||||
const total = Number(inv.total_amount || 0);
|
||||
let balance = total - paid - discounted - refunded;
|
||||
if (!Number.isFinite(balance) || balance < 0) balance = 0;
|
||||
let status = 'Unpaid';
|
||||
const balance = Number(inv.balance || 0);
|
||||
const status = String(inv.status || 'unpaid');
|
||||
let statusClass = 'bg-danger';
|
||||
if (balance <= 0.00001) {
|
||||
status = 'Paid';
|
||||
statusClass = 'bg-success';
|
||||
} else if (paid > 0.00001) {
|
||||
status = 'Partially Paid';
|
||||
statusClass = 'bg-warning text-dark';
|
||||
}
|
||||
if (status === 'paid') statusClass = 'bg-success';
|
||||
if (status === 'partially_paid') statusClass = 'bg-warning text-dark';
|
||||
const statusLabel = status.replace(/_/g, ' ');
|
||||
const tr = document.createElement('tr');
|
||||
const parentHtml = (inv.parent_id && Number(inv.parent_id) > 0) ?
|
||||
`<a href="<?= site_url('family') ?>?guardian_id=${encodeURIComponent(inv.parent_id)}" class="text-decoration-none" data-family-guardian-id="${inv.parent_id}">${inv.parent_name||''}</a>` :
|
||||
@@ -402,7 +389,7 @@
|
||||
<td data-order="${balance}">${fmt(balance)}</td>
|
||||
<td data-order="${refunded}">${fmt(refunded)}</td>
|
||||
<td data-order="${discounted}">${fmt(discounted)}</td>
|
||||
<td data-order="${status}"><span class="badge ${statusClass}">${status}</span></td>`;
|
||||
<td data-order="${status}"><span class="badge ${statusClass}">${statusLabel}</span></td>`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
|
||||
@@ -371,8 +371,7 @@
|
||||
<td>$<?= number_format((float)$invoice['paid_amount'], 2) ?></td>
|
||||
<td>$<?= number_format((float)($invoice['discount'] ?? 0), 2) ?></td>
|
||||
<td>$<?= number_format((float)($invoice['refund_paid'] ?? 0), 2) ?></td>
|
||||
<?php $tblBal = max(0, (float)($invoice['total_amount'] ?? 0) - (float)($invoice['paid_amount'] ?? 0) - (float)($invoice['discount'] ?? 0) - (float)($invoice['refund_paid'] ?? 0)); ?>
|
||||
<td>$<?= number_format($tblBal, 2) ?></td>
|
||||
<td>$<?= number_format((float)($invoice['balance'] ?? 0), 2) ?></td>
|
||||
<td><?= esc($invoice['status']) ?></td>
|
||||
<td><?= esc(!empty($invoice['due_date']) ? local_date($invoice['due_date'], 'm-d-Y') : '') ?></td>
|
||||
</tr>
|
||||
@@ -397,6 +396,7 @@
|
||||
<form method="post" action="<?= base_url('payment/manual_pay_update') ?>" id="addPaymentForm" enctype="multipart/form-data">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="search_term" value="<?= esc($searchTermUsedInSearch ?? '') ?>">
|
||||
<input type="hidden" name="idempotency_key" value="<?= esc(bin2hex(random_bytes(16))) ?>">
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="addPaymentModalLabel">Add Manual Payment</h5>
|
||||
@@ -419,17 +419,13 @@
|
||||
<option
|
||||
value="<?= esc($invoice['id']) ?>"
|
||||
data-balance="<?= esc($invoice['balance']) ?>"
|
||||
data-total="<?= esc($invoice['total_amount'] ?? $invoice['total'] ?? '') ?>"
|
||||
data-paid="<?= esc($invoice['paid_amount'] ?? 0) ?>"
|
||||
data-discount="<?= esc($invoice['discount'] ?? 0) ?>"
|
||||
data-refund="<?= esc($invoice['refund_paid'] ?? 0) ?>"
|
||||
data-display-total="<?= esc($invoice['display_total'] ?? $invoice['total_amount'] ?? '') ?>"
|
||||
data-balance-due-cents="<?= (int)($invoice['balance_due_cents'] ?? 0) ?>"
|
||||
data-customer-credit-cents="<?= (int)($invoice['customer_credit_cents'] ?? 0) ?>"
|
||||
data-next-installment="<?= (int)($invoice['next_installment'] ?? 1) ?>">
|
||||
<?php
|
||||
$uiTotal = (float)($invoice['total_amount'] ?? 0);
|
||||
$uiPaid = (float)($invoice['paid_amount'] ?? 0);
|
||||
$uiDisc = (float)($invoice['discount'] ?? 0);
|
||||
$uiRef = (float)($invoice['refund_paid'] ?? 0);
|
||||
$uiBal = max(0, $uiTotal - $uiPaid - $uiDisc - $uiRef);
|
||||
$uiBal = (float)($invoice['balance'] ?? 0);
|
||||
?>
|
||||
Invoice <?= esc($invoice['invoice_number']) ?> |
|
||||
Balance: $<?= number_format($uiBal, 2) ?> |
|
||||
@@ -615,20 +611,11 @@
|
||||
function getBalance() {
|
||||
const opt = currentOpt();
|
||||
if (!opt) return 0;
|
||||
// Compute from total - paid FIRST (more reliable if DB balance is stale)
|
||||
const totRaw = opt.getAttribute('data-total');
|
||||
const paidRaw = opt.getAttribute('data-paid');
|
||||
const discRaw = opt.getAttribute('data-discount');
|
||||
const refRaw = opt.getAttribute('data-refund');
|
||||
const tot = (totRaw !== null && totRaw !== '') ? parseFloat(totRaw) : NaN;
|
||||
const paid = (paidRaw !== null && paidRaw !== '') ? parseFloat(paidRaw) : NaN;
|
||||
const disc = (discRaw !== null && discRaw !== '') ? parseFloat(discRaw) : 0;
|
||||
const ref = (refRaw !== null && refRaw !== '') ? parseFloat(refRaw) : 0;
|
||||
if (isFinite(tot) && isFinite(paid)) {
|
||||
const computed = tot - paid - (isFinite(disc) ? disc : 0) - (isFinite(ref) ? ref : 0);
|
||||
if (isFinite(computed)) return Math.max(0, computed);
|
||||
const centsRaw = opt.getAttribute('data-balance-due-cents');
|
||||
const cents = (centsRaw !== null && centsRaw !== '') ? parseInt(centsRaw, 10) : NaN;
|
||||
if (isFinite(cents)) {
|
||||
return Math.max(0, cents / 100);
|
||||
}
|
||||
// Fallback to provided balance
|
||||
const raw = opt.getAttribute('data-balance');
|
||||
const bal = (raw !== null && raw !== '') ? parseFloat(raw) : NaN;
|
||||
return isFinite(bal) ? Math.max(0, bal) : 0;
|
||||
@@ -636,7 +623,7 @@
|
||||
|
||||
function getTotal() {
|
||||
const opt = currentOpt();
|
||||
const v = opt ? parseFloat(opt.getAttribute('data-total') || '0') : 0;
|
||||
const v = opt ? parseFloat(opt.getAttribute('data-display-total') || '0') : 0;
|
||||
return isFinite(v) ? v : 0;
|
||||
}
|
||||
|
||||
@@ -926,25 +913,7 @@
|
||||
}
|
||||
|
||||
const invoiceText = invOpt.text || ('Invoice #' + invOpt.value);
|
||||
// Derive balance: prefer total - paid - discount - refunds, else fallback to data-balance
|
||||
let balance = NaN;
|
||||
if (invOpt) {
|
||||
const rt = invOpt.getAttribute('data-total');
|
||||
const rp = invOpt.getAttribute('data-paid');
|
||||
const rd = invOpt.getAttribute('data-discount');
|
||||
const rr = invOpt.getAttribute('data-refund');
|
||||
const t = (rt !== null && rt !== '') ? parseFloat(rt) : NaN;
|
||||
const p = (rp !== null && rp !== '') ? parseFloat(rp) : NaN;
|
||||
const d = (rd !== null && rd !== '') ? parseFloat(rd) : 0;
|
||||
const r = (rr !== null && rr !== '') ? parseFloat(rr) : 0;
|
||||
if (isFinite(t) && isFinite(p)) {
|
||||
balance = t - p - (isFinite(d) ? d : 0) - (isFinite(r) ? r : 0);
|
||||
}
|
||||
if (!isFinite(balance)) {
|
||||
const rb = invOpt.getAttribute('data-balance');
|
||||
balance = (rb !== null && rb !== '') ? parseFloat(rb) : NaN;
|
||||
}
|
||||
}
|
||||
const balance = getBalance();
|
||||
const nextInst = invOpt.dataset?.nextInstallment || '';
|
||||
const instCount = $instCount() ? ($instCount().value || '') : '';
|
||||
const instAmt = $instAmt() ? ($instAmt().value || '') : '';
|
||||
@@ -1131,19 +1100,13 @@
|
||||
// Normalize initial state
|
||||
const inv = $invoice();
|
||||
if (inv && inv.options.length > 0) {
|
||||
// Prefer the first invoice whose (total - paid) > 0
|
||||
// Prefer the first invoice with a backend-provided amount due.
|
||||
let chosen = inv.selectedIndex;
|
||||
for (let i = 0; i < inv.options.length; i++) {
|
||||
const o = inv.options[i];
|
||||
const rt = o.getAttribute('data-total');
|
||||
const rp = o.getAttribute('data-paid');
|
||||
const rd = o.getAttribute('data-discount');
|
||||
const rr = o.getAttribute('data-refund');
|
||||
const t = (rt !== null && rt !== '') ? parseFloat(rt) : NaN;
|
||||
const p = (rp !== null && rp !== '') ? parseFloat(rp) : NaN;
|
||||
const d = (rd !== null && rd !== '') ? parseFloat(rd) : 0;
|
||||
const r = (rr !== null && rr !== '') ? parseFloat(rr) : 0;
|
||||
let b = (isFinite(t) && isFinite(p)) ? (t - p - (isFinite(d) ? d : 0) - (isFinite(r) ? r : 0)) : NaN;
|
||||
const centsRaw = o.getAttribute('data-balance-due-cents');
|
||||
const cents = (centsRaw !== null && centsRaw !== '') ? parseInt(centsRaw, 10) : NaN;
|
||||
let b = isFinite(cents) ? cents / 100 : NaN;
|
||||
if (!isFinite(b)) {
|
||||
const rb = o.getAttribute('data-balance');
|
||||
b = (rb !== null && rb !== '') ? parseFloat(rb) : NaN;
|
||||
|
||||
@@ -69,6 +69,8 @@
|
||||
<th>Check #</th>
|
||||
<th>Check File</th>
|
||||
<th class="text-end">Paid Amount</th>
|
||||
<th class="text-end">Source Available</th>
|
||||
<th class="text-end">Parent Available</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -119,6 +121,12 @@
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-end">$<?= esc(number_format((float)$r['refund_paid_amount'], 2)) ?></td>
|
||||
<td class="text-end">
|
||||
<?= $r['available_refundable_credit'] === null ? '-' : '$' . esc(number_format((float)$r['available_refundable_credit'], 2)) ?>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<?= isset($r['parent_available_refundable_credit']) ? '$' . esc(number_format((float)$r['parent_available_refundable_credit'], 2)) : '-' ?>
|
||||
</td>
|
||||
<td class="d-flex gap-2">
|
||||
<button class="btn btn-success btn-sm"
|
||||
onclick="handleRecordRefundClick(<?= (int)$r['id'] ?>, '<?= esc($r['status']) ?>', <?= (float)$r['refund_amount'] ?>)">
|
||||
@@ -178,6 +186,7 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" name="refund_id" id="refundIdPayment">
|
||||
<input type="hidden" name="idempotency_key" id="refundPayoutIdempotencyKey">
|
||||
<div class="mb-3">
|
||||
<label for="paidAmount" class="form-label">Paid Amount</label>
|
||||
<input type="number" step="0.01" min="0.01" class="form-control" name="paid_amount" id="paidAmount" required>
|
||||
@@ -304,6 +313,11 @@ function showPaymentModal(refundId) {
|
||||
$('#paymentForm').removeClass('d-none');
|
||||
|
||||
$('#refundIdPayment').val(refundId);
|
||||
$('#refundPayoutIdempotencyKey').val(
|
||||
(window.crypto && crypto.randomUUID)
|
||||
? crypto.randomUUID()
|
||||
: ('refund-' + refundId + '-' + Date.now() + '-' + Math.random().toString(16).slice(2))
|
||||
);
|
||||
$('#paidAmount').val('');
|
||||
$('#paymentMethod').val('').trigger('change');
|
||||
$('#checkDetails').addClass('d-none');
|
||||
|
||||
+2
-1
@@ -49,6 +49,7 @@
|
||||
"sort-packages": true
|
||||
},
|
||||
"scripts": {
|
||||
"test": "php -d variables_order=EGPCS vendor/bin/phpunit"
|
||||
"test": "php -d variables_order=EGPCS vendor/bin/phpunit",
|
||||
"check:financial-formulas": "php scripts/check-financial-formulas.php"
|
||||
}
|
||||
}
|
||||
|
||||
+1799
File diff suppressed because it is too large
Load Diff
+1228
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
$patterns = [
|
||||
'refund_paid_amount',
|
||||
'SUM\\(paid_amount\\)',
|
||||
'SUM\\(amount\\)',
|
||||
'newBalance',
|
||||
'balance\\s*=',
|
||||
'total\\s*-\\s*discount',
|
||||
'total\\s*-\\s*paid',
|
||||
'Partially Paid',
|
||||
'Unpaid',
|
||||
'Paid',
|
||||
];
|
||||
|
||||
$allowlist = [
|
||||
'/app/Libraries/InvoiceLedgerService.php',
|
||||
'/app/Libraries/ParentLedgerService.php',
|
||||
'/app/Libraries/FinancialReportProjectionService.php',
|
||||
'/app/Libraries/FinancialCorrectionReportService.php',
|
||||
'/app/Libraries/FinancialStatus.php',
|
||||
'/app/Database/Migrations/',
|
||||
'/tests/',
|
||||
'/scripts/check-financial-formulas.php',
|
||||
];
|
||||
|
||||
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS));
|
||||
$violations = [];
|
||||
|
||||
foreach ($rii as $file) {
|
||||
if (!$file->isFile() || $file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = str_replace($root, '', $file->getPathname());
|
||||
if (!str_starts_with($path, '/app/') && !str_starts_with($path, '/tests/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($allowlist as $allowed) {
|
||||
if (str_starts_with($path, $allowed) || $path === $allowed) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
$lines = file($file->getPathname(), FILE_IGNORE_NEW_LINES) ?: [];
|
||||
foreach ($lines as $lineNumber => $line) {
|
||||
foreach ($patterns as $pattern) {
|
||||
if (preg_match('/' . $pattern . '/i', $line)) {
|
||||
$violations[] = sprintf('%s:%d matches /%s/i', $path, $lineNumber + 1, $pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($violations !== []) {
|
||||
sort($violations);
|
||||
}
|
||||
|
||||
$baselineFile = $root . '/scripts/financial-formula-baseline.txt';
|
||||
if (in_array('--update-baseline', $argv, true)) {
|
||||
file_put_contents($baselineFile, implode("\n", $violations) . ($violations === [] ? '' : "\n"));
|
||||
echo 'Updated financial formula baseline with ' . count($violations) . " entries.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
$baseline = [];
|
||||
if (is_file($baselineFile)) {
|
||||
$baseline = array_values(array_filter(file($baselineFile, FILE_IGNORE_NEW_LINES) ?: [], static fn ($line) => trim($line) !== ''));
|
||||
sort($baseline);
|
||||
}
|
||||
|
||||
$newViolations = array_values(array_diff($violations, $baseline));
|
||||
if ($newViolations !== []) {
|
||||
fwrite(STDERR, "New financial formula patterns found outside allowlist:\n");
|
||||
fwrite(STDERR, implode("\n", $newViolations) . "\n");
|
||||
fwrite(STDERR, "Run `php scripts/check-financial-formulas.php --update-baseline` only after reviewing intentional legacy exceptions.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
echo 'No new financial formula patterns found outside baseline. Current baseline entries: ' . count($baseline) . ".\n";
|
||||
@@ -0,0 +1,632 @@
|
||||
/app/Commands/RecalculateInvoices.php:151 matches /Paid/i
|
||||
/app/Commands/SendMonthlyPaymentNotifications.php:155 matches /balance\s*=/i
|
||||
/app/Commands/SendMonthlyPaymentNotifications.php:82 matches /balance\s*=/i
|
||||
/app/Commands/SendTestPaymentNotification.php:47 matches /balance\s*=/i
|
||||
/app/Config/Routes.php:1491 matches /Paid/i
|
||||
/app/Config/Routes.php:1491 matches /Unpaid/i
|
||||
/app/Config/Routes.php:647 matches /Paid/i
|
||||
/app/Config/Routes.php:647 matches /Unpaid/i
|
||||
/app/Config/Routes.php:648 matches /Paid/i
|
||||
/app/Config/Routes.php:648 matches /Unpaid/i
|
||||
/app/Config/Routes.php:649 matches /Paid/i
|
||||
/app/Config/Routes.php:649 matches /Unpaid/i
|
||||
/app/Controllers/View/AdministratorController.php:2189 matches /Paid/i
|
||||
/app/Controllers/View/AdministratorController.php:2190 matches /Paid/i
|
||||
/app/Controllers/View/AdministratorController.php:2191 matches /balance\s*=/i
|
||||
/app/Controllers/View/AdministratorController.php:2214 matches /Paid/i
|
||||
/app/Controllers/View/AdministratorController.php:2861 matches /Paid/i
|
||||
/app/Controllers/View/AdministratorController.php:2863 matches /Paid/i
|
||||
/app/Controllers/View/AdministratorController.php:2864 matches /Paid/i
|
||||
/app/Controllers/View/AdministratorController.php:2875 matches /Paid/i
|
||||
/app/Controllers/View/AdministratorController.php:2878 matches /Paid/i
|
||||
/app/Controllers/View/AdministratorController.php:2900 matches /Paid/i
|
||||
/app/Controllers/View/AdministratorController.php:2900 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/AssignmentController.php:156 matches /Paid/i
|
||||
/app/Controllers/View/AssignmentController.php:391 matches /Paid/i
|
||||
/app/Controllers/View/DiscountController.php:164 matches /balance\s*=/i
|
||||
/app/Controllers/View/DiscountController.php:173 matches /balance\s*=/i
|
||||
/app/Controllers/View/DiscountController.php:223 matches /balance\s*=/i
|
||||
/app/Controllers/View/DiscountController.php:262 matches /balance\s*=/i
|
||||
/app/Controllers/View/DiscountController.php:263 matches /balance\s*=/i
|
||||
/app/Controllers/View/DiscountController.php:356 matches /Paid/i
|
||||
/app/Controllers/View/DiscountController.php:373 matches /Paid/i
|
||||
/app/Controllers/View/DiscountController.php:375 matches /Paid/i
|
||||
/app/Controllers/View/DiscountController.php:410 matches /Paid/i
|
||||
/app/Controllers/View/DiscountController.php:421 matches /balance\s*=/i
|
||||
/app/Controllers/View/DiscountController.php:430 matches /Paid/i
|
||||
/app/Controllers/View/DiscountController.php:436 matches /Paid/i
|
||||
/app/Controllers/View/DiscountController.php:445 matches /Paid/i
|
||||
/app/Controllers/View/DiscountController.php:447 matches /Paid/i
|
||||
/app/Controllers/View/DiscountController.php:470 matches /Paid/i
|
||||
/app/Controllers/View/DiscountController.php:471 matches /Paid/i
|
||||
/app/Controllers/View/DiscountController.php:519 matches /Paid/i
|
||||
/app/Controllers/View/DiscountController.php:653 matches /Paid/i
|
||||
/app/Controllers/View/EventController.php:1063 matches /Paid/i
|
||||
/app/Controllers/View/EventController.php:1102 matches /Paid/i
|
||||
/app/Controllers/View/EventController.php:1113 matches /Paid/i
|
||||
/app/Controllers/View/EventController.php:1114 matches /Paid/i
|
||||
/app/Controllers/View/EventController.php:1114 matches /Unpaid/i
|
||||
/app/Controllers/View/EventController.php:1122 matches /Paid/i
|
||||
/app/Controllers/View/EventController.php:1128 matches /Paid/i
|
||||
/app/Controllers/View/EventController.php:1206 matches /Paid/i
|
||||
/app/Controllers/View/EventController.php:805 matches /Paid/i
|
||||
/app/Controllers/View/EventController.php:829 matches /Paid/i
|
||||
/app/Controllers/View/EventController.php:889 matches /Paid/i
|
||||
/app/Controllers/View/EventController.php:910 matches /Paid/i
|
||||
/app/Controllers/View/EventController.php:963 matches /Paid/i
|
||||
/app/Controllers/View/EventController.php:964 matches /Paid/i
|
||||
/app/Controllers/View/ExtraChargesController.php:280 matches /balance\s*=/i
|
||||
/app/Controllers/View/ExtraChargesController.php:397 matches /balance\s*=/i
|
||||
/app/Controllers/View/ExtraChargesController.php:398 matches /balance\s*=/i
|
||||
/app/Controllers/View/FamilyAdminController.php:125 matches /Paid/i
|
||||
/app/Controllers/View/FamilyAdminController.php:132 matches /Paid/i
|
||||
/app/Controllers/View/FamilyAdminController.php:148 matches /Paid/i
|
||||
/app/Controllers/View/FamilyAdminController.php:154 matches /Paid/i
|
||||
/app/Controllers/View/FamilyAdminController.php:351 matches /Paid/i
|
||||
/app/Controllers/View/FamilyAdminController.php:381 matches /Paid/i
|
||||
/app/Controllers/View/FamilyAdminController.php:395 matches /Paid/i
|
||||
/app/Controllers/View/FamilyAdminController.php:401 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1016 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1019 matches /balance\s*=/i
|
||||
/app/Controllers/View/FinancialController.php:1029 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1035 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1077 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1130 matches /balance\s*=/i
|
||||
/app/Controllers/View/FinancialController.php:1161 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1166 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1166 matches /SUM\(paid_amount\)/i
|
||||
/app/Controllers/View/FinancialController.php:1175 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1183 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1194 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1273 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1273 matches /SUM\(paid_amount\)/i
|
||||
/app/Controllers/View/FinancialController.php:1282 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1294 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1294 matches /SUM\(paid_amount\)/i
|
||||
/app/Controllers/View/FinancialController.php:1312 matches /SUM\(amount\)/i
|
||||
/app/Controllers/View/FinancialController.php:1326 matches /SUM\(amount\)/i
|
||||
/app/Controllers/View/FinancialController.php:1341 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1353 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1355 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1359 matches /balance\s*=/i
|
||||
/app/Controllers/View/FinancialController.php:1364 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1373 matches /balance\s*=/i
|
||||
/app/Controllers/View/FinancialController.php:1381 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1392 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1405 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1474 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1475 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1475 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:1513 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1513 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:1519 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1519 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:1618 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1623 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1625 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:169 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:169 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:1815 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1815 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:1816 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1823 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1825 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1826 matches /balance\s*=/i
|
||||
/app/Controllers/View/FinancialController.php:1829 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1829 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:1831 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1842 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1850 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1850 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:1853 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1864 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1869 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1875 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1875 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:1889 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1891 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1891 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:1892 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1923 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1923 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:1967 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1970 matches /balance\s*=/i
|
||||
/app/Controllers/View/FinancialController.php:1980 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:1986 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:2025 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:2072 matches /balance\s*=/i
|
||||
/app/Controllers/View/FinancialController.php:2106 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:2111 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:2111 matches /SUM\(paid_amount\)/i
|
||||
/app/Controllers/View/FinancialController.php:2120 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:2129 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:2142 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:2161 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:2161 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:242 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:247 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:266 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:266 matches /SUM\(paid_amount\)/i
|
||||
/app/Controllers/View/FinancialController.php:287 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:287 matches /SUM\(paid_amount\)/i
|
||||
/app/Controllers/View/FinancialController.php:288 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:289 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:290 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:317 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:333 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:335 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:344 matches /SUM\(amount\)/i
|
||||
/app/Controllers/View/FinancialController.php:350 matches /SUM\(amount\)/i
|
||||
/app/Controllers/View/FinancialController.php:383 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:39 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:41 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:44 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:44 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:462 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:463 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:464 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:464 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:465 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:465 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:478 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:478 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:479 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:487 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:495 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:539 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:540 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:541 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:541 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:542 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:542 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:559 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:559 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:560 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:572 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:572 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:632 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:655 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:657 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:658 matches /balance\s*=/i
|
||||
/app/Controllers/View/FinancialController.php:667 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:696 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:714 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:727 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:727 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/FinancialController.php:731 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:742 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:742 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/FinancialController.php:752 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:753 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:770 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:777 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:983 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:983 matches /Unpaid/i
|
||||
/app/Controllers/View/FinancialController.php:992 matches /Paid/i
|
||||
/app/Controllers/View/FinancialController.php:992 matches /Unpaid/i
|
||||
/app/Controllers/View/InvoiceController.php:1134 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1161 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1162 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1222 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1223 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1224 matches /balance\s*=/i
|
||||
/app/Controllers/View/InvoiceController.php:1253 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1258 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1267 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1269 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:139 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1407 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:141 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:141 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/InvoiceController.php:1412 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1412 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/InvoiceController.php:1414 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1420 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1420 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/InvoiceController.php:1431 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:144 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1440 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1478 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1478 matches /Unpaid/i
|
||||
/app/Controllers/View/InvoiceController.php:148 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:148 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/InvoiceController.php:1535 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1535 matches /Unpaid/i
|
||||
/app/Controllers/View/InvoiceController.php:1536 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1536 matches /Unpaid/i
|
||||
/app/Controllers/View/InvoiceController.php:1538 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1538 matches /Unpaid/i
|
||||
/app/Controllers/View/InvoiceController.php:1539 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:1539 matches /Unpaid/i
|
||||
/app/Controllers/View/InvoiceController.php:293 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:295 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:295 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/InvoiceController.php:298 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:300 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:300 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/InvoiceController.php:525 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:923 matches /Paid/i
|
||||
/app/Controllers/View/InvoiceController.php:993 matches /Paid/i
|
||||
/app/Controllers/View/LandingPageController.php:869 matches /balance\s*=/i
|
||||
/app/Controllers/View/ParentController.php:1411 matches /Paid/i
|
||||
/app/Controllers/View/ParentController.php:1970 matches /Paid/i
|
||||
/app/Controllers/View/ParentController.php:545 matches /Paid/i
|
||||
/app/Controllers/View/ParentController.php:545 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/ParentController.php:747 matches /Paid/i
|
||||
/app/Controllers/View/ParentController.php:750 matches /Paid/i
|
||||
/app/Controllers/View/ParentController.php:756 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:1014 matches /balance\s*=/i
|
||||
/app/Controllers/View/PaymentController.php:1017 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:1128 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:1132 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:1168 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:1171 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:1190 matches /balance\s*=/i
|
||||
/app/Controllers/View/PaymentController.php:1192 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:1196 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:1201 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:1205 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:1317 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:1317 matches /balance\s*=/i
|
||||
/app/Controllers/View/PaymentController.php:1335 matches /balance\s*=/i
|
||||
/app/Controllers/View/PaymentController.php:1341 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:1377 matches /balance\s*=/i
|
||||
/app/Controllers/View/PaymentController.php:1434 matches /balance\s*=/i
|
||||
/app/Controllers/View/PaymentController.php:1442 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:1494 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:163 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:164 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:174 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:175 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:360 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:388 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:390 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:481 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:503 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:508 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:526 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:528 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:532 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:552 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:553 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:602 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:83 matches /Paid/i
|
||||
/app/Controllers/View/PaymentController.php:884 matches /balance\s*=/i
|
||||
/app/Controllers/View/PaymentController.php:925 matches /balance\s*=/i
|
||||
/app/Controllers/View/PaymentNotificationController.php:101 matches /balance\s*=/i
|
||||
/app/Controllers/View/PaymentNotificationController.php:200 matches /Paid/i
|
||||
/app/Controllers/View/PaymentNotificationController.php:200 matches /Unpaid/i
|
||||
/app/Controllers/View/PaymentNotificationController.php:253 matches /Paid/i
|
||||
/app/Controllers/View/PaymentNotificationController.php:253 matches /Unpaid/i
|
||||
/app/Controllers/View/PaymentNotificationController.php:333 matches /Paid/i
|
||||
/app/Controllers/View/PaymentNotificationController.php:333 matches /Unpaid/i
|
||||
/app/Controllers/View/PaymentNotificationController.php:93 matches /balance\s*=/i
|
||||
/app/Controllers/View/RefundController.php:1007 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:1014 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:1015 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:1028 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:1028 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/RefundController.php:1214 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:164 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:168 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:178 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:178 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/RefundController.php:206 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:237 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:250 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:252 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:253 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:268 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:271 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:395 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:395 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/RefundController.php:542 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:553 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:560 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:596 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:600 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:618 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:625 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:626 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:628 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:630 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:631 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:636 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:660 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:664 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:698 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:698 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/RefundController.php:858 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:862 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:865 matches /Paid/i
|
||||
/app/Controllers/View/RefundController.php:865 matches /refund_paid_amount/i
|
||||
/app/Controllers/View/RefundController.php:867 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:1204 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:1288 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:2038 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:2058 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:2111 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:2112 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:2132 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:2133 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:2210 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:2211 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:2259 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:2325 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:2348 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:2350 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:935 matches /Paid/i
|
||||
/app/Controllers/View/ReimbursementController.php:951 matches /Paid/i
|
||||
/app/Controllers/View/StudentController.php:1449 matches /Paid/i
|
||||
/app/Controllers/View/StudentController.php:1484 matches /Paid/i
|
||||
/app/Controllers/View/StudentController.php:1548 matches /Paid/i
|
||||
/app/Controllers/View/TuitionForecastController.php:134 matches /Paid/i
|
||||
/app/Libraries/RefundEligibilityService.php:110 matches /Paid/i
|
||||
/app/Libraries/RefundEligibilityService.php:110 matches /refund_paid_amount/i
|
||||
/app/Libraries/RefundEligibilityService.php:156 matches /Paid/i
|
||||
/app/Libraries/RefundEligibilityService.php:156 matches /refund_paid_amount/i
|
||||
/app/Libraries/RefundEligibilityService.php:158 matches /Paid/i
|
||||
/app/Libraries/RefundEligibilityService.php:158 matches /refund_paid_amount/i
|
||||
/app/Libraries/RefundEligibilityService.php:264 matches /Paid/i
|
||||
/app/Libraries/RefundEligibilityService.php:264 matches /refund_paid_amount/i
|
||||
/app/Libraries/RefundEligibilityService.php:266 matches /Paid/i
|
||||
/app/Libraries/RefundEligibilityService.php:269 matches /Paid/i
|
||||
/app/Libraries/RefundEligibilityService.php:275 matches /Paid/i
|
||||
/app/Libraries/RefundEligibilityService.php:275 matches /refund_paid_amount/i
|
||||
/app/Libraries/RefundEligibilityService.php:278 matches /Paid/i
|
||||
/app/Libraries/RefundEligibilityService.php:289 matches /Paid/i
|
||||
/app/Libraries/RefundEligibilityService.php:290 matches /Paid/i
|
||||
/app/Libraries/Tuition/TuitionForecastService.php:236 matches /Paid/i
|
||||
/app/Libraries/Tuition/TuitionForecastService.php:467 matches /Paid/i
|
||||
/app/Libraries/Tuition/TuitionForecastService.php:467 matches /SUM\(paid_amount\)/i
|
||||
/app/Libraries/Tuition/TuitionForecastService.php:490 matches /Paid/i
|
||||
/app/Libraries/Tuition/TuitionForecastService.php:493 matches /Paid/i
|
||||
/app/Libraries/Tuition/TuitionForecastService.php:493 matches /refund_paid_amount/i
|
||||
/app/Libraries/Tuition/TuitionForecastService.php:505 matches /Paid/i
|
||||
/app/Listeners/SchoolEventListener.php:638 matches /Paid/i
|
||||
/app/Listeners/SchoolEventListener.php:639 matches /balance\s*=/i
|
||||
/app/Listeners/SchoolEventListener.php:640 matches /balance\s*=/i
|
||||
/app/Listeners/SchoolEventListener.php:662 matches /Paid/i
|
||||
/app/Listeners/SchoolEventListener.php:776 matches /balance\s*=/i
|
||||
/app/Listeners/SchoolEventListener.php:777 matches /balance\s*=/i
|
||||
/app/Models/EventChargesModel.php:22 matches /Paid/i
|
||||
/app/Models/InvoiceModel.php:156 matches /Paid/i
|
||||
/app/Models/InvoiceModel.php:156 matches /Unpaid/i
|
||||
/app/Models/InvoiceModel.php:158 matches /Paid/i
|
||||
/app/Models/InvoiceModel.php:158 matches /Partially Paid/i
|
||||
/app/Models/InvoiceModel.php:158 matches /Unpaid/i
|
||||
/app/Models/InvoiceModel.php:172 matches /Paid/i
|
||||
/app/Models/InvoiceModel.php:176 matches /Paid/i
|
||||
/app/Models/InvoiceModel.php:178 matches /Paid/i
|
||||
/app/Models/InvoiceModel.php:217 matches /Paid/i
|
||||
/app/Models/InvoiceModel.php:23 matches /Paid/i
|
||||
/app/Models/InvoiceModel.php:264 matches /balance\s*=/i
|
||||
/app/Models/InvoiceModel.php:276 matches /balance\s*=/i
|
||||
/app/Models/PaymentErrorModel.php:19 matches /Paid/i
|
||||
/app/Models/PaymentErrorModel.php:31 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:112 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:113 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:114 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:115 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:225 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:255 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:259 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:262 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:270 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:271 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:281 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:283 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:293 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:30 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:300 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:377 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:386 matches /Paid/i
|
||||
/app/Models/PaymentModel.php:70 matches /Paid/i
|
||||
/app/Models/PaymentTransactionModel.php:102 matches /Paid/i
|
||||
/app/Models/PaymentTransactionModel.php:107 matches /Paid/i
|
||||
/app/Models/RefundModel.php:31 matches /Paid/i
|
||||
/app/Models/RefundModel.php:31 matches /refund_paid_amount/i
|
||||
/app/Models/RefundModel.php:62 matches /Paid/i
|
||||
/app/Models/RefundModel.php:62 matches /Partially Paid/i
|
||||
/app/Models/RefundModel.php:63 matches /Paid/i
|
||||
/app/Models/RefundModel.php:63 matches /refund_paid_amount/i
|
||||
/app/Models/RefundModel.php:66 matches /Paid/i
|
||||
/app/Models/RefundModel.php:70 matches /Paid/i
|
||||
/app/Models/ReimbursementModel.php:34 matches /Paid/i
|
||||
/app/Models/StudentModel.php:23 matches /Paid/i
|
||||
/app/Services/FeeCalculationService.php:107 matches /Paid/i
|
||||
/app/Services/FeeCalculationService.php:108 matches /Paid/i
|
||||
/app/Services/FeeCalculationService.php:109 matches /Paid/i
|
||||
/app/Services/FeeCalculationService.php:22 matches /Paid/i
|
||||
/app/Services/FeeCalculationService.php:24 matches /Paid/i
|
||||
/app/Services/SchoolYearClosingService.php:347 matches /Paid/i
|
||||
/app/Services/SchoolYearClosingService.php:360 matches /Paid/i
|
||||
/app/Services/SchoolYearClosingService.php:360 matches /SUM\(paid_amount\)/i
|
||||
/app/Services/SchoolYearClosingService.php:375 matches /Paid/i
|
||||
/app/Services/SchoolYearClosingService.php:375 matches /Unpaid/i
|
||||
/app/Services/SchoolYearClosingService.php:384 matches /Paid/i
|
||||
/app/Services/SchoolYearClosingService.php:384 matches /Partially Paid/i
|
||||
/app/Services/SchoolYearClosingService.php:384 matches /Unpaid/i
|
||||
/app/Services/SchoolYearClosingService.php:403 matches /Paid/i
|
||||
/app/Services/SchoolYearClosingService.php:403 matches /Partially Paid/i
|
||||
/app/Services/SchoolYearClosingService.php:403 matches /Unpaid/i
|
||||
/app/Services/SchoolYearClosingService.php:41 matches /Paid/i
|
||||
/app/Services/SchoolYearClosingService.php:421 matches /balance\s*=/i
|
||||
/app/Services/SchoolYearClosingService.php:52 matches /Paid/i
|
||||
/app/Services/SchoolYearClosingService.php:52 matches /Unpaid/i
|
||||
/app/Services/SchoolYearClosingService.php:533 matches /Paid/i
|
||||
/app/Services/SchoolYearClosingService.php:539 matches /Paid/i
|
||||
/app/Services/SchoolYearClosingService.php:539 matches /Unpaid/i
|
||||
/app/Views/administrator/class_assignment.php:62 matches /Paid/i
|
||||
/app/Views/administrator/class_assignment.php:91 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges.php:291 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges.php:300 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges.php:374 matches /balance\s*=/i
|
||||
/app/Views/administrator/events/event_charges.php:376 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges.php:391 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges.php:391 matches /Unpaid/i
|
||||
/app/Views/administrator/events/event_charges.php:392 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges.php:393 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges.php:395 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges.php:395 matches /Unpaid/i
|
||||
/app/Views/administrator/events/event_charges.php:398 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges.php:398 matches /Unpaid/i
|
||||
/app/Views/administrator/events/event_charges.php:402 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges.php:404 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges.php:405 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges.php:406 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges.php:641 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:123 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:131 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:134 matches /balance\s*=/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:136 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:183 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:186 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:186 matches /Unpaid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:187 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:187 matches /Unpaid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:189 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:189 matches /Unpaid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:190 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:190 matches /Unpaid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:71 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:76 matches /Paid/i
|
||||
/app/Views/administrator/events/event_charges_pdf.php:76 matches /Unpaid/i
|
||||
/app/Views/administrator/fee_collection.php:21 matches /Paid/i
|
||||
/app/Views/administrator/fee_collection.php:23 matches /Paid/i
|
||||
/app/Views/administrator/fee_payment_records.php:21 matches /Paid/i
|
||||
/app/Views/administrator/fee_payment_records.php:23 matches /Paid/i
|
||||
/app/Views/administrator/payroll_management.php:21 matches /Paid/i
|
||||
/app/Views/administrator/payroll_management.php:22 matches /Paid/i
|
||||
/app/Views/administrator/student_profiles.php:363 matches /Paid/i
|
||||
/app/Views/administrator/student_profiles.php:365 matches /Paid/i
|
||||
/app/Views/administrator/student_profiles.php:366 matches /Paid/i
|
||||
/app/Views/administrator/student_profiles.php:368 matches /Paid/i
|
||||
/app/Views/administrator/student_profiles.php:369 matches /Paid/i
|
||||
/app/Views/administrator/tuition_forecast.php:31 matches /Paid/i
|
||||
/app/Views/emails/payment_receipt.php:38 matches /Paid/i
|
||||
/app/Views/emails/status_payment_pending.php:86 matches /Paid/i
|
||||
/app/Views/family/card.php:302 matches /Paid/i
|
||||
/app/Views/family/card.php:303 matches /Paid/i
|
||||
/app/Views/family/card.php:324 matches /Paid/i
|
||||
/app/Views/family/card.php:333 matches /Paid/i
|
||||
/app/Views/family/card.php:361 matches /Paid/i
|
||||
/app/Views/family/card.php:6 matches /Paid/i
|
||||
/app/Views/family/index.php:237 matches /Paid/i
|
||||
/app/Views/family/index.php:246 matches /Paid/i
|
||||
/app/Views/family/index.php:274 matches /Paid/i
|
||||
/app/Views/family/index.php:75 matches /Paid/i
|
||||
/app/Views/parent/event_participation.php:70 matches /Paid/i
|
||||
/app/Views/parent/event_participation.php:80 matches /Paid/i
|
||||
/app/Views/parent/event_participation.php:81 matches /Paid/i
|
||||
/app/Views/parent/event_participation.php:81 matches /Unpaid/i
|
||||
/app/Views/parent/invoice_payment.php:123 matches /Paid/i
|
||||
/app/Views/parent/invoice_payment.php:167 matches /Paid/i
|
||||
/app/Views/parent/invoice_payment.php:168 matches /Paid/i
|
||||
/app/Views/parent/invoice_payment.php:176 matches /Paid/i
|
||||
/app/Views/parent/invoice_payment.php:176 matches /Unpaid/i
|
||||
/app/Views/parent/payment_success.php:12 matches /Paid/i
|
||||
/app/Views/parent/payment_success.php:8 matches /Paid/i
|
||||
/app/Views/parent/payment_view.php:19 matches /Paid/i
|
||||
/app/Views/parent/payment_view.php:20 matches /Paid/i
|
||||
/app/Views/partials/school_year_selector.php:10 matches /Paid/i
|
||||
/app/Views/partials/school_year_selector.php:10 matches /Unpaid/i
|
||||
/app/Views/payment/_list_modal.php:23 matches /Paid/i
|
||||
/app/Views/payment/_list_modal.php:44 matches /Paid/i
|
||||
/app/Views/payment/extra_charges.php:179 matches /Paid/i
|
||||
/app/Views/payment/extra_charges.php:179 matches /Unpaid/i
|
||||
/app/Views/payment/financial_report.php:113 matches /Paid/i
|
||||
/app/Views/payment/financial_report.php:349 matches /Paid/i
|
||||
/app/Views/payment/financial_report.php:362 matches /Paid/i
|
||||
/app/Views/payment/financial_report.php:370 matches /balance\s*=/i
|
||||
/app/Views/payment/financial_report.php:371 matches /Paid/i
|
||||
/app/Views/payment/financial_report.php:371 matches /Unpaid/i
|
||||
/app/Views/payment/financial_report.php:373 matches /Paid/i
|
||||
/app/Views/payment/financial_report.php:374 matches /Paid/i
|
||||
/app/Views/payment/financial_report.php:385 matches /Paid/i
|
||||
/app/Views/payment/financial_report.php:63 matches /Paid/i
|
||||
/app/Views/payment/financial_report.php:80 matches /Paid/i
|
||||
/app/Views/payment/financial_report.php:92 matches /balance\s*=/i
|
||||
/app/Views/payment/financial_report.php:93 matches /Paid/i
|
||||
/app/Views/payment/financial_report.php:93 matches /Unpaid/i
|
||||
/app/Views/payment/financial_report.php:94 matches /Paid/i
|
||||
/app/Views/payment/financial_report.php:96 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:107 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:109 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:109 matches /Unpaid/i
|
||||
/app/Views/payment/financial_report_summary.php:110 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:124 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:125 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:125 matches /Unpaid/i
|
||||
/app/Views/payment/financial_report_summary.php:126 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:128 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:128 matches /Unpaid/i
|
||||
/app/Views/payment/financial_report_summary.php:159 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:160 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:161 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:161 matches /Unpaid/i
|
||||
/app/Views/payment/financial_report_summary.php:255 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:255 matches /Unpaid/i
|
||||
/app/Views/payment/financial_report_summary.php:264 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:264 matches /Unpaid/i
|
||||
/app/Views/payment/financial_report_summary.php:266 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:47 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:48 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:49 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:49 matches /Unpaid/i
|
||||
/app/Views/payment/financial_report_summary.php:50 matches /Paid/i
|
||||
/app/Views/payment/financial_report_summary.php:50 matches /Unpaid/i
|
||||
/app/Views/payment/manual_pay.php:1119 matches /balance\s*=/i
|
||||
/app/Views/payment/manual_pay.php:226 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:227 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:245 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:246 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:247 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:250 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:251 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:311 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:312 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:358 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:371 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:373 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:421 matches /balance\s*=/i
|
||||
/app/Views/payment/manual_pay.php:427 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:432 matches /Paid/i
|
||||
/app/Views/payment/manual_pay.php:602 matches /balance\s*=/i
|
||||
/app/Views/payment/manual_pay.php:916 matches /balance\s*=/i
|
||||
/app/Views/payment/manual_payment.php:137 matches /Paid/i
|
||||
/app/Views/payment/manual_payment.php:138 matches /Paid/i
|
||||
/app/Views/payment/manual_payment.php:188 matches /Paid/i
|
||||
/app/Views/payment/manual_payment.php:205 matches /Paid/i
|
||||
/app/Views/payment/manual_payment.php:253 matches /balance\s*=/i
|
||||
/app/Views/payment/manual_payment.php:256 matches /Paid/i
|
||||
/app/Views/payment/manual_payment.php:384 matches /balance\s*=/i
|
||||
/app/Views/payment/manual_payment.php:418 matches /balance\s*=/i
|
||||
/app/Views/payment/manual_payment.php:58 matches /Paid/i
|
||||
/app/Views/payment/manual_payment.php:59 matches /Paid/i
|
||||
/app/Views/payment/manual_payment.php:73 matches /Paid/i
|
||||
/app/Views/payment/unpaid_parents.php:104 matches /Paid/i
|
||||
/app/Views/payment/unpaid_parents.php:122 matches /Paid/i
|
||||
/app/Views/payment/unpaid_parents.php:122 matches /Unpaid/i
|
||||
/app/Views/payment/unpaid_parents.php:137 matches /Paid/i
|
||||
/app/Views/payment/unpaid_parents.php:159 matches /Paid/i
|
||||
/app/Views/payment/unpaid_parents.php:159 matches /Unpaid/i
|
||||
/app/Views/payment/unpaid_parents.php:4 matches /Paid/i
|
||||
/app/Views/payment/unpaid_parents.php:44 matches /Paid/i
|
||||
/app/Views/payment/unpaid_parents.php:44 matches /Unpaid/i
|
||||
/app/Views/payment/unpaid_parents.php:50 matches /Paid/i
|
||||
/app/Views/payment/unpaid_parents.php:50 matches /Unpaid/i
|
||||
/app/Views/payment/unpaid_parents.php:59 matches /Paid/i
|
||||
/app/Views/payment/unpaid_parents.php:74 matches /Paid/i
|
||||
/app/Views/payment/unpaid_parents.php:97 matches /Paid/i
|
||||
/app/Views/payment_list.php:21 matches /Paid/i
|
||||
/app/Views/payment_list.php:39 matches /Paid/i
|
||||
/app/Views/refunds/list.php:123 matches /Paid/i
|
||||
/app/Views/refunds/list.php:123 matches /refund_paid_amount/i
|
||||
/app/Views/refunds/list.php:191 matches /Paid/i
|
||||
/app/Views/refunds/list.php:192 matches /Paid/i
|
||||
/app/Views/refunds/list.php:321 matches /Paid/i
|
||||
/app/Views/refunds/list.php:71 matches /Paid/i
|
||||
/app/Views/reimbursements/index.php:13 matches /Paid/i
|
||||
/app/Views/school_years/closing_preview.php:168 matches /Paid/i
|
||||
/app/Views/school_years/closing_preview.php:169 matches /Paid/i
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Config;
|
||||
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class FinancialRouteIntegrityTest extends CIUnitTestCase
|
||||
{
|
||||
private string $routesFile;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->routesFile = ROOTPATH . 'app/Config/Routes.php';
|
||||
}
|
||||
|
||||
public function testFinancialBrowserWriteRoutesArePostOnlyAndFiltered(): void
|
||||
{
|
||||
$routes = file($this->routesFile, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
$financialWrites = array_values(array_filter($routes, static function (string $line): bool {
|
||||
return str_contains($line, '$routes->post(')
|
||||
&& preg_match('/(refunds|expenses|reimbursements|discount|charges|payment|invoice|purchase|inventory)/i', $line);
|
||||
}));
|
||||
|
||||
$this->assertNotEmpty($financialWrites);
|
||||
foreach ($financialWrites as $line) {
|
||||
$this->assertStringContainsString("'filter'", $line, $line);
|
||||
$this->assertStringContainsString('auth:', $line, $line);
|
||||
}
|
||||
}
|
||||
|
||||
public function testFinancialStateChangingRoutesDoNotUseGet(): void
|
||||
{
|
||||
$routes = file($this->routesFile, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
foreach ($routes as $line) {
|
||||
if (!str_contains($line, '$routes->get(')) {
|
||||
continue;
|
||||
}
|
||||
if (! preg_match('/\$routes->get\(\s*[\'"]([^\'"]+)/', $line, $matches)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$routePath = $matches[1];
|
||||
if (!preg_match('/(refunds|expenses|reimbursements|discount|charges|payment|invoice|purchase|inventory)/i', $routePath)) {
|
||||
continue;
|
||||
}
|
||||
$this->assertDoesNotMatchRegularExpression('/(update|store|approve|reverse|void|delete|apply|recalculate)/i', $routePath, $line);
|
||||
}
|
||||
}
|
||||
|
||||
public function testFinancialWritesDoNotUseGetPostMatchRoutes(): void
|
||||
{
|
||||
$routes = file($this->routesFile, FILE_IGNORE_NEW_LINES) ?: [];
|
||||
$checked = 0;
|
||||
foreach ($routes as $line) {
|
||||
if (!str_contains($line, '$routes->match(') || !str_contains($line, "'get'") || !str_contains($line, "'post'")) {
|
||||
continue;
|
||||
}
|
||||
if (!preg_match('/(refunds|expenses|reimbursements|discount|charges|payment|invoice|purchase|inventory)/i', $line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$checked++;
|
||||
$this->fail('Financial routes must split read GET and write POST handlers: ' . $line);
|
||||
}
|
||||
|
||||
$this->assertSame(0, $checked);
|
||||
}
|
||||
|
||||
public function testGenericApiDoesNotExposeFinancialMutations(): void
|
||||
{
|
||||
$text = file_get_contents($this->routesFile) ?: '';
|
||||
$forbidden = [
|
||||
"post('payments'",
|
||||
"put('payments/",
|
||||
"post('payment-transactions'",
|
||||
"get('payment-transactions'",
|
||||
"post('payment-notifications/send'",
|
||||
"post('expenses'",
|
||||
"put('expenses/",
|
||||
"post('reimbursements'",
|
||||
"put('reimbursements/",
|
||||
"post('refunds'",
|
||||
"put('refunds/",
|
||||
"post('discounts/apply'",
|
||||
"post('extra-charges'",
|
||||
"put('extra-charges/",
|
||||
"post('purchase-orders'",
|
||||
"put('purchase-orders/",
|
||||
"post('inventory'",
|
||||
"put('inventory/",
|
||||
"delete('inventory/",
|
||||
];
|
||||
|
||||
$apiV1Start = strpos($text, "\$routes->group('api/v1'");
|
||||
$this->assertIsInt($apiV1Start);
|
||||
$apiV1Text = substr($text, $apiV1Start);
|
||||
foreach ($forbidden as $needle) {
|
||||
$this->assertStringNotContainsString($needle, $apiV1Text, $needle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Controllers\View;
|
||||
|
||||
use App\Controllers\View\RefundController;
|
||||
use App\Libraries\RefundEligibilityService;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class RefundRequestStub
|
||||
{
|
||||
public function __construct(private array $post = [])
|
||||
{
|
||||
}
|
||||
|
||||
public function getPost(?string $key = null)
|
||||
{
|
||||
if ($key === null) {
|
||||
return $this->post;
|
||||
}
|
||||
|
||||
return $this->post[$key] ?? null;
|
||||
}
|
||||
|
||||
public function getFile(string $name)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class TestableRefundController extends RefundController
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function setRequestObject($request): self
|
||||
{
|
||||
$this->request = $request;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setResponseObject($response): self
|
||||
{
|
||||
$this->response = $response;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
class RefundEligibilityForControllerHarness extends RefundEligibilityService
|
||||
{
|
||||
public function __construct(private int $completedPayoutCents)
|
||||
{
|
||||
}
|
||||
|
||||
public function getCompletedPayoutTotalCentsForRefund(int $refundId): int
|
||||
{
|
||||
return $this->completedPayoutCents;
|
||||
}
|
||||
}
|
||||
|
||||
class RefundControllerRegressionTest extends CIUnitTestCase
|
||||
{
|
||||
public function testRequestRefundReadsRequestTypeFromPostBody(): void
|
||||
{
|
||||
$controller = $this->controllerWithPost([
|
||||
'parent_id' => '123',
|
||||
'amount' => '25.00',
|
||||
'request_type' => 'unsupported',
|
||||
]);
|
||||
|
||||
$response = $controller->requestRefund();
|
||||
|
||||
$this->assertSame(['error' => 'Invalid refund request type.'], $this->jsonResponse($response));
|
||||
}
|
||||
|
||||
public function testPayRefundRouteArgumentIsAcceptedBeforePostRefundId(): void
|
||||
{
|
||||
$controller = $this->controllerWithPost([
|
||||
'paid_amount' => '10.00',
|
||||
'payment_method' => 'Wire',
|
||||
]);
|
||||
|
||||
$response = $controller->payRefund(77);
|
||||
|
||||
$this->assertSame(['error' => 'Invalid refund payment method.'], $this->jsonResponse($response));
|
||||
}
|
||||
|
||||
public function testCheckRefundRequiresCheckNumberBeforeDatabaseWrite(): void
|
||||
{
|
||||
$controller = $this->controllerWithPost([
|
||||
'refund_id' => '77',
|
||||
'paid_amount' => '10.00',
|
||||
'payment_method' => 'Check',
|
||||
]);
|
||||
|
||||
$response = $controller->updatePayment();
|
||||
|
||||
$this->assertSame(['error' => 'Check number is required for check refunds.'], $this->jsonResponse($response));
|
||||
}
|
||||
|
||||
public function testUpdateStatusAcceptsRouteRefundId(): void
|
||||
{
|
||||
$controller = $this->controllerWithPost([
|
||||
'status' => 'Voided',
|
||||
'reason' => 'duplicate request',
|
||||
]);
|
||||
|
||||
$response = $controller->updateStatus(77);
|
||||
|
||||
$this->assertSame(['error' => 'Invalid status update request.'], $this->jsonResponse($response));
|
||||
}
|
||||
|
||||
public function testReversePayoutRequiresReasonBeforeDatabaseWrite(): void
|
||||
{
|
||||
$controller = $this->controllerWithPost([
|
||||
'idempotency_key' => 'reverse-77',
|
||||
]);
|
||||
|
||||
$response = $controller->reversePayout(77);
|
||||
|
||||
$this->assertSame(['error' => 'Reversal reason is required.'], $this->jsonResponse($response));
|
||||
}
|
||||
|
||||
public function testReversePayoutRequiresIdempotencyKeyBeforeDatabaseWrite(): void
|
||||
{
|
||||
$controller = $this->controllerWithPost([
|
||||
'reason' => 'wrong payout amount',
|
||||
]);
|
||||
|
||||
$response = $controller->reversePayout(77);
|
||||
|
||||
$this->assertSame(['error' => 'Missing reversal idempotency key.'], $this->jsonResponse($response));
|
||||
}
|
||||
|
||||
public function testReversePayoutRejectsNonPositiveAmountBeforeDatabaseWrite(): void
|
||||
{
|
||||
$controller = $this->controllerWithPost([
|
||||
'reason' => 'wrong payout amount',
|
||||
'idempotency_key' => 'reverse-77',
|
||||
'amount' => '0.00',
|
||||
]);
|
||||
|
||||
$response = $controller->reversePayout(77);
|
||||
|
||||
$this->assertSame(['error' => 'Reversal amount must be greater than zero.'], $this->jsonResponse($response));
|
||||
}
|
||||
|
||||
public function testApprovedRefundRecalculationCannotDropBelowCompletedPayouts(): void
|
||||
{
|
||||
$controller = $this->controllerWithPost([]);
|
||||
$property = (new \ReflectionClass(RefundController::class))->getProperty('refundEligibilityService');
|
||||
$property->setAccessible(true);
|
||||
$property->setValue($controller, new RefundEligibilityForControllerHarness(2500));
|
||||
|
||||
$method = (new \ReflectionClass(RefundController::class))->getMethod('buildRefundRecalculationUpdate');
|
||||
$method->setAccessible(true);
|
||||
$update = $method->invoke($controller, [
|
||||
'id' => 55,
|
||||
'status' => 'Approved',
|
||||
], 10.00);
|
||||
|
||||
$this->assertEqualsWithDelta(25.0, (float)$update['refund_amount'], 0.0001);
|
||||
$this->assertSame(2500, $update['approved_amount_cents']);
|
||||
$this->assertSame('requires_review', $update['reconciliation_status']);
|
||||
$this->assertStringContainsString('Completed payouts', $update['reconciliation_reason']);
|
||||
}
|
||||
|
||||
private function controllerWithPost(array $post): TestableRefundController
|
||||
{
|
||||
return (new TestableRefundController())
|
||||
->setRequestObject(new RefundRequestStub($post))
|
||||
->setResponseObject(service('response'));
|
||||
}
|
||||
|
||||
private function jsonResponse($response): array
|
||||
{
|
||||
return json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -68,11 +68,28 @@ class FinancialAttachmentServiceTest extends CIUnitTestCase
|
||||
|
||||
$file->expects($this->once())
|
||||
->method('move')
|
||||
->with($this->stringContains('writable/uploads/payments'), 'proof.pdf');
|
||||
->with($this->stringContains('writable/uploads/_tmp/payments'), $this->stringContains('proof.pdf'))
|
||||
->willReturnCallback(static function (string $dir, string $name): bool {
|
||||
file_put_contents($dir . DIRECTORY_SEPARATOR . $name, 'pdf');
|
||||
return true;
|
||||
});
|
||||
|
||||
$this->assertSame('proof.pdf', $this->service->saveUploadedFile($file, 'payments'));
|
||||
}
|
||||
|
||||
public function testDiscardStagedFileDeletesTemporaryUpload(): void
|
||||
{
|
||||
$tmpDir = $this->service->ensureSubdir('_tmp/checks');
|
||||
$tmpPath = $tmpDir . DIRECTORY_SEPARATOR . 'pending-test.pdf';
|
||||
file_put_contents($tmpPath, 'pdf');
|
||||
|
||||
$this->service->discardStagedFile([
|
||||
'temporary_path' => $tmpPath,
|
||||
]);
|
||||
|
||||
$this->assertFileDoesNotExist($tmpPath);
|
||||
}
|
||||
|
||||
public function testResolvePathUsesBasenameAndReturnsNullForMissingFiles(): void
|
||||
{
|
||||
$dir = $this->service->ensureSubdir('receipts');
|
||||
|
||||
@@ -10,11 +10,32 @@ class InvoiceLedgerServiceHarness extends InvoiceLedgerService
|
||||
{
|
||||
private array $invoice;
|
||||
private float $paidTotal;
|
||||
private float $refundPaidTotal;
|
||||
private float $tuitionTotal;
|
||||
private float $eventTotal;
|
||||
private float $additionalTotal;
|
||||
private float $discountTotal;
|
||||
private ?array $frozenTotals;
|
||||
|
||||
public function __construct(array $invoice, float $paidTotal = 0.0)
|
||||
public function __construct(
|
||||
array $invoice,
|
||||
float $paidTotal = 0.0,
|
||||
float $refundPaidTotal = 0.0,
|
||||
float $tuitionTotal = 0.0,
|
||||
float $eventTotal = 0.0,
|
||||
float $additionalTotal = 0.0,
|
||||
float $discountTotal = 0.0,
|
||||
?array $frozenTotals = null
|
||||
)
|
||||
{
|
||||
$this->invoice = $invoice;
|
||||
$this->paidTotal = $paidTotal;
|
||||
$this->refundPaidTotal = $refundPaidTotal;
|
||||
$this->tuitionTotal = $tuitionTotal;
|
||||
$this->eventTotal = $eventTotal;
|
||||
$this->additionalTotal = $additionalTotal;
|
||||
$this->discountTotal = $discountTotal;
|
||||
$this->frozenTotals = $frozenTotals;
|
||||
}
|
||||
|
||||
protected function loadInvoice(int $invoiceId): ?array
|
||||
@@ -24,22 +45,22 @@ class InvoiceLedgerServiceHarness extends InvoiceLedgerService
|
||||
|
||||
protected function calculateTuitionTotal(array $invoice): float
|
||||
{
|
||||
return 0.0;
|
||||
return $this->tuitionTotal;
|
||||
}
|
||||
|
||||
protected function calculateEventTotal(array $invoice): float
|
||||
{
|
||||
return 0.0;
|
||||
return $this->eventTotal;
|
||||
}
|
||||
|
||||
protected function calculateAdditionalCharges(int $invoiceId): float
|
||||
{
|
||||
return 0.0;
|
||||
return $this->additionalTotal;
|
||||
}
|
||||
|
||||
protected function calculateDiscounts(int $invoiceId): float
|
||||
{
|
||||
return 0.0;
|
||||
return $this->discountTotal;
|
||||
}
|
||||
|
||||
protected function calculateValidPayments(int $invoiceId): float
|
||||
@@ -49,12 +70,275 @@ class InvoiceLedgerServiceHarness extends InvoiceLedgerService
|
||||
|
||||
protected function calculatePaidRefunds(int $invoiceId): float
|
||||
{
|
||||
return 0.0;
|
||||
return $this->refundPaidTotal;
|
||||
}
|
||||
|
||||
protected function calculateFrozenLineTotals(int $invoiceId): ?array
|
||||
{
|
||||
return $this->frozenTotals;
|
||||
}
|
||||
}
|
||||
|
||||
class InvoiceLedgerServiceTest extends CIUnitTestCase
|
||||
{
|
||||
public function testInvoiceWithNoPaymentHasFullBalanceDue(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 20,
|
||||
'invoice_number' => 'INV-2026-00020',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 0.0, 0.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(20);
|
||||
|
||||
$this->assertSame('100.00', $calculation['total_amount']);
|
||||
$this->assertSame(10000, $calculation['totalAmountCents']);
|
||||
$this->assertSame(10000, $calculation['balanceDueCents']);
|
||||
$this->assertSame(0, $calculation['customerCreditCents']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_UNPAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testPartiallyPaidInvoiceHasRemainingBalance(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 21,
|
||||
'invoice_number' => 'INV-2026-00021',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 40.0, 0.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(21);
|
||||
|
||||
$this->assertSame('60.00', $calculation['balance']);
|
||||
$this->assertSame(6000, $calculation['rawBalanceCents']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PARTIALLY_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testFullyPaidInvoiceHasNoBalanceOrCredit(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 22,
|
||||
'invoice_number' => 'INV-2026-00022',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 100.0, 0.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(22);
|
||||
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame('0.00', $calculation['customer_credit']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testOverpaidInvoiceExposesCustomerCreditOnly(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 23,
|
||||
'invoice_number' => 'INV-2026-00023',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 125.0, 0.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(23);
|
||||
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame('25.00', $calculation['customer_credit']);
|
||||
$this->assertSame(-2500, $calculation['rawBalanceCents']);
|
||||
$this->assertSame(2500, $calculation['customerCreditCents']);
|
||||
}
|
||||
|
||||
public function testPartiallyRefundedInvoiceUsesRefundAsCashOut(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 24,
|
||||
'invoice_number' => 'INV-2026-00024',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 125.0, 10.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(24);
|
||||
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame('15.00', $calculation['customer_credit']);
|
||||
$this->assertSame(-1500, $calculation['rawBalanceCents']);
|
||||
}
|
||||
|
||||
public function testFullyRefundedOverpaymentClearsCustomerCredit(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 25,
|
||||
'invoice_number' => 'INV-2026-00025',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 125.0, 25.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(25);
|
||||
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame('0.00', $calculation['customer_credit']);
|
||||
$this->assertSame(0, $calculation['rawBalanceCents']);
|
||||
}
|
||||
|
||||
public function testPaymentAfterRefundCanRestorePaidStatus(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 26,
|
||||
'invoice_number' => 'INV-2026-00026',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
], 130.0, 30.0, 100.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(26);
|
||||
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame('0.00', $calculation['customer_credit']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testGeneratedTwiceWithSameSourceDataReturnsIdenticalLedger(): void
|
||||
{
|
||||
$invoice = [
|
||||
'id' => 27,
|
||||
'invoice_number' => 'INV-2026-00027',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
];
|
||||
$first = (new InvoiceLedgerServiceHarness($invoice, 55.0, 5.0, 100.0, 20.0, 10.0, 15.0))->calculateInvoice(27);
|
||||
$second = (new InvoiceLedgerServiceHarness($invoice, 55.0, 5.0, 100.0, 20.0, 10.0, 15.0))->calculateInvoice(27);
|
||||
|
||||
$this->assertSame($first, $second);
|
||||
}
|
||||
|
||||
public function testFrozenInvoiceLinesOverrideChangedLiveSources(): void
|
||||
{
|
||||
$invoice = [
|
||||
'id' => 28,
|
||||
'invoice_number' => 'INV-2026-00028',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
];
|
||||
|
||||
$service = new InvoiceLedgerServiceHarness(
|
||||
$invoice,
|
||||
paidTotal: 0.0,
|
||||
refundPaidTotal: 0.0,
|
||||
tuitionTotal: 999.0,
|
||||
eventTotal: 888.0,
|
||||
additionalTotal: 777.0,
|
||||
discountTotal: 0.0,
|
||||
frozenTotals: [
|
||||
'tuition_cents' => 10000,
|
||||
'event_cents' => 2000,
|
||||
'additional_cents' => 500,
|
||||
'total_cents' => 12500,
|
||||
]
|
||||
);
|
||||
|
||||
$calculation = $service->calculateInvoice(28);
|
||||
|
||||
$this->assertSame('100.00', $calculation['tuition_total']);
|
||||
$this->assertSame('20.00', $calculation['event_total']);
|
||||
$this->assertSame('5.00', $calculation['additional_total']);
|
||||
$this->assertSame('125.00', $calculation['total_amount']);
|
||||
$this->assertSame('125.00', $calculation['balance']);
|
||||
}
|
||||
|
||||
public function testFrozenInvoiceRecalculationIsStableAfterPayment(): void
|
||||
{
|
||||
$invoice = [
|
||||
'id' => 29,
|
||||
'invoice_number' => 'INV-2026-00029',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
];
|
||||
|
||||
$frozenTotals = [
|
||||
'tuition_cents' => 10000,
|
||||
'event_cents' => 0,
|
||||
'additional_cents' => 0,
|
||||
'total_cents' => 10000,
|
||||
];
|
||||
|
||||
$before = (new InvoiceLedgerServiceHarness(
|
||||
$invoice,
|
||||
paidTotal: 0.0,
|
||||
tuitionTotal: 400.0,
|
||||
frozenTotals: $frozenTotals
|
||||
))->calculateInvoice(29);
|
||||
|
||||
$after = (new InvoiceLedgerServiceHarness(
|
||||
$invoice,
|
||||
paidTotal: 25.0,
|
||||
tuitionTotal: 600.0,
|
||||
frozenTotals: $frozenTotals
|
||||
))->calculateInvoice(29);
|
||||
|
||||
$this->assertSame('100.00', $before['total_amount']);
|
||||
$this->assertSame('100.00', $after['total_amount']);
|
||||
$this->assertSame('75.00', $after['balance']);
|
||||
}
|
||||
|
||||
public function testFrozenInvoiceExposesEligibleDiscountBaseFromLines(): void
|
||||
{
|
||||
$invoice = [
|
||||
'id' => 30,
|
||||
'invoice_number' => 'INV-2026-00030',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
];
|
||||
|
||||
$service = new InvoiceLedgerServiceHarness(
|
||||
$invoice,
|
||||
discountTotal: 50.0,
|
||||
frozenTotals: [
|
||||
'tuition_cents' => 10000,
|
||||
'event_cents' => 3000,
|
||||
'additional_cents' => 0,
|
||||
'total_cents' => 13000,
|
||||
'discount_eligible_base_cents' => 10000,
|
||||
]
|
||||
);
|
||||
|
||||
$calculation = $service->calculateInvoice(30);
|
||||
|
||||
$this->assertSame(13000, $calculation['gross_charge_cents']);
|
||||
$this->assertSame(10000, $calculation['discount_eligible_base_cents']);
|
||||
$this->assertSame(5000, $calculation['requested_discount_cents']);
|
||||
$this->assertSame(5000, $calculation['applied_discount_cents']);
|
||||
$this->assertSame(8000, $calculation['net_charge_cents']);
|
||||
}
|
||||
|
||||
public function testEventOnlyFrozenInvoiceReceivesNoDiscount(): void
|
||||
{
|
||||
$invoice = [
|
||||
'id' => 31,
|
||||
'invoice_number' => 'INV-2026-00031',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
];
|
||||
|
||||
$service = new InvoiceLedgerServiceHarness(
|
||||
$invoice,
|
||||
discountTotal: 25.0,
|
||||
frozenTotals: [
|
||||
'tuition_cents' => 0,
|
||||
'event_cents' => 7500,
|
||||
'additional_cents' => 0,
|
||||
'total_cents' => 7500,
|
||||
'discount_eligible_base_cents' => 0,
|
||||
]
|
||||
);
|
||||
|
||||
$calculation = $service->calculateInvoice(31);
|
||||
|
||||
$this->assertSame(0, $calculation['discount_eligible_base_cents']);
|
||||
$this->assertSame(2500, $calculation['requested_discount_cents']);
|
||||
$this->assertSame(0, $calculation['applied_discount_cents']);
|
||||
$this->assertSame(7500, $calculation['net_charge_cents']);
|
||||
$this->assertSame('75.00', $calculation['balance']);
|
||||
}
|
||||
|
||||
public function testCarryForwardInvoiceUsesStoredOpeningBalance(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
@@ -88,4 +372,60 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testCashRefundReducesCustomerCreditWithoutIncreasingOverpayment(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 11,
|
||||
'invoice_number' => 'CF-20252026-20262027-P8-I5',
|
||||
'total_amount' => '100.00',
|
||||
'semester' => 'Opening Balance',
|
||||
'description' => 'Balance carried over from previous school year 2025-2026.',
|
||||
], 125.0, 20.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(11);
|
||||
|
||||
$this->assertSame('100.00', $calculation['total_amount']);
|
||||
$this->assertSame('20.00', $calculation['refund_paid_total']);
|
||||
$this->assertSame('5.00', $calculation['customer_credit']);
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testCashRefundCanRestoreBalanceAfterOverpaymentIsReturned(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 12,
|
||||
'invoice_number' => 'CF-20252026-20262027-P8-I6',
|
||||
'total_amount' => '100.00',
|
||||
'semester' => 'Opening Balance',
|
||||
'description' => 'Balance carried over from previous school year 2025-2026.',
|
||||
], 125.0, 30.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(12);
|
||||
|
||||
$this->assertSame('0.00', $calculation['customer_credit']);
|
||||
$this->assertSame('5.00', $calculation['balance']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PARTIALLY_PAID, $calculation['status']);
|
||||
}
|
||||
|
||||
public function testIssuedInvoiceUsesPaidRefundsAsCashOutInsteadOfAdditionalCredit(): void
|
||||
{
|
||||
$service = new InvoiceLedgerServiceHarness([
|
||||
'id' => 13,
|
||||
'invoice_number' => 'INV-2026-00013',
|
||||
'total_amount' => '0.00',
|
||||
'semester' => 'Fall',
|
||||
'description' => 'Current year tuition invoice.',
|
||||
], 120.0, 15.0, 100.0, 0.0, 0.0, 10.0);
|
||||
|
||||
$calculation = $service->calculateInvoice(13);
|
||||
|
||||
$this->assertSame('100.00', $calculation['total_amount']);
|
||||
$this->assertSame('10.00', $calculation['discount_total']);
|
||||
$this->assertSame('15.00', $calculation['refund_paid_total']);
|
||||
$this->assertSame('15.00', $calculation['customer_credit']);
|
||||
$this->assertSame('0.00', $calculation['balance']);
|
||||
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Libraries;
|
||||
|
||||
use App\Libraries\ParentLedgerService;
|
||||
use App\Libraries\RefundEligibilityResult;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class ParentLedgerServiceHarness extends ParentLedgerService
|
||||
{
|
||||
public function __construct(
|
||||
private array $invoices,
|
||||
private array $ledgers,
|
||||
private array $eligibilities
|
||||
) {
|
||||
}
|
||||
|
||||
protected function loadInvoices(int $parentId, string $schoolYear, ?string $semester): array
|
||||
{
|
||||
return array_values(array_filter($this->invoices, static function (array $invoice) use ($parentId, $schoolYear, $semester): bool {
|
||||
if ((int)($invoice['parent_id'] ?? 0) !== $parentId || (string)($invoice['school_year'] ?? '') !== $schoolYear) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $semester === null || $semester === '' || (string)($invoice['semester'] ?? '') === $semester;
|
||||
}));
|
||||
}
|
||||
|
||||
protected function ledgerForInvoice(int $invoiceId): array
|
||||
{
|
||||
return $this->ledgers[$invoiceId] ?? [];
|
||||
}
|
||||
|
||||
protected function eligibilityForSource(
|
||||
int $parentId,
|
||||
?int $invoiceId,
|
||||
string $sourceType,
|
||||
int $sourceId
|
||||
): RefundEligibilityResult {
|
||||
return $this->eligibilities[$sourceId] ?? new RefundEligibilityResult(0, 0, 0, 0, []);
|
||||
}
|
||||
}
|
||||
|
||||
class ParentLedgerServiceTest extends CIUnitTestCase
|
||||
{
|
||||
public function testParentProjectionAggregatesLedgerAndEligibilityFields(): void
|
||||
{
|
||||
$service = new ParentLedgerServiceHarness(
|
||||
[
|
||||
['id' => 10, 'parent_id' => 5, 'school_year' => '2026-2027', 'semester' => 'Fall', 'invoice_number' => 'INV-10'],
|
||||
['id' => 11, 'parent_id' => 5, 'school_year' => '2026-2027', 'semester' => 'Fall', 'invoice_number' => 'INV-11'],
|
||||
['id' => 12, 'parent_id' => 5, 'school_year' => '2026-2027', 'semester' => 'Spring', 'invoice_number' => 'INV-12'],
|
||||
],
|
||||
[
|
||||
10 => [
|
||||
'totalAmountCents' => 10000,
|
||||
'discountCents' => 1000,
|
||||
'paidCents' => 11000,
|
||||
'completedRefundCents' => 500,
|
||||
'balanceDueCents' => 0,
|
||||
'customerCreditCents' => 1500,
|
||||
'status' => 'paid',
|
||||
],
|
||||
11 => [
|
||||
'totalAmountCents' => 8000,
|
||||
'discountCents' => 0,
|
||||
'paidCents' => 2000,
|
||||
'completedRefundCents' => 0,
|
||||
'balanceDueCents' => 6000,
|
||||
'customerCreditCents' => 0,
|
||||
'status' => 'partially_paid',
|
||||
],
|
||||
],
|
||||
[
|
||||
10 => new RefundEligibilityResult(1500, 0, 300, 1200, ['HAS_APPROVED_RESERVATIONS']),
|
||||
11 => new RefundEligibilityResult(0, 0, 0, 0, ['NO_AVAILABLE_CREDIT']),
|
||||
]
|
||||
);
|
||||
|
||||
$projection = $service->getParentProjection(5, '2026-2027', 'Fall');
|
||||
|
||||
$this->assertSame(18000, $projection['grossChargesCents']);
|
||||
$this->assertSame(1000, $projection['discountCents']);
|
||||
$this->assertSame(17000, $projection['netInvoiceChargesCents']);
|
||||
$this->assertSame(13000, $projection['validPaymentCents']);
|
||||
$this->assertSame(500, $projection['completedRefundCents']);
|
||||
$this->assertSame(6000, $projection['balanceDueCents']);
|
||||
$this->assertSame(1500, $projection['customerCreditCents']);
|
||||
$this->assertSame(300, $projection['approvedRefundReservationCents']);
|
||||
$this->assertSame(1200, $projection['availableRefundableCreditCents']);
|
||||
$this->assertSame('170.00', $projection['net_invoice_charges']);
|
||||
$this->assertSame('12.00', $projection['available_refundable_credit']);
|
||||
$this->assertCount(2, $projection['invoices']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\App\Libraries;
|
||||
|
||||
use App\Libraries\RefundEligibilityService;
|
||||
use CodeIgniter\Test\CIUnitTestCase;
|
||||
|
||||
class RefundEligibilityServiceHarness extends RefundEligibilityService
|
||||
{
|
||||
public function __construct(
|
||||
private int $sourceCreditCents,
|
||||
private int $completedPayoutCents,
|
||||
private int $reservedAmountCents
|
||||
) {
|
||||
}
|
||||
|
||||
protected function calculateSourceCreditCents(int $parentId, ?int $invoiceId, string $sourceType, int $sourceId): int
|
||||
{
|
||||
return $this->sourceCreditCents;
|
||||
}
|
||||
|
||||
protected function calculateCompletedPayoutCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int
|
||||
{
|
||||
return $this->completedPayoutCents;
|
||||
}
|
||||
|
||||
protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int
|
||||
{
|
||||
return $this->reservedAmountCents;
|
||||
}
|
||||
}
|
||||
|
||||
class RefundEligibilityServiceTest extends CIUnitTestCase
|
||||
{
|
||||
public function testInvoiceOverpaymentAvailableCreditDoesNotDoubleSubtractCompletedPayouts(): void
|
||||
{
|
||||
$service = new RefundEligibilityServiceHarness(10000, 2500, 1500);
|
||||
|
||||
$result = $service->calculateAvailableCredit(10, 20, 'invoice_overpayment', 20);
|
||||
|
||||
$this->assertSame(10000, $result->sourceCreditCents);
|
||||
$this->assertSame(0, $result->completedPayoutCents);
|
||||
$this->assertSame(1500, $result->reservedAmountCents);
|
||||
$this->assertSame(8500, $result->availableAmountCents);
|
||||
$this->assertNotContains('HAS_COMPLETED_PAYOUTS', $result->reasonCodes);
|
||||
$this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes);
|
||||
}
|
||||
|
||||
public function testPaymentSourceAvailableCreditSubtractsCompletedPayoutsAndReservations(): void
|
||||
{
|
||||
$service = new RefundEligibilityServiceHarness(10000, 2500, 1500);
|
||||
|
||||
$result = $service->calculateAvailableCredit(10, 20, 'payment_duplicate', 77);
|
||||
|
||||
$this->assertSame(10000, $result->sourceCreditCents);
|
||||
$this->assertSame(2500, $result->completedPayoutCents);
|
||||
$this->assertSame(1500, $result->reservedAmountCents);
|
||||
$this->assertSame(6000, $result->availableAmountCents);
|
||||
$this->assertContains('HAS_COMPLETED_PAYOUTS', $result->reasonCodes);
|
||||
$this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes);
|
||||
}
|
||||
|
||||
public function testValidateRejectsAmountAboveAvailableCredit(): void
|
||||
{
|
||||
$service = new RefundEligibilityServiceHarness(10000, 7000, 1000);
|
||||
$result = $service->calculateAvailableCredit(10, 20, 'payment_duplicate', 20);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Refund amount exceeds available source credit.');
|
||||
|
||||
$service->validateRequestedAmount($result, 2500);
|
||||
}
|
||||
|
||||
public function testValidateRejectsNonPositiveAmount(): void
|
||||
{
|
||||
$service = new RefundEligibilityServiceHarness(10000, 0, 0);
|
||||
$result = $service->calculateAvailableCredit(10, 20, 'invoice_overpayment', 20);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Refund amount must be greater than zero.');
|
||||
|
||||
$service->validateRequestedAmount($result, 0);
|
||||
}
|
||||
|
||||
public function testZeroAvailableCreditCarriesReasonCodes(): void
|
||||
{
|
||||
$service = new RefundEligibilityServiceHarness(0, 0, 0);
|
||||
|
||||
$result = $service->calculateAvailableCredit(10, 20, 'invoice_overpayment', 20);
|
||||
|
||||
$this->assertSame(0, $result->availableAmountCents);
|
||||
$this->assertContains('NO_SOURCE_CREDIT', $result->reasonCodes);
|
||||
$this->assertContains('NO_AVAILABLE_CREDIT', $result->reasonCodes);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user