From e1c4385a9c6ca432e593e3f9b7ce2362b05e85d2 Mon Sep 17 00:00:00 2001 From: Kazem Alghasi Date: Sat, 8 Aug 2026 03:15:51 +0330 Subject: [PATCH] feat(finance): implement Filament admin panel and expand wallet/discount features Implement a comprehensive financial management suite within the Filament admin panel, including new resources for payments and discount codes, along with a financial dashboard. - feat(ui): add Filament Dashboard with finance overview and transaction widgets - feat(ui): implement PaymentResource for monitoring gateway transactions - feat(ui): complete DiscountCodeResource with full CRUD and filtering - feat(wallet): add automatic wallet creation on balance retrieval - fix(wallet): resolve null handling in isFrozen() and balance viewing - fix(db): correct enum type from 'percent' to 'percentage' in discount_codes migration - fix(api): update pricing calculation endpoint to /api/v1/calculate - test: add 21 new feature and service tests covering wallet, payment, and discount logic - chore: remove obsolete PaymentGatewayService and update project status --- 01_Documents/STATUS.md | 16 +- 04_Laravel/.phpunit.result.cache | 2 +- 04_Laravel/app/Filament/Pages/Dashboard.php | 32 +++ .../Resources/DiscountCodeResource.php | 139 +++++++++++- .../Filament/Resources/PaymentResource.php | 194 +++++++++++++++++ .../PaymentResource/Pages/ListPayments.php | 11 + .../PaymentResource/Pages/ViewPayment.php | 19 ++ .../Widgets/FinanceOverviewWidget.php | 44 ++++ .../Widgets/RecentTransactionsWidget.php | 69 ++++++ .../Widgets/TransactionChartWidget.php | 87 ++++++++ .../Http/Controllers/Api/WalletController.php | 21 +- 04_Laravel/app/Models/Wallet.php | 2 +- .../Providers/Filament/AdminPanelProvider.php | 11 +- .../app/Services/PaymentGatewayService.php | 203 ------------------ ..._05_135026_create_discount_codes_table.php | 2 +- 04_Laravel/public/images/favicon.svg | 4 + 04_Laravel/public/images/ifnex-logo.svg | 5 + .../Api/DiscountCodeControllerTest.php | 157 ++++++++++++++ .../Feature/Api/PaymentControllerTest.php | 123 +++++++++++ .../Feature/Api/PricingControllerTest.php | 8 +- .../Feature/Services/WalletServiceTest.php | 180 ++++++++++++++++ 21 files changed, 1101 insertions(+), 228 deletions(-) create mode 100644 04_Laravel/app/Filament/Pages/Dashboard.php create mode 100644 04_Laravel/app/Filament/Resources/PaymentResource.php create mode 100644 04_Laravel/app/Filament/Resources/PaymentResource/Pages/ListPayments.php create mode 100644 04_Laravel/app/Filament/Resources/PaymentResource/Pages/ViewPayment.php create mode 100644 04_Laravel/app/Filament/Widgets/FinanceOverviewWidget.php create mode 100644 04_Laravel/app/Filament/Widgets/RecentTransactionsWidget.php create mode 100644 04_Laravel/app/Filament/Widgets/TransactionChartWidget.php delete mode 100644 04_Laravel/app/Services/PaymentGatewayService.php create mode 100644 04_Laravel/public/images/favicon.svg create mode 100644 04_Laravel/public/images/ifnex-logo.svg create mode 100644 04_Laravel/tests/Feature/Api/DiscountCodeControllerTest.php create mode 100644 04_Laravel/tests/Feature/Api/PaymentControllerTest.php create mode 100644 04_Laravel/tests/Feature/Services/WalletServiceTest.php 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('IFNEX') + ->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 @@ + + + I + 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 @@ + + + IFNEX + LOGISTICS + 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); + } +}