diff --git a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php
index d57484b..a1278d3 100644
--- a/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php
+++ b/03_WordPress/wp-content/plugins/ifnex-bridge/includes/shortcodes.php
@@ -246,13 +246,37 @@ function ifnex_user_profile_shortcode($atts) {
@@ -1792,6 +1873,282 @@ function ifnex_customer_dashboard_shortcode($atts) {
+
+ 0) {
+ // دریافت جزئیات فاکتور از API
+ $single_result = $bridge->authenticated_request($user_id, "/customer/invoices/{$single_invoice_id}", 'GET');
+ if (is_wp_error($single_result)) {
+ echo '
خطا در دریافت فاکتور: ' . esc_html($single_result->get_error_message()) . '
';
+ } else {
+ $inv = $single_result['data'] ?? null;
+ if ($inv):
+ $status_labels = [
+ 'pending_payment' => 'در انتظار پرداخت',
+ 'paid' => 'پرداخت شده',
+ 'cancelled' => 'لغو شده',
+ 'expired' => 'منقضی',
+ ];
+ $status_class = $inv['status'] ?? '';
+ $status_label = $status_labels[$inv['status']] ?? $inv['status'];
+ $items = $inv['items'] ?? [];
+ $subtotal = (float)($inv['subtotal'] ?? 0);
+ $discount = (float)($inv['discount_amount'] ?? 0);
+ $total = (float)($inv['total_amount'] ?? 0);
+ ?>
+
+
+
+
+
+
+
+
+
+
+ | # |
+ شرح خدمت/کالا |
+ تعداد |
+ قیمت واحد (ریال) |
+ مجموع (ریال) |
+
+
+
+ $item): ?>
+
+
+ |
+ |
+ |
+ |
+ |
+
+
+
+
+
+
+
+
مجموع اقلام: ریال
+ 0): ?>
+
تخفیف:- ریال
+
+
مبلغ قابل پرداخت: ریال
+
+
+
+
+
+
+
+
+
+
+
+
+ ✅ این فاکتور پرداخت شده است.
+
+
+
+ ❌ این فاکتور لغو شده است.
+
+
+
+
+
+
فاکتور یافت نشد.
+
+
+ authenticated_request($user_id, $invoices_url, 'GET');
+ $invoices = [];
+ if (!is_wp_error($list_result) && isset($list_result['data']['invoices'])) {
+ $invoices = $list_result['data']['invoices'];
+ }
+ $status_labels = [
+ 'pending_payment' => 'در انتظار پرداخت',
+ 'paid' => 'پرداخت شده',
+ 'cancelled' => 'لغو شده',
+ 'expired' => 'منقضی',
+ ];
+ $status_classes = [
+ 'pending_payment' => 'pending',
+ 'paid' => 'paid',
+ 'cancelled' => 'cancelled',
+ 'expired' => 'expired',
+ ];
+ ?>
+
+
+
+
+
📄
+
فاکتوری برای نمایش وجود ندارد.
+
+
+
+
+
+
+
+ قلم |
+ | موعد:
+
+
+
+
+
+
+
+
+
+
+
موجودی فعلی کیف پول
@@ -1921,7 +2278,17 @@ function ifnex_customer_dashboard_shortcode($atts) {
$action = $data['action_url'] ?? '';
$created = $n['created_at'] ?? '';
$n_type = $data['type'] ?? '';
- $icon_name = ($n_type === 'order_approved') ? 'check' : (($n_type === 'order_needs_changes') ? 'clock' : 'bell');
+ $icon_name = match($n_type) {
+ 'order_approved' => 'check',
+ 'order_needs_changes' => 'clock',
+ 'manual_invoice_created' => 'invoice',
+ default => 'bell',
+ };
+ $invoice_no = $data['invoice_number'] ?? '';
+ // اگه action_url برای فاکتور نیست، خودکار به تب فاکتورها هدایت کن
+ if ($n_type === 'manual_invoice_created' && empty($action)) {
+ $action = '/my-account/?tab=invoices';
+ }
?>
@@ -1947,4 +2314,149 @@ function ifnex_customer_dashboard_shortcode($atts) {
برای مشاهده فاکتورها باید وارد شوید.
';
+ }
+
+ $bridge = new IFNEX_User_Bridge();
+ $user_id = get_current_user_id();
+
+ // فراخوانی API برای دریافت فاکتورها
+ $result = $bridge->authenticated_request($user_id, '/customer/invoices', 'GET');
+
+ if (is_wp_error($result)) {
+ return '
خطا در دریافت فاکتورها: ' . esc_html($result->get_error_message()) . '
';
+ }
+
+ $invoices = $result['data']['invoices'] ?? [];
+ $pagination = $result['data']['pagination'] ?? null;
+
+ ob_start();
+ ?>
+
+
فاکتورهای دستی
+
+
+
+
📄
+
فاکتور دستی برای شما ثبت نشده است.
+
+
+
+
+ 'در انتظار پرداخت',
+ 'paid' => 'پرداخت شده',
+ 'cancelled' => 'لغو شده',
+ 'expired' => 'منقضی',
+ ][$inv['status']] ?? $inv['status'];
+ $status_class = [
+ 'pending_payment' => 'pending',
+ 'paid' => 'paid',
+ 'cancelled' => 'cancelled',
+ 'expired' => 'expired',
+ ][$inv['status']] ?? '';
+ $items_count = is_array($inv['items'] ?? null) ? count($inv['items']) : 0;
+ ?>
+
+
+
+
+ تعداد اقلام:
+ قلم
+
+
+ مبلغ نهایی:
+ ریال
+
+
+
+ موعد پرداخت:
+
+
+
+
+
+
+
+
+
جزئیات
+
+
+
+
+
+
+
+
+
+
+ authenticated_request(
+ get_current_user_id(),
+ "/customer/invoices/{$invoice_id}/pay",
+ 'POST'
+ );
+
+ if (is_wp_error($result)) {
+ wp_send_json_error($result->get_error_message());
+ }
+
+ if (isset($result['success']) && !$result['success']) {
+ wp_send_json_error($result['message'] ?? 'خطا در پرداخت فاکتور');
+ }
+
+ wp_send_json_success($result);
+}
diff --git a/04_Laravel/app/Enums/TransactionType.php b/04_Laravel/app/Enums/TransactionType.php
index f6217e6..1f8922e 100644
--- a/04_Laravel/app/Enums/TransactionType.php
+++ b/04_Laravel/app/Enums/TransactionType.php
@@ -9,6 +9,7 @@ enum TransactionType: string
case ORDER_PAYMENT = 'order_payment'; // پرداخت سفارش
case REFUND = 'refund'; // بازگشت وجه
case CREDIT_SETTLEMENT = 'credit_settlement'; // تسویه اعتبار چندارزی از کیف پول
+ case INVOICE_PAYMENT = 'invoice_payment'; // پرداخت فاکتور دستی از کیف پول
public function label(): string
{
@@ -18,6 +19,7 @@ enum TransactionType: string
self::ORDER_PAYMENT => 'پرداخت سفارش',
self::REFUND => 'بازگشت وجه',
self::CREDIT_SETTLEMENT => 'تسویه اعتبار',
+ self::INVOICE_PAYMENT => 'پرداخت فاکتور',
};
}
@@ -29,6 +31,7 @@ enum TransactionType: string
self::ORDER_PAYMENT => 'warning',
self::REFUND => 'info',
self::CREDIT_SETTLEMENT => 'info',
+ self::INVOICE_PAYMENT => 'warning',
};
}
}
\ No newline at end of file
diff --git a/04_Laravel/app/Filament/Resources/ManualInvoiceResource.php b/04_Laravel/app/Filament/Resources/ManualInvoiceResource.php
new file mode 100644
index 0000000..75a3728
--- /dev/null
+++ b/04_Laravel/app/Filament/Resources/ManualInvoiceResource.php
@@ -0,0 +1,317 @@
+getRecord() !== null;
+
+ return $form
+ ->schema([
+ Section::make('اطلاعات فاکتور')
+ ->schema([
+ Select::make('user_id')
+ ->label('مشتری')
+ ->options(fn () => User::role('customer')->pluck('name', 'id'))
+ ->searchable()
+ ->required()
+ ->disabled($isEdit)
+ ->helperText($isEdit ? 'مشتری بعد از صدور فاکتور قابل تغییر نیست.' : 'کاربری که فاکتور برایش صادر میشود.'),
+
+ TextInput::make('invoice_number')
+ ->label('شماره فاکتور')
+ ->disabled()
+ ->dehydrated(false)
+ ->hidden(fn ($record) => $record === null)
+ ->formatStateUsing(fn ($record) => $record?->invoice_number ?? '— تولید خودکار بعد از ذخیره —'),
+
+ DatePicker::make('due_date')
+ ->label('موعد پرداخت (اختیاری)')
+ ->minDate(now()->toDateString())
+ ->helperText('بعد از این تاریخ، فاکتور بهصورت خودکار منقضی میشود.'),
+ ])->columns(2),
+
+ Section::make('اقلام فاکتور')
+ ->description('اقلامی که به مشتری فاکتور میشوند. هر قلم: شرح، تعداد، قیمت واحد (ریال). اعداد بهصورت خودکار ۳ رقم جدا میشوند.')
+ ->schema([
+ Repeater::make('items')
+ ->label('اقلام')
+ ->schema([
+ TextInput::make('description')
+ ->label('شرح خدمت/کالا')
+ ->required()
+ ->columnSpan(2),
+ TextInput::make('quantity')
+ ->label('تعداد')
+ ->numeric()
+ ->default(1)
+ ->minValue(0.01)
+ ->step(0.01)
+ ->required(),
+ TextInput::make('unit_price')
+ ->label('قیمت واحد (ریال)')
+ ->numeric()
+ ->minValue(0)
+ ->step(1)
+ ->required()
+ ->suffix('ریال')
+ ->formatStateUsing(fn ($state) => is_numeric($state) && $state > 0 ? number_format((float) $state, 0, '.', ',') : $state)
+ ->dehydrateStateUsing(fn ($state) => is_string($state) ? (float) str_replace(',', '', $state) : (float) $state)
+ ->live()
+ ->afterStateUpdated(function (Get $get, Forms\Set $set) {
+ self::recalcTotals($get, $set);
+ }),
+ ])
+ ->columns(4)
+ ->collapsible()
+ ->itemLabel(fn (array $state) => ($state['description'] ?? 'قلم جدید'))
+ ->defaultItems(1)
+ ->live()
+ ->afterStateUpdated(function (Get $get, Forms\Set $set) {
+ self::recalcTotals($get, $set);
+ })
+ ->deleteAction(fn ($action) => $action->after(fn (Get $get, Forms\Set $set) => self::recalcTotals($get, $set)))
+ ->reorderable(false),
+ ]),
+
+ Section::make('تخفیف و مبالغ نهایی')
+ ->schema([
+ TextInput::make('discount_amount')
+ ->label('مبلغ تخفیف (ریال)')
+ ->numeric()
+ ->default(0)
+ ->minValue(0)
+ ->step(1)
+ ->suffix('ریال')
+ ->formatStateUsing(fn ($state) => is_numeric($state) && $state > 0 ? number_format((float) $state, 0, '.', ',') : $state)
+ ->dehydrateStateUsing(fn ($state) => is_string($state) ? (float) str_replace(',', '', $state) : (float) $state)
+ ->live()
+ ->afterStateUpdated(fn (Get $get, Forms\Set $set) => self::recalcTotals($get, $set)),
+
+ Placeholder::make('subtotal_display')
+ ->label('مجموع اقلام (قبل از تخفیف)')
+ ->content(function (Get $get): HtmlString {
+ $items = $get('items') ?? [];
+ $subtotal = 0;
+ foreach ($items as $item) {
+ $rawQty = $item['quantity'] ?? 0;
+ $rawUnit = $item['unit_price'] ?? 0;
+ // پشتیبانی از اعداد با کاما (مثلاً "1,000,000")
+ $qty = is_string($rawQty) ? (float) str_replace(',', '', $rawQty) : (float) $rawQty;
+ $unit = is_string($rawUnit) ? (float) str_replace(',', '', $rawUnit) : (float) $rawUnit;
+ $subtotal += $qty * $unit;
+ }
+ return new HtmlString('
' . number_format($subtotal) . ' ریال');
+ })
+ ->live(),
+
+ Placeholder::make('total_amount_display')
+ ->label('مبلغ نهایی قابل پرداخت')
+ ->content(function (Get $get): HtmlString {
+ $items = $get('items') ?? [];
+ $subtotal = 0;
+ foreach ($items as $item) {
+ $rawQty = $item['quantity'] ?? 0;
+ $rawUnit = $item['unit_price'] ?? 0;
+ // پشتیبانی از اعداد با کاما
+ $qty = is_string($rawQty) ? (float) str_replace(',', '', $rawQty) : (float) $rawQty;
+ $unit = is_string($rawUnit) ? (float) str_replace(',', '', $rawUnit) : (float) $rawUnit;
+ $subtotal += $qty * $unit;
+ }
+ $rawDiscount = $get('discount_amount') ?? 0;
+ $discount = is_string($rawDiscount) ? (float) str_replace(',', '', $rawDiscount) : (float) $rawDiscount;
+ $total = max(0, $subtotal - $discount);
+ return new HtmlString(
+ '
'
+ . number_format($total)
+ . ' ریال'
+ );
+ })
+ ->live(),
+ ])->columns(2),
+
+ Section::make('توضیحات')
+ ->schema([
+ Textarea::make('notes_to_customer')
+ ->label('توضیحات برای مشتری')
+ ->rows(3)
+ ->helperText('این متن در پورتال مشتری نمایش داده میشود.'),
+ Textarea::make('admin_notes')
+ ->label('یادداشت داخلی (نمایش داده نمیشود به مشتری)')
+ ->rows(2),
+ ])->columns(2),
+ ]);
+ }
+
+ public static function table(Table $table): Table
+ {
+ return $table
+ ->columns([
+ Tables\Columns\TextColumn::make('invoice_number')
+ ->label('شماره فاکتور')
+ ->searchable()
+ ->sortable(),
+ Tables\Columns\TextColumn::make('user.name')
+ ->label('مشتری')
+ ->searchable()
+ ->sortable(),
+ Tables\Columns\TextColumn::make('total_amount')
+ ->label('مبلغ نهایی')
+ ->formatStateUsing(fn ($state) => number_format($state) . ' ریال')
+ ->sortable(),
+ Tables\Columns\TextColumn::make('items_count')
+ ->label('تعداد اقلام')
+ ->state(fn (ManualInvoice $record) => count($record->items ?? [])),
+ Tables\Columns\TextColumn::make('status')
+ ->label('وضعیت')
+ ->badge()
+ ->formatStateUsing(fn ($state) => match($state) {
+ 'pending_payment' => 'در انتظار پرداخت',
+ 'paid' => 'پرداخت شده',
+ 'cancelled' => 'لغو شده',
+ 'expired' => 'منقضی',
+ default => $state,
+ })
+ ->color(fn ($state) => match($state) {
+ 'pending_payment' => 'warning',
+ 'paid' => 'success',
+ 'cancelled' => 'danger',
+ 'expired' => 'gray',
+ default => 'gray',
+ }),
+ Tables\Columns\TextColumn::make('due_date')
+ ->label('موعد پرداخت')
+ ->date('Y-m-d')
+ ->placeholder('—'),
+ Tables\Columns\TextColumn::make('paid_at')
+ ->label('تاریخ پرداخت')
+ ->dateTime('Y-m-d H:i')
+ ->placeholder('—'),
+ Tables\Columns\TextColumn::make('issuedBy.name')
+ ->label('صادرکننده')
+ ->placeholder('—'),
+ Tables\Columns\TextColumn::make('created_at')
+ ->label('تاریخ صدور')
+ ->dateTime('Y-m-d H:i')
+ ->sortable(),
+ ])
+ ->filters([
+ Tables\Filters\SelectFilter::make('status')
+ ->label('وضعیت')
+ ->options([
+ 'pending_payment' => 'در انتظار پرداخت',
+ 'paid' => 'پرداخت شده',
+ 'cancelled' => 'لغو شده',
+ 'expired' => 'منقضی',
+ ]),
+ ])
+ ->actions([
+ Tables\Actions\ViewAction::make(),
+ Tables\Actions\EditAction::make()
+ ->visible(fn (ManualInvoice $record) => $record->isPending()),
+ Action::make('cancel')
+ ->label('لغو فاکتور')
+ ->icon('heroicon-o-x-circle')
+ ->color('danger')
+ ->visible(fn (ManualInvoice $record) => $record->isPending())
+ ->requiresConfirmation()
+ ->form([
+ Textarea::make('reason')
+ ->label('علت لغو (اختیاری)')
+ ->rows(2),
+ ])
+ ->action(function (ManualInvoice $record, array $data) {
+ try {
+ $service = app(ManualInvoiceService::class);
+ $service->cancelInvoice($record, auth()->id(), $data['reason'] ?? null);
+ Notification::make()
+ ->title('فاکتور لغو شد.')
+ ->success()
+ ->send();
+ } catch (\Throwable $e) {
+ Notification::make()
+ ->title('خطا در لغو فاکتور')
+ ->body($e->getMessage())
+ ->danger()
+ ->send();
+ }
+ }),
+ ])
+ ->bulkActions([])
+ ->defaultSort('created_at', 'desc');
+ }
+
+ public static function getPages(): array
+ {
+ return [
+ 'index' => \App\Filament\Resources\ManualInvoiceResource\Pages\ListManualInvoices::route('/'),
+ 'create' => \App\Filament\Resources\ManualInvoiceResource\Pages\CreateManualInvoice::route('/create'),
+ 'view' => \App\Filament\Resources\ManualInvoiceResource\Pages\ViewManualInvoice::route('/{record}'),
+ 'edit' => \App\Filament\Resources\ManualInvoiceResource\Pages\EditManualInvoice::route('/{record}/edit'),
+ ];
+ }
+
+ /**
+ * محاسبه و تنظیم subtotal و total در فرم.
+ */
+ public static function recalcTotals(Get $get, Forms\Set $set): void
+ {
+ $items = $get('items') ?? [];
+ $subtotal = 0;
+ foreach ($items as $item) {
+ $qty = (float) ($item['quantity'] ?? 0);
+ $unit = (float) ($item['unit_price'] ?? 0);
+ $subtotal += $qty * $unit;
+ }
+ $discount = (float) ($get('discount_amount') ?? 0);
+ $total = max(0, $subtotal - $discount);
+ }
+
+ /**
+ * بعد از ایجاد رکورد، شماره فاکتور رو تولید کن و ذخیره کن.
+ */
+ public static function mutateFormDataBeforeCreate(array $data): array
+ {
+ // محاسبه subtotal و total
+ $totals = ManualInvoice::calculateTotal($data['items'] ?? [], (float) ($data['discount_amount'] ?? 0));
+ $data['subtotal'] = $totals['subtotal'];
+ $data['discount_amount'] = $totals['discount_amount'];
+ $data['total_amount'] = $totals['total_amount'];
+ $data['issued_by'] = auth()->id();
+ $data['invoice_number'] = ManualInvoice::generateInvoiceNumber();
+ $data['status'] = 'pending_payment';
+
+ return $data;
+ }
+}
diff --git a/04_Laravel/app/Filament/Resources/ManualInvoiceResource/Pages/CreateManualInvoice.php b/04_Laravel/app/Filament/Resources/ManualInvoiceResource/Pages/CreateManualInvoice.php
new file mode 100644
index 0000000..b54b136
--- /dev/null
+++ b/04_Laravel/app/Filament/Resources/ManualInvoiceResource/Pages/CreateManualInvoice.php
@@ -0,0 +1,28 @@
+getResource()::getUrl('index');
+ }
+
+ protected function getCreatedNotificationTitle(): ?string
+ {
+ return 'فاکتور با موفقیت صادر شد.';
+ }
+}
diff --git a/04_Laravel/app/Filament/Resources/ManualInvoiceResource/Pages/EditManualInvoice.php b/04_Laravel/app/Filament/Resources/ManualInvoiceResource/Pages/EditManualInvoice.php
new file mode 100644
index 0000000..b11eeeb
--- /dev/null
+++ b/04_Laravel/app/Filament/Resources/ManualInvoiceResource/Pages/EditManualInvoice.php
@@ -0,0 +1,33 @@
+getResource()::getUrl('index');
+ }
+}
diff --git a/04_Laravel/app/Filament/Resources/ManualInvoiceResource/Pages/ListManualInvoices.php b/04_Laravel/app/Filament/Resources/ManualInvoiceResource/Pages/ListManualInvoices.php
new file mode 100644
index 0000000..684fbd5
--- /dev/null
+++ b/04_Laravel/app/Filament/Resources/ManualInvoiceResource/Pages/ListManualInvoices.php
@@ -0,0 +1,21 @@
+label('صدور فاکتور جدید'),
+ ];
+ }
+}
diff --git a/04_Laravel/app/Filament/Resources/ManualInvoiceResource/Pages/ViewManualInvoice.php b/04_Laravel/app/Filament/Resources/ManualInvoiceResource/Pages/ViewManualInvoice.php
new file mode 100644
index 0000000..130bef3
--- /dev/null
+++ b/04_Laravel/app/Filament/Resources/ManualInvoiceResource/Pages/ViewManualInvoice.php
@@ -0,0 +1,13 @@
+validate([
+ 'status' => ['nullable', 'string', 'in:pending_payment,paid,cancelled,expired'],
+ 'per_page' => ['nullable', 'integer', 'min:10', 'max:100'],
+ ]);
+
+ $user = Auth::user();
+ $invoices = $this->invoiceService->getCustomerInvoices($user, $validated);
+
+ return response()->json([
+ 'success' => true,
+ 'data' => [
+ 'invoices' => $invoices->items(),
+ 'pagination' => [
+ 'current_page' => $invoices->currentPage(),
+ 'last_page' => $invoices->lastPage(),
+ 'per_page' => $invoices->perPage(),
+ 'total' => $invoices->total(),
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * GET /api/v1/customer/invoices/{id}
+ * جزئیات یک فاکتور.
+ */
+ public function show(int $id): JsonResponse
+ {
+ $user = Auth::user();
+ $invoice = ManualInvoice::where('id', $id)
+ ->where('user_id', $user->id)
+ ->with(['issuedBy:id,name'])
+ ->first();
+
+ if (! $invoice) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'فاکتور یافت نشد.',
+ ], 404);
+ }
+
+ return response()->json([
+ 'success' => true,
+ 'data' => $this->invoiceService->toArray($invoice),
+ ]);
+ }
+
+ /**
+ * POST /api/v1/customer/invoices/{id}/pay
+ * پرداخت فاکتور از کیف پول مشتری.
+ */
+ public function pay(int $id): JsonResponse
+ {
+ $user = Auth::user();
+ $invoice = ManualInvoice::where('id', $id)
+ ->where('user_id', $user->id)
+ ->first();
+
+ if (! $invoice) {
+ return response()->json([
+ 'success' => false,
+ 'message' => 'فاکتور یافت نشد.',
+ ], 404);
+ }
+
+ try {
+ $paid = $this->invoiceService->payFromWallet($invoice, $user->id);
+
+ return response()->json([
+ 'success' => true,
+ 'message' => 'فاکتور با موفقیت پرداخت شد.',
+ 'data' => $this->invoiceService->toArray($paid),
+ ]);
+ } catch (\RuntimeException $e) {
+ return response()->json([
+ 'success' => false,
+ 'message' => $e->getMessage(),
+ ], 400);
+ } catch (\Throwable $e) {
+ report($e);
+ return response()->json([
+ 'success' => false,
+ 'message' => 'خطای داخلی سرور هنگام پرداخت فاکتور.',
+ ], 500);
+ }
+ }
+}
diff --git a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
index 2cfb245..3c99f1a 100644
--- a/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
+++ b/04_Laravel/app/Http/Controllers/Api/Customer/CustomerOrderController.php
@@ -7,6 +7,7 @@ use App\Models\Shipment;
use App\Models\ShipmentItem;
use App\Models\ShipmentPackage;
use App\Models\Country;
+use App\Models\ManualInvoice;
use App\Enums\ShipmentStatus;
use App\Enums\ReviewState;
use App\Models\ShipmentReview;
@@ -723,9 +724,21 @@ class CustomerOrderController extends Controller
->where('status', ShipmentStatus::Approved)
->sum('total_fee');
- // مانده حساب = موجودی کیف پول - بدهیها
+ // محاسبه مجموع فاکتورهای دستی در انتظار پرداخت
+ $pending_invoices_total = ManualInvoice::where('user_id', $user->id)
+ ->where('status', 'pending_payment')
+ ->sum('total_amount');
+
+ $pending_invoices_count = ManualInvoice::where('user_id', $user->id)
+ ->where('status', 'pending_payment')
+ ->count();
+
+ // کل بدهی = بدهی سفارشات + بدهی فاکتورهای دستی
+ $total_pending_payments = $pending_payments_total + $pending_invoices_total;
+
+ // مانده حساب = موجودی کیف پول - کل بدهیها
$wallet_balance = $user->wallet ? $user->wallet->balance : 0;
- $account_balance = $wallet_balance - $pending_payments_total;
+ $account_balance = $wallet_balance - $total_pending_payments;
// بدهیهای چندارزی (EUR/USD/AED/CNY) — به همان ارز خارجی، نه معادل ریالی
$foreign_credits = app(\App\Services\CustomerCreditService::class)
@@ -741,6 +754,16 @@ class CustomerOrderController extends Controller
->where('status', ShipmentStatus::Approved)
->count(),
'pending_payment_total' => $pending_payments_total,
+ // ─── فاکتورهای دستی ───
+ 'pending_invoices_count' => $pending_invoices_count,
+ 'pending_invoices_total' => $pending_invoices_total,
+ 'paid_invoices_count' => ManualInvoice::where('user_id', $user->id)
+ ->where('status', 'paid')
+ ->count(),
+ 'paid_invoices_total' => ManualInvoice::where('user_id', $user->id)
+ ->where('status', 'paid')
+ ->sum('total_amount'),
+ // ─── پایان فاکتورهای دستی ───
'active_orders' => Shipment::where('user_id', $user->id)
->whereIn('status', [
ShipmentStatus::Processed,
@@ -773,7 +796,14 @@ class CustomerOrderController extends Controller
'is_frozen' => $user->wallet->is_frozen,
] : null,
'account_balance' => $account_balance,
- 'pending_payments_total' => $pending_payments_total,
+ 'pending_payments_total' => $total_pending_payments,
+ // تفکیک برای نمایش در پورتال
+ 'pending_payments_breakdown' => [
+ 'orders' => $pending_payments_total,
+ 'invoices' => $pending_invoices_total,
+ 'total' => $total_pending_payments,
+ ],
+ 'pending_invoices_count' => $pending_invoices_count,
'foreign_credits' => $foreign_credits,
'stats' => $stats,
]);
diff --git a/04_Laravel/app/Models/ManualInvoice.php b/04_Laravel/app/Models/ManualInvoice.php
new file mode 100644
index 0000000..70fbfdf
--- /dev/null
+++ b/04_Laravel/app/Models/ManualInvoice.php
@@ -0,0 +1,161 @@
+ 'array', // اقلام بهصورت JSON
+ 'subtotal' => 'decimal:2',
+ 'discount_amount' => 'decimal:2',
+ 'total_amount' => 'decimal:2',
+ 'due_date' => 'date',
+ 'paid_at' => 'datetime',
+ ];
+
+ // ─── Relationships ───────────────────────────────
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'user_id');
+ }
+
+ public function issuedBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'issued_by');
+ }
+
+ public function cancelledBy(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'cancelled_by');
+ }
+
+ public function walletTransaction(): BelongsTo
+ {
+ return $this->belongsTo(WalletTransaction::class);
+ }
+
+ public function payment(): BelongsTo
+ {
+ return $this->belongsTo(Payment::class);
+ }
+
+ // ─── Helpers ─────────────────────────────────────
+
+ public function isPending(): bool
+ {
+ return $this->status === 'pending_payment';
+ }
+
+ public function isPaid(): bool
+ {
+ return $this->status === 'paid';
+ }
+
+ public function isCancelled(): bool
+ {
+ return $this->status === 'cancelled';
+ }
+
+ public function isExpired(): bool
+ {
+ return $this->status === 'expired';
+ }
+
+ /**
+ * آیا فاکتور منقضی شده است؟ (بررسی بر اساس due_date)
+ */
+ public function checkExpired(): bool
+ {
+ if ($this->status !== 'pending_payment') {
+ return false;
+ }
+ if (! $this->due_date) {
+ return false;
+ }
+ return $this->due_date->isPast();
+ }
+
+ /**
+ * تولید شماره فاکتور بعدی.
+ * فرمت: INV-2026-000001
+ */
+ public static function generateInvoiceNumber(): string
+ {
+ $year = now()->year;
+ $prefix = "INV-{$year}-";
+
+ $last = self::where('invoice_number', 'like', "{$prefix}%")
+ ->orderByDesc('id')
+ ->first();
+
+ $next = 1;
+ if ($last) {
+ $parts = explode('-', $last->invoice_number);
+ $next = (int) end($parts) + 1;
+ }
+
+ return $prefix . str_pad($next, 6, '0', STR_PAD_LEFT);
+ }
+
+ /**
+ * محاسبه مبلغ نهایی بر اساس اقلام و تخفیف.
+ */
+ public static function calculateTotal(array $items, float $discount = 0): array
+ {
+ $subtotal = 0;
+ foreach ($items as $item) {
+ $qty = (float) ($item['quantity'] ?? 1);
+ $unit = (float) ($item['unit_price'] ?? 0);
+ $subtotal += $qty * $unit;
+ }
+
+ $discount = max(0, $discount);
+ $total = max(0, $subtotal - $discount);
+
+ return [
+ 'subtotal' => round($subtotal, 2),
+ 'discount_amount' => round($discount, 2),
+ 'total_amount' => round($total, 2),
+ ];
+ }
+}
diff --git a/04_Laravel/app/Notifications/ManualInvoiceCreatedNotification.php b/04_Laravel/app/Notifications/ManualInvoiceCreatedNotification.php
new file mode 100644
index 0000000..d04dcee
--- /dev/null
+++ b/04_Laravel/app/Notifications/ManualInvoiceCreatedNotification.php
@@ -0,0 +1,43 @@
+ 'manual_invoice_created',
+ 'invoice_id' => $this->invoice->id,
+ 'invoice_number' => $this->invoice->invoice_number,
+ 'total_amount' => $this->invoice->total_amount,
+ 'due_date' => $this->invoice->due_date?->toDateString(),
+ 'message' => "فاکتور جدید {$this->invoice->invoice_number} به مبلغ "
+ . number_format($this->invoice->total_amount) . " ریال برای شما صادر شد.",
+ 'action_url' => '/my-account/?tab=invoices',
+ 'issued_by' => $this->issuedBy?->name,
+ ];
+ }
+}
diff --git a/04_Laravel/app/Services/ManualInvoiceService.php b/04_Laravel/app/Services/ManualInvoiceService.php
new file mode 100644
index 0000000..25b6565
--- /dev/null
+++ b/04_Laravel/app/Services/ManualInvoiceService.php
@@ -0,0 +1,291 @@
+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('کاربر انتخابی نقش مشتری ندارد.');
+ }
+ }
+}
diff --git a/04_Laravel/database/migrations/2026_10_29_150000_create_manual_invoices_table.php b/04_Laravel/database/migrations/2026_10_29_150000_create_manual_invoices_table.php
new file mode 100644
index 0000000..3e0d009
--- /dev/null
+++ b/04_Laravel/database/migrations/2026_10_29_150000_create_manual_invoices_table.php
@@ -0,0 +1,117 @@
+id();
+
+ // مشتری و صادرکننده — ستونها اول ایجاد میشوند، foreign keys بعد از ایجاد جدول اضافه میشوند
+ $table->unsignedBigInteger('user_id')->comment('مشتری که فاکتور برایش صادر شده');
+ $table->unsignedBigInteger('issued_by')->nullable()->comment('مدیر یا کارمندی که فاکتور را صادر کرده');
+
+ // شماره فاکتور (ترتیبی)
+ $table->string('invoice_number', 50)->unique()
+ ->comment('شماره فاکتور قابل نمایش: INV-YYYY-XXXXXX');
+
+ // مبالغ (همگی به ریال ایران)
+ $table->decimal('subtotal', 20, 2)->default(0)
+ ->comment('مجموع اقلام قبل از تخفیف');
+ $table->decimal('discount_amount', 20, 2)->default(0)
+ ->comment('مبلغ تخفیف (ریال)');
+ $table->decimal('total_amount', 20, 2)->default(0)
+ ->comment('مبلغ نهایی قابل پرداخت (ریال) = subtotal - discount');
+
+ // اقلام فاکتور (JSON آرایه)
+ $table->json('items')
+ ->comment('اقلام فاکتور بهصورت JSON');
+
+ // توضیحات
+ $table->text('notes_to_customer')->nullable()
+ ->comment('توضیحات نمایش داده شده به مشتری');
+ $table->text('admin_notes')->nullable()
+ ->comment('یادداشت داخلی ادمین (برای مشتری نمایش داده نمیشود)');
+
+ // وضعیت
+ $table->enum('status', [
+ 'pending_payment',
+ 'paid',
+ 'cancelled',
+ 'expired',
+ ])->default('pending_payment');
+
+ // موعد پرداخت (اختیاری)
+ $table->date('due_date')->nullable()
+ ->comment('موعد پرداخت (اختیاری)');
+
+ // پرداخت
+ $table->timestamp('paid_at')->nullable();
+ $table->unsignedBigInteger('wallet_transaction_id')->nullable();
+ $table->unsignedBigInteger('payment_id')->nullable();
+ $table->unsignedBigInteger('cancelled_by')->nullable();
+
+ $table->timestamps();
+
+ // ایندکسها
+ $table->index(['user_id', 'status']);
+ $table->index('issued_by');
+ $table->index('due_date');
+ $table->index('status');
+ });
+
+ // اضافهکردن foreign keys بهصورت جداگانه (روش امنتر، جلوگیری از errno 150)
+ Schema::table('manual_invoices', function (Blueprint $table) {
+ $table->foreign('user_id')
+ ->references('id')
+ ->on('users')
+ ->cascadeOnDelete();
+
+ $table->foreign('issued_by')
+ ->references('id')
+ ->on('users')
+ ->nullOnDelete();
+
+ $table->foreign('cancelled_by')
+ ->references('id')
+ ->on('users')
+ ->nullOnDelete();
+
+ $table->foreign('wallet_transaction_id')
+ ->references('id')
+ ->on('wallet_transactions')
+ ->nullOnDelete();
+
+ $table->foreign('payment_id')
+ ->references('id')
+ ->on('payments')
+ ->nullOnDelete();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('manual_invoices');
+ }
+};
\ No newline at end of file
diff --git a/04_Laravel/database/migrations/2026_10_29_160000_add_invoice_payment_to_wallet_transactions_type.php b/04_Laravel/database/migrations/2026_10_29_160000_add_invoice_payment_to_wallet_transactions_type.php
new file mode 100644
index 0000000..172883a
--- /dev/null
+++ b/04_Laravel/database/migrations/2026_10_29_160000_add_invoice_payment_to_wallet_transactions_type.php
@@ -0,0 +1,60 @@
+where('type', 'invoice_payment')
+ ->count();
+ if ($count > 0) {
+ throw new RuntimeException(
+ "Cannot rollback: {$count} wallet transactions have type='invoice_payment'. "
+ . "Delete or reclassify them first."
+ );
+ }
+
+ DB::statement("
+ ALTER TABLE wallet_transactions
+ MODIFY COLUMN type ENUM(
+ 'deposit',
+ 'withdrawal',
+ 'order_payment',
+ 'refund',
+ 'credit_settlement'
+ ) NOT NULL
+ COMMENT 'نوع تراکنش'
+ ");
+ }
+};
diff --git a/04_Laravel/routes/api.php b/04_Laravel/routes/api.php
index 0060ad5..151e676 100644
--- a/04_Laravel/routes/api.php
+++ b/04_Laravel/routes/api.php
@@ -83,10 +83,16 @@ Route::middleware(['auth:sanctum', 'throttle:customer'])->prefix('v1')->group(fu
Route::prefix('customer')->group(function () {
// پروفایل و آمار
Route::get('/profile', [CustomerOrderController::class, 'profile']);
-
+
+ // فاکتورهای دستی (صدور شده توسط مدیر)
+ Route::get('/invoices', [\App\Http\Controllers\Api\Customer\CustomerInvoiceController::class, 'index']);
+ Route::get('/invoices/{invoice}', [\App\Http\Controllers\Api\Customer\CustomerInvoiceController::class, 'show']);
+ Route::post('/invoices/{invoice}/pay', [\App\Http\Controllers\Api\Customer\CustomerInvoiceController::class, 'pay'])
+ ->middleware('throttle:wallet');
+
// لیست کشورها (عمومی - بدون نیاز به احراز هویت)
// این endpoint در گروه auth:sanctum نیست
-
+
// سفارشات
Route::get('/orders', [CustomerOrderController::class, 'index']);
Route::post('/orders', [CustomerOrderController::class, 'store']);