add controllers, servoices
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\PaymentNotificationLog;
|
||||
use App\Models\User;
|
||||
use App\Services\EmailService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PaymentNotificationService
|
||||
{
|
||||
public function __construct(
|
||||
private EmailService $emailService,
|
||||
private PaymentNotificationDispatchService $dispatchService
|
||||
) {
|
||||
}
|
||||
|
||||
public function listLogs(?string $from, ?string $to, ?string $type): array
|
||||
{
|
||||
$query = PaymentNotificationLog::query()->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 = <<<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</h2>
|
||||
<p>{$greeting}</p>
|
||||
<p>{$intro}</p>
|
||||
<p><strong>Current Outstanding Balance:</strong> {$balanceFmt}</p>
|
||||
<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>
|
||||
<p>As a friendly reminder, our tuition installments are due at the beginning of each month.</p>
|
||||
<p>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;
|
||||
|
||||
if (view()->exists('emails._wrap_layout')) {
|
||||
$body = view('emails._wrap_layout', [
|
||||
'title' => 'Monthly Tuition Reminder',
|
||||
'body_html' => $bodyHtml,
|
||||
], ['saveData' => true]);
|
||||
} else {
|
||||
$body = $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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user