Fix semester context, attendance rosters, and billing workflows
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 31s
Tests / PHPUnit (push) Failing after 55s

- 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:
root
2026-08-16 17:41:11 -04:00
parent 36c7e3fc6d
commit 0ac3a8375e
99 changed files with 1598 additions and 814 deletions
+91 -17
View File
@@ -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();