diff --git a/04_Laravel/.env.example b/04_Laravel/.env.example index b939686..53ace8d 100644 --- a/04_Laravel/.env.example +++ b/04_Laravel/.env.example @@ -68,3 +68,32 @@ IFNEX_API_KEY= CORS_ALLOWED_ORIGINS=http://localhost,http://127.0.0.1 IFNEX_TRACKING_RATE_LIMIT=60 CURRENCY_API_KEY= + + +# Zarinpal Payment Gateway +ZARINPAL_MERCHANT_ID=fake-merchant-id-for-testing +ZARINPAL_SANDBOX=true +ZARINPAL_CALLBACK_URL=http://localhost:8000/api/v1/payment/callback +ZARINPAL_FRONTEND_SUCCESS_URL=http://localhost:8080/payment/success +ZARINPAL_FRONTEND_FAILURE_URL=http://localhost:8080/payment/failed +ZARINPAL_EXPIRY_MINUTES=30 + +# Wallet Settings +WALLET_MIN_DEPOSIT=10000 +WALLET_MAX_DEPOSIT=500000000 +WALLET_MIN_BALANCE=1000 +WALLET_DEPOSIT_FEE=0 +WALLET_AUTO_CREATE=true +WALLET_ALLOW_WITHDRAWAL=false + +# Currency API +CURRENCY_API_KEY=your_api_key_here +CURRENCY_API_URL=https://api.freecurrencyapi.com/v1/latest + +# Notifications +NOTIFY_ON_DEPOSIT=true +NOTIFY_ON_MANUAL_ADJUSTMENT=true +LOW_BALANCE_THRESHOLD=500000 + +# Audit +AUDIT_LOG_RETENTION_DAYS=365 \ No newline at end of file diff --git a/04_Laravel/.postman/resources.yaml b/04_Laravel/.postman/resources.yaml new file mode 100644 index 0000000..0d77e70 --- /dev/null +++ b/04_Laravel/.postman/resources.yaml @@ -0,0 +1,9 @@ +# Use this workspace to collaborate +workspace: + id: c5409f98-698e-4a6c-8edb-443e130b005c + +# All resources in the `postman/` folder are automatically registered in Local View. +# Point to additional files outside the `postman/` folder to register them individually. Example: +#localResources: +# collections: +# - ../tests/E2E Test Collection/ diff --git a/04_Laravel/app/Console/Commands/GenerateApiToken.php b/04_Laravel/app/Console/Commands/GenerateApiToken.php new file mode 100644 index 0000000..cdeb78a --- /dev/null +++ b/04_Laravel/app/Console/Commands/GenerateApiToken.php @@ -0,0 +1,57 @@ +option('email'); + $tokenName = $this->option('name'); + + $user = User::where('email', $email)->first(); + + if (!$user) { + $this->error("کاربر با ایمیل '{$email}' یافت نشد!"); + $this->info('کاربران موجود:'); + User::all(['id', 'name', 'email', 'role'])->each(function ($u) { + $this->line(" - ID: {$u->id} | {$u->name} | {$u->email} | {$u->role->value}"); + }); + return 1; + } + + // پاک کردن توکن‌های قبلی + $oldCount = $user->tokens()->count(); + $user->tokens()->delete(); + $this->info("{$oldCount} توکن قدیمی پاک شد."); + + // ساخت توکن جدید + $token = $user->createToken($tokenName)->plainTextToken; + + $this->newLine(); + $this->line('========================================'); + $this->info('توکن جدید (این را کپی کنید):'); + $this->line('========================================'); + $this->newLine(); + $this->line($token); + $this->newLine(); + $this->line('========================================'); + $this->newLine(); + $this->info("اطلاعات کاربر:"); + $this->line(" ID: {$user->id}"); + $this->line(" Name: {$user->name}"); + $this->line(" Email: {$user->email}"); + $this->line(" Role: {$user->role->value}"); + + return 0; + } +} \ No newline at end of file diff --git a/04_Laravel/app/Enums/PaymentGateway.php b/04_Laravel/app/Enums/PaymentGateway.php new file mode 100644 index 0000000..ec6151b --- /dev/null +++ b/04_Laravel/app/Enums/PaymentGateway.php @@ -0,0 +1,19 @@ + 'زرین‌پال', + self::MANUAL => 'دستی (ادمین)', + self::SYSTEM => 'سیستمی', + }; + } +} \ No newline at end of file diff --git a/04_Laravel/app/Enums/TransactionStatus.php b/04_Laravel/app/Enums/TransactionStatus.php new file mode 100644 index 0000000..19ca666 --- /dev/null +++ b/04_Laravel/app/Enums/TransactionStatus.php @@ -0,0 +1,31 @@ + 'در انتظار', + self::COMPLETED => 'موفق', + self::FAILED => 'ناموفق', + self::REFUNDED => 'بازگشت شده', + }; + } + + public function color(): string + { + return match($this) { + self::PENDING => 'warning', + self::COMPLETED => 'success', + self::FAILED => 'danger', + self::REFUNDED => 'info', + }; + } +} \ No newline at end of file diff --git a/04_Laravel/app/Enums/TransactionType.php b/04_Laravel/app/Enums/TransactionType.php new file mode 100644 index 0000000..1679acb --- /dev/null +++ b/04_Laravel/app/Enums/TransactionType.php @@ -0,0 +1,31 @@ + 'واریز', + self::WITHDRAWAL => 'برداشت', + self::ORDER_PAYMENT => 'پرداخت سفارش', + self::REFUND => 'بازگشت وجه', + }; + } + + public function color(): string + { + return match($this) { + self::DEPOSIT => 'success', + self::WITHDRAWAL => 'danger', + self::ORDER_PAYMENT => 'warning', + self::REFUND => 'info', + }; + } +} \ No newline at end of file diff --git a/04_Laravel/app/Filament/Resources/WalletResource.php b/04_Laravel/app/Filament/Resources/WalletResource.php new file mode 100644 index 0000000..486791c --- /dev/null +++ b/04_Laravel/app/Filament/Resources/WalletResource.php @@ -0,0 +1,195 @@ +schema([ + Forms\Components\Section::make('اطلاعات کیف پول') + ->schema([ + Forms\Components\Select::make('user_id') + ->relationship('user', 'name') + ->searchable() + ->preload() + ->required() + ->label('کاربر'), + Forms\Components\TextInput::make('balance') + ->numeric() + ->suffix('ریال') + ->disabled() + ->label('موجودی فعلی'), + Forms\Components\TextInput::make('total_deposited') + ->numeric() + ->suffix('ریال') + ->disabled() + ->label('مجموع واریزی'), + Forms\Components\TextInput::make('total_withdrawn') + ->numeric() + ->suffix('ریال') + ->disabled() + ->label('مجموع برداشت'), + Forms\Components\Toggle::make('is_frozen') + ->label('مسدود است؟') + ->reactive(), + Forms\Components\Textarea::make('freeze_reason') + ->label('دلیل مسدودی') + ->visible(fn ($get) => $get('is_frozen')) + ->maxLength(1000), + ]) + ->columns(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('user.name') + ->label('کاربر') + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('user.email') + ->label('ایمیل') + ->searchable(), + Tables\Columns\TextColumn::make('balance') + ->label('موجودی') + ->money('IRR', divideBy: 1) + ->sortable() + ->color(fn ($record) => $record->balance > 0 ? 'success' : 'danger'), + Tables\Columns\TextColumn::make('total_deposited') + ->label('مجموع واریزی') + ->money('IRR', divideBy: 1) + ->sortable(), + Tables\Columns\IconColumn::make('is_frozen') + ->label('وضعیت') + ->boolean() + ->trueIcon('heroicon-o-lock-closed') + ->falseIcon('heroicon-o-lock-open') + ->trueColor('danger') + ->falseColor('success'), + Tables\Columns\TextColumn::make('transactions_count') + ->label('تعداد تراکنش') + ->counts('transactions') + ->sortable(), + Tables\Columns\TextColumn::make('created_at') + ->dateTime('Y/m/d H:i', 'Asia/Tehran') + ->label('تاریخ ایجاد') + ->sortable(), + ]) + ->defaultSort('created_at', 'desc') + ->actions([ + // اکشن شارژ/کسر دستی + Action::make('manual_adjust') + ->label('شارژ/کسر دستی') + ->icon('heroicon-o-currency-dollar') + ->color('primary') + ->form([ + Forms\Components\TextInput::make('amount') + ->label('مبلغ (ریال) — مثبت = شارژ، منفی = کسر') + ->numeric() + ->required() + ->helperText('مثال: 5000000 برای شارژ، -1000000 برای کسر'), + Forms\Components\Textarea::make('description') + ->label('توضیحات (قابل نمایش به کاربر)') + ->required() + ->maxLength(1000), + Forms\Components\Textarea::make('admin_notes') + ->label('یادداشت داخلی ادمین') + ->helperText('فقط برای ادمین‌ها قابل مشاهده است') + ->maxLength(2000), + ]) + ->action(function (Wallet $record, array $data): void { + $admin = auth()->user(); + $amount = $data['amount']; + $walletService = app(\App\Services\WalletService::class); + + try { + if ($amount > 0) { + $walletService->manualDeposit($record, $amount, $data['description'], $admin, $data['admin_notes'] ?? null); + Notification::make() + ->success() + ->title('شارژ با موفقیت انجام شد') + ->body("مبلغ: " . number_format($amount) . " ریال") + ->send(); + } else { + $walletService->manualWithdrawal($record, abs($amount), $data['description'], $admin, $data['admin_notes'] ?? null); + Notification::make() + ->success() + ->title('کسر با موفقیت انجام شد') + ->body("مبلغ: " . number_format(abs($amount)) . " ریال") + ->send(); + } + } catch (\Exception $e) { + Notification::make() + ->danger() + ->title('خطا در انجام عملیات') + ->body($e->getMessage()) + ->send(); + } + }), + + // اکشن مشاهده تراکنش‌ها + Action::make('view_transactions') + ->label('تراکنش‌ها') + ->icon('heroicon-o-list-bullet') + ->url(fn (Wallet $record) => WalletTransactionResource::getUrl('index', ['wallet_id' => $record->id])), + + // اکشن مسدود/رفع مسدود + Action::make('toggle_freeze') + ->label(fn (Wallet $record) => $record->isFrozen() ? 'رفع مسدودی' : 'مسدود کردن') + ->icon(fn (Wallet $record) => $record->isFrozen() ? 'heroicon-o-lock-open' : 'heroicon-o-lock-closed') + ->color(fn (Wallet $record) => $record->isFrozen() ? 'success' : 'danger') + ->form(fn (Wallet $record) => $record->isFrozen() ? [] : [ + Forms\Components\Textarea::make('reason') + ->label('دلیل مسدودی') + ->required() + ->maxLength(1000), + ]) + ->action(function (Wallet $record, ?array $data): void { + $admin = auth()->user(); + + if ($record->isFrozen()) { + $record->unfreeze($admin); + Notification::make()->success()->title('کیف پول از مسدودی خارج شد')->send(); + } else { + $record->freeze($data['reason'], $admin); + Notification::make()->success()->title('کیف پول مسدود شد')->send(); + } + }) + ->requiresConfirmation(), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('is_frozen') + ->label('وضعیت مسدودی'), + ]); + } + + public static function getPages(): array +{ + return [ + 'index' => Pages\ListWallets::route('/'), + 'create' => Pages\CreateWallet::route('/create'), + 'edit' => Pages\EditWallet::route('/{record}/edit'), + 'view' => Pages\ViewWallet::route('/{record}'), + ]; +} +} \ No newline at end of file diff --git a/04_Laravel/app/Filament/Resources/WalletResource/Pages/CreateWallet.php b/04_Laravel/app/Filament/Resources/WalletResource/Pages/CreateWallet.php new file mode 100644 index 0000000..cb79592 --- /dev/null +++ b/04_Laravel/app/Filament/Resources/WalletResource/Pages/CreateWallet.php @@ -0,0 +1,11 @@ +schema([ + Forms\Components\Section::make('اطلاعات تراکنش') + ->schema([ + Forms\Components\Select::make('wallet_id') + ->relationship('wallet', 'id') + ->searchable() + ->preload() + ->required() + ->label('کیف پول'), + Forms\Components\TextInput::make('amount') + ->numeric() + ->suffix('ریال') + ->required() + ->label('مبلغ'), + Forms\Components\Select::make('type') + ->options(TransactionType::class) + ->required() + ->label('نوع'), + Forms\Components\Select::make('status') + ->options(TransactionStatus::class) + ->required() + ->label('وضعیت'), + Forms\Components\Select::make('gateway') + ->options(PaymentGateway::class) + ->label('درگاه'), + Forms\Components\TextInput::make('gateway_reference_id') + ->label('کد پیگیری'), + Forms\Components\Textarea::make('description') + ->label('توضیحات') + ->maxLength(1000), + Forms\Components\Textarea::make('admin_notes') + ->label('یادداشت ادمین') + ->maxLength(2000), + ]) + ->columns(2), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('id') + ->label('شناسه') + ->sortable(), + Tables\Columns\TextColumn::make('wallet.user.name') + ->label('کاربر') + ->searchable(), + Tables\Columns\TextColumn::make('amount') + ->label('مبلغ') + ->money('IRR', divideBy: 1) + ->color(fn ($record) => $record->amount >= 0 ? 'success' : 'danger') + ->sortable(), + Tables\Columns\BadgeColumn::make('type') + ->label('نوع') + ->getStateUsing(fn ($record) => $record->type->label()) + ->colors([ + 'success' => TransactionType::DEPOSIT, + 'danger' => TransactionType::WITHDRAWAL, + 'warning' => TransactionType::ORDER_PAYMENT, + 'info' => TransactionType::REFUND, + ]), + Tables\Columns\BadgeColumn::make('status') + ->label('وضعیت') + ->getStateUsing(fn ($record) => $record->status->label()) + ->colors([ + 'success' => TransactionStatus::COMPLETED, + 'warning' => TransactionStatus::PENDING, + 'danger' => TransactionStatus::FAILED, + 'info' => TransactionStatus::REFUNDED, + ]), + Tables\Columns\BadgeColumn::make('gateway') + ->label('درگاه') + ->getStateUsing(fn ($record) => $record->gateway?->label() ?? '-') + ->colors([ + 'primary' => PaymentGateway::ZARINPAL, + 'success' => PaymentGateway::MANUAL, + 'gray' => PaymentGateway::SYSTEM, + ]), + Tables\Columns\TextColumn::make('gateway_reference_id') + ->label('کد پیگیری') + ->searchable() + ->toggleable(), + Tables\Columns\TextColumn::make('description') + ->label('توضیحات') + ->limit(50) + ->toggleable(), + Tables\Columns\TextColumn::make('creator.name') + ->label('ایجادکننده') + ->toggleable(), + Tables\Columns\TextColumn::make('created_at') + ->dateTime('Y/m/d H:i', 'Asia/Tehran') + ->label('تاریخ') + ->sortable(), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + Tables\Filters\SelectFilter::make('type') + ->options(TransactionType::class) + ->label('نوع تراکنش'), + Tables\Filters\SelectFilter::make('status') + ->options(TransactionStatus::class) + ->label('وضعیت'), + Tables\Filters\SelectFilter::make('gateway') + ->options(PaymentGateway::class) + ->label('درگاه'), + ]) + ->actions([ + Tables\Actions\ViewAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListWalletTransactions::route('/'), + 'view' => Pages\ViewWalletTransaction::route('/{record}'), + ]; + } +} \ No newline at end of file diff --git a/04_Laravel/app/Filament/Resources/WalletTransactionResource/Pages/ListWalletTransactions.php b/04_Laravel/app/Filament/Resources/WalletTransactionResource/Pages/ListWalletTransactions.php new file mode 100644 index 0000000..a24e12f --- /dev/null +++ b/04_Laravel/app/Filament/Resources/WalletTransactionResource/Pages/ListWalletTransactions.php @@ -0,0 +1,11 @@ +user(); - + if (!$user) { return response()->json(['message' => 'Unauthorized'], 401); } + $validated = $request->validate([ + 'amount' => ['required', 'numeric', 'min:10000'], // حداقل ۱۰ هزار ریال + 'description' => ['nullable', 'string', 'max:500'], + 'frontend_callback' => ['nullable', 'url', 'max:500'], // URL فرانت‌اند برای redirect بعد از پرداخت + ]); + + $wallet = $user->wallet ?? Wallet::create([ + 'user_id' => $user->id, + 'balance' => 0, + ]); + + // بررسی مسدود نبودن کیف پول + if ($wallet->isFrozen()) { + return response()->json([ + 'message' => 'کیف پول شما مسدود است. لطفاً با پشتیبانی تماس بگیرید.', + ], 403); + } + try { - $validated = $request->validate([ - 'amount' => ['required', 'numeric', 'min:1000'], - 'description' => ['nullable', 'string', 'max:500'], - ]); - } catch (ValidationException $e) { + // ایجاد تراکنش pending + $transaction = $this->walletService->requestDeposit( + wallet: $wallet, + amount: $validated['amount'], + description: $validated['description'] ?? 'شارژ آنلاین کیف پول', + user: $user, + gateway: PaymentGateway::ZARINPAL + ); + + // دریافت لینک پرداخت از زرین‌پال + $paymentData = $this->zarinpalService->requestPayment( + amount: $validated['amount'], + description: $validated['description'] ?? 'شارژ کیف پول IFNEX', + mobile: $user->phone, + email: $user->email + ); + + // ذخیره authority و frontend_callback در metadata + $metadata = $transaction->metadata ?? []; + $metadata['zarinpal_authority'] = $paymentData['authority']; + $metadata['frontend_callback'] = $validated['frontend_callback'] ?? null; + $transaction->update(['metadata' => $metadata]); + return response()->json([ - 'message' => 'Validation failed', - 'errors' => $e->errors(), - ], 422); - } + 'success' => true, + 'message' => 'در حال انتقال به درگاه پرداخت...', + 'payment_url' => $paymentData['payment_url'], + 'transaction_id' => $transaction->id, + 'authority' => $paymentData['authority'], + 'amount' => $paymentData['amount'], + ]); - $wallet = $user->wallet()->first(); - - if (!$wallet) { - $wallet = Wallet::create([ + } catch (\Exception $e) { + Log::error('Payment gateway error', [ 'user_id' => $user->id, - 'balance' => 0, + 'error' => $e->getMessage(), ]); - } - $result = $this->gateway->requestPayment( - $user, - $validated['amount'], - $validated['description'] ?? 'شارچ کیف پول' - ); - - if (!$result['success']) { - return response()->json($result, 400); - } - - return response()->json([ - 'message' => 'درخواست پرداخت ایجاد شد', - 'authority' => $result['authority'], - 'payment_url' => $result['payment_url'] ?? null, - 'mock' => $result['mock'] ?? false, - ]); - } - - public function callback(Request $request): JsonResponse - { - $authority = $request->query('Authority'); - $status = $request->query('Status'); - - if (!$authority) { - return response()->json(['message' => 'Authority not provided'], 400); - } - - if ($status !== 'OK') { return response()->json([ - 'message' => 'پرداخت لغو شد.', - 'status' => $status, - ]); + 'success' => false, + 'message' => 'خطا در ارتباط با درگاه پرداخت: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Callback از درگاه پرداخت (بعد از بازگشت کاربر) + */ + public function callback(Request $request): RedirectResponse + { + $authority = $request->input('Authority'); + $status = $request->input('Status'); // OK یا NOK + + // پیدا کردن تراکنش بر اساس authority + $transaction = WalletTransaction::whereJsonContains('metadata->zarinpal_authority', $authority)->first(); + + if (!$transaction) { + Log::warning('Payment callback: transaction not found', ['authority' => $authority]); + return redirect(config('ifnex.zarinpal.frontend_failure_url', '/payment/failed')); } - $result = $this->gateway->verifyPayment($authority); + $frontendCallback = $transaction->metadata['frontend_callback'] ?? config('ifnex.zarinpal.frontend_failure_url', '/payment/failed'); - if (!$result['success']) { - return response()->json($result, 400); + // کاربر پرداخت را لغو کرده + if ($status !== 'OK') { + $this->walletService->failTransaction($transaction, 'کاربر پرداخت را لغو کرد یا پرداخت ناموفق بود.'); + + $failureUrl = $frontendCallback . '?' . http_build_query([ + 'status' => 'failed', + 'transaction_id' => $transaction->id, + 'message' => 'پرداخت ناموفق بود', + ]); + + return redirect($failureUrl); + } + + try { + // بررسی صحت پرداخت + $verification = $this->zarinpalService->verifyPayment( + authority: $authority, + expectedAmount: $transaction->amount + ); + + if (!$verification['success']) { + $this->walletService->failTransaction( + $transaction, + 'تأیید درگاه ناموفق بود: ' . ($verification['error_message'] ?? 'نامشخص') + ); + + $failureUrl = $frontendCallback . '?' . http_build_query([ + 'status' => 'failed', + 'transaction_id' => $transaction->id, + 'message' => 'تأیید پرداخت ناموفق بود', + ]); + + return redirect($failureUrl); + } + + // تأیید تراکنش و افزایش موجودی + $this->walletService->completeDeposit( + transaction: $transaction, + gatewayReferenceId: $verification['ref_id'] ?? $authority + ); + + $successUrl = config('ifnex.zarinpal.frontend_success_url', '/payment/success') . '?' . http_build_query([ + 'status' => 'success', + 'transaction_id' => $transaction->id, + 'ref_id' => $verification['ref_id'] ?? '', + 'amount' => $transaction->amount, + ]); + + return redirect($successUrl); + + } catch (\Exception $e) { + Log::error('Payment callback error', [ + 'transaction_id' => $transaction->id, + 'error' => $e->getMessage(), + ]); + + $this->walletService->failTransaction($transaction, 'خطای سیستمی: ' . $e->getMessage()); + + $failureUrl = $frontendCallback . '?' . http_build_query([ + 'status' => 'failed', + 'transaction_id' => $transaction->id, + 'message' => 'خطای سیستمی در پردازش پرداخت', + ]); + + return redirect($failureUrl); + } + } + + /** + * استعلام وضعیت یک پرداخت + */ + public function checkStatus(Request $request, $transactionId): JsonResponse + { + $user = $request->user(); + + $transaction = WalletTransaction::where('id', $transactionId) + ->whereHas('wallet', fn ($q) => $q->where('user_id', $user->id)) + ->first(); + + if (!$transaction) { + return response()->json(['message' => 'تراکنش یافت نشد'], 404); } return response()->json([ - 'message' => $result['message'], - 'ref_id' => $result['ref_id'], - 'amount' => $result['amount'], + 'transaction_id' => $transaction->id, + 'status' => $transaction->status->label(), + 'status_code' => $transaction->status->value, + 'amount' => $transaction->amount, + 'description' => $transaction->description, + 'reference_id' => $transaction->gateway_reference_id, + 'created_at' => $transaction->created_at->toIso8601String(), ]); } - - public function status(Request $request, string $authority): JsonResponse - { - $result = $this->gateway->checkPayment($authority); - - if (!$result['success']) { - return response()->json($result, 404); - } - - return response()->json($result); - } } \ No newline at end of file diff --git a/04_Laravel/app/Http/Controllers/Api/WalletController.php b/04_Laravel/app/Http/Controllers/Api/WalletController.php index 6f593ce..f419689 100644 --- a/04_Laravel/app/Http/Controllers/Api/WalletController.php +++ b/04_Laravel/app/Http/Controllers/Api/WalletController.php @@ -2,112 +2,102 @@ namespace App\Http\Controllers\Api; +use App\Enums\PaymentGateway; +use App\Http\Controllers\Controller; use App\Models\User; use App\Models\Wallet; +use App\Models\WalletActivityLog; +use App\Models\WalletTransaction; use App\Services\WalletService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; -class WalletController +class WalletController extends Controller { public function __construct(protected WalletService $walletService) {} + /** + * مشاهده موجودی کیف پول کاربر + */ public function balance(Request $request): JsonResponse { $user = $request->user(); - + if (!$user) { return response()->json(['message' => 'Unauthorized'], 401); } - $wallet = $user->wallet()->first(); + $wallet = $user->wallet; - if (!$wallet) { - return response()->json([ - 'balance' => 0, - 'currency' => 'IRR', - ]); - } + WalletActivityLog::log( + $wallet ?? new Wallet(['user_id' => $user->id]), + 'balance_viewed', + 'مشاهده موجودی کیف پول', + null, + ['balance' => $wallet?->balance ?? 0], + $user + ); return response()->json([ - 'balance' => $wallet->balance, + 'balance' => $wallet?->balance ?? 0, + 'total_deposited' => $wallet?->total_deposited ?? 0, + 'total_withdrawn' => $wallet?->total_withdrawn ?? 0, + 'is_frozen' => $wallet?->isFrozen() ?? false, 'currency' => 'IRR', ]); } - public function recharge(Request $request): JsonResponse - { - try { - $validated = $request->validate([ - 'amount' => ['required', 'numeric', 'min:10000'], - 'description' => ['nullable', 'string', 'max:500'], - ]); - } catch (ValidationException $e) { - return response()->json([ - 'message' => 'Validation failed', - 'errors' => $e->errors(), - ], 422); - } - - $user = $request->user(); - - if (!$user) { - return response()->json(['message' => 'Unauthorized'], 401); - } - - $wallet = $user->wallet()->first(); - - if (!$wallet) { - $wallet = Wallet::create([ - 'user_id' => $user->id, - 'balance' => 0, - ]); - } - - $transaction = $this->walletService->deposit( - $wallet, - $validated['amount'], - $validated['description'] ?? 'شارژ آنلاین کیف پول', - ); - - return response()->json([ - 'message' => 'شارژ با موفقیت انجام شد', - 'wallet_id' => $wallet->id, - 'balance' => $wallet->fresh()->balance, - 'transaction_id' => $transaction->id, - 'amount' => $transaction->amount, - ]); - } - + /** + * لیست تراکنش‌های کاربر (paginated) + */ public function transactions(Request $request): JsonResponse { $user = $request->user(); - + if (!$user) { return response()->json(['message' => 'Unauthorized'], 401); } - $wallet = $user->wallet()->first(); + $wallet = $user->wallet; if (!$wallet) { - return response()->json(['transactions' => [], 'total' => 0]); + return response()->json([ + 'transactions' => [], + 'total' => 0, + 'current_page' => 1, + ]); } $perPage = $request->query('per_page', 15); + $status = $request->query('status'); // pending, completed, failed + $type = $request->query('type'); // deposit, withdrawal - $transactions = $wallet->transactions() - ->orderByDesc('created_at') - ->paginate($perPage); + $query = $wallet->transactions()->orderByDesc('created_at'); + + if ($status) { + $query->where('status', $status); + } + + if ($type) { + $query->where('type', $type); + } + + $transactions = $query->paginate($perPage); return response()->json([ 'transactions' => $transactions->map(fn ($t) => [ 'id' => $t->id, 'amount' => $t->amount, - 'type' => $t->type, + 'formatted_amount' => $t->formatted_amount, + 'type' => $t->type->label(), + 'status' => $t->status->label(), + 'status_code' => $t->status->value, + 'gateway' => $t->gateway?->label(), + 'reference_id' => $t->gateway_reference_id, 'description' => $t->description, 'created_at' => $t->created_at->toIso8601String(), + 'created_at_jalali' => \Morilog\Jalali\Jalalian::fromCarbon($t->created_at)->format('Y/m/d H:i'), ]), 'total' => $transactions->total(), 'current_page' => $transactions->currentPage(), @@ -115,19 +105,23 @@ class WalletController ]); } - public function adminRecharge(Request $request): JsonResponse + /** + * شارژ/کسر دستی توسط ادمین + */ + public function adminAdjust(Request $request): JsonResponse { $user = $request->user(); - + if (!$user || !$user->isSuperAdmin()) { - return response()->json(['message' => 'Forbidden'], 403); + return response()->json(['message' => 'Forbidden — Only Super Admin can perform this action'], 403); } try { $validated = $request->validate([ 'user_id' => ['required', 'exists:users,id'], - 'amount' => ['required', 'numeric', 'min:1'], - 'description' => ['nullable', 'string', 'max:500'], + 'amount' => ['required', 'numeric', 'not_in:0'], // مثبت = شارژ، منفی = کسر + 'description' => ['required', 'string', 'max:1000'], + 'admin_notes' => ['nullable', 'string', 'max:2000'], ]); } catch (ValidationException $e) { return response()->json([ @@ -137,28 +131,160 @@ class WalletController } $targetUser = User::findOrFail($validated['user_id']); - $wallet = $targetUser->wallet()->first(); + $wallet = $targetUser->wallet ?? Wallet::create([ + 'user_id' => $targetUser->id, + 'balance' => 0, + ]); - if (!$wallet) { - $wallet = Wallet::create([ - 'user_id' => $targetUser->id, - 'balance' => 0, + try { + $amount = abs($validated['amount']); + + if ($validated['amount'] > 0) { + // شارژ + $transaction = $this->walletService->manualDeposit( + wallet: $wallet, + amount: $amount, + description: $validated['description'], + admin: $user, + adminNotes: $validated['admin_notes'] ?? null + ); + $message = 'شارژ دستی با موفقیت انجام شد'; + } else { + // کسر + $transaction = $this->walletService->manualWithdrawal( + wallet: $wallet, + amount: $amount, + description: $validated['description'], + admin: $user, + adminNotes: $validated['admin_notes'] ?? null + ); + $message = 'کسر دستی با موفقیت انجام شد'; + } + + return response()->json([ + 'message' => $message, + 'transaction_id' => $transaction->id, + 'target_user_id' => $targetUser->id, + 'target_user_name' => $targetUser->name, + 'balance_before' => $transaction->balance_before, + 'balance_after' => $transaction->balance_after, + 'amount' => $transaction->amount, + 'description' => $transaction->description, + 'admin_notes' => $transaction->admin_notes, + 'performed_by' => $user->name, + 'created_at' => $transaction->created_at->toIso8601String(), ]); + + } catch (\Exception $e) { + return response()->json([ + 'message' => 'خطا در انجام عملیات: ' . $e->getMessage(), + ], 422); + } + } + + /** + * مسدود کردن کیف پول (توسط ادمین) + */ + public function freeze(Request $request, $walletId): JsonResponse + { + $user = $request->user(); + + if (!$user || !$user->isSuperAdmin()) { + return response()->json(['message' => 'Forbidden'], 403); } - $transaction = $this->walletService->deposit( + $validated = $request->validate([ + 'reason' => ['required', 'string', 'max:1000'], + ]); + + $wallet = Wallet::findOrFail($walletId); + $oldFrozen = $wallet->isFrozen(); + + $wallet->freeze($validated['reason'], $user); + + WalletActivityLog::log( $wallet, - $validated['amount'], - $validated['description'] ?? 'شارژ دستی توسط ادمین', + 'wallet_frozen', + 'کیف پول مسدود شد توسط ' . $user->name, + ['is_frozen' => $oldFrozen], + ['is_frozen' => true, 'reason' => $validated['reason']], + $user ); return response()->json([ - 'message' => 'شارژ دستی با موفقیت انجام شد', + 'message' => 'کیف پول با موفقیت مسدود شد', 'wallet_id' => $wallet->id, - 'target_user_id' => $targetUser->id, - 'balance' => $wallet->fresh()->balance, - 'transaction_id' => $transaction->id, - 'amount' => $transaction->amount, + 'reason' => $validated['reason'], + ]); + } + + /** + * رفع مسدودی کیف پول (توسط ادمین) + */ + public function unfreeze(Request $request, $walletId): JsonResponse + { + $user = $request->user(); + + if (!$user || !$user->isSuperAdmin()) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $wallet = Wallet::findOrFail($walletId); + $oldReason = $wallet->freeze_reason; + + $wallet->unfreeze($user); + + WalletActivityLog::log( + $wallet, + 'wallet_unfrozen', + 'کیف پول از مسدودی خارج شد توسط ' . $user->name, + ['reason' => $oldReason], + ['is_frozen' => false], + $user + ); + + return response()->json([ + 'message' => 'کیف پول با موفقیت از مسدودی خارج شد', + 'wallet_id' => $wallet->id, + ]); + } + + /** + * مشاهده لاگ فعالیت‌های کیف پول (توسط ادمین) + */ + public function activityLog(Request $request, $walletId): JsonResponse + { + $user = $request->user(); + + if (!$user || !$user->isSuperAdmin()) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $wallet = Wallet::findOrFail($walletId); + $perPage = $request->query('per_page', 20); + + $logs = $wallet->activityLogs()->with('user')->paginate($perPage); + + return response()->json([ + 'wallet_id' => $wallet->id, + 'user_name' => $wallet->user->name, + 'balance' => $wallet->balance, + 'is_frozen' => $wallet->isFrozen(), + 'freeze_reason' => $wallet->freeze_reason, + 'logs' => $logs->map(fn ($log) => [ + 'id' => $log->id, + 'action' => $log->action, + 'description' => $log->description, + 'old_values' => $log->old_values, + 'new_values' => $log->new_values, + 'performed_by' => $log->user?->name ?? 'سیستم', + 'ip_address' => $log->ip_address, + 'created_at' => $log->created_at->toIso8601String(), + 'created_at_jalali' => \Morilog\Jalali\Jalalian::fromCarbon($log->created_at)->format('Y/m/d H:i'), + ]), + 'total' => $logs->total(), + 'current_page' => $logs->currentPage(), + 'last_page' => $logs->lastPage(), ]); } } \ No newline at end of file diff --git a/04_Laravel/app/Models/User.php b/04_Laravel/app/Models/User.php index 1abcdf2..4777a4b 100644 --- a/04_Laravel/app/Models/User.php +++ b/04_Laravel/app/Models/User.php @@ -5,12 +5,12 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Sanctum\HasApiTokens; // ← اضافه کنید use App\Models\Wallet; class User extends Authenticatable { - /** @use HasFactory */ - use HasFactory, Notifiable; + use HasApiTokens, HasFactory, Notifiable; // ← HasApiTokens اضافه شد protected $fillable = [ 'name', diff --git a/04_Laravel/app/Models/Wallet.php b/04_Laravel/app/Models/Wallet.php index 55e45bc..e0806e0 100644 --- a/04_Laravel/app/Models/Wallet.php +++ b/04_Laravel/app/Models/Wallet.php @@ -3,14 +3,32 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; -use App\Models\User; -use App\Models\WalletTransaction; -use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class Wallet extends Model { - protected $fillable = ['user_id', 'balance']; + use SoftDeletes; + + protected $fillable = [ + 'user_id', + 'balance', + 'total_deposited', + 'total_withdrawn', + 'is_frozen', + 'freeze_reason', + 'frozen_at', + 'frozen_by', + ]; + + protected $casts = [ + 'balance' => 'decimal:2', + 'total_deposited' => 'decimal:2', + 'total_withdrawn' => 'decimal:2', + 'is_frozen' => 'boolean', + 'frozen_at' => 'datetime', + ]; public function user(): BelongsTo { @@ -19,6 +37,76 @@ class Wallet extends Model public function transactions(): HasMany { - return $this->hasMany(WalletTransaction::class); + return $this->hasMany(WalletTransaction::class)->orderByDesc('created_at'); } -} + + public function completedTransactions(): HasMany + { + return $this->transactions()->where('status', 'completed'); + } + + public function pendingTransactions(): HasMany + { + return $this->transactions()->where('status', 'pending'); + } + + public function activityLogs(): HasMany + { + return $this->hasMany(WalletActivityLog::class)->orderByDesc('created_at'); + } + + public function frozenByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'frozen_by'); + } + + // Helper methods + public function isFrozen(): bool + { + return $this->is_frozen; + } + + public function hasSufficientBalance(float $amount): bool + { + return $this->balance >= $amount && !$this->is_frozen; + } + + public function freeze(string $reason, ?User $admin = null): bool + { + return $this->update([ + 'is_frozen' => true, + 'freeze_reason' => $reason, + 'frozen_at' => now(), + 'frozen_by' => $admin?->id, + ]); + } + + public function unfreeze(?User $admin = null): bool + { + return $this->update([ + 'is_frozen' => false, + 'freeze_reason' => null, + 'frozen_at' => null, + 'frozen_by' => null, + ]); + } + + public function refreshBalance(): self + { + $balance = $this->completedTransactions()->sum('amount'); + $totalDeposited = $this->completedTransactions() + ->where('type', 'deposit') + ->sum('amount'); + $totalWithdrawn = $this->completedTransactions() + ->whereIn('type', ['withdrawal', 'order_payment']) + ->sum('amount'); + + $this->update([ + 'balance' => $balance, + 'total_deposited' => $totalDeposited, + 'total_withdrawn' => abs($totalWithdrawn), + ]); + + return $this->fresh(); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Models/WalletActivityLog.php b/04_Laravel/app/Models/WalletActivityLog.php new file mode 100644 index 0000000..e768f93 --- /dev/null +++ b/04_Laravel/app/Models/WalletActivityLog.php @@ -0,0 +1,55 @@ + 'array', + 'new_values' => 'array', + ]; + + public function wallet(): BelongsTo + { + return $this->belongsTo(Wallet::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public static function log( + Wallet $wallet, + string $action, + ?string $description = null, + ?array $oldValues = null, + ?array $newValues = null, + ?User $user = null + ): self { + return self::create([ + 'wallet_id' => $wallet->id, + 'user_id' => $user?->id ?? auth()->id(), + 'action' => $action, + 'description' => $description, + 'old_values' => $oldValues, + 'new_values' => $newValues, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ]); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Models/WalletTransaction.php b/04_Laravel/app/Models/WalletTransaction.php index c402b65..fd4b11f 100644 --- a/04_Laravel/app/Models/WalletTransaction.php +++ b/04_Laravel/app/Models/WalletTransaction.php @@ -3,21 +3,129 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; -use App\Models\Wallet; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; +use App\Enums\TransactionType; +use App\Enums\TransactionStatus; +use App\Enums\PaymentGateway; class WalletTransaction extends Model { - protected $fillable = ['wallet_id', 'amount', 'type', 'description', 'transactionable_type', 'transactionable_id']; + use SoftDeletes; + + protected $fillable = [ + 'wallet_id', + 'amount', + 'balance_before', + 'balance_after', + 'type', + 'status', + 'gateway', + 'gateway_reference_id', + 'description', + 'admin_notes', + 'created_by', + 'approved_by', + 'approved_at', + 'transactionable_type', + 'transactionable_id', + 'ip_address', + 'user_agent', + 'metadata', + ]; + + protected $casts = [ + 'amount' => 'decimal:2', + 'balance_before' => 'decimal:2', + 'balance_after' => 'decimal:2', + 'type' => TransactionType::class, + 'status' => TransactionStatus::class, + 'gateway' => PaymentGateway::class, + 'approved_at' => 'datetime', + 'metadata' => 'array', + ]; public function wallet(): BelongsTo { return $this->belongsTo(Wallet::class); } + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + public function transactionable(): MorphTo { return $this->morphTo(); } + + // Scopes + public function scopeCompleted($query) + { + return $query->where('status', TransactionStatus::COMPLETED); + } + + public function scopePending($query) + { + return $query->where('status', TransactionStatus::PENDING); + } + + public function scopeDeposits($query) + { + return $query->where('type', TransactionType::DEPOSIT); + } + + public function scopeWithdrawals($query) + { + return $query->where('type', TransactionType::WITHDRAWAL); + } + + // Helper methods + public function isCompleted(): bool + { + return $this->status === TransactionStatus::COMPLETED; + } + + public function isPending(): bool + { + return $this->status === TransactionStatus::PENDING; + } + + public function approve(?User $admin = null): bool + { + return $this->update([ + 'status' => TransactionStatus::COMPLETED, + 'approved_by' => $admin?->id, + 'approved_at' => now(), + ]); + } + + public function fail(string $reason = null): bool + { + $metadata = $this->metadata ?? []; + $metadata['fail_reason'] = $reason; + + return $this->update([ + 'status' => TransactionStatus::FAILED, + 'metadata' => $metadata, + ]); + } + + public function getFormattedAmountAttribute(): string + { + $prefix = $this->amount >= 0 ? '+' : ''; + return $prefix . number_format($this->amount, 0) . ' ریال'; + } + + public function getGatewayLabelAttribute(): string + { + return $this->gateway?->label() ?? 'نامشخص'; + } } \ No newline at end of file diff --git a/04_Laravel/app/Services/WalletService.php b/04_Laravel/app/Services/WalletService.php index 27242ac..351c448 100644 --- a/04_Laravel/app/Services/WalletService.php +++ b/04_Laravel/app/Services/WalletService.php @@ -1,53 +1,326 @@ increment('balance', $amount); - - return $this->createTransaction($wallet, $amount, 'deposit', $description, $transactionable); + public function requestDeposit( + Wallet $wallet, + float $amount, + string $description = 'شارژ آنلاین کیف پول', + ?User $user = null, + PaymentGateway $gateway = PaymentGateway::ZARINPAL + ): WalletTransaction { + if ($wallet->isFrozen()) { + throw new \Exception('کیف پول شما مسدود است. لطفاً با پشتیبانی تماس بگیرید.'); + } + + $transaction = DB::transaction(function () use ($wallet, $amount, $description, $user, $gateway) { + $transaction = WalletTransaction::create([ + 'wallet_id' => $wallet->id, + 'amount' => $amount, + 'balance_before' => $wallet->balance, + 'balance_after' => $wallet->balance, // هنوز تغییر نکرده + 'type' => TransactionType::DEPOSIT, + 'status' => TransactionStatus::PENDING, + 'gateway' => $gateway, + 'description' => $description, + 'created_by' => $user?->id ?? auth()->id(), + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ]); + + // لاگ فعالیت + WalletActivityLog::log( + $wallet, + 'deposit_requested', + "درخواست شارژ {$amount} ریال از طریق {$gateway->label()}", + null, + ['transaction_id' => $transaction->id, 'amount' => $amount], + $user + ); + + return $transaction; + }); + + Log::info('Wallet deposit requested', [ + 'wallet_id' => $wallet->id, + 'transaction_id' => $transaction->id, + 'amount' => $amount, + 'gateway' => $gateway->value, + ]); + + return $transaction; + } + + /** + * تأیید و تکمیل تراکنش شارژ (بعد از callback درگاه) + */ + public function completeDeposit( + WalletTransaction $transaction, + string $gatewayReferenceId, + ?User $admin = null + ): WalletTransaction { + if ($transaction->status !== TransactionStatus::PENDING) { + throw new \Exception('این تراکنش قابل تأیید نیست.'); + } + + return DB::transaction(function () use ($transaction, $gatewayReferenceId, $admin) { + $wallet = $transaction->wallet; + $oldBalance = $wallet->balance; + + // به‌روزرسانی موجودی کیف پول + $wallet->increment('balance', $transaction->amount); + $wallet->increment('total_deposited', $transaction->amount); + + // تأیید تراکنش + $transaction->update([ + 'status' => TransactionStatus::COMPLETED, + 'gateway_reference_id' => $gatewayReferenceId, + 'balance_after' => $wallet->balance, + 'approved_by' => $admin?->id, + 'approved_at' => now(), + ]); + + // لاگ فعالیت + WalletActivityLog::log( + $wallet, + 'deposit_completed', + "شارژ {$transaction->amount} ریال با موفقیت تکمیل شد. کد پیگیری: {$gatewayReferenceId}", + ['balance' => $oldBalance], + ['balance' => $wallet->balance, 'reference_id' => $gatewayReferenceId], + $admin + ); + + Log::info('Wallet deposit completed', [ + 'wallet_id' => $wallet->id, + 'transaction_id' => $transaction->id, + 'amount' => $transaction->amount, + 'reference_id' => $gatewayReferenceId, + ]); + + return $transaction->fresh(); }); } /** - * برداشت وجه از کیف پول + * شارژ دستی توسط ادمین (پرداخت تلفنی یا کارت به کارت) */ - public function withdraw(Wallet $wallet, float $amount, string $description = null, $transactionable = null): WalletTransaction - { + public function manualDeposit( + Wallet $wallet, + float $amount, + string $description, + User $admin, + ?string $adminNotes = null + ): WalletTransaction { + if ($wallet->isFrozen()) { + throw new \Exception('این کیف پول مسدود است.'); + } + + return DB::transaction(function () use ($wallet, $amount, $description, $admin, $adminNotes) { + $oldBalance = $wallet->balance; + + // به‌روزرسانی موجودی + $wallet->increment('balance', $amount); + $wallet->increment('total_deposited', $amount); + + // ایجاد تراکنش + $transaction = WalletTransaction::create([ + 'wallet_id' => $wallet->id, + 'amount' => $amount, + 'balance_before' => $oldBalance, + 'balance_after' => $wallet->balance, + 'type' => TransactionType::DEPOSIT, + 'status' => TransactionStatus::COMPLETED, + 'gateway' => PaymentGateway::MANUAL, + 'description' => $description, + 'admin_notes' => $adminNotes, + 'created_by' => $admin->id, + 'approved_by' => $admin->id, + 'approved_at' => now(), + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ]); + + // لاگ فعالیت + WalletActivityLog::log( + $wallet, + 'manual_adjustment', + "شارژ دستی {$amount} ریال توسط {$admin->name}. توضیحات: {$description}", + ['balance' => $oldBalance], + ['balance' => $wallet->balance, 'transaction_id' => $transaction->id], + $admin + ); + + Log::info('Manual wallet deposit', [ + 'wallet_id' => $wallet->id, + 'transaction_id' => $transaction->id, + 'amount' => $amount, + 'admin_id' => $admin->id, + 'description' => $description, + ]); + + return $transaction; + }); + } + + /** + * کسر دستی توسط ادمین (بدهی مشتری یا جریمه) + */ + public function manualWithdrawal( + Wallet $wallet, + float $amount, + string $description, + User $admin, + ?string $adminNotes = null + ): WalletTransaction { + if ($wallet->isFrozen()) { + throw new \Exception('این کیف پول مسدود است.'); + } + if ($wallet->balance < $amount) { throw new \Exception('موجودی کیف پول کافی نیست.'); } - return DB::transaction(function () use ($wallet, $amount, $description, $transactionable) { + return DB::transaction(function () use ($wallet, $amount, $description, $admin, $adminNotes) { + $oldBalance = $wallet->balance; + + // به‌روزرسانی موجودی $wallet->decrement('balance', $amount); - - // مبلغ برداشت به صورت منفی ثبت می‌شود - return $this->createTransaction($wallet, -$amount, 'withdraw', $description, $transactionable); + $wallet->increment('total_withdrawn', $amount); + + // ایجاد تراکنش (مبلغ منفی) + $transaction = WalletTransaction::create([ + 'wallet_id' => $wallet->id, + 'amount' => -$amount, + 'balance_before' => $oldBalance, + 'balance_after' => $wallet->balance, + 'type' => TransactionType::WITHDRAWAL, + 'status' => TransactionStatus::COMPLETED, + 'gateway' => PaymentGateway::MANUAL, + 'description' => $description, + 'admin_notes' => $adminNotes, + 'created_by' => $admin->id, + 'approved_by' => $admin->id, + 'approved_at' => now(), + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ]); + + // لاگ فعالیت + WalletActivityLog::log( + $wallet, + 'manual_adjustment', + "کسر دستی {$amount} ریال توسط {$admin->name}. توضیحات: {$description}", + ['balance' => $oldBalance], + ['balance' => $wallet->balance, 'transaction_id' => $transaction->id], + $admin + ); + + Log::info('Manual wallet withdrawal', [ + 'wallet_id' => $wallet->id, + 'transaction_id' => $transaction->id, + 'amount' => $amount, + 'admin_id' => $admin->id, + 'description' => $description, + ]); + + return $transaction; }); } /** - * ثبت تراکنش در دیتابیس + * پرداخت سفارش از کیف پول */ - private function createTransaction(Wallet $wallet, float $amount, string $type, ?string $description, $transactionable): WalletTransaction - { - return WalletTransaction::create([ - 'wallet_id' => $wallet->id, - 'amount' => $amount, - 'type' => $type, - 'description' => $description, - 'transactionable_type' => $transactionable ? get_class($transactionable) : null, - 'transactionable_id' => $transactionable?->id, - ]); + public function payOrder( + Wallet $wallet, + float $amount, + string $description, + $order = null + ): WalletTransaction { + if ($wallet->isFrozen()) { + throw new \Exception('کیف پول شما مسدود است.'); + } + + if (!$wallet->hasSufficientBalance($amount)) { + throw new \Exception('موجودی کیف پول کافی نیست.'); + } + + return DB::transaction(function () use ($wallet, $amount, $description, $order) { + $oldBalance = $wallet->balance; + + // به‌روزرسانی موجودی + $wallet->decrement('balance', $amount); + $wallet->increment('total_withdrawn', $amount); + + // ایجاد تراکنش + $transaction = WalletTransaction::create([ + 'wallet_id' => $wallet->id, + 'amount' => -$amount, + 'balance_before' => $oldBalance, + 'balance_after' => $wallet->balance, + 'type' => TransactionType::ORDER_PAYMENT, + 'status' => TransactionStatus::COMPLETED, + 'gateway' => PaymentGateway::SYSTEM, + 'description' => $description, + 'created_by' => auth()->id(), + 'approved_by' => auth()->id(), + 'approved_at' => now(), + 'transactionable_type' => $order ? get_class($order) : null, + 'transactionable_id' => $order?->id, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + ]); + + // لاگ فعالیت + WalletActivityLog::log( + $wallet, + 'withdrawal_completed', + "پرداخت سفارش {$amount} ریال", + ['balance' => $oldBalance], + ['balance' => $wallet->balance, 'transaction_id' => $transaction->id] + ); + + return $transaction; + }); } -} + + /** + * شکست تراکنش + */ + public function failTransaction( + WalletTransaction $transaction, + string $reason + ): WalletTransaction { + $transaction->fail($reason); + + // لاگ فعالیت + WalletActivityLog::log( + $transaction->wallet, + $transaction->type === TransactionType::DEPOSIT ? 'deposit_failed' : 'withdrawal_failed', + "تراکنش {$transaction->id} شکست خورد. دلیل: {$reason}", + null, + ['transaction_id' => $transaction->id, 'reason' => $reason] + ); + + Log::warning('Wallet transaction failed', [ + 'transaction_id' => $transaction->id, + 'reason' => $reason, + ]); + + return $transaction->fresh(); + } +} \ No newline at end of file diff --git a/04_Laravel/app/Services/ZarinpalService.php b/04_Laravel/app/Services/ZarinpalService.php new file mode 100644 index 0000000..3b4ddaa --- /dev/null +++ b/04_Laravel/app/Services/ZarinpalService.php @@ -0,0 +1,128 @@ +merchantId = config('ifnex.zarinpal.merchant_id', 'fake-merchant-id-for-testing'); + $isProduction = config('ifnex.zarinpal.sandbox', true); + + // URLs برای محیط تست و واقعی + $this->baseUrl = $isProduction + ? 'https://sandbox.zarinpal.com/pg/v4' + : 'https://api.zarinpal.com/pg/v4'; + + $this->callbackUrl = config('ifnex.zarinpal.callback_url', url('/api/v1/payment/callback')); + } + + /** + * ایجاد درخواست پرداخت (Payment Request) + */ + public function requestPayment( + float $amount, + string $description, + ?string $mobile = null, + ?string $email = null + ): array { + $amountToman = (int) round($amount / 10); // ریال به تومان + + $response = Http::timeout(30)->post("{$this->baseUrl}/PaymentRequest.json", [ + 'merchant_id' => $this->merchantId, + 'amount' => $amountToman, + 'currency' => 'IRR', + 'description' => $description, + 'callback_url' => $this->callbackUrl, + 'metadata' => [ + 'mobile' => $mobile, + 'email' => $email, + ], + ]); + + $result = $response->json(); + + Log::info('Zarinpal Payment Request', [ + 'amount' => $amountToman, + 'response' => $result, + ]); + + if ($result['data']['code'] !== 100) { + throw new \Exception('خطا در ارتباط با زرین‌پال: ' . ($result['errors']['message'] ?? 'نامشخص')); + } + + $authority = $result['data']['authority']; + $paymentUrl = $this->getPaymentUrl($authority); + + return [ + 'authority' => $authority, + 'payment_url' => $paymentUrl, + 'amount' => $amount, + 'amount_toman' => $amountToman, + ]; + } + + /** + * بررسی صحت پرداخت (Verification) + */ + public function verifyPayment(string $authority, float $expectedAmount): array + { + $amountToman = (int) round($expectedAmount / 10); + + $response = Http::timeout(30)->post("{$this->baseUrl}/PaymentVerification.json", [ + 'merchant_id' => $this->merchantId, + 'authority' => $authority, + 'amount' => $amountToman, + ]); + + $result = $response->json(); + + Log::info('Zarinpal Payment Verification', [ + 'authority' => $authority, + 'response' => $result, + ]); + + if ($result['data']['code'] === 100) { + return [ + 'success' => true, + 'ref_id' => $result['data']['ref_id'], + 'card_hash' => $result['data']['card_hash'] ?? null, + 'card_pan' => $result['data']['card_pan'] ?? null, + ]; + } elseif ($result['data']['code'] === 101) { + // قبلاً verify شده + return [ + 'success' => true, + 'ref_id' => $result['data']['ref_id'] ?? null, + 'already_verified' => true, + ]; + } + + return [ + 'success' => false, + 'error_code' => $result['data']['code'] ?? null, + 'error_message' => $result['errors']['message'] ?? 'نامشخص', + ]; + } + + /** + * ساخت لینک پرداخت + */ + private function getPaymentUrl(string $authority): string + { + $isProduction = config('ifnex.zarinpal.sandbox', true); + $gatewayUrl = $isProduction + ? 'https://sandbox.zarinpal.com/pg/StartPay/' + : 'https://www.zarinpal.com/pg/StartPay/'; + + return $gatewayUrl . $authority; + } +} \ No newline at end of file diff --git a/04_Laravel/bootstrap/app.php b/04_Laravel/bootstrap/app.php index 4ce9e3b..e297769 100644 --- a/04_Laravel/bootstrap/app.php +++ b/04_Laravel/bootstrap/app.php @@ -1,8 +1,10 @@ withRouting( @@ -15,10 +17,16 @@ return Application::configure(basePath: dirname(__DIR__)) \App\Console\Commands\ImportShippingRates::class, \App\Console\Commands\ImportTrackingData::class, \App\Console\Commands\UpdateExchangeRates::class, + \App\Console\Commands\GenerateApiToken::class, ]) ->withMiddleware(function (Middleware $middleware): void { + // ✅ فعال‌سازی Stateful API برای Sanctum + $middleware->statefulApi(); + + // API با CORS $middleware->api(prepend: \Illuminate\Http\Middleware\HandleCors::class); + // گروه web برای Filament و صفحات عادی $middleware->group('web', [ \Illuminate\Cookie\Middleware\EncryptCookies::class, \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class, @@ -29,5 +37,14 @@ return Application::configure(basePath: dirname(__DIR__)) ]); }) ->withExceptions(function (Exceptions $exceptions): void { - // + // ✅ مهم: به جای redirect به login، JSON برگردان + $exceptions->render(function (AuthenticationException $e, Request $request) { + if ($request->is('api/*') || $request->expectsJson() || $request->wantsJson()) { + return response()->json([ + 'success' => false, + 'message' => 'Unauthenticated', + 'error' => 'توکن نامعتبر است یا منقضی شده است. لطفاً دوباره وارد شوید.', + ], 401); + } + }); })->create(); \ No newline at end of file diff --git a/04_Laravel/composer.json b/04_Laravel/composer.json index 8c99b50..78f3b60 100644 --- a/04_Laravel/composer.json +++ b/04_Laravel/composer.json @@ -10,6 +10,7 @@ "barryvdh/laravel-dompdf": "*", "filament/filament": "3.3.*", "laravel/framework": "^11.0", + "laravel/sanctum": "^4.0", "laravel/tinker": "^2.10.1", "maatwebsite/excel": "^3.1", "morilog/jalali": "^3.0" diff --git a/04_Laravel/composer.lock b/04_Laravel/composer.lock index 4d56a2c..08bb923 100644 --- a/04_Laravel/composer.lock +++ b/04_Laravel/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c0d4db086122fe759e56ff3bf438bc30", + "content-hash": "86be2c9d3213013f1619d58ee77dc33c", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -2814,6 +2814,69 @@ }, "time": "2026-06-26T00:11:25+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6", + "reference": "fee27a573d1a013af3721d86153a65e0b11927e6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-06-23T18:26:55+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.15", diff --git a/04_Laravel/config/ifnex.php b/04_Laravel/config/ifnex.php index 01cb651..e56cce8 100644 --- a/04_Laravel/config/ifnex.php +++ b/04_Laravel/config/ifnex.php @@ -2,16 +2,41 @@ return [ + /* + |-------------------------------------------------------------------------- + | IFNEX API Settings + |-------------------------------------------------------------------------- + | تنظیمات عمومی API سیستم ایف‌نکس + */ 'api_key' => env('IFNEX_API_KEY', 'change-me'), 'cors_allowed_origins' => explode(',', env('CORS_ALLOWED_ORIGINS', 'http://localhost,http://127.0.0.1')), 'tracking_rate_limit' => env('IFNEX_TRACKING_RATE_LIMIT', 60), + /* + |-------------------------------------------------------------------------- + | Carrier Codes (کدهای شرکت‌های حمل‌ونقل) + |-------------------------------------------------------------------------- + | لیست شرکت‌های حمل‌ونقل فعال در سیستم + */ 'carrier_codes' => [ - 'DHL', 'FEDEX', 'UPS', 'ARAMEX', 'NAGHEL', 'EMX', 'APSITEX', 'IMPEX', 'OTHER', + 'DHL', + 'FEDEX', + 'UPS', + 'ARAMEX', + 'NAGHEL', + 'EMX', + 'APSITEX', + 'IMPEX', + 'OTHER', ], + /* + |-------------------------------------------------------------------------- + | Tracking Statuses (وضعیت‌های رهگیری مرسوله) + |-------------------------------------------------------------------------- + */ 'tracking_statuses' => [ 'processed', 'picked_up', @@ -22,9 +47,114 @@ return [ 'returned', ], - 'currency_api_key' => env('CURRENCY_API_KEY'), + /* + |-------------------------------------------------------------------------- + | Currency API (به‌روزرسانی نرخ ارز) + |-------------------------------------------------------------------------- + */ + 'currency' => [ + 'api_key' => env('CURRENCY_API_KEY'), + 'api_url' => env('CURRENCY_API_URL', 'https://api.freecurrencyapi.com/v1/latest'), + 'base_currency' => 'USD', + 'target_currencies' => ['IRR', 'AED', 'EUR', 'CNY'], + ], - 'zarinpal_merchant_id' => env('ZARINPAL_MERCHANT_ID'), - 'zarinpal_callback_url' => env('ZARINPAL_CALLBACK_URL', 'http://localhost/payment/callback'), + /* + |-------------------------------------------------------------------------- + | Zarinpal Payment Gateway (درگاه پرداخت زرین‌پال) + |-------------------------------------------------------------------------- + | تنظیمات اتصال به درگاه زرین‌پال برای شارژ کیف پول + */ + 'zarinpal' => [ + // شناسه پذیرنده (Merchant ID) از پنل زرین‌پال + 'merchant_id' => env('ZARINPAL_MERCHANT_ID', 'fake-merchant-id-for-testing'), -]; + // محیط Sandbox برای تست (true) یا Production (false) + 'sandbox' => env('ZARINPAL_SANDBOX', true), + + // URL Callback که زرین‌پال بعد از پرداخت کاربر را به آن redirect می‌کند + 'callback_url' => env('ZARINPAL_CALLBACK_URL', null), + // اگر null باشد، از url('/api/v1/payment/callback') استفاده می‌شود + + // URL فرانت‌اند برای redirect بعد از پرداخت موفق + 'frontend_success_url' => env('ZARINPAL_FRONTEND_SUCCESS_URL', '/payment/success'), + + // URL فرانت‌اند برای redirect بعد از پرداخت ناموفق + 'frontend_failure_url' => env('ZARINPAL_FRONTEND_FAILURE_URL', '/payment/failed'), + + // زمان انقضای تراکنش‌های pending (به دقیقه) + 'transaction_expiry_minutes' => env('ZARINPAL_EXPIRY_MINUTES', 30), + ], + + /* + |-------------------------------------------------------------------------- + | Wallet Settings (تنظیمات کیف پول) + |-------------------------------------------------------------------------- + | قوانین و محدودیت‌های کیف پول کاربران + */ + 'wallet' => [ + // حداقل مبلغ شارژ آنلاین (ریال) - معمولاً ۱۰,۰۰۰ ریال + 'min_deposit_amount' => env('WALLET_MIN_DEPOSIT', 10000), + + // حداکثر مبلغ شارژ در هر تراکنش (ریال) - معمولاً ۵۰۰ میلیون ریال + 'max_deposit_amount' => env('WALLET_MAX_DEPOSIT', 500000000), + + // حداقل موجودی لازم برای پرداخت سفارش از کیف پول (ریال) + 'min_balance_for_payment' => env('WALLET_MIN_BALANCE', 1000), + + // کارمزد شارژ کیف پول (درصد) - معمولاً ۰٪ + 'deposit_fee_percent' => env('WALLET_DEPOSIT_FEE', 0), + + // آیا کیف پول به‌صورت پیش‌فرض هنگام ثبت‌نام کاربر ساخته شود؟ + 'auto_create_on_register' => env('WALLET_AUTO_CREATE', true), + + // آیا کاربران عادی اجازه برداشت (cash out) دارند؟ + 'allow_withdrawal' => env('WALLET_ALLOW_WITHDRAWAL', false), + ], + + /* + |-------------------------------------------------------------------------- + | Financial Defaults (پیش‌فرض‌های مالی) + |-------------------------------------------------------------------------- + | این مقادیر فقط fallback هستند — مقادیر واقعی از جدول system_settings خوانده می‌شوند + */ + 'financial_defaults' => [ + 'vat_rate' => 0.09, // ۹٪ مالیات بر ارزش افزوده + 'profit_margin' => 1.25, // ۲۵٪ حاشیه سود + 'packing_cost' => 100000, // ۱۰۰,۰۰۰ ریال هزینه بسته‌بندی پیش‌فرض + 'aed_to_irr' => 455000, // نرخ درهم به ریال (fallback) + ], + + /* + |-------------------------------------------------------------------------- + | Notification Settings (تنظیمات اعلان‌ها) + |-------------------------------------------------------------------------- + */ + 'notifications' => [ + // آیا هنگام شارژ موفق به کاربر ایمیل/پیامک زده شود؟ + 'notify_on_deposit' => env('NOTIFY_ON_DEPOSIT', true), + + // آیا هنگام شارژ دستی توسط ادمین به کاربر اطلاع داده شود؟ + 'notify_on_manual_adjustment' => env('NOTIFY_ON_MANUAL_ADJUSTMENT', true), + + // آیا هنگام رسیدن موجودی به آستانه هشدار، اطلاع داده شود؟ + 'low_balance_threshold' => env('LOW_BALANCE_THRESHOLD', 500000), // ۵۰۰,۰۰۰ ریال + ], + + /* + |-------------------------------------------------------------------------- + | Audit & Logging (ثبت فعالیت‌ها و لاگ) + |-------------------------------------------------------------------------- + */ + 'audit' => [ + // آیا تمام عملیات مالی لاگ شوند؟ (توصیه می‌شود همیشه true باشد) + 'log_financial_operations' => true, + + // آیا IP و User Agent کاربر در تراکنش‌ها ذخیره شود؟ + 'track_client_info' => true, + + // تعداد روز نگهداری لاگ‌های قدیمی (برای پاک‌سازی خودکار) + 'log_retention_days' => env('AUDIT_LOG_RETENTION_DAYS', 365), + ], + +]; \ No newline at end of file diff --git a/04_Laravel/config/sanctum.php b/04_Laravel/config/sanctum.php new file mode 100644 index 0000000..cde73cf --- /dev/null +++ b/04_Laravel/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/04_Laravel/database/migrations/2026_08_05_135016_create_wallets_table.php b/04_Laravel/database/migrations/2026_08_05_135016_create_wallets_table.php index 82857d8..a9555b6 100644 --- a/04_Laravel/database/migrations/2026_08_05_135016_create_wallets_table.php +++ b/04_Laravel/database/migrations/2026_08_05_135016_create_wallets_table.php @@ -6,25 +6,25 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { - /** - * Run the migrations. - */ public function up(): void { Schema::create('wallets', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); - $table->decimal('balance', 15, 2)->default(0); // موجودی به ریال یا تومان + $table->decimal('balance', 15, 2)->default(0); // موجودی به ریال + $table->decimal('total_deposited', 15, 2)->default(0); // مجموع واریزی‌ها + $table->decimal('total_withdrawn', 15, 2)->default(0); // مجموع برداشت‌ها + $table->boolean('is_frozen')->default(false); // مسدود بودن کیف پول + $table->text('freeze_reason')->nullable(); // دلیل مسدودی + $table->timestamp('frozen_at')->nullable(); + $table->foreignId('frozen_by')->nullable()->constrained('users'); // ادمینی که مسدود کرده $table->timestamps(); + $table->softDeletes(); }); - } - /** - * Reverse the migrations. - */ public function down(): void { Schema::dropIfExists('wallets'); } -}; +}; \ No newline at end of file diff --git a/04_Laravel/database/migrations/2026_08_05_135022_create_wallet_transactions_table.php b/04_Laravel/database/migrations/2026_08_05_135022_create_wallet_transactions_table.php index 713dba1..1f7dd5f 100644 --- a/04_Laravel/database/migrations/2026_08_05_135022_create_wallet_transactions_table.php +++ b/04_Laravel/database/migrations/2026_08_05_135022_create_wallet_transactions_table.php @@ -6,30 +6,67 @@ use Illuminate\Support\Facades\Schema; return new class extends Migration { - /** - * Run the migrations. - */ public function up(): void { Schema::create('wallet_transactions', function (Blueprint $table) { $table->id(); $table->foreignId('wallet_id')->constrained()->cascadeOnDelete(); - $table->decimal('amount', 15, 2); // مثبت برای واریز، منفی برای برداشت - $table->enum('type', ['deposit', 'withdraw', 'order_payment']); // نوع تراکنش - $table->string('description')->nullable(); - $table->string('transactionable_type'); - $table->unsignedBigInteger('transactionable_id'); - $table->index(['transactionable_type', 'transactionable_id'], 'wallet_txn_morph_index'); + $table->decimal('amount', 15, 2); + $table->decimal('balance_before', 15, 2); + $table->decimal('balance_after', 15, 2); + + $table->enum('type', ['deposit', 'withdrawal', 'order_payment', 'refund']) + ->comment('نوع تراکنش'); + + $table->enum('status', ['pending', 'completed', 'failed', 'refunded']) + ->default('pending') + ->comment('وضعیت تراکنش'); + + $table->enum('gateway', ['zarinpal', 'manual', 'system']) + ->nullable() + ->comment('درگاه پرداخت'); + + $table->string('gateway_reference_id')->nullable() + ->comment('کد پیگیری درگاه پرداخت'); + + $table->string('description', 1000)->nullable() + ->comment('توضیحات تراکنش'); + + $table->text('admin_notes')->nullable() + ->comment('یادداشت‌های داخلی ادمین'); + + $table->foreignId('created_by')->nullable() + ->constrained('users') + ->comment('کاربری که تراکنش را ایجاد کرده'); + + $table->foreignId('approved_by')->nullable() + ->constrained('users') + ->comment('ادمینی که تراکنش را تأیید کرده'); + + $table->timestamp('approved_at')->nullable() + ->comment('زمان تأیید تراکنش'); + + // ✅ راه‌حل: استفاده از نام سفارشی برای morphs index + $table->nullableMorphs('transactionable', 'wallet_txn_morph_idx'); + + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent')->nullable(); + $table->json('metadata')->nullable() + ->comment('داده‌های اضافی به صورت JSON'); + $table->timestamps(); + $table->softDeletes(); + + // ✅ Indexes با نام‌های سفارشی کوتاه + $table->index(['wallet_id', 'status'], 'wallet_txn_wallet_status_idx'); + $table->index(['wallet_id', 'type'], 'wallet_txn_wallet_type_idx'); + $table->index('gateway_reference_id', 'wallet_txn_gateway_ref_idx'); + $table->index('created_by', 'wallet_txn_created_by_idx'); }); - } - /** - * Reverse the migrations. - */ public function down(): void { Schema::dropIfExists('wallet_transactions'); } -}; +}; \ No newline at end of file diff --git a/04_Laravel/database/migrations/2026_08_07_000001_create_wallet_activity_logs_table.php b/04_Laravel/database/migrations/2026_08_07_000001_create_wallet_activity_logs_table.php new file mode 100644 index 0000000..2d33440 --- /dev/null +++ b/04_Laravel/database/migrations/2026_08_07_000001_create_wallet_activity_logs_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('wallet_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained(); // کاربری که اکشن را انجام داده + $table->enum('action', [ + 'balance_viewed', // مشاهده موجودی + 'deposit_requested', // درخواست شارژ + 'deposit_completed', // تکمیل شارژ + 'deposit_failed', // شکست شارژ + 'withdrawal_requested', // درخواست برداشت + 'withdrawal_completed', // تکمیل برداشت + 'withdrawal_failed', // شکست برداشت + 'manual_adjustment', // تنظیم دستی توسط ادمین + 'wallet_frozen', // مسدود شدن کیف پول + 'wallet_unfrozen', // رفع مسدودی + 'transaction_refunded', // بازگشت تراکنش + 'export_requested', // درخواست خروجی + ]); + $table->text('description')->nullable(); + $table->json('old_values')->nullable(); // مقادیر قبل از تغییر + $table->json('new_values')->nullable(); // مقادیر بعد از تغییر + $table->string('ip_address', 45)->nullable(); + $table->string('user_agent')->nullable(); + $table->timestamps(); + + $table->index(['wallet_id', 'action']); + $table->index('user_id'); + $table->index('created_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('wallet_activity_logs'); + } +}; \ No newline at end of file diff --git a/04_Laravel/database/migrations/2026_08_07_033748_create_personal_access_tokens_table.php b/04_Laravel/database/migrations/2026_08_07_033748_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/04_Laravel/database/migrations/2026_08_07_033748_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/04_Laravel/echo b/04_Laravel/echo new file mode 100644 index 0000000..e69de29 diff --git a/04_Laravel/postman/collections/New Collection/.resources/definition.yaml b/04_Laravel/postman/collections/New Collection/.resources/definition.yaml new file mode 100644 index 0000000..8697d90 --- /dev/null +++ b/04_Laravel/postman/collections/New Collection/.resources/definition.yaml @@ -0,0 +1,2 @@ +$kind: collection +name: New Collection diff --git a/04_Laravel/postman/environments/IFNEX Local.environment.yaml b/04_Laravel/postman/environments/IFNEX Local.environment.yaml new file mode 100644 index 0000000..c68636e --- /dev/null +++ b/04_Laravel/postman/environments/IFNEX Local.environment.yaml @@ -0,0 +1,8 @@ +name: IFNEX Local +values: + - key: base_url + value: '' + - key: token + value: '' + - key: '' + value: '' diff --git a/04_Laravel/postman/environments/IFNEX Local2.environment.yaml b/04_Laravel/postman/environments/IFNEX Local2.environment.yaml new file mode 100644 index 0000000..62b1e7e --- /dev/null +++ b/04_Laravel/postman/environments/IFNEX Local2.environment.yaml @@ -0,0 +1,6 @@ +name: IFNEX Local2 +values: + - key: base_url + value: '' + - key: token + value: '' diff --git a/04_Laravel/postman/globals/workspace.globals.yaml b/04_Laravel/postman/globals/workspace.globals.yaml new file mode 100644 index 0000000..e96c6d6 --- /dev/null +++ b/04_Laravel/postman/globals/workspace.globals.yaml @@ -0,0 +1,2 @@ +name: Globals +values: [] diff --git a/04_Laravel/routes/api.php b/04_Laravel/routes/api.php index f0fd2e3..6d56eca 100644 --- a/04_Laravel/routes/api.php +++ b/04_Laravel/routes/api.php @@ -1,24 +1,41 @@ prefix('v1')->group(function () { Route::get('/track/{awb_no}', [TrackController::class, 'show']); - - Route::get('/wallet/balance', [WalletController::class, 'balance']); - Route::post('/wallet/recharge', [WalletController::class, 'recharge']); - Route::get('/wallet/transactions', [WalletController::class, 'transactions']); - Route::post('/wallet/admin-recharge', [WalletController::class, 'adminRecharge']); }); -Route::post('/v1/calculate', [PricingController::class, 'calculate']); +// APIهای احراز هویت شده (نیاز به Sanctum یا Bearer Token) +Route::middleware(['auth:sanctum'])->prefix('v1')->group(function () { + + // Wallet APIها (کاربر عادی) + Route::get('/wallet/balance', [WalletController::class, 'balance']); + Route::get('/wallet/transactions', [WalletController::class, 'transactions']); + + // Payment APIها + Route::post('/payment/redirect', [PaymentController::class, 'redirectToGateway']); + Route::get('/payment/check/{transaction}', [PaymentController::class, 'checkStatus']); + + // Admin Wallet APIها + Route::post('/wallet/admin-adjust', [WalletController::class, 'adminAdjust']); + Route::post('/wallet/{wallet}/freeze', [WalletController::class, 'freeze']); + Route::post('/wallet/{wallet}/unfreeze', [WalletController::class, 'unfreeze']); + Route::get('/wallet/{wallet}/activity-log', [WalletController::class, 'activityLog']); +}); +// Callback از درگاه (بدون auth) +Route::any('/v1/payment/callback', [PaymentController::class, 'callback']) + ->name('payment.callback'); + +// APIهای عمومی (بدون auth) +Route::post('/v1/calculate', [PricingController::class, 'calculate']); Route::get('/v1/discount-codes', [DiscountCodeController::class, 'index']); -Route::post('/v1/discount-codes/validate', [DiscountCodeController::class, 'validate']); -Route::post('/calculate', [PricingController::class, 'calculate']); +Route::post('/v1/discount-codes/validate', [DiscountCodeController::class, 'validate']); \ No newline at end of file