Files
alrahma_sunday_school_api/app/Services/Payments/PaymentNotificationService.php
T
2026-06-09 02:32:58 -04:00

391 lines
14 KiB
PHP

<?php
namespace App\Services\Payments;
use App\Models\Configuration;
use App\Models\PaymentNotificationLog;
use App\Models\User;
use App\Services\EmailService;
use App\Support\MailHtml;
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;
$body = MailHtml::document('Monthly Tuition Reminder', $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');
}
}