false, 'message' => 'Invalid email address.'];
}
$type = in_array($type, ['no_payment', 'installment'], true) ? $type : 'no_payment';
$tzName = $this->resolveTimezone();
$tz = new DateTimeZone($tzName);
$now = new DateTimeImmutable('now', $tz);
$year = (int) $now->format('Y');
$month = (int) $now->format('n');
$schoolYear = (string) (Configuration::getConfig('school_year') ?? $year);
$user = User::query()->where('email', $email)->first();
$parentId = (int) ($user->id ?? 0);
$invoices = $parentId > 0
? Invoice::query()->where('parent_id', $parentId)->where('school_year', $schoolYear)->get()
: collect();
$totalBalance = 0.0;
$latestInvoiceId = null;
foreach ($invoices as $invoice) {
$totalBalance += (float) ($invoice->balance ?? 0);
if ($latestInvoiceId === null) {
$latestInvoiceId = (int) ($invoice->id ?? 0);
}
}
$ccEmail = $this->findSecondaryGuardianEmail($parentId, $email);
$parentName = $user ? trim(($user->firstname ?? '').' '.($user->lastname ?? '')) : '';
$monthYear = $now->format('F Y');
$subject = sprintf('Monthly Tuition Reminder — %s', $schoolYear);
$greeting = $parentName !== '' ? "Dear {$parentName}," : 'Dear Parent,';
$balanceFmt = '$'.number_format($totalBalance, 2);
$intro = ($type === 'no_payment')
? "We noticed there are outstanding tuition charges for {$schoolYear} but no payment has been recorded yet."
: "This is your monthly installment reminder for {$schoolYear}.";
$installmentInfo = $this->computeInstallmentSuggestion($totalBalance, $tzName);
$bodyHtml = <<
Monthly Tuition Reminder (Test)
{$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;
$bodyHtml .= ''
."- Remaining installments: {$installmentInfo['remaining_installments']}
"
."- Suggested installment this month: {$installmentInfo['installment_due_fmt']}
"
."- Maximum installments available: {$installmentInfo['max_installments']}
"
.'
';
$body = MailHtml::document('Monthly Tuition Reminder', $bodyHtml);
$ok = $this->emailService->send($email, $subject, (string) $body, 'finance');
if ($ok && $ccEmail && strcasecmp($ccEmail, $email) !== 0) {
$this->emailService->send($ccEmail, $subject, (string) $body, 'finance');
}
$existing = PaymentNotificationLog::query()
->where('parent_id', $parentId)
->where('period_year', $year)
->where('period_month', $month)
->where('type', $type)
->first();
$payload = [
'parent_id' => $parentId,
'invoice_id' => $latestInvoiceId,
'school_year' => $schoolYear,
'period_year' => $year,
'period_month' => $month,
'type' => $type,
'to_email' => $email,
'cc_email' => $ccEmail,
'head_fa_notified' => 0,
'subject' => $subject,
'body' => (string) $body,
'status' => $ok ? 'sent' : 'failed',
'error_message' => $ok ? null : 'Email send failed (see logs)',
'balance_snapshot' => $totalBalance,
'sent_at' => $now->format('Y-m-d H:i:s'),
];
if ($existing) {
$existing->fill($payload)->save();
$logId = (int) $existing->id;
} else {
$log = PaymentNotificationLog::query()->create($payload);
$logId = (int) $log->id;
}
return [
'ok' => $ok,
'log_id' => $logId,
'cc_email' => $ccEmail,
];
}
private function computeInstallmentSuggestion(float $balance, string $tzName): array
{
$installmentEndRaw = (string) (Configuration::getConfig('installment_date') ?? '');
$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 findSecondaryGuardianEmail(int $parentId, string $primaryEmail): ?string
{
if ($parentId <= 0) {
return null;
}
$row = FamilyGuardian::query()->where('user_id', $parentId)->first();
if (! $row || empty($row->family_id)) {
return null;
}
$others = FamilyGuardian::query()
->where('family_id', (int) $row->family_id)
->where('user_id', '!=', $parentId)
->where('receive_emails', 1)
->get();
foreach ($others as $guardian) {
$email = (string) (User::query()->where('id', (int) $guardian->user_id)->value('email') ?? '');
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL) && strcasecmp($email, $primaryEmail) !== 0) {
return $email;
}
}
return null;
}
private function resolveTimezone(): string
{
$schoolConfig = config('School');
if (is_object($schoolConfig) && isset($schoolConfig->attendance['timezone'])) {
return (string) $schoolConfig->attendance['timezone'];
}
if (is_array($schoolConfig) && isset($schoolConfig['attendance']['timezone'])) {
return (string) $schoolConfig['attendance']['timezone'];
}
return (string) (config('app.timezone') ?: 'UTC');
}
}