fix installment for carry over balance parent account
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 48s
Tests / PHPUnit (push) Successful in 1m19s

This commit is contained in:
root
2026-08-29 18:33:28 -04:00
parent 611a5c8e4b
commit 6cf3a607a4
13 changed files with 477 additions and 57 deletions
@@ -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 !== []) {
+9 -9
View File
@@ -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,
+98 -8
View File
@@ -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
@@ -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:
@@ -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
);
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
use RuntimeException;
final class EnsureWhatsappGroupLinksUniquePerSchoolYear extends Migration
{
public function up(): void
{
if ($this->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)
));
}
}
+3 -2
View File
@@ -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);
@@ -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 :
<form method="post" action="<?= site_url('administrator/enrollment-admin/flags/' . $flagId . '/approve-exception') ?>" class="mb-2">
<?= csrf_field() ?>
<div class="small fw-semibold mb-1">Approve an exception for this student</div>
<?php if ($flagTypeValue === 'FINANCIAL_REVIEW_REQUIRED'): ?>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="allow_current_year_installments" id="allow_current_year_installments_<?= $flagId ?>" value="1">
<label class="form-check-label small" for="allow_current_year_installments_<?= $flagId ?>">
<?= esc(enrollment_admin_rule_label('CURRENT_YEAR_INSTALLMENT_OVERRIDE')) ?>
</label>
</div>
<?php endif; ?>
<input class="form-control form-control-sm mb-2" name="reason" placeholder="Approval reason" required>
<button class="btn btn-sm btn-warning" type="submit">Approve exception</button>
</form>
@@ -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']);
?>
<tr>
<td>
<?php if ($overridableCodes !== []): ?>
<input class="form-check-input" type="checkbox" name="student_ids[]" value="<?= $studentId ?>">
<?php else: ?>
<span class="text-muted">-</span>
<?php endif; ?>
<input class="form-check-input" type="checkbox" name="student_ids[]" value="<?= $studentId ?>">
</td>
<td>
<?= esc($studentPreview['student_name'] ?? '') ?>
@@ -550,9 +555,13 @@ $reasonCodes = is_array($exceptionReasonCodes ?? null) ? $exceptionReasonCodes :
</label>
</div>
<?php endforeach; ?>
<?php else: ?>
<span class="text-muted small">None</span>
<?php endif; ?>
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" name="bypassed_rule_codes_by_student[<?= $studentId ?>][]" id="bypass_<?= $studentId ?>_current_year_installments" value="CURRENT_YEAR_INSTALLMENT_OVERRIDE">
<label class="form-check-label small" for="bypass_<?= $studentId ?>_current_year_installments">
<?= esc(enrollment_admin_rule_label('CURRENT_YEAR_INSTALLMENT_OVERRIDE')) ?>
</label>
</div>
</td>
<td>
<?php if ($warningCodes !== []): ?>
+1 -1
View File
@@ -156,7 +156,7 @@
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="5" class="text-center">No students found.</td>
<td colspan="8" class="text-center">No students found.</td>
</tr>
<?php endif; ?>
</tbody>
+16 -7
View File
@@ -423,6 +423,7 @@
data-display-total="<?= esc($invoice['display_total'] ?? $invoice['total_amount'] ?? '') ?>"
data-balance-due-cents="<?= (int)($invoice['balance_due_cents'] ?? 0) ?>"
data-customer-credit-cents="<?= (int)($invoice['customer_credit_cents'] ?? 0) ?>"
data-carry-forward-invoice="<?= !empty($invoice['is_carry_forward_invoice']) ? '1' : '0' ?>"
data-next-installment="<?= (int)($invoice['next_installment'] ?? 1) ?>">
<?php
$uiPaid = (float)($invoice['paid_amount'] ?? 0);
@@ -634,6 +635,11 @@
return isFinite(v) ? v : 1;
}
function selectedInvoiceRequiresCarryForwardFull() {
const opt = currentOpt();
return carryForwardFullRequired || (opt && opt.getAttribute('data-carry-forward-invoice') === '1');
}
function monthsUntil(endYmd) {
if (!endYmd) return 0;
const today = new Date();
@@ -788,12 +794,15 @@
if (m === 'card') {
forceCardRules();
} else if (carryForwardFullRequired) {
} else if (selectedInvoiceRequiresCarryForwardFull()) {
forceCarryForwardFullRules();
} else {
// enable full/installment select
$type().removeAttribute('disabled');
$type().value = 'full';
const type = $type();
type.removeAttribute('disabled');
const installmentOption = type.querySelector('option[value="installment"]');
if (installmentOption) installmentOption.disabled = false;
type.value = 'full';
$instSec().style.display = 'none';
$instSeqRow().style.display = 'none';
$amount().removeAttribute('readonly');
@@ -810,7 +819,7 @@
forceCardRules();
return;
}
if (carryForwardFullRequired) {
if (selectedInvoiceRequiresCarryForwardFull()) {
forceCarryForwardFullRules();
return;
}
@@ -834,7 +843,7 @@
if (($method().value || '').toLowerCase() === 'card') {
forceCardRules();
} else if (carryForwardFullRequired) {
} else if (selectedInvoiceRequiresCarryForwardFull()) {
forceCarryForwardFullRules();
} else {
updateAmountHint();
@@ -908,7 +917,7 @@
alert('Please enter a valid amount > 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;
+54 -20
View File
@@ -60,21 +60,47 @@
<th>Second Parent</th>
<th>Phone</th>
<!--th>Email</th-->
<th>
In Group (Second)
<div class="form-check d-inline-block ms-2">
<input class="form-check-input" type="checkbox" id="selectAllSecond<?= $index ?>" title="Set all second parents: checked=Yes, unchecked=No">
<label for="selectAllSecond<?= $index ?>" class="form-check-label small">All</label>
</div>
</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php foreach ($classSection['parents'] as $rIdx => $p): ?>
<?php $formId = 'mform-' . $index . '-' . $rIdx; ?>
<tr>
<td><?= esc($p['primary_name']) ?></td>
<th>
In Group (Second)
<div class="form-check d-inline-block ms-2">
<input class="form-check-input" type="checkbox" id="selectAllSecond<?= $index ?>" title="Set all second parents: checked=Yes, unchecked=No">
<label for="selectAllSecond<?= $index ?>" class="form-check-label small">All</label>
</div>
</th>
<th>Delivery</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<?php foreach ($classSection['parents'] as $rIdx => $p): ?>
<?php $formId = 'mform-' . $index . '-' . $rIdx; ?>
<?php
$deliveryStatus = strtolower((string)($p['delivery_status'] ?? 'not_sent'));
$deliveryLabel = 'Not sent';
$deliveryClass = 'bg-secondary';
if ($deliveryStatus === 'sent') {
$deliveryLabel = 'Sent';
$deliveryClass = 'bg-success';
} elseif ($deliveryStatus === 'failed') {
$deliveryLabel = 'Failed';
$deliveryClass = 'bg-danger';
}
$deliveryTitleParts = [];
if (!empty($p['delivery_sent_at'])) {
$deliveryTitleParts[] = 'Last attempt: ' . $p['delivery_sent_at'];
}
if (!empty($p['delivery_error'])) {
$deliveryTitleParts[] = 'Error: ' . $p['delivery_error'];
}
if (!empty($p['delivery_class_specific']) && $deliveryStatus !== 'not_sent') {
$deliveryTitleParts[] = 'Class-specific log';
} elseif ($deliveryStatus !== 'not_sent') {
$deliveryTitleParts[] = 'Parent-level log';
}
$deliveryTitle = implode(' | ', $deliveryTitleParts);
?>
<tr>
<td><?= esc($p['primary_name']) ?></td>
<td>
<?php if (!empty($p['primary_phone'])): ?>
<a href="tel:<?= esc(preg_replace('/\D+/', '', $p['primary_phone'])) ?>">
@@ -131,11 +157,19 @@
</select>
<?php else: ?>
<span class="text-muted">—</span>
<?php endif; ?>
</td>
<td class="text-center">
<form id="<?= $formId ?>" method="post" action="<?= site_url('whatsapp/update-membership') ?>" class="d-inline wa-membership-form">
<?= csrf_field() ?>
<?php endif; ?>
</td>
<td class="text-center">
<span class="badge <?= esc($deliveryClass) ?>" title="<?= esc($deliveryTitle) ?>">
<?= esc($deliveryLabel) ?>
</span>
<?php if (!empty($p['delivery_sent_at'])): ?>
<div class="small text-muted"><?= esc($p['delivery_sent_at']) ?></div>
<?php endif; ?>
</td>
<td class="text-center">
<form id="<?= $formId ?>" method="post" action="<?= site_url('whatsapp/update-membership') ?>" class="d-inline wa-membership-form">
<?= csrf_field() ?>
<input type="hidden" name="class_section_id" value="<?= (int)($p['class_section_id'] ?? $classSection['class_section_id']) ?>">
<input type="hidden" name="school_year" value="<?= esc($classSection['school_year'] ?? '') ?>">
<input type="hidden" name="semester" value="<?= esc($classSection['semester'] ?? '') ?>">
+34
View File
@@ -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**
+77
View File
@@ -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();
}
}