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
+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' => 'این درخواست قبلا بررسی شده است.']);
}
}
}