feat: add withdraw request functionality and UI components

This commit is contained in:
2026-08-27 02:39:57 +03:30
parent aeef95339d
commit 4ce663c669
115 changed files with 4064 additions and 315 deletions
+8
View File
@@ -8,5 +8,13 @@ enum DocumentPriorityEnum: string
case Medium = 'عادی';
case High = 'اورژانسی';
public function processingHours(): int
{
return match ($this) {
self::Medium => 24,
self::High => 3,
};
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum InvoicePaymentMethodEnum: string
{
use EnumHasOptions;
case Wallet = 'کیف پول';
case Gateway = 'درگاه پرداخت';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum InvoiceStatusEnum: string
{
use EnumHasOptions;
case Pending = 'در انتظار پرداخت';
case Paid = 'پرداخت شده';
case Canceled = 'لغو شده';
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum PaymentPurposeEnum: string
{
use EnumHasOptions;
case Invoice = 'پرداخت فاکتور';
case WalletTopUp = 'شارژ کیف پول';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum PaymentStatusEnum: string
{
use EnumHasOptions;
case Pending = 'در انتظار پرداخت';
case Paid = 'پرداخت شده';
case Failed = 'ناموفق';
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Enums;
enum WalletTransactionReasonEnum: string
{
use EnumHasOptions;
case TopUp = 'شارژ کیف پول';
case Commission = 'کمیسیون پزشک';
case InvoicePayment = 'پرداخت فاکتور';
case Refund = 'بازگشت وجه';
case Withdraw = 'برداشت وجه';
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum WalletTransactionTypeEnum: string
{
use EnumHasOptions;
case Deposit = 'واریز';
case Withdrawal = 'برداشت';
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Enums;
enum WithdrawRequestStatusEnum: string
{
use EnumHasOptions;
case Pending = 'در انتظار بررسی';
case Approved = 'تایید شده';
case Rejected = 'رد شده';
}
@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Enums\WithdrawRequestStatusEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\WithdrawRejectRequest;
use App\Http\Resources\Admin\WithdrawRequestResource;
use App\Models\WithdrawRequest;
use App\Services\Wallet\WalletService;
use App\Services\Wallet\WithdrawRequestService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class WithdrawRequestController extends Controller
{
protected WithdrawRequestService $withdrawRequestService;
protected WalletService $walletService;
public function __construct(WithdrawRequestService $withdrawRequestService, WalletService $walletService)
{
$this->withdrawRequestService = $withdrawRequestService;
$this->walletService = $walletService;
}
public function index(Request $request)
{
$lists = WithdrawRequest::with(['user', 'bankCard', 'reviewedBy'])
->when(isset($request->search), function ($query) use ($request) {
$query->whereHas('user', function ($userQuery) use ($request) {
$userQuery->where('fullname', 'LIKE', "%{$request->search}%")
->orWhere('phone', 'LIKE', "%{$request->search}%")
->orWhere('email', 'LIKE', "%{$request->search}%");
});
})
->when(isset($request->status) && $request->status != 'همه وضعیت ها', function ($query) use ($request) {
$query->where('status', $request->status);
})
->latest()
->paginate(20)
->withQueryString()
->onEachSide(1);
$pending = WithdrawRequest::where('status', WithdrawRequestStatusEnum::Pending->value);
return Inertia::admin('WithdrawRequests/index', [
'withdrawRequests' => WithdrawRequestResource::collection($lists),
'stats' => [
'pending_count' => (clone $pending)->count(),
'pending_amount' => (int) (clone $pending)->sum('amount'),
'system_wallet_balance' => $this->walletService->system()->balance,
],
'search' => $request->search,
'status' => $request->status,
]);
}
public function approve(WithdrawRequest $withdrawRequest)
{
$this->withdrawRequestService->approve($withdrawRequest, Auth::user());
session()->flash('success', 'درخواست برداشت تایید شد و مبلغ به ولت سایت منتقل شد.');
return response(null, 200);
}
public function reject(WithdrawRejectRequest $request, WithdrawRequest $withdrawRequest)
{
$this->withdrawRequestService->reject($withdrawRequest, Auth::user(), $request->validated('note'));
session()->flash('success', 'درخواست برداشت رد شد.');
return response(null, 200);
}
}
@@ -3,7 +3,10 @@
namespace App\Http\Controllers\Client\Doctor;
use App\Http\Controllers\Controller;
use App\Http\Requests\Client\CaseRejectRequest;
use App\Http\Resources\Client\CaseDetailResource;
use App\Http\Resources\Client\CaseResource;
use App\Models\Document;
use App\Services\Document\CaseService;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
@@ -28,4 +31,25 @@ class CaseController extends Controller
'cases' => CaseResource::collection($cases),
]);
}
public function show(Document $case)
{
abort_unless($this->caseService->canAccess($case, Auth::user()), 403);
$case->load(['pet', 'requester', 'media', 'reports']);
return Inertia::client('Dashboard/Cases/show', [
'case' => (new CaseDetailResource($case))->resolve(),
'canReport' => $this->caseService->canReport($case, Auth::user()),
]);
}
public function reject(CaseRejectRequest $request, Document $case)
{
$this->caseService->reject($case, Auth::user(), $request->validated('reason'));
session()->flash('success', 'درخواست رد شد و مبلغ فاکتور به کیف پول درخواست‌کننده بازگردانده شد.');
return redirect()->route('dashboard.cases.index');
}
}
@@ -23,6 +23,7 @@ class DashboardController extends Controller
return Inertia::client('Dashboard/Dashboard', [
'cases' => CaseResource::collection($cases),
'stats' => $this->caseService->statsForUser(Auth::user()),
]);
}
}
@@ -32,8 +32,6 @@ class DocumentController extends Controller
public function show(Document $document)
{
return Inertia::client('Dashboard/Documents/show', [
'document' => $document,
]);
return redirect()->route('dashboard.cases.show', $document);
}
}
@@ -3,13 +3,53 @@
namespace App\Http\Controllers\Client\Doctor;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Http\Requests\Client\ReportSubmitRequest;
use App\Http\Resources\Client\ReportResource;
use App\Models\Document;
use App\Models\Report;
use App\Services\Document\CaseService;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class ReportController extends Controller
{
protected CaseService $caseService;
public function __construct(CaseService $caseService)
{
$this->caseService = $caseService;
}
public function index()
{
return Inertia::client('Dashboard/Reports/Index');
$reports = Report::with(['document', 'doctor'])
->where(function ($query) {
$query->where('doctor_id', Auth::id())
->orWhereHas('document', fn ($documentQuery) => $documentQuery->where('requester_id', Auth::id()));
})
->latest('submitted_at')
->paginate(10)
->withQueryString()
->onEachSide(1);
return Inertia::client('Dashboard/Reports/Index', [
'reports' => ReportResource::collection($reports),
]);
}
public function store(ReportSubmitRequest $request)
{
$document = Document::findOrFail($request->validated('document_id'));
$this->caseService->submitReport(
$document,
Auth::user(),
$request->validated(),
$request->file('files', [])
);
session()->flash('success', 'گزارش با موفقیت ثبت شد.');
return redirect()->route('dashboard.cases.index');
}
}
@@ -45,7 +45,7 @@ class FastRequestController extends Controller
);
return response()->json([
'document' => new FastRequestResource($document->refresh()->load('pet')),
'document' => new FastRequestResource($document->refresh()->load(['pet', 'invoice'])),
]);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Client;
use App\Http\Controllers\Controller;
use App\Http\Resources\Client\InvoiceResource;
use App\Models\Invoice;
use App\Services\Wallet\WalletService;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class InvoiceController extends Controller
{
protected WalletService $walletService;
public function __construct(WalletService $walletService)
{
$this->walletService = $walletService;
}
public function index()
{
$invoices = Auth::user()->invoices()
->with('document')
->latest()
->paginate(10)
->withQueryString()
->onEachSide(1);
return Inertia::client('Dashboard/Invoices/Index', [
'invoices' => InvoiceResource::collection($invoices),
]);
}
public function payWithWallet(Invoice $invoice)
{
abort_unless($invoice->user_id === Auth::id(), 403);
$invoice = $this->walletService->payInvoice(Auth::user(), $invoice);
return response()->json([
'invoice' => new InvoiceResource($invoice),
]);
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers\Client;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use App\Enums\PaymentPurposeEnum;
use App\Enums\PaymentStatusEnum;
use App\Enums\WalletTransactionReasonEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Client\PaymentRequest;
use App\Http\Resources\Client\PaymentResource;
use App\Models\Invoice;
use App\Models\Payment;
use App\Services\Invoice\InvoiceService;
use App\Services\Payment\ZibalPaymentService;
use App\Services\Wallet\WalletService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class PaymentController extends Controller
{
protected ZibalPaymentService $zibalPaymentService;
protected WalletService $walletService;
protected InvoiceService $invoiceService;
public function __construct(ZibalPaymentService $zibalPaymentService, WalletService $walletService, InvoiceService $invoiceService)
{
$this->zibalPaymentService = $zibalPaymentService;
$this->walletService = $walletService;
$this->invoiceService = $invoiceService;
}
public function store(PaymentRequest $request)
{
$invoice = Invoice::where('id', $request->invoice_id)
->where('user_id', Auth::id())
->where('status', InvoiceStatusEnum::Pending->value)
->firstOrFail();
$payment = $this->zibalPaymentService->request(Auth::user(), $invoice->amount, $invoice->number, [
'purpose' => PaymentPurposeEnum::Invoice->value,
'invoice_id' => $invoice->id,
]);
return response()->json([
'payment' => new PaymentResource($payment),
'url' => $this->zibalPaymentService->paymentUrl($payment),
]);
}
public function callback(Request $request)
{
$payment = Payment::where('track_id', $request->trackId)->first();
if (!$payment) {
return redirect()->route('dashboard.index');
}
$wasPending = $payment->status === PaymentStatusEnum::Pending;
if ($request->success == 1) {
$payment = $this->zibalPaymentService->verify($payment);
} else {
$payment = $this->zibalPaymentService->markFailed($payment);
}
if ($wasPending && $payment->status === PaymentStatusEnum::Paid) {
$this->settle($payment);
}
session()->flash('payment_result', [
'status' => $payment->status === PaymentStatusEnum::Paid ? 'paid' : 'failed',
'track_id' => $payment->track_id,
'purpose' => $payment->purpose->name,
]);
return redirect()->route('dashboard.index');
}
protected function settle(Payment $payment): void
{
if ($payment->purpose === PaymentPurposeEnum::WalletTopUp) {
$this->walletService->deposit($payment->payer, $payment->amount, WalletTransactionReasonEnum::TopUp, $payment);
return;
}
$invoice = $payment->invoice;
if ($invoice && $invoice->status === InvoiceStatusEnum::Pending) {
$this->invoiceService->markPaid($invoice, InvoicePaymentMethodEnum::Gateway);
$this->walletService->recordGatewayInvoicePayment($invoice, $payment);
}
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Client;
use App\Http\Controllers\Controller;
use App\Http\Requests\Client\ProfileUpdateRequest;
use App\Models\Medium;
use App\Services\FileStorageService;
use Illuminate\Support\Facades\Auth;
class ProfileController extends Controller
{
protected FileStorageService $fileStorageService;
public function __construct(FileStorageService $fileStorageService)
{
$this->fileStorageService = $fileStorageService;
}
public function update(ProfileUpdateRequest $request)
{
$user = Auth::user();
$data = $request->safe()->except('avatar');
if ($request->hasFile('avatar')) {
if (!empty($user->profile_url)) {
if ($medium = Medium::where('url', $user->profile_url)->first()) {
$this->fileStorageService->deleteMedium($medium);
}
}
$medium = $this->fileStorageService->storeFile($request->file('avatar'), 'public', 'profile');
$data['profile_url'] = $medium->url;
}
$user->update($data);
session()->flash('success', 'حساب کاربری شما با موفقیت ویرایش شد.');
return redirect()->back();
}
}
@@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers\Client;
use App\Enums\PaymentPurposeEnum;
use App\Http\Controllers\Controller;
use App\Http\Requests\Client\BankCardStoreRequest;
use App\Http\Requests\Client\WalletDepositRequest;
use App\Http\Requests\Client\WithdrawRequestStoreRequest;
use App\Http\Resources\Client\BankCardResource;
use App\Http\Resources\Client\PaymentResource;
use App\Http\Resources\Client\WalletTransactionResource;
use App\Http\Resources\Client\WithdrawRequestResource;
use App\Models\BankCard;
use App\Services\Payment\ZibalPaymentService;
use App\Services\Wallet\WalletService;
use App\Services\Wallet\WithdrawRequestService;
use Illuminate\Support\Facades\Auth;
use Inertia\Inertia;
class WalletController extends Controller
{
protected WalletService $walletService;
protected ZibalPaymentService $zibalPaymentService;
protected WithdrawRequestService $withdrawRequestService;
public function __construct(WalletService $walletService, ZibalPaymentService $zibalPaymentService, WithdrawRequestService $withdrawRequestService)
{
$this->walletService = $walletService;
$this->zibalPaymentService = $zibalPaymentService;
$this->withdrawRequestService = $withdrawRequestService;
}
public function index()
{
$wallet = $this->walletService->forUser(Auth::user());
$transactions = $wallet->transactions()
->with('invoice')
->latest()
->paginate(10)
->withQueryString()
->onEachSide(1);
$withdrawRequests = Auth::user()->withdrawRequests()
->with('bankCard')
->latest()
->take(10)
->get();
return Inertia::client('Dashboard/Wallet/Index', [
'transactions' => WalletTransactionResource::collection($transactions),
'withdrawRequests' => WithdrawRequestResource::collection($withdrawRequests)->resolve(),
'bankCards' => BankCardResource::collection(Auth::user()->bankCards()->latest()->get())->resolve(),
]);
}
public function deposit(WalletDepositRequest $request)
{
$payment = $this->zibalPaymentService->request(
Auth::user(),
$request->validated('amount'),
'wallet-' . Auth::id() . '-' . now()->timestamp,
['purpose' => PaymentPurposeEnum::WalletTopUp->value]
);
return response()->json([
'payment' => new PaymentResource($payment),
'url' => $this->zibalPaymentService->paymentUrl($payment),
]);
}
public function storeWithdrawRequest(WithdrawRequestStoreRequest $request)
{
$bankCard = BankCard::findOrFail($request->validated('bank_card_id'));
$this->withdrawRequestService->request(Auth::user(), $request->validated('amount'), $bankCard);
session()->flash('success', 'درخواست برداشت شما ثبت شد و در انتظار بررسی است.');
return redirect()->route('dashboard.wallet.index');
}
public function storeBankCard(BankCardStoreRequest $request)
{
Auth::user()->bankCards()->create($request->validated());
session()->flash('success', 'کارت بانکی با موفقیت ثبت شد.');
return redirect()->route('dashboard.wallet.index');
}
public function destroyBankCard(BankCard $bankCard)
{
abort_unless($bankCard->user_id === Auth::id(), 403);
$bankCard->delete();
return redirect()->route('dashboard.wallet.index');
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Admin;
use Illuminate\Foundation\Http\FormRequest;
class WithdrawRejectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'note' => ['nullable', 'string', 'max:1000'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class BankCardStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'card_number' => ['required', 'digits:16'],
'holder_name' => ['required', 'string', 'max:255'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class CaseRejectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'reason' => ['required', 'string', 'max:1000'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class PaymentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'invoice_id' => ['required', 'integer', 'exists:invoices,id'],
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class ProfileUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'fullname' => ['required', 'string', 'max:255'],
'birthdate' => ['nullable', 'date'],
'national_code' => ['required', 'string', 'max:20'],
'email' => ['nullable', 'email', 'max:255'],
'province' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:255'],
'address' => ['nullable', 'string', 'max:500'],
'avatar' => ['nullable', 'image', 'mimes:png,jpg,jpeg', 'max:10240'],
];
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class ReportSubmitRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'document_id' => ['required', 'exists:documents,id'],
'technique' => ['nullable', 'string', 'max:10000'],
'finding' => ['required', 'string', 'max:10000'],
'diagnose' => ['nullable', 'string', 'max:10000'],
'recommendation' => ['nullable', 'string', 'max:10000'],
'files' => ['nullable', 'array'],
'files.*' => ['file', 'image', 'max:10240'],
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class WalletDepositRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:10000', 'max:500000000'],
];
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Http\Requests\Client;
use Illuminate\Foundation\Http\FormRequest;
class WithdrawRequestStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'amount' => ['required', 'integer', 'min:10000', 'max:500000000'],
'bank_card_id' => ['required', 'exists:bank_cards,id'],
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Resources\Admin;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WithdrawRequestResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'amount' => $this->amount,
'status' => $this->status,
'admin_note' => $this->admin_note,
'user' => $this->relationLoaded('user') ? $this->user->only(['id', 'fullname', 'level', 'phone']) : null,
'bank_card' => $this->relationLoaded('bankCard') && $this->bankCard
? $this->bankCard->only(['id', 'card_number', 'holder_name'])
: null,
'reviewed_by' => $this->relationLoaded('reviewedBy') && $this->reviewedBy ? $this->reviewedBy->only(['id', 'fullname']) : null,
'created_at' => verta($this->created_at)->format('%Y/%m/%d - H:i'),
'reviewed_at' => $this->reviewed_at ? verta($this->reviewed_at)->format('%Y/%m/%d - H:i') : null,
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class BankCardResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'card_number' => $this->card_number,
'holder_name' => $this->holder_name,
];
}
}
@@ -0,0 +1,76 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class CaseDetailResource extends JsonResource
{
public function toArray(Request $request): array
{
$report = $this->reports->sortByDesc('submitted_at')->first();
return [
'id' => $this->id,
'code' => $this->code,
'status' => $this->status,
'modality' => $this->modality,
'priority' => $this->priority,
'note' => $this->note,
'rejection_reason' => $this->rejection_reason,
'submitted_at' => $this->submitted_at ? verta($this->submitted_at)->format('%Y/%m/%d - H:i') : null,
'processing_deadline' => $this->processing_deadline?->toIso8601String(),
'pet' => $this->whenLoaded('pet', fn () => [
'name' => $this->pet->name,
'type' => $this->pet->type,
'gender' => $this->pet->gender,
'breed' => $this->pet->breed,
'age' => $this->pet->birthday ? (int) $this->pet->birthday->diffInYears(now()) : null,
'weight' => $this->pet->weight,
'sterilized' => $this->pet->sterilized,
]),
'requester' => $this->whenLoaded('requester', fn () => [
'level' => $this->requester->level,
'fullname' => $this->requester->fullname,
'phone' => $this->requester->phone,
'specialty' => $this->requester->specialty,
'clinic_name' => $this->requester->clinic_name,
'clinic_phone' => $this->requester->clinic_phone,
'province' => $this->requester->province,
'city' => $this->requester->city,
]),
'primary_files' => $this->mediaFiles('primary'),
'history_files' => $this->mediaFiles('history'),
'report' => $report ? [
'id' => $report->id,
'status' => $report->status,
'technique' => $report->technique,
'finding' => $report->finding,
'diagnose' => $report->diagnose,
'recommendation' => $report->recommendation,
'submitted_at' => $report->submitted_at ? verta($report->submitted_at)->format('%Y/%m/%d - H:i') : null,
'attachments' => $report->attachments->map(fn ($medium) => $this->mediumPayload($medium))->values(),
] : null,
];
}
protected function mediaFiles(string $collection): array
{
return $this->media
->filter(fn ($medium) => $medium->pivot->collection === $collection)
->map(fn ($medium) => $this->mediumPayload($medium))
->values()
->all();
}
protected function mediumPayload($medium): array
{
return [
'id' => $medium->id,
'filename' => $medium->filename,
'url' => $medium->url,
'mime_type' => $medium->mime_type,
];
}
}
@@ -2,7 +2,6 @@
namespace App\Http\Resources\Client;
use App\Enums\DocumentModalityEnum;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -17,10 +16,7 @@ class FastRequestResource extends JsonResource
'status' => $this->status,
'priority' => $this->priority,
'pet' => new PetArchiveResource($this->whenLoaded('pet')),
'invoice' => [
'number' => str_pad((string) $this->id, 5, '0', STR_PAD_LEFT),
'amount' => DocumentModalityEnum::from($this->modality)->price(),
],
'invoice' => new InvoiceResource($this->whenLoaded('invoice')),
];
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class InvoiceResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'number' => $this->number,
'amount' => $this->amount,
'status' => $this->status,
'payment_method' => $this->payment_method,
'paid_at' => $this->paid_at,
'created_at' => $this->created_at,
'document' => $this->whenLoaded('document', fn () => [
'id' => $this->document->id,
'code' => $this->document->code,
'modality' => $this->document->modality,
]),
];
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PaymentResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'track_id' => $this->track_id,
'amount' => $this->amount,
'status' => $this->status,
'ref_number' => $this->ref_number,
];
}
}
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ReportResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'status' => $this->status,
'submitted_at' => $this->submitted_at ? verta($this->submitted_at)->format('%Y/%m/%d - H:i') : null,
'document' => $this->whenLoaded('document', fn () => [
'id' => $this->document->id,
'code' => $this->document->code,
'modality' => $this->document->modality,
'status' => $this->document->status,
]),
'doctor' => $this->whenLoaded('doctor', fn () => optional($this->doctor)->fullname),
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WalletTransactionResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'type' => $this->type,
'reason' => $this->reason,
'amount' => $this->amount,
'balance_after' => $this->balance_after,
'description' => $this->description,
'via_gateway' => $this->payment_id !== null,
'invoice_number' => $this->whenLoaded('invoice', fn () => $this->invoice?->number),
'created_at' => verta($this->created_at)->format('%Y/%m/%d - H:i'),
];
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class WithdrawRequestResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'amount' => $this->amount,
'status' => $this->status,
'admin_note' => $this->admin_note,
'bank_card' => $this->relationLoaded('bankCard') && $this->bankCard
? $this->bankCard->only(['id', 'card_number', 'holder_name'])
: null,
'created_at' => verta($this->created_at)->format('%Y/%m/%d - H:i'),
'reviewed_at' => $this->reviewed_at ? verta($this->reviewed_at)->format('%Y/%m/%d - H:i') : null,
];
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class BankCard extends Model
{
use HasFactory;
protected $fillable = [
'card_number',
'holder_name',
'user_id',
];
protected $casts = [
'user_id' => 'integer',
];
public function user()
{
return $this->belongsTo(User::class);
}
}
+5
View File
@@ -56,6 +56,11 @@ class Document extends Model
return $this->belongsTo(User::class);
}
public function invoice()
{
return $this->hasOne(Invoice::class);
}
public function assignments()
{
return $this->hasMany(DocumentAssignment::class);
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Invoice extends Model
{
use HasFactory;
protected $fillable = [
'number',
'amount',
'status',
'payment_method',
'paid_at',
'document_id',
'user_id',
];
protected $casts = [
'status' => InvoiceStatusEnum::class,
'payment_method' => InvoicePaymentMethodEnum::class,
'amount' => 'integer',
'paid_at' => 'datetime',
'document_id' => 'integer',
'user_id' => 'integer',
];
public function document()
{
return $this->belongsTo(Document::class);
}
public function user()
{
return $this->belongsTo(User::class);
}
public function payments()
{
return $this->hasMany(Payment::class);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Models;
use App\Enums\PaymentPurposeEnum;
use App\Enums\PaymentStatusEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Payment extends Model
{
use HasFactory;
protected $fillable = [
'track_id',
'amount',
'status',
'purpose',
'ref_number',
'verified_at',
'invoice_id',
'payer_id',
];
protected $casts = [
'status' => PaymentStatusEnum::class,
'purpose' => PaymentPurposeEnum::class,
'amount' => 'integer',
'verified_at' => 'datetime',
'invoice_id' => 'integer',
'payer_id' => 'integer',
];
public function invoice()
{
return $this->belongsTo(Invoice::class);
}
public function payer()
{
return $this->belongsTo(User::class, 'payer_id');
}
}
+5 -1
View File
@@ -17,6 +17,10 @@ class Report extends Model
'medium_id',
'status',
'body',
'technique',
'finding',
'diagnose',
'recommendation',
'submitted_at',
];
@@ -43,7 +47,7 @@ class Report extends Model
'media_assignments',
'mediable_id',
'media_id'
)->withPivot('collection')->withTimestamps();
)->withPivot('collection');
}
public function medium()
+22
View File
@@ -22,6 +22,7 @@ class User extends Authenticatable
protected $fillable = [
'username',
'fullname',
'birthdate',
'national_code',
'level',
'email',
@@ -56,6 +57,7 @@ class User extends Authenticatable
{
return [
'email_verified_at' => 'datetime',
'birthdate' => 'date:Y-m-d',
'password' => 'hashed',
];
}
@@ -76,4 +78,24 @@ class User extends Authenticatable
{
return $this->hasMany(Shift::class);
}
public function wallet()
{
return $this->hasOne(Wallet::class);
}
public function invoices()
{
return $this->hasMany(Invoice::class);
}
public function bankCards()
{
return $this->hasMany(BankCard::class);
}
public function withdrawRequests()
{
return $this->hasMany(WithdrawRequest::class);
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Wallet extends Model
{
use HasFactory;
protected $fillable = [
'balance',
'user_id',
];
protected $casts = [
'balance' => 'integer',
'user_id' => 'integer',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function transactions()
{
return $this->hasMany(WalletTransaction::class);
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use App\Enums\WalletTransactionReasonEnum;
use App\Enums\WalletTransactionTypeEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class WalletTransaction extends Model
{
use HasFactory;
protected $fillable = [
'type',
'reason',
'amount',
'balance_after',
'description',
'wallet_id',
'invoice_id',
'payment_id',
'withdraw_request_id',
];
protected $casts = [
'type' => WalletTransactionTypeEnum::class,
'reason' => WalletTransactionReasonEnum::class,
'amount' => 'integer',
'balance_after' => 'integer',
'wallet_id' => 'integer',
'invoice_id' => 'integer',
'payment_id' => 'integer',
'withdraw_request_id' => 'integer',
];
public function wallet()
{
return $this->belongsTo(Wallet::class);
}
public function invoice()
{
return $this->belongsTo(Invoice::class);
}
public function payment()
{
return $this->belongsTo(Payment::class);
}
public function withdrawRequest()
{
return $this->belongsTo(WithdrawRequest::class);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use App\Enums\WithdrawRequestStatusEnum;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class WithdrawRequest extends Model
{
use HasFactory;
protected $fillable = [
'amount',
'status',
'admin_note',
'reviewed_at',
'reviewed_by',
'user_id',
'bank_card_id',
];
protected $casts = [
'status' => WithdrawRequestStatusEnum::class,
'amount' => 'integer',
'reviewed_at' => 'datetime',
'reviewed_by' => 'integer',
'user_id' => 'integer',
'bank_card_id' => 'integer',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function bankCard()
{
return $this->belongsTo(BankCard::class);
}
public function reviewedBy()
{
return $this->belongsTo(User::class, 'reviewed_by');
}
}
+2
View File
@@ -45,6 +45,7 @@ class AppServiceProvider extends ServiceProvider
return [
'User' => $user,
'permissions' => $permissions,
'wallet_balance' => (int) ($user?->wallet?->balance ?? 0),
];
},
]);
@@ -52,6 +53,7 @@ class AppServiceProvider extends ServiceProvider
return [
'success' => Session::get('success'),
'error' => Session::get('error'),
'payment_result' => Session::get('payment_result'),
];
});
}
+152 -10
View File
@@ -3,25 +3,167 @@
namespace App\Services\Document;
use App\Enums\DocumentStatusEnum;
use App\Enums\InvoiceStatusEnum;
use App\Enums\MediaCollectionEnum;
use App\Enums\ReportStatusEnum;
use App\Enums\UserEnum;
use App\Enums\WalletTransactionReasonEnum;
use App\Models\Document;
use App\Models\Report;
use App\Models\User;
use App\Services\FileStorageService;
use App\Services\Shift\ShiftScheduleService;
use App\Services\Wallet\WalletService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class CaseService
{
protected FileStorageService $fileStorageService;
protected WalletService $walletService;
protected ShiftScheduleService $shiftScheduleService;
public function __construct(FileStorageService $fileStorageService, WalletService $walletService, ShiftScheduleService $shiftScheduleService)
{
$this->fileStorageService = $fileStorageService;
$this->walletService = $walletService;
$this->shiftScheduleService = $shiftScheduleService;
}
public function pendingForUser(User $user): Builder
{
$statuses = $this->isDoctor($user)
? [DocumentStatusEnum::InProgress->value]
: [DocumentStatusEnum::Registered->value, DocumentStatusEnum::InProgress->value];
return Document::with(['pet', 'requester'])
->whereIn('status', [
DocumentStatusEnum::Registered->value,
DocumentStatusEnum::InProgress->value,
])
->where(function ($query) use ($user) {
$query->where('requester_id', $user->id)
->orWhereHas('assignments', function ($assignmentQuery) use ($user) {
$assignmentQuery->where('user_id', $user->id);
});
})
->whereIn('status', $statuses)
->where(fn ($query) => $this->scopeVisibleTo($query, $user))
->latest('submitted_at');
}
public function canAccess(Document $document, User $user): bool
{
return $document->requester_id === $user->id
|| $document->assignments()->where('user_id', $user->id)->exists();
}
public function canReport(Document $document, User $user): bool
{
return $this->isDoctor($user)
&& $document->status === DocumentStatusEnum::InProgress
&& $document->assignments()->where('user_id', $user->id)->exists();
}
public function submitReport(Document $document, User $doctor, array $data, array $files = []): Report
{
return DB::transaction(function () use ($document, $doctor, $data, $files) {
if (!$this->canReport($document, $doctor)) {
throw ValidationException::withMessages(['document' => 'امکان ثبت گزارش برای این پرونده وجود ندارد.']);
}
$report = $document->reports()->create([
'doctor_id' => $doctor->id,
'status' => ReportStatusEnum::Approved->value,
'technique' => $data['technique'] ?? null,
'finding' => $data['finding'] ?? null,
'diagnose' => $data['diagnose'] ?? null,
'recommendation' => $data['recommendation'] ?? null,
'submitted_at' => now(),
]);
foreach ($files as $file) {
$medium = $this->fileStorageService->storeFile($file);
$report->media()->attach($medium->id, ['collection' => MediaCollectionEnum::Attachments->value]);
}
$document->update([
'status' => DocumentStatusEnum::Completed->value,
'finalized_at' => now(),
'finalized_by' => $doctor->id,
]);
return $report;
});
}
public function reject(Document $document, User $doctor, string $reason): Document
{
return DB::transaction(function () use ($document, $doctor, $reason) {
if (!$this->canReport($document, $doctor)) {
throw ValidationException::withMessages(['document' => 'امکان رد این پرونده وجود ندارد.']);
}
$document->update([
'status' => DocumentStatusEnum::Rejected->value,
'finalized_at' => now(),
'finalized_by' => $doctor->id,
'rejection_reason' => $reason,
]);
$invoice = $document->invoice;
if ($invoice && $invoice->status === InvoiceStatusEnum::Paid) {
$invoice->update(['status' => InvoiceStatusEnum::Canceled->value]);
$this->walletService->deposit($document->requester, $invoice->amount, WalletTransactionReasonEnum::Refund);
}
return $document;
});
}
public function statsForUser(User $user): array
{
$monthStart = now()->startOfMonth();
$isDoctor = $this->isDoctor($user);
$reviewedQuery = Document::where('status', DocumentStatusEnum::Completed->value)
->where('finalized_at', '>=', $monthStart);
if ($isDoctor) {
$reviewedQuery->where('finalized_by', $user->id);
} else {
$reviewedQuery->where('requester_id', $user->id);
}
$amountQuery = $isDoctor
? Document::where('documents.finalized_by', $user->id)
->where('documents.status', DocumentStatusEnum::Completed->value)
->where('documents.finalized_at', '>=', $monthStart)
->join('invoices', 'invoices.document_id', '=', 'documents.id')
->where('invoices.status', InvoiceStatusEnum::Paid->value)
: Document::where('documents.requester_id', $user->id)
->join('invoices', 'invoices.document_id', '=', 'documents.id')
->where('invoices.status', InvoiceStatusEnum::Paid->value)
->where('invoices.paid_at', '>=', $monthStart);
$nextShift = $isDoctor ? $this->shiftScheduleService->nextShiftStart($user, now()) : null;
return [
'pending_cases' => $this->pendingForUser($user)->count(),
'monthly_reviewed' => $reviewedQuery->count(),
'monthly_amount' => (int) $amountQuery->sum('invoices.amount'),
'next_shift_hours' => $nextShift ? (int) ceil(now()->diffInMinutes($nextShift, true) / 60) : null,
'is_doctor' => $isDoctor,
];
}
protected function scopeVisibleTo($query, User $user): void
{
if ($this->isDoctor($user)) {
$query->whereHas('assignments', fn ($assignmentQuery) => $assignmentQuery->where('user_id', $user->id));
return;
}
$query->where('requester_id', $user->id)
->orWhereHas('assignments', fn ($assignmentQuery) => $assignmentQuery->where('user_id', $user->id));
}
protected function isDoctor(User $user): bool
{
return $user->level === UserEnum::Doctor->value;
}
}
+7 -1
View File
@@ -10,6 +10,7 @@ use App\Models\Document;
use App\Models\Pet;
use App\Models\User;
use App\Services\FileStorageService;
use App\Services\Invoice\InvoiceService;
use App\Services\Shift\ShiftScheduleService;
use Illuminate\Support\Facades\DB;
@@ -19,10 +20,13 @@ class FastRequestService
protected ShiftScheduleService $shiftScheduleService;
public function __construct(FileStorageService $fileStorageService, ShiftScheduleService $shiftScheduleService)
protected InvoiceService $invoiceService;
public function __construct(FileStorageService $fileStorageService, ShiftScheduleService $shiftScheduleService, InvoiceService $invoiceService)
{
$this->fileStorageService = $fileStorageService;
$this->shiftScheduleService = $shiftScheduleService;
$this->invoiceService = $invoiceService;
}
public function create(User $requester, array $data, array $historyFiles = [], array $scanFiles = []): Document
@@ -39,6 +43,8 @@ class FastRequestService
'requester_id' => $requester->id,
]);
$this->invoiceService->createForDocument($document);
$this->attachFiles($document, $historyFiles, MediaCollectionEnum::History);
$this->attachFiles($document, $scanFiles, MediaCollectionEnum::Primary);
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Services\Invoice;
use App\Enums\DocumentModalityEnum;
use App\Enums\DocumentStatusEnum;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use App\Models\Document;
use App\Models\Invoice;
class InvoiceService
{
public function createForDocument(Document $document): Invoice
{
$invoice = Invoice::create([
'number' => 'pending',
'amount' => DocumentModalityEnum::from($document->modality)->price(),
'status' => InvoiceStatusEnum::Pending->value,
'document_id' => $document->id,
'user_id' => $document->requester_id,
]);
$invoice->update(['number' => str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT)]);
return $invoice;
}
public function markPaid(Invoice $invoice, InvoicePaymentMethodEnum $method): Invoice
{
$invoice->update([
'status' => InvoiceStatusEnum::Paid->value,
'payment_method' => $method->value,
'paid_at' => now(),
]);
$document = $invoice->document;
if ($document && $document->status === DocumentStatusEnum::Registered) {
$document->update([
'status' => DocumentStatusEnum::InProgress->value,
'processing_deadline' => now()->addHours($document->priority->processingHours()),
]);
}
return $invoice;
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Services\Payment;
use App\Enums\PaymentStatusEnum;
use App\Models\Payment;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class ZibalPaymentService
{
protected const API_URL = 'https://gateway.zibal.ir/v1';
protected const START_URL = 'https://gateway.zibal.ir/start';
public function request(User $payer, int $amount, string $orderId, array $attributes = []): Payment
{
$response = Http::post(self::API_URL . '/request', [
'merchant' => $this->merchant(),
'amount' => $amount * 10,
'callbackUrl' => $this->callbackUrl(),
'orderId' => $orderId,
])->json();
if (($response['result'] ?? null) !== 100) {
throw new RuntimeException('خطا در ایجاد درخواست پرداخت: ' . ($response['message'] ?? 'پاسخ نامعتبر از درگاه'));
}
return Payment::create(array_merge([
'track_id' => (string) $response['trackId'],
'amount' => $amount,
'status' => PaymentStatusEnum::Pending->value,
'payer_id' => $payer->id,
], $attributes));
}
public function paymentUrl(Payment $payment): string
{
return self::START_URL . '/' . $payment->track_id;
}
public function verify(Payment $payment): Payment
{
if ($payment->status !== PaymentStatusEnum::Pending) {
return $payment;
}
$response = Http::post(self::API_URL . '/verify', [
'merchant' => $this->merchant(),
'trackId' => $payment->track_id,
])->json();
if (in_array($response['result'] ?? null, [100, 201], true)) {
$payment->update([
'status' => PaymentStatusEnum::Paid->value,
'ref_number' => $response['refNumber'] ?? null,
'verified_at' => now(),
]);
} else {
$payment->update(['status' => PaymentStatusEnum::Failed->value]);
}
return $payment;
}
public function markFailed(Payment $payment): Payment
{
if ($payment->status === PaymentStatusEnum::Pending) {
$payment->update(['status' => PaymentStatusEnum::Failed->value]);
}
return $payment;
}
protected function merchant(): string
{
return config('services.zibal.sandbox') ? 'zibal' : config('services.zibal.merchant');
}
protected function callbackUrl(): string
{
return config('services.zibal.callback_url') ?: route('dashboard.payments.callback');
}
}
@@ -81,6 +81,33 @@ class ShiftScheduleService
->first();
}
public function nextShiftStart(User $doctor, Carbon $from): ?Carbon
{
$shifts = Shift::active()->where('user_id', $doctor->id)->get();
return $shifts
->map(function (Shift $shift) use ($from) {
for ($offset = 0; $offset <= 7; $offset++) {
$day = $from->copy()->addDays($offset)->startOfDay();
if (!$this->coversDate($shift, $day)) {
continue;
}
$start = $day->copy()->addMinutes($this->toMinutes($shift->start_time));
if ($start->greaterThanOrEqualTo($from)) {
return $start;
}
}
return null;
})
->filter()
->sort()
->first();
}
public function hasConflict(array $data, ?int $ignoreId = null): bool
{
if (empty($data['user_id'])) {
+113
View File
@@ -0,0 +1,113 @@
<?php
namespace App\Services\Wallet;
use App\Enums\InvoicePaymentMethodEnum;
use App\Enums\InvoiceStatusEnum;
use App\Enums\WalletTransactionReasonEnum;
use App\Enums\WalletTransactionTypeEnum;
use App\Models\Invoice;
use App\Models\Payment;
use App\Models\User;
use App\Models\Wallet;
use App\Models\WalletTransaction;
use App\Models\WithdrawRequest;
use App\Services\Invoice\InvoiceService;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class WalletService
{
protected InvoiceService $invoiceService;
public function __construct(InvoiceService $invoiceService)
{
$this->invoiceService = $invoiceService;
}
public function forUser(User $user): Wallet
{
return Wallet::firstOrCreate(['user_id' => $user->id]);
}
public function system(): Wallet
{
return Wallet::firstOrCreate(['user_id' => null]);
}
public function deposit(User $user, int $amount, WalletTransactionReasonEnum $reason, ?Payment $payment = null): WalletTransaction
{
return $this->depositToWallet($this->forUser($user), $amount, $reason, $payment);
}
public function depositToWallet(Wallet $wallet, int $amount, WalletTransactionReasonEnum $reason, ?Payment $payment = null, ?WithdrawRequest $withdrawRequest = null): WalletTransaction
{
return DB::transaction(function () use ($wallet, $amount, $reason, $payment, $withdrawRequest) {
$wallet = $this->lockedWallet($wallet);
$wallet->update(['balance' => $wallet->balance + $amount]);
return $wallet->transactions()->create([
'type' => WalletTransactionTypeEnum::Deposit->value,
'reason' => $reason->value,
'amount' => $amount,
'balance_after' => $wallet->balance,
'payment_id' => $payment?->id,
'withdraw_request_id' => $withdrawRequest?->id,
]);
});
}
public function withdraw(User $user, int $amount, WalletTransactionReasonEnum $reason, ?Invoice $invoice = null, ?WithdrawRequest $withdrawRequest = null): WalletTransaction
{
return DB::transaction(function () use ($user, $amount, $reason, $invoice, $withdrawRequest) {
$wallet = $this->lockedWallet($this->forUser($user));
if ($wallet->balance < $amount) {
throw ValidationException::withMessages(['amount' => 'موجودی کیف پول کافی نیست']);
}
$wallet->update(['balance' => $wallet->balance - $amount]);
return $wallet->transactions()->create([
'type' => WalletTransactionTypeEnum::Withdrawal->value,
'reason' => $reason->value,
'amount' => $amount,
'balance_after' => $wallet->balance,
'invoice_id' => $invoice?->id,
'withdraw_request_id' => $withdrawRequest?->id,
]);
});
}
public function payInvoice(User $user, Invoice $invoice): Invoice
{
return DB::transaction(function () use ($user, $invoice) {
if ($invoice->status !== InvoiceStatusEnum::Pending) {
throw ValidationException::withMessages(['invoice' => 'این فاکتور قابل پرداخت نیست']);
}
$this->withdraw($user, $invoice->amount, WalletTransactionReasonEnum::InvoicePayment, $invoice);
return $this->invoiceService->markPaid($invoice, InvoicePaymentMethodEnum::Wallet);
});
}
public function recordGatewayInvoicePayment(Invoice $invoice, Payment $payment): WalletTransaction
{
$wallet = $this->forUser($invoice->user);
return $wallet->transactions()->create([
'type' => WalletTransactionTypeEnum::Withdrawal->value,
'reason' => WalletTransactionReasonEnum::InvoicePayment->value,
'amount' => $invoice->amount,
'balance_after' => $wallet->balance,
'invoice_id' => $invoice->id,
'payment_id' => $payment->id,
]);
}
protected function lockedWallet(Wallet $wallet): Wallet
{
return Wallet::whereKey($wallet->id)->lockForUpdate()->first();
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Services\Wallet;
use App\Enums\WalletTransactionReasonEnum;
use App\Enums\WithdrawRequestStatusEnum;
use App\Models\BankCard;
use App\Models\User;
use App\Models\WithdrawRequest;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class WithdrawRequestService
{
protected WalletService $walletService;
public function __construct(WalletService $walletService)
{
$this->walletService = $walletService;
}
public function request(User $user, int $amount, BankCard $bankCard): WithdrawRequest
{
if ($bankCard->user_id !== $user->id) {
throw ValidationException::withMessages(['bank_card_id' => 'کارت بانکی انتخاب‌شده معتبر نیست.']);
}
$balance = $this->walletService->forUser($user)->balance;
$pendingAmount = WithdrawRequest::where('user_id', $user->id)
->where('status', WithdrawRequestStatusEnum::Pending->value)
->sum('amount');
if ($amount + $pendingAmount > $balance) {
throw ValidationException::withMessages(['amount' => 'مجموع مبلغ درخواست‌های برداشت از موجودی کیف پول شما بیشتر است.']);
}
return WithdrawRequest::create([
'amount' => $amount,
'status' => WithdrawRequestStatusEnum::Pending->value,
'user_id' => $user->id,
'bank_card_id' => $bankCard->id,
]);
}
public function approve(WithdrawRequest $withdrawRequest, User $admin): WithdrawRequest
{
return DB::transaction(function () use ($withdrawRequest, $admin) {
$this->ensurePending($withdrawRequest);
$this->walletService->withdraw(
$withdrawRequest->user,
$withdrawRequest->amount,
WalletTransactionReasonEnum::Withdraw,
null,
$withdrawRequest
);
$this->walletService->depositToWallet(
$this->walletService->system(),
$withdrawRequest->amount,
WalletTransactionReasonEnum::Withdraw,
null,
$withdrawRequest
);
$withdrawRequest->update([
'status' => WithdrawRequestStatusEnum::Approved->value,
'reviewed_at' => now(),
'reviewed_by' => $admin->id,
]);
return $withdrawRequest;
});
}
public function reject(WithdrawRequest $withdrawRequest, User $admin, ?string $note = null): WithdrawRequest
{
$this->ensurePending($withdrawRequest);
$withdrawRequest->update([
'status' => WithdrawRequestStatusEnum::Rejected->value,
'admin_note' => $note,
'reviewed_at' => now(),
'reviewed_by' => $admin->id,
]);
return $withdrawRequest;
}
protected function ensurePending(WithdrawRequest $withdrawRequest): void
{
if ($withdrawRequest->status !== WithdrawRequestStatusEnum::Pending) {
throw ValidationException::withMessages(['withdraw_request' => 'این درخواست قبلا بررسی شده است.']);
}
}
}