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
This commit is contained in:
Kazem Alghasi 2026-08-08 03:15:51 +03:30
parent 7948487a7a
commit e1c4385a9c
21 changed files with 1101 additions and 228 deletions

View File

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

View File

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

View File

@ -0,0 +1,32 @@
<?php
namespace App\Filament\Pages;
use App\Filament\Widgets\FinanceOverviewWidget;
use App\Filament\Widgets\RecentTransactionsWidget;
use App\Filament\Widgets\TransactionChartWidget;
use Filament\Pages\Dashboard as BaseDashboard;
class Dashboard extends BaseDashboard
{
protected static ?string $navigationLabel = 'داشبورد';
protected static ?string $slug = 'dashboard';
protected static ?string $title = 'داشبورد مالی';
protected static ?string $navigationIcon = 'heroicon-o-home';
public function getHeaderWidgets(): array
{
return [
FinanceOverviewWidget::class,
];
}
public function getWidgets(): array
{
return [
TransactionChartWidget::class,
RecentTransactionsWidget::class,
];
}
}

View File

@ -3,15 +3,12 @@
namespace App\Filament\Resources;
use App\Filament\Resources\DiscountCodeResource\Pages;
use App\Filament\Resources\DiscountCodeResource\RelationManagers;
use App\Models\DiscountCode;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
class DiscountCodeResource extends Resource
{
@ -19,11 +16,74 @@ class DiscountCodeResource extends Resource
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
protected static ?string $navigationLabel = 'کدهای تخفیف';
protected static ?string $navigationGroup = 'مالی';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->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([

View File

@ -0,0 +1,194 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PaymentResource\Pages;
use App\Models\Payment;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class PaymentResource extends Resource
{
protected static ?string $model = Payment::class;
protected static ?string $navigationIcon = 'heroicon-o-credit-card';
protected static ?string $navigationLabel = 'پرداخت‌ها';
protected static ?string $navigationGroup = 'مالی';
protected static ?int $navigationSort = 4;
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\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}'),
];
}
}

View File

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

View File

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

View File

@ -0,0 +1,44 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class FinanceOverviewWidget extends BaseWidget
{
protected static ?string $pollingInterval = '60s';
protected function getStats(): array
{
$totalBalance = Wallet::sum('balance');
$totalDeposited = Wallet::sum('total_deposited');
$totalWithdrawn = Wallet::sum('total_withdrawn');
$transactionCount = WalletTransaction::count();
$pendingCount = WalletTransaction::where('status', 'pending')->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'),
];
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace App\Filament\Widgets;
use App\Models\WalletTransaction;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;
use Illuminate\Database\Eloquent\Builder;
class RecentTransactionsWidget extends BaseWidget
{
protected static ?string $pollingInterval = '30s';
protected static ?string $heading = 'آخرین تراکنش‌ها';
protected static ?int $sort = 2;
public function table(Table $table): Table
{
return $table
->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');
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Filament\Widgets;
use App\Models\WalletTransaction;
use Filament\Widgets\ChartWidget;
use Illuminate\Support\Facades\DB;
class TransactionChartWidget extends ChartWidget
{
protected static ?string $pollingInterval = '60s';
protected static ?string $heading = 'روند تراکنش‌های ۳۰ روز اخیر';
protected static ?string $maxHeight = '300px';
protected function getData(): array
{
$data = WalletTransaction::select(
DB::raw('DATE(created_at) as date'),
DB::raw('SUM(CASE WHEN type = "deposit" AND status = "completed" THEN amount ELSE 0 END) as deposits'),
DB::raw('SUM(CASE WHEN type IN ("withdrawal", "order_payment") AND status = "completed" THEN amount ELSE 0 END) as withdrawals')
)
->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,
],
],
],
];
}
}

View File

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

View File

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

View File

@ -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('<img src="' . asset('images/ifnex-logo.svg') . '" alt="IFNEX" style="height: 40px;">')
->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,
]);
}
}

View File

@ -1,203 +0,0 @@
<?php
namespace App\Services;
use App\Models\Payment;
use App\Models\User;
use App\Models\Wallet;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class PaymentGatewayService
{
public function __construct(protected Payment $payment) {}
public function requestPayment(User $user, float $amount, string $description = null): array
{
$merchantId = config('ifnex.zarinpal_merchant_id');
if (!$merchantId || $merchantId === 'change-me') {
return $this->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,
];
}
}

View File

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

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#f59e0b" rx="6"/>
<text x="16" y="22" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="white" text-anchor="middle">I</text>
</svg>

After

Width:  |  Height:  |  Size: 254 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 60">
<rect width="200" height="60" fill="#f59e0b" rx="8"/>
<text x="100" y="38" font-family="Arial, sans-serif" font-size="24" font-weight="bold" fill="white" text-anchor="middle">IFNEX</text>
<text x="100" y="52" font-family="Arial, sans-serif" font-size="10" fill="white" text-anchor="middle" opacity="0.9">LOGISTICS</text>
</svg>

After

Width:  |  Height:  |  Size: 396 B

View File

@ -0,0 +1,157 @@
<?php
namespace Tests\Feature\Api;
use App\Models\DiscountCode;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class DiscountCodeControllerTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function it_returns_active_discount_codes()
{
DiscountCode::create([
'code' => '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,
]);
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace Tests\Feature\Api;
use App\Enums\TransactionStatus;
use App\Models\User;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class PaymentControllerTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Wallet $wallet;
protected function setUp(): void
{
parent::setUp();
$this->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,
]);
}
}

View File

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

View File

@ -0,0 +1,180 @@
<?php
namespace Tests\Feature\Services;
use App\Enums\PaymentGateway;
use App\Enums\TransactionStatus;
use App\Enums\TransactionType;
use App\Models\User;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use App\Services\WalletService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
class WalletServiceTest extends TestCase
{
use RefreshDatabase;
private WalletService $walletService;
private User $user;
private Wallet $wallet;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}