From d1d42b38d15459c544f498e1d4953230da87646d Mon Sep 17 00:00:00 2001 From: soheil khaledabadi Date: Sun, 7 Jun 2026 00:18:32 +0330 Subject: [PATCH] feat(wallets): implement wallet and transaction management with associated models, policies, and resources --- app/Enums/WalletTransactionType.php | 17 +++ .../Pages/ListWalletTransactions.php | 11 ++ .../Pages/ViewWalletTransaction.php | 11 ++ .../Tables/WalletTransactionsTable.php | 65 +++++++++++ .../WalletTransactionResource.php | 60 ++++++++++ .../Resources/Wallets/Pages/EditWallet.php | 19 ++++ .../Resources/Wallets/Pages/ListWallets.php | 11 ++ .../Resources/Wallets/Pages/ViewWallet.php | 19 ++++ .../TransactionsRelationManager.php | 62 ++++++++++ .../Resources/Wallets/Schemas/WalletForm.php | 19 ++++ .../Resources/Wallets/Tables/WalletsTable.php | 106 ++++++++++++++++++ .../Resources/Wallets/WalletResource.php | 67 +++++++++++ app/Models/User.php | 18 +++ app/Models/Wallet.php | 39 +++++++ app/Models/WalletTransaction.php | 49 ++++++++ app/Observers/UserObserver.php | 16 +++ app/Policies/WalletPolicy.php | 29 +++++ app/Policies/WalletTransactionPolicy.php | 24 ++++ app/Providers/AppServiceProvider.php | 9 ++ app/Services/WalletService.php | 81 +++++++++++++ ...2026_06_06_000000_create_wallets_table.php | 24 ++++ ...00001_create_wallet_transactions_table.php | 30 +++++ database/seeders/DatabaseSeeder.php | 4 + 23 files changed, 790 insertions(+) create mode 100644 app/Enums/WalletTransactionType.php create mode 100644 app/Filament/Resources/WalletTransactions/Pages/ListWalletTransactions.php create mode 100644 app/Filament/Resources/WalletTransactions/Pages/ViewWalletTransaction.php create mode 100644 app/Filament/Resources/WalletTransactions/Tables/WalletTransactionsTable.php create mode 100644 app/Filament/Resources/WalletTransactions/WalletTransactionResource.php create mode 100644 app/Filament/Resources/Wallets/Pages/EditWallet.php create mode 100644 app/Filament/Resources/Wallets/Pages/ListWallets.php create mode 100644 app/Filament/Resources/Wallets/Pages/ViewWallet.php create mode 100644 app/Filament/Resources/Wallets/RelationManagers/TransactionsRelationManager.php create mode 100644 app/Filament/Resources/Wallets/Schemas/WalletForm.php create mode 100644 app/Filament/Resources/Wallets/Tables/WalletsTable.php create mode 100644 app/Filament/Resources/Wallets/WalletResource.php create mode 100644 app/Models/Wallet.php create mode 100644 app/Models/WalletTransaction.php create mode 100644 app/Observers/UserObserver.php create mode 100644 app/Policies/WalletPolicy.php create mode 100644 app/Policies/WalletTransactionPolicy.php create mode 100644 app/Services/WalletService.php create mode 100644 database/migrations/2026_06_06_000000_create_wallets_table.php create mode 100644 database/migrations/2026_06_06_000001_create_wallet_transactions_table.php diff --git a/app/Enums/WalletTransactionType.php b/app/Enums/WalletTransactionType.php new file mode 100644 index 0000000..d421d48 --- /dev/null +++ b/app/Enums/WalletTransactionType.php @@ -0,0 +1,17 @@ + 'واریز', + self::Debit => 'برداشت', + }; + } +} diff --git a/app/Filament/Resources/WalletTransactions/Pages/ListWalletTransactions.php b/app/Filament/Resources/WalletTransactions/Pages/ListWalletTransactions.php new file mode 100644 index 0000000..e82796e --- /dev/null +++ b/app/Filament/Resources/WalletTransactions/Pages/ListWalletTransactions.php @@ -0,0 +1,11 @@ +columns([ + TextColumn::make('id') + ->label('#') + ->sortable(), + TextColumn::make('wallet.user.name') + ->label('کاربر') + ->searchable() + ->sortable(), + TextColumn::make('wallet.user.mobile') + ->label('موبایل') + ->searchable() + ->toggleable(), + TextColumn::make('type') + ->label('نوع') + ->badge() + ->formatStateUsing(fn (WalletTransactionType $state): string => $state->label()) + ->color(fn (WalletTransactionType $state): string => $state === WalletTransactionType::Credit ? 'success' : 'danger'), + TextColumn::make('amount') + ->label('مبلغ') + ->sortable() + ->formatStateUsing(fn (int $state): string => number_format($state).' تومان'), + TextColumn::make('balance_after') + ->label('موجودی بعد') + ->formatStateUsing(fn (int $state): string => number_format($state).' تومان'), + TextColumn::make('description') + ->label('توضیحات') + ->limit(50) + ->searchable(), + TextColumn::make('creator.name') + ->label('ثبت‌کننده') + ->placeholder('سیستم'), + TextColumn::make('created_at') + ->label('تاریخ') + ->dateTime() + ->sortable(), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + SelectFilter::make('type') + ->label('نوع') + ->options([ + WalletTransactionType::Credit->value => WalletTransactionType::Credit->label(), + WalletTransactionType::Debit->value => WalletTransactionType::Debit->label(), + ]), + ]) + ->recordActions([ + ViewAction::make(), + ]); + } +} diff --git a/app/Filament/Resources/WalletTransactions/WalletTransactionResource.php b/app/Filament/Resources/WalletTransactions/WalletTransactionResource.php new file mode 100644 index 0000000..516a8d0 --- /dev/null +++ b/app/Filament/Resources/WalletTransactions/WalletTransactionResource.php @@ -0,0 +1,60 @@ + ListWalletTransactions::route('/'), + 'view' => ViewWalletTransaction::route('/{record}'), + ]; + } + + public static function canCreate(): bool + { + return false; + } + + public static function canEdit($record): bool + { + return false; + } + + public static function canDelete($record): bool + { + return false; + } +} diff --git a/app/Filament/Resources/Wallets/Pages/EditWallet.php b/app/Filament/Resources/Wallets/Pages/EditWallet.php new file mode 100644 index 0000000..88db144 --- /dev/null +++ b/app/Filament/Resources/Wallets/Pages/EditWallet.php @@ -0,0 +1,19 @@ +columns([ + TextColumn::make('type') + ->label('نوع') + ->badge() + ->formatStateUsing(fn (WalletTransactionType $state): string => $state->label()) + ->color(fn (WalletTransactionType $state): string => $state === WalletTransactionType::Credit ? 'success' : 'danger'), + TextColumn::make('amount') + ->label('مبلغ') + ->formatStateUsing(fn (int $state): string => number_format($state).' تومان'), + TextColumn::make('balance_before') + ->label('قبل') + ->formatStateUsing(fn (int $state): string => number_format($state)), + TextColumn::make('balance_after') + ->label('بعد') + ->formatStateUsing(fn (int $state): string => number_format($state)), + TextColumn::make('description') + ->label('توضیحات') + ->limit(40), + TextColumn::make('creator.name') + ->label('ثبت‌کننده') + ->placeholder('سیستم'), + TextColumn::make('created_at') + ->label('تاریخ') + ->dateTime() + ->sortable(), + ]) + ->defaultSort('created_at', 'desc') + ->filters([ + SelectFilter::make('type') + ->label('نوع') + ->options([ + WalletTransactionType::Credit->value => WalletTransactionType::Credit->label(), + WalletTransactionType::Debit->value => WalletTransactionType::Debit->label(), + ]), + ]) + ->recordActions([]) + ->toolbarActions([]); + } +} diff --git a/app/Filament/Resources/Wallets/Schemas/WalletForm.php b/app/Filament/Resources/Wallets/Schemas/WalletForm.php new file mode 100644 index 0000000..01d4475 --- /dev/null +++ b/app/Filament/Resources/Wallets/Schemas/WalletForm.php @@ -0,0 +1,19 @@ +components([ + Toggle::make('is_active') + ->label('فعال') + ->default(true), + ]); + } +} diff --git a/app/Filament/Resources/Wallets/Tables/WalletsTable.php b/app/Filament/Resources/Wallets/Tables/WalletsTable.php new file mode 100644 index 0000000..4d0eb1c --- /dev/null +++ b/app/Filament/Resources/Wallets/Tables/WalletsTable.php @@ -0,0 +1,106 @@ +columns([ + TextColumn::make('user.name') + ->label('کاربر') + ->searchable() + ->sortable(), + TextColumn::make('user.email') + ->label('ایمیل') + ->searchable() + ->toggleable(), + TextColumn::make('user.mobile') + ->label('موبایل') + ->searchable() + ->toggleable(), + TextColumn::make('balance') + ->label('موجودی (تومان)') + ->numeric() + ->sortable() + ->formatStateUsing(fn (int $state): string => number_format($state).' تومان'), + IconColumn::make('is_active') + ->label('فعال') + ->boolean(), + TextColumn::make('updated_at') + ->label('آخرین بروزرسانی') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('balance', 'desc') + ->filters([ + TernaryFilter::make('is_active') + ->label('وضعیت') + ->placeholder('همه') + ->trueLabel('فعال') + ->falseLabel('غیرفعال'), + ]) + ->recordActions([ + ViewAction::make(), + EditAction::make(), + self::adjustAction('credit', 'واریز', Heroicon::OutlinedPlusCircle, 'success'), + self::adjustAction('debit', 'برداشت', Heroicon::OutlinedMinusCircle, 'danger'), + ]); + } + + private static function adjustAction(string $type, string $label, Heroicon $icon, string $color): Action + { + return Action::make($type) + ->label($label) + ->icon($icon) + ->color($color) + ->schema([ + TextInput::make('amount') + ->label('مبلغ (تومان)') + ->numeric() + ->required() + ->minValue(1) + ->integer(), + Textarea::make('description') + ->label('توضیحات') + ->maxLength(500) + ->rows(2), + ]) + ->action(function (Wallet $record, array $data, WalletService $walletService): void { + try { + $transaction = $type === 'credit' + ? $walletService->credit($record, (int) $data['amount'], $data['description'] ?? null, auth()->user()) + : $walletService->debit($record, (int) $data['amount'], $data['description'] ?? null, auth()->user()); + + Notification::make() + ->title($type === 'credit' ? 'واریز انجام شد' : 'برداشت انجام شد') + ->body('موجودی جدید: '.number_format($transaction->balance_after).' تومان') + ->success() + ->send(); + } catch (RuntimeException $exception) { + Notification::make() + ->title('خطا') + ->body($exception->getMessage()) + ->danger() + ->send(); + } + }); + } +} diff --git a/app/Filament/Resources/Wallets/WalletResource.php b/app/Filament/Resources/Wallets/WalletResource.php new file mode 100644 index 0000000..2ffdeb5 --- /dev/null +++ b/app/Filament/Resources/Wallets/WalletResource.php @@ -0,0 +1,67 @@ + ListWallets::route('/'), + 'view' => ViewWallet::route('/{record}'), + 'edit' => EditWallet::route('/{record}/edit'), + ]; + } + + public static function canCreate(): bool + { + return false; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index c2145f7..317b174 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -29,6 +31,22 @@ class User extends Authenticatable implements FilamentUser return $this->belongsToMany(Role::class); } + /** + * @return HasOne + */ + public function wallet(): HasOne + { + return $this->hasOne(Wallet::class); + } + + /** + * @return HasManyThrough + */ + public function walletTransactions(): HasManyThrough + { + return $this->hasManyThrough(WalletTransaction::class, Wallet::class); + } + public function hasRole(string|array $roles): bool { $roles = (array) $roles; diff --git a/app/Models/Wallet.php b/app/Models/Wallet.php new file mode 100644 index 0000000..d2e09de --- /dev/null +++ b/app/Models/Wallet.php @@ -0,0 +1,39 @@ + + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return HasMany + */ + public function transactions(): HasMany + { + return $this->hasMany(WalletTransaction::class)->latest(); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'balance' => 'integer', + 'is_active' => 'boolean', + ]; + } +} diff --git a/app/Models/WalletTransaction.php b/app/Models/WalletTransaction.php new file mode 100644 index 0000000..283e531 --- /dev/null +++ b/app/Models/WalletTransaction.php @@ -0,0 +1,49 @@ + + */ + public function wallet(): BelongsTo + { + return $this->belongsTo(Wallet::class); + } + + /** + * @return BelongsTo + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => WalletTransactionType::class, + 'amount' => 'integer', + 'balance_before' => 'integer', + 'balance_after' => 'integer', + ]; + } +} diff --git a/app/Observers/UserObserver.php b/app/Observers/UserObserver.php new file mode 100644 index 0000000..2db8423 --- /dev/null +++ b/app/Observers/UserObserver.php @@ -0,0 +1,16 @@ +walletService->createForUser($user); + } +} diff --git a/app/Policies/WalletPolicy.php b/app/Policies/WalletPolicy.php new file mode 100644 index 0000000..f90b5a4 --- /dev/null +++ b/app/Policies/WalletPolicy.php @@ -0,0 +1,29 @@ +canManage($user); + } + + public function view(User $user, Wallet $wallet): bool + { + return $this->canManage($user); + } + + public function update(User $user, Wallet $wallet): bool + { + return $this->canManage($user); + } + + private function canManage(User $user): bool + { + return $user->hasRole('admin') || $user->hasPermission('wallets.manage'); + } +} diff --git a/app/Policies/WalletTransactionPolicy.php b/app/Policies/WalletTransactionPolicy.php new file mode 100644 index 0000000..cbff25c --- /dev/null +++ b/app/Policies/WalletTransactionPolicy.php @@ -0,0 +1,24 @@ +canManage($user); + } + + public function view(User $user, WalletTransaction $transaction): bool + { + return $this->canManage($user); + } + + private function canManage(User $user): bool + { + return $user->hasRole('admin') || $user->hasPermission('wallets.manage'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f69eddb..e654229 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,9 +5,14 @@ namespace App\Providers; use App\Models\Permission; use App\Models\Role; use App\Models\User; +use App\Models\Wallet; +use App\Models\WalletTransaction; +use App\Observers\UserObserver; use App\Policies\PermissionPolicy; use App\Policies\RolePolicy; use App\Policies\UserPolicy; +use App\Policies\WalletPolicy; +use App\Policies\WalletTransactionPolicy; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; @@ -26,8 +31,12 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { + User::observe(UserObserver::class); + Gate::policy(User::class, UserPolicy::class); Gate::policy(Role::class, RolePolicy::class); Gate::policy(Permission::class, PermissionPolicy::class); + Gate::policy(Wallet::class, WalletPolicy::class); + Gate::policy(WalletTransaction::class, WalletTransactionPolicy::class); } } diff --git a/app/Services/WalletService.php b/app/Services/WalletService.php new file mode 100644 index 0000000..037c7d3 --- /dev/null +++ b/app/Services/WalletService.php @@ -0,0 +1,81 @@ +firstOrCreate( + ['user_id' => $user->id], + ['balance' => 0, 'is_active' => true], + ); + } + + public function credit( + Wallet $wallet, + int $amount, + ?string $description = null, + ?User $performedBy = null, + ): WalletTransaction { + return $this->apply($wallet, WalletTransactionType::Credit, $amount, $description, $performedBy); + } + + public function debit( + Wallet $wallet, + int $amount, + ?string $description = null, + ?User $performedBy = null, + ): WalletTransaction { + return $this->apply($wallet, WalletTransactionType::Debit, $amount, $description, $performedBy); + } + + private function apply( + Wallet $wallet, + WalletTransactionType $type, + int $amount, + ?string $description, + ?User $performedBy, + ): WalletTransaction { + if ($amount <= 0) { + throw new InvalidArgumentException('مبلغ باید بزرگ‌تر از صفر باشد.'); + } + + return DB::transaction(function () use ($wallet, $type, $amount, $description, $performedBy): WalletTransaction { + $wallet = Wallet::query()->lockForUpdate()->findOrFail($wallet->id); + + if (! $wallet->is_active) { + throw new RuntimeException('کیف پول غیرفعال است.'); + } + + $balanceBefore = $wallet->balance; + $balanceAfter = $type === WalletTransactionType::Credit + ? $balanceBefore + $amount + : $balanceBefore - $amount; + + if ($balanceAfter < 0) { + throw new RuntimeException('موجودی کیف پول کافی نیست.'); + } + + $wallet->update(['balance' => $balanceAfter]); + + return WalletTransaction::query()->create([ + 'wallet_id' => $wallet->id, + 'type' => $type, + 'amount' => $amount, + 'balance_before' => $balanceBefore, + 'balance_after' => $balanceAfter, + 'description' => $description, + 'created_by' => $performedBy?->id, + ]); + }); + } +} diff --git a/database/migrations/2026_06_06_000000_create_wallets_table.php b/database/migrations/2026_06_06_000000_create_wallets_table.php new file mode 100644 index 0000000..2fa3ee9 --- /dev/null +++ b/database/migrations/2026_06_06_000000_create_wallets_table.php @@ -0,0 +1,24 @@ +id(); + $table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete(); + $table->unsignedBigInteger('balance')->default(0); + $table->boolean('is_active')->default(true); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('wallets'); + } +}; diff --git a/database/migrations/2026_06_06_000001_create_wallet_transactions_table.php b/database/migrations/2026_06_06_000001_create_wallet_transactions_table.php new file mode 100644 index 0000000..bebd25a --- /dev/null +++ b/database/migrations/2026_06_06_000001_create_wallet_transactions_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('wallet_id')->constrained()->cascadeOnDelete(); + $table->string('type'); + $table->unsignedBigInteger('amount'); + $table->unsignedBigInteger('balance_before'); + $table->unsignedBigInteger('balance_after'); + $table->string('description')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['wallet_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('wallet_transactions'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 3e58147..fee4708 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; use App\Models\Permission; use App\Models\Role; use App\Models\User; +use App\Services\WalletService; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; @@ -24,6 +25,7 @@ class DatabaseSeeder extends Seeder ['name' => 'مدیریت کاربران', 'slug' => 'users.manage'], ['name' => 'مدیریت نقش‌ها', 'slug' => 'roles.manage'], ['name' => 'مدیریت دسترسی‌ها', 'slug' => 'permissions.manage'], + ['name' => 'مدیریت کیف پول', 'slug' => 'wallets.manage'], ])->map(fn (array $permission) => Permission::query()->updateOrCreate( ['slug' => $permission['slug']], ['name' => $permission['name']], @@ -47,5 +49,7 @@ class DatabaseSeeder extends Seeder ); $user->roles()->sync([$adminRole->id]); + + User::query()->each(fn (User $user) => app(WalletService::class)->createForUser($user)); } }