feat: add manual deposit system, implement Bank model, remove Docker configs, and initialize comprehensive Laravel best practice documentation for AI agents.

This commit is contained in:
pbao9
2026-05-14 16:33:35 +07:00
parent ae3b681aaa
commit 41de3744a8
73 changed files with 5397 additions and 218 deletions
@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Api\Admin\Bank;
use App\Http\Controllers\Controller;
use App\Models\Bank;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class BankController extends Controller
{
public function index(): JsonResponse
{
$banks = Bank::withTrashed()->orderBy('sort_order')->orderBy('id')->get();
return response()->json(['error' => false, 'data' => $banks]);
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'bank_code' => 'required|string|max:20',
'bank_name' => 'required|string|max:100',
'account_no' => 'required|string|max:50',
'account_name' => 'required|string|max:200',
'is_active' => 'boolean',
'sort_order' => 'integer|min:0',
]);
$bank = Bank::create($data);
return response()->json([
'error' => false,
'message' => 'Thêm ngân hàng thành công',
'data' => $bank,
], 201);
}
public function update(Request $request, int $id): JsonResponse
{
$bank = Bank::withTrashed()->findOrFail($id);
$data = $request->validate([
'bank_code' => 'sometimes|string|max:20',
'bank_name' => 'sometimes|string|max:100',
'account_no' => 'sometimes|string|max:50',
'account_name' => 'sometimes|string|max:200',
'is_active' => 'boolean',
'sort_order' => 'integer|min:0',
]);
$bank->update($data);
return response()->json([
'error' => false,
'message' => 'Cập nhật ngân hàng thành công',
'data' => $bank->fresh(),
]);
}
public function destroy(int $id): JsonResponse
{
$bank = Bank::findOrFail($id);
DB::transaction(fn () => $bank->delete());
return response()->json([
'error' => false,
'message' => 'Đã xoá ngân hàng',
]);
}
public function restore(int $id): JsonResponse
{
$bank = Bank::onlyTrashed()->findOrFail($id);
$bank->restore();
return response()->json([
'error' => false,
'message' => 'Đã khôi phục ngân hàng',
'data' => $bank,
]);
}
}
@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Api\Admin\Dashboard;
use App\Enums\TransactionStatus;
use App\Http\Controllers\Controller;
use App\Models\Customer;
use App\Models\Transaction;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function stats(): JsonResponse
{
$total_customers = Customer::count();
$total_deposits = (float) Transaction::where('type', 'manual')
->where('status', TransactionStatus::COMPLETED->value)
->sum('amount');
return response()->json([
'error' => false,
'data' => [
'total_customers' => $total_customers,
'total_deposits' => $total_deposits,
],
]);
}
public function monthlyStats(Request $request): JsonResponse
{
$year = $request->integer('year', now()->year);
$deposits = Transaction::where('type', 'manual')
->where('status', TransactionStatus::COMPLETED->value)
->whereYear('created_at', $year)
->selectRaw('MONTH(created_at) as month, SUM(amount) as total_deposits')
->groupBy('month')
->pluck('total_deposits', 'month');
$newCustomers = Customer::whereYear('created_at', $year)
->selectRaw('MONTH(created_at) as month, COUNT(*) as new_customers')
->groupBy('month')
->pluck('new_customers', 'month');
$months = ['T1', 'T2', 'T3', 'T4', 'T5', 'T6', 'T7', 'T8', 'T9', 'T10', 'T11', 'T12'];
$data = collect(range(1, 12))->map(function ($m) use ($months, $deposits, $newCustomers) {
return [
'month' => $months[$m - 1],
'deposits' => (float) ($deposits[$m] ?? 0),
'new_customers' => (int) ($newCustomers[$m] ?? 0),
];
})->values();
return response()->json([
'error' => false,
'data' => $data,
]);
}
public function latestCustomers(): JsonResponse
{
$customers = Customer::orderByDesc('created_at')
->limit(5)
->get(['id', 'uuid', 'fullname', 'email', 'total', 'created_at']);
return response()->json([
'error' => false,
'data' => [
'items' => $customers->map(fn(Customer $c) => [
'id' => $c->id,
'uuid' => $c->uuid,
'fullname' => $c->fullname,
'email' => $c->email,
'total' => (float) $c->total,
'created_at' => $c->created_at?->toISOString(),
])->values(),
'total' => Customer::count(),
],
]);
}
}
@@ -0,0 +1,176 @@
<?php
namespace App\Http\Controllers\Api\Admin\Transaction;
use App\Enums\TransactionStatus;
use App\Http\Controllers\Controller;
use App\Models\Log;
use App\Models\Transaction;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log as FacadesLog;
use Illuminate\Support\Facades\Storage;
class AdminTransactionController extends Controller
{
public function pendingCount(): JsonResponse
{
$count = Transaction::where('type', 'manual')
->where('status', TransactionStatus::PENDING->value)
->whereNotNull('proof_image')
->count();
return response()->json(['error' => false, 'data' => ['count' => $count]]);
}
public function index(Request $request): JsonResponse
{
$query = Transaction::with('customer')
->where('type', 'manual')
->orderByDesc('created_at');
if ($request->filled('status')) {
$query->where('status', $request->integer('status'));
}
if ($request->filled('filter')) {
$search = $request->string('filter')->trim();
$query->where(function ($q) use ($search) {
$q->where('public_code', 'like', "%{$search}%")
->orWhereHas('customer', fn($c) => $c
->where('fullname', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%"));
});
}
$perPage = $request->integer('per_page', 15);
$paginated = $query->paginate($perPage);
$items = $paginated->map(fn(Transaction $t) => [
'id' => $t->id,
'uuid' => $t->uuid,
'public_code' => $t->public_code,
'trans' => $t->trans,
'amount' => $t->amount,
'type' => $t->type,
'status' => $t->status,
'status_label' => TransactionStatus::from((int) $t->status)->label(),
'proof_image' => $t->proof_image ? Storage::disk('public')->url($t->proof_image) : null,
'created_at' => $t->created_at?->toISOString(),
'customer' => $t->customer ? [
'uuid' => $t->customer->uuid,
'fullname' => $t->customer->fullname,
'email' => $t->customer->email,
'phone' => $t->customer->phone,
'total' => $t->customer->total,
'total_pending' => $t->customer->total_pending,
] : null,
]);
return response()->json([
'error' => false,
'data' => [
'items' => $items,
'total' => $paginated->total(),
'per_page' => $paginated->perPage(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
],
]);
}
public function approve(string $uuid): JsonResponse
{
$transaction = Transaction::with('customer')
->where('uuid', $uuid)
->where('type', 'manual')
->where('status', TransactionStatus::PENDING->value)
->first();
if (!$transaction) {
return response()->json([
'error' => true,
'message' => 'Giao dịch không tồn tại hoặc không thể duyệt',
], 404);
}
DB::beginTransaction();
try {
$transaction->update(['status' => TransactionStatus::COMPLETED->value]);
$customer = $transaction->customer;
$customer->total += $transaction->amount;
$customer->credit += (int) floor($transaction->amount / 100);
$customer->total_pending = max(0, $customer->total_pending - $transaction->amount);
$customer->save();
Log::create([
'agent' => 'admin',
'client_ip' => request()->ip(),
'description' => 'Admin duyệt nạp tiền thủ công: ' . number_format($transaction->amount) . ' VNĐ (+' . floor($transaction->amount / 100) . ' credit)',
'customer_id' => $customer->id,
]);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
FacadesLog::error('AdminTransactionController::approve error: ' . $e->getMessage());
return response()->json([
'error' => true,
'message' => 'Có lỗi xảy ra, vui lòng thử lại',
], 500);
}
return response()->json([
'error' => false,
'message' => 'Đã duyệt giao dịch thành công',
]);
}
public function reject(string $uuid): JsonResponse
{
$transaction = Transaction::with('customer')
->where('uuid', $uuid)
->where('type', 'manual')
->where('status', TransactionStatus::PENDING->value)
->first();
if (!$transaction) {
return response()->json([
'error' => true,
'message' => 'Giao dịch không tồn tại hoặc không thể từ chối',
], 404);
}
DB::beginTransaction();
try {
$transaction->update(['status' => TransactionStatus::FAILED->value]);
$customer = $transaction->customer;
$customer->total_pending = max(0, $customer->total_pending - $transaction->amount);
$customer->save();
Log::create([
'agent' => 'admin',
'client_ip' => request()->ip(),
'description' => 'Admin từ chối nạp tiền thủ công: ' . number_format($transaction->amount) . ' VNĐ',
'customer_id' => $customer->id,
]);
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
FacadesLog::error('AdminTransactionController::reject error: ' . $e->getMessage());
return response()->json([
'error' => true,
'message' => 'Có lỗi xảy ra, vui lòng thử lại',
], 500);
}
return response()->json([
'error' => false,
'message' => 'Đã từ chối giao dịch',
]);
}
}
@@ -0,0 +1,17 @@
<?php
namespace App\Http\Controllers\Api\Client;
use App\Http\Controllers\Controller;
use App\Models\Bank;
use Illuminate\Http\JsonResponse;
class BankController extends Controller
{
public function index(): JsonResponse
{
$banks = Bank::active()->get(['id', 'bank_code', 'bank_name', 'account_no', 'account_name']);
return response()->json(['error' => false, 'data' => $banks]);
}
}
@@ -0,0 +1,216 @@
<?php
namespace App\Http\Controllers\Api\Transaction;
use App\Enums\TransactionStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\DepositRequest;
use App\Http\Services\Telegram;
use App\Models\Log;
use App\Models\Transaction;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ManualDepositController extends Controller
{
/**
* GET /v1/transaction/manual-deposit/pending
* Kiểm tra xem user có GD manual đang chờ duyệt (đã submit proof) không.
*/
public function pending(Request $request): JsonResponse
{
$user = $request->user();
$transaction = Transaction::where('customer_id', $user->id)
->where('type', 'manual')
->where('status', TransactionStatus::PENDING->value)
->whereNotNull('proof_image')
->latest()
->first();
if (!$transaction) {
return response()->json(['error' => false, 'data' => ['has_pending' => false]]);
}
return response()->json([
'error' => false,
'data' => [
'has_pending' => true,
'transaction' => [
'public_code' => $transaction->public_code,
'amount' => $transaction->amount,
'created_at' => $transaction->created_at?->toISOString(),
],
],
]);
}
/**
* POST /v1/transaction/manual-deposit/prepare
* Tạo transaction PENDING và trả về public_code để show trong QR / nội dung CK.
* Client gọi khi chuyển sang bước chọn ngân hàng.
*/
public function prepare(DepositRequest $request): JsonResponse
{
$user = $request->user();
// Chặn nếu đã có GD đã submit đang chờ admin duyệt
$submitted = Transaction::where('customer_id', $user->id)
->where('type', 'manual')
->where('status', TransactionStatus::PENDING->value)
->whereNotNull('proof_image')
->exists();
if ($submitted) {
return response()->json([
'error' => true,
'message' => 'Bạn đang có yêu cầu nạp tiền chờ duyệt. Vui lòng chờ admin xác nhận trước khi tạo yêu cầu mới.',
], 422);
}
// Huỷ transaction prepare cũ (chưa có proof) nếu còn tồn tại
$old = Transaction::where('customer_id', $user->id)
->where('type', 'manual')
->where('status', TransactionStatus::PENDING->value)
->whereNull('proof_image')
->latest()
->first();
if ($old) {
DB::transaction(function () use ($old, $user) {
$user->total_pending = max(0, $user->total_pending - $old->amount);
$user->save();
$old->forceDelete();
});
}
$uuid = Str::uuid()->toString();
$publicCode = 'MAN-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6));
DB::beginTransaction();
try {
Transaction::create([
'uuid' => $uuid,
'public_code' => $publicCode,
'trans' => 'MAN' . time(),
'amount' => $request->amount,
'customer_id' => $user->id,
'type' => 'manual',
'status' => TransactionStatus::PENDING->value,
]);
$user->total_pending += $request->amount;
$user->save();
DB::commit();
} catch (\Exception) {
DB::rollBack();
return response()->json(['error' => true, 'message' => 'Có lỗi xảy ra, vui lòng thử lại'], 500);
}
return response()->json([
'error' => false,
'data' => [
'uuid' => $uuid,
'public_code' => $publicCode,
],
]);
}
/**
* DELETE /v1/transaction/manual-deposit/{uuid}/cancel
* Huỷ transaction đang prepare (chưa có proof, chưa submit).
* Client gọi khi user quay lại step 1 để đổi số tiền.
*/
public function cancel(Request $request, string $uuid): JsonResponse
{
$user = $request->user();
$transaction = Transaction::where('uuid', $uuid)
->where('customer_id', $user->id)
->where('type', 'manual')
->where('status', TransactionStatus::PENDING->value)
->whereNull('proof_image')
->firstOrFail();
DB::transaction(function () use ($transaction, $user) {
$user->total_pending = max(0, $user->total_pending - $transaction->amount);
$user->save();
$transaction->forceDelete();
});
return response()->json(['error' => false, 'message' => 'Đã huỷ yêu cầu']);
}
/**
* POST /v1/transaction/manual-deposit/confirm
* Attach proof image vào transaction đã prepare rồi đánh dấu đã submit.
* Client gọi sau khi upload proof ở step 3.
*/
public function confirm(Request $request): JsonResponse
{
$request->validate([
'uuid' => 'required|string|uuid',
'proof_image' => 'required|string|max:500',
]);
$user = $request->user();
$transaction = Transaction::where('uuid', $request->uuid)
->where('customer_id', $user->id)
->where('type', 'manual')
->where('status', TransactionStatus::PENDING->value)
->whereNull('proof_image')
->first();
if (!$transaction) {
return response()->json([
'error' => true,
'message' => 'Giao dịch không hợp lệ hoặc đã được xử lý',
], 422);
}
$transaction->update(['proof_image' => $request->proof_image]);
try {
(new Telegram())->SendManualDepositPendingApproval($user, $transaction);
} catch (\Exception) {
// Không để lỗi Telegram ảnh hưởng response
}
Log::create([
'agent' => 'api',
'client_ip' => $request->ip(),
'description' => 'Gửi yêu cầu nạp tiền thủ công: ' . number_format($transaction->amount) . ' VNĐ (chờ duyệt)',
'customer_id' => $user->id,
]);
return response()->json([
'error' => false,
'message' => 'Yêu cầu nạp tiền đã được ghi nhận, vui lòng chờ admin xác nhận',
'data' => ['public_code' => $transaction->public_code],
]);
}
/**
* POST /v1/transaction/manual-deposit/upload-proof
* Upload ảnh chứng từ, trả về path để dùng ở bước confirm.
*/
public function uploadProof(Request $request): JsonResponse
{
$request->validate([
'file' => 'required|image|mimes:jpeg,jpg,png|max:5120',
]);
$path = $request->file('file')->store('deposit-proofs', 'public');
return response()->json([
'error' => false,
'url' => Storage::disk('public')->url($path),
'path' => $path,
]);
}
}