ifnex/04_Laravel/app/Services/CustomerCreditService.php
Kazem Alghasi 93ebbb1fc8 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
2026-10-04 01:01:20 +03:30

186 lines
7.6 KiB
PHP

<?php
namespace App\Services;
use App\Enums\PaymentGateway;
use App\Enums\TransactionStatus;
use App\Enums\TransactionType;
use App\Models\CreditSettlement;
use App\Models\CustomerCredit;
use App\Models\User;
use App\Models\WalletTransaction;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* سرویس اعتبار چندارزی مشتری.
* کیف پول (IRR) مرجع است. اعتبار = بدهی به ارز خارجی. تسویه = پرداخت ریالی با نرخ روز.
*/
class CustomerCreditService
{
/**
* اعطای اعتبار (بدهی) چندارزی. فقط super_admin (Policy).
*/
public function grantCredit(
User $customer,
string $currencyCode,
float $amount,
?string $reason = null,
?string $reference = null,
?int $shipmentId = null,
int $grantedBy
): CustomerCredit {
if ($amount <= 0) {
throw new RuntimeException('مبلغ اعتبار باید مثبت باشد.');
}
return DB::transaction(function () use (
$customer, $currencyCode, $amount, $reason, $reference, $shipmentId, $grantedBy
) {
return CustomerCredit::create([
'user_id' => $customer->id,
'currency_code' => strtoupper(trim($currencyCode)),
'amount' => $amount,
'settled_amount' => 0,
'status' => 'open',
'reason' => $reason,
'reference' => $reference,
'shipment_id' => $shipmentId,
'granted_by' => $grantedBy,
'granted_at' => now(),
]);
});
}
/**
* ثبت تسویه (پرداخت ریالی) برای بخشی از یک اعتبار.
*
* @param bool $fromWallet اگه true، مبلغ ریالی از کیف پول کسر می‌شه.
*/
public function settle(
CustomerCredit $credit,
float $amountForeign,
float $exchangeRate,
string $rateDate,
bool $fromWallet,
int $settledBy,
?string $reason = null,
?string $reference = null
): CreditSettlement {
if ($amountForeign <= 0) {
throw new RuntimeException('مبلغ تسویه باید مثبت باشد.');
}
if ($exchangeRate <= 0) {
throw new RuntimeException('نرخ ارز باید مثبت باشد.');
}
if (! $credit->isOpen()) {
throw new RuntimeException('این اعتبار قابل تسویه نیست (settled یا بسته).');
}
if ($amountForeign > $credit->balance + 0.005) {
throw new RuntimeException('مبلغ تسویه از مانده بدهی بیشتر است. مانده: '
. number_format($credit->balance, 2) . ' ' . $credit->currency_code);
}
return DB::transaction(function () use (
$credit, $amountForeign, $exchangeRate, $rateDate, $fromWallet, $settledBy, $reason, $reference
) {
$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;
if ($fromWallet) {
$wallet = $credit->user->wallet;
if (! $wallet) {
throw new RuntimeException('کاربر کیف پول ندارد.');
}
if ($wallet->isFrozen()) {
throw new RuntimeException('کیف پول کاربر مسدود است.');
}
if (! $wallet->hasSufficientBalance($settlementAmountIrr)) {
throw new RuntimeException('موجودی کیف پول کافی نیست. نیاز: '
. number_format($settlementAmountIrr) . ' ریال');
}
$wt = WalletTransaction::create([
'wallet_id' => $wallet->id,
'amount' => -$settlementAmountIrr,
'balance_before' => $wallet->balance,
'balance_after' => $wallet->balance - $settlementAmountIrr,
'type' => TransactionType::CREDIT_SETTLEMENT,
'status' => TransactionStatus::COMPLETED,
'gateway' => PaymentGateway::WALLET,
'description' => "تسویه اعتبار {$credit->currency_code} {$amountForeign} (credit #{$credit->id})",
'transactionable_type' => CustomerCredit::class,
'transactionable_id' => $credit->id,
'created_by' => $settledBy,
]);
$wallet->update([
'balance' => $wallet->balance - $settlementAmountIrr,
'total_withdrawn' => $wallet->total_withdrawn + $settlementAmountIrr,
]);
$walletTransactionId = $wt->id;
}
$settlement = CreditSettlement::create([
'customer_credit_id' => $credit->id,
'settled_amount_foreign' => $amountForeign,
'currency_code' => $credit->currency_code,
'settlement_currency' => 'IRR',
'exchange_rate' => $exchangeRate,
'rate_date' => $rateDate,
'settlement_amount_irr' => $settlementAmountIrr,
'wallet_transaction_id' => $walletTransactionId,
'reason' => $reason,
'reference' => $reference,
'settled_by' => $settledBy,
'settled_at' => now(),
]);
$credit->settled_amount = (float) $credit->settled_amount + $amountForeign;
$credit->recomputeStatus();
return $settlement;
});
}
/**
* بدهی‌های باز مشتری به تفکیک ارز.
*
* @return array<int, array{currency_code: string, total_amount: float, total_settled: float, balance: float, count: int}>
*/
public function getCustomerDebtsByCurrency(User $customer): array
{
return CustomerCredit::where('user_id', $customer->id)
->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 balance')
->selectRaw('COUNT(*) as count')
->groupBy('currency_code')
->get()
->map(function ($row) {
return [
'currency_code' => $row->currency_code,
'total_amount' => (float) $row->total_amount,
'total_settled' => (float) $row->total_settled,
'balance' => (float) $row->balance,
'count' => (int) $row->count,
];
})
->toArray();
}
}