logModel = new PaymentNotificationLogModel(); $this->userModel = new UserModel(); $this->configModel = new ConfigurationModel(); $this->invoiceModel = new InvoiceModel(); $this->paymentModel = new PaymentModel(); $this->familyGuardianModel = new FamilyGuardianModel(); $this->emailService = new EmailService(); } public function index() { helper(['form', 'url']); $from = $this->request->getGet('from'); $to = $this->request->getGet('to'); $type = $this->request->getGet('type'); $rows = $this->logModel->listLogs($from, $to, $type); // Preload parent names $parents = []; foreach ($rows as $r) { $pid = (int) ($r['parent_id'] ?? 0); if ($pid && !isset($parents[$pid])) { $u = $this->userModel->select('firstname,lastname')->find($pid); $parents[$pid] = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? '')); } } return view('payment/notification_management', [ 'logs' => $rows, 'parentsById' => $parents, 'filters' => ['from' => $from, 'to' => $to, 'type' => $type], ]); } private function wantsJson(): bool { $accept = strtolower((string)($this->request->getHeaderLine('Accept') ?? '')); $fmt = strtolower((string)($this->request->getGet('format') ?? $this->request->getPost('format') ?? '')); return $this->request->isAJAX() || str_contains($accept, 'application/json') || $fmt === 'json'; } /** * POST /api/payments/notifications/send * Body: parent_id? (int), type? ('no_payment'|'installment'), school_year? * - When parent_id provided: send for that parent only (if balance > 0 or force=1) * - Otherwise: send to all parents with positive balance for selected school year * respecting idempotency (1 email per month/type) unless force=1. */ public function send() { $parentId = (int)($this->request->getPost('parent_id') ?? 0); $typeInput = (string)($this->request->getPost('type') ?? ''); $force = (int)($this->request->getPost('force') ?? 0) === 1; $schoolYear = (string)($this->request->getPost('school_year') ?? ($this->configModel->getConfig('school_year') ?? date('Y'))); $returnTo = (string)($this->request->getPost('return_to') ?? ''); $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); $tz = new \DateTimeZone($tzName); $now = new \DateTime('now', $tz); $year = (int)$now->format('Y'); $month= (int)$now->format('n'); // Helper: compute current total balance across invoices for this parent/year $computeBalance = function (int $pid) use ($schoolYear): array { $db = \Config\Database::connect(); $rows = $db->table('invoices') ->select('id, total_amount') ->where('parent_id', $pid) ->where('school_year', $schoolYear) ->get()->getResultArray(); $paymentsTbl = 'payments'; $hasStatus = $db->fieldExists('status', $paymentsTbl); $hasVoid = $db->fieldExists('is_void', $paymentsTbl); $sumBalance = 0.0; $latestId = null; foreach ($rows as $ir) { $iid = (int)$ir['id']; if ($latestId === null) $latestId = $iid; $total = (float)($ir['total_amount'] ?? 0); $qb = $db->table('payments')->select('COALESCE(SUM(paid_amount),0) AS tot')->where('invoice_id', $iid); if ($hasStatus) { $qb->groupStart() ->whereNotIn('status', ['void','voided','refunded','failed','chargeback','declined','reversed','canceled','cancelled']) ->orWhere('status IS NULL', null, false) ->groupEnd(); } if ($hasVoid) { $qb->groupStart() ->where('is_void', 0) ->orWhere('is_void IS NULL', null, false) ->groupEnd(); } $paid = (float)($qb->get()->getRowArray()['tot'] ?? 0); $disc = (float)($db->table('discount_usages')->select('COALESCE(SUM(discount_amount),0) AS tot')->where('invoice_id', $iid)->get()->getRowArray()['tot'] ?? 0); $rfnd = (float)($db->table('refunds')->select('COALESCE(SUM(refund_paid_amount),0) AS tot')->where('invoice_id', $iid)->whereIn('status', ['Partial','Paid'])->get()->getRowArray()['tot'] ?? 0); $sumBalance += max(0.0, round($total - $disc - $paid - $rfnd, 2)); } return [$sumBalance, $latestId]; }; // Helper: get secondary guardian email $getSecondary = function (int $primaryUserId): ?string { $row = $this->familyGuardianModel->where('user_id', $primaryUserId)->first(); if (!$row || empty($row['family_id'])) return null; $others = $this->familyGuardianModel ->where('family_id', (int)$row['family_id']) ->where('user_id !=', $primaryUserId) ->where('receive_emails', 1) ->findAll(); foreach ($others as $g) { $email = $this->userModel->select('email')->find((int)$g['user_id'])['email'] ?? null; if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) return $email; } return null; }; // Compose subject/body using same content as CLI command $compose = function (int $pid, float $balance) use ($schoolYear, $now): array { $u = $this->userModel->find($pid) ?: []; $parentName = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? '')); $greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,'; $monthYear = $now->format('F Y'); $subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear); $balanceFmt = '$' . number_format($balance, 2); $intro = 'This is your monthly installment reminder for ' . $schoolYear . '.'; // Compute remaining/max installments similar to Manual Payment UI $installmentEndRaw = (string)$this->configModel->getConfig('installment_date'); $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); $tz = new \DateTimeZone($tzName); $today = new \DateTimeImmutable('today', $tz); $end = null; try { if ($installmentEndRaw) { $end = new \DateTimeImmutable($installmentEndRaw, $tz); } } catch (\Throwable $e) { $end = null; } $remMonths = 0; if ($end) { $y = (int)$end->format('Y') - (int)$today->format('Y'); $m = (int)$end->format('n') - (int)$today->format('n'); $remMonths = $y * 12 + $m; if ((int)$end->format('j') > (int)$today->format('j')) $remMonths += 1; if ($remMonths < 0) $remMonths = 0; } $remainingInst = max(1, $remMonths); $maxInst = max(($remMonths ?: 0), ($balance > 0 ? 2 : 0)); $instDueFmt = '$' . number_format($remainingInst > 0 ? ($balance / $remainingInst) : 0, 2); $bodyHtml = <<

Monthly Tuition Reminder

{$greeting}

{$intro}

Current Outstanding Balance: {$balanceFmt}

As a friendly reminder, our tuition installments are due at the beginning of each month.

You can review your invoice and payment options by logging in to your parent portal.

Thank you for your prompt attention.

Reminder Period: {$monthYear}

HTML; $body = view('emails/_wrap_layout', [ 'title' => 'Monthly Tuition Reminder', 'body_html' => $bodyHtml ], ['saveData' => true]); return [$subject, $body]; }; $results = [ 'sent' => 0, 'skipped' => 0, 'failed' => 0, 'details' => [] ]; if ($parentId > 0) { [$balance, $latestInv] = $computeBalance($parentId); // detect type if not provided if ($typeInput === 'no_payment' || $typeInput === 'installment') { $type = $typeInput; } else { $hasPayments = $this->paymentModel ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->countAllResults() > 0; $type = $hasPayments ? 'installment' : 'no_payment'; } // Idempotency: skip if already sent this month for this type if (!$force && $this->logModel->existsForPeriod($parentId, $year, $month, $type)) { $results['skipped']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'already_sent']; } else { if ($balance <= 0 && !$force) { $results['skipped']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'no_balance']; // For HTML submit, redirect back with details if (!$this->wantsJson()) { $dest = $returnTo !== '' ? $returnTo : site_url('payment/unpaid-parents?school_year=' . rawurlencode($schoolYear)); return redirect()->to($dest)->with('status', 'No balance to remind this parent.'); } } else { // Compute current-month installment suggestion $installmentEndRaw = (string)$this->configModel->getConfig('installment_date'); $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone()); $tz = new \DateTimeZone($tzName); $nowD = new \DateTimeImmutable('today', $tz); $endD = null; try { if ($installmentEndRaw) $endD = new \DateTimeImmutable($installmentEndRaw, $tz); } catch (\Throwable $e) { $endD = null; } $remMonths = 0; if ($endD) { $y = (int)$endD->format('Y') - (int)$nowD->format('Y'); $m = (int)$endD->format('n') - (int)$nowD->format('n'); $remMonths = $y * 12 + $m; if ((int)$endD->format('j') > (int)$nowD->format('j')) $remMonths += 1; if ($remMonths < 0) $remMonths = 0; } $remInst = max(1, $remMonths); $instDue = round($balance / $remInst, 2); $toEmail = $this->userModel->select('email')->find($parentId)['email'] ?? null; $toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null; $ccEmail = $getSecondary($parentId); [$subject, $body] = $compose($parentId, $balance); $ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false; if ($ok && $ccEmail && strcasecmp($ccEmail, (string)$toEmail) !== 0) { $this->emailService->send($ccEmail, $subject, $body, 'finance'); } $this->logModel->insert([ 'parent_id' => $parentId, 'invoice_id' => $latestInv, 'school_year' => $schoolYear, 'period_year' => $year, 'period_month' => $month, 'type' => $type, 'to_email' => $toEmail, 'cc_email' => $ccEmail, 'head_fa_notified' => 0, 'subject' => $subject, 'body' => $body, 'status' => $ok ? 'sent' : 'failed', 'error_message' => $ok ? null : 'Missing/invalid email or send failed', 'balance_snapshot' => $balance, 'sent_at' => $now->format('Y-m-d H:i:s'), ]); if ($ok) { $results['sent']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'sent', 'balance' => $balance, 'installment_due' => $instDue, 'remaining' => $remInst]; } else { $results['failed']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'failed']; } // For HTML submit, redirect back with detailed message if (!$this->wantsJson()) { $dest = $returnTo !== '' ? $returnTo : site_url('payment/unpaid-parents?school_year=' . rawurlencode($schoolYear)); $msg = sprintf('Reminder sent. Remaining balance: $%s. Current installment due: $%s. Remaining installments: %d', number_format($balance, 2), number_format($instDue, 2), (int)$remInst); return redirect()->to($dest)->with('status', $msg); } } } // Single-parent branch handled; otherwise do bulk } else { // Bulk: parents with positive balance in selected year $db = \Config\Database::connect(); $rows = $db->table('invoices') ->select('DISTINCT parent_id', false) ->where('school_year', $schoolYear) ->get()->getResultArray(); $pids = array_values(array_unique(array_map(static fn($r) => (int)$r['parent_id'], $rows))); foreach ($pids as $pid) { [$balance, $latestInv] = $computeBalance($pid); if ($balance <= 0 && !$force) { $results['skipped']++; continue; } $hasPayments = $this->paymentModel ->where('parent_id', $pid) ->where('school_year', $schoolYear) ->countAllResults() > 0; $type = $hasPayments ? 'installment' : 'no_payment'; if (!$force && $this->logModel->existsForPeriod($pid, $year, $month, $type)) { $results['skipped']++; continue; } $toEmail = $this->userModel->select('email')->find($pid)['email'] ?? null; $toEmail = $toEmail && filter_var($toEmail, FILTER_VALIDATE_EMAIL) ? $toEmail : null; $ccEmail = $getSecondary($pid); [$subject, $body] = $compose($pid, $balance); $ok = $toEmail ? $this->emailService->send($toEmail, $subject, $body, 'finance') : false; if ($ok && $ccEmail && strcasecmp($ccEmail, (string)$toEmail) !== 0) { $this->emailService->send($ccEmail, $subject, $body, 'finance'); } // Optional: notify head of finance in-app foreach ($this->getHeadOfFinanceUsers() as $u) { NotificationService::toUser((int)$u['id'], 'Payment Reminder Sent', sprintf('A %s reminder was sent to parent #%d (balance: $%0.2f).', $type, $pid, $balance), ['in_app'] ); } $this->logModel->insert([ 'parent_id' => $pid, 'invoice_id' => $latestInv, 'school_year' => $schoolYear, 'period_year' => $year, 'period_month' => $month, 'type' => $type, 'to_email' => $toEmail, 'cc_email' => $ccEmail, 'head_fa_notified' => 1, 'subject' => $subject, 'body' => $body, 'status' => $ok ? 'sent' : 'failed', 'error_message' => $ok ? null : 'Missing/invalid email or send failed', 'balance_snapshot' => $balance, 'sent_at' => $now->format('Y-m-d H:i:s'), ]); if ($ok) $results['sent']++; else $results['failed']++; } } // Respond if ($this->wantsJson()) { return $this->response->setJSON(['ok' => true] + $results + [ 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash(), ]); } $msg = sprintf('Reminders — sent: %d, skipped: %d, failed: %d', (int)$results['sent'], (int)$results['skipped'], (int)$results['failed']); $dest = $returnTo !== '' ? $returnTo : site_url('payment/unpaid-parents?school_year=' . rawurlencode($schoolYear)); return redirect()->to($dest)->with('status', $msg); } private function getHeadOfFinanceUsers(): array { $heads = $this->userModel->getUsersByRole('head of department (finance)'); if (!empty($heads)) return $heads; $acct = $this->userModel->getUsersByRole('accountant'); return $acct ?: []; } }