Files
alrahma_sunday_school/app/Views/refunds/list.php
T
root d906a915d6
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Failing after 1m20s
add refund logic and fix books inventory logic
2026-08-22 13:44:25 -04:00

415 lines
17 KiB
PHP

<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid mt-4">
<h2 class="text-center mt-4 mb-3">Refunds</h2>
<?php
$withdrawalReviews = $withdrawalReviews ?? [];
$refunds = $refunds ?? [];
$moneyFromCents = static fn($cents): string => '$' . number_format(((int) $cents) / 100, 2);
$dateOnly = static function($dt) {
if (empty($dt) || $dt === '0000-00-00 00:00:00') return '-';
return local_date($dt, 'm-d-Y');
};
$normalizeRefundStatus = static function($status): string {
$key = strtolower(str_replace(' ', '_', trim((string) $status)));
if ($key === 'requested') return 'pending';
if ($key === 'partially_paid') return 'partial';
return $key !== '' ? $key : 'pending';
};
$summary = [
'reviews' => count($withdrawalReviews),
'approval' => 0,
'payment' => 0,
'complete' => 0,
];
foreach ($refunds as $refund) {
$key = $normalizeRefundStatus($refund['status'] ?? '');
$isWithdrawalRefund = (string)($refund['source_type'] ?? '') === 'tuition_withdrawal';
$amount = (float) ($refund['refund_amount'] ?? 0);
$paid = (float) ($refund['refund_paid_amount'] ?? 0);
$remaining = max(0, $amount - $paid);
if ($key === 'pending' && !$isWithdrawalRefund) {
$summary['approval']++;
} elseif (in_array($key, ['approved', 'partial'], true) && $remaining > 0) {
$summary['payment']++;
} elseif (in_array($key, ['paid', 'rejected'], true)) {
$summary['complete']++;
}
}
?>
<!-- Flash messages -->
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= session()->getFlashdata('success') ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger"><?= session()->getFlashdata('error') ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('info')): ?>
<div class="alert alert-info mb-3 d-inline-block"><?= session()->getFlashdata('info') ?></div>
<?php endif; ?>
<div class="row g-3 mb-4">
<div class="col-md-3"><div class="card h-100"><div class="card-body"><div class="text-muted small">Withdrawal reviews</div><div class="fs-4 fw-semibold"><?= (int) $summary['reviews'] ?></div></div></div></div>
<div class="col-md-3"><div class="card h-100"><div class="card-body"><div class="text-muted small">Need approval</div><div class="fs-4 fw-semibold"><?= (int) $summary['approval'] ?></div></div></div></div>
<div class="col-md-3"><div class="card h-100"><div class="card-body"><div class="text-muted small">Ready to pay</div><div class="fs-4 fw-semibold"><?= (int) $summary['payment'] ?></div></div></div></div>
<div class="col-md-3"><div class="card h-100"><div class="card-body"><div class="text-muted small">Closed</div><div class="fs-4 fw-semibold"><?= (int) $summary['complete'] ?></div></div></div></div>
</div>
<?php if (!empty($withdrawalReviews)): ?>
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Withdrawal Review Queue</strong>
<span class="badge bg-warning text-dark"><?= count($withdrawalReviews) ?> pending</span>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-bordered table-striped align-middle w-100 mb-0 no-mgmt-sticky no-dt-fixedheader" data-no-mgmt-sticky data-no-dt-fixedheader>
<thead>
<tr>
<th>Parent</th>
<th>Student</th>
<th>Invoice&nbsp;#</th>
<th>Requested</th>
<th>Expected Refund</th>
<th>Balance Due</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($withdrawalReviews as $review): ?>
<?php
$reviewStatus = (string) ($review['status'] ?? '');
$reviewStatusClass = $reviewStatus === 'requires_review' ? 'danger' : 'warning text-dark';
?>
<tr>
<td><?= esc(trim(($review['parent_firstname'] ?? '') . ' ' . ($review['parent_lastname'] ?? '')) ?: '-') ?></td>
<td><?= esc(trim(($review['student_firstname'] ?? '') . ' ' . ($review['student_lastname'] ?? '')) ?: '-') ?></td>
<td><?= esc($review['invoice_number'] ?? '-') ?></td>
<td>
<div><?= esc($dateOnly($review['withdrawal_request_date'] ?? null)) ?></div>
<div class="text-muted small">Calculated <?= esc($dateOnly($review['calculated_at'] ?? null)) ?></div>
</td>
<td class="fw-semibold"><?= esc($moneyFromCents($review['new_refund_request_cents'] ?? 0)) ?></td>
<td><?= esc($moneyFromCents($review['balance_due_cents'] ?? 0)) ?></td>
<td><span class="badge bg-<?= esc($reviewStatusClass) ?>"><?= esc($reviewStatus ?: 'preview') ?></span></td>
<td>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-primary btn-sm" href="<?= site_url('administrator/withdrawals/' . (int) $review['enrollment_id'] . '/review') ?>">Open Review</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<?php endif; ?>
<div class="d-flex justify-content-between align-items-center mb-2">
<h4 class="mb-0">Refund Queue</h4>
</div>
<div class="table-responsive">
<table id="refundsTable" class="table table-bordered table-striped align-middle w-100 no-mgmt-sticky no-dt-fixedheader" data-no-mgmt-sticky data-no-dt-fixedheader>
<thead>
<tr>
<th>Parent</th>
<th>Invoice&nbsp;#</th>
<th>Amount</th>
<th>Status</th>
<th>Payment</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php foreach ($refunds as $r): ?>
<?php
// Date formatting (keep raw if null)
$fmt = function($dt) {
if (empty($dt) || $dt === '0000-00-00 00:00:00') return '-';
// show date only; assumes DB is UTC
return local_date($dt, 'm-d-Y');
};
$statusRaw = (string)($r['status'] ?? '');
$statusKey = $normalizeRefundStatus($statusRaw);
$statusLabel = [
'pending' => 'Pending',
'approved' => 'Approved',
'rejected' => 'Rejected',
'partial' => 'Partial',
'paid' => 'Paid',
][$statusKey] ?? ($statusRaw !== '' ? $statusRaw : 'Pending');
$statusClass = [
'pending' => 'warning text-dark',
'approved' => 'primary',
'rejected' => 'danger',
'partial' => 'info text-dark',
'paid' => 'success',
][$statusKey] ?? 'secondary';
$refundAmount = (float)($r['refund_amount'] ?? 0);
$paidAmount = (float)($r['refund_paid_amount'] ?? 0);
$remainingAmount = max(0, $refundAmount - $paidAmount);
$hasSource = !empty($r['source_type']) && !empty($r['source_id']);
$isWithdrawalRefund = (string)($r['source_type'] ?? '') === 'tuition_withdrawal';
$canApprove = !$isWithdrawalRefund && $statusKey === 'pending' && $refundAmount > 0 && $hasSource;
$canReject = !$isWithdrawalRefund && $statusKey === 'pending';
$canRecord = in_array($statusKey, ['approved', 'partial'], true) && $remainingAmount > 0;
$nextStep = 'Closed';
if ($statusKey === 'pending') {
$nextStep = $isWithdrawalRefund ? 'Approved by withdrawal review' : ($canApprove ? 'Needs approval' : 'Needs review');
} elseif ($canRecord) {
$nextStep = 'Ready to pay';
} elseif ($statusKey === 'paid') {
$nextStep = 'Paid';
} elseif ($statusKey === 'rejected') {
$nextStep = 'Rejected';
}
?>
<tr>
<td><?= esc(($r['firstname'] ?? '').' '.($r['lastname'] ?? '')) ?></td>
<td><?= esc($r['invoice_number'] ?? '-') ?></td>
<td>
<div class="fw-semibold">$<?= esc(number_format($refundAmount, 2)) ?></div>
<div class="text-muted small"><?= esc($fmt($r['requested_at'] ?? null)) ?></div>
</td>
<td>
<div><span class="badge bg-<?= esc($statusClass) ?>"><?= esc($statusLabel) ?></span></div>
<div class="text-muted small mt-1"><?= esc($nextStep) ?></div>
<?php if (!empty($r['approved_at']) && $r['approved_at'] !== '0000-00-00 00:00:00'): ?>
<div class="text-muted small mt-1">
<?= esc($fmt($r['approved_at'])) ?>
<?php if (!empty($r['approved_by_name']) && $r['approved_by_name'] !== '-'): ?>
by <?= esc($r['approved_by_name']) ?>
<?php endif; ?>
</div>
<?php endif; ?>
</td>
<td>
<div><span class="text-muted small">Paid:</span> $<?= esc(number_format($paidAmount, 2)) ?></div>
<div><span class="text-muted small">Remaining:</span> $<?= esc(number_format($remainingAmount, 2)) ?></div>
<div><span class="text-muted small">Check #:</span> <?= esc($r['check_nbr'] ?? '-') ?></div>
<div>
<span class="text-muted small">Check File:</span>
<?php if (!empty($r['check_file'])): ?>
<a href="<?= base_url('refunds/file/' . (int) $r['id'] . '/inline') ?>" target="_blank">View</a>
<?php else: ?>
-
<?php endif; ?>
</div>
</td>
<td>
<div class="d-flex gap-2 flex-wrap">
<?php if ($canRecord): ?>
<button class="btn btn-success btn-sm" onclick="handleRecordRefundClick(<?= (int)$r['id'] ?>, '<?= esc($statusLabel) ?>', <?= $remainingAmount ?>)">Pay</button>
<?php endif; ?>
<?php if ($canApprove): ?>
<button class="btn btn-primary btn-sm" onclick="handleStatusClick(<?= (int)$r['id'] ?>, 'Approved', <?= $refundAmount ?>)">Approve</button>
<?php endif; ?>
<?php if ($canReject): ?>
<button class="btn btn-outline-danger btn-sm" onclick="handleStatusClick(<?= (int)$r['id'] ?>, 'Rejected', <?= max($refundAmount, 0.01) ?>)">Reject</button>
<?php endif; ?>
<?php if (!$canRecord && !$canApprove && !$canReject): ?>
<span class="text-muted small">No action</span>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Modal -->
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<!-- APPROVE / REJECT FORM -->
<form id="statusForm" method="post" action="/refunds/updateStatus" class="d-none">
<?= csrf_field() ?>
<div class="modal-header">
<h5 class="modal-title">Update Refund Status</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" name="refund_id" id="refundIdStatus">
<input type="hidden" name="status" id="newStatus">
<p id="confirmationText" class="mb-2">
Please provide a reason to <strong id="statusText"></strong> this refund.
</p>
<div class="mb-3">
<label for="reason" class="form-label">Reason <span class="text-danger">*</span></label>
<textarea class="form-control" name="reason" id="reason" rows="3" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Confirm</button>
</div>
</form>
<!-- PAYMENT FORM -->
<form id="paymentForm" method="post" action="/refunds/updatePayment" enctype="multipart/form-data" class="d-none">
<?= csrf_field() ?>
<div class="modal-header">
<h5 class="modal-title">Record Refund</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" name="refund_id" id="refundIdPayment">
<input type="hidden" name="idempotency_key" id="refundPayoutIdempotencyKey">
<div class="mb-3">
<label for="paidAmount" class="form-label">Paid Amount</label>
<input type="number" step="0.01" min="0.01" class="form-control" name="paid_amount" id="paidAmount" required>
</div>
<div class="mb-3">
<label for="paymentMethod" class="form-label">Payment Method</label>
<select class="form-select" name="payment_method" id="paymentMethod" required>
<option value="">-- Select Method --</option>
<option value="Check">Check</option>
<option value="Online">Online</option>
<option value="Cash">Cash</option>
</select>
</div>
<div id="checkDetails" class="d-none">
<div class="mb-3">
<label for="checkNumber" class="form-label">Check Number</label>
<input type="text" class="form-control" name="check_number" id="checkNumber">
</div>
<div class="mb-3">
<label for="checkFile" class="form-label">Upload Check Image</label>
<input type="file" class="form-control" name="check_file" id="checkFile" accept=".jpg,.jpeg,.png,.pdf">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Submit Payment</button>
</div>
</form>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
$(function () {
// DataTable
$('#refundsTable').DataTable({
pageLength: 25,
order: [[2, 'desc']], // order by Requested desc
scrollX: true,
autoWidth: false,
fixedHeader: false,
});
// Status form (approve/reject)
$('#statusForm').on('submit', function (e) {
e.preventDefault();
const reason = $('#reason').val().trim();
if (!reason) {
alert('Reason is required.');
return;
}
$.post($(this).attr('action'), $(this).serialize())
.done(function (resp) { alert(resp.success || resp.error); location.reload(); })
.fail(function () { alert('An error occurred. Please try again.'); });
});
// Payment form
$('#paymentForm').on('submit', function (e) {
e.preventDefault();
const fd = new FormData(this);
$.ajax({
url: $(this).attr('action'),
method: 'POST',
data: fd,
processData: false,
contentType: false
})
.done(function (resp) { alert(resp.success || resp.error); location.reload(); })
.fail(function () { alert('An error occurred. Please try again.'); });
});
// Toggle check details by method
$('#paymentMethod').on('change', function () {
if ($(this).val() === 'Check') {
$('#checkDetails').removeClass('d-none');
} else {
$('#checkDetails').addClass('d-none');
$('#checkNumber').val('');
$('#checkFile').val('');
}
});
});
// ---- Actions ----
function normalizeRefundStatus(status) {
return String(status || '').trim().toLowerCase().replace(/\s+/g, '_');
}
function handleRecordRefundClick(refundId, status, amount) {
const normalized = normalizeRefundStatus(status);
if (!['approved','partial','partially_paid'].includes(normalized)) {
alert('Refund must be approved or partial before recording a payout.');
return;
}
if (!amount || parseFloat(amount) <= 0) {
alert('Refund amount is not set.');
return;
}
showPaymentModal(refundId, amount);
}
function handleStatusClick(refundId, status, amount) {
const normalized = normalizeRefundStatus(status);
if (normalized !== 'rejected' && (!amount || parseFloat(amount) <= 0)) {
alert('Refund amount is not set.');
return;
}
showStatusModal(refundId, status);
}
function showStatusModal(refundId, status) {
$('#paymentForm').addClass('d-none');
$('#statusForm').removeClass('d-none');
$('#refundIdStatus').val(refundId);
$('#newStatus').val(status);
$('#reason').val('');
$('#statusText').text(status.toLowerCase());
$('#statusModal').modal('show');
}
function showPaymentModal(refundId, amount) {
$('#statusForm').addClass('d-none');
$('#paymentForm').removeClass('d-none');
$('#refundIdPayment').val(refundId);
$('#refundPayoutIdempotencyKey').val(
(window.crypto && crypto.randomUUID)
? crypto.randomUUID()
: ('refund-' + refundId + '-' + Date.now() + '-' + Math.random().toString(16).slice(2))
);
$('#paidAmount').val(Number(amount || 0).toFixed(2));
$('#paidAmount').attr('max', Number(amount || 0).toFixed(2));
$('#paymentMethod').val('').trigger('change');
$('#checkDetails').addClass('d-none');
$('#checkNumber').val('');
$('#checkFile').val('');
$('#statusModal').modal('show');
}
</script>
<?= $this->endSection() ?>