diff --git a/app/Console/Commands/ProcessGoldCreditOverdue.php b/app/Console/Commands/ProcessGoldCreditOverdue.php new file mode 100644 index 0000000..2809ef6 --- /dev/null +++ b/app/Console/Commands/ProcessGoldCreditOverdue.php @@ -0,0 +1,26 @@ +processOverdueInstallments(); + $this->info(sprintf( + 'warnings=%d penalties=%d reminders=%d', + $result['warnings'] ?? 0, + $result['penalties'] ?? 0, + $result['reminders'] ?? 0 + )); + + return self::SUCCESS; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index db2fb5c..535e4f7 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -13,6 +13,11 @@ class Kernel extends ConsoleKernel protected function schedule(Schedule $schedule) { + $schedule->command('gold-credit:process-overdue') + ->everyFiveMinutes() + ->name('gold_credit_process_overdue') + ->withoutOverlapping(); + // تیک قیمت — ۶ بار در دقیقه با فاصله ۱۰ ثانیه (montzar + zaarbox + محصولات) // نیاز: * * * * * php artisan schedule:run // یا یک‌بار: php artisan schedule:bootstrap diff --git a/app/Http/Controllers/Admin/GoldCreditController.php b/app/Http/Controllers/Admin/GoldCreditController.php new file mode 100644 index 0000000..32541bc --- /dev/null +++ b/app/Http/Controllers/Admin/GoldCreditController.php @@ -0,0 +1,221 @@ +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); + } +} diff --git a/app/Http/Controllers/Admin/SettingsController.php b/app/Http/Controllers/Admin/SettingsController.php index 3c4255e..65f598d 100644 --- a/app/Http/Controllers/Admin/SettingsController.php +++ b/app/Http/Controllers/Admin/SettingsController.php @@ -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) diff --git a/app/Http/Controllers/Customer/GoldCreditController.php b/app/Http/Controllers/Customer/GoldCreditController.php new file mode 100644 index 0000000..68aebb1 --- /dev/null +++ b/app/Http/Controllers/Customer/GoldCreditController.php @@ -0,0 +1,157 @@ +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); + } +} diff --git a/app/Models/GoldCredit.php b/app/Models/GoldCredit.php new file mode 100644 index 0000000..9a35646 --- /dev/null +++ b/app/Models/GoldCredit.php @@ -0,0 +1,61 @@ + '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'); + } +} diff --git a/app/Models/GoldCreditInstallment.php b/app/Models/GoldCreditInstallment.php new file mode 100644 index 0000000..bc444ea --- /dev/null +++ b/app/Models/GoldCreditInstallment.php @@ -0,0 +1,52 @@ + '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); + } +} diff --git a/app/Models/GoldCreditRequest.php b/app/Models/GoldCreditRequest.php new file mode 100644 index 0000000..27c085c --- /dev/null +++ b/app/Models/GoldCreditRequest.php @@ -0,0 +1,33 @@ + 'float', + ]; + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + public function goldCredit(): BelongsTo + { + return $this->belongsTo(GoldCredit::class); + } +} diff --git a/app/Services/ConfigService.php b/app/Services/ConfigService.php index 29ab937..fa8a14f 100644 --- a/app/Services/ConfigService.php +++ b/app/Services/ConfigService.php @@ -1223,6 +1223,11 @@ class ConfigService return $this->getBool('enable_product_funds', false); } + public function isGoldCreditEnabled(): bool + { + return $this->getBool('enable_gold_credit', false); + } + /** * سوییچ اصلی جیبیت (can_edit=0، پیش‌فرض روشن). * خاموش = هیچ فراخوانی احراز هویت به جیبیت انجام نمی‌شود. diff --git a/app/Services/GoldCreditService.php b/app/Services/GoldCreditService.php new file mode 100644 index 0000000..0a68d7e --- /dev/null +++ b/app/Services/GoldCreditService.php @@ -0,0 +1,851 @@ +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); + } + } +} diff --git a/app/Services/SuperAdminProgramSettingsOverlay.php b/app/Services/SuperAdminProgramSettingsOverlay.php index 77d95db..da2bf13 100644 --- a/app/Services/SuperAdminProgramSettingsOverlay.php +++ b/app/Services/SuperAdminProgramSettingsOverlay.php @@ -11,7 +11,7 @@ use App\Models\Setting; class SuperAdminProgramSettingsOverlay { /** - * فقط این ۱۴ دسترسی از سوپرادمین خوانده می‌شود. + * فقط این ۱۵ دسترسی از سوپرادمین خوانده می‌شود. * هر کلید دیگری (قیمت، ساعت کاری، برندینگ، …) مال پنل خانگی است. */ public const SUPERADMIN_OWNED_KEYS = [ @@ -20,6 +20,7 @@ class SuperAdminProgramSettingsOverlay 'enable_automation', 'enable_conditional_order', 'enable_customer_level_profit', + 'enable_gold_credit', 'enable_product_funds', 'group_sms', 'has_page_earn_money', @@ -37,6 +38,7 @@ class SuperAdminProgramSettingsOverlay */ public const CLIENT_FEATURE_KEYS = [ 'enable_conditional_order', + 'enable_gold_credit', 'enable_product_funds', 'has_page_earn_money', 'has_page_gift_card', @@ -117,7 +119,7 @@ class SuperAdminProgramSettingsOverlay } /** - * آخرین مقادیر ۱۴ دسترسی را در settings لوکال ذخیره می‌کند (can_edit=0). + * آخرین مقادیر ۱۵ دسترسی را در settings لوکال ذخیره می‌کند (can_edit=0). * * @param array> $map */ diff --git a/database/migrations/2026_08_28_000001_create_gold_credit_tables.php b/database/migrations/2026_08_28_000001_create_gold_credit_tables.php new file mode 100644 index 0000000..0103b25 --- /dev/null +++ b/database/migrations/2026_08_28_000001_create_gold_credit_tables.php @@ -0,0 +1,79 @@ +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'); + } +}; diff --git a/database/migrations/2026_08_28_000002_add_gold_credit_penalty_and_guarantee.php b/database/migrations/2026_08_28_000002_add_gold_credit_penalty_and_guarantee.php new file mode 100644 index 0000000..4641d15 --- /dev/null +++ b/database/migrations/2026_08_28_000002_add_gold_credit_penalty_and_guarantee.php @@ -0,0 +1,56 @@ +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', + ]); + }); + } +}; diff --git a/database/migrations/2026_08_28_000003_add_gold_credit_upcoming_reminder_columns.php b/database/migrations/2026_08_28_000003_add_gold_credit_upcoming_reminder_columns.php new file mode 100644 index 0000000..d10f50d --- /dev/null +++ b/database/migrations/2026_08_28_000003_add_gold_credit_upcoming_reminder_columns.php @@ -0,0 +1,23 @@ +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']); + }); + } +}; diff --git a/database/migrations/2026_08_29_000010_add_gold_credit_settings.php b/database/migrations/2026_08_29_000010_add_gold_credit_settings.php new file mode 100644 index 0000000..92f6dc2 --- /dev/null +++ b/database/migrations/2026_08_29_000010_add_gold_credit_settings.php @@ -0,0 +1,73 @@ + '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 + } +}; diff --git a/routes/admin.php b/routes/admin.php index abf4b79..df2b3a2 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -22,6 +22,7 @@ use App\Http\Controllers\Admin\PargarController; use App\Http\Controllers\Admin\FileController; use App\Http\Controllers\Site\OfferCodesController; 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\CategoryController; use App\Http\Controllers\Chat\SupportChatNotifyController; @@ -169,6 +170,18 @@ Route::middleware('auth:user')->group(function (){ //-------------wallet Route::post('/wallet/charge',[WalletController::class,'charge']); 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::get('/wallet/charge-targets',[WalletController::class,'chargeTargets']); Route::post('/wallet/table',[WalletController::class,'indexWallet']); diff --git a/routes/customer.php b/routes/customer.php index 71f7073..d0fdb4f 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -1,6 +1,7 @@ group(function () use ( //offer 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) { Route::post('vc_validate', [havaleController::class, 'virtualCardValidate']); Route::post('havale', [havaleController::class, 'havale']); @@ -168,3 +174,4 @@ Route::middleware(['auth:customer', 'customer.active'])->group(function () use ( }); }); + diff --git a/tests/Unit/SuperAdminProgramSettingsOverlayTest.php b/tests/Unit/SuperAdminProgramSettingsOverlayTest.php index a687d2b..b7f018c 100644 --- a/tests/Unit/SuperAdminProgramSettingsOverlayTest.php +++ b/tests/Unit/SuperAdminProgramSettingsOverlayTest.php @@ -16,6 +16,7 @@ class SuperAdminProgramSettingsOverlayTest extends TestCase 'enable_automation', 'enable_conditional_order', 'enable_customer_level_profit', + 'enable_gold_credit', 'enable_product_funds', 'group_sms', 'has_page_earn_money', @@ -28,7 +29,7 @@ class SuperAdminProgramSettingsOverlayTest extends TestCase ]; $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