with('parent')->orderByDesc('sent_at'); if (!empty($from)) { $query->where('sent_at', '>=', $from); } if (!empty($to)) { $query->where('sent_at', '<=', $to); } if (!empty($type)) { $query->where('type', $type); } return $query->get()->all(); } public function send(array $payload): array { $parentId = (int) ($payload['parent_id'] ?? 0); $typeInput = (string) ($payload['type'] ?? ''); $force = (bool) ($payload['force'] ?? false); $schoolYear = (string) ($payload['school_year'] ?? (Configuration::getConfig('school_year') ?? date('Y'))); $tzName = $this->getTimezone(); $tz = new \DateTimeZone($tzName); $now = new \DateTime('now', $tz); $year = (int) $now->format('Y'); $month = (int) $now->format('n'); $results = [ 'sent' => 0, 'skipped' => 0, 'failed' => 0, 'details' => [], ]; if ($parentId > 0) { $result = $this->sendToParent($parentId, $typeInput, $schoolYear, $year, $month, $now, $force, false); $results = $this->mergeResults($results, $result); } else { $parentIds = $this->getParentsWithInvoices($schoolYear); foreach ($parentIds as $pid) { $result = $this->sendToParent($pid, $typeInput, $schoolYear, $year, $month, $now, $force, true); $results = $this->mergeResults($results, $result); } } return $results; } private function sendToParent( int $parentId, string $typeInput, string $schoolYear, int $year, int $month, \DateTime $now, bool $force, bool $notifyHeads ): array { $results = ['sent' => 0, 'skipped' => 0, 'failed' => 0, 'details' => []]; [$balance, $latestInv] = $this->computeBalance($parentId, $schoolYear); $type = $this->resolveType($parentId, $schoolYear, $typeInput); if (!$force && PaymentNotificationLog::existsForPeriod($parentId, $year, $month, $type)) { $results['skipped']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'already_sent']; return $results; } if ($balance <= 0 && !$force) { $results['skipped']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'no_balance']; return $results; } [$subject, $body] = $this->composeMessage($parentId, $balance, $schoolYear, $now); $installmentInfo = $this->computeInstallmentSuggestion($balance); $toEmail = $this->getUserEmail($parentId); $ccEmail = $this->getSecondaryGuardianEmail($parentId); $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'); } PaymentNotificationLog::query()->create([ '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' => $notifyHeads ? 1 : 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' => $installmentInfo['installment_due'], 'remaining' => $installmentInfo['remaining_installments'], ]; } else { $results['failed']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'failed']; } if ($notifyHeads) { $this->notifyHeadsOfFinance($type, $parentId, $balance); } return $results; } private function computeBalance(int $parentId, string $schoolYear): array { $rows = DB::table('invoices') ->select('id', 'total_amount') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->get() ->map(fn ($row) => (array) $row) ->all(); $hasStatus = DB::getSchemaBuilder()->hasColumn('payments', 'status'); $hasVoid = DB::getSchemaBuilder()->hasColumn('payments', 'is_void'); $sumBalance = 0.0; $latestId = null; foreach ($rows as $row) { $invoiceId = (int) ($row['id'] ?? 0); if ($invoiceId <= 0) { continue; } if ($latestId === null) { $latestId = $invoiceId; } $total = (float) ($row['total_amount'] ?? 0); $qb = DB::table('payments') ->selectRaw('COALESCE(SUM(paid_amount),0) AS total') ->where('invoice_id', $invoiceId); if ($hasStatus) { $qb->where(function ($q) { $q->whereNotIn('status', [ 'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled', ])->orWhereNull('status'); }); } if ($hasVoid) { $qb->where(function ($q) { $q->where('is_void', 0)->orWhereNull('is_void'); }); } $paid = (float) (optional($qb->first())->total ?? 0); $disc = (float) (optional(DB::table('discount_usages') ->selectRaw('COALESCE(SUM(discount_amount),0) AS total') ->where('invoice_id', $invoiceId) ->first())->total ?? 0); $rfnd = (float) (optional(DB::table('refunds') ->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total') ->where('invoice_id', $invoiceId) ->whereIn('status', ['Partial', 'Paid']) ->first())->total ?? 0); $sumBalance += max(0.0, round($total - $disc - $paid - $rfnd, 2)); } return [$sumBalance, $latestId]; } private function resolveType(int $parentId, string $schoolYear, string $typeInput): string { if (in_array($typeInput, ['no_payment', 'installment'], true)) { return $typeInput; } $hasPayments = DB::table('payments') ->where('parent_id', $parentId) ->where('school_year', $schoolYear) ->count() > 0; return $hasPayments ? 'installment' : 'no_payment'; } private function composeMessage(int $parentId, float $balance, string $schoolYear, \DateTime $now): array { $user = User::query()->find($parentId); $parentName = $user ? trim(($user->firstname ?? '') . ' ' . ($user->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 . '.'; $installmentInfo = $this->computeInstallmentSuggestion($balance); $bodyHtml = <<
{$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 = MailHtml::document('Monthly Tuition Reminder', $bodyHtml); return [$subject, $body]; } private function computeInstallmentSuggestion(float $balance): array { $installmentEndRaw = (string) (Configuration::getConfig('installment_date') ?? ''); $tzName = $this->getTimezone(); $tz = new \DateTimeZone($tzName); $today = new \DateTimeImmutable('today', $tz); $end = null; if ($installmentEndRaw !== '') { try { $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; } } $remainingInstallments = max(1, $remMonths); $maxInstallments = max($remMonths ?: 0, $balance > 0 ? 2 : 0); $installmentDue = $remainingInstallments > 0 ? ($balance / $remainingInstallments) : 0; return [ 'remaining_installments' => $remainingInstallments, 'max_installments' => $maxInstallments, 'installment_due' => round($installmentDue, 2), 'installment_due_fmt' => '$' . number_format($installmentDue, 2), ]; } private function getSecondaryGuardianEmail(int $primaryUserId): ?string { $row = DB::table('family_guardians')->where('user_id', $primaryUserId)->first(); if (!$row || empty($row->family_id)) { return null; } $others = DB::table('family_guardians') ->where('family_id', (int) $row->family_id) ->where('user_id', '!=', $primaryUserId) ->where('receive_emails', 1) ->get() ->map(fn ($r) => (array) $r) ->all(); foreach ($others as $g) { $email = (string) (DB::table('users')->where('id', (int) ($g['user_id'] ?? 0))->value('email') ?? ''); if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { return $email; } } return null; } private function getUserEmail(int $userId): ?string { $email = (string) (DB::table('users')->where('id', $userId)->value('email') ?? ''); if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { return null; } return $email; } private function notifyHeadsOfFinance(string $type, int $parentId, float $balance): void { try { $heads = User::getUsersByRole('head of department (finance)'); if (empty($heads)) { $heads = User::getUsersByRole('accountant'); } foreach ($heads as $user) { $this->dispatchService->notifyUser( (int) ($user['id'] ?? 0), 'Payment Reminder Sent', sprintf('A %s reminder was sent to parent #%d (balance: $%0.2f).', $type, $parentId, $balance), ['in_app'] ); } } catch (\Throwable $e) { Log::warning('Unable to notify heads of finance: ' . $e->getMessage()); } } private function mergeResults(array $base, array $update): array { $base['sent'] += $update['sent'] ?? 0; $base['skipped'] += $update['skipped'] ?? 0; $base['failed'] += $update['failed'] ?? 0; if (!empty($update['details'])) { $base['details'] = array_merge($base['details'], $update['details']); } return $base; } private function getParentsWithInvoices(string $schoolYear): array { $rows = DB::table('invoices') ->selectRaw('DISTINCT parent_id') ->where('school_year', $schoolYear) ->get() ->map(fn ($row) => (array) $row) ->all(); return array_values(array_unique(array_map(static fn ($r) => (int) ($r['parent_id'] ?? 0), $rows))); } private function getTimezone(): string { if (function_exists('user_timezone')) { return (string) user_timezone(); } return (string) (config('app.timezone') ?: 'UTC'); } }