Polish the manual invoice feature with 4 improvements: 1. Admin invoice form: numeric fields (unit_price, discount_amount) now display values with thousand-separators (e.g. 1,000,000 instead of 1000000) via formatStateUsing/dehydrateStateUsing for easier reading of large Rial amounts. The placeholder/subtotal/total calculations also strip commas before arithmetic. 2. Fix 'total amount payable = 0' bug in the form. The placeholder had a typo: 'foreach ( as )' overwrote the loop variable and made the inner calculation use the array instead of each item, so the sum was always 0. Now correctly iterates with . 3. Customer dashboard widget: add an 'Invoices pending payment' card showing the count, with a link to /my-account/?tab=invoices if > 0. Relabel the existing 'Pending payment' card to 'Pending payment (orders)' to clarify it's for orders, not invoices. 4. Customer profile financial status: include manual invoices in the accounting_balance and pending_payments_total. Show a breakdown list (orders vs invoices) under the balance card, plus two new stat cards (pending invoices count/total, paid invoices count/total) with a link to the invoices tab.
292 lines
12 KiB
PHP
292 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\ManualInvoice;
|
|
use App\Models\User;
|
|
use App\Models\Wallet;
|
|
use App\Models\WalletTransaction;
|
|
use App\Notifications\ManualInvoiceCreatedNotification;
|
|
use App\Enums\TransactionType;
|
|
use App\Enums\TransactionStatus;
|
|
use App\Enums\PaymentGateway;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Notification;
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* ManualInvoiceService — سرویس صدور و تسویه فاکتورهای دستی
|
|
*
|
|
* مسئولیتها:
|
|
* 1. صدور فاکتور جدید توسط مدیر
|
|
* 2. لغو فاکتور
|
|
* 3. پرداخت فاکتور از کیف پول مشتری
|
|
* 4. دریافت لیست فاکتورها برای مشتری/مدیر
|
|
*
|
|
* قاعده ارزی: همه مبالغ به ریال (IRR).
|
|
* قاعده کیفپولی: پرداخت فاکتور از کیف پول مشتری کسر میشود (معادل هزینهای که مدیر گرفته).
|
|
*/
|
|
class ManualInvoiceService
|
|
{
|
|
/**
|
|
* صدور فاکتور جدید.
|
|
*
|
|
* @param array $data {
|
|
* @var int $user_id آیدی مشتری
|
|
* @var int $issued_by آیدی مدیر/کارمند صادرکننده
|
|
* @var array $items [{description, quantity, unit_price}, ...]
|
|
* @var float $discount_amount مبلغ تخفیف (ریال)
|
|
* @var string|null $notes_to_customer توضیحات برای مشتری
|
|
* @var string|null $admin_notes یادداشت داخلی
|
|
* @var string|null $due_date موعد پرداخت (Y-m-d)
|
|
* }
|
|
*/
|
|
public function createInvoice(array $data): ManualInvoice
|
|
{
|
|
$this->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('کاربر انتخابی نقش مشتری ندارد.');
|
|
}
|
|
}
|
|
}
|