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:
parent
285acdae89
commit
78f5919b69
@ -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
|
||||
9
04_Laravel/.postman/resources.yaml
Normal file
9
04_Laravel/.postman/resources.yaml
Normal 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/
|
||||
57
04_Laravel/app/Console/Commands/GenerateApiToken.php
Normal file
57
04_Laravel/app/Console/Commands/GenerateApiToken.php
Normal 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;
|
||||
}
|
||||
}
|
||||
19
04_Laravel/app/Enums/PaymentGateway.php
Normal file
19
04_Laravel/app/Enums/PaymentGateway.php
Normal 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 => 'سیستمی',
|
||||
};
|
||||
}
|
||||
}
|
||||
31
04_Laravel/app/Enums/TransactionStatus.php
Normal file
31
04_Laravel/app/Enums/TransactionStatus.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
31
04_Laravel/app/Enums/TransactionType.php
Normal file
31
04_Laravel/app/Enums/TransactionType.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
195
04_Laravel/app/Filament/Resources/WalletResource.php
Normal file
195
04_Laravel/app/Filament/Resources/WalletResource.php
Normal 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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
150
04_Laravel/app/Filament/Resources/WalletTransactionResource.php
Normal file
150
04_Laravel/app/Filament/Resources/WalletTransactionResource.php
Normal 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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
55
04_Laravel/app/Models/WalletActivityLog.php
Normal file
55
04_Laravel/app/Models/WalletActivityLog.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -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() ?? 'نامشخص';
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
128
04_Laravel/app/Services/ZarinpalService.php
Normal file
128
04_Laravel/app/Services/ZarinpalService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
@ -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"
|
||||
|
||||
65
04_Laravel/composer.lock
generated
65
04_Laravel/composer.lock
generated
@ -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",
|
||||
|
||||
@ -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),
|
||||
],
|
||||
|
||||
];
|
||||
87
04_Laravel/config/sanctum.php
Normal file
87
04_Laravel/config/sanctum.php
Normal 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,
|
||||
],
|
||||
|
||||
];
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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
0
04_Laravel/echo
Normal file
@ -0,0 +1,2 @@
|
||||
$kind: collection
|
||||
name: New Collection
|
||||
@ -0,0 +1,8 @@
|
||||
name: IFNEX Local
|
||||
values:
|
||||
- key: base_url
|
||||
value: ''
|
||||
- key: token
|
||||
value: ''
|
||||
- key: ''
|
||||
value: ''
|
||||
@ -0,0 +1,6 @@
|
||||
name: IFNEX Local2
|
||||
values:
|
||||
- key: base_url
|
||||
value: ''
|
||||
- key: token
|
||||
value: ''
|
||||
2
04_Laravel/postman/globals/workspace.globals.yaml
Normal file
2
04_Laravel/postman/globals/workspace.globals.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
name: Globals
|
||||
values: []
|
||||
@ -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']);
|
||||
Loading…
Reference in New Issue
Block a user