Add gold credit feature gated by SuperAdmin enable_gold_credit.
Port tables, APIs, and overdue cron from goldali, default the flag off, and keep customer routes behind auth. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\GoldCreditService;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class ProcessGoldCreditOverdue extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'gold-credit:process-overdue';
|
||||||
|
|
||||||
|
protected $description = 'SMS هشدار سررسید اقساط اعتبار طلایی و اعمال جریمه روزانه پس از مهلت';
|
||||||
|
|
||||||
|
public function handle(GoldCreditService $service): int
|
||||||
|
{
|
||||||
|
$result = $service->processOverdueInstallments();
|
||||||
|
$this->info(sprintf(
|
||||||
|
'warnings=%d penalties=%d reminders=%d',
|
||||||
|
$result['warnings'] ?? 0,
|
||||||
|
$result['penalties'] ?? 0,
|
||||||
|
$result['reminders'] ?? 0
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@ class Kernel extends ConsoleKernel
|
|||||||
|
|
||||||
protected function schedule(Schedule $schedule)
|
protected function schedule(Schedule $schedule)
|
||||||
{
|
{
|
||||||
|
$schedule->command('gold-credit:process-overdue')
|
||||||
|
->everyFiveMinutes()
|
||||||
|
->name('gold_credit_process_overdue')
|
||||||
|
->withoutOverlapping();
|
||||||
|
|
||||||
// تیک قیمت — ۶ بار در دقیقه با فاصله ۱۰ ثانیه (montzar + zaarbox + محصولات)
|
// تیک قیمت — ۶ بار در دقیقه با فاصله ۱۰ ثانیه (montzar + zaarbox + محصولات)
|
||||||
// نیاز: * * * * * php artisan schedule:run
|
// نیاز: * * * * * php artisan schedule:run
|
||||||
// یا یکبار: php artisan schedule:bootstrap
|
// یا یکبار: php artisan schedule:bootstrap
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\DB\Te\TableEngine;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\GoldCredit;
|
||||||
|
use App\Models\GoldCreditInstallment;
|
||||||
|
use App\Models\GoldCreditRequest;
|
||||||
|
use App\Services\GoldCreditService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class GoldCreditController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private GoldCreditService $service)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureEnabled()
|
||||||
|
{
|
||||||
|
if (! app(\App\Services\ConfigService::class)->isGoldCreditEnabled()) {
|
||||||
|
abort(403, 'اعتبار طلایی برای این نسخه فعال نیست');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureEnabled();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'customer_id' => 'required|integer|exists:customers,id',
|
||||||
|
'gold_amount' => 'required|numeric|min:0.001',
|
||||||
|
'months' => 'required|integer|min:1|max:120',
|
||||||
|
'interest_type' => 'nullable|in:none,percent,fixed_gram',
|
||||||
|
'interest_value' => 'nullable|numeric|min:0',
|
||||||
|
'delivery_method' => 'nullable|in:auto,manual,in_person',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
'has_check' => 'nullable|boolean',
|
||||||
|
'has_contract' => 'nullable|boolean',
|
||||||
|
'check_note' => 'nullable|string|max:255',
|
||||||
|
'contract_note' => 'nullable|string|max:255',
|
||||||
|
'penalty_type' => 'nullable|in:rial,gold',
|
||||||
|
'penalty_amount' => 'nullable|numeric|min:0',
|
||||||
|
'grace_hours' => 'nullable|integer|min:0|max:720',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$credit = $this->service->createCredit($data, auth()->id(), 'admin_direct');
|
||||||
|
return response()->json($credit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$this->ensureEnabled();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'gold_amount' => 'nullable|numeric|min:0.001',
|
||||||
|
'months' => 'nullable|integer|min:1|max:120',
|
||||||
|
'interest_type' => 'nullable|in:none,percent,fixed_gram',
|
||||||
|
'interest_value' => 'nullable|numeric|min:0',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
'status' => 'nullable|in:cancelled',
|
||||||
|
'has_check' => 'nullable|boolean',
|
||||||
|
'has_contract' => 'nullable|boolean',
|
||||||
|
'check_note' => 'nullable|string|max:255',
|
||||||
|
'contract_note' => 'nullable|string|max:255',
|
||||||
|
'penalty_type' => 'nullable|in:rial,gold',
|
||||||
|
'penalty_amount' => 'nullable|numeric|min:0',
|
||||||
|
'grace_hours' => 'nullable|integer|min:0|max:720',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$credit = $this->service->updateCredit($id, $data);
|
||||||
|
return response()->json($credit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureEnabled();
|
||||||
|
|
||||||
|
$data = $request->all();
|
||||||
|
if (isset($data['select']) && is_null($data['select'])) {
|
||||||
|
$data['select'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = GoldCredit::with(['customer:id,name,mobile', 'creator:id,name'])
|
||||||
|
->whereIn('status', ['pending', 'active', 'completed']);
|
||||||
|
|
||||||
|
TableEngine::create($query, $data)
|
||||||
|
->initSearch(['notes', 'customer.name', 'customer.mobile'])
|
||||||
|
->select(['*'])
|
||||||
|
->export($query);
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestsTable(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureEnabled();
|
||||||
|
|
||||||
|
$data = $request->all();
|
||||||
|
if (isset($data['select']) && is_null($data['select'])) {
|
||||||
|
$data['select'] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = GoldCreditRequest::with(['customer:id,name,mobile']);
|
||||||
|
|
||||||
|
TableEngine::create($query, $data)
|
||||||
|
->initSearch(['note', 'admin_note', 'customer.name', 'customer.mobile'])
|
||||||
|
->select(['*'])
|
||||||
|
->export($query);
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function customerInstallments(Request $request, int $customerId)
|
||||||
|
{
|
||||||
|
$this->ensureEnabled();
|
||||||
|
|
||||||
|
$credit = GoldCredit::with('installments')
|
||||||
|
->where('customer_id', $customerId)
|
||||||
|
->whereIn('status', ['pending', 'active', 'completed'])
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$credit) {
|
||||||
|
return response()->json(['rows' => [], 'total' => 0, 'credit' => null]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->service->refreshOverdueStatuses($credit);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'rows' => $credit->installments,
|
||||||
|
'total' => $credit->installments->count(),
|
||||||
|
'credit' => $credit,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approveRequest(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$this->ensureEnabled();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'gold_amount' => 'nullable|numeric|min:0.001',
|
||||||
|
'months' => 'required|integer|min:1|max:120',
|
||||||
|
'interest_type' => 'nullable|in:none,percent,fixed_gram',
|
||||||
|
'interest_value' => 'nullable|numeric|min:0',
|
||||||
|
'delivery_method' => 'nullable|in:auto,manual,in_person',
|
||||||
|
'admin_note' => 'nullable|string',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
'has_check' => 'nullable|boolean',
|
||||||
|
'has_contract' => 'nullable|boolean',
|
||||||
|
'check_note' => 'nullable|string|max:255',
|
||||||
|
'contract_note' => 'nullable|string|max:255',
|
||||||
|
'penalty_type' => 'nullable|in:rial,gold',
|
||||||
|
'penalty_amount' => 'nullable|numeric|min:0',
|
||||||
|
'grace_hours' => 'nullable|integer|min:0|max:720',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$credit = $this->service->approveRequest($id, $data, auth()->id());
|
||||||
|
return response()->json($credit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rejectRequest(Request $request, int $id)
|
||||||
|
{
|
||||||
|
$this->ensureEnabled();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'admin_note' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = $this->service->rejectRequest($id, $data['admin_note'] ?? null);
|
||||||
|
return response()->json($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payInstallments(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureEnabled();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'gold_credit_id' => 'required|integer|exists:gold_credits,id',
|
||||||
|
'installment_ids' => 'required|array|min:1',
|
||||||
|
'installment_ids.*' => 'integer|exists:gold_credit_installments,id',
|
||||||
|
'pay_method' => 'required|in:gold,rial,mixed',
|
||||||
|
'gold_amount' => 'nullable|numeric|min:0',
|
||||||
|
'rial_amount' => 'nullable|integer|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$credit = GoldCredit::findOrFail($data['gold_credit_id']);
|
||||||
|
$result = $this->service->payInstallments(
|
||||||
|
$credit,
|
||||||
|
$data['installment_ids'],
|
||||||
|
$data['pay_method'],
|
||||||
|
$data['gold_amount'] ?? null,
|
||||||
|
$data['rial_amount'] ?? null,
|
||||||
|
auth()->id()
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function preview(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureEnabled();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'gold_amount' => 'required|numeric|min:0.001',
|
||||||
|
'months' => 'required|integer|min:1|max:120',
|
||||||
|
'interest_type' => 'nullable|in:none,percent,fixed_gram',
|
||||||
|
'interest_value' => 'nullable|numeric|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$plan = $this->service->buildInstallmentPlan(
|
||||||
|
(float) $data['gold_amount'],
|
||||||
|
(int) $data['months'],
|
||||||
|
$data['interest_type'] ?? 'none',
|
||||||
|
(float) ($data['interest_value'] ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($plan);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -235,7 +235,7 @@ class SettingsController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* تنظیمات پیشرفته: ۱۴ دسترسی از سوپرادمین + بقیهٔ can_edit=0 از DB لوکال.
|
* تنظیمات پیشرفته: ۱۵ دسترسی از سوپرادمین + بقیهٔ can_edit=0 از DB لوکال.
|
||||||
*/
|
*/
|
||||||
private function composeSystemSettings($localRows)
|
private function composeSystemSettings($localRows)
|
||||||
{
|
{
|
||||||
@@ -319,7 +319,7 @@ class SettingsController extends Controller
|
|||||||
/**
|
/**
|
||||||
* get_settings ادمین (و پایهٔ publicIndex قبل از فیلتر show_to_user):
|
* get_settings ادمین (و پایهٔ publicIndex قبل از فیلتر show_to_user):
|
||||||
* can_edit=1 (+ runtime + local-owned) از DB لوکال
|
* can_edit=1 (+ runtime + local-owned) از DB لوکال
|
||||||
* فقط ۱۴ دسترسی سوپرادمین از overlay؛ بقیهٔ can_edit=0 از کپی لوکال
|
* فقط ۱۵ دسترسی سوپرادمین از overlay؛ بقیهٔ can_edit=0 از کپی لوکال
|
||||||
* اگر سوپرادمین در دسترس نباشد کل جدول لوکال برمیگردد.
|
* اگر سوپرادمین در دسترس نباشد کل جدول لوکال برمیگردد.
|
||||||
*/
|
*/
|
||||||
private function composeGetSettings($localRows)
|
private function composeGetSettings($localRows)
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Customer;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\GoldCredit;
|
||||||
|
use App\Models\GoldCreditRequest;
|
||||||
|
use App\Services\GoldCreditService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class GoldCreditController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private GoldCreditService $service)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureEnabled()
|
||||||
|
{
|
||||||
|
if (! app(\App\Services\ConfigService::class)->isGoldCreditEnabled()) {
|
||||||
|
abort(403, 'اعتبار طلایی برای این نسخه فعال نیست');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function show()
|
||||||
|
{
|
||||||
|
$this->ensureEnabled();
|
||||||
|
|
||||||
|
$customerId = auth('customer')->id();
|
||||||
|
$config = $this->service->getPublicConfig();
|
||||||
|
$walletGold = $this->service->getCustomerGoldBalance($customerId);
|
||||||
|
$tier = $this->service->resolveTierForBalance($walletGold);
|
||||||
|
$minBalance = (float) $config['min_balance_gram'];
|
||||||
|
|
||||||
|
$credit = GoldCredit::with('installments')
|
||||||
|
->where('customer_id', $customerId)
|
||||||
|
->whereIn('status', ['pending', 'active', 'completed'])
|
||||||
|
->orderByDesc('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($credit) {
|
||||||
|
$this->service->refreshOverdueStatuses($credit);
|
||||||
|
$credit->load('installments');
|
||||||
|
}
|
||||||
|
|
||||||
|
$pendingRequest = GoldCreditRequest::where('customer_id', $customerId)
|
||||||
|
->where('status', 'pending')
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$remainingSoot = 0;
|
||||||
|
if ($credit) {
|
||||||
|
$remainingSoot = $credit->installments
|
||||||
|
->whereIn('status', ['pending', 'overdue', 'partial'])
|
||||||
|
->sum('amount_soot');
|
||||||
|
}
|
||||||
|
|
||||||
|
$canRequest = !$credit
|
||||||
|
&& !$pendingRequest
|
||||||
|
&& $walletGold >= $minBalance
|
||||||
|
&& $tier !== null;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'credit' => $credit,
|
||||||
|
'pending_request' => $pendingRequest,
|
||||||
|
'remaining_soot' => $remainingSoot,
|
||||||
|
'gram_price_rial' => $this->service->getGramPriceRial(),
|
||||||
|
'wallet_gold' => $walletGold,
|
||||||
|
'can_request' => $canRequest,
|
||||||
|
'suggested_tier' => $tier,
|
||||||
|
'config' => $config,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requestLoan(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureEnabled();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'note' => 'nullable|string|max:500',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$customerId = auth('customer')->id();
|
||||||
|
$config = $this->service->getPublicConfig();
|
||||||
|
$walletGold = $this->service->getCustomerGoldBalance($customerId);
|
||||||
|
$minBalance = (float) $config['min_balance_gram'];
|
||||||
|
$tier = $this->service->resolveTierForBalance($walletGold);
|
||||||
|
|
||||||
|
if ($walletGold < $minBalance || !$tier) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'موجودی طلای شما برای ثبت درخواست کافی نیست',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasActive = GoldCredit::where('customer_id', $customerId)
|
||||||
|
->whereIn('status', ['pending', 'active'])
|
||||||
|
->exists();
|
||||||
|
if ($hasActive) {
|
||||||
|
return response()->json(['message' => 'شما اعتبار فعال دارید'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasPending = GoldCreditRequest::where('customer_id', $customerId)
|
||||||
|
->where('status', 'pending')
|
||||||
|
->exists();
|
||||||
|
if ($hasPending) {
|
||||||
|
return response()->json(['message' => 'درخواست قبلی شما در انتظار بررسی است'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$loanRequest = GoldCreditRequest::create([
|
||||||
|
'customer_id' => $customerId,
|
||||||
|
'requested_gold' => $tier['credit_gram'],
|
||||||
|
'requested_months' => $tier['months'] ?? $config['default_months'],
|
||||||
|
'note' => $data['note'] ?? null,
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->service->notifyAdminsLoanRequest($loanRequest);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'request' => $loanRequest,
|
||||||
|
'message' => $config['request_success_text'],
|
||||||
|
'suggested_tier' => $tier,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payInstallments(Request $request)
|
||||||
|
{
|
||||||
|
$this->ensureEnabled();
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'installment_ids' => 'required|array|min:1',
|
||||||
|
'installment_ids.*' => 'integer|exists:gold_credit_installments,id',
|
||||||
|
'pay_method' => 'required|in:gold,rial,mixed',
|
||||||
|
'gold_amount' => 'nullable|numeric|min:0',
|
||||||
|
'rial_amount' => 'nullable|integer|min:0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->service->assertPayMethodAllowed($data['pay_method']);
|
||||||
|
|
||||||
|
$customerId = auth('customer')->id();
|
||||||
|
$credit = GoldCredit::where('customer_id', $customerId)
|
||||||
|
->whereIn('status', ['active'])
|
||||||
|
->orderByDesc('id')
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$result = $this->service->payInstallments(
|
||||||
|
$credit,
|
||||||
|
$data['installment_ids'],
|
||||||
|
$data['pay_method'],
|
||||||
|
$data['gold_amount'] ?? null,
|
||||||
|
$data['rial_amount'] ?? null,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class GoldCredit extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'customer_id',
|
||||||
|
'gold_amount',
|
||||||
|
'months',
|
||||||
|
'interest_type',
|
||||||
|
'interest_value',
|
||||||
|
'delivery_method',
|
||||||
|
'status',
|
||||||
|
'source',
|
||||||
|
'total_interest_gold',
|
||||||
|
'total_payable_gold',
|
||||||
|
'paid_gold',
|
||||||
|
'paid_rial',
|
||||||
|
'created_by',
|
||||||
|
'notes',
|
||||||
|
'has_check',
|
||||||
|
'has_contract',
|
||||||
|
'check_note',
|
||||||
|
'contract_note',
|
||||||
|
'penalty_type',
|
||||||
|
'penalty_amount',
|
||||||
|
'grace_hours',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'gold_amount' => 'float',
|
||||||
|
'interest_value' => 'float',
|
||||||
|
'total_interest_gold' => 'float',
|
||||||
|
'total_payable_gold' => 'float',
|
||||||
|
'paid_gold' => 'float',
|
||||||
|
'paid_rial' => 'integer',
|
||||||
|
'has_check' => 'boolean',
|
||||||
|
'has_contract' => 'boolean',
|
||||||
|
'penalty_amount' => 'float',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function customer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Customer::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function installments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(GoldCreditInstallment::class)->orderBy('installment_number');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function creator(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'created_by');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class GoldCreditInstallment extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'gold_credit_id',
|
||||||
|
'installment_number',
|
||||||
|
'due_date',
|
||||||
|
'amount_soot',
|
||||||
|
'principal_gold',
|
||||||
|
'interest_gold',
|
||||||
|
'total_gold',
|
||||||
|
'paid_gold',
|
||||||
|
'paid_rial',
|
||||||
|
'status',
|
||||||
|
'paid_at',
|
||||||
|
'warning_sms_sent_at',
|
||||||
|
'reminder_3d_sent_at',
|
||||||
|
'reminder_1d_sent_at',
|
||||||
|
'penalty_started_at',
|
||||||
|
'last_penalty_at',
|
||||||
|
'penalty_days',
|
||||||
|
'penalty_rial',
|
||||||
|
'penalty_gold',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'due_date' => 'date',
|
||||||
|
'principal_gold' => 'float',
|
||||||
|
'interest_gold' => 'float',
|
||||||
|
'total_gold' => 'float',
|
||||||
|
'paid_gold' => 'float',
|
||||||
|
'paid_rial' => 'integer',
|
||||||
|
'paid_at' => 'datetime',
|
||||||
|
'warning_sms_sent_at' => 'datetime',
|
||||||
|
'reminder_3d_sent_at' => 'datetime',
|
||||||
|
'reminder_1d_sent_at' => 'datetime',
|
||||||
|
'penalty_started_at' => 'datetime',
|
||||||
|
'last_penalty_at' => 'datetime',
|
||||||
|
'penalty_gold' => 'float',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function goldCredit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(GoldCredit::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class GoldCreditRequest extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'customer_id',
|
||||||
|
'requested_gold',
|
||||||
|
'requested_months',
|
||||||
|
'note',
|
||||||
|
'status',
|
||||||
|
'admin_note',
|
||||||
|
'gold_credit_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'requested_gold' => 'float',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function customer(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Customer::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function goldCredit(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(GoldCredit::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1223,6 +1223,11 @@ class ConfigService
|
|||||||
return $this->getBool('enable_product_funds', false);
|
return $this->getBool('enable_product_funds', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isGoldCreditEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->getBool('enable_gold_credit', false);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* سوییچ اصلی جیبیت (can_edit=0، پیشفرض روشن).
|
* سوییچ اصلی جیبیت (can_edit=0، پیشفرض روشن).
|
||||||
* خاموش = هیچ فراخوانی احراز هویت به جیبیت انجام نمیشود.
|
* خاموش = هیچ فراخوانی احراز هویت به جیبیت انجام نمیشود.
|
||||||
|
|||||||
@@ -0,0 +1,851 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Customer;
|
||||||
|
use App\Models\GoldCredit;
|
||||||
|
use App\Models\GoldCreditInstallment;
|
||||||
|
use App\Models\GoldCreditRequest;
|
||||||
|
use App\Models\Price;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Withdraw;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Rooyesh\Wallet\interfaces\TransactionInterface;
|
||||||
|
use Rooyesh\Wallet\interfaces\WalletInterface;
|
||||||
|
use Rooyesh\Wallet\models\Wallet;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
|
||||||
|
class GoldCreditService
|
||||||
|
{
|
||||||
|
public function buildInstallmentPlan(
|
||||||
|
float $goldAmount,
|
||||||
|
int $months,
|
||||||
|
string $interestType = 'none',
|
||||||
|
float $interestValue = 0
|
||||||
|
): array {
|
||||||
|
if ($months < 1) {
|
||||||
|
throw new UnprocessableEntityHttpException('تعداد ماه باید حداقل ۱ باشد');
|
||||||
|
}
|
||||||
|
if ($goldAmount <= 0) {
|
||||||
|
throw new UnprocessableEntityHttpException('مقدار طلا باید بیشتر از صفر باشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalSoot = (int) round($goldAmount * 1000);
|
||||||
|
$baseSootPerMonth = intdiv($totalSoot, $months);
|
||||||
|
$remainder = $totalSoot - ($baseSootPerMonth * $months);
|
||||||
|
|
||||||
|
$totalInterestGold = $this->calculateTotalInterest($goldAmount, $months, $interestType, $interestValue);
|
||||||
|
$interestPerMonth = $months > 0 ? $totalInterestGold / $months : 0;
|
||||||
|
|
||||||
|
$installments = [];
|
||||||
|
for ($i = 1; $i <= $months; $i++) {
|
||||||
|
$soot = $baseSootPerMonth + ($i === $months ? $remainder : 0);
|
||||||
|
$principalGold = $soot / 1000;
|
||||||
|
$interestGold = round($interestPerMonth, 6);
|
||||||
|
$installments[] = [
|
||||||
|
'installment_number' => $i,
|
||||||
|
'amount_soot' => $soot,
|
||||||
|
'principal_gold' => $principalGold,
|
||||||
|
'interest_gold' => $interestGold,
|
||||||
|
'total_gold' => round($principalGold + $interestGold, 6),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'installments' => $installments,
|
||||||
|
'total_interest_gold' => round($totalInterestGold, 6),
|
||||||
|
'total_payable_gold' => round($goldAmount + $totalInterestGold, 6),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createCredit(array $data, ?int $adminId = null, string $source = 'admin_direct'): GoldCredit
|
||||||
|
{
|
||||||
|
$customer = Customer::findOrFail($data['customer_id']);
|
||||||
|
$active = GoldCredit::where('customer_id', $customer->id)
|
||||||
|
->whereIn('status', ['pending', 'active'])
|
||||||
|
->exists();
|
||||||
|
if ($active) {
|
||||||
|
throw new UnprocessableEntityHttpException('این کاربر اعتبار فعال دارد');
|
||||||
|
}
|
||||||
|
|
||||||
|
$plan = $this->buildInstallmentPlan(
|
||||||
|
(float) $data['gold_amount'],
|
||||||
|
(int) $data['months'],
|
||||||
|
$data['interest_type'] ?? 'none',
|
||||||
|
(float) ($data['interest_value'] ?? 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($data, $adminId, $source, $customer, $plan) {
|
||||||
|
$cfg = app(ConfigService::class);
|
||||||
|
$credit = GoldCredit::create([
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'gold_amount' => $data['gold_amount'],
|
||||||
|
'months' => $data['months'],
|
||||||
|
'interest_type' => $data['interest_type'] ?? 'none',
|
||||||
|
'interest_value' => $data['interest_value'] ?? 0,
|
||||||
|
'delivery_method' => $data['delivery_method'] ?? 'manual',
|
||||||
|
'status' => 'active',
|
||||||
|
'source' => $source,
|
||||||
|
'total_interest_gold' => $plan['total_interest_gold'],
|
||||||
|
'total_payable_gold' => $plan['total_payable_gold'],
|
||||||
|
'created_by' => $adminId,
|
||||||
|
'notes' => $data['notes'] ?? null,
|
||||||
|
'has_check' => (bool) ($data['has_check'] ?? false),
|
||||||
|
'has_contract' => (bool) ($data['has_contract'] ?? false),
|
||||||
|
'check_note' => $data['check_note'] ?? null,
|
||||||
|
'contract_note' => $data['contract_note'] ?? null,
|
||||||
|
'penalty_type' => $data['penalty_type'] ?? $cfg->get('gold_credit_penalty_type', 'rial'),
|
||||||
|
'penalty_amount' => $data['penalty_amount'] ?? $cfg->get('gold_credit_penalty_amount', '1000000'),
|
||||||
|
'grace_hours' => $data['grace_hours'] ?? $cfg->getInt('gold_credit_grace_hours', 24),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$startDate = Carbon::now();
|
||||||
|
foreach ($plan['installments'] as $item) {
|
||||||
|
GoldCreditInstallment::create([
|
||||||
|
'gold_credit_id' => $credit->id,
|
||||||
|
'installment_number' => $item['installment_number'],
|
||||||
|
'due_date' => $startDate->copy()->addMonths($item['installment_number'])->toDateString(),
|
||||||
|
'amount_soot' => $item['amount_soot'],
|
||||||
|
'principal_gold' => $item['principal_gold'],
|
||||||
|
'interest_gold' => $item['interest_gold'],
|
||||||
|
'total_gold' => $item['total_gold'],
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($data['delivery_method'] ?? 'manual') === 'auto') {
|
||||||
|
$this->deliverCredit($credit, $adminId, $data['notes'] ?? 'اعتبار طلایی');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $credit->load('installments', 'customer');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deliverCredit(GoldCredit $credit, ?int $adminId, string $note = 'اعتبار طلایی'): void
|
||||||
|
{
|
||||||
|
$customer = Customer::findOrFail($credit->customer_id);
|
||||||
|
$walletOwnerType = $customer->resolveWalletOwnerType();
|
||||||
|
$walletInterface = new WalletInterface($customer->id, $walletOwnerType);
|
||||||
|
|
||||||
|
$withdraw = Withdraw::create([
|
||||||
|
'user_id' => $adminId,
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'gold' => $credit->gold_amount,
|
||||||
|
'note' => $note,
|
||||||
|
'sts' => 4,
|
||||||
|
]);
|
||||||
|
|
||||||
|
new TransactionInterface($walletInterface, [
|
||||||
|
'cash' => 0,
|
||||||
|
'gold' => $credit->gold_amount,
|
||||||
|
'expire_at' => now(),
|
||||||
|
'note' => $note,
|
||||||
|
'withdraw_id' => $withdraw->id,
|
||||||
|
'active' => true,
|
||||||
|
], 7, true, false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function approveRequest(int $requestId, array $creditData, int $adminId): GoldCredit
|
||||||
|
{
|
||||||
|
$request = GoldCreditRequest::where('status', 'pending')->findOrFail($requestId);
|
||||||
|
$creditData['customer_id'] = $request->customer_id;
|
||||||
|
if (!isset($creditData['gold_amount'])) {
|
||||||
|
$creditData['gold_amount'] = $request->requested_gold;
|
||||||
|
}
|
||||||
|
if (!isset($creditData['months'])) {
|
||||||
|
$creditData['months'] = $request->requested_months
|
||||||
|
?? (int) app(ConfigService::class)->getInt('gold_credit_default_months', 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::transaction(function () use ($request, $creditData, $adminId) {
|
||||||
|
$credit = $this->createCredit($creditData, $adminId, 'user_request');
|
||||||
|
$request->update([
|
||||||
|
'status' => 'approved',
|
||||||
|
'gold_credit_id' => $credit->id,
|
||||||
|
'admin_note' => $creditData['admin_note'] ?? $creditData['notes'] ?? null,
|
||||||
|
]);
|
||||||
|
return $credit;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rejectRequest(int $requestId, ?string $adminNote = null): GoldCreditRequest
|
||||||
|
{
|
||||||
|
$request = GoldCreditRequest::where('status', 'pending')->findOrFail($requestId);
|
||||||
|
$request->update([
|
||||||
|
'status' => 'rejected',
|
||||||
|
'admin_note' => $adminNote,
|
||||||
|
]);
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payInstallments(
|
||||||
|
GoldCredit $credit,
|
||||||
|
array $installmentIds,
|
||||||
|
string $payMethod,
|
||||||
|
?float $goldAmount = null,
|
||||||
|
?int $rialAmount = null,
|
||||||
|
?int $adminId = null
|
||||||
|
): array {
|
||||||
|
$installments = GoldCreditInstallment::where('gold_credit_id', $credit->id)
|
||||||
|
->whereIn('id', $installmentIds)
|
||||||
|
->orderBy('installment_number')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($installments->isEmpty()) {
|
||||||
|
throw new UnprocessableEntityHttpException('قسطی یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSequentialPayable($credit, $installments);
|
||||||
|
|
||||||
|
$totalDueGold = (float) $installments->sum(fn ($i) => (float) $i->total_gold + (float) $i->penalty_gold);
|
||||||
|
$totalDueSoot = (int) $installments->sum('amount_soot');
|
||||||
|
$totalPenaltyRial = (int) $installments->sum('penalty_rial');
|
||||||
|
|
||||||
|
[$payGold, $payRial] = $this->resolvePaymentAmounts(
|
||||||
|
$payMethod,
|
||||||
|
$totalDueGold,
|
||||||
|
$goldAmount,
|
||||||
|
$rialAmount
|
||||||
|
);
|
||||||
|
$payRial += $totalPenaltyRial;
|
||||||
|
|
||||||
|
$customer = Customer::findOrFail($credit->customer_id);
|
||||||
|
$this->deductFromWallet($customer, $payGold, $payRial, $adminId);
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
foreach ($installments as $installment) {
|
||||||
|
$isPrepaid = $installment->due_date->isFuture();
|
||||||
|
$installment->update([
|
||||||
|
'paid_gold' => (float) $installment->total_gold + (float) $installment->penalty_gold,
|
||||||
|
'paid_rial' => (int) $installment->penalty_rial,
|
||||||
|
'status' => $isPrepaid ? 'prepaid' : 'paid',
|
||||||
|
'paid_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$credit->increment('paid_gold', $totalDueGold);
|
||||||
|
$credit->increment('paid_rial', $payRial);
|
||||||
|
|
||||||
|
$remaining = GoldCreditInstallment::where('gold_credit_id', $credit->id)
|
||||||
|
->whereIn('status', ['pending', 'partial', 'overdue'])
|
||||||
|
->count();
|
||||||
|
if ($remaining === 0) {
|
||||||
|
$credit->update(['status' => 'completed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->refreshOverdueStatuses($credit);
|
||||||
|
|
||||||
|
$this->notifyAdminsPayment($customer, $credit, $installments, $totalDueSoot, $payGold, $payRial);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'paid_installments' => $installments->pluck('id'),
|
||||||
|
'total_soot' => $totalDueSoot,
|
||||||
|
'paid_gold' => $payGold,
|
||||||
|
'paid_rial' => $payRial,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function refreshOverdueStatuses(GoldCredit $credit): void
|
||||||
|
{
|
||||||
|
GoldCreditInstallment::where('gold_credit_id', $credit->id)
|
||||||
|
->where('status', 'pending')
|
||||||
|
->where('due_date', '<', now()->toDateString())
|
||||||
|
->update(['status' => 'overdue']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateCredit(int $creditId, array $data): GoldCredit
|
||||||
|
{
|
||||||
|
$credit = GoldCredit::with('installments')->findOrFail($creditId);
|
||||||
|
if (!in_array($credit->status, ['pending', 'active'], true)) {
|
||||||
|
throw new UnprocessableEntityHttpException('فقط اعتبارهای فعال قابل ویرایش هستند');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($data['notes'])) {
|
||||||
|
$credit->notes = $data['notes'];
|
||||||
|
}
|
||||||
|
foreach (['has_check', 'has_contract'] as $boolField) {
|
||||||
|
if (array_key_exists($boolField, $data)) {
|
||||||
|
$credit->{$boolField} = (bool) $data[$boolField];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach (['check_note', 'contract_note', 'penalty_type'] as $textField) {
|
||||||
|
if (array_key_exists($textField, $data)) {
|
||||||
|
$credit->{$textField} = $data[$textField];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (array_key_exists('penalty_amount', $data)) {
|
||||||
|
$credit->penalty_amount = $data['penalty_amount'];
|
||||||
|
}
|
||||||
|
if (array_key_exists('grace_hours', $data)) {
|
||||||
|
$credit->grace_hours = $data['grace_hours'];
|
||||||
|
}
|
||||||
|
if (isset($data['status']) && in_array($data['status'], ['cancelled'], true)) {
|
||||||
|
$credit->status = $data['status'];
|
||||||
|
$credit->save();
|
||||||
|
return $credit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$needsRecalc = isset($data['gold_amount']) || isset($data['months'])
|
||||||
|
|| isset($data['interest_type']) || isset($data['interest_value']);
|
||||||
|
|
||||||
|
if ($needsRecalc) {
|
||||||
|
$paidNumbers = $credit->installments
|
||||||
|
->whereIn('status', ['paid', 'prepaid'])
|
||||||
|
->pluck('installment_number')
|
||||||
|
->toArray();
|
||||||
|
if (count($paidNumbers) > 0) {
|
||||||
|
throw new UnprocessableEntityHttpException('پس از پرداخت قسط، امکان تغییر مبلغ/مدت وجود ندارد');
|
||||||
|
}
|
||||||
|
|
||||||
|
$goldAmount = (float) ($data['gold_amount'] ?? $credit->gold_amount);
|
||||||
|
$months = (int) ($data['months'] ?? $credit->months);
|
||||||
|
$interestType = $data['interest_type'] ?? $credit->interest_type;
|
||||||
|
$interestValue = (float) ($data['interest_value'] ?? $credit->interest_value);
|
||||||
|
|
||||||
|
$plan = $this->buildInstallmentPlan($goldAmount, $months, $interestType, $interestValue);
|
||||||
|
$credit->installments()->delete();
|
||||||
|
|
||||||
|
$startDate = Carbon::parse($credit->created_at);
|
||||||
|
foreach ($plan['installments'] as $item) {
|
||||||
|
GoldCreditInstallment::create([
|
||||||
|
'gold_credit_id' => $credit->id,
|
||||||
|
'installment_number' => $item['installment_number'],
|
||||||
|
'due_date' => $startDate->copy()->addMonths($item['installment_number'])->toDateString(),
|
||||||
|
'amount_soot' => $item['amount_soot'],
|
||||||
|
'principal_gold' => $item['principal_gold'],
|
||||||
|
'interest_gold' => $item['interest_gold'],
|
||||||
|
'total_gold' => $item['total_gold'],
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$credit->fill([
|
||||||
|
'gold_amount' => $goldAmount,
|
||||||
|
'months' => $months,
|
||||||
|
'interest_type' => $interestType,
|
||||||
|
'interest_value' => $interestValue,
|
||||||
|
'total_interest_gold' => $plan['total_interest_gold'],
|
||||||
|
'total_payable_gold' => $plan['total_payable_gold'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$credit->save();
|
||||||
|
return $credit->fresh(['installments', 'customer']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGramPriceRial(): int
|
||||||
|
{
|
||||||
|
$price = Price::query()->orderByDesc('id')->value('price');
|
||||||
|
return (int) ($price ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* هشدار SMS در سررسید + جریمه روزانه پس از مهلت (grace) + یادآوری ۱ و ۳ روز قبل.
|
||||||
|
* پرداخت خودکار نیست — فقط بدهی جریمه اضافه میشود.
|
||||||
|
*
|
||||||
|
* @return array{warnings:int,penalties:int,reminders:int}
|
||||||
|
*/
|
||||||
|
public function processOverdueInstallments(): array
|
||||||
|
{
|
||||||
|
$cfg = app(ConfigService::class);
|
||||||
|
if (! $cfg->isGoldCreditEnabled() || ! Schema::hasTable('gold_credit_installments')) {
|
||||||
|
return ['warnings' => 0, 'penalties' => 0, 'reminders' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$smsEnabled = $cfg->getBool('gold_credit_overdue_sms_enabled', true);
|
||||||
|
$warnings = 0;
|
||||||
|
$penalties = 0;
|
||||||
|
$reminders = $this->processUpcomingDueReminders();
|
||||||
|
|
||||||
|
$installments = GoldCreditInstallment::with(['goldCredit.customer'])
|
||||||
|
->whereIn('status', ['pending', 'overdue'])
|
||||||
|
->where('due_date', '<=', now()->toDateString())
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($installments as $installment) {
|
||||||
|
$credit = $installment->goldCredit;
|
||||||
|
if (!$credit || $credit->status !== 'active') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($installment->status === 'pending') {
|
||||||
|
$installment->status = 'overdue';
|
||||||
|
$installment->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($smsEnabled && !$installment->warning_sms_sent_at) {
|
||||||
|
$this->sendOverdueWarningSms($installment);
|
||||||
|
$installment->warning_sms_sent_at = now();
|
||||||
|
$installment->save();
|
||||||
|
$warnings++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->applyDailyPenaltyIfDue($installment, $credit)) {
|
||||||
|
$penalties++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return compact('warnings', 'penalties', 'reminders');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** یادآوری ۳ روز و ۱ روز مانده به سررسید — برای مشتری و ادمین */
|
||||||
|
public function processUpcomingDueReminders(): int
|
||||||
|
{
|
||||||
|
$cfg = app(ConfigService::class);
|
||||||
|
if (!$cfg->getBool('gold_credit_upcoming_sms_enabled', true)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sent = 0;
|
||||||
|
$today = now()->startOfDay();
|
||||||
|
|
||||||
|
foreach ([3 => 'reminder_3d_sent_at', 1 => 'reminder_1d_sent_at'] as $days => $field) {
|
||||||
|
$targetDate = $today->copy()->addDays($days)->toDateString();
|
||||||
|
$installments = GoldCreditInstallment::with(['goldCredit.customer'])
|
||||||
|
->where('status', 'pending')
|
||||||
|
->whereNull($field)
|
||||||
|
->whereDate('due_date', $targetDate)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($installments as $installment) {
|
||||||
|
$credit = $installment->goldCredit;
|
||||||
|
if (!$credit || $credit->status !== 'active') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->sendUpcomingReminderSms($installment, $days);
|
||||||
|
$installment->{$field} = now();
|
||||||
|
$installment->save();
|
||||||
|
$sent++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notifyAdminsLoanRequest(GoldCreditRequest $request): void
|
||||||
|
{
|
||||||
|
$cfg = app(ConfigService::class);
|
||||||
|
if (!$cfg->getBool('gold_credit_admin_sms_enabled', true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$customer = Customer::find($request->customer_id);
|
||||||
|
$company = $cfg->get('name_fa', '');
|
||||||
|
$message = sprintf(
|
||||||
|
"درخواست اعتبار طلایی جدید\nمشتری: %s\nموبایل: %s\nمقدار: %s گرم\nمدت: %s ماه\n%s",
|
||||||
|
$customer->name ?? '-',
|
||||||
|
$customer->mobile ?? '-',
|
||||||
|
$request->requested_gold,
|
||||||
|
$request->requested_months ?? '-',
|
||||||
|
$company
|
||||||
|
);
|
||||||
|
$this->notifyAdminsSms($message, 'gold_credit_request');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notifyAdminsPayment(
|
||||||
|
Customer $customer,
|
||||||
|
GoldCredit $credit,
|
||||||
|
$installments,
|
||||||
|
int $totalSoot,
|
||||||
|
float $payGold,
|
||||||
|
int $payRial
|
||||||
|
): void {
|
||||||
|
$cfg = app(ConfigService::class);
|
||||||
|
if (!$cfg->getBool('gold_credit_admin_sms_enabled', true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$numbers = $installments->pluck('installment_number')->implode(',');
|
||||||
|
$company = $cfg->get('name_fa', '');
|
||||||
|
$message = sprintf(
|
||||||
|
"پرداخت قسط اعتبار طلایی\nمشتری: %s\nموبایل: %s\nاقساط: %s\nمبلغ: %s سوت\nطلا: %s گرم / ریال: %s\n%s",
|
||||||
|
$customer->name ?? '-',
|
||||||
|
$customer->mobile ?? '-',
|
||||||
|
$numbers,
|
||||||
|
number_format($totalSoot),
|
||||||
|
$payGold,
|
||||||
|
number_format($payRial),
|
||||||
|
$company
|
||||||
|
);
|
||||||
|
$this->notifyAdminsSms($message, 'gold_credit_payment');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function notifyAdminsSms(string $message, string $source): void
|
||||||
|
{
|
||||||
|
User::where('send_sms', 1)->get()->each(function ($admin) use ($message, $source) {
|
||||||
|
if (!$admin->mobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
app(\App\Services\SmsBilling\SmsGatewayService::class)
|
||||||
|
->send($admin->mobile, $message, $source);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('gold credit admin sms failed', [
|
||||||
|
'admin_id' => $admin->id,
|
||||||
|
'source' => $source,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendUpcomingReminderSms(GoldCreditInstallment $installment, int $daysLeft): void
|
||||||
|
{
|
||||||
|
$customer = $installment->goldCredit?->customer;
|
||||||
|
$cfg = app(ConfigService::class);
|
||||||
|
$company = $cfg->get('name_fa', '');
|
||||||
|
$soot = number_format((int) $installment->amount_soot);
|
||||||
|
|
||||||
|
$customerMsg = sprintf(
|
||||||
|
"%s عزیز، %d روز تا سررسید قسط شماره %d اعتبار طلایی باقی مانده است.\nمبلغ قسط: %s سوت\nلطفاً از بخش اعتبار طلایی پرداخت کنید.\n%s",
|
||||||
|
$customer->name ?? 'کاربر',
|
||||||
|
$daysLeft,
|
||||||
|
$installment->installment_number,
|
||||||
|
$soot,
|
||||||
|
$company
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($customer?->mobile) {
|
||||||
|
try {
|
||||||
|
app(\App\Services\SmsBilling\SmsGatewayService::class)
|
||||||
|
->send($customer->mobile, $customerMsg, 'gold_credit_upcoming');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning('gold credit upcoming sms failed', [
|
||||||
|
'installment_id' => $installment->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cfg->getBool('gold_credit_admin_sms_enabled', true)) {
|
||||||
|
$adminMsg = sprintf(
|
||||||
|
"اخطار سررسید اعتبار طلایی\n%d روز مانده\nمشتری: %s\nموبایل: %s\nقسط %d — %s سوت\n%s",
|
||||||
|
$daysLeft,
|
||||||
|
$customer->name ?? '-',
|
||||||
|
$customer->mobile ?? '-',
|
||||||
|
$installment->installment_number,
|
||||||
|
$soot,
|
||||||
|
$company
|
||||||
|
);
|
||||||
|
$this->notifyAdminsSms($adminMsg, 'gold_credit_upcoming_admin');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolvePenaltyConfig(GoldCredit $credit): array
|
||||||
|
{
|
||||||
|
$cfg = app(ConfigService::class);
|
||||||
|
$type = $credit->penalty_type ?: $cfg->get('gold_credit_penalty_type', 'rial');
|
||||||
|
$type = in_array($type, ['rial', 'gold'], true) ? $type : 'rial';
|
||||||
|
$amount = $credit->penalty_amount !== null
|
||||||
|
? (float) $credit->penalty_amount
|
||||||
|
: (float) $cfg->get('gold_credit_penalty_amount', '1000000');
|
||||||
|
$grace = $credit->grace_hours !== null
|
||||||
|
? (int) $credit->grace_hours
|
||||||
|
: $cfg->getInt('gold_credit_grace_hours', 24);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'type' => $type,
|
||||||
|
'amount' => $amount,
|
||||||
|
'grace_hours' => max(0, $grace),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sendOverdueWarningSms(GoldCreditInstallment $installment): void
|
||||||
|
{
|
||||||
|
$customer = $installment->goldCredit?->customer;
|
||||||
|
if (!$customer?->mobile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = app(ConfigService::class);
|
||||||
|
$grace = $this->resolvePenaltyConfig($installment->goldCredit)['grace_hours'];
|
||||||
|
$penalty = $this->resolvePenaltyConfig($installment->goldCredit);
|
||||||
|
$penaltyLabel = $penalty['type'] === 'gold'
|
||||||
|
? number_format($penalty['amount'], 4) . ' گرم طلا'
|
||||||
|
: number_format((int) $penalty['amount']) . ' ریال';
|
||||||
|
|
||||||
|
$template = $cfg->get(
|
||||||
|
'gold_credit_overdue_sms_text',
|
||||||
|
'قسط شماره {n} سررسید شده است. در صورت عدم پرداخت تا {hours} ساعت آینده، روزانه {penalty} به بدهی شما اضافه میشود.'
|
||||||
|
);
|
||||||
|
$message = str_replace(
|
||||||
|
['{n}', '{hours}', '{penalty}', '{name}'],
|
||||||
|
[
|
||||||
|
(string) $installment->installment_number,
|
||||||
|
(string) $grace,
|
||||||
|
$penaltyLabel,
|
||||||
|
$customer->name ?? '',
|
||||||
|
],
|
||||||
|
(string) $template
|
||||||
|
);
|
||||||
|
$message .= "\n" . $cfg->get('name_fa', '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
app(\App\Services\SmsBilling\SmsGatewayService::class)
|
||||||
|
->send($customer->mobile, $message, 'gold_credit_overdue');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Illuminate\Support\Facades\Log::warning('gold credit overdue sms failed', [
|
||||||
|
'installment_id' => $installment->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function applyDailyPenaltyIfDue(GoldCreditInstallment $installment, GoldCredit $credit): bool
|
||||||
|
{
|
||||||
|
if (!$installment->warning_sms_sent_at) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = $this->resolvePenaltyConfig($credit);
|
||||||
|
$graceEnds = Carbon::parse($installment->warning_sms_sent_at)->addHours($config['grace_hours']);
|
||||||
|
if (now()->lt($graceEnds)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$installment->penalty_started_at) {
|
||||||
|
$installment->penalty_started_at = now();
|
||||||
|
}
|
||||||
|
|
||||||
|
$last = $installment->last_penalty_at
|
||||||
|
? Carbon::parse($installment->last_penalty_at)->startOfDay()
|
||||||
|
: null;
|
||||||
|
$today = now()->startOfDay();
|
||||||
|
if ($last && $last->equalTo($today)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($config['type'] === 'gold') {
|
||||||
|
$installment->penalty_gold = round((float) $installment->penalty_gold + $config['amount'], 6);
|
||||||
|
} else {
|
||||||
|
$installment->penalty_rial = (int) $installment->penalty_rial + (int) round($config['amount']);
|
||||||
|
}
|
||||||
|
$installment->penalty_days = (int) $installment->penalty_days + 1;
|
||||||
|
$installment->last_penalty_at = now();
|
||||||
|
$installment->status = 'overdue';
|
||||||
|
$installment->save();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function calculateTotalInterest(
|
||||||
|
float $goldAmount,
|
||||||
|
int $months,
|
||||||
|
string $interestType,
|
||||||
|
float $interestValue
|
||||||
|
): float {
|
||||||
|
return match ($interestType) {
|
||||||
|
'percent' => round($goldAmount * ($interestValue / 100) * $months, 6),
|
||||||
|
'fixed_gram' => round($interestValue * $months, 6),
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertSequentialPayable(GoldCredit $credit, $installments): void
|
||||||
|
{
|
||||||
|
$allPending = GoldCreditInstallment::where('gold_credit_id', $credit->id)
|
||||||
|
->whereIn('status', ['pending', 'overdue', 'partial'])
|
||||||
|
->orderBy('installment_number')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$expectedIds = $allPending->take($installments->count())->pluck('id')->sort()->values();
|
||||||
|
$actualIds = $installments->pluck('id')->sort()->values();
|
||||||
|
|
||||||
|
if ($expectedIds->toArray() !== $actualIds->toArray()) {
|
||||||
|
throw new UnprocessableEntityHttpException('اقساط باید به ترتیب و بدون پرش پرداخت شوند');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($installments as $installment) {
|
||||||
|
if (!in_array($installment->status, ['pending', 'overdue', 'partial'], true)) {
|
||||||
|
throw new UnprocessableEntityHttpException('یکی از اقساط انتخابشده قبلاً پرداخت شده است');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolvePaymentAmounts(
|
||||||
|
string $payMethod,
|
||||||
|
float $totalDueGold,
|
||||||
|
?float $goldAmount,
|
||||||
|
?int $rialAmount
|
||||||
|
): array {
|
||||||
|
return match ($payMethod) {
|
||||||
|
'gold' => [$totalDueGold, 0],
|
||||||
|
'rial' => [0, $this->goldToRial($totalDueGold)],
|
||||||
|
'mixed' => [
|
||||||
|
(float) ($goldAmount ?? 0),
|
||||||
|
(int) ($rialAmount ?? 0),
|
||||||
|
],
|
||||||
|
default => throw new UnprocessableEntityHttpException('روش پرداخت نامعتبر است'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function defaultTiers(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['min_gram' => 0.5, 'max_gram' => 1, 'credit_gram' => 3, 'monthly_soot' => 250, 'months' => 12],
|
||||||
|
['min_gram' => 1, 'max_gram' => 2, 'credit_gram' => 4, 'monthly_soot' => 333, 'months' => 12],
|
||||||
|
['min_gram' => 2, 'max_gram' => 3, 'credit_gram' => 5, 'monthly_soot' => 417, 'months' => 12],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTiers(): array
|
||||||
|
{
|
||||||
|
$cfg = app(ConfigService::class);
|
||||||
|
$raw = $cfg->getString('gold_credit_tiers', '');
|
||||||
|
if ($raw === '') {
|
||||||
|
return $this->defaultTiers();
|
||||||
|
}
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded) || count($decoded) === 0) {
|
||||||
|
return $this->defaultTiers();
|
||||||
|
}
|
||||||
|
return array_values(array_map(function ($tier) {
|
||||||
|
return [
|
||||||
|
'min_gram' => (float) ($tier['min_gram'] ?? 0),
|
||||||
|
'max_gram' => (float) ($tier['max_gram'] ?? 0),
|
||||||
|
'credit_gram' => (float) ($tier['credit_gram'] ?? 0),
|
||||||
|
'monthly_soot' => (int) ($tier['monthly_soot'] ?? 0),
|
||||||
|
'months' => (int) ($tier['months'] ?? 12),
|
||||||
|
];
|
||||||
|
}, $decoded));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolveTierForBalance(float $goldBalance): ?array
|
||||||
|
{
|
||||||
|
$tiers = $this->getTiers();
|
||||||
|
$count = count($tiers);
|
||||||
|
foreach ($tiers as $i => $tier) {
|
||||||
|
$min = (float) $tier['min_gram'];
|
||||||
|
$max = (float) $tier['max_gram'];
|
||||||
|
$isLast = $i === $count - 1;
|
||||||
|
if ($goldBalance >= $min && ($isLast ? $goldBalance <= $max : $goldBalance < $max)) {
|
||||||
|
return $tier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCustomerGoldBalance(int $customerId): float
|
||||||
|
{
|
||||||
|
$customer = Customer::findOrFail($customerId);
|
||||||
|
$walletOwnerType = $customer->resolveWalletOwnerType();
|
||||||
|
$wallet = Wallet::where('owner', $customerId)
|
||||||
|
->where('owner_type', $walletOwnerType)
|
||||||
|
->whereNull('fund_id')
|
||||||
|
->whereNull('product_id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return round((float) ($wallet->gold ?? 0), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getPublicConfig(): array
|
||||||
|
{
|
||||||
|
$cfg = app(ConfigService::class);
|
||||||
|
$defaultInfo = "اعتبار طلایی بر اساس موجودی طلای شما در قلک اعطا میشود.\n"
|
||||||
|
. "• کاربران با موجودی ۵۰۰ سوت تا ۱ گرم: ۳ گرم اعتبار — بازپرداخت ماهانه ۲۵۰ سوت\n"
|
||||||
|
. "• کاربران با موجودی ۱ تا ۲ گرم: ۴ گرم اعتبار — بازپرداخت ماهانه ۳۳۳ سوت\n"
|
||||||
|
. "• کاربران با موجودی ۲ تا ۳ گرم: ۵ گرم اعتبار — بازپرداخت ماهانه ۴۱۷ سوت\n"
|
||||||
|
. "بازپرداخت همه طرحها ۱۲ ماهه است.";
|
||||||
|
$defaultSuccess = 'درخواست شما ثبت شد و در حال بررسی است. تا ۲۴ ساعت آینده با شما تماس گرفته میشود و توضیحات تکمیلی توسط کارشناس ارائه خواهد شد.';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'min_balance_gram' => (float) $cfg->get('gold_credit_min_balance_gram', '0.5'),
|
||||||
|
'info_text' => $cfg->getString('gold_credit_info_text', $defaultInfo),
|
||||||
|
'request_success_text' => $cfg->getString('gold_credit_request_success_text', $defaultSuccess),
|
||||||
|
'default_months' => $cfg->getInt('gold_credit_default_months', 12),
|
||||||
|
'pay_methods' => [
|
||||||
|
'gold' => $cfg->getBool('gold_credit_pay_gold_enabled', true),
|
||||||
|
'rial' => $cfg->getBool('gold_credit_pay_rial_enabled', false),
|
||||||
|
'mixed' => $cfg->getBool('gold_credit_pay_mixed_enabled', false),
|
||||||
|
],
|
||||||
|
'tiers' => $this->getTiers(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assertPayMethodAllowed(string $payMethod): void
|
||||||
|
{
|
||||||
|
$methods = $this->getPublicConfig()['pay_methods'];
|
||||||
|
if (empty($methods[$payMethod])) {
|
||||||
|
throw new UnprocessableEntityHttpException('این روش پرداخت فعال نیست');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function goldToRial(float $gold): int
|
||||||
|
{
|
||||||
|
$gramPrice = $this->getGramPriceRial();
|
||||||
|
if ($gramPrice <= 0) {
|
||||||
|
throw new UnprocessableEntityHttpException('قیمت طلا در دسترس نیست');
|
||||||
|
}
|
||||||
|
return (int) ceil($gold * $gramPrice);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deductFromWallet(Customer $customer, float $payGold, int $payRial, ?int $adminId): void
|
||||||
|
{
|
||||||
|
$walletOwnerType = $customer->resolveWalletOwnerType();
|
||||||
|
$wallet = Wallet::where('owner', $customer->id)
|
||||||
|
->where('owner_type', $walletOwnerType)
|
||||||
|
->whereNull('fund_id')
|
||||||
|
->whereNull('product_id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($payGold > 0) {
|
||||||
|
$available = $wallet ? (float) $wallet->gold : 0;
|
||||||
|
if ($payGold > $available) {
|
||||||
|
throw new UnprocessableEntityHttpException('موجودی طلای کیف پول کافی نیست');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($payRial > 0) {
|
||||||
|
$mainWallet = app(MainWalletProductService::class);
|
||||||
|
foreach (app(ConfigService::class)->mainWalletProductIds() as $productId) {
|
||||||
|
$mainWallet->consolidateOrphanedForOwner($customer->id, $walletOwnerType, (int) $productId);
|
||||||
|
}
|
||||||
|
$availableCash = $mainWallet->effectiveMainWalletCash($customer->id, $walletOwnerType);
|
||||||
|
if ($payRial > $availableCash) {
|
||||||
|
throw new UnprocessableEntityHttpException('موجودی ریالی کیف پول کافی نیست');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$walletInterface = new WalletInterface($customer->id, $walletOwnerType);
|
||||||
|
$note = 'بازپرداخت اعتبار طلایی';
|
||||||
|
|
||||||
|
if ($payGold > 0) {
|
||||||
|
$withdraw = Withdraw::create([
|
||||||
|
'user_id' => $adminId,
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'gold' => -$payGold,
|
||||||
|
'note' => $note,
|
||||||
|
'sts' => 4,
|
||||||
|
]);
|
||||||
|
new TransactionInterface($walletInterface, [
|
||||||
|
'cash' => 0,
|
||||||
|
'gold' => -$payGold,
|
||||||
|
'expire_at' => now(),
|
||||||
|
'note' => $note,
|
||||||
|
'withdraw_id' => $withdraw->id,
|
||||||
|
'active' => true,
|
||||||
|
], 7, false, false, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($payRial > 0) {
|
||||||
|
$withdraw = Withdraw::create([
|
||||||
|
'user_id' => $adminId,
|
||||||
|
'customer_id' => $customer->id,
|
||||||
|
'cash' => -$payRial,
|
||||||
|
'note' => $note,
|
||||||
|
'sts' => 4,
|
||||||
|
]);
|
||||||
|
new TransactionInterface($walletInterface, [
|
||||||
|
'cash' => -$payRial,
|
||||||
|
'gold' => 0,
|
||||||
|
'expire_at' => now(),
|
||||||
|
'note' => $note,
|
||||||
|
'withdraw_id' => $withdraw->id,
|
||||||
|
'active' => true,
|
||||||
|
], 4, false, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ use App\Models\Setting;
|
|||||||
class SuperAdminProgramSettingsOverlay
|
class SuperAdminProgramSettingsOverlay
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* فقط این ۱۴ دسترسی از سوپرادمین خوانده میشود.
|
* فقط این ۱۵ دسترسی از سوپرادمین خوانده میشود.
|
||||||
* هر کلید دیگری (قیمت، ساعت کاری، برندینگ، …) مال پنل خانگی است.
|
* هر کلید دیگری (قیمت، ساعت کاری، برندینگ، …) مال پنل خانگی است.
|
||||||
*/
|
*/
|
||||||
public const SUPERADMIN_OWNED_KEYS = [
|
public const SUPERADMIN_OWNED_KEYS = [
|
||||||
@@ -20,6 +20,7 @@ class SuperAdminProgramSettingsOverlay
|
|||||||
'enable_automation',
|
'enable_automation',
|
||||||
'enable_conditional_order',
|
'enable_conditional_order',
|
||||||
'enable_customer_level_profit',
|
'enable_customer_level_profit',
|
||||||
|
'enable_gold_credit',
|
||||||
'enable_product_funds',
|
'enable_product_funds',
|
||||||
'group_sms',
|
'group_sms',
|
||||||
'has_page_earn_money',
|
'has_page_earn_money',
|
||||||
@@ -37,6 +38,7 @@ class SuperAdminProgramSettingsOverlay
|
|||||||
*/
|
*/
|
||||||
public const CLIENT_FEATURE_KEYS = [
|
public const CLIENT_FEATURE_KEYS = [
|
||||||
'enable_conditional_order',
|
'enable_conditional_order',
|
||||||
|
'enable_gold_credit',
|
||||||
'enable_product_funds',
|
'enable_product_funds',
|
||||||
'has_page_earn_money',
|
'has_page_earn_money',
|
||||||
'has_page_gift_card',
|
'has_page_gift_card',
|
||||||
@@ -117,7 +119,7 @@ class SuperAdminProgramSettingsOverlay
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* آخرین مقادیر ۱۴ دسترسی را در settings لوکال ذخیره میکند (can_edit=0).
|
* آخرین مقادیر ۱۵ دسترسی را در settings لوکال ذخیره میکند (can_edit=0).
|
||||||
*
|
*
|
||||||
* @param array<string, array<string, mixed>> $map
|
* @param array<string, array<string, mixed>> $map
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('gold_credits', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('customer_id');
|
||||||
|
$table->decimal('gold_amount', 12, 4);
|
||||||
|
$table->unsignedSmallInteger('months');
|
||||||
|
$table->enum('interest_type', ['none', 'percent', 'fixed_gram'])->default('none');
|
||||||
|
$table->decimal('interest_value', 12, 4)->default(0);
|
||||||
|
$table->enum('delivery_method', ['auto', 'manual', 'in_person'])->default('manual');
|
||||||
|
$table->enum('status', ['pending', 'active', 'completed', 'cancelled'])->default('active');
|
||||||
|
$table->enum('source', ['admin_direct', 'user_request'])->default('admin_direct');
|
||||||
|
$table->decimal('total_interest_gold', 12, 4)->default(0);
|
||||||
|
$table->decimal('total_payable_gold', 12, 4)->default(0);
|
||||||
|
$table->decimal('paid_gold', 12, 4)->default(0);
|
||||||
|
$table->unsignedBigInteger('paid_rial')->default(0);
|
||||||
|
$table->unsignedBigInteger('created_by')->nullable();
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('customer_id', 'gc_credit_customer_fk')
|
||||||
|
->references('id')->on('customers')->cascadeOnDelete();
|
||||||
|
$table->index(['customer_id', 'status'], 'gc_credit_cust_st_idx');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('gold_credit_installments', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('gold_credit_id');
|
||||||
|
$table->unsignedSmallInteger('installment_number');
|
||||||
|
$table->date('due_date');
|
||||||
|
$table->unsignedInteger('amount_soot');
|
||||||
|
$table->decimal('principal_gold', 12, 6)->default(0);
|
||||||
|
$table->decimal('interest_gold', 12, 6)->default(0);
|
||||||
|
$table->decimal('total_gold', 12, 6)->default(0);
|
||||||
|
$table->decimal('paid_gold', 12, 6)->default(0);
|
||||||
|
$table->unsignedBigInteger('paid_rial')->default(0);
|
||||||
|
$table->enum('status', ['pending', 'paid', 'partial', 'overdue', 'prepaid'])->default('pending');
|
||||||
|
$table->timestamp('paid_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('gold_credit_id', 'gc_inst_credit_fk')
|
||||||
|
->references('id')->on('gold_credits')->cascadeOnDelete();
|
||||||
|
$table->unique(['gold_credit_id', 'installment_number'], 'gc_inst_num_uniq');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('gold_credit_requests', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('customer_id');
|
||||||
|
$table->decimal('requested_gold', 12, 4);
|
||||||
|
$table->unsignedSmallInteger('requested_months')->nullable();
|
||||||
|
$table->text('note')->nullable();
|
||||||
|
$table->enum('status', ['pending', 'approved', 'rejected'])->default('pending');
|
||||||
|
$table->text('admin_note')->nullable();
|
||||||
|
$table->unsignedBigInteger('gold_credit_id')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('customer_id', 'gc_req_customer_fk')
|
||||||
|
->references('id')->on('customers')->cascadeOnDelete();
|
||||||
|
$table->foreign('gold_credit_id', 'gc_req_credit_fk')
|
||||||
|
->references('id')->on('gold_credits')->nullOnDelete();
|
||||||
|
$table->index(['customer_id', 'status'], 'gc_req_cust_st_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('gold_credit_requests');
|
||||||
|
Schema::dropIfExists('gold_credit_installments');
|
||||||
|
Schema::dropIfExists('gold_credits');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('gold_credits', function (Blueprint $table) {
|
||||||
|
$table->boolean('has_check')->default(false)->after('notes');
|
||||||
|
$table->boolean('has_contract')->default(false)->after('has_check');
|
||||||
|
$table->string('check_note')->nullable()->after('has_contract');
|
||||||
|
$table->string('contract_note')->nullable()->after('check_note');
|
||||||
|
$table->enum('penalty_type', ['rial', 'gold'])->nullable()->after('contract_note');
|
||||||
|
$table->decimal('penalty_amount', 14, 4)->nullable()->after('penalty_type');
|
||||||
|
$table->unsignedSmallInteger('grace_hours')->nullable()->after('penalty_amount');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('gold_credit_installments', function (Blueprint $table) {
|
||||||
|
$table->timestamp('warning_sms_sent_at')->nullable()->after('paid_at');
|
||||||
|
$table->timestamp('penalty_started_at')->nullable()->after('warning_sms_sent_at');
|
||||||
|
$table->timestamp('last_penalty_at')->nullable()->after('penalty_started_at');
|
||||||
|
$table->unsignedInteger('penalty_days')->default(0)->after('last_penalty_at');
|
||||||
|
$table->unsignedBigInteger('penalty_rial')->default(0)->after('penalty_days');
|
||||||
|
$table->decimal('penalty_gold', 12, 6)->default(0)->after('penalty_rial');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('gold_credits', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'has_check',
|
||||||
|
'has_contract',
|
||||||
|
'check_note',
|
||||||
|
'contract_note',
|
||||||
|
'penalty_type',
|
||||||
|
'penalty_amount',
|
||||||
|
'grace_hours',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('gold_credit_installments', function (Blueprint $table) {
|
||||||
|
$table->dropColumn([
|
||||||
|
'warning_sms_sent_at',
|
||||||
|
'penalty_started_at',
|
||||||
|
'last_penalty_at',
|
||||||
|
'penalty_days',
|
||||||
|
'penalty_rial',
|
||||||
|
'penalty_gold',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('gold_credit_installments', function (Blueprint $table) {
|
||||||
|
$table->timestamp('reminder_3d_sent_at')->nullable()->after('warning_sms_sent_at');
|
||||||
|
$table->timestamp('reminder_1d_sent_at')->nullable()->after('reminder_3d_sent_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('gold_credit_installments', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['reminder_3d_sent_at', 'reminder_1d_sent_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('settings')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = [
|
||||||
|
// SuperAdmin-owned feature flag (default off). Local copy; overlay may overwrite.
|
||||||
|
[
|
||||||
|
'key' => 'enable_gold_credit',
|
||||||
|
'value' => 'false',
|
||||||
|
'title_fa' => 'اعتبار طلایی',
|
||||||
|
'description' => 'روشن: منوی اعتبار در ادمین و صفحه اعتبار در اپ مشتری فعال میشود. خاموش: غیرفعال. فقط از سوپرادمین قابل تغییر است.',
|
||||||
|
'can_edit' => 0,
|
||||||
|
'is_active' => 1,
|
||||||
|
'type' => 'switch',
|
||||||
|
],
|
||||||
|
['key' => 'gold_credit_penalty_type', 'value' => 'rial', 'title_fa' => 'نوع جریمه اعتبار طلایی', 'description' => 'ریالی یا طلایی|rial,gold', 'can_edit' => 1, 'is_active' => 1, 'type' => 'select'],
|
||||||
|
['key' => 'gold_credit_penalty_amount', 'value' => '1000000', 'title_fa' => 'مبلغ جریمه روزانه اعتبار طلایی', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'number'],
|
||||||
|
['key' => 'gold_credit_grace_hours', 'value' => '24', 'title_fa' => 'مهلت پرداخت بعد از سررسید (ساعت)', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'number'],
|
||||||
|
['key' => 'gold_credit_default_months', 'value' => '12', 'title_fa' => 'تعداد ماه پیشفرض اعتبار طلایی', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'number'],
|
||||||
|
['key' => 'gold_credit_min_balance_gram', 'value' => '0.5', 'title_fa' => 'حداقل موجودی طلا برای درخواست (گرم)', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'number'],
|
||||||
|
['key' => 'gold_credit_overdue_sms_enabled', 'value' => 'true', 'title_fa' => 'ارسال SMS هشدار سررسید اعتبار طلایی', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'switch'],
|
||||||
|
['key' => 'gold_credit_admin_sms_enabled', 'value' => 'true', 'title_fa' => 'پیامک ادمین برای درخواست و پرداخت اعتبار طلایی', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'switch'],
|
||||||
|
['key' => 'gold_credit_upcoming_sms_enabled', 'value' => 'true', 'title_fa' => 'یادآوری ۱ و ۳ روز مانده به سررسید قسط', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'switch'],
|
||||||
|
['key' => 'gold_credit_pay_gold_enabled', 'value' => 'true', 'title_fa' => 'پرداخت اقساط با طلا', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'switch'],
|
||||||
|
['key' => 'gold_credit_pay_rial_enabled', 'value' => 'false', 'title_fa' => 'پرداخت اقساط با ریال', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'switch'],
|
||||||
|
['key' => 'gold_credit_pay_mixed_enabled', 'value' => 'false', 'title_fa' => 'پرداخت اقساط ترکیبی', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'switch'],
|
||||||
|
['key' => 'gold_credit_overdue_sms_text', 'value' => 'مشتری گرامی، قسط اعتبار طلایی شما سررسید شده است.', 'title_fa' => 'متن SMS سررسید اعتبار طلایی', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'longtext'],
|
||||||
|
['key' => 'gold_credit_info_text', 'value' => '', 'title_fa' => 'متن راهنمای اعتبار طلایی', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'longtext'],
|
||||||
|
['key' => 'gold_credit_request_success_text', 'value' => 'درخواست شما ثبت شد و حداکثر تا ۲۴ ساعت بررسی میشود.', 'title_fa' => 'متن پس از ثبت درخواست اعتبار', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'longtext'],
|
||||||
|
['key' => 'gold_credit_tiers', 'value' => '[]', 'title_fa' => 'سطوح اعتبار طلایی (JSON)', 'description' => '', 'can_edit' => 1, 'is_active' => 1, 'type' => 'longtext'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$hasType = Schema::hasColumn('settings', 'type');
|
||||||
|
$hasShow = Schema::hasColumn('settings', 'show_to_user');
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$exists = DB::table('settings')->where('key', $row['key'])->exists();
|
||||||
|
if ($exists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$insert = [
|
||||||
|
'key' => $row['key'],
|
||||||
|
'value' => $row['value'],
|
||||||
|
'title_fa' => $row['title_fa'],
|
||||||
|
'description' => $row['description'],
|
||||||
|
'can_edit' => $row['can_edit'],
|
||||||
|
'is_active' => $row['is_active'],
|
||||||
|
];
|
||||||
|
if ($hasType) {
|
||||||
|
$insert['type'] = $row['type'];
|
||||||
|
}
|
||||||
|
if ($hasShow && $row['key'] === 'enable_gold_credit') {
|
||||||
|
$insert['show_to_user'] = 1;
|
||||||
|
}
|
||||||
|
DB::table('settings')->insert($insert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// keep data
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@ use App\Http\Controllers\Admin\PargarController;
|
|||||||
use App\Http\Controllers\Admin\FileController;
|
use App\Http\Controllers\Admin\FileController;
|
||||||
use App\Http\Controllers\Site\OfferCodesController;
|
use App\Http\Controllers\Site\OfferCodesController;
|
||||||
use App\Http\Controllers\Admin\SettingsController;
|
use App\Http\Controllers\Admin\SettingsController;
|
||||||
|
use App\Http\Controllers\Admin\GoldCreditController as AdminGoldCreditController;
|
||||||
use \App\Http\Controllers\Admin\GiftCardController;
|
use \App\Http\Controllers\Admin\GiftCardController;
|
||||||
use App\Http\Controllers\Admin\CategoryController;
|
use App\Http\Controllers\Admin\CategoryController;
|
||||||
use App\Http\Controllers\Chat\SupportChatNotifyController;
|
use App\Http\Controllers\Chat\SupportChatNotifyController;
|
||||||
@@ -169,6 +170,18 @@ Route::middleware('auth:user')->group(function (){
|
|||||||
//-------------wallet
|
//-------------wallet
|
||||||
Route::post('/wallet/charge',[WalletController::class,'charge']);
|
Route::post('/wallet/charge',[WalletController::class,'charge']);
|
||||||
Route::post('/wallet/charge_gold',[WalletController::class,'charge_gold']);
|
Route::post('/wallet/charge_gold',[WalletController::class,'charge_gold']);
|
||||||
|
|
||||||
|
//-------------gold credit
|
||||||
|
Route::post('/gold_credit/preview', [AdminGoldCreditController::class, 'preview']);
|
||||||
|
Route::post('/gold_credit/table', [AdminGoldCreditController::class, 'table']);
|
||||||
|
Route::post('/gold_credit/requests/table', [AdminGoldCreditController::class, 'requestsTable']);
|
||||||
|
Route::post('/gold_credit/installments/pay', [AdminGoldCreditController::class, 'payInstallments']);
|
||||||
|
Route::post('/gold_credit/requests/{id}/approve', [AdminGoldCreditController::class, 'approveRequest']);
|
||||||
|
Route::post('/gold_credit/requests/{id}/reject', [AdminGoldCreditController::class, 'rejectRequest']);
|
||||||
|
Route::post('/customers/{id}/gold_credit/installments', [AdminGoldCreditController::class, 'customerInstallments']);
|
||||||
|
Route::put('/gold_credit/{id}', [AdminGoldCreditController::class, 'update']);
|
||||||
|
Route::post('/gold_credit', [AdminGoldCreditController::class, 'store']);
|
||||||
|
|
||||||
Route::post('/wallet/charge_wallet',[WalletController::class,'charge_wallet']);
|
Route::post('/wallet/charge_wallet',[WalletController::class,'charge_wallet']);
|
||||||
Route::get('/wallet/charge-targets',[WalletController::class,'chargeTargets']);
|
Route::get('/wallet/charge-targets',[WalletController::class,'chargeTargets']);
|
||||||
Route::post('/wallet/table',[WalletController::class,'indexWallet']);
|
Route::post('/wallet/table',[WalletController::class,'indexWallet']);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\Customer\CustomerController;
|
use App\Http\Controllers\Customer\CustomerController;
|
||||||
|
use App\Http\Controllers\Customer\GoldCreditController;
|
||||||
use App\Http\Controllers\Customer\OrderController;
|
use App\Http\Controllers\Customer\OrderController;
|
||||||
use App\Http\Controllers\Customer\AddressController;
|
use App\Http\Controllers\Customer\AddressController;
|
||||||
use App\Http\Controllers\Customer\WalletController;
|
use App\Http\Controllers\Customer\WalletController;
|
||||||
@@ -158,6 +159,11 @@ Route::middleware(['auth:customer', 'customer.active'])->group(function () use (
|
|||||||
//offer code
|
//offer code
|
||||||
Route::post('offer_code/check_code', [OfferCodesController::class, 'check_code']);
|
Route::post('offer_code/check_code', [OfferCodesController::class, 'check_code']);
|
||||||
|
|
||||||
|
//--------------------gold credit
|
||||||
|
Route::get('gold_credit', [GoldCreditController::class, 'show']);
|
||||||
|
Route::post('gold_credit/request', [GoldCreditController::class, 'requestLoan']);
|
||||||
|
Route::post('gold_credit/installments/pay', [GoldCreditController::class, 'payInstallments']);
|
||||||
|
|
||||||
if ($isStore) {
|
if ($isStore) {
|
||||||
Route::post('vc_validate', [havaleController::class, 'virtualCardValidate']);
|
Route::post('vc_validate', [havaleController::class, 'virtualCardValidate']);
|
||||||
Route::post('havale', [havaleController::class, 'havale']);
|
Route::post('havale', [havaleController::class, 'havale']);
|
||||||
@@ -168,3 +174,4 @@ Route::middleware(['auth:customer', 'customer.active'])->group(function () use (
|
|||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class SuperAdminProgramSettingsOverlayTest extends TestCase
|
|||||||
'enable_automation',
|
'enable_automation',
|
||||||
'enable_conditional_order',
|
'enable_conditional_order',
|
||||||
'enable_customer_level_profit',
|
'enable_customer_level_profit',
|
||||||
|
'enable_gold_credit',
|
||||||
'enable_product_funds',
|
'enable_product_funds',
|
||||||
'group_sms',
|
'group_sms',
|
||||||
'has_page_earn_money',
|
'has_page_earn_money',
|
||||||
@@ -28,7 +29,7 @@ class SuperAdminProgramSettingsOverlayTest extends TestCase
|
|||||||
];
|
];
|
||||||
|
|
||||||
$this->assertSame($expected, SuperAdminProgramSettingsOverlay::SUPERADMIN_OWNED_KEYS);
|
$this->assertSame($expected, SuperAdminProgramSettingsOverlay::SUPERADMIN_OWNED_KEYS);
|
||||||
$this->assertCount(14, SuperAdminProgramSettingsOverlay::SUPERADMIN_OWNED_KEYS);
|
$this->assertCount(15, SuperAdminProgramSettingsOverlay::SUPERADMIN_OWNED_KEYS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_overlay_keeps_only_access_keys(): void
|
public function test_overlay_keeps_only_access_keys(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user