add notifications logic and add support of both JWT and Sanctum

This commit is contained in:
root
2026-03-11 01:20:31 -04:00
parent f6be51576c
commit 182036cc41
141 changed files with 8685 additions and 648 deletions
@@ -0,0 +1,74 @@
<?php
namespace App\Services\Payments;
use App\Services\EmailService;
use App\Services\Notifications\UserNotificationDispatchService;
use Illuminate\Support\Facades\DB;
class PaymentMissedCheckService
{
public function __construct(
private UserNotificationDispatchService $notifier,
private EmailService $emailService
) {
}
public function findUsersWithMissedPayments(): array
{
$today = now()->toDateString();
return DB::table('invoices as i')
->join('users as u', 'u.id', '=', 'i.parent_id')
->select(
'u.id as user_id',
'u.firstname',
'u.lastname',
'u.email',
DB::raw('MIN(i.due_date) as due_date'),
DB::raw('SUM(i.balance) as balance')
)
->whereNotNull('i.due_date')
->where('i.due_date', '<', $today)
->where('i.balance', '>', 0)
->whereNotIn(DB::raw('LOWER(i.status)'), ['paid'])
->groupBy('u.id', 'u.firstname', 'u.lastname', 'u.email')
->get()
->map(fn ($row) => (array) $row)
->all();
}
public function sendReminders(array $users): array
{
$sent = 0;
$failed = 0;
foreach ($users as $user) {
$userId = (int) ($user['user_id'] ?? 0);
$email = (string) ($user['email'] ?? '');
$name = trim((string) ($user['firstname'] ?? '') . ' ' . (string) ($user['lastname'] ?? ''));
$this->notifier->notifyUser(
$userId,
'Payment Missed',
'You have a missed payment. Please pay to avoid penalty.',
['in_app'],
'payment',
'parent'
);
if ($email !== '') {
$subject = 'Payment Missed';
$body = '<p>Dear ' . e($name !== '' ? $name : 'Parent') . ',</p>'
. '<p>You have a missed payment. Please pay to avoid penalty.</p>';
$ok = $this->emailService->send($email, $subject, $body, 'finance');
$ok ? $sent++ : $failed++;
}
}
return [
'sent' => $sent,
'failed' => $failed,
];
}
}
@@ -0,0 +1,220 @@
<?php
namespace App\Services\Payments;
use App\Models\Configuration;
use App\Models\FamilyGuardian;
use App\Models\Invoice;
use App\Models\PaymentNotificationLog;
use App\Models\User;
use App\Services\EmailService;
use DateTimeImmutable;
use DateTimeZone;
class PaymentTestNotificationService
{
public function __construct(private EmailService $emailService)
{
}
public function send(string $email, string $type = 'no_payment'): array
{
$email = trim($email);
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return ['ok' => 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 = <<<HTML
<div style="font-family:Arial,Helvetica,sans-serif; font-size:14px; color:#212529; line-height:1.5;">
<h2 style="margin:0 0 10px; font-size:18px;">Monthly Tuition Reminder (Test)</h2>
<p>{$greeting}</p>
<p>{$intro}</p>
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
<p>
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.
</p>
<p>Thank you for your prompt attention.</p>
<p><em>Reminder Period:</em> {$monthYear}</p>
</div>
HTML;
$bodyHtml .= "<ul>"
. "<li><strong>Remaining installments:</strong> {$installmentInfo['remaining_installments']}</li>"
. "<li><strong>Suggested installment this month:</strong> {$installmentInfo['installment_due_fmt']}</li>"
. "<li><strong>Maximum installments available:</strong> {$installmentInfo['max_installments']}</li>"
. "</ul>";
if (view()->exists('emails._wrap_layout')) {
$body = view('emails._wrap_layout', [
'title' => 'Monthly Tuition Reminder',
'body_html' => $bodyHtml,
], ['saveData' => true]);
} else {
$body = $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');
}
}
@@ -0,0 +1,173 @@
<?php
namespace App\Services\Payments;
use App\Models\Configuration;
use App\Models\Enrollment;
use App\Models\Invoice;
use App\Models\PayPalPayment;
use App\Models\Payment;
use App\Models\Student;
use App\Models\User;
use App\Services\EmailService;
use Illuminate\Support\Facades\Log;
class PaypalPaymentSyncService
{
public function __construct(private EmailService $emailService)
{
}
public function sync(bool $dryRun = false, bool $reportOnly = false): array
{
$mode = $reportOnly ? 'REPORT-ONLY' : ($dryRun ? 'DRY-RUN' : 'LIVE');
$semester = (string) (Configuration::getConfig('semester') ?? '');
$schoolYear = (string) (Configuration::getConfig('school_year') ?? '');
$entries = PayPalPayment::query()
->where('status', 'COMPLETED')
->where('synced', 0)
->where('sync_attempts', '<', 3)
->whereNotNull('transaction_id')
->get();
$synced = 0;
$failed = [];
foreach ($entries as $entry) {
if (!$reportOnly) {
$entry->sync_attempts = (int) $entry->sync_attempts + 1;
$entry->save();
}
$user = User::query()->where('school_id', $entry->parent_school_id)->first();
if (!$user) {
$failed[] = (string) $entry->transaction_id;
Log::error('PayPal sync: no user for parent_school_id', ['parent_school_id' => $entry->parent_school_id]);
continue;
}
$invoice = Invoice::query()
->where('parent_id', $user->id)
->where('school_year', $schoolYear)
->latest('created_at')
->first();
if (!$reportOnly && !$dryRun) {
if ($invoice) {
$ok = $this->applyPayment($invoice, $entry->amount, (string) $entry->transaction_id, $schoolYear, $semester, $entry->created_at);
if (!$ok) {
$failed[] = (string) $entry->transaction_id;
continue;
}
} else {
Payment::query()->create([
'parent_id' => $user->id,
'invoice_id' => 0,
'total_amount' => $entry->amount,
'paid_amount' => $entry->amount,
'balance' => 0.00,
'number_of_installments' => 1,
'transaction_id' => $entry->transaction_id,
'payment_method' => 'PayPal',
'payment_date' => optional($entry->created_at)->format('Y-m-d'),
'school_year' => $schoolYear,
'semester' => $semester,
'status' => 'Completed',
'updated_by' => null,
]);
}
$entry->synced = 1;
$entry->save();
}
$synced++;
}
$this->sendReport($mode, $synced, $failed);
return [
'mode' => $mode,
'processed' => $synced,
'failed' => $failed,
];
}
private function applyPayment(Invoice $invoice, float $amount, string $transactionId, string $schoolYear, string $semester, $createdAt): bool
{
$newPaid = (float) $invoice->paid_amount + $amount;
$newBalance = (float) $invoice->balance - $amount;
$invoice->paid_amount = $newPaid;
$invoice->balance = $newBalance;
if ($newBalance <= 0) {
$invoice->status = 'Paid';
}
if (!$invoice->save()) {
return false;
}
Payment::query()->create([
'parent_id' => $invoice->parent_id,
'invoice_id' => $invoice->id,
'total_amount' => $invoice->total_amount,
'paid_amount' => $amount,
'balance' => $newBalance,
'number_of_installments' => 1,
'transaction_id' => $transactionId,
'payment_method' => 'PayPal',
'payment_date' => $createdAt ? date('Y-m-d', strtotime((string) $createdAt)) : date('Y-m-d'),
'status' => $newBalance <= 0 ? 'Full' : 'Partial',
'check_file' => null,
'updated_by' => null,
'school_year' => $schoolYear,
'semester' => $semester,
]);
$this->updateEnrollmentStatusIfPaid($invoice->id, $schoolYear);
return true;
}
private function updateEnrollmentStatusIfPaid(int $invoiceId, string $schoolYear): void
{
$invoice = Invoice::query()->find($invoiceId);
if (!$invoice || (float) $invoice->balance > 0) {
return;
}
$students = Student::query()
->where('parent_id', $invoice->parent_id)
->where('school_year', $schoolYear)
->get();
foreach ($students as $student) {
Enrollment::query()
->where('student_id', $student->id)
->update(['enrollment_status' => 'enrolled']);
}
}
private function sendReport(string $mode, int $processed, array $failed): void
{
if ($processed === 0 && empty($failed)) {
Log::info("[{$mode}] No PayPal sync updates.");
return;
}
$subject = "[{$mode}] PayPal Sync Report - " . now()->format('Y-m-d H:i');
$body = "PayPal Sync Mode: {$mode}\n\n";
$body .= "{$processed} PayPal payments processed.\n\n";
if (!empty($failed)) {
$body .= count($failed) . " failed transactions:\n" . implode("\n", $failed);
} else {
$body .= "No failed transactions.\n";
}
$this->emailService->send('support@alrahmaisgl.org', $subject, nl2br($body), 'finance');
}
}