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: