classify admin

This commit is contained in:
Husanjonazamov
2026-02-24 12:52:01 +05:00
commit e0f1989655
769 changed files with 1263008 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Services;
class BootstrapTableService
{
private static string $defaultClasses = 'btn icon btn-xs btn-rounded btn-icon rounded-pill';
/**
* @return string
*/
public static function button(string $iconClass, string $url, array $customClass = [], array $customAttributes = [], string $iconText = '')
{
$customClassStr = implode(' ', $customClass);
$class = self::$defaultClasses . ' ' . $customClassStr;
$attributes = '';
if (count($customAttributes) > 0) {
foreach ($customAttributes as $key => $value) {
$attributes .= $key . '="' . $value . '" ';
}
}
return '<a href="' . $url . '" class="' . $class . '" ' . $attributes . '><i class="' . $iconClass . '"></i>' . $iconText . '</a>&nbsp;&nbsp;';
}
public static function dropdown(
string $iconClass,
array $dropdownItems,
array $customClass = [],
array $customAttributes = []
) {
$customClassStr = implode(' ', $customClass);
$class = self::$defaultClasses . ' dropdown ' . $customClassStr;
$attributes = '';
if (count($customAttributes) > 0) {
foreach ($customAttributes as $key => $value) {
$attributes .= $key . '="' . $value . '" ';
}
}
$dropdown = '<div class="' . $class . '" ' . $attributes . '>';
$dropdown .= '<button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenuButton" data-bs-toggle="dropdown" aria-expanded="false">';
$dropdown .= '<i class="' . $iconClass . '"></i>'; // Use the icon class here
$dropdown .= '</button>';
$dropdown .= '<ul class="dropdown-menu" data-bs-popper="static" aria-labelledby="dropdownMenuButton">';
foreach ($dropdownItems as $item) {
$dropdown .= '<li><a class="dropdown-item" href="' . $item['url'] . '"><i class="' . $item['icon'] . '"></i> ' . $item['text'] . '</a></li>';
}
$dropdown .= '</ul>';
$dropdown .= '</div>';
return $dropdown;
}
/**
* @param string $dataBsTarget
* @param null $customClass
* @param null $id
* @param string $iconClass
* @param null $onClick
* @return string
*/
public static function editButton($url, bool $modal = false, $dataBsTarget = '#editModal', $customClass = null, $id = null, $iconClass = 'fa fa-edit', $onClick = null)
{
$customClass = ['btn-primary' . ' ' . $customClass];
$customAttributes = [
'title' => trans('Edit'),
];
if ($modal) {
$customAttributes = [
'title' => trans('Edit'),
'data-bs-target' => $dataBsTarget,
'data-bs-toggle' => 'modal',
'id' => $id,
'onclick' => $onClick,
];
$customClass[] = 'edit_btn set-form-url';
}
return self::button($iconClass, $url, $customClass, $customAttributes);
}
/**
* @param null $id
* @param null $dataId
* @param null $dataCategory
* @param null $customClass
* @return string
*/
public static function deleteButton($url, $id = null, $dataId = null, $dataCategory = null, $customClass = null)
{
// dd($dataId);
$customClass = ['delete-form', 'btn-danger' . $customClass];
$customAttributes = [
'title' => trans('Delete'),
'id' => $id,
'data-id' => $dataId,
'data-category' => $dataCategory,
];
$iconClass = 'fas fa-trash';
return self::button($iconClass, $url, $customClass, $customAttributes);
}
/**
* @return string
*/
public static function restoreButton($url, string $title = 'Restore')
{
$customClass = ['btn-gradient-success', 'restore-data'];
$customAttributes = [
'title' => trans($title),
];
$iconClass = 'fa fa-refresh';
return self::button($iconClass, $url, $customClass, $customAttributes);
}
/**
* @return string
*/
public static function trashButton($url)
{
$customClass = ['btn-gradient-danger', 'trash-data'];
$customAttributes = [
'title' => trans('Delete Permanent'),
];
$iconClass = 'fa fa-times';
return self::button($iconClass, $url, $customClass, $customAttributes);
}
public static function optionButton($url)
{
$customClass = ['btn-option'];
$customAttributes = [
'title' => trans('View Option Data'),
];
$iconClass = 'bi bi-gear';
$iconText = ' Options';
return self::button($iconClass, $url, $customClass, $customAttributes, $iconText);
}
}

View File

@@ -0,0 +1,76 @@
<?php
namespace App\Services;
use App\Models\Language;
use App\Models\Setting;
use Illuminate\Support\Facades\Cache;
class CachingService
{
/**
* @param $key
* @param callable $callback - Callback function must return a value
* @param int $time = 3600
* @return mixed
*/
public static function cacheRemember($key, callable $callback, int $time = 3600)
{
return Cache::remember($key, $time, $callback);
}
public static function removeCache($key)
{
Cache::forget($key);
}
/**
* @param array|string $key
* @return mixed|string
*/
public static function getSystemSettings(array|string $key = '*')
{
$settings = self::cacheRemember(config('constants.CACHE.SETTINGS'), static function () {
return Setting::all()->pluck('value', 'name');
});
if (($key != '*')) {
/* There is a minor possibility of getting a specific key from the $systemSettings
* So I have not fetched Specific key from DB. Otherwise, Specific key will be fetched here
* And it will be appended to the cached array here
*/
$specificSettings = [];
// If array is given in Key param
if (is_array($key)) {
foreach ($key as $row) {
if ($settings && is_array($settings) && array_key_exists($row, $settings)) {
$specificSettings[$row] = $settings[$row] ?? '';
}
}
return $specificSettings;
}
// If String is given in Key param
if ($settings && is_object($settings) && $settings->has($key)) {
return $settings[$key] ?? '';
}
return "";
}
return $settings;
}
public static function getLanguages()
{
return self::cacheRemember(config('constants.CACHE.LANGUAGE'), static function () {
return Language::all();
});
}
public static function getDefaultLanguage()
{
return Language::where('code', 'en')->first();
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Services;
use App\Models\Setting;
class CurrencyFormatterService
{
protected function resolveCurrency($currency): object
{
return (object) [
'symbol' => $currency?->symbol ?? Setting::getValue('currency_symbol'),
'symbol_position' => $currency?->symbol_position ?? Setting::getValue('currency_symbol_position'),
'decimal_places' => $currency?->decimal_places ?? Setting::getValue('decimal_places'),
'thousand_separator'=> $currency?->thousand_separator ?? Setting::getValue('thousand_separator'),
'decimal_separator' => $currency?->decimal_separator ?? Setting::getValue('decimal_separator'),
];
}
public function formatPrice($amount, $currency = null): string
{
$currency = $this->resolveCurrency($currency);
$number = number_format(
$amount,
$currency->decimal_places,
$currency->decimal_separator,
$currency->thousand_separator
);
$position = strtolower((string) $currency->symbol_position);
return $position === 'right'
? $number. ' ' .$currency->symbol
: $currency->symbol. ' ' .$number;
}
public function formatSalaryRange($min, $max, $currency = null): ?string
{
if (! $min && ! $max) {
return null;
}
if ($min && ! $max) {
return __('From'). ' ' .$this->formatPrice($min, $currency);
}
if (! $min && $max) {
return __('Upto'). ' ' .$this->formatPrice($max, $currency);
}
return
$this->formatPrice($min, $currency)
. ' - '
. $this->formatPrice($max, $currency);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Services;
class DefaultSettingService {
public static function get() {
$SYSTEM_VERSION = "2.10.1";
return [
['name' => 'currency_symbol', 'value' => '$', 'type' => 'string'],
['name' => 'ios_version', 'value' => '1.0.0', 'type' => 'string'],
['name' => 'default_language', 'value' => 'en', 'type' => 'string'],
['name' => 'force_update', 'value' => '0', 'type' => 'string'],
['name' => 'android_version', 'value' => '1.0.0', 'type' => 'string'],
['name' => 'number_with_suffix', 'value' => '0', 'type' => 'string'],
['name' => 'maintenance_mode', 'value' => 0, 'type' => 'string'],
['name' => 'privacy_policy', 'value' => '', 'type' => 'string'],
['name' => 'contact_us', 'value' => '', 'type' => 'string'],
['name' => 'terms_conditions', 'value' => '', 'type' => 'string'],
['name' => 'about_us', 'value' => '', 'type' => 'string'],
['name' => 'company_tel1', 'value' => '', 'type' => 'string'],
['name' => 'company_tel2', 'value' => '', 'type' => 'string'],
['name' => 'system_version', 'value' => $SYSTEM_VERSION, 'type' => 'string'],
['name' => 'company_email', 'value' => '', 'type' => 'string'],
['name' => 'company_name', 'value' => 'Eclassify', 'type' => 'string'],
['name' => 'company_logo', 'value' => 'assets/images/logo/sidebar_logo.png', 'type' => 'file'],
['name' => 'favicon_icon', 'value' => 'assets/images/logo/favicon.png', 'type' => 'file'],
['name' => 'login_image', 'value' => 'assets/images/bg/login.jpg', 'type' => 'file'],
['name' => 'banner_ad_id_android', 'value' => '', 'type' => 'string'],
['name' => 'banner_ad_id_ios', 'value' => '', 'type' => 'string'],
['name' => 'banner_ad_status', 'value' => '', 'type' => 'string'],
['name' => 'interstitial_ad_id_ios', 'value' => '', 'type' => 'string'],
['name' => 'interstitial_ad_id_android', 'value' => '', 'type' => 'string'],
['name' => 'interstitial_ad_status', 'value' => '', 'type' => 'string'],
['name' => 'pinterest_link', 'value' => '', 'type' => 'string'],
['name' => 'linkedin_link', 'value' => '', 'type' => 'string'],
['name' => 'facebook_link', 'value' => '', 'type' => 'string'],
['name' => 'x_link', 'value' => '', 'type' => 'string'],
['name' => 'instagram_link', 'value' => '', 'type' => 'string'],
['name' => 'google_map_iframe_link', 'value' => '', 'type' => 'string'],
['name' => 'app_store_link', 'value' => '', 'type' => 'string'],
['name' => 'play_store_link', 'value' => '', 'type' => 'string'],
['name' => 'footer_description', 'value' => '', 'type' => 'string'],
['name' => 'web_theme_color', 'value' => '#00B2CA', 'type' => 'string'],
['name' => 'firebase_project_id', 'value' => '', 'type' => 'string'],
['name' => 'company_address', 'value' => '', 'type' => 'string'],
['name' => 'place_api_key', 'value' => '', 'type' => 'string'],
['name' => 'placeholder_image', 'value' => 'assets/images/logo/placeholder.png', 'type' => 'file'],
['name' => 'header_logo', 'value' => 'assets/images/logo/Header Logo.svg', 'type' => 'file'],
['name' => 'footer_logo', 'value' => 'assets/images/logo/Footer Logo.svg', 'type' => 'file'],
['name' => 'default_latitude', 'value' => '-23.2420', 'type' => 'string'],
['name' => 'default_longitude', 'value' => '-69.6669', 'type' => 'string'],
['name' => 'file_manager', 'value' => 'public', 'type' => 'string'],
['name' => 'show_landing_page', 'value' => '1', 'type' => 'boolean'],
['name' => 'mobile_authentication', 'value' => '1', 'type' => 'boolean'],
['name' => 'google_authentication', 'value' => '1', 'type' => 'boolean'],
['name' => 'email_authentication', 'value' => '1', 'type' => 'boolean'],
['name' => 'min_length', 'value' => '5', 'type' => 'number'],
['name' => 'max_length', 'value' => '100', 'type' => 'number'],
['name' => 'currency_symbol_position', 'value' => 'right', 'type' => 'string'],
['name' => 'free_ad_listing', 'value' => '0', 'type' => 'boolean'],
['name' => 'auto_approve_item', 'value' => '0', 'type' => 'boolean'],
['name' => 'auto_approve_edited_item', 'value' => '0', 'type' => 'boolean'],
['name' => 'mail_mailer', 'value' => 'smtp', 'type' => 'string'],
['name' => 'mail_host', 'value' => 'mailhog', 'type' => 'string'],
['name' => 'mail_port', 'value' => '1025', 'type' => 'string'],
['name' => 'mail_username', 'value' => '', 'type' => 'string'],
['name' => 'mail_password', 'value' => '', 'type' => 'string'],
['name' => 'mail_encryption', 'value' => 'tls', 'type' => 'string'],
['name' => 'mail_from_address', 'value' => 'hello@example.com', 'type' => 'string'],
['name' => 'depp_link_scheme', 'value' => 'eclassify', 'type' => 'string'],
['name' => 'otp_service_provider', 'value' => 'firebase', 'type' => 'string'],
['name' => 'account_holder_name', 'value' => '', 'type' => 'string'],
['name' => 'bank_name', 'value' => '', 'type' => 'string'],
['name' => 'account_number', 'value' => '', 'type' => 'string'],
['name' => 'ifsc_swift_code', 'value' => '', 'type' => 'string'],
['name' => 'twilio_account_sid', 'value' => '', 'type' => 'string'],
['name' => 'twilio_auth_token', 'value' => '', 'type' => 'string'],
['name' => 'twilio_my_phone_number', 'value' => '', 'type' => 'string'],
['name' => 'map_provider', 'value' => 'free_api', 'type' => 'string'],
['name' => 'refund_policy', 'value' => '', 'type' => 'string'],
['name' => 'free_ad_unlimited', 'value' => '1', 'type' => 'boolean'],
['name' => 'watermark_enabled', 'value' => '0', 'type' => 'boolean'],
['name' => 'watermark_image', 'value' => '', 'type' => 'file'],
['name' => 'watermark_opacity', 'value' => '50', 'type' => 'string'],
['name' => 'watermark_size', 'value' => '20', 'type' => 'string'],
['name' => 'watermark_style', 'value' => 'single', 'type' => 'string'],
['name' => 'watermark_position', 'value' => 'center', 'type' => 'string'],
['name' => 'watermark_rotation', 'value' => '-30', 'type' => 'string'],
['name' => 'currency_iso_code', 'value' => 'USD', 'type' => 'string'],
['name' => 'currency_symbol_position', 'value' => 'left', 'type' => 'string'],
// --- Currency Formatting Settings ---
['name' => 'decimal_places', 'value' => '2', 'type' => 'integer'],
['name' => 'thousand_separator', 'value' => ',', 'type' => 'string'],
['name' => 'decimal_separator', 'value' => '.', 'type' => 'string'],
];
}
}

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Services;
use App\Models\Item;
use App\Models\UserPurchasedPackage;
use App\Models\User;
use App\Models\UserFcmToken;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class ExpiringItemService
{
/**
* Send notifications for expiring items and packages.
*/
public function notifyExpiringItemsAndPackages()
{
$this->notifyExpiringItems();
$this->notifyExpiringPackages();
}
/**
* Send notifications for expiring items.
*/
public function notifyExpiringItems()
{
$twoDaysFromNow = Carbon::now()->addDays(2)->startOfDay();
$items = Item::whereDate('expiry_date', '=', $twoDaysFromNow)
->where('status', 'approved')
->get();
$skippedCount = 0;
foreach ($items as $item) {
$user = $item->user;
if ($user && $user->email) {
$this->sendNotification($user, $item);
} else {
$skippedCount++;
}
}
if ($skippedCount > 0) {
Log::warning("Skipped {$skippedCount} item notifications due to missing user or email");
}
}
/**
* Send notifications for expiring packages.
*/
public function notifyExpiringPackages()
{
$twoDaysFromNow = Carbon::now()->addDays(2)->startOfDay();
$packages = UserPurchasedPackage::whereDate('end_date', '=', $twoDaysFromNow)->get();
$skippedCount = 0;
foreach ($packages as $package) {
$user = $package->user;
$packageDetails = $package->package;
if ($user && $user->email && $packageDetails) {
$this->sendPackageNotification($user, $packageDetails, $package);
} else {
$skippedCount++;
}
}
if ($skippedCount > 0) {
Log::warning("Skipped {$skippedCount} package notifications due to missing user, email, or package details");
}
}
/**
* Send email + push notification for expiring items.
*/
protected function sendNotification(User $user, $item)
{
try {
$expiryDate = Carbon::parse($item->expiry_date)->format('d M Y');
$message = "Your advertisement '{$item->name}' is expiring on {$expiryDate}. Please take action before it expires.";
// ✅ Send Email
Mail::raw(
"Hello {$user->name},\n\n{$message}",
function ($msg) use ($user) {
$msg->to($user->email)
->from('admin@yourdomain.com', 'Admin')
->subject('Advertisement Expiring Soon');
}
);
// ✅ Send Push Notification
$user_tokens = UserFcmToken::where('user_id', $user->id)->pluck('fcm_token')->toArray();
if (!empty($user_tokens)) {
$fcmMsg = [
'item_id' => $item->id,
'type' => 'item_expiry',
'expiry_date' => $item->expiry_date,
];
NotificationService::sendFcmNotification(
$user_tokens,
'Advertisement Expiring Soon',
$message,
'item_expiry',
$fcmMsg
);
}
Log::info("Expiry notification sent to: {$user->email} for Advertisement: {$item->name}");
} catch (\Exception $e) {
Log::error("Failed to send notification for Advertisement {$item->id}: " . $e->getMessage());
}
}
/**
* Send email + push notification for expiring packages.
*/
protected function sendPackageNotification(User $user, $package, $userPackage)
{
try {
$expiryDate = Carbon::parse($userPackage->end_date)->format('d M Y');
$message = "Your subscription package '{$package->name}' is expiring on {$expiryDate}. Please renew or upgrade your subscription.";
// ✅ Send Email
Mail::raw(
"Hello {$user->name},\n\n{$message}",
function ($msg) use ($user) {
$msg->to($user->email)
->from('admin@yourdomain.com', 'Admin')
->subject('Package Expiring Soon');
}
);
// ✅ Send Push Notification
$user_tokens = UserFcmToken::where('user_id', $user->id)->pluck('fcm_token')->toArray();
if (!empty($user_tokens)) {
$fcmMsg = [
'package_id' => $package->id,
'type' => 'package_expiry',
'expiry_date' => $userPackage->end_date,
];
NotificationService::sendFcmNotification(
$user_tokens,
'Package Expiring Soon',
$message,
'package_expiry',
$fcmMsg
);
}
Log::info("Package expiry notification sent to: {$user->email} for package: {$package->name}");
} catch (\Exception $e) {
Log::error("Failed to send notification for Package {$userPackage->id}: " . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,286 @@
<?php
namespace App\Services;
use App\Jobs\AddWatermarkJob;
use App\Models\Setting;
use App\Services\HelperService;
use Exception;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Intervention\Image\Facades\Image;
use Illuminate\Support\Facades\Log;
use RuntimeException;
use Spatie\ImageOptimizer\OptimizerChainFactory;
class FileService
{
/**
* @param $requestFile
* @param string $folder
* @param bool $addWaterMark
* @return string|false
*/
public static function compressAndUpload($requestFile, string $folder, bool $addWaterMark = false)
{
$filenameWithoutExt = pathinfo($requestFile->getClientOriginalName(), PATHINFO_FILENAME);
$extension = strtolower($requestFile->getClientOriginalExtension());
$fileName = time() . '-' . Str::slug($filenameWithoutExt) . '.' . $extension;
$disk = config('filesystems.default');
$path = $folder . '/' . $fileName;
try {
if (in_array($extension, ['jpg', 'jpeg', 'png', 'webp'])) {
// Compress and save image
$image = Image::make($requestFile)->encode($extension, 80);
Storage::disk($disk)->put($path, (string) $image);
// Get absolute path safely
$absolutePath = self::getAbsolutePath($disk, $path);
if (! $absolutePath) {
// Log::warning('Cannot get absolute path for image', ['disk' => $disk, 'path' => $path]);
return $path;
}
// Queue watermark if enabled
if ($addWaterMark && HelperService::getWatermarkConfigStatus()) {
// Log::info('Watermark: watermark is enable');
AddWatermarkJob::dispatch($absolutePath, $extension);
} elseif ($addWaterMark) {
Log::info('Watermark skipped: watermark is disabled');
}
return $path;
}
// Non-image files
$requestFile->storeAs($folder, $fileName, $disk);
return $path;
} catch (Exception $e) {
Log::error('FileService::compressAndUpload error: ' . $e->getMessage(), ['file' => $path]);
return false;
}
}
/**
* Get absolute path for a file stored on a disk
*
* @param string $disk
* @param string $path
* @return string|null
*/
private static function getAbsolutePath(string $disk, string $path): ?string
{
try {
$storagePath = Storage::disk($disk)->path($path);
if (file_exists($storagePath)) {
return $storagePath;
}
} catch (Exception $e) {
Log::error('FileService::getAbsolutePath error: ' . $e->getMessage(), ['disk' => $disk, 'path' => $path]);
}
return null;
}
/**
* @param $requestFile
* @param $folder
* @return string
*/
public static function upload($requestFile, $folder)
{
$file_name = uniqid('', true) . time() . '.' . $requestFile->getClientOriginalExtension();
Storage::disk(config('filesystems.default'))->putFileAs($folder, $requestFile, $file_name);
return $folder . '/' . $file_name;
}
/**
* @param $requestFile
* @param $folder
* @param $deleteRawOriginalImage
* @return string
*/
public static function replace($requestFile, $folder, $deleteRawOriginalImage)
{
self::delete($deleteRawOriginalImage);
return self::upload($requestFile, $folder);
}
/**
* @param $requestFile
* @param $folder
* @param $deleteRawOriginalImage
* @return string
*/
public static function compressAndReplace($requestFile, $folder, $deleteRawOriginalImage, bool $addWaterMark = false)
{
if (!empty($deleteRawOriginalImage)) {
self::delete($deleteRawOriginalImage);
}
return self::compressAndUpload($requestFile, $folder, $addWaterMark);
}
/**
* @param $requestFile
* @param $code
* @return string
*/
public static function uploadLanguageFile($requestFile, $code)
{
$filename = $code . '.' . $requestFile->getClientOriginalExtension();
if (file_exists(base_path('resources/lang/') . $filename)) {
File::delete(base_path('resources/lang/') . $filename);
}
$requestFile->move(base_path('resources/lang/'), $filename);
return $filename;
}
/**
* @param $file
* @return bool
*/
public static function deleteLanguageFile($file)
{
if (file_exists(base_path('resources/lang/') . $file)) {
return File::delete(base_path('resources/lang/') . $file);
}
return true;
}
/**
* @param $image = rawOriginalPath
* @return bool
*/
public static function delete($image)
{
if (!empty($image) && Storage::disk(config('filesystems.default'))->exists($image)) {
return Storage::disk(config('filesystems.default'))->delete($image);
}
//Image does not exist in server so feel free to upload new image
return true;
}
/**
* @throws Exception
*/
public static function compressAndUploadWithWatermark($requestFile, $folder)
{
$file_name = uniqid('', true) . time() . '.' . $requestFile->getClientOriginalExtension();
try {
if (in_array($requestFile->getClientOriginalExtension(), ['jpg', 'jpeg', 'png'])) {
$watermarkPath = Setting::where('name', 'watermark_image')->value('value');
$fullWatermarkPath = storage_path('app/public/' . $watermarkPath);
$watermark = null;
$imagePath = $requestFile->getPathname();
if (!file_exists($imagePath) || !is_readable($imagePath)) {
throw new RuntimeException("Uploaded image file is not readable at path: " . $imagePath);
}
$image = Image::make($imagePath)->encode(null, 60);
$imageWidth = $image->width();
$imageHeight = $image->height();
if (!empty($watermarkPath) && file_exists($fullWatermarkPath)) {
$watermark = Image::make($fullWatermarkPath)
->resize($imageWidth, $imageHeight, function ($constraint) {
$constraint->aspectRatio(); // Preserve aspect ratio
})
->opacity(10);
}
if ($watermark) {
$image->insert($watermark, 'center');
}
Storage::disk(config('filesystems.default'))->put($folder . '/' . $file_name, (string)$image->encode());
} else {
// Else assign file as it is
$file = $requestFile;
$file->storeAs($folder, $file_name, 'public');
}
return $folder . '/' . $file_name;
} catch (Exception $e) {
throw new RuntimeException($e);
// $file = $requestFile;
// return $file->storeAs($folder, $file_name, 'public');
}
}
public static function compressAndReplaceWithWatermark($requestFile, $folder, $deleteRawOriginalImage = null)
{
if (!empty($deleteRawOriginalImage)) {
self::delete($deleteRawOriginalImage);
}
$file_name = uniqid('', true) . time() . '.' . $requestFile->getClientOriginalExtension();
try {
if (in_array($requestFile->getClientOriginalExtension(), ['jpg', 'jpeg', 'png'])) {
$watermarkPath = Setting::where('name', 'watermark_image')->value('value');
$fullWatermarkPath = storage_path('app/public/' . $watermarkPath);
$watermark = null;
$imagePath = $requestFile->getPathname();
if (!file_exists($imagePath) || !is_readable($imagePath)) {
throw new RuntimeException("Uploaded image file is not readable at path: " . $imagePath);
}
$image = Image::make($imagePath)->encode(null, 60);
$imageWidth = $image->width();
$imageHeight = $image->height();
if (!empty($watermarkPath) && file_exists($fullWatermarkPath)) {
$watermark = Image::make($fullWatermarkPath)
->resize($imageWidth, $imageHeight, function ($constraint) {
$constraint->aspectRatio(); // Preserve aspect ratio
})
->opacity(10);
}
if ($watermark) {
$image->insert($watermark, 'center');
}
Storage::disk(config('filesystems.default'))->put($folder . '/' . $file_name, (string)$image->encode());
} else {
$file = $requestFile;
$file->storeAs($folder, $file_name, 'public');
}
return $folder . '/' . $file_name;
} catch (Exception $e) {
throw new RuntimeException($e);
}
}
public static function renameLanguageFiles(string $oldCode, string $newCode): void
{
$langPath = resource_path('lang');
// Rename JSON file (for frontend)
if (file_exists($langPath . '/' . $oldCode . '.json')) {
rename(
$langPath . '/' . $oldCode . '.json',
$langPath . '/' . $newCode . '.json'
);
}
// Rename PHP language folder (for backend)
if (is_dir($langPath . '/' . $oldCode)) {
rename(
$langPath . '/' . $oldCode,
$langPath . '/' . $newCode
);
}
}
}

View File

@@ -0,0 +1,624 @@
<?php
namespace App\Services;
use App\Models\Area;
use App\Models\Category;
use App\Models\Setting;
use App\Services\CachingService;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use JsonException;
class HelperService
{
public static function changeEnv($updateData = []): bool
{
if (count($updateData) > 0) {
// Read .env-file
$env = file_get_contents(base_path() . '/.env');
// Split string on every " " and write into array
// $env = explode(PHP_EOL, $env);
$env = preg_split('/\r\n|\r|\n/', $env);
$env_array = [];
foreach ($env as $env_value) {
if (empty($env_value)) {
// Add and Empty Line
$env_array[] = '';
continue;
}
$entry = explode('=', $env_value, 2);
$env_array[$entry[0]] = $entry[0] . '="' . str_replace('"', '', $entry[1]) . '"';
}
foreach ($updateData as $key => $value) {
$env_array[$key] = $key . '="' . str_replace('"', '', $value) . '"';
}
// Turn the array back to a String
$env = implode("\n", $env_array);
// And overwrite the .env with the new data
file_put_contents(base_path() . '/.env', $env);
return true;
}
return false;
}
/**
* @description - This function will return the nested category Option tags using in memory optimization
*/
public static function childCategoryRendering(&$categories, int $level = 0, ?string $parentCategoryID = ''): bool
{
// Foreach loop only on the parent category objects
foreach (collect($categories)->where('parent_category_id', $parentCategoryID) as $key => $category) {
echo "<option value='$category->id'>" . str_repeat('&nbsp;', $level * 4) . '|-- ' . $category->name . '</option>';
// Once the parent category object is rendered we can remove the category from the main object so that redundant data can be removed
$categories->forget($key);
// Now fetch the subcategories of the main category
$subcategories = $categories->where('parent_category_id', $category->id);
if (! empty($subcategories)) {
// Finally if subcategories are available then call the recursive function & see the magic
self::childCategoryRendering($categories, $level + 1, $category->id);
}
}
return false;
}
public static function buildNestedChildSubcategoryObject($categories)
{
// Used json_decode & encode simultaneously because i wanted to convert whole nested array into object
try {
return json_decode(json_encode(self::buildNestedChildSubcategoryArray($categories), JSON_THROW_ON_ERROR), false, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
return (object) [];
}
}
private static function buildNestedChildSubcategoryArray($categories)
{
$children = [];
// First Add Parent Categories to root level in an array
foreach ($categories->toArray() as $value) {
if ($value['parent_category_id'] == '') {
$children[] = $value;
}
}
// Then loop on the Parent Category to find the children categories
foreach ($children as $key => $value) {
$children[$key]['subcategories'] = self::findChildCategories($categories->toArray(), $value['id']);
}
return $children;
}
public static function findChildCategories($arr, $parent)
{
$children = [];
foreach ($arr as $key => $value) {
if ($value['parent_category_id'] == $parent) {
$children[] = $value;
}
}
foreach ($children as $key => $value) {
$children[$key]['subcategories'] = self::findChildCategories($arr, $value['id']);
}
return $children;
}
/*
* Sagar's Code :
* in this i have approached the reverse object moving & removing.
* which is not working as of now.
* but will continue working on this in future as it seems bit optimized approach from the current one
public static function buildNestedChildSubcategoryObject($categories, $finalCategories = []) {
echo "<pre>";
// Foreach loop only on the parent category objects
if (!empty($finalCategories)) {
$finalCategories = $categories->whereNull('parent_category_id');
}
foreach ($categories->whereNotNull('parent_category_id')->sortByDesc('parent_category_id') as $key => $category) {
echo "----------------------------------------------------------------------<br>";
$parentCategoryIndex = $categories->search(function ($data) use ($category) {
return $data['id'] == $category->parent_category_id;
});
if (!$parentCategoryIndex) {
continue;
}
// echo "*** This category will be moved to its parent category object ***<br>";
// print_r($category->toArray());
// Once the parent category object is rendered we can remove the category from the main object so that redundant data can be removed
$categories[$parentCategoryIndex]->subcategories[] = $category->toArray();
$categories->forget($key);
echo "<br>*** After all the operation main categories object will look like this ***<br>";
print_r($categories->toArray());
if (!empty($categories)) {
// Finally if subcategories are available then call the recursive function & see the magic
return self::buildNestedChildSubcategoryObject($categories, $finalCategories);
}
}
return $categories;
} */
public static function findParentCategory($category, $finalCategories = [])
{
$category = Category::find($category);
if (! empty($category)) {
$finalCategories[] = $category->id;
if (! empty($category->parent_category_id)) {
$finalCategories[] = self::findParentCategory($category->id, $finalCategories);
}
}
return $finalCategories;
}
/**
* Generate Slug for any model
*
* @param $model - Instance of Model
*/
public static function generateUniqueSlug($model, string $slug, ?int $excludeID = null, int $count = 0): string
{
/* NOTE : This can be improved by directly calling in the UI on type of title via AJAX */
$slug = Str::slug($slug);
$newSlug = $count ? $slug . '-' . $count : $slug;
$data = $model::where('slug', $newSlug);
if ($excludeID !== null) {
$data->where('id', '!=', $excludeID);
}
if (in_array(SoftDeletes::class, class_uses_recursive($model), true)) {
$data->withTrashed();
}
while ($data->exists()) {
return self::generateUniqueSlug($model, $slug, $excludeID, $count + 1);
}
return $newSlug;
}
public static function findAllCategoryIds($model): array
{
$ids = [];
foreach ($model as $item) {
$ids[] = $item['id'];
if (! empty($item['children'])) {
$ids = array_merge($ids, self::findAllCategoryIds($item['children']));
}
}
return $ids;
}
public static function generateRandomSlug($length = 10)
{
// Generate a random string of lowercase letters and numbers
$characters = 'abcdefghijklmnopqrstuvwxyz-';
$slug = '';
for ($i = 0; $i < $length; $i++) {
$index = rand(0, strlen($characters) - 1);
$slug .= $characters[$index];
}
return $slug;
}
/**
* Apply location filters to Item query with fallback logic
* Priority: area_id > city > state > country > latitude/longitude
*
* @param \Illuminate\Database\Eloquent\Builder $query
* @param \Illuminate\Http\Request $request
* @param callable $applyAuthFilters Function to apply auth-specific filters
* @return array ['query' => $query, 'message' => $locationMessage]
*/
public static function applyLocationFilters($query, $request, $applyAuthFilters)
{
$isHomePage = $request->current_page === 'home';
$locationMessage = null;
$hasLocationFilter = $request->latitude !== null && $request->longitude !== null;
$hasCityFilter = !empty($request->city);
$hasStateFilter = !empty($request->state);
$hasCountryFilter = !empty($request->country);
$hasAreaFilter = !empty($request->area_id);
$hasAreaLocationFilter = !empty($request->area_latitude) && !empty($request->area_longitude);
$cityName = $request->city ?? null;
$stateName = $request->state ?? null;
$countryName = $request->country ?? null;
$areaId = $request->area_id ?? null;
$cityItemCount = 0;
$stateItemCount = 0;
$countryItemCount = 0;
$areaItemCount = 0;
$areaName = null;
// Handle area location filter (find closest area by lat/long)
if ($hasAreaLocationFilter && !$hasAreaFilter) {
$areaLat = $request->area_latitude;
$areaLng = $request->area_longitude;
$haversine = "(6371 * acos(cos(radians($areaLat))
* cos(radians(latitude))
* cos(radians(longitude) - radians($areaLng))
+ sin(radians($areaLat)) * sin(radians(latitude))))";
$closestArea = Area::whereNotNull('latitude')
->whereNotNull('longitude')
->selectRaw("areas.*, {$haversine} AS distance")
// ->orderBy('distance', 'asc')
->orderByRaw(
'(6371 * acos(
cos(radians(?)) *
cos(radians(latitude)) *
cos(radians(longitude) - radians(?)) +
sin(radians(?)) * sin(radians(latitude))
)) ASC',
[$areaLat, $areaLng, $areaLat]
)
->first();
if ($closestArea) {
$hasAreaFilter = true;
$areaId = $closestArea->id;
}
}
// Get area name if area filter is set
if ($hasAreaFilter) {
$area = Area::find($areaId);
$areaName = $area ? $area->name : __('the selected area');
}
// Save base query before location filters for fallback
$baseQueryBeforeLocation = clone $query;
// First, check for area filter (highest priority)
if ($hasAreaFilter) {
$areaQuery = clone $query;
$areaQuery->where('area_id', $areaId);
$areaQuery = $applyAuthFilters($areaQuery);
$areaItemExists = $areaQuery->exists();
if ($areaItemExists) {
$query = $areaQuery;
$areaItemCount = 1;
} else {
if ($isHomePage) {
$locationMessage = __('No Ads found in :area. Showing all available Ads.', ['area' => $areaName]);
} else {
$query = $areaQuery;
}
$areaItemCount = 0;
}
}
// Second, check for city filter
if ($hasCityFilter && (!$hasAreaFilter || $areaItemCount == 0)) {
$cityQuery = clone $query;
$cityQuery->where('city', $cityName);
$cityQuery = $applyAuthFilters($cityQuery);
$cityItemExists = $cityQuery->exists();
if ($cityItemExists) {
$query = $cityQuery;
$cityItemCount = 1;
if ($hasAreaFilter && $areaItemCount == 0 && $isHomePage) {
$locationMessage = __('No Ads found in :city. Showing all available Ads.', ['city' => $cityName]);
}
} else {
$cityItemCount = 0;
if ($isHomePage) {
if (!$locationMessage) {
$locationMessage = __('No Ads found in :city. Showing all available Ads.', ['city' => $cityName]);
} else {
$locationMessage = __('No Ads found in :area or :city. Showing all available Ads.', ['area' => $areaName, 'city' => $cityName]);
}
} else {
$query = $cityQuery;
}
}
}
// Third, check for state filter
if ($hasStateFilter && (!$hasAreaFilter || $areaItemCount == 0) && (!$hasCityFilter || $cityItemCount == 0)) {
$stateQuery = clone $query;
$stateQuery->where('state', $stateName);
$stateQuery = $applyAuthFilters($stateQuery);
$stateItemExists = $stateQuery->exists();
if ($stateItemExists) {
$query = $stateQuery;
$stateItemCount = 1;
if (($hasAreaFilter && $areaItemCount == 0) || ($hasCityFilter && $cityItemCount == 0)) {
if ($isHomePage) {
$locationMessage = __('No Ads found in :state. Showing all available Ads.', ['state' => $stateName]);
}
}
} else {
$stateItemCount = 0;
if ($isHomePage) {
if (!$locationMessage) {
$locationMessage = __('No Ads found in :state. Showing all available Ads.', ['state' => $stateName]);
} else {
$parts = [];
if ($hasAreaFilter && $areaItemCount == 0) {
$parts[] = $areaName;
}
if ($hasCityFilter && $cityItemCount == 0) {
$parts[] = $cityName;
}
$parts[] = $stateName;
$locationMessage = __('No Ads found in :locations. Showing all available Ads.', ['locations' => implode(', ', $parts)]);
}
} else {
$query = $stateQuery;
}
}
}
// Fourth, check for country filter
if ($hasCountryFilter && (!$hasAreaFilter || $areaItemCount == 0) && (!$hasCityFilter || $cityItemCount == 0) && (!$hasStateFilter || $stateItemCount == 0)) {
$countryQuery = clone $query;
$countryQuery->where('country', $countryName);
$countryQuery = $applyAuthFilters($countryQuery);
$countryItemExists = $countryQuery->exists();
if ($countryItemExists) {
$query = $countryQuery;
$countryItemCount = 1;
if (($hasAreaFilter && $areaItemCount == 0) || ($hasCityFilter && $cityItemCount == 0) || ($hasStateFilter && $stateItemCount == 0)) {
if ($isHomePage) {
$locationMessage = __('No Ads found in :country. Showing all available Ads.', ['country' => $countryName]);
}
}
} else {
$countryItemCount = 0;
if ($isHomePage) {
if (!$locationMessage) {
$locationMessage = __('No Ads found in :country. Showing all available Ads.', ['country' => $countryName]);
} else {
$parts = [];
if ($hasAreaFilter && $areaItemCount == 0) {
$parts[] = $areaName;
}
if ($hasCityFilter && $cityItemCount == 0) {
$parts[] = $cityName;
}
if ($hasStateFilter && $stateItemCount == 0) {
$parts[] = $stateName;
}
$parts[] = $countryName;
$locationMessage = __('No Ads found in :locations. Showing all available Ads.', ['locations' => implode(', ', $parts)]);
}
} else {
$query = $countryQuery;
}
}
}
// Fifth, handle latitude/longitude location-based search
$hasHigherPriorityFilter = ($hasAreaFilter && $areaItemCount > 0) || ($hasCityFilter && $cityItemCount > 0) || ($hasStateFilter && $stateItemCount > 0) || ($hasCountryFilter && $countryItemCount > 0);
if ($hasLocationFilter && ((!$hasAreaFilter && !$hasCityFilter && !$hasStateFilter && !$hasCountryFilter) || $hasHigherPriorityFilter)) {
$latitude = $request->latitude;
$longitude = $request->longitude;
$requestedRadius = (float)($request->radius ?? null);
$exactLocationRadius = $request->radius;
$haversine = '(6371 * acos(cos(radians(?))
* cos(radians(latitude))
* cos(radians(longitude) - radians(?))
+ sin(radians(?)) * sin(radians(latitude))))';
$exactLocationQuery = clone $query;
$exactLocationQuery
->select('items.*')
->selectRaw("$haversine AS distance", [$latitude, $longitude, $latitude])
->where('latitude', '!=', 0)
->where('longitude', '!=', 0)
// CHANGE THIS: Use whereRaw instead of having to support pagination count
// Use <= so radius=0.0 still returns items at the exact same coordinates (distance 0)
->whereRaw("$haversine <= ?", [$latitude, $longitude, $latitude, $exactLocationRadius])
->orderBy('distance', 'asc');
if (Auth::check()) {
$exactLocationQuery->with(['item_offers' => function ($q) {
$q->where('buyer_id', Auth::user()->id);
}, 'user_reports' => function ($q) {
$q->where('user_id', Auth::user()->id);
}]);
$currentURI = explode('?', $request->getRequestUri(), 2);
if ($currentURI[0] == '/api/my-items') {
$exactLocationQuery->where(['user_id' => Auth::user()->id])->withTrashed();
} else {
$exactLocationQuery->where('status', 'approved')->has('user')->onlyNonBlockedUsers()->getNonExpiredItems();
}
} else {
$exactLocationQuery->where('status', 'approved')->getNonExpiredItems();
}
$exactLocationExists = $exactLocationQuery->exists();
if ($exactLocationExists) {
$query = $exactLocationQuery;
} else {
$searchRadius = $requestedRadius !== null && $requestedRadius > 0 ? $requestedRadius : 50;
$nearbyQuery = clone $query;
$nearbyQuery
->select('items.*')
->selectRaw("$haversine AS distance", [$latitude, $longitude, $latitude])
->where('latitude', '!=', 0)
->where('longitude', '!=', 0)
// CHANGE THIS: Use whereRaw instead of having
->whereRaw("$haversine < ?", [$latitude, $longitude, $latitude, $searchRadius])
->orderBy('distance', 'asc');
$nearbyQuery = $applyAuthFilters($nearbyQuery);
$nearbyItemExists = $nearbyQuery->exists();
if ($nearbyItemExists) {
$query = $nearbyQuery;
if (!$locationMessage) {
$locationMessage = __('No Ads found at your location. Showing nearby Ads.');
}
} else {
if ($isHomePage) {
$query = clone $baseQueryBeforeLocation;
if (!$locationMessage) {
$locationMessage = __('No Ads found at your location. Showing all available Ads.');
}
} else {
$query = $nearbyQuery;
}
}
}
}
return ['query' => $query, 'message' => $locationMessage];
}
/**
* Get watermark configuration status (enabled/disabled)
*
* @return bool
*/
public static function getWatermarkConfigStatus(): bool
{
$enabled = CachingService::getSystemSettings('watermark_enabled');
return !empty($enabled) && (int)$enabled === 1;
}
/**
* Get watermark configuration as decoded array
*
* @return array
*/
public static function getWatermarkConfigDecoded(): array
{
$settings = CachingService::getSystemSettings([
'watermark_enabled',
'watermark_image',
'watermark_opacity',
'watermark_size',
'watermark_style',
'watermark_position',
'watermark_rotation',
]);
return [
'enabled' => (int)($settings['watermark_enabled'] ?? 0),
'watermark_image' => $settings['watermark_image'] ?? null,
'opacity' => (int)($settings['watermark_opacity'] ?? 25),
'size' => (int)($settings['watermark_size'] ?? 10),
'style' => $settings['watermark_style'] ?? 'tile',
'position' => $settings['watermark_position'] ?? 'center',
'rotation' => (int)($settings['watermark_rotation'] ?? -30),
];
}
/**
* Get a specific setting value
*
* @param string $key
* @return string|null
*/
public static function getSettingData(string $key): ?string
{
return CachingService::getSystemSettings($key);
}
/**
* Calculate item expiry date based on package listing duration
* Priority: listing_duration > package expiry > default
*
* @param \App\Models\Package|null $package
* @param \App\Models\UserPurchasedPackage|null $userPackage
* @return \Carbon\Carbon|null
*/
public static function calculateItemExpiryDate($package = null, $userPackage = null)
{
if (!$package) {
$freeAdUnlimited = Setting::where('name', 'free_ad_unlimited')->value('value') ?? 0;
$freeAdDays = Setting::where('name', 'free_ad_duration_days')->value('value') ?? 0;
// Unlimited free ads
if ((int) $freeAdUnlimited === 1) {
return null;
}
// Limited free ads
if (!empty($freeAdDays) && (int) $freeAdDays > 0) {
return Carbon::now()->addDays((int) $freeAdDays);
}
// Safety fallback (no expiry)
return null;
}
// ---------------------------------------
// PACKAGE LOGIC (existing, unchanged)
// ---------------------------------------
$listingDurationType = $package->listing_duration_type ?? null;
$listingDurationDays = $package->listing_duration_days ?? null;
// If listing_duration_type is null → use package expiry
if ($listingDurationType === null) {
if ($userPackage && $userPackage->end_date) {
return Carbon::parse($userPackage->end_date);
}
if ($package->duration === 'unlimited') {
return null;
}
return Carbon::now()->addDays((int) $package->duration);
}
if ($listingDurationType === 'package') {
if ($package->duration === 'unlimited') {
return null;
}
return Carbon::now()->addDays((int) $package->duration);
}
if ($listingDurationType === 'custom') {
if (!empty($listingDurationDays) && (int) $listingDurationDays > 0) {
return Carbon::now()->addDays((int) $listingDurationDays);
}
return Carbon::now()->addDays(30);
}
// Standard fallback
if (!empty($listingDurationDays) && (int) $listingDurationDays > 0) {
return Carbon::now()->addDays((int) $listingDurationDays);
}
return Carbon::now()->addDays(30);
}
}

View File

@@ -0,0 +1,267 @@
<?php
namespace App\Services;
use App\Models\Setting;
use App\Models\User;
use App\Models\UserFcmToken;
use Google\Client;
use Google\Exception;
use Illuminate\Http\Request as HttpRequest;
use RuntimeException;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Storage;
use Throwable;
class NotificationService {
/**
* @param array $registrationIDs
* @param string|null $title
* @param string|null $message
* @param string $type
* @param array $customBodyFields
* @return string|array|bool
*/
public static function sendFcmNotification(
array $registrationIDs,
string|null $title = '',
string|null $message = '',
string $type = "default",
array $customBodyFields = [],
bool $sendToAll = false
): string|array|bool {
try {
$project_id = Setting::select('value')->where('name', 'firebase_project_id')->first();
if (empty($project_id->value)) {
return ['error' => true, 'message' => 'FCM configurations are not configured.'];
}
$project_id = $project_id->value;
$url = 'https://fcm.googleapis.com/v1/projects/' . $project_id . '/messages:send';
$access_token = self::getAccessToken();
if ($access_token['error']) {
return $access_token;
}
$dataWithTitle = [
...$customBodyFields,
"title" => $title,
"body" => $message,
"type" => $type,
];
// ✅ Case 1: Send to all (topic-based)
if ($sendToAll) {
$data = [
"message" => [
"topic" => "allUsers", // universal topic (subscribe everyone here)
"data" => self::convertToStringRecursively($dataWithTitle),
"notification" => [
"title" => $title,
"body" => $message
]
]
];
$encodedData = json_encode($data);
$headers = [
'Authorization: Bearer ' . $access_token['data'],
'Content-Type: application/json',
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $encodedData);
$result = curl_exec($ch);
if (!$result) {
return ['error' => true, 'message' => 'Curl failed: ' . curl_error($ch)];
}
curl_close($ch);
return ['error' => false, 'message' => "Bulk notification sent via topic", 'data' => $result];
}
// ✅ Case 2: Send individually (like existing code)
$deviceInfo = UserFcmToken::with('user')
->select(['platform_type', 'fcm_token'])
->whereIn('fcm_token', $registrationIDs)
->whereHas('user', fn($q) => $q->where('notification', 1))
->get();
// ✅ Log all targeted users and their FCM tokens before sending
try {
// Log::info('🔔 Device Info for Notification', $deviceInfo->toArray());
} catch (Throwable $e) {
Log::error('Failed to log device info: ' . $e->getMessage());
}
$result = [];
foreach ($registrationIDs as $registrationID) {
$platform = $deviceInfo->first(fn($q) => $q->fcm_token == $registrationID);
if (!$platform) continue;
$data = [
'message' => [
'token' => $registrationID,
// Notification block (title & body)
'notification' => [
'title' => $title,
'body' => $message,
],
// Custom data (ALL values must be strings)
'data' => self::convertToStringRecursively($dataWithTitle),
// Android config
'android' => [
'priority' => 'high',
'notification' => [
'image' => !empty($dataWithTitle['image']) ? $dataWithTitle['image'] : null,
],
],
// iOS (APNs) config
'apns' => [
'headers' => [
'apns-priority' => '10',
],
'payload' => [
'aps' => [
'alert' => [
'title' => $title,
'body' => $message,
],
'sound' => 'default',
'mutable-content' => 1, // REQUIRED for images
],
],
'fcm_options' => [
'image' => !empty($dataWithTitle['image']) ? $dataWithTitle['image'] : null,
],
],
],
];
$encodedData = json_encode($data);
$headers = [
'Authorization: Bearer ' . $access_token['data'],
'Content-Type: application/json',
];
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $encodedData);
$result[] = curl_exec($ch);
curl_close($ch);
}
return ['error' => false, 'message' => "Individual notifications sent", 'data' => $result];
} catch (Throwable $th) {
throw new RuntimeException($th);
}
}
public static function getAccessToken() {
try {
$file_name = Setting::select('value')->where('name', 'service_file')->first();
if (empty($file_name)) {
return [
'error' => true,
'message' => 'FCM Configuration not found'
];
}
$disk = config('filesystems.default');
$file = $file_name->value;
if ($disk === 'local' || $disk === 'public') {
// LOCAL STORAGE
$file_path = Storage::disk($disk)->path($file);
} else {
// S3 (or any cloud disk)
// Download file to local temp
$fileContent = Storage::disk($disk)->get($file);
$file_path = storage_path('app/firebase_service.json');
file_put_contents($file_path, $fileContent);
}
if (!file_exists($file_path)) {
return [
'error' => true,
'message' => 'FCM Service File not found'
];
}
$client = new Client();
$client->setAuthConfig($file_path);
$client->setScopes(['https://www.googleapis.com/auth/firebase.messaging']);
return [
'error' => false,
'message' => 'Access Token generated successfully',
'data' => $client->fetchAccessTokenWithAssertion()['access_token']
];
} catch (Exception $e) {
throw new RuntimeException($e);
}
}
public static function convertToStringRecursively($data, &$flattenedArray = []) {
foreach ($data as $key => $value) {
if (is_array($value)) {
self::convertToStringRecursively($value, $flattenedArray);
} elseif (is_null($value)) {
$flattenedArray[$key] = '';
} else {
$flattenedArray[$key] = (string)$value;
}
}
return $flattenedArray;
}
public static function sendNewDeviceLoginEmail(User $user, HttpRequest $request)
{
try {
Log::info("sending New device login alert");
$deviceType = ucfirst($request->platform_type ?? 'Unknown');
$ip = request()->ip();
$loginTime = now()->format('d M Y - h:i A');
// Fetch company name
$companyName = Setting::where('name', 'company_name')->value('value') ?? 'Unknown';
// Email message
$message =
"A new device has just logged in to your {$companyName} account.\n\n" .
"⏰ Login Time: {$loginTime}\n\n";
Mail::raw($message, function ($msg) use ($user, $companyName) {
$msg->to($user->email)
->from('admin@yourdomain.com', $companyName)
->subject("New Device Login Detected - {$companyName}");
});
Log::info("New device login alert sent to: {$user->email}");
} catch (\Exception $e) {
Log::error("Failed to send new device login email: " . $e->getMessage());
}
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Services\Payment;
use App\Services\ResponseService;
use KingFlamez\Rave\Rave as Flutterwave;
use Exception;
use Illuminate\Support\Facades\Auth;
class FlutterwavePayment implements PaymentInterface
{
private string $currencyCode;
private Flutterwave $flutterwave;
private string $encryptionKey;
protected $baseUrl;
private string $secretKey;
public function __construct($secret_key, $public_key, $encryption_key, $currencyCode)
{
$this->currencyCode = $currencyCode;
$this->encryptionKey = $encryption_key;
$this->baseUrl = 'https://api.flutterwave.com/v3';
$this->secretKey = $secret_key;
// Initialize Flutterwave SDK with API keys
$this->flutterwave = new Flutterwave([
'publicKey' => $public_key,
'secretKey' => $secret_key,
'encryptionKey' => $encryption_key,
]);
}
public function createPaymentIntent($amount, $customMetaData)
{
try {
if (empty($customMetaData['email'])) {
throw new Exception("Email cannot be empty");
}
$redirectUrl = ($customMetaData['platform_type'] == 'app')
? route('flutterwave.success')
: route('flutterwave.success.web');
$finalAmount =$amount;
// $transactionRef = uniqid('flw_');
$transactionRef = 't' .'-'. $customMetaData['payment_transaction_id'] .'-'. 'p' .'-'. $customMetaData['package_id'];
$data = [
'tx_ref' => $transactionRef,
'amount' => $finalAmount,
'currency' => $this->currencyCode,
'redirect_url' => $redirectUrl,
'payment_options' => 'card,banktransfer', // You can add more payment options
'customer' => [
'email' => $customMetaData['email'],
'phonenumber' => $customMetaData['phone'] ?? Auth::user()->mobile,
'name' => $customMetaData['name'] ?? Auth::user()->name,
],
'meta' => [
'package_id' => $customMetaData['package_id'],
'user_id' => $customMetaData['user_id'],
]
];
$data = json_encode($data, JSON_UNESCAPED_SLASHES);
$url = 'https://api.flutterwave.com/v3/payments';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->secretKey
]);
$response = curl_exec($ch);
if (curl_errno($ch)) {
return ['error' => curl_error($ch)];
}
curl_close($ch);
$payment = json_decode($response,true);
return $this->formatPaymentIntent($transactionRef, $finalAmount,$this->currencyCode,'pending',$customMetaData,$payment);
} catch (Exception $e) {
return ResponseService::errorResponse("Payment failed: " . $e->getMessage());
}
}
public function createAndFormatPaymentIntent($amount, $customMetaData): array
{
return $this->createPaymentIntent($amount, $customMetaData);
}
public function retrievePaymentIntent($transactionId): array
{
try {
$response = $this->flutterwave->verifyTransaction($transactionId);
if ($response['status'] === 'success') {
return $this->formatPaymentIntent(
$response['data']['tx_ref'],
$response['data']['amount'],
$response['data']['currency'],
$response['data']['status'],
[],
$response
);
}
throw new Exception("Error fetching payment status: " . $response['message']);
} catch (Exception $e) {
throw new Exception("Error verifying transaction: " . $e->getMessage());
}
}
public function formatPaymentIntent($id, $amount, $currency, $status, $metadata, $paymentIntent): array
{
return [
'id' => $id,
'amount' => $amount,
'currency' => $currency,
'metadata' => $metadata,
'status' => match ($status) {
'successful' => 'succeeded',
'pending' => 'pending',
'failed' => 'failed',
default => 'unknown'
},
'payment_gateway_response' => $paymentIntent
];
}
public function minimumAmountValidation($currency, $amount)
{
$minimumAmount = match ($currency) {
'NGN' => 50, // 50 Naira
default => 1.00
};
return ($amount >= $minimumAmount) ? $amount : $minimumAmount;
}
}

View File

@@ -0,0 +1,251 @@
<?php
namespace App\Services\Payment;
use Exception;
use GuzzleHttp\Client;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class PayPalPayment implements PaymentInterface
{
private string $clientId;
private string $secretKey;
private string $currencyCode;
private string $paymentmode;
private Client $http;
private string $baseUrl;
public function __construct($clientId, $secretKey, $currencyCode, $paymentmode)
{
$this->clientId = $clientId;
$this->secretKey = $secretKey;
$this->currencyCode = $currencyCode;
$this->paymentmode = $paymentmode;
$this->http = new Client([
'base_uri' => ($paymentmode == "UAT") ? 'https://api.sandbox.paypal.com' : 'https://api-m.paypal.com',
'timeout' => 30,
]);
$this->baseUrl = ($paymentmode == "UAT")
? 'https://api.sandbox.paypal.com'
: 'https://api.paypal.com';
}
private function generateAccessToken(): string
{
$response = $this->http->post('/v1/oauth2/token', [
'auth' => [$this->clientId, $this->secretKey],
'form_params' => ['grant_type' => 'client_credentials'],
'headers' => [
'Accept' => 'application/json',
'Accept-Language' => 'en_US'
]
]);
$data = json_decode((string)$response->getBody(), true);
return $data['access_token'] ?? throw new Exception("Unable to generate PayPal token");
}
/**
* Create a PayPal Order (Payment Intent equivalent)
*/
public function createPaymentIntent($amount, $customMetaData)
{
$amount = $this->minimumAmountValidation($this->currencyCode, $amount);
$accessToken = $this->generateAccessToken();
if($customMetaData['platform_type'] == 'app') {
$callbackUrl = route('paypal.success') ;
}else{
$callbackUrl = route('paypal.success.web');
}
$metaData = 't' . '-' . $customMetaData['payment_transaction_id'] . '-' . 'p' . '-' . $customMetaData['package_id'];
$response = $this->http->post('/v2/checkout/orders', [
'headers' => [
'Authorization' => "Bearer {$accessToken}",
'Content-Type' => 'application/json'
],
'json' => [
'intent' => 'CAPTURE',
'purchase_units' => [[
'amount' => [
'currency_code' => $this->currencyCode,
'value' => number_format($amount, 2, '.', '')
],
'custom_id' => $metaData,
'description' => $customMetaData['description'] ?? null,
]],
'application_context' => [
'return_url' => $callbackUrl,
'cancel_url' => $callbackUrl,
]
]
]);
$data = json_decode((string)$response->getBody(), true);
return $data;
}
/**
* ✅ Matches PaymentInterface (like Stripe)
*/
public function createAndFormatPaymentIntent($amount, $customMetaData): array
{
$order = $this->createPaymentIntent($amount, $customMetaData);
// Extract approve link from PayPal response
$approveLink = null;
if (isset($order['links']) && is_array($order['links'])) {
foreach ($order['links'] as $link) {
if ($link['rel'] === 'approve') {
$approveLink = $link['href'];
break;
}
}
}
$formatted = $this->formatPaymentIntent(
$order['id'],
$amount,
$this->currencyCode,
$order['status'] ?? 'CREATED',
$customMetaData,
$order
);
// Add approval link for frontend
$formatted['approval_url'] = $approveLink;
return $formatted;
}
/**
* ✅ Retrieve order details (similar to Stripes retrievePaymentIntent)
*/
public function retrievePaymentIntent($paymentId): array
{
$accessToken = $this->generateAccessToken();
$response = $this->http->get("/v2/checkout/orders/{$paymentId}", [
'headers' => [
'Authorization' => "Bearer {$accessToken}",
'Content-Type' => 'application/json'
]
]);
$data = json_decode((string)$response->getBody(), true);
return $this->formatPaymentIntent(
$data['id'],
$data['purchase_units'][0]['amount']['value'] ?? 0,
$data['purchase_units'][0]['amount']['currency_code'] ?? $this->currencyCode,
$data['status'],
[],
$data
);
}
public function capturePayment($orderId): array
{
$accessToken = $this->generateAccessToken();
$response = $this->http->post("/v2/checkout/orders/{$orderId}/capture", [
'headers' => [
'Authorization' => "Bearer {$accessToken}",
'Content-Type' => 'application/json'
]
]);
$data = json_decode((string)$response->getBody(), true);
return $this->formatPaymentIntent(
$data['id'] ?? $orderId,
$data['purchase_units'][0]['payments']['captures'][0]['amount']['value'] ?? 0,
$data['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code'] ?? $this->currencyCode,
$data['status'] ?? 'FAILED',
[],
$data
);
}
public function refund($captureId, $amount, $currency = null): array
{
$accessToken = $this->generateAccessToken();
$currency = $currency ?? $this->currencyCode;
$response = $this->http->post("/v2/payments/captures/{$captureId}/refund", [
'headers' => [
'Authorization' => "Bearer {$accessToken}",
'Content-Type' => 'application/json'
],
'json' => [
'amount' => [
'value' => number_format($amount, 2, '.', ''),
'currency_code' => $currency
]
]
]);
return json_decode((string)$response->getBody(), true);
}
public function formatPaymentIntent($id, $amount, $currency, $status, $metadata, $paymentIntent): array
{
return [
'id' => $id,
'amount' => $amount,
'currency' => $currency,
'metadata' => $metadata,
'status' => match (strtolower($status)) {
"completed" => "succeed",
"approved" => "pending",
"created" => "pending",
default => "failed",
},
'payment_gateway_response' => $paymentIntent
];
}
public function minimumAmountValidation($currency, $amount)
{
$minimumAmount = 0.50;
return max($amount, $minimumAmount);
}
public function verifyWebhookSignature(array $headers, string $payload, string $webhookId): bool
{
try {
$accessToken = $this->generateAccessToken();
if($this->baseUrl == 'https://api.sandbox.paypal.com'){
return true;
}
$verification = Http::withToken($accessToken)
->post($this->baseUrl . '/v1/notifications/verify-webhook-signature', [
'auth_algo' => $headers['paypal-auth-algo'][0] ?? '',
'cert_url' => $headers['paypal-cert-url'][0] ?? '',
'transmission_id' => $headers['paypal-transmission-id'][0] ?? '',
'transmission_sig' => $headers['paypal-transmission-sig'][0] ?? '',
'transmission_time' => $headers['paypal-transmission-time'][0] ?? '',
'webhook_id' => $webhookId,
'webhook_event' => json_decode($payload, true),
]);
if (!$verification->successful()) {
Log::error('PayPal verifyWebhookSignature failed: ' . $verification->body());
return false;
}
return ($verification->json()['verification_status'] ?? '') === 'SUCCESS';
} catch (Throwable $e) {
Log::error('PayPal verifyWebhookSignature exception: ' . $e->getMessage());
return false;
}
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Services\Payment;
interface PaymentInterface {
public function createPaymentIntent($amount, $customMetaData);
public function createAndFormatPaymentIntent($amount, $customMetaData): array;
public function retrievePaymentIntent($paymentId): array;
public function minimumAmountValidation($currency, $amount);
public function formatPaymentIntent($id, $amount, $currency, $status, $metadata, $paymentIntent): array;
//
// public function checkPayment(Order $order): PaymentStatus;
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Services\Payment;
use App\Models\PaymentConfiguration;
use InvalidArgumentException;
class PaymentService {
/**
* @param string $paymentGateway - Stripe
* @return StripePayment
*/
public static function create(string $paymentGateway) {
$paymentGateway = strtolower($paymentGateway);
$payment = PaymentConfiguration::where(['payment_method' => $paymentGateway, 'status' => 1])->first();
if (!$payment) {
throw new InvalidArgumentException('Invalid Payment Gateway.');
}
return match ($paymentGateway) {
'stripe' => new StripePayment($payment->secret_key, $payment->currency_code),
'paystack' => new PaystackPayment($payment->currency_code),
'razorpay' => new RazorpayPayment($payment->secret_key, $payment->api_key, $payment->currency_code),
'phonepe' => new PhonePePayment($payment->secret_key , $payment->api_key,$payment->additional_data_1,$payment->additional_data_2,$payment->payment_mode),
'flutterwave' => new FlutterWavePayment($payment->secret_key, $payment->api_key, $payment->webhook_secret_key, $payment->currency_code),
'paypal' => new PayPalPayment($payment->api_key, $payment->secret_key, $payment->currency_code,$payment->payment_mode),
'google,apple' => null,
// any other payment processor implementations
default => throw new InvalidArgumentException('Invalid Payment Gateway.'),
};
}
/***
* @param string $paymentGateway
* @param $paymentIntentData
* @return array
* Stripe Payment Intent : https://stripe.com/docs/api/payment_intents/object
*/
// public static function formatPaymentIntent(string $paymentGateway, $paymentIntentData) {
// $paymentGateway = strtolower($paymentGateway);
// return match ($paymentGateway) {
// 'stripe' => [
// 'id' => $paymentIntentData->id,
// 'amount' => $paymentIntentData->amount,
// 'currency' => $paymentIntentData->currency,
// 'metadata' => $paymentIntentData->metadata,
// 'status' => match ($paymentIntentData->status) {
// "canceled" => "failed",
// "succeeded" => "succeed",
// "processing", "requires_action", "requires_capture", "requires_confirmation", "requires_payment_method" => "pending",
// },
// 'payment_gateway_response' => $paymentIntentData
// ],
//
// 'paystack' => [
// 'id' => $paymentIntentData['data']['reference'],
// 'amount' => $paymentIntentData->amount,
// 'currency' => $paymentIntentData->currency,
// 'metadata' => $paymentIntentData->metadata,
// 'status' => match ($paymentIntentData['data']['status']) {
// "abandoned" => "failed",
// "succeed" => "succeed",
// default => $paymentIntentData['data']['status'] ?? true
// },
// 'payment_gateway_response' => $paymentIntentData
// ],
// // any other payment processor implementations
// default => $paymentIntentData,
// };
// }
}

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Services\Payment;
use RuntimeException;
use Throwable;
use Unicodeveloper\Paystack\Paystack;
class PaystackPayment extends Paystack implements PaymentInterface {
private Paystack $paystack;
private string $currencyCode;
/**
* PaystackPayment constructor.
* @param $currencyCode
*/
public function __construct($currencyCode) {
// Call Paystack Class and Create Payment Intent
$this->paystack = new Paystack();
$this->currencyCode = $currencyCode;
parent::__construct();
}
/**
* @param $amount
* @param $customMetaData
* @return array
*/
public function createPaymentIntent($amount, $customMetaData) {
try {
if (empty($customMetaData['email'])) {
throw new RuntimeException("Email cannot be empty");
}
if($customMetaData['platform_type'] == 'app') {
$callbackUrl = route('paystack.success') ;
}else{
$callbackUrl = route('paystack.success.web');
}
$finalAmount = $amount * 100;
$reference = $this->genTranxRef();
$data = [
'amount' => $finalAmount,
'currency' => $this->currencyCode,
'email' => $customMetaData['email'],
'metadata' => $customMetaData,
'reference' => $reference,
'callback_url' => $callbackUrl
];
return $this->paystack->getAuthorizationResponse($data);
} catch (Throwable $e) {
throw new RuntimeException($e);
}
}
/**
* @param $amount
* @param $customMetaData
* @return array
*/
public function createAndFormatPaymentIntent($amount, $customMetaData): array {
$response = $this->createPaymentIntent($amount, $customMetaData);
return $this->format($response, $amount, $this->currencyCode, $customMetaData);
}
/**
* @param $paymentId
* @return array
* @throws Throwable
*/
public function retrievePaymentIntent($paymentId): array {
try {
$relativeUrl = "/transaction/verify/{$paymentId}";
$this->response = $this->client->get($this->baseUrl . $relativeUrl, []);
$response = json_decode($this->response->getBody(), true, 512, JSON_THROW_ON_ERROR);
return $this->format($response['data'], $response['data']['amount'], $response['data']['currency'], $response['data']['metadata']);
} catch (Throwable $e) {
throw new RuntimeException($e);
}
}
/**
* @param $currency
* @param $amount
*/
public function minimumAmountValidation($currency, $amount) {
// TODO: Implement minimumAmountValidation() method.
}
/**
* @param $paymentIntent
* @param $amount
* @param $currencyCode
* @param $metadata
* @return array
*/
public function format($paymentIntent, $amount, $currencyCode, $metadata) {
return $this->formatPaymentIntent($paymentIntent['data']['reference'], $amount, $currencyCode, $paymentIntent['status'], $metadata, $paymentIntent);
}
/**
* @param $id
* @param $amount
* @param $currency
* @param $status
* @param $metadata
* @param $paymentIntent
* @return array
*/
public function formatPaymentIntent($id, $amount, $currency, $status, $metadata, $paymentIntent): array {
return [
'id' => $id,
'amount' => $amount,
'currency' => $currency,
'metadata' => $metadata,
'status' => match ($status) {
"abandoned" => "failed",
"succeed" => "succeed",
default => $status ?? true
},
'payment_gateway_response' => $paymentIntent
];
}
}

View File

@@ -0,0 +1,382 @@
<?php
namespace App\Services\Payment;
use Auth;
use PhonePe\PhonePe as PhonePeSDK;
use Illuminate\Support\Facades\Log;
use Exception;
class PhonePePayment implements PaymentInterface
{
private string $clientId;
private string $callbackUrl;
private string $transactionId;
private string $clientSecret;
private string $clientVersion;
private string $payment_mode;
private string $merchantId;
private string $pgUrl;
public function __construct($clientSecret, $clientId, $addtional_data_1,$addtional_data_2, $payment_mode)
{
// $this->clientId = 'TEST-CITYSURFONLINE_2508';
$this->clientId = $clientId;
$this->callbackUrl = url('/webhook/phonePe');
$this->transactionId = uniqid();
$this->clientSecret = $clientSecret;
$this->clientVersion = $addtional_data_1;
$this->payment_mode = $payment_mode;
$this->merchantId = $addtional_data_2;
$this->pgUrl = ($payment_mode == "UAT") ? "https://api-preprod.phonepe.com/apis/pg-sandbox" : "https://api.phonepe.com/apis/pg";
}
/**
* Create payment intent for PhonePe
*
* @param $amount
* @param $customMetaData
* @return array
* @throws Exception
*/
public function createPaymentIntent($amount, $customMetaData) {
Log::info("PhonePe Payment custome", [
'amount' => $amount,
'customMetaData' => $customMetaData,
]);
$amount = $this->minimumAmountValidation('INR', $amount);
$userMobile = Auth::user()->mobile;
$metaData = 't' . '-' . $customMetaData['payment_transaction_id'] . '-' . 'p' . '-' . $customMetaData['package_id'];
if ($customMetaData['platform_type'] == 'web') {
$transactionId = uniqid();
$mode = $this->payment_mode;
if ($mode === 'PROD') {
$tokenUrl = 'https://api.phonepe.com/apis/identity-manager/v1/oauth/token';
$orderUrl = 'https://api.phonepe.com/apis/pg/checkout/v2/sdk/order';
$payUrl = 'https://api.phonepe.com/apis/pg/checkout/v2/pay';
} else {
$tokenUrl = 'https://api-preprod.phonepe.com/apis/pg-sandbox/v1/oauth/token';
$orderUrl = 'https://api-preprod.phonepe.com/apis/pg-sandbox/checkout/v2/sdk/order';
$payUrl = 'https://api-preprod.phonepe.com/apis/pg-sandbox/checkout/v2/pay';
}
$tokenCurl = curl_init();
curl_setopt_array($tokenCurl, array(
CURLOPT_URL => $tokenUrl,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '',
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => http_build_query([
'client_id' => $this->clientId,
'client_version' => $this->clientVersion,
'client_secret' => $this->clientSecret,
'grant_type' => 'client_credentials',
]),
CURLOPT_HTTPHEADER => array(
'Content-Type: application/x-www-form-urlencoded'
),
));
$response = curl_exec($tokenCurl);
curl_close($tokenCurl);
// Decode token response
$tokenResponse = json_decode($response, true);
$accessToken = $tokenResponse['access_token'] ?? null;
if (!$accessToken) {
dd("Failed to retrieve token", $response);
}
// Build JSON payload properly
$paymentData = [
'merchantOrderId' => $metaData,
'amount' => (int) round($amount * 100),
"metadata" => [
"package_id" => $customMetaData['package_id'],
"payment_transaction_id" => $customMetaData['payment_transaction_id'],
"user_id" => Auth::user()->id,
],
'paymentFlow' => [
'type' => 'PG_CHECKOUT',
'message' => 'Payment message used for collect requests',
'merchantUrls' => [
'redirectUrl' => route('phonepe.success.web'),
],
],
];
$payCUrl = curl_init();
curl_setopt_array($payCUrl, array(
CURLOPT_URL => $payUrl,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => '',
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 0,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => 'POST',
CURLOPT_POSTFIELDS => json_encode($paymentData),
CURLOPT_HTTPHEADER => array(
'Content-Type: application/json',
'Authorization: O-Bearer ' . $accessToken,
),
));
$response = curl_exec($payCUrl);
$final_response = json_decode($response, true);
if (!empty($final_response)) {
$redirectURL = $final_response['redirectUrl'];
return $this->formatPaymentIntent($transactionId, $amount, 'INR', 'pending', $customMetaData, $redirectURL);
}
curl_close($payCUrl);
} else {
$redirectUrl = route('phonepe.success');
$orderId = 'TX' . time(); // unique order ID
$amount = (int) round($amount * 100); // amount in INR (not multiplied)
$expireAfter = 1200; // in seconds (20 mins)
$token = $this->getPhonePeToken();
$order = $this->createOrder($token, $orderId, $amount);
$order_data = json_decode($order, true);
$requestPayload = [
"orderId" => $order_data['orderId'],
// "state" => "PENDING",
'merchantOrderId' => $metaData,
"merchantId" => $this->merchantId,
"expireAT" => $expireAfter,
"token" => $order_data['token'],
"paymentMode" => [
"type" => "PAY_PAGE"
]
];
// Convert to JSON string as required by Flutter SDK
$requestString = json_encode($requestPayload);
if ($this->payment_mode == "UAT") {
$payment_mode = "SANDBOX";
} else {
$payment_mode = "PRODUCTION";
}
return [
"environment" => $payment_mode, // or "PRODUCTION"
"merchantId" => $this->merchantId,
"flowId" => $orderId,
"enableLogging" => true, // false in production
"request" => $requestPayload,
"appSchema" => "eclassify", // for iOS deep link return
"token" => $token,
];
}
throw new Exception("Error initiating payment: " . $redirectURL);
}
/**
* Create and format payment intent for PhonePe
*
* @param $amount
* @param $customMetaData
* @return array
* @throws Exception
*/
public function createAndFormatPaymentIntent($amount, $customMetaData): array
{
$paymentIntent = $this->createPaymentIntent($amount, $customMetaData);
$metaData = 't' . '-' . $customMetaData['payment_transaction_id'] . '-' . 'p' . '-' . $customMetaData['package_id'];
return $this->formatPaymentIntent(
id: $metaData,
amount: $amount,
currency: 'INR',
status: "PENDING",
metadata: $customMetaData,
paymentIntent: $paymentIntent
);
}
/**
* Retrieve payment intent (check payment status)
*
* @param $transactionId
* @return array
* @throws Exception
*/
public function retrievePaymentIntent($transactionId): array
{
$statusUrl = 'https://api.phonepe.com/v3/transaction/' . $transactionId . '/status';
$signature = $this->generateSignature(''); // Adjust if needed based on PhonePe requirements
$response = $this->sendRequest($statusUrl, '', $signature);
if ($response['success']) {
return $this->formatPaymentIntent($transactionId, $response['amount'], 'INR', $response['status'], [], $response);
}
throw new Exception("Error fetching payment status: " . $response['message']);
}
/**
* Format payment intent response
*
* @param $id
* @param $amount
* @param $currency
* @param $status
* @param $metadata
* @param $paymentIntent
* @return array
*/
public function formatPaymentIntent($id, $amount, $currency, $status, $metadata, $paymentIntent): array
{
return [
'id' => $id,
'amount' => $amount,
'currency' => $currency,
'metadata' => $metadata,
'status' => match ($status) {
"SUCCESS" => "succeeded",
"PENDING" => "pending",
"FAILED" => "failed",
default => "unknown"
},
'payment_gateway_response' => $paymentIntent
];
}
/**
* Minimum amount validation
*
* @param $currency
* @param $amount
* @return float|int
*/
public function minimumAmountValidation($currency, $amount)
{
$minimumAmount = match ($currency) {
'INR' => 1.00, // 1 Rupee
default => 0.50
};
return ($amount >= $minimumAmount) ? $amount : $minimumAmount;
}
/**
* Generate HMAC signature for PhonePe
*
* @param $encodedRequestBody
* @return string
*/
private function generateSignature($requestBody): string
{
// Concatenate raw JSON payload, endpoint, and salt key
$stringToHash = $requestBody . '/pg/v1/pay' . $this->saltKey;
// Hash the string using SHA256
$hash = hash('sha256', $stringToHash);
// Append salt index (Assumed to be 1 in this example)
return $hash . '###' . 1;
}
/**
* Send cURL request to PhonePe API
*
* @param $url
* @param $requestBody
* @param $signature
* @return array
*/
// private function sendRequest($url, $requestBody, $signature): array
// {
// // dd($requestBody);
// $ch = curl_init($url);
// curl_setopt($ch, CURLOPT_POST, 1);
// curl_setopt($ch, CURLOPT_POSTFIELDS, $requestBody);
// curl_setopt($ch, CURLOPT_HTTPHEADER, [
// 'Content-Type: application/json',
// 'X-VERIFY: ' . $signature,
// ]);
// curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// $response = curl_exec($ch);
// curl_close($ch);
// return json_decode($response, true);
// }
public function getPhonePeToken() {
$clientId = $this->clientId;
$clientSecret = $this->clientSecret;
$clientVersion = $this->clientVersion;
$postData = http_build_query([
'client_id' => $clientId,
'client_version' => $clientVersion,
'client_secret' => $clientSecret,
'grant_type' => 'client_credentials',
]);
if ($this->payment_mode == "UAT") {
$url = 'https://api-preprod.phonepe.com/apis/pg-sandbox/v1/oauth/token';
} else {
$url = 'https://api.phonepe.com/apis/identity-manager/v1/oauth/token';
}
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $postData);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/x-www-form-urlencoded',
]);
$response = curl_exec($ch);
if (curl_errno($ch)) {
throw new \Exception('Curl error: ' . curl_error($ch));
}
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$responseData = json_decode($response, true);
if ($httpCode === 200 && isset($responseData['access_token'])) {
return $responseData['access_token'];
}
throw new \Exception('Failed to fetch PhonePe token. Response: ' . $response);
}
public function createOrder($token, $merchantOrderId, $amount) {
$url = $this->pgUrl . '/checkout/v2/sdk/order';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
"merchantOrderId" => $merchantOrderId,
"amount" => $amount,
"paymentFlow" => [
"type" => "PG_CHECKOUT"
]
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
'Authorization: O-Bearer ' . $token,
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
return $response;
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Services\Payment;
use Log;
use Razorpay\Api\Api;
use RuntimeException;
use Throwable;
class RazorpayPayment implements PaymentInterface {
private Api $api;
private string $currencyCode;
/**
* RazorpayPayment constructor.
* @param $secretKey
* @param $publicKey
* @param $currencyCode
*/
public function __construct($secretKey, $publicKey, $currencyCode) {
// Call Stripe Class and Create Payment Intent
$this->api = new Api($publicKey, $secretKey);
$this->currencyCode = $currencyCode;
}
/**
* @param $amount
* @param $customMetaData
* @return mixed
*/
public function createPaymentIntent($amount, $customMetaData) {
try {
$orderData = [
'amount' => $this->minimumAmountValidation($this->currencyCode, $amount),
'currency' => $this->currencyCode,
'notes' => $customMetaData,
];
return $this->api->order->create($orderData);
} catch (Throwable $e) {
Log::error('Failed to create payment intent: ' . $e->getMessage());
throw new RuntimeException($e->getMessage());
}
}
/**
* @param $amount
* @param $customMetaData
* @return array
*/
public function createAndFormatPaymentIntent($amount, $customMetaData): array {
$response = $this->createPaymentIntent($amount, $customMetaData);
return $this->format($response);
}
/**
* @param $paymentId
* @return array
* @throws Throwable
*/
public function retrievePaymentIntent($paymentId): array {
try {
return $this->api->order->fetch($paymentId);
} catch (Throwable $e) {
throw $e;
}
}
/**
* @param $currency
* @param $amount
* @return float|int
*/
public function minimumAmountValidation($currency, $amount) {
return match ($currency) {
"BHD", "IQD", "JOD", "KWD", "OMR", "TND" => $amount * 1000,
"AED", "ALL", "AMD", "ARS", "AUD", "AWG", "AZN", "BAM", "BBD", "BDT", "BGN", "BMD", "BND", "BOB", "BRL", "BSD", "BTN", "BWP", "BZD", "CAD", "CHF",
"CNY", "COP", "CRC", "CUP", "CVE", "CZK", "DKK", "DOP", "DZD", "EGP", "ETB", "EUR", "FJD", "GBP", "GHS", "GIP", "GMD", "GTQ", "GYD", "HKD", "HNL",
"HTG", "HUF", "IDR", "ILS", "INR", "JMD", "KES", "KGS", "KHR", "KYD", "KZT", "LAK", "LKR", "LRD", "LSL", "MAD", "MDL", "MGA", "MKD", "MMK", "MNT",
"MOP", "MUR", "MVR", "MWK", "MXN", "MYR", "MZN", "NAD", "NGN", "NIO", "NOK", "NPR", "NZD", "PEN", "PGK", "PHP", "PKR", "PLN", "QAR", "RON", "RSD",
"RUB", "SAR", "SCR", "SEK", "SGD", "SLL", "SOS", "SSP", "SVC", "SZL", "THB", "TTD", "TWD", "TZS", "UAH", "USD", "UYU", "UZS", "XCD", "YER", "ZAR", "ZMW" => $amount * 100,
"BIF", "CLP", "DJF", "GNF", "ISK", "JPY", "KMF", "KRW", "PYG", "RWF", "UGX", "VND", "VUV", "XAF", "XOF", "XPF", "HRK" => $amount,
};
}
/**
* @param $paymentIntent
* @return array
*/
private function format($paymentIntent) {
return $this->formatPaymentIntent($paymentIntent->id, $paymentIntent->amount, $paymentIntent->currency, $paymentIntent->status, $paymentIntent->notes->toArray(), $paymentIntent->toArray());
}
/**
* @param $id
* @param $amount
* @param $currency
* @param $status
* @param $metadata
* @param $paymentIntent
* @return array
*/
public function formatPaymentIntent($id, $amount, $currency, $status, $metadata, $paymentIntent): array {
return [
'id' => $id,
'amount' => $amount,
'currency' => $currency,
'metadata' => $metadata,
'status' => match ($status) {
"failed" => "failed",//NOTE : Failed status is not known, please test the failure status
"created", "attempted" => "pending",
"paid" => "succeed",
},
'payment_gateway_response' => $paymentIntent
];
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace App\Services\Payment;
use App\Models\Setting;
use App\Services\CurrencyFormatterService;
use Stripe\Exception\ApiErrorException;
use Stripe\PaymentIntent;
use Stripe\StripeClient;
class StripePayment implements PaymentInterface {
private StripeClient $stripe;
private string $currencyCode;
/**
* StripePayment constructor.
* @param $secretKey
* @param $currencyCode
*/
public function __construct($secretKey, $currencyCode) {
// Call Stripe Class and Create Payment Intent
$this->stripe = new StripeClient($secretKey);
$this->currencyCode = $currencyCode;
}
/**
* @param $amount
* @param $customMetaData
* @return PaymentIntent
* @throws ApiErrorException
*/
public function createPaymentIntent($amount, $customMetaData) {
try {
$amount = $this->minimumAmountValidation($this->currencyCode, $amount);
$zeroDecimalCurrencies = [
'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG',
'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF'
];
// if (!in_array($this->currencyCode, $zeroDecimalCurrencies)) {
// $amount *= 100;
// }
return $this->stripe->paymentIntents->create(
[
'amount' => $amount,
'currency' => $this->currencyCode,
'metadata' => $customMetaData,
// 'description' => 'Fees Payment',
// 'shipping' => [
// 'name' => 'Jenny Rosen',
// 'address' => [
// 'line1' => '510 Townsend St',
// 'postal_code' => '98140',
// 'city' => 'San Francisco',
// 'state' => 'CA',
// 'country' => 'US',
// ],
// ],
]
);
} catch (ApiErrorException $e) {
throw $e;
}
}
/**
* @param $amount
* @param $customMetaData
* @return array
* @throws ApiErrorException
*/
public function createAndFormatPaymentIntent($amount, $customMetaData): array {
$paymentIntent = $this->createPaymentIntent($amount, $customMetaData);
return $this->format($paymentIntent);
}
/**
* @param $paymentId
* @return array
* @throws ApiErrorException
*/
public function retrievePaymentIntent($paymentId): array {
try {
return $this->format($this->stripe->paymentIntents->retrieve($paymentId));
} catch (ApiErrorException $e) {
throw $e;
}
}
/**
* @param $paymentIntent
* @return array
*/
public function format($paymentIntent) {
return $this->formatPaymentIntent($paymentIntent->id, $paymentIntent->amount, $paymentIntent->currency, $paymentIntent->status, $paymentIntent->metadata, $paymentIntent);
}
/**
* @param $id
* @param $amount
* @param $currency
* @param $status
* @param $metadata
* @param $paymentIntent
* @return array
*/
public function formatPaymentIntent($id, $amount, $currency, $status, $metadata, $paymentIntent): array {
$formatter = app(CurrencyFormatterService::class);
$iso_code = Setting::where('name', 'currency_iso_code')->value('value');
$symbol = Setting::where('name', 'currency_symbol')->value('value');
$position = Setting::where('name', 'currency_symbol_position')->value('value');
$currency = (object) [
'iso_code' => $iso_code,
'symbol' => $symbol,
'symbol_position' => $position,
];
$displayAmount = $amount;
// If it's NOT a zero-decimal currency, divide by 100 for the formatter
// if (!in_array($iso_code, ['BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG', 'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF', 'ISK'])) {
$displayAmount = $amount * 100;
// }
$formatted_final_price = $formatter->formatPrice($displayAmount ?? 0, $currency);
return [
'id' => $paymentIntent->id,
'amount' => $paymentIntent->amount,
'formatted_price' => $formatted_final_price,
'currency' => $paymentIntent->currency,
'metadata' => $paymentIntent->metadata,
'status' => match ($paymentIntent->status) {
"canceled" => "failed",
"succeeded" => "succeed",
"processing", "requires_action", "requires_capture", "requires_confirmation", "requires_payment_method" => "pending",
},
'payment_gateway_response' => $paymentIntent
];
}
/**
* @param $currency
* @param $amount
* @return float|int
*/
public function minimumAmountValidation($currency, $amount) {
$minimumAmountMap = [
'USD' => 0.50, 'EUR' => 0.50, 'INR' => 0.50, 'NZD' => 0.50, 'SGD' => 0.50,
'BRL' => 0.50, 'CAD' => 0.50, 'AUD' => 0.50, 'CHF' => 0.50,
'AED' => 2.00, 'PLN' => 2.00, 'RON' => 2.00,
'BGN' => 1.00, 'CZK' => 15.00, 'DKK' => 2.50,
'GBP' => 0.30, 'HKD' => 4.00, 'HUF' => 175.00,
'JPY' => 50, 'MXN' => 10, 'THB' => 10, 'MYR' => 2,
'NOK' => 3.00, 'SEK' => 3.00, 'XAF' => 100,
'ISK' => 100 // ISK minimum is usually higher
];
$zeroDecimalCurrencies = [
'BIF', 'CLP', 'DJF', 'GNF', 'JPY', 'KMF', 'KRW', 'MGA', 'PYG',
'RWF', 'UGX', 'VND', 'VUV', 'XAF', 'XOF', 'XPF', 'ISK' // Added ISK here
];
$minimumAmount = $minimumAmountMap[$currency] ?? 0.50;
if (!in_array($currency, $zeroDecimalCurrencies)) {
// Standard Currencies (USD, INR, etc.)
$minimumAmount *= 100;
$amount = (int)round($amount * 100);
} else {
// Zero-decimal Currencies
if ($currency === 'ISK') {
// Special Rule for ISK: Must be divisible by 100
$amount = (int)round($amount / 100) * 100;
if ($amount < 100) $amount = 100;
} else {
$amount = (int)$amount;
}
}
return max($amount, (int)$minimumAmount);
}
}

View File

@@ -0,0 +1,255 @@
<?php
namespace App\Services;
use Exception;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Throwable;
class ResponseService
{
/**
* @return Application|RedirectResponse|Redirector|true
*/
public static function noPermissionThenRedirect($permission)
{
if (! Auth::user()->can($permission)) {
return redirect(route('home'))->withErrors([
'message' => trans("You Don't have enough permissions"),
])->send();
}
return true;
}
/**
* @return true
*/
public static function noPermissionThenSendJson($permission)
{
if (! Auth::user()->can($permission)) {
self::errorResponse("You Don't have enough permissions");
}
return true;
}
/**
* @return Application|\Illuminate\Foundation\Application|RedirectResponse|Redirector|true
*/
// Check user role
public static function noRoleThenRedirect($role)
{
if (! Auth::user()->hasRole($role)) {
return redirect(route('home'))->withErrors([
'message' => trans("You Don't have enough permissions"),
])->send();
}
return true;
}
/**
* @return bool|Application|\Illuminate\Foundation\Application|RedirectResponse|Redirector
*/
public static function noAnyRoleThenRedirect(array $role)
{
if (! Auth::user()->hasAnyRole($role)) {
return redirect(route('home'))->withErrors([
'message' => trans("You Don't have enough permissions"),
])->send();
}
return true;
}
// /**
// * @param $role
// * @return true
// */
// public static function noRoleThenSendJson($role)
// {
// if (!Auth::user()->hasRole($role)) {
// self::errorResponse("You Don't have enough permissions");
// }
// return true;
// }
/**
* @param $feature
* @return RedirectResponse|true
*/
// Check Feature
// public static function noFeatureThenRedirect($feature) {
// if (Auth::user()->school_id && !app(FeaturesService::class)->hasFeature($feature)) {
// return redirect()->back()->withErrors([
// 'message' => trans('Purchase') . " " . $feature . " " . trans("to Continue using this functionality")
// ])->send();
// }
// return true;
// }
//
// public static function noFeatureThenSendJson($feature) {
// if (Auth::user()->school_id && !app(FeaturesService::class)->hasFeature($feature)) {
// self::errorResponse(trans('Purchase') . " " . $feature . " " . trans("to Continue using this functionality"));
// }
// return true;
// }
/**
* If User don't have any of the permission that is specified in Array then Redirect will happen
*
* @return RedirectResponse|true
*/
public static function noAnyPermissionThenRedirect(array $permissions)
{
if (! Auth::user()->canany($permissions)) {
return redirect()->back()->withErrors([
'message' => trans("You Don't have enough permissions"),
])->send();
}
return true;
}
/**
* If User don't have any of the permission that is specified in Array then Json Response will be sent
*
* @return true
*/
public static function noAnyPermissionThenSendJson(array $permissions)
{
if (! Auth::user()->canany($permissions)) {
self::errorResponse("You Don't have enough permissions");
}
return true;
}
/**
* @param null $data
* @param null $code
*/
public static function successResponse(?string $message = 'Success', $data = null, array $customData = [], $code = null): void
{
response()->json(array_merge([
'error' => false,
'message' => trans($message),
'data' => $data,
'code' => $code ?? config('constants.RESPONSE_CODE.SUCCESS'),
], $customData))->send();
exit();
}
/**
* @return Application|\Illuminate\Foundation\Application|RedirectResponse|Redirector
*/
public static function successRedirectResponse(string $message = 'success', $url = null)
{
return isset($url) ? redirect($url)->with([
'success' => trans($message),
])->send() : redirect()->back()->with([
'success' => trans($message),
])->send();
}
/**
* @param string $message - Pass the Translatable Field
* @param null $data
* @param string $code
* @param null $e
* @return void
*/
public static function errorResponse(string $message = 'Error Occurred', $data = null, string|int|null $code = null, $e = null)
{
response()->json([
'error' => true,
'message' => trans($message),
'data' => $data,
'code' => $code ?? config('constants.RESPONSE_CODE.EXCEPTION_ERROR'),
'details' => (! empty($e) && is_object($e)) ? $e->getMessage().' --> '.$e->getFile().' At Line : '.$e->getLine() : '',
])->send();
exit();
}
/**
* return keyword should, must be used wherever this function is called.
*
* @param string|string[] $message
* @param null $input
* @return RedirectResponse
*/
public static function errorRedirectResponse(string|array $message = 'Error Occurred', $url = 'back', $input = null)
{
return $url == 'back' ? redirect()->back()->with([
'errors' => trans($message),
])->withInput($input) : redirect($url)->with([
'errors' => trans($message),
])->withInput($input);
}
/**
* @param null $data
* @param null $code
* @return void
*/
public static function warningResponse(string $message = 'Error Occurred', $data = null, $code = null)
{
response()->json([
'error' => false,
'warning' => true,
'code' => $code,
'message' => trans($message),
'data' => $data,
])->send();
exit();
}
/**
* @param null $data
* @return void
*/
public static function validationError(string $message = 'Error Occurred', $data = null)
{
self::errorResponse($message, $data, config('constants.RESPONSE_CODE.VALIDATION_ERROR'));
}
/**
* @return void
*/
public static function validationErrorRedirect(string $message = 'Error Occurred')
{
self::errorRedirectResponse(route('custom-fields.create'), $message);
exit();
}
/**
* @return void
*/
public static function logErrorResponse(Throwable|Exception $e, string $logMessage = ' ', string $responseMessage = 'Error Occurred', bool $jsonResponse = true)
{
$token = request()->bearerToken();
Log::error($logMessage.' '.$e->getMessage().'---> '.$e->getFile().' At Line : '.$e->getLine()."\n\n".request()->method().' : '.request()->fullUrl()."\nToken : ".$token."\nParams : ", request()->all());
if ($jsonResponse && config('app.debug')) {
self::errorResponse($responseMessage, null, null, $e);
}
}
public static function logErrorRedirect($e, string $logMessage = ' ', string $responseMessage = 'Error Occurred', bool $jsonResponse = true)
{
Log::error($logMessage.' '.$e->getMessage().'---> '.$e->getFile().' At Line : '.$e->getLine());
if ($jsonResponse && config('app.debug')) {
throw $e;
}
}
public static function errorRedirectWithToast(string $message, $input = null)
{
return redirect()->back()->with('error', $message)->withInput($input);
}
}