feat(wallets): implement wallet and transaction management with associated models, policies, and resources

This commit is contained in:
2026-06-07 00:18:32 +03:30
parent c2319a55cb
commit d1d42b38d1
23 changed files with 790 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Enums;
enum WalletTransactionType: string
{
case Credit = 'credit';
case Debit = 'debit';
public function label(): string
{
return match ($this) {
self::Credit => 'واریز',
self::Debit => 'برداشت',
};
}
}

View File

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

View File

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

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Filament\Resources\WalletTransactions\Tables;
use App\Enums\WalletTransactionType;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class WalletTransactionsTable
{
public static function configure(Table $table): Table
{
return $table
->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(),
]);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Filament\Resources\WalletTransactions;
use App\Filament\Resources\WalletTransactions\Pages\ListWalletTransactions;
use App\Filament\Resources\WalletTransactions\Pages\ViewWalletTransaction;
use App\Filament\Resources\WalletTransactions\Tables\WalletTransactionsTable;
use App\Models\WalletTransaction;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class WalletTransactionResource extends Resource
{
protected static ?string $model = WalletTransaction::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedArrowsRightLeft;
protected static string|UnitEnum|null $navigationGroup = 'مالی';
protected static ?string $recordTitleAttribute = 'id';
protected static ?string $modelLabel = 'تراکنش';
protected static ?string $pluralModelLabel = 'تراکنش‌ها';
protected static ?string $navigationLabel = 'تراکنش‌ها';
protected static ?int $navigationSort = 11;
public static function table(Table $table): Table
{
return WalletTransactionsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => 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;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\Wallets\RelationManagers;
use App\Enums\WalletTransactionType;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class TransactionsRelationManager extends RelationManager
{
protected static string $relationship = 'transactions';
protected static ?string $title = 'تراکنش‌ها';
protected static ?string $modelLabel = 'تراکنش';
protected static ?string $pluralModelLabel = 'تراکنش‌ها';
public function table(Table $table): Table
{
return $table
->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([]);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\Wallets\Schemas;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Schema;
class WalletForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Toggle::make('is_active')
->label('فعال')
->default(true),
]);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Filament\Resources\Wallets\Tables;
use App\Models\Wallet;
use App\Services\WalletService;
use Filament\Actions\Action;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use RuntimeException;
class WalletsTable
{
public static function configure(Table $table): Table
{
return $table
->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();
}
});
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Filament\Resources\Wallets;
use App\Filament\Resources\Wallets\Pages\EditWallet;
use App\Filament\Resources\Wallets\Pages\ListWallets;
use App\Filament\Resources\Wallets\Pages\ViewWallet;
use App\Filament\Resources\Wallets\RelationManagers\TransactionsRelationManager;
use App\Filament\Resources\Wallets\Schemas\WalletForm;
use App\Filament\Resources\Wallets\Tables\WalletsTable;
use App\Models\Wallet;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class WalletResource extends Resource
{
protected static ?string $model = Wallet::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedWallet;
protected static string|UnitEnum|null $navigationGroup = 'مالی';
protected static ?string $recordTitleAttribute = 'id';
protected static ?string $modelLabel = 'کیف پول';
protected static ?string $pluralModelLabel = 'کیف پول‌ها';
protected static ?string $navigationLabel = 'کیف پول‌ها';
protected static ?int $navigationSort = 10;
public static function form(Schema $schema): Schema
{
return WalletForm::configure($schema);
}
public static function table(Table $table): Table
{
return WalletsTable::configure($table);
}
public static function getRelations(): array
{
return [
TransactionsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListWallets::route('/'),
'view' => ViewWallet::route('/{record}'),
'edit' => EditWallet::route('/{record}/edit'),
];
}
public static function canCreate(): bool
{
return false;
}
}

View File

@@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; 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\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
@@ -29,6 +31,22 @@ class User extends Authenticatable implements FilamentUser
return $this->belongsToMany(Role::class); return $this->belongsToMany(Role::class);
} }
/**
* @return HasOne<Wallet, $this>
*/
public function wallet(): HasOne
{
return $this->hasOne(Wallet::class);
}
/**
* @return HasManyThrough<WalletTransaction, Wallet, $this>
*/
public function walletTransactions(): HasManyThrough
{
return $this->hasManyThrough(WalletTransaction::class, Wallet::class);
}
public function hasRole(string|array $roles): bool public function hasRole(string|array $roles): bool
{ {
$roles = (array) $roles; $roles = (array) $roles;

39
app/Models/Wallet.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['user_id', 'balance', 'is_active'])]
class Wallet extends Model
{
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return HasMany<WalletTransaction, $this>
*/
public function transactions(): HasMany
{
return $this->hasMany(WalletTransaction::class)->latest();
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'balance' => 'integer',
'is_active' => 'boolean',
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use App\Enums\WalletTransactionType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'wallet_id',
'type',
'amount',
'balance_before',
'balance_after',
'description',
'created_by',
])]
class WalletTransaction extends Model
{
/**
* @return BelongsTo<Wallet, $this>
*/
public function wallet(): BelongsTo
{
return $this->belongsTo(Wallet::class);
}
/**
* @return BelongsTo<User, $this>
*/
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'type' => WalletTransactionType::class,
'amount' => 'integer',
'balance_before' => 'integer',
'balance_after' => 'integer',
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Observers;
use App\Models\User;
use App\Services\WalletService;
class UserObserver
{
public function __construct(private WalletService $walletService) {}
public function created(User $user): void
{
$this->walletService->createForUser($user);
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\Wallet;
class WalletPolicy
{
public function viewAny(User $user): bool
{
return $this->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');
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Policies;
use App\Models\User;
use App\Models\WalletTransaction;
class WalletTransactionPolicy
{
public function viewAny(User $user): bool
{
return $this->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');
}
}

View File

@@ -5,9 +5,14 @@ namespace App\Providers;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use App\Observers\UserObserver;
use App\Policies\PermissionPolicy; use App\Policies\PermissionPolicy;
use App\Policies\RolePolicy; use App\Policies\RolePolicy;
use App\Policies\UserPolicy; use App\Policies\UserPolicy;
use App\Policies\WalletPolicy;
use App\Policies\WalletTransactionPolicy;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@@ -26,8 +31,12 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void public function boot(): void
{ {
User::observe(UserObserver::class);
Gate::policy(User::class, UserPolicy::class); Gate::policy(User::class, UserPolicy::class);
Gate::policy(Role::class, RolePolicy::class); Gate::policy(Role::class, RolePolicy::class);
Gate::policy(Permission::class, PermissionPolicy::class); Gate::policy(Permission::class, PermissionPolicy::class);
Gate::policy(Wallet::class, WalletPolicy::class);
Gate::policy(WalletTransaction::class, WalletTransactionPolicy::class);
} }
} }

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Services;
use App\Enums\WalletTransactionType;
use App\Models\User;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use RuntimeException;
class WalletService
{
public function createForUser(User $user): Wallet
{
return Wallet::query()->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,
]);
});
}
}

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('wallets', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('wallet_transactions', function (Blueprint $table) {
$table->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');
}
};

View File

@@ -5,6 +5,7 @@ namespace Database\Seeders;
use App\Models\Permission; use App\Models\Permission;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
use App\Services\WalletService;
use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
@@ -24,6 +25,7 @@ class DatabaseSeeder extends Seeder
['name' => 'مدیریت کاربران', 'slug' => 'users.manage'], ['name' => 'مدیریت کاربران', 'slug' => 'users.manage'],
['name' => 'مدیریت نقش‌ها', 'slug' => 'roles.manage'], ['name' => 'مدیریت نقش‌ها', 'slug' => 'roles.manage'],
['name' => 'مدیریت دسترسی‌ها', 'slug' => 'permissions.manage'], ['name' => 'مدیریت دسترسی‌ها', 'slug' => 'permissions.manage'],
['name' => 'مدیریت کیف پول', 'slug' => 'wallets.manage'],
])->map(fn (array $permission) => Permission::query()->updateOrCreate( ])->map(fn (array $permission) => Permission::query()->updateOrCreate(
['slug' => $permission['slug']], ['slug' => $permission['slug']],
['name' => $permission['name']], ['name' => $permission['name']],
@@ -47,5 +49,7 @@ class DatabaseSeeder extends Seeder
); );
$user->roles()->sync([$adminRole->id]); $user->roles()->sync([$adminRole->id]);
User::query()->each(fn (User $user) => app(WalletService::class)->createForUser($user));
} }
} }