diff --git a/01_Documents/STATUS.md b/01_Documents/STATUS.md
index d822922..c60a26e 100644
--- a/01_Documents/STATUS.md
+++ b/01_Documents/STATUS.md
@@ -255,7 +255,12 @@
- [x] حذف migrationهای قدیمی تکراری (`2023_10_27_*`, `2026_07_31_*`)
- [x] اتصال درگاه پرداخت (زرینپال)
- [x] شارژ آنلاین کامل با درگاه پرداخت
-- [ ] پنل مالی در Filament (داشبورد سود/زیان)
+- [x] پنل مالی در Filament (داشبورد سود/زیان)
+- [x] تکمیل DiscountCodeResource با form/table/filters
+- [x] ساخت PaymentResource در Filament
+- [x] تستهای Unit/Feature برای Phase 2 (24 تست، 77 assertion)
+- [x] رفع باگ WalletController::balance()
+- [x] حذف PaymentGatewayService قدیمی (استفاده نمیشد)
---
@@ -516,6 +521,15 @@ IFNEX-Logistics/
- تست کامل flow پرداخت آنلاین (redirect → verify → complete)
- رفع باگ double-click در PaymentController
- ساخت صفحه Payment Result با UI موفق/ناموفق
+- رفع باگ WalletController::balance() — کیف پول خودکار ایجاد میشود
+- حذف PaymentGatewayService قدیمی (استفاده نمیشد)
+- ساخت پنل مالی Filament با ۳ Widget: FinanceOverviewWidget، TransactionChartWidget، RecentTransactionsWidget
+- تکمیل DiscountCodeResource با form/table/filters کامل
+- ساخت PaymentResource در Filament برای مشاهده تراکنشهای درگاه
+- نوشتن ۲۱ تست جدید Phase 2: WalletServiceTest (8)، PaymentControllerTest (5)، DiscountCodeControllerTest (6)
+- رفع باگ CHECK constraint در migration discount_codes (enum type: percent → percentage)
+- رفع باگ Wallet::isFrozen() — مدیریت null برای مقدار پیشفرض
+- بهروزرسانی STATUS.md با کارهای انجامشده Phase 2
diff --git a/04_Laravel/.phpunit.result.cache b/04_Laravel/.phpunit.result.cache
index 51dbb82..aa6a7d5 100644
--- a/04_Laravel/.phpunit.result.cache
+++ b/04_Laravel/.phpunit.result.cache
@@ -1 +1 @@
-{"version":2,"defects":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":5,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":7,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":8},"times":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":0.107,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":0.052,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":0.069}}
\ No newline at end of file
+{"version":2,"defects":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":5,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":7,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":8,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":8,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":7,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":8,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":8,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":7,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":8,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":7,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":8},"times":{"Tests\\Feature\\Api\\PricingControllerTest::it_can_calculate_pricing_with_valid_data":0.055,"Tests\\Feature\\Api\\PricingControllerTest::it_returns_validation_errors_for_invalid_data":0.052,"Tests\\Feature\\Services\\PriceCalculatorServiceTest::it_calculates_price_correctly_for_standard_package":0.009,"Tests\\Unit\\ExampleTest::test_that_true_is_true":0.006,"Tests\\Feature\\ExampleTest::test_the_application_returns_a_successful_response":0.056,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_returns_active_discount_codes":0.09,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_validates_discount_code_successfully":0.059,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_invalid_discount_code":0.052,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_expired_discount_code":0.055,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_rejects_code_below_minimum_order_amount":0.048,"Tests\\Feature\\Api\\DiscountCodeControllerTest::it_calculates_fixed_discount_correctly":0.048,"Tests\\Feature\\Api\\PaymentControllerTest::it_redirects_to_gateway_for_payment":0.067,"Tests\\Feature\\Api\\PaymentControllerTest::it_validates_minimum_deposit_amount":0.054,"Tests\\Feature\\Api\\PaymentControllerTest::it_prevents_payment_for_frozen_wallet":0.05,"Tests\\Feature\\Api\\PaymentControllerTest::it_completes_mock_payment_successfully":0.079,"Tests\\Feature\\Api\\PaymentControllerTest::it_checks_transaction_status":0.051,"Tests\\Feature\\Services\\WalletServiceTest::it_creates_deposit_transaction":0.005,"Tests\\Feature\\Services\\WalletServiceTest::it_completes_deposit_and_updates_balance":0.008,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_deposit":0.004,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_manual_withdrawal":0.004,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_withdrawal_when_balance_is_insufficient":0.002,"Tests\\Feature\\Services\\WalletServiceTest::it_processes_order_payment":0.004,"Tests\\Feature\\Services\\WalletServiceTest::it_prevents_order_payment_when_balance_is_insufficient":0.001,"Tests\\Feature\\Services\\WalletServiceTest::it_fails_transaction":0.006}}
\ No newline at end of file
diff --git a/04_Laravel/app/Filament/Pages/Dashboard.php b/04_Laravel/app/Filament/Pages/Dashboard.php
new file mode 100644
index 0000000..1e2470c
--- /dev/null
+++ b/04_Laravel/app/Filament/Pages/Dashboard.php
@@ -0,0 +1,32 @@
+schema([
- //
+ Forms\Components\Section::make('اطلاعات کد تخفیف')
+ ->schema([
+ Forms\Components\TextInput::make('code')
+ ->required()
+ ->unique(ignoreRecord: true)
+ ->maxLength(50)
+ ->label('کد تخفیف')
+ ->columnSpanFull(),
+
+ Forms\Components\Select::make('type')
+ ->options([
+ 'percentage' => 'درصدی',
+ 'fixed' => 'مبلغ ثابت',
+ ])
+ ->required()
+ ->label('نوع تخفیف')
+ ->reactive()
+ ->columnSpan(1),
+
+ Forms\Components\TextInput::make('value')
+ ->required()
+ ->numeric()
+ ->label(fn ($get) => $get('type') === 'percentage' ? 'درصد تخفیف' : 'مبلغ تخفیف (ریال)')
+ ->suffix(fn ($get) => $get('type') === 'percentage' ? '%' : 'ریال')
+ ->maxLength(13)
+ ->columnSpan(1),
+
+ Forms\Components\Toggle::make('is_active')
+ ->label('فعال')
+ ->default(true)
+ ->columnSpanFull(),
+
+ Forms\Components\TextInput::make('min_order_amount')
+ ->numeric()
+ ->label('حداقل مبلغ سفارش (ریال)')
+ ->suffix('ریال')
+ ->default(0)
+ ->columnSpan(1),
+
+ Forms\Components\TextInput::make('usage_limit')
+ ->numeric()
+ ->label('محدودیت استفاده')
+ ->suffix('بار')
+ ->nullable()
+ ->columnSpan(1),
+
+ Forms\Components\TextInput::make('used_count')
+ ->numeric()
+ ->label('تعداد استفاده شده')
+ ->default(0)
+ ->disabled()
+ ->columnSpan(1),
+
+ Forms\Components\DateTimePicker::make('expires_at')
+ ->label('تاریخ انقضا')
+ ->nullable()
+ ->displayFormat('Y/m/d H:i')
+ ->columnSpanFull(),
+ ])
+ ->columns(2),
]);
}
@@ -31,13 +91,80 @@ class DiscountCodeResource extends Resource
{
return $table
->columns([
- //
+ Tables\Columns\TextColumn::make('code')
+ ->label('کد')
+ ->searchable()
+ ->sortable()
+ ->copyable()
+ ->copyMessage('کد کپی شد!')
+ ->fontFamily('mono'),
+
+ Tables\Columns\BadgeColumn::make('type')
+ ->label('نوع')
+ ->getStateUsing(fn ($record) => $record->type === 'percentage' ? 'درصدی' : 'ثابت')
+ ->colors([
+ 'success' => 'percentage',
+ 'info' => 'fixed',
+ ]),
+
+ Tables\Columns\TextColumn::make('value')
+ ->label('مقدار')
+ ->getStateUsing(fn ($record) => $record->type === 'percentage' ? $record->value . '%' : number_format($record->value) . ' ریال')
+ ->sortable(),
+
+ Tables\Columns\TextColumn::make('min_order_amount')
+ ->label('حداقل سفارش')
+ ->getStateUsing(fn ($record) => $record->min_order_amount > 0 ? number_format($record->min_order_amount) . ' ریال' : 'بدون محدودیت')
+ ->sortable(),
+
+ Tables\Columns\TextColumn::make('usage_limit')
+ ->label('محدودیت')
+ ->getStateUsing(fn ($record) => $record->usage_limit ? $record->usage_limit . ' بار' : 'نامحدود')
+ ->sortable(),
+
+ Tables\Columns\TextColumn::make('used_count')
+ ->label('استفاده شده')
+ ->getStateUsing(fn ($record) => $record->usage_limit ? "{$record->used_count} / {$record->usage_limit}" : "{$record->used_count} / ∞")
+ ->sortable(),
+
+ Tables\Columns\IconColumn::make('is_active')
+ ->label('فعال')
+ ->boolean()
+ ->trueIcon('heroicon-o-check-circle')
+ ->falseIcon('heroicon-o-x-circle')
+ ->trueColor('success')
+ ->falseColor('danger'),
+
+ Tables\Columns\TextColumn::make('expires_at')
+ ->label('انقضا')
+ ->dateTime('Y/m/d H:i', 'Asia/Tehran')
+ ->sortable()
+ ->color(fn ($record) => $record->expires_at?->isPast() ? 'danger' : null),
+
+ Tables\Columns\TextColumn::make('created_at')
+ ->label('تاریخ ایجاد')
+ ->dateTime('Y/m/d H:i', 'Asia/Tehran')
+ ->sortable()
+ ->toggleable(isToggledHiddenByDefault: true),
])
->filters([
- //
+ Tables\Filters\TernaryFilter::make('is_active')
+ ->label('وضعیت فعال'),
+
+ Tables\Filters\SelectFilter::make('type')
+ ->label('نوع تخفیف')
+ ->options([
+ 'percentage' => 'درصدی',
+ 'fixed' => 'مبلغ ثابت',
+ ]),
+
+ Tables\Filters\Filter::make('expired')
+ ->label('منقضی شده')
+ ->query(fn (Builder $query): Builder => $query->where('expires_at', '<', now())),
])
->actions([
Tables\Actions\EditAction::make(),
+ Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
diff --git a/04_Laravel/app/Filament/Resources/PaymentResource.php b/04_Laravel/app/Filament/Resources/PaymentResource.php
new file mode 100644
index 0000000..a8b0cc0
--- /dev/null
+++ b/04_Laravel/app/Filament/Resources/PaymentResource.php
@@ -0,0 +1,194 @@
+schema([
+ Forms\Components\Section::make('اطلاعات پرداخت')
+ ->schema([
+ Forms\Components\Select::make('user_id')
+ ->relationship('user', 'name')
+ ->searchable()
+ ->preload()
+ ->required()
+ ->label('کاربر'),
+
+ Forms\Components\Select::make('wallet_id')
+ ->relationship('wallet', 'id')
+ ->searchable()
+ ->preload()
+ ->nullable()
+ ->label('کیف پول'),
+
+ Forms\Components\TextInput::make('gateway')
+ ->label('درگاه پرداخت')
+ ->disabled(),
+
+ Forms\Components\TextInput::make('merchant_id')
+ ->label('Merchant ID')
+ ->disabled(),
+
+ Forms\Components\TextInput::make('authority')
+ ->label('Authority')
+ ->disabled(),
+
+ Forms\Components\TextInput::make('ref_id')
+ ->label('کد پیگیری (Ref ID)')
+ ->disabled(),
+
+ Forms\Components\TextInput::make('amount')
+ ->numeric()
+ ->suffix('ریال')
+ ->required()
+ ->label('مبلغ'),
+
+ Forms\Components\TextInput::make('currency')
+ ->label('واحد پول')
+ ->disabled(),
+
+ Forms\Components\Select::make('status')
+ ->options([
+ 'pending' => 'در انتظار',
+ 'completed' => 'موفق',
+ 'failed' => 'ناموفق',
+ ])
+ ->label('وضعیت')
+ ->disabled(),
+
+ Forms\Components\Textarea::make('description')
+ ->label('توضیحات')
+ ->maxLength(1000)
+ ->columnSpanFull(),
+
+ Forms\Components\DateTimePicker::make('paid_at')
+ ->label('تاریخ پرداخت')
+ ->disabled()
+ ->displayFormat('Y/m/d H:i'),
+ ])
+ ->columns(2),
+ ]);
+ }
+
+ public static function table(Table $table): Table
+ {
+ return $table
+ ->columns([
+ Tables\Columns\TextColumn::make('id')
+ ->label('ID')
+ ->sortable()
+ ->width(60),
+
+ Tables\Columns\TextColumn::make('user.name')
+ ->label('کاربر')
+ ->searchable()
+ ->sortable(),
+
+ Tables\Columns\TextColumn::make('amount')
+ ->label('مبلغ')
+ ->money('IRR', divideBy: 1)
+ ->sortable(),
+
+ Tables\Columns\BadgeColumn::make('gateway')
+ ->label('درگاه')
+ ->getStateUsing(fn ($record) => $record->gateway ?? 'نامشخص')
+ ->colors([
+ 'primary' => 'zarinpal',
+ 'success' => 'manual',
+ 'gray' => 'system',
+ ]),
+
+ Tables\Columns\BadgeColumn::make('status')
+ ->label('وضعیت')
+ ->getStateUsing(fn ($record) => match ($record->status) {
+ 'completed' => 'موفق',
+ 'pending' => 'در انتظار',
+ 'failed' => 'ناموفق',
+ default => $record->status,
+ })
+ ->colors([
+ 'success' => 'completed',
+ 'warning' => 'pending',
+ 'danger' => 'failed',
+ ]),
+
+ Tables\Columns\TextColumn::make('ref_id')
+ ->label('کد پیگیری')
+ ->searchable()
+ ->toggleable(),
+
+ Tables\Columns\TextColumn::make('paid_at')
+ ->label('تاریخ پرداخت')
+ ->dateTime('Y/m/d H:i', 'Asia/Tehran')
+ ->sortable()
+ ->toggleable(),
+
+ Tables\Columns\TextColumn::make('created_at')
+ ->label('ایجاد')
+ ->dateTime('Y/m/d H:i', 'Asia/Tehran')
+ ->sortable()
+ ->toggleable(isToggledHiddenByDefault: true),
+ ])
+ ->defaultSort('created_at', 'desc')
+ ->filters([
+ Tables\Filters\SelectFilter::make('status')
+ ->label('وضعیت')
+ ->options([
+ 'pending' => 'در انتظار',
+ 'completed' => 'موفق',
+ 'failed' => 'ناموفق',
+ ]),
+
+ Tables\Filters\SelectFilter::make('gateway')
+ ->label('درگاه')
+ ->options([
+ 'zarinpal' => 'زرینپال',
+ 'manual' => 'دستی',
+ 'system' => 'سیستمی',
+ ]),
+ ])
+ ->actions([
+ Tables\Actions\ViewAction::make(),
+ Tables\Actions\EditAction::make(),
+ ])
+ ->bulkActions([
+ Tables\Actions\BulkActionGroup::make([
+ Tables\Actions\DeleteBulkAction::make(),
+ ]),
+ ]);
+ }
+
+ public static function getRelations(): array
+ {
+ return [
+ //
+ ];
+ }
+
+ public static function getPages(): array
+ {
+ return [
+ 'index' => Pages\ListPayments::route('/'),
+ 'view' => Pages\ViewPayment::route('/{record}'),
+ ];
+ }
+}
diff --git a/04_Laravel/app/Filament/Resources/PaymentResource/Pages/ListPayments.php b/04_Laravel/app/Filament/Resources/PaymentResource/Pages/ListPayments.php
new file mode 100644
index 0000000..a16be6c
--- /dev/null
+++ b/04_Laravel/app/Filament/Resources/PaymentResource/Pages/ListPayments.php
@@ -0,0 +1,11 @@
+count();
+
+ return [
+ Stat::make('مجموع موجودی کیف پولها', number_format($totalBalance) . ' ریال')
+ ->description('موجودی کل کاربران')
+ ->descriptionIcon('heroicon-o-wallet')
+ ->color('success'),
+
+ Stat::make('مجموع واریزیها', number_format($totalDeposited) . ' ریال')
+ ->description('کل مبالغ شارژ شده')
+ ->descriptionIcon('heroicon-o-arrow-down')
+ ->color('info'),
+
+ Stat::make('مجموع برداشتها', number_format($totalWithdrawn) . ' ریال')
+ ->description('کل مبالغ برداشت شده')
+ ->descriptionIcon('heroicon-o-arrow-up')
+ ->color('warning'),
+
+ Stat::make('تراکنشهای در انتظار', number_format($pendingCount))
+ ->description('از کل ' . number_format($transactionCount) . ' تراکنش')
+ ->descriptionIcon('heroicon-o-clock')
+ ->color($pendingCount > 0 ? 'danger' : 'success'),
+ ];
+ }
+}
diff --git a/04_Laravel/app/Filament/Widgets/RecentTransactionsWidget.php b/04_Laravel/app/Filament/Widgets/RecentTransactionsWidget.php
new file mode 100644
index 0000000..296410c
--- /dev/null
+++ b/04_Laravel/app/Filament/Widgets/RecentTransactionsWidget.php
@@ -0,0 +1,69 @@
+query(
+ WalletTransaction::query()
+ ->latest()
+ ->limit(10)
+ )
+ ->columns([
+ Tables\Columns\TextColumn::make('id')
+ ->label('ID')
+ ->sortable()
+ ->width(60),
+ Tables\Columns\TextColumn::make('wallet.user.name')
+ ->label('کاربر')
+ ->searchable()
+ ->limit(20),
+ 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' => \App\Enums\TransactionType::DEPOSIT,
+ 'danger' => \App\Enums\TransactionType::WITHDRAWAL,
+ 'warning' => \App\Enums\TransactionType::ORDER_PAYMENT,
+ 'info' => \App\Enums\TransactionType::REFUND,
+ ]),
+ Tables\Columns\BadgeColumn::make('status')
+ ->label('وضعیت')
+ ->getStateUsing(fn ($record) => $record->status->label())
+ ->colors([
+ 'success' => \App\Enums\TransactionStatus::COMPLETED,
+ 'warning' => \App\Enums\TransactionStatus::PENDING,
+ 'danger' => \App\Enums\TransactionStatus::FAILED,
+ 'info' => \App\Enums\TransactionStatus::REFUNDED,
+ ]),
+ Tables\Columns\TextColumn::make('gateway_reference_id')
+ ->label('کد پیگیری')
+ ->searchable()
+ ->toggleable(),
+ Tables\Columns\TextColumn::make('created_at')
+ ->label('تاریخ')
+ ->dateTime('Y/m/d H:i', 'Asia/Tehran')
+ ->sortable()
+ ->since(),
+ ])
+ ->defaultSort('created_at', 'desc');
+ }
+}
diff --git a/04_Laravel/app/Filament/Widgets/TransactionChartWidget.php b/04_Laravel/app/Filament/Widgets/TransactionChartWidget.php
new file mode 100644
index 0000000..132b0c7
--- /dev/null
+++ b/04_Laravel/app/Filament/Widgets/TransactionChartWidget.php
@@ -0,0 +1,87 @@
+where('created_at', '>=', now()->subDays(30))
+ ->groupBy('date')
+ ->orderBy('date')
+ ->get();
+
+ $labels = $data->pluck('date')->map(fn ($date) => \Morilog\Jalali\Jalalian::fromCarbon(\Carbon\Carbon::parse($date))->format('Y/m/d'))->toArray();
+ $deposits = $data->pluck('deposits')->toArray();
+ $withdrawals = $data->pluck('withdrawals')->toArray();
+
+ return [
+ 'datasets' => [
+ [
+ 'label' => 'واریزیها',
+ 'data' => $deposits,
+ 'borderColor' => '#10b981',
+ 'backgroundColor' => 'rgba(16, 185, 129, 0.1)',
+ 'fill' => true,
+ 'tension' => 0.4,
+ ],
+ [
+ 'label' => 'برداشتها',
+ 'data' => $withdrawals,
+ 'borderColor' => '#f59e0b',
+ 'backgroundColor' => 'rgba(245, 158, 11, 0.1)',
+ 'fill' => true,
+ 'tension' => 0.4,
+ ],
+ ],
+ 'labels' => $labels,
+ ];
+ }
+
+ protected function getType(): string
+ {
+ return 'line';
+ }
+
+ protected function getOptions(): array
+ {
+ return [
+ 'plugins' => [
+ 'legend' => [
+ 'position' => 'top',
+ 'labels' => [
+ 'font' => [
+ 'family' => 'inherit',
+ ],
+ ],
+ ],
+ ],
+ 'scales' => [
+ 'y' => [
+ 'beginAtZero' => true,
+ 'ticks' => [
+ 'callback' => 'function(value) { return value.toLocaleString("fa-IR"); }',
+ ],
+ ],
+ 'x' => [
+ 'ticks' => [
+ 'maxRotation' => 0,
+ ],
+ ],
+ ],
+ ];
+ }
+}
diff --git a/04_Laravel/app/Http/Controllers/Api/WalletController.php b/04_Laravel/app/Http/Controllers/Api/WalletController.php
index f419689..ae0c98f 100644
--- a/04_Laravel/app/Http/Controllers/Api/WalletController.php
+++ b/04_Laravel/app/Http/Controllers/Api/WalletController.php
@@ -30,20 +30,29 @@ class WalletController extends Controller
$wallet = $user->wallet;
+ if (!$wallet) {
+ $wallet = Wallet::create([
+ 'user_id' => $user->id,
+ 'balance' => 0,
+ 'total_deposited' => 0,
+ 'total_withdrawn' => 0,
+ ]);
+ }
+
WalletActivityLog::log(
- $wallet ?? new Wallet(['user_id' => $user->id]),
+ $wallet,
'balance_viewed',
'مشاهده موجودی کیف پول',
null,
- ['balance' => $wallet?->balance ?? 0],
+ ['balance' => $wallet->balance],
$user
);
return response()->json([
- 'balance' => $wallet?->balance ?? 0,
- 'total_deposited' => $wallet?->total_deposited ?? 0,
- 'total_withdrawn' => $wallet?->total_withdrawn ?? 0,
- 'is_frozen' => $wallet?->isFrozen() ?? false,
+ 'balance' => $wallet->balance,
+ 'total_deposited' => $wallet->total_deposited,
+ 'total_withdrawn' => $wallet->total_withdrawn,
+ 'is_frozen' => $wallet->isFrozen(),
'currency' => 'IRR',
]);
}
diff --git a/04_Laravel/app/Models/Wallet.php b/04_Laravel/app/Models/Wallet.php
index e0806e0..e1de601 100644
--- a/04_Laravel/app/Models/Wallet.php
+++ b/04_Laravel/app/Models/Wallet.php
@@ -63,7 +63,7 @@ class Wallet extends Model
// Helper methods
public function isFrozen(): bool
{
- return $this->is_frozen;
+ return (bool) ($this->is_frozen ?? false);
}
public function hasSufficientBalance(float $amount): bool
diff --git a/04_Laravel/app/Providers/Filament/AdminPanelProvider.php b/04_Laravel/app/Providers/Filament/AdminPanelProvider.php
index 7167ac7..3686737 100644
--- a/04_Laravel/app/Providers/Filament/AdminPanelProvider.php
+++ b/04_Laravel/app/Providers/Filament/AdminPanelProvider.php
@@ -2,12 +2,13 @@
namespace App\Providers\Filament;
+use App\Filament\Pages\Auth\Login;
+use App\Filament\Pages\Dashboard;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
-use Filament\Widgets;
class AdminPanelProvider extends PanelProvider
{
@@ -18,27 +19,27 @@ class AdminPanelProvider extends PanelProvider
->id('admin')
->path('admin')
->login()
+ ->brandName('IFNEX Logistics')
+ ->brandLogo('
')
+ ->favicon(asset('images/favicon.svg'))
->colors([
'primary' => Color::Amber,
])
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
->pages([
- \Filament\Pages\Dashboard::class,
+ Dashboard::class,
])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([
\Filament\Widgets\AccountWidget::class,
\Filament\Widgets\FilamentInfoWidget::class,
])
-
->middleware([
'web',
])
->authMiddleware([
Authenticate::class,
]);
-
-
}
}
diff --git a/04_Laravel/app/Services/PaymentGatewayService.php b/04_Laravel/app/Services/PaymentGatewayService.php
deleted file mode 100644
index 94e4c68..0000000
--- a/04_Laravel/app/Services/PaymentGatewayService.php
+++ /dev/null
@@ -1,203 +0,0 @@
-mockPayment($user, $amount, $description);
- }
-
- $callbackUrl = config('ifnex.zarinpal_callback_url', url('/payment/callback'));
-
- $response = Http::post('https://api.zarinpal.com/pg/v4/payment/request', [
- 'merchant_id' => $merchantId,
- 'amount' => (int) ($amount * 10),
- 'callback_url' => $callbackUrl,
- 'description' => $description ?? 'شارچ کیف پول IFNEX',
- 'metadata' => [
- 'user_id' => $user->id,
- 'email' => $user->email,
- ],
- ]);
-
- $data = $response->json();
-
- if (($data['data']['code'] ?? 0) !== 100) {
- Log::error('ZarinPal payment request failed', ['response' => $data]);
-
- return [
- 'success' => false,
- 'message' => 'خطا در درخواست پرداخت.',
- 'code' => $data['data']['code'] ?? 0,
- ];
- }
-
- $authority = $data['data']['authority'];
-
- $this->payment->create([
- 'user_id' => $user->id,
- 'gateway' => 'zarinpal',
- 'merchant_id' => $merchantId,
- 'authority' => $authority,
- 'amount' => (int) ($amount * 10),
- 'currency' => 'IRR',
- 'status' => 'pending',
- 'description' => $description,
- ]);
-
- return [
- 'success' => true,
- 'authority' => $authority,
- 'payment_url' => "https://www.zarinpal.com/pg/StartPay/{$authority}",
- ];
- }
-
- public function verifyPayment(string $authority, string $refId = null): array
- {
- $merchantId = config('ifnex.zarinpal_merchant_id');
-
- $payment = $this->payment->where('authority', $authority)->first();
-
- if (!$payment) {
- return [
- 'success' => false,
- 'message' => 'تراکنش پیدا نشد.',
- ];
- }
-
- if ($payment->status !== 'pending') {
- return [
- 'success' => false,
- 'message' => 'این تراکنش قبلاً بررسی شده است.',
- ];
- }
-
- if (!$merchantId || $merchantId === 'change-me') {
- return $this->mockVerify($payment);
- }
-
- $response = Http::post("https://api.zarinpal.com/pg/v4/payment/verify/{$merchantId}", [
- 'merchant_id' => $merchantId,
- 'authority' => $authority,
- 'amount' => $payment->amount,
- ]);
-
- $data = $response->json();
-
- if (($data['data']['code'] ?? 0) !== 100) {
- $payment->update(['status' => 'failed']);
-
- return [
- 'success' => false,
- 'message' => 'تأیید پرداخت ناموفق بود.',
- 'code' => $data['data']['code'] ?? 0,
- ];
- }
-
- $refId = $data['data']['ref_id'] ?? null;
-
- $payment->update([
- 'status' => 'completed',
- 'ref_id' => $refId,
- 'paid_at' => now(),
- ]);
-
- if ($payment->wallet_id) {
- $wallet = Wallet::find($payment->wallet_id);
- if ($wallet) {
- $wallet->increment('balance', $payment->amount);
- }
- }
-
- return [
- 'success' => true,
- 'message' => 'پرداخت با موفقیت تأیید شد.',
- 'ref_id' => $refId,
- 'amount' => $payment->amount,
- ];
- }
-
- public function checkPayment(string $authority): array
- {
- $payment = $this->payment->where('authority', $authority)->first();
-
- if (!$payment) {
- return [
- 'success' => false,
- 'message' => 'تراکنش پیدا نشد.',
- ];
- }
-
- return [
- 'success' => true,
- 'status' => $payment->status,
- 'amount' => $payment->amount,
- 'ref_id' => $payment->ref_id,
- 'paid_at' => $payment->paid_at?->toIso8601String(),
- ];
- }
-
- private function mockPayment(User $user, float $amount, string $description = null): array
- {
- $this->payment->create([
- 'user_id' => $user->id,
- 'gateway' => 'zarinpal',
- 'amount' => (int) ($amount * 10),
- 'currency' => 'IRR',
- 'status' => 'completed',
- 'description' => $description,
- 'paid_at' => now(),
- ]);
-
- $user->wallet()->updateOrCreate(
- [],
- ['balance' => 0]
- );
-
- $wallet = $user->wallet()->first();
- $wallet->increment('balance', (int) ($amount * 10));
-
- return [
- 'success' => true,
- 'authority' => 'MOCK-' . strtoupper(uniqid()),
- 'payment_url' => null,
- 'mock' => true,
- ];
- }
-
- private function mockVerify(Payment $payment): array
- {
- $payment->update([
- 'status' => 'completed',
- 'ref_id' => 'MOCK-' . strtoupper(uniqid()),
- 'paid_at' => now(),
- ]);
-
- if ($payment->wallet_id) {
- $wallet = Wallet::find($payment->wallet_id);
- if ($wallet) {
- $wallet->increment('balance', $payment->amount);
- }
- }
-
- return [
- 'success' => true,
- 'message' => 'پرداخت موقت تأیید شد (sandbox).',
- 'ref_id' => $payment->ref_id,
- 'amount' => $payment->amount,
- ];
- }
-}
\ No newline at end of file
diff --git a/04_Laravel/database/migrations/2026_08_05_135026_create_discount_codes_table.php b/04_Laravel/database/migrations/2026_08_05_135026_create_discount_codes_table.php
index 48ada3c..e9c6362 100644
--- a/04_Laravel/database/migrations/2026_08_05_135026_create_discount_codes_table.php
+++ b/04_Laravel/database/migrations/2026_08_05_135026_create_discount_codes_table.php
@@ -14,7 +14,7 @@ return new class extends Migration
Schema::create('discount_codes', function (Blueprint $table) {
$table->id();
$table->string('code')->unique();
- $table->enum('type', ['fixed', 'percent']); // مبلغ ثابت یا درصد
+ $table->enum('type', ['fixed', 'percentage']); // مبلغ ثابت یا درصد
$table->decimal('value', 15, 2);
$table->decimal('min_order_amount', 15, 2)->default(0);
$table->integer('usage_limit')->nullable();
diff --git a/04_Laravel/public/images/favicon.svg b/04_Laravel/public/images/favicon.svg
new file mode 100644
index 0000000..2778e23
--- /dev/null
+++ b/04_Laravel/public/images/favicon.svg
@@ -0,0 +1,4 @@
+
diff --git a/04_Laravel/public/images/ifnex-logo.svg b/04_Laravel/public/images/ifnex-logo.svg
new file mode 100644
index 0000000..1358430
--- /dev/null
+++ b/04_Laravel/public/images/ifnex-logo.svg
@@ -0,0 +1,5 @@
+
diff --git a/04_Laravel/tests/Feature/Api/DiscountCodeControllerTest.php b/04_Laravel/tests/Feature/Api/DiscountCodeControllerTest.php
new file mode 100644
index 0000000..f57d8c0
--- /dev/null
+++ b/04_Laravel/tests/Feature/Api/DiscountCodeControllerTest.php
@@ -0,0 +1,157 @@
+ 'TEST10',
+ 'type' => 'percentage',
+ 'value' => 10,
+ 'min_order_amount' => 100000,
+ 'is_active' => true,
+ ]);
+
+ DiscountCode::create([
+ 'code' => 'INACTIVE',
+ 'type' => 'fixed',
+ 'value' => 50000,
+ 'is_active' => false,
+ ]);
+
+ $response = $this->get('/api/v1/discount-codes');
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'codes' => [
+ [
+ 'code' => 'TEST10',
+ 'type' => 'percentage',
+ 'value' => 10,
+ ],
+ ],
+ ])
+ ->assertJsonCount(1, 'codes');
+ }
+
+ #[Test]
+ public function it_validates_discount_code_successfully()
+ {
+ DiscountCode::create([
+ 'code' => 'PERCENT10',
+ 'type' => 'percentage',
+ 'value' => 10,
+ 'min_order_amount' => 100000,
+ 'is_active' => true,
+ ]);
+
+ $response = $this->post('/api/v1/discount-codes/validate', [
+ 'code' => 'PERCENT10',
+ 'total_price' => 500000,
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'valid' => true,
+ 'type' => 'percentage',
+ 'value' => 10,
+ 'discount_amount' => 50000,
+ 'final_price' => 450000,
+ ]);
+ }
+
+ #[Test]
+ public function it_rejects_invalid_discount_code()
+ {
+ $response = $this->post('/api/v1/discount-codes/validate', [
+ 'code' => 'INVALID',
+ 'total_price' => 500000,
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'valid' => false,
+ 'message' => 'کد تخفیف نامعتبر است.',
+ ]);
+ }
+
+ #[Test]
+ public function it_rejects_expired_discount_code()
+ {
+ DiscountCode::create([
+ 'code' => 'EXPIRED',
+ 'type' => 'fixed',
+ 'value' => 50000,
+ 'is_active' => true,
+ 'expires_at' => now()->subDay(),
+ ]);
+
+ $response = $this->post('/api/v1/discount-codes/validate', [
+ 'code' => 'EXPIRED',
+ 'total_price' => 500000,
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'valid' => false,
+ 'message' => 'این کد تخفیف منقضی شده است.',
+ ]);
+ }
+
+ #[Test]
+ public function it_rejects_code_below_minimum_order_amount()
+ {
+ DiscountCode::create([
+ 'code' => 'MIN500',
+ 'type' => 'fixed',
+ 'value' => 50000,
+ 'min_order_amount' => 500000,
+ 'is_active' => true,
+ ]);
+
+ $response = $this->post('/api/v1/discount-codes/validate', [
+ 'code' => 'MIN500',
+ 'total_price' => 100000,
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'valid' => false,
+ ])
+ ->assertJsonStructure(['message']);
+ }
+
+ #[Test]
+ public function it_calculates_fixed_discount_correctly()
+ {
+ DiscountCode::create([
+ 'code' => 'FIXED50',
+ 'type' => 'fixed',
+ 'value' => 50000,
+ 'min_order_amount' => 100000,
+ 'is_active' => true,
+ ]);
+
+ $response = $this->post('/api/v1/discount-codes/validate', [
+ 'code' => 'FIXED50',
+ 'total_price' => 500000,
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'valid' => true,
+ 'discount_amount' => 50000,
+ 'final_price' => 450000,
+ ]);
+ }
+}
diff --git a/04_Laravel/tests/Feature/Api/PaymentControllerTest.php b/04_Laravel/tests/Feature/Api/PaymentControllerTest.php
new file mode 100644
index 0000000..58aba79
--- /dev/null
+++ b/04_Laravel/tests/Feature/Api/PaymentControllerTest.php
@@ -0,0 +1,123 @@
+user = User::factory()->create();
+ $this->wallet = Wallet::create([
+ 'user_id' => $this->user->id,
+ 'balance' => 0,
+ ]);
+ Sanctum::actingAs($this->user);
+ }
+
+ #[Test]
+ public function it_redirects_to_gateway_for_payment()
+ {
+ $response = $this->postJson('/api/v1/payment/redirect', [
+ 'amount' => 100000,
+ 'description' => 'شارژ تستی',
+ ]);
+
+ $response->assertStatus(200)
+ ->assertJson([
+ 'success' => true,
+ 'is_mock' => true,
+ ])
+ ->assertJsonStructure([
+ 'success',
+ 'message',
+ 'payment_url',
+ 'transaction_id',
+ 'authority',
+ 'amount',
+ 'is_mock',
+ ]);
+ }
+
+ #[Test]
+ public function it_validates_minimum_deposit_amount()
+ {
+ $response = $this->postJson('/api/v1/payment/redirect', [
+ 'amount' => 1000,
+ 'description' => 'شارژ تستی',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['amount']);
+ }
+
+ #[Test]
+ public function it_prevents_payment_for_frozen_wallet()
+ {
+ $this->wallet->update(['is_frozen' => true]);
+
+ $response = $this->postJson('/api/v1/payment/redirect', [
+ 'amount' => 100000,
+ 'description' => 'شارژ تستی',
+ ]);
+
+ $response->assertStatus(403)
+ ->assertJson(['message' => 'کیف پول شما مسدود است.']);
+ }
+
+ #[Test]
+ public function it_completes_mock_payment_successfully()
+ {
+ $response = $this->postJson('/api/v1/payment/redirect', [
+ 'amount' => 100000,
+ 'description' => 'شارژ تستی',
+ ]);
+
+ $response->assertStatus(200);
+ $transactionId = $response->json('transaction_id');
+ $authority = $response->json('authority');
+
+ $transaction = WalletTransaction::findOrFail($transactionId);
+ $this->assertEquals(TransactionStatus::PENDING, $transaction->status);
+
+ $callbackResponse = $this->get("/api/v1/payment/callback?Authority={$authority}&Status=OK");
+ $callbackResponse->assertRedirect();
+
+ $transaction->refresh();
+ $this->assertEquals(TransactionStatus::COMPLETED, $transaction->status);
+ $this->wallet->refresh();
+ $this->assertEquals(100000, $this->wallet->balance);
+ }
+
+ #[Test]
+ public function it_checks_transaction_status()
+ {
+ $response = $this->postJson('/api/v1/payment/redirect', [
+ 'amount' => 100000,
+ 'description' => 'شارژ تستی',
+ ]);
+
+ $transactionId = $response->json('transaction_id');
+
+ $checkResponse = $this->get("/api/v1/payment/check/{$transactionId}");
+ $checkResponse->assertStatus(200)
+ ->assertJson([
+ 'transaction_id' => $transactionId,
+ 'status_code' => TransactionStatus::PENDING->value,
+ ]);
+ }
+}
diff --git a/04_Laravel/tests/Feature/Api/PricingControllerTest.php b/04_Laravel/tests/Feature/Api/PricingControllerTest.php
index 939769c..3fa6b3d 100644
--- a/04_Laravel/tests/Feature/Api/PricingControllerTest.php
+++ b/04_Laravel/tests/Feature/Api/PricingControllerTest.php
@@ -23,8 +23,8 @@ class PricingControllerTest extends TestCase
]);
});
- // ارسال درخواست معتبر (اصلاح آدرس به /api/calculate)
- $response = $this->postJson('/api/calculate', [
+ // ارسال درخواست معتبر
+ $response = $this->postJson('/api/v1/calculate', [
'direction' => 'Outbound',
'type' => 'DOC_NORMAL',
'country_iso' => 'US',
@@ -44,8 +44,8 @@ class PricingControllerTest extends TestCase
#[Test]
public function it_returns_validation_errors_for_invalid_data()
{
- // ارسال درخواست نامعتبر (اصلاح آدرس به /api/calculate)
- $response = $this->postJson('/api/calculate', [
+ // ارسال درخواست نامعتبر
+ $response = $this->postJson('/api/v1/calculate', [
'direction' => 'InvalidDirection',
'type' => 'PARCEL',
]);
diff --git a/04_Laravel/tests/Feature/Services/WalletServiceTest.php b/04_Laravel/tests/Feature/Services/WalletServiceTest.php
new file mode 100644
index 0000000..8cfd1ce
--- /dev/null
+++ b/04_Laravel/tests/Feature/Services/WalletServiceTest.php
@@ -0,0 +1,180 @@
+walletService = app(WalletService::class);
+ $this->user = User::factory()->create();
+ $this->wallet = Wallet::create([
+ 'user_id' => $this->user->id,
+ 'balance' => 0,
+ 'total_deposited' => 0,
+ 'total_withdrawn' => 0,
+ ]);
+ }
+
+ #[Test]
+ public function it_creates_deposit_transaction()
+ {
+ $transaction = $this->walletService->requestDeposit(
+ wallet: $this->wallet,
+ amount: 100000,
+ description: 'شارژ تستی',
+ user: $this->user
+ );
+
+ $this->assertInstanceOf(WalletTransaction::class, $transaction);
+ $this->assertEquals(100000, $transaction->amount);
+ $this->assertEquals(TransactionType::DEPOSIT, $transaction->type);
+ $this->assertEquals(TransactionStatus::PENDING, $transaction->status);
+ $this->assertEquals(0, $transaction->balance_after);
+ }
+
+ #[Test]
+ public function it_completes_deposit_and_updates_balance()
+ {
+ $transaction = $this->walletService->requestDeposit(
+ wallet: $this->wallet,
+ amount: 100000,
+ description: 'شارژ تستی',
+ user: $this->user
+ );
+
+ $completed = $this->walletService->completeDeposit(
+ transaction: $transaction,
+ gatewayReferenceId: 'REF123'
+ );
+
+ $this->wallet->refresh();
+ $this->assertEquals(100000, $this->wallet->balance);
+ $this->assertEquals(100000, $this->wallet->total_deposited);
+ $this->assertEquals(TransactionStatus::COMPLETED, $completed->status);
+ $this->assertEquals('REF123', $completed->gateway_reference_id);
+ }
+
+ #[Test]
+ public function it_processes_manual_deposit()
+ {
+ $admin = User::factory()->create();
+ $transaction = $this->walletService->manualDeposit(
+ wallet: $this->wallet,
+ amount: 500000,
+ description: 'شارژ دستی',
+ admin: $admin
+ );
+
+ $this->wallet->refresh();
+ $this->assertEquals(500000, $this->wallet->balance);
+ $this->assertEquals(500000, $this->wallet->total_deposited);
+ $this->assertEquals(TransactionStatus::COMPLETED, $transaction->status);
+ $this->assertEquals(PaymentGateway::MANUAL, $transaction->gateway);
+ }
+
+ #[Test]
+ public function it_processes_manual_withdrawal()
+ {
+ $this->wallet->update(['balance' => 1000000]);
+ $admin = User::factory()->create();
+
+ $transaction = $this->walletService->manualWithdrawal(
+ wallet: $this->wallet,
+ amount: 300000,
+ description: 'کسر دستی',
+ admin: $admin
+ );
+
+ $this->wallet->refresh();
+ $this->assertEquals(700000, $this->wallet->balance);
+ $this->assertEquals(300000, $this->wallet->total_withdrawn);
+ $this->assertEquals(-300000, $transaction->amount);
+ $this->assertEquals(TransactionStatus::COMPLETED, $transaction->status);
+ }
+
+ #[Test]
+ public function it_prevents_withdrawal_when_balance_is_insufficient()
+ {
+ $this->wallet->update(['balance' => 100000]);
+ $admin = User::factory()->create();
+
+ $this->expectException(\Exception::class);
+ $this->walletService->manualWithdrawal(
+ wallet: $this->wallet,
+ amount: 200000,
+ description: 'کسر دستی',
+ admin: $admin
+ );
+ }
+
+ #[Test]
+ public function it_processes_order_payment()
+ {
+ $this->wallet->update(['balance' => 500000]);
+ $order = new \stdClass();
+ $order->id = 1;
+
+ $transaction = $this->walletService->payOrder(
+ wallet: $this->wallet,
+ amount: 200000,
+ description: 'پرداخت سفارش #1',
+ order: $order
+ );
+
+ $this->wallet->refresh();
+ $this->assertEquals(300000, $this->wallet->balance);
+ $this->assertEquals(200000, $this->wallet->total_withdrawn);
+ $this->assertEquals(TransactionType::ORDER_PAYMENT, $transaction->type);
+ }
+
+ #[Test]
+ public function it_prevents_order_payment_when_balance_is_insufficient()
+ {
+ $this->wallet->update(['balance' => 100000]);
+
+ $this->expectException(\Exception::class);
+ $this->walletService->payOrder(
+ wallet: $this->wallet,
+ amount: 200000,
+ description: 'پرداخت سفارش'
+ );
+ }
+
+ #[Test]
+ public function it_fails_transaction()
+ {
+ $transaction = $this->walletService->requestDeposit(
+ wallet: $this->wallet,
+ amount: 100000,
+ description: 'شارژ تستی',
+ user: $this->user
+ );
+
+ $failed = $this->walletService->failTransaction(
+ $transaction,
+ 'کاربر پرداخت را لغو کرد.'
+ );
+
+ $this->assertEquals(TransactionStatus::FAILED, $failed->status);
+ $this->assertEquals(0, $this->wallet->fresh()->balance);
+ }
+}