feat(wallet): complete wallet system with admin adjust, freeze/unfreeze, activity logs

- Add Enums: TransactionType, TransactionStatus, PaymentGateway
- Migrations: wallets, wallet_transactions, wallet_activity_logs
- Models: Wallet, WalletTransaction, WalletActivityLog
- Services: WalletService with complete business logic
- Controllers: WalletController, PaymentController
- Filament Resources: WalletResource, WalletTransactionResource
- Artisan Command: ifnex:token for API token generation
- Sanctum integration with statefulApi and exception handling
- Full API endpoints: balance, transactions, admin-adjust, freeze/unfreeze
This commit is contained in:
Kazem Alghasi 2026-08-07 22:51:42 +03:30
parent 285acdae89
commit 78f5919b69
37 changed files with 2174 additions and 225 deletions

View File

@ -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

View File

@ -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/

View File

@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
class GenerateApiToken extends Command
{
protected $signature = 'ifnex:token
{--email=admin@ifnex.local : ایمیل کاربر}
{--name=test-token : نام توکن}';
protected $description = 'ساخت توکن API برای تست';
public function handle()
{
$email = $this->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;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Enums;
enum PaymentGateway: string
{
case ZARINPAL = 'zarinpal'; // درگاه زرین‌پال
case MANUAL = 'manual'; // شارژ/کسر دستی توسط ادمین
case SYSTEM = 'system'; // تراکنش‌های سیستمی (خودکار)
public function label(): string
{
return match($this) {
self::ZARINPAL => 'زرین‌پال',
self::MANUAL => 'دستی (ادمین)',
self::SYSTEM => 'سیستمی',
};
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Enums;
enum TransactionStatus: string
{
case PENDING = 'pending'; // در انتظار تأیید درگاه
case COMPLETED = 'completed'; // موفق
case FAILED = 'failed'; // ناموفق
case REFUNDED = 'refunded'; // بازگشت داده شده
public function label(): string
{
return match($this) {
self::PENDING => 'در انتظار',
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',
};
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Enums;
enum TransactionType: string
{
case DEPOSIT = 'deposit'; // واریز / شارژ
case WITHDRAWAL = 'withdrawal'; // برداشت / کسر
case ORDER_PAYMENT = 'order_payment'; // پرداخت سفارش
case REFUND = 'refund'; // بازگشت وجه
public function label(): string
{
return match($this) {
self::DEPOSIT => 'واریز',
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',
};
}
}

View File

@ -0,0 +1,195 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\WalletResource\Pages;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Actions\Action;
use Filament\Notifications\Notification;
class WalletResource extends Resource
{
protected static ?string $model = Wallet::class;
protected static ?string $navigationIcon = 'heroicon-o-wallet';
protected static ?string $navigationLabel = 'کیف پول کاربران';
protected static ?string $navigationGroup = 'مالی';
protected static ?int $navigationSort = 2;
public static function form(Form $form): Form
{
return $form->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}'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\WalletResource\Pages;
use App\Filament\Resources\WalletResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWallet extends CreateRecord
{
protected static string $resource = WalletResource::class;
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WalletResource\Pages;
use App\Filament\Resources\WalletResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditWallet extends EditRecord
{
protected static string $resource = WalletResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WalletResource\Pages;
use App\Filament\Resources\WalletResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListWallets extends ListRecords
{
protected static string $resource = WalletResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WalletResource\Pages;
use App\Filament\Resources\WalletResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewWallet extends ViewRecord
{
protected static string $resource = WalletResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
];
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\WalletTransactionResource\Pages;
use App\Models\WalletTransaction;
use App\Enums\TransactionType;
use App\Enums\TransactionStatus;
use App\Enums\PaymentGateway;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class WalletTransactionResource extends Resource
{
protected static ?string $model = WalletTransaction::class;
protected static ?string $navigationIcon = 'heroicon-o-arrows-right-left';
protected static ?string $navigationLabel = 'تراکنش‌ها';
protected static ?string $navigationGroup = 'مالی';
protected static ?int $navigationSort = 3;
public static function form(Form $form): Form
{
return $form->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}'),
];
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\WalletTransactionResource\Pages;
use App\Filament\Resources\WalletTransactionResource;
use Filament\Resources\Pages\ListRecords;
class ListWalletTransactions extends ListRecords
{
protected static string $resource = WalletTransactionResource::class;
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Filament\Resources\WalletTransactionResource\Pages;
use App\Filament\Resources\WalletTransactionResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewWalletTransaction extends ViewRecord
{
protected static string $resource = WalletTransactionResource::class;
}

View File

@ -2,102 +2,212 @@
namespace App\Http\Controllers\Api;
use App\Enums\PaymentGateway;
use App\Enums\TransactionStatus;
use App\Enums\TransactionType;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\Wallet;
use App\Services\PaymentGatewayService;
use App\Models\WalletActivityLog;
use App\Models\WalletTransaction;
use App\Services\WalletService;
use App\Services\ZarinpalService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Log;
class PaymentController
class PaymentController extends Controller
{
public function __construct(protected PaymentGatewayService $gateway) {}
public function __construct(
protected WalletService $walletService,
protected ZarinpalService $zarinpalService
) {}
public function request(Request $request): JsonResponse
/**
* ارسال کاربر به درگاه پرداخت
*/
public function redirectToGateway(Request $request): JsonResponse
{
$user = $request->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);
}
}

View File

@ -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(),
]);
}
}

View File

@ -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<UserFactory> */
use HasFactory, Notifiable;
use HasApiTokens, HasFactory, Notifiable; // ← HasApiTokens اضافه شد
protected $fillable = [
'name',

View File

@ -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();
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class WalletActivityLog extends Model
{
protected $fillable = [
'wallet_id',
'user_id',
'action',
'description',
'old_values',
'new_values',
'ip_address',
'user_agent',
];
protected $casts = [
'old_values' => '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(),
]);
}
}

View File

@ -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() ?? 'نامشخص';
}
}

View File

@ -1,53 +1,326 @@
<?php
namespace App\Services;
use App\Enums\PaymentGateway;
use App\Enums\TransactionStatus;
use App\Enums\TransactionType;
use App\Models\User;
use App\Models\Wallet;
use App\Models\WalletActivityLog;
use App\Models\WalletTransaction;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class WalletService
{
/**
* واریز وجه به کیف پول
* شارژ کیف پول توسط کاربر (آنلاین از طریق درگاه)
*/
public function deposit(Wallet $wallet, float $amount, string $description = null, $transactionable = null): WalletTransaction
{
return DB::transaction(function () use ($wallet, $amount, $description, $transactionable) {
$wallet->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();
}
}

View File

@ -0,0 +1,128 @@
<?php
namespace App\Services;
use App\Models\WalletTransaction;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ZarinpalService
{
private string $merchantId;
private string $baseUrl;
private string $callbackUrl;
public function __construct()
{
$this->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;
}
}

View File

@ -1,8 +1,10 @@
<?php
use Illuminate\Auth\AuthenticationException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->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();

View File

@ -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"

View File

@ -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",

View File

@ -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),
],
];

View File

@ -0,0 +1,87 @@
<?php
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken;
use Laravel\Sanctum\Http\Middleware\AuthenticateSession;
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => 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,
],
];

View File

@ -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');
}
};
};

View File

@ -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');
}
};
};

View File

@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('wallet_activity_logs', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->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');
}
};

0
04_Laravel/echo Normal file
View File

View File

@ -0,0 +1,2 @@
$kind: collection
name: New Collection

View File

@ -0,0 +1,8 @@
name: IFNEX Local
values:
- key: base_url
value: ''
- key: token
value: ''
- key: ''
value: ''

View File

@ -0,0 +1,6 @@
name: IFNEX Local2
values:
- key: base_url
value: ''
- key: token
value: ''

View File

@ -0,0 +1,2 @@
name: Globals
values: []

View File

@ -1,24 +1,41 @@
<?php
use App\Http\Controllers\Api\DiscountCodeController;
use App\Http\Controllers\Api\PaymentController;
use App\Http\Controllers\Api\PricingController;
use App\Http\Controllers\Api\TrackController;
use App\Http\Controllers\Api\WalletController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Middleware\ApiKeyMiddleware;
// APIهای عمومی با API Key
Route::middleware([ApiKeyMiddleware::class])->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']);