diff --git a/app/Controllers/View/EnrollmentAdminController.php b/app/Controllers/View/EnrollmentAdminController.php index ae12d05..4e5f96e 100644 --- a/app/Controllers/View/EnrollmentAdminController.php +++ b/app/Controllers/View/EnrollmentAdminController.php @@ -235,6 +235,13 @@ class EnrollmentAdminController extends BaseController } $ruleCodes = $this->ruleCodesForFlag((string) $flag['flag_type']); + if ( + (string) $flag['flag_type'] === 'FINANCIAL_REVIEW_REQUIRED' + && (string) ($this->request->getPost('allow_current_year_installments') ?? '') === '1' + ) { + $ruleCodes[] = 'CURRENT_YEAR_INSTALLMENT_OVERRIDE'; + $ruleCodes = array_values(array_unique($ruleCodes)); + } $now = date('Y-m-d H:i:s'); $this->db->transStart(); @@ -349,7 +356,9 @@ class EnrollmentAdminController extends BaseController $postedCodes = is_array($postedCodes) ? array_values(array_filter(array_map('strval', $postedCodes))) : []; $postedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $postedCodes))); $failedCodes = array_values(array_unique(array_map(static fn (string $code): string => strtoupper(trim($code)), $failedCodes))); - $ruleCodes = $postedCodes !== [] ? array_values(array_intersect($postedCodes, $failedCodes)) : $failedCodes; + $allowedCodes = $failedCodes; + $allowedCodes[] = 'CURRENT_YEAR_INSTALLMENT_OVERRIDE'; + $ruleCodes = $postedCodes !== [] ? array_values(array_intersect($postedCodes, $allowedCodes)) : $failedCodes; $ruleCodes = array_values(array_filter($ruleCodes, static fn (string $code): bool => $code !== '')); $nonOverridable = array_values(array_intersect($ruleCodes, ['ADULT_STUDENT_PARENT_BLOCKED'])); if ($nonOverridable !== []) { diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index 794f929..2155048 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -287,6 +287,15 @@ class ParentController extends BaseController $previousSchoolYear = $this->previousSchoolYearName($selectedYear); $fallMakeupExamOn = $this->fallMakeupExamDateForYear($selectedYear); + if ($previousSchoolYear !== null) { + service('enrollmentTransition')->syncParentFinancialReviewFlags( + (int) $parentId, + $previousSchoolYear, + $selectedYear, + array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students)) + ); + } + // Map enrollment statuses $statusMap = [ 'admission under review' => 'admission under review', @@ -404,15 +413,6 @@ class ParentController extends BaseController ))); } - if ($previousSchoolYear !== null) { - service('enrollmentTransition')->syncParentFinancialReviewFlags( - (int) $parentId, - $previousSchoolYear, - $selectedYear, - array_values(array_map(static fn (array $student): int => (int) ($student['id'] ?? 0), $students)) - ); - } - // Render view return view('/parent/enroll_classes', [ 'students' => $students, diff --git a/app/Controllers/View/PaymentController.php b/app/Controllers/View/PaymentController.php index a36ae27..5a6fd7d 100644 --- a/app/Controllers/View/PaymentController.php +++ b/app/Controllers/View/PaymentController.php @@ -391,9 +391,14 @@ class PaymentController extends ResourceController if ($parent && !empty($parent['id'])) { $parentData = $parent; $parentId = (int) $parent['id']; - $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.'; + $hasCarryForwardInvoice = $this->parentHasCarryForwardInvoice($parentId, $manualPaySchoolYear); + $hasCurrentYearInstallmentOverride = $this->parentHasCurrentYearInstallmentOverride($parentId, $manualPaySchoolYear); + $carryForwardPaymentRequired = $hasCarryForwardInvoice && ! $hasCurrentYearInstallmentOverride; + if ($hasCarryForwardInvoice) { + $carryForwardPaymentMessage = 'This parent has a carry-over balance record from a previous school year. Installments are not allowed without an admin exception.'; + if ($hasCurrentYearInstallmentOverride) { + $carryForwardPaymentMessage = 'Carry-over invoices must be paid in full. Installments are allowed only for current-year balances by admin override.'; + } } // Students @@ -433,6 +438,7 @@ class PaymentController extends ResourceController // Always use the configured end date for installments $inv['due_ymd'] = $installmentEndYmd; + $inv['is_carry_forward_invoice'] = $this->isCarryForwardInvoiceRow($inv) ? 1 : 0; // Optional: keep a start marker if you ever need it elsewhere $issueYmd = ''; @@ -956,7 +962,7 @@ class PaymentController extends ResourceController try { // Lock invoice & get context (also ensure totals are up-to-date before validation) $row = $this->db->query( - 'SELECT id, parent_id, invoice_number, total_amount, school_year FROM invoices WHERE id = ? FOR UPDATE', + 'SELECT id, parent_id, invoice_number, total_amount, school_year, semester, description FROM invoices WHERE id = ? FOR UPDATE', [$invoiceId] )->getRowArray(); @@ -972,7 +978,10 @@ class PaymentController extends ResourceController // Recompute invoice totals from tuition + events + additional charges $currentBalance = (float) $this->invoiceLedgerService->recalculateInvoice($invoiceId)['balance']; - $carryForwardPaymentRequired = $this->parentHasActiveCarryForwardBalance($parentId, $invYear); + $hasCarryForwardInvoice = $this->parentHasCarryForwardInvoice($parentId, $invYear); + $hasCurrentYearInstallmentOverride = $this->parentHasCurrentYearInstallmentOverride($parentId, $invYear); + $carryForwardPaymentRequired = $hasCarryForwardInvoice + && ($this->isCarryForwardInvoiceRow($row) || ! $hasCurrentYearInstallmentOverride); if ($carryForwardPaymentRequired && $paymentType === 'installment') { $this->db->transRollback(); $this->financialAttachmentService->discardStagedFile($stagedEvidence); @@ -1061,6 +1070,8 @@ class PaymentController extends ResourceController // Post-payment balance from snapshot $postBalance = (float) ($ledger['balance'] ?? max(0.0, round($initialPreBalance - $amount, 2))); + $this->syncEnrollmentFinanceAfterPayment($parentId, $invYear); + // Optional enrollment update $enrollmentupdated = $this->updateEnrollmentStatusIfPaid($invoiceId); if ($enrollmentupdated != 0) { @@ -1268,15 +1279,14 @@ class PaymentController extends ResourceController } } - private function parentHasActiveCarryForwardBalance(int $parentId, ?string $schoolYear = null): bool + private function parentHasCarryForwardInvoice(int $parentId, ?string $schoolYear = null): bool { if ($parentId <= 0 || ! $this->db->tableExists('invoices')) { return false; } $builder = $this->db->table('invoices') - ->where('parent_id', $parentId) - ->where('balance >', 0); + ->where('parent_id', $parentId); if ($schoolYear !== null && $schoolYear !== '') { $builder->where('school_year', $schoolYear); @@ -1290,6 +1300,7 @@ class PaymentController extends ResourceController $builder ->orLike('description', 'carried over') ->orLike('description', 'carry-forward') + ->orLike('description', 'carry over') ->orLike('description', 'previous school year'); } @@ -1298,6 +1309,85 @@ class PaymentController extends ResourceController return $builder->countAllResults() > 0; } + private function parentHasCurrentYearInstallmentOverride(int $parentId, string $schoolYear): bool + { + if ($parentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('enrollment_exceptions')) { + return false; + } + + $rows = $this->db->table('enrollment_exceptions') + ->select('bypassed_rule_codes_json') + ->where('parent_id', $parentId) + ->where('school_year', $schoolYear) + ->whereIn('status', ['active', 'used']) + ->get() + ->getResultArray(); + + foreach ($rows as $row) { + $codes = json_decode((string) ($row['bypassed_rule_codes_json'] ?? ''), true); + if (! is_array($codes)) { + continue; + } + + $codes = array_map(static fn ($code): string => strtoupper(trim((string) $code)), $codes); + if (in_array('CURRENT_YEAR_INSTALLMENT_OVERRIDE', $codes, true)) { + return true; + } + } + + return false; + } + + private function isCarryForwardInvoiceRow(array $invoice): bool + { + $invoiceNumber = (string) ($invoice['invoice_number'] ?? ''); + if (str_starts_with($invoiceNumber, 'CF-')) { + return true; + } + + if (strcasecmp((string) ($invoice['semester'] ?? ''), 'Opening Balance') === 0) { + return true; + } + + $description = strtolower((string) ($invoice['description'] ?? '')); + + return str_contains($description, 'carried over') + || str_contains($description, 'carry-forward') + || str_contains($description, 'carry over') + || str_contains($description, 'previous school year'); + } + + private function syncEnrollmentFinanceAfterPayment(int $parentId, string $targetSchoolYear): void + { + $sourceSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + if ($parentId <= 0 || $sourceSchoolYear === null) { + return; + } + + try { + $studentIds = $this->studentModel->getStudentIdsByParentId($parentId); + service('enrollmentTransition')->syncParentFinancialReviewFlags( + $parentId, + $sourceSchoolYear, + $targetSchoolYear, + array_values(array_map('intval', $studentIds ?? [])) + ); + } catch (\Throwable $e) { + log_message('error', 'Enrollment finance sync after payment failed for parent {parent_id}, school year {school_year}: {error}', [ + 'parent_id' => $parentId, + 'school_year' => $targetSchoolYear, + 'error' => $e->getMessage(), + ]); + } + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + return preg_match('/^(\d{4})-(\d{4})$/', trim($schoolYear), $matches) + ? ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1) + : null; + } + /** * 🔄 Helper: Recalculate invoice totals and status based on all payments for current school year diff --git a/app/Controllers/View/WhatsappController.php b/app/Controllers/View/WhatsappController.php index 92ed54a..9910ef5 100644 --- a/app/Controllers/View/WhatsappController.php +++ b/app/Controllers/View/WhatsappController.php @@ -651,6 +651,13 @@ class WhatsappController extends BaseController // ignore membership annotation errors } + // Annotate latest invite delivery state from whatsapp_invites_log. + try { + $this->annotateWhatsappDeliveryStatuses($classSections, $schoolYear); + } catch (\Throwable $e) { + // Keep the roster usable even if delivery history cannot be loaded. + } + // 4) (Optional) Teachers, if you keep them in teacher_class try { $tb = $this->db->table('teacher_class tc') @@ -687,6 +694,83 @@ class WhatsappController extends BaseController ]); } + /** + * Add latest email delivery state for each primary parent/class row. + * + * Exact class-section logs win. Older consolidated "all classes" logs without + * class_section_id are used only as a fallback for the parent/year. + */ + private function annotateWhatsappDeliveryStatuses(array &$classSections, string $schoolYear): void + { + if (! $this->db->tableExists('whatsapp_invites_log') || empty($classSections)) { + return; + } + + $sectionIds = array_keys($classSections); + $parentIds = []; + foreach ($classSections as $sec) { + foreach (($sec['parents'] ?? []) as $p) { + $primaryId = (int) ($p['primary_id'] ?? 0); + if ($primaryId > 0) { + $parentIds[$primaryId] = true; + } + } + } + + if (empty($sectionIds) || empty($parentIds)) { + return; + } + + $rows = $this->db->table('whatsapp_invites_log') + ->select('parent_id, class_section_id, status, error_message, sent_at') + ->where('school_year', $schoolYear) + ->whereIn('parent_id', array_keys($parentIds)) + ->groupStart() + ->whereIn('class_section_id', $sectionIds) + ->orWhere('class_section_id IS NULL', null, false) + ->groupEnd() + ->orderBy('sent_at', 'DESC') + ->orderBy('id', 'DESC') + ->get() + ->getResultArray(); + + $exact = []; + $fallbackByParent = []; + foreach ($rows as $row) { + $parentId = (int) ($row['parent_id'] ?? 0); + $sectionId = (int) ($row['class_section_id'] ?? 0); + if ($parentId <= 0) { + continue; + } + + if ($sectionId > 0) { + $key = $sectionId . ':' . $parentId; + if (! isset($exact[$key])) { + $exact[$key] = $row; + } + continue; + } + + if (! isset($fallbackByParent[$parentId])) { + $fallbackByParent[$parentId] = $row; + } + } + + foreach ($classSections as $sid => &$sec) { + foreach ($sec['parents'] as &$p) { + $primaryId = (int) ($p['primary_id'] ?? 0); + $log = $exact[$sid . ':' . $primaryId] ?? $fallbackByParent[$primaryId] ?? null; + + $p['delivery_status'] = $log ? strtolower((string) ($log['status'] ?? '')) : 'not_sent'; + $p['delivery_sent_at'] = $log['sent_at'] ?? null; + $p['delivery_error'] = $log['error_message'] ?? null; + $p['delivery_class_specific'] = $log && ! empty($log['class_section_id']); + } + unset($p); + } + unset($sec); + } + /** * POST: Update WhatsApp group membership flags for a class/parent(s). * Accepts fields: diff --git a/app/Database/Migrations/2026-07-18-000200_AlignSchemaToScoolViewDump.php b/app/Database/Migrations/2026-07-18-000200_AlignSchemaToScoolViewDump.php index c8b2602..0c7b6f7 100644 --- a/app/Database/Migrations/2026-07-18-000200_AlignSchemaToScoolViewDump.php +++ b/app/Database/Migrations/2026-07-18-000200_AlignSchemaToScoolViewDump.php @@ -110,7 +110,7 @@ final class AlignSchemaToScoolViewDump extends Migration $this->ensureIndex( 'whatsapp_group_links', 'uq_section_term', - ['class_section_id'], + ['class_section_id', 'school_year'], true ); diff --git a/app/Database/Migrations/2026-08-29-000100_EnsureWhatsappGroupLinksUniquePerSchoolYear.php b/app/Database/Migrations/2026-08-29-000100_EnsureWhatsappGroupLinksUniquePerSchoolYear.php new file mode 100644 index 0000000..bbb2ec5 --- /dev/null +++ b/app/Database/Migrations/2026-08-29-000100_EnsureWhatsappGroupLinksUniquePerSchoolYear.php @@ -0,0 +1,73 @@ +db->DBDriver !== 'MySQLi') { + throw new RuntimeException('This migration requires MySQL/MariaDB through the MySQLi driver.'); + } + + if (! $this->db->tableExists('whatsapp_group_links') + || ! $this->db->fieldExists('class_section_id', 'whatsapp_group_links') + || ! $this->db->fieldExists('school_year', 'whatsapp_group_links')) { + return; + } + + $duplicates = $this->db->query( + 'SELECT class_section_id, school_year, COUNT(*) AS row_count + FROM `whatsapp_group_links` + GROUP BY class_section_id, school_year + HAVING COUNT(*) > 1 + LIMIT 5' + )->getResultArray(); + + if ($duplicates !== []) { + throw new RuntimeException( + 'Cannot add whatsapp_group_links unique key by school year because duplicate class_section_id/school_year rows exist: ' + . json_encode($duplicates) + ); + } + + $this->dropIndexIfExists('whatsapp_group_links', 'uq_section_term'); + + $this->db->query( + 'ALTER TABLE `whatsapp_group_links` + ADD UNIQUE INDEX `uq_section_term` (`class_section_id`, `school_year`)' + ); + } + + public function down(): void + { + throw new RuntimeException('This migration is intentionally irreversible because reverting can fail once multiple school years exist for the same section.'); + } + + private function dropIndexIfExists(string $table, string $index): void + { + $exists = $this->db->query( + 'SELECT COUNT(*) AS aggregate + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND INDEX_NAME = ?', + [$table, $index] + )->getRow(); + + if ((int) ($exists->aggregate ?? 0) === 0) { + return; + } + + $this->db->query(sprintf( + 'ALTER TABLE `%s` DROP INDEX `%s`', + str_replace('`', '``', $table), + str_replace('`', '``', $index) + )); + } +} diff --git a/app/Models/StudentModel.php b/app/Models/StudentModel.php index fd5a70a..903fefc 100644 --- a/app/Models/StudentModel.php +++ b/app/Models/StudentModel.php @@ -168,10 +168,11 @@ class StudentModel extends Model ->join('emergency_contacts ec', 'ec.parent_id = students.parent_id', 'left'); if ($useYearScopedIsNew) { + $statusJoinType = ($filterByYear || $isNew === 0 || $isNew === 1) ? 'inner' : 'left'; $builder->join( 'student_year_status sys', 'sys.student_id = students.id AND sys.school_year = ' . $this->db->escape($statusYear), - 'left' + $statusJoinType ); } @@ -182,7 +183,7 @@ class StudentModel extends Model $builder->where('COALESCE(sys.is_new, 1)', $isNew, false); } - if ($filterByYear) { + if ($filterByYear && ! $useYearScopedIsNew) { $builder ->join('student_class sc_filter', 'sc_filter.student_id = students.id', 'inner') ->where('sc_filter.school_year', $schoolYear); diff --git a/app/Views/administrator/enrollment_admin_dashboard.php b/app/Views/administrator/enrollment_admin_dashboard.php index 34172ba..fdc133d 100644 --- a/app/Views/administrator/enrollment_admin_dashboard.php +++ b/app/Views/administrator/enrollment_admin_dashboard.php @@ -161,6 +161,7 @@ if (!function_exists('enrollment_admin_rule_label')) { 'SIBLING_LAST_NAME_MISMATCH' => 'Sibling last names do not match', 'OUTSTANDING_BALANCE_BLOCKED' => 'Previous-year balance must be paid', 'FINANCE_APPROVAL_REQUIRED' => 'Finance approval required', + 'CURRENT_YEAR_INSTALLMENT_OVERRIDE' => 'Allow installments for new-year balance only', 'AGE_RULE_BLOCKED' => 'Age rule not met', 'REGISTRATION_CLOSED' => 'Registration is closed', 'REGISTRATION_NOT_OPEN' => 'Registration is not open yet', @@ -427,6 +428,14 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
Approve an exception for this student
+ +
+ + +
+
@@ -516,17 +525,13 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes : $warningCodes = array_values(array_filter(array_map('strval', $evaluation['warning_rule_codes'] ?? []))); $failedCodes = array_values(array_unique(array_merge($blockingCodes, $reviewCodes))); $overridableCodes = array_values(array_diff($failedCodes, $nonOverridableCodes)); - $hasGrantableStudent = $hasGrantableStudent || $overridableCodes !== []; + $hasGrantableStudent = true; $decision = (string) ($evaluation['decision'] ?? ''); $canEnroll = !empty($evaluation['can_enroll']); ?> - - - - - - + @@ -550,9 +555,13 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes : - - None +
+ + +
diff --git a/app/Views/enroll_withdraw/new-students.php b/app/Views/enroll_withdraw/new-students.php index 8aef420..ef2f1d1 100644 --- a/app/Views/enroll_withdraw/new-students.php +++ b/app/Views/enroll_withdraw/new-students.php @@ -156,7 +156,7 @@ - No students found. + No students found. diff --git a/app/Views/payment/manual_pay.php b/app/Views/payment/manual_pay.php index b33e0ef..b90db33 100644 --- a/app/Views/payment/manual_pay.php +++ b/app/Views/payment/manual_pay.php @@ -423,6 +423,7 @@ data-display-total="" data-balance-due-cents="" data-customer-credit-cents="" + data-carry-forward-invoice="" data-next-installment=""> 0.'); return; } - if (carryForwardFullRequired && type === 'installment') { + if (selectedInvoiceRequiresCarryForwardFull() && type === 'installment') { alert(carryForwardFullMessage); return; } @@ -924,7 +933,7 @@ const newBal = (isFinite(balance) && isFinite(amount)) ? (balance - amount) : NaN; const overpay = (isFinite(newBal) && newBal < -0.005); - if (carryForwardFullRequired && isFinite(balance) && Math.abs(amount - balance) > 0.005) { + if (selectedInvoiceRequiresCarryForwardFull() && isFinite(balance) && Math.abs(amount - balance) > 0.005) { alert(carryForwardFullMessage); $amount().value = Math.max(0, balance).toFixed(2); return; diff --git a/app/Views/whatsapp/parent_contacts_by_class.php b/app/Views/whatsapp/parent_contacts_by_class.php index 7beb482..3b1217b 100644 --- a/app/Views/whatsapp/parent_contacts_by_class.php +++ b/app/Views/whatsapp/parent_contacts_by_class.php @@ -60,21 +60,47 @@ Second Parent Phone - - In Group (Second) -
- - -
- - Status - - - - $p): ?> - - - + + In Group (Second) +
+ + +
+ + Delivery + Status + + + + $p): ?> + + + + @@ -131,11 +157,19 @@ — - - - -
- + + + + + + + +
+ + + + + diff --git a/book_price.md b/book_price.md new file mode 100644 index 0000000..e3d172c --- /dev/null +++ b/book_price.md @@ -0,0 +1,34 @@ +- Islamic Studies - Student Workbook - Level 8: **$5.00** +- Islamic Studies - Student Workbook - Level 7: **$5.00** +- Islamic Studies - Student Workbook - Level 6: **$5.00** +- Islamic Studies - Student Workbook - Level 5: **$5.00** +- Islamic Studies - Student Workbook - Level 4: **$5.00** +- Islamic Studies - Student Workbook - Level 3: **$5.00** +- Islamic Studies - Student Workbook - Level 2: **$5.00** +- Islamic Studies - Student Workbook - Level 1: **$5.00** +- Arabic Writing Workbook: **$11.00** +- Beginners Arabic Reading: **$6.00** +- Ready to Write Alif Ba Ta: **$11.00** +- Teacher's Manual - Level 8: **$20.00** +- Teacher's Manual - Level 7: **$20.00** +- Teacher's Manual - Level 6: **$20.00** +- Teacher's Manual - Level 5: **$20.00** +- Teacher's Manual - Level 4: **$20.00** +- Teacher's Manual - Level 3: **$20.00** +- Teacher's Manual - Level 2: **$20.00** +- Teacher's Manual - Level 1: **$20.00** +- Juz Tabarak: **$14.00** +- Juz Amma Workbook - Vol 2: **$8.00** +- Juz Amma Workbook - Vol 1: **$8.00** +- Juz Amma Workbook - Vol 1 (B&W version): **$4.00** +- Juz Amma for School Students: **$14.00** +- Islamic Studies Level 9 (Revised and Enlarged Edition): **$17.00** +- Islamic Studies Level 8 (Revised & Enlarged Edition): **$17.00** +- Islamic Studies Level 7 (Revised & Enlarged Edition): **$17.00** +- Islamic Studies Level 6 (Revised & Enlarged Edition): **$17.00** +- Islamic Studies Level 5 (Revised & Enlarged Edition): **$17.00** +- Islamic Studies Level 4 (Revised & Enlarged Edition): **$17.00** +- Islamic Studies Level 3 (Revised & Enlarged Edition): **$17.00** +- Islamic Studies Level 2 (Revised & Enlarged Edition): **$17.00** +- Islamic Studies Level 1 (Revised & Enlarged Edition): **$17.00** +- Islamic Studies Level K (Revised & Enlarged Edition): **$17.00** \ No newline at end of file diff --git a/tests/app/Models/StudentModelTest.php b/tests/app/Models/StudentModelTest.php index 28e0e33..494371f 100644 --- a/tests/app/Models/StudentModelTest.php +++ b/tests/app/Models/StudentModelTest.php @@ -4,6 +4,7 @@ namespace Tests\App\Models; use Tests\Support\ModelCrudTestCase; use App\Models\StudentModel; +use Config\Database; class StudentModelTest extends ModelCrudTestCase { @@ -22,4 +23,80 @@ class StudentModelTest extends ModelCrudTestCase { $this->assertModelCanDelete(StudentModel::class); } + + public function testNewStudentQueryIncludesStudentsWithoutClassAssignment(): void + { + $db = Database::connect('tests'); + $schoolYear = $this->validSchoolYear(); + $parentId = $this->insertParent($db, 'parent-with-new-student@example.test'); + $newStudentId = $this->insertStudent($db, $parentId, 'Unassigned', 'New'); + $returningStudentId = $this->insertStudent($db, $parentId, 'Assigned', 'Returning'); + + $db->table('student_year_status')->insert([ + 'student_id' => $newStudentId, + 'school_year' => $schoolYear, + 'is_new' => 1, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + $db->table('student_year_status')->insert([ + 'student_id' => $returningStudentId, + 'school_year' => $schoolYear, + 'is_new' => 0, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + $rows = (new StudentModel())->getStudentsWithParentsAndEmergency($schoolYear, 1); + $ids = array_map(static fn (array $row): int => (int) $row['id'], $rows); + + $this->assertContains($newStudentId, $ids); + $this->assertNotContains($returningStudentId, $ids); + } + + private function insertParent($db, string $email): int + { + $db->table('users')->insert([ + 'school_id' => random_int(100000, 999999), + 'firstname' => 'Test', + 'lastname' => 'Parent', + 'gender' => 'Male', + 'cellphone' => '555-0100', + 'email' => $email, + 'address_street' => '1 Test St', + 'city' => 'Lowell', + 'state' => 'MA', + 'zip' => '01852', + 'accept_school_policy' => 1, + 'is_verified' => 1, + 'status' => 'Active', + 'password' => password_hash('password', PASSWORD_DEFAULT), + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return (int) $db->insertID(); + } + + private function insertStudent($db, int $parentId, string $firstname, string $lastname): int + { + $db->table('students')->insert([ + 'school_id' => uniqid('STU', false), + 'firstname' => $firstname, + 'lastname' => $lastname, + 'dob' => '2018-01-01', + 'age' => 8, + 'gender' => 'Male', + 'is_active' => 1, + 'registration_grade' => '1', + 'is_new' => 1, + 'photo_consent' => 1, + 'parent_id' => $parentId, + 'registration_date' => date('Y-m-d H:i:s'), + 'tuition_paid' => 0, + 'year_of_registration' => '2026', + ]); + + return (int) $db->insertID(); + } }