fix financials
Tests / PHPUnit (push) Failing after 1m21s

This commit is contained in:
root
2026-07-18 22:57:40 -04:00
parent 068e739408
commit a30c1398a1
61 changed files with 10908 additions and 1775 deletions
+43 -77
View File
@@ -263,6 +263,7 @@ $routes->get('refunds/list', 'View\RefundController::listRefunds', ['filter' =>
$routes->post('refunds/request', 'View\RefundController::requestRefund', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
$routes->post('refunds/approve/(:num)', 'View\RefundController::approveRefund/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/pay/(:num)', 'View\RefundController::payRefund/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/reversePayout/(:num)', 'View\RefundController::reversePayout/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/updateStatus/(:num)', 'View\RefundController::updateStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/processRefunds', 'View\RefundController::processRefunds', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('refunds/updateStatus', 'View\RefundController::updateStatus', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
@@ -566,14 +567,14 @@ $routes->match(['get', 'post'], 'administrator/events/edit/(:num)', 'View\EventC
$routes->post('administrator/events/delete/(:num)', 'View\EventController::delete/$1'); // handle delete
$routes->post('payment/event_charges', 'View\EventController::eventUpdate');
$routes->post('payment/event_charges', 'View\EventController::eventUpdate', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
// Parent event participation
$routes->get('administrator/event-charges', 'View\EventController::eventShow');
$routes->get('administrator/event-charges/pdf', 'View\EventController::eventChargesPdf');
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1');
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1');
$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1');
$routes->post('administrator/event-charges/remove/(:num)', 'View\EventController::removeCharge/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('administrator/event-charges/payment/(:num)', 'View\EventController::toggleEventPayment/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('administrator/event-charges/waiver/(:num)', 'View\EventController::toggleWaiverStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('administrator/get-students-with-charges', 'View\EventController::getStudentsWithCharges');
$routes->get('parent/events', 'View\ParentController::parentEventPage', ['filter' => 'auth:parent']);
@@ -584,7 +585,7 @@ $routes->post('parent/updateParticipation', 'View\ParentController::updatePartic
// Web View Routes for Invoices
$routes->get('invoices/getByParent/(:num)', 'View\InvoiceController::getByParent/$1');
$routes->get('invoices/create', 'View\InvoiceController::create');
$routes->post('invoices/updateStatus/(:num)', 'View\InvoiceController::updateStatus/$1');
$routes->post('invoices/updateStatus/(:num)', 'View\InvoiceController::updateStatus/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
// app/Config/Routes.php
@@ -606,24 +607,6 @@ $routes->group('payment', ['filter' => 'auth'], static function ($routes) {
$routes->get('file/(:num)', 'View\PaymentController::servePaymentFile/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
});
// Optional: if someone opens it in the browser via GET, redirect to the search page:
$routes->get('payment/manual_pay_update', function () {
return redirect()->to(site_url('payment/manual_pay'));
});
/*
$routes->group('admin/charges', ['filter' => 'auth'], static function($routes) {
$routes->get('/', 'ExtraChargesController::index');
$routes->get('parents', 'ExtraChargesController::parentOptions'); // <-- for Select2
$routes->post('store', 'ExtraChargesController::store');
$routes->post('update/(:num)', 'ExtraChargesController::update/$1');
$routes->post('void/(:num)', 'ExtraChargesController::void/$1');
});
*/
$routes->group('admin', ['filter' => 'auth'], static function ($routes) {
$routes->group('charges', static function ($routes) {
// Serve HTML page from ::page, keep ::index for API JSON
@@ -631,10 +614,12 @@ $routes->group('admin', ['filter' => 'auth'], static function ($routes) {
$routes->get('parents', [ExtraChargesController::class, 'parentOptions']);
$routes->get('invoices', [ExtraChargesController::class, 'invoicesForParent']); // <-- AJAX endpoint
$routes->get('list', [ExtraChargesController::class, 'apiList']);
$routes->post('store', [ExtraChargesController::class, 'store']);
$routes->post('update/(:num)', [ExtraChargesController::class, 'update/$1']);
$routes->post('void/(:num)', [ExtraChargesController::class, 'void/$1']);
$routes->post('reverse/(:num)', [ExtraChargesController::class, 'reverse/$1']);
$routes->post('store', [ExtraChargesController::class, 'store'], ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
$routes->post('update/(:num)', [ExtraChargesController::class, 'update/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('approve/(:num)', [ExtraChargesController::class, 'approve/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('apply/(:num)', [ExtraChargesController::class, 'apply/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('void/(:num)', [ExtraChargesController::class, 'void/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('reverse/(:num)', [ExtraChargesController::class, 'reverse/$1'], ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
});
});
@@ -683,6 +668,7 @@ $routes->get('reimbursements/batch/admin-file/(:segment)/(:segment)', 'View\Reim
$routes->post('reimbursements/batch/send', 'View\ReimbursementController::sendBatchEmail', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('reimbursements/batch/export', 'View\ReimbursementController::exportBatch', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->post('reimbursements/process', 'View\ReimbursementController::process', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->post('reimbursements/reverse/(:num)', 'View\ReimbursementController::reverse/$1', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('reimbursements/export', 'View\ReimbursementController::export', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
$routes->get('reimbursements', 'View\ReimbursementController::index', ['filter' => 'auth:view_financial_reports|administrator|administrative staff|principal']);
@@ -733,21 +719,21 @@ $routes->post(
'View\InvoiceController::generateInvoice',
['filter' => 'auth:create_invoice']
);
$routes->get('/parent/invoice_payment', 'View\InvoiceController::invoicePayment'); // Route for invoice payment page
$routes->get('invoice/pay/(:num)', 'View\InvoiceController::payInvoice/$1'); // Route for paying the invoice
$routes->get('invoices', 'View\InvoiceController::index'); // Default route for invoices
$routes->get('invoices/(:any)', 'View\InvoiceController::index/$1'); // Route with school year filter
$routes->get('invoices/recalculate/(:segment)', 'View\PaymentController::recalculateInvoiceByNumber/$1', ['filter' => 'auth:update_invoice']); // Recalculate invoice totals/balance
$routes->get('/parent/invoice_payment', 'View\InvoiceController::invoicePayment', ['filter' => 'auth:parent']); // Route for invoice payment page
$routes->get('invoice/pay/(:num)', 'View\InvoiceController::payInvoice/$1', ['filter' => 'auth:view_invoice|view_payment|administrator|administrative staff|principal|parent']); // Route for paying the invoice
$routes->get('invoices', 'View\InvoiceController::index', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); // Default route for invoices
$routes->get('invoices/(:any)', 'View\InvoiceController::index/$1', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']); // Route with school year filter
$routes->post('invoices/recalculate/(:segment)', 'View\PaymentController::recalculateInvoiceByNumber/$1', ['filter' => 'auth:update_invoice']); // Recalculate invoice totals/balance
$routes->get('invoice/pdf/(:num)', 'View\InvoiceController::generatePdfInvoice/$1', ['filter' => 'auth:invoice_pdf,read']);
// Web View Routes for Payments
$routes->get('payments/getByParent/(:num)', 'View\PaymentController::getByParent/$1');
$routes->get('payments/create', 'View\PaymentController::create');
$routes->post('payments/updateBalance/(:num)', 'View\PaymentController::updateBalance/$1');
$routes->get('payments/getByParent/(:num)', 'View\PaymentController::getByParent/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('payments/create', 'View\PaymentController::create', ['filter' => 'auth:create_payment|create_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('payments/updateBalance/(:num)', 'View\PaymentController::updateBalance/$1', ['filter' => 'auth:update_payment|update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
// Routes for payment pages
$routes->get('payments/view/(:num)', 'View\PaymentController::viewPayment/$1');
$routes->get('payments/view/(:num)', 'View\PaymentController::viewPayment/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
$routes->get('payment/get_enrolled_students/(:num)', 'View\PaymentController::getEnrolledStudents/$1', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
// Payment Notification Management
@@ -925,11 +911,11 @@ $routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
$routes->group('movements', static function ($routes) {
$routes->get('/', 'View\InventoryController::movementsIndex'); // /inventory/movements
$routes->get('create', 'View\InventoryController::movementsCreate'); // /inventory/movements/create
$routes->post('store', 'View\InventoryController::movementsStore'); // /inventory/movements/store
$routes->post('store', 'View\InventoryController::movementsStore', ['filter' => 'auth:create_inventory|administrator|administrative staff|principal,create']); // /inventory/movements/store
$routes->get('edit/(:num)', 'View\InventoryController::movementsEdit/$1'); // /inventory/movements/edit/123
$routes->post('update/(:num)', 'View\InventoryController::movementsUpdate/$1'); // /inventory/movements/update/123
$routes->post('delete/(:num)', 'View\InventoryController::movementsDelete/$1'); // /inventory/movements/delete/123
$routes->post('bulk-delete', 'View\InventoryController::movementsBulkDelete');
$routes->post('update/(:num)', 'View\InventoryController::movementsUpdate/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']); // /inventory/movements/update/123
$routes->post('delete/(:num)', 'View\InventoryController::movementsDelete/$1', ['filter' => 'auth:delete_inventory|administrator|administrative staff|principal,delete']); // /inventory/movements/delete/123
$routes->post('bulk-delete', 'View\InventoryController::movementsBulkDelete', ['filter' => 'auth:delete_inventory|administrator|administrative staff|principal,delete']);
});
/** ---------------------------
@@ -938,19 +924,21 @@ $routes->group('inventory', ['filter' => 'csrf'], static function ($routes) {
// Summary & actions (keep these BEFORE the wildcard too)
$routes->get('summary-all', 'View\InventoryController::summaryAll');
$routes->get('adjust/(:num)', 'View\InventoryController::adjustForm/$1');
$routes->post('adjust/(:num)', 'View\InventoryController::adjustStore/$1');
$routes->post('adjust/(:num)', 'View\InventoryController::adjustStore/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
$routes->get('books/distribute', 'View\InventoryController::teacherDistributeForm');
$routes->post('books/distribute', 'View\InventoryController::teacherDistributeStore');
$routes->post('books/distribute', 'View\InventoryController::teacherDistributeStore', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
$routes->get('classroom/audit/(:num)', 'View\InventoryController::auditClassroomForm/$1');
$routes->post('classroom/audit/(:num)', 'View\InventoryController::auditClassroomStore/$1');
$routes->post('classroom/audit/(:num)', 'View\InventoryController::auditClassroomStore/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
// CRUD for items/categories
$routes->get('create/(:alpha)', 'View\InventoryController::create/$1');
$routes->post('store', 'View\InventoryController::store');
$routes->post('store', 'View\InventoryController::store', ['filter' => 'auth:create_inventory|administrator|administrative staff|principal,create']);
$routes->get('edit/(:num)', 'View\InventoryController::edit/$1');
$routes->post('update/(:num)', 'View\InventoryController::update/$1');
$routes->post('delete/(:num)', 'View\InventoryController::delete/$1');
$routes->post('category/save', 'View\InventoryController::saveCategory');
$routes->post('update/(:num)', 'View\InventoryController::update/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
$routes->post('delete/(:num)', 'View\InventoryController::delete/$1', ['filter' => 'auth:delete_inventory|administrator|administrative staff|principal,delete']);
$routes->post('category/save', 'View\InventoryController::saveCategory', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
$routes->post('po/receive/(:num)', 'View\PurchaseOrderController::receive/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
$routes->post('po/receipt/reverse/(:num)', 'View\PurchaseOrderController::reverseReceipt/$1', ['filter' => 'auth:update_inventory|administrator|administrative staff|principal,update']);
/** ---------------------------
* Index & section switcher
@@ -1219,14 +1207,17 @@ $routes->get('/help_center', 'View\PageController::helpCenter');
//payment
$routes->get('/payment', 'View\PaymentController::redirectPage', ['filter' => 'auth:parent']);
$routes->get('/payment/manual', 'View\PaymentController::manual');
$routes->post('/payment/manual', 'View\PaymentController::manual');
$routes->get('/payment/manual', 'View\PaymentController::manual', ['filter' => 'auth:view_invoice|view_payment|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('/payment/manual', 'View\PaymentController::manual', ['filter' => 'auth:create_payment|create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
// Voucher management
$routes->get('discounts/list', 'View\DiscountController::listVouchers', ['filter' => 'auth:view_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->match(['get', 'post'], 'discount/create', 'View\DiscountController::createVoucher', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
$routes->match(['get', 'post'], 'discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->match(['get', 'post'], 'discount/apply', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('discount/create', 'View\DiscountController::createVoucher', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('discount/create', 'View\DiscountController::createVoucher', ['filter' => 'auth:create_invoice|view_financial_reports|administrator|administrative staff|principal,create']);
$routes->get('discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('discount/editVoucher/(:num)', 'View\DiscountController::editVoucher/$1', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
$routes->get('discount/voucher-form', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal']);
$routes->post('discount/apply', 'View\DiscountController::applyVoucher', ['filter' => 'auth:update_invoice|view_financial_reports|administrator|administrative staff|principal,update']);
@@ -1370,14 +1361,11 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
$routes->get('payments', 'View\UserController::index');
$routes->get('payments/(:num)', 'View\SupportController::show/$1');
$routes->get('payments/parent/(:num)', 'View\SupportController::getByParent/$1');
$routes->post('payments', 'View\TeacherController::create');
$routes->put('payments/(:num)', 'View\TeacherController::update/$1');
// Invoices
$routes->get('invoices', 'View\UserController::index');
$routes->get('invoices/(:num)', 'View\SupportController::show/$1');
$routes->get('invoices/parent/(:num)', 'View\SupportController::getByParent/$1');
$routes->put('invoices/(:num)', 'View\TeacherController::update/$1');
// Notifications
$routes->get('notifications', 'View\UserController::index');
@@ -1398,14 +1386,10 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
// Expenses
$routes->get('expenses', 'View\UserController::index');
$routes->get('expenses/(:num)', 'View\SupportController::show/$1');
$routes->post('expenses', 'View\TeacherController::create');
$routes->put('expenses/(:num)', 'View\TeacherController::update/$1');
// Reimbursements
$routes->get('reimbursements', 'View\UserController::index');
$routes->get('reimbursements/(:num)', 'View\SupportController::show/$1');
$routes->post('reimbursements', 'View\TeacherController::create');
$routes->put('reimbursements/(:num)', 'View\TeacherController::update/$1');
// Dashboard Data
$routes->get('dashboard', 'DashboardController::index');
@@ -1471,19 +1455,10 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
// Refunds
$routes->get('refunds', 'View\UserController::index');
$routes->get('refunds/(:num)', 'View\SupportController::show/$1');
$routes->post('refunds', 'View\TeacherController::create');
$routes->put('refunds/(:num)', 'View\TeacherController::update/$1');
// Discounts
$routes->get('discounts', 'View\UserController::index');
$routes->get('discounts/code/(:segment)', 'View\SupportController::getByCode/$1');
$routes->post('discounts/apply', 'View\SupportController::apply');
// Payment Transactions
$routes->get('payment-transactions', 'View\UserController::index');
$routes->get('payment-transactions/(:num)', 'View\SupportController::show/$1');
$routes->get('payment-transactions/payment/(:num)', 'View\SupportController::getByPayment/$1');
$routes->post('payment-transactions', 'View\TeacherController::create');
// Emergency Contacts
$routes->get('emergency-contacts', 'View\UserController::index');
@@ -1529,9 +1504,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
// Extra Charges
$routes->get('extra-charges', 'View\UserController::index');
$routes->get('extra-charges/(:num)', 'View\SupportController::show/$1');
$routes->post('extra-charges', 'View\TeacherController::create');
$routes->put('extra-charges/(:num)', 'View\TeacherController::update/$1');
$routes->post('extra-charges/(:num)/void', 'View\SupportController::void/$1');
$routes->get('extra-charges/parents', 'View\SupportController::parentOptions');
// Flags
@@ -1582,7 +1554,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
// Payment Notifications
$routes->get('payment-notifications', 'View\UserController::index');
$routes->post('payment-notifications/send', 'View\SupportController::send');
// RFID
$routes->post('rfid/process', 'View\SupportController::process');
@@ -1599,9 +1570,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
// Inventory
$routes->get('inventory', 'View\UserController::index');
$routes->get('inventory/(:num)', 'View\SupportController::show/$1');
$routes->post('inventory', 'View\TeacherController::create');
$routes->put('inventory/(:num)', 'View\TeacherController::update/$1');
$routes->delete('inventory/(:num)', 'View\TeacherController::delete/$1');
$routes->get('inventory/movements', 'View\SupportController::movements');
// Preferences
@@ -1653,8 +1621,6 @@ $routes->group('api/v1', ['filter' => 'apiAuth', 'namespace' => 'App\Controllers
// Purchase Orders
$routes->get('purchase-orders', 'View\UserController::index');
$routes->get('purchase-orders/(:num)', 'View\SupportController::show/$1');
$routes->post('purchase-orders', 'View\TeacherController::create');
$routes->put('purchase-orders/(:num)', 'View\TeacherController::update/$1');
// Grading
$routes->get('grading/(:segment)/(:num)/(:num)', 'View\SupportController::show/$1/$2/$3');
@@ -22,6 +22,7 @@ use App\Models\StudentClassModel;
use App\Models\StudentSectionDistributionDraftModel;
use App\Controllers\View\EmailController;
use App\Controllers\View\InvoiceController;
use App\Libraries\RefundEligibilityService;
use App\Models\StaffAttendanceModel;
use App\Libraries\StaffTimeOffLinkService;
use App\Models\AttendanceDayModel;
@@ -2855,19 +2856,51 @@ class AdministratorController extends BaseController
$existingRefund = $this->refundModel->where('invoice_id', $invoice['id'])->first();
if ($existingRefund) {
$this->refundModel->update($existingRefund['id'], [
'refund_amount' => $refundAmount,
'status' => 'Pending',
$refundId = (int)$existingRefund['id'];
$status = strtolower((string)($existingRefund['status'] ?? ''));
$isApprovedState = in_array($status, ['approved', 'partial', 'paid', 'partially_paid'], true);
$calculatedCents = max(0, (int)round($refundAmount * 100));
$paidCents = (new RefundEligibilityService())->getCompletedPayoutTotalCentsForRefund($refundId);
$targetCents = $isApprovedState ? max($calculatedCents, $paidCents) : $calculatedCents;
$update = [
'refund_amount' => $targetCents / 100,
'updated_by' => session()->get('user_id') ?? null,
]);
];
if ($isApprovedState) {
$update['approved_amount_cents'] = $targetCents;
} else {
$update['status'] = 'Pending';
$update['requested_amount_cents'] = $targetCents;
}
if ($isApprovedState && $paidCents > $calculatedCents) {
$message = sprintf(
'Completed payouts (%0.2f) exceed recalculated refundable credit (%0.2f).',
$paidCents / 100,
$calculatedCents / 100
);
$update['reconciliation_status'] = 'requires_review';
$update['reconciliation_reason'] = $message;
$update['reconciliation_required_at'] = utc_now();
log_message('critical', 'Refund reconciliation required for refund #' . $refundId . ': ' . $message);
} else {
$update['reconciliation_status'] = null;
$update['reconciliation_reason'] = null;
$update['reconciliation_required_at'] = null;
}
$this->refundModel->update($refundId, $update);
} else {
$this->refundModel->insert([
'parent_id' => $pid,
'school_year' => $invoice['school_year'],
'invoice_id' => $invoice['id'],
'refund_amount' => $refundAmount,
'requested_amount_cents' => (int)round($refundAmount * 100),
'approved_amount_cents' => null,
'currency' => 'USD',
'refund_paid_amount' => 0.0,
'status' => 'Pending',
'source_type' => 'invoice_overpayment',
'source_id' => (int)$invoice['id'],
'requested_at' => utc_now(),
'updated_by' => session()->get('user_id') ?? null,
]);
+89 -12
View File
@@ -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().
*
+11 -83
View File
@@ -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);
}
}
}
+175 -40
View File
@@ -2,6 +2,7 @@
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Libraries\FinancialAttachmentService;
use App\Models\ExpenseModel;
use App\Models\UserModel;
use App\Models\ConfigurationModel;
@@ -12,6 +13,7 @@ class ExpenseController extends BaseController
protected $expenseModel;
protected $userModel;
protected $configModel;
protected FinancialAttachmentService $financialAttachmentService;
protected $schoolYear;
protected $semester;
protected $retailors;
@@ -21,6 +23,7 @@ class ExpenseController extends BaseController
$this->expenseModel = new ExpenseModel();
$this->userModel = new UserModel();
$this->configModel = new ConfigurationModel();
$this->financialAttachmentService = new FinancialAttachmentService();
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->semester = $this->configModel->getConfig('semester');
@@ -135,19 +138,19 @@ class ExpenseController extends BaseController
// Optional extra fields
'retailor' => 'permit_empty|max_length[255]',
'date_of_purchase' => 'permit_empty',
// allow JPG/JPEG/PNG/WEBP/GIF and PDF up to 2MB
// allow JPG/JPEG/PNG and PDF up to 2MB
'receipt' => 'uploaded[receipt]'
. '|max_size[receipt,2048]'
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]',
. '|max_size[receipt,5120]'
. '|ext_in[receipt,jpg,jpeg,png,pdf]'
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,application/pdf]',
];
$messages = [
'receipt' => [
'uploaded' => 'Receipt file is required.',
'max_size' => 'Maximum file size is 2MB.',
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
'max_size' => 'Maximum file size is 5MB.',
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, or PDF.',
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, or PDF.',
]
];
@@ -170,24 +173,32 @@ class ExpenseController extends BaseController
$purchasedById = (int) $purchasedById;
// School context
$schoolYear = $this->schoolYear ?: date('Y');
$schoolYear = (string)$this->schoolYear;
if (!preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
return redirect()->back()->withInput()->with('error', 'Invalid school year configuration. Expected YYYY-YYYY.');
}
$semester = $this->semester ?: 'Fall';
// Handle upload: store under writable/uploads/receipts and save only the filename
$receiptName = null;
$file = $this->request->getFile('receipt');
if ($file && $file->isValid() && !$file->hasMoved()) {
$stored = $file->store('receipts'); // -> writable/uploads/receipts/<randomname>.ext
$receiptName = basename($stored);
$stagedReceipt = null;
try {
$stagedReceipt = $this->financialAttachmentService->stageUploadedFile(
$this->request->getFile('receipt'),
'receipts'
);
} catch (\RuntimeException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
$status = $isDonation ? 'approved' : 'pending';
$statusReason = $isDonation ? 'Marked as Donation (non-reimbursable).' : null;
$this->expenseModel->insert([
$db = \Config\Database::connect();
$db->transBegin();
try {
$expenseId = (int)$this->expenseModel->insert([
'category' => $category,
'amount' => $amount,
'receipt_path' => $receiptName, // filename only
'receipt_path' => null,
'description' => $description,
'retailor' => ($retailor !== '') ? $retailor : null,
'date_of_purchase' => ($datePurchase !== '') ? $datePurchase : null,
@@ -198,7 +209,33 @@ class ExpenseController extends BaseController
'approved_by' => $isDonation ? $userId : null,
'school_year' => $schoolYear,
'semester' => $semester,
]);
]);
if ($expenseId <= 0) {
throw new \RuntimeException('Expense insert failed.');
}
if ($db->transStatus() === false) {
throw new \RuntimeException('Expense transaction failed.');
}
$db->transCommit();
} catch (\Throwable $e) {
$db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
log_message('error', 'Expense creation failed: ' . $e->getMessage());
return redirect()->back()->withInput()->with('error', 'Expense could not be saved.');
}
if ($stagedReceipt !== null) {
try {
$receiptName = $this->financialAttachmentService->finalizeStagedFile($stagedReceipt);
if (!$this->expenseModel->update($expenseId, ['receipt_path' => $receiptName])) {
throw new \RuntimeException('Expense receipt update failed.');
}
} catch (\Throwable $e) {
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
log_message('critical', 'Expense receipt incomplete for expense #' . $expenseId . ': ' . $e->getMessage());
return redirect()->to('/expenses/index')->with('error', 'Expense saved, but receipt could not be finalized. Operations must review.');
}
}
return redirect()->to('/expenses/index')->with('success', 'Record added successfully!');
}
@@ -217,18 +254,36 @@ class ExpenseController extends BaseController
return $this->response->setJSON(['error' => 'Invalid data']);
}
$expense = $this->expenseModel->find($id);
if (!$expense) {
log_message('error', 'Expense not found for ID ' . $id);
return $this->response->setJSON(['error' => 'Expense not found']);
}
$db = \Config\Database::connect();
$db->transBegin();
try {
$expense = $db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$id])->getRowArray();
if (!$expense) {
throw new \RuntimeException('Expense not found');
}
$db->query(
"SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE",
[$id]
)->getResultArray();
if ($this->hasActiveReimbursement($expense)) {
throw new \RuntimeException('Expense status cannot change after reimbursement without reversal.');
}
$success = $this->expenseModel->update($id, [
'status' => $status,
'status_reason' => $reason,
'approved_by' => $userId,
'updated_by' => $userId
]);
$success = $this->expenseModel->update($id, [
'status' => $status === 'denied' ? 'rejected' : $status,
'status_reason' => $reason,
'approved_by' => $status === 'approved' ? $userId : null,
'updated_by' => $userId
]);
if (!$success || $db->transStatus() === false) {
throw new \RuntimeException('Update failed');
}
$db->transCommit();
} catch (\Throwable $e) {
$db->transRollback();
log_message('error', 'Expense status update failed for ID ' . $id . ': ' . $e->getMessage());
return $this->response->setJSON(['error' => $e->getMessage()]);
}
if (!$success) {
log_message('error', 'Expense update failed for ID ' . $id);
@@ -260,6 +315,10 @@ class ExpenseController extends BaseController
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
}
if ($this->hasActiveReimbursement($expense)) {
return redirect()->back()->with('error', 'Reimbursed expenses are immutable. Reverse the reimbursement and create a replacement expense.');
}
// same user list you use in create()
$users = $this->staffUsers();
@@ -308,14 +367,13 @@ class ExpenseController extends BaseController
$isDonation = ($category === 'Donation');
$userId = (int) (session()->get('user_id') ?? 0);
// Keep old receipt unless replaced or removed
$receiptName = $expense['receipt_path'];
$stagedReceipt = null;
if ($file && $file->isValid() && !$file->hasMoved() && ($file->getSize() ?? 0) > 0) {
$stored = $file->store('receipts');
$receiptName = basename($stored);
}
if ($this->request->getPost('remove_receipt') === '1') {
$receiptName = null;
try {
$stagedReceipt = $this->financialAttachmentService->stageUploadedFile($file, 'receipts');
} catch (\RuntimeException $e) {
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
}
$updateData = [
@@ -325,9 +383,11 @@ class ExpenseController extends BaseController
'retailor' => trim((string) $this->request->getPost('retailor')) ?: null,
'date_of_purchase' => (string) $this->request->getPost('date_of_purchase') ?: null,
'purchased_by' => $purchasedById,
'receipt_path' => $receiptName,
'updated_by' => $userId,
];
if ($this->request->getPost('remove_receipt') === '1') {
$updateData['receipt_path'] = null;
}
if ($isDonation) {
$updateData['status'] = 'approved';
@@ -335,14 +395,89 @@ class ExpenseController extends BaseController
$updateData['approved_by'] = $userId ?: null;
$updateData['reimbursement_id'] = null;
} elseif (($expense['category'] ?? '') === 'Donation') {
// Moving a donation back to a reimbursable category: clear the marker.
// Moving a donation back to a reimbursable category must re-enter approval.
$updateData['status_reason'] = null;
$updateData['approved_by'] = $expense['approved_by'] ?? null;
$updateData['status'] = $expense['status'] ?? 'pending';
$updateData['approved_by'] = null;
$updateData['status'] = 'pending';
}
$this->expenseModel->update($id, $updateData);
$db = \Config\Database::connect();
$db->transBegin();
try {
$lockedExpense = $db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$id])->getRowArray();
if (!$lockedExpense) {
throw new \RuntimeException('Expense not found.');
}
$activeReimbursements = $db->query(
"SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE",
[$id]
)->getResultArray();
if ($activeReimbursements !== []) {
$protectedChanged = (
(float)$lockedExpense['amount'] !== (float)$updateData['amount']
|| (string)$lockedExpense['category'] !== (string)$updateData['category']
|| (int)$lockedExpense['purchased_by'] !== (int)$updateData['purchased_by']
|| array_key_exists('receipt_path', $updateData)
);
if ($protectedChanged) {
throw new \RuntimeException('Reimbursed expenses are immutable. Reverse the reimbursement and create a replacement expense.');
}
}
if (!$this->expenseModel->update($id, $updateData) || $db->transStatus() === false) {
throw new \RuntimeException('Expense update failed.');
}
$db->transCommit();
} catch (\Throwable $e) {
$db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
return redirect()->back()->withInput()->with('error', $e->getMessage());
}
if ($stagedReceipt !== null) {
try {
$receiptName = $this->financialAttachmentService->finalizeStagedFile($stagedReceipt);
if (!$this->expenseModel->update($id, ['receipt_path' => $receiptName])) {
throw new \RuntimeException('Expense receipt update failed.');
}
} catch (\Throwable $e) {
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
log_message('critical', 'Expense receipt replacement incomplete for expense #' . $id . ': ' . $e->getMessage());
return redirect()->to('/expenses/index')->with('error', 'Expense updated, but receipt could not be finalized. Operations must review.');
}
}
return redirect()->to('/expenses/index')->with('success', 'Expense updated.');
}
private function hasActiveReimbursement(array $expense): bool
{
$expenseId = (int) ($expense['id'] ?? 0);
if ($expenseId <= 0) {
return false;
}
if (!empty($expense['reimbursement_id'])) {
$row = \Config\Database::connect()
->table('reimbursements')
->select('id')
->where('id', (int) $expense['reimbursement_id'])
->where("LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')", null, false)
->get()
->getRowArray();
if ($row) {
return true;
}
}
$row = \Config\Database::connect()
->table('reimbursements')
->select('id')
->where('expense_id', $expenseId)
->where("LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')", null, false)
->get()
->getRowArray();
return $row !== null;
}
}
+137 -59
View File
@@ -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). */
+140 -340
View File
@@ -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] = [
+72 -321
View File
@@ -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'),
];
+90 -65
View File
@@ -378,66 +378,22 @@ class PaymentController extends ResourceController
->orderBy('issue_date', 'DESC')
->findAll();
// Preload sums of actual payments/discounts/refunds per invoice to avoid stale invoice.paid_amount/balance
$paidByInvoice = [];
$discountByInvoice = [];
$refundByInvoice = [];
if (!empty($rawInvoices)) {
$invoiceIds = array_values(array_unique(array_map(static fn($r) => (int)($r['id'] ?? 0), $rawInvoices)));
if (!empty($invoiceIds)) {
$rows = $this->paymentModel
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id')
->get()->getResultArray();
foreach ($rows as $r) {
$iid = (int)($r['invoice_id'] ?? 0);
$paidByInvoice[$iid] = (float)($r['total_paid'] ?? 0);
}
// Sum discounts per invoice_id
$discRows = $this->db->table('discount_usages')
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id')
->get()->getResultArray();
foreach ($discRows as $dr) {
$iid = (int)($dr['invoice_id'] ?? 0);
$discountByInvoice[$iid] = (float)($dr['total_disc'] ?? 0);
}
// Sum PAID refunds per invoice_id (only Partial/Paid reduce liability)
$refRows = $this->db->table('refunds')
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->whereIn('invoice_id', $invoiceIds)
->whereIn('status', ['Partial','Paid'])
->groupBy('invoice_id')
->get()->getResultArray();
foreach ($refRows as $rr) {
$iid = (int)($rr['invoice_id'] ?? 0);
$refundByInvoice[$iid] = (float)($rr['total_refund_paid'] ?? 0);
}
}
}
// Normalize invoices for the view/JS; force due_ymd to the CONFIG date
$invoices = array_map(function (array $inv) use ($installmentEndYmd, $paidByInvoice, $discountByInvoice, $refundByInvoice) {
// Cast numeric fields we rely on
$inv['total_amount'] = isset($inv['total_amount']) ? (float) $inv['total_amount'] : 0.0;
// Prefer actual sum of payments for paid_amount
// Normalize invoices for the view/JS; all accounting amounts come from the ledger.
$invoices = array_map(function (array $inv) use ($installmentEndYmd) {
$iid = (int)($inv['id'] ?? 0);
$actualPaid = isset($paidByInvoice[$iid]) ? (float)$paidByInvoice[$iid] : null;
$inv['paid_amount'] = is_numeric($actualPaid)
? (float)$actualPaid
: (isset($inv['paid_amount']) ? (float)$inv['paid_amount'] : 0.0);
// Per-invoice discount
$inv['discount'] = isset($discountByInvoice[$iid]) ? (float)$discountByInvoice[$iid] : (float)($inv['discount'] ?? 0.0);
// Per-invoice refunds (paid out)
$inv['refund_paid'] = isset($refundByInvoice[$iid]) ? (float)$refundByInvoice[$iid] : 0.0;
// Derive balance from total - paid - discount - refundsPaid (never below 0)
$inv['balance'] = max(0.0, (float)$inv['total_amount'] - (float)$inv['paid_amount'] - (float)$inv['discount'] - (float)$inv['refund_paid']);
if ($iid > 0) {
$ledger = $this->invoiceLedgerService->recalculateInvoice($iid);
$inv['display_total'] = (float) ($ledger['total_amount'] ?? 0);
$inv['total_amount'] = $inv['display_total'];
$inv['paid_amount'] = (float) ($ledger['paid_amount'] ?? 0);
$inv['discount'] = (float) ($ledger['discount_total'] ?? 0);
$inv['refund_paid'] = (float) ($ledger['refund_paid_total'] ?? 0);
$inv['balance'] = (float) ($ledger['balance'] ?? 0);
$inv['balance_due_cents'] = (int) ($ledger['balanceDueCents'] ?? 0);
$inv['customer_credit_cents'] = (int) ($ledger['customerCreditCents'] ?? 0);
$inv['customer_credit'] = (float) ($ledger['customer_credit'] ?? 0);
$inv['status'] = (string) ($ledger['status'] ?? ($inv['status'] ?? ''));
}
// Always use the configured end date for installments
$inv['due_ymd'] = $installmentEndYmd;
@@ -889,6 +845,10 @@ class PaymentController extends ResourceController
$paymentMethod = strtolower(trim((string) $this->request->getPost('payment_method'))); // cash|check|card
$checkNumber = trim((string) $this->request->getPost('check_number'));
$searchTerm = $this->request->getPost('search_term') ?? $this->request->getGet('search_term') ?? '';
$idempotencyKey = trim((string)($this->request->getPost('idempotency_key') ?? ''));
if ($idempotencyKey === '') {
$idempotencyKey = bin2hex(random_bytes(16));
}
// UI-only
$paymentType = strtolower((string) ($this->request->getPost('payment_type') ?? 'full')); // full|installment
@@ -932,10 +892,11 @@ class PaymentController extends ResourceController
}
// Optional receipt upload
$stagedEvidence = null;
$checkFile = null;
$paymentFile = $this->request->getFile('payment_file');
try {
$checkFile = $this->financialAttachmentService->saveUploadedFile(
$stagedEvidence = $this->financialAttachmentService->stageUploadedFile(
$paymentFile,
$paymentMethod === 'check' ? 'checks' : ($paymentMethod === 'card' ? 'cards' : 'misc')
);
@@ -953,6 +914,7 @@ class PaymentController extends ResourceController
if (!$row) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->with('error', 'Invoice not found.');
}
@@ -965,6 +927,7 @@ class PaymentController extends ResourceController
$carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $invYear);
if ($carryForwardPaymentRequired && $paymentType === 'installment') {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->withInput()->with(
'error',
'This parent has a balance carried over from a previous school year. Installments are not allowed; payment must be made in full.'
@@ -973,6 +936,7 @@ class PaymentController extends ResourceController
if ($amount > $currentBalance + 0.00001) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->withInput()->with(
'error',
'Entered amount (' . number_format($amount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').'
@@ -981,6 +945,7 @@ class PaymentController extends ResourceController
if ($carryForwardPaymentRequired && (float)round($amount, 2) !== (float)round($currentBalance, 2)) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->withInput()->with(
'error',
'This parent has a balance carried over from a previous school year. Payment must equal the full remaining balance (' . number_format($currentBalance, 2) . ').'
@@ -989,6 +954,7 @@ class PaymentController extends ResourceController
if ($paymentMethod === 'card' && (float)round($amount, 2) !== (float)round($currentBalance, 2)) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->withInput()->with(
'error',
'Debit/Credit Card payments must equal the full remaining balance (' . number_format($currentBalance, 2) . ').'
@@ -1010,13 +976,20 @@ class PaymentController extends ResourceController
$checkNumber,
null,
(array) $row,
$currentBalance
$currentBalance,
$idempotencyKey
);
if ($paymentResult === false) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->with('error', 'Failed to record payment.');
}
if (!empty($paymentResult['conflict'])) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->withInput()->with('error', 'Payment idempotency key conflicts with a different request.');
}
$installmentSeq = (int) ($paymentResult['installment_seq'] ?? 1);
if (!$this->recordManualPayment(
@@ -1029,6 +1002,7 @@ class PaymentController extends ResourceController
$paymentDate
)) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
log_message('error', '[manualPayUpdate] Failed to record manual payment audit row: ' . json_encode($this->manualPaymentModel->errors()));
return redirect()->back()->with('error', 'Payment was not recorded because the manual payment audit row could not be saved.');
}
@@ -1071,6 +1045,25 @@ class PaymentController extends ResourceController
$this->db->transCommit();
$evidenceWarning = null;
if ($stagedEvidence !== null && empty($paymentResult['duplicate'])) {
try {
$checkFile = $this->financialAttachmentService->finalizeStagedFile($stagedEvidence);
$this->paymentModel->update((int)($paymentResult['payment_id'] ?? 0), [
'check_file' => $checkFile,
'evidence_status' => 'complete',
]);
} catch (\Throwable $e) {
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
$evidenceWarning = 'Payment recorded, but evidence could not be finalized.';
log_message('critical', '[manualPayUpdate] evidence incomplete for payment #' . (int)($paymentResult['payment_id'] ?? 0) . ': ' . $e->getMessage());
$this->paymentModel->update((int)($paymentResult['payment_id'] ?? 0), [
'evidence_status' => 'incomplete',
'evidence_failure_message' => $evidenceWarning,
]);
}
}
// Build payload & notify
[$eventData, $studentData] = $this->buildPaymentEventData(
$invoiceId,
@@ -1090,9 +1083,10 @@ class PaymentController extends ResourceController
return redirect()
->to(site_url('payment/manual_pay?search_term=' . urlencode($searchTerm)))
->with('success', 'Payment recorded successfully (Installment #' . $installmentSeq . '). Transaction ID: ' . $transactionId);
->with($evidenceWarning ? 'warning' : 'success', ($evidenceWarning ?? 'Payment recorded successfully') . ' (Installment #' . $installmentSeq . '). Transaction ID: ' . $transactionId);
} catch (\Throwable $e) {
if ($this->db->transStatus()) $this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence ?? null);
log_message('error', '[manualPayUpdate] ' . $e->getMessage());
return redirect()->back()->withInput()->with('error', 'Unexpected error while recording payment.');
}
@@ -1402,8 +1396,22 @@ class PaymentController extends ResourceController
}
if ($idempotencyKey !== null && $idempotencyKey !== '') {
$fingerprint = $this->buildPaymentFingerprint(
(int)$invoice['parent_id'],
$invoiceId,
(int)round($amount * 100),
strtolower($paymentMethod),
'USD'
);
$existing = $this->paymentModel->where('idempotency_key', $idempotencyKey)->first();
if ($existing) {
if ((string)($existing['request_fingerprint_hash'] ?? '') !== $fingerprint) {
return [
'conflict' => true,
'duplicate' => false,
];
}
return [
'payment_id' => (int) ($existing['id'] ?? 0),
'installment_seq' => (int) ($existing['installment_seq'] ?? $existing['number_of_installments'] ?? 1),
@@ -1427,22 +1435,22 @@ class PaymentController extends ResourceController
if ($amount > $preBalance + 0.00001) {
return false;
}
$newBalance = max(0.0, round($preBalance - $amount, 2));
$paymentData = [
'parent_id' => (int) $invoice['parent_id'],
'invoice_id' => $invoiceId,
'total_amount' => $invoice['total_amount'],
'paid_amount' => $amount,
'balance' => $newBalance,
'balance' => null,
'number_of_installments' => $installmentSeq, // <-- installment sequence (1,2,3,...)
'installment_seq' => $installmentSeq,
'transaction_id' => $transactionId,
'idempotency_key' => $idempotencyKey,
'request_fingerprint_hash' => $idempotencyKey ? ($fingerprint ?? null) : null,
'payment_method' => strtolower($paymentMethod),
'payment_date' => $paymentDate,
'status' => FinancialStatus::PAYMENT_RECORDED,
'check_file' => $checkFile,
'evidence_status' => $checkFile ? 'complete' : null,
'check_number' => (strtolower($paymentMethod) === 'check') ? $checkNumber : null,
'updated_by' => session()->get('user_id'),
'school_year' => $schoolYear ?? ($invoice['school_year'] ?? $this->schoolYear),
@@ -1460,6 +1468,23 @@ class PaymentController extends ResourceController
];
}
private function buildPaymentFingerprint(
int $parentId,
int $invoiceId,
int $amountCents,
string $paymentMethod,
string $currency
): string {
return hash('sha256', json_encode([
'operation_type' => 'payment_creation',
'parent_id' => $parentId,
'invoice_id' => $invoiceId,
'amount_cents' => $amountCents,
'payment_method' => strtolower($paymentMethod),
'currency' => strtoupper($currency),
], JSON_UNESCAPED_SLASHES));
}
private function getSuccessfulPaymentCount(int $invoiceId): int
{
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
@@ -8,6 +8,7 @@ use App\Models\ConfigurationModel;
use App\Models\InvoiceModel;
use App\Models\PaymentModel;
use App\Models\FamilyGuardianModel;
use App\Libraries\InvoiceLedgerService;
use App\Services\EmailService;
use App\Services\NotificationService;
@@ -20,6 +21,7 @@ class PaymentNotificationController extends BaseController
protected PaymentModel $paymentModel;
protected FamilyGuardianModel $familyGuardianModel;
protected EmailService $emailService;
protected InvoiceLedgerService $invoiceLedgerService;
public function __construct()
{
@@ -30,6 +32,7 @@ class PaymentNotificationController extends BaseController
$this->paymentModel = new PaymentModel();
$this->familyGuardianModel = new FamilyGuardianModel();
$this->emailService = new EmailService();
$this->invoiceLedgerService = new InvoiceLedgerService();
}
public function index()
@@ -87,43 +90,19 @@ class PaymentNotificationController extends BaseController
$year = (int)$now->format('Y');
$month= (int)$now->format('n');
// Helper: compute current total balance across invoices for this parent/year
$computeBalance = function (int $pid) use ($schoolYear): array {
$db = \Config\Database::connect();
$rows = $db->table('invoices')
->select('id, total_amount')
->select('id')
->where('parent_id', $pid)
->where('school_year', $schoolYear)
->get()->getResultArray();
$paymentsTbl = 'payments';
$hasStatus = $db->fieldExists('status', $paymentsTbl);
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
$sumBalance = 0.0; $latestId = null;
foreach ($rows as $ir) {
$iid = (int)$ir['id'];
if ($latestId === null) $latestId = $iid;
$total = (float)($ir['total_amount'] ?? 0);
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
if ($hasStatus) {
$qb->groupStart()
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
->orWhere('status IS NULL', null, false)
->groupEnd();
}
if ($hasVoid) {
$qb->groupStart()
->where('is_void', 0)
->orWhere('is_void IS NULL', null, false)
->groupEnd();
}
$paid = (float)($qb->get()->getRowArray()['tot'] ?? 0);
$disc = (float)($db->table('discount_usages')->select('COALESCE(SUM(discount_amount),0) AS tot')->where('invoice_id', $iid)->get()->getRowArray()['tot'] ?? 0);
$rfnd = (float)($db->table('refunds')->select('COALESCE(SUM(refund_paid_amount),0) AS tot')->where('invoice_id', $iid)->whereIn('status', ['Partial','Paid'])->get()->getRowArray()['tot'] ?? 0);
$sumBalance += max(0.0, round($total - $disc - $paid - $rfnd, 2));
$sumBalance += (float)($this->invoiceLedgerService->calculateInvoice($iid)['balance'] ?? 0.0);
}
return [$sumBalance, $latestId];
};
@@ -1,84 +0,0 @@
<?php
namespace App\Controllers\View;
use App\Models\PaymentTransactionModel;
use CodeIgniter\RESTful\ResourceController;
class PaymentTransactionController extends ResourceController
{
protected $paymentTransactionModel;
public function __construct()
{
$this->paymentTransactionModel = new PaymentTransactionModel();
}
// API: Create a new payment transaction (installment)
public function createAPI()
{
$data = [
'transaction_id' => $this->request->getPost('transaction_id'),
'payment_id' => $this->request->getPost('payment_id'),
'transaction_date' => $this->request->getPost('transaction_date'),
'amount' => $this->request->getPost('amount'),
'payment_method' => $this->request->getPost('payment_method'),
'payment_status' => 'Pending',
'transaction_fee' => $this->request->getPost('transaction_fee'),
'payment_reference' => $this->request->getPost('payment_reference'),
'is_full_payment' => $this->request->getPost('is_full_payment') ? 1 : 0,
];
if ($this->paymentTransactionModel->save($data)) {
return $this->respondCreated($data);
} else {
return $this->failValidationErrors($this->paymentTransactionModel->errors());
}
}
// API: Get all transactions for a specific payment
public function getByPaymentAPI($paymentId)
{
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
if ($transactions) {
return $this->respond($transactions);
} else {
return $this->failNotFound('Transactions not found for the payment.');
}
}
// View: Get all transactions for a specific payment (for web views)
public function getByPayment($paymentId)
{
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
return view('payment_transaction_list', ['transactions' => $transactions]);
}
// View: Create a new payment transaction
public function create()
{
return view('payment_transaction_create');
}
// API: Update payment status by transaction ID
public function updateStatusAPI($transactionId)
{
$status = $this->request->getPost('status');
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
return $this->respond(['status' => 'success']);
} else {
return $this->failNotFound('Transaction not found.');
}
}
// View: Update payment status by transaction ID
public function updateStatus($transactionId)
{
$status = $this->request->getPost('status');
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
return redirect()->to('/payment_transactions');
} else {
return redirect()->back()->with('error', 'Failed to update status.');
}
}
}
+305 -50
View File
@@ -79,8 +79,11 @@ class PurchaseOrderController extends BaseController
$subtotal = 0.0;
$items = [];
foreach ($supply_ids as $i => $sid) {
$q = max(0, (int)($qtys[$i] ?? 0));
$q = (int)($qtys[$i] ?? 0);
$uc = (float)($unit_costs[$i] ?? 0);
if ($sid && ($q <= 0 || $uc < 0)) {
return redirect()->back()->withInput()->with('error', 'Quantity must be greater than zero and unit cost cannot be negative.');
}
if ($sid && $q > 0) {
$line = $q * $uc;
$subtotal += $line;
@@ -151,77 +154,329 @@ class PurchaseOrderController extends BaseController
*/
public function receive($id)
{
$po = $this->poModel->find($id);
if (!$po || in_array($po['status'], ['canceled','received'], true)) {
return redirect()->to('inventory/po/show/'.$id)->with('error', 'PO not receivable.');
}
$received = $this->request->getPost('received') ?? []; // [itemId => qty]
if (!$received) {
return redirect()->to('inventory/po/show/'.$id)->with('error', 'No items to receive.');
}
$idempotencyKey = trim((string)($this->request->getPost('idempotency_key') ?? ''));
if ($idempotencyKey === '') {
$idempotencyKey = bin2hex(random_bytes(16));
}
$fingerprint = $this->buildReceiptFingerprint((int)$id, $received);
$issuedBy = (string) (session('user.email') ?? session('user.username') ?? 'system');
$this->db->transStart();
$completed = true;
foreach ($received as $itemId => $qty) {
$qty = (int)$qty;
if ($qty <= 0) continue;
$item = $this->itemModel->where('purchase_order_id', $id)->find($itemId);
if (!$item) { $completed = false; continue; }
$remaining = (int)$item['quantity'] - (int)$item['received_qty'];
$toReceive = min($remaining, $qty);
if ($toReceive <= 0) continue;
// Update item received qty
$this->itemModel->update($itemId, [
'received_qty' => (int)$item['received_qty'] + $toReceive
]);
// Update supply on hand
$supply = $this->supplyModel->find($item['supply_id']);
if (!$supply) { $completed = false; continue; }
$newQty = (int)$supply['qty_on_hand'] + $toReceive;
$this->supplyModel->update($supply['id'], ['qty_on_hand' => $newQty]);
// Log transaction IN
$this->txnModel->insert([
'supply_id' => $supply['id'],
'type' => 'in',
'quantity' => $toReceive,
'ref' => 'PO ' . $po['po_number'],
'issued_to' => 'Inventory',
'issued_by' => $issuedBy,
'notes' => 'Received against PO',
]);
if (($item['received_qty'] + $toReceive) < $item['quantity']) {
$completed = false;
$this->db->transBegin();
try {
$existingOperation = $this->db->query(
'SELECT * FROM inventory_receipt_operations WHERE idempotency_key = ? FOR UPDATE',
[$idempotencyKey]
)->getRowArray();
if ($existingOperation) {
if ((int)$existingOperation['purchase_order_id'] !== (int)$id || (string)$existingOperation['request_fingerprint_hash'] !== $fingerprint) {
$this->db->transCommit();
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Receipt idempotency key conflicts with a different request.');
}
$this->db->transCommit();
return redirect()->to('inventory/po/show/'.$id)->with('success', 'Receipt already recorded.');
}
}
// Set PO status
$this->poModel->update($id, ['status' => $completed ? 'received' : 'ordered']);
$po = $this->db->query('SELECT * FROM purchase_orders WHERE id = ? FOR UPDATE', [(int) $id])->getRowArray();
if (!$po || in_array($po['status'], ['canceled','received'], true)) {
throw new \RuntimeException('PO not receivable.');
}
$this->db->transComplete();
if ($this->db->transStatus() === false) {
$items = $this->db->query(
'SELECT * FROM purchase_order_items WHERE purchase_order_id = ? FOR UPDATE',
[(int) $id]
)->getResultArray();
if ($items === []) {
throw new \RuntimeException('PO has no receivable lines.');
}
$operationInserted = $this->db->table('inventory_receipt_operations')->insert([
'idempotency_key' => $idempotencyKey,
'purchase_order_id' => (int)$id,
'request_fingerprint_hash' => $fingerprint,
'status' => 'processing',
'actor_id' => (int)(session()->get('user_id') ?? 0) ?: null,
'created_at' => utc_now(),
'updated_at' => utc_now(),
]);
$operationId = (int)$this->db->insertID();
if (!$operationInserted) {
$operationId = 0;
}
if ($operationId <= 0) {
throw new \RuntimeException('Failed to create receipt operation.');
}
$itemsById = [];
foreach ($items as $item) {
$ordered = (int) ($item['quantity'] ?? 0);
$alreadyReceived = (int) ($item['received_qty'] ?? 0);
if ($ordered <= 0 || $alreadyReceived < 0 || $alreadyReceived > $ordered) {
throw new \RuntimeException('PO contains invalid received quantities.');
}
$itemsById[(int) $item['id']] = $item;
}
foreach ($received as $itemId => $qty) {
$itemId = (int) $itemId;
$qty = (int)$qty;
if ($qty <= 0) {
continue;
}
if (!isset($itemsById[$itemId])) {
throw new \RuntimeException('Submitted item does not belong to this PO.');
}
$item = $itemsById[$itemId];
$remaining = (int)$item['quantity'] - (int)$item['received_qty'];
if ($qty > $remaining) {
throw new \RuntimeException('Received quantity exceeds ordered quantity.');
}
$supply = $this->db->query('SELECT * FROM supplies WHERE id = ? FOR UPDATE', [(int) $item['supply_id']])->getRowArray();
if (!$supply) {
throw new \RuntimeException('Supply not found for PO line.');
}
if (!$this->db->table('supplies')
->where('id', (int) $supply['id'])
->set('qty_on_hand', 'qty_on_hand + ' . $qty, false)
->update()) {
throw new \RuntimeException('Failed to update supply quantity.');
}
$movementId = $this->txnModel->insert([
'supply_id' => (int) $supply['id'],
'type' => 'in',
'quantity' => $qty,
'ref' => 'PO ' . $po['po_number'],
'issued_to' => 'Inventory',
'issued_by' => $issuedBy,
'notes' => 'Received against PO',
]);
if (!$movementId) {
throw new \RuntimeException('Failed to record inventory transaction.');
}
if (!$this->db->table('inventory_receipt_lines')->insert([
'operation_id' => $operationId,
'purchase_order_item_id' => $itemId,
'quantity' => $qty,
'movement_id' => (int)$movementId,
'reversed_quantity' => 0,
'created_at' => utc_now(),
])) {
throw new \RuntimeException('Failed to record receipt line.');
}
if (!$this->itemModel->update($itemId, [
'received_qty' => (int)$item['received_qty'] + $qty
])) {
throw new \RuntimeException('Failed to update PO line quantity.');
}
$itemsById[$itemId]['received_qty'] = (int)$item['received_qty'] + $qty;
}
$completed = true;
foreach ($itemsById as $item) {
if ((int) $item['received_qty'] < (int) $item['quantity']) {
$completed = false;
break;
}
}
if (!$this->poModel->update($id, ['status' => $completed ? 'received' : 'ordered'])) {
throw new \RuntimeException('Failed to update PO status.');
}
$this->db->table('inventory_receipt_operations')
->where('id', $operationId)
->update(['status' => 'completed', 'updated_at' => utc_now()]);
if (!$this->db->transStatus()) {
throw new \RuntimeException('PO receive transaction failed.');
}
$this->db->transCommit();
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Failed to receive PO #{po}: {msg}', ['po' => $id, 'msg' => $e->getMessage()]);
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Failed to receive items.');
}
return redirect()->to('inventory/po/show/'.$id)->with('success', $completed ? 'PO fully received.' : 'PO partially received.');
}
public function reverseReceipt(int $operationId)
{
$reason = trim((string)($this->request->getPost('reason') ?? ''));
if ($reason === '') {
return redirect()->back()->with('error', 'Receipt reversal reason is required.');
}
$issuedBy = (string) (session('user.email') ?? session('user.username') ?? 'system');
$poId = 0;
$this->db->transBegin();
try {
$operation = $this->db->query(
'SELECT * FROM inventory_receipt_operations WHERE id = ? FOR UPDATE',
[$operationId]
)->getRowArray();
if (!$operation || (string)($operation['status'] ?? '') !== 'completed') {
throw new \RuntimeException('Receipt operation is not reversible.');
}
$poId = (int)$operation['purchase_order_id'];
$po = $this->db->query('SELECT * FROM purchase_orders WHERE id = ? FOR UPDATE', [$poId])->getRowArray();
if (!$po) {
throw new \RuntimeException('Purchase order not found.');
}
$lines = $this->db->query(
'SELECT rl.*, poi.supply_id, poi.received_qty, poi.quantity AS ordered_quantity
FROM inventory_receipt_lines rl
JOIN purchase_order_items poi ON poi.id = rl.purchase_order_item_id
WHERE rl.operation_id = ?
FOR UPDATE',
[$operationId]
)->getResultArray();
if ($lines === []) {
throw new \RuntimeException('Receipt operation has no lines.');
}
$reversedAny = false;
foreach ($lines as $line) {
$remaining = (int)$line['quantity'] - (int)$line['reversed_quantity'];
if ($remaining <= 0) {
continue;
}
$supply = $this->db->query('SELECT * FROM supplies WHERE id = ? FOR UPDATE', [(int)$line['supply_id']])->getRowArray();
if (!$supply) {
throw new \RuntimeException('Supply not found for receipt line.');
}
if ((int)($supply['qty_on_hand'] ?? 0) < $remaining) {
throw new \RuntimeException('Insufficient inventory for receipt reversal.');
}
if ((int)$line['received_qty'] < $remaining) {
throw new \RuntimeException('PO line received quantity cannot cover reversal.');
}
if (!$this->db->table('supplies')
->where('id', (int)$supply['id'])
->set('qty_on_hand', 'qty_on_hand - ' . $remaining, false)
->update()) {
throw new \RuntimeException('Failed to update supply quantity.');
}
$movementId = $this->txnModel->insert([
'supply_id' => (int)$supply['id'],
'type' => 'out',
'quantity' => $remaining,
'ref' => 'PO ' . ($po['po_number'] ?? $poId),
'issued_to' => 'Inventory',
'issued_by' => $issuedBy,
'notes' => 'Reversal of receipt operation #' . $operationId . ': ' . $reason,
]);
if (!$movementId) {
throw new \RuntimeException('Failed to record inventory reversal transaction.');
}
if (!$this->db->table('purchase_order_items')
->where('id', (int)$line['purchase_order_item_id'])
->set('received_qty', 'received_qty - ' . $remaining, false)
->update()) {
throw new \RuntimeException('Failed to update PO line received quantity.');
}
if (!$this->db->table('inventory_receipt_lines')
->where('id', (int)$line['id'])
->update([
'reversed_quantity' => (int)$line['reversed_quantity'] + $remaining,
'reversal_movement_id' => (int)$movementId,
])) {
throw new \RuntimeException('Failed to mark receipt line reversed.');
}
$reversedAny = true;
}
if (!$reversedAny) {
throw new \RuntimeException('Receipt operation is already fully reversed.');
}
$items = $this->db->query(
'SELECT quantity, received_qty FROM purchase_order_items WHERE purchase_order_id = ? FOR UPDATE',
[$poId]
)->getResultArray();
$allReceived = $items !== [];
foreach ($items as $item) {
$allReceived = $allReceived && (int)($item['received_qty'] ?? 0) >= (int)($item['quantity'] ?? 0);
}
if (!$this->poModel->update($poId, ['status' => $allReceived ? 'received' : 'ordered'])) {
throw new \RuntimeException('Failed to update PO status.');
}
$this->db->table('inventory_receipt_operations')
->where('id', $operationId)
->update(['status' => 'reversed', 'updated_at' => utc_now()]);
if (!$this->db->transStatus()) {
throw new \RuntimeException('Receipt reversal transaction failed.');
}
$this->db->transCommit();
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Failed to reverse receipt operation #{operation}: {msg}', [
'operation' => $operationId,
'msg' => $e->getMessage(),
]);
return redirect()->to($poId > 0 ? 'inventory/po/show/' . $poId : 'inventory/po')->with('error', 'Failed to reverse receipt.');
}
return redirect()->to('inventory/po/show/' . $poId)->with('success', 'Receipt reversed.');
}
public function cancel($id)
{
$po = $this->poModel->find($id);
if (!$po || $po['status'] === 'received') {
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Cannot cancel this PO.');
}
$this->poModel->update($id, ['status' => 'canceled']);
$received = $this->itemModel
->where('purchase_order_id', (int) $id)
->where('received_qty >', 0)
->countAllResults();
if ($received > 0) {
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Cannot cancel a partially received PO without inventory reversal.');
}
if (!$this->poModel->update($id, ['status' => 'canceled'])) {
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Failed to cancel PO.');
}
return redirect()->to('inventory/po/show/'.$id)->with('success', 'PO canceled.');
}
private function buildReceiptFingerprint(int $purchaseOrderId, array $received): string
{
$normalized = [];
foreach ($received as $itemId => $qty) {
$qty = (int)$qty;
if ($qty > 0) {
$normalized[(int)$itemId] = $qty;
}
}
ksort($normalized);
return hash('sha256', json_encode([
'operation_type' => 'inventory_receipt',
'purchase_order_id' => $purchaseOrderId,
'received' => $normalized,
], JSON_UNESCAPED_SLASHES));
}
}
File diff suppressed because it is too large Load Diff
+278 -126
View File
@@ -9,6 +9,7 @@ use App\Models\ConfigurationModel;
use App\Models\ReimbursementBatchModel;
use App\Models\ReimbursementBatchItemModel;
use App\Models\ReimbursementBatchAdminFileModel;
use App\Libraries\FinancialStatus;
use App\Services\EmailService;
use CodeIgniter\Exceptions\PageNotFoundException;
use CodeIgniter\HTTP\Files\UploadedFile;
@@ -127,10 +128,13 @@ class ReimbursementController extends BaseController
if ($year === '') {
return 1;
}
$record = $this->batchModel
->select('MAX(yearly_batch_number) AS max_number')
->where('school_year', $year)
->first();
$record = $this->db->query(
'SELECT COALESCE(MAX(yearly_batch_number), 0) AS max_number
FROM reimbursement_batches
WHERE school_year = ?
FOR UPDATE',
[$year]
)->getRowArray();
$max = (int) ($record['max_number'] ?? 0);
return $max + 1;
@@ -553,22 +557,38 @@ class ReimbursementController extends BaseController
$title = trim((string) ($this->request->getPost('title') ?? ''));
$userId = (int) (session()->get('user_id') ?? 0);
$now = date('Y-m-d H:i:s');
$sequence = $this->nextYearlyBatchNumberForSchoolYear();
$data = [
'title' => $title !== '' ? $title : null,
'status' => 'open',
'created_by' => $userId ?: null,
'opened_at' => $now,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'yearly_batch_number' => $sequence,
];
$this->db->transBegin();
try {
$sequence = $this->nextYearlyBatchNumberForSchoolYear();
$data = [
'title' => $title !== '' ? $title : null,
'status' => 'open',
'created_by' => $userId ?: null,
'opened_at' => $now,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'yearly_batch_number' => $sequence,
];
$this->batchModel->insert($data);
$batchId = (int) $this->batchModel->getInsertID();
if ($batchId <= 0) {
throw new \RuntimeException('Batch insert failed.');
}
$label = $title !== '' ? $title : 'Batch #' . $sequence;
if ($title === '' && !$this->batchModel->update($batchId, ['title' => $label])) {
throw new \RuntimeException('Batch title update failed.');
}
if (!$this->db->transStatus()) {
throw new \RuntimeException('Batch transaction failed.');
}
$this->db->transCommit();
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Failed to create reimbursement batch: {msg}', ['msg' => $e->getMessage()]);
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
@@ -576,18 +596,6 @@ class ReimbursementController extends BaseController
]);
}
if ($batchId <= 0) {
return $this->response->setStatusCode(500)->setJSON([
'success' => false,
'error' => 'Failed to create batch.',
]);
}
$label = $title !== '' ? $title : 'Batch #' . $sequence;
if ($title === '') {
$this->batchModel->update($batchId, ['title' => $label]);
}
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
return $this->response
@@ -865,26 +873,55 @@ public function updateBatchAssignment()
}
}
$this->db->transBegin();
// Create reimbursement records for any batch items that don't yet have one
$items = $this->db->table('reimbursement_batch_items bi')
->select('bi.id AS batch_item_id, bi.reimbursement_id AS batch_reimb_id, bi.expense_id, e.amount, e.purchased_by, e.description, e.reimbursement_id AS expense_reimb_id, e.school_year AS expense_school_year, e.semester AS expense_semester')
->join('expenses e', 'e.id = bi.expense_id', 'inner')
->where('bi.batch_id', $batchId)
->where('bi.unassigned_at IS NULL', null, false)
->get()
->getResultArray();
$now = date('Y-m-d H:i:s');
$userId = (int) (session()->get('user_id') ?? 0);
$this->db->transBegin();
try {
$lockedBatch = $this->db->query('SELECT * FROM reimbursement_batches WHERE id = ? FOR UPDATE', [$batchId])->getRowArray();
if (!$lockedBatch || strtolower((string) ($lockedBatch['status'] ?? '')) !== 'open') {
throw new \RuntimeException('Batch is not open.');
}
$items = $this->db->query(
'SELECT bi.id AS batch_item_id,
bi.reimbursement_id AS batch_reimb_id,
bi.expense_id,
e.amount,
e.purchased_by,
e.category,
e.status AS expense_status,
e.description,
e.reimbursement_id AS expense_reimb_id,
e.school_year AS expense_school_year,
e.semester AS expense_semester
FROM reimbursement_batch_items bi
JOIN expenses e ON e.id = bi.expense_id
WHERE bi.batch_id = ?
AND bi.unassigned_at IS NULL
FOR UPDATE',
[$batchId]
)->getResultArray();
if ($items === []) {
throw new \RuntimeException('Batch has no active items.');
}
foreach ($items as $item) {
$expenseId = (int) ($item['expense_id'] ?? 0);
$recipientId = (int) ($item['purchased_by'] ?? 0);
if ($expenseId <= 0 || $recipientId <= 0) {
continue;
throw new \RuntimeException('Batch contains an invalid expense or recipient.');
}
if (FinancialStatus::normalize((string) ($item['expense_status'] ?? '')) !== 'approved') {
throw new \RuntimeException('Every batch expense must be approved before closing.');
}
if ((float) ($item['amount'] ?? 0) <= 0) {
throw new \RuntimeException('Every batch expense amount must be positive.');
}
if (strcasecmp((string) ($item['category'] ?? ''), 'Donation') === 0) {
throw new \RuntimeException('Donation expenses cannot be reimbursed.');
}
$reimbId = $item['batch_reimb_id'] ?: ($item['expense_reimb_id'] ?: $this->lookupReimbursementId($expenseId));
@@ -895,28 +932,31 @@ public function updateBatchAssignment()
'reimbursed_to' => $recipientId,
'approved_by' => $userId ?: null,
'description' => trim((string) ($item['description'] ?? '')),
'status' => 'Paid',
'status' => FinancialStatus::REIMBURSEMENT_PAID,
'added_by' => $userId ?: null,
'school_year' => $item['expense_school_year'] ?: $this->schoolYear,
'semester' => $item['expense_semester'] ?: $this->semester,
'reimbursement_method' => 'Check',
'batch_number' => $batchId,
'created_at' => $now,
'updated_at' => $now,
];
$reimbId = $this->reimbModel->insert($payload);
$reimbId = (int) $this->reimbModel->insert($payload);
if ($reimbId <= 0) {
throw new \RuntimeException('Failed to create reimbursement for batch item.');
}
}
if ($reimbId) {
$this->reimbModel->update($reimbId, [
'batch_number' => $batchId,
'approved_by' => $userId ?: null,
'status' => 'Paid',
]);
$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbId]);
if (!empty($item['batch_item_id'])) {
$this->batchItemModel->update((int) $item['batch_item_id'], ['reimbursement_id' => $reimbId]);
}
if (!$this->reimbModel->update($reimbId, [
'batch_number' => $batchId,
'approved_by' => $userId ?: null,
'status' => FinancialStatus::REIMBURSEMENT_PAID,
])) {
throw new \RuntimeException('Failed to update batch reimbursement.');
}
if (!$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbId])) {
throw new \RuntimeException('Failed to link batch expense reimbursement.');
}
if (empty($item['batch_item_id']) || !$this->batchItemModel->update((int) $item['batch_item_id'], ['reimbursement_id' => $reimbId])) {
throw new \RuntimeException('Failed to link batch item reimbursement.');
}
}
@@ -929,7 +969,9 @@ public function updateBatchAssignment()
$update['closed_by'] = $userId;
}
$this->batchModel->update($batchId, $update);
if (!$this->batchModel->update($batchId, $update)) {
throw new \RuntimeException('Failed to close reimbursement batch.');
}
} catch (\Throwable $e) {
$this->db->transRollback();
log_message('error', 'Failed to lock reimbursement batch #{batch}: {msg}', [
@@ -1979,15 +2021,6 @@ public function updateBatchAssignment()
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
if ($expenseId > 0) {
$expense = $this->expenseModel->find($expenseId);
if ($expense && strcasecmp($expense['category'] ?? '', 'Donation') === 0) {
return redirect()->back()->withInput()->with('errors', [
'expense_id' => 'Donation expenses are tracked but should not be reimbursed.'
]);
}
}
// Store file only if one was actually uploaded
try {
$receiptName = $this->saveReimbReceipt($this->request->getFile('receipt'));
@@ -2001,77 +2034,34 @@ public function updateBatchAssignment()
$userId = (int) (session()->get('user_id') ?? 0);
$recipientId = (int) $this->request->getPost('reimbursed_to');
// Mark reimbursement as Paid when recorded
$data = [
'expense_id' => $expenseId ?: null,
'amount' => $this->request->getPost('amount'),
'reimbursed_to' => $recipientId,
'description' => $this->request->getPost('description'),
'reimbursement_method' => $method,
'check_number' => $method === 'Check' ? $this->request->getPost('check_number') : null,
'receipt_path' => $receiptName, // may be null for Cash
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'added_by' => $userId,
'approved_by' => $userId,
'status' => 'Paid',
];
$this->reimbModel->insert($data);
$reimbursementId = $this->reimbModel->getInsertID();
if ($expenseId = $this->request->getPost('expense_id')) {
$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbursementId]);
try {
$this->createPaidReimbursementForExpense(
$expenseId,
(float) $this->request->getPost('amount'),
$recipientId,
$method,
$method === 'Check' ? (string) $this->request->getPost('check_number') : null,
$receiptName,
(string) $this->request->getPost('description'),
$userId
);
} catch (\Throwable $e) {
if ($receiptName !== null) {
@unlink(WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . 'reimbursements' . DIRECTORY_SEPARATOR . basename($receiptName));
}
log_message('error', 'Reimbursement creation failed: {msg}', ['msg' => $e->getMessage()]);
return redirect()->back()->withInput()->with('errors', [
'expense_id' => $e->getMessage(),
]);
}
return redirect()->to('/reimbursements')->with('success', 'Reimbursement recorded as Paid.');
}
// Optional old flow kept for compatibility (also sets Paid)
public function process()
{
$expenseId = (int) ($this->request->getPost('expense_id') ?? 0);
if ($expenseId > 0) {
$expense = $this->expenseModel->find($expenseId);
if ($expense && strcasecmp($expense['category'] ?? '', 'Donation') === 0) {
return redirect()->back()->withInput()->with('errors', [
'expense_id' => 'Donation expenses are tracked but should not be reimbursed.'
]);
}
}
try {
$receiptName = $this->saveReimbReceipt($this->request->getFile('receipt'));
} catch (\Throwable $e) {
log_message('error', 'Failed to save reimbursement receipt in process(): {msg}', ['msg' => $e->getMessage()]);
return redirect()->back()->withInput()->with('errors', [
'receipt' => 'Failed to save uploaded file. Please try again or contact admin.'
]);
}
$userId = (int) (session()->get('user_id') ?? 0);
$recipientId = (int) $this->request->getPost('reimbursed_to');
$reimbursementId = $this->reimbModel->insert([
'amount' => $this->request->getPost('amount'),
'reimbursed_to' => $recipientId,
'approved_by' => $userId,
'receipt_path' => $receiptName,
'description' => 'Expense reimbursement',
'status' => 'Paid',
'added_by' => $userId,
'school_year' => $this->schoolYear,
'semester' => $this->semester,
'check_number' => $this->request->getPost('check_number'),
'reimbursement_method' => $this->request->getPost('reimbursement_method')
]);
$this->expenseModel->update($expenseId, [
'reimbursement_id' => $reimbursementId
]);
return redirect()->to('/reimbursements/under-processing')->with('success', 'Reimbursement processed!');
return $this->store();
}
public function reimbursedExpenses()
@@ -2118,6 +2108,9 @@ public function updateBatchAssignment()
if (!$reimb) {
throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found");
}
if ($this->isPaidReimbursement($reimb)) {
return redirect()->to('/reimbursements')->with('error', 'Paid reimbursements are immutable. Reverse and replace the transaction instead.');
}
$users = $this->recipientOptions();
@@ -2136,6 +2129,9 @@ public function updateBatchAssignment()
if (!$reimb) {
throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found");
}
if ($this->isPaidReimbursement($reimb)) {
return redirect()->to('/reimbursements')->with('error', 'Paid reimbursements are immutable. Reverse and replace the transaction instead.');
}
$methodRaw = (string) $this->request->getPost('reimbursement_method');
$method = ucfirst(strtolower($methodRaw));
@@ -2198,6 +2194,162 @@ public function updateBatchAssignment()
return redirect()->to('/reimbursements')->with('success', 'Reimbursement updated.');
}
public function reverse(int $id)
{
$reason = trim((string)$this->request->getPost('reason'));
if ($reason === '') {
return redirect()->back()->with('error', 'Reversal reason is required.');
}
$this->db->transBegin();
try {
$reimbursement = $this->db->query('SELECT * FROM reimbursements WHERE id = ? FOR UPDATE', [$id])->getRowArray();
if (!$reimbursement) {
throw new \RuntimeException('Reimbursement not found.');
}
if (FinancialStatus::normalizeReimbursementStatus($reimbursement['status'] ?? null) !== FinancialStatus::REIMBURSEMENT_PAID) {
throw new \RuntimeException('Only paid reimbursements can be reversed.');
}
$expenseId = (int)($reimbursement['expense_id'] ?? 0);
if ($expenseId > 0) {
$this->db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$expenseId])->getRowArray();
}
$existingReversal = $this->db->table('reimbursement_reversals')
->where('reimbursement_id', $id)
->get()
->getRowArray();
if ($existingReversal) {
throw new \RuntimeException('Reimbursement has already been reversed.');
}
$amountCents = (int)round(((float)($reimbursement['amount'] ?? 0)) * 100);
$now = utc_now();
if (!$this->db->table('reimbursement_reversals')->insert([
'reimbursement_id' => $id,
'amount_cents' => $amountCents,
'reason' => $reason,
'reversed_by' => (int)(session()->get('user_id') ?? 0) ?: null,
'reversed_at' => $now,
'created_at' => $now,
])) {
throw new \RuntimeException('Reimbursement reversal could not be recorded.');
}
if (!$this->reimbModel->update($id, ['status' => FinancialStatus::REIMBURSEMENT_REVERSED])) {
throw new \RuntimeException('Reimbursement status could not be updated.');
}
if ($expenseId > 0 && !$this->expenseModel->update($expenseId, ['reimbursement_id' => null])) {
throw new \RuntimeException('Expense reimbursement link could not be cleared.');
}
if ($this->db->transStatus() === false) {
throw new \RuntimeException('Reimbursement reversal transaction failed.');
}
$this->db->transCommit();
} catch (\Throwable $e) {
$this->db->transRollback();
return redirect()->back()->with('error', $e->getMessage());
}
return redirect()->to('/reimbursements')->with('success', 'Reimbursement reversed.');
}
private function createPaidReimbursementForExpense(
int $expenseId,
float $amount,
int $recipientId,
string $method,
?string $checkNumber,
?string $receiptName,
string $description,
int $userId,
?int $batchId = null
): int {
if ($expenseId <= 0) {
throw new \RuntimeException('A valid approved expense is required.');
}
$this->db->transBegin();
try {
$expense = $this->db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$expenseId])->getRowArray();
if (!$expense) {
throw new \RuntimeException('Expense not found.');
}
if (FinancialStatus::normalize((string) ($expense['status'] ?? '')) !== 'approved') {
throw new \RuntimeException('Expense must be approved before reimbursement.');
}
if ((float) ($expense['amount'] ?? 0) <= 0 || $amount <= 0) {
throw new \RuntimeException('Reimbursement amount must be positive.');
}
if (abs((float) ($expense['amount'] ?? 0) - $amount) > 0.005) {
throw new \RuntimeException('Reimbursement amount must match the approved expense.');
}
if (strcasecmp((string) ($expense['category'] ?? ''), 'Donation') === 0) {
throw new \RuntimeException('Donation expenses are tracked but should not be reimbursed.');
}
if ($recipientId !== (int) ($expense['purchased_by'] ?? 0)) {
throw new \RuntimeException('Reimbursement recipient must match the expense purchaser.');
}
if ((string) ($expense['school_year'] ?? '') !== (string) $this->schoolYear) {
throw new \RuntimeException('Expense is outside the active school year.');
}
if (!empty($expense['semester']) && (string) $expense['semester'] !== (string) $this->semester) {
throw new \RuntimeException('Expense is outside the active semester.');
}
$active = $this->db->query(
"SELECT id FROM reimbursements
WHERE expense_id = ?
AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')
FOR UPDATE",
[$expenseId]
)->getRowArray();
if ($active || !empty($expense['reimbursement_id'])) {
throw new \RuntimeException('Expense already has an active reimbursement.');
}
$reimbursementId = (int) $this->reimbModel->insert([
'expense_id' => $expenseId,
'amount' => $amount,
'reimbursed_to' => $recipientId,
'description' => $description !== '' ? $description : (string) ($expense['description'] ?? ''),
'reimbursement_method' => $method,
'check_number' => $checkNumber,
'receipt_path' => $receiptName,
'school_year' => $expense['school_year'] ?: $this->schoolYear,
'semester' => $expense['semester'] ?: $this->semester,
'added_by' => $userId ?: null,
'approved_by' => $userId ?: null,
'status' => FinancialStatus::REIMBURSEMENT_PAID,
'batch_number' => $batchId,
]);
if ($reimbursementId <= 0) {
throw new \RuntimeException('Reimbursement insert failed.');
}
if (!$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbursementId])) {
throw new \RuntimeException('Expense reimbursement link failed.');
}
if (!$this->db->transStatus()) {
throw new \RuntimeException('Reimbursement transaction failed.');
}
$this->db->transCommit();
return $reimbursementId;
} catch (\Throwable $e) {
$this->db->transRollback();
throw $e;
}
}
private function isPaidReimbursement(array $reimbursement): bool
{
return FinancialStatus::normalizeReimbursementStatus((string) ($reimbursement['status'] ?? '')) === FinancialStatus::REIMBURSEMENT_PAID;
}
private function lookupReimbursementId(int $expenseId): ?int
{
if ($expenseId <= 0) {
@@ -69,11 +69,28 @@ class PaymentsLogicAndDataRepairSupport extends Migration
if (!$this->db->fieldExists('idempotency_key', 'payments')) {
$columns['idempotency_key'] = [
'type' => 'CHAR',
'constraint' => 36,
'type' => 'VARCHAR',
'constraint' => 100,
'null' => true,
'after' => 'transaction_id',
];
} else {
$this->forge->modifyColumn('payments', [
'idempotency_key' => [
'type' => 'VARCHAR',
'constraint' => 100,
'null' => true,
],
]);
}
if (!$this->db->fieldExists('request_fingerprint_hash', 'payments')) {
$columns['request_fingerprint_hash'] = [
'type' => 'CHAR',
'constraint' => 64,
'null' => true,
'after' => 'idempotency_key',
];
}
if (!$this->db->fieldExists('is_void', 'payments')) {
@@ -86,6 +103,24 @@ class PaymentsLogicAndDataRepairSupport extends Migration
];
}
if (!$this->db->fieldExists('evidence_status', 'payments')) {
$columns['evidence_status'] = [
'type' => 'VARCHAR',
'constraint' => 30,
'null' => true,
'after' => 'check_file',
];
}
if (!$this->db->fieldExists('evidence_failure_message', 'payments')) {
$columns['evidence_failure_message'] = [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
'after' => 'evidence_status',
];
}
if (!$this->db->fieldExists('reversal_of_payment_id', 'payments')) {
$columns['reversal_of_payment_id'] = [
'type' => 'INT',
@@ -0,0 +1,167 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateInvoiceLines extends Migration
{
public function up()
{
if (!$this->db->tableExists('invoice_lines')) {
$this->forge->addField([
'id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true,
],
'invoice_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => false,
],
'line_type' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => false,
],
'source_type' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'source_id' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
],
'active_source_key' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
],
'description' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => false,
],
'quantity' => [
'type' => 'DECIMAL',
'constraint' => '10,2',
'null' => false,
'default' => '1.00',
],
'unit_amount_cents' => [
'type' => 'INT',
'constraint' => 11,
'null' => false,
'default' => 0,
],
'line_amount_cents' => [
'type' => 'INT',
'constraint' => 11,
'null' => false,
'default' => 0,
],
'discount_eligible' => [
'type' => 'TINYINT',
'constraint' => 1,
'null' => false,
'default' => 1,
],
'calculation_version' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
],
'metadata_json' => [
'type' => 'TEXT',
'null' => true,
],
'created_at' => [
'type' => 'DATETIME',
'null' => false,
],
'updated_at' => [
'type' => 'DATETIME',
'null' => false,
],
'voided_at' => [
'type' => 'DATETIME',
'null' => true,
],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('invoice_id');
$this->forge->addKey(['source_type', 'source_id']);
$this->forge->addUniqueKey('active_source_key', 'uniq_invoice_lines_active_source_key');
$this->forge->createTable('invoice_lines', true);
}
$this->backfillLegacyInvoiceLines();
}
public function down()
{
if ($this->db->tableExists('invoice_lines')) {
$this->forge->dropTable('invoice_lines', true);
}
}
private function backfillLegacyInvoiceLines(): void
{
if (!$this->db->tableExists('invoices') || !$this->db->tableExists('invoice_lines')) {
return;
}
$existingRows = $this->db->table('invoice_lines')
->select('invoice_id')
->groupBy('invoice_id')
->get()
->getResultArray();
$existing = array_fill_keys(array_map(static fn ($row) => (int) ($row['invoice_id'] ?? 0), $existingRows), true);
$invoices = $this->db->table('invoices')
->select('id, total_amount, invoice_number, created_at, updated_at')
->orderBy('id', 'ASC')
->get()
->getResultArray();
$now = date('Y-m-d H:i:s');
foreach ($invoices as $invoice) {
$invoiceId = (int) ($invoice['id'] ?? 0);
if ($invoiceId <= 0 || isset($existing[$invoiceId])) {
continue;
}
$amountCents = (int) round(((float) ($invoice['total_amount'] ?? 0)) * 100);
$createdAt = $invoice['created_at'] ?? $now;
$updatedAt = $invoice['updated_at'] ?? $createdAt;
$this->db->table('invoice_lines')->insert([
'invoice_id' => $invoiceId,
'line_type' => 'legacy_invoice_total',
'source_type' => 'legacy_invoice',
'source_id' => $invoiceId,
'active_source_key' => null,
'description' => 'Legacy invoice total preserved from invoice ' . (string) ($invoice['invoice_number'] ?? $invoiceId),
'quantity' => '1.00',
'unit_amount_cents' => $amountCents,
'line_amount_cents' => $amountCents,
'discount_eligible' => 0,
'calculation_version' => 'legacy_import',
'metadata_json' => json_encode([
'source' => 'invoices.total_amount',
'legacy_discount_eligible_base_cents' => 0,
'reconciliation_required' => true,
], JSON_UNESCAPED_SLASHES),
'created_at' => $createdAt ?: $now,
'updated_at' => $updatedAt ?: $now,
'voided_at' => null,
]);
}
}
}
@@ -0,0 +1,214 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddRefundSourcesAndPayouts extends Migration
{
public function up()
{
$this->ensureRefundSourceColumns();
$this->ensureRefundPayoutsTable();
$this->ensureRefundPayoutFingerprintColumns();
$this->backfillRefundSources();
$this->backfillLegacyPayouts();
}
public function down()
{
if ($this->db->tableExists('refund_payouts')) {
$this->forge->dropTable('refund_payouts', true);
}
if ($this->db->tableExists('refunds')) {
foreach (['source_type', 'source_id', 'requested_amount_cents', 'approved_amount_cents', 'currency'] as $column) {
if ($this->db->fieldExists($column, 'refunds')) {
$this->forge->dropColumn('refunds', $column);
}
}
}
}
private function ensureRefundSourceColumns(): void
{
if (!$this->db->tableExists('refunds')) {
return;
}
$columns = [];
if (!$this->db->fieldExists('source_type', 'refunds')) {
$columns['source_type'] = ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true, 'after' => 'request'];
}
if (!$this->db->fieldExists('source_id', 'refunds')) {
$columns['source_id'] = ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'source_type'];
}
if (!$this->db->fieldExists('requested_amount_cents', 'refunds')) {
$columns['requested_amount_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'refund_amount'];
}
if (!$this->db->fieldExists('approved_amount_cents', 'refunds')) {
$columns['approved_amount_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'requested_amount_cents'];
}
if (!$this->db->fieldExists('currency', 'refunds')) {
$columns['currency'] = ['type' => 'CHAR', 'constraint' => 3, 'null' => false, 'default' => 'USD', 'after' => 'approved_amount_cents'];
}
if ($columns !== []) {
$this->forge->addColumn('refunds', $columns);
}
}
private function ensureRefundPayoutsTable(): void
{
if ($this->db->tableExists('refund_payouts')) {
return;
}
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'refund_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'amount_cents' => ['type' => 'INT', 'constraint' => 11, 'null' => false],
'currency' => ['type' => 'CHAR', 'constraint' => 3, 'null' => false, 'default' => 'USD'],
'payout_type' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false, 'default' => 'cash_out'],
'payment_method' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true],
'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false],
'external_reference' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
'check_number' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
'check_date' => ['type' => 'DATE', 'null' => true],
'evidence_path' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true],
'idempotency_key' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => false],
'operation_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true],
'request_fingerprint_hash' => ['type' => 'CHAR', 'constraint' => 64, 'null' => true],
'processed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'processed_at' => ['type' => 'DATETIME', 'null' => true],
'reversed_payout_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'failure_code' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
'failure_message' => ['type' => 'TEXT', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => false],
'updated_at' => ['type' => 'DATETIME', 'null' => false],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('refund_id');
$this->forge->addKey('reversed_payout_id');
$this->forge->addUniqueKey('idempotency_key', 'uniq_refund_payouts_idempotency_key');
$this->forge->createTable('refund_payouts', true);
}
private function ensureRefundPayoutFingerprintColumns(): void
{
if (!$this->db->tableExists('refund_payouts')) {
return;
}
$columns = [];
if (!$this->db->fieldExists('operation_type', 'refund_payouts')) {
$columns['operation_type'] = [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
'after' => 'idempotency_key',
];
}
if (!$this->db->fieldExists('request_fingerprint_hash', 'refund_payouts')) {
$columns['request_fingerprint_hash'] = [
'type' => 'CHAR',
'constraint' => 64,
'null' => true,
'after' => 'operation_type',
];
}
if ($columns !== []) {
$this->forge->addColumn('refund_payouts', $columns);
}
}
private function backfillRefundSources(): void
{
if (!$this->db->tableExists('refunds')) {
return;
}
$this->db->query(
"UPDATE `refunds`
SET `requested_amount_cents` = ROUND(COALESCE(`refund_amount`, 0) * 100),
`approved_amount_cents` = ROUND(COALESCE(`refund_amount`, 0) * 100),
`currency` = COALESCE(NULLIF(`currency`, ''), 'USD')
WHERE `requested_amount_cents` IS NULL OR `approved_amount_cents` IS NULL"
);
if ($this->db->fieldExists('source_type', 'refunds')) {
$this->db->query(
"UPDATE `refunds`
SET `source_type` = CASE
WHEN `invoice_id` IS NOT NULL THEN 'invoice_overpayment'
WHEN LOWER(COALESCE(`request`, '')) = 'duplicate' THEN 'payment_duplicate'
ELSE 'administrative_credit'
END,
`source_id` = CASE
WHEN `invoice_id` IS NOT NULL THEN `invoice_id`
ELSE `source_id`
END
WHERE `source_type` IS NULL"
);
}
}
private function backfillLegacyPayouts(): void
{
if (!$this->db->tableExists('refunds') || !$this->db->tableExists('refund_payouts')) {
return;
}
$refunds = $this->db->table('refunds')
->select('id, refund_paid_amount, currency, refund_method, check_nbr, check_file, refunded_at, updated_by, created_at, updated_at')
->where('refund_paid_amount >', 0)
->get()
->getResultArray();
$now = date('Y-m-d H:i:s');
foreach ($refunds as $refund) {
$refundId = (int) ($refund['id'] ?? 0);
if ($refundId <= 0) {
continue;
}
$exists = $this->db->table('refund_payouts')
->where('refund_id', $refundId)
->where('idempotency_key', 'legacy-refund-' . $refundId)
->countAllResults();
if ($exists > 0) {
continue;
}
$this->db->table('refund_payouts')->insert([
'refund_id' => $refundId,
'amount_cents' => (int) round(((float) ($refund['refund_paid_amount'] ?? 0)) * 100),
'currency' => (string) ($refund['currency'] ?? 'USD') ?: 'USD',
'payout_type' => 'cash_out',
'payment_method' => $refund['refund_method'] ?? null,
'status' => 'completed',
'external_reference' => 'legacy_import',
'check_number' => $refund['check_nbr'] ?? null,
'check_date' => null,
'evidence_path' => $refund['check_file'] ?? null,
'idempotency_key' => 'legacy-refund-' . $refundId,
'operation_type' => 'refund_payout',
'request_fingerprint_hash' => hash('sha256', json_encode([
'operation_type' => 'refund_payout',
'refund_id' => $refundId,
'amount_cents' => (int) round(((float) ($refund['refund_paid_amount'] ?? 0)) * 100),
'payment_method' => $refund['refund_method'] ?? null,
'currency' => (string) ($refund['currency'] ?? 'USD') ?: 'USD',
'external_reference' => 'legacy_import',
], JSON_UNESCAPED_SLASHES)),
'processed_by' => $refund['updated_by'] ?? null,
'processed_at' => $refund['refunded_at'] ?? $refund['updated_at'] ?? $now,
'reversed_payout_id' => null,
'failure_code' => null,
'failure_message' => null,
'created_at' => $refund['created_at'] ?? $now,
'updated_at' => $refund['updated_at'] ?? $now,
]);
}
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddRefundReconciliationFields extends Migration
{
public function up()
{
if (!$this->db->tableExists('refunds')) {
return;
}
$columns = [];
if (!$this->db->fieldExists('reconciliation_status', 'refunds')) {
$columns['reconciliation_status'] = [
'type' => 'VARCHAR',
'constraint' => 30,
'null' => true,
'after' => 'approved_amount_cents',
];
}
if (!$this->db->fieldExists('reconciliation_reason', 'refunds')) {
$columns['reconciliation_reason'] = [
'type' => 'TEXT',
'null' => true,
'after' => 'reconciliation_status',
];
}
if (!$this->db->fieldExists('reconciliation_required_at', 'refunds')) {
$columns['reconciliation_required_at'] = [
'type' => 'DATETIME',
'null' => true,
'after' => 'reconciliation_reason',
];
}
if ($columns !== []) {
$this->forge->addColumn('refunds', $columns);
}
}
public function down()
{
if (!$this->db->tableExists('refunds')) {
return;
}
foreach (['reconciliation_status', 'reconciliation_reason', 'reconciliation_required_at'] as $column) {
if ($this->db->fieldExists($column, 'refunds')) {
$this->forge->dropColumn('refunds', $column);
}
}
}
}
@@ -0,0 +1,191 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class FinancialWorkflowHardening extends Migration
{
public function up()
{
$this->ensureDiscountUsageColumns();
$this->ensureDiscountUsageUniqueness();
$this->ensureAdditionalChargeLedgerColumns();
$this->ensureInvoiceLineActiveSourceKey();
$this->expandAdditionalChargeStatus();
}
public function down()
{
if ($this->db->tableExists('discount_usages')) {
foreach (['requested_discount_cents', 'eligible_base_cents', 'eligible_base_before_cents', 'applied_discount_cents', 'application_order'] as $column) {
if ($this->db->fieldExists($column, 'discount_usages')) {
$this->forge->dropColumn('discount_usages', $column);
}
}
}
}
private function ensureDiscountUsageColumns(): void
{
if (!$this->db->tableExists('discount_usages')) {
return;
}
$columns = [];
if (!$this->db->fieldExists('requested_discount_cents', 'discount_usages')) {
$columns['requested_discount_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'discount_amount'];
}
if (!$this->db->fieldExists('eligible_base_cents', 'discount_usages')) {
$columns['eligible_base_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'requested_discount_cents'];
}
if (!$this->db->fieldExists('eligible_base_before_cents', 'discount_usages')) {
$columns['eligible_base_before_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'eligible_base_cents'];
}
if (!$this->db->fieldExists('applied_discount_cents', 'discount_usages')) {
$columns['applied_discount_cents'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'eligible_base_before_cents'];
}
if (!$this->db->fieldExists('application_order', 'discount_usages')) {
$columns['application_order'] = ['type' => 'INT', 'constraint' => 11, 'null' => true, 'after' => 'applied_discount_cents'];
}
if ($columns !== []) {
$this->forge->addColumn('discount_usages', $columns);
}
$this->db->query(
"UPDATE discount_usages
SET requested_discount_cents = COALESCE(requested_discount_cents, ROUND(COALESCE(discount_amount, 0) * 100)),
eligible_base_cents = COALESCE(eligible_base_cents, ROUND(COALESCE(discount_amount, 0) * 100)),
eligible_base_before_cents = COALESCE(eligible_base_before_cents, eligible_base_cents, ROUND(COALESCE(discount_amount, 0) * 100)),
applied_discount_cents = COALESCE(applied_discount_cents, ROUND(COALESCE(discount_amount, 0) * 100))
WHERE requested_discount_cents IS NULL
OR eligible_base_cents IS NULL
OR eligible_base_before_cents IS NULL
OR applied_discount_cents IS NULL"
);
$this->db->query(
"UPDATE discount_usages du
JOIN (
SELECT id, ROW_NUMBER() OVER (PARTITION BY invoice_id ORDER BY COALESCE(used_at, created_at), id) AS rn
FROM discount_usages
) ordered ON ordered.id = du.id
SET du.application_order = COALESCE(du.application_order, ordered.rn)
WHERE du.application_order IS NULL"
);
}
private function ensureDiscountUsageUniqueness(): void
{
if (!$this->db->tableExists('discount_usages')) {
return;
}
$indexes = $this->db->query('SHOW INDEX FROM discount_usages')->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === 'uniq_discount_usage_voucher_invoice') {
return;
}
}
$this->db->query(
'CREATE UNIQUE INDEX uniq_discount_usage_voucher_invoice ON discount_usages (voucher_id, invoice_id)'
);
$this->assertIndexExists('discount_usages', 'uniq_discount_usage_voucher_invoice');
}
private function ensureAdditionalChargeLedgerColumns(): void
{
if (!$this->db->tableExists('additional_charges')) {
return;
}
$columns = [];
if (!$this->db->fieldExists('applied_invoice_line_id', 'additional_charges')) {
$columns['applied_invoice_line_id'] = ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'status'];
}
if (!$this->db->fieldExists('applied_by', 'additional_charges')) {
$columns['applied_by'] = ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'applied_invoice_line_id'];
}
if (!$this->db->fieldExists('applied_at', 'additional_charges')) {
$columns['applied_at'] = ['type' => 'DATETIME', 'null' => true, 'after' => 'applied_by'];
}
if (!$this->db->fieldExists('voided_by', 'additional_charges')) {
$columns['voided_by'] = ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true, 'after' => 'applied_at'];
}
if (!$this->db->fieldExists('voided_at', 'additional_charges')) {
$columns['voided_at'] = ['type' => 'DATETIME', 'null' => true, 'after' => 'voided_by'];
}
if (!$this->db->fieldExists('void_reason', 'additional_charges')) {
$columns['void_reason'] = ['type' => 'TEXT', 'null' => true, 'after' => 'voided_at'];
}
if ($columns !== []) {
$this->forge->addColumn('additional_charges', $columns);
}
}
private function ensureInvoiceLineActiveSourceKey(): void
{
if (!$this->db->tableExists('invoice_lines')) {
return;
}
if (!$this->db->fieldExists('active_source_key', 'invoice_lines')) {
$this->forge->addColumn('invoice_lines', [
'active_source_key' => [
'type' => 'VARCHAR',
'constraint' => 120,
'null' => true,
'after' => 'source_id',
],
]);
}
$indexes = $this->db->query('SHOW INDEX FROM invoice_lines')->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === 'uniq_invoice_lines_active_source_key') {
return;
}
}
$this->db->query(
'CREATE UNIQUE INDEX uniq_invoice_lines_active_source_key ON invoice_lines (active_source_key)'
);
$this->assertIndexExists('invoice_lines', 'uniq_invoice_lines_active_source_key');
}
private function expandAdditionalChargeStatus(): void
{
if (!$this->db->tableExists('additional_charges')) {
return;
}
try {
$this->forge->modifyColumn('additional_charges', [
'status' => [
'type' => 'ENUM',
'constraint' => ['pending', 'approved', 'applied', 'rejected', 'voided', 'reversed'],
'default' => 'pending',
'null' => false,
],
]);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not expand additional charge statuses: ' . $e->getMessage(), 0, $e);
}
}
private function assertIndexExists(string $table, string $indexName): void
{
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === $indexName) {
return;
}
}
throw new \RuntimeException("Required index {$indexName} was not created on {$table}.");
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class FinancialReimbursementPoHardening extends Migration
{
public function up()
{
$this->ensureReimbursementInvariants();
$this->ensurePurchaseOrderInvariants();
}
public function down()
{
if ($this->db->tableExists('reimbursements')) {
$this->dropIndexIfExists('reimbursements', 'uniq_reimbursement_active_expense');
if ($this->db->fieldExists('active_expense_id', 'reimbursements')) {
$this->forge->dropColumn('reimbursements', 'active_expense_id');
}
}
$this->dropIndexIfExists('reimbursement_batches', 'uniq_reimbursement_batch_year_sequence');
$this->dropIndexIfExists('reimbursement_batch_items', 'uniq_reimbursement_batch_item_active_expense');
}
private function ensureReimbursementInvariants(): void
{
if (!$this->db->tableExists('reimbursements')) {
return;
}
try {
$this->forge->modifyColumn('reimbursements', [
'status' => [
'type' => 'ENUM',
'constraint' => ['pending', 'approved', 'paid', 'rejected', 'reversed', 'Pending', 'Approved', 'Paid', 'Rejected', 'Reversed'],
'default' => 'pending',
'null' => false,
],
]);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not normalize reimbursement statuses: ' . $e->getMessage(), 0, $e);
}
if (!$this->db->fieldExists('active_expense_id', 'reimbursements')) {
try {
$this->db->query(
"ALTER TABLE reimbursements
ADD active_expense_id INT
GENERATED ALWAYS AS (
CASE
WHEN expense_id IS NOT NULL
AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')
THEN expense_id
ELSE NULL
END
) STORED"
);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not add reimbursement active_expense_id generated column: ' . $e->getMessage(), 0, $e);
}
}
$this->createIndexIfMissing('reimbursements', 'uniq_reimbursement_active_expense', 'CREATE UNIQUE INDEX uniq_reimbursement_active_expense ON reimbursements (active_expense_id)');
$this->createIndexIfMissing('reimbursement_batches', 'uniq_reimbursement_batch_year_sequence', 'CREATE UNIQUE INDEX uniq_reimbursement_batch_year_sequence ON reimbursement_batches (school_year, yearly_batch_number)');
$this->createIndexIfMissing('reimbursement_batch_items', 'uniq_reimbursement_batch_item_active_expense', 'CREATE UNIQUE INDEX uniq_reimbursement_batch_item_active_expense ON reimbursement_batch_items (batch_id, expense_id)');
}
private function ensurePurchaseOrderInvariants(): void
{
if (!$this->db->tableExists('purchase_order_items')) {
return;
}
try {
$this->forge->modifyColumn('purchase_order_items', [
'quantity' => [
'type' => 'INT',
'constraint' => 11,
'null' => false,
],
'received_qty' => [
'type' => 'INT',
'constraint' => 11,
'default' => 0,
'null' => false,
],
'unit_cost' => [
'type' => 'DECIMAL',
'constraint' => '10,2',
'default' => 0,
'null' => false,
],
]);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not tighten purchase order item columns: ' . $e->getMessage(), 0, $e);
}
try {
$this->db->query(
'ALTER TABLE purchase_order_items
ADD CONSTRAINT chk_po_item_quantities
CHECK (quantity > 0 AND received_qty >= 0 AND received_qty <= quantity AND unit_cost >= 0)'
);
} catch (\Throwable $e) {
throw new \RuntimeException('Could not add purchase order quantity check: ' . $e->getMessage(), 0, $e);
}
}
private function createIndexIfMissing(string $table, string $indexName, string $sql): void
{
if (!$this->db->tableExists($table)) {
return;
}
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === $indexName) {
return;
}
}
$this->db->query($sql);
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === $indexName) {
return;
}
}
throw new \RuntimeException('Required index ' . $indexName . ' was not created.');
}
private function dropIndexIfExists(string $table, string $indexName): void
{
if (!$this->db->tableExists($table)) {
return;
}
try {
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === $indexName) {
$this->db->query('DROP INDEX ' . $this->db->escapeIdentifiers($indexName) . ' ON ' . $this->db->escapeIdentifiers($table));
return;
}
}
} catch (\Throwable $e) {
log_message('warning', 'Could not drop index ' . $indexName . ': ' . $e->getMessage());
}
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreatePaymentCorrections extends Migration
{
public function up()
{
if ($this->db->tableExists('payment_corrections')) {
return;
}
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'payment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'invoice_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'parent_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'correction_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false],
'approved_refundable_cents' => ['type' => 'INT', 'constraint' => 11, 'null' => false],
'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false],
'reason' => ['type' => 'TEXT', 'null' => true],
'approved_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'approved_at' => ['type' => 'DATETIME', 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => false],
'updated_at' => ['type' => 'DATETIME', 'null' => false],
]);
$this->forge->addKey('id', true);
$this->forge->createTable('payment_corrections', true);
$this->db->query('CREATE INDEX idx_payment_corrections_payment_status ON payment_corrections (payment_id, status)');
$this->db->query('CREATE INDEX idx_payment_corrections_invoice_status ON payment_corrections (invoice_id, status)');
$this->assertIndexExists('payment_corrections', 'idx_payment_corrections_payment_status');
}
public function down()
{
if ($this->db->tableExists('payment_corrections')) {
$this->forge->dropTable('payment_corrections', true);
}
}
private function assertIndexExists(string $table, string $indexName): void
{
$indexes = $this->db->query('SHOW INDEX FROM ' . $this->db->escapeIdentifiers($table))->getResultArray();
foreach ($indexes as $index) {
if (($index['Key_name'] ?? '') === $indexName) {
return;
}
}
throw new \RuntimeException("Required index {$indexName} was not created on {$table}.");
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateReimbursementReversals extends Migration
{
public function up()
{
if ($this->db->tableExists('reimbursement_reversals')) {
return;
}
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'reimbursement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'amount_cents' => ['type' => 'INT', 'constraint' => 11, 'null' => false],
'reason' => ['type' => 'TEXT', 'null' => false],
'reversed_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'reversed_at' => ['type' => 'DATETIME', 'null' => false],
'created_at' => ['type' => 'DATETIME', 'null' => false],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('reimbursement_id');
$this->forge->createTable('reimbursement_reversals', true);
}
public function down()
{
if ($this->db->tableExists('reimbursement_reversals')) {
$this->forge->dropTable('reimbursement_reversals', true);
}
}
}
@@ -0,0 +1,54 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateInventoryReceiptOperations extends Migration
{
public function up()
{
if (!$this->db->tableExists('inventory_receipt_operations')) {
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'idempotency_key' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => false],
'purchase_order_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'request_fingerprint_hash' => ['type' => 'CHAR', 'constraint' => 64, 'null' => false],
'status' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => false],
'actor_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'created_at' => ['type' => 'DATETIME', 'null' => false],
'updated_at' => ['type' => 'DATETIME', 'null' => false],
]);
$this->forge->addKey('id', true);
$this->forge->addUniqueKey('idempotency_key', 'uniq_inventory_receipt_operation_key');
$this->forge->createTable('inventory_receipt_operations', true);
}
if (!$this->db->tableExists('inventory_receipt_lines')) {
$this->forge->addField([
'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true],
'operation_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'purchase_order_item_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => false],
'quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => false],
'movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'reversal_movement_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
'reversed_quantity' => ['type' => 'INT', 'constraint' => 11, 'null' => false, 'default' => 0],
'created_at' => ['type' => 'DATETIME', 'null' => false],
]);
$this->forge->addKey('id', true);
$this->forge->addKey('operation_id');
$this->forge->addKey('purchase_order_item_id');
$this->forge->createTable('inventory_receipt_lines', true);
}
}
public function down()
{
if ($this->db->tableExists('inventory_receipt_lines')) {
$this->forge->dropTable('inventory_receipt_lines', true);
}
if ($this->db->tableExists('inventory_receipt_operations')) {
$this->forge->dropTable('inventory_receipt_operations', true);
}
}
}
+54 -3
View File
@@ -21,6 +21,18 @@ final class FinancialAttachmentService
];
public function saveUploadedFile($file, string $subdir): ?string
{
$staged = $this->stageUploadedFile($file, $subdir);
if ($staged === null) {
return null;
}
$this->finalizeStagedFile($staged);
return $staged['final_name'];
}
public function stageUploadedFile($file, string $subdir): ?array
{
if (!$file instanceof UploadedFile) {
return null;
@@ -46,11 +58,50 @@ final class FinancialAttachmentService
throw new \RuntimeException('File too large. Maximum size is 5 MB.');
}
$dir = $this->ensureSubdir($subdir);
$name = $file->getRandomName();
$file->move($dir, $name);
$tmpSubdir = '_tmp' . DIRECTORY_SEPARATOR . trim($subdir, '/');
$tmpDir = $this->ensureSubdir($tmpSubdir);
$tmpName = 'pending-' . bin2hex(random_bytes(8)) . '-' . $name;
$file->move($tmpDir, $tmpName);
return $name;
return [
'subdir' => trim($subdir, '/'),
'final_name' => $name,
'temporary_subdir' => $tmpSubdir,
'temporary_name' => $tmpName,
'temporary_path' => $tmpDir . DIRECTORY_SEPARATOR . $tmpName,
];
}
public function finalizeStagedFile(array $staged): string
{
$tmpPath = (string)($staged['temporary_path'] ?? '');
$finalName = basename((string)($staged['final_name'] ?? ''));
$subdir = (string)($staged['subdir'] ?? '');
if ($tmpPath === '' || $finalName === '' || $subdir === '' || !is_file($tmpPath)) {
throw new \RuntimeException('Temporary upload is missing.');
}
$finalDir = $this->ensureSubdir($subdir);
$finalPath = $finalDir . DIRECTORY_SEPARATOR . $finalName;
if (!@rename($tmpPath, $finalPath)) {
throw new \RuntimeException('Unable to finalize uploaded evidence.');
}
return $finalName;
}
public function discardStagedFile(?array $staged): void
{
if ($staged === null) {
return;
}
$tmpPath = (string)($staged['temporary_path'] ?? '');
if ($tmpPath !== '' && is_file($tmpPath)) {
@unlink($tmpPath);
}
}
public function resolvePath(string $subdir, string $filename): ?string
@@ -0,0 +1,66 @@
<?php
namespace App\Libraries;
use App\Models\InvoiceModel;
class FinancialCorrectionReportService
{
private InvoiceModel $invoiceModel;
private FinancialReportProjectionService $projectionService;
public function __construct(?FinancialReportProjectionService $projectionService = null)
{
$this->invoiceModel = new InvoiceModel();
$this->projectionService = $projectionService ?? new FinancialReportProjectionService();
}
public function invoiceReport(?string $schoolYear = null): array
{
$query = $this->invoiceModel->orderBy('id', 'ASC');
if ($schoolYear !== null && $schoolYear !== '') {
$query->where('school_year', $schoolYear);
}
$rows = [];
foreach ($query->findAll() as $invoice) {
$projection = $this->projectionService->invoiceProjection((int) $invoice['id']);
$storedTotalCents = $this->toCents((float) ($invoice['total_amount'] ?? 0));
$storedBalanceCents = $this->toCents((float) ($invoice['balance'] ?? 0));
$flags = [];
if ($storedTotalCents !== $projection['invoice_gross_charges_cents']) {
$flags[] = 'stored_total_mismatch';
}
if ($storedBalanceCents !== $projection['amount_due_cents']) {
$flags[] = 'stored_balance_mismatch';
}
if ($projection['completed_cash_refunds_cents'] < 0) {
$flags[] = 'wrong_refund_sign';
}
if ($projection['applied_discounts_cents'] > $projection['invoice_gross_charges_cents']) {
$flags[] = 'discount_above_eligible_base';
}
$rows[] = [
'invoice_id' => (int) $invoice['id'],
'invoice_number' => $invoice['invoice_number'] ?? null,
'stored_total_cents' => $storedTotalCents,
'canonical_frozen_charge_total_cents' => $projection['invoice_gross_charges_cents'],
'valid_payment_total_cents' => $projection['valid_payments_cents'],
'completed_refund_payout_total_cents' => $projection['completed_cash_refunds_cents'],
'applied_discount_total_cents' => $projection['applied_discounts_cents'],
'expected_balance_due_cents' => $projection['amount_due_cents'],
'expected_customer_credit_cents' => $projection['customer_credit_cents'],
'flags' => $flags,
];
}
return $rows;
}
private function toCents(float $amount): int
{
return (int) round($amount * 100);
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Libraries;
class FinancialPersistenceException extends \RuntimeException
{
public function __construct(
public readonly string $codeName,
public readonly array $details = []
) {
parent::__construct($codeName);
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Libraries;
use App\Models\InvoiceModel;
class FinancialReportProjectionService
{
private InvoiceLedgerService $invoiceLedgerService;
private InvoiceModel $invoiceModel;
public function __construct(?InvoiceLedgerService $invoiceLedgerService = null)
{
$this->invoiceLedgerService = $invoiceLedgerService ?? new InvoiceLedgerService();
$this->invoiceModel = new InvoiceModel();
}
public function invoiceProjection(int $invoiceId): array
{
$ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId);
return [
'invoice_id' => $invoiceId,
'invoice_gross_charges_cents' => (int) ($ledger['totalAmountCents'] ?? 0),
'applied_discounts_cents' => (int) ($ledger['discountCents'] ?? 0),
'net_charges_cents' => max(0, (int) ($ledger['totalAmountCents'] ?? 0) - (int) ($ledger['discountCents'] ?? 0)),
'valid_payments_cents' => (int) ($ledger['paidCents'] ?? 0),
'completed_cash_refunds_cents' => (int) ($ledger['completedRefundCents'] ?? 0),
'amount_due_cents' => (int) ($ledger['balanceDueCents'] ?? 0),
'customer_credit_cents' => (int) ($ledger['customerCreditCents'] ?? 0),
'approved_refund_reservations_cents' => (int) ($ledger['approvedRefundReservationCents'] ?? 0),
'available_refundable_credit_cents' => (int) ($ledger['availableRefundableCreditCents'] ?? 0),
];
}
public function allInvoiceProjections(?string $schoolYear = null): array
{
$query = $this->invoiceModel->select('id')->orderBy('id', 'ASC');
if ($schoolYear !== null && $schoolYear !== '') {
$query->where('school_year', $schoolYear);
}
$rows = $query->findAll();
return array_map(fn (array $row): array => $this->invoiceProjection((int) $row['id']), $rows);
}
}
+59 -1
View File
@@ -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,
};
}
+252
View File
@@ -0,0 +1,252 @@
<?php
namespace App\Libraries;
use App\Models\AdditionalChargeModel;
use App\Models\InvoiceLineModel;
class InvoiceAdjustmentService
{
private \CodeIgniter\Database\BaseConnection $db;
private AdditionalChargeModel $additionalChargeModel;
private InvoiceLineModel $invoiceLineModel;
private InvoiceLedgerService $invoiceLedgerService;
public function __construct(
?\CodeIgniter\Database\BaseConnection $db = null,
?AdditionalChargeModel $additionalChargeModel = null,
?InvoiceLineModel $invoiceLineModel = null,
?InvoiceLedgerService $invoiceLedgerService = null
) {
$this->db = $db ?? db_connect();
$this->additionalChargeModel = $additionalChargeModel ?? new AdditionalChargeModel();
$this->invoiceLineModel = $invoiceLineModel ?? new InvoiceLineModel();
$this->invoiceLedgerService = $invoiceLedgerService ?? new InvoiceLedgerService();
}
public function applyAdditionalCharge(int $chargeId, int $invoiceId, int $actorId): InvoiceLedgerResult
{
$this->db->transBegin();
try {
$charge = $this->lockCharge($chargeId);
$invoice = $this->lockInvoice($invoiceId);
$this->assertInvoiceAcceptsAdjustment($invoice);
$this->assertChargeMatchesInvoice($charge, $invoice);
if ((string)($charge['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_APPROVED) {
throw new \RuntimeException('Only approved charges can be applied.');
}
$amountCents = $this->signedChargeAmountCents($charge);
if ($amountCents === 0) {
throw new \RuntimeException('Zero amount additional charges cannot be applied.');
}
$activeSourceKey = $this->activeSourceKey($chargeId);
$existing = $this->db->table('invoice_lines')
->where('active_source_key', $activeSourceKey)
->where('voided_at IS NULL', null, false)
->get()
->getRowArray();
if ($existing) {
throw new \RuntimeException('Additional charge already has an active invoice line.');
}
$now = utc_now();
$lineId = $this->invoiceLineModel->insert([
'invoice_id' => $invoiceId,
'line_type' => $amountCents > 0 ? 'additional_charge' : 'additional_deduction',
'source_type' => 'additional_charge',
'source_id' => $chargeId,
'active_source_key' => $activeSourceKey,
'description' => $this->chargeDescription($charge),
'quantity' => '1.00',
'unit_amount_cents' => $amountCents,
'line_amount_cents' => $amountCents,
'discount_eligible' => 0,
'calculation_version' => 'invoice_adjustment_v1',
'metadata_json' => json_encode([
'charge_type' => (string)($charge['charge_type'] ?? ''),
'applied_by' => $actorId,
], JSON_UNESCAPED_SLASHES),
'created_at' => $now,
'updated_at' => $now,
'voided_at' => null,
]);
$this->requireWrite($lineId, 'INVOICE_ADJUSTMENT_LINE_INSERT_FAILED', $this->invoiceLineModel);
$this->requireWrite($this->additionalChargeModel->update($chargeId, [
'status' => FinancialStatus::ADDITIONAL_CHARGE_APPLIED,
'invoice_id' => $invoiceId,
'parent_id' => (int)$invoice['parent_id'],
'school_year' => (string)$invoice['school_year'],
'semester' => (string)($invoice['semester'] ?? ''),
'applied_invoice_line_id' => (int)$lineId,
'applied_by' => $actorId > 0 ? $actorId : null,
'applied_at' => $now,
]), 'ADDITIONAL_CHARGE_APPLY_UPDATE_FAILED', $this->additionalChargeModel);
$ledger = $this->invoiceLedgerService->recalculateInvoice($invoiceId);
$this->requireTransactionStatus();
$this->db->transCommit();
return new InvoiceLedgerResult($invoiceId, (int)$lineId, $ledger);
} catch (\Throwable $e) {
$this->db->transRollback();
throw $e;
}
}
public function reverseAdditionalCharge(int $chargeId, string $reason, int $actorId): InvoiceLedgerResult
{
$reason = trim($reason);
if ($reason === '') {
throw new \RuntimeException('Reversal reason is required.');
}
$this->db->transBegin();
try {
$charge = $this->lockCharge($chargeId);
if ((string)($charge['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_APPLIED) {
throw new \RuntimeException('Only applied charges can be reversed.');
}
$invoiceId = (int)($charge['invoice_id'] ?? 0);
$invoice = $this->lockInvoice($invoiceId);
$this->assertInvoiceAcceptsAdjustment($invoice);
$originalLineId = (int)($charge['applied_invoice_line_id'] ?? 0);
$originalLine = $originalLineId > 0
? $this->db->query('SELECT * FROM invoice_lines WHERE id = ? FOR UPDATE', [$originalLineId])->getRowArray()
: null;
if (!$originalLine) {
$originalLine = $this->db->query(
'SELECT * FROM invoice_lines WHERE source_type = ? AND source_id = ? AND voided_at IS NULL ORDER BY id ASC LIMIT 1 FOR UPDATE',
['additional_charge', $chargeId]
)->getRowArray();
}
if (!$originalLine) {
throw new \RuntimeException('Original invoice line not found.');
}
$reverseAmountCents = -1 * (int)($originalLine['line_amount_cents'] ?? 0);
if ($reverseAmountCents === 0) {
throw new \RuntimeException('Zero amount additional charges cannot be reversed.');
}
$now = utc_now();
$lineId = $this->invoiceLineModel->insert([
'invoice_id' => $invoiceId,
'line_type' => $reverseAmountCents > 0 ? 'additional_charge_reversal' : 'additional_deduction_reversal',
'source_type' => 'additional_charge_reversal',
'source_id' => $chargeId,
'active_source_key' => null,
'description' => 'Reversal: ' . $this->chargeDescription($charge),
'quantity' => '1.00',
'unit_amount_cents' => $reverseAmountCents,
'line_amount_cents' => $reverseAmountCents,
'discount_eligible' => 0,
'calculation_version' => 'invoice_adjustment_v1',
'metadata_json' => json_encode([
'original_invoice_line_id' => (int)($originalLine['id'] ?? 0),
'reason' => $reason,
'reversed_by' => $actorId,
], JSON_UNESCAPED_SLASHES),
'created_at' => $now,
'updated_at' => $now,
'voided_at' => null,
]);
$this->requireWrite($lineId, 'INVOICE_ADJUSTMENT_REVERSAL_LINE_INSERT_FAILED', $this->invoiceLineModel);
$this->requireWrite($this->additionalChargeModel->update($chargeId, [
'status' => 'reversed',
'voided_by' => $actorId > 0 ? $actorId : null,
'voided_at' => $now,
'void_reason' => $reason,
]), 'ADDITIONAL_CHARGE_REVERSE_UPDATE_FAILED', $this->additionalChargeModel);
$ledger = $this->invoiceLedgerService->recalculateInvoice($invoiceId);
$this->requireTransactionStatus();
$this->db->transCommit();
return new InvoiceLedgerResult($invoiceId, (int)$lineId, $ledger);
} catch (\Throwable $e) {
$this->db->transRollback();
throw $e;
}
}
private function lockCharge(int $chargeId): array
{
$charge = $this->db->query('SELECT * FROM additional_charges WHERE id = ? FOR UPDATE', [$chargeId])->getRowArray();
if (!$charge) {
throw new \RuntimeException('Additional charge not found.');
}
return $charge;
}
private function lockInvoice(int $invoiceId): array
{
$invoice = $this->db->query('SELECT * FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId])->getRowArray();
if (!$invoice) {
throw new \RuntimeException('Invoice not found.');
}
return $invoice;
}
private function assertInvoiceAcceptsAdjustment(array $invoice): void
{
if (FinancialStatus::normalizeInvoiceStatus($invoice['status'] ?? null) === FinancialStatus::INVOICE_VOIDED) {
throw new \RuntimeException('Voided invoices cannot be adjusted.');
}
}
private function assertChargeMatchesInvoice(array $charge, array $invoice): void
{
if ((int)($charge['parent_id'] ?? 0) !== (int)($invoice['parent_id'] ?? 0)) {
throw new \RuntimeException('Charge parent does not match invoice parent.');
}
if ((string)($charge['school_year'] ?? '') !== (string)($invoice['school_year'] ?? '')) {
throw new \RuntimeException('Charge school year does not match invoice school year.');
}
if ((string)($charge['semester'] ?? '') !== (string)($invoice['semester'] ?? '')) {
throw new \RuntimeException('Charge semester does not match invoice semester.');
}
}
private function signedChargeAmountCents(array $charge): int
{
$amount = (int)round(abs((float)($charge['amount'] ?? 0)) * 100);
return (string)($charge['charge_type'] ?? 'add') === 'deduct' ? -1 * $amount : $amount;
}
private function activeSourceKey(int $chargeId): string
{
return 'additional_charge:' . $chargeId;
}
private function chargeDescription(array $charge): string
{
$title = trim((string)($charge['title'] ?? 'Additional charge'));
return $title !== '' ? $title : 'Additional charge';
}
private function requireWrite($result, string $code, $model = null): void
{
if ($result === false || $result === null || $result === 0) {
$details = is_object($model) && method_exists($model, 'errors') ? (array)$model->errors() : [];
throw new FinancialPersistenceException($code, $details);
}
}
private function requireTransactionStatus(string $code = 'TRANSACTION_FAILED'): void
{
if (method_exists($this->db, 'transStatus') && $this->db->transStatus() === false) {
throw new FinancialPersistenceException($code);
}
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
namespace App\Libraries;
use App\Models\InvoiceLineModel;
use App\Models\InvoiceModel;
class InvoiceIssuanceService
{
private \CodeIgniter\Database\BaseConnection $db;
private InvoiceModel $invoiceModel;
private InvoiceLineModel $invoiceLineModel;
private InvoiceLedgerService $invoiceLedgerService;
public function __construct(
?\CodeIgniter\Database\BaseConnection $db = null,
?InvoiceModel $invoiceModel = null,
?InvoiceLineModel $invoiceLineModel = null,
?InvoiceLedgerService $invoiceLedgerService = null
) {
$this->db = $db ?? db_connect();
$this->invoiceModel = $invoiceModel ?? new InvoiceModel();
$this->invoiceLineModel = $invoiceLineModel ?? new InvoiceLineModel();
$this->invoiceLedgerService = $invoiceLedgerService ?? new InvoiceLedgerService();
}
public function issueInvoice(IssueInvoiceCommand $command): InvoiceLedgerResult
{
$invoiceData = $command->invoiceData;
$invoiceData['status'] = FinancialStatus::INVOICE_DRAFT;
$invoiceData['total_amount'] = $invoiceData['total_amount'] ?? number_format($command->tuitionAmount + $command->eventAmount, 2, '.', '');
$invoiceData['balance'] = $invoiceData['balance'] ?? $invoiceData['total_amount'];
$this->db->transBegin();
try {
$invoiceId = $this->invoiceModel->insert($invoiceData);
$this->requireWrite($invoiceId, 'INVOICE_ISSUE_INSERT_FAILED', $this->invoiceModel);
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int)$invoiceId]);
$inserted = $this->invoiceLedgerService->issueInitialInvoiceLines(
(int)$invoiceId,
$command->tuitionAmount,
$command->eventAmount,
$command->metadata
);
if ($inserted <= 0) {
throw new FinancialPersistenceException('INVOICE_ISSUE_NO_LINES');
}
$lineTotals = $this->db->table('invoice_lines')
->select('COUNT(*) AS line_count, COALESCE(SUM(line_amount_cents),0) AS total_cents')
->where('invoice_id', (int)$invoiceId)
->where('voided_at IS NULL', null, false)
->get()
->getRowArray();
if ((int)($lineTotals['line_count'] ?? 0) !== $inserted) {
throw new FinancialPersistenceException('INVOICE_ISSUE_LINE_COUNT_MISMATCH');
}
if ((int)($lineTotals['total_cents'] ?? 0) === 0) {
throw new FinancialPersistenceException('INVOICE_ISSUE_ZERO_LINE_TOTAL');
}
$this->requireWrite($this->invoiceModel->update((int)$invoiceId, [
'status' => FinancialStatus::INVOICE_ISSUED,
'updated_at' => utc_now(),
]), 'INVOICE_ISSUE_STATUS_UPDATE_FAILED', $this->invoiceModel);
$ledger = $this->invoiceLedgerService->recalculateInvoice((int)$invoiceId);
$this->requireTransactionStatus();
$this->db->transCommit();
return new InvoiceLedgerResult((int)$invoiceId, 0, $ledger);
} catch (\Throwable $e) {
$this->db->transRollback();
throw $e;
}
}
private function requireWrite($result, string $code, $model = null): void
{
if ($result === false || $result === null || $result === 0) {
$details = is_object($model) && method_exists($model, 'errors') ? (array)$model->errors() : [];
throw new FinancialPersistenceException($code, $details);
}
}
private function requireTransactionStatus(string $code = 'TRANSACTION_FAILED'): void
{
if (method_exists($this->db, 'transStatus') && $this->db->transStatus() === false) {
throw new FinancialPersistenceException($code);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Libraries;
class InvoiceLedgerResult
{
public function __construct(
public readonly int $invoiceId,
public readonly int $invoiceLineId,
public readonly array $ledger
) {
}
}
+330 -9
View File
@@ -12,9 +12,11 @@ use App\Models\DiscountUsageModel;
use App\Models\EnrollmentModel;
use App\Models\EventChargesModel;
use App\Models\InvoiceEventModel;
use App\Models\InvoiceLineModel;
use App\Models\InvoiceModel;
use App\Models\PaymentModel;
use App\Models\RefundModel;
use App\Models\RefundPayoutModel;
use App\Models\StudentClassModel;
use App\Models\StudentModel;
@@ -23,6 +25,7 @@ class InvoiceLedgerService
protected InvoiceModel $invoiceModel;
protected PaymentModel $paymentModel;
protected RefundModel $refundModel;
protected ?RefundPayoutModel $refundPayoutModel = null;
protected DiscountUsageModel $discountUsageModel;
protected AdditionalChargeModel $additionalChargeModel;
protected ConfigurationModel $configurationModel;
@@ -31,6 +34,7 @@ class InvoiceLedgerService
protected ClassSectionModel $classSectionModel;
protected EventChargesModel $eventChargesModel;
protected InvoiceEventModel $invoiceEventModel;
protected ?InvoiceLineModel $invoiceLineModel = null;
protected StudentModel $studentModel;
protected TuitionCalculatorInterface $oldCalculator;
protected TuitionCalculatorInterface $newCalculator;
@@ -40,6 +44,7 @@ class InvoiceLedgerService
$this->invoiceModel = new InvoiceModel();
$this->paymentModel = new PaymentModel();
$this->refundModel = new RefundModel();
$this->refundPayoutModel = new RefundPayoutModel();
$this->discountUsageModel = new DiscountUsageModel();
$this->additionalChargeModel = new AdditionalChargeModel();
$this->configurationModel = new ConfigurationModel();
@@ -48,6 +53,7 @@ class InvoiceLedgerService
$this->classSectionModel = new ClassSectionModel();
$this->eventChargesModel = new EventChargesModel();
$this->invoiceEventModel = new InvoiceEventModel();
$this->invoiceLineModel = new InvoiceLineModel();
$this->studentModel = new StudentModel();
$this->oldCalculator = new OldTuitionCalculatorService();
$this->newCalculator = new NewTuitionCalculatorService();
@@ -60,9 +66,18 @@ class InvoiceLedgerService
throw new \RuntimeException('Invoice not found.');
}
$tuitionTotal = $this->calculateTuitionTotal($invoice);
$eventTotal = $this->calculateEventTotal($invoice);
$additionalTotal = $this->calculateAdditionalCharges($invoiceId);
$frozenTotals = $this->calculateFrozenLineTotals($invoiceId);
if ($frozenTotals !== null) {
$tuitionTotal = $this->fromCentsNumber($frozenTotals['tuition_cents']);
$eventTotal = $this->fromCentsNumber($frozenTotals['event_cents']);
$additionalTotal = $this->fromCentsNumber($frozenTotals['additional_cents']);
$frozenTotalCents = $frozenTotals['total_cents'];
} else {
$tuitionTotal = $this->calculateTuitionTotal($invoice);
$eventTotal = $this->calculateEventTotal($invoice);
$additionalTotal = $this->calculateAdditionalCharges($invoiceId);
$frozenTotalCents = null;
}
$discountRawTotal = $this->calculateDiscounts($invoiceId);
$paidTotal = $this->calculateValidPayments($invoiceId);
$refundPaidTotal = $this->calculatePaidRefunds($invoiceId);
@@ -74,8 +89,13 @@ class InvoiceLedgerService
$paidCents = $this->toCents($paidTotal);
$refundPaidCents = $this->toCents($refundPaidTotal);
if ($this->isCarryForwardInvoice($invoice)) {
if ($frozenTotalCents !== null) {
$totalAmountCents = $frozenTotalCents;
$discountBaseCents = max(0, (int) ($frozenTotals['discount_eligible_base_cents'] ?? ($tuitionCents + $additionalCents)));
$discountCents = $discountBaseCents > 0 ? min($discountRawCents, $discountBaseCents) : 0;
} elseif ($this->isCarryForwardInvoice($invoice)) {
$totalAmountCents = $this->toCents((float) ($invoice['total_amount'] ?? 0));
$discountBaseCents = 0;
$discountCents = 0;
} else {
$discountBaseCents = max(0, $tuitionCents + $additionalCents);
@@ -83,7 +103,8 @@ class InvoiceLedgerService
$totalAmountCents = $tuitionCents + $eventCents + $additionalCents;
}
$balanceCents = max(0, $totalAmountCents - $discountCents - $paidCents - $refundPaidCents);
$rawBalanceCents = $totalAmountCents - $discountCents - $paidCents + $refundPaidCents;
$balanceCents = max(0, $rawBalanceCents);
if ($balanceCents === 0) {
$status = FinancialStatus::INVOICE_PAID;
@@ -95,6 +116,18 @@ class InvoiceLedgerService
return [
'invoice_id' => $invoiceId,
'gross_charge_cents' => $totalAmountCents,
'discount_eligible_base_cents' => $discountBaseCents,
'requested_discount_cents' => $discountRawCents,
'applied_discount_cents' => $discountCents,
'net_charge_cents' => $totalAmountCents - $discountCents,
'totalAmountCents' => $totalAmountCents,
'discountCents' => $discountCents,
'paidCents' => $paidCents,
'completedRefundCents' => $refundPaidCents,
'rawBalanceCents' => $rawBalanceCents,
'balanceDueCents' => $balanceCents,
'customerCreditCents' => max(0, -$rawBalanceCents),
'tuition_total' => $this->fromCents($tuitionCents),
'event_total' => $this->fromCents($eventCents),
'additional_total' => $this->fromCents($additionalCents),
@@ -103,6 +136,7 @@ class InvoiceLedgerService
'paid_amount' => $this->fromCents($paidCents),
'refund_paid_total' => $this->fromCents($refundPaidCents),
'total_amount' => $this->fromCents($totalAmountCents),
'customer_credit' => $this->fromCents(max(0, -$rawBalanceCents)),
'balance' => $this->fromCents($balanceCents),
'status' => $status,
'has_discount' => $discountCents > 0 ? 1 : 0,
@@ -125,16 +159,257 @@ class InvoiceLedgerService
$payload['discount'] = $calculation['discount_total'];
}
$this->invoiceModel->update($invoiceId, $payload);
if (!$this->invoiceModel->update($invoiceId, $payload)) {
throw new FinancialPersistenceException('INVOICE_LEDGER_UPDATE_FAILED', $this->invoiceModel->errors());
}
return $calculation;
}
public function recalculate(int $invoiceId): array
{
return $this->recalculateInvoice($invoiceId);
}
public function getValidPaymentTotalCents(int $invoiceId): int
{
return $this->toCents($this->calculateValidPayments($invoiceId));
}
public function issueInitialInvoiceLines(
int $invoiceId,
float $tuitionAmount,
float $eventAmount,
array $metadata = []
): int {
if ($invoiceId <= 0) {
throw new \RuntimeException('Invoice ID is required to issue invoice lines.');
}
if (!$this->invoiceLinesAvailable()) {
throw new \RuntimeException('Invoice lines table is not available.');
}
if ($this->invoiceHasLines($invoiceId)) {
return 0;
}
$now = utc_now();
$version = (string) ($metadata['calculation_version'] ?? $this->getCalculationVersion());
$rows = [];
$tuitionCents = $this->toCents($tuitionAmount);
if ($tuitionCents !== 0) {
$rows[] = $this->buildInvoiceLineRow(
$invoiceId,
'tuition',
'tuition_calculation',
null,
'Tuition charges',
$tuitionCents,
1,
$version,
$metadata,
$now
);
}
$eventCents = $this->toCents($eventAmount);
if ($eventCents !== 0) {
$rows[] = $this->buildInvoiceLineRow(
$invoiceId,
'event_fee',
'event_charges',
null,
'Event charges',
$eventCents,
0,
$version,
$metadata,
$now
);
}
foreach ($this->loadApprovedAdjustmentRowsForIssuance($invoiceId, $metadata) as $charge) {
$adjustmentCents = (string)($charge['charge_type'] ?? 'add') === 'deduct'
? -1 * $this->toCents(abs((float)($charge['amount'] ?? 0)))
: $this->toCents(abs((float)($charge['amount'] ?? 0)));
if ($adjustmentCents === 0) {
throw new \RuntimeException('Zero amount approved additional charges cannot be issued.');
}
$row = $this->buildInvoiceLineRow(
$invoiceId,
$adjustmentCents > 0 ? 'additional_charge' : 'additional_deduction',
'additional_charge',
(int)$charge['id'],
trim((string)($charge['title'] ?? 'Additional charge')) ?: 'Additional charge',
$adjustmentCents,
0,
$version,
$metadata + ['charge_type' => (string)($charge['charge_type'] ?? '')],
$now
);
$row['active_source_key'] = 'additional_charge:' . (int)$charge['id'];
$rows[] = $row;
}
if ($rows === []) {
throw new \RuntimeException('Invoice issuance requires at least one non-zero invoice line.');
}
$inserted = 0;
foreach ($rows as $row) {
$lineId = $this->invoiceLineModel()->insert($row);
if (!$lineId) {
throw new FinancialPersistenceException('INVOICE_LINE_INSERT_FAILED', $this->invoiceLineModel()->errors());
}
$inserted++;
if (($row['source_type'] ?? null) === 'additional_charge' && !empty($row['source_id'])) {
$this->additionalChargeModel->update((int)$row['source_id'], [
'invoice_id' => $invoiceId,
'status' => FinancialStatus::ADDITIONAL_CHARGE_APPLIED,
'applied_invoice_line_id' => (int)$lineId,
'applied_at' => $now,
]);
}
}
return $inserted;
}
protected function loadApprovedAdjustmentRowsForIssuance(int $invoiceId, array $metadata): array
{
$parentId = (int)($metadata['parent_id'] ?? 0);
$schoolYear = (string)($metadata['school_year'] ?? '');
$semester = (string)($metadata['semester'] ?? '');
if ($parentId <= 0 || $schoolYear === '' || $semester === '') {
return [];
}
return $this->additionalChargeModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear)
->where('semester', $semester)
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPROVED)
->groupStart()
->where('invoice_id', $invoiceId)
->orWhere('invoice_id IS NULL', null, false)
->groupEnd()
->orderBy('id', 'ASC')
->findAll();
}
protected function loadInvoice(int $invoiceId): ?array
{
return $this->invoiceModel->find($invoiceId);
}
protected function calculateFrozenLineTotals(int $invoiceId): ?array
{
if (!$this->invoiceLinesAvailable() || !$this->invoiceHasLines($invoiceId)) {
return null;
}
$rows = $this->invoiceLineModel()
->select('line_type, line_amount_cents, discount_eligible')
->where('invoice_id', $invoiceId)
->where('voided_at IS NULL', null, false)
->findAll();
$totals = [
'tuition_cents' => 0,
'event_cents' => 0,
'additional_cents' => 0,
'total_cents' => 0,
'discount_eligible_base_cents' => 0,
];
foreach ($rows as $row) {
$amount = (int) ($row['line_amount_cents'] ?? 0);
$type = (string) ($row['line_type'] ?? '');
$totals['total_cents'] += $amount;
if ((int) ($row['discount_eligible'] ?? 0) === 1) {
$totals['discount_eligible_base_cents'] += $amount;
}
if (str_contains($type, 'event')) {
$totals['event_cents'] += $amount;
} elseif (str_contains($type, 'additional') || str_contains($type, 'adjustment') || str_contains($type, 'charge')) {
$totals['additional_cents'] += $amount;
} else {
$totals['tuition_cents'] += $amount;
}
}
return $totals;
}
protected function invoiceHasLines(int $invoiceId): bool
{
if (!$this->invoiceLinesAvailable()) {
return false;
}
return $this->invoiceLineModel()
->where('invoice_id', $invoiceId)
->where('voided_at IS NULL', null, false)
->countAllResults() > 0;
}
protected function invoiceLinesAvailable(): bool
{
try {
return $this->invoiceLineModel()->db->tableExists('invoice_lines');
} catch (\Throwable $e) {
return false;
}
}
protected function invoiceLineModel(): InvoiceLineModel
{
if ($this->invoiceLineModel === null) {
$this->invoiceLineModel = new InvoiceLineModel();
}
return $this->invoiceLineModel;
}
protected function buildInvoiceLineRow(
int $invoiceId,
string $lineType,
?string $sourceType,
?int $sourceId,
string $description,
int $amountCents,
int $discountEligible,
string $version,
array $metadata,
string $timestamp
): array {
return [
'invoice_id' => $invoiceId,
'line_type' => $lineType,
'source_type' => $sourceType,
'source_id' => $sourceId,
'active_source_key' => null,
'description' => $description,
'quantity' => '1.00',
'unit_amount_cents' => $amountCents,
'line_amount_cents' => $amountCents,
'discount_eligible' => $discountEligible,
'calculation_version' => $version,
'metadata_json' => json_encode($metadata, JSON_UNESCAPED_SLASHES),
'created_at' => $timestamp,
'updated_at' => $timestamp,
'voided_at' => null,
];
}
protected function getCalculationVersion(): string
{
return 'invoice_lines_v1:' . get_class($this->resolveActiveCalculator());
}
protected function isCarryForwardInvoice(array $invoice): bool
{
$invoiceNumber = (string) ($invoice['invoice_number'] ?? '');
@@ -196,9 +471,9 @@ class InvoiceLedgerService
protected function calculateAdditionalCharges(int $invoiceId): float
{
$rows = $this->additionalChargeModel
->select('COALESCE(SUM(amount),0) AS total_amount')
->select("COALESCE(SUM(CASE WHEN charge_type = 'deduct' THEN -ABS(amount) ELSE ABS(amount) END),0) AS total_amount", false)
->where('invoice_id', $invoiceId)
->whereNotIn('status', ['void', FinancialStatus::ADDITIONAL_CHARGE_VOIDED, 'cancelled', 'canceled'])
->where('status', FinancialStatus::ADDITIONAL_CHARGE_APPLIED)
->findAll();
return (float) ($rows[0]['total_amount'] ?? 0);
@@ -206,6 +481,15 @@ class InvoiceLedgerService
protected function calculateDiscounts(int $invoiceId): float
{
if ($this->discountUsageModel->db->fieldExists('applied_discount_cents', $this->discountUsageModel->table)) {
$row = $this->discountUsageModel
->select('COALESCE(SUM(applied_discount_cents),0) AS total_cents')
->where('invoice_id', $invoiceId)
->first();
return ((int) ($row['total_cents'] ?? 0)) / 100;
}
$row = $this->discountUsageModel
->select('COALESCE(SUM(discount_amount),0) AS total_amount')
->where('invoice_id', $invoiceId)
@@ -222,7 +506,7 @@ class InvoiceLedgerService
if ($this->paymentModel->db->fieldExists('status', $this->paymentModel->table)) {
$query->groupStart()
->whereNotIn('status', FinancialStatus::EXCLUDED_PAYMENT_STATUSES)
->whereIn('status', FinancialStatus::VALID_PAYMENT_STATUSES)
->orWhere('status IS NULL', null, false)
->groupEnd();
}
@@ -241,6 +525,20 @@ class InvoiceLedgerService
protected function calculatePaidRefunds(int $invoiceId): float
{
if ($this->refundPayoutsAvailable()) {
$row = $this->refundPayoutModel()
->select("COALESCE(SUM(CASE
WHEN refund_payouts.payout_type = 'cash_out' AND refund_payouts.status = 'completed' THEN refund_payouts.amount_cents
WHEN refund_payouts.payout_type = 'reversal' AND refund_payouts.status = 'completed' THEN -refund_payouts.amount_cents
ELSE 0
END),0) AS total_cents", false)
->join('refunds', 'refunds.id = refund_payouts.refund_id', 'inner')
->where('refunds.invoice_id', $invoiceId)
->first();
return max(0, (float) ($row['total_cents'] ?? 0)) / 100;
}
$row = $this->refundModel
->select('COALESCE(SUM(refund_paid_amount),0) AS total_amount')
->where('invoice_id', $invoiceId)
@@ -250,6 +548,24 @@ class InvoiceLedgerService
return (float) ($row['total_amount'] ?? 0);
}
protected function refundPayoutsAvailable(): bool
{
try {
return $this->refundPayoutModel()->db->tableExists('refund_payouts');
} catch (\Throwable $e) {
return false;
}
}
protected function refundPayoutModel(): RefundPayoutModel
{
if ($this->refundPayoutModel === null) {
$this->refundPayoutModel = new RefundPayoutModel();
}
return $this->refundPayoutModel;
}
protected function loadTuitionStudents(int $parentId, string $schoolYear): array
{
$enrollments = $this->enrollmentModel
@@ -364,4 +680,9 @@ class InvoiceLedgerService
{
return number_format($cents / 100, 2, '.', '');
}
protected function fromCentsNumber(int $cents): float
{
return $cents / 100;
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Libraries;
class IssueInvoiceCommand
{
public function __construct(
public readonly array $invoiceData,
public readonly float $tuitionAmount,
public readonly float $eventAmount,
public readonly array $metadata = []
) {
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace App\Libraries;
use App\Models\InvoiceModel;
class ParentLedgerService
{
protected InvoiceModel $invoiceModel;
protected InvoiceLedgerService $invoiceLedgerService;
protected RefundEligibilityService $refundEligibilityService;
public function __construct()
{
$this->invoiceModel = new InvoiceModel();
$this->invoiceLedgerService = new InvoiceLedgerService();
$this->refundEligibilityService = new RefundEligibilityService();
}
public function getParentProjection(int $parentId, string $schoolYear, ?string $semester = null): array
{
$invoices = $this->loadInvoices($parentId, $schoolYear, $semester);
$grossChargesCents = 0;
$discountCents = 0;
$netInvoiceChargesCents = 0;
$validPaymentCents = 0;
$completedRefundCents = 0;
$balanceDueCents = 0;
$customerCreditCents = 0;
$approvedRefundReservationCents = 0;
$availableRefundableCreditCents = 0;
$invoiceProjections = [];
foreach ($invoices as $invoice) {
$invoiceId = (int)($invoice['id'] ?? 0);
if ($invoiceId <= 0) {
continue;
}
$ledger = $this->ledgerForInvoice($invoiceId);
$eligibility = $this->eligibilityForSource(
$parentId,
$invoiceId,
'invoice_overpayment',
$invoiceId
);
$invoiceGrossCents = (int)($ledger['totalAmountCents'] ?? 0);
$invoiceDiscountCents = (int)($ledger['discountCents'] ?? 0);
$grossChargesCents += $invoiceGrossCents;
$discountCents += $invoiceDiscountCents;
$netInvoiceChargesCents += max(0, $invoiceGrossCents - $invoiceDiscountCents);
$validPaymentCents += (int)($ledger['paidCents'] ?? 0);
$completedRefundCents += (int)($ledger['completedRefundCents'] ?? 0);
$balanceDueCents += (int)($ledger['balanceDueCents'] ?? 0);
$customerCreditCents += (int)($ledger['customerCreditCents'] ?? 0);
$approvedRefundReservationCents += $eligibility->reservedAmountCents;
$availableRefundableCreditCents += $eligibility->availableAmountCents;
$invoiceProjections[] = [
'invoice_id' => $invoiceId,
'invoice_number' => (string)($invoice['invoice_number'] ?? ''),
'grossChargesCents' => $invoiceGrossCents,
'discountCents' => $invoiceDiscountCents,
'netInvoiceChargesCents' => max(0, $invoiceGrossCents - $invoiceDiscountCents),
'validPaymentCents' => (int)($ledger['paidCents'] ?? 0),
'completedRefundCents' => (int)($ledger['completedRefundCents'] ?? 0),
'balanceDueCents' => (int)($ledger['balanceDueCents'] ?? 0),
'customerCreditCents' => (int)($ledger['customerCreditCents'] ?? 0),
'approvedRefundReservationCents' => $eligibility->reservedAmountCents,
'availableRefundableCreditCents' => $eligibility->availableAmountCents,
'status' => (string)($ledger['status'] ?? ''),
];
}
return [
'parent_id' => $parentId,
'school_year' => $schoolYear,
'semester' => $semester,
'grossChargesCents' => $grossChargesCents,
'discountCents' => $discountCents,
'netInvoiceChargesCents' => $netInvoiceChargesCents,
'validPaymentCents' => $validPaymentCents,
'completedRefundCents' => $completedRefundCents,
'balanceDueCents' => $balanceDueCents,
'customerCreditCents' => $customerCreditCents,
'approvedRefundReservationCents' => $approvedRefundReservationCents,
'availableRefundableCreditCents' => $availableRefundableCreditCents,
'gross_charges' => $this->fromCents($grossChargesCents),
'discounts' => $this->fromCents($discountCents),
'net_invoice_charges' => $this->fromCents($netInvoiceChargesCents),
'valid_payments' => $this->fromCents($validPaymentCents),
'completed_refunds' => $this->fromCents($completedRefundCents),
'balance_due' => $this->fromCents($balanceDueCents),
'customer_credit' => $this->fromCents($customerCreditCents),
'approved_refund_reservations' => $this->fromCents($approvedRefundReservationCents),
'available_refundable_credit' => $this->fromCents($availableRefundableCreditCents),
'invoices' => $invoiceProjections,
];
}
protected function loadInvoices(int $parentId, string $schoolYear, ?string $semester): array
{
$query = $this->invoiceModel
->where('parent_id', $parentId)
->where('school_year', $schoolYear);
if ($semester !== null && $semester !== '') {
$query->where('semester', $semester);
}
return $query->orderBy('id', 'ASC')->findAll();
}
protected function ledgerForInvoice(int $invoiceId): array
{
return $this->invoiceLedgerService->calculateInvoice($invoiceId);
}
protected function eligibilityForSource(
int $parentId,
?int $invoiceId,
string $sourceType,
int $sourceId
): RefundEligibilityResult {
return $this->refundEligibilityService->calculateAvailableCredit(
$parentId,
$invoiceId,
$sourceType,
$sourceId
);
}
private function fromCents(int $cents): string
{
return number_format($cents / 100, 2, '.', '');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Libraries;
class RefundEligibilityResult
{
public function __construct(
public int $sourceCreditCents,
public int $completedPayoutCents,
public int $reservedAmountCents,
public int $availableAmountCents,
public array $reasonCodes = []
) {
}
public function toArray(): array
{
return [
'sourceCreditCents' => $this->sourceCreditCents,
'completedPayoutCents' => $this->completedPayoutCents,
'reservedAmountCents' => $this->reservedAmountCents,
'availableAmountCents' => $this->availableAmountCents,
'reasonCodes' => $this->reasonCodes,
];
}
}
+321
View File
@@ -0,0 +1,321 @@
<?php
namespace App\Libraries;
use App\Models\InvoiceModel;
use App\Models\PaymentModel;
use App\Models\PaymentCorrectionModel;
use App\Models\RefundModel;
use App\Models\RefundPayoutModel;
class RefundEligibilityService
{
protected InvoiceLedgerService $invoiceLedgerService;
protected RefundModel $refundModel;
protected RefundPayoutModel $refundPayoutModel;
protected PaymentModel $paymentModel;
protected PaymentCorrectionModel $paymentCorrectionModel;
protected InvoiceModel $invoiceModel;
public function __construct()
{
$this->invoiceLedgerService = new InvoiceLedgerService();
$this->refundModel = new RefundModel();
$this->refundPayoutModel = new RefundPayoutModel();
$this->paymentModel = new PaymentModel();
$this->paymentCorrectionModel = new PaymentCorrectionModel();
$this->invoiceModel = new InvoiceModel();
}
public function calculateAvailableCredit(
int $parentId,
?int $invoiceId,
string $sourceType,
int $sourceId,
?int $excludeRefundId = null
): RefundEligibilityResult {
$sourceCreditCents = $this->calculateSourceCreditCents($parentId, $invoiceId, $sourceType, $sourceId);
$completedPayoutCents = $this->completedPayoutsAffectAvailability($sourceType)
? $this->calculateCompletedPayoutCents($sourceType, $sourceId, $excludeRefundId)
: 0;
$reservedAmountCents = $this->calculateReservedAmountCents($sourceType, $sourceId, $excludeRefundId);
$availableAmountCents = max(0, $sourceCreditCents - $completedPayoutCents - $reservedAmountCents);
$reasons = [];
if ($sourceCreditCents <= 0) {
$reasons[] = 'NO_SOURCE_CREDIT';
}
if ($completedPayoutCents > 0) {
$reasons[] = 'HAS_COMPLETED_PAYOUTS';
}
if ($reservedAmountCents > 0) {
$reasons[] = 'HAS_APPROVED_RESERVATIONS';
}
if ($availableAmountCents <= 0) {
$reasons[] = 'NO_AVAILABLE_CREDIT';
}
return new RefundEligibilityResult(
$sourceCreditCents,
$completedPayoutCents,
$reservedAmountCents,
$availableAmountCents,
array_values(array_unique($reasons))
);
}
public function validateRequestedAmount(RefundEligibilityResult $eligibility, int $requestedAmountCents): void
{
if ($requestedAmountCents <= 0) {
throw new \RuntimeException('Refund amount must be greater than zero.');
}
if ($requestedAmountCents > $eligibility->availableAmountCents) {
throw new \RuntimeException('Refund amount exceeds available source credit.');
}
}
public function getCompletedPayoutTotalCentsForRefund(int $refundId): int
{
return $this->getNetCompletedCashOutCentsForRefund($refundId);
}
public function getNetCompletedCashOutCentsForRefund(int $refundId): int
{
if ($refundId <= 0) {
return 0;
}
if ($this->refundPayoutsAvailable()) {
$row = $this->refundPayoutModel->db->query(
"SELECT
COALESCE(SUM(CASE
WHEN payout_type = 'cash_out' AND status = 'completed' THEN amount_cents
WHEN payout_type = 'reversal' AND status = 'completed' THEN -amount_cents
ELSE 0
END), 0) AS total_cents
FROM refund_payouts
WHERE refund_id = ?",
[$refundId]
)->getRowArray();
return max(0, (int)($row['total_cents'] ?? 0));
}
$refund = $this->refundModel->find($refundId);
if (!$refund) {
return 0;
}
return (int)round(((float)($refund['refund_paid_amount'] ?? 0)) * 100);
}
public function getUnreversedPayoutAmountCents(int $payoutId): int
{
if ($payoutId <= 0 || !$this->refundPayoutsAvailable()) {
return 0;
}
$row = $this->refundPayoutModel->db->query(
"SELECT p.amount_cents
- COALESCE(SUM(CASE
WHEN r.payout_type = 'reversal' AND r.status = 'completed' THEN r.amount_cents
ELSE 0
END), 0) AS unreversed_cents
FROM refund_payouts p
LEFT JOIN refund_payouts r ON r.reversed_payout_id = p.id
WHERE p.id = ?
GROUP BY p.id, p.amount_cents",
[$payoutId]
)->getRowArray();
return max(0, (int)($row['unreversed_cents'] ?? 0));
}
public function findPayoutsExceedingApprovedAmounts(): array
{
if ($this->refundPayoutsAvailable()) {
return $this->refundModel->db->query(
"SELECT r.id AS refund_id,
COALESCE(r.approved_amount_cents, ROUND(COALESCE(r.refund_amount, 0) * 100)) AS approved_amount_cents,
COALESCE(SUM(CASE
WHEN p.payout_type = 'cash_out' AND p.status = 'completed' THEN p.amount_cents
WHEN p.payout_type = 'reversal' AND p.status = 'completed' THEN -p.amount_cents
ELSE 0
END), 0) AS completed_payout_cents
FROM refunds r
LEFT JOIN refund_payouts p ON p.refund_id = r.id
GROUP BY r.id, r.approved_amount_cents, r.refund_amount
HAVING completed_payout_cents > approved_amount_cents"
)->getResultArray();
}
return $this->refundModel->db->query(
"SELECT id AS refund_id,
COALESCE(approved_amount_cents, ROUND(COALESCE(refund_amount, 0) * 100)) AS approved_amount_cents,
ROUND(COALESCE(refund_paid_amount, 0) * 100) AS completed_payout_cents
FROM refunds
WHERE ROUND(COALESCE(refund_paid_amount, 0) * 100)
> COALESCE(approved_amount_cents, ROUND(COALESCE(refund_amount, 0) * 100))"
)->getResultArray();
}
protected function calculateSourceCreditCents(int $parentId, ?int $invoiceId, string $sourceType, int $sourceId): int
{
return match ($sourceType) {
'invoice_overpayment' => $this->invoiceCreditCents($parentId, $invoiceId ?: $sourceId),
'payment_duplicate', 'payment_correction' => $this->paymentCreditCents($parentId, $invoiceId, $sourceId),
'credit_memo', 'administrative_credit' => 0,
default => 0,
};
}
protected function completedPayoutsAffectAvailability(string $sourceType): bool
{
return $sourceType !== 'invoice_overpayment';
}
protected function invoiceCreditCents(int $parentId, int $invoiceId): int
{
if ($invoiceId <= 0) {
return 0;
}
$invoice = $this->invoiceModel->find($invoiceId);
if (!$invoice || (int)($invoice['parent_id'] ?? 0) !== $parentId) {
return 0;
}
$ledger = $this->invoiceLedgerService->calculateInvoice($invoiceId);
return (int)($ledger['customerCreditCents'] ?? 0);
}
protected function paymentCreditCents(int $parentId, ?int $invoiceId, int $paymentId): int
{
if ($paymentId <= 0) {
return 0;
}
$payment = $this->paymentModel->find($paymentId);
if (!$payment || (int)($payment['parent_id'] ?? 0) !== $parentId) {
return 0;
}
if ($invoiceId !== null && $invoiceId > 0 && (int)($payment['invoice_id'] ?? 0) !== $invoiceId) {
return 0;
}
$status = FinancialStatus::normalizePaymentStatus($payment['status'] ?? null);
if (!in_array($status, [FinancialStatus::PAYMENT_RECORDED], true)) {
return 0;
}
if (!$this->paymentCorrectionsAvailable()) {
return 0;
}
$correction = $this->paymentCorrectionModel
->where('payment_id', $paymentId)
->where('invoice_id', (int)($payment['invoice_id'] ?? 0))
->where('parent_id', $parentId)
->where('status', 'approved')
->orderBy('approved_at', 'DESC')
->orderBy('id', 'DESC')
->first();
if (!$correction) {
return 0;
}
return max(0, (int)($correction['approved_refundable_cents'] ?? 0));
}
protected function paymentCorrectionsAvailable(): bool
{
try {
return $this->paymentCorrectionModel->db->tableExists('payment_corrections');
} catch (\Throwable $e) {
return false;
}
}
protected function calculateCompletedPayoutCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int
{
$refundIds = $this->matchingRefundIds($sourceType, $sourceId, $excludeRefundId);
if ($refundIds === []) {
return 0;
}
if ($this->refundPayoutsAvailable()) {
$row = $this->refundPayoutModel->db->table('refund_payouts')
->select("COALESCE(SUM(CASE
WHEN payout_type = 'cash_out' AND status = 'completed' THEN amount_cents
WHEN payout_type = 'reversal' AND status = 'completed' THEN -amount_cents
ELSE 0
END),0) AS total_cents", false)
->whereIn('refund_id', $refundIds)
->get()
->getRowArray();
return max(0, (int)($row['total_cents'] ?? 0));
}
$row = $this->refundModel
->select('COALESCE(SUM(refund_paid_amount),0) AS total_paid')
->whereIn('id', $refundIds)
->whereIn('status', ['Partial', 'Paid', 'partial', 'paid', 'partially_paid'])
->first();
return (int)round(((float)($row['total_paid'] ?? 0)) * 100);
}
protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int
{
$query = $this->refundModel
->select('id, refund_amount, refund_paid_amount, approved_amount_cents')
->where('source_type', $sourceType)
->where('source_id', $sourceId)
->whereIn('status', ['Approved', 'Partial', 'approved', 'partially_paid']);
if ($excludeRefundId !== null && $excludeRefundId > 0) {
$query->where('id !=', $excludeRefundId);
}
$reserved = 0;
foreach ($query->findAll() as $refund) {
$approved = isset($refund['approved_amount_cents']) && $refund['approved_amount_cents'] !== null
? (int)$refund['approved_amount_cents']
: (int)round(((float)($refund['refund_amount'] ?? 0)) * 100);
$paid = $this->getCompletedPayoutTotalCentsForRefund((int)($refund['id'] ?? 0));
$reserved += max(0, $approved - $paid);
}
return $reserved;
}
protected function matchingRefundIds(string $sourceType, int $sourceId, ?int $excludeRefundId): array
{
$query = $this->refundModel
->select('id')
->where('source_type', $sourceType)
->where('source_id', $sourceId);
if ($excludeRefundId !== null && $excludeRefundId > 0) {
$query->where('id !=', $excludeRefundId);
}
return array_values(array_filter(array_map(
static fn(array $row): int => (int)($row['id'] ?? 0),
$query->findAll()
)));
}
protected function refundPayoutsAvailable(): bool
{
try {
return $this->refundPayoutModel->db->tableExists('refund_payouts');
} catch (\Throwable $e) {
return false;
}
}
}
+8 -2
View File
@@ -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',
];
+11 -2
View File
@@ -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
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class InvoiceLineModel extends Model
{
protected $table = 'invoice_lines';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = false;
protected $allowedFields = [
'invoice_id',
'line_type',
'source_type',
'source_id',
'active_source_key',
'description',
'quantity',
'unit_amount_cents',
'line_amount_cents',
'discount_eligible',
'calculation_version',
'metadata_json',
'created_at',
'updated_at',
'voided_at',
];
protected $validationRules = [
'invoice_id' => 'required|integer',
'line_type' => 'required|max_length[50]',
'description' => 'required|max_length[255]',
'quantity' => 'required|decimal',
'unit_amount_cents' => 'required|integer',
'line_amount_cents' => 'required|integer',
'discount_eligible' => 'required|in_list[0,1]',
];
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class PaymentCorrectionModel extends Model
{
protected $table = 'payment_corrections';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = false;
protected $allowedFields = [
'payment_id',
'invoice_id',
'parent_id',
'correction_type',
'approved_refundable_cents',
'status',
'reason',
'approved_by',
'approved_at',
'created_at',
'updated_at',
];
}
+8 -2
View File
@@ -35,7 +35,10 @@ class PaymentModel extends Model
'installment_seq',
'transaction_id',
'idempotency_key',
'request_fingerprint_hash',
'check_file',
'evidence_status',
'evidence_failure_message',
'check_number',
'payment_method',
'payment_date',
@@ -76,7 +79,8 @@ class PaymentModel extends Model
'installment_seq' => 'permit_empty|integer|greater_than[0]',
'transaction_id' => 'required|max_length[100]',
'idempotency_key' => 'permit_empty|max_length[36]',
'idempotency_key' => 'permit_empty|max_length[100]',
'request_fingerprint_hash' => 'permit_empty|exact_length[64]',
'payment_method' => 'required|in_list[cash,check,card]',
'payment_date' => 'required|valid_date',
'school_year' => 'required|string|max_length[9]',
@@ -87,6 +91,8 @@ class PaymentModel extends Model
'voided_by' => 'permit_empty|integer',
'void_reason' => 'permit_empty|max_length[255]',
'check_file' => 'permit_empty|max_length[255]',
'evidence_status' => 'permit_empty|max_length[30]',
'evidence_failure_message' => 'permit_empty|max_length[255]',
'check_number' => 'permit_empty|max_length[100]',
'updated_by' => 'permit_empty|integer',
];
@@ -145,7 +151,7 @@ class PaymentModel extends Model
'greater_than' => 'Installment sequence must be greater than zero.',
],
'idempotency_key' => [
'max_length' => 'Idempotency key must not exceed 36 characters.',
'max_length' => 'Idempotency key must not exceed 100 characters.',
],
'check_number' => [
'max_length' => 'Check number must not exceed 100 characters.',
+2 -1
View File
@@ -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]',
];
}
+8
View File
@@ -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',
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class RefundPayoutModel extends Model
{
protected $table = 'refund_payouts';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = false;
protected $allowedFields = [
'refund_id',
'amount_cents',
'currency',
'payout_type',
'payment_method',
'status',
'external_reference',
'check_number',
'check_date',
'evidence_path',
'idempotency_key',
'operation_type',
'request_fingerprint_hash',
'processed_by',
'processed_at',
'reversed_payout_id',
'failure_code',
'failure_message',
'created_at',
'updated_at',
];
protected $validationRules = [
'refund_id' => 'required|integer',
'amount_cents' => 'required|integer|greater_than[0]',
'currency' => 'required|exact_length[3]',
'payout_type' => 'required|in_list[cash_out,account_credit,reversal]',
'status' => 'required|max_length[30]',
'idempotency_key' => 'required|max_length[100]',
'operation_type' => 'permit_empty|max_length[50]',
'request_fingerprint_hash' => 'permit_empty|exact_length[64]',
];
}
+5 -1
View File
@@ -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]',
];
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="d-flex gap-2 mb-3 justify-content-end">
<div>
<a href="/discount/create" class="btn btn-primary">Create New Voucher</a>
<a href="/discount/apply" class="btn btn-success">Apply Voucher to Parents</a>
<a href="/discount/voucher-form" class="btn btn-success">Apply Voucher to Parents</a>
</div>
</div>
<div class="table-responsive">
+13 -26
View File
@@ -89,20 +89,12 @@
$disc = $discMatches ? reset($discMatches) : null;
$discounted = (float)($disc['discount_amount'] ?? 0);
$totalAmount = (float)($inv['total_amount'] ?? 0);
// Recompute balance for display: total - discount - paid - refunded
$balanceCalc = (float)$totalAmount - (float)$discounted - (float)$paid - (float)$refunded;
if ($balanceCalc < 0) $balanceCalc = 0.0;
$balance = $balanceCalc;
if ($balanceCalc <= 0.00001) {
$status = 'Paid';
$statusClass = 'bg-success';
} elseif ($paid > 0.00001) {
$status = 'Partially Paid';
$statusClass = 'bg-warning text-dark';
} else {
$status = 'Unpaid';
$statusClass = 'bg-danger';
}
$balance = (float)($inv['balance'] ?? 0);
$status = (string)($inv['status'] ?? 'unpaid');
$statusClass = $status === 'paid'
? 'bg-success'
: ($status === 'partially_paid' ? 'bg-warning text-dark' : 'bg-danger');
$statusLabel = str_replace('_', ' ', $status);
?>
<tr>
<td><?= esc($inv['invoice_number']) ?></td>
@@ -125,7 +117,7 @@
<td data-order="<?= $balance ?>">$<?= number_format($balance, 2) ?></td>
<td data-order="<?= $refunded ?>">$<?= number_format($refunded, 2) ?></td>
<td data-order="<?= $discounted ?>">$<?= number_format($discounted, 2) ?></td>
<td data-order="<?= esc($status) ?>"><span class="badge <?= esc($statusClass) ?>"><?= esc($status) ?></span></td>
<td data-order="<?= esc($status) ?>"><span class="badge <?= esc($statusClass) ?>"><?= esc($statusLabel) ?></span></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
@@ -375,17 +367,12 @@
const refunded = refundsMap[inv.id] || 0;
const discounted = discountsMap[inv.id] || 0;
const total = Number(inv.total_amount || 0);
let balance = total - paid - discounted - refunded;
if (!Number.isFinite(balance) || balance < 0) balance = 0;
let status = 'Unpaid';
const balance = Number(inv.balance || 0);
const status = String(inv.status || 'unpaid');
let statusClass = 'bg-danger';
if (balance <= 0.00001) {
status = 'Paid';
statusClass = 'bg-success';
} else if (paid > 0.00001) {
status = 'Partially Paid';
statusClass = 'bg-warning text-dark';
}
if (status === 'paid') statusClass = 'bg-success';
if (status === 'partially_paid') statusClass = 'bg-warning text-dark';
const statusLabel = status.replace(/_/g, ' ');
const tr = document.createElement('tr');
const parentHtml = (inv.parent_id && Number(inv.parent_id) > 0) ?
`<a href="<?= site_url('family') ?>?guardian_id=${encodeURIComponent(inv.parent_id)}" class="text-decoration-none" data-family-guardian-id="${inv.parent_id}">${inv.parent_name||''}</a>` :
@@ -402,7 +389,7 @@
<td data-order="${balance}">${fmt(balance)}</td>
<td data-order="${refunded}">${fmt(refunded)}</td>
<td data-order="${discounted}">${fmt(discounted)}</td>
<td data-order="${status}"><span class="badge ${statusClass}">${status}</span></td>`;
<td data-order="${status}"><span class="badge ${statusClass}">${statusLabel}</span></td>`;
tbody.appendChild(tr);
});
+16 -53
View File
@@ -371,8 +371,7 @@
<td>$<?= number_format((float)$invoice['paid_amount'], 2) ?></td>
<td>$<?= number_format((float)($invoice['discount'] ?? 0), 2) ?></td>
<td>$<?= number_format((float)($invoice['refund_paid'] ?? 0), 2) ?></td>
<?php $tblBal = max(0, (float)($invoice['total_amount'] ?? 0) - (float)($invoice['paid_amount'] ?? 0) - (float)($invoice['discount'] ?? 0) - (float)($invoice['refund_paid'] ?? 0)); ?>
<td>$<?= number_format($tblBal, 2) ?></td>
<td>$<?= number_format((float)($invoice['balance'] ?? 0), 2) ?></td>
<td><?= esc($invoice['status']) ?></td>
<td><?= esc(!empty($invoice['due_date']) ? local_date($invoice['due_date'], 'm-d-Y') : '') ?></td>
</tr>
@@ -397,6 +396,7 @@
<form method="post" action="<?= base_url('payment/manual_pay_update') ?>" id="addPaymentForm" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="hidden" name="search_term" value="<?= esc($searchTermUsedInSearch ?? '') ?>">
<input type="hidden" name="idempotency_key" value="<?= esc(bin2hex(random_bytes(16))) ?>">
<div class="modal-header">
<h5 class="modal-title" id="addPaymentModalLabel">Add Manual Payment</h5>
@@ -419,17 +419,13 @@
<option
value="<?= esc($invoice['id']) ?>"
data-balance="<?= esc($invoice['balance']) ?>"
data-total="<?= esc($invoice['total_amount'] ?? $invoice['total'] ?? '') ?>"
data-paid="<?= esc($invoice['paid_amount'] ?? 0) ?>"
data-discount="<?= esc($invoice['discount'] ?? 0) ?>"
data-refund="<?= esc($invoice['refund_paid'] ?? 0) ?>"
data-display-total="<?= esc($invoice['display_total'] ?? $invoice['total_amount'] ?? '') ?>"
data-balance-due-cents="<?= (int)($invoice['balance_due_cents'] ?? 0) ?>"
data-customer-credit-cents="<?= (int)($invoice['customer_credit_cents'] ?? 0) ?>"
data-next-installment="<?= (int)($invoice['next_installment'] ?? 1) ?>">
<?php
$uiTotal = (float)($invoice['total_amount'] ?? 0);
$uiPaid = (float)($invoice['paid_amount'] ?? 0);
$uiDisc = (float)($invoice['discount'] ?? 0);
$uiRef = (float)($invoice['refund_paid'] ?? 0);
$uiBal = max(0, $uiTotal - $uiPaid - $uiDisc - $uiRef);
$uiBal = (float)($invoice['balance'] ?? 0);
?>
Invoice <?= esc($invoice['invoice_number']) ?> |
Balance: $<?= number_format($uiBal, 2) ?> |
@@ -615,20 +611,11 @@
function getBalance() {
const opt = currentOpt();
if (!opt) return 0;
// Compute from total - paid FIRST (more reliable if DB balance is stale)
const totRaw = opt.getAttribute('data-total');
const paidRaw = opt.getAttribute('data-paid');
const discRaw = opt.getAttribute('data-discount');
const refRaw = opt.getAttribute('data-refund');
const tot = (totRaw !== null && totRaw !== '') ? parseFloat(totRaw) : NaN;
const paid = (paidRaw !== null && paidRaw !== '') ? parseFloat(paidRaw) : NaN;
const disc = (discRaw !== null && discRaw !== '') ? parseFloat(discRaw) : 0;
const ref = (refRaw !== null && refRaw !== '') ? parseFloat(refRaw) : 0;
if (isFinite(tot) && isFinite(paid)) {
const computed = tot - paid - (isFinite(disc) ? disc : 0) - (isFinite(ref) ? ref : 0);
if (isFinite(computed)) return Math.max(0, computed);
const centsRaw = opt.getAttribute('data-balance-due-cents');
const cents = (centsRaw !== null && centsRaw !== '') ? parseInt(centsRaw, 10) : NaN;
if (isFinite(cents)) {
return Math.max(0, cents / 100);
}
// Fallback to provided balance
const raw = opt.getAttribute('data-balance');
const bal = (raw !== null && raw !== '') ? parseFloat(raw) : NaN;
return isFinite(bal) ? Math.max(0, bal) : 0;
@@ -636,7 +623,7 @@
function getTotal() {
const opt = currentOpt();
const v = opt ? parseFloat(opt.getAttribute('data-total') || '0') : 0;
const v = opt ? parseFloat(opt.getAttribute('data-display-total') || '0') : 0;
return isFinite(v) ? v : 0;
}
@@ -926,25 +913,7 @@
}
const invoiceText = invOpt.text || ('Invoice #' + invOpt.value);
// Derive balance: prefer total - paid - discount - refunds, else fallback to data-balance
let balance = NaN;
if (invOpt) {
const rt = invOpt.getAttribute('data-total');
const rp = invOpt.getAttribute('data-paid');
const rd = invOpt.getAttribute('data-discount');
const rr = invOpt.getAttribute('data-refund');
const t = (rt !== null && rt !== '') ? parseFloat(rt) : NaN;
const p = (rp !== null && rp !== '') ? parseFloat(rp) : NaN;
const d = (rd !== null && rd !== '') ? parseFloat(rd) : 0;
const r = (rr !== null && rr !== '') ? parseFloat(rr) : 0;
if (isFinite(t) && isFinite(p)) {
balance = t - p - (isFinite(d) ? d : 0) - (isFinite(r) ? r : 0);
}
if (!isFinite(balance)) {
const rb = invOpt.getAttribute('data-balance');
balance = (rb !== null && rb !== '') ? parseFloat(rb) : NaN;
}
}
const balance = getBalance();
const nextInst = invOpt.dataset?.nextInstallment || '';
const instCount = $instCount() ? ($instCount().value || '') : '';
const instAmt = $instAmt() ? ($instAmt().value || '') : '';
@@ -1131,19 +1100,13 @@
// Normalize initial state
const inv = $invoice();
if (inv && inv.options.length > 0) {
// Prefer the first invoice whose (total - paid) > 0
// Prefer the first invoice with a backend-provided amount due.
let chosen = inv.selectedIndex;
for (let i = 0; i < inv.options.length; i++) {
const o = inv.options[i];
const rt = o.getAttribute('data-total');
const rp = o.getAttribute('data-paid');
const rd = o.getAttribute('data-discount');
const rr = o.getAttribute('data-refund');
const t = (rt !== null && rt !== '') ? parseFloat(rt) : NaN;
const p = (rp !== null && rp !== '') ? parseFloat(rp) : NaN;
const d = (rd !== null && rd !== '') ? parseFloat(rd) : 0;
const r = (rr !== null && rr !== '') ? parseFloat(rr) : 0;
let b = (isFinite(t) && isFinite(p)) ? (t - p - (isFinite(d) ? d : 0) - (isFinite(r) ? r : 0)) : NaN;
const centsRaw = o.getAttribute('data-balance-due-cents');
const cents = (centsRaw !== null && centsRaw !== '') ? parseInt(centsRaw, 10) : NaN;
let b = isFinite(cents) ? cents / 100 : NaN;
if (!isFinite(b)) {
const rb = o.getAttribute('data-balance');
b = (rb !== null && rb !== '') ? parseFloat(rb) : NaN;
+14
View File
@@ -69,6 +69,8 @@
<th>Check #</th>
<th>Check File</th>
<th class="text-end">Paid Amount</th>
<th class="text-end">Source Available</th>
<th class="text-end">Parent Available</th>
<th>Actions</th>
</tr>
</thead>
@@ -119,6 +121,12 @@
<?php endif; ?>
</td>
<td class="text-end">$<?= esc(number_format((float)$r['refund_paid_amount'], 2)) ?></td>
<td class="text-end">
<?= $r['available_refundable_credit'] === null ? '-' : '$' . esc(number_format((float)$r['available_refundable_credit'], 2)) ?>
</td>
<td class="text-end">
<?= isset($r['parent_available_refundable_credit']) ? '$' . esc(number_format((float)$r['parent_available_refundable_credit'], 2)) : '-' ?>
</td>
<td class="d-flex gap-2">
<button class="btn btn-success btn-sm"
onclick="handleRecordRefundClick(<?= (int)$r['id'] ?>, '<?= esc($r['status']) ?>', <?= (float)$r['refund_amount'] ?>)">
@@ -178,6 +186,7 @@
</div>
<div class="modal-body">
<input type="hidden" name="refund_id" id="refundIdPayment">
<input type="hidden" name="idempotency_key" id="refundPayoutIdempotencyKey">
<div class="mb-3">
<label for="paidAmount" class="form-label">Paid Amount</label>
<input type="number" step="0.01" min="0.01" class="form-control" name="paid_amount" id="paidAmount" required>
@@ -304,6 +313,11 @@ function showPaymentModal(refundId) {
$('#paymentForm').removeClass('d-none');
$('#refundIdPayment').val(refundId);
$('#refundPayoutIdempotencyKey').val(
(window.crypto && crypto.randomUUID)
? crypto.randomUUID()
: ('refund-' + refundId + '-' + Date.now() + '-' + Math.random().toString(16).slice(2))
);
$('#paidAmount').val('');
$('#paymentMethod').val('').trigger('change');
$('#checkDetails').addClass('d-none');
+2 -1
View File
@@ -49,6 +49,7 @@
"sort-packages": true
},
"scripts": {
"test": "php -d variables_order=EGPCS vendor/bin/phpunit"
"test": "php -d variables_order=EGPCS vendor/bin/phpunit",
"check:financial-formulas": "php scripts/check-financial-formulas.php"
}
}
+1799
View File
File diff suppressed because it is too large Load Diff
+1228
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
<?php
$root = dirname(__DIR__);
$patterns = [
'refund_paid_amount',
'SUM\\(paid_amount\\)',
'SUM\\(amount\\)',
'newBalance',
'balance\\s*=',
'total\\s*-\\s*discount',
'total\\s*-\\s*paid',
'Partially Paid',
'Unpaid',
'Paid',
];
$allowlist = [
'/app/Libraries/InvoiceLedgerService.php',
'/app/Libraries/ParentLedgerService.php',
'/app/Libraries/FinancialReportProjectionService.php',
'/app/Libraries/FinancialCorrectionReportService.php',
'/app/Libraries/FinancialStatus.php',
'/app/Database/Migrations/',
'/tests/',
'/scripts/check-financial-formulas.php',
];
$rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS));
$violations = [];
foreach ($rii as $file) {
if (!$file->isFile() || $file->getExtension() !== 'php') {
continue;
}
$path = str_replace($root, '', $file->getPathname());
if (!str_starts_with($path, '/app/') && !str_starts_with($path, '/tests/')) {
continue;
}
foreach ($allowlist as $allowed) {
if (str_starts_with($path, $allowed) || $path === $allowed) {
continue 2;
}
}
$lines = file($file->getPathname(), FILE_IGNORE_NEW_LINES) ?: [];
foreach ($lines as $lineNumber => $line) {
foreach ($patterns as $pattern) {
if (preg_match('/' . $pattern . '/i', $line)) {
$violations[] = sprintf('%s:%d matches /%s/i', $path, $lineNumber + 1, $pattern);
}
}
}
}
if ($violations !== []) {
sort($violations);
}
$baselineFile = $root . '/scripts/financial-formula-baseline.txt';
if (in_array('--update-baseline', $argv, true)) {
file_put_contents($baselineFile, implode("\n", $violations) . ($violations === [] ? '' : "\n"));
echo 'Updated financial formula baseline with ' . count($violations) . " entries.\n";
exit(0);
}
$baseline = [];
if (is_file($baselineFile)) {
$baseline = array_values(array_filter(file($baselineFile, FILE_IGNORE_NEW_LINES) ?: [], static fn ($line) => trim($line) !== ''));
sort($baseline);
}
$newViolations = array_values(array_diff($violations, $baseline));
if ($newViolations !== []) {
fwrite(STDERR, "New financial formula patterns found outside allowlist:\n");
fwrite(STDERR, implode("\n", $newViolations) . "\n");
fwrite(STDERR, "Run `php scripts/check-financial-formulas.php --update-baseline` only after reviewing intentional legacy exceptions.\n");
exit(1);
}
echo 'No new financial formula patterns found outside baseline. Current baseline entries: ' . count($baseline) . ".\n";
+632
View File
@@ -0,0 +1,632 @@
/app/Commands/RecalculateInvoices.php:151 matches /Paid/i
/app/Commands/SendMonthlyPaymentNotifications.php:155 matches /balance\s*=/i
/app/Commands/SendMonthlyPaymentNotifications.php:82 matches /balance\s*=/i
/app/Commands/SendTestPaymentNotification.php:47 matches /balance\s*=/i
/app/Config/Routes.php:1491 matches /Paid/i
/app/Config/Routes.php:1491 matches /Unpaid/i
/app/Config/Routes.php:647 matches /Paid/i
/app/Config/Routes.php:647 matches /Unpaid/i
/app/Config/Routes.php:648 matches /Paid/i
/app/Config/Routes.php:648 matches /Unpaid/i
/app/Config/Routes.php:649 matches /Paid/i
/app/Config/Routes.php:649 matches /Unpaid/i
/app/Controllers/View/AdministratorController.php:2189 matches /Paid/i
/app/Controllers/View/AdministratorController.php:2190 matches /Paid/i
/app/Controllers/View/AdministratorController.php:2191 matches /balance\s*=/i
/app/Controllers/View/AdministratorController.php:2214 matches /Paid/i
/app/Controllers/View/AdministratorController.php:2861 matches /Paid/i
/app/Controllers/View/AdministratorController.php:2863 matches /Paid/i
/app/Controllers/View/AdministratorController.php:2864 matches /Paid/i
/app/Controllers/View/AdministratorController.php:2875 matches /Paid/i
/app/Controllers/View/AdministratorController.php:2878 matches /Paid/i
/app/Controllers/View/AdministratorController.php:2900 matches /Paid/i
/app/Controllers/View/AdministratorController.php:2900 matches /refund_paid_amount/i
/app/Controllers/View/AssignmentController.php:156 matches /Paid/i
/app/Controllers/View/AssignmentController.php:391 matches /Paid/i
/app/Controllers/View/DiscountController.php:164 matches /balance\s*=/i
/app/Controllers/View/DiscountController.php:173 matches /balance\s*=/i
/app/Controllers/View/DiscountController.php:223 matches /balance\s*=/i
/app/Controllers/View/DiscountController.php:262 matches /balance\s*=/i
/app/Controllers/View/DiscountController.php:263 matches /balance\s*=/i
/app/Controllers/View/DiscountController.php:356 matches /Paid/i
/app/Controllers/View/DiscountController.php:373 matches /Paid/i
/app/Controllers/View/DiscountController.php:375 matches /Paid/i
/app/Controllers/View/DiscountController.php:410 matches /Paid/i
/app/Controllers/View/DiscountController.php:421 matches /balance\s*=/i
/app/Controllers/View/DiscountController.php:430 matches /Paid/i
/app/Controllers/View/DiscountController.php:436 matches /Paid/i
/app/Controllers/View/DiscountController.php:445 matches /Paid/i
/app/Controllers/View/DiscountController.php:447 matches /Paid/i
/app/Controllers/View/DiscountController.php:470 matches /Paid/i
/app/Controllers/View/DiscountController.php:471 matches /Paid/i
/app/Controllers/View/DiscountController.php:519 matches /Paid/i
/app/Controllers/View/DiscountController.php:653 matches /Paid/i
/app/Controllers/View/EventController.php:1063 matches /Paid/i
/app/Controllers/View/EventController.php:1102 matches /Paid/i
/app/Controllers/View/EventController.php:1113 matches /Paid/i
/app/Controllers/View/EventController.php:1114 matches /Paid/i
/app/Controllers/View/EventController.php:1114 matches /Unpaid/i
/app/Controllers/View/EventController.php:1122 matches /Paid/i
/app/Controllers/View/EventController.php:1128 matches /Paid/i
/app/Controllers/View/EventController.php:1206 matches /Paid/i
/app/Controllers/View/EventController.php:805 matches /Paid/i
/app/Controllers/View/EventController.php:829 matches /Paid/i
/app/Controllers/View/EventController.php:889 matches /Paid/i
/app/Controllers/View/EventController.php:910 matches /Paid/i
/app/Controllers/View/EventController.php:963 matches /Paid/i
/app/Controllers/View/EventController.php:964 matches /Paid/i
/app/Controllers/View/ExtraChargesController.php:280 matches /balance\s*=/i
/app/Controllers/View/ExtraChargesController.php:397 matches /balance\s*=/i
/app/Controllers/View/ExtraChargesController.php:398 matches /balance\s*=/i
/app/Controllers/View/FamilyAdminController.php:125 matches /Paid/i
/app/Controllers/View/FamilyAdminController.php:132 matches /Paid/i
/app/Controllers/View/FamilyAdminController.php:148 matches /Paid/i
/app/Controllers/View/FamilyAdminController.php:154 matches /Paid/i
/app/Controllers/View/FamilyAdminController.php:351 matches /Paid/i
/app/Controllers/View/FamilyAdminController.php:381 matches /Paid/i
/app/Controllers/View/FamilyAdminController.php:395 matches /Paid/i
/app/Controllers/View/FamilyAdminController.php:401 matches /Paid/i
/app/Controllers/View/FinancialController.php:1016 matches /Paid/i
/app/Controllers/View/FinancialController.php:1019 matches /balance\s*=/i
/app/Controllers/View/FinancialController.php:1029 matches /Paid/i
/app/Controllers/View/FinancialController.php:1035 matches /Paid/i
/app/Controllers/View/FinancialController.php:1077 matches /Paid/i
/app/Controllers/View/FinancialController.php:1130 matches /balance\s*=/i
/app/Controllers/View/FinancialController.php:1161 matches /Paid/i
/app/Controllers/View/FinancialController.php:1166 matches /Paid/i
/app/Controllers/View/FinancialController.php:1166 matches /SUM\(paid_amount\)/i
/app/Controllers/View/FinancialController.php:1175 matches /Paid/i
/app/Controllers/View/FinancialController.php:1183 matches /Paid/i
/app/Controllers/View/FinancialController.php:1194 matches /Paid/i
/app/Controllers/View/FinancialController.php:1273 matches /Paid/i
/app/Controllers/View/FinancialController.php:1273 matches /SUM\(paid_amount\)/i
/app/Controllers/View/FinancialController.php:1282 matches /Paid/i
/app/Controllers/View/FinancialController.php:1294 matches /Paid/i
/app/Controllers/View/FinancialController.php:1294 matches /SUM\(paid_amount\)/i
/app/Controllers/View/FinancialController.php:1312 matches /SUM\(amount\)/i
/app/Controllers/View/FinancialController.php:1326 matches /SUM\(amount\)/i
/app/Controllers/View/FinancialController.php:1341 matches /Paid/i
/app/Controllers/View/FinancialController.php:1353 matches /Paid/i
/app/Controllers/View/FinancialController.php:1355 matches /Paid/i
/app/Controllers/View/FinancialController.php:1359 matches /balance\s*=/i
/app/Controllers/View/FinancialController.php:1364 matches /Paid/i
/app/Controllers/View/FinancialController.php:1373 matches /balance\s*=/i
/app/Controllers/View/FinancialController.php:1381 matches /Paid/i
/app/Controllers/View/FinancialController.php:1392 matches /Paid/i
/app/Controllers/View/FinancialController.php:1405 matches /Paid/i
/app/Controllers/View/FinancialController.php:1474 matches /Paid/i
/app/Controllers/View/FinancialController.php:1475 matches /Paid/i
/app/Controllers/View/FinancialController.php:1475 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:1513 matches /Paid/i
/app/Controllers/View/FinancialController.php:1513 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:1519 matches /Paid/i
/app/Controllers/View/FinancialController.php:1519 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:1618 matches /Paid/i
/app/Controllers/View/FinancialController.php:1623 matches /Paid/i
/app/Controllers/View/FinancialController.php:1625 matches /Paid/i
/app/Controllers/View/FinancialController.php:169 matches /Paid/i
/app/Controllers/View/FinancialController.php:169 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:1815 matches /Paid/i
/app/Controllers/View/FinancialController.php:1815 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:1816 matches /Paid/i
/app/Controllers/View/FinancialController.php:1823 matches /Paid/i
/app/Controllers/View/FinancialController.php:1825 matches /Paid/i
/app/Controllers/View/FinancialController.php:1826 matches /balance\s*=/i
/app/Controllers/View/FinancialController.php:1829 matches /Paid/i
/app/Controllers/View/FinancialController.php:1829 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:1831 matches /Paid/i
/app/Controllers/View/FinancialController.php:1842 matches /Paid/i
/app/Controllers/View/FinancialController.php:1850 matches /Paid/i
/app/Controllers/View/FinancialController.php:1850 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:1853 matches /Paid/i
/app/Controllers/View/FinancialController.php:1864 matches /Paid/i
/app/Controllers/View/FinancialController.php:1869 matches /Paid/i
/app/Controllers/View/FinancialController.php:1875 matches /Paid/i
/app/Controllers/View/FinancialController.php:1875 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:1889 matches /Paid/i
/app/Controllers/View/FinancialController.php:1891 matches /Paid/i
/app/Controllers/View/FinancialController.php:1891 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:1892 matches /Paid/i
/app/Controllers/View/FinancialController.php:1923 matches /Paid/i
/app/Controllers/View/FinancialController.php:1923 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:1967 matches /Paid/i
/app/Controllers/View/FinancialController.php:1970 matches /balance\s*=/i
/app/Controllers/View/FinancialController.php:1980 matches /Paid/i
/app/Controllers/View/FinancialController.php:1986 matches /Paid/i
/app/Controllers/View/FinancialController.php:2025 matches /Paid/i
/app/Controllers/View/FinancialController.php:2072 matches /balance\s*=/i
/app/Controllers/View/FinancialController.php:2106 matches /Paid/i
/app/Controllers/View/FinancialController.php:2111 matches /Paid/i
/app/Controllers/View/FinancialController.php:2111 matches /SUM\(paid_amount\)/i
/app/Controllers/View/FinancialController.php:2120 matches /Paid/i
/app/Controllers/View/FinancialController.php:2129 matches /Paid/i
/app/Controllers/View/FinancialController.php:2142 matches /Paid/i
/app/Controllers/View/FinancialController.php:2161 matches /Paid/i
/app/Controllers/View/FinancialController.php:2161 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:242 matches /Paid/i
/app/Controllers/View/FinancialController.php:247 matches /Paid/i
/app/Controllers/View/FinancialController.php:266 matches /Paid/i
/app/Controllers/View/FinancialController.php:266 matches /SUM\(paid_amount\)/i
/app/Controllers/View/FinancialController.php:287 matches /Paid/i
/app/Controllers/View/FinancialController.php:287 matches /SUM\(paid_amount\)/i
/app/Controllers/View/FinancialController.php:288 matches /Paid/i
/app/Controllers/View/FinancialController.php:289 matches /Paid/i
/app/Controllers/View/FinancialController.php:290 matches /Paid/i
/app/Controllers/View/FinancialController.php:317 matches /Paid/i
/app/Controllers/View/FinancialController.php:333 matches /Paid/i
/app/Controllers/View/FinancialController.php:335 matches /Paid/i
/app/Controllers/View/FinancialController.php:344 matches /SUM\(amount\)/i
/app/Controllers/View/FinancialController.php:350 matches /SUM\(amount\)/i
/app/Controllers/View/FinancialController.php:383 matches /Paid/i
/app/Controllers/View/FinancialController.php:39 matches /Paid/i
/app/Controllers/View/FinancialController.php:41 matches /Paid/i
/app/Controllers/View/FinancialController.php:44 matches /Paid/i
/app/Controllers/View/FinancialController.php:44 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:462 matches /Paid/i
/app/Controllers/View/FinancialController.php:463 matches /Paid/i
/app/Controllers/View/FinancialController.php:464 matches /Paid/i
/app/Controllers/View/FinancialController.php:464 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:465 matches /Paid/i
/app/Controllers/View/FinancialController.php:465 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:478 matches /Paid/i
/app/Controllers/View/FinancialController.php:478 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:479 matches /Paid/i
/app/Controllers/View/FinancialController.php:487 matches /Paid/i
/app/Controllers/View/FinancialController.php:495 matches /Paid/i
/app/Controllers/View/FinancialController.php:539 matches /Paid/i
/app/Controllers/View/FinancialController.php:540 matches /Paid/i
/app/Controllers/View/FinancialController.php:541 matches /Paid/i
/app/Controllers/View/FinancialController.php:541 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:542 matches /Paid/i
/app/Controllers/View/FinancialController.php:542 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:559 matches /Paid/i
/app/Controllers/View/FinancialController.php:559 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:560 matches /Paid/i
/app/Controllers/View/FinancialController.php:572 matches /Paid/i
/app/Controllers/View/FinancialController.php:572 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:632 matches /Paid/i
/app/Controllers/View/FinancialController.php:655 matches /Paid/i
/app/Controllers/View/FinancialController.php:657 matches /Paid/i
/app/Controllers/View/FinancialController.php:658 matches /balance\s*=/i
/app/Controllers/View/FinancialController.php:667 matches /Paid/i
/app/Controllers/View/FinancialController.php:696 matches /Paid/i
/app/Controllers/View/FinancialController.php:714 matches /Paid/i
/app/Controllers/View/FinancialController.php:727 matches /Paid/i
/app/Controllers/View/FinancialController.php:727 matches /refund_paid_amount/i
/app/Controllers/View/FinancialController.php:731 matches /Paid/i
/app/Controllers/View/FinancialController.php:742 matches /Paid/i
/app/Controllers/View/FinancialController.php:742 matches /refund_paid_amount/i
/app/Controllers/View/FinancialController.php:752 matches /Paid/i
/app/Controllers/View/FinancialController.php:753 matches /Paid/i
/app/Controllers/View/FinancialController.php:770 matches /Paid/i
/app/Controllers/View/FinancialController.php:777 matches /Paid/i
/app/Controllers/View/FinancialController.php:983 matches /Paid/i
/app/Controllers/View/FinancialController.php:983 matches /Unpaid/i
/app/Controllers/View/FinancialController.php:992 matches /Paid/i
/app/Controllers/View/FinancialController.php:992 matches /Unpaid/i
/app/Controllers/View/InvoiceController.php:1134 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1161 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1162 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1222 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1223 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1224 matches /balance\s*=/i
/app/Controllers/View/InvoiceController.php:1253 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1258 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1267 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1269 matches /Paid/i
/app/Controllers/View/InvoiceController.php:139 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1407 matches /Paid/i
/app/Controllers/View/InvoiceController.php:141 matches /Paid/i
/app/Controllers/View/InvoiceController.php:141 matches /refund_paid_amount/i
/app/Controllers/View/InvoiceController.php:1412 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1412 matches /refund_paid_amount/i
/app/Controllers/View/InvoiceController.php:1414 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1420 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1420 matches /refund_paid_amount/i
/app/Controllers/View/InvoiceController.php:1431 matches /Paid/i
/app/Controllers/View/InvoiceController.php:144 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1440 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1478 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1478 matches /Unpaid/i
/app/Controllers/View/InvoiceController.php:148 matches /Paid/i
/app/Controllers/View/InvoiceController.php:148 matches /refund_paid_amount/i
/app/Controllers/View/InvoiceController.php:1535 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1535 matches /Unpaid/i
/app/Controllers/View/InvoiceController.php:1536 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1536 matches /Unpaid/i
/app/Controllers/View/InvoiceController.php:1538 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1538 matches /Unpaid/i
/app/Controllers/View/InvoiceController.php:1539 matches /Paid/i
/app/Controllers/View/InvoiceController.php:1539 matches /Unpaid/i
/app/Controllers/View/InvoiceController.php:293 matches /Paid/i
/app/Controllers/View/InvoiceController.php:295 matches /Paid/i
/app/Controllers/View/InvoiceController.php:295 matches /refund_paid_amount/i
/app/Controllers/View/InvoiceController.php:298 matches /Paid/i
/app/Controllers/View/InvoiceController.php:300 matches /Paid/i
/app/Controllers/View/InvoiceController.php:300 matches /refund_paid_amount/i
/app/Controllers/View/InvoiceController.php:525 matches /Paid/i
/app/Controllers/View/InvoiceController.php:923 matches /Paid/i
/app/Controllers/View/InvoiceController.php:993 matches /Paid/i
/app/Controllers/View/LandingPageController.php:869 matches /balance\s*=/i
/app/Controllers/View/ParentController.php:1411 matches /Paid/i
/app/Controllers/View/ParentController.php:1970 matches /Paid/i
/app/Controllers/View/ParentController.php:545 matches /Paid/i
/app/Controllers/View/ParentController.php:545 matches /refund_paid_amount/i
/app/Controllers/View/ParentController.php:747 matches /Paid/i
/app/Controllers/View/ParentController.php:750 matches /Paid/i
/app/Controllers/View/ParentController.php:756 matches /Paid/i
/app/Controllers/View/PaymentController.php:1014 matches /balance\s*=/i
/app/Controllers/View/PaymentController.php:1017 matches /Paid/i
/app/Controllers/View/PaymentController.php:1128 matches /Paid/i
/app/Controllers/View/PaymentController.php:1132 matches /Paid/i
/app/Controllers/View/PaymentController.php:1168 matches /Paid/i
/app/Controllers/View/PaymentController.php:1171 matches /Paid/i
/app/Controllers/View/PaymentController.php:1190 matches /balance\s*=/i
/app/Controllers/View/PaymentController.php:1192 matches /Paid/i
/app/Controllers/View/PaymentController.php:1196 matches /Paid/i
/app/Controllers/View/PaymentController.php:1201 matches /Paid/i
/app/Controllers/View/PaymentController.php:1205 matches /Paid/i
/app/Controllers/View/PaymentController.php:1317 matches /Paid/i
/app/Controllers/View/PaymentController.php:1317 matches /balance\s*=/i
/app/Controllers/View/PaymentController.php:1335 matches /balance\s*=/i
/app/Controllers/View/PaymentController.php:1341 matches /Paid/i
/app/Controllers/View/PaymentController.php:1377 matches /balance\s*=/i
/app/Controllers/View/PaymentController.php:1434 matches /balance\s*=/i
/app/Controllers/View/PaymentController.php:1442 matches /Paid/i
/app/Controllers/View/PaymentController.php:1494 matches /Paid/i
/app/Controllers/View/PaymentController.php:163 matches /Paid/i
/app/Controllers/View/PaymentController.php:164 matches /Paid/i
/app/Controllers/View/PaymentController.php:174 matches /Paid/i
/app/Controllers/View/PaymentController.php:175 matches /Paid/i
/app/Controllers/View/PaymentController.php:360 matches /Paid/i
/app/Controllers/View/PaymentController.php:388 matches /Paid/i
/app/Controllers/View/PaymentController.php:390 matches /Paid/i
/app/Controllers/View/PaymentController.php:481 matches /Paid/i
/app/Controllers/View/PaymentController.php:503 matches /Paid/i
/app/Controllers/View/PaymentController.php:508 matches /Paid/i
/app/Controllers/View/PaymentController.php:526 matches /Paid/i
/app/Controllers/View/PaymentController.php:528 matches /Paid/i
/app/Controllers/View/PaymentController.php:532 matches /Paid/i
/app/Controllers/View/PaymentController.php:552 matches /Paid/i
/app/Controllers/View/PaymentController.php:553 matches /Paid/i
/app/Controllers/View/PaymentController.php:602 matches /Paid/i
/app/Controllers/View/PaymentController.php:83 matches /Paid/i
/app/Controllers/View/PaymentController.php:884 matches /balance\s*=/i
/app/Controllers/View/PaymentController.php:925 matches /balance\s*=/i
/app/Controllers/View/PaymentNotificationController.php:101 matches /balance\s*=/i
/app/Controllers/View/PaymentNotificationController.php:200 matches /Paid/i
/app/Controllers/View/PaymentNotificationController.php:200 matches /Unpaid/i
/app/Controllers/View/PaymentNotificationController.php:253 matches /Paid/i
/app/Controllers/View/PaymentNotificationController.php:253 matches /Unpaid/i
/app/Controllers/View/PaymentNotificationController.php:333 matches /Paid/i
/app/Controllers/View/PaymentNotificationController.php:333 matches /Unpaid/i
/app/Controllers/View/PaymentNotificationController.php:93 matches /balance\s*=/i
/app/Controllers/View/RefundController.php:1007 matches /Paid/i
/app/Controllers/View/RefundController.php:1014 matches /Paid/i
/app/Controllers/View/RefundController.php:1015 matches /Paid/i
/app/Controllers/View/RefundController.php:1028 matches /Paid/i
/app/Controllers/View/RefundController.php:1028 matches /refund_paid_amount/i
/app/Controllers/View/RefundController.php:1214 matches /Paid/i
/app/Controllers/View/RefundController.php:164 matches /Paid/i
/app/Controllers/View/RefundController.php:168 matches /Paid/i
/app/Controllers/View/RefundController.php:178 matches /Paid/i
/app/Controllers/View/RefundController.php:178 matches /refund_paid_amount/i
/app/Controllers/View/RefundController.php:206 matches /Paid/i
/app/Controllers/View/RefundController.php:237 matches /Paid/i
/app/Controllers/View/RefundController.php:250 matches /Paid/i
/app/Controllers/View/RefundController.php:252 matches /Paid/i
/app/Controllers/View/RefundController.php:253 matches /Paid/i
/app/Controllers/View/RefundController.php:268 matches /Paid/i
/app/Controllers/View/RefundController.php:271 matches /Paid/i
/app/Controllers/View/RefundController.php:395 matches /Paid/i
/app/Controllers/View/RefundController.php:395 matches /refund_paid_amount/i
/app/Controllers/View/RefundController.php:542 matches /Paid/i
/app/Controllers/View/RefundController.php:553 matches /Paid/i
/app/Controllers/View/RefundController.php:560 matches /Paid/i
/app/Controllers/View/RefundController.php:596 matches /Paid/i
/app/Controllers/View/RefundController.php:600 matches /Paid/i
/app/Controllers/View/RefundController.php:618 matches /Paid/i
/app/Controllers/View/RefundController.php:625 matches /Paid/i
/app/Controllers/View/RefundController.php:626 matches /Paid/i
/app/Controllers/View/RefundController.php:628 matches /Paid/i
/app/Controllers/View/RefundController.php:630 matches /Paid/i
/app/Controllers/View/RefundController.php:631 matches /Paid/i
/app/Controllers/View/RefundController.php:636 matches /Paid/i
/app/Controllers/View/RefundController.php:660 matches /Paid/i
/app/Controllers/View/RefundController.php:664 matches /Paid/i
/app/Controllers/View/RefundController.php:698 matches /Paid/i
/app/Controllers/View/RefundController.php:698 matches /refund_paid_amount/i
/app/Controllers/View/RefundController.php:858 matches /Paid/i
/app/Controllers/View/RefundController.php:862 matches /Paid/i
/app/Controllers/View/RefundController.php:865 matches /Paid/i
/app/Controllers/View/RefundController.php:865 matches /refund_paid_amount/i
/app/Controllers/View/RefundController.php:867 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:1204 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:1288 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:2038 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:2058 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:2111 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:2112 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:2132 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:2133 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:2210 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:2211 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:2259 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:2325 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:2348 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:2350 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:935 matches /Paid/i
/app/Controllers/View/ReimbursementController.php:951 matches /Paid/i
/app/Controllers/View/StudentController.php:1449 matches /Paid/i
/app/Controllers/View/StudentController.php:1484 matches /Paid/i
/app/Controllers/View/StudentController.php:1548 matches /Paid/i
/app/Controllers/View/TuitionForecastController.php:134 matches /Paid/i
/app/Libraries/RefundEligibilityService.php:110 matches /Paid/i
/app/Libraries/RefundEligibilityService.php:110 matches /refund_paid_amount/i
/app/Libraries/RefundEligibilityService.php:156 matches /Paid/i
/app/Libraries/RefundEligibilityService.php:156 matches /refund_paid_amount/i
/app/Libraries/RefundEligibilityService.php:158 matches /Paid/i
/app/Libraries/RefundEligibilityService.php:158 matches /refund_paid_amount/i
/app/Libraries/RefundEligibilityService.php:264 matches /Paid/i
/app/Libraries/RefundEligibilityService.php:264 matches /refund_paid_amount/i
/app/Libraries/RefundEligibilityService.php:266 matches /Paid/i
/app/Libraries/RefundEligibilityService.php:269 matches /Paid/i
/app/Libraries/RefundEligibilityService.php:275 matches /Paid/i
/app/Libraries/RefundEligibilityService.php:275 matches /refund_paid_amount/i
/app/Libraries/RefundEligibilityService.php:278 matches /Paid/i
/app/Libraries/RefundEligibilityService.php:289 matches /Paid/i
/app/Libraries/RefundEligibilityService.php:290 matches /Paid/i
/app/Libraries/Tuition/TuitionForecastService.php:236 matches /Paid/i
/app/Libraries/Tuition/TuitionForecastService.php:467 matches /Paid/i
/app/Libraries/Tuition/TuitionForecastService.php:467 matches /SUM\(paid_amount\)/i
/app/Libraries/Tuition/TuitionForecastService.php:490 matches /Paid/i
/app/Libraries/Tuition/TuitionForecastService.php:493 matches /Paid/i
/app/Libraries/Tuition/TuitionForecastService.php:493 matches /refund_paid_amount/i
/app/Libraries/Tuition/TuitionForecastService.php:505 matches /Paid/i
/app/Listeners/SchoolEventListener.php:638 matches /Paid/i
/app/Listeners/SchoolEventListener.php:639 matches /balance\s*=/i
/app/Listeners/SchoolEventListener.php:640 matches /balance\s*=/i
/app/Listeners/SchoolEventListener.php:662 matches /Paid/i
/app/Listeners/SchoolEventListener.php:776 matches /balance\s*=/i
/app/Listeners/SchoolEventListener.php:777 matches /balance\s*=/i
/app/Models/EventChargesModel.php:22 matches /Paid/i
/app/Models/InvoiceModel.php:156 matches /Paid/i
/app/Models/InvoiceModel.php:156 matches /Unpaid/i
/app/Models/InvoiceModel.php:158 matches /Paid/i
/app/Models/InvoiceModel.php:158 matches /Partially Paid/i
/app/Models/InvoiceModel.php:158 matches /Unpaid/i
/app/Models/InvoiceModel.php:172 matches /Paid/i
/app/Models/InvoiceModel.php:176 matches /Paid/i
/app/Models/InvoiceModel.php:178 matches /Paid/i
/app/Models/InvoiceModel.php:217 matches /Paid/i
/app/Models/InvoiceModel.php:23 matches /Paid/i
/app/Models/InvoiceModel.php:264 matches /balance\s*=/i
/app/Models/InvoiceModel.php:276 matches /balance\s*=/i
/app/Models/PaymentErrorModel.php:19 matches /Paid/i
/app/Models/PaymentErrorModel.php:31 matches /Paid/i
/app/Models/PaymentModel.php:112 matches /Paid/i
/app/Models/PaymentModel.php:113 matches /Paid/i
/app/Models/PaymentModel.php:114 matches /Paid/i
/app/Models/PaymentModel.php:115 matches /Paid/i
/app/Models/PaymentModel.php:225 matches /Paid/i
/app/Models/PaymentModel.php:255 matches /Paid/i
/app/Models/PaymentModel.php:259 matches /Paid/i
/app/Models/PaymentModel.php:262 matches /Paid/i
/app/Models/PaymentModel.php:270 matches /Paid/i
/app/Models/PaymentModel.php:271 matches /Paid/i
/app/Models/PaymentModel.php:281 matches /Paid/i
/app/Models/PaymentModel.php:283 matches /Paid/i
/app/Models/PaymentModel.php:293 matches /Paid/i
/app/Models/PaymentModel.php:30 matches /Paid/i
/app/Models/PaymentModel.php:300 matches /Paid/i
/app/Models/PaymentModel.php:377 matches /Paid/i
/app/Models/PaymentModel.php:386 matches /Paid/i
/app/Models/PaymentModel.php:70 matches /Paid/i
/app/Models/PaymentTransactionModel.php:102 matches /Paid/i
/app/Models/PaymentTransactionModel.php:107 matches /Paid/i
/app/Models/RefundModel.php:31 matches /Paid/i
/app/Models/RefundModel.php:31 matches /refund_paid_amount/i
/app/Models/RefundModel.php:62 matches /Paid/i
/app/Models/RefundModel.php:62 matches /Partially Paid/i
/app/Models/RefundModel.php:63 matches /Paid/i
/app/Models/RefundModel.php:63 matches /refund_paid_amount/i
/app/Models/RefundModel.php:66 matches /Paid/i
/app/Models/RefundModel.php:70 matches /Paid/i
/app/Models/ReimbursementModel.php:34 matches /Paid/i
/app/Models/StudentModel.php:23 matches /Paid/i
/app/Services/FeeCalculationService.php:107 matches /Paid/i
/app/Services/FeeCalculationService.php:108 matches /Paid/i
/app/Services/FeeCalculationService.php:109 matches /Paid/i
/app/Services/FeeCalculationService.php:22 matches /Paid/i
/app/Services/FeeCalculationService.php:24 matches /Paid/i
/app/Services/SchoolYearClosingService.php:347 matches /Paid/i
/app/Services/SchoolYearClosingService.php:360 matches /Paid/i
/app/Services/SchoolYearClosingService.php:360 matches /SUM\(paid_amount\)/i
/app/Services/SchoolYearClosingService.php:375 matches /Paid/i
/app/Services/SchoolYearClosingService.php:375 matches /Unpaid/i
/app/Services/SchoolYearClosingService.php:384 matches /Paid/i
/app/Services/SchoolYearClosingService.php:384 matches /Partially Paid/i
/app/Services/SchoolYearClosingService.php:384 matches /Unpaid/i
/app/Services/SchoolYearClosingService.php:403 matches /Paid/i
/app/Services/SchoolYearClosingService.php:403 matches /Partially Paid/i
/app/Services/SchoolYearClosingService.php:403 matches /Unpaid/i
/app/Services/SchoolYearClosingService.php:41 matches /Paid/i
/app/Services/SchoolYearClosingService.php:421 matches /balance\s*=/i
/app/Services/SchoolYearClosingService.php:52 matches /Paid/i
/app/Services/SchoolYearClosingService.php:52 matches /Unpaid/i
/app/Services/SchoolYearClosingService.php:533 matches /Paid/i
/app/Services/SchoolYearClosingService.php:539 matches /Paid/i
/app/Services/SchoolYearClosingService.php:539 matches /Unpaid/i
/app/Views/administrator/class_assignment.php:62 matches /Paid/i
/app/Views/administrator/class_assignment.php:91 matches /Paid/i
/app/Views/administrator/events/event_charges.php:291 matches /Paid/i
/app/Views/administrator/events/event_charges.php:300 matches /Paid/i
/app/Views/administrator/events/event_charges.php:374 matches /balance\s*=/i
/app/Views/administrator/events/event_charges.php:376 matches /Paid/i
/app/Views/administrator/events/event_charges.php:391 matches /Paid/i
/app/Views/administrator/events/event_charges.php:391 matches /Unpaid/i
/app/Views/administrator/events/event_charges.php:392 matches /Paid/i
/app/Views/administrator/events/event_charges.php:393 matches /Paid/i
/app/Views/administrator/events/event_charges.php:395 matches /Paid/i
/app/Views/administrator/events/event_charges.php:395 matches /Unpaid/i
/app/Views/administrator/events/event_charges.php:398 matches /Paid/i
/app/Views/administrator/events/event_charges.php:398 matches /Unpaid/i
/app/Views/administrator/events/event_charges.php:402 matches /Paid/i
/app/Views/administrator/events/event_charges.php:404 matches /Paid/i
/app/Views/administrator/events/event_charges.php:405 matches /Paid/i
/app/Views/administrator/events/event_charges.php:406 matches /Paid/i
/app/Views/administrator/events/event_charges.php:641 matches /Paid/i
/app/Views/administrator/events/event_charges_pdf.php:123 matches /Paid/i
/app/Views/administrator/events/event_charges_pdf.php:131 matches /Paid/i
/app/Views/administrator/events/event_charges_pdf.php:134 matches /balance\s*=/i
/app/Views/administrator/events/event_charges_pdf.php:136 matches /Paid/i
/app/Views/administrator/events/event_charges_pdf.php:183 matches /Paid/i
/app/Views/administrator/events/event_charges_pdf.php:186 matches /Paid/i
/app/Views/administrator/events/event_charges_pdf.php:186 matches /Unpaid/i
/app/Views/administrator/events/event_charges_pdf.php:187 matches /Paid/i
/app/Views/administrator/events/event_charges_pdf.php:187 matches /Unpaid/i
/app/Views/administrator/events/event_charges_pdf.php:189 matches /Paid/i
/app/Views/administrator/events/event_charges_pdf.php:189 matches /Unpaid/i
/app/Views/administrator/events/event_charges_pdf.php:190 matches /Paid/i
/app/Views/administrator/events/event_charges_pdf.php:190 matches /Unpaid/i
/app/Views/administrator/events/event_charges_pdf.php:71 matches /Paid/i
/app/Views/administrator/events/event_charges_pdf.php:76 matches /Paid/i
/app/Views/administrator/events/event_charges_pdf.php:76 matches /Unpaid/i
/app/Views/administrator/fee_collection.php:21 matches /Paid/i
/app/Views/administrator/fee_collection.php:23 matches /Paid/i
/app/Views/administrator/fee_payment_records.php:21 matches /Paid/i
/app/Views/administrator/fee_payment_records.php:23 matches /Paid/i
/app/Views/administrator/payroll_management.php:21 matches /Paid/i
/app/Views/administrator/payroll_management.php:22 matches /Paid/i
/app/Views/administrator/student_profiles.php:363 matches /Paid/i
/app/Views/administrator/student_profiles.php:365 matches /Paid/i
/app/Views/administrator/student_profiles.php:366 matches /Paid/i
/app/Views/administrator/student_profiles.php:368 matches /Paid/i
/app/Views/administrator/student_profiles.php:369 matches /Paid/i
/app/Views/administrator/tuition_forecast.php:31 matches /Paid/i
/app/Views/emails/payment_receipt.php:38 matches /Paid/i
/app/Views/emails/status_payment_pending.php:86 matches /Paid/i
/app/Views/family/card.php:302 matches /Paid/i
/app/Views/family/card.php:303 matches /Paid/i
/app/Views/family/card.php:324 matches /Paid/i
/app/Views/family/card.php:333 matches /Paid/i
/app/Views/family/card.php:361 matches /Paid/i
/app/Views/family/card.php:6 matches /Paid/i
/app/Views/family/index.php:237 matches /Paid/i
/app/Views/family/index.php:246 matches /Paid/i
/app/Views/family/index.php:274 matches /Paid/i
/app/Views/family/index.php:75 matches /Paid/i
/app/Views/parent/event_participation.php:70 matches /Paid/i
/app/Views/parent/event_participation.php:80 matches /Paid/i
/app/Views/parent/event_participation.php:81 matches /Paid/i
/app/Views/parent/event_participation.php:81 matches /Unpaid/i
/app/Views/parent/invoice_payment.php:123 matches /Paid/i
/app/Views/parent/invoice_payment.php:167 matches /Paid/i
/app/Views/parent/invoice_payment.php:168 matches /Paid/i
/app/Views/parent/invoice_payment.php:176 matches /Paid/i
/app/Views/parent/invoice_payment.php:176 matches /Unpaid/i
/app/Views/parent/payment_success.php:12 matches /Paid/i
/app/Views/parent/payment_success.php:8 matches /Paid/i
/app/Views/parent/payment_view.php:19 matches /Paid/i
/app/Views/parent/payment_view.php:20 matches /Paid/i
/app/Views/partials/school_year_selector.php:10 matches /Paid/i
/app/Views/partials/school_year_selector.php:10 matches /Unpaid/i
/app/Views/payment/_list_modal.php:23 matches /Paid/i
/app/Views/payment/_list_modal.php:44 matches /Paid/i
/app/Views/payment/extra_charges.php:179 matches /Paid/i
/app/Views/payment/extra_charges.php:179 matches /Unpaid/i
/app/Views/payment/financial_report.php:113 matches /Paid/i
/app/Views/payment/financial_report.php:349 matches /Paid/i
/app/Views/payment/financial_report.php:362 matches /Paid/i
/app/Views/payment/financial_report.php:370 matches /balance\s*=/i
/app/Views/payment/financial_report.php:371 matches /Paid/i
/app/Views/payment/financial_report.php:371 matches /Unpaid/i
/app/Views/payment/financial_report.php:373 matches /Paid/i
/app/Views/payment/financial_report.php:374 matches /Paid/i
/app/Views/payment/financial_report.php:385 matches /Paid/i
/app/Views/payment/financial_report.php:63 matches /Paid/i
/app/Views/payment/financial_report.php:80 matches /Paid/i
/app/Views/payment/financial_report.php:92 matches /balance\s*=/i
/app/Views/payment/financial_report.php:93 matches /Paid/i
/app/Views/payment/financial_report.php:93 matches /Unpaid/i
/app/Views/payment/financial_report.php:94 matches /Paid/i
/app/Views/payment/financial_report.php:96 matches /Paid/i
/app/Views/payment/financial_report_summary.php:107 matches /Paid/i
/app/Views/payment/financial_report_summary.php:109 matches /Paid/i
/app/Views/payment/financial_report_summary.php:109 matches /Unpaid/i
/app/Views/payment/financial_report_summary.php:110 matches /Paid/i
/app/Views/payment/financial_report_summary.php:124 matches /Paid/i
/app/Views/payment/financial_report_summary.php:125 matches /Paid/i
/app/Views/payment/financial_report_summary.php:125 matches /Unpaid/i
/app/Views/payment/financial_report_summary.php:126 matches /Paid/i
/app/Views/payment/financial_report_summary.php:128 matches /Paid/i
/app/Views/payment/financial_report_summary.php:128 matches /Unpaid/i
/app/Views/payment/financial_report_summary.php:159 matches /Paid/i
/app/Views/payment/financial_report_summary.php:160 matches /Paid/i
/app/Views/payment/financial_report_summary.php:161 matches /Paid/i
/app/Views/payment/financial_report_summary.php:161 matches /Unpaid/i
/app/Views/payment/financial_report_summary.php:255 matches /Paid/i
/app/Views/payment/financial_report_summary.php:255 matches /Unpaid/i
/app/Views/payment/financial_report_summary.php:264 matches /Paid/i
/app/Views/payment/financial_report_summary.php:264 matches /Unpaid/i
/app/Views/payment/financial_report_summary.php:266 matches /Paid/i
/app/Views/payment/financial_report_summary.php:47 matches /Paid/i
/app/Views/payment/financial_report_summary.php:48 matches /Paid/i
/app/Views/payment/financial_report_summary.php:49 matches /Paid/i
/app/Views/payment/financial_report_summary.php:49 matches /Unpaid/i
/app/Views/payment/financial_report_summary.php:50 matches /Paid/i
/app/Views/payment/financial_report_summary.php:50 matches /Unpaid/i
/app/Views/payment/manual_pay.php:1119 matches /balance\s*=/i
/app/Views/payment/manual_pay.php:226 matches /Paid/i
/app/Views/payment/manual_pay.php:227 matches /Paid/i
/app/Views/payment/manual_pay.php:245 matches /Paid/i
/app/Views/payment/manual_pay.php:246 matches /Paid/i
/app/Views/payment/manual_pay.php:247 matches /Paid/i
/app/Views/payment/manual_pay.php:250 matches /Paid/i
/app/Views/payment/manual_pay.php:251 matches /Paid/i
/app/Views/payment/manual_pay.php:311 matches /Paid/i
/app/Views/payment/manual_pay.php:312 matches /Paid/i
/app/Views/payment/manual_pay.php:358 matches /Paid/i
/app/Views/payment/manual_pay.php:371 matches /Paid/i
/app/Views/payment/manual_pay.php:373 matches /Paid/i
/app/Views/payment/manual_pay.php:421 matches /balance\s*=/i
/app/Views/payment/manual_pay.php:427 matches /Paid/i
/app/Views/payment/manual_pay.php:432 matches /Paid/i
/app/Views/payment/manual_pay.php:602 matches /balance\s*=/i
/app/Views/payment/manual_pay.php:916 matches /balance\s*=/i
/app/Views/payment/manual_payment.php:137 matches /Paid/i
/app/Views/payment/manual_payment.php:138 matches /Paid/i
/app/Views/payment/manual_payment.php:188 matches /Paid/i
/app/Views/payment/manual_payment.php:205 matches /Paid/i
/app/Views/payment/manual_payment.php:253 matches /balance\s*=/i
/app/Views/payment/manual_payment.php:256 matches /Paid/i
/app/Views/payment/manual_payment.php:384 matches /balance\s*=/i
/app/Views/payment/manual_payment.php:418 matches /balance\s*=/i
/app/Views/payment/manual_payment.php:58 matches /Paid/i
/app/Views/payment/manual_payment.php:59 matches /Paid/i
/app/Views/payment/manual_payment.php:73 matches /Paid/i
/app/Views/payment/unpaid_parents.php:104 matches /Paid/i
/app/Views/payment/unpaid_parents.php:122 matches /Paid/i
/app/Views/payment/unpaid_parents.php:122 matches /Unpaid/i
/app/Views/payment/unpaid_parents.php:137 matches /Paid/i
/app/Views/payment/unpaid_parents.php:159 matches /Paid/i
/app/Views/payment/unpaid_parents.php:159 matches /Unpaid/i
/app/Views/payment/unpaid_parents.php:4 matches /Paid/i
/app/Views/payment/unpaid_parents.php:44 matches /Paid/i
/app/Views/payment/unpaid_parents.php:44 matches /Unpaid/i
/app/Views/payment/unpaid_parents.php:50 matches /Paid/i
/app/Views/payment/unpaid_parents.php:50 matches /Unpaid/i
/app/Views/payment/unpaid_parents.php:59 matches /Paid/i
/app/Views/payment/unpaid_parents.php:74 matches /Paid/i
/app/Views/payment/unpaid_parents.php:97 matches /Paid/i
/app/Views/payment_list.php:21 matches /Paid/i
/app/Views/payment_list.php:39 matches /Paid/i
/app/Views/refunds/list.php:123 matches /Paid/i
/app/Views/refunds/list.php:123 matches /refund_paid_amount/i
/app/Views/refunds/list.php:191 matches /Paid/i
/app/Views/refunds/list.php:192 matches /Paid/i
/app/Views/refunds/list.php:321 matches /Paid/i
/app/Views/refunds/list.php:71 matches /Paid/i
/app/Views/reimbursements/index.php:13 matches /Paid/i
/app/Views/school_years/closing_preview.php:168 matches /Paid/i
/app/Views/school_years/closing_preview.php:169 matches /Paid/i
@@ -0,0 +1,102 @@
<?php
namespace Tests\App\Config;
use CodeIgniter\Test\CIUnitTestCase;
class FinancialRouteIntegrityTest extends CIUnitTestCase
{
private string $routesFile;
protected function setUp(): void
{
parent::setUp();
$this->routesFile = ROOTPATH . 'app/Config/Routes.php';
}
public function testFinancialBrowserWriteRoutesArePostOnlyAndFiltered(): void
{
$routes = file($this->routesFile, FILE_IGNORE_NEW_LINES) ?: [];
$financialWrites = array_values(array_filter($routes, static function (string $line): bool {
return str_contains($line, '$routes->post(')
&& preg_match('/(refunds|expenses|reimbursements|discount|charges|payment|invoice|purchase|inventory)/i', $line);
}));
$this->assertNotEmpty($financialWrites);
foreach ($financialWrites as $line) {
$this->assertStringContainsString("'filter'", $line, $line);
$this->assertStringContainsString('auth:', $line, $line);
}
}
public function testFinancialStateChangingRoutesDoNotUseGet(): void
{
$routes = file($this->routesFile, FILE_IGNORE_NEW_LINES) ?: [];
foreach ($routes as $line) {
if (!str_contains($line, '$routes->get(')) {
continue;
}
if (! preg_match('/\$routes->get\(\s*[\'"]([^\'"]+)/', $line, $matches)) {
continue;
}
$routePath = $matches[1];
if (!preg_match('/(refunds|expenses|reimbursements|discount|charges|payment|invoice|purchase|inventory)/i', $routePath)) {
continue;
}
$this->assertDoesNotMatchRegularExpression('/(update|store|approve|reverse|void|delete|apply|recalculate)/i', $routePath, $line);
}
}
public function testFinancialWritesDoNotUseGetPostMatchRoutes(): void
{
$routes = file($this->routesFile, FILE_IGNORE_NEW_LINES) ?: [];
$checked = 0;
foreach ($routes as $line) {
if (!str_contains($line, '$routes->match(') || !str_contains($line, "'get'") || !str_contains($line, "'post'")) {
continue;
}
if (!preg_match('/(refunds|expenses|reimbursements|discount|charges|payment|invoice|purchase|inventory)/i', $line)) {
continue;
}
$checked++;
$this->fail('Financial routes must split read GET and write POST handlers: ' . $line);
}
$this->assertSame(0, $checked);
}
public function testGenericApiDoesNotExposeFinancialMutations(): void
{
$text = file_get_contents($this->routesFile) ?: '';
$forbidden = [
"post('payments'",
"put('payments/",
"post('payment-transactions'",
"get('payment-transactions'",
"post('payment-notifications/send'",
"post('expenses'",
"put('expenses/",
"post('reimbursements'",
"put('reimbursements/",
"post('refunds'",
"put('refunds/",
"post('discounts/apply'",
"post('extra-charges'",
"put('extra-charges/",
"post('purchase-orders'",
"put('purchase-orders/",
"post('inventory'",
"put('inventory/",
"delete('inventory/",
];
$apiV1Start = strpos($text, "\$routes->group('api/v1'");
$this->assertIsInt($apiV1Start);
$apiV1Text = substr($text, $apiV1Start);
foreach ($forbidden as $needle) {
$this->assertStringNotContainsString($needle, $apiV1Text, $needle);
}
}
}
@@ -0,0 +1,179 @@
<?php
namespace Tests\App\Controllers\View;
use App\Controllers\View\RefundController;
use App\Libraries\RefundEligibilityService;
use CodeIgniter\Test\CIUnitTestCase;
class RefundRequestStub
{
public function __construct(private array $post = [])
{
}
public function getPost(?string $key = null)
{
if ($key === null) {
return $this->post;
}
return $this->post[$key] ?? null;
}
public function getFile(string $name)
{
return null;
}
}
class TestableRefundController extends RefundController
{
public function __construct()
{
}
public function setRequestObject($request): self
{
$this->request = $request;
return $this;
}
public function setResponseObject($response): self
{
$this->response = $response;
return $this;
}
}
class RefundEligibilityForControllerHarness extends RefundEligibilityService
{
public function __construct(private int $completedPayoutCents)
{
}
public function getCompletedPayoutTotalCentsForRefund(int $refundId): int
{
return $this->completedPayoutCents;
}
}
class RefundControllerRegressionTest extends CIUnitTestCase
{
public function testRequestRefundReadsRequestTypeFromPostBody(): void
{
$controller = $this->controllerWithPost([
'parent_id' => '123',
'amount' => '25.00',
'request_type' => 'unsupported',
]);
$response = $controller->requestRefund();
$this->assertSame(['error' => 'Invalid refund request type.'], $this->jsonResponse($response));
}
public function testPayRefundRouteArgumentIsAcceptedBeforePostRefundId(): void
{
$controller = $this->controllerWithPost([
'paid_amount' => '10.00',
'payment_method' => 'Wire',
]);
$response = $controller->payRefund(77);
$this->assertSame(['error' => 'Invalid refund payment method.'], $this->jsonResponse($response));
}
public function testCheckRefundRequiresCheckNumberBeforeDatabaseWrite(): void
{
$controller = $this->controllerWithPost([
'refund_id' => '77',
'paid_amount' => '10.00',
'payment_method' => 'Check',
]);
$response = $controller->updatePayment();
$this->assertSame(['error' => 'Check number is required for check refunds.'], $this->jsonResponse($response));
}
public function testUpdateStatusAcceptsRouteRefundId(): void
{
$controller = $this->controllerWithPost([
'status' => 'Voided',
'reason' => 'duplicate request',
]);
$response = $controller->updateStatus(77);
$this->assertSame(['error' => 'Invalid status update request.'], $this->jsonResponse($response));
}
public function testReversePayoutRequiresReasonBeforeDatabaseWrite(): void
{
$controller = $this->controllerWithPost([
'idempotency_key' => 'reverse-77',
]);
$response = $controller->reversePayout(77);
$this->assertSame(['error' => 'Reversal reason is required.'], $this->jsonResponse($response));
}
public function testReversePayoutRequiresIdempotencyKeyBeforeDatabaseWrite(): void
{
$controller = $this->controllerWithPost([
'reason' => 'wrong payout amount',
]);
$response = $controller->reversePayout(77);
$this->assertSame(['error' => 'Missing reversal idempotency key.'], $this->jsonResponse($response));
}
public function testReversePayoutRejectsNonPositiveAmountBeforeDatabaseWrite(): void
{
$controller = $this->controllerWithPost([
'reason' => 'wrong payout amount',
'idempotency_key' => 'reverse-77',
'amount' => '0.00',
]);
$response = $controller->reversePayout(77);
$this->assertSame(['error' => 'Reversal amount must be greater than zero.'], $this->jsonResponse($response));
}
public function testApprovedRefundRecalculationCannotDropBelowCompletedPayouts(): void
{
$controller = $this->controllerWithPost([]);
$property = (new \ReflectionClass(RefundController::class))->getProperty('refundEligibilityService');
$property->setAccessible(true);
$property->setValue($controller, new RefundEligibilityForControllerHarness(2500));
$method = (new \ReflectionClass(RefundController::class))->getMethod('buildRefundRecalculationUpdate');
$method->setAccessible(true);
$update = $method->invoke($controller, [
'id' => 55,
'status' => 'Approved',
], 10.00);
$this->assertEqualsWithDelta(25.0, (float)$update['refund_amount'], 0.0001);
$this->assertSame(2500, $update['approved_amount_cents']);
$this->assertSame('requires_review', $update['reconciliation_status']);
$this->assertStringContainsString('Completed payouts', $update['reconciliation_reason']);
}
private function controllerWithPost(array $post): TestableRefundController
{
return (new TestableRefundController())
->setRequestObject(new RefundRequestStub($post))
->setResponseObject(service('response'));
}
private function jsonResponse($response): array
{
return json_decode($response->getBody(), true, 512, JSON_THROW_ON_ERROR);
}
}
@@ -68,11 +68,28 @@ class FinancialAttachmentServiceTest extends CIUnitTestCase
$file->expects($this->once())
->method('move')
->with($this->stringContains('writable/uploads/payments'), 'proof.pdf');
->with($this->stringContains('writable/uploads/_tmp/payments'), $this->stringContains('proof.pdf'))
->willReturnCallback(static function (string $dir, string $name): bool {
file_put_contents($dir . DIRECTORY_SEPARATOR . $name, 'pdf');
return true;
});
$this->assertSame('proof.pdf', $this->service->saveUploadedFile($file, 'payments'));
}
public function testDiscardStagedFileDeletesTemporaryUpload(): void
{
$tmpDir = $this->service->ensureSubdir('_tmp/checks');
$tmpPath = $tmpDir . DIRECTORY_SEPARATOR . 'pending-test.pdf';
file_put_contents($tmpPath, 'pdf');
$this->service->discardStagedFile([
'temporary_path' => $tmpPath,
]);
$this->assertFileDoesNotExist($tmpPath);
}
public function testResolvePathUsesBasenameAndReturnsNullForMissingFiles(): void
{
$dir = $this->service->ensureSubdir('receipts');
@@ -10,11 +10,32 @@ class InvoiceLedgerServiceHarness extends InvoiceLedgerService
{
private array $invoice;
private float $paidTotal;
private float $refundPaidTotal;
private float $tuitionTotal;
private float $eventTotal;
private float $additionalTotal;
private float $discountTotal;
private ?array $frozenTotals;
public function __construct(array $invoice, float $paidTotal = 0.0)
public function __construct(
array $invoice,
float $paidTotal = 0.0,
float $refundPaidTotal = 0.0,
float $tuitionTotal = 0.0,
float $eventTotal = 0.0,
float $additionalTotal = 0.0,
float $discountTotal = 0.0,
?array $frozenTotals = null
)
{
$this->invoice = $invoice;
$this->paidTotal = $paidTotal;
$this->refundPaidTotal = $refundPaidTotal;
$this->tuitionTotal = $tuitionTotal;
$this->eventTotal = $eventTotal;
$this->additionalTotal = $additionalTotal;
$this->discountTotal = $discountTotal;
$this->frozenTotals = $frozenTotals;
}
protected function loadInvoice(int $invoiceId): ?array
@@ -24,22 +45,22 @@ class InvoiceLedgerServiceHarness extends InvoiceLedgerService
protected function calculateTuitionTotal(array $invoice): float
{
return 0.0;
return $this->tuitionTotal;
}
protected function calculateEventTotal(array $invoice): float
{
return 0.0;
return $this->eventTotal;
}
protected function calculateAdditionalCharges(int $invoiceId): float
{
return 0.0;
return $this->additionalTotal;
}
protected function calculateDiscounts(int $invoiceId): float
{
return 0.0;
return $this->discountTotal;
}
protected function calculateValidPayments(int $invoiceId): float
@@ -49,12 +70,275 @@ class InvoiceLedgerServiceHarness extends InvoiceLedgerService
protected function calculatePaidRefunds(int $invoiceId): float
{
return 0.0;
return $this->refundPaidTotal;
}
protected function calculateFrozenLineTotals(int $invoiceId): ?array
{
return $this->frozenTotals;
}
}
class InvoiceLedgerServiceTest extends CIUnitTestCase
{
public function testInvoiceWithNoPaymentHasFullBalanceDue(): void
{
$service = new InvoiceLedgerServiceHarness([
'id' => 20,
'invoice_number' => 'INV-2026-00020',
'total_amount' => '0.00',
'semester' => 'Fall',
], 0.0, 0.0, 100.0);
$calculation = $service->calculateInvoice(20);
$this->assertSame('100.00', $calculation['total_amount']);
$this->assertSame(10000, $calculation['totalAmountCents']);
$this->assertSame(10000, $calculation['balanceDueCents']);
$this->assertSame(0, $calculation['customerCreditCents']);
$this->assertSame(FinancialStatus::INVOICE_UNPAID, $calculation['status']);
}
public function testPartiallyPaidInvoiceHasRemainingBalance(): void
{
$service = new InvoiceLedgerServiceHarness([
'id' => 21,
'invoice_number' => 'INV-2026-00021',
'total_amount' => '0.00',
'semester' => 'Fall',
], 40.0, 0.0, 100.0);
$calculation = $service->calculateInvoice(21);
$this->assertSame('60.00', $calculation['balance']);
$this->assertSame(6000, $calculation['rawBalanceCents']);
$this->assertSame(FinancialStatus::INVOICE_PARTIALLY_PAID, $calculation['status']);
}
public function testFullyPaidInvoiceHasNoBalanceOrCredit(): void
{
$service = new InvoiceLedgerServiceHarness([
'id' => 22,
'invoice_number' => 'INV-2026-00022',
'total_amount' => '0.00',
'semester' => 'Fall',
], 100.0, 0.0, 100.0);
$calculation = $service->calculateInvoice(22);
$this->assertSame('0.00', $calculation['balance']);
$this->assertSame('0.00', $calculation['customer_credit']);
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
}
public function testOverpaidInvoiceExposesCustomerCreditOnly(): void
{
$service = new InvoiceLedgerServiceHarness([
'id' => 23,
'invoice_number' => 'INV-2026-00023',
'total_amount' => '0.00',
'semester' => 'Fall',
], 125.0, 0.0, 100.0);
$calculation = $service->calculateInvoice(23);
$this->assertSame('0.00', $calculation['balance']);
$this->assertSame('25.00', $calculation['customer_credit']);
$this->assertSame(-2500, $calculation['rawBalanceCents']);
$this->assertSame(2500, $calculation['customerCreditCents']);
}
public function testPartiallyRefundedInvoiceUsesRefundAsCashOut(): void
{
$service = new InvoiceLedgerServiceHarness([
'id' => 24,
'invoice_number' => 'INV-2026-00024',
'total_amount' => '0.00',
'semester' => 'Fall',
], 125.0, 10.0, 100.0);
$calculation = $service->calculateInvoice(24);
$this->assertSame('0.00', $calculation['balance']);
$this->assertSame('15.00', $calculation['customer_credit']);
$this->assertSame(-1500, $calculation['rawBalanceCents']);
}
public function testFullyRefundedOverpaymentClearsCustomerCredit(): void
{
$service = new InvoiceLedgerServiceHarness([
'id' => 25,
'invoice_number' => 'INV-2026-00025',
'total_amount' => '0.00',
'semester' => 'Fall',
], 125.0, 25.0, 100.0);
$calculation = $service->calculateInvoice(25);
$this->assertSame('0.00', $calculation['balance']);
$this->assertSame('0.00', $calculation['customer_credit']);
$this->assertSame(0, $calculation['rawBalanceCents']);
}
public function testPaymentAfterRefundCanRestorePaidStatus(): void
{
$service = new InvoiceLedgerServiceHarness([
'id' => 26,
'invoice_number' => 'INV-2026-00026',
'total_amount' => '0.00',
'semester' => 'Fall',
], 130.0, 30.0, 100.0);
$calculation = $service->calculateInvoice(26);
$this->assertSame('0.00', $calculation['balance']);
$this->assertSame('0.00', $calculation['customer_credit']);
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
}
public function testGeneratedTwiceWithSameSourceDataReturnsIdenticalLedger(): void
{
$invoice = [
'id' => 27,
'invoice_number' => 'INV-2026-00027',
'total_amount' => '0.00',
'semester' => 'Fall',
];
$first = (new InvoiceLedgerServiceHarness($invoice, 55.0, 5.0, 100.0, 20.0, 10.0, 15.0))->calculateInvoice(27);
$second = (new InvoiceLedgerServiceHarness($invoice, 55.0, 5.0, 100.0, 20.0, 10.0, 15.0))->calculateInvoice(27);
$this->assertSame($first, $second);
}
public function testFrozenInvoiceLinesOverrideChangedLiveSources(): void
{
$invoice = [
'id' => 28,
'invoice_number' => 'INV-2026-00028',
'total_amount' => '0.00',
'semester' => 'Fall',
];
$service = new InvoiceLedgerServiceHarness(
$invoice,
paidTotal: 0.0,
refundPaidTotal: 0.0,
tuitionTotal: 999.0,
eventTotal: 888.0,
additionalTotal: 777.0,
discountTotal: 0.0,
frozenTotals: [
'tuition_cents' => 10000,
'event_cents' => 2000,
'additional_cents' => 500,
'total_cents' => 12500,
]
);
$calculation = $service->calculateInvoice(28);
$this->assertSame('100.00', $calculation['tuition_total']);
$this->assertSame('20.00', $calculation['event_total']);
$this->assertSame('5.00', $calculation['additional_total']);
$this->assertSame('125.00', $calculation['total_amount']);
$this->assertSame('125.00', $calculation['balance']);
}
public function testFrozenInvoiceRecalculationIsStableAfterPayment(): void
{
$invoice = [
'id' => 29,
'invoice_number' => 'INV-2026-00029',
'total_amount' => '0.00',
'semester' => 'Fall',
];
$frozenTotals = [
'tuition_cents' => 10000,
'event_cents' => 0,
'additional_cents' => 0,
'total_cents' => 10000,
];
$before = (new InvoiceLedgerServiceHarness(
$invoice,
paidTotal: 0.0,
tuitionTotal: 400.0,
frozenTotals: $frozenTotals
))->calculateInvoice(29);
$after = (new InvoiceLedgerServiceHarness(
$invoice,
paidTotal: 25.0,
tuitionTotal: 600.0,
frozenTotals: $frozenTotals
))->calculateInvoice(29);
$this->assertSame('100.00', $before['total_amount']);
$this->assertSame('100.00', $after['total_amount']);
$this->assertSame('75.00', $after['balance']);
}
public function testFrozenInvoiceExposesEligibleDiscountBaseFromLines(): void
{
$invoice = [
'id' => 30,
'invoice_number' => 'INV-2026-00030',
'total_amount' => '0.00',
'semester' => 'Fall',
];
$service = new InvoiceLedgerServiceHarness(
$invoice,
discountTotal: 50.0,
frozenTotals: [
'tuition_cents' => 10000,
'event_cents' => 3000,
'additional_cents' => 0,
'total_cents' => 13000,
'discount_eligible_base_cents' => 10000,
]
);
$calculation = $service->calculateInvoice(30);
$this->assertSame(13000, $calculation['gross_charge_cents']);
$this->assertSame(10000, $calculation['discount_eligible_base_cents']);
$this->assertSame(5000, $calculation['requested_discount_cents']);
$this->assertSame(5000, $calculation['applied_discount_cents']);
$this->assertSame(8000, $calculation['net_charge_cents']);
}
public function testEventOnlyFrozenInvoiceReceivesNoDiscount(): void
{
$invoice = [
'id' => 31,
'invoice_number' => 'INV-2026-00031',
'total_amount' => '0.00',
'semester' => 'Fall',
];
$service = new InvoiceLedgerServiceHarness(
$invoice,
discountTotal: 25.0,
frozenTotals: [
'tuition_cents' => 0,
'event_cents' => 7500,
'additional_cents' => 0,
'total_cents' => 7500,
'discount_eligible_base_cents' => 0,
]
);
$calculation = $service->calculateInvoice(31);
$this->assertSame(0, $calculation['discount_eligible_base_cents']);
$this->assertSame(2500, $calculation['requested_discount_cents']);
$this->assertSame(0, $calculation['applied_discount_cents']);
$this->assertSame(7500, $calculation['net_charge_cents']);
$this->assertSame('75.00', $calculation['balance']);
}
public function testCarryForwardInvoiceUsesStoredOpeningBalance(): void
{
$service = new InvoiceLedgerServiceHarness([
@@ -88,4 +372,60 @@ class InvoiceLedgerServiceTest extends CIUnitTestCase
$this->assertSame('0.00', $calculation['balance']);
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
}
public function testCashRefundReducesCustomerCreditWithoutIncreasingOverpayment(): void
{
$service = new InvoiceLedgerServiceHarness([
'id' => 11,
'invoice_number' => 'CF-20252026-20262027-P8-I5',
'total_amount' => '100.00',
'semester' => 'Opening Balance',
'description' => 'Balance carried over from previous school year 2025-2026.',
], 125.0, 20.0);
$calculation = $service->calculateInvoice(11);
$this->assertSame('100.00', $calculation['total_amount']);
$this->assertSame('20.00', $calculation['refund_paid_total']);
$this->assertSame('5.00', $calculation['customer_credit']);
$this->assertSame('0.00', $calculation['balance']);
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
}
public function testCashRefundCanRestoreBalanceAfterOverpaymentIsReturned(): void
{
$service = new InvoiceLedgerServiceHarness([
'id' => 12,
'invoice_number' => 'CF-20252026-20262027-P8-I6',
'total_amount' => '100.00',
'semester' => 'Opening Balance',
'description' => 'Balance carried over from previous school year 2025-2026.',
], 125.0, 30.0);
$calculation = $service->calculateInvoice(12);
$this->assertSame('0.00', $calculation['customer_credit']);
$this->assertSame('5.00', $calculation['balance']);
$this->assertSame(FinancialStatus::INVOICE_PARTIALLY_PAID, $calculation['status']);
}
public function testIssuedInvoiceUsesPaidRefundsAsCashOutInsteadOfAdditionalCredit(): void
{
$service = new InvoiceLedgerServiceHarness([
'id' => 13,
'invoice_number' => 'INV-2026-00013',
'total_amount' => '0.00',
'semester' => 'Fall',
'description' => 'Current year tuition invoice.',
], 120.0, 15.0, 100.0, 0.0, 0.0, 10.0);
$calculation = $service->calculateInvoice(13);
$this->assertSame('100.00', $calculation['total_amount']);
$this->assertSame('10.00', $calculation['discount_total']);
$this->assertSame('15.00', $calculation['refund_paid_total']);
$this->assertSame('15.00', $calculation['customer_credit']);
$this->assertSame('0.00', $calculation['balance']);
$this->assertSame(FinancialStatus::INVOICE_PAID, $calculation['status']);
}
}
@@ -0,0 +1,95 @@
<?php
namespace Tests\App\Libraries;
use App\Libraries\ParentLedgerService;
use App\Libraries\RefundEligibilityResult;
use CodeIgniter\Test\CIUnitTestCase;
class ParentLedgerServiceHarness extends ParentLedgerService
{
public function __construct(
private array $invoices,
private array $ledgers,
private array $eligibilities
) {
}
protected function loadInvoices(int $parentId, string $schoolYear, ?string $semester): array
{
return array_values(array_filter($this->invoices, static function (array $invoice) use ($parentId, $schoolYear, $semester): bool {
if ((int)($invoice['parent_id'] ?? 0) !== $parentId || (string)($invoice['school_year'] ?? '') !== $schoolYear) {
return false;
}
return $semester === null || $semester === '' || (string)($invoice['semester'] ?? '') === $semester;
}));
}
protected function ledgerForInvoice(int $invoiceId): array
{
return $this->ledgers[$invoiceId] ?? [];
}
protected function eligibilityForSource(
int $parentId,
?int $invoiceId,
string $sourceType,
int $sourceId
): RefundEligibilityResult {
return $this->eligibilities[$sourceId] ?? new RefundEligibilityResult(0, 0, 0, 0, []);
}
}
class ParentLedgerServiceTest extends CIUnitTestCase
{
public function testParentProjectionAggregatesLedgerAndEligibilityFields(): void
{
$service = new ParentLedgerServiceHarness(
[
['id' => 10, 'parent_id' => 5, 'school_year' => '2026-2027', 'semester' => 'Fall', 'invoice_number' => 'INV-10'],
['id' => 11, 'parent_id' => 5, 'school_year' => '2026-2027', 'semester' => 'Fall', 'invoice_number' => 'INV-11'],
['id' => 12, 'parent_id' => 5, 'school_year' => '2026-2027', 'semester' => 'Spring', 'invoice_number' => 'INV-12'],
],
[
10 => [
'totalAmountCents' => 10000,
'discountCents' => 1000,
'paidCents' => 11000,
'completedRefundCents' => 500,
'balanceDueCents' => 0,
'customerCreditCents' => 1500,
'status' => 'paid',
],
11 => [
'totalAmountCents' => 8000,
'discountCents' => 0,
'paidCents' => 2000,
'completedRefundCents' => 0,
'balanceDueCents' => 6000,
'customerCreditCents' => 0,
'status' => 'partially_paid',
],
],
[
10 => new RefundEligibilityResult(1500, 0, 300, 1200, ['HAS_APPROVED_RESERVATIONS']),
11 => new RefundEligibilityResult(0, 0, 0, 0, ['NO_AVAILABLE_CREDIT']),
]
);
$projection = $service->getParentProjection(5, '2026-2027', 'Fall');
$this->assertSame(18000, $projection['grossChargesCents']);
$this->assertSame(1000, $projection['discountCents']);
$this->assertSame(17000, $projection['netInvoiceChargesCents']);
$this->assertSame(13000, $projection['validPaymentCents']);
$this->assertSame(500, $projection['completedRefundCents']);
$this->assertSame(6000, $projection['balanceDueCents']);
$this->assertSame(1500, $projection['customerCreditCents']);
$this->assertSame(300, $projection['approvedRefundReservationCents']);
$this->assertSame(1200, $projection['availableRefundableCreditCents']);
$this->assertSame('170.00', $projection['net_invoice_charges']);
$this->assertSame('12.00', $projection['available_refundable_credit']);
$this->assertCount(2, $projection['invoices']);
}
}
@@ -0,0 +1,95 @@
<?php
namespace Tests\App\Libraries;
use App\Libraries\RefundEligibilityService;
use CodeIgniter\Test\CIUnitTestCase;
class RefundEligibilityServiceHarness extends RefundEligibilityService
{
public function __construct(
private int $sourceCreditCents,
private int $completedPayoutCents,
private int $reservedAmountCents
) {
}
protected function calculateSourceCreditCents(int $parentId, ?int $invoiceId, string $sourceType, int $sourceId): int
{
return $this->sourceCreditCents;
}
protected function calculateCompletedPayoutCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int
{
return $this->completedPayoutCents;
}
protected function calculateReservedAmountCents(string $sourceType, int $sourceId, ?int $excludeRefundId): int
{
return $this->reservedAmountCents;
}
}
class RefundEligibilityServiceTest extends CIUnitTestCase
{
public function testInvoiceOverpaymentAvailableCreditDoesNotDoubleSubtractCompletedPayouts(): void
{
$service = new RefundEligibilityServiceHarness(10000, 2500, 1500);
$result = $service->calculateAvailableCredit(10, 20, 'invoice_overpayment', 20);
$this->assertSame(10000, $result->sourceCreditCents);
$this->assertSame(0, $result->completedPayoutCents);
$this->assertSame(1500, $result->reservedAmountCents);
$this->assertSame(8500, $result->availableAmountCents);
$this->assertNotContains('HAS_COMPLETED_PAYOUTS', $result->reasonCodes);
$this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes);
}
public function testPaymentSourceAvailableCreditSubtractsCompletedPayoutsAndReservations(): void
{
$service = new RefundEligibilityServiceHarness(10000, 2500, 1500);
$result = $service->calculateAvailableCredit(10, 20, 'payment_duplicate', 77);
$this->assertSame(10000, $result->sourceCreditCents);
$this->assertSame(2500, $result->completedPayoutCents);
$this->assertSame(1500, $result->reservedAmountCents);
$this->assertSame(6000, $result->availableAmountCents);
$this->assertContains('HAS_COMPLETED_PAYOUTS', $result->reasonCodes);
$this->assertContains('HAS_APPROVED_RESERVATIONS', $result->reasonCodes);
}
public function testValidateRejectsAmountAboveAvailableCredit(): void
{
$service = new RefundEligibilityServiceHarness(10000, 7000, 1000);
$result = $service->calculateAvailableCredit(10, 20, 'payment_duplicate', 20);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Refund amount exceeds available source credit.');
$service->validateRequestedAmount($result, 2500);
}
public function testValidateRejectsNonPositiveAmount(): void
{
$service = new RefundEligibilityServiceHarness(10000, 0, 0);
$result = $service->calculateAvailableCredit(10, 20, 'invoice_overpayment', 20);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Refund amount must be greater than zero.');
$service->validateRequestedAmount($result, 0);
}
public function testZeroAvailableCreditCarriesReasonCodes(): void
{
$service = new RefundEligibilityServiceHarness(0, 0, 0);
$result = $service->calculateAvailableCredit(10, 20, 'invoice_overpayment', 20);
$this->assertSame(0, $result->availableAmountCents);
$this->assertContains('NO_SOURCE_CREDIT', $result->reasonCodes);
$this->assertContains('NO_AVAILABLE_CREDIT', $result->reasonCodes);
}
}