Files
alrahma_sunday_school_api/app/Http/Controllers/Api/PaymentNotificationController.php
T
2026-03-05 12:29:37 -05:00

494 lines
19 KiB
PHP
Executable File

<?php
namespace App\Http\Controllers\Api;
use App\Models\Configuration;
use App\Models\FamilyGuardian;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\PaymentNotificationLog;
use App\Models\User;
use App\Services\EmailService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class PaymentNotificationController extends BaseApiController
{
protected PaymentNotificationLog $log;
protected User $user;
protected Configuration $config;
protected Invoice $invoice;
protected Payment $payment;
protected FamilyGuardian $familyGuardian;
protected EmailService $emailService;
public function __construct()
{
parent::__construct();
$this->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 = <<<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> {$remainingInst}</li>
<li><strong>Suggested installment this month:</strong> {$instDueFmt}</li>
<li><strong>Maximum installments available:</strong> {$maxInst}</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;
// 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 ?: [];
}
}