validateCreateData($data); $totals = ManualInvoice::calculateTotal($data['items'], $data['discount_amount'] ?? 0); return DB::transaction(function () use ($data, $totals) { $invoice = ManualInvoice::create([ 'user_id' => $data['user_id'], 'issued_by' => $data['issued_by'], 'invoice_number' => ManualInvoice::generateInvoiceNumber(), 'subtotal' => $totals['subtotal'], 'discount_amount' => $totals['discount_amount'], 'total_amount' => $totals['total_amount'], 'items' => $data['items'], 'notes_to_customer' => $data['notes_to_customer'] ?? null, 'admin_notes' => $data['admin_notes'] ?? null, 'due_date' => $data['due_date'] ?? null, 'status' => 'pending_payment', ]); // ارسال اعلان به مشتری $customer = User::find($data['user_id']); $issuedBy = User::find($data['issued_by']); if ($customer) { try { $customer->notify(new ManualInvoiceCreatedNotification($invoice, $issuedBy)); } catch (\Throwable $e) { Log::warning('ManualInvoice: failed to send notification: ' . $e->getMessage()); } } return $invoice; }); } /** * لغو فاکتور توسط مدیر. */ public function cancelInvoice(ManualInvoice $invoice, int $cancelledBy, ?string $reason = null): ManualInvoice { if (! $invoice->isPending()) { throw new RuntimeException( "این فاکتور قابل لغو نیست. وضعیت فعلی: {$invoice->status}" ); } return DB::transaction(function () use ($invoice, $cancelledBy, $reason) { $invoice->update([ 'status' => 'cancelled', 'cancelled_by' => $cancelledBy, 'admin_notes' => $reason ? ($invoice->admin_notes ? $invoice->admin_notes . "\n\n[علت لغو]: " . $reason : "[علت لغو]: " . $reason) : $invoice->admin_notes, ]); return $invoice->fresh(); }); } /** * پرداخت فاکتور از کیف پول مشتری. * * @param ManualInvoice $invoice * @param int $paidBy آیدی کاربر که پرداخت رو انجام میده (معمولاً مشتری) * @return ManualInvoice * @throws RuntimeException اگر فاکتور قابل پرداخت نباشد یا موجودی ناکافی باشد */ public function payFromWallet(ManualInvoice $invoice, int $paidBy): ManualInvoice { if (! $invoice->isPending()) { throw new RuntimeException("این فاکتور قابل پرداخت نیست. وضعیت: {$invoice->status}"); } if ($invoice->checkExpired()) { throw new RuntimeException('این فاکتور منقضی شده است.'); } $amount = (float) $invoice->total_amount; if ($amount <= 0) { throw new RuntimeException('مبلغ فاکتور صفر یا منفی است.'); } $wallet = Wallet::where('user_id', $invoice->user_id)->first(); if (! $wallet) { throw new RuntimeException('مشتری کیف پول ندارد.'); } if ($wallet->isFrozen()) { throw new RuntimeException('کیف پول مشتری مسدود است.'); } if (! $wallet->hasSufficientBalance($amount)) { throw new RuntimeException( 'موجودی کیف پول کافی نیست. موجودی: ' . number_format($wallet->balance) . ' | نیاز: ' . number_format($amount) . ' ریال' ); } return DB::transaction(function () use ($invoice, $paidBy, $amount, $wallet) { // قفل کیف پول برای جلوگیری از race condition $wallet = Wallet::lockForUpdate()->find($wallet->id); // بررسی مجدد موجودی if (! $wallet->hasSufficientBalance($amount)) { throw new RuntimeException('موجودی کیف پول تغییر کرده — مجدداً تلاش کنید.'); } // ثبت تراکنش کسر از کیف پول $transaction = WalletTransaction::create([ 'wallet_id' => $wallet->id, 'amount' => -$amount, 'balance_before' => $wallet->balance, 'balance_after' => $wallet->balance - $amount, 'type' => TransactionType::INVOICE_PAYMENT->value, 'status' => TransactionStatus::COMPLETED, 'gateway' => PaymentGateway::WALLET, 'description' => "پرداخت فاکتور {$invoice->invoice_number} از کیف پول", 'transactionable_type' => ManualInvoice::class, 'transactionable_id' => $invoice->id, 'created_by' => $paidBy, ]); // کسر مبلغ از کیف پول $wallet->update([ 'balance' => $wallet->balance - $amount, 'total_withdrawn' => $wallet->total_withdrawn + $amount, ]); // به‌روزرسانی فاکتور $invoice->update([ 'status' => 'paid', 'paid_at' => now(), 'wallet_transaction_id' => $transaction->id, ]); return $invoice->fresh(); }); } /** * لیست فاکتورهای مشتری (برای نمایش در پورتال). * * @param User $customer * @param array $filters {status?, per_page?} */ public function getCustomerInvoices(User $customer, array $filters = []) { $query = ManualInvoice::where('user_id', $customer->id) ->with(['issuedBy:id,name']) ->orderBy('created_at', 'desc'); if (! empty($filters['status'])) { $query->where('status', $filters['status']); } $perPage = min(100, max(10, (int) ($filters['per_page'] ?? 20))); return $query->paginate($perPage); } /** * لیست همه فاکتورها (برای پنل ادمین). */ public function getAllInvoices(array $filters = []) { $query = ManualInvoice::with(['user:id,name', 'issuedBy:id,name']) ->orderBy('created_at', 'desc'); if (! empty($filters['status'])) { $query->where('status', $filters['status']); } if (! empty($filters['user_id'])) { $query->where('user_id', $filters['user_id']); } $perPage = min(100, max(10, (int) ($filters['per_page'] ?? 20))); return $query->paginate($perPage); } /** * تبدیل فاکتور به آرایه نمایشی (برای API). */ public function toArray(ManualInvoice $invoice): array { return [ 'id' => $invoice->id, 'invoice_number' => $invoice->invoice_number, 'user' => $invoice->user ? [ 'id' => $invoice->user->id, 'name' => $invoice->user->name, ] : null, 'issued_by' => $invoice->issuedBy ? [ 'id' => $invoice->issuedBy->id, 'name' => $invoice->issuedBy->name, ] : null, 'items' => $invoice->items ?? [], 'subtotal' => (float) $invoice->subtotal, 'discount_amount' => (float) $invoice->discount_amount, 'total_amount' => (float) $invoice->total_amount, 'notes_to_customer' => $invoice->notes_to_customer, 'status' => $invoice->status, 'due_date' => $invoice->due_date?->toDateString(), 'paid_at' => $invoice->paid_at?->toIso8601String(), 'created_at' => $invoice->created_at->toIso8601String(), 'can_pay' => $invoice->isPending() && ! $invoice->checkExpired(), 'is_expired' => $invoice->checkExpired(), ]; } // ─── Validation ─────────────────────────────────── protected function validateCreateData(array $data): void { if (empty($data['user_id'])) { throw new RuntimeException('انتخاب مشتری الزامی است.'); } if (empty($data['issued_by'])) { throw new RuntimeException('صادرکننده فاکتور مشخص نیست.'); } if (empty($data['items']) || ! is_array($data['items'])) { throw new RuntimeException('حداقل یک قلم الزامی است.'); } foreach ($data['items'] as $i => $item) { if (empty($item['description'])) { throw new RuntimeException("شرح قلم شماره " . ($i + 1) . " الزامی است."); } $qty = (float) ($item['quantity'] ?? 0); $unit = (float) ($item['unit_price'] ?? 0); if ($qty <= 0) { throw new RuntimeException("تعداد قلم شماره " . ($i + 1) . " باید مثبت باشد."); } if ($unit < 0) { throw new RuntimeException("قیمت واحد قلم شماره " . ($i + 1) . " نمی‌تواند منفی باشد."); } } if ((float) ($data['discount_amount'] ?? 0) < 0) { throw new RuntimeException('مبلغ تخفیف نمی‌تواند منفی باشد.'); } // بررسی اینکه کاربر مشتری هست $user = User::find($data['user_id']); if (! $user) { throw new RuntimeException('مشتری یافت نشد.'); } if (! $user->hasRole('customer')) { throw new RuntimeException('کاربر انتخابی نقش مشتری ندارد.'); } } }