log = model(PaymentNotificationLog::class); $this->user = model(User::class); $this->config = model(Configuration::class); $this->invoice = model(Invoice::class); $this->payment = model(Payment::class); $this->familyGuardian = model(FamilyGuardian::class); $this->emailService = app(EmailService::class); } /** * GET /api/v1/payments/notifications * List payment notification logs with optional filters */ public function index(): JsonResponse { $page = max(1, (int) ($this->request->getGet('page') ?? 1)); $perPage = min(100, max(1, (int) ($this->request->getGet('per_page') ?? 20))); $from = $this->request->getGet('from'); $to = $this->request->getGet('to'); $type = $this->request->getGet('type'); try { $rows = $this->log->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->user->select('firstname,lastname')->find($pid); $parents[$pid] = trim(($u['firstname'] ?? '') . ' ' . ($u['lastname'] ?? '')); } } // Add parent names to rows foreach ($rows as &$row) { $pid = (int) ($row['parent_id'] ?? 0); $row['parent_name'] = $parents[$pid] ?? ''; } unset($row); $offset = ($page - 1) * $perPage; $total = count($rows); $paginatedRows = array_slice($rows, $offset, $perPage); $result = [ 'data' => $paginatedRows, 'pagination' => [ 'current_page' => $page, 'per_page' => $perPage, 'total' => $total, 'total_pages' => (int) ceil($total / $perPage), ], ]; return $this->success($result, 'Payment notification logs retrieved successfully'); } catch (\Throwable $e) { Log::error('Payment notification logs error: ' . $e->getMessage()); return $this->respondError('Failed to retrieve payment notification logs', Response::HTTP_INTERNAL_SERVER_ERROR); } } /** * POST /api/v1/payments/notifications/send * Send payment reminder notifications * * Body: parent_id? (int), type? ('no_payment'|'installment'), school_year?, force? (0|1) * - 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(): JsonResponse { $payload = $this->payloadData(); $parentId = (int) ($payload['parent_id'] ?? 0); $typeInput = (string) ($payload['type'] ?? ''); $force = (int) ($payload['force'] ?? 0) === 1; $schoolYear = (string) ($payload['school_year'] ?? ($this->config->getConfig('school_year') ?? date('Y'))); $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 { $rows = DB::table('invoices') ->select('id', 'total_amount') ->where('parent_id', $pid) ->where('school_year', $schoolYear) ->get() ->toArray(); $paymentsTbl = 'payments'; $hasStatus = DB::getSchemaBuilder()->hasColumn($paymentsTbl, 'status'); $hasVoid = DB::getSchemaBuilder()->hasColumn($paymentsTbl, 'is_void'); $sumBalance = 0.0; $latestId = null; foreach ($rows as $ir) { $ir = (array) $ir; // Convert to array for consistent access $iid = (int) ($ir['id'] ?? 0); if ($latestId === null) { $latestId = $iid; } $total = (float) ($ir['total_amount'] ?? 0); $qb = DB::table('payments')->select(DB::raw('COALESCE(SUM(paid_amount),0) AS tot'))->where('invoice_id', $iid); if ($hasStatus) { $qb->where(function ($query) { $query->whereNotIn('status', ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled']) ->orWhereNull('status'); }); } if ($hasVoid) { $qb->where(function ($query) { $query->where('is_void', 0) ->orWhereNull('is_void'); }); } $paidRow = $qb->first(); $paid = (float) (($paidRow && isset($paidRow->tot)) ? $paidRow->tot : 0); $discRow = DB::table('discount_usages') ->select(DB::raw('COALESCE(SUM(discount_amount),0) AS tot')) ->where('invoice_id', $iid) ->first(); $disc = (float) (($discRow && isset($discRow->tot)) ? $discRow->tot : 0); $rfndRow = DB::table('refunds') ->select(DB::raw('COALESCE(SUM(refund_paid_amount),0) AS tot')) ->where('invoice_id', $iid) ->whereIn('status', ['Partial', 'Paid']) ->first(); $rfnd = (float) (($rfndRow && isset($rfndRow->tot)) ? $rfndRow->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->familyGuardian->where('user_id', $primaryUserId)->first(); if (!$row || empty($row['family_id'])) { return null; } $others = $this->familyGuardian ->where('family_id', (int) $row['family_id']) ->where('user_id', '!=', $primaryUserId) ->where('receive_emails', 1) ->findAll(); foreach ($others as $g) { $email = $this->user->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, $tzName): array { $u = $this->user->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->config->getConfig('installment_date'); $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; // Try to wrap in email layout view, fallback to plain HTML try { $body = view('emails._wrap_layout', [ 'title' => 'Monthly Tuition Reminder', 'body_html' => $bodyHtml, ], ['saveData' => false]); } catch (\Throwable $e) { // Fallback if view doesn't exist $body = $bodyHtml; } return [$subject, $body]; }; $results = [ 'sent' => 0, 'skipped' => 0, 'failed' => 0, 'details' => [], ]; if ($parentId > 0) { // Single parent mode [$balance, $latestInv] = $computeBalance($parentId); // Detect type if not provided if ($typeInput === 'no_payment' || $typeInput === 'installment') { $type = $typeInput; } else { $hasPayments = $this->payment ->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->log->existsForPeriod($parentId, $year, $month, $type)) { $results['skipped']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'already_sent']; } elseif ($balance <= 0 && !$force) { $results['skipped']++; $results['details'][] = ['parent_id' => $parentId, 'result' => 'no_balance']; } else { // Compute current-month installment suggestion $installmentEndRaw = (string) $this->config->getConfig('installment_date'); $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->user->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->log->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']; } } } else { // Bulk mode: parents with positive balance in selected year $rows = DB::table('invoices') ->select(DB::raw('DISTINCT parent_id')) ->where('school_year', $schoolYear) ->get(); $pids = array_values(array_unique($rows->pluck('parent_id')->map(fn($id) => (int) $id)->toArray())); foreach ($pids as $pid) { [$balance, $latestInv] = $computeBalance($pid); if ($balance <= 0 && !$force) { $results['skipped']++; continue; } $hasPayments = $this->payment ->where('parent_id', $pid) ->where('school_year', $schoolYear) ->countAllResults() > 0; $type = $hasPayments ? 'installment' : 'no_payment'; if (!$force && $this->log->existsForPeriod($pid, $year, $month, $type)) { $results['skipped']++; continue; } $toEmail = $this->user->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 $this->notifyHeadOfFinance($pid, $type, $balance); $this->log->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']++; } } } return $this->success([ 'ok' => true, ] + $results + [ 'csrf_token' => csrf_token(), 'csrf_hash' => csrf_hash(), ], 'Payment notifications processed'); } /** * Notify head of finance users about payment reminder sent */ private function notifyHeadOfFinance(int $parentId, string $type, float $balance): void { try { $heads = $this->getHeadOfFinanceUsers(); foreach ($heads as $u) { // Create in-app notification if notification system exists // This is optional functionality - can be implemented later Log::info(sprintf( 'Payment reminder sent: %s reminder to parent #%d (balance: $%0.2f)', $type, $parentId, $balance )); } } catch (\Throwable $e) { // Silently fail - this is optional Log::debug('Failed to notify head of finance: ' . $e->getMessage()); } } /** * Get head of finance users */ private function getHeadOfFinanceUsers(): array { $heads = $this->user->getUsersByRole('head of department (finance)'); if (!empty($heads)) { return $heads; } $acct = $this->user->getUsersByRole('accountant'); return $acct ?: []; } }