Files
alrahma_sunday_school_api/app/Services/Payments/PaymentManualService.php
T
2026-06-09 00:03:03 -04:00

647 lines
23 KiB
PHP

<?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 (function_exists('event')) {
event($name, $payload);
}
} catch (\Throwable $e) {
Log::warning($name.' hook failed: '.$e->getMessage());
}
}
}