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.
This commit is contained in:
Kazem Alghasi 2026-10-29 23:59:19 +03:30
parent 848f95f32e
commit 7fe368bc46
16 changed files with 1801 additions and 15 deletions

View File

@ -246,13 +246,37 @@ function ifnex_user_profile_shortcode($atts) {
<div style="font-size: 24px; font-weight: bold; color: <?php echo ($profile['account_balance'] ?? 0) >= 0 ? '#059669' : '#dc2626'; ?>;">
<?php echo number_format($profile['account_balance'] ?? 0); ?> ریال
</div>
<?php if (($profile['pending_payments_total'] ?? 0) > 0): ?>
<div style="font-size: 11px; color: #dc2626; margin-top: 5px;">
⚠️ بدهی در انتظار پرداخت: <?php echo number_format($profile['pending_payments_total']); ?> ریال
<?php
// تفکیک بدهی‌ها
$breakdown = $profile['pending_payments_breakdown'] ?? null;
$pending_total = $profile['pending_payments_total'] ?? 0;
$orders_debt = $breakdown['orders'] ?? $pending_total;
$invoices_debt = $breakdown['invoices'] ?? 0;
$invoices_count = $profile['pending_invoices_count'] ?? 0;
?>
<?php if ($pending_total > 0): ?>
<div style="font-size: 11px; color: #dc2626; margin-top: 8px; padding-top: 8px; border-top: 1px dashed #fecaca;">
⚠️ مجموع بدهی در انتظار پرداخت: <strong><?php echo number_format($pending_total); ?> ریال</strong>
</div>
<ul style="margin: 8px 0 0 0; padding-right: 20px; font-size: 11px; color: #666;">
<li>پرداختی سفارشات: <?php echo number_format($orders_debt); ?> ریال</li>
<li>
پرداختی فاکتورهای دستی
<?php if ($invoices_count > 0): ?>
(<?php echo (int)$invoices_count; ?> فاکتور)
<?php endif; ?>:
<?php echo number_format($invoices_debt); ?> ریال
<?php if ($invoices_count > 0): ?>
— <a href="<?php echo esc_url(home_url('/my-account/?tab=invoices')); ?>" style="color:#dc2626;font-weight:600;">مشاهده فاکتورها ←</a>
<?php endif; ?>
</li>
</ul>
<?php endif; ?>
</div>
<!-- بدهی‌های چندارزی -->
<!-- بدهی‌های چندارزی -->
<?php if (!empty($profile['foreign_credits'])): ?>
<div style="background: white; border-radius: 8px; padding: 15px; margin-bottom: 15px; border: 1px solid #fed7aa;">
<div style="font-size: 12px; color: #666; margin-bottom: 10px; font-weight: bold;">
@ -331,7 +355,7 @@ function ifnex_user_profile_shortcode($atts) {
</div>
<div class="ifnex-stat-card ifnex-stat-info">
<div class="ifnex-stat-value"><?php echo number_format($profile['stats']['pending_payment'] ?? 0); ?></div>
<div class="ifnex-stat-label">در انتظار پرداخت</div>
<div class="ifnex-stat-label">در انتظار پرداخت (سفارش)</div>
</div>
<div class="ifnex-stat-card ifnex-stat-info">
<div class="ifnex-stat-value"><?php echo number_format($profile['stats']['active_orders']); ?></div>
@ -342,6 +366,38 @@ function ifnex_user_profile_shortcode($atts) {
<div class="ifnex-stat-label">تحویل شده</div>
</div>
</div>
<?php
// بخش فاکتورهای دستی — اگه هرگز آمار فاکتور داره
$inv_pending_count = $profile['stats']['pending_invoices_count'] ?? 0;
$inv_pending_total = $profile['stats']['pending_invoices_total'] ?? 0;
$inv_paid_count = $profile['stats']['paid_invoices_count'] ?? 0;
$inv_paid_total = $profile['stats']['paid_invoices_total'] ?? 0;
?>
<?php if ($inv_pending_count > 0 || $inv_paid_count > 0): ?>
<div class="ifnex-stats-cards" style="margin-top:12px;">
<div class="ifnex-stat-card ifnex-stat-warning">
<div class="ifnex-stat-value"><?php echo (int)$inv_pending_count; ?></div>
<div class="ifnex-stat-label">فاکتور در انتظار پرداخت</div>
<div style="font-size:11px;color:#92400e;margin-top:4px;">
<?php echo number_format($inv_pending_total); ?> ریال
</div>
</div>
<div class="ifnex-stat-card ifnex-stat-success">
<div class="ifnex-stat-value"><?php echo (int)$inv_paid_count; ?></div>
<div class="ifnex-stat-label">فاکتور پرداخت‌شده</div>
<div style="font-size:11px;color:#065f46;margin-top:4px;">
<?php echo number_format($inv_paid_total); ?> ریال
</div>
</div>
</div>
<p style="font-size:12px;margin-top:8px;">
<a href="<?php echo esc_url(home_url('/my-account/?tab=invoices')); ?>" style="color:#0369a1;font-weight:600;">
📋 مشاهده همه فاکتورها ←
</a>
</p>
<?php endif; ?>
<?php if ($profile['stats']['total_spent'] > 0): ?>
<p class="ifnex-total-spent">
<strong>مجموع خرید:</strong>
@ -1616,6 +1672,7 @@ function ifnex_icon($name, $size = 20) {
'check' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>',
'clock' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"/>',
'bell' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>',
'invoice' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>',
];
$path = $icons[$name] ?? $icons['dashboard'];
@ -1643,7 +1700,7 @@ function ifnex_customer_dashboard_shortcode($atts) {
}
$tab = sanitize_key($_GET['tab'] ?? 'dashboard');
$allowed_tabs = ['dashboard', 'notifications', 'orders', 'new-order', 'wallet', 'transactions', 'tracking', 'profile'];
$allowed_tabs = ['dashboard', 'notifications', 'orders', 'new-order', 'invoices', 'wallet', 'transactions', 'tracking', 'profile'];
if (!in_array($tab, $allowed_tabs)) $tab = 'dashboard';
$bridge = new IFNEX_User_Bridge();
@ -1664,6 +1721,13 @@ function ifnex_customer_dashboard_shortcode($atts) {
$unread_notifications = (int) $notif_count_result['unread_count'];
}
// تعداد فاکتورهای در انتظار پرداخت برای badge
$pending_invoices_count = 0;
$invoices_result = $bridge->authenticated_request($user_id, '/customer/invoices?status=pending_payment&per_page=100', 'GET');
if (!is_wp_error($invoices_result) && isset($invoices_result['data']['invoices'])) {
$pending_invoices_count = count($invoices_result['data']['invoices']);
}
$recent_orders = [];
if ($tab === 'dashboard') {
$orders_result = $bridge->get_customer_orders($user_id, 3);
@ -1676,6 +1740,7 @@ function ifnex_customer_dashboard_shortcode($atts) {
'notifications'=> ['icon' => 'bell', 'label' => 'اعلان‌ها'],
'orders' => ['icon' => 'orders', 'label' => 'سفارشات من'],
'new-order' => ['icon' => 'plus', 'label' => 'ثبت سفارش جدید'],
'invoices' => ['icon' => 'invoice', 'label' => 'فاکتورهای دستی'],
'wallet' => ['icon' => 'wallet', 'label' => 'کیف پول'],
'transactions' => ['icon' => 'chart', 'label' => 'تراکنش‌ها'],
'tracking' => ['icon' => 'tracking', 'label' => 'رهگیری مرسوله'],
@ -1684,6 +1749,7 @@ function ifnex_customer_dashboard_shortcode($atts) {
$tab_titles = [
'dashboard' => 'داشبورد', 'notifications' => 'اعلان‌ها', 'orders' => 'سفارشات من', 'new-order' => 'ثبت سفارش جدید',
'invoices' => 'فاکتورهای دستی',
'wallet' => 'کیف پول', 'transactions' => 'تراکنش‌ها', 'tracking' => 'رهگیری مرسوله', 'profile' => 'پروفایل',
];
@ -1707,6 +1773,9 @@ function ifnex_customer_dashboard_shortcode($atts) {
<?php if ($key === 'notifications' && $unread_notifications > 0): ?>
<span class="ifnx-menu-badge"><?php echo (int)$unread_notifications; ?></span>
<?php endif; ?>
<?php if ($key === 'invoices' && $pending_invoices_count > 0): ?>
<span class="ifnx-menu-badge"><?php echo (int)$pending_invoices_count; ?></span>
<?php endif; ?>
</a>
<?php endforeach; ?>
<div class="ifnx-menu-divider"></div>
@ -1744,7 +1813,19 @@ function ifnex_customer_dashboard_shortcode($atts) {
<div class="ifnx-stat-icon"><?php echo ifnex_icon('clock', 22); ?></div>
<div class="ifnx-stat-info">
<span class="ifnx-stat-value"><?php echo number_format($stats['pending_payment'] ?? 0); ?></span>
<span class="ifnx-stat-label">در انتظار پرداخت</span>
<span class="ifnx-stat-label">در انتظار پرداخت (سفارش)</span>
</div>
</div>
<div class="ifnx-stat" style="--tint:#fff7ed; --clr:#ea580c;">
<div class="ifnx-stat-icon"><?php echo ifnex_icon('invoice', 22); ?></div>
<div class="ifnx-stat-info">
<span class="ifnx-stat-value"><?php echo (int)($stats['pending_invoices_count'] ?? 0); ?></span>
<span class="ifnx-stat-label">
فاکتور دستی در انتظار پرداخت
<?php if (($stats['pending_invoices_count'] ?? 0) > 0): ?>
<a href="<?php echo esc_url(home_url('/my-account/?tab=invoices')); ?>" style="font-size:10px;color:#ea580c;font-weight:600;display:block;margin-top:2px;">مشاهده ←</a>
<?php endif; ?>
</span>
</div>
</div>
<div class="ifnx-stat" style="--tint:#f5f3ff; --clr:#8b5cf6;">
@ -1792,6 +1873,282 @@ function ifnex_customer_dashboard_shortcode($atts) {
<?php echo do_shortcode('[ifnex_orders_list]'); ?>
<?php elseif ($tab === 'new-order'): ?>
<?php echo do_shortcode('[ifnex_order_form]'); ?>
<?php elseif ($tab === 'invoices'): ?>
<?php
// نمایش لیست یا جزئیات فاکتور بر اساس پارامتر ?invoice=X
$single_invoice_id = isset($_GET['invoice']) ? intval($_GET['invoice']) : 0;
if ($single_invoice_id > 0) {
// دریافت جزئیات فاکتور از API
$single_result = $bridge->authenticated_request($user_id, "/customer/invoices/{$single_invoice_id}", 'GET');
if (is_wp_error($single_result)) {
echo '<div class="ifnex-alert ifnex-alert-danger">خطا در دریافت فاکتور: ' . esc_html($single_result->get_error_message()) . '</div>';
} 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);
?>
<style>
.ifnex-invoice-detail {background:#fff;border-radius:12px;padding:24px;border:1px solid #e2e8f0;}
.ifnex-invoice-detail h3 {margin:0 0 16px;color:#0f172a;font-size:20px;}
.ifnex-invoice-meta {display:flex;flex-wrap:wrap;gap:16px;margin-bottom:20px;padding:14px 16px;background:#f8fafc;border-radius:8px;}
.ifnex-invoice-meta div {font-size:13px;color:#475569;}
.ifnex-invoice-meta strong {color:#0f172a;font-weight:600;}
.ifnex-status-badge {padding:4px 12px;border-radius:999px;font-size:12px;font-weight:700;display:inline-block;}
.ifnex-status-pending_payment {background:#fef3c7;color:#92400e;}
.ifnex-status-paid {background:#ecfdf5;color:#065f46;}
.ifnex-status-cancelled {background:#fee2e2;color:#991b1b;}
.ifnex-status-expired {background:#f1f5f9;color:#475569;}
.ifnex-items-table {width:100%;border-collapse:collapse;margin:16px 0;}
.ifnex-items-table th, .ifnex-items-table td {padding:10px 12px;text-align:right;border-bottom:1px solid #e2e8f0;}
.ifnex-items-table th {background:#f1f5f9;font-weight:600;color:#334155;font-size:13px;}
.ifnex-items-table td {font-size:14px;color:#1e293b;}
.ifnex-items-table .num {text-align:left;font-variant-numeric:tabular-nums;}
.ifnex-totals {margin-top:16px;padding:14px 16px;background:#f8fafc;border-radius:8px;}
.ifnex-totals div {display:flex;justify-content:space-between;padding:6px 0;font-size:14px;}
.ifnex-totals .grand {font-size:18px;font-weight:700;color:#059669;border-top:2px solid #10b981;margin-top:8px;padding-top:10px;}
.ifnex-note {margin-top:16px;padding:12px 16px;background:#fef3c7;border-right:4px solid #f59e0b;border-radius:6px;font-size:13px;color:#78350f;}
.ifnex-invoice-actions {margin-top:20px;display:flex;gap:10px;}
.ifnex-pay-btn {background:#10b981;color:#fff;border:none;padding:12px 24px;border-radius:8px;font-size:15px;font-weight:700;cursor:pointer;}
.ifnex-pay-btn:hover {background:#059669;}
.ifnex-pay-btn:disabled {background:#9ca3af;cursor:not-allowed;}
.ifnex-back-btn {background:#e5e7eb;color:#1e293b;padding:12px 24px;border-radius:8px;text-decoration:none;font-size:14px;display:inline-flex;align-items:center;}
</style>
<div class="ifnex-invoice-detail">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px;">
<div>
<h3>فاکتور <?php echo esc_html($inv['invoice_number']); ?></h3>
<span class="ifnex-status-badge ifnex-status-<?php echo esc_attr($status_class); ?>"><?php echo esc_html($status_label); ?></span>
</div>
<a href="<?php echo esc_url(home_url('/my-account/?tab=invoices')); ?>" class="ifnex-back-btn">→ بازگشت به لیست</a>
</div>
<div class="ifnex-invoice-meta">
<div>صادرکننده: <strong><?php echo esc_html($inv['issued_by']['name'] ?? '—'); ?></strong></div>
<div>تاریخ صدور: <strong><?php echo esc_html(ifnex_format_jalali_date($inv['created_at'])); ?></strong></div>
<?php if (!empty($inv['due_date'])): ?>
<div>موعد پرداخت: <strong><?php echo esc_html($inv['due_date']); ?></strong></div>
<?php endif; ?>
<?php if (!empty($inv['paid_at'])): ?>
<div>تاریخ پرداخت: <strong><?php echo esc_html(ifnex_format_jalali_date($inv['paid_at'])); ?></strong></div>
<?php endif; ?>
</div>
<?php if (!empty($items)): ?>
<table class="ifnex-items-table">
<thead>
<tr>
<th>#</th>
<th>شرح خدمت/کالا</th>
<th class="num">تعداد</th>
<th class="num">قیمت واحد (ریال)</th>
<th class="num">مجموع (ریال)</th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $i => $item): ?>
<?php
$qty = (float)($item['quantity'] ?? 0);
$unit = (float)($item['unit_price'] ?? 0);
$line_total = $qty * $unit;
?>
<tr>
<td><?php echo (int)$i + 1; ?></td>
<td><?php echo esc_html($item['description'] ?? ''); ?></td>
<td class="num"><?php echo number_format($qty); ?></td>
<td class="num"><?php echo number_format($unit); ?></td>
<td class="num"><?php echo number_format($line_total); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<div class="ifnex-totals">
<div><span>مجموع اقلام:</span><strong><?php echo number_format($subtotal); ?> ریال</strong></div>
<?php if ($discount > 0): ?>
<div><span>تخفیف:</span><strong>- <?php echo number_format($discount); ?> ریال</strong></div>
<?php endif; ?>
<div class="grand"><span>مبلغ قابل پرداخت:</span><span><?php echo number_format($total); ?> ریال</span></div>
</div>
<?php if (!empty($inv['notes_to_customer'])): ?>
<div class="ifnex-note"><?php echo esc_html($inv['notes_to_customer']); ?></div>
<?php endif; ?>
<?php if (!empty($inv['can_pay'])): ?>
<div class="ifnex-invoice-actions">
<button type="button" class="ifnex-pay-btn" data-invoice-id="<?php echo (int)$inv['id']; ?>">💳 پرداخت از کیف پول</button>
</div>
<?php elseif ($inv['status'] === 'paid'): ?>
<div style="margin-top:20px;padding:14px 16px;background:#ecfdf5;border:1px solid #10b981;border-radius:8px;color:#065f46;font-weight:600;">
✅ این فاکتور پرداخت شده است.
</div>
<?php elseif ($inv['status'] === 'cancelled'): ?>
<div style="margin-top:20px;padding:14px 16px;background:#fee2e2;border:1px solid #ef4444;border-radius:8px;color:#991b1b;font-weight:600;">
❌ این فاکتور لغو شده است.
</div>
<?php endif; ?>
</div>
<script>
jQuery(function($) {
$('.ifnex-pay-btn').on('click', function() {
var btn = $(this);
var invoiceId = btn.data('invoice-id');
if (!confirm('پرداخت این فاکتور از کیف پول شما انجام شود؟')) return;
btn.prop('disabled', true).text('در حال پرداخت...');
$.ajax({
url: ifnex_ajax.ajax_url,
method: 'POST',
data: { action: 'ifnex_pay_invoice', nonce: ifnex_ajax.nonce, invoice_id: invoiceId },
success: function(res) {
if (res.success) {
alert('✅ فاکتور با موفقیت پرداخت شد.');
location.reload();
} else {
alert('❌ ' + (res.data || 'خطا در پرداخت.'));
btn.prop('disabled', false).text('💳 پرداخت از کیف پول');
}
},
error: function() {
alert('❌ خطا در ارتباط با سرور.');
btn.prop('disabled', false).text('💳 پرداخت از کیف پول');
}
});
});
});
</script>
<?php else: ?>
<div class="ifnex-alert ifnex-alert-warning">فاکتور یافت نشد.</div>
<?php endif; ?>
<?php } ?>
<?php } else { // نمایش لیست فاکتورها
// فیلتر از URL
$status_filter = isset($_GET['status']) ? sanitize_key($_GET['status']) : '';
$invoices_url = '/customer/invoices?per_page=50';
if (!empty($status_filter)) {
$invoices_url .= '&status=' . $status_filter;
}
$list_result = $bridge->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',
];
?>
<style>
.ifnex-inv-filters {display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap;}
.ifnex-inv-filter-btn {padding:8px 16px;border-radius:8px;border:1px solid #e2e8f0;background:#fff;color:#475569;text-decoration:none;font-size:13px;font-weight:500;}
.ifnex-inv-filter-btn.active {background:#1e3a5f;color:#fff;border-color:#1e3a5f;}
.ifnex-inv-card {background:#fff;border-radius:12px;padding:18px;border:1px solid #e2e8f0;margin-bottom:12px;display:flex;justify-content:space-between;align-items:center;transition:box-shadow 0.2s;}
.ifnex-inv-card:hover {box-shadow:0 4px 12px rgba(0,0,0,0.08);}
.ifnex-inv-card-meta {font-size:12px;color:#64748b;margin-top:4px;}
.ifnex-inv-card-amount {font-size:18px;font-weight:700;color:#0f172a;}
.ifnex-status-badge {padding:4px 12px;border-radius:999px;font-size:11px;font-weight:700;display:inline-block;}
.ifnex-status-pending {background:#fef3c7;color:#92400e;}
.ifnex-status-paid {background:#ecfdf5;color:#065f46;}
.ifnex-status-cancelled {background:#fee2e2;color:#991b1b;}
.ifnex-status-expired {background:#f1f5f9;color:#475569;}
.ifnex-empty {text-align:center;padding:40px 20px;color:#64748b;}
.ifnex-empty-icon {font-size:48px;margin-bottom:12px;}
.ifnex-inv-actions {display:flex;gap:8px;}
.ifnex-inv-btn {padding:8px 16px;border-radius:6px;text-decoration:none;font-size:13px;font-weight:600;}
.ifnex-inv-btn-view {background:#e5e7eb;color:#1e293b;}
.ifnex-inv-btn-pay {background:#10b981;color:#fff;border:none;cursor:pointer;}
</style>
<div class="ifnex-inv-filters">
<a href="<?php echo esc_url(home_url('/my-account/?tab=invoices')); ?>" class="ifnex-inv-filter-btn <?php echo empty($status_filter) ? 'active' : ''; ?>">همه</a>
<a href="<?php echo esc_url(home_url('/my-account/?tab=invoices&status=pending_payment')); ?>" class="ifnex-inv-filter-btn <?php echo $status_filter === 'pending_payment' ? 'active' : ''; ?>">در انتظار پرداخت</a>
<a href="<?php echo esc_url(home_url('/my-account/?tab=invoices&status=paid')); ?>" class="ifnex-inv-filter-btn <?php echo $status_filter === 'paid' ? 'active' : ''; ?>">پرداخت شده</a>
<a href="<?php echo esc_url(home_url('/my-account/?tab=invoices&status=cancelled')); ?>" class="ifnex-inv-filter-btn <?php echo $status_filter === 'cancelled' ? 'active' : ''; ?>">لغو شده</a>
</div>
<?php if (empty($invoices)): ?>
<div class="ifnex-empty">
<div class="ifnex-empty-icon">📄</div>
<p>فاکتوری برای نمایش وجود ندارد.</p>
</div>
<?php else: ?>
<?php foreach ($invoices as $inv):
$inv_status = $inv['status'] ?? 'pending_payment';
$status_label = $status_labels[$inv_status] ?? $inv_status;
$status_class = $status_classes[$inv_status] ?? '';
$items_count = is_array($inv['items'] ?? null) ? count($inv['items']) : 0;
$detail_url = home_url('/my-account/?tab=invoices&invoice=' . $inv['id']);
$can_pay = !empty($inv['can_pay']);
?>
<div class="ifnex-inv-card">
<div>
<div style="font-weight:700;color:#0f172a;font-size:15px;"><?php echo esc_html($inv['invoice_number']); ?></div>
<div class="ifnex-inv-card-meta">
<?php echo (int)$items_count; ?> قلم | <?php echo esc_html(ifnex_format_jalali_date($inv['created_at'])); ?>
<?php if (!empty($inv['due_date'])): ?> | موعد: <?php echo esc_html($inv['due_date']); ?><?php endif; ?>
</div>
<div style="margin-top:8px;">
<span class="ifnex-status-badge ifnex-status-<?php echo esc_attr($status_class); ?>"><?php echo esc_html($status_label); ?></span>
</div>
</div>
<div style="text-align:left;">
<div class="ifnex-inv-card-amount"><?php echo number_format((float)$inv['total_amount']); ?> <small style="font-size:11px;color:#64748b;font-weight:normal;">ریال</small></div>
<div class="ifnex-inv-actions" style="margin-top:8px;">
<a href="<?php echo esc_url($detail_url); ?>" class="ifnex-inv-btn ifnex-inv-btn-view">مشاهده</a>
<?php if ($can_pay): ?>
<button type="button" class="ifnex-inv-btn ifnex-inv-btn-pay ifnex-btn-pay-list" data-invoice-id="<?php echo (int)$inv['id']; ?>">پرداخت</button>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<script>
jQuery(function($) {
$('.ifnex-btn-pay-list').on('click', function() {
var btn = $(this);
var invoiceId = btn.data('invoice-id');
if (!confirm('پرداخت این فاکتور از کیف پول شما انجام شود؟')) return;
btn.prop('disabled', true).text('...');
$.ajax({
url: ifnex_ajax.ajax_url,
method: 'POST',
data: { action: 'ifnex_pay_invoice', nonce: ifnex_ajax.nonce, invoice_id: invoiceId },
success: function(res) {
if (res.success) {
alert('✅ فاکتور با موفقیت پرداخت شد.');
location.reload();
} else {
alert('❌ ' + (res.data || 'خطا در پرداخت.'));
btn.prop('disabled', false).text('پرداخت');
}
},
error: function() {
alert('❌ خطا در ارتباط با سرور.');
btn.prop('disabled', false).text('پرداخت');
}
});
});
});
</script>
<?php } ?>
<?php elseif ($tab === 'wallet'): ?>
<div class="ifnx-wallet-hero">
<div class="ifnx-wallet-label">موجودی فعلی کیف پول</div>
@ -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';
}
?>
<div class="ifnex-notification <?php echo $is_unread ? 'unread' : ''; ?>">
<div class="ifnex-notification-icon"><?php echo ifnex_icon($icon_name, 22); ?></div>
@ -1947,4 +2314,149 @@ function ifnex_customer_dashboard_shortcode($atts) {
</div>
<?php
return ob_get_clean();
}
}
// ═══════════════════════════════════════════════════════════════════
// فاکتورهای دستی (Manual Invoices)
// ═══════════════════════════════════════════════════════════════════
/**
* شورت‌کد لیست فاکتورهای دستی مشتری.
* استفاده: [ifnex_invoices]
*/
add_shortcode('ifnex_invoices', 'ifnex_invoices_list_shortcode');
function ifnex_invoices_list_shortcode($atts) {
if (!is_user_logged_in()) {
return '<div class="ifnex-alert ifnex-alert-warning">برای مشاهده فاکتورها باید وارد شوید.</div>';
}
$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 '<div class="ifnex-alert ifnex-alert-danger">خطا در دریافت فاکتورها: ' . esc_html($result->get_error_message()) . '</div>';
}
$invoices = $result['data']['invoices'] ?? [];
$pagination = $result['data']['pagination'] ?? null;
ob_start();
?>
<div class="ifnex-invoices-wrap">
<h2 class="ifnex-section-title">فاکتورهای دستی</h2>
<?php if (empty($invoices)): ?>
<div class="ifnex-empty-state">
<div class="ifnex-empty-icon">📄</div>
<p>فاکتور دستی برای شما ثبت نشده است.</p>
</div>
<?php else: ?>
<div class="ifnex-invoices-list">
<?php foreach ($invoices as $inv): ?>
<?php
$status_label = [
'pending_payment' => 'در انتظار پرداخت',
'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;
?>
<div class="ifnex-invoice-card ifnex-status-<?php echo esc_attr($status_class); ?>">
<div class="ifnex-invoice-header">
<div>
<div class="ifnex-invoice-number"><?php echo esc_html($inv['invoice_number']); ?></div>
<div class="ifnex-invoice-date">صدور: <?php echo esc_html(ifnex_format_jalali_date($inv['created_at'])); ?></div>
</div>
<span class="ifnex-invoice-status ifnex-status-<?php echo esc_attr($status_class); ?>"><?php echo esc_html($status_label); ?></span>
</div>
<div class="ifnex-invoice-body">
<div class="ifnex-invoice-row">
<span>تعداد اقلام:</span>
<strong><?php echo (int)$items_count; ?> قلم</strong>
</div>
<div class="ifnex-invoice-row">
<span>مبلغ نهایی:</span>
<strong class="ifnex-amount"><?php echo number_format((float)$inv['total_amount']); ?> ریال</strong>
</div>
<?php if (!empty($inv['due_date'])): ?>
<div class="ifnex-invoice-row">
<span>موعد پرداخت:</span>
<strong><?php echo esc_html($inv['due_date']); ?></strong>
</div>
<?php endif; ?>
<?php if (!empty($inv['notes_to_customer'])): ?>
<div class="ifnex-invoice-note"><?php echo esc_html($inv['notes_to_customer']); ?></div>
<?php endif; ?>
</div>
<div class="ifnex-invoice-actions">
<a href="<?php echo esc_url(home_url('/my-account/?tab=invoices&invoice=' . $inv['id'])); ?>" class="ifnex-btn ifnex-btn-sm">جزئیات</a>
<?php if (!empty($inv['can_pay'])): ?>
<button type="button" class="ifnex-btn ifnex-btn-sm ifnex-btn-pay" data-invoice-id="<?php echo (int)$inv['id']; ?>">پرداخت از کیف پول</button>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<script>
jQuery(function($) {
$(document).on('click', '.ifnex-btn-pay', function() {
var btn = $(this);
var invoiceId = btn.data('invoice-id');
if (!confirm('پرداخت این فاکتور از کیف پول شما انجام شود؟')) return;
btn.prop('disabled', true).text('در حال پرداخت...');
$.ajax({
url: ifnex_ajax.ajax_url,
method: 'POST',
data: {
action: 'ifnex_pay_invoice',
nonce: ifnex_ajax.nonce,
invoice_id: invoiceId
},
success: function(res) {
if (res.success) {
alert('✅ فاکتور با موفقیت پرداخت شد.');
location.reload();
} else {
alert('❌ ' + (res.data || 'خطا در پرداخت فاکتور.'));
btn.prop('disabled', false).text('پرداخت از کیف پول');
}
},
error: function() {
alert('❌ خطا در ارتباط با سرور.');
btn.prop('disabled', false).text('پرداخت از کیف پول');
}
});
});
});
</script>
<?php
return ob_get_clean();
}
/**
* فرمت تاریخ شمسی (helper).
*/
if (!function_exists('ifnex_format_jalali_date')) {
function ifnex_format_jalali_date($iso_date) {
if (empty($iso_date)) return '';
try {
$ts = strtotime($iso_date);
return jdate('Y/m/d H:i', $ts);
} catch (\Throwable $e) {
return $iso_date;
}
}
}

View File

@ -1002,4 +1002,37 @@ function ifnex_upload_commitment_form_ajax() {
if (!$result || !isset($result['success'])) wp_send_json_error('خطا در پردازش پاسخ سرور.');
wp_send_json_success($result['data']);
}
}
// ═══════════════════════════════════════════════════════════════════
// AJAX: پرداخت فاکتور دستی از کیف پول
// ═══════════════════════════════════════════════════════════════════
add_action('wp_ajax_ifnex_pay_invoice', 'ifnex_pay_invoice_ajax');
function ifnex_pay_invoice_ajax() {
check_ajax_referer('ifnex_ajax_nonce', 'nonce');
if (!is_user_logged_in()) {
wp_send_json_error('باید وارد شوید.');
}
$invoice_id = intval($_POST['invoice_id'] ?? 0);
if (!$invoice_id) {
wp_send_json_error('شناسه فاکتور نامعتبر است.');
}
$bridge = new IFNEX_User_Bridge();
$result = $bridge->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);
}

View File

@ -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',
};
}
}

View File

@ -0,0 +1,317 @@
<?php
namespace App\Filament\Resources;
use App\Models\ManualInvoice;
use App\Models\User;
use App\Services\ManualInvoiceService;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Get;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Tables\Actions\Action;
use Illuminate\Support\HtmlString;
class ManualInvoiceResource extends Resource
{
protected static ?string $model = ManualInvoice::class;
protected static ?string $navigationIcon = 'heroicon-o-document-text';
protected static ?string $navigationLabel = 'فاکتورهای دستی';
protected static ?string $navigationGroup = 'مالی';
protected static ?int $navigationSort = 6;
protected static ?string $slug = 'manual-invoices';
protected static ?string $recordTitleAttribute = 'invoice_number';
public static function form(Form $form): Form
{
$isEdit = $form->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('<strong style="font-size:16px">' . number_format($subtotal) . ' ریال</strong>');
})
->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(
'<strong style="font-size:18px;color:#059669">'
. number_format($total)
. ' ریال</strong>'
);
})
->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;
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Filament\Resources\ManualInvoiceResource\Pages;
use App\Filament\Resources\ManualInvoiceResource;
use Filament\Resources\Pages\CreateRecord;
class CreateManualInvoice extends CreateRecord
{
protected static string $resource = ManualInvoiceResource::class;
protected static ?string $title = 'صدور فاکتور دستی';
protected function mutateFormDataBeforeCreate(array $data): array
{
return ManualInvoiceResource::mutateFormDataBeforeCreate($data);
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
protected function getCreatedNotificationTitle(): ?string
{
return 'فاکتور با موفقیت صادر شد.';
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Filament\Resources\ManualInvoiceResource\Pages;
use App\Filament\Resources\ManualInvoiceResource;
use Filament\Resources\Pages\EditRecord;
class EditManualInvoice extends EditRecord
{
protected static string $resource = ManualInvoiceResource::class;
protected static ?string $title = 'ویرایش فاکتور';
protected function mutateFormDataBeforeSave(array $data): array
{
// فقط اگه فاکتور pending_payment باشه قابل ویرایشه
// محاسبه مجدد subtotal و total
$totals = \App\Models\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'];
return $data;
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\ManualInvoiceResource\Pages;
use App\Filament\Resources\ManualInvoiceResource;
use Filament\Resources\Pages\ListRecords;
class ListManualInvoices extends ListRecords
{
protected static string $resource = ManualInvoiceResource::class;
protected static ?string $title = 'فاکتورهای دستی';
protected function getHeaderActions(): array
{
return [
\Filament\Actions\CreateAction::make()
->label('صدور فاکتور جدید'),
];
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Filament\Resources\ManualInvoiceResource\Pages;
use App\Filament\Resources\ManualInvoiceResource;
use Filament\Resources\Pages\ViewRecord;
class ViewManualInvoice extends ViewRecord
{
protected static string $resource = ManualInvoiceResource::class;
protected static ?string $title = 'مشاهده فاکتور';
}

View File

@ -0,0 +1,118 @@
<?php
namespace App\Http\Controllers\Api\Customer;
use App\Http\Controllers\Controller;
use App\Models\ManualInvoice;
use App\Services\ManualInvoiceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
/**
* CustomerInvoiceController — API فاکتورهای دستی برای مشتری (در پورتال وردپرس)
*
* مسیرها:
* GET /api/v1/customer/invoices — لیست فاکتورها
* GET /api/v1/customer/invoices/{id} — جزئیات فاکتور
* POST /api/v1/customer/invoices/{id}/pay — پرداخت از کیف پول
*/
class CustomerInvoiceController extends Controller
{
public function __construct(
protected ManualInvoiceService $invoiceService,
) {}
/**
* GET /api/v1/customer/invoices
* لیست فاکتورهای مشتری با امکان فیلتر بر اساس وضعیت.
*/
public function index(Request $request): JsonResponse
{
$validated = $request->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);
}
}
}

View File

@ -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,
]);

View File

@ -0,0 +1,161 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasOne;
use App\Traits\Auditable;
/**
* ManualInvoice — فاکتور دستی صادر شده توسط مدیر
*
* سناریو:
* - مشتری خدماتی دریافت کرده (مثلاً مشاوره تلفنی، انبارداری اضافی)
* - مدیر برای او فاکتور صادر می‌کند با اقلام + مبالغ
* - مشتری در پورتالش فاکتور را می‌بیند
* - مشتری از کیف پول یا درگاه پرداخت می‌کند
* - پس از پرداخت، مبلغ در تراکنش‌های کیف پول ثبت می‌شود
*
* همه مبالغ به ریال ایران (IRR) ذخیره می‌شوند.
*/
class ManualInvoice extends Model
{
use Auditable;
protected $table = 'manual_invoices';
protected $fillable = [
'user_id',
'issued_by',
'invoice_number',
'subtotal',
'discount_amount',
'total_amount',
'items',
'notes_to_customer',
'admin_notes',
'status',
'due_date',
'paid_at',
'wallet_transaction_id',
'payment_id',
'cancelled_by',
];
protected $casts = [
'items' => '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),
];
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Notifications;
use App\Models\ManualInvoice;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
/**
* اعلان به مشتری: فاکتور دستی جدیدی برای شما صادر شده است.
*
* Trigger: ManualInvoiceService::createInvoice()
*/
class ManualInvoiceCreatedNotification extends Notification
{
use Queueable;
public function __construct(
public ManualInvoice $invoice,
public ?User $issuedBy = null,
) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
return [
'type' => '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,
];
}
}

View File

@ -0,0 +1,291 @@
<?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('کاربر انتخابی نقش مشتری ندارد.');
}
}
}

View File

@ -0,0 +1,117 @@
<?php
/**
* Migration: ایجاد جدول فاکتورهای دستی مدیر
*
* فاکتور دستی = سندی که مدیر/کارمند برای مشتری صادر می‌کند تا هزینه‌های خارج از سفارش
* (خدمات تلفنی، هزینه‌های جانبی، انبارداری اضافی و...) را از مشتری دریافت کند.
*
* مشتری فاکتور را در پورتالش می‌بیند و از کیف پول یا درگاه پرداخت می‌کند.
*
* جریان:
* 1. مدیر فاکتور صادر می‌کند → status = 'pending_payment'
* 2. مشتری در پورتالش می‌بیند
* 3. مشتری از کیف پول پرداخت می‌کند → status = 'paid'
* 4. مبلغ از کیف پول مشتری کسر و در تراکنش‌ها ثبت می‌شود
* 5. در صورت لغو توسط مدیر → status = 'cancelled'
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
Schema::create('manual_invoices', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,60 @@
<?php
/**
* Migration: افزودن 'invoice_payment' به enum type در wallet_transactions
*
* برای پشتیبانی از فاکتورهای دستی مدیر، یک نوع تراکنش جدید اضافه می‌کنیم:
* 'invoice_payment' — کسر مبلغ فاکتور از کیف پول مشتری.
*/
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// MySQL: ALTER COLUMN به‌روزرسانی enum
// مقادیر جدید شامل همه موارد قبلی + 'invoice_payment'
DB::statement("
ALTER TABLE wallet_transactions
MODIFY COLUMN type ENUM(
'deposit',
'withdrawal',
'order_payment',
'refund',
'credit_settlement',
'invoice_payment'
) NOT NULL
COMMENT 'نوع تراکنش'
");
}
public function down(): void
{
// بازگشت به حالت قبلی — فقط اگه هیچ رکورد با type='invoice_payment' نباشد
$count = DB::table('wallet_transactions')
->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 'نوع تراکنش'
");
}
};

View File

@ -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']);