Files
portal-api/app/Http/Controllers/Api/Admin/Transaction/AdminTransactionController.php
T
baodev0andClaude Opus 5.5 c5115b52dd feat: nạp tiền thủ công mã 30 phút, gia hạn trừ số dư, đồng bộ domain từ Plesk
- Nạp tiền: mã thanh toán sống 30 phút (ManualDepositDraft), khôi phục/huỷ mã, lệnh dọn mã hết hạn; total_pending chỉ tính giao dịch đã nộp chứng từ, admin duyệt/từ chối/xoá giao dịch
- Gia hạn dịch vụ: phát hành invoice gia hạn trên order gốc và trừ số dư ngay
- Đồng bộ domain từ Plesk theo server, ServiceOrderRecorder tạo order/invoice cho dịch vụ admin dựng
- Danh sách dịch vụ trả field theo loại (service_plan, dns_records_count), đếm dịch vụ theo loại

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:07:51 +07:00

358 lines
15 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Admin\Transaction;
use App\Enums\TransactionStatus;
use App\Http\Controllers\Controller;
use App\Mail\Customer\ManualDepositStatus;
use App\Models\Customer;
use App\Models\Notification;
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\Mail;
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,
// Số thực nhận trên sao kê; null = chưa duyệt. Chênh với `amount` chính là
// phần khách chuyển thiếu/dư so với số đã khai.
'received_amount' => $t->received_amount === null ? null : (int) $t->received_amount,
'note' => $t->note,
'type' => $t->type,
'status' => $t->status,
'status_label' => TransactionStatus::from((int) $t->status)->label(),
'proof_image' => $t->proof_image_urls[0] ?? 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(Request $request, string $uuid): JsonResponse
{
// Số tiền THỰC NHẬN trên sao kê. Bỏ trống = tin số khách khai (hành vi cũ), nhưng
// khi khách chuyển thiếu thì admin nhập đúng số đã về để số dư không bị cộng khống.
$validated = $request->validate([
'received_amount' => 'nullable|integer|min:0',
'note' => 'nullable|string|max:1000',
]);
try {
// Lock the transaction (and customer) row for the whole read-check-write so two
// admins approving the same transaction concurrently cannot both credit the customer.
$transaction = DB::transaction(function () use ($uuid, $validated) {
$transaction = Transaction::where('uuid', $uuid)
->where('type', 'manual')
->lockForUpdate()
->first();
if (!$transaction || (int) $transaction->status !== TransactionStatus::PENDING->value) {
return null;
}
$declared = (int) $transaction->amount;
$received = $validated['received_amount'] ?? $declared;
$shortfall = $declared - $received;
$note = trim(implode(' ', array_filter([
$transaction->note,
$validated['note'] ?? null,
$shortfall !== 0
? sprintf(
'[Lệch %s VNĐ: khai %s, thực nhận %s]',
number_format(abs($shortfall)),
number_format($declared),
number_format($received),
)
: null,
])));
$transaction->update([
'status' => TransactionStatus::COMPLETED->value,
'received_amount' => $received,
'note' => $note !== '' ? $note : null,
]);
$customer = Customer::where('id', $transaction->customer_id)->lockForUpdate()->first();
$creditEarned = Customer::creditFor($received);
$customer->total += $received;
$customer->credit += $creditEarned;
$this->releasePending($customer, $transaction, $declared);
$customer->save();
Log::create([
'agent' => 'admin',
'client_ip' => request()->ip(),
'description' => sprintf(
'Admin duyệt nạp tiền thủ công: thực nhận %s VNĐ (khai %s VNĐ) (+%d credit)%s',
number_format($received),
number_format($declared),
$creditEarned,
$shortfall > 0 ? ' — THIẾU ' . number_format($shortfall) . ' VNĐ' : '',
),
'customer_id' => $customer->id,
]);
Notification::notifyCustomer($customer, [
'type' => 'deposit_success',
'title' => 'Nạp tiền thành công',
'body' => 'Giao dịch ' . $transaction->public_code . ' đã được duyệt.',
'link' => '/lich-su-giao-dich/' . $transaction->uuid,
'data' => [
'transaction_uuid' => $transaction->uuid,
'amount' => $received,
'public_code' => $transaction->public_code,
],
]);
return $transaction->setRelation('customer', $customer);
});
} catch (\Exception $e) {
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);
}
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);
}
$customer = $transaction->customer;
try {
Mail::to($customer->email)->send(new ManualDepositStatus([
'customer_id' => $customer->id,
'customer_email' => $customer->email,
'customer_name' => $customer->fullname,
'transaction_code' => $transaction->public_code,
'amount' => $transaction->received_amount ?? $transaction->amount,
'status_label' => 'Đã duyệt',
'status_color' => '#15803d',
'status_bg' => '#f0fdf4',
'subject' => 'Yêu cầu nạp tiền đã được duyệt - ' . $transaction->public_code,
'status_message' => 'Yêu cầu nạp tiền của bạn đã được duyệt. Số tiền đã được cộng vào tài khoản và có thể sử dụng ngay.',
'deposit_date' => $transaction->created_at?->format('d/m/Y H:i:s') ?? now()->format('d/m/Y H:i:s'),
'processed_date' => now()->format('d/m/Y H:i:s'),
]));
} catch (\Throwable $e) {
FacadesLog::error('Không thể gửi email duyệt nạp tiền thủ công: ' . $e->getMessage(), [
'transaction_code' => $transaction->public_code,
'customer_email' => $customer->email,
]);
}
return response()->json([
'error' => false,
'message' => 'Đã duyệt giao dịch thành công',
]);
}
public function reject(string $uuid): JsonResponse
{
try {
// Same lock-then-verify-status pattern as approve() — prevents a duplicate
// reject (or reject-after-approve) race from double-adjusting total_pending.
$transaction = DB::transaction(function () use ($uuid) {
$transaction = Transaction::where('uuid', $uuid)
->where('type', 'manual')
->lockForUpdate()
->first();
if (!$transaction || (int) $transaction->status !== TransactionStatus::PENDING->value) {
return null;
}
$transaction->update(['status' => TransactionStatus::FAILED->value]);
$customer = Customer::where('id', $transaction->customer_id)->lockForUpdate()->first();
$this->releasePending($customer, $transaction, (int) $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,
]);
return $transaction->setRelation('customer', $customer);
});
} catch (\Exception $e) {
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);
}
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);
}
$customer = $transaction->customer;
try {
Mail::to($customer->email)->send(new ManualDepositStatus([
'customer_id' => $customer->id,
'customer_email' => $customer->email,
'customer_name' => $customer->fullname,
'transaction_code' => $transaction->public_code,
'amount' => $transaction->amount,
'status_label' => 'Không phù hợp',
'status_color' => '#b91c1c',
'status_bg' => '#fef2f2',
'subject' => 'Yêu cầu nạp tiền không phù hợp - ' . $transaction->public_code,
'status_message' => 'Yêu cầu nạp tiền của bạn chưa phù hợp để được duyệt. Vui lòng kiểm tra lại chứng từ hoặc liên hệ bộ phận hỗ trợ để được hướng dẫn thêm.',
'deposit_date' => $transaction->created_at?->format('d/m/Y H:i:s') ?? now()->format('d/m/Y H:i:s'),
'processed_date' => now()->format('d/m/Y H:i:s'),
]));
} catch (\Throwable $e) {
FacadesLog::error('Không thể gửi email từ chối nạp tiền thủ công: ' . $e->getMessage(), [
'transaction_code' => $transaction->public_code,
'customer_email' => $customer->email,
]);
}
return response()->json([
'error' => false,
'message' => 'Đã từ chối giao dịch',
]);
}
public function destroy(string $uuid): JsonResponse
{
$transaction = DB::transaction(function () use ($uuid) {
$transaction = Transaction::where('uuid', $uuid)
->where('type', 'manual')
->lockForUpdate()
->first();
if (! $transaction) {
return null;
}
if ((int) $transaction->status === TransactionStatus::PENDING->value) {
$customer = Customer::where('id', $transaction->customer_id)->lockForUpdate()->first();
if ($customer) {
$this->releasePending($customer, $transaction, (int) $transaction->amount);
$customer->save();
}
}
$transaction->delete();
Log::create([
'agent' => 'admin',
'client_ip' => request()->ip(),
'description' => sprintf(
'Admin xoá giao dịch nạp tiền thủ công %s: %s VNĐ (%s)',
$transaction->public_code,
number_format($transaction->amount),
TransactionStatus::from((int) $transaction->status)->label(),
),
'customer_id' => $transaction->customer_id,
]);
return $transaction;
});
if (! $transaction) {
return response()->json(['error' => true, 'message' => 'Giao dịch không tồn tại'], 404);
}
return response()->json(['error' => false, 'message' => 'Đã xoá giao dịch']);
}
private function releasePending(Customer $customer, Transaction $transaction, int $amount): void
{
if ($transaction->proof_image) {
$customer->total_pending = max(0, $customer->total_pending - $amount);
}
}
public function proofImage(Transaction $transaction, int $index = 0)
{
abort_unless($transaction->proof_image, 404);
$paths = json_decode((string) $transaction->proof_image, true);
$paths = is_array($paths) ? array_values($paths) : [$transaction->proof_image];
$path = $paths[$index] ?? null;
abort_unless(is_string($path) && $path !== '', 404);
$disk = Storage::disk('local')->exists($path) ? 'local' : 'public';
abort_unless(Storage::disk($disk)->exists($path), 404);
return Storage::disk($disk)->response($path, null, ['Cache-Control' => 'private, no-store']);
}
}