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:
2026-08-30 00:17:01 +03:30
co-authored by Cursor
parent a8be3d499b
commit 050bd26318
18 changed files with 1670 additions and 5 deletions
@@ -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)
{
@@ -319,7 +319,7 @@ class SettingsController extends Controller
/**
* get_settings ادمین (و پایهٔ publicIndex قبل از فیلتر show_to_user):
* can_edit=1 (+ runtime + local-owned) از DB لوکال
* فقط ۱۴ دسترسی سوپرادمین از overlay؛ بقیهٔ can_edit=0 از کپی لوکال
* فقط ۱۵ دسترسی سوپرادمین از overlay؛ بقیهٔ can_edit=0 از کپی لوکال
* اگر سوپرادمین در دسترس نباشد کل جدول لوکال برمی‌گردد.
*/
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);
}
}