494 lines
19 KiB
PHP
Executable File
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 ?: [];
|
|
}
|
|
}
|