ifnex/04_Laravel/app/Services/ManualInvoiceService.php
Kazem Alghasi 7fe368bc46 feat(invoices): thousand-separator formatting + fix total calc + integrate invoices into customer portal
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.
2026-10-29 23:59:19 +03:30

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('کاربر انتخابی نقش مشتری ندارد.');
}
}
}