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
+43 -20
View File
@@ -64,7 +64,7 @@ class PaymentController extends ResourceController
$this->studentModel = new StudentModel();
$this->studentClassModel = new StudentClassModel();
$this->schoolYear = $this->currentSchoolYearName();
$this->semester = $this->configModel->getConfig('semester'); //installment_date
$this->semester = getSemester(); //installment_date
$this->installmentDate = $this->configModel->getConfig('installment_date');
$this->discountUsageModel = new DiscountUsageModel();
$this->additionalChargeModel = new AdditionalChargeModel();
@@ -151,6 +151,15 @@ class PaymentController extends ResourceController
}
}
private function activeSchoolYearName(): string
{
try {
return service('schoolYearContext')->active()->yearName();
} catch (\Throwable $e) {
return (string) ($this->configModel->getConfig('school_year') ?? '');
}
}
private function assertSchoolYearNameWritable(string $schoolYear): void
{
service('schoolYearWriteGuard')->assertWritable(
@@ -323,6 +332,7 @@ class PaymentController extends ResourceController
// Read the search term (email or phone)
$searchTerm = trim((string) $this->request->getGet('search_term'));
$manualPaySchoolYear = $this->activeSchoolYearName();
// --- Installment end date comes ONLY from config ---
$installmentDateRaw = (string) ($this->installmentDate ?? '');
@@ -338,29 +348,36 @@ class PaymentController extends ResourceController
$searchClean = preg_replace('/\D/', '', $searchTerm);
$searchFormatted = $this->formatPhone($searchClean);
$builder = $this->db->table('users');
$builder->select('*');
$builder = $this->db->table('users u');
$builder->distinct();
$builder->select('u.*');
$builder->join('user_roles ur', 'ur.user_id = u.id', 'inner');
$builder->join('roles r', 'r.id = ur.role_id', 'inner');
$builder->where('LOWER(r.name)', 'parent');
// Build search conditions
$builder->groupStart();
$builder->where('email', $searchTerm);
$builder->where('u.email', $searchTerm);
$builder->orLike("CONCAT_WS(' ', u.firstname, u.lastname)", $searchTerm, 'both', null, true);
$builder->orLike('u.firstname', $searchTerm);
$builder->orLike('u.lastname', $searchTerm);
if (!empty($searchClean)) {
if (strlen($searchClean) === 10) {
$formattedPhone = substr($searchClean, 0, 3) . '-' . substr($searchClean, 3, 3) . '-' . substr($searchClean, 6, 4);
$builder->orWhere('cellphone', $formattedPhone);
$builder->orWhere('u.cellphone', $formattedPhone);
}
// compare stripped formatting
$builder->orWhere(
"REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') =",
"REPLACE(REPLACE(REPLACE(REPLACE(u.cellphone, '(', ''), ')', ''), '-', ''), ' ', '') =",
$this->db->escapeString($searchClean),
false
);
}
if (!empty($searchFormatted)) {
$builder->orWhere('cellphone', $searchFormatted);
$builder->orWhere('u.cellphone', $searchFormatted);
}
$builder->groupEnd();
@@ -374,7 +391,7 @@ class PaymentController extends ResourceController
if ($parent && !empty($parent['id'])) {
$parentData = $parent;
$parentId = (int) $parent['id'];
$carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $this->schoolYear);
$carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $manualPaySchoolYear);
if ($carryForwardPaymentRequired) {
$carryForwardPaymentMessage = 'This parent has a balance carried over from a previous school year. Manual payments must be paid in full; installments are not allowed.';
}
@@ -386,14 +403,14 @@ class PaymentController extends ResourceController
// Payments (paginated). Join invoices so history is filtered by invoice term
// and displays current invoice state instead of stale payment snapshots.
$selectedYear = $this->getSelectedPaymentHistoryYear();
$paymentHistory = $this->paymentModel->parentPaymentHistoryQuery($parentId, $selectedYear);
$paymentHistory = $this->paymentModel->parentPaymentHistoryQuery($parentId, $manualPaySchoolYear);
$payments = $paymentHistory->paginate(10);
$pager = $paymentHistory->pager;
// Invoices
$rawInvoices = $this->invoiceModel
->where('parent_id', $parentId) // <- fixed (removed "value:")
->where('parent_id', $parentId)
->where('school_year', $manualPaySchoolYear)
->orderBy('issue_date', 'DESC')
->findAll();
@@ -460,21 +477,23 @@ class PaymentController extends ResourceController
$qs = '%' . $db->escapeLikeString($q) . '%';
$digits = preg_replace('/\D/', '', $q);
$sql = "SELECT id, firstname, lastname, email, cellphone
FROM users
$sql = "SELECT DISTINCT u.id, u.firstname, u.lastname, u.email, u.cellphone
FROM users u
JOIN user_roles ur ON ur.user_id = u.id
JOIN roles r ON r.id = ur.role_id AND LOWER(r.name) = 'parent'
WHERE (
CONCAT_WS(' ', firstname, lastname) LIKE ?
OR email LIKE ?
OR cellphone LIKE ?";
CONCAT_WS(' ', u.firstname, u.lastname) LIKE ?
OR u.email LIKE ?
OR u.cellphone LIKE ?";
$params = [$qs, $qs, $qs];
if ($digits !== '') {
$sql .= " OR REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') LIKE ?";
$sql .= " OR REPLACE(REPLACE(REPLACE(REPLACE(u.cellphone, '(', ''), ')', ''), '-', ''), ' ', '') LIKE ?";
$params[] = '%' . $digits . '%';
}
$sql .= ")
ORDER BY lastname, firstname
ORDER BY u.lastname, u.firstname
LIMIT 20";
$rows = $db->query($sql, $params)->getResultArray();
@@ -562,7 +581,7 @@ class PaymentController extends ResourceController
->orWhere('enrollment_status', 'Payment pending')
->orWhere('enrollment_status', 'Payment Pending')
->orWhere('enrollment_status', 'PAYMENT PENDING')
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CHAR(160), ' '))) = 'payment pending'", null, false)
->orWhere("LOWER(TRIM(REPLACE(enrollment_status, CONVERT(0xC2A0 USING utf8mb4), ' '))) = 'payment pending'", null, false)
->groupEnd();
if ($semester !== null) {
@@ -1457,12 +1476,15 @@ class PaymentController extends ResourceController
if ($amount > $preBalance + 0.00001) {
return false;
}
$postBalance = 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' => null,
'balance' => $postBalance,
'balance_amount' => $postBalance,
'balance_after_payment' => $postBalance,
'number_of_installments' => $installmentSeq, // <-- installment sequence (1,2,3,...)
'installment_seq' => $installmentSeq,
'transaction_id' => $transactionId,
@@ -1480,6 +1502,7 @@ class PaymentController extends ResourceController
$paymentId = $this->paymentModel->insert($paymentData);
if (!$paymentId) {
log_message('error', '[processPayment] Failed to insert payment: ' . json_encode($this->paymentModel->errors()));
return false;
}