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

This commit is contained in:
root
2026-07-18 22:57:40 -04:00
parent 068e739408
commit a30c1398a1
61 changed files with 10908 additions and 1775 deletions
+90 -65
View File
@@ -378,66 +378,22 @@ class PaymentController extends ResourceController
->orderBy('issue_date', 'DESC')
->findAll();
// Preload sums of actual payments/discounts/refunds per invoice to avoid stale invoice.paid_amount/balance
$paidByInvoice = [];
$discountByInvoice = [];
$refundByInvoice = [];
if (!empty($rawInvoices)) {
$invoiceIds = array_values(array_unique(array_map(static fn($r) => (int)($r['id'] ?? 0), $rawInvoices)));
if (!empty($invoiceIds)) {
$rows = $this->paymentModel
->select('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id')
->get()->getResultArray();
foreach ($rows as $r) {
$iid = (int)($r['invoice_id'] ?? 0);
$paidByInvoice[$iid] = (float)($r['total_paid'] ?? 0);
}
// Sum discounts per invoice_id
$discRows = $this->db->table('discount_usages')
->select('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
->whereIn('invoice_id', $invoiceIds)
->groupBy('invoice_id')
->get()->getResultArray();
foreach ($discRows as $dr) {
$iid = (int)($dr['invoice_id'] ?? 0);
$discountByInvoice[$iid] = (float)($dr['total_disc'] ?? 0);
}
// Sum PAID refunds per invoice_id (only Partial/Paid reduce liability)
$refRows = $this->db->table('refunds')
->select('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
->whereIn('invoice_id', $invoiceIds)
->whereIn('status', ['Partial','Paid'])
->groupBy('invoice_id')
->get()->getResultArray();
foreach ($refRows as $rr) {
$iid = (int)($rr['invoice_id'] ?? 0);
$refundByInvoice[$iid] = (float)($rr['total_refund_paid'] ?? 0);
}
}
}
// Normalize invoices for the view/JS; force due_ymd to the CONFIG date
$invoices = array_map(function (array $inv) use ($installmentEndYmd, $paidByInvoice, $discountByInvoice, $refundByInvoice) {
// Cast numeric fields we rely on
$inv['total_amount'] = isset($inv['total_amount']) ? (float) $inv['total_amount'] : 0.0;
// Prefer actual sum of payments for paid_amount
// Normalize invoices for the view/JS; all accounting amounts come from the ledger.
$invoices = array_map(function (array $inv) use ($installmentEndYmd) {
$iid = (int)($inv['id'] ?? 0);
$actualPaid = isset($paidByInvoice[$iid]) ? (float)$paidByInvoice[$iid] : null;
$inv['paid_amount'] = is_numeric($actualPaid)
? (float)$actualPaid
: (isset($inv['paid_amount']) ? (float)$inv['paid_amount'] : 0.0);
// Per-invoice discount
$inv['discount'] = isset($discountByInvoice[$iid]) ? (float)$discountByInvoice[$iid] : (float)($inv['discount'] ?? 0.0);
// Per-invoice refunds (paid out)
$inv['refund_paid'] = isset($refundByInvoice[$iid]) ? (float)$refundByInvoice[$iid] : 0.0;
// Derive balance from total - paid - discount - refundsPaid (never below 0)
$inv['balance'] = max(0.0, (float)$inv['total_amount'] - (float)$inv['paid_amount'] - (float)$inv['discount'] - (float)$inv['refund_paid']);
if ($iid > 0) {
$ledger = $this->invoiceLedgerService->recalculateInvoice($iid);
$inv['display_total'] = (float) ($ledger['total_amount'] ?? 0);
$inv['total_amount'] = $inv['display_total'];
$inv['paid_amount'] = (float) ($ledger['paid_amount'] ?? 0);
$inv['discount'] = (float) ($ledger['discount_total'] ?? 0);
$inv['refund_paid'] = (float) ($ledger['refund_paid_total'] ?? 0);
$inv['balance'] = (float) ($ledger['balance'] ?? 0);
$inv['balance_due_cents'] = (int) ($ledger['balanceDueCents'] ?? 0);
$inv['customer_credit_cents'] = (int) ($ledger['customerCreditCents'] ?? 0);
$inv['customer_credit'] = (float) ($ledger['customer_credit'] ?? 0);
$inv['status'] = (string) ($ledger['status'] ?? ($inv['status'] ?? ''));
}
// Always use the configured end date for installments
$inv['due_ymd'] = $installmentEndYmd;
@@ -889,6 +845,10 @@ class PaymentController extends ResourceController
$paymentMethod = strtolower(trim((string) $this->request->getPost('payment_method'))); // cash|check|card
$checkNumber = trim((string) $this->request->getPost('check_number'));
$searchTerm = $this->request->getPost('search_term') ?? $this->request->getGet('search_term') ?? '';
$idempotencyKey = trim((string)($this->request->getPost('idempotency_key') ?? ''));
if ($idempotencyKey === '') {
$idempotencyKey = bin2hex(random_bytes(16));
}
// UI-only
$paymentType = strtolower((string) ($this->request->getPost('payment_type') ?? 'full')); // full|installment
@@ -932,10 +892,11 @@ class PaymentController extends ResourceController
}
// Optional receipt upload
$stagedEvidence = null;
$checkFile = null;
$paymentFile = $this->request->getFile('payment_file');
try {
$checkFile = $this->financialAttachmentService->saveUploadedFile(
$stagedEvidence = $this->financialAttachmentService->stageUploadedFile(
$paymentFile,
$paymentMethod === 'check' ? 'checks' : ($paymentMethod === 'card' ? 'cards' : 'misc')
);
@@ -953,6 +914,7 @@ class PaymentController extends ResourceController
if (!$row) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->with('error', 'Invoice not found.');
}
@@ -965,6 +927,7 @@ class PaymentController extends ResourceController
$carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $invYear);
if ($carryForwardPaymentRequired && $paymentType === 'installment') {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->withInput()->with(
'error',
'This parent has a balance carried over from a previous school year. Installments are not allowed; payment must be made in full.'
@@ -973,6 +936,7 @@ class PaymentController extends ResourceController
if ($amount > $currentBalance + 0.00001) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->withInput()->with(
'error',
'Entered amount (' . number_format($amount, 2) . ') exceeds remaining balance (' . number_format($currentBalance, 2) . ').'
@@ -981,6 +945,7 @@ class PaymentController extends ResourceController
if ($carryForwardPaymentRequired && (float)round($amount, 2) !== (float)round($currentBalance, 2)) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->withInput()->with(
'error',
'This parent has a balance carried over from a previous school year. Payment must equal the full remaining balance (' . number_format($currentBalance, 2) . ').'
@@ -989,6 +954,7 @@ class PaymentController extends ResourceController
if ($paymentMethod === 'card' && (float)round($amount, 2) !== (float)round($currentBalance, 2)) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->withInput()->with(
'error',
'Debit/Credit Card payments must equal the full remaining balance (' . number_format($currentBalance, 2) . ').'
@@ -1010,13 +976,20 @@ class PaymentController extends ResourceController
$checkNumber,
null,
(array) $row,
$currentBalance
$currentBalance,
$idempotencyKey
);
if ($paymentResult === false) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->with('error', 'Failed to record payment.');
}
if (!empty($paymentResult['conflict'])) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
return redirect()->back()->withInput()->with('error', 'Payment idempotency key conflicts with a different request.');
}
$installmentSeq = (int) ($paymentResult['installment_seq'] ?? 1);
if (!$this->recordManualPayment(
@@ -1029,6 +1002,7 @@ class PaymentController extends ResourceController
$paymentDate
)) {
$this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
log_message('error', '[manualPayUpdate] Failed to record manual payment audit row: ' . json_encode($this->manualPaymentModel->errors()));
return redirect()->back()->with('error', 'Payment was not recorded because the manual payment audit row could not be saved.');
}
@@ -1071,6 +1045,25 @@ class PaymentController extends ResourceController
$this->db->transCommit();
$evidenceWarning = null;
if ($stagedEvidence !== null && empty($paymentResult['duplicate'])) {
try {
$checkFile = $this->financialAttachmentService->finalizeStagedFile($stagedEvidence);
$this->paymentModel->update((int)($paymentResult['payment_id'] ?? 0), [
'check_file' => $checkFile,
'evidence_status' => 'complete',
]);
} catch (\Throwable $e) {
$this->financialAttachmentService->discardStagedFile($stagedEvidence);
$evidenceWarning = 'Payment recorded, but evidence could not be finalized.';
log_message('critical', '[manualPayUpdate] evidence incomplete for payment #' . (int)($paymentResult['payment_id'] ?? 0) . ': ' . $e->getMessage());
$this->paymentModel->update((int)($paymentResult['payment_id'] ?? 0), [
'evidence_status' => 'incomplete',
'evidence_failure_message' => $evidenceWarning,
]);
}
}
// Build payload & notify
[$eventData, $studentData] = $this->buildPaymentEventData(
$invoiceId,
@@ -1090,9 +1083,10 @@ class PaymentController extends ResourceController
return redirect()
->to(site_url('payment/manual_pay?search_term=' . urlencode($searchTerm)))
->with('success', 'Payment recorded successfully (Installment #' . $installmentSeq . '). Transaction ID: ' . $transactionId);
->with($evidenceWarning ? 'warning' : 'success', ($evidenceWarning ?? 'Payment recorded successfully') . ' (Installment #' . $installmentSeq . '). Transaction ID: ' . $transactionId);
} catch (\Throwable $e) {
if ($this->db->transStatus()) $this->db->transRollback();
$this->financialAttachmentService->discardStagedFile($stagedEvidence ?? null);
log_message('error', '[manualPayUpdate] ' . $e->getMessage());
return redirect()->back()->withInput()->with('error', 'Unexpected error while recording payment.');
}
@@ -1402,8 +1396,22 @@ class PaymentController extends ResourceController
}
if ($idempotencyKey !== null && $idempotencyKey !== '') {
$fingerprint = $this->buildPaymentFingerprint(
(int)$invoice['parent_id'],
$invoiceId,
(int)round($amount * 100),
strtolower($paymentMethod),
'USD'
);
$existing = $this->paymentModel->where('idempotency_key', $idempotencyKey)->first();
if ($existing) {
if ((string)($existing['request_fingerprint_hash'] ?? '') !== $fingerprint) {
return [
'conflict' => true,
'duplicate' => false,
];
}
return [
'payment_id' => (int) ($existing['id'] ?? 0),
'installment_seq' => (int) ($existing['installment_seq'] ?? $existing['number_of_installments'] ?? 1),
@@ -1427,22 +1435,22 @@ class PaymentController extends ResourceController
if ($amount > $preBalance + 0.00001) {
return false;
}
$newBalance = max(0.0, round($preBalance - $amount, 2));
$paymentData = [
'parent_id' => (int) $invoice['parent_id'],
'invoice_id' => $invoiceId,
'total_amount' => $invoice['total_amount'],
'paid_amount' => $amount,
'balance' => $newBalance,
'balance' => null,
'number_of_installments' => $installmentSeq, // <-- installment sequence (1,2,3,...)
'installment_seq' => $installmentSeq,
'transaction_id' => $transactionId,
'idempotency_key' => $idempotencyKey,
'request_fingerprint_hash' => $idempotencyKey ? ($fingerprint ?? null) : null,
'payment_method' => strtolower($paymentMethod),
'payment_date' => $paymentDate,
'status' => FinancialStatus::PAYMENT_RECORDED,
'check_file' => $checkFile,
'evidence_status' => $checkFile ? 'complete' : null,
'check_number' => (strtolower($paymentMethod) === 'check') ? $checkNumber : null,
'updated_by' => session()->get('user_id'),
'school_year' => $schoolYear ?? ($invoice['school_year'] ?? $this->schoolYear),
@@ -1460,6 +1468,23 @@ class PaymentController extends ResourceController
];
}
private function buildPaymentFingerprint(
int $parentId,
int $invoiceId,
int $amountCents,
string $paymentMethod,
string $currency
): string {
return hash('sha256', json_encode([
'operation_type' => 'payment_creation',
'parent_id' => $parentId,
'invoice_id' => $invoiceId,
'amount_cents' => $amountCents,
'payment_method' => strtolower($paymentMethod),
'currency' => strtoupper($currency),
], JSON_UNESCAPED_SLASHES));
}
private function getSuccessfulPaymentCount(int $invoiceId): int
{
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];