@@ -22,6 +22,7 @@ use App\Models\StudentClassModel;
|
||||
use App\Models\StudentSectionDistributionDraftModel;
|
||||
use App\Controllers\View\EmailController;
|
||||
use App\Controllers\View\InvoiceController;
|
||||
use App\Libraries\RefundEligibilityService;
|
||||
use App\Models\StaffAttendanceModel;
|
||||
use App\Libraries\StaffTimeOffLinkService;
|
||||
use App\Models\AttendanceDayModel;
|
||||
@@ -2855,19 +2856,51 @@ class AdministratorController extends BaseController
|
||||
$existingRefund = $this->refundModel->where('invoice_id', $invoice['id'])->first();
|
||||
|
||||
if ($existingRefund) {
|
||||
$this->refundModel->update($existingRefund['id'], [
|
||||
'refund_amount' => $refundAmount,
|
||||
'status' => 'Pending',
|
||||
$refundId = (int)$existingRefund['id'];
|
||||
$status = strtolower((string)($existingRefund['status'] ?? ''));
|
||||
$isApprovedState = in_array($status, ['approved', 'partial', 'paid', 'partially_paid'], true);
|
||||
$calculatedCents = max(0, (int)round($refundAmount * 100));
|
||||
$paidCents = (new RefundEligibilityService())->getCompletedPayoutTotalCentsForRefund($refundId);
|
||||
$targetCents = $isApprovedState ? max($calculatedCents, $paidCents) : $calculatedCents;
|
||||
$update = [
|
||||
'refund_amount' => $targetCents / 100,
|
||||
'updated_by' => session()->get('user_id') ?? null,
|
||||
]);
|
||||
];
|
||||
if ($isApprovedState) {
|
||||
$update['approved_amount_cents'] = $targetCents;
|
||||
} else {
|
||||
$update['status'] = 'Pending';
|
||||
$update['requested_amount_cents'] = $targetCents;
|
||||
}
|
||||
if ($isApprovedState && $paidCents > $calculatedCents) {
|
||||
$message = sprintf(
|
||||
'Completed payouts (%0.2f) exceed recalculated refundable credit (%0.2f).',
|
||||
$paidCents / 100,
|
||||
$calculatedCents / 100
|
||||
);
|
||||
$update['reconciliation_status'] = 'requires_review';
|
||||
$update['reconciliation_reason'] = $message;
|
||||
$update['reconciliation_required_at'] = utc_now();
|
||||
log_message('critical', 'Refund reconciliation required for refund #' . $refundId . ': ' . $message);
|
||||
} else {
|
||||
$update['reconciliation_status'] = null;
|
||||
$update['reconciliation_reason'] = null;
|
||||
$update['reconciliation_required_at'] = null;
|
||||
}
|
||||
$this->refundModel->update($refundId, $update);
|
||||
} else {
|
||||
$this->refundModel->insert([
|
||||
'parent_id' => $pid,
|
||||
'school_year' => $invoice['school_year'],
|
||||
'invoice_id' => $invoice['id'],
|
||||
'refund_amount' => $refundAmount,
|
||||
'requested_amount_cents' => (int)round($refundAmount * 100),
|
||||
'approved_amount_cents' => null,
|
||||
'currency' => 'USD',
|
||||
'refund_paid_amount' => 0.0,
|
||||
'status' => 'Pending',
|
||||
'source_type' => 'invoice_overpayment',
|
||||
'source_id' => (int)$invoice['id'],
|
||||
'requested_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id') ?? null,
|
||||
]);
|
||||
|
||||
@@ -108,9 +108,41 @@ class DiscountController extends BaseController
|
||||
// Collect invoice IDs that end up fully covered by the voucher in this run
|
||||
$fullyCoveredInvoiceIds = [];
|
||||
$touchedInvoiceIds = [];
|
||||
$pendingEvents = [];
|
||||
$appliedCount = 0;
|
||||
|
||||
$this->db->transStart();
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
$lockedVoucher = $this->db->query('SELECT * FROM discount_vouchers WHERE id = ? FOR UPDATE', [(int)$voucherId])->getRowArray();
|
||||
if (!$lockedVoucher) {
|
||||
throw new \RuntimeException('Voucher not found.');
|
||||
}
|
||||
$today = date('Y-m-d');
|
||||
if ((int)($lockedVoucher['is_active'] ?? 0) !== 1) {
|
||||
throw new \RuntimeException('Voucher is inactive.');
|
||||
}
|
||||
if (!empty($lockedVoucher['valid_from']) && (string)$lockedVoucher['valid_from'] > $today) {
|
||||
throw new \RuntimeException('Voucher is not active yet.');
|
||||
}
|
||||
if (!empty($lockedVoucher['valid_until']) && (string)$lockedVoucher['valid_until'] < $today) {
|
||||
throw new \RuntimeException('Voucher is expired.');
|
||||
}
|
||||
if (!empty($lockedVoucher['school_year']) && (string)$lockedVoucher['school_year'] !== (string)$this->schoolYear) {
|
||||
throw new \RuntimeException('Voucher is not valid for this school year.');
|
||||
}
|
||||
if (!empty($lockedVoucher['semester']) && (string)$lockedVoucher['semester'] !== (string)$this->semester) {
|
||||
throw new \RuntimeException('Voucher is not valid for this semester.');
|
||||
}
|
||||
|
||||
$voucher = $lockedVoucher;
|
||||
$maxUsesRaw = $voucher['max_uses'] ?? null;
|
||||
$maxUses = ($maxUsesRaw === null || $maxUsesRaw === '') ? null : (int) $maxUsesRaw;
|
||||
$timesUsed = (int) ($voucher['times_used'] ?? 0);
|
||||
$remainingUses = ($maxUses === null) ? PHP_INT_MAX : ($maxUses - $timesUsed);
|
||||
if ($remainingUses <= 0) {
|
||||
throw new \RuntimeException('This voucher has reached its maximum allowed uses.');
|
||||
}
|
||||
|
||||
foreach ($parentIds as $parentId) {
|
||||
// Fetch invoices for this parent & school year
|
||||
@@ -125,10 +157,17 @@ class DiscountController extends BaseController
|
||||
if ($remainingUses <= 0) break 2; // out of parentIds loop too
|
||||
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $invoice['id']]);
|
||||
$this->db->query('SELECT id FROM discount_usages WHERE invoice_id = ? FOR UPDATE', [(int)$invoice['id']])->getResultArray();
|
||||
|
||||
// Snapshot current balance BEFORE applying
|
||||
$initialPreBalance = (float) $this->getCurrentInvoiceBalance($invoice['id'], $this->schoolYear);
|
||||
if ($initialPreBalance <= 0) {
|
||||
$ledgerBefore = $this->invoiceLedgerService->calculateInvoice((int)$invoice['id']);
|
||||
$initialPreBalance = (float)($ledgerBefore['balance'] ?? 0);
|
||||
$eligibleBaseCents = max(
|
||||
0,
|
||||
(int)($ledgerBefore['discount_eligible_base_cents'] ?? 0)
|
||||
- (int)($ledgerBefore['applied_discount_cents'] ?? 0)
|
||||
);
|
||||
if ($eligibleBaseCents <= 0) {
|
||||
log_message(
|
||||
'error',
|
||||
'applyVoucher skip: zero balance | voucher_id={vid} parent_id={pid} invoice_id={iid} invoice_number={inum} balance={bal}',
|
||||
@@ -168,11 +207,14 @@ class DiscountController extends BaseController
|
||||
|
||||
// Calculate discount
|
||||
$rawDiscount = ($voucher['discount_type'] === 'percent')
|
||||
? round(((float)$invoice['total_amount'] * (float)$voucher['discount_value']) / 100, 2)
|
||||
? round(($eligibleBaseCents / 100 * (float)$voucher['discount_value']) / 100, 2)
|
||||
: (float) $voucher['discount_value'];
|
||||
|
||||
// Cap by CURRENT invoice balance snapshot
|
||||
$discount = min($rawDiscount, $initialPreBalance);
|
||||
$requestedDiscountCents = max(0, (int)round($rawDiscount * 100));
|
||||
$appliedDiscountCents = min($requestedDiscountCents, $eligibleBaseCents);
|
||||
$discount = $appliedDiscountCents / 100;
|
||||
|
||||
// Nothing to do if no discount
|
||||
if ($discount <= 0) {
|
||||
@@ -193,7 +235,7 @@ class DiscountController extends BaseController
|
||||
|
||||
// Insert discount usage
|
||||
$now = utc_now();
|
||||
$this->db->table('discount_usages')->insert([
|
||||
$usagePayload = [
|
||||
'voucher_id' => $voucherId,
|
||||
'invoice_id' => $invoice['id'],
|
||||
'parent_id' => $parentId,
|
||||
@@ -204,17 +246,30 @@ class DiscountController extends BaseController
|
||||
'used_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
];
|
||||
if ($this->db->fieldExists('requested_discount_cents', 'discount_usages')) {
|
||||
$usagePayload['requested_discount_cents'] = $requestedDiscountCents;
|
||||
$usagePayload['eligible_base_cents'] = $eligibleBaseCents;
|
||||
$usagePayload['eligible_base_before_cents'] = $eligibleBaseCents;
|
||||
$usagePayload['applied_discount_cents'] = $appliedDiscountCents;
|
||||
$usagePayload['application_order'] = $this->nextDiscountApplicationOrder((int)$invoice['id']);
|
||||
}
|
||||
if (!$this->db->table('discount_usages')->insert($usagePayload)) {
|
||||
throw new \RuntimeException('Discount usage could not be recorded.');
|
||||
}
|
||||
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice((int) $invoice['id']);
|
||||
$postBalance = (float) ($ledger['balance'] ?? 0.0);
|
||||
$currentBalance = $postBalance;
|
||||
|
||||
// Increment voucher usage
|
||||
$this->db->table('discount_vouchers')
|
||||
$updatedVoucher = $this->db->table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->set('times_used', 'COALESCE(times_used,0) + 1', false)
|
||||
->update();
|
||||
if (!$updatedVoucher) {
|
||||
throw new \RuntimeException('Voucher usage could not be updated.');
|
||||
}
|
||||
|
||||
// Prepare and trigger payment event
|
||||
[$eventData, $studentData] = $this->buildPaymentEventData(
|
||||
@@ -228,7 +283,7 @@ class DiscountController extends BaseController
|
||||
$initialPreBalance, // pre-payment snapshot
|
||||
$postBalance // computed post-payment
|
||||
);
|
||||
Events::trigger('paymentReceived', $eventData, $studentData);
|
||||
$pendingEvents[] = [$eventData, $studentData];
|
||||
|
||||
$touchedInvoiceIds[] = (int) $invoice['id'];
|
||||
$appliedCount++;
|
||||
@@ -248,20 +303,27 @@ class DiscountController extends BaseController
|
||||
|
||||
// Deactivate if we just hit the cap
|
||||
if ($remainingUses <= 0 && $maxUses !== null) {
|
||||
$this->db->table('discount_vouchers')
|
||||
$deactivated = $this->db->table('discount_vouchers')
|
||||
->where('id', $voucherId)
|
||||
->update([
|
||||
'is_active' => 0,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
if (!$deactivated) {
|
||||
throw new \RuntimeException('Voucher could not be deactivated.');
|
||||
}
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if ($this->db->transStatus() === false) {
|
||||
if ($this->db->transStatus() === false) {
|
||||
throw new \RuntimeException('Voucher transaction failed.');
|
||||
}
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'Voucher application failed: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
|
||||
return redirect()->back()->with('error', 'Voucher application failed. Transaction rolled back.');
|
||||
}
|
||||
|
||||
@@ -287,6 +349,10 @@ class DiscountController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($pendingEvents as [$eventData, $studentData]) {
|
||||
Events::trigger('paymentReceived', $eventData, $studentData);
|
||||
}
|
||||
|
||||
// ✅ AFTER COMMIT: recalculate invoice totals/balance/paid/discount/refund
|
||||
foreach (array_unique($touchedInvoiceIds) as $iid) {
|
||||
try {
|
||||
@@ -632,6 +698,17 @@ class DiscountController extends BaseController
|
||||
return 999;
|
||||
}
|
||||
|
||||
private function nextDiscountApplicationOrder(int $invoiceId): int
|
||||
{
|
||||
$row = $this->db->table('discount_usages')
|
||||
->select('COALESCE(MAX(application_order),0) + 1 AS next_order', false)
|
||||
->where('invoice_id', $invoiceId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return max(1, (int)($row['next_order'] ?? 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect parent/invoice/payment data to trigger handlePaymentReceived().
|
||||
*
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Models\ParentModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\CalendarModel;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use App\Services\EmailService;
|
||||
use Config\Database;
|
||||
use App\Controllers\View\InvoiceController;
|
||||
@@ -43,6 +44,7 @@ class EventController extends ResourceController
|
||||
protected $semester;
|
||||
protected $categories;
|
||||
protected $enrollmentModel;
|
||||
protected $invoiceLedgerService;
|
||||
private ?bool $eventChargesHasCreatedBy = null;
|
||||
private ?bool $eventChargesHasWaiverSigned = null;
|
||||
|
||||
@@ -62,6 +64,7 @@ class EventController extends ResourceController
|
||||
$this->enrollmentModel = new EnrollmentModel();
|
||||
$this->parentModel = new ParentModel();
|
||||
$this->emailService = new EmailService();
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
|
||||
$this->schoolYear = $this->currentSchoolYearName();
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
@@ -1050,77 +1053,11 @@ class EventController extends ResourceController
|
||||
return;
|
||||
}
|
||||
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return;
|
||||
}
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
|
||||
$paidSum = 0.0;
|
||||
try {
|
||||
$qb = $db->table('payments')
|
||||
->select('COALESCE(SUM(paid_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('paid_amount >', 0);
|
||||
|
||||
if ($db->fieldExists('status', 'payments')) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', $exclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($db->fieldExists('is_void', 'payments')) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$row = $qb->get()->getRowArray();
|
||||
$paidSum = (float)($row['tot'] ?? 0.0);
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to sum payments for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
log_message('error', 'Failed to recalculate invoice ledger for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$discountSum = 0.0;
|
||||
try {
|
||||
$row = $db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->get()
|
||||
->getRowArray();
|
||||
$discountSum = (float)($row['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to sum discounts for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$refundSum = 0.0;
|
||||
try {
|
||||
$row = $db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->get()
|
||||
->getRowArray();
|
||||
$refundSum = (float)($row['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to sum refunds for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$total = (float)($invoice['total_amount'] ?? 0.0);
|
||||
$newBalance = round($total - $discountSum - $refundSum - $paidSum, 2);
|
||||
$newStatus = ($newBalance <= 0.00001)
|
||||
? 'Paid'
|
||||
: (($paidSum > 0) ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
$this->invoiceModel->update($invoiceId, [
|
||||
'paid_amount' => $paidSum,
|
||||
'balance' => $newBalance,
|
||||
'status' => $newStatus,
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function applyEventPaymentStatus(int $chargeId, bool $isPaid): ?array
|
||||
@@ -1171,9 +1108,7 @@ class EventController extends ResourceController
|
||||
$eventAmount,
|
||||
$paymentSchoolYear,
|
||||
$paymentSemester,
|
||||
(float)($invoice['total_amount'] ?? 0.0),
|
||||
(float)($invoice['paid_amount'] ?? 0.0),
|
||||
(float)($invoice['balance'] ?? 0.0)
|
||||
(float)($invoice['total_amount'] ?? 0.0)
|
||||
) ?? 0);
|
||||
} elseif (!$isPaid && $paymentId > 0) {
|
||||
$this->voidPayment($paymentId, 'Event charge marked unpaid.');
|
||||
@@ -1250,16 +1185,13 @@ class EventController extends ResourceController
|
||||
float $amount,
|
||||
string $schoolYear,
|
||||
?string $semester,
|
||||
float $invoiceTotal = 0.0,
|
||||
float $invoicePaid = 0.0,
|
||||
float $invoiceBalance = 0.0
|
||||
float $invoiceTotal = 0.0
|
||||
): ?int
|
||||
{
|
||||
if ($amount <= 0 || $invoiceId <= 0 || $parentId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$newBalance = max(0.0, round((float)$invoiceBalance - $amount, 2));
|
||||
$row = $this->paymentModel->db->table('payments')
|
||||
->select('COALESCE(MAX(installment_seq), 0) + 1 AS next_seq', false)
|
||||
->where('invoice_id', $invoiceId)
|
||||
@@ -1272,7 +1204,7 @@ class EventController extends ResourceController
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => $invoiceTotal > 0 ? $invoiceTotal : $amount,
|
||||
'paid_amount' => $amount,
|
||||
'balance' => $newBalance,
|
||||
'balance' => null,
|
||||
'number_of_installments' => $installmentSeq,
|
||||
'installment_seq' => $installmentSeq,
|
||||
'payment_method' => 'cash',
|
||||
@@ -1347,13 +1279,9 @@ class EventController extends ResourceController
|
||||
->findAll();
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
$status = strtolower(trim($invoice['status'] ?? ''));
|
||||
$balance = (float)($invoice['balance'] ?? 0.0);
|
||||
|
||||
if ($balance <= 0.00001 && $status !== 'paid') {
|
||||
$this->invoiceModel->update($invoice['id'], ['status' => 'Paid']);
|
||||
} elseif ($status === 'paid' && $balance > 0) {
|
||||
$this->invoiceModel->update($invoice['id'], ['status' => 'Unpaid']);
|
||||
$invoiceId = (int)($invoice['id'] ?? 0);
|
||||
if ($invoiceId > 0) {
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Controllers\View;
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\FinancialAttachmentService;
|
||||
use App\Models\ExpenseModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
@@ -12,6 +13,7 @@ class ExpenseController extends BaseController
|
||||
protected $expenseModel;
|
||||
protected $userModel;
|
||||
protected $configModel;
|
||||
protected FinancialAttachmentService $financialAttachmentService;
|
||||
protected $schoolYear;
|
||||
protected $semester;
|
||||
protected $retailors;
|
||||
@@ -21,6 +23,7 @@ class ExpenseController extends BaseController
|
||||
$this->expenseModel = new ExpenseModel();
|
||||
$this->userModel = new UserModel();
|
||||
$this->configModel = new ConfigurationModel();
|
||||
$this->financialAttachmentService = new FinancialAttachmentService();
|
||||
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
@@ -135,19 +138,19 @@ class ExpenseController extends BaseController
|
||||
// Optional extra fields
|
||||
'retailor' => 'permit_empty|max_length[255]',
|
||||
'date_of_purchase' => 'permit_empty',
|
||||
// allow JPG/JPEG/PNG/WEBP/GIF and PDF up to 2MB
|
||||
// allow JPG/JPEG/PNG and PDF up to 2MB
|
||||
'receipt' => 'uploaded[receipt]'
|
||||
. '|max_size[receipt,2048]'
|
||||
. '|ext_in[receipt,jpg,jpeg,png,webp,gif,pdf]'
|
||||
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,image/webp,image/gif,application/pdf]',
|
||||
. '|max_size[receipt,5120]'
|
||||
. '|ext_in[receipt,jpg,jpeg,png,pdf]'
|
||||
. '|mime_in[receipt,image/jpg,image/jpeg,image/png,application/pdf]',
|
||||
];
|
||||
|
||||
$messages = [
|
||||
'receipt' => [
|
||||
'uploaded' => 'Receipt file is required.',
|
||||
'max_size' => 'Maximum file size is 2MB.',
|
||||
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
||||
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, WEBP, GIF, or PDF.',
|
||||
'max_size' => 'Maximum file size is 5MB.',
|
||||
'ext_in' => 'Allowed formats: JPG, JPEG, PNG, or PDF.',
|
||||
'mime_in' => 'Allowed formats: JPG, JPEG, PNG, or PDF.',
|
||||
]
|
||||
];
|
||||
|
||||
@@ -170,24 +173,32 @@ class ExpenseController extends BaseController
|
||||
$purchasedById = (int) $purchasedById;
|
||||
|
||||
// School context
|
||||
$schoolYear = $this->schoolYear ?: date('Y');
|
||||
$schoolYear = (string)$this->schoolYear;
|
||||
if (!preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Invalid school year configuration. Expected YYYY-YYYY.');
|
||||
}
|
||||
$semester = $this->semester ?: 'Fall';
|
||||
|
||||
// Handle upload: store under writable/uploads/receipts and save only the filename
|
||||
$receiptName = null;
|
||||
$file = $this->request->getFile('receipt');
|
||||
if ($file && $file->isValid() && !$file->hasMoved()) {
|
||||
$stored = $file->store('receipts'); // -> writable/uploads/receipts/<randomname>.ext
|
||||
$receiptName = basename($stored);
|
||||
$stagedReceipt = null;
|
||||
try {
|
||||
$stagedReceipt = $this->financialAttachmentService->stageUploadedFile(
|
||||
$this->request->getFile('receipt'),
|
||||
'receipts'
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
$status = $isDonation ? 'approved' : 'pending';
|
||||
$statusReason = $isDonation ? 'Marked as Donation (non-reimbursable).' : null;
|
||||
|
||||
$this->expenseModel->insert([
|
||||
$db = \Config\Database::connect();
|
||||
$db->transBegin();
|
||||
try {
|
||||
$expenseId = (int)$this->expenseModel->insert([
|
||||
'category' => $category,
|
||||
'amount' => $amount,
|
||||
'receipt_path' => $receiptName, // filename only
|
||||
'receipt_path' => null,
|
||||
'description' => $description,
|
||||
'retailor' => ($retailor !== '') ? $retailor : null,
|
||||
'date_of_purchase' => ($datePurchase !== '') ? $datePurchase : null,
|
||||
@@ -198,7 +209,33 @@ class ExpenseController extends BaseController
|
||||
'approved_by' => $isDonation ? $userId : null,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
]);
|
||||
if ($expenseId <= 0) {
|
||||
throw new \RuntimeException('Expense insert failed.');
|
||||
}
|
||||
if ($db->transStatus() === false) {
|
||||
throw new \RuntimeException('Expense transaction failed.');
|
||||
}
|
||||
$db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
|
||||
log_message('error', 'Expense creation failed: ' . $e->getMessage());
|
||||
return redirect()->back()->withInput()->with('error', 'Expense could not be saved.');
|
||||
}
|
||||
|
||||
if ($stagedReceipt !== null) {
|
||||
try {
|
||||
$receiptName = $this->financialAttachmentService->finalizeStagedFile($stagedReceipt);
|
||||
if (!$this->expenseModel->update($expenseId, ['receipt_path' => $receiptName])) {
|
||||
throw new \RuntimeException('Expense receipt update failed.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
|
||||
log_message('critical', 'Expense receipt incomplete for expense #' . $expenseId . ': ' . $e->getMessage());
|
||||
return redirect()->to('/expenses/index')->with('error', 'Expense saved, but receipt could not be finalized. Operations must review.');
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to('/expenses/index')->with('success', 'Record added successfully!');
|
||||
}
|
||||
@@ -217,18 +254,36 @@ class ExpenseController extends BaseController
|
||||
return $this->response->setJSON(['error' => 'Invalid data']);
|
||||
}
|
||||
|
||||
$expense = $this->expenseModel->find($id);
|
||||
if (!$expense) {
|
||||
log_message('error', 'Expense not found for ID ' . $id);
|
||||
return $this->response->setJSON(['error' => 'Expense not found']);
|
||||
}
|
||||
$db = \Config\Database::connect();
|
||||
$db->transBegin();
|
||||
try {
|
||||
$expense = $db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$id])->getRowArray();
|
||||
if (!$expense) {
|
||||
throw new \RuntimeException('Expense not found');
|
||||
}
|
||||
$db->query(
|
||||
"SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE",
|
||||
[$id]
|
||||
)->getResultArray();
|
||||
if ($this->hasActiveReimbursement($expense)) {
|
||||
throw new \RuntimeException('Expense status cannot change after reimbursement without reversal.');
|
||||
}
|
||||
|
||||
$success = $this->expenseModel->update($id, [
|
||||
'status' => $status,
|
||||
'status_reason' => $reason,
|
||||
'approved_by' => $userId,
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
$success = $this->expenseModel->update($id, [
|
||||
'status' => $status === 'denied' ? 'rejected' : $status,
|
||||
'status_reason' => $reason,
|
||||
'approved_by' => $status === 'approved' ? $userId : null,
|
||||
'updated_by' => $userId
|
||||
]);
|
||||
if (!$success || $db->transStatus() === false) {
|
||||
throw new \RuntimeException('Update failed');
|
||||
}
|
||||
$db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->transRollback();
|
||||
log_message('error', 'Expense status update failed for ID ' . $id . ': ' . $e->getMessage());
|
||||
return $this->response->setJSON(['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
if (!$success) {
|
||||
log_message('error', 'Expense update failed for ID ' . $id);
|
||||
@@ -260,6 +315,10 @@ class ExpenseController extends BaseController
|
||||
throw PageNotFoundException::forPageNotFound("Expense #$id not found");
|
||||
}
|
||||
|
||||
if ($this->hasActiveReimbursement($expense)) {
|
||||
return redirect()->back()->with('error', 'Reimbursed expenses are immutable. Reverse the reimbursement and create a replacement expense.');
|
||||
}
|
||||
|
||||
// same user list you use in create()
|
||||
$users = $this->staffUsers();
|
||||
|
||||
@@ -308,14 +367,13 @@ class ExpenseController extends BaseController
|
||||
$isDonation = ($category === 'Donation');
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
// Keep old receipt unless replaced or removed
|
||||
$receiptName = $expense['receipt_path'];
|
||||
$stagedReceipt = null;
|
||||
if ($file && $file->isValid() && !$file->hasMoved() && ($file->getSize() ?? 0) > 0) {
|
||||
$stored = $file->store('receipts');
|
||||
$receiptName = basename($stored);
|
||||
}
|
||||
if ($this->request->getPost('remove_receipt') === '1') {
|
||||
$receiptName = null;
|
||||
try {
|
||||
$stagedReceipt = $this->financialAttachmentService->stageUploadedFile($file, 'receipts');
|
||||
} catch (\RuntimeException $e) {
|
||||
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
@@ -325,9 +383,11 @@ class ExpenseController extends BaseController
|
||||
'retailor' => trim((string) $this->request->getPost('retailor')) ?: null,
|
||||
'date_of_purchase' => (string) $this->request->getPost('date_of_purchase') ?: null,
|
||||
'purchased_by' => $purchasedById,
|
||||
'receipt_path' => $receiptName,
|
||||
'updated_by' => $userId,
|
||||
];
|
||||
if ($this->request->getPost('remove_receipt') === '1') {
|
||||
$updateData['receipt_path'] = null;
|
||||
}
|
||||
|
||||
if ($isDonation) {
|
||||
$updateData['status'] = 'approved';
|
||||
@@ -335,14 +395,89 @@ class ExpenseController extends BaseController
|
||||
$updateData['approved_by'] = $userId ?: null;
|
||||
$updateData['reimbursement_id'] = null;
|
||||
} elseif (($expense['category'] ?? '') === 'Donation') {
|
||||
// Moving a donation back to a reimbursable category: clear the marker.
|
||||
// Moving a donation back to a reimbursable category must re-enter approval.
|
||||
$updateData['status_reason'] = null;
|
||||
$updateData['approved_by'] = $expense['approved_by'] ?? null;
|
||||
$updateData['status'] = $expense['status'] ?? 'pending';
|
||||
$updateData['approved_by'] = null;
|
||||
$updateData['status'] = 'pending';
|
||||
}
|
||||
|
||||
$this->expenseModel->update($id, $updateData);
|
||||
$db = \Config\Database::connect();
|
||||
$db->transBegin();
|
||||
try {
|
||||
$lockedExpense = $db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$id])->getRowArray();
|
||||
if (!$lockedExpense) {
|
||||
throw new \RuntimeException('Expense not found.');
|
||||
}
|
||||
$activeReimbursements = $db->query(
|
||||
"SELECT id FROM reimbursements WHERE expense_id = ? AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided') FOR UPDATE",
|
||||
[$id]
|
||||
)->getResultArray();
|
||||
if ($activeReimbursements !== []) {
|
||||
$protectedChanged = (
|
||||
(float)$lockedExpense['amount'] !== (float)$updateData['amount']
|
||||
|| (string)$lockedExpense['category'] !== (string)$updateData['category']
|
||||
|| (int)$lockedExpense['purchased_by'] !== (int)$updateData['purchased_by']
|
||||
|| array_key_exists('receipt_path', $updateData)
|
||||
);
|
||||
if ($protectedChanged) {
|
||||
throw new \RuntimeException('Reimbursed expenses are immutable. Reverse the reimbursement and create a replacement expense.');
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->expenseModel->update($id, $updateData) || $db->transStatus() === false) {
|
||||
throw new \RuntimeException('Expense update failed.');
|
||||
}
|
||||
$db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
|
||||
return redirect()->back()->withInput()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
if ($stagedReceipt !== null) {
|
||||
try {
|
||||
$receiptName = $this->financialAttachmentService->finalizeStagedFile($stagedReceipt);
|
||||
if (!$this->expenseModel->update($id, ['receipt_path' => $receiptName])) {
|
||||
throw new \RuntimeException('Expense receipt update failed.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->financialAttachmentService->discardStagedFile($stagedReceipt);
|
||||
log_message('critical', 'Expense receipt replacement incomplete for expense #' . $id . ': ' . $e->getMessage());
|
||||
return redirect()->to('/expenses/index')->with('error', 'Expense updated, but receipt could not be finalized. Operations must review.');
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->to('/expenses/index')->with('success', 'Expense updated.');
|
||||
}
|
||||
|
||||
private function hasActiveReimbursement(array $expense): bool
|
||||
{
|
||||
$expenseId = (int) ($expense['id'] ?? 0);
|
||||
if ($expenseId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!empty($expense['reimbursement_id'])) {
|
||||
$row = \Config\Database::connect()
|
||||
->table('reimbursements')
|
||||
->select('id')
|
||||
->where('id', (int) $expense['reimbursement_id'])
|
||||
->where("LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')", null, false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if ($row) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$row = \Config\Database::connect()
|
||||
->table('reimbursements')
|
||||
->select('id')
|
||||
->where('expense_id', $expenseId)
|
||||
->where("LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')", null, false)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
return $row !== null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Controllers\View;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Libraries\InvoiceAdjustmentService;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use App\Models\AdditionalChargeModel;
|
||||
use CodeIgniter\Controller;
|
||||
@@ -31,6 +32,7 @@ class ExtraChargesController extends BaseController
|
||||
protected $enableAttendance;
|
||||
protected $attendanceDayModel;
|
||||
protected $invoiceLedgerService;
|
||||
protected InvoiceAdjustmentService $invoiceAdjustmentService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -43,6 +45,7 @@ class ExtraChargesController extends BaseController
|
||||
$this->semester = $this->configModel->getConfig('semester');
|
||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
$this->invoiceAdjustmentService = new InvoiceAdjustmentService($this->db);
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -216,16 +219,19 @@ class ExtraChargesController extends BaseController
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
if (($row['status'] ?? '') === FinancialStatus::ADDITIONAL_CHARGE_APPLIED) {
|
||||
$message = 'Applied charges are immutable. Void and create an adjustment instead.';
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $message, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->with('error', $message);
|
||||
}
|
||||
|
||||
$data = $this->request->getPost();
|
||||
$newAmount = isset($data['amount']) ? (float)$data['amount'] : (float)$row['amount'];
|
||||
$delta = $newAmount - (float)$row['amount'];
|
||||
$newAmount = isset($data['amount']) ? round(abs((float)$data['amount']), 2) : round(abs((float)$row['amount']), 2);
|
||||
|
||||
$db = \Config\Database::connect();
|
||||
$db->transStart();
|
||||
|
||||
// Update the charge first
|
||||
$this->additionalChargeModel->update($id, [
|
||||
$updated = $this->additionalChargeModel->update($id, [
|
||||
'title' => trim($data['title'] ?? $row['title']),
|
||||
'description' => trim($data['description'] ?? $row['description']),
|
||||
'amount' => $newAmount,
|
||||
@@ -233,6 +239,11 @@ class ExtraChargesController extends BaseController
|
||||
'charge_type' => $data['charge_type'] ?? $row['charge_type'],
|
||||
// keep status as-is
|
||||
]);
|
||||
if (!$updated) {
|
||||
$db->transRollback();
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to update charge', 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->with('error', 'Failed to update charge.');
|
||||
}
|
||||
|
||||
if (($row['status'] ?? '') === FinancialStatus::ADDITIONAL_CHARGE_APPLIED && !empty($row['invoice_id'])) {
|
||||
$db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [(int) $row['invoice_id']]);
|
||||
@@ -314,22 +325,30 @@ class ExtraChargesController extends BaseController
|
||||
}
|
||||
|
||||
$invoiceId = !empty($data['invoice_id']) ? (int)$data['invoice_id'] : null;
|
||||
$invoice = null;
|
||||
if ($invoiceId !== null) {
|
||||
$invoice = $this->invoiceModel->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
$msg = 'Invoice not found for charge.';
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $msg, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->withInput()->with('error', $msg);
|
||||
}
|
||||
}
|
||||
$chargeType = (string)$data['charge_type'];
|
||||
|
||||
$amountAbs = round(abs((float)$data['amount']), 2);
|
||||
$signedAmount = ($chargeType === 'add') ? $amountAbs : -$amountAbs;
|
||||
|
||||
$payload = [
|
||||
'parent_id' => (int)$data['parent_id'], // ← users.id of the parent
|
||||
'parent_id' => $invoice ? (int)$invoice['parent_id'] : (int)$data['parent_id'],
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => (string)$this->semester,
|
||||
'school_year' => $invoice ? (string)$invoice['school_year'] : $schoolYear,
|
||||
'semester' => $invoice ? (string)($invoice['semester'] ?? $this->semester) : (string)$this->semester,
|
||||
'charge_type' => $chargeType,
|
||||
'title' => trim($data['title']),
|
||||
'description' => trim($data['description'] ?? ''),
|
||||
'amount' => $signedAmount,
|
||||
'amount' => $amountAbs,
|
||||
'due_date' => !empty($data['due_date']) ? $data['due_date'] : null,
|
||||
'status' => $invoiceId ? FinancialStatus::ADDITIONAL_CHARGE_APPLIED : FinancialStatus::ADDITIONAL_CHARGE_PENDING,
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_PENDING,
|
||||
'created_by' => (int)(session()->get('user_id') ?? 0),
|
||||
'created_at' => \CodeIgniter\I18n\Time::now('UTC')->toDateTimeString(), // store UTC
|
||||
];
|
||||
@@ -337,22 +356,22 @@ class ExtraChargesController extends BaseController
|
||||
$this->db->transStart();
|
||||
|
||||
// BEFORE
|
||||
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
|
||||
$invoiceBefore = $this->invoiceModel->getInvoicesByParentId($payload['parent_id'], $payload['school_year']);
|
||||
|
||||
// Insert charge
|
||||
$this->additionalChargeModel->insert($payload);
|
||||
$chargeId = (int)$this->additionalChargeModel->getInsertID();
|
||||
|
||||
if ($invoiceId) {
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
$chargeId = (int)$this->additionalChargeModel->insert($payload);
|
||||
if ($chargeId <= 0) {
|
||||
$this->db->transRollback();
|
||||
$msg = 'Failed to save charge.';
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $msg, 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash()]);
|
||||
return redirect()->back()->withInput()->with('error', $msg);
|
||||
}
|
||||
|
||||
// AFTER
|
||||
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($data['parent_id'], $schoolYear);
|
||||
$invoiceAfter = $this->invoiceModel->getInvoicesByParentId($payload['parent_id'], $payload['school_year']);
|
||||
|
||||
// Parent USER (not parent table)
|
||||
$parentUser = $this->userModel->getUserInfoById($data['parent_id']);
|
||||
$parentUser = $this->userModel->getUserInfoById($payload['parent_id']);
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
@@ -399,7 +418,7 @@ class ExtraChargesController extends BaseController
|
||||
'charge_title' => $payload['title'],
|
||||
'charge_desc' => $payload['description'],
|
||||
'charge_type' => $payload['charge_type'], // add|deduct
|
||||
'amount_signed' => $signedAmount,
|
||||
'amount_signed' => $chargeType === 'add' ? $amountAbs : -$amountAbs,
|
||||
'amount_abs' => $amountAbs,
|
||||
'due_date' => $payload['due_date'],
|
||||
'created_at' => $payload['created_at'],
|
||||
@@ -422,7 +441,7 @@ class ExtraChargesController extends BaseController
|
||||
'ok' => true,
|
||||
'id' => $chargeId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'parent_id' => (int)$data['parent_id'],
|
||||
'parent_id' => $payload['parent_id'],
|
||||
'csrf_token' => csrf_token(),
|
||||
'csrf_hash' => csrf_hash(),
|
||||
]);
|
||||
@@ -430,6 +449,61 @@ class ExtraChargesController extends BaseController
|
||||
return redirect()->to(site_url('admin/charges'))->with('status', 'Charge recorded.');
|
||||
}
|
||||
|
||||
public function approve($id)
|
||||
{
|
||||
$row = $this->additionalChargeModel->find((int)$id);
|
||||
if (!$row) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
if (($row['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_PENDING) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Only pending charges can be approved']);
|
||||
return redirect()->back()->with('error', 'Only pending charges can be approved.');
|
||||
}
|
||||
|
||||
if (!$this->additionalChargeModel->update((int)$id, ['status' => FinancialStatus::ADDITIONAL_CHARGE_APPROVED])) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to approve charge']);
|
||||
return redirect()->back()->with('error', 'Failed to approve charge.');
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
return redirect()->back()->with('status', 'Charge approved.');
|
||||
}
|
||||
|
||||
public function apply($id)
|
||||
{
|
||||
$row = $this->additionalChargeModel->find((int)$id);
|
||||
if (!$row) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge not found']);
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
if ((string)($row['status'] ?? '') !== FinancialStatus::ADDITIONAL_CHARGE_APPROVED) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Only approved charges can be applied']);
|
||||
return redirect()->back()->with('error', 'Only approved charges can be applied.');
|
||||
}
|
||||
|
||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
||||
if ($invoiceId <= 0) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Charge must reference an invoice before application']);
|
||||
return redirect()->back()->with('error', 'Charge must reference an invoice before application.');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->invoiceAdjustmentService->applyAdditionalCharge(
|
||||
(int)$id,
|
||||
$invoiceId,
|
||||
(int)(session()->get('user_id') ?? 0)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Additional charge apply failed: ' . $e->getMessage());
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
return redirect()->back()->with('status', 'Charge applied.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a charge as void and roll back its impact on the invoice if applied.
|
||||
*/
|
||||
@@ -441,27 +515,36 @@ class ExtraChargesController extends BaseController
|
||||
return redirect()->back()->with('error', 'Charge not found.');
|
||||
}
|
||||
|
||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
||||
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
|
||||
$chargeType = (string)($row['charge_type'] ?? 'add');
|
||||
$status = (string)($row['status'] ?? 'pending');
|
||||
$reason = trim((string)($this->request->getPost('reason') ?? 'Voided by staff'));
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
$this->additionalChargeModel->update((int)$id, [
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_VOIDED,
|
||||
]);
|
||||
|
||||
if ($status === FinancialStatus::ADDITIONAL_CHARGE_APPLIED && $invoiceId > 0) {
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to void charge']);
|
||||
return redirect()->back()->with('error', 'Failed to void charge.');
|
||||
try {
|
||||
if ($status === FinancialStatus::ADDITIONAL_CHARGE_APPLIED) {
|
||||
$this->invoiceAdjustmentService->reverseAdditionalCharge(
|
||||
(int)$id,
|
||||
$reason,
|
||||
(int)(session()->get('user_id') ?? 0)
|
||||
);
|
||||
} else {
|
||||
$this->db->transBegin();
|
||||
$this->additionalChargeModel->update((int)$id, [
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_VOIDED,
|
||||
'voided_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||
'voided_at' => utc_now(),
|
||||
'void_reason' => $reason,
|
||||
]);
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('Failed to void charge.');
|
||||
}
|
||||
$this->db->transCommit();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->db->transStatus() === false) {
|
||||
$this->db->transRollback();
|
||||
}
|
||||
log_message('error', 'Additional charge void failed: ' . $e->getMessage());
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
@@ -469,7 +552,7 @@ class ExtraChargesController extends BaseController
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse a previously applied charge: undo invoice impact and return to pending state.
|
||||
* Reverse a previously applied charge with an immutable reversing invoice line.
|
||||
*/
|
||||
public function reverse($id)
|
||||
{
|
||||
@@ -480,33 +563,28 @@ class ExtraChargesController extends BaseController
|
||||
}
|
||||
|
||||
$invoiceId = (int)($row['invoice_id'] ?? 0);
|
||||
$amountAbs = round(abs((float)($row['amount'] ?? 0)), 2);
|
||||
$chargeType = (string)($row['charge_type'] ?? 'add');
|
||||
$status = (string)($row['status'] ?? 'pending');
|
||||
|
||||
if ($status !== FinancialStatus::ADDITIONAL_CHARGE_APPLIED || $invoiceId <= 0 || $amountAbs <= 0) {
|
||||
if ($status !== FinancialStatus::ADDITIONAL_CHARGE_APPLIED || $invoiceId <= 0) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Nothing to reverse']);
|
||||
return redirect()->back()->with('error', 'Nothing to reverse.');
|
||||
}
|
||||
|
||||
$this->db->transStart();
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId]);
|
||||
|
||||
$this->additionalChargeModel->update((int)$id, [
|
||||
'status' => FinancialStatus::ADDITIONAL_CHARGE_PENDING,
|
||||
'invoice_id' => null,
|
||||
]);
|
||||
$this->invoiceLedgerService->recalculateInvoice($invoiceId);
|
||||
|
||||
$this->db->transComplete();
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => 'Failed to reverse charge']);
|
||||
return redirect()->back()->with('error', 'Failed to reverse charge.');
|
||||
$reason = trim((string)($this->request->getPost('reason') ?? 'Reversed by staff'));
|
||||
try {
|
||||
$this->invoiceAdjustmentService->reverseAdditionalCharge(
|
||||
(int)$id,
|
||||
$reason,
|
||||
(int)(session()->get('user_id') ?? 0)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Additional charge reverse failed: ' . $e->getMessage());
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => false, 'error' => $e->getMessage()]);
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
if ($this->wantsJson()) return $this->response->setJSON(['ok' => true]);
|
||||
return redirect()->back()->with('status', 'Charge reversed to pending.');
|
||||
return redirect()->back()->with('status', 'Charge reversed.');
|
||||
}
|
||||
|
||||
/** JSON: list charges for the current term (with optional filters). */
|
||||
|
||||
@@ -9,12 +9,55 @@ use App\Models\RefundModel;
|
||||
use App\Models\ExpenseModel;
|
||||
use App\Models\ReimbursementModel;
|
||||
use App\Models\UserModel;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
|
||||
require_once APPPATH . 'ThirdParty/fpdf/fpdf.php';
|
||||
use FPDF;
|
||||
|
||||
class FinancialController extends BaseController
|
||||
{
|
||||
private ?InvoiceLedgerService $invoiceLedgerService = null;
|
||||
|
||||
private function invoiceLedger(): InvoiceLedgerService
|
||||
{
|
||||
if ($this->invoiceLedgerService === null) {
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
}
|
||||
|
||||
return $this->invoiceLedgerService;
|
||||
}
|
||||
|
||||
private function ledgerProjectionForInvoice(int $invoiceId): array
|
||||
{
|
||||
try {
|
||||
return $this->invoiceLedger()->calculateInvoice($invoiceId);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Invoice ledger projection failed for invoice ' . $invoiceId . ': ' . $e->getMessage());
|
||||
return [
|
||||
'total_amount' => '0.00',
|
||||
'paid_amount' => '0.00',
|
||||
'discount_total' => '0.00',
|
||||
'refund_paid_total' => '0.00',
|
||||
'balance' => '0.00',
|
||||
'customer_credit' => '0.00',
|
||||
'status' => FinancialStatus::INVOICE_UNPAID,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function ledgerProjectionMap(array $invoiceIds): array
|
||||
{
|
||||
$map = [];
|
||||
foreach (array_values(array_unique(array_map('intval', $invoiceIds))) as $invoiceId) {
|
||||
if ($invoiceId > 0) {
|
||||
$map[$invoiceId] = $this->ledgerProjectionForInvoice($invoiceId);
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function wantsJson(): bool
|
||||
{
|
||||
$accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? ''));
|
||||
@@ -157,6 +200,7 @@ public function financialReport()
|
||||
$id = (int)($inv['id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
}, $invoices))));
|
||||
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIds);
|
||||
|
||||
// Helper to build a fresh, filtered PaymentModel each time (so filters don't get lost between queries)
|
||||
$buildPaymentModel = function () use ($schoolYear, $dateFrom, $dateTo) {
|
||||
@@ -195,18 +239,14 @@ public function financialReport()
|
||||
return $qb;
|
||||
};
|
||||
|
||||
// === Payments aggregated by invoice_id (for the "Paid" column) ===
|
||||
$paymentsQuery = $applyPaymentFilters($buildPaymentModel());
|
||||
if (!empty($invoiceIds)) {
|
||||
$paymentsQuery->whereIn('invoice_id', $invoiceIds);
|
||||
} else {
|
||||
$paymentsQuery->where('invoice_id', -1);
|
||||
// === Ledger-derived invoice totals (for Paid/Refund/Discount/Balance/Status columns) ===
|
||||
$payments = [];
|
||||
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||
$payments[] = [
|
||||
'invoice_id' => $invoiceId,
|
||||
'paid_amount' => (float) ($ledger['paid_amount'] ?? 0),
|
||||
];
|
||||
}
|
||||
$payments = $paymentsQuery
|
||||
->select('invoice_id, SUM(paid_amount) AS paid_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy('invoice_id')
|
||||
->findAll();
|
||||
|
||||
// === Per-invoice breakdown by normalized method (cash/credit/check) ===
|
||||
// Normalization rules:
|
||||
@@ -260,21 +300,44 @@ public function financialReport()
|
||||
'total_credit' => (float)($paymentTotalsRow['total_credit'] ?? 0),
|
||||
];
|
||||
|
||||
// === Refunds (grouped) ===
|
||||
$refunds = $refundModel
|
||||
->select('invoice_id, school_year, SUM(refund_paid_amount) AS total_refunded')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->groupBy(['invoice_id', 'school_year'])
|
||||
->findAll();
|
||||
// === Ledger-derived refunds and discounts ===
|
||||
$refunds = [];
|
||||
$discounts = [];
|
||||
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||
$invoiceSchoolYear = '';
|
||||
foreach ($invoices as $invoiceRow) {
|
||||
if ((int)($invoiceRow['id'] ?? 0) === (int)$invoiceId) {
|
||||
$invoiceSchoolYear = (string)($invoiceRow['school_year'] ?? '');
|
||||
break;
|
||||
}
|
||||
}
|
||||
$refunds[] = [
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $invoiceSchoolYear,
|
||||
'total_refunded' => (float) ($ledger['refund_paid_total'] ?? 0),
|
||||
];
|
||||
$discounts[] = [
|
||||
'invoice_id' => $invoiceId,
|
||||
'school_year' => $invoiceSchoolYear,
|
||||
'discount_amount' => (float) ($ledger['discount_total'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
// === Discounts (grouped) ===
|
||||
$discounts = $discountModel
|
||||
->select('invoice_id, school_year, SUM(discount_amount) AS discount_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy(['invoice_id', 'school_year'])
|
||||
->get()
|
||||
->getResultArray();
|
||||
foreach ($invoices as &$invoiceRow) {
|
||||
$invoiceId = (int)($invoiceRow['id'] ?? 0);
|
||||
$ledger = $ledgerByInvoice[$invoiceId] ?? null;
|
||||
if ($ledger === null) {
|
||||
continue;
|
||||
}
|
||||
$invoiceRow['total_amount'] = (float)($ledger['total_amount'] ?? 0);
|
||||
$invoiceRow['paid_amount'] = (float)($ledger['paid_amount'] ?? 0);
|
||||
$invoiceRow['discount'] = (float)($ledger['discount_total'] ?? 0);
|
||||
$invoiceRow['refund_paid'] = (float)($ledger['refund_paid_total'] ?? 0);
|
||||
$invoiceRow['balance'] = (float)($ledger['balance'] ?? 0);
|
||||
$invoiceRow['customer_credit'] = (float)($ledger['customer_credit'] ?? 0);
|
||||
$invoiceRow['status'] = (string)($ledger['status'] ?? ($invoiceRow['status'] ?? ''));
|
||||
}
|
||||
unset($invoiceRow);
|
||||
|
||||
// === Expenses ===
|
||||
$expenses = $expenseModel
|
||||
@@ -583,77 +646,27 @@ public function financialReport()
|
||||
$invoiceRows = $invoiceRows->orderBy('invoices.id', 'ASC')->get()->getResultArray();
|
||||
|
||||
$invoiceIds = array_values(array_filter(array_map(static fn($row) => (int)($row['id'] ?? 0), $invoiceRows)));
|
||||
|
||||
$paidByInvoice = [];
|
||||
$discountByInvoice = [];
|
||||
$refundByInvoice = [];
|
||||
if (!empty($invoiceIds)) {
|
||||
$paidRows = $db->table('payments')
|
||||
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupStart()
|
||||
->whereNotIn('status', $paymentExclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
if (!empty($schoolYear)) {
|
||||
$paidRows->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
$paidRows->where('DATE(payment_date) >=', $invoiceDateFrom);
|
||||
}
|
||||
if (!empty($invoiceDateTo)) {
|
||||
$paidRows->where('DATE(payment_date) <=', $invoiceDateTo);
|
||||
}
|
||||
foreach ($paidRows->groupBy('invoice_id')->get()->getResultArray() as $row) {
|
||||
$paidByInvoice[(int)($row['invoice_id'] ?? 0)] = (float)($row['total_paid'] ?? 0);
|
||||
}
|
||||
|
||||
$discountRows = $db->table('discount_usages')
|
||||
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_discount')
|
||||
->whereIn('invoice_id', $invoiceIds);
|
||||
if (!empty($dateFrom)) {
|
||||
$discountRows->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$discountRows->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
foreach ($discountRows->groupBy('invoice_id')->get()->getResultArray() as $row) {
|
||||
$discountByInvoice[(int)($row['invoice_id'] ?? 0)] = (float)($row['total_discount'] ?? 0);
|
||||
}
|
||||
|
||||
$refundRows = $db->table('refunds')
|
||||
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial', 'Paid']);
|
||||
if (!empty($dateFrom)) {
|
||||
$refundRows->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$refundRows->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
foreach ($refundRows->groupBy('invoice_id')->get()->getResultArray() as $row) {
|
||||
$refundByInvoice[(int)($row['invoice_id'] ?? 0)] = (float)($row['total_refund'] ?? 0);
|
||||
}
|
||||
}
|
||||
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIds);
|
||||
|
||||
$invoices = [];
|
||||
foreach ($invoiceRows as $row) {
|
||||
$iid = (int)($row['id'] ?? 0);
|
||||
$paid = (float)($paidByInvoice[$iid] ?? 0);
|
||||
$disc = (float)($discountByInvoice[$iid] ?? 0);
|
||||
$refund = (float)($refundByInvoice[$iid] ?? 0);
|
||||
$balance = round((float)($row['total_amount'] ?? 0) - $disc - $refund - $paid, 2);
|
||||
$ledger = $ledgerByInvoice[$iid] ?? [];
|
||||
$paid = (float)($ledger['paid_amount'] ?? 0);
|
||||
$disc = (float)($ledger['discount_total'] ?? 0);
|
||||
$refund = (float)($ledger['refund_paid_total'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
$invoices[] = [
|
||||
'Invoice #' => (string)($row['invoice_number'] ?? ''),
|
||||
'Parent' => trim((string)($row['parent_name'] ?? '')),
|
||||
'Issue Date' => (string)($row['issue_date'] ?? ''),
|
||||
'Due Date' => (string)($row['due_date'] ?? ''),
|
||||
'Gross Charges' => (float)($row['total_amount'] ?? 0),
|
||||
'Gross Charges' => (float)($ledger['total_amount'] ?? 0),
|
||||
'Discounts' => $disc,
|
||||
'Refunds' => $refund,
|
||||
'Paid' => $paid,
|
||||
'Balance' => $balance,
|
||||
'Status' => (string)($row['status'] ?? ''),
|
||||
'Status' => (string)($ledger['status'] ?? ($row['status'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -992,10 +1005,6 @@ public function financialReport()
|
||||
->get()->getResultArray();
|
||||
|
||||
$byParent = [];
|
||||
$paymentsTbl = 'payments';
|
||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
||||
|
||||
foreach ($invRows as $r) {
|
||||
$iid = (int)($r['id'] ?? 0);
|
||||
$pid = (int)($r['parent_id'] ?? 0);
|
||||
@@ -1003,37 +1012,11 @@ public function financialReport()
|
||||
continue;
|
||||
}
|
||||
|
||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paidRow = $qb->get()->getRowArray();
|
||||
$paidSum = (float)($paidRow['tot'] ?? 0);
|
||||
|
||||
$discRow = $db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->get()->getRowArray();
|
||||
$discSum = (float)($discRow['tot'] ?? 0);
|
||||
|
||||
$refRow = $db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$refSum = (float)($refRow['tot'] ?? 0);
|
||||
|
||||
$total = (float)($r['total_amount'] ?? 0);
|
||||
$balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2));
|
||||
$ledger = $this->ledgerProjectionForInvoice($iid);
|
||||
$paidSum = (float)($ledger['paid_amount'] ?? 0);
|
||||
$discSum = (float)($ledger['discount_total'] ?? 0);
|
||||
$total = (float)($ledger['total_amount'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
|
||||
if (!isset($byParent[$pid])) {
|
||||
$byParent[$pid] = [
|
||||
@@ -1293,8 +1276,10 @@ public function financialReport()
|
||||
->findAll();
|
||||
|
||||
$paymentsMap = [];
|
||||
foreach ($payments as $payment) {
|
||||
$paymentsMap[(int)($payment['invoice_id'] ?? 0)] = (float)($payment['paid_amount'] ?? 0);
|
||||
$invoiceIdsForExport = array_values(array_filter(array_map(static fn($row) => (int)($row['id'] ?? 0), $invoices)));
|
||||
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIdsForExport);
|
||||
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||
$paymentsMap[(int)$invoiceId] = (float)($ledger['paid_amount'] ?? 0);
|
||||
}
|
||||
|
||||
$paymentBreakdownRows = $applyPaymentFilters($buildPaymentModel())
|
||||
@@ -1350,47 +1335,11 @@ public function financialReport()
|
||||
}
|
||||
$reimbursements = $reimbBuilder->groupBy('status')->findAll();
|
||||
|
||||
// Refunds (grouped)
|
||||
if (!empty($schoolYear)) {
|
||||
$refundModel->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($hasFrom) {
|
||||
$refundModel->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if ($hasTo) {
|
||||
$refundModel->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
$refundsData = $refundModel
|
||||
->select('invoice_id, SUM(refund_paid_amount) AS total_refunded')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->groupBy('invoice_id')
|
||||
->findAll();
|
||||
$refunds = [];
|
||||
foreach ($refundsData as $refund) {
|
||||
$refunds[$refund['invoice_id']] = $refund['total_refunded'];
|
||||
}
|
||||
|
||||
$discountBuilder = $discountModel;
|
||||
if (!empty($schoolYear)) {
|
||||
$discountBuilder->where('school_year', $schoolYear);
|
||||
}
|
||||
if ($hasFrom) {
|
||||
$discountBuilder->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if ($hasTo) {
|
||||
$discountBuilder->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
$discountsData = $discountBuilder
|
||||
->select('invoice_id, SUM(discount_amount) AS discount_amount')
|
||||
->where('invoice_id IS NOT NULL')
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$discounts = [];
|
||||
foreach ($discountsData as $disc) {
|
||||
$discounts[$disc['invoice_id']] = $disc['discount_amount'];
|
||||
foreach ($ledgerByInvoice as $invoiceId => $ledger) {
|
||||
$refunds[(int)$invoiceId] = (float)($ledger['refund_paid_total'] ?? 0);
|
||||
$discounts[(int)$invoiceId] = (float)($ledger['discount_total'] ?? 0);
|
||||
}
|
||||
|
||||
$filename = 'financial_report_' . date('Ymd_His') . '.csv';
|
||||
@@ -1419,10 +1368,10 @@ public function financialReport()
|
||||
$check = (float)($bd['check'] ?? 0);
|
||||
$refunded = (float)($refunds[$invoiceId] ?? 0);
|
||||
$discount = (float)($discounts[$invoiceId] ?? 0);
|
||||
$total = (float)($inv['total_amount'] ?? 0);
|
||||
$balance = $total - $paid - $discount - $refunded;
|
||||
if ($balance < 0) $balance = 0.0;
|
||||
$status = ($balance === 0.0) ? 'Paid' : 'Unpaid';
|
||||
$ledger = $ledgerByInvoice[$invoiceId] ?? [];
|
||||
$total = (float)($ledger['total_amount'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
$status = (string)($ledger['status'] ?? ($inv['status'] ?? ''));
|
||||
|
||||
fputcsv($out, [
|
||||
$inv['invoice_number'],
|
||||
@@ -1660,11 +1609,21 @@ public function financialReport()
|
||||
$invoiceBuilder->where('DATE(COALESCE(invoices.issue_date, invoices.created_at)) <=', $invoiceDateTo);
|
||||
}
|
||||
$invoices = $invoiceBuilder->findAll();
|
||||
$totalCharges = array_sum(array_column($invoices, 'total_amount'));
|
||||
$invoiceIds = array_values(array_unique(array_filter(array_map(static function ($inv) {
|
||||
$id = (int)($inv['id'] ?? 0);
|
||||
return $id > 0 ? $id : null;
|
||||
}, $invoices))));
|
||||
$ledgerByInvoice = $this->ledgerProjectionMap($invoiceIds);
|
||||
$totalCharges = 0.0;
|
||||
$totalPaid = 0.0;
|
||||
$totalDiscounts = 0.0;
|
||||
$totalRefunds = 0.0;
|
||||
foreach ($ledgerByInvoice as $ledger) {
|
||||
$totalCharges += (float)($ledger['total_amount'] ?? 0);
|
||||
$totalPaid += (float)($ledger['paid_amount'] ?? 0);
|
||||
$totalDiscounts += (float)($ledger['discount_total'] ?? 0);
|
||||
$totalRefunds += (float)($ledger['refund_paid_total'] ?? 0);
|
||||
}
|
||||
|
||||
// === Additional Charges ===
|
||||
$hasExplicitDates = !empty($dateFrom) || !empty($dateTo);
|
||||
@@ -1714,98 +1673,6 @@ public function financialReport()
|
||||
|
||||
$totalCharges += $extraChargesUnapplied;
|
||||
|
||||
// === Payments: Total Paid ===
|
||||
$paymentBuilder = $paymentModel->where('school_year', $schoolYear);
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
$paymentBuilder->where('DATE(payment_date) >=', $invoiceDateFrom);
|
||||
}
|
||||
if (!empty($invoiceDateTo)) {
|
||||
$paymentBuilder->where('DATE(payment_date) <=', $invoiceDateTo);
|
||||
}
|
||||
$payHasStatus = $db->fieldExists('status', 'payments');
|
||||
$payHasVoid = $db->fieldExists('is_void', 'payments');
|
||||
if ($payHasStatus) {
|
||||
$paymentBuilder->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($payHasVoid) {
|
||||
$paymentBuilder->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paymentResult = $paymentBuilder->selectSum('paid_amount')->get()->getRowArray();
|
||||
$totalPaid = isset($paymentResult['paid_amount']) ? (float) $paymentResult['paid_amount'] : 0.00;
|
||||
|
||||
// === Per-invoice paid/discount/refund totals for outstanding balance ===
|
||||
$paidByInvoice = [];
|
||||
$discountByInvoice = [];
|
||||
$refundByInvoice = [];
|
||||
if (!empty($invoiceIds)) {
|
||||
$paidRows = $db->table('payments')
|
||||
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->whereIn('invoice_id', $invoiceIds);
|
||||
if (!empty($schoolYear)) {
|
||||
$paidRows->where('school_year', $schoolYear);
|
||||
}
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
$paidRows->where('DATE(payment_date) >=', $invoiceDateFrom);
|
||||
}
|
||||
if (!empty($invoiceDateTo)) {
|
||||
$paidRows->where('DATE(payment_date) <=', $invoiceDateTo);
|
||||
}
|
||||
if ($payHasStatus) {
|
||||
$paidRows->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($payHasVoid) {
|
||||
$paidRows->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paidRows = $paidRows->groupBy('invoice_id')->get()->getResultArray();
|
||||
foreach ($paidRows as $r) {
|
||||
$iid = (int)($r['invoice_id'] ?? 0);
|
||||
if ($iid > 0) $paidByInvoice[$iid] = (float)($r['total_paid'] ?? 0);
|
||||
}
|
||||
|
||||
$discRows = $db->table('discount_usages')
|
||||
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->whereIn('invoice_id', $invoiceIds);
|
||||
if (!empty($dateFrom)) {
|
||||
$discRows->where('DATE(COALESCE(used_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$discRows->where('DATE(COALESCE(used_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
$discRows = $discRows->groupBy('invoice_id')->get()->getResultArray();
|
||||
foreach ($discRows as $r) {
|
||||
$iid = (int)($r['invoice_id'] ?? 0);
|
||||
if ($iid > 0) $discountByInvoice[$iid] = (float)($r['total_disc'] ?? 0);
|
||||
}
|
||||
|
||||
$refRows = $db->table('refunds')
|
||||
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial', 'Paid']);
|
||||
if (!empty($dateFrom)) {
|
||||
$refRows->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$refRows->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
$refRows = $refRows->groupBy('invoice_id')->get()->getResultArray();
|
||||
foreach ($refRows as $r) {
|
||||
$iid = (int)($r['invoice_id'] ?? 0);
|
||||
if ($iid > 0) $refundByInvoice[$iid] = (float)($r['total_refund'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// === Expenses ===
|
||||
$expenseBuilder = $expenseModel->where('school_year', $schoolYear);
|
||||
if (!empty($invoiceDateFrom)) {
|
||||
@@ -1943,39 +1810,6 @@ public function financialReport()
|
||||
$donationToSchool = $donationExpense + $donationReimb;
|
||||
$totalReimbursements = max(0.0, $totalReimbursements - $donationReimb);
|
||||
|
||||
// === Refunds ===
|
||||
$refundBuilder = $refundModel
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->where('refund_paid_amount IS NOT NULL');
|
||||
|
||||
if (!empty($dateFrom)) {
|
||||
$refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$refundBuilder->where('DATE(COALESCE(refunded_at, created_at)) <=', $dateTo);
|
||||
}
|
||||
|
||||
$refundResult = $refundBuilder
|
||||
->selectSum('refund_paid_amount')
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
$totalRefunds = isset($refundResult['refund_paid_amount']) ? (float) $refundResult['refund_paid_amount'] : 0.00;
|
||||
|
||||
// === Discounts ===
|
||||
$discountBuilder = $discountModel
|
||||
->join('invoices', 'invoices.id = discount_usages.invoice_id')
|
||||
->where('invoices.school_year', $schoolYear);
|
||||
if (!empty($dateFrom)) {
|
||||
$discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) >=', $dateFrom);
|
||||
}
|
||||
if (!empty($dateTo)) {
|
||||
$discountBuilder->where('DATE(COALESCE(discount_usages.used_at, discount_usages.created_at)) <=', $dateTo);
|
||||
}
|
||||
$discountResult = $discountBuilder->selectSum('discount_amount')->get()->getRowArray();
|
||||
$totalDiscounts = isset($discountResult['discount_amount']) ? (float) $discountResult['discount_amount'] : 0.00;
|
||||
|
||||
// === Net, Outstanding & Overpayments ===
|
||||
$overpaymentDetails = [];
|
||||
$totalUnpaid = 0.0;
|
||||
@@ -1984,15 +1818,16 @@ public function financialReport()
|
||||
$iid = (int)($inv['id'] ?? 0);
|
||||
$pid = (int)($inv['parent_id'] ?? 0);
|
||||
if ($iid <= 0 || $pid <= 0) continue;
|
||||
$total = (float)($inv['total_amount'] ?? 0);
|
||||
$paid = (float)($paidByInvoice[$iid] ?? 0);
|
||||
$disc = (float)($discountByInvoice[$iid] ?? 0);
|
||||
$ref = (float)($refundByInvoice[$iid] ?? 0);
|
||||
$rawBal = round($total - $disc - $paid - $ref, 2);
|
||||
if ($rawBal > 0.00001) {
|
||||
$totalUnpaid += $rawBal;
|
||||
} elseif ($rawBal < -0.00001) {
|
||||
$credit = abs($rawBal);
|
||||
$ledger = $ledgerByInvoice[$iid] ?? [];
|
||||
$total = (float)($ledger['total_amount'] ?? 0);
|
||||
$paid = (float)($ledger['paid_amount'] ?? 0);
|
||||
$disc = (float)($ledger['discount_total'] ?? 0);
|
||||
$ref = (float)($ledger['refund_paid_total'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
$credit = (float)($ledger['customer_credit'] ?? 0);
|
||||
if ($balance > 0.00001) {
|
||||
$totalUnpaid += $balance;
|
||||
} elseif ($credit > 0.00001) {
|
||||
$totalOverpaid += $credit;
|
||||
$overpaymentDetails[] = [
|
||||
'type' => 'invoice',
|
||||
@@ -2005,7 +1840,7 @@ public function financialReport()
|
||||
'discount_amount' => $disc,
|
||||
'refund_amount' => $ref,
|
||||
'paid_amount' => $paid,
|
||||
'note' => 'Invoice payments/discounts exceed net invoice charges.',
|
||||
'note' => 'Invoice ledger shows customer credit.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2111,9 +1946,7 @@ public function financialReport()
|
||||
$schoolYears[] = (string)$schoolYear;
|
||||
}
|
||||
|
||||
// Aggregate balances by parent for selected school year
|
||||
// IMPORTANT: Compute current balance = total - payments - discounts - refundsPaid
|
||||
// rather than trusting invoices.balance which may become stale.
|
||||
// Aggregate balances by parent for selected school year from the canonical invoice ledger.
|
||||
$db = \Config\Database::connect();
|
||||
$invRows = $db->table('invoices i')
|
||||
->select('i.id, i.parent_id, i.total_amount, u.firstname, u.lastname, u.email')
|
||||
@@ -2123,51 +1956,18 @@ public function financialReport()
|
||||
->orderBy('i.id', 'ASC')
|
||||
->get()->getResultArray();
|
||||
|
||||
// Group by parent and compute balances dynamically
|
||||
$byParent = [];
|
||||
$paymentsTbl = 'payments';
|
||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
||||
|
||||
foreach ($invRows as $r) {
|
||||
$iid = (int)($r['id'] ?? 0);
|
||||
$pid = (int)($r['parent_id'] ?? 0);
|
||||
if ($iid <= 0 || $pid <= 0) continue;
|
||||
|
||||
// Sum payments for this invoice (exclude void/failed if such columns exist)
|
||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paidRow = $qb->get()->getRowArray();
|
||||
$paidSum = (float)($paidRow['tot'] ?? 0);
|
||||
|
||||
// Sum discounts for this invoice
|
||||
$discRow = $db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->get()->getRowArray();
|
||||
$discSum = (float)($discRow['tot'] ?? 0);
|
||||
|
||||
// Sum refunds PAID for this invoice (Partial/Paid only)
|
||||
$refRow = $db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $iid)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$refSum = (float)($refRow['tot'] ?? 0);
|
||||
|
||||
$total = (float)($r['total_amount'] ?? 0);
|
||||
$balance = max(0.0, round($total - $discSum - $paidSum - $refSum, 2));
|
||||
$ledger = $this->ledgerProjectionForInvoice($iid);
|
||||
$paidSum = (float)($ledger['paid_amount'] ?? 0);
|
||||
$discSum = (float)($ledger['discount_total'] ?? 0);
|
||||
$total = (float)($ledger['total_amount'] ?? 0);
|
||||
$balance = (float)($ledger['balance'] ?? 0);
|
||||
|
||||
if (!isset($byParent[$pid])) {
|
||||
$byParent[$pid] = [
|
||||
|
||||
@@ -18,6 +18,10 @@ use App\Models\InvoiceEventModel;
|
||||
use App\Models\ClassSectionModel;
|
||||
use App\Models\DiscountUsageModel;
|
||||
use App\Models\RefundModel;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Libraries\IssueInvoiceCommand;
|
||||
use App\Libraries\InvoiceIssuanceService;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
|
||||
@@ -50,6 +54,8 @@ class InvoiceController extends ResourceController
|
||||
protected $request;
|
||||
protected $gradeFee;
|
||||
protected $classSectionModel;
|
||||
protected $invoiceLedgerService;
|
||||
protected InvoiceIssuanceService $invoiceIssuanceService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -67,7 +73,9 @@ class InvoiceController extends ResourceController
|
||||
$this->chargesModel = new EventChargesModel();
|
||||
$this->discountUsageModel = new DiscountUsageModel();
|
||||
$this->refundModel = new RefundModel();
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->invoiceIssuanceService = new InvoiceIssuanceService($this->db, $this->invoiceModel, null, $this->invoiceLedgerService);
|
||||
$this->request = \Config\Services::request();
|
||||
|
||||
$this->gradeFee = $this->configModel->getConfig('grade_fee');
|
||||
@@ -391,7 +399,7 @@ class InvoiceController extends ResourceController
|
||||
}
|
||||
|
||||
public function generateInvoice(
|
||||
string $parentId = null,
|
||||
?string $parentId = null,
|
||||
?string $schoolYearOverride = null,
|
||||
?string $semesterOverride = null,
|
||||
bool $recalculateDiscounts = true
|
||||
@@ -472,20 +480,9 @@ class InvoiceController extends ResourceController
|
||||
);
|
||||
}
|
||||
|
||||
// ✅ Refunds PAID to the parent for this year (Partial/Paid)
|
||||
$refundPaid = (float) $this->refundModel->getTotalApprovedRefundByParentIdAndSchoolYear($parentId, $schoolYear);
|
||||
|
||||
$totalPaid = $this->paymentModel->getTotalPaidByParentId($parentId, $schoolYear);
|
||||
|
||||
$discountedTuition = max(0, $tuitionFee);
|
||||
$totalAmount = $discountedTuition + $eventchargeTotal;
|
||||
|
||||
// Parent-level balance (informational); we will recalc per invoice below
|
||||
$parentBalance = $totalAmount // original charges (tuition + events)
|
||||
- $totalDiscount // any applied discounts/vouchers
|
||||
- $refundPaid // approved refunds paid to parent
|
||||
- $totalPaid; // payments received
|
||||
|
||||
// Business rule: single invoice per parent per school year.
|
||||
// If legacy duplicates exist, prefer the invoice that already has a discount applied,
|
||||
// otherwise use the latest invoice for the parent/year.
|
||||
@@ -494,106 +491,10 @@ class InvoiceController extends ResourceController
|
||||
$updated = false;
|
||||
$updatedIds = [];
|
||||
if (!empty($invoice) && isset($invoice['id'])) {
|
||||
$paymentExclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
$paymentsHasStatus = false;
|
||||
$paymentsHasVoid = false;
|
||||
try {
|
||||
$paymentsHasStatus = $this->db->fieldExists('status', 'payments');
|
||||
$paymentsHasVoid = $this->db->fieldExists('is_void', 'payments');
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
// Preserve applied additional charges and recalc this invoice only
|
||||
$extrasSum = 0.0;
|
||||
try {
|
||||
$rows = $this->db->table('additional_charges')
|
||||
->select('charge_type, amount')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->where('status', 'applied')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$amt = (float)($r['amount'] ?? 0);
|
||||
$typ = strtolower((string)($r['charge_type'] ?? 'add'));
|
||||
if ($typ === 'deduct') $amt = -abs($amt); else $amt = abs($amt);
|
||||
$extrasSum += $amt;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'additional_charges sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$newTotal = round($totalAmount + $extrasSum, 2);
|
||||
|
||||
// Per-invoice discount
|
||||
$invDiscount = 0.0;
|
||||
try {
|
||||
$d = $this->db->table('discount_usages')
|
||||
->select('COALESCE(SUM(discount_amount),0) AS tot')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->get()->getRowArray();
|
||||
$invDiscount = (float)($d['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'discount sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Per-invoice refunds paid
|
||||
$invRefunds = 0.0;
|
||||
try {
|
||||
$r = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$invRefunds = (float)($r['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'refund sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Payments recorded on this invoice (sum payments to avoid stale invoice.paid_amount)
|
||||
$paidOnInv = 0.0;
|
||||
try {
|
||||
$qb = $this->db->table('payments')
|
||||
->select('COALESCE(SUM(paid_amount),0) AS tot')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('paid_amount >', 0);
|
||||
|
||||
if ($paymentsHasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', $paymentExclude)
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($paymentsHasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
$row = $qb->get()->getRowArray();
|
||||
$paidOnInv = (float)($row['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'payment sum failed for invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
$paidOnInv = (float)($invoice['paid_amount'] ?? 0.0);
|
||||
}
|
||||
|
||||
$newBalance = $newTotal - $invDiscount - $invRefunds - $paidOnInv;
|
||||
$newStatus = ($newBalance <= 0.00001)
|
||||
? 'Paid'
|
||||
: (($paidOnInv > 0) ? 'Partially Paid' : 'Unpaid');
|
||||
|
||||
$this->invoiceModel->update($invoice['id'], [
|
||||
'total_amount' => $newTotal,
|
||||
'paid_amount' => $paidOnInv,
|
||||
'balance' => $newBalance,
|
||||
'status' => $newStatus,
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
|
||||
$updatedIds[] = (int)$invoice['id'];
|
||||
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
|
||||
$updated = true;
|
||||
$ledger = $this->invoiceLedgerService->recalculate((int) $invoice['id']);
|
||||
$updatedIds[] = (int) $ledger['invoice_id'];
|
||||
log_message('info', "Updated invoice ID {$invoice['id']} for parent ID {$parentId}.");
|
||||
$updated = true;
|
||||
} else {
|
||||
// Generate invoice number
|
||||
$schoolId = $this->userModel->getSchoolIdByUserId($parentId);
|
||||
@@ -616,30 +517,35 @@ class InvoiceController extends ResourceController
|
||||
$dueUtc = $dueLocal->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
$insertId = $this->invoiceModel->insert([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'total_amount' => $totalAmount,
|
||||
'paid_amount' => 0,
|
||||
// Initial balance equals the created total; discounts/refunds/payments will adjust later
|
||||
'balance' => $totalAmount,
|
||||
'status' => 'Unpaid',
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'issue_date' => $issueUtc,
|
||||
'due_date' => $dueUtc,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
]);
|
||||
|
||||
if (!$insertId) {
|
||||
log_message('error', 'Invoice insert failed: ' . json_encode($this->invoiceModel->errors()));
|
||||
try {
|
||||
$issueResult = $this->invoiceIssuanceService->issueInvoice(new IssueInvoiceCommand([
|
||||
'parent_id' => $parentId,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'total_amount' => $totalAmount,
|
||||
'paid_amount' => 0,
|
||||
'balance' => $totalAmount,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'issue_date' => $issueUtc,
|
||||
'due_date' => $dueUtc,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now()
|
||||
], (float) $tuitionFee, (float) $eventchargeTotal, [
|
||||
'parent_id' => (int) $parentId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'registered_student_count' => count($registeredKids),
|
||||
'withdrawn_student_count' => count($withdrawnKids),
|
||||
]));
|
||||
$insertId = $issueResult->invoiceId;
|
||||
$ledger = $issueResult->ledger;
|
||||
log_message('info', "Invoice created successfully. Insert ID: {$insertId}");
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Invoice issuance failed: ' . $e->getMessage() . ' errors=' . json_encode($this->invoiceModel->errors()));
|
||||
if ($isAjax) {
|
||||
return $this->response->setJSON(['ok' => false, 'message' => 'Failed to create invoice.']);
|
||||
}
|
||||
return redirect()->back()->with('error', 'Failed to create invoice. Please check input values.');
|
||||
} else {
|
||||
log_message('info', "Invoice created successfully. Insert ID: {$insertId}");
|
||||
}
|
||||
$updated = false;
|
||||
}
|
||||
@@ -708,98 +614,12 @@ class InvoiceController extends ResourceController
|
||||
float $tuitionFee,
|
||||
array $enrollments
|
||||
): float {
|
||||
$totalDiscount = 0.00;
|
||||
$eventchargeTotal = 0.0;
|
||||
try {
|
||||
$eventsList = $this->chargesModel->getChargesWithEventInfo($parentId, $schoolYear);
|
||||
$eventchargeTotal = array_sum(array_column($eventsList, 'charged'));
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to load event charges for discount recalculation: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Get all invoices for this parent and school year
|
||||
$invoices = $this->invoiceModel->getInvoicesByParentId($parentId, $schoolYear);
|
||||
|
||||
if (empty($invoices)) {
|
||||
log_message('info', "No invoices found for parent ID $parentId in school year $schoolYear.");
|
||||
return 0.00;
|
||||
}
|
||||
$totalDiscount = 0.0;
|
||||
|
||||
foreach ($invoices as $invoice) {
|
||||
if (!isset($invoice['id'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$invoiceId = $invoice['id'];
|
||||
|
||||
// Get discount usage + voucher details
|
||||
$discountUsage = $this->db->table('discount_usages du')
|
||||
->select('du.id, dv.id as voucher_id, dv.discount_type, dv.discount_value')
|
||||
->join('discount_vouchers dv', 'du.voucher_id = dv.id')
|
||||
->join('invoices i', 'du.invoice_id = i.id')
|
||||
->where('du.invoice_id', $invoiceId)
|
||||
->where('i.school_year', $schoolYear)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if (!$discountUsage) {
|
||||
log_message('info', "No discount applied to invoice ID $invoiceId.");
|
||||
continue;
|
||||
}
|
||||
|
||||
$extrasSum = 0.0;
|
||||
try {
|
||||
$rows = $this->db->table('additional_charges')
|
||||
->select('charge_type, amount')
|
||||
->where('invoice_id', (int)$invoice['id'])
|
||||
->where('school_year', $schoolYear)
|
||||
->where('status', 'applied')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$amt = (float)($r['amount'] ?? 0);
|
||||
$typ = strtolower((string)($r['charge_type'] ?? 'add'));
|
||||
if ($typ === 'deduct') $amt = -abs($amt); else $amt = abs($amt);
|
||||
$extrasSum += $amt;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'additional_charges sum failed for discount recalculation invoice ' . (int)$invoice['id'] . ': ' . $e->getMessage());
|
||||
}
|
||||
|
||||
$baseTotal = round($tuitionFee + $extrasSum, 2);
|
||||
|
||||
// Recalculate discount
|
||||
if ($discountUsage['discount_type'] === 'percent') {
|
||||
$discountAmount = round(($baseTotal * $discountUsage['discount_value']) / 100, 2);
|
||||
} else {
|
||||
$discountAmount = min($discountUsage['discount_value'], $baseTotal);
|
||||
}
|
||||
|
||||
// Update discount usage
|
||||
$this->db->table('discount_usages')
|
||||
->where('id', $discountUsage['id'])
|
||||
->update([
|
||||
'discount_amount' => $discountAmount,
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id')
|
||||
]);
|
||||
|
||||
$totalDiscount += $discountAmount;
|
||||
|
||||
// Log enrollment summary
|
||||
$added = [];
|
||||
$withdrawn = [];
|
||||
foreach ($enrollments as $e) {
|
||||
if (
|
||||
in_array($e['enrollment_status'], ['enrolled', 'payment pending']) &&
|
||||
$e['admission_status'] === 'accepted'
|
||||
) {
|
||||
$added[] = $e['student_id'];
|
||||
} elseif (in_array($e['enrollment_status'], ['withdrawn', 'refund pending', 'withdraw under review'])) {
|
||||
$withdrawn[] = $e['student_id'];
|
||||
}
|
||||
}
|
||||
|
||||
log_message('info', "Recalculated discount for invoice ID $invoiceId: Added students [" . implode(',', $added) . "], Withdrawn students [" . implode(',', $withdrawn) . "]. Discount updated to $discountAmount.");
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice((int)$invoice['id']);
|
||||
$totalDiscount += (float)($ledger['discount_total'] ?? 0.0);
|
||||
}
|
||||
|
||||
return $totalDiscount;
|
||||
@@ -964,6 +784,15 @@ class InvoiceController extends ResourceController
|
||||
return ['error' => "Parent associated with the invoice was not found."];
|
||||
}
|
||||
|
||||
$ledger = $this->invoiceLedgerService->calculateInvoice((int) $invoiceId);
|
||||
$invoiceLines = $this->db->table('invoice_lines')
|
||||
->select('description, quantity, unit_amount_cents, line_amount_cents, line_type, source_type, source_id, created_at, metadata_json')
|
||||
->where('invoice_id', (int)$invoiceId)
|
||||
->where('voided_at IS NULL', null, false)
|
||||
->orderBy('id', 'ASC')
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$enrollments = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
@@ -1091,19 +920,7 @@ class InvoiceController extends ResourceController
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
|
||||
// Refunds PAID for this specific invoice (money returned to the parent)
|
||||
$refundsPaidTotal = 0.0;
|
||||
try {
|
||||
$r = $this->db->table('refunds')
|
||||
->select('COALESCE(SUM(refund_paid_amount),0) AS tot')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('school_year', $schoolYear)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->get()->getRowArray();
|
||||
$refundsPaidTotal = (float)($r['tot'] ?? 0.0);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to sum refunds for invoice ' . (int)$invoiceId . ': ' . $e->getMessage());
|
||||
}
|
||||
$refundsPaidTotal = (float) ($ledger['refund_paid_total'] ?? 0.0);
|
||||
|
||||
/* ============================================================
|
||||
* ADDITIONAL CHARGES (itemized) for this invoice
|
||||
@@ -1172,7 +989,9 @@ class InvoiceController extends ResourceController
|
||||
'discounts' => $discounts,
|
||||
'additionalChargesTotal' => $additionalChargesTotal,
|
||||
'additionalChargeLines' => $additionalChargeLines,
|
||||
'invoiceLines' => $invoiceLines,
|
||||
'refundsPaidTotal' => $refundsPaidTotal,
|
||||
'ledger' => $ledger,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1326,56 +1145,14 @@ class InvoiceController extends ResourceController
|
||||
];
|
||||
};
|
||||
|
||||
// --- Tuition/registration lines (charges) ---
|
||||
foreach ($registeredKids as $student) {
|
||||
$id = $student['student_id'];
|
||||
$unit = (float)($studentCharges[$id]['unit_fee'] ?? 0.0);
|
||||
$name = $student['student_firstname'] . ' ' . $student['student_lastname'];
|
||||
|
||||
$classSectionName = $this->classSectionModel->getClassSectionNameByClassId($student['grade']);
|
||||
$lowerCaseName = strtolower((string)$classSectionName);
|
||||
$gradeName = ($lowerCaseName === 'kg')
|
||||
? 'in Kindergarten '
|
||||
: (($lowerCaseName === 'youth') ? 'in Youth ' : ('in Grade ' . $classSectionName));
|
||||
|
||||
$dt = $toLocal($invoice['created_at'] ?? null, false);
|
||||
$push($dt, 'Registration of student "' . $name . '" ' . $gradeName, $unit, 'registration');
|
||||
}
|
||||
|
||||
// --- Event charges (charges) ---
|
||||
foreach ($events as $event) {
|
||||
$studentName = 'N/A';
|
||||
if (!empty($event['student_id'])) {
|
||||
foreach ($students as $st) {
|
||||
if (($st['student_id'] ?? null) == $event['student_id']) {
|
||||
$studentName = $st['student_firstname'] . ' ' . $st['student_lastname'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($studentName === 'N/A') {
|
||||
$externalName = trim((string)($event['external_firstname'] ?? '') . ' ' . (string)($event['external_lastname'] ?? ''));
|
||||
if ($externalName !== '') {
|
||||
$studentName = $externalName . ' (external)';
|
||||
}
|
||||
}
|
||||
|
||||
$dt = $toLocal($event['created_at'] ?? null, false);
|
||||
$amount = (float)($event['charged'] ?? 0.0);
|
||||
$eventName = !empty($event['event_name']) ? $event['event_name'] : 'with no name';
|
||||
|
||||
$push($dt, 'Event ' . $eventName . ' charge for "' . $studentName . '"', $amount, 'event');
|
||||
}
|
||||
|
||||
// --- Withdrawn refunds (negative) — only if a non-zero refund figure exists
|
||||
foreach ($withdrawnKids as $student) {
|
||||
$id = $student['student_id'];
|
||||
$ref = (float)($studentCharges[$id]['refund'] ?? 0.0);
|
||||
if ($ref <= 0) { continue; }
|
||||
$name = $student['student_firstname'] . ' ' . $student['student_lastname'];
|
||||
|
||||
$dt = $toLocal($invoice['created_at'] ?? null, false);
|
||||
$push($dt, 'Refund for student "' . $name . '"', -1 * $ref, 'refund');
|
||||
// --- Frozen invoice charge lines. Do not rebuild issued charges from current enrollment/events.
|
||||
foreach (($data['invoiceLines'] ?? []) as $line) {
|
||||
$dt = $toLocal($line['created_at'] ?? ($invoice['created_at'] ?? null), true);
|
||||
$amount = ((int)($line['line_amount_cents'] ?? 0)) / 100;
|
||||
$type = (string)($line['line_type'] ?? 'other');
|
||||
$category = str_contains($type, 'event') ? 'event'
|
||||
: (str_contains($type, 'additional') ? 'additional' : 'registration');
|
||||
$push($dt, (string)($line['description'] ?? 'Invoice line'), $amount, $category);
|
||||
}
|
||||
|
||||
// --- Payments (negative) — stored in local time
|
||||
@@ -1386,17 +1163,11 @@ class InvoiceController extends ResourceController
|
||||
$push($dt, 'Payment (' . ($payment['payment_method'] ?? 'Payment') . ')', -1 * $amount, 'payment');
|
||||
}
|
||||
|
||||
// --- Additional charges (already signed: deduct < 0, add > 0)
|
||||
foreach ($additionalChargeLines as $l) {
|
||||
$dt = $toLocal($l['date'] ?? null, false);
|
||||
$desc = (string)($l['description'] ?? 'Additional Charge');
|
||||
$amt = (float)($l['amount'] ?? 0.0);
|
||||
$push($dt, $desc, $amt, 'additional');
|
||||
}
|
||||
|
||||
// --- Discounts (negative) integrated into the timeline
|
||||
foreach (($discounts ?? []) as $discount) {
|
||||
$amt = (float)($discount['discount_amount'] ?? 0.0);
|
||||
$amt = isset($discount['applied_discount_cents']) && $discount['applied_discount_cents'] !== null
|
||||
? ((int)$discount['applied_discount_cents']) / 100
|
||||
: (float)($discount['discount_amount'] ?? 0.0);
|
||||
$totalDiscount += $amt;
|
||||
|
||||
$dt = $toLocal($discount['used_at'] ?? ($invoice['created_at'] ?? null), false);
|
||||
@@ -1445,33 +1216,13 @@ class InvoiceController extends ResourceController
|
||||
}
|
||||
|
||||
// ======== SUMMARY (bottom) ========
|
||||
// Compute total charges from components (tuition + event charges + additional charges)
|
||||
// to ensure the PDF always reflects all elements accurately.
|
||||
$tuitionSubtotal = 0.0;
|
||||
foreach (($studentCharges ?? []) as $sc) {
|
||||
$tuitionSubtotal += (float)($sc['unit_fee'] ?? 0.0);
|
||||
}
|
||||
|
||||
$eventSubtotal = 0.0;
|
||||
foreach (($events ?? []) as $ev) {
|
||||
$eventSubtotal += (float)($ev['charged'] ?? 0.0);
|
||||
}
|
||||
|
||||
$additionalSubtotal = (float)($additionalChargesTotal ?? 0.0);
|
||||
|
||||
$totalRefund = (float)($data['refundsPaidTotal'] ?? 0.0);
|
||||
$totalAmount = round($tuitionSubtotal + $eventSubtotal + $additionalSubtotal, 2);
|
||||
$calcBalance = $totalAmount - $totalPaid - $totalDiscount - $totalRefund;
|
||||
// Prefer computed balance in PDF to avoid stale DB values
|
||||
$totalBalance = $calcBalance;
|
||||
|
||||
// Display rule: if negative, show as Credit (Overpayment) and clamp balance due to 0.00
|
||||
$displayBalance = $totalBalance;
|
||||
$creditOverpay = 0.0;
|
||||
if ($displayBalance < -0.00001) {
|
||||
$creditOverpay = abs($displayBalance);
|
||||
$displayBalance = 0.00;
|
||||
}
|
||||
$ledger = $data['ledger'] ?? [];
|
||||
$totalAmount = (float) ($ledger['total_amount'] ?? 0.0);
|
||||
$totalDiscount = (float) ($ledger['discount_total'] ?? $totalDiscount);
|
||||
$totalPaid = (float) ($ledger['paid_amount'] ?? $totalPaid);
|
||||
$totalRefund = (float) ($ledger['refund_paid_total'] ?? 0.0);
|
||||
$displayBalance = (float) ($ledger['balance'] ?? 0.0);
|
||||
$creditOverpay = (float) ($ledger['customer_credit'] ?? 0.0);
|
||||
|
||||
$pdf->Ln(5);
|
||||
$labelWidth = 165;
|
||||
@@ -1724,7 +1475,7 @@ private function getGradeLevel($grade): array
|
||||
'issue_date' => $this->request->getPost('issue_date'),
|
||||
'refund_issue_date' => $this->request->getPost('refund_issue_date'),
|
||||
'due_date' => $this->request->getPost('due_date'),
|
||||
'status' => 'Unpaid',
|
||||
'status' => FinancialStatus::INVOICE_UNPAID,
|
||||
'description' => $this->request->getPost('description'),
|
||||
];
|
||||
|
||||
|
||||
@@ -378,66 +378,22 @@ class PaymentController extends ResourceController
|
||||
->orderBy('issue_date', 'DESC')
|
||||
->findAll();
|
||||
|
||||
// Preload sums of actual payments/discounts/refunds per invoice to avoid stale invoice.paid_amount/balance
|
||||
$paidByInvoice = [];
|
||||
$discountByInvoice = [];
|
||||
$refundByInvoice = [];
|
||||
if (!empty($rawInvoices)) {
|
||||
$invoiceIds = array_values(array_unique(array_map(static fn($r) => (int)($r['id'] ?? 0), $rawInvoices)));
|
||||
if (!empty($invoiceIds)) {
|
||||
$rows = $this->paymentModel
|
||||
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id')
|
||||
->get()->getResultArray();
|
||||
foreach ($rows as $r) {
|
||||
$iid = (int)($r['invoice_id'] ?? 0);
|
||||
$paidByInvoice[$iid] = (float)($r['total_paid'] ?? 0);
|
||||
}
|
||||
|
||||
// Sum discounts per invoice_id
|
||||
$discRows = $this->db->table('discount_usages')
|
||||
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id')
|
||||
->get()->getResultArray();
|
||||
foreach ($discRows as $dr) {
|
||||
$iid = (int)($dr['invoice_id'] ?? 0);
|
||||
$discountByInvoice[$iid] = (float)($dr['total_disc'] ?? 0);
|
||||
}
|
||||
|
||||
// Sum PAID refunds per invoice_id (only Partial/Paid reduce liability)
|
||||
$refRows = $this->db->table('refunds')
|
||||
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial','Paid'])
|
||||
->groupBy('invoice_id')
|
||||
->get()->getResultArray();
|
||||
foreach ($refRows as $rr) {
|
||||
$iid = (int)($rr['invoice_id'] ?? 0);
|
||||
$refundByInvoice[$iid] = (float)($rr['total_refund_paid'] ?? 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize invoices for the view/JS; force due_ymd to the CONFIG date
|
||||
$invoices = array_map(function (array $inv) use ($installmentEndYmd, $paidByInvoice, $discountByInvoice, $refundByInvoice) {
|
||||
// Cast numeric fields we rely on
|
||||
$inv['total_amount'] = isset($inv['total_amount']) ? (float) $inv['total_amount'] : 0.0;
|
||||
// Prefer actual sum of payments for paid_amount
|
||||
// Normalize invoices for the view/JS; all accounting amounts come from the ledger.
|
||||
$invoices = array_map(function (array $inv) use ($installmentEndYmd) {
|
||||
$iid = (int)($inv['id'] ?? 0);
|
||||
$actualPaid = isset($paidByInvoice[$iid]) ? (float)$paidByInvoice[$iid] : null;
|
||||
$inv['paid_amount'] = is_numeric($actualPaid)
|
||||
? (float)$actualPaid
|
||||
: (isset($inv['paid_amount']) ? (float)$inv['paid_amount'] : 0.0);
|
||||
// Per-invoice discount
|
||||
$inv['discount'] = isset($discountByInvoice[$iid]) ? (float)$discountByInvoice[$iid] : (float)($inv['discount'] ?? 0.0);
|
||||
|
||||
// Per-invoice refunds (paid out)
|
||||
$inv['refund_paid'] = isset($refundByInvoice[$iid]) ? (float)$refundByInvoice[$iid] : 0.0;
|
||||
|
||||
// Derive balance from total - paid - discount - refundsPaid (never below 0)
|
||||
$inv['balance'] = max(0.0, (float)$inv['total_amount'] - (float)$inv['paid_amount'] - (float)$inv['discount'] - (float)$inv['refund_paid']);
|
||||
if ($iid > 0) {
|
||||
$ledger = $this->invoiceLedgerService->recalculateInvoice($iid);
|
||||
$inv['display_total'] = (float) ($ledger['total_amount'] ?? 0);
|
||||
$inv['total_amount'] = $inv['display_total'];
|
||||
$inv['paid_amount'] = (float) ($ledger['paid_amount'] ?? 0);
|
||||
$inv['discount'] = (float) ($ledger['discount_total'] ?? 0);
|
||||
$inv['refund_paid'] = (float) ($ledger['refund_paid_total'] ?? 0);
|
||||
$inv['balance'] = (float) ($ledger['balance'] ?? 0);
|
||||
$inv['balance_due_cents'] = (int) ($ledger['balanceDueCents'] ?? 0);
|
||||
$inv['customer_credit_cents'] = (int) ($ledger['customerCreditCents'] ?? 0);
|
||||
$inv['customer_credit'] = (float) ($ledger['customer_credit'] ?? 0);
|
||||
$inv['status'] = (string) ($ledger['status'] ?? ($inv['status'] ?? ''));
|
||||
}
|
||||
|
||||
// Always use the configured end date for installments
|
||||
$inv['due_ymd'] = $installmentEndYmd;
|
||||
@@ -889,6 +845,10 @@ class PaymentController extends ResourceController
|
||||
$paymentMethod = strtolower(trim((string) $this->request->getPost('payment_method'))); // cash|check|card
|
||||
$checkNumber = trim((string) $this->request->getPost('check_number'));
|
||||
$searchTerm = $this->request->getPost('search_term') ?? $this->request->getGet('search_term') ?? '';
|
||||
$idempotencyKey = trim((string)($this->request->getPost('idempotency_key') ?? ''));
|
||||
if ($idempotencyKey === '') {
|
||||
$idempotencyKey = bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
// UI-only
|
||||
$paymentType = strtolower((string) ($this->request->getPost('payment_type') ?? 'full')); // full|installment
|
||||
@@ -932,10 +892,11 @@ class PaymentController extends ResourceController
|
||||
}
|
||||
|
||||
// Optional receipt upload
|
||||
$stagedEvidence = null;
|
||||
$checkFile = null;
|
||||
$paymentFile = $this->request->getFile('payment_file');
|
||||
try {
|
||||
$checkFile = $this->financialAttachmentService->saveUploadedFile(
|
||||
$stagedEvidence = $this->financialAttachmentService->stageUploadedFile(
|
||||
$paymentFile,
|
||||
$paymentMethod === 'check' ? 'checks' : ($paymentMethod === 'card' ? 'cards' : 'misc')
|
||||
);
|
||||
@@ -953,6 +914,7 @@ class PaymentController extends ResourceController
|
||||
|
||||
if (!$row) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->with('error', 'Invoice not found.');
|
||||
}
|
||||
|
||||
@@ -965,6 +927,7 @@ class PaymentController extends ResourceController
|
||||
$carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $invYear);
|
||||
if ($carryForwardPaymentRequired && $paymentType === 'installment') {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->withInput()->with(
|
||||
'error',
|
||||
'This parent has a balance carried over from a previous school year. Installments are not allowed; payment must be made in full.'
|
||||
@@ -973,6 +936,7 @@ class PaymentController extends ResourceController
|
||||
|
||||
if ($amount > $currentBalance + 0.00001) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->withInput()->with(
|
||||
'error',
|
||||
'Entered amount (' . number_format($amount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').'
|
||||
@@ -981,6 +945,7 @@ class PaymentController extends ResourceController
|
||||
|
||||
if ($carryForwardPaymentRequired && (float)round($amount, 2) !== (float)round($currentBalance, 2)) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->withInput()->with(
|
||||
'error',
|
||||
'This parent has a balance carried over from a previous school year. Payment must equal the full remaining balance (' . number_format($currentBalance, 2) . ').'
|
||||
@@ -989,6 +954,7 @@ class PaymentController extends ResourceController
|
||||
|
||||
if ($paymentMethod === 'card' && (float)round($amount, 2) !== (float)round($currentBalance, 2)) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->withInput()->with(
|
||||
'error',
|
||||
'Debit/Credit Card payments must equal the full remaining balance (' . number_format($currentBalance, 2) . ').'
|
||||
@@ -1010,13 +976,20 @@ class PaymentController extends ResourceController
|
||||
$checkNumber,
|
||||
null,
|
||||
(array) $row,
|
||||
$currentBalance
|
||||
$currentBalance,
|
||||
$idempotencyKey
|
||||
);
|
||||
|
||||
if ($paymentResult === false) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->with('error', 'Failed to record payment.');
|
||||
}
|
||||
if (!empty($paymentResult['conflict'])) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
return redirect()->back()->withInput()->with('error', 'Payment idempotency key conflicts with a different request.');
|
||||
}
|
||||
$installmentSeq = (int) ($paymentResult['installment_seq'] ?? 1);
|
||||
|
||||
if (!$this->recordManualPayment(
|
||||
@@ -1029,6 +1002,7 @@ class PaymentController extends ResourceController
|
||||
$paymentDate
|
||||
)) {
|
||||
$this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
log_message('error', '[manualPayUpdate] Failed to record manual payment audit row: ' . json_encode($this->manualPaymentModel->errors()));
|
||||
return redirect()->back()->with('error', 'Payment was not recorded because the manual payment audit row could not be saved.');
|
||||
}
|
||||
@@ -1071,6 +1045,25 @@ class PaymentController extends ResourceController
|
||||
|
||||
$this->db->transCommit();
|
||||
|
||||
$evidenceWarning = null;
|
||||
if ($stagedEvidence !== null && empty($paymentResult['duplicate'])) {
|
||||
try {
|
||||
$checkFile = $this->financialAttachmentService->finalizeStagedFile($stagedEvidence);
|
||||
$this->paymentModel->update((int)($paymentResult['payment_id'] ?? 0), [
|
||||
'check_file' => $checkFile,
|
||||
'evidence_status' => 'complete',
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
|
||||
$evidenceWarning = 'Payment recorded, but evidence could not be finalized.';
|
||||
log_message('critical', '[manualPayUpdate] evidence incomplete for payment #' . (int)($paymentResult['payment_id'] ?? 0) . ': ' . $e->getMessage());
|
||||
$this->paymentModel->update((int)($paymentResult['payment_id'] ?? 0), [
|
||||
'evidence_status' => 'incomplete',
|
||||
'evidence_failure_message' => $evidenceWarning,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Build payload & notify
|
||||
[$eventData, $studentData] = $this->buildPaymentEventData(
|
||||
$invoiceId,
|
||||
@@ -1090,9 +1083,10 @@ class PaymentController extends ResourceController
|
||||
|
||||
return redirect()
|
||||
->to(site_url('payment/manual_pay?search_term=' . urlencode($searchTerm)))
|
||||
->with('success', 'Payment recorded successfully (Installment #' . $installmentSeq . '). Transaction ID: ' . $transactionId);
|
||||
->with($evidenceWarning ? 'warning' : 'success', ($evidenceWarning ?? 'Payment recorded successfully') . ' (Installment #' . $installmentSeq . '). Transaction ID: ' . $transactionId);
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->db->transStatus()) $this->db->transRollback();
|
||||
$this->financialAttachmentService->discardStagedFile($stagedEvidence ?? null);
|
||||
log_message('error', '[manualPayUpdate] ' . $e->getMessage());
|
||||
return redirect()->back()->withInput()->with('error', 'Unexpected error while recording payment.');
|
||||
}
|
||||
@@ -1402,8 +1396,22 @@ class PaymentController extends ResourceController
|
||||
}
|
||||
|
||||
if ($idempotencyKey !== null && $idempotencyKey !== '') {
|
||||
$fingerprint = $this->buildPaymentFingerprint(
|
||||
(int)$invoice['parent_id'],
|
||||
$invoiceId,
|
||||
(int)round($amount * 100),
|
||||
strtolower($paymentMethod),
|
||||
'USD'
|
||||
);
|
||||
$existing = $this->paymentModel->where('idempotency_key', $idempotencyKey)->first();
|
||||
if ($existing) {
|
||||
if ((string)($existing['request_fingerprint_hash'] ?? '') !== $fingerprint) {
|
||||
return [
|
||||
'conflict' => true,
|
||||
'duplicate' => false,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'payment_id' => (int) ($existing['id'] ?? 0),
|
||||
'installment_seq' => (int) ($existing['installment_seq'] ?? $existing['number_of_installments'] ?? 1),
|
||||
@@ -1427,22 +1435,22 @@ class PaymentController extends ResourceController
|
||||
if ($amount > $preBalance + 0.00001) {
|
||||
return false;
|
||||
}
|
||||
$newBalance = max(0.0, round($preBalance - $amount, 2));
|
||||
|
||||
$paymentData = [
|
||||
'parent_id' => (int) $invoice['parent_id'],
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => $invoice['total_amount'],
|
||||
'paid_amount' => $amount,
|
||||
'balance' => $newBalance,
|
||||
'balance' => null,
|
||||
'number_of_installments' => $installmentSeq, // <-- installment sequence (1,2,3,...)
|
||||
'installment_seq' => $installmentSeq,
|
||||
'transaction_id' => $transactionId,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'request_fingerprint_hash' => $idempotencyKey ? ($fingerprint ?? null) : null,
|
||||
'payment_method' => strtolower($paymentMethod),
|
||||
'payment_date' => $paymentDate,
|
||||
'status' => FinancialStatus::PAYMENT_RECORDED,
|
||||
'check_file' => $checkFile,
|
||||
'evidence_status' => $checkFile ? 'complete' : null,
|
||||
'check_number' => (strtolower($paymentMethod) === 'check') ? $checkNumber : null,
|
||||
'updated_by' => session()->get('user_id'),
|
||||
'school_year' => $schoolYear ?? ($invoice['school_year'] ?? $this->schoolYear),
|
||||
@@ -1460,6 +1468,23 @@ class PaymentController extends ResourceController
|
||||
];
|
||||
}
|
||||
|
||||
private function buildPaymentFingerprint(
|
||||
int $parentId,
|
||||
int $invoiceId,
|
||||
int $amountCents,
|
||||
string $paymentMethod,
|
||||
string $currency
|
||||
): string {
|
||||
return hash('sha256', json_encode([
|
||||
'operation_type' => 'payment_creation',
|
||||
'parent_id' => $parentId,
|
||||
'invoice_id' => $invoiceId,
|
||||
'amount_cents' => $amountCents,
|
||||
'payment_method' => strtolower($paymentMethod),
|
||||
'currency' => strtoupper($currency),
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
|
||||
private function getSuccessfulPaymentCount(int $invoiceId): int
|
||||
{
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\PaymentModel;
|
||||
use App\Models\FamilyGuardianModel;
|
||||
use App\Libraries\InvoiceLedgerService;
|
||||
use App\Services\EmailService;
|
||||
use App\Services\NotificationService;
|
||||
|
||||
@@ -20,6 +21,7 @@ class PaymentNotificationController extends BaseController
|
||||
protected PaymentModel $paymentModel;
|
||||
protected FamilyGuardianModel $familyGuardianModel;
|
||||
protected EmailService $emailService;
|
||||
protected InvoiceLedgerService $invoiceLedgerService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
@@ -30,6 +32,7 @@ class PaymentNotificationController extends BaseController
|
||||
$this->paymentModel = new PaymentModel();
|
||||
$this->familyGuardianModel = new FamilyGuardianModel();
|
||||
$this->emailService = new EmailService();
|
||||
$this->invoiceLedgerService = new InvoiceLedgerService();
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -87,43 +90,19 @@ class PaymentNotificationController extends BaseController
|
||||
$year = (int)$now->format('Y');
|
||||
$month= (int)$now->format('n');
|
||||
|
||||
// Helper: compute current total balance across invoices for this parent/year
|
||||
$computeBalance = function (int $pid) use ($schoolYear): array {
|
||||
$db = \Config\Database::connect();
|
||||
$rows = $db->table('invoices')
|
||||
->select('id, total_amount')
|
||||
->select('id')
|
||||
->where('parent_id', $pid)
|
||||
->where('school_year', $schoolYear)
|
||||
->get()->getResultArray();
|
||||
$paymentsTbl = 'payments';
|
||||
$hasStatus = $db->fieldExists('status', $paymentsTbl);
|
||||
$hasVoid = $db->fieldExists('is_void', $paymentsTbl);
|
||||
|
||||
$sumBalance = 0.0; $latestId = null;
|
||||
foreach ($rows as $ir) {
|
||||
$iid = (int)$ir['id'];
|
||||
if ($latestId === null) $latestId = $iid;
|
||||
$total = (float)($ir['total_amount'] ?? 0);
|
||||
|
||||
$qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid);
|
||||
if ($hasStatus) {
|
||||
$qb->groupStart()
|
||||
->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled'])
|
||||
->orWhere('status IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
if ($hasVoid) {
|
||||
$qb->groupStart()
|
||||
->where('is_void', 0)
|
||||
->orWhere('is_void IS NULL', null, false)
|
||||
->groupEnd();
|
||||
}
|
||||
$paid = (float)($qb->get()->getRowArray()['tot'] ?? 0);
|
||||
|
||||
$disc = (float)($db->table('discount_usages')->select('COALESCE(SUM(discount_amount),0) AS tot')->where('invoice_id', $iid)->get()->getRowArray()['tot'] ?? 0);
|
||||
$rfnd = (float)($db->table('refunds')->select('COALESCE(SUM(refund_paid_amount),0) AS tot')->where('invoice_id', $iid)->whereIn('status', ['Partial','Paid'])->get()->getRowArray()['tot'] ?? 0);
|
||||
|
||||
$sumBalance += max(0.0, round($total - $disc - $paid - $rfnd, 2));
|
||||
$sumBalance += (float)($this->invoiceLedgerService->calculateInvoice($iid)['balance'] ?? 0.0);
|
||||
}
|
||||
return [$sumBalance, $latestId];
|
||||
};
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\View;
|
||||
|
||||
use App\Models\PaymentTransactionModel;
|
||||
use CodeIgniter\RESTful\ResourceController;
|
||||
|
||||
class PaymentTransactionController extends ResourceController
|
||||
{
|
||||
protected $paymentTransactionModel;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->paymentTransactionModel = new PaymentTransactionModel();
|
||||
}
|
||||
|
||||
// API: Create a new payment transaction (installment)
|
||||
public function createAPI()
|
||||
{
|
||||
$data = [
|
||||
'transaction_id' => $this->request->getPost('transaction_id'),
|
||||
'payment_id' => $this->request->getPost('payment_id'),
|
||||
'transaction_date' => $this->request->getPost('transaction_date'),
|
||||
'amount' => $this->request->getPost('amount'),
|
||||
'payment_method' => $this->request->getPost('payment_method'),
|
||||
'payment_status' => 'Pending',
|
||||
'transaction_fee' => $this->request->getPost('transaction_fee'),
|
||||
'payment_reference' => $this->request->getPost('payment_reference'),
|
||||
'is_full_payment' => $this->request->getPost('is_full_payment') ? 1 : 0,
|
||||
];
|
||||
|
||||
if ($this->paymentTransactionModel->save($data)) {
|
||||
return $this->respondCreated($data);
|
||||
} else {
|
||||
return $this->failValidationErrors($this->paymentTransactionModel->errors());
|
||||
}
|
||||
}
|
||||
|
||||
// API: Get all transactions for a specific payment
|
||||
public function getByPaymentAPI($paymentId)
|
||||
{
|
||||
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
|
||||
if ($transactions) {
|
||||
return $this->respond($transactions);
|
||||
} else {
|
||||
return $this->failNotFound('Transactions not found for the payment.');
|
||||
}
|
||||
}
|
||||
|
||||
// View: Get all transactions for a specific payment (for web views)
|
||||
public function getByPayment($paymentId)
|
||||
{
|
||||
$transactions = $this->paymentTransactionModel->getTransactionsByPaymentId($paymentId);
|
||||
return view('payment_transaction_list', ['transactions' => $transactions]);
|
||||
}
|
||||
|
||||
// View: Create a new payment transaction
|
||||
public function create()
|
||||
{
|
||||
return view('payment_transaction_create');
|
||||
}
|
||||
|
||||
// API: Update payment status by transaction ID
|
||||
public function updateStatusAPI($transactionId)
|
||||
{
|
||||
$status = $this->request->getPost('status');
|
||||
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
|
||||
return $this->respond(['status' => 'success']);
|
||||
} else {
|
||||
return $this->failNotFound('Transaction not found.');
|
||||
}
|
||||
}
|
||||
|
||||
// View: Update payment status by transaction ID
|
||||
public function updateStatus($transactionId)
|
||||
{
|
||||
$status = $this->request->getPost('status');
|
||||
if ($this->paymentTransactionModel->updateTransactionStatus($transactionId, $status)) {
|
||||
return redirect()->to('/payment_transactions');
|
||||
} else {
|
||||
return redirect()->back()->with('error', 'Failed to update status.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,8 +79,11 @@ class PurchaseOrderController extends BaseController
|
||||
$subtotal = 0.0;
|
||||
$items = [];
|
||||
foreach ($supply_ids as $i => $sid) {
|
||||
$q = max(0, (int)($qtys[$i] ?? 0));
|
||||
$q = (int)($qtys[$i] ?? 0);
|
||||
$uc = (float)($unit_costs[$i] ?? 0);
|
||||
if ($sid && ($q <= 0 || $uc < 0)) {
|
||||
return redirect()->back()->withInput()->with('error', 'Quantity must be greater than zero and unit cost cannot be negative.');
|
||||
}
|
||||
if ($sid && $q > 0) {
|
||||
$line = $q * $uc;
|
||||
$subtotal += $line;
|
||||
@@ -151,77 +154,329 @@ class PurchaseOrderController extends BaseController
|
||||
*/
|
||||
public function receive($id)
|
||||
{
|
||||
$po = $this->poModel->find($id);
|
||||
if (!$po || in_array($po['status'], ['canceled','received'], true)) {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'PO not receivable.');
|
||||
}
|
||||
|
||||
$received = $this->request->getPost('received') ?? []; // [itemId => qty]
|
||||
if (!$received) {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'No items to receive.');
|
||||
}
|
||||
$idempotencyKey = trim((string)($this->request->getPost('idempotency_key') ?? ''));
|
||||
if ($idempotencyKey === '') {
|
||||
$idempotencyKey = bin2hex(random_bytes(16));
|
||||
}
|
||||
$fingerprint = $this->buildReceiptFingerprint((int)$id, $received);
|
||||
|
||||
$issuedBy = (string) (session('user.email') ?? session('user.username') ?? 'system');
|
||||
|
||||
$this->db->transStart();
|
||||
|
||||
$completed = true;
|
||||
foreach ($received as $itemId => $qty) {
|
||||
$qty = (int)$qty;
|
||||
if ($qty <= 0) continue;
|
||||
|
||||
$item = $this->itemModel->where('purchase_order_id', $id)->find($itemId);
|
||||
if (!$item) { $completed = false; continue; }
|
||||
|
||||
$remaining = (int)$item['quantity'] - (int)$item['received_qty'];
|
||||
$toReceive = min($remaining, $qty);
|
||||
if ($toReceive <= 0) continue;
|
||||
|
||||
// Update item received qty
|
||||
$this->itemModel->update($itemId, [
|
||||
'received_qty' => (int)$item['received_qty'] + $toReceive
|
||||
]);
|
||||
|
||||
// Update supply on hand
|
||||
$supply = $this->supplyModel->find($item['supply_id']);
|
||||
if (!$supply) { $completed = false; continue; }
|
||||
$newQty = (int)$supply['qty_on_hand'] + $toReceive;
|
||||
$this->supplyModel->update($supply['id'], ['qty_on_hand' => $newQty]);
|
||||
|
||||
// Log transaction IN
|
||||
$this->txnModel->insert([
|
||||
'supply_id' => $supply['id'],
|
||||
'type' => 'in',
|
||||
'quantity' => $toReceive,
|
||||
'ref' => 'PO ' . $po['po_number'],
|
||||
'issued_to' => 'Inventory',
|
||||
'issued_by' => $issuedBy,
|
||||
'notes' => 'Received against PO',
|
||||
]);
|
||||
|
||||
if (($item['received_qty'] + $toReceive) < $item['quantity']) {
|
||||
$completed = false;
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$existingOperation = $this->db->query(
|
||||
'SELECT * FROM inventory_receipt_operations WHERE idempotency_key = ? FOR UPDATE',
|
||||
[$idempotencyKey]
|
||||
)->getRowArray();
|
||||
if ($existingOperation) {
|
||||
if ((int)$existingOperation['purchase_order_id'] !== (int)$id || (string)$existingOperation['request_fingerprint_hash'] !== $fingerprint) {
|
||||
$this->db->transCommit();
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Receipt idempotency key conflicts with a different request.');
|
||||
}
|
||||
$this->db->transCommit();
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('success', 'Receipt already recorded.');
|
||||
}
|
||||
}
|
||||
|
||||
// Set PO status
|
||||
$this->poModel->update($id, ['status' => $completed ? 'received' : 'ordered']);
|
||||
$po = $this->db->query('SELECT * FROM purchase_orders WHERE id = ? FOR UPDATE', [(int) $id])->getRowArray();
|
||||
if (!$po || in_array($po['status'], ['canceled','received'], true)) {
|
||||
throw new \RuntimeException('PO not receivable.');
|
||||
}
|
||||
|
||||
$this->db->transComplete();
|
||||
if ($this->db->transStatus() === false) {
|
||||
$items = $this->db->query(
|
||||
'SELECT * FROM purchase_order_items WHERE purchase_order_id = ? FOR UPDATE',
|
||||
[(int) $id]
|
||||
)->getResultArray();
|
||||
if ($items === []) {
|
||||
throw new \RuntimeException('PO has no receivable lines.');
|
||||
}
|
||||
|
||||
$operationInserted = $this->db->table('inventory_receipt_operations')->insert([
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'purchase_order_id' => (int)$id,
|
||||
'request_fingerprint_hash' => $fingerprint,
|
||||
'status' => 'processing',
|
||||
'actor_id' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||
'created_at' => utc_now(),
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
$operationId = (int)$this->db->insertID();
|
||||
if (!$operationInserted) {
|
||||
$operationId = 0;
|
||||
}
|
||||
if ($operationId <= 0) {
|
||||
throw new \RuntimeException('Failed to create receipt operation.');
|
||||
}
|
||||
|
||||
$itemsById = [];
|
||||
foreach ($items as $item) {
|
||||
$ordered = (int) ($item['quantity'] ?? 0);
|
||||
$alreadyReceived = (int) ($item['received_qty'] ?? 0);
|
||||
if ($ordered <= 0 || $alreadyReceived < 0 || $alreadyReceived > $ordered) {
|
||||
throw new \RuntimeException('PO contains invalid received quantities.');
|
||||
}
|
||||
$itemsById[(int) $item['id']] = $item;
|
||||
}
|
||||
|
||||
foreach ($received as $itemId => $qty) {
|
||||
$itemId = (int) $itemId;
|
||||
$qty = (int)$qty;
|
||||
if ($qty <= 0) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($itemsById[$itemId])) {
|
||||
throw new \RuntimeException('Submitted item does not belong to this PO.');
|
||||
}
|
||||
|
||||
$item = $itemsById[$itemId];
|
||||
$remaining = (int)$item['quantity'] - (int)$item['received_qty'];
|
||||
if ($qty > $remaining) {
|
||||
throw new \RuntimeException('Received quantity exceeds ordered quantity.');
|
||||
}
|
||||
|
||||
$supply = $this->db->query('SELECT * FROM supplies WHERE id = ? FOR UPDATE', [(int) $item['supply_id']])->getRowArray();
|
||||
if (!$supply) {
|
||||
throw new \RuntimeException('Supply not found for PO line.');
|
||||
}
|
||||
|
||||
if (!$this->db->table('supplies')
|
||||
->where('id', (int) $supply['id'])
|
||||
->set('qty_on_hand', 'qty_on_hand + ' . $qty, false)
|
||||
->update()) {
|
||||
throw new \RuntimeException('Failed to update supply quantity.');
|
||||
}
|
||||
|
||||
$movementId = $this->txnModel->insert([
|
||||
'supply_id' => (int) $supply['id'],
|
||||
'type' => 'in',
|
||||
'quantity' => $qty,
|
||||
'ref' => 'PO ' . $po['po_number'],
|
||||
'issued_to' => 'Inventory',
|
||||
'issued_by' => $issuedBy,
|
||||
'notes' => 'Received against PO',
|
||||
]);
|
||||
if (!$movementId) {
|
||||
throw new \RuntimeException('Failed to record inventory transaction.');
|
||||
}
|
||||
if (!$this->db->table('inventory_receipt_lines')->insert([
|
||||
'operation_id' => $operationId,
|
||||
'purchase_order_item_id' => $itemId,
|
||||
'quantity' => $qty,
|
||||
'movement_id' => (int)$movementId,
|
||||
'reversed_quantity' => 0,
|
||||
'created_at' => utc_now(),
|
||||
])) {
|
||||
throw new \RuntimeException('Failed to record receipt line.');
|
||||
}
|
||||
|
||||
if (!$this->itemModel->update($itemId, [
|
||||
'received_qty' => (int)$item['received_qty'] + $qty
|
||||
])) {
|
||||
throw new \RuntimeException('Failed to update PO line quantity.');
|
||||
}
|
||||
|
||||
$itemsById[$itemId]['received_qty'] = (int)$item['received_qty'] + $qty;
|
||||
}
|
||||
|
||||
$completed = true;
|
||||
foreach ($itemsById as $item) {
|
||||
if ((int) $item['received_qty'] < (int) $item['quantity']) {
|
||||
$completed = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->poModel->update($id, ['status' => $completed ? 'received' : 'ordered'])) {
|
||||
throw new \RuntimeException('Failed to update PO status.');
|
||||
}
|
||||
$this->db->table('inventory_receipt_operations')
|
||||
->where('id', $operationId)
|
||||
->update(['status' => 'completed', 'updated_at' => utc_now()]);
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('PO receive transaction failed.');
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'Failed to receive PO #{po}: {msg}', ['po' => $id, 'msg' => $e->getMessage()]);
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Failed to receive items.');
|
||||
}
|
||||
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('success', $completed ? 'PO fully received.' : 'PO partially received.');
|
||||
}
|
||||
|
||||
public function reverseReceipt(int $operationId)
|
||||
{
|
||||
$reason = trim((string)($this->request->getPost('reason') ?? ''));
|
||||
if ($reason === '') {
|
||||
return redirect()->back()->with('error', 'Receipt reversal reason is required.');
|
||||
}
|
||||
|
||||
$issuedBy = (string) (session('user.email') ?? session('user.username') ?? 'system');
|
||||
$poId = 0;
|
||||
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$operation = $this->db->query(
|
||||
'SELECT * FROM inventory_receipt_operations WHERE id = ? FOR UPDATE',
|
||||
[$operationId]
|
||||
)->getRowArray();
|
||||
if (!$operation || (string)($operation['status'] ?? '') !== 'completed') {
|
||||
throw new \RuntimeException('Receipt operation is not reversible.');
|
||||
}
|
||||
|
||||
$poId = (int)$operation['purchase_order_id'];
|
||||
$po = $this->db->query('SELECT * FROM purchase_orders WHERE id = ? FOR UPDATE', [$poId])->getRowArray();
|
||||
if (!$po) {
|
||||
throw new \RuntimeException('Purchase order not found.');
|
||||
}
|
||||
|
||||
$lines = $this->db->query(
|
||||
'SELECT rl.*, poi.supply_id, poi.received_qty, poi.quantity AS ordered_quantity
|
||||
FROM inventory_receipt_lines rl
|
||||
JOIN purchase_order_items poi ON poi.id = rl.purchase_order_item_id
|
||||
WHERE rl.operation_id = ?
|
||||
FOR UPDATE',
|
||||
[$operationId]
|
||||
)->getResultArray();
|
||||
if ($lines === []) {
|
||||
throw new \RuntimeException('Receipt operation has no lines.');
|
||||
}
|
||||
|
||||
$reversedAny = false;
|
||||
foreach ($lines as $line) {
|
||||
$remaining = (int)$line['quantity'] - (int)$line['reversed_quantity'];
|
||||
if ($remaining <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$supply = $this->db->query('SELECT * FROM supplies WHERE id = ? FOR UPDATE', [(int)$line['supply_id']])->getRowArray();
|
||||
if (!$supply) {
|
||||
throw new \RuntimeException('Supply not found for receipt line.');
|
||||
}
|
||||
if ((int)($supply['qty_on_hand'] ?? 0) < $remaining) {
|
||||
throw new \RuntimeException('Insufficient inventory for receipt reversal.');
|
||||
}
|
||||
if ((int)$line['received_qty'] < $remaining) {
|
||||
throw new \RuntimeException('PO line received quantity cannot cover reversal.');
|
||||
}
|
||||
|
||||
if (!$this->db->table('supplies')
|
||||
->where('id', (int)$supply['id'])
|
||||
->set('qty_on_hand', 'qty_on_hand - ' . $remaining, false)
|
||||
->update()) {
|
||||
throw new \RuntimeException('Failed to update supply quantity.');
|
||||
}
|
||||
|
||||
$movementId = $this->txnModel->insert([
|
||||
'supply_id' => (int)$supply['id'],
|
||||
'type' => 'out',
|
||||
'quantity' => $remaining,
|
||||
'ref' => 'PO ' . ($po['po_number'] ?? $poId),
|
||||
'issued_to' => 'Inventory',
|
||||
'issued_by' => $issuedBy,
|
||||
'notes' => 'Reversal of receipt operation #' . $operationId . ': ' . $reason,
|
||||
]);
|
||||
if (!$movementId) {
|
||||
throw new \RuntimeException('Failed to record inventory reversal transaction.');
|
||||
}
|
||||
|
||||
if (!$this->db->table('purchase_order_items')
|
||||
->where('id', (int)$line['purchase_order_item_id'])
|
||||
->set('received_qty', 'received_qty - ' . $remaining, false)
|
||||
->update()) {
|
||||
throw new \RuntimeException('Failed to update PO line received quantity.');
|
||||
}
|
||||
|
||||
if (!$this->db->table('inventory_receipt_lines')
|
||||
->where('id', (int)$line['id'])
|
||||
->update([
|
||||
'reversed_quantity' => (int)$line['reversed_quantity'] + $remaining,
|
||||
'reversal_movement_id' => (int)$movementId,
|
||||
])) {
|
||||
throw new \RuntimeException('Failed to mark receipt line reversed.');
|
||||
}
|
||||
|
||||
$reversedAny = true;
|
||||
}
|
||||
|
||||
if (!$reversedAny) {
|
||||
throw new \RuntimeException('Receipt operation is already fully reversed.');
|
||||
}
|
||||
|
||||
$items = $this->db->query(
|
||||
'SELECT quantity, received_qty FROM purchase_order_items WHERE purchase_order_id = ? FOR UPDATE',
|
||||
[$poId]
|
||||
)->getResultArray();
|
||||
$allReceived = $items !== [];
|
||||
foreach ($items as $item) {
|
||||
$allReceived = $allReceived && (int)($item['received_qty'] ?? 0) >= (int)($item['quantity'] ?? 0);
|
||||
}
|
||||
|
||||
if (!$this->poModel->update($poId, ['status' => $allReceived ? 'received' : 'ordered'])) {
|
||||
throw new \RuntimeException('Failed to update PO status.');
|
||||
}
|
||||
|
||||
$this->db->table('inventory_receipt_operations')
|
||||
->where('id', $operationId)
|
||||
->update(['status' => 'reversed', 'updated_at' => utc_now()]);
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('Receipt reversal transaction failed.');
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'Failed to reverse receipt operation #{operation}: {msg}', [
|
||||
'operation' => $operationId,
|
||||
'msg' => $e->getMessage(),
|
||||
]);
|
||||
return redirect()->to($poId > 0 ? 'inventory/po/show/' . $poId : 'inventory/po')->with('error', 'Failed to reverse receipt.');
|
||||
}
|
||||
|
||||
return redirect()->to('inventory/po/show/' . $poId)->with('success', 'Receipt reversed.');
|
||||
}
|
||||
|
||||
public function cancel($id)
|
||||
{
|
||||
$po = $this->poModel->find($id);
|
||||
if (!$po || $po['status'] === 'received') {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Cannot cancel this PO.');
|
||||
}
|
||||
$this->poModel->update($id, ['status' => 'canceled']);
|
||||
$received = $this->itemModel
|
||||
->where('purchase_order_id', (int) $id)
|
||||
->where('received_qty >', 0)
|
||||
->countAllResults();
|
||||
if ($received > 0) {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Cannot cancel a partially received PO without inventory reversal.');
|
||||
}
|
||||
if (!$this->poModel->update($id, ['status' => 'canceled'])) {
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('error', 'Failed to cancel PO.');
|
||||
}
|
||||
return redirect()->to('inventory/po/show/'.$id)->with('success', 'PO canceled.');
|
||||
}
|
||||
|
||||
private function buildReceiptFingerprint(int $purchaseOrderId, array $received): string
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($received as $itemId => $qty) {
|
||||
$qty = (int)$qty;
|
||||
if ($qty > 0) {
|
||||
$normalized[(int)$itemId] = $qty;
|
||||
}
|
||||
}
|
||||
ksort($normalized);
|
||||
|
||||
return hash('sha256', json_encode([
|
||||
'operation_type' => 'inventory_receipt',
|
||||
'purchase_order_id' => $purchaseOrderId,
|
||||
'received' => $normalized,
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ use App\Models\ConfigurationModel;
|
||||
use App\Models\ReimbursementBatchModel;
|
||||
use App\Models\ReimbursementBatchItemModel;
|
||||
use App\Models\ReimbursementBatchAdminFileModel;
|
||||
use App\Libraries\FinancialStatus;
|
||||
use App\Services\EmailService;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
use CodeIgniter\HTTP\Files\UploadedFile;
|
||||
@@ -127,10 +128,13 @@ class ReimbursementController extends BaseController
|
||||
if ($year === '') {
|
||||
return 1;
|
||||
}
|
||||
$record = $this->batchModel
|
||||
->select('MAX(yearly_batch_number) AS max_number')
|
||||
->where('school_year', $year)
|
||||
->first();
|
||||
$record = $this->db->query(
|
||||
'SELECT COALESCE(MAX(yearly_batch_number), 0) AS max_number
|
||||
FROM reimbursement_batches
|
||||
WHERE school_year = ?
|
||||
FOR UPDATE',
|
||||
[$year]
|
||||
)->getRowArray();
|
||||
|
||||
$max = (int) ($record['max_number'] ?? 0);
|
||||
return $max + 1;
|
||||
@@ -553,22 +557,38 @@ class ReimbursementController extends BaseController
|
||||
$title = trim((string) ($this->request->getPost('title') ?? ''));
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$sequence = $this->nextYearlyBatchNumberForSchoolYear();
|
||||
|
||||
$data = [
|
||||
'title' => $title !== '' ? $title : null,
|
||||
'status' => 'open',
|
||||
'created_by' => $userId ?: null,
|
||||
'opened_at' => $now,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'yearly_batch_number' => $sequence,
|
||||
];
|
||||
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$sequence = $this->nextYearlyBatchNumberForSchoolYear();
|
||||
$data = [
|
||||
'title' => $title !== '' ? $title : null,
|
||||
'status' => 'open',
|
||||
'created_by' => $userId ?: null,
|
||||
'opened_at' => $now,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'yearly_batch_number' => $sequence,
|
||||
];
|
||||
|
||||
$this->batchModel->insert($data);
|
||||
$batchId = (int) $this->batchModel->getInsertID();
|
||||
if ($batchId <= 0) {
|
||||
throw new \RuntimeException('Batch insert failed.');
|
||||
}
|
||||
|
||||
$label = $title !== '' ? $title : 'Batch #' . $sequence;
|
||||
if ($title === '' && !$this->batchModel->update($batchId, ['title' => $label])) {
|
||||
throw new \RuntimeException('Batch title update failed.');
|
||||
}
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('Batch transaction failed.');
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'Failed to create reimbursement batch: {msg}', ['msg' => $e->getMessage()]);
|
||||
return $this->response->setStatusCode(500)->setJSON([
|
||||
'success' => false,
|
||||
@@ -576,18 +596,6 @@ class ReimbursementController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
if ($batchId <= 0) {
|
||||
return $this->response->setStatusCode(500)->setJSON([
|
||||
'success' => false,
|
||||
'error' => 'Failed to create batch.',
|
||||
]);
|
||||
}
|
||||
|
||||
$label = $title !== '' ? $title : 'Batch #' . $sequence;
|
||||
if ($title === '') {
|
||||
$this->batchModel->update($batchId, ['title' => $label]);
|
||||
}
|
||||
|
||||
$newHash = function_exists('csrf_hash') ? csrf_hash() : null;
|
||||
|
||||
return $this->response
|
||||
@@ -865,26 +873,55 @@ public function updateBatchAssignment()
|
||||
}
|
||||
}
|
||||
|
||||
$this->db->transBegin();
|
||||
|
||||
// Create reimbursement records for any batch items that don't yet have one
|
||||
$items = $this->db->table('reimbursement_batch_items bi')
|
||||
->select('bi.id AS batch_item_id, bi.reimbursement_id AS batch_reimb_id, bi.expense_id, e.amount, e.purchased_by, e.description, e.reimbursement_id AS expense_reimb_id, e.school_year AS expense_school_year, e.semester AS expense_semester')
|
||||
->join('expenses e', 'e.id = bi.expense_id', 'inner')
|
||||
->where('bi.batch_id', $batchId)
|
||||
->where('bi.unassigned_at IS NULL', null, false)
|
||||
->get()
|
||||
->getResultArray();
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
|
||||
$this->db->transBegin();
|
||||
|
||||
try {
|
||||
$lockedBatch = $this->db->query('SELECT * FROM reimbursement_batches WHERE id = ? FOR UPDATE', [$batchId])->getRowArray();
|
||||
if (!$lockedBatch || strtolower((string) ($lockedBatch['status'] ?? '')) !== 'open') {
|
||||
throw new \RuntimeException('Batch is not open.');
|
||||
}
|
||||
|
||||
$items = $this->db->query(
|
||||
'SELECT bi.id AS batch_item_id,
|
||||
bi.reimbursement_id AS batch_reimb_id,
|
||||
bi.expense_id,
|
||||
e.amount,
|
||||
e.purchased_by,
|
||||
e.category,
|
||||
e.status AS expense_status,
|
||||
e.description,
|
||||
e.reimbursement_id AS expense_reimb_id,
|
||||
e.school_year AS expense_school_year,
|
||||
e.semester AS expense_semester
|
||||
FROM reimbursement_batch_items bi
|
||||
JOIN expenses e ON e.id = bi.expense_id
|
||||
WHERE bi.batch_id = ?
|
||||
AND bi.unassigned_at IS NULL
|
||||
FOR UPDATE',
|
||||
[$batchId]
|
||||
)->getResultArray();
|
||||
|
||||
if ($items === []) {
|
||||
throw new \RuntimeException('Batch has no active items.');
|
||||
}
|
||||
|
||||
foreach ($items as $item) {
|
||||
$expenseId = (int) ($item['expense_id'] ?? 0);
|
||||
$recipientId = (int) ($item['purchased_by'] ?? 0);
|
||||
if ($expenseId <= 0 || $recipientId <= 0) {
|
||||
continue;
|
||||
throw new \RuntimeException('Batch contains an invalid expense or recipient.');
|
||||
}
|
||||
if (FinancialStatus::normalize((string) ($item['expense_status'] ?? '')) !== 'approved') {
|
||||
throw new \RuntimeException('Every batch expense must be approved before closing.');
|
||||
}
|
||||
if ((float) ($item['amount'] ?? 0) <= 0) {
|
||||
throw new \RuntimeException('Every batch expense amount must be positive.');
|
||||
}
|
||||
if (strcasecmp((string) ($item['category'] ?? ''), 'Donation') === 0) {
|
||||
throw new \RuntimeException('Donation expenses cannot be reimbursed.');
|
||||
}
|
||||
|
||||
$reimbId = $item['batch_reimb_id'] ?: ($item['expense_reimb_id'] ?: $this->lookupReimbursementId($expenseId));
|
||||
@@ -895,28 +932,31 @@ public function updateBatchAssignment()
|
||||
'reimbursed_to' => $recipientId,
|
||||
'approved_by' => $userId ?: null,
|
||||
'description' => trim((string) ($item['description'] ?? '')),
|
||||
'status' => 'Paid',
|
||||
'status' => FinancialStatus::REIMBURSEMENT_PAID,
|
||||
'added_by' => $userId ?: null,
|
||||
'school_year' => $item['expense_school_year'] ?: $this->schoolYear,
|
||||
'semester' => $item['expense_semester'] ?: $this->semester,
|
||||
'reimbursement_method' => 'Check',
|
||||
'batch_number' => $batchId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
$reimbId = $this->reimbModel->insert($payload);
|
||||
$reimbId = (int) $this->reimbModel->insert($payload);
|
||||
if ($reimbId <= 0) {
|
||||
throw new \RuntimeException('Failed to create reimbursement for batch item.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($reimbId) {
|
||||
$this->reimbModel->update($reimbId, [
|
||||
'batch_number' => $batchId,
|
||||
'approved_by' => $userId ?: null,
|
||||
'status' => 'Paid',
|
||||
]);
|
||||
$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbId]);
|
||||
if (!empty($item['batch_item_id'])) {
|
||||
$this->batchItemModel->update((int) $item['batch_item_id'], ['reimbursement_id' => $reimbId]);
|
||||
}
|
||||
if (!$this->reimbModel->update($reimbId, [
|
||||
'batch_number' => $batchId,
|
||||
'approved_by' => $userId ?: null,
|
||||
'status' => FinancialStatus::REIMBURSEMENT_PAID,
|
||||
])) {
|
||||
throw new \RuntimeException('Failed to update batch reimbursement.');
|
||||
}
|
||||
if (!$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbId])) {
|
||||
throw new \RuntimeException('Failed to link batch expense reimbursement.');
|
||||
}
|
||||
if (empty($item['batch_item_id']) || !$this->batchItemModel->update((int) $item['batch_item_id'], ['reimbursement_id' => $reimbId])) {
|
||||
throw new \RuntimeException('Failed to link batch item reimbursement.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -929,7 +969,9 @@ public function updateBatchAssignment()
|
||||
$update['closed_by'] = $userId;
|
||||
}
|
||||
|
||||
$this->batchModel->update($batchId, $update);
|
||||
if (!$this->batchModel->update($batchId, $update)) {
|
||||
throw new \RuntimeException('Failed to close reimbursement batch.');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
log_message('error', 'Failed to lock reimbursement batch #{batch}: {msg}', [
|
||||
@@ -1979,15 +2021,6 @@ public function updateBatchAssignment()
|
||||
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
|
||||
}
|
||||
|
||||
if ($expenseId > 0) {
|
||||
$expense = $this->expenseModel->find($expenseId);
|
||||
if ($expense && strcasecmp($expense['category'] ?? '', 'Donation') === 0) {
|
||||
return redirect()->back()->withInput()->with('errors', [
|
||||
'expense_id' => 'Donation expenses are tracked but should not be reimbursed.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Store file only if one was actually uploaded
|
||||
try {
|
||||
$receiptName = $this->saveReimbReceipt($this->request->getFile('receipt'));
|
||||
@@ -2001,77 +2034,34 @@ public function updateBatchAssignment()
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
$recipientId = (int) $this->request->getPost('reimbursed_to');
|
||||
|
||||
// Mark reimbursement as Paid when recorded
|
||||
$data = [
|
||||
'expense_id' => $expenseId ?: null,
|
||||
'amount' => $this->request->getPost('amount'),
|
||||
'reimbursed_to' => $recipientId,
|
||||
'description' => $this->request->getPost('description'),
|
||||
'reimbursement_method' => $method,
|
||||
'check_number' => $method === 'Check' ? $this->request->getPost('check_number') : null,
|
||||
'receipt_path' => $receiptName, // may be null for Cash
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'added_by' => $userId,
|
||||
'approved_by' => $userId,
|
||||
'status' => 'Paid',
|
||||
];
|
||||
|
||||
$this->reimbModel->insert($data);
|
||||
$reimbursementId = $this->reimbModel->getInsertID();
|
||||
|
||||
if ($expenseId = $this->request->getPost('expense_id')) {
|
||||
$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbursementId]);
|
||||
try {
|
||||
$this->createPaidReimbursementForExpense(
|
||||
$expenseId,
|
||||
(float) $this->request->getPost('amount'),
|
||||
$recipientId,
|
||||
$method,
|
||||
$method === 'Check' ? (string) $this->request->getPost('check_number') : null,
|
||||
$receiptName,
|
||||
(string) $this->request->getPost('description'),
|
||||
$userId
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
if ($receiptName !== null) {
|
||||
@unlink(WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . 'reimbursements' . DIRECTORY_SEPARATOR . basename($receiptName));
|
||||
}
|
||||
log_message('error', 'Reimbursement creation failed: {msg}', ['msg' => $e->getMessage()]);
|
||||
return redirect()->back()->withInput()->with('errors', [
|
||||
'expense_id' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->to('/reimbursements')->with('success', 'Reimbursement recorded as Paid.');
|
||||
}
|
||||
|
||||
|
||||
// Optional old flow kept for compatibility (also sets Paid)
|
||||
public function process()
|
||||
{
|
||||
$expenseId = (int) ($this->request->getPost('expense_id') ?? 0);
|
||||
if ($expenseId > 0) {
|
||||
$expense = $this->expenseModel->find($expenseId);
|
||||
if ($expense && strcasecmp($expense['category'] ?? '', 'Donation') === 0) {
|
||||
return redirect()->back()->withInput()->with('errors', [
|
||||
'expense_id' => 'Donation expenses are tracked but should not be reimbursed.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$receiptName = $this->saveReimbReceipt($this->request->getFile('receipt'));
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Failed to save reimbursement receipt in process(): {msg}', ['msg' => $e->getMessage()]);
|
||||
return redirect()->back()->withInput()->with('errors', [
|
||||
'receipt' => 'Failed to save uploaded file. Please try again or contact admin.'
|
||||
]);
|
||||
}
|
||||
|
||||
$userId = (int) (session()->get('user_id') ?? 0);
|
||||
$recipientId = (int) $this->request->getPost('reimbursed_to');
|
||||
|
||||
$reimbursementId = $this->reimbModel->insert([
|
||||
'amount' => $this->request->getPost('amount'),
|
||||
'reimbursed_to' => $recipientId,
|
||||
'approved_by' => $userId,
|
||||
'receipt_path' => $receiptName,
|
||||
'description' => 'Expense reimbursement',
|
||||
'status' => 'Paid',
|
||||
'added_by' => $userId,
|
||||
'school_year' => $this->schoolYear,
|
||||
'semester' => $this->semester,
|
||||
'check_number' => $this->request->getPost('check_number'),
|
||||
'reimbursement_method' => $this->request->getPost('reimbursement_method')
|
||||
]);
|
||||
|
||||
$this->expenseModel->update($expenseId, [
|
||||
'reimbursement_id' => $reimbursementId
|
||||
]);
|
||||
|
||||
return redirect()->to('/reimbursements/under-processing')->with('success', 'Reimbursement processed!');
|
||||
return $this->store();
|
||||
}
|
||||
|
||||
public function reimbursedExpenses()
|
||||
@@ -2118,6 +2108,9 @@ public function updateBatchAssignment()
|
||||
if (!$reimb) {
|
||||
throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found");
|
||||
}
|
||||
if ($this->isPaidReimbursement($reimb)) {
|
||||
return redirect()->to('/reimbursements')->with('error', 'Paid reimbursements are immutable. Reverse and replace the transaction instead.');
|
||||
}
|
||||
|
||||
$users = $this->recipientOptions();
|
||||
|
||||
@@ -2136,6 +2129,9 @@ public function updateBatchAssignment()
|
||||
if (!$reimb) {
|
||||
throw PageNotFoundException::forPageNotFound("Reimbursement #$id not found");
|
||||
}
|
||||
if ($this->isPaidReimbursement($reimb)) {
|
||||
return redirect()->to('/reimbursements')->with('error', 'Paid reimbursements are immutable. Reverse and replace the transaction instead.');
|
||||
}
|
||||
|
||||
$methodRaw = (string) $this->request->getPost('reimbursement_method');
|
||||
$method = ucfirst(strtolower($methodRaw));
|
||||
@@ -2198,6 +2194,162 @@ public function updateBatchAssignment()
|
||||
return redirect()->to('/reimbursements')->with('success', 'Reimbursement updated.');
|
||||
}
|
||||
|
||||
public function reverse(int $id)
|
||||
{
|
||||
$reason = trim((string)$this->request->getPost('reason'));
|
||||
if ($reason === '') {
|
||||
return redirect()->back()->with('error', 'Reversal reason is required.');
|
||||
}
|
||||
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$reimbursement = $this->db->query('SELECT * FROM reimbursements WHERE id = ? FOR UPDATE', [$id])->getRowArray();
|
||||
if (!$reimbursement) {
|
||||
throw new \RuntimeException('Reimbursement not found.');
|
||||
}
|
||||
if (FinancialStatus::normalizeReimbursementStatus($reimbursement['status'] ?? null) !== FinancialStatus::REIMBURSEMENT_PAID) {
|
||||
throw new \RuntimeException('Only paid reimbursements can be reversed.');
|
||||
}
|
||||
|
||||
$expenseId = (int)($reimbursement['expense_id'] ?? 0);
|
||||
if ($expenseId > 0) {
|
||||
$this->db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$expenseId])->getRowArray();
|
||||
}
|
||||
|
||||
$existingReversal = $this->db->table('reimbursement_reversals')
|
||||
->where('reimbursement_id', $id)
|
||||
->get()
|
||||
->getRowArray();
|
||||
if ($existingReversal) {
|
||||
throw new \RuntimeException('Reimbursement has already been reversed.');
|
||||
}
|
||||
|
||||
$amountCents = (int)round(((float)($reimbursement['amount'] ?? 0)) * 100);
|
||||
$now = utc_now();
|
||||
if (!$this->db->table('reimbursement_reversals')->insert([
|
||||
'reimbursement_id' => $id,
|
||||
'amount_cents' => $amountCents,
|
||||
'reason' => $reason,
|
||||
'reversed_by' => (int)(session()->get('user_id') ?? 0) ?: null,
|
||||
'reversed_at' => $now,
|
||||
'created_at' => $now,
|
||||
])) {
|
||||
throw new \RuntimeException('Reimbursement reversal could not be recorded.');
|
||||
}
|
||||
|
||||
if (!$this->reimbModel->update($id, ['status' => FinancialStatus::REIMBURSEMENT_REVERSED])) {
|
||||
throw new \RuntimeException('Reimbursement status could not be updated.');
|
||||
}
|
||||
if ($expenseId > 0 && !$this->expenseModel->update($expenseId, ['reimbursement_id' => null])) {
|
||||
throw new \RuntimeException('Expense reimbursement link could not be cleared.');
|
||||
}
|
||||
if ($this->db->transStatus() === false) {
|
||||
throw new \RuntimeException('Reimbursement reversal transaction failed.');
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
return redirect()->back()->with('error', $e->getMessage());
|
||||
}
|
||||
|
||||
return redirect()->to('/reimbursements')->with('success', 'Reimbursement reversed.');
|
||||
}
|
||||
|
||||
private function createPaidReimbursementForExpense(
|
||||
int $expenseId,
|
||||
float $amount,
|
||||
int $recipientId,
|
||||
string $method,
|
||||
?string $checkNumber,
|
||||
?string $receiptName,
|
||||
string $description,
|
||||
int $userId,
|
||||
?int $batchId = null
|
||||
): int {
|
||||
if ($expenseId <= 0) {
|
||||
throw new \RuntimeException('A valid approved expense is required.');
|
||||
}
|
||||
|
||||
$this->db->transBegin();
|
||||
try {
|
||||
$expense = $this->db->query('SELECT * FROM expenses WHERE id = ? FOR UPDATE', [$expenseId])->getRowArray();
|
||||
if (!$expense) {
|
||||
throw new \RuntimeException('Expense not found.');
|
||||
}
|
||||
if (FinancialStatus::normalize((string) ($expense['status'] ?? '')) !== 'approved') {
|
||||
throw new \RuntimeException('Expense must be approved before reimbursement.');
|
||||
}
|
||||
if ((float) ($expense['amount'] ?? 0) <= 0 || $amount <= 0) {
|
||||
throw new \RuntimeException('Reimbursement amount must be positive.');
|
||||
}
|
||||
if (abs((float) ($expense['amount'] ?? 0) - $amount) > 0.005) {
|
||||
throw new \RuntimeException('Reimbursement amount must match the approved expense.');
|
||||
}
|
||||
if (strcasecmp((string) ($expense['category'] ?? ''), 'Donation') === 0) {
|
||||
throw new \RuntimeException('Donation expenses are tracked but should not be reimbursed.');
|
||||
}
|
||||
if ($recipientId !== (int) ($expense['purchased_by'] ?? 0)) {
|
||||
throw new \RuntimeException('Reimbursement recipient must match the expense purchaser.');
|
||||
}
|
||||
if ((string) ($expense['school_year'] ?? '') !== (string) $this->schoolYear) {
|
||||
throw new \RuntimeException('Expense is outside the active school year.');
|
||||
}
|
||||
if (!empty($expense['semester']) && (string) $expense['semester'] !== (string) $this->semester) {
|
||||
throw new \RuntimeException('Expense is outside the active semester.');
|
||||
}
|
||||
|
||||
$active = $this->db->query(
|
||||
"SELECT id FROM reimbursements
|
||||
WHERE expense_id = ?
|
||||
AND LOWER(status) NOT IN ('reversed','rejected','cancelled','canceled','voided')
|
||||
FOR UPDATE",
|
||||
[$expenseId]
|
||||
)->getRowArray();
|
||||
if ($active || !empty($expense['reimbursement_id'])) {
|
||||
throw new \RuntimeException('Expense already has an active reimbursement.');
|
||||
}
|
||||
|
||||
$reimbursementId = (int) $this->reimbModel->insert([
|
||||
'expense_id' => $expenseId,
|
||||
'amount' => $amount,
|
||||
'reimbursed_to' => $recipientId,
|
||||
'description' => $description !== '' ? $description : (string) ($expense['description'] ?? ''),
|
||||
'reimbursement_method' => $method,
|
||||
'check_number' => $checkNumber,
|
||||
'receipt_path' => $receiptName,
|
||||
'school_year' => $expense['school_year'] ?: $this->schoolYear,
|
||||
'semester' => $expense['semester'] ?: $this->semester,
|
||||
'added_by' => $userId ?: null,
|
||||
'approved_by' => $userId ?: null,
|
||||
'status' => FinancialStatus::REIMBURSEMENT_PAID,
|
||||
'batch_number' => $batchId,
|
||||
]);
|
||||
if ($reimbursementId <= 0) {
|
||||
throw new \RuntimeException('Reimbursement insert failed.');
|
||||
}
|
||||
|
||||
if (!$this->expenseModel->update($expenseId, ['reimbursement_id' => $reimbursementId])) {
|
||||
throw new \RuntimeException('Expense reimbursement link failed.');
|
||||
}
|
||||
|
||||
if (!$this->db->transStatus()) {
|
||||
throw new \RuntimeException('Reimbursement transaction failed.');
|
||||
}
|
||||
|
||||
$this->db->transCommit();
|
||||
return $reimbursementId;
|
||||
} catch (\Throwable $e) {
|
||||
$this->db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function isPaidReimbursement(array $reimbursement): bool
|
||||
{
|
||||
return FinancialStatus::normalizeReimbursementStatus((string) ($reimbursement['status'] ?? '')) === FinancialStatus::REIMBURSEMENT_PAID;
|
||||
}
|
||||
|
||||
private function lookupReimbursementId(int $expenseId): ?int
|
||||
{
|
||||
if ($expenseId <= 0) {
|
||||
|
||||
Reference in New Issue
Block a user