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:
parent
7948487a7a
commit
e1c4385a9c
@ -255,7 +255,12 @@
|
|||||||
- [x] حذف migrationهای قدیمی تکراری (`2023_10_27_*`, `2026_07_31_*`)
|
- [x] حذف migrationهای قدیمی تکراری (`2023_10_27_*`, `2026_07_31_*`)
|
||||||
- [x] اتصال درگاه پرداخت (زرینپال)
|
- [x] اتصال درگاه پرداخت (زرینپال)
|
||||||
- [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)
|
- تست کامل flow پرداخت آنلاین (redirect → verify → complete)
|
||||||
- رفع باگ double-click در PaymentController
|
- رفع باگ double-click در PaymentController
|
||||||
- ساخت صفحه Payment Result با UI موفق/ناموفق
|
- ساخت صفحه 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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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}}
|
||||||
32
04_Laravel/app/Filament/Pages/Dashboard.php
Normal file
32
04_Laravel/app/Filament/Pages/Dashboard.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,15 +3,12 @@
|
|||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use App\Filament\Resources\DiscountCodeResource\Pages;
|
use App\Filament\Resources\DiscountCodeResource\Pages;
|
||||||
use App\Filament\Resources\DiscountCodeResource\RelationManagers;
|
|
||||||
use App\Models\DiscountCode;
|
use App\Models\DiscountCode;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Forms\Form;
|
use Filament\Forms\Form;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
use Filament\Tables;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
|
||||||
|
|
||||||
class DiscountCodeResource extends Resource
|
class DiscountCodeResource extends Resource
|
||||||
{
|
{
|
||||||
@ -19,11 +16,74 @@ class DiscountCodeResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
|
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
|
public static function form(Form $form): Form
|
||||||
{
|
{
|
||||||
return $form
|
return $form
|
||||||
->schema([
|
->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
|
return $table
|
||||||
->columns([
|
->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([
|
->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([
|
->actions([
|
||||||
Tables\Actions\EditAction::make(),
|
Tables\Actions\EditAction::make(),
|
||||||
|
Tables\Actions\DeleteAction::make(),
|
||||||
])
|
])
|
||||||
->bulkActions([
|
->bulkActions([
|
||||||
Tables\Actions\BulkActionGroup::make([
|
Tables\Actions\BulkActionGroup::make([
|
||||||
|
|||||||
194
04_Laravel/app/Filament/Resources/PaymentResource.php
Normal file
194
04_Laravel/app/Filament/Resources/PaymentResource.php
Normal 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}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
44
04_Laravel/app/Filament/Widgets/FinanceOverviewWidget.php
Normal file
44
04_Laravel/app/Filament/Widgets/FinanceOverviewWidget.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
69
04_Laravel/app/Filament/Widgets/RecentTransactionsWidget.php
Normal file
69
04_Laravel/app/Filament/Widgets/RecentTransactionsWidget.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
87
04_Laravel/app/Filament/Widgets/TransactionChartWidget.php
Normal file
87
04_Laravel/app/Filament/Widgets/TransactionChartWidget.php
Normal 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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -30,20 +30,29 @@ class WalletController extends Controller
|
|||||||
|
|
||||||
$wallet = $user->wallet;
|
$wallet = $user->wallet;
|
||||||
|
|
||||||
|
if (!$wallet) {
|
||||||
|
$wallet = Wallet::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'balance' => 0,
|
||||||
|
'total_deposited' => 0,
|
||||||
|
'total_withdrawn' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
WalletActivityLog::log(
|
WalletActivityLog::log(
|
||||||
$wallet ?? new Wallet(['user_id' => $user->id]),
|
$wallet,
|
||||||
'balance_viewed',
|
'balance_viewed',
|
||||||
'مشاهده موجودی کیف پول',
|
'مشاهده موجودی کیف پول',
|
||||||
null,
|
null,
|
||||||
['balance' => $wallet?->balance ?? 0],
|
['balance' => $wallet->balance],
|
||||||
$user
|
$user
|
||||||
);
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'balance' => $wallet?->balance ?? 0,
|
'balance' => $wallet->balance,
|
||||||
'total_deposited' => $wallet?->total_deposited ?? 0,
|
'total_deposited' => $wallet->total_deposited,
|
||||||
'total_withdrawn' => $wallet?->total_withdrawn ?? 0,
|
'total_withdrawn' => $wallet->total_withdrawn,
|
||||||
'is_frozen' => $wallet?->isFrozen() ?? false,
|
'is_frozen' => $wallet->isFrozen(),
|
||||||
'currency' => 'IRR',
|
'currency' => 'IRR',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -63,7 +63,7 @@ class Wallet extends Model
|
|||||||
// Helper methods
|
// Helper methods
|
||||||
public function isFrozen(): bool
|
public function isFrozen(): bool
|
||||||
{
|
{
|
||||||
return $this->is_frozen;
|
return (bool) ($this->is_frozen ?? false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function hasSufficientBalance(float $amount): bool
|
public function hasSufficientBalance(float $amount): bool
|
||||||
|
|||||||
@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Providers\Filament;
|
namespace App\Providers\Filament;
|
||||||
|
|
||||||
|
use App\Filament\Pages\Auth\Login;
|
||||||
|
use App\Filament\Pages\Dashboard;
|
||||||
use Filament\Http\Middleware\Authenticate;
|
use Filament\Http\Middleware\Authenticate;
|
||||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Filament\PanelProvider;
|
use Filament\PanelProvider;
|
||||||
use Filament\Support\Colors\Color;
|
use Filament\Support\Colors\Color;
|
||||||
use Filament\Widgets;
|
|
||||||
|
|
||||||
class AdminPanelProvider extends PanelProvider
|
class AdminPanelProvider extends PanelProvider
|
||||||
{
|
{
|
||||||
@ -18,27 +19,27 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
->id('admin')
|
->id('admin')
|
||||||
->path('admin')
|
->path('admin')
|
||||||
->login()
|
->login()
|
||||||
|
->brandName('IFNEX Logistics')
|
||||||
|
->brandLogo('<img src="' . asset('images/ifnex-logo.svg') . '" alt="IFNEX" style="height: 40px;">')
|
||||||
|
->favicon(asset('images/favicon.svg'))
|
||||||
->colors([
|
->colors([
|
||||||
'primary' => Color::Amber,
|
'primary' => Color::Amber,
|
||||||
])
|
])
|
||||||
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources')
|
||||||
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
|
->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages')
|
||||||
->pages([
|
->pages([
|
||||||
\Filament\Pages\Dashboard::class,
|
Dashboard::class,
|
||||||
])
|
])
|
||||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||||
->widgets([
|
->widgets([
|
||||||
\Filament\Widgets\AccountWidget::class,
|
\Filament\Widgets\AccountWidget::class,
|
||||||
\Filament\Widgets\FilamentInfoWidget::class,
|
\Filament\Widgets\FilamentInfoWidget::class,
|
||||||
])
|
])
|
||||||
|
|
||||||
->middleware([
|
->middleware([
|
||||||
'web',
|
'web',
|
||||||
])
|
])
|
||||||
->authMiddleware([
|
->authMiddleware([
|
||||||
Authenticate::class,
|
Authenticate::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -14,7 +14,7 @@ return new class extends Migration
|
|||||||
Schema::create('discount_codes', function (Blueprint $table) {
|
Schema::create('discount_codes', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->string('code')->unique();
|
$table->string('code')->unique();
|
||||||
$table->enum('type', ['fixed', 'percent']); // مبلغ ثابت یا درصد
|
$table->enum('type', ['fixed', 'percentage']); // مبلغ ثابت یا درصد
|
||||||
$table->decimal('value', 15, 2);
|
$table->decimal('value', 15, 2);
|
||||||
$table->decimal('min_order_amount', 15, 2)->default(0);
|
$table->decimal('min_order_amount', 15, 2)->default(0);
|
||||||
$table->integer('usage_limit')->nullable();
|
$table->integer('usage_limit')->nullable();
|
||||||
|
|||||||
4
04_Laravel/public/images/favicon.svg
Normal file
4
04_Laravel/public/images/favicon.svg
Normal 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 |
5
04_Laravel/public/images/ifnex-logo.svg
Normal file
5
04_Laravel/public/images/ifnex-logo.svg
Normal 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 |
157
04_Laravel/tests/Feature/Api/DiscountCodeControllerTest.php
Normal file
157
04_Laravel/tests/Feature/Api/DiscountCodeControllerTest.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
04_Laravel/tests/Feature/Api/PaymentControllerTest.php
Normal file
123
04_Laravel/tests/Feature/Api/PaymentControllerTest.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -23,8 +23,8 @@ class PricingControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ارسال درخواست معتبر (اصلاح آدرس به /api/calculate)
|
// ارسال درخواست معتبر
|
||||||
$response = $this->postJson('/api/calculate', [
|
$response = $this->postJson('/api/v1/calculate', [
|
||||||
'direction' => 'Outbound',
|
'direction' => 'Outbound',
|
||||||
'type' => 'DOC_NORMAL',
|
'type' => 'DOC_NORMAL',
|
||||||
'country_iso' => 'US',
|
'country_iso' => 'US',
|
||||||
@ -44,8 +44,8 @@ class PricingControllerTest extends TestCase
|
|||||||
#[Test]
|
#[Test]
|
||||||
public function it_returns_validation_errors_for_invalid_data()
|
public function it_returns_validation_errors_for_invalid_data()
|
||||||
{
|
{
|
||||||
// ارسال درخواست نامعتبر (اصلاح آدرس به /api/calculate)
|
// ارسال درخواست نامعتبر
|
||||||
$response = $this->postJson('/api/calculate', [
|
$response = $this->postJson('/api/v1/calculate', [
|
||||||
'direction' => 'InvalidDirection',
|
'direction' => 'InvalidDirection',
|
||||||
'type' => 'PARCEL',
|
'type' => 'PARCEL',
|
||||||
]);
|
]);
|
||||||
|
|||||||
180
04_Laravel/tests/Feature/Services/WalletServiceTest.php
Normal file
180
04_Laravel/tests/Feature/Services/WalletServiceTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user