add notifications logic and add support of both JWT and Sanctum
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user