@@ -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'];
|
||||
|
||||
Reference in New Issue
Block a user