Fix semester context, attendance rosters, and billing workflows
- load global semester helpers consistently and use date-based semester defaults - fix grading and daily attendance duplicate student/section rows - keep attendance violations scoped to the current semester by default - update invoice, refund, discount, payment, and financial aid flows - add configuration cleanup migrations for duplicate calendar/semester keys - refresh parent registration/report-card and print request handling - update related models, services, views, cron notes, and test coverage
This commit is contained in:
@@ -16,6 +16,7 @@ use App\Models\PaymentModel;
|
||||
use App\Models\ConfigurationModel;
|
||||
use App\Models\InvoiceModel;
|
||||
use App\Models\EnrollmentModel;
|
||||
use App\Services\FeeCalculationService;
|
||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||
|
||||
class RefundController extends BaseController
|
||||
@@ -31,6 +32,7 @@ class RefundController extends BaseController
|
||||
protected ParentLedgerService $parentLedgerService;
|
||||
protected RefundEligibilityService $refundEligibilityService;
|
||||
protected FinancialAttachmentService $financialAttachmentService;
|
||||
protected FeeCalculationService $feeCalculationService;
|
||||
protected $db;
|
||||
|
||||
// Allowed request types (mapped to your `refunds.request` column)
|
||||
@@ -52,6 +54,7 @@ class RefundController extends BaseController
|
||||
$this->parentLedgerService = new ParentLedgerService();
|
||||
$this->refundEligibilityService = new RefundEligibilityService();
|
||||
$this->financialAttachmentService = new FinancialAttachmentService();
|
||||
$this->feeCalculationService = new FeeCalculationService();
|
||||
$this->db = \Config\Database::connect();
|
||||
}
|
||||
|
||||
@@ -70,6 +73,22 @@ class RefundController extends BaseController
|
||||
}
|
||||
}
|
||||
|
||||
private function refundStatusForStorage(string $status): string
|
||||
{
|
||||
return match (FinancialStatus::normalizeRefundStatus($status)) {
|
||||
FinancialStatus::REFUND_APPROVED => 'Approved',
|
||||
FinancialStatus::REFUND_REJECTED => 'Rejected',
|
||||
FinancialStatus::REFUND_PARTIALLY_PAID => 'Partial',
|
||||
FinancialStatus::REFUND_PAID => 'Paid',
|
||||
default => 'Pending',
|
||||
};
|
||||
}
|
||||
|
||||
private function refundOpenStatuses(): array
|
||||
{
|
||||
return ['Pending', 'Approved', 'Partial', 'pending', 'requested', 'approved', 'partial', 'partially_paid'];
|
||||
}
|
||||
|
||||
private function refundFailureResponse(
|
||||
string $publicCode,
|
||||
string $publicMessage,
|
||||
@@ -89,12 +108,12 @@ class RefundController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
/** Get current term (school_year, semester) from configuration */
|
||||
/** Get current school year from configuration and derive the current semester from calendar dates. */
|
||||
private function getCurrentTerm(): array
|
||||
{
|
||||
$rows = $this->configModel
|
||||
->select('config_key, config_value')
|
||||
->whereIn('config_key', ['school_year','semester'])
|
||||
->whereIn('config_key', ['school_year'])
|
||||
->findAll();
|
||||
|
||||
$map = [];
|
||||
@@ -103,7 +122,7 @@ class RefundController extends BaseController
|
||||
}
|
||||
return [
|
||||
'school_year' => $map['school_year'] ?? date('Y') . '-' . (date('Y') + 1),
|
||||
'semester' => $map['semester'] ?? 'Fall',
|
||||
'semester' => getSemester(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -140,7 +159,7 @@ class RefundController extends BaseController
|
||||
->where('invoice_id', $iid)
|
||||
->where('source_type', 'invoice_overpayment')
|
||||
->where('source_id', $iid)
|
||||
->whereIn('status', ['Pending','Approved','Partial'])
|
||||
->whereIn('status', $this->refundOpenStatuses())
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
if ($openRow) {
|
||||
@@ -161,7 +180,7 @@ class RefundController extends BaseController
|
||||
->where('invoice_id', $iid)
|
||||
->where('source_type', 'invoice_overpayment')
|
||||
->where('source_id', $iid)
|
||||
->whereIn('status', ['Pending','Approved','Partial','Paid'])
|
||||
->whereIn('status', array_merge($this->refundOpenStatuses(), ['Paid', 'paid']))
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
|
||||
@@ -176,7 +195,7 @@ class RefundController extends BaseController
|
||||
'approved_amount_cents' => null,
|
||||
'currency' => 'USD',
|
||||
'refund_paid_amount' => 0.00,
|
||||
'status' => FinancialStatus::REFUND_REQUESTED,
|
||||
'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_REQUESTED),
|
||||
'request' => 'overpayment',
|
||||
'source_type' => 'invoice_overpayment',
|
||||
'source_id' => $iid,
|
||||
@@ -393,7 +412,7 @@ class RefundController extends BaseController
|
||||
'approved_amount_cents' => null,
|
||||
'currency' => 'USD',
|
||||
'refund_paid_amount' => 0.00, // IMPORTANT: your column is NOT NULL
|
||||
'status' => FinancialStatus::REFUND_REQUESTED,
|
||||
'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_REQUESTED),
|
||||
'reason' => $reason,
|
||||
'request' => $requestType, // <- store the source/type here
|
||||
'source_type' => $sourceType,
|
||||
@@ -470,7 +489,7 @@ class RefundController extends BaseController
|
||||
$this->refundEligibilityService->validateRequestedAmount($eligibility, $requestedCents);
|
||||
|
||||
$ok = $this->refundModel->update($refundId, [
|
||||
'status' => FinancialStatus::REFUND_APPROVED,
|
||||
'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_APPROVED),
|
||||
'approved_amount_cents' => $requestedCents,
|
||||
'approved_at' => utc_now(),
|
||||
'approved_by' => session()->get('user_id'),
|
||||
@@ -513,7 +532,7 @@ class RefundController extends BaseController
|
||||
}
|
||||
|
||||
$ok = $this->refundModel->update($refundId, [
|
||||
'status' => FinancialStatus::REFUND_REJECTED,
|
||||
'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_REJECTED),
|
||||
'reason' => $this->request->getPost('reason') ?: ($refund['reason'] ?? null),
|
||||
'approved_at' => utc_now(),
|
||||
'approved_by' => session()->get('user_id'),
|
||||
@@ -697,7 +716,7 @@ class RefundController extends BaseController
|
||||
];
|
||||
if (!$isOnline) {
|
||||
$refundProjection['refund_paid_amount'] = $total;
|
||||
$refundProjection['status'] = $newStatus;
|
||||
$refundProjection['status'] = $this->refundStatusForStorage($newStatus);
|
||||
$refundProjection['refunded_at'] = utc_now();
|
||||
}
|
||||
if (!$this->refundModel->update($refundId, $refundProjection)) {
|
||||
@@ -865,7 +884,7 @@ class RefundController extends BaseController
|
||||
|
||||
if (!$this->refundModel->update($refundId, [
|
||||
'refund_paid_amount' => $netPaidCents / 100,
|
||||
'status' => $newStatus,
|
||||
'status' => $this->refundStatusForStorage($newStatus),
|
||||
'refunded_at' => $netPaidCents > 0 ? ($refund['refunded_at'] ?? utc_now()) : null,
|
||||
'updated_at' => utc_now(),
|
||||
'updated_by' => session()->get('user_id'),
|
||||
@@ -912,15 +931,17 @@ class RefundController extends BaseController
|
||||
/** Keep your listing; added extra fields for clarity */
|
||||
public function listRefunds()
|
||||
{
|
||||
// NOTE: We no longer auto-create/adjust refunds on page load to avoid duplicate lines
|
||||
// when staff are recording payouts. Use the "Recalculate" buttons to run detection on demand.
|
||||
// Repair legacy withdrawal placeholders only; overpayment recalculation remains explicit.
|
||||
$this->repairPendingWithdrawalRefunds();
|
||||
|
||||
// 2) List refunds with joins
|
||||
$refunds = $this->refundModel
|
||||
->select('refunds.*,
|
||||
i.invoice_number,
|
||||
u.firstname, u.lastname, u.school_id,
|
||||
a.firstname AS approved_by_firstname,
|
||||
a.lastname AS approved_by_lastname')
|
||||
->join('invoices i', 'refunds.invoice_id = i.id', 'left')
|
||||
->join('users u', 'refunds.parent_id = u.id')
|
||||
->join('users a', 'refunds.approved_by = a.id', 'left')
|
||||
->orderBy('refunds.created_at', 'DESC')
|
||||
@@ -970,6 +991,59 @@ class RefundController extends BaseController
|
||||
]);
|
||||
}
|
||||
|
||||
private function repairPendingWithdrawalRefunds(): void
|
||||
{
|
||||
try {
|
||||
$rows = $this->refundModel
|
||||
->groupStart()
|
||||
->where('refund_amount <=', 0)
|
||||
->orWhere('source_type IS NULL', null, false)
|
||||
->orWhere("(request = 'tuition' AND source_type = 'invoice_overpayment')", null, false)
|
||||
->orWhere('source_id IS NULL', null, false)
|
||||
->groupEnd()
|
||||
->whereIn('status', ['Pending', 'pending', 'requested'])
|
||||
->like('reason', 'Withdrawal under review')
|
||||
->findAll();
|
||||
|
||||
foreach ($rows as $refund) {
|
||||
$parentId = (int)($refund['parent_id'] ?? 0);
|
||||
$schoolYear = (string)($refund['school_year'] ?? '');
|
||||
if ($parentId <= 0 || $schoolYear === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$invoice = $this->invoiceModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->orderBy('created_at', 'DESC')
|
||||
->first();
|
||||
if (!$invoice) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$students = $this->enrollmentModel
|
||||
->where('parent_id', $parentId)
|
||||
->where('school_year', $schoolYear)
|
||||
->findAll();
|
||||
$refundAmount = $this->feeCalculationService->calculateRefund($students, $parentId);
|
||||
$refundCents = max(0, (int)round($refundAmount * 100));
|
||||
|
||||
$this->refundModel->update((int)$refund['id'], [
|
||||
'invoice_id' => (int)$invoice['id'],
|
||||
'refund_amount' => $refundAmount,
|
||||
'requested_amount_cents' => $refundCents,
|
||||
'currency' => 'USD',
|
||||
'request' => 'tuition',
|
||||
'source_type' => 'tuition_withdrawal',
|
||||
'source_id' => (int)$invoice['id'],
|
||||
'updated_at' => utc_now(),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', 'Pending withdrawal refund repair failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Manual endpoint to recalculate/sync overpayments and optionally notify newly created entries. */
|
||||
public function recalculateOverpayments()
|
||||
{
|
||||
@@ -991,7 +1065,7 @@ class RefundController extends BaseController
|
||||
->where('invoice_id', $iid)
|
||||
->where('source_type', 'invoice_overpayment')
|
||||
->where('source_id', $iid)
|
||||
->whereIn('status', ['Pending','Approved','Partial'])
|
||||
->whereIn('status', $this->refundOpenStatuses())
|
||||
->orderBy('id', 'DESC')
|
||||
->first();
|
||||
if ($openRow) {
|
||||
@@ -1028,7 +1102,7 @@ class RefundController extends BaseController
|
||||
'approved_amount_cents' => null,
|
||||
'currency' => 'USD',
|
||||
'refund_paid_amount' => 0.00,
|
||||
'status' => FinancialStatus::REFUND_REQUESTED,
|
||||
'status' => $this->refundStatusForStorage(FinancialStatus::REFUND_REQUESTED),
|
||||
'request' => 'overpayment',
|
||||
'source_type' => 'invoice_overpayment',
|
||||
'source_id' => $iid,
|
||||
@@ -1113,7 +1187,7 @@ class RefundController extends BaseController
|
||||
}
|
||||
|
||||
$ok = $this->refundModel->update($refundId, [
|
||||
'status' => $status,
|
||||
'status' => $this->refundStatusForStorage($status),
|
||||
'reason' => $reason,
|
||||
'approved_amount_cents' => $approvedCents,
|
||||
'approved_at' => utc_now(),
|
||||
@@ -1188,7 +1262,7 @@ class RefundController extends BaseController
|
||||
|
||||
private function lockRefundSourceForUpdate(string $sourceType, int $sourceId, ?int $invoiceId): void
|
||||
{
|
||||
if ($sourceType === 'invoice_overpayment') {
|
||||
if (in_array($sourceType, ['invoice_overpayment', 'tuition_withdrawal'], true)) {
|
||||
$this->db->query('SELECT id FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId ?: $sourceId])->getRowArray();
|
||||
} elseif (in_array($sourceType, ['payment_duplicate', 'payment_correction'], true)) {
|
||||
$payment = $this->db->query('SELECT * FROM payments WHERE id = ? FOR UPDATE', [$sourceId])->getRowArray();
|
||||
|
||||
Reference in New Issue
Block a user