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
@@ -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) {