diff --git a/app/Config/Routes.php b/app/Config/Routes.php index f6824e3..ecd668c 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -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'); diff --git a/app/Controllers/View/AdministratorController.php b/app/Controllers/View/AdministratorController.php index 56664ba..8ab2bc7 100644 --- a/app/Controllers/View/AdministratorController.php +++ b/app/Controllers/View/AdministratorController.php @@ -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, ]); diff --git a/app/Controllers/View/DiscountController.php b/app/Controllers/View/DiscountController.php index 65f9f82..7332f79 100644 --- a/app/Controllers/View/DiscountController.php +++ b/app/Controllers/View/DiscountController.php @@ -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(). * diff --git a/app/Controllers/View/EventController.php b/app/Controllers/View/EventController.php index e71dba6..2d4e3be 100644 --- a/app/Controllers/View/EventController.php +++ b/app/Controllers/View/EventController.php @@ -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); } } } diff --git a/app/Controllers/View/ExpenseController.php b/app/Controllers/View/ExpenseController.php index 01bd5d3..5a55c2c 100644 --- a/app/Controllers/View/ExpenseController.php +++ b/app/Controllers/View/ExpenseController.php @@ -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/.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; + } } diff --git a/app/Controllers/View/ExtraChargesController.php b/app/Controllers/View/ExtraChargesController.php index c889af0..6c3cb96 100644 --- a/app/Controllers/View/ExtraChargesController.php +++ b/app/Controllers/View/ExtraChargesController.php @@ -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). */ diff --git a/app/Controllers/View/FinancialController.php b/app/Controllers/View/FinancialController.php index 6d55238..0c6e7e6 100644 --- a/app/Controllers/View/FinancialController.php +++ b/app/Controllers/View/FinancialController.php @@ -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] = [ diff --git a/app/Controllers/View/InvoiceController.php b/app/Controllers/View/InvoiceController.php index 692adca..4ce7802 100644 --- a/app/Controllers/View/InvoiceController.php +++ b/app/Controllers/View/InvoiceController.php @@ -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'), ]; diff --git a/app/Controllers/View/PaymentController.php b/app/Controllers/View/PaymentController.php index 417cbab..4c21f2c 100644 --- a/app/Controllers/View/PaymentController.php +++ b/app/Controllers/View/PaymentController.php @@ -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']; diff --git a/app/Controllers/View/PaymentNotificationController.php b/app/Controllers/View/PaymentNotificationController.php index 17eed99..f9b38a4 100644 --- a/app/Controllers/View/PaymentNotificationController.php +++ b/app/Controllers/View/PaymentNotificationController.php @@ -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]; }; diff --git a/app/Controllers/View/PaymentTransactionController.php b/app/Controllers/View/PaymentTransactionController.php deleted file mode 100644 index 461f51d..0000000 --- a/app/Controllers/View/PaymentTransactionController.php +++ /dev/null @@ -1,84 +0,0 @@ -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.'); - } - } -} \ No newline at end of file diff --git a/app/Controllers/View/PurchaseOrderController.php b/app/Controllers/View/PurchaseOrderController.php index c014418..3634063 100644 --- a/app/Controllers/View/PurchaseOrderController.php +++ b/app/Controllers/View/PurchaseOrderController.php @@ -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)); + } } diff --git a/app/Controllers/View/RefundController.php b/app/Controllers/View/RefundController.php index decef2d..e85ff80 100644 --- a/app/Controllers/View/RefundController.php +++ b/app/Controllers/View/RefundController.php @@ -4,8 +4,13 @@ namespace App\Controllers\View; use App\Controllers\BaseController; use App\Libraries\FinancialAttachmentService; +use App\Libraries\FinancialPersistenceException; +use App\Libraries\FinancialStatus; use App\Libraries\InvoiceLedgerService; +use App\Libraries\ParentLedgerService; +use App\Libraries\RefundEligibilityService; use App\Models\RefundModel; +use App\Models\RefundPayoutModel; use App\Models\UserModel; use App\Models\PaymentModel; use App\Models\ConfigurationModel; @@ -16,33 +21,74 @@ use CodeIgniter\Exceptions\PageNotFoundException; class RefundController extends BaseController { protected RefundModel $refundModel; + protected RefundPayoutModel $refundPayoutModel; protected UserModel $userModel; protected PaymentModel $paymentModel; protected ConfigurationModel $configModel; protected InvoiceModel $invoiceModel; protected EnrollmentModel $enrollmentModel; protected InvoiceLedgerService $invoiceLedgerService; + protected ParentLedgerService $parentLedgerService; + protected RefundEligibilityService $refundEligibilityService; protected FinancialAttachmentService $financialAttachmentService; protected $db; // Allowed request types (mapped to your `refunds.request` column) - private const REQUEST_TYPES = ['tuition','overpayment','duplicate','extra']; + private const REQUEST_TYPES = ['overpayment','duplicate']; // Allowed finalization decisions - private const DECISIONS = ['Approved','Rejected']; + private const DECISIONS = ['approved','rejected']; + private const PAYMENT_METHODS = ['Check','Online','Cash']; public function __construct() { $this->refundModel = new RefundModel(); + $this->refundPayoutModel = new RefundPayoutModel(); $this->userModel = new UserModel(); $this->paymentModel = new PaymentModel(); $this->configModel = new ConfigurationModel(); $this->invoiceModel = new InvoiceModel(); $this->enrollmentModel = new EnrollmentModel(); $this->invoiceLedgerService = new InvoiceLedgerService(); + $this->parentLedgerService = new ParentLedgerService(); + $this->refundEligibilityService = new RefundEligibilityService(); $this->financialAttachmentService = new FinancialAttachmentService(); $this->db = \Config\Database::connect(); } + 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($db, string $code = 'TRANSACTION_FAILED'): void + { + if (method_exists($db, 'transStatus') && $db->transStatus() === false) { + throw new FinancialPersistenceException($code); + } + } + + private function refundFailureResponse( + string $publicCode, + string $publicMessage, + \Throwable $e, + array $context = [] + ) { + $context += [ + 'request_id' => service('request')->getHeaderLine('X-Request-ID') ?: bin2hex(random_bytes(8)), + 'user_id' => session()->get('user_id'), + ]; + log_message('error', $publicCode . ': ' . $e->getMessage() . "\n" . $e->getTraceAsString() . ' context=' . json_encode($context)); + + return $this->response->setJSON([ + 'success' => false, + 'code' => $publicCode, + 'message' => $publicMessage, + ]); + } + /** Get current term (school_year, semester) from configuration */ private function getCurrentTerm(): array { @@ -75,205 +121,71 @@ class RefundController extends BaseController $year = (string)$term['school_year']; $sem = (string)$term['semester']; - // Sum payments by parent for the term - $pm = $this->paymentModel; - $payTable = $pm->table; - $hasStatus = $this->paymentModel->db->fieldExists('status', $payTable); - $hasVoid = $this->paymentModel->db->fieldExists('is_void', $payTable); - - $qbPaid = $pm->select('parent_id, COALESCE(SUM(paid_amount),0) AS total_paid') - ->where('school_year', $year) - ->groupBy('parent_id'); - if ($hasStatus) { - $qbPaid->groupStart() - ->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled']) - ->orWhere('status IS NULL', null, false) - ->groupEnd(); - } - if ($hasVoid) { - $qbPaid->groupStart() - ->where('is_void', 0) - ->orWhere('is_void IS NULL', null, false) - ->groupEnd(); - } - $paidRows = $qbPaid->findAll(); - - // Sum invoices (charges) by parent for the term - $invRows = $this->invoiceModel - ->select('parent_id, COALESCE(SUM(total_amount),0) AS total_invoiced') - ->where('school_year', $year) - ->groupBy('parent_id') - ->findAll(); - - // Sum refunds PAID (cash out) by parent for the term - $refRows = $this->refundModel - ->select('parent_id, COALESCE(SUM(refund_paid_amount),0) AS total_refunded') - ->where('school_year', $year) - ->whereIn('status', ['Partial','Paid']) - ->groupBy('parent_id') - ->findAll(); - - $paidMap = []; - foreach ($paidRows as $r) $paidMap[(int)$r['parent_id']] = (float)($r['total_paid'] ?? 0); - $invMap = []; - foreach ($invRows as $r) $invMap[(int)$r['parent_id']] = (float)($r['total_invoiced'] ?? 0); - $refMap = []; - foreach ($refRows as $r) $refMap[(int)$r['parent_id']] = (float)($r['total_refunded'] ?? 0); - - $parentIds = array_unique(array_merge(array_keys($paidMap), array_keys($invMap))); - - foreach ($parentIds as $pid) { - $paid = (float)($paidMap[$pid] ?? 0); - $invd = (float)($invMap[$pid] ?? 0); - $rfnd = (float)($refMap[$pid] ?? 0); - $over = round($paid - $invd - $rfnd, 2); - if ($over > 0.00 + 0.0001) { - // Parent-level policy: DO NOT create new overpayment rows; only keep existing parent-level - // overpayment rows in sync if they are still open (Pending/Partial). Prefer per-invoice rows. - $existing = $this->refundModel - ->where('parent_id', $pid) - ->where('school_year', $year) - ->where('request', 'overpayment') - ->orderBy('id', 'DESC') - ->first(); - - if ($existing && in_array($existing['status'], ['Pending','Partial'], true)) { - $this->refundModel->update((int)$existing['id'], [ - 'refund_amount' => $over, - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - ]); - $updated[] = (int)$existing['id']; - } - // else: no parent-level row created - } - } - - // ===== Stage 2: Per-invoice overpayment detection (current school year) ===== + // Per-invoice overpayment detection (current school year) try { $invRows = $this->invoiceModel - ->select('id, parent_id, total_amount, school_year') + ->select('id, parent_id, school_year') ->where('school_year', $year) ->findAll(); if ($invRows) { - $invoiceIds = array_map(static fn($r) => (int)$r['id'], $invRows); - - // payments by invoice - $qbInvPaid = $this->paymentModel - ->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid') - ->whereIn('invoice_id', $invoiceIds) - ->groupBy('invoice_id'); - $payTable2 = $this->paymentModel->table; - $hasStatus2 = $this->paymentModel->db->fieldExists('status', $payTable2); - $hasVoid2 = $this->paymentModel->db->fieldExists('is_void', $payTable2); - if ($hasStatus2) { - $qbInvPaid->groupStart() - ->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled']) - ->orWhere('status IS NULL', null, false) - ->groupEnd(); - } - if ($hasVoid2) { - $qbInvPaid->groupStart() - ->where('is_void', 0) - ->orWhere('is_void IS NULL', null, false) - ->groupEnd(); - } - $invPaidRows = $qbInvPaid->findAll(); - - // discounts by invoice - $invDiscRows = $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(); - - // refunds paid by invoice - $invRefRows = $this->refundModel - ->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_ref') - ->whereIn('invoice_id', $invoiceIds) - ->whereIn('status', ['Partial','Paid']) - ->groupBy('invoice_id') - ->findAll(); - - $paidByInv = []; - foreach ($invPaidRows as $r) { $paidByInv[(int)$r['invoice_id']] = (float)($r['total_paid'] ?? 0); } - $discByInv = []; - foreach ($invDiscRows as $r) { $discByInv[(int)$r['invoice_id']] = (float)($r['total_disc'] ?? 0); } - $refByInv = []; - foreach ($invRefRows as $r) { $refByInv[(int)$r['invoice_id']] = (float)($r['total_ref'] ?? 0); } - foreach ($invRows as $inv) { $iid = (int)$inv['id']; $pid = (int)$inv['parent_id']; - $total = (float)($inv['total_amount'] ?? 0); - $paid = (float)($paidByInv[$iid] ?? 0); - $disc = (float)($discByInv[$iid] ?? 0); - $rfnd = (float)($refByInv[$iid] ?? 0); - - $rawBalance = round($total - $disc - $paid - $rfnd, 2); - if ($rawBalance < -0.0001) { - $over = abs($rawBalance); + $ledger = $this->invoiceLedgerService->calculateInvoice($iid); + $over = (float)($ledger['customer_credit'] ?? 0.0); + if ($over > 0.0001) { // If ANY open refund exists for this invoice, update it instead of creating another line $openRow = $this->refundModel ->where('invoice_id', $iid) + ->where('source_type', 'invoice_overpayment') + ->where('source_id', $iid) ->whereIn('status', ['Pending','Approved','Partial']) ->orderBy('id', 'DESC') ->first(); if ($openRow) { // Prefer merging into 'overpayment' if present, else update the open row - $this->refundModel->update((int)$openRow['id'], [ - 'refund_amount' => $over, - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - ]); + $this->requireWrite($this->refundModel->update( + (int)$openRow['id'], + $this->buildRefundRecalculationUpdate($openRow, $over, [ + 'source_type' => 'invoice_overpayment', + 'source_id' => $iid, + ]) + ), 'REFUND_RECALC_UPDATE_FAILED', $this->refundModel); $updated[] = (int)$openRow['id']; continue; } - // If there is an open PARENT-LEVEL overpayment (invoice_id IS NULL), migrate it to this invoice - $parentLevelOpen = $this->refundModel - ->where('parent_id', $pid) - ->where('school_year', $year) - ->where('invoice_id', null) - ->where('request', 'overpayment') - ->whereIn('status', ['Pending','Approved','Partial']) - ->orderBy('id', 'DESC') - ->first(); - if ($parentLevelOpen) { - $this->refundModel->update((int)$parentLevelOpen['id'], [ - 'invoice_id' => $iid, - 'refund_amount' => $over, - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - ]); - $updated[] = (int)$parentLevelOpen['id']; - continue; // merged instead of creating new - } - // Existing overpayment refund for this invoice? $existing = $this->refundModel ->where('invoice_id', $iid) - ->where('request', 'overpayment') + ->where('source_type', 'invoice_overpayment') + ->where('source_id', $iid) ->whereIn('status', ['Pending','Approved','Partial','Paid']) ->orderBy('id', 'DESC') ->first(); - if (!$existing || $existing['status'] === 'Paid') { + if (!$existing || FinancialStatus::normalizeRefundStatus($existing['status'] ?? null) === FinancialStatus::REFUND_PAID) { $ok = $this->refundModel->insert([ 'parent_id' => $pid, 'school_year' => $year, 'semester' => $sem, 'invoice_id' => $iid, 'refund_amount' => $over, + 'requested_amount_cents' => (int) round($over * 100), + 'approved_amount_cents' => null, + 'currency' => 'USD', 'refund_paid_amount' => 0.00, - 'status' => 'Pending', + 'status' => FinancialStatus::REFUND_REQUESTED, 'request' => 'overpayment', + 'source_type' => 'invoice_overpayment', + 'source_id' => $iid, 'reason' => 'Auto-detected per-invoice overpayment', 'updated_by' => session()->get('user_id'), 'created_at' => utc_now(), 'updated_at' => utc_now(), ]); + $this->requireWrite($ok, 'REFUND_RECALC_INSERT_FAILED', $this->refundModel); if ($ok) { $created[] = (int)$this->refundModel->getInsertID(); if ($triggerEvents) { @@ -291,12 +203,14 @@ class RefundController extends BaseController } } else { // Keep Pending/Partial in sync with current overpayment - if (in_array($existing['status'], ['Pending','Partial'], true)) { - $this->refundModel->update((int)$existing['id'], [ - 'refund_amount' => $over, - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - ]); + if (in_array(FinancialStatus::normalizeRefundStatus($existing['status'] ?? null), [FinancialStatus::REFUND_REQUESTED, FinancialStatus::REFUND_PARTIALLY_PAID], true)) { + $this->requireWrite($this->refundModel->update( + (int)$existing['id'], + $this->buildRefundRecalculationUpdate($existing, $over, [ + 'source_type' => 'invoice_overpayment', + 'source_id' => $iid, + ]) + ), 'REFUND_RECALC_UPDATE_FAILED', $this->refundModel); $updated[] = (int)$existing['id']; } } @@ -311,52 +225,65 @@ class RefundController extends BaseController } /** - * Parent financial summary for *current term* (adjust if you want all-time). - * Assumes invoices.total_amount and payments.amount (positive=in, negative=out). + * Parent financial summary for the selected term. */ private function getParentFinancialSummary(int $parentId, string $schoolYear, string $semester): array { - // invoices total for term - $inv = $this->invoiceModel - ->select('COALESCE(SUM(total_amount),0) AS total_invoiced') - ->where('parent_id', $parentId) - ->where('school_year', $schoolYear) - ->where('semester', $semester) - ->first(); - - // payments in (amount > 0) for term - $payIn = $this->paymentModel - ->select('COALESCE(SUM(amount),0) AS paid_in') - ->where('parent_id', $parentId) - ->where('school_year', $schoolYear) - ->where('semester', $semester) - ->where('amount >', 0) - ->first(); - - // refunds already paid out (your refunds table) - $refPaid = $this->refundModel - ->select('COALESCE(SUM(refund_paid_amount),0) AS refunded_cash') - ->where('parent_id', $parentId) - ->where('school_year', $schoolYear) - ->where('semester', $semester) - ->whereIn('status', ['Partial','Paid']) - ->first(); - - $totalInvoiced = (float)($inv['total_invoiced'] ?? 0); - $paidIn = (float)($payIn['paid_in'] ?? 0); - $refundedCash = (float)($refPaid['refunded_cash'] ?? 0); - - // Unapplied balance = money in − invoices − cash refunds out - $unapplied = $paidIn - $totalInvoiced - $refundedCash; + $projection = $this->parentLedgerService->getParentProjection($parentId, $schoolYear, $semester); return [ - 'total_invoiced' => $totalInvoiced, - 'total_paid_in' => $paidIn, - 'total_refunded' => $refundedCash, - 'unapplied_balance' => $unapplied, + 'total_invoiced' => (float)($projection['gross_charges'] ?? 0), + 'net_invoice_charges' => (float)($projection['net_invoice_charges'] ?? 0), + 'total_paid_in' => (float)($projection['valid_payments'] ?? 0), + 'total_refunded' => (float)($projection['completed_refunds'] ?? 0), + 'approved_refund_reservations' => (float)($projection['approved_refund_reservations'] ?? 0), + 'unapplied_balance' => (float)($projection['customer_credit'] ?? 0), + 'available_refundable_credit' => (float)($projection['available_refundable_credit'] ?? 0), + 'projection' => $projection, ]; } + private function buildRefundRecalculationUpdate(array $refund, float $calculatedAmount, array $extra = []): array + { + $refundId = (int)($refund['id'] ?? 0); + $calculatedCents = max(0, (int)round($calculatedAmount * 100)); + $paidCents = $this->refundEligibilityService->getCompletedPayoutTotalCentsForRefund($refundId); + $status = strtolower((string)($refund['status'] ?? '')); + $isApprovedState = in_array($status, ['approved', 'partial', 'paid', 'partially_paid'], true); + $targetCents = $isApprovedState ? max($calculatedCents, $paidCents) : $calculatedCents; + $now = utc_now(); + + $update = array_merge([ + 'refund_amount' => $targetCents / 100, + 'updated_at' => $now, + 'updated_by' => session()->get('user_id'), + ], $extra); + + if ($isApprovedState) { + $update['approved_amount_cents'] = $targetCents; + } else { + $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'] = $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; + } + + return $update; + } + /** Optional helper if you want a quick API for balances in UI */ public function parentBalances(int $parentId) { @@ -366,27 +293,37 @@ class RefundController extends BaseController /** * Create refund request. - * Stores source/type in `refunds.request` as: tuition|overpayment|duplicate|extra + * Stores source/type in `refunds.request` as: overpayment|duplicate * * @param int $parentId * @param float $amount - * @param string|null $requestType tuition|overpayment|duplicate|extra - * @param int|null $invoiceId required for tuition; useful for duplicate + * @param string|null $requestType overpayment|duplicate + * @param int|null $invoiceId required for overpayment; derived for duplicate * @param int|null $paymentId useful for duplicate * @param string|null $reason */ public function requestRefund( - int $parentId, - float $amount, - ?string $requestType = 'tuition', + ?int $parentId = null, + ?float $amount = null, + ?string $requestType = null, ?int $invoiceId = null, ?int $paymentId = null, ?string $reason = null ) { + $parentId = (int)($parentId ?: $this->request->getPost('parent_id')); + $amount = (float)($amount ?: $this->request->getPost('amount') ?: $this->request->getPost('refund_amount')); + $requestType = $requestType ?: $this->request->getPost('request_type') ?: $this->request->getPost('request') ?: 'overpayment'; + $invoiceId = $invoiceId ?: ($this->request->getPost('invoice_id') !== null ? (int) $this->request->getPost('invoice_id') : null); + $paymentId = $paymentId ?: ($this->request->getPost('payment_id') !== null ? (int) $this->request->getPost('payment_id') : null); + $reason = $reason ?: $this->request->getPost('reason'); + $requestType = strtolower((string)$requestType); if (!in_array($requestType, self::REQUEST_TYPES, true)) { return $this->response->setJSON(['error' => 'Invalid refund request type.']); } + if ($parentId <= 0) { + return $this->response->setJSON(['error' => 'Parent ID is required.']); + } $parent = $this->userModel->find($parentId); if (!$parent) { @@ -400,10 +337,9 @@ class RefundController extends BaseController $schoolYear = $term['school_year']; $semester = $term['semester']; - // Tuition requires an invoice check - if ($requestType === 'tuition') { + if ($requestType === 'overpayment') { if (empty($invoiceId)) { - return $this->response->setJSON(['error' => 'invoice_id is required for tuition refunds.']); + return $this->response->setJSON(['error' => 'invoice_id is required for overpayment refunds.']); } $inv = $this->invoiceModel->find($invoiceId); if (!$inv || (int)$inv['parent_id'] !== $parentId) { @@ -412,24 +348,39 @@ class RefundController extends BaseController // Optionally cap to eligible amount per your policy. } - // Overpayment/extra must not exceed unapplied balance - if (in_array($requestType, ['overpayment','extra'], true)) { - $sum = $this->getParentFinancialSummary($parentId, $schoolYear, $semester); - if ($sum['unapplied_balance'] <= 0) { - return $this->response->setJSON(['error' => 'No unapplied balance available.']); + if ($requestType === 'duplicate') { + if (empty($paymentId)) { + return $this->response->setJSON(['error' => 'payment_id is required for duplicate payment refunds.']); } - if ($amount > $sum['unapplied_balance'] + 0.0001) { - return $this->response->setJSON([ - 'error' => 'Amount exceeds available unapplied balance.', - 'available' => number_format($sum['unapplied_balance'], 2) - ]); + $payment = $this->paymentModel->find($paymentId); + if (!$payment || (int)($payment['parent_id'] ?? 0) !== $parentId) { + return $this->response->setJSON(['error' => 'Payment not found for parent.']); } + if (!empty($invoiceId) && (int)$invoiceId !== (int)($payment['invoice_id'] ?? 0)) { + return $this->response->setJSON(['error' => 'Duplicate payment refund invoice does not match the payment invoice.']); + } + $invoiceId = (int)($payment['invoice_id'] ?? 0) ?: null; } - // Duplicate: optionally tie to payment; otherwise your staff can fill details in reason/note - if ($requestType === 'duplicate' && empty($paymentId) && empty($invoiceId)) { - // Not hard-failing; but better to guide: - // return $this->response->setJSON(['error' => 'Provide payment_id or invoice_id for duplicate refunds.']); + $sourceType = match ($requestType) { + 'overpayment' => 'invoice_overpayment', + 'duplicate' => 'payment_duplicate', + }; + $sourceId = $sourceType === 'payment_duplicate' ? (int)$paymentId : (int)($invoiceId ?? 0); + if ($sourceId <= 0) { + return $this->response->setJSON(['error' => 'A refund source record is required.']); + } + + try { + $eligibility = $this->refundEligibilityService->calculateAvailableCredit( + $parentId, + $invoiceId, + $sourceType, + $sourceId + ); + $this->refundEligibilityService->validateRequestedAmount($eligibility, (int)round($amount * 100)); + } catch (\Throwable $e) { + return $this->response->setJSON(['error' => $e->getMessage()]); } $payload = [ @@ -438,10 +389,15 @@ class RefundController extends BaseController 'semester' => $semester, 'invoice_id' => $invoiceId, 'refund_amount' => $amount, + 'requested_amount_cents' => (int) round($amount * 100), + 'approved_amount_cents' => null, + 'currency' => 'USD', 'refund_paid_amount' => 0.00, // IMPORTANT: your column is NOT NULL - 'status' => 'Pending', + 'status' => FinancialStatus::REFUND_REQUESTED, 'reason' => $reason, 'request' => $requestType, // <- store the source/type here + 'source_type' => $sourceType, + 'source_id' => $sourceId, 'note' => $paymentId ? ('duplicate-of-payment#' . $paymentId) : null, 'created_at' => utc_now(), 'updated_at' => utc_now(), @@ -451,6 +407,7 @@ class RefundController extends BaseController if (!$ok) { return $this->response->setJSON(['error' => 'Failed to create refund request.']); } + $this->requireWrite($ok, 'REFUND_REQUEST_INSERT_FAILED', $this->refundModel); if (!empty($invoiceId)) { try { @@ -482,35 +439,57 @@ class RefundController extends BaseController // Approve refund (no money movement) public function approveRefund(int $refundId) { - $refund = $this->refundModel->find($refundId); - if (!$refund) { - return $this->response->setJSON(['error' => 'Refund not found']); - } - $this->db->transBegin(); try { + $refund = $this->db->query('SELECT * FROM refunds WHERE id = ? FOR UPDATE', [$refundId])->getRowArray(); + if (!$refund) { + throw new \RuntimeException('Refund not found'); + } + if (FinancialStatus::normalizeRefundStatus($refund['status'] ?? null) !== FinancialStatus::REFUND_REQUESTED) { + throw new \RuntimeException('Only pending refunds can be approved.'); + } if (!empty($refund['invoice_id'])) { $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $refund['invoice_id']]); } + $sourceType = (string)($refund['source_type'] ?? ''); + $sourceId = (int)($refund['source_id'] ?? 0); + if ($sourceType === '' || $sourceId <= 0) { + throw new \RuntimeException('Refund source is required for approval.'); + } + $this->lockRefundSourceForUpdate($sourceType, $sourceId, isset($refund['invoice_id']) ? (int)$refund['invoice_id'] : null); + $requestedCents = (int)round(((float)($refund['refund_amount'] ?? 0)) * 100); + $eligibility = $this->refundEligibilityService->calculateAvailableCredit( + (int)$refund['parent_id'], + isset($refund['invoice_id']) ? (int)$refund['invoice_id'] : null, + $sourceType, + $sourceId, + $refundId + ); + $this->refundEligibilityService->validateRequestedAmount($eligibility, $requestedCents); + $ok = $this->refundModel->update($refundId, [ - 'status' => 'Approved', + 'status' => FinancialStatus::REFUND_APPROVED, + 'approved_amount_cents' => $requestedCents, 'approved_at' => utc_now(), 'approved_by' => session()->get('user_id'), 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), ]); + $this->requireWrite($ok, 'REFUND_APPROVE_UPDATE_FAILED', $this->refundModel); if (!empty($refund['invoice_id'])) { $this->invoiceLedgerService->recalculateInvoice((int) $refund['invoice_id']); } + $this->requireTransactionStatus($this->db); $this->db->transCommit(); } catch (\Throwable $e) { $this->db->transRollback(); - log_message('error', 'approveRefund failed: ' . $e->getMessage()); - $ok = false; + return $this->refundFailureResponse('REFUND_APPROVAL_FAILED', 'The refund could not be approved.', $e, [ + 'refund_id' => $refundId, + ]); } return $this->response->setJSON($ok ? ['success' => 'Refund approved'] : ['error' => 'Approve failed']); @@ -519,35 +498,41 @@ class RefundController extends BaseController // Reject refund public function rejectRefund(int $refundId) { - $refund = $this->refundModel->find($refundId); - if (!$refund) { - return $this->response->setJSON(['error' => 'Refund not found']); - } - $this->db->transBegin(); try { + $refund = $this->db->query('SELECT * FROM refunds WHERE id = ? FOR UPDATE', [$refundId])->getRowArray(); + if (!$refund) { + throw new \RuntimeException('Refund not found.'); + } + if (FinancialStatus::normalizeRefundStatus($refund['status'] ?? null) !== FinancialStatus::REFUND_REQUESTED) { + throw new \RuntimeException('Only requested refunds can be rejected.'); + } if (!empty($refund['invoice_id'])) { $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $refund['invoice_id']]); } $ok = $this->refundModel->update($refundId, [ - 'status' => 'Rejected', + 'status' => FinancialStatus::REFUND_REJECTED, + 'reason' => $this->request->getPost('reason') ?: ($refund['reason'] ?? null), 'approved_at' => utc_now(), 'approved_by' => session()->get('user_id'), 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), ]); + $this->requireWrite($ok, 'REFUND_REJECT_UPDATE_FAILED', $this->refundModel); if (!empty($refund['invoice_id'])) { $this->invoiceLedgerService->recalculateInvoice((int) $refund['invoice_id']); } + $this->requireTransactionStatus($this->db); $this->db->transCommit(); } catch (\Throwable $e) { $this->db->transRollback(); - log_message('error', 'rejectRefund failed: ' . $e->getMessage()); - $ok = false; + return $this->refundFailureResponse('REFUND_REJECTION_FAILED', 'The refund could not be rejected.', $e, [ + 'refund_id' => $refundId, + ]); } return $this->response->setJSON($ok ? ['success' => 'Refund rejected'] : ['error' => 'Reject failed']); @@ -555,141 +540,371 @@ class RefundController extends BaseController /** * POST: refund_id, paid_amount, payment_method (Check|Online|Cash), check_number, check_file (optional) - * Marks payout (can be partial) and inserts a negative Payment row to keep balances correct. + * Marks payout, which can be partial, after rechecking the refund under a row lock. */ - public function updatePayment() + public function payRefund(int $refundId) { - log_message('debug', 'POST data: ' . print_r($_POST, true)); + return $this->updatePayment($refundId); + } - $refundId = (int)$this->request->getPost('refund_id'); + public function updatePayment(?int $routeRefundId = null) + { + $refundId = (int)($routeRefundId ?: $this->request->getPost('refund_id')); $newPaidAmount = (float)$this->request->getPost('paid_amount'); $refundMethod = $this->request->getPost('payment_method'); // Check|Online|Cash $checkNbr = $this->request->getPost('check_number'); + $idempotencyKey = trim((string)($this->request->getPost('idempotency_key') ?? '')); + $externalReference = trim((string)($this->request->getPost('external_reference') ?? '')); if ($refundId <= 0) return $this->response->setJSON(['error' => 'Missing refund ID.']); if ($newPaidAmount <= 0) return $this->response->setJSON(['error' => 'Valid paid amount is required.']); + if (!in_array($refundMethod, self::PAYMENT_METHODS, true)) { + return $this->response->setJSON(['error' => 'Invalid refund payment method.']); + } + if ($refundMethod === 'Check' && trim((string) $checkNbr) === '') { + return $this->response->setJSON(['error' => 'Check number is required for check refunds.']); + } + if ($idempotencyKey === '') { + return $this->response->setJSON(['error' => 'Missing payout idempotency key.']); + } $refund = $this->refundModel->find($refundId); if (!$refund) return $this->response->setJSON(['error' => 'Refund not found.']); - if (!in_array($refund['status'], ['Approved','Partial'], true)) { - return $this->response->setJSON(['error' => 'Refund must be Approved/Partial to pay.']); - } - - $oldPaid = (float)$refund['refund_paid_amount']; - $target = (float)$refund['refund_amount']; - $total = $oldPaid + $newPaidAmount; - - if ($total > $target + 0.0001) { - return $this->response->setJSON(['error' => 'Total paid cannot exceed refund amount.']); - } - - // Optional file upload for Check - $checkFileName = $refund['check_file'] ?? null; + $stagedEvidence = null; if ($refundMethod === 'Check') { try { $checkFile = $this->request->getFile('check_file'); - $uploaded = $this->financialAttachmentService->saveUploadedFile($checkFile, 'checks'); - if ($uploaded !== null) { - $checkFileName = $uploaded; - } + $stagedEvidence = $this->financialAttachmentService->stageUploadedFile($checkFile, 'checks'); } catch (\RuntimeException $e) { return $this->response->setJSON(['error' => $e->getMessage()]); } } - $newStatus = ($total < $target) ? 'Partial' : 'Paid'; - $db = db_connect(); $db->transBegin(); - - // 0) If this refund is not tied to an invoice yet, assign it to the most overpaid invoice - $assignInvoiceId = null; - if (empty($refund['invoice_id'])) { - try { - // Gather invoices for this parent/year - $invRows = $this->invoiceModel - ->select('id, total_amount') - ->where('parent_id', (int)$refund['parent_id']) - ->where('school_year', $refund['school_year']) - ->findAll(); - - if ($invRows) { - $invoiceIds = array_map(static fn($r) => (int)$r['id'], $invRows); - - // Payments per invoice - $payRows = $this->paymentModel - ->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid') - ->whereIn('invoice_id', $invoiceIds) - ->groupBy('invoice_id') - ->findAll(); - $paidBy = []; - foreach ($payRows as $r) { $paidBy[(int)$r['invoice_id']] = (float)($r['total_paid'] ?? 0); } - - // Discounts per invoice - $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(); - $discBy = []; - foreach ($discRows as $r) { $discBy[(int)$r['invoice_id']] = (float)($r['total_disc'] ?? 0); } - - // Choose invoice with most negative raw (total - disc - paid) - $minRaw = 0.0; $minId = null; - foreach ($invRows as $ir) { - $iid = (int)$ir['id']; - $raw = (float)($ir['total_amount'] ?? 0) - (float)($discBy[$iid] ?? 0) - (float)($paidBy[$iid] ?? 0); - if ($raw < $minRaw) { $minRaw = $raw; $minId = $iid; } - } - if ($minId !== null) { - $assignInvoiceId = $minId; - } else { - // fallback: latest invoice in this year - $row = $this->invoiceModel - ->select('id') - ->where('parent_id', (int)$refund['parent_id']) - ->where('school_year', $refund['school_year']) - ->orderBy('created_at', 'DESC') - ->first(); - if ($row && !empty($row['id'])) $assignInvoiceId = (int)$row['id']; - } - } - } catch (\Throwable $e) { - log_message('error', 'updatePayment: invoice assignment failed: ' . $e->getMessage()); - } - } + $payoutId = null; + $affectedInvoiceId = 0; + $evidenceWarning = null; try { - $affectedInvoiceId = (int) ($assignInvoiceId ?? $refund['invoice_id'] ?? 0); + $lockedRefund = $db->query('SELECT * FROM refunds WHERE id = ? FOR UPDATE', [$refundId])->getRowArray(); + if (!$lockedRefund) { + throw new \RuntimeException('Refund not found.'); + } + + $newPaidCents = (int)round($newPaidAmount * 100); + $payoutFingerprint = $this->buildPayoutFingerprint( + 'refund_payout', + $refundId, + $newPaidCents, + $refundMethod, + (string)($lockedRefund['currency'] ?? 'USD') ?: 'USD', + $externalReference !== '' ? $externalReference : null + ); + $existingPayout = $this->refundPayoutModel->where('idempotency_key', $idempotencyKey)->first(); + if ($existingPayout) { + if (!$this->payoutFingerprintMatches($existingPayout, 'refund_payout', $payoutFingerprint)) { + $this->financialAttachmentService->discardStagedFile($stagedEvidence); + $db->transCommit(); + return $this->response->setStatusCode(409)->setJSON(['error' => 'IDEMPOTENCY_CONFLICT']); + } + + $this->financialAttachmentService->discardStagedFile($stagedEvidence); + $db->transCommit(); + return $this->response->setJSON(['success' => 'Payment already recorded.']); + } + + if (!in_array(FinancialStatus::normalizeRefundStatus($lockedRefund['status'] ?? null), [FinancialStatus::REFUND_APPROVED, FinancialStatus::REFUND_PARTIALLY_PAID], true)) { + throw new \RuntimeException('Refund must be Approved/Partial to pay.'); + } + + $approvedCents = isset($lockedRefund['approved_amount_cents']) && $lockedRefund['approved_amount_cents'] !== null + ? (int)$lockedRefund['approved_amount_cents'] + : (int)round(((float)($lockedRefund['refund_amount'] ?? 0)) * 100); + $oldPaidCents = $this->refundEligibilityService->getNetCompletedCashOutCentsForRefund($refundId); + $oldPaid = $oldPaidCents / 100; + $target = $approvedCents / 100; + $total = $oldPaid + $newPaidAmount; + + if ($newPaidCents > max(0, $approvedCents - $oldPaidCents)) { + throw new \RuntimeException('Total paid cannot exceed approved refund amount.'); + } + + $isOnline = $refundMethod === 'Online'; + $payoutStatus = $isOnline ? 'processing' : 'completed'; + $newStatus = ($total < $target) ? FinancialStatus::REFUND_PARTIALLY_PAID : FinancialStatus::REFUND_PAID; + + if (empty($lockedRefund['invoice_id'])) { + throw new \RuntimeException('Refund payout requires a specific invoice credit source.'); + } + $sourceType = (string)($lockedRefund['source_type'] ?? ''); + $sourceId = (int)($lockedRefund['source_id'] ?? 0); + if ($sourceType === '' || $sourceId <= 0) { + throw new \RuntimeException('Refund source is required for payout.'); + } + + $affectedInvoiceId = (int) ($lockedRefund['invoice_id'] ?? 0); if ($affectedInvoiceId > 0) { $db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$affectedInvoiceId]); } + $this->lockRefundSourceForUpdate($sourceType, $sourceId, $affectedInvoiceId ?: null); - $this->refundModel->update($refundId, [ - 'refund_paid_amount' => $total, - 'status' => $newStatus, - 'refunded_at' => utc_now(), + $eligibility = $this->refundEligibilityService->calculateAvailableCredit( + (int)$lockedRefund['parent_id'], + $affectedInvoiceId > 0 ? $affectedInvoiceId : null, + $sourceType, + $sourceId, + $refundId + ); + $this->refundEligibilityService->validateRequestedAmount($eligibility, $newPaidCents); + + $payoutId = $this->refundPayoutModel->insert([ + 'refund_id' => $refundId, + 'amount_cents' => $newPaidCents, + 'currency' => (string)($lockedRefund['currency'] ?? 'USD') ?: 'USD', + 'payout_type' => 'cash_out', + 'payment_method' => $refundMethod, + 'status' => $payoutStatus, + 'external_reference' => $externalReference !== '' ? $externalReference : null, + 'check_number' => $checkNbr, + 'check_date' => null, + 'evidence_path' => null, + 'idempotency_key' => $idempotencyKey, + 'operation_type' => 'refund_payout', + 'request_fingerprint_hash' => $payoutFingerprint, + 'processed_by' => session()->get('user_id'), + 'processed_at' => utc_now(), + 'reversed_payout_id' => null, + 'failure_code' => null, + 'failure_message' => null, + 'created_at' => utc_now(), + 'updated_at' => utc_now(), + ]); + if (!$payoutId) { + throw new \RuntimeException('Refund payout could not be recorded.'); + } + $this->requireWrite($payoutId, 'REFUND_PAYOUT_INSERT_FAILED', $this->refundPayoutModel); + + $refundProjection = [ 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), 'refund_method' => $refundMethod, 'check_nbr' => $checkNbr, - 'check_file' => $checkFileName, + 'check_file' => $lockedRefund['check_file'] ?? null, 'invoice_id' => $affectedInvoiceId ?: null, - ]); + ]; + if (!$isOnline) { + $refundProjection['refund_paid_amount'] = $total; + $refundProjection['status'] = $newStatus; + $refundProjection['refunded_at'] = utc_now(); + } + if (!$this->refundModel->update($refundId, $refundProjection)) { + throw new FinancialPersistenceException('REFUND_PROJECTION_UPDATE_FAILED', $this->refundModel->errors()); + } - if ($affectedInvoiceId > 0) { + if (!$isOnline && $affectedInvoiceId > 0) { $this->invoiceLedgerService->recalculateInvoice($affectedInvoiceId); } + $this->requireTransactionStatus($db); $db->transCommit(); } catch (\Throwable $e) { $db->transRollback(); - log_message('error', 'updatePayment failed: ' . $e->getMessage()); - return $this->response->setJSON(['error' => 'Failed to update payment.']); + $this->financialAttachmentService->discardStagedFile($stagedEvidence); + return $this->refundFailureResponse('REFUND_PAYOUT_FAILED', 'The refund could not be processed.', $e, [ + 'refund_id' => $refundId, + 'idempotency_key' => $idempotencyKey, + ]); } - return $this->response->setJSON(['success' => 'Payment updated successfully.']); + if ($stagedEvidence !== null && $payoutId !== null) { + try { + $finalName = $this->financialAttachmentService->finalizeStagedFile($stagedEvidence); + $this->requireWrite($this->refundPayoutModel->update((int)$payoutId, [ + 'evidence_path' => $finalName, + 'updated_at' => utc_now(), + ]), 'REFUND_PAYOUT_EVIDENCE_UPDATE_FAILED', $this->refundPayoutModel); + $this->requireWrite($this->refundModel->update($refundId, [ + 'check_file' => $finalName, + 'updated_at' => utc_now(), + 'updated_by' => session()->get('user_id'), + ]), 'REFUND_EVIDENCE_UPDATE_FAILED', $this->refundModel); + } catch (\Throwable $e) { + $this->financialAttachmentService->discardStagedFile($stagedEvidence); + $evidenceWarning = 'Payment recorded, but evidence could not be finalized.'; + log_message('critical', 'Refund payout evidence incomplete for payout #' . (int)$payoutId . ': ' . $e->getMessage()); + try { + $this->requireWrite($this->refundPayoutModel->update((int)$payoutId, [ + 'failure_code' => 'EVIDENCE_INCOMPLETE', + 'failure_message' => $evidenceWarning, + 'updated_at' => utc_now(), + ]), 'REFUND_PAYOUT_EVIDENCE_MARK_FAILED', $this->refundPayoutModel); + } catch (\Throwable $markError) { + log_message('critical', 'Unable to mark refund payout evidence incomplete for payout #' . (int)$payoutId . ': ' . $markError->getMessage()); + } + } + } + + $payload = ['success' => 'Payment updated successfully.']; + if ($evidenceWarning !== null) { + $payload['warning'] = $evidenceWarning; + } + + return $this->response->setJSON($payload); + } + + public function reversePayout(?int $routePayoutId = null) + { + $payoutId = (int)($routePayoutId ?: $this->request->getPost('payout_id')); + $reason = trim((string)($this->request->getPost('reason') ?? '')); + $idempotencyKey = trim((string)($this->request->getPost('idempotency_key') ?? '')); + $amountPost = $this->request->getPost('amount'); + $requestedCents = $amountPost !== null && $amountPost !== '' + ? (int)round(((float)$amountPost) * 100) + : null; + + if ($payoutId <= 0) { + return $this->response->setJSON(['error' => 'Missing refund payout ID.']); + } + if ($reason === '') { + return $this->response->setJSON(['error' => 'Reversal reason is required.']); + } + if ($idempotencyKey === '') { + return $this->response->setJSON(['error' => 'Missing reversal idempotency key.']); + } + if ($requestedCents !== null && $requestedCents <= 0) { + return $this->response->setJSON(['error' => 'Reversal amount must be greater than zero.']); + } + + $db = db_connect(); + $db->transBegin(); + + try { + $requestedFingerprint = null; + $existingReversal = $this->refundPayoutModel->where('idempotency_key', $idempotencyKey)->first(); + + $payout = $db->query('SELECT * FROM refund_payouts WHERE id = ? FOR UPDATE', [$payoutId])->getRowArray(); + if (!$payout) { + throw new \RuntimeException('Refund payout not found.'); + } + if ((string)($payout['payout_type'] ?? '') !== 'cash_out' || (string)($payout['status'] ?? '') !== 'completed') { + throw new \RuntimeException('Only completed cash-out payouts can be reversed.'); + } + + $refundId = (int)($payout['refund_id'] ?? 0); + $refund = $db->query('SELECT * FROM refunds WHERE id = ? FOR UPDATE', [$refundId])->getRowArray(); + if (!$refund) { + throw new \RuntimeException('Refund not found.'); + } + if (!empty($refund['invoice_id'])) { + $db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int)$refund['invoice_id']]); + } + + $db->query('SELECT id FROM refund_payouts WHERE reversed_payout_id = ? FOR UPDATE', [$payoutId])->getResultArray(); + $unreversedCents = $this->calculateUnreversedPayoutAmountCents((int)$payout['id'], (int)$payout['amount_cents']); + if ($unreversedCents <= 0) { + throw new \RuntimeException('Refund payout is already fully reversed.'); + } + $reverseCents = $requestedCents ?? $unreversedCents; + if ($reverseCents > $unreversedCents) { + throw new \RuntimeException('Reversal amount exceeds unreversed payout amount.'); + } + $requestedFingerprint = $this->buildPayoutFingerprint( + 'refund_reversal', + $refundId, + $reverseCents, + (string)($payout['payment_method'] ?? ''), + (string)($payout['currency'] ?? $refund['currency'] ?? 'USD') ?: 'USD', + 'reversal:' . $payoutId + ); + if ($existingReversal) { + if (!$this->payoutFingerprintMatches($existingReversal, 'refund_reversal', $requestedFingerprint)) { + $db->transCommit(); + return $this->response->setStatusCode(409)->setJSON(['error' => 'IDEMPOTENCY_CONFLICT']); + } + + $db->transCommit(); + return $this->response->setJSON(['success' => 'Payout reversal already recorded.']); + } + + $reversalId = $this->refundPayoutModel->insert([ + 'refund_id' => $refundId, + 'amount_cents' => $reverseCents, + 'currency' => (string)($payout['currency'] ?? $refund['currency'] ?? 'USD') ?: 'USD', + 'payout_type' => 'reversal', + 'payment_method' => $payout['payment_method'] ?? null, + 'status' => 'completed', + 'external_reference' => 'reversal:' . $payoutId, + 'check_number' => null, + 'check_date' => null, + 'evidence_path' => null, + 'idempotency_key' => $idempotencyKey, + 'operation_type' => 'refund_reversal', + 'request_fingerprint_hash' => $requestedFingerprint, + 'processed_by' => session()->get('user_id'), + 'processed_at' => utc_now(), + 'reversed_payout_id' => $payoutId, + 'failure_code' => null, + 'failure_message' => $reason, + 'created_at' => utc_now(), + 'updated_at' => utc_now(), + ]); + if (!$reversalId) { + throw new \RuntimeException('Refund payout reversal could not be recorded.'); + } + $this->requireWrite($reversalId, 'REFUND_PAYOUT_REVERSAL_INSERT_FAILED', $this->refundPayoutModel); + + $netPaidCents = $this->refundEligibilityService->getNetCompletedCashOutCentsForRefund($refundId); + $approvedCents = isset($refund['approved_amount_cents']) && $refund['approved_amount_cents'] !== null + ? (int)$refund['approved_amount_cents'] + : (int)round(((float)($refund['refund_amount'] ?? 0)) * 100); + $newStatus = $netPaidCents <= 0 ? FinancialStatus::REFUND_APPROVED : ($netPaidCents < $approvedCents ? FinancialStatus::REFUND_PARTIALLY_PAID : FinancialStatus::REFUND_PAID); + + if (!$this->refundModel->update($refundId, [ + 'refund_paid_amount' => $netPaidCents / 100, + 'status' => $newStatus, + 'refunded_at' => $netPaidCents > 0 ? ($refund['refunded_at'] ?? utc_now()) : null, + 'updated_at' => utc_now(), + 'updated_by' => session()->get('user_id'), + 'note' => trim((string)($refund['note'] ?? '') . "\nReversal: " . $reason), + ])) { + throw new FinancialPersistenceException('REFUND_REVERSAL_PROJECTION_UPDATE_FAILED', $this->refundModel->errors()); + } + + if (!empty($refund['invoice_id'])) { + $this->invoiceLedgerService->recalculateInvoice((int)$refund['invoice_id']); + } + + $this->requireTransactionStatus($db); + $db->transCommit(); + } catch (\Throwable $e) { + $db->transRollback(); + return $this->refundFailureResponse('REFUND_PAYOUT_REVERSAL_FAILED', 'The refund payout could not be reversed.', $e, [ + 'payout_id' => $payoutId, + 'idempotency_key' => $idempotencyKey, + ]); + } + + return $this->response->setJSON(['success' => 'Refund payout reversed successfully.']); + } + + private function calculateUnreversedPayoutAmountCents(int $payoutId, int $originalAmountCents): int + { + $row = $this->refundPayoutModel->db->table('refund_payouts') + ->select('COALESCE(SUM(amount_cents),0) AS reversed_cents') + ->where('reversed_payout_id', $payoutId) + ->where('payout_type', 'reversal') + ->where('status', 'completed') + ->get() + ->getRowArray(); + + return max(0, $originalAmountCents - (int)($row['reversed_cents'] ?? 0)); + } + + public function processRefunds() + { + return $this->recalculateOverpayments(); } /** Keep your listing; added extra fields for clarity */ @@ -711,7 +926,39 @@ class RefundController extends BaseController foreach ($refunds as &$r) { $r['approved_by_name'] = trim(($r['approved_by_firstname'] ?? '') . ' ' . ($r['approved_by_lastname'] ?? '')) ?: '-'; + $r['available_refundable_credit_cents'] = null; + $r['available_refundable_credit'] = null; + $sourceType = (string)($r['source_type'] ?? ''); + $sourceId = (int)($r['source_id'] ?? 0); + if ($sourceType !== '' && $sourceId > 0) { + try { + $eligibility = $this->refundEligibilityService->calculateAvailableCredit( + (int)$r['parent_id'], + !empty($r['invoice_id']) ? (int)$r['invoice_id'] : null, + $sourceType, + $sourceId, + (int)$r['id'] + ); + $r['available_refundable_credit_cents'] = $eligibility->availableAmountCents; + $r['available_refundable_credit'] = $eligibility->availableAmountCents / 100; + $r['approved_refund_reservation_cents'] = $eligibility->reservedAmountCents; + } catch (\Throwable $e) { + log_message('error', 'Refund eligibility projection failed for refund ' . (int)$r['id'] . ': ' . $e->getMessage()); + } + } + try { + $projection = $this->parentLedgerService->getParentProjection( + (int)$r['parent_id'], + (string)($r['school_year'] ?? ''), + !empty($r['semester']) ? (string)$r['semester'] : null + ); + $r['parent_available_refundable_credit_cents'] = (int)($projection['availableRefundableCreditCents'] ?? 0); + $r['parent_available_refundable_credit'] = (float)($projection['available_refundable_credit'] ?? 0); + } catch (\Throwable $e) { + log_message('error', 'Parent refund projection failed for refund ' . (int)$r['id'] . ': ' . $e->getMessage()); + } } + unset($r); $parentId = !empty($refunds) ? $refunds[0]['parent_id'] : null; @@ -734,65 +981,61 @@ class RefundController extends BaseController $iid = (int)$inv['id']; $year = (string)$inv['school_year']; - // Compute per-invoice overpayment now (independent of current term) - $paid = (float)($this->paymentModel - ->select('COALESCE(SUM(paid_amount),0) AS tot') - ->where('invoice_id', $iid) - ->first()['tot'] ?? 0); - $disc = (float)($this->db->table('discount_usages') - ->select('COALESCE(SUM(discount_amount),0) AS tot') - ->where('invoice_id', $iid) - ->get()->getRowArray()['tot'] ?? 0); - $rfnd = (float)($this->refundModel - ->select('COALESCE(SUM(refund_paid_amount),0) AS tot') - ->where('invoice_id', $iid) - ->whereIn('status', ['Partial','Paid']) - ->first()['tot'] ?? 0); - $total = (float)($inv['total_amount'] ?? 0); - $raw = round($total - $disc - $paid - $rfnd, 2); - if ($raw < -0.0001) { - $over = abs($raw); + $ledger = $this->invoiceLedgerService->calculateInvoice($iid); + $over = (float)($ledger['customer_credit'] ?? 0.0); + if ($over > 0.0001) { // If there is ANY open refund for this invoice (any request), update it rather than create $openRow = $this->refundModel ->where('invoice_id', $iid) + ->where('source_type', 'invoice_overpayment') + ->where('source_id', $iid) ->whereIn('status', ['Pending','Approved','Partial']) ->orderBy('id', 'DESC') ->first(); if ($openRow) { - $this->refundModel->update((int)$openRow['id'], [ - 'refund_amount' => $over, - 'request' => 'overpayment', - 'updated_at' => utc_now(), - 'updated_by' => session()->get('user_id'), - ]); + $this->requireWrite($this->refundModel->update( + (int)$openRow['id'], + $this->buildRefundRecalculationUpdate($openRow, $over, [ + 'request' => 'overpayment', + 'source_type' => 'invoice_overpayment', + 'source_id' => $iid, + ]) + ), 'REFUND_RECALC_UPDATE_FAILED', $this->refundModel); return redirect()->to(site_url('refunds/list'))->with('success', 'Overpayment updated for invoice ' . esc($invoiceNumber)); } // Else, if no open rows exist, either update existing overpayment (Paid) — skip creating new $existing = $this->refundModel ->where('invoice_id', $iid) - ->where('request', 'overpayment') + ->where('source_type', 'invoice_overpayment') + ->where('source_id', $iid) ->orderBy('id', 'DESC') ->first(); - if ($existing) { - // If it's Paid, do not create a new duplicate line + if ($existing && FinancialStatus::normalizeRefundStatus($existing['status'] ?? null) !== FinancialStatus::REFUND_PAID) { + // Open/non-paid overpayment rows remain the active reservation for this source. return redirect()->to(site_url('refunds/list'))->with('info', 'Overpayment was already resolved for this invoice.'); } // As a last resort, create a single overpayment row - $this->refundModel->insert([ + $insertId = $this->refundModel->insert([ 'parent_id' => $pid, 'school_year' => $year, 'semester' => $inv['semester'] ?? null, 'invoice_id' => $iid, 'refund_amount' => $over, + 'requested_amount_cents' => (int) round($over * 100), + 'approved_amount_cents' => null, + 'currency' => 'USD', 'refund_paid_amount' => 0.00, - 'status' => 'Pending', + 'status' => FinancialStatus::REFUND_REQUESTED, 'request' => 'overpayment', + 'source_type' => 'invoice_overpayment', + 'source_id' => $iid, 'reason' => 'Auto-detected per-invoice overpayment (manual)', 'updated_by' => session()->get('user_id'), 'created_at' => utc_now(), 'updated_at' => utc_now(), ]); + $this->requireWrite($insertId, 'REFUND_RECALC_INSERT_FAILED', $this->refundModel); $user = $this->userModel->select('id, email, firstname, lastname')->find($pid) ?: []; $eventData = [ 'user_id' => (int)($user['id'] ?? $pid), @@ -821,12 +1064,10 @@ class RefundController extends BaseController } /** Decision endpoint (Approved/Rejected) with reason */ - public function updateStatus() + public function updateStatus(?int $routeRefundId = null) { - log_message('debug', 'POST data: ' . print_r($_POST, true)); - - $refundId = (int)$this->request->getPost('refund_id'); - $status = $this->request->getPost('status'); + $refundId = (int)($routeRefundId ?: $this->request->getPost('refund_id')); + $status = FinancialStatus::normalizeRefundStatus((string)$this->request->getPost('status')); $reason = $this->request->getPost('reason'); if ($refundId <= 0 || !in_array($status, self::DECISIONS, true)) { @@ -836,36 +1077,61 @@ class RefundController extends BaseController return $this->response->setJSON(['error' => 'Reason is required for approval or rejection.']); } - $refund = $this->refundModel->find($refundId); - if (!$refund) { - return $this->response->setJSON(['error' => 'Refund not found.']); - } - $this->db->transBegin(); try { + $refund = $this->db->query('SELECT * FROM refunds WHERE id = ? FOR UPDATE', [$refundId])->getRowArray(); + if (!$refund) { + throw new \RuntimeException('Refund not found.'); + } + if (FinancialStatus::normalizeRefundStatus($refund['status'] ?? null) !== FinancialStatus::REFUND_REQUESTED) { + throw new \RuntimeException('Only pending refunds can be approved or rejected.'); + } if (!empty($refund['invoice_id'])) { $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $refund['invoice_id']]); } + $approvedCents = null; + if ($status === FinancialStatus::REFUND_APPROVED) { + $sourceType = (string)($refund['source_type'] ?? ''); + $sourceId = (int)($refund['source_id'] ?? 0); + if ($sourceType === '' || $sourceId <= 0) { + throw new \RuntimeException('Refund source is required for approval.'); + } + $approvedCents = (int)round(((float)($refund['refund_amount'] ?? 0)) * 100); + $this->lockRefundSourceForUpdate($sourceType, $sourceId, isset($refund['invoice_id']) ? (int)$refund['invoice_id'] : null); + $eligibility = $this->refundEligibilityService->calculateAvailableCredit( + (int)$refund['parent_id'], + isset($refund['invoice_id']) ? (int)$refund['invoice_id'] : null, + $sourceType, + $sourceId, + $refundId + ); + $this->refundEligibilityService->validateRequestedAmount($eligibility, $approvedCents); + } + $ok = $this->refundModel->update($refundId, [ 'status' => $status, 'reason' => $reason, + 'approved_amount_cents' => $approvedCents, 'approved_at' => utc_now(), 'approved_by' => session()->get('user_id'), 'updated_at' => utc_now(), 'updated_by' => session()->get('user_id'), ]); + $this->requireWrite($ok, 'REFUND_STATUS_UPDATE_FAILED', $this->refundModel); if (!empty($refund['invoice_id'])) { $this->invoiceLedgerService->recalculateInvoice((int) $refund['invoice_id']); } + $this->requireTransactionStatus($this->db); $this->db->transCommit(); } catch (\Throwable $e) { $this->db->transRollback(); - log_message('error', 'updateStatus failed: ' . $e->getMessage()); - $ok = false; + return $this->refundFailureResponse('REFUND_STATUS_UPDATE_FAILED', 'The refund status could not be updated.', $e, [ + 'refund_id' => $refundId, + ]); } return $this->response->setJSON($ok ? ['success' => 'Refund status updated successfully.'] @@ -898,6 +1164,71 @@ class RefundController extends BaseController return $this->response->download($path, null); } + private function buildPayoutFingerprint( + string $operationType, + int $refundId, + int $amountCents, + string $paymentMethod, + string $currency, + ?string $externalReference + ): string { + $payload = [ + 'operation_type' => $operationType, + 'refund_id' => $refundId, + 'amount_cents' => $amountCents, + 'payment_method' => $paymentMethod, + 'currency' => strtoupper($currency ?: 'USD'), + 'external_reference' => $externalReference, + ]; + + return hash('sha256', json_encode($payload, JSON_UNESCAPED_SLASHES)); + } + + private function lockRefundSourceForUpdate(string $sourceType, int $sourceId, ?int $invoiceId): void + { + if ($sourceType === 'invoice_overpayment') { + $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId ?: $sourceId])->getRowArray(); + } elseif (in_array($sourceType, ['payment_duplicate', 'payment_correction'], true)) { + $payment = $this->db->query('SELECT * FROM payments WHERE id = ? FOR UPDATE', [$sourceId])->getRowArray(); + if (!$payment) { + throw new \RuntimeException('Refund payment source not found.'); + } + $lockedInvoiceId = $invoiceId ?: (int)($payment['invoice_id'] ?? 0); + if ($lockedInvoiceId > 0) { + $this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$lockedInvoiceId])->getRowArray(); + } + if ($this->db->tableExists('payment_corrections')) { + $this->db->query( + 'SELECT id FROM payment_corrections WHERE payment_id = ? AND status = ? FOR UPDATE', + [$sourceId, 'approved'] + )->getResultArray(); + } + } elseif (in_array($sourceType, ['credit_memo', 'administrative_credit'], true)) { + throw new \RuntimeException('Unsupported refund source type.'); + } + + $this->db->query( + "SELECT id FROM refunds + WHERE source_type = ? + AND source_id = ? + AND status IN ('Approved','Partial','approved','partially_paid') + FOR UPDATE", + [$sourceType, $sourceId] + )->getResultArray(); + } + + private function payoutFingerprintMatches(array $existingPayout, string $operationType, string $requestFingerprint): bool + { + $storedOperation = (string)($existingPayout['operation_type'] ?? ''); + $storedFingerprint = (string)($existingPayout['request_fingerprint_hash'] ?? ''); + + if ($storedOperation === '' && $storedFingerprint === '') { + return false; + } + + return $storedOperation === $operationType && hash_equals($storedFingerprint, $requestFingerprint); + } + private function canViewRefund(array $refund): bool { $roles = array_map('strtolower', (array) (session()->get('roles') ?? [])); diff --git a/app/Controllers/View/ReimbursementController.php b/app/Controllers/View/ReimbursementController.php index a9ff4cf..0f64a68 100644 --- a/app/Controllers/View/ReimbursementController.php +++ b/app/Controllers/View/ReimbursementController.php @@ -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) { diff --git a/app/Database/Migrations/2026-07-18-000300_PaymentsLogicAndDataRepairSupport.php b/app/Database/Migrations/2026-07-18-000300_PaymentsLogicAndDataRepairSupport.php index 61c5faf..85d4c72 100644 --- a/app/Database/Migrations/2026-07-18-000300_PaymentsLogicAndDataRepairSupport.php +++ b/app/Database/Migrations/2026-07-18-000300_PaymentsLogicAndDataRepairSupport.php @@ -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', diff --git a/app/Database/Migrations/2026-07-19-000100_CreateInvoiceLines.php b/app/Database/Migrations/2026-07-19-000100_CreateInvoiceLines.php new file mode 100644 index 0000000..f44c764 --- /dev/null +++ b/app/Database/Migrations/2026-07-19-000100_CreateInvoiceLines.php @@ -0,0 +1,167 @@ +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, + ]); + } + } +} diff --git a/app/Database/Migrations/2026-07-19-000200_AddRefundSourcesAndPayouts.php b/app/Database/Migrations/2026-07-19-000200_AddRefundSourcesAndPayouts.php new file mode 100644 index 0000000..782457f --- /dev/null +++ b/app/Database/Migrations/2026-07-19-000200_AddRefundSourcesAndPayouts.php @@ -0,0 +1,214 @@ +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, + ]); + } + } +} diff --git a/app/Database/Migrations/2026-07-19-000300_AddRefundReconciliationFields.php b/app/Database/Migrations/2026-07-19-000300_AddRefundReconciliationFields.php new file mode 100644 index 0000000..94fabc7 --- /dev/null +++ b/app/Database/Migrations/2026-07-19-000300_AddRefundReconciliationFields.php @@ -0,0 +1,56 @@ +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); + } + } + } +} diff --git a/app/Database/Migrations/2026-07-19-000400_FinancialWorkflowHardening.php b/app/Database/Migrations/2026-07-19-000400_FinancialWorkflowHardening.php new file mode 100644 index 0000000..7bd3b1d --- /dev/null +++ b/app/Database/Migrations/2026-07-19-000400_FinancialWorkflowHardening.php @@ -0,0 +1,191 @@ +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}."); + } + +} diff --git a/app/Database/Migrations/2026-07-19-000500_FinancialReimbursementPoHardening.php b/app/Database/Migrations/2026-07-19-000500_FinancialReimbursementPoHardening.php new file mode 100644 index 0000000..9c9caf3 --- /dev/null +++ b/app/Database/Migrations/2026-07-19-000500_FinancialReimbursementPoHardening.php @@ -0,0 +1,153 @@ +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()); + } + } +} diff --git a/app/Database/Migrations/2026-07-19-000600_CreatePaymentCorrections.php b/app/Database/Migrations/2026-07-19-000600_CreatePaymentCorrections.php new file mode 100644 index 0000000..ca6e136 --- /dev/null +++ b/app/Database/Migrations/2026-07-19-000600_CreatePaymentCorrections.php @@ -0,0 +1,55 @@ +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}."); + } +} diff --git a/app/Database/Migrations/2026-07-19-000700_CreateReimbursementReversals.php b/app/Database/Migrations/2026-07-19-000700_CreateReimbursementReversals.php new file mode 100644 index 0000000..6fae9d4 --- /dev/null +++ b/app/Database/Migrations/2026-07-19-000700_CreateReimbursementReversals.php @@ -0,0 +1,35 @@ +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); + } + } +} diff --git a/app/Database/Migrations/2026-07-19-000800_CreateInventoryReceiptOperations.php b/app/Database/Migrations/2026-07-19-000800_CreateInventoryReceiptOperations.php new file mode 100644 index 0000000..320088b --- /dev/null +++ b/app/Database/Migrations/2026-07-19-000800_CreateInventoryReceiptOperations.php @@ -0,0 +1,54 @@ +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); + } + } +} diff --git a/app/Libraries/FinancialAttachmentService.php b/app/Libraries/FinancialAttachmentService.php index fc7d2c5..e464f47 100644 --- a/app/Libraries/FinancialAttachmentService.php +++ b/app/Libraries/FinancialAttachmentService.php @@ -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 diff --git a/app/Libraries/FinancialCorrectionReportService.php b/app/Libraries/FinancialCorrectionReportService.php new file mode 100644 index 0000000..fd90d7f --- /dev/null +++ b/app/Libraries/FinancialCorrectionReportService.php @@ -0,0 +1,66 @@ +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); + } +} diff --git a/app/Libraries/FinancialPersistenceException.php b/app/Libraries/FinancialPersistenceException.php new file mode 100644 index 0000000..501a130 --- /dev/null +++ b/app/Libraries/FinancialPersistenceException.php @@ -0,0 +1,13 @@ +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); + } +} diff --git a/app/Libraries/FinancialStatus.php b/app/Libraries/FinancialStatus.php index 3182cdf..c4f0386 100644 --- a/app/Libraries/FinancialStatus.php +++ b/app/Libraries/FinancialStatus.php @@ -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, }; } diff --git a/app/Libraries/InvoiceAdjustmentService.php b/app/Libraries/InvoiceAdjustmentService.php new file mode 100644 index 0000000..d5d66b2 --- /dev/null +++ b/app/Libraries/InvoiceAdjustmentService.php @@ -0,0 +1,252 @@ +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); + } + } +} diff --git a/app/Libraries/InvoiceIssuanceService.php b/app/Libraries/InvoiceIssuanceService.php new file mode 100644 index 0000000..b4b273b --- /dev/null +++ b/app/Libraries/InvoiceIssuanceService.php @@ -0,0 +1,95 @@ +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); + } + } +} diff --git a/app/Libraries/InvoiceLedgerResult.php b/app/Libraries/InvoiceLedgerResult.php new file mode 100644 index 0000000..9c87340 --- /dev/null +++ b/app/Libraries/InvoiceLedgerResult.php @@ -0,0 +1,13 @@ +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; + } } diff --git a/app/Libraries/IssueInvoiceCommand.php b/app/Libraries/IssueInvoiceCommand.php new file mode 100644 index 0000000..8fb26c5 --- /dev/null +++ b/app/Libraries/IssueInvoiceCommand.php @@ -0,0 +1,14 @@ +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, '.', ''); + } +} diff --git a/app/Libraries/RefundEligibilityResult.php b/app/Libraries/RefundEligibilityResult.php new file mode 100644 index 0000000..525e73e --- /dev/null +++ b/app/Libraries/RefundEligibilityResult.php @@ -0,0 +1,26 @@ + $this->sourceCreditCents, + 'completedPayoutCents' => $this->completedPayoutCents, + 'reservedAmountCents' => $this->reservedAmountCents, + 'availableAmountCents' => $this->availableAmountCents, + 'reasonCodes' => $this->reasonCodes, + ]; + } +} diff --git a/app/Libraries/RefundEligibilityService.php b/app/Libraries/RefundEligibilityService.php new file mode 100644 index 0000000..2d73708 --- /dev/null +++ b/app/Libraries/RefundEligibilityService.php @@ -0,0 +1,321 @@ +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; + } + } +} diff --git a/app/Models/AdditionalChargeModel.php b/app/Models/AdditionalChargeModel.php index f0b28ad..8946935 100644 --- a/app/Models/AdditionalChargeModel.php +++ b/app/Models/AdditionalChargeModel.php @@ -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', ]; diff --git a/app/Models/DiscountUsageModel.php b/app/Models/DiscountUsageModel.php index ca594f5..bae9f57 100644 --- a/app/Models/DiscountUsageModel.php +++ b/app/Models/DiscountUsageModel.php @@ -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 } } - diff --git a/app/Models/InvoiceLineModel.php b/app/Models/InvoiceLineModel.php new file mode 100644 index 0000000..98f216a --- /dev/null +++ b/app/Models/InvoiceLineModel.php @@ -0,0 +1,41 @@ + '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]', + ]; +} diff --git a/app/Models/PaymentCorrectionModel.php b/app/Models/PaymentCorrectionModel.php new file mode 100644 index 0000000..33cc98c --- /dev/null +++ b/app/Models/PaymentCorrectionModel.php @@ -0,0 +1,27 @@ + '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.', diff --git a/app/Models/PurchaseOrderItemModel.php b/app/Models/PurchaseOrderItemModel.php index fcc1bee..9938636 100644 --- a/app/Models/PurchaseOrderItemModel.php +++ b/app/Models/PurchaseOrderItemModel.php @@ -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]', ]; } diff --git a/app/Models/RefundModel.php b/app/Models/RefundModel.php index 3eda549..27a8c5b 100644 --- a/app/Models/RefundModel.php +++ b/app/Models/RefundModel.php @@ -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', diff --git a/app/Models/RefundPayoutModel.php b/app/Models/RefundPayoutModel.php new file mode 100644 index 0000000..cf02f59 --- /dev/null +++ b/app/Models/RefundPayoutModel.php @@ -0,0 +1,47 @@ + '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]', + ]; +} diff --git a/app/Models/ReimbursementModel.php b/app/Models/ReimbursementModel.php index bf3b4e3..1ec1b6f 100644 --- a/app/Models/ReimbursementModel.php +++ b/app/Models/ReimbursementModel.php @@ -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]', ]; diff --git a/app/Views/discounts/list.php b/app/Views/discounts/list.php index ca96af9..3d2646f 100644 --- a/app/Views/discounts/list.php +++ b/app/Views/discounts/list.php @@ -6,7 +6,7 @@
Create New Voucher - Apply Voucher to Parents + Apply Voucher to Parents
diff --git a/app/Views/payment/financial_report.php b/app/Views/payment/financial_report.php index 79a5647..c8155a7 100644 --- a/app/Views/payment/financial_report.php +++ b/app/Views/payment/financial_report.php @@ -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); ?> @@ -125,7 +117,7 @@ $ $ $ - + @@ -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) ? `${inv.parent_name||''}` : @@ -402,7 +389,7 @@ ${fmt(balance)} ${fmt(refunded)} ${fmt(discounted)} - ${status}`; + ${statusLabel}`; tbody.appendChild(tr); }); diff --git a/app/Views/payment/manual_pay.php b/app/Views/payment/manual_pay.php index 52c6447..4ac86ac 100644 --- a/app/Views/payment/manual_pay.php +++ b/app/Views/payment/manual_pay.php @@ -371,8 +371,7 @@ $ $ $ - - $ + $ @@ -397,6 +396,7 @@
+