feat(finance): implement multi-currency credit tracking and dashboard widgets

Integrate multi-currency debt visibility across the platform by adding
support for foreign currency credits in the user profile shortcode,
improving the admin dashboard with unsettled credits widgets, and
enhancing the Filament admin panel with navigation badges and relation
managers.

- Add multi-currency debt table to WordPress user profile shortcode
- Implement `UnsettledCreditsWidget` for Filament dashboard
- Add navigation badge to `CustomerCreditResource` for open credits
- Add `CreditsRelationManager` to `UserResource`
- Update `CustomerOrderController` API to include `foreign_credits`
- Add concurrency check in `CustomerCreditService` during settlement
This commit is contained in:
Kazem Alghasi 2026-10-04 01:01:20 +03:30
parent ab972fe3e8
commit 93ebbb1fc8
10 changed files with 497 additions and 3 deletions

View File

@ -252,7 +252,87 @@ function ifnex_user_profile_shortcode($atts) {
</div>
<?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;">
🌍 بدهی‌های چندارزی
</div>
<table style="width: 100%; font-size: 13px; border-collapse: collapse;">
<thead>
<tr style="text-align: right; color: #666; border-bottom: 1px solid #e5e7eb;">
<th style="padding: 4px 8px; font-weight: 600;">ارز</th>
<th style="padding: 4px 8px; font-weight: 600;">مبلغ کل</th>
<th style="padding: 4px 8px; font-weight: 600;">تسویه‌شده</th>
<th style="padding: 4px 8px; font-weight: 600;">مانده</th>
</tr>
</thead>
<tbody>
<?php foreach ($profile['foreign_credits'] as $fc): ?>
<tr style="border-bottom: 1px solid #f3f4f6;">
<td style="padding: 6px 8px;">
<span style="background: #fef3c7; color: #b45309; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700;">
<?php echo esc_html($fc['currency_code']); ?>
</span>
</td>
<td style="padding: 6px 8px; font-weight: 600;">
<?php echo number_format($fc['total_amount'], 2); ?>
</td>
<td style="padding: 6px 8px; color: #059669;">
<?php echo number_format($fc['total_settled'], 2); ?>
</td>
<td style="padding: 6px 8px; font-weight: 700; color: #dc2626;">
<?php echo number_format($fc['balance'], 2); ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div style="font-size: 10px; color: #9ca3af; margin-top: 8px;">
⚠️ بدهی‌های بالا به ارز اصلی ثبت شده‌اند و با نرخ روز تسویه می‌شوند.
</div>
</div>
<?php endif; ?>
<!-- بدهی‌های چندارزی -->
<?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;">
🌍 بدهی‌های چندارزی
</div>
<table style="width: 100%; font-size: 13px; border-collapse: collapse;">
<thead>
<tr style="text-align: right; color: #666; border-bottom: 1px solid #e5e7eb;">
<th style="padding: 4px 8px;">ارز</th>
<th style="padding: 4px 8px;">مبلغ کل</th>
<th style="padding: 4px 8px;">تسویه‌شده</th>
<th style="padding: 4px 8px;">مانده</th>
</tr>
</thead>
<tbody>
<?php foreach ($profile['foreign_credits'] as $fc): ?>
<tr style="border-bottom: 1px solid #f3f4f6;">
<td style="padding: 6px 8px;">
<span style="background: #fef3c7; color: #b45309; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 700;">
<?php echo esc_html($fc['currency_code']); ?>
</span>
</td>
<td style="padding: 6px 8px; font-weight: 600;">
<?php echo number_format($fc['total_amount'], 2); ?>
</td>
<td style="padding: 6px 8px; color: #059669;">
<?php echo number_format($fc['total_settled'], 2); ?>
</td>
<td style="padding: 6px 8px; font-weight: 700; color: #dc2626;">
<?php echo number_format($fc['balance'], 2); ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<!-- جزئیات -->
<div style="display: flex; gap: 15px;">
<div style="flex: 1; background: white; border-radius: 8px; padding: 12px; border: 1px solid #e0f2fe;">
@ -1749,6 +1829,73 @@ function ifnex_customer_dashboard_shortcode($atts) {
<a href="<?php echo esc_url(add_query_arg('tab', 'transactions', $base_url)); ?>" class="ifnx-wallet-btn ghost">مشاهده تراکنش‌ها</a>
</div>
</div>
<!-- ════════ بدهی در انتظار پرداخت (ریالی) ════════ -->
<?php if (($profile['pending_payments_total'] ?? 0) > 0): ?>
<div class="ifnx-card" style="margin-top: 20px; background: #fef2f2; border: 1px solid #fecaca; border-radius: 12px; padding: 18px;">
<div style="font-size: 14px; font-weight: bold; color: #dc2626; margin-bottom: 8px;">
⚠️ بدهی در انتظار پرداخت
</div>
<div style="font-size: 22px; font-weight: bold; color: #dc2626;">
<?php echo number_format($profile['pending_payments_total']); ?> <small>ریال</small>
</div>
<div style="font-size: 12px; color: #991b1b; margin-top: 6px;">
<?php echo number_format($stats['pending_payment'] ?? 0); ?> سفارش تأییدشده در انتظار پرداخت
</div>
</div>
<?php endif; ?>
<!-- ════════ بدهی‌های چندارزی (EUR/USD/AED/CNY) ════════ -->
<?php if (!empty($profile['foreign_credits'])): ?>
<div class="ifnx-card" style="margin-top: 20px; background: #fffbeb; border: 1px solid #fed7aa; border-radius: 12px; padding: 18px;">
<div style="font-size: 14px; font-weight: bold; color: #92400e; margin-bottom: 12px;">
🌍 بدهی‌های چندارزی
</div>
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="border-bottom: 2px solid #fde68a; color: #92400e;">
<th style="padding: 8px 10px; text-align: right;">ارز</th>
<th style="padding: 8px 10px; text-align: right;">مبلغ کل</th>
<th style="padding: 8px 10px; text-align: right;">تسویه‌شده</th>
<th style="padding: 8px 10px; text-align: right;">مانده بدهی</th>
</tr>
</thead>
<tbody>
<?php foreach ($profile['foreign_credits'] as $fc): ?>
<tr style="border-bottom: 1px solid #fef3c7;">
<td style="padding: 8px 10px;">
<span style="background: #fef3c7; color: #92400e; padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 700;">
<?php echo esc_html($fc['currency_code']); ?>
</span>
</td>
<td style="padding: 8px 10px; font-weight: 600; color: #1f2937;">
<?php echo number_format($fc['total_amount'], 2); ?>
</td>
<td style="padding: 8px 10px; color: #059669; font-weight: 600;">
<?php echo number_format($fc['total_settled'], 2); ?>
</td>
<td style="padding: 8px 10px; color: #dc2626; font-weight: 700; font-size: 15px;">
<?php echo number_format($fc['balance'], 2); ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<div style="font-size: 11px; color: #a16207; margin-top: 10px; line-height: 1.5;">
ℹ️ این بدهی‌ها به ارز اصلی ثبت شده‌اند و با نرخ روز تسویه می‌شوند.
برای تسویه با مدیر تماس بگیرید.
</div>
</div>
<?php endif; ?>
<!-- ════════ مانده حساب ════════ -->
<div class="ifnx-card" style="margin-top: 20px; background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 12px; padding: 18px;">
<div style="font-size: 12px; color: #15803d; margin-bottom: 6px;">مانده حساب (موجودی منهای بدهی‌های ریالی)</div>
<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); ?> <small>ریال</small>
</div>
</div>
<?php elseif ($tab === 'transactions'): ?>
<?php echo do_shortcode('[ifnex_transactions]'); ?>
<?php elseif ($tab === 'tracking'): ?>

View File

@ -9,6 +9,8 @@ use App\Filament\Widgets\RecentShipmentsWidget;
use App\Filament\Widgets\RecentTransactionsWidget;
use App\Filament\Widgets\TransactionChartWidget;
use Filament\Pages\Dashboard as BaseDashboard;
use App\Filament\Widgets\UnsettledCreditsWidget;
/**
* IFNEX Admin Dashboard — layered layout (12-column grid)
@ -42,8 +44,9 @@ class Dashboard extends BaseDashboard
public function getHeaderWidgets(): array
{
return [
FinanceOverviewWidget::class,
DashboardInfoWidget::class,
FinanceOverviewWidget::class,
DashboardInfoWidget::class,
UnsettledCreditsWidget::class,
];
}

View File

@ -296,6 +296,20 @@ class CustomerCreditResource extends Resource
];
}
/**
* Badge روی منوی سایدبار — تعداد اعتبارهای باز (تسویه‌نشده).
*/
public static function getNavigationBadge(): ?string
{
$count = \App\Models\CustomerCredit::whereIn('status', ['open', 'partially_settled'])->count();
return $count > 0 ? (string) ifnex_persian_digits(number_format($count)) : null;
}
public static function getNavigationBadgeColor(): ?string
{
return 'danger';
}
// ─── Authorization ────────────────────────────────
public static function canViewAny(): bool

View File

@ -3,6 +3,7 @@
namespace App\Filament\Resources;
use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\RelationManagers;
use App\Models\User;
use Filament\Forms;
use Filament\Forms\Form;
@ -206,7 +207,9 @@ class UserResource extends Resource
public static function getRelations(): array
{
return [];
return [
RelationManagers\CreditsRelationManager::class,
];
}
public static function getPages(): array

View File

@ -0,0 +1,116 @@
<?php
namespace App\Filament\Resources\UserResource\RelationManagers;
use App\Models\CustomerCredit;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class CreditsRelationManager extends RelationManager
{
protected static string $relationship = 'credits';
protected static ?string $title = 'اعتبارها و بدهی‌های چندارزی';
protected static ?string $modelLabel = 'اعتبار';
protected static ?string $pluralModelLabel = 'اعتبارها';
public function form(Form $form): Form
{
return $form->schema([
Forms\Components\TextInput::make('currency_code')
->label('ارز')
->disabled(),
Forms\Components\TextInput::make('amount')
->label('مبلغ کل')
->disabled(),
Forms\Components\TextInput::make('settled_amount')
->label('تسویه‌شده')
->disabled(),
]);
}
public function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('currency_code')
->label('ارز')
->badge()
->color('info'),
Tables\Columns\TextColumn::make('amount')
->label('مبلغ کل')
->numeric(2)
->sortable(),
Tables\Columns\TextColumn::make('settled_amount')
->label('تسویه‌شده')
->numeric(2)
->color('success'),
Tables\Columns\TextColumn::make('balance')
->label('مانده')
->numeric(2)
->badge()
->color(fn ($record) => $record->balance > 0 ? 'danger' : 'gray'),
Tables\Columns\TextColumn::make('status')
->label('وضعیت')
->badge()
->formatStateUsing(fn (string $state) => match ($state) {
'open' => 'باز',
'partially_settled' => 'نیمه‌تسویه',
'settled' => 'تسویه‌شده',
default => $state,
})
->color(fn (string $state) => match ($state) {
'open' => 'danger',
'partially_settled' => 'warning',
'settled' => 'success',
default => 'gray',
}),
Tables\Columns\TextColumn::make('reason')
->label('دلیل')
->limit(40)
->toggleable(),
Tables\Columns\TextColumn::make('granted_at')
->label('تاریخ اعطا')
->dateTime('Y/m/d')
->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->label('وضعیت')
->options([
'open' => 'باز',
'partially_settled' => 'نیمه‌تسویه',
'settled' => 'تسویه‌شده',
]),
Tables\Filters\SelectFilter::make('currency_code')
->label('ارز')
->options(fn () => CustomerCredit::select('currency_code')
->distinct()
->orderBy('currency_code')
->pluck('currency_code', 'currency_code')
->toArray()),
])
->headerActions([
// ایجاد اعتبار فقط از طریق CustomerCreditResource (با Policy)
Tables\Actions\Action::make('manage_credits')
->label('مدیریت اعتبارها')
->icon('heroicon-o-arrow-left')
->url(fn () => route('filament.admin.resources.customer-credits.index'))
->color('primary'),
])
->actions([
Tables\Actions\ViewAction::make(),
])
->defaultSort('created_at', 'desc');
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace App\Filament\Widgets;
use App\Models\CustomerCredit;
use App\Models\User;
use Filament\Widgets\Widget;
use Illuminate\Support\Facades\DB;
/**
* ویجت داشبورد — هشدار بدهی‌های ارزی تسویه‌نشده.
*
* نمایش:
* - مجموع بدهی به تفکیک ارز (EUR/USD/AED/CNY/...)
* - لیست ۵ بدهکار برتر (مشتریانی که بیشترین بدهی تسویه‌نشده دارند)
* - لینک به صفحه اعتبارها فیلتر‌شده با status=open
*/
class UnsettledCreditsWidget extends Widget
{
protected static ?int $sort = 2;
protected static bool $isLazy = false;
protected int|string|array $columnSpan = 'full';
protected static string $view = 'filament.widgets.unsettled-credits';
protected function getViewData(): array
{
// ─── خلاصه به تفکیک ارز ───
$currencySummary = CustomerCredit::whereIn('status', ['open', 'partially_settled'])
->select('currency_code')
->selectRaw('SUM(amount) as total_amount')
->selectRaw('SUM(settled_amount) as total_settled')
->selectRaw('SUM(amount - settled_amount) as total_balance')
->selectRaw('COUNT(*) as credit_count')
->groupBy('currency_code')
->orderBy('total_balance', 'desc')
->get()
->map(fn ($r) => [
'currency_code' => $r->currency_code,
'total_amount' => (float) $r->total_amount,
'total_settled' => (float) $r->total_settled,
'total_balance' => (float) $r->total_balance,
'credit_count' => (int) $r->credit_count,
]);
// ─── تعداد کل اعتبارهای باز ───
$totalOpenCredits = CustomerCredit::whereIn('status', ['open', 'partially_settled'])->count();
// ─── تعداد مشتریان بدهکار ───
$totalDebtors = CustomerCredit::whereIn('status', ['open', 'partially_settled'])
->distinct('user_id')
->count('user_id');
// ─── ۵ بدهکار برتر (بر اساس مانده کل به ارز) ───
$topDebtors = CustomerCredit::whereIn('customer_credits.status', ['open', 'partially_settled'])
->join('users', 'customer_credits.user_id', '=', 'users.id')
->select(
'users.id as user_id',
'users.name as user_name',
'users.phone as user_phone',
DB::raw('COUNT(customer_credits.id) as credit_count'),
DB::raw('GROUP_CONCAT(DISTINCT customer_credits.currency_code) as currencies'),
DB::raw('SUM(customer_credits.amount - customer_credits.settled_amount) as total_balance_sum'),
DB::raw('MAX(customer_credits.granted_at) as latest_grant')
)
->groupBy('users.id', 'users.name', 'users.phone')
->orderBy('total_balance_sum', 'desc')
->limit(5)
->get();
return [
'currencySummary' => $currencySummary,
'totalOpenCredits' => $totalOpenCredits,
'totalDebtors' => $totalDebtors,
'topDebtors' => $topDebtors,
'hasDebts' => $currencySummary->isNotEmpty(),
];
}
}

View File

@ -719,6 +719,16 @@ class CustomerOrderController extends Controller
$wallet_balance = $user->wallet ? $user->wallet->balance : 0;
$account_balance = $wallet_balance - $pending_payments_total;
// بدهی‌های چندارزی (EUR/USD/AED/CNY) — به همان ارز خارجی، نه معادل ریالی
$foreign_credits = app(\App\Services\CustomerCreditService::class)
->getCustomerDebtsByCurrency($user);
$account_balance = $wallet_balance - $pending_payments_total;
// بدهی‌های چندارزی — به همان ارز خارجی
$foreign_credits = app(\App\Services\CustomerCreditService::class)
->getCustomerDebtsByCurrency($user);
// آمار سفارشات
$stats = [
'total_orders' => Shipment::where('user_id', $user->id)->count(),
@ -762,6 +772,7 @@ class CustomerOrderController extends Controller
] : null,
'account_balance' => $account_balance,
'pending_payments_total' => $pending_payments_total,
'foreign_credits' => $foreign_credits,
'stats' => $stats,
]);
}

View File

@ -76,6 +76,7 @@ class AdminPanelProvider extends PanelProvider
->widgets([
\App\Filament\Widgets\RecentShipmentsWidget::class,
\App\Filament\Widgets\ExchangeRateWidget::class,
\App\Filament\Widgets\UnsettledCreditsWidget::class,
])
->middleware([
EncryptCookies::class,

View File

@ -86,6 +86,15 @@ class CustomerCreditService
) {
$credit = CustomerCredit::lockForUpdate()->findOrFail($credit->id);
// بررسی مجدد مانده بعد از lock
$currentBalance = (float) $credit->amount - (float) $credit->settled_amount;
if ($amountForeign > $currentBalance + 0.005) {
throw new RuntimeException(
'مانده بدهی تغییر کرده. مانده فعلی: '
. number_format($currentBalance, 2) . ' ' . $credit->currency_code
);
}
$settlementAmountIrr = round($amountForeign * $exchangeRate, 2);
$walletTransactionId = null;

View File

@ -0,0 +1,109 @@
{{--
IFNEX — Unsettled foreign currency credits dashboard widget.
Shows: currency summary badges + top 5 debtors table.
--}}
<x-filament-widgets::widget>
<div class="ifnex-card-shadow ifnex-card bg-white dark:bg-gray-800 rounded-2xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{{-- ──────── Header ──────── --}}
<div class="px-5 py-4 border-b border-gray-100 dark:border-gray-700 flex items-center justify-between">
<div class="flex items-center gap-2.5">
<span class="inline-flex items-center justify-center w-9 h-9 rounded-xl bg-amber-100 dark:bg-amber-900/40 text-amber-600 dark:text-amber-400">
<x-heroicon-o-globe-alt class="w-5 h-5" />
</span>
<div>
<h3 class="text-base font-bold text-gray-900 dark:text-white">بدهی‌های ارزی تسویه‌نشده</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ ifnex_persian_digits(number_format($totalDebtors)) }} مشتری بدهکار،
{{ ifnex_persian_digits(number_format($totalOpenCredits)) }} اعتبار باز
</span>
</div>
</div>
<a href="{{ route('filament.admin.resources.customer-credits.index') }}?tableFilters[status][value]=open"
class="ifnex-link-inline text-xs font-semibold text-amber-600 hover:text-amber-700 dark:text-amber-400 transition-colors">
مشاهده همه ←
</a>
</div>
@if(!$hasDebts)
{{-- Empty state --}}
<div class="p-8 text-center">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-green-100 dark:bg-green-900/30 text-green-500 mb-3">
<x-heroicon-o-check-circle class="w-7 h-7" />
</div>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">
✅ هیچ بدهی ارزی تسویه‌نشده‌ای وجود ندارد
</p>
</div>
@else
{{-- ──────── Currency Summary ──────── --}}
<div class="px-5 py-3 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/40">
<div class="flex flex-wrap gap-2">
@foreach($currencySummary as $cs)
<span class="ifnex-badge ifnex-badge-warning" style="font-size: 13px; padding: 4px 10px;">
{{ $cs['currency_code'] }}:
{{ number_format($cs['total_balance'], 2) }}
<span style="opacity:0.7; font-weight:400;">
({{ ifnex_persian_digits(number_format($cs['credit_count'])) }} رکورد)
</span>
</span>
@endforeach
</div>
</div>
{{-- ──────── Top Debtors Table ──────── --}}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 dark:bg-gray-900/40 text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-700">
<th class="ifnex-th text-right">مشتری</th>
<th class="ifnex-th text-right">موبایل</th>
<th class="ifnex-th text-right">ارزها</th>
<th class="ifnex-th text-right">تعداد اعتبار</th>
<th class="ifnex-th text-right">آخرین اعطا</th>
<th class="ifnex-th text-right">اقدام</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50 dark:divide-gray-700/60">
@foreach($topDebtors as $debtor)
<tr class="ifnex-table-row-hover transition-colors duration-150">
<td class="ifnex-td">
<a href="{{ route('filament.admin.resources.users.edit', $debtor->user_id) }}"
class="font-bold text-gray-900 dark:text-white hover:text-amber-600 transition-colors">
{{ $debtor->user_name }}
</a>
</td>
<td class="ifnex-td text-xs text-gray-700 dark:text-gray-300 ltr">
{{ $debtor->user_phone ?: '—' }}
</td>
<td class="ifnex-td">
@php $currencies = explode(',', $debtor->currencies); @endphp
@foreach($currencies as $cur)
<span class="ifnex-badge ifnex-badge-info" style="margin-inline-end:4px;">
{{ $cur }}
</span>
@endforeach
</td>
<td class="ifnex-td text-center">
<span class="ifnex-badge ifnex-badge-warning">
{{ ifnex_persian_digits(number_format($debtor->credit_count)) }}
</span>
</td>
<td class="ifnex-td text-xs text-gray-500">
{{ $debtor->latest_grant ? \Morilog\Jalali\Jalalian::fromCarbon(\Carbon\Carbon::parse($debtor->latest_grant))->format('Y/m/d') : '—' }}
</td>
<td class="ifnex-td">
<a href="{{ route('filament.admin.pages.customer-financial') }}?data.customer_id={{ $debtor->user_id }}"
class="ifnex-btn ifnex-btn-sm ifnex-btn-info"
style="font-size:11px; padding:3px 8px;">
وضعیت مالی
</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</x-filament-widgets::widget>