add controllers, servoices
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Payment;
|
||||
|
||||
class PaymentBalanceService
|
||||
{
|
||||
public function updateBalance(int $paymentId, float $amountPaid): bool
|
||||
{
|
||||
return Payment::updateBalance($paymentId, $amountPaid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PaymentEnrollmentEventService
|
||||
{
|
||||
public function buildEventData(
|
||||
int $parentId,
|
||||
array $studentIds,
|
||||
?string $schoolYear = null,
|
||||
?string $semester = null,
|
||||
?string $portalLink = null
|
||||
): array {
|
||||
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
|
||||
$semester = $semester ?: Configuration::getConfig('semester');
|
||||
$portalLink = $portalLink ?: url('/login');
|
||||
|
||||
$parent = DB::table('users')
|
||||
->select('id', 'firstname', 'lastname', 'email')
|
||||
->where('id', $parentId)
|
||||
->first();
|
||||
|
||||
if (!$parent) {
|
||||
throw new \RuntimeException("Parent user #{$parentId} not found");
|
||||
}
|
||||
|
||||
$ids = array_values(array_unique(array_map('intval', $studentIds)));
|
||||
if (empty($ids)) {
|
||||
$ids = DB::table('students')
|
||||
->where('parent_id', $parentId)
|
||||
->pluck('id')
|
||||
->map(fn ($id) => (int) $id)
|
||||
->all();
|
||||
}
|
||||
|
||||
if (empty($ids)) {
|
||||
$data = [
|
||||
'user_id' => (int) $parent->id,
|
||||
'email' => $parent->email ?? null,
|
||||
'firstname' => $parent->firstname ?? '',
|
||||
'lastname' => $parent->lastname ?? '',
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'portal_link' => $portalLink,
|
||||
];
|
||||
|
||||
return [$data, []];
|
||||
}
|
||||
|
||||
$rows = DB::table('students as s')
|
||||
->select([
|
||||
's.id as student_id',
|
||||
's.firstname as s_firstname',
|
||||
's.lastname as s_lastname',
|
||||
's.registration_grade',
|
||||
'sc.class_section_id',
|
||||
'cs.class_section_name',
|
||||
'tu.firstname as t_firstname',
|
||||
'tu.lastname as t_lastname',
|
||||
])
|
||||
->leftJoin('student_class as sc', function ($join) use ($schoolYear) {
|
||||
$join->on('sc.student_id', '=', 's.id')
|
||||
->where('sc.school_year', '=', $schoolYear);
|
||||
})
|
||||
->leftJoin('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
|
||||
->leftJoin('teacher_class as tc', function ($join) use ($schoolYear) {
|
||||
$join->on('tc.class_section_id', '=', 'sc.class_section_id')
|
||||
->where('tc.position', '=', 'main')
|
||||
->where('tc.school_year', '=', $schoolYear);
|
||||
})
|
||||
->leftJoin('users as tu', 'tu.id', '=', 'tc.teacher_id')
|
||||
->where('s.parent_id', $parentId)
|
||||
->whereIn('s.id', $ids)
|
||||
->orderByDesc('sc.updated_at')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
if (empty($rows)) {
|
||||
$rows = DB::table('students as s')
|
||||
->select('s.id as student_id', 's.firstname as s_firstname', 's.lastname as s_lastname', 's.registration_grade')
|
||||
->where('s.parent_id', $parentId)
|
||||
->whereIn('s.id', $ids)
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
}
|
||||
|
||||
$students = [];
|
||||
$seen = [];
|
||||
foreach ($rows as $row) {
|
||||
$sid = (int) ($row['student_id'] ?? 0);
|
||||
if ($sid <= 0 || isset($seen[$sid])) {
|
||||
continue;
|
||||
}
|
||||
$seen[$sid] = true;
|
||||
|
||||
$teacherFull = trim(trim((string) ($row['t_firstname'] ?? '')) . ' ' . trim((string) ($row['t_lastname'] ?? '')));
|
||||
$teacherFull = $teacherFull !== '' ? $teacherFull : null;
|
||||
|
||||
$students[] = [
|
||||
'id' => $sid,
|
||||
'student_id' => $sid,
|
||||
'firstname' => (string) ($row['s_firstname'] ?? ''),
|
||||
'lastname' => (string) ($row['s_lastname'] ?? ''),
|
||||
'name' => trim(((string) ($row['s_firstname'] ?? '')) . ' ' . ((string) ($row['s_lastname'] ?? ''))),
|
||||
'registration_grade' => (string) ($row['registration_grade'] ?? ''),
|
||||
'class_section_id' => isset($row['class_section_id']) ? (int) $row['class_section_id'] : null,
|
||||
'class_section_name' => $row['class_section_name'] ?? null,
|
||||
'teacher_name' => $teacherFull,
|
||||
'start_date' => '',
|
||||
'portal_link' => $portalLink,
|
||||
];
|
||||
}
|
||||
|
||||
$data = [
|
||||
'user_id' => (int) $parent->id,
|
||||
'email' => $parent->email ?? null,
|
||||
'firstname' => $parent->firstname ?? '',
|
||||
'lastname' => $parent->lastname ?? '',
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
'portal_link' => $portalLink,
|
||||
];
|
||||
|
||||
return [$data, $students];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Enrollment;
|
||||
use App\Models\StudentClass;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PaymentEventChargesService
|
||||
{
|
||||
public function listCharges(?string $schoolYear, ?string $semester): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
|
||||
$semester = $semester ?: Configuration::getConfig('semester');
|
||||
|
||||
$charges = DB::table('event_charges')
|
||||
->select(
|
||||
'event_charges.*',
|
||||
'users.firstname AS parent_firstname',
|
||||
'users.lastname AS parent_lastname',
|
||||
'students.firstname AS student_firstname',
|
||||
'students.lastname AS student_lastname'
|
||||
)
|
||||
->leftJoin('users', 'users.id', '=', 'event_charges.parent_id')
|
||||
->leftJoin('students', 'students.id', '=', 'event_charges.student_id')
|
||||
->where('event_charges.school_year', $schoolYear)
|
||||
->where('event_charges.semester', $semester)
|
||||
->orderByDesc('event_charges.created_at')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
return [
|
||||
'charges' => $charges,
|
||||
'parents' => User::getParents(),
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
];
|
||||
}
|
||||
|
||||
public function addCharges(array $payload, int $userId): int
|
||||
{
|
||||
$studentIds = $payload['student_ids'] ?? [];
|
||||
$parentId = (int) $payload['parent_id'];
|
||||
|
||||
$commonData = [
|
||||
'parent_id' => $parentId,
|
||||
'event_name' => $payload['event_name'],
|
||||
'description' => $payload['description'] ?? null,
|
||||
'amount' => (float) $payload['amount'],
|
||||
'semester' => $payload['semester'],
|
||||
'school_year' => $payload['school_year'],
|
||||
'charged' => 0,
|
||||
'updated_by' => $userId,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
$count = 0;
|
||||
|
||||
if (!empty($studentIds)) {
|
||||
foreach ($studentIds as $studentId) {
|
||||
DB::table('event_charges')->insert(array_merge($commonData, [
|
||||
'student_id' => (int) $studentId,
|
||||
]));
|
||||
$count++;
|
||||
}
|
||||
} elseif (!empty($payload['student_id'])) {
|
||||
DB::table('event_charges')->insert(array_merge($commonData, [
|
||||
'student_id' => (int) $payload['student_id'],
|
||||
]));
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function getEnrolledStudents(int $parentId, ?string $schoolYear): array
|
||||
{
|
||||
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
|
||||
|
||||
$enrollments = Enrollment::getEnrolledStudents($parentId, $schoolYear);
|
||||
|
||||
$students = [];
|
||||
foreach ($enrollments as $row) {
|
||||
$students[] = [
|
||||
'id' => (int) ($row->id ?? 0),
|
||||
'firstname' => (string) ($row->firstname ?? ''),
|
||||
'lastname' => (string) ($row->lastname ?? ''),
|
||||
'grade' => StudentClass::getStudentGrade((int) ($row->id ?? 0)) ?: 'N/A',
|
||||
];
|
||||
}
|
||||
|
||||
return $students;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Payment;
|
||||
|
||||
class PaymentLookupService
|
||||
{
|
||||
public function getByParent(int $parentId, ?string $schoolYear = null): array
|
||||
{
|
||||
$query = Payment::query()->where('parent_id', $parentId);
|
||||
|
||||
if ($schoolYear) {
|
||||
$query->where('school_year', $schoolYear);
|
||||
}
|
||||
|
||||
return $query->orderByDesc('payment_date')->get()->toArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use App\Models\Student;
|
||||
use App\Services\Discounts\DiscountInvoiceService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class PaymentManualService
|
||||
{
|
||||
public function __construct(
|
||||
private DiscountInvoiceService $invoiceService,
|
||||
private PaymentEnrollmentEventService $enrollmentEventService
|
||||
) {
|
||||
}
|
||||
|
||||
public function search(string $searchTerm, ?string $schoolYear = null): array
|
||||
{
|
||||
$searchTerm = trim($searchTerm);
|
||||
$schoolYear = $schoolYear ?: Configuration::getConfig('school_year');
|
||||
$installmentDateRaw = (string) (Configuration::getConfig('installment_date') ?? '');
|
||||
|
||||
$installmentEndYmd = '';
|
||||
if ($installmentDateRaw !== '') {
|
||||
$ts = strtotime($installmentDateRaw);
|
||||
if ($ts !== false) {
|
||||
$installmentEndYmd = date('Y-m-d', $ts);
|
||||
}
|
||||
}
|
||||
|
||||
$parentData = null;
|
||||
$students = [];
|
||||
$payments = [];
|
||||
$invoices = [];
|
||||
$pagination = null;
|
||||
|
||||
if ($searchTerm !== '') {
|
||||
$searchClean = preg_replace('/\D/', '', $searchTerm);
|
||||
$searchFormatted = $this->formatPhone($searchClean);
|
||||
|
||||
$builder = DB::table('users')->select('*');
|
||||
$builder->where(function ($q) use ($searchTerm, $searchClean, $searchFormatted) {
|
||||
$q->where('email', $searchTerm);
|
||||
|
||||
if (!empty($searchClean)) {
|
||||
if (strlen($searchClean) === 10) {
|
||||
$formattedPhone = substr($searchClean, 0, 3) . '-' . substr($searchClean, 3, 3) . '-' . substr($searchClean, 6, 4);
|
||||
$q->orWhere('cellphone', $formattedPhone);
|
||||
}
|
||||
|
||||
$q->orWhereRaw(
|
||||
"REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') = ?",
|
||||
[$searchClean]
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($searchFormatted)) {
|
||||
$q->orWhere('cellphone', $searchFormatted);
|
||||
}
|
||||
});
|
||||
|
||||
$parent = (array) $builder->first();
|
||||
if (!empty($parent)) {
|
||||
unset($parent['password']);
|
||||
$parentData = $parent;
|
||||
$parentId = (int) ($parent['id'] ?? 0);
|
||||
|
||||
if ($parentId > 0) {
|
||||
$students = Student::query()->where('parent_id', $parentId)->get()->toArray();
|
||||
|
||||
$paymentPaginator = Payment::query()
|
||||
->where('parent_id', $parentId)
|
||||
->orderByDesc('payment_date')
|
||||
->paginate(10);
|
||||
|
||||
$payments = $paymentPaginator->items();
|
||||
$pagination = [
|
||||
'current_page' => $paymentPaginator->currentPage(),
|
||||
'per_page' => $paymentPaginator->perPage(),
|
||||
'total' => $paymentPaginator->total(),
|
||||
'last_page' => $paymentPaginator->lastPage(),
|
||||
];
|
||||
|
||||
$rawInvoices = Invoice::query()
|
||||
->where('parent_id', $parentId)
|
||||
->when($schoolYear, fn ($q) => $q->where('school_year', $schoolYear))
|
||||
->orderByDesc('issue_date')
|
||||
->get()
|
||||
->toArray();
|
||||
|
||||
$invoices = $this->normalizeInvoices($rawInvoices, $installmentEndYmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'parent' => $parentData,
|
||||
'students' => $students,
|
||||
'payments' => $payments,
|
||||
'invoices' => $invoices,
|
||||
'pagination' => $pagination,
|
||||
'search_term' => $searchTerm,
|
||||
'today' => function_exists('utc_now') ? utc_now() : now()->toDateTimeString(),
|
||||
'installment_end_ymd' => $installmentEndYmd,
|
||||
];
|
||||
}
|
||||
|
||||
public function suggest(string $query): array
|
||||
{
|
||||
$q = trim($query);
|
||||
if ($q === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$digits = preg_replace('/\D/', '', $q);
|
||||
|
||||
$sql = "SELECT id, firstname, lastname, email, cellphone
|
||||
FROM users
|
||||
WHERE (
|
||||
CONCAT_WS(' ', firstname, lastname) LIKE ?
|
||||
OR email LIKE ?
|
||||
OR cellphone LIKE ?";
|
||||
|
||||
$params = ['%' . $q . '%', '%' . $q . '%', '%' . $q . '%'];
|
||||
|
||||
if ($digits !== '') {
|
||||
$sql .= " OR REPLACE(REPLACE(REPLACE(REPLACE(cellphone, '(', ''), ')', ''), '-', ''), ' ', '') LIKE ?";
|
||||
$params[] = '%' . $digits . '%';
|
||||
}
|
||||
|
||||
$sql .= ") ORDER BY lastname, firstname LIMIT 20";
|
||||
|
||||
$rows = DB::select($sql, $params);
|
||||
|
||||
$items = [];
|
||||
foreach ($rows as $row) {
|
||||
$label = trim(($row->firstname ?? '') . ' ' . ($row->lastname ?? ''));
|
||||
$email = trim((string) ($row->email ?? ''));
|
||||
$phone = trim((string) ($row->cellphone ?? ''));
|
||||
$sub = trim($email . ' ' . $phone);
|
||||
$value = $email !== '' ? $email : ($phone !== '' ? $phone : $label);
|
||||
|
||||
$items[] = [
|
||||
'id' => (int) ($row->id ?? 0),
|
||||
'label' => $label,
|
||||
'sub' => $sub,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function recordPayment(
|
||||
int $invoiceId,
|
||||
float $amount,
|
||||
string $paymentMethod,
|
||||
?string $paymentDate,
|
||||
?string $checkNumber,
|
||||
?string $paymentType,
|
||||
?UploadedFile $paymentFile,
|
||||
?string $schoolYear = null,
|
||||
?string $semester = null,
|
||||
?int $userId = null
|
||||
): array {
|
||||
$paymentMethod = strtolower(trim($paymentMethod));
|
||||
$allowedMethods = ['cash', 'check', 'card'];
|
||||
|
||||
if (!in_array($paymentMethod, $allowedMethods, true)) {
|
||||
throw new \InvalidArgumentException('Invalid payment method.');
|
||||
}
|
||||
|
||||
if ($paymentMethod === 'check' && ($checkNumber === null || $checkNumber === '')) {
|
||||
throw new \InvalidArgumentException('Check number is required for check payments.');
|
||||
}
|
||||
|
||||
if ($amount <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid amount.');
|
||||
}
|
||||
|
||||
$invoice = Invoice::query()->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
throw new \RuntimeException('Invoice not found.');
|
||||
}
|
||||
|
||||
$schoolYear = $schoolYear ?: (string) ($invoice->school_year ?? Configuration::getConfig('school_year'));
|
||||
$semester = $semester ?: (string) ($invoice->semester ?? Configuration::getConfig('semester'));
|
||||
|
||||
$initialPreBalance = $this->getCurrentInvoiceBalance($invoiceId, $schoolYear);
|
||||
|
||||
if ($paymentMethod === 'card' && round($amount, 2) !== round($initialPreBalance, 2)) {
|
||||
throw new \InvalidArgumentException('Card payments must equal the full remaining balance.');
|
||||
}
|
||||
|
||||
$checkFile = null;
|
||||
if ($paymentFile) {
|
||||
$checkFile = $this->storePaymentFile($paymentFile, $paymentMethod);
|
||||
}
|
||||
|
||||
$paymentDate = $paymentDate ? date('Y-m-d H:i:s', strtotime($paymentDate)) : (function_exists('utc_now') ? utc_now() : now()->toDateTimeString());
|
||||
|
||||
DB::beginTransaction();
|
||||
|
||||
try {
|
||||
$locked = DB::table('invoices')->where('id', $invoiceId)->lockForUpdate()->first();
|
||||
if (!$locked) {
|
||||
DB::rollBack();
|
||||
throw new \RuntimeException('Invoice not found.');
|
||||
}
|
||||
|
||||
$this->invoiceService->recalculateInvoice($invoiceId, $schoolYear);
|
||||
$currentBalance = $this->getCurrentInvoiceBalance($invoiceId, $schoolYear);
|
||||
|
||||
if ($amount > $currentBalance + 0.00001) {
|
||||
DB::rollBack();
|
||||
throw new \InvalidArgumentException('Entered amount exceeds remaining balance.');
|
||||
}
|
||||
|
||||
if ($paymentMethod === 'card' && round($amount, 2) !== round($currentBalance, 2)) {
|
||||
DB::rollBack();
|
||||
throw new \InvalidArgumentException('Card payments must equal the full remaining balance.');
|
||||
}
|
||||
|
||||
$paidCount = $this->getSuccessfulPaymentCount($invoiceId);
|
||||
$installmentSeq = $paidCount + 1;
|
||||
|
||||
$transactionId = 'INV-' . $invoiceId . '-' . str_replace('.', '', (string) microtime(true));
|
||||
|
||||
$ok = $this->processPayment(
|
||||
$invoiceId,
|
||||
$amount,
|
||||
$paymentMethod,
|
||||
$checkFile,
|
||||
$transactionId,
|
||||
$paymentDate,
|
||||
$schoolYear,
|
||||
$semester,
|
||||
$checkNumber,
|
||||
$installmentSeq,
|
||||
$userId
|
||||
);
|
||||
|
||||
if (!$ok) {
|
||||
DB::rollBack();
|
||||
throw new \RuntimeException('Failed to record payment.');
|
||||
}
|
||||
|
||||
$this->invoiceService->recalculateInvoice($invoiceId, $schoolYear);
|
||||
|
||||
$postBalance = max(0.0, round($initialPreBalance - $amount, 2));
|
||||
|
||||
$enrollmentUpdated = $this->invoiceService->updateEnrollmentStatusIfPaid($invoiceId, $schoolYear);
|
||||
if ($enrollmentUpdated > 0) {
|
||||
$studentIds = Student::query()->where('parent_id', $invoice->parent_id)->pluck('id')->all();
|
||||
[$eventDataEnroll, $studentDataEnroll] = $this->enrollmentEventService->buildEventData(
|
||||
(int) $invoice->parent_id,
|
||||
$studentIds,
|
||||
$schoolYear,
|
||||
$semester
|
||||
);
|
||||
|
||||
$this->triggerEvent('studentEnrolled', [$eventDataEnroll, $studentDataEnroll]);
|
||||
}
|
||||
|
||||
$invoiceDiscount = (float) (DB::table('discount_usages')
|
||||
->selectRaw('COALESCE(SUM(discount_amount),0) AS sum_disc')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->value('sum_disc') ?? 0);
|
||||
|
||||
$parentYearDiscountTotal = (float) (DB::table('discount_usages')
|
||||
->selectRaw('COALESCE(SUM(discount_amount),0) AS sum_disc')
|
||||
->where('parent_id', $invoice->parent_id)
|
||||
->where('school_year', $schoolYear)
|
||||
->value('sum_disc') ?? 0);
|
||||
|
||||
DB::commit();
|
||||
|
||||
[$eventData, $studentData] = $this->invoiceService->buildPaymentEventData(
|
||||
$invoiceId,
|
||||
$transactionId,
|
||||
$amount,
|
||||
$paymentMethod,
|
||||
$paymentDate,
|
||||
$checkNumber,
|
||||
$installmentSeq,
|
||||
$initialPreBalance,
|
||||
$postBalance,
|
||||
$schoolYear,
|
||||
$semester
|
||||
);
|
||||
|
||||
$eventData['invoice_discount'] = $invoiceDiscount;
|
||||
$eventData['parent_year_discount_total'] = $parentYearDiscountTotal;
|
||||
|
||||
$this->triggerEvent('paymentReceived', [$eventData, $studentData]);
|
||||
|
||||
return [
|
||||
'transaction_id' => $transactionId,
|
||||
'installment_seq' => $installmentSeq,
|
||||
'pre_balance' => $initialPreBalance,
|
||||
'post_balance' => $postBalance,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::error('[manualPayUpdate] ' . $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function editPayment(
|
||||
int $paymentId,
|
||||
float $paidAmount,
|
||||
string $paymentMethod,
|
||||
?string $checkNumber,
|
||||
?UploadedFile $paymentFile,
|
||||
?int $userId = null
|
||||
): void {
|
||||
$paymentMethod = strtolower(trim($paymentMethod));
|
||||
|
||||
if ($paidAmount <= 0) {
|
||||
throw new \InvalidArgumentException('Invalid amount entered.');
|
||||
}
|
||||
|
||||
$payment = Payment::query()->find($paymentId);
|
||||
if (!$payment) {
|
||||
throw new \RuntimeException('Original payment not found.');
|
||||
}
|
||||
|
||||
$invoice = Invoice::query()->find((int) $payment->invoice_id);
|
||||
if (!$invoice) {
|
||||
throw new \RuntimeException('Linked invoice not found.');
|
||||
}
|
||||
|
||||
$checkFile = $payment->check_file;
|
||||
if ($paymentFile) {
|
||||
$checkFile = $this->storePaymentFile($paymentFile, $paymentMethod);
|
||||
}
|
||||
|
||||
$this->invoiceService->recalculateInvoice((int) $payment->invoice_id, (string) ($invoice->school_year ?? Configuration::getConfig('school_year')));
|
||||
|
||||
$currentBalance = $this->getCurrentInvoiceBalanceExcludingPayment((int) $payment->invoice_id, $paymentId);
|
||||
|
||||
if ($paidAmount > $currentBalance) {
|
||||
throw new \InvalidArgumentException('Entered amount exceeds remaining balance.');
|
||||
}
|
||||
|
||||
$updateData = [
|
||||
'paid_amount' => $paidAmount,
|
||||
'payment_method' => $paymentMethod,
|
||||
'check_file' => $checkFile,
|
||||
'updated_by' => $userId,
|
||||
'check_number' => $paymentMethod === 'check' ? $checkNumber : null,
|
||||
];
|
||||
|
||||
Payment::query()->whereKey($paymentId)->update($updateData);
|
||||
|
||||
$this->invoiceService->recalculateInvoice((int) $payment->invoice_id, (string) ($invoice->school_year ?? Configuration::getConfig('school_year')));
|
||||
}
|
||||
|
||||
public function serveCheckFile(string $filename, string $mode = 'download')
|
||||
{
|
||||
$filename = basename($filename);
|
||||
$roots = [
|
||||
storage_path('app/uploads/checks/' . $filename),
|
||||
storage_path('app/uploads/cards/' . $filename),
|
||||
storage_path('app/uploads/misc/' . $filename),
|
||||
storage_path('app/uploads/' . $filename),
|
||||
];
|
||||
|
||||
$path = null;
|
||||
foreach ($roots as $candidate) {
|
||||
if (is_file($candidate)) {
|
||||
$path = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$path) {
|
||||
abort(404, 'Payment file not found.');
|
||||
}
|
||||
|
||||
if ($mode === 'inline') {
|
||||
return response()->file($path, [
|
||||
'Content-Type' => mime_content_type($path) ?: 'application/octet-stream',
|
||||
'Content-Disposition' => 'inline; filename="' . $filename . '"',
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->download($path, $filename);
|
||||
}
|
||||
|
||||
private function processPayment(
|
||||
int $invoiceId,
|
||||
float $amount,
|
||||
string $paymentMethod,
|
||||
?string $checkFile,
|
||||
string $transactionId,
|
||||
string $paymentDate,
|
||||
?string $schoolYear,
|
||||
?string $semester,
|
||||
?string $checkNumber,
|
||||
int $installmentSeq,
|
||||
?int $userId
|
||||
): bool {
|
||||
$invoice = Invoice::query()->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$newPaid = (float) $invoice->paid_amount + $amount;
|
||||
$newBalance = (float) $invoice->balance - $amount;
|
||||
|
||||
if ($newBalance == $invoice->total_amount) {
|
||||
$paymentStatus = 'Unpaid';
|
||||
} elseif ($newBalance > 0 && $newBalance < $invoice->total_amount) {
|
||||
$paymentStatus = 'Partially Paid';
|
||||
} elseif ($newBalance <= 0.00001) {
|
||||
$paymentStatus = 'Paid';
|
||||
} else {
|
||||
$paymentStatus = $invoice->status;
|
||||
}
|
||||
|
||||
$invoiceUpdateData = [
|
||||
'paid_amount' => $newPaid,
|
||||
'balance' => $newBalance,
|
||||
'status' => $paymentStatus,
|
||||
'updated_by' => $userId,
|
||||
];
|
||||
|
||||
if (!Invoice::query()->whereKey($invoiceId)->update($invoiceUpdateData)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$paymentData = [
|
||||
'parent_id' => $invoice->parent_id,
|
||||
'invoice_id' => $invoiceId,
|
||||
'total_amount' => $invoice->total_amount,
|
||||
'paid_amount' => $amount,
|
||||
'balance' => $newBalance,
|
||||
'number_of_installments' => $installmentSeq,
|
||||
'transaction_id' => $transactionId,
|
||||
'payment_method' => strtolower($paymentMethod),
|
||||
'payment_date' => $paymentDate,
|
||||
'status' => $paymentStatus,
|
||||
'check_file' => $checkFile,
|
||||
'check_number' => strtolower($paymentMethod) === 'check' ? $checkNumber : null,
|
||||
'updated_by' => $userId,
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
];
|
||||
|
||||
return (bool) Payment::query()->create($paymentData);
|
||||
}
|
||||
|
||||
private function getCurrentInvoiceBalance(int $invoiceId, string $schoolYear): float
|
||||
{
|
||||
return $this->invoiceService->getCurrentInvoiceBalance($invoiceId, $schoolYear);
|
||||
}
|
||||
|
||||
private function getCurrentInvoiceBalanceExcludingPayment(int $invoiceId, int $excludePaymentId): float
|
||||
{
|
||||
$invoice = Invoice::query()->find($invoiceId);
|
||||
if (!$invoice) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$paymentQuery = Payment::query()
|
||||
->selectRaw('COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('id', '!=', $excludePaymentId);
|
||||
|
||||
if (Schema::hasColumn('payments', 'status')) {
|
||||
$paymentQuery->where(function ($q) {
|
||||
$q->whereNotIn('status', [
|
||||
'void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled',
|
||||
])->orWhereNull('status');
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('payments', 'is_void')) {
|
||||
$paymentQuery->where(function ($q) {
|
||||
$q->where('is_void', 0)->orWhereNull('is_void');
|
||||
});
|
||||
}
|
||||
|
||||
$row = (array) $paymentQuery->first();
|
||||
$totalPaid = (float) ($row['total_paid'] ?? 0);
|
||||
|
||||
$totalDisc = (float) (DB::table('discount_usages')
|
||||
->selectRaw('COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->value('total_disc') ?? 0);
|
||||
|
||||
$totalRefundPaid = (float) (DB::table('refunds')
|
||||
->selectRaw('COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->where('invoice_id', $invoiceId)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->value('total_refund_paid') ?? 0);
|
||||
|
||||
$total = (float) ($invoice->total_amount ?? 0);
|
||||
|
||||
return max(0.0, round($total - $totalPaid - $totalDisc - $totalRefundPaid, 2));
|
||||
}
|
||||
|
||||
private function getSuccessfulPaymentCount(int $invoiceId): int
|
||||
{
|
||||
$exclude = ['void', 'voided', 'refunded', 'failed', 'chargeback', 'declined', 'reversed', 'canceled', 'cancelled'];
|
||||
|
||||
return Payment::query()
|
||||
->where('invoice_id', $invoiceId)
|
||||
->where('paid_amount', '>', 0)
|
||||
->whereNotIn('status', $exclude)
|
||||
->count();
|
||||
}
|
||||
|
||||
private function normalizeInvoices(array $rawInvoices, string $installmentEndYmd): array
|
||||
{
|
||||
if (empty($rawInvoices)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$invoiceIds = array_values(array_unique(array_map(static fn ($r) => (int) ($r['id'] ?? 0), $rawInvoices)));
|
||||
$paidByInvoice = [];
|
||||
$discountByInvoice = [];
|
||||
$refundByInvoice = [];
|
||||
|
||||
if (!empty($invoiceIds)) {
|
||||
$rows = Payment::query()
|
||||
->selectRaw('invoice_id, COALESCE(SUM(paid_amount),0) AS total_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$paidByInvoice[(int) ($row['invoice_id'] ?? 0)] = (float) ($row['total_paid'] ?? 0);
|
||||
}
|
||||
|
||||
$discRows = DB::table('discount_usages')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(discount_amount),0) AS total_disc')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
foreach ($discRows as $row) {
|
||||
$discountByInvoice[(int) ($row['invoice_id'] ?? 0)] = (float) ($row['total_disc'] ?? 0);
|
||||
}
|
||||
|
||||
$refundRows = DB::table('refunds')
|
||||
->selectRaw('invoice_id, COALESCE(SUM(refund_paid_amount),0) AS total_refund_paid')
|
||||
->whereIn('invoice_id', $invoiceIds)
|
||||
->whereIn('status', ['Partial', 'Paid'])
|
||||
->groupBy('invoice_id')
|
||||
->get()
|
||||
->map(fn ($row) => (array) $row)
|
||||
->all();
|
||||
|
||||
foreach ($refundRows as $row) {
|
||||
$refundByInvoice[(int) ($row['invoice_id'] ?? 0)] = (float) ($row['total_refund_paid'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
return array_map(function (array $inv) use ($installmentEndYmd, $paidByInvoice, $discountByInvoice, $refundByInvoice) {
|
||||
$inv['total_amount'] = isset($inv['total_amount']) ? (float) $inv['total_amount'] : 0.0;
|
||||
$iid = (int) ($inv['id'] ?? 0);
|
||||
$actualPaid = $paidByInvoice[$iid] ?? null;
|
||||
$inv['paid_amount'] = is_numeric($actualPaid) ? (float) $actualPaid : (float) ($inv['paid_amount'] ?? 0);
|
||||
$inv['discount'] = isset($discountByInvoice[$iid]) ? (float) $discountByInvoice[$iid] : (float) ($inv['discount'] ?? 0.0);
|
||||
$inv['refund_paid'] = isset($refundByInvoice[$iid]) ? (float) $refundByInvoice[$iid] : 0.0;
|
||||
$inv['balance'] = max(0.0, (float) $inv['total_amount'] - (float) $inv['paid_amount'] - (float) $inv['discount'] - (float) $inv['refund_paid']);
|
||||
$inv['due_ymd'] = $installmentEndYmd;
|
||||
|
||||
$issueYmd = '';
|
||||
foreach (['issue_date', 'start_date', 'created_at'] as $k) {
|
||||
if (!empty($inv[$k])) {
|
||||
$its = strtotime((string) $inv[$k]);
|
||||
if ($its !== false) {
|
||||
$issueYmd = date('Y-m-d', $its);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$inv['issue_ymd'] = $issueYmd;
|
||||
|
||||
return $inv;
|
||||
}, $rawInvoices);
|
||||
}
|
||||
|
||||
private function formatPhone(string $input): string
|
||||
{
|
||||
$digits = preg_replace('/\D/', '', $input);
|
||||
if (strlen($digits) !== 10) {
|
||||
return $input;
|
||||
}
|
||||
|
||||
return sprintf('(%s)-%s-%s', substr($digits, 0, 3), substr($digits, 3, 3), substr($digits, 6, 4));
|
||||
}
|
||||
|
||||
private function storePaymentFile(UploadedFile $file, string $paymentMethod): string
|
||||
{
|
||||
$mime = $file->getMimeType();
|
||||
$ext = strtolower($file->getClientOriginalExtension() ?: '');
|
||||
|
||||
$okTypes = ['image/jpeg', 'image/png', 'application/pdf'];
|
||||
$okExts = ['jpg', 'jpeg', 'png', 'pdf'];
|
||||
|
||||
if (!in_array($mime, $okTypes, true) || !in_array($ext, $okExts, true)) {
|
||||
throw new \InvalidArgumentException('Unsupported file type. Use JPG, PNG, or PDF.');
|
||||
}
|
||||
|
||||
if ($file->getSize() > 5 * 1024 * 1024) {
|
||||
throw new \InvalidArgumentException('File too large. Max 5MB.');
|
||||
}
|
||||
|
||||
$fileName = $file->hashName();
|
||||
$subdir = $paymentMethod === 'check' ? 'checks' : ($paymentMethod === 'card' ? 'cards' : 'misc');
|
||||
$targetDir = storage_path('app/uploads/' . $subdir);
|
||||
|
||||
if (!is_dir($targetDir)) {
|
||||
mkdir($targetDir, 0775, true);
|
||||
}
|
||||
|
||||
$file->move($targetDir, $fileName);
|
||||
|
||||
return $fileName;
|
||||
}
|
||||
|
||||
private function triggerEvent(string $name, array $payload): void
|
||||
{
|
||||
try {
|
||||
if (class_exists(\CodeIgniter\Events\Events::class)) {
|
||||
\CodeIgniter\Events\Events::trigger($name, ...$payload);
|
||||
} elseif (function_exists('event')) {
|
||||
event($name, $payload);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning($name . ' hook failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Notification;
|
||||
use App\Models\UserNotification;
|
||||
use App\Models\Configuration;
|
||||
|
||||
class PaymentNotificationDispatchService
|
||||
{
|
||||
public function notifyUser(int $userId, string $title, string $message, array $channels = ['in_app']): void
|
||||
{
|
||||
$schoolYear = Configuration::getConfig('school_year');
|
||||
$semester = Configuration::getConfig('semester');
|
||||
|
||||
$notification = Notification::query()->create([
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
'target_group' => 'user',
|
||||
'delivery_channels' => $channels,
|
||||
'priority' => 'normal',
|
||||
'status' => 'sent',
|
||||
'scheduled_at' => now(),
|
||||
'sent_at' => now(),
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
|
||||
UserNotification::query()->create([
|
||||
'notification_id' => (int) $notification->id,
|
||||
'user_id' => $userId,
|
||||
'is_read' => 0,
|
||||
'delivered' => 1,
|
||||
'delivered_at' => now(),
|
||||
'school_year' => $schoolYear,
|
||||
'semester' => $semester,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Configuration;
|
||||
use App\Models\Payment;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class PaymentPlanService
|
||||
{
|
||||
public function createPlan(array $payload): Payment
|
||||
{
|
||||
$schoolYear = $payload['school_year'] ?? Configuration::getConfig('school_year');
|
||||
$semester = $payload['semester'] ?? Configuration::getConfig('semester');
|
||||
|
||||
$data = [
|
||||
'parent_id' => (int) $payload['parent_id'],
|
||||
'total_amount' => (float) $payload['total_amount'],
|
||||
'number_of_installments' => (int) $payload['number_of_installments'],
|
||||
'paid_amount' => 0,
|
||||
'balance' => (float) $payload['total_amount'],
|
||||
'payment_date' => $payload['payment_date'] ?? null,
|
||||
'payment_method' => $payload['payment_method'] ?? null,
|
||||
'status' => $payload['status'] ?? 'Pending',
|
||||
'semester' => $semester,
|
||||
'school_year' => $schoolYear,
|
||||
];
|
||||
|
||||
if (isset($payload['invoice_id']) && Schema::hasColumn('payments', 'invoice_id')) {
|
||||
$data['invoice_id'] = (int) $payload['invoice_id'];
|
||||
}
|
||||
|
||||
if (isset($payload['payment_type']) && Schema::hasColumn('payments', 'payment_type')) {
|
||||
$data['payment_type'] = $payload['payment_type'];
|
||||
}
|
||||
|
||||
return Payment::query()->create($data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\PaymentTransaction;
|
||||
|
||||
class PaymentTransactionService
|
||||
{
|
||||
public function create(array $payload): PaymentTransaction
|
||||
{
|
||||
$data = [
|
||||
'transaction_id' => $payload['transaction_id'],
|
||||
'payment_id' => (int) $payload['payment_id'],
|
||||
'transaction_date' => $payload['transaction_date'],
|
||||
'amount' => (float) $payload['amount'],
|
||||
'payment_method' => $payload['payment_method'],
|
||||
'payment_status' => $payload['payment_status'] ?? 'Pending',
|
||||
'transaction_fee' => $payload['transaction_fee'] ?? null,
|
||||
'payment_reference' => $payload['payment_reference'] ?? null,
|
||||
'is_full_payment' => !empty($payload['is_full_payment']),
|
||||
'school_year' => $payload['school_year'] ?? null,
|
||||
'semester' => $payload['semester'] ?? null,
|
||||
];
|
||||
|
||||
return PaymentTransaction::query()->create($data);
|
||||
}
|
||||
|
||||
public function getByPayment(int $paymentId): array
|
||||
{
|
||||
return PaymentTransaction::getTransactionsByPaymentId($paymentId);
|
||||
}
|
||||
|
||||
public function updateStatus(string $transactionId, string $status): bool
|
||||
{
|
||||
return PaymentTransaction::updateTransactionStatus($transactionId, $status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\Payment;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PaypalPaymentService
|
||||
{
|
||||
public function getRedirectUrl(): string
|
||||
{
|
||||
return (string) (config('services.paypal.payment_url')
|
||||
?: env('PAYPAL_PAYMENT_URL')
|
||||
?: 'https://www.paypal.com/ncp/payment/87FJL3EV8C7NE');
|
||||
}
|
||||
|
||||
public function createPayment(int $paymentId): array
|
||||
{
|
||||
$payment = Payment::query()->find($paymentId);
|
||||
if (!$payment) {
|
||||
throw new \RuntimeException('Payment not found.');
|
||||
}
|
||||
|
||||
if (!$this->sdkAvailable()) {
|
||||
return [
|
||||
'redirect_url' => $this->getRedirectUrl(),
|
||||
'mode' => 'hosted',
|
||||
'message' => 'PayPal SDK not installed; using hosted redirect URL.',
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$apiContext = $this->buildApiContext();
|
||||
|
||||
$payer = new \PayPal\Api\Payer();
|
||||
$payer->setPaymentMethod('paypal');
|
||||
|
||||
$amount = new \PayPal\Api\Amount();
|
||||
$amount->setCurrency('USD')->setTotal($payment->balance ?? $payment->total_amount ?? 0);
|
||||
|
||||
$transaction = new \PayPal\Api\Transaction();
|
||||
$transaction->setAmount($amount)
|
||||
->setDescription('Payment for school fees')
|
||||
->setInvoiceNumber(uniqid('inv_', true));
|
||||
|
||||
$paypalPayment = new \PayPal\Api\Payment();
|
||||
$paypalPayment->setIntent('sale')
|
||||
->setPayer($payer)
|
||||
->setTransactions([$transaction]);
|
||||
|
||||
$redirectUrls = new \PayPal\Api\RedirectUrls();
|
||||
$redirectUrls->setReturnUrl(url('/api/v1/finance/paypal/execute'))
|
||||
->setCancelUrl(url('/api/v1/finance/paypal/cancel'));
|
||||
|
||||
$paypalPayment->setRedirectUrls($redirectUrls);
|
||||
$paypalPayment->create($apiContext);
|
||||
|
||||
session()->put('paypalPaymentId', $paypalPayment->getId());
|
||||
session()->put('paymentId', $paymentId);
|
||||
|
||||
return [
|
||||
'redirect_url' => $paypalPayment->getApprovalLink(),
|
||||
'mode' => 'sdk',
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('PayPal create payment failed: ' . $e->getMessage());
|
||||
throw new \RuntimeException('Unable to create PayPal payment.');
|
||||
}
|
||||
}
|
||||
|
||||
public function executePayment(string $payerId, ?string $paypalPaymentId = null): void
|
||||
{
|
||||
if (!$this->sdkAvailable()) {
|
||||
throw new \RuntimeException('PayPal SDK not installed.');
|
||||
}
|
||||
|
||||
$paymentId = $paypalPaymentId ?: (string) session()->get('paypalPaymentId');
|
||||
if (!$paymentId) {
|
||||
throw new \RuntimeException('Missing PayPal payment ID.');
|
||||
}
|
||||
|
||||
$apiContext = $this->buildApiContext();
|
||||
$payment = \PayPal\Api\Payment::get($paymentId, $apiContext);
|
||||
$execution = new \PayPal\Api\PaymentExecution();
|
||||
$execution->setPayerId($payerId);
|
||||
|
||||
$payment->execute($execution, $apiContext);
|
||||
|
||||
$internalPaymentId = (int) session()->get('paymentId');
|
||||
if ($internalPaymentId) {
|
||||
Payment::query()->whereKey($internalPaymentId)->update(['status' => 'Completed']);
|
||||
}
|
||||
}
|
||||
|
||||
public function cancelPayment(): void
|
||||
{
|
||||
// no-op (client handles redirect)
|
||||
}
|
||||
|
||||
private function sdkAvailable(): bool
|
||||
{
|
||||
return class_exists(\PayPal\Rest\ApiContext::class)
|
||||
&& class_exists(\PayPal\Auth\OAuthTokenCredential::class);
|
||||
}
|
||||
|
||||
private function buildApiContext(): \PayPal\Rest\ApiContext
|
||||
{
|
||||
$clientId = (string) (config('services.paypal.client_id') ?: env('PAYPAL_CLIENT_ID'));
|
||||
$secret = (string) (config('services.paypal.secret') ?: env('PAYPAL_SECRET'));
|
||||
$mode = (string) (config('services.paypal.mode') ?: env('PAYPAL_MODE') ?: 'sandbox');
|
||||
|
||||
$apiContext = new \PayPal\Rest\ApiContext(
|
||||
new \PayPal\Auth\OAuthTokenCredential($clientId, $secret)
|
||||
);
|
||||
|
||||
$apiContext->setConfig([
|
||||
'mode' => $mode,
|
||||
'http.headers' => ['Connection' => 'Close'],
|
||||
]);
|
||||
|
||||
return $apiContext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\PayPalPayment;
|
||||
|
||||
class PaypalTransactionsService
|
||||
{
|
||||
public function list(?string $keyword, int $perPage = 10)
|
||||
{
|
||||
$query = PayPalPayment::query()->orderByDesc('created_at');
|
||||
|
||||
if ($keyword) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('transaction_id', 'like', '%' . $keyword . '%')
|
||||
->orWhere('payer_email', 'like', '%' . $keyword . '%')
|
||||
->orWhere('event_type', 'like', '%' . $keyword . '%')
|
||||
->orWhere('order_id', 'like', '%' . $keyword . '%')
|
||||
->orWhere('parent_school_id', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
return $query->paginate($perPage);
|
||||
}
|
||||
|
||||
public function listAll(?string $keyword): array
|
||||
{
|
||||
$query = PayPalPayment::query()->orderByDesc('created_at');
|
||||
|
||||
if ($keyword) {
|
||||
$query->where(function ($q) use ($keyword) {
|
||||
$q->where('transaction_id', 'like', '%' . $keyword . '%')
|
||||
->orWhere('payer_email', 'like', '%' . $keyword . '%')
|
||||
->orWhere('event_type', 'like', '%' . $keyword . '%')
|
||||
->orWhere('order_id', 'like', '%' . $keyword . '%')
|
||||
->orWhere('parent_school_id', 'like', '%' . $keyword . '%');
|
||||
});
|
||||
}
|
||||
|
||||
return $query->get()->map(fn ($row) => (array) $row)->all();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user