diff --git a/app/Exports/Reports/ReportRowsExport.php b/app/Exports/Reports/ReportRowsExport.php
new file mode 100644
index 0000000..89a9819
--- /dev/null
+++ b/app/Exports/Reports/ReportRowsExport.php
@@ -0,0 +1,38 @@
+rows;
+ }
+
+ public function headings(): array
+ {
+ return array_map(fn ($field) => $this->labels[$field] ?? $field, $this->fields);
+ }
+
+ public function map($row): array
+ {
+ return array_map(fn ($field) => $row[$field] ?? '', $this->fields);
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/Auth/AuthController.php b/app/Http/Controllers/Api/Admin/Auth/AuthController.php
index a4c8ea3..6d07634 100644
--- a/app/Http/Controllers/Api/Admin/Auth/AuthController.php
+++ b/app/Http/Controllers/Api/Admin/Auth/AuthController.php
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Api\Admin\Auth;
use App\Http\Controllers\Controller;
+use App\Http\Requests\UpdateProfileRequest;
use App\Http\Resources\Api\Admin\AdminUserResource;
use App\Services\ActivityLogger;
use Illuminate\Http\JsonResponse;
@@ -61,6 +62,19 @@ class AuthController extends Controller
]);
}
+ public function updateProfile(UpdateProfileRequest $request): JsonResponse
+ {
+ $user = Auth::guard('admin')->user();
+
+ $user->update($request->validated());
+
+ return response()->json([
+ 'error' => false,
+ 'message' => 'Cập nhật hồ sơ thành công.',
+ 'data' => ['user' => new AdminUserResource($user->fresh())],
+ ]);
+ }
+
public function refresh(): JsonResponse
{
try {
diff --git a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php
index f0db3e7..0ac1687 100644
--- a/app/Http/Controllers/Api/Admin/Customer/CustomerController.php
+++ b/app/Http/Controllers/Api/Admin/Customer/CustomerController.php
@@ -31,6 +31,7 @@ use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use App\Enums\Invoice\InvoiceStatus;
use App\Enums\Order\OrderStatus;
+use App\Enums\TransactionStatus;
use App\Helpers\OrderHelper;
class CustomerController extends Controller
@@ -73,8 +74,6 @@ class CustomerController extends Controller
public function store(CreateCustomerRequest $request): JsonResponse
{
- $request->validated();
-
$existingCustomer = $this->customerRepository->findBy('email', $request->get('email'));
if ($existingCustomer) {
return response()->json([
@@ -85,7 +84,7 @@ class CustomerController extends Controller
try {
DB::beginTransaction();
- $data = $request->all();
+ $data = $request->validated();
$data['password'] = Hash::make($request->password);
$data['email_marketing'] = $request->email_marketing === "on" ? 1 : 0;
@@ -170,11 +169,10 @@ class CustomerController extends Controller
], 404);
}
- $request->validated();
-
try {
DB::beginTransaction();
- $data = $request->except(['_token', 'password']);
+ $data = $request->validated();
+ unset($data['password']);
if (!empty($request->password)) {
$data['password'] = Hash::make($request->password);
@@ -413,10 +411,15 @@ class CustomerController extends Controller
$this->customerRepository->update($customer->id, ['password' => Hash::make($password)]);
DB::commit();
+ Mail::to($customer->email)->send(new \App\Mail\Customer\PasswordResetByAdmin([
+ 'fullname' => $customer->fullname,
+ 'email' => $customer->email,
+ 'password' => $password,
+ ]));
+
return response()->json([
'error' => false,
- 'message' => 'Password reset successfully',
- 'data' => ['new_password' => $password]
+ 'message' => 'Password reset successfully, new password has been sent to customer email',
]);
} catch (\Exception $e) {
DB::rollBack();
@@ -616,12 +619,15 @@ class CustomerController extends Controller
], 404);
}
+ $admin = Auth::guard('admin')->user();
+
try {
DB::beginTransaction();
$amount = (int) $request->amount;
-
- if ($request->action === "+") {
+ $isAdd = $request->action === "+";
+
+ if ($isAdd) {
$this->customerRepository->update($customer->id, [
'credit' => (int) $customer->credit + $amount,
'total' => (int) $customer->total + $amount,
@@ -633,6 +639,22 @@ class CustomerController extends Controller
]);
}
+ Transaction::create([
+ 'uuid' => (string) Str::uuid(),
+ 'trans' => 'ADMIN' . now()->format('YmdHis'),
+ 'amount' => $amount,
+ 'customer_id' => $customer->id,
+ 'user_id' => $admin?->id,
+ 'status' => TransactionStatus::COMPLETED->value,
+ 'type' => $isAdd ? 'admin_add_credit' : 'admin_deduct_credit',
+ 'note' => sprintf(
+ '%s %s tiền trực tiếp cho khách hàng: %s VNĐ',
+ $admin->username ?? $admin->email ?? 'Admin',
+ $isAdd ? 'cộng' : 'trừ',
+ number_format($amount)
+ ),
+ ]);
+
DB::commit();
return response()->json([
diff --git a/app/Http/Controllers/Api/Admin/Dashboard/DashboardController.php b/app/Http/Controllers/Api/Admin/Dashboard/DashboardController.php
index 39b67da..c4b14bf 100644
--- a/app/Http/Controllers/Api/Admin/Dashboard/DashboardController.php
+++ b/app/Http/Controllers/Api/Admin/Dashboard/DashboardController.php
@@ -19,7 +19,7 @@ use Illuminate\Support\Facades\DB;
class DashboardController extends Controller
{
// Các type được tính là "nạp tiền vào ví"
- private const DEPOSIT_TYPES = ['manual', 'vnpay', 'deposit'];
+ private const DEPOSIT_TYPES = ['manual', 'vnpay', 'deposit', 'admin_add_total', 'admin_add_credit'];
/** Map Order status int → string FE */
private function orderStatusLabel(int $status): string
diff --git a/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php b/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php
index 2d763c5..6789062 100644
--- a/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php
+++ b/app/Http/Controllers/Api/Admin/Domain/AdminDomainOrderController.php
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Admin\Domain\ConfirmRegistrationRequest;
use App\Http\Requests\Api\Admin\Domain\RejectAndRefundRequest;
use App\Http\Requests\Api\Admin\Domain\UpdateKycUrlRequest;
+use App\Http\Requests\Api\Admin\Domain\UpdateRegistrantInfoRequest;
use App\Mail\Customer\DomainOrderStatusMail;
use App\Models\Notification;
use App\Models\Order;
@@ -16,16 +17,19 @@ use App\Models\Setting;
use App\Models\Template;
use App\Repositories\Api\Domain\DomainOrderRepository;
use App\Services\Api\Domain\DomainOrderService;
+use App\Services\Api\Domain\DomainRegistrantService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class AdminDomainOrderController extends Controller
{
public function __construct(
- private readonly DomainOrderRepository $repo,
- private readonly DomainOrderService $service,
+ private readonly DomainOrderRepository $repo,
+ private readonly DomainOrderService $service,
+ private readonly DomainRegistrantService $registrantService,
) {}
public function pendingCount(): JsonResponse
@@ -188,6 +192,65 @@ class AdminDomainOrderController extends Controller
}
}
+ public function getRegistrant(int $id): JsonResponse
+ {
+ $order = $this->repo->findDomainOrder($id);
+ $service = $order?->orderItems->first()?->service;
+
+ if (!$order || !$service) {
+ return response()->json([
+ 'error' => true,
+ 'message' => 'Không tìm thấy đơn hàng domain.',
+ ], 404);
+ }
+
+ return response()->json([
+ 'error' => false,
+ 'data' => $this->registrantService->get($service, $order->customer),
+ ]);
+ }
+
+ public function updateRegistrant(UpdateRegistrantInfoRequest $request, int $id): JsonResponse
+ {
+ $order = $this->repo->findDomainOrder($id);
+ $service = $order?->orderItems->first()?->service;
+
+ if (!$order || !$service) {
+ return response()->json([
+ 'error' => true,
+ 'message' => 'Không tìm thấy đơn hàng domain.',
+ ], 404);
+ }
+
+ $data = $request->validated();
+ $service = $this->registrantService->update($service, $data['registrant_type'], $data['registrant_info']);
+
+ return response()->json([
+ 'error' => false,
+ 'message' => 'Cập nhật thông tin đăng ký thành công.',
+ 'data' => $this->formatService($service),
+ ]);
+ }
+
+ public function downloadRegistrantDocx(int $id): Response
+ {
+ $order = $this->repo->findDomainOrder($id);
+ $item = $order?->orderItems->first();
+ $service = $item?->service;
+
+ abort_if(!$order || !$service, 404, 'Không tìm thấy đơn hàng domain.');
+
+ $registrant = $this->registrantService->get($service, $order->customer);
+ $docx = $this->registrantService->renderDocx($item->domain_name, $registrant['type'], $registrant['info']);
+
+ $filename = 'phieu-dang-ky-' . $item->domain_name . '.docx';
+
+ return response($docx, 200, [
+ 'Content-Type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+ 'Content-Disposition' => 'attachment; filename="' . $filename . '"',
+ ]);
+ }
+
// =========================================================================
// Private helpers
// =========================================================================
@@ -280,6 +343,8 @@ class AdminDomainOrderController extends Controller
'registrar_reference' => $service->registrar_reference,
'kyc_url' => $service->kyc_url,
'rejection_reason' => $service->rejection_reason,
+ 'registrant_type' => $service->registrant_type,
+ 'registrant_info' => $service->registrant_info,
'auto_renew' => $service->auto_renew,
'started_at' => $service->started_at?->toIso8601String(),
'expires_at' => $service->expires_at?->toIso8601String(),
diff --git a/app/Http/Controllers/Api/Admin/Domain/AdminDomainPricingController.php b/app/Http/Controllers/Api/Admin/Domain/AdminDomainPricingController.php
index f1c492d..69ce3aa 100644
--- a/app/Http/Controllers/Api/Admin/Domain/AdminDomainPricingController.php
+++ b/app/Http/Controllers/Api/Admin/Domain/AdminDomainPricingController.php
@@ -5,85 +5,67 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin\Domain;
use App\Http\Controllers\Controller;
-use App\Models\Setting;
+use App\Models\DomainPricing;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class AdminDomainPricingController extends Controller
{
- /**
- * Default domain pricing data (used as fallback and for seeding)
- */
- private function getDefaultData(): array
- {
- return [
- 'domains' => [
- ['tld' => '.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 400000, 'renewal' => 400000],
- ['tld' => '.com.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000],
- ['tld' => '.net.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000],
- ['tld' => '.org.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000],
- ['tld' => '.com', 'setupFee' => 'Miễn phí', 'firstYear' => 320000, 'renewal' => 340000],
- ['tld' => '.net', 'setupFee' => 'Miễn phí', 'firstYear' => 369000, 'renewal' => 409000],
- ['tld' => '.org', 'setupFee' => 'Miễn phí', 'firstYear' => 339000, 'renewal' => 369000],
- ['tld' => '.biz', 'setupFee' => 'Miễn phí', 'firstYear' => 399000, 'renewal' => 409000],
- ['tld' => '.info', 'setupFee' => 'Miễn phí', 'firstYear' => 490000, 'renewal' => 509000],
- ['tld' => '.pro', 'setupFee' => 'Miễn phí', 'firstYear' => 469000, 'renewal' => 489000],
- ['tld' => '.name', 'setupFee' => 'Miễn phí', 'firstYear' => 259000, 'renewal' => 259000],
- ['tld' => '.ws', 'setupFee' => 'Miễn phí', 'firstYear' => 909000, 'renewal' => 909000],
- ['tld' => '.tv', 'setupFee' => 'Miễn phí', 'firstYear' => 765000, 'renewal' => 819000],
- ['tld' => '.mobi', 'setupFee' => 'Miễn phí', 'firstYear' => 589000, 'renewal' => 589000],
- ['tld' => '.uk', 'setupFee' => 'Miễn phí', 'firstYear' => 537000, 'renewal' => 537000],
- ['tld' => '.asia', 'setupFee' => 'Miễn phí', 'firstYear' => 380000, 'renewal' => 452000],
- ['tld' => '.me', 'setupFee' => 'Miễn phí', 'firstYear' => 1029000, 'renewal' => 1029000],
- ['tld' => '.us', 'setupFee' => 'Miễn phí', 'firstYear' => 323000, 'renewal' => 323000],
- ],
- 'services' => [
- ['name' => 'Chứng thực Rapid SSL', 'setupFee' => 230000, 'maintenance' => 750000, 'note' => '01 Domain'],
- ['name' => 'Chứng thực Quick SSL', 'setupFee' => 230000, 'maintenance' => 2300000, 'note' => '01 Domain'],
- ['name' => 'Chứng thực Wildcard SSL', 'setupFee' => 230000, 'maintenance' => 3300000, 'note' => 'Unlimited Subdomain'],
- ['name' => 'Chứng thực True BusinessID', 'setupFee' => 540000, 'maintenance' => 3900000, 'note' => '01 Domain'],
- ['name' => 'Chứng thực True BusinessID WildCard', 'setupFee' => 900000, 'maintenance' => 15000000, 'note' => 'Unlimited Subdomain'],
- ['name' => 'Đăng ký DNS riêng', 'setupFee' => 200000, 'maintenance' => 'Miễn phí', 'note' => "dns1.tenmiencuaban.com\ndns2.tenmiencuaban.com"],
- ['name' => 'Bảo mật thông tin tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 90000, 'note' => 'Toàn bộ thông tin Whois tên miền của bạn sẽ được giấu kín hoàn toàn với mọi công cụ Whois'],
- ['name' => 'Khóa tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 'Miễn phí', 'note' => 'Tăng cường bảo mật cho tên miền'],
- ],
- ];
- }
+ private const DEFAULT_DOMAINS = [
+ ['tld' => '.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 400000, 'renewal' => 400000],
+ ['tld' => '.com.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000],
+ ['tld' => '.net.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000],
+ ['tld' => '.org.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000],
+ ['tld' => '.com', 'setupFee' => 'Miễn phí', 'firstYear' => 320000, 'renewal' => 340000],
+ ['tld' => '.net', 'setupFee' => 'Miễn phí', 'firstYear' => 369000, 'renewal' => 409000],
+ ['tld' => '.org', 'setupFee' => 'Miễn phí', 'firstYear' => 339000, 'renewal' => 369000],
+ ['tld' => '.biz', 'setupFee' => 'Miễn phí', 'firstYear' => 399000, 'renewal' => 409000],
+ ['tld' => '.info', 'setupFee' => 'Miễn phí', 'firstYear' => 490000, 'renewal' => 509000],
+ ['tld' => '.pro', 'setupFee' => 'Miễn phí', 'firstYear' => 469000, 'renewal' => 489000],
+ ['tld' => '.name', 'setupFee' => 'Miễn phí', 'firstYear' => 259000, 'renewal' => 259000],
+ ['tld' => '.ws', 'setupFee' => 'Miễn phí', 'firstYear' => 909000, 'renewal' => 909000],
+ ['tld' => '.tv', 'setupFee' => 'Miễn phí', 'firstYear' => 765000, 'renewal' => 819000],
+ ['tld' => '.mobi', 'setupFee' => 'Miễn phí', 'firstYear' => 589000, 'renewal' => 589000],
+ ['tld' => '.uk', 'setupFee' => 'Miễn phí', 'firstYear' => 537000, 'renewal' => 537000],
+ ['tld' => '.asia', 'setupFee' => 'Miễn phí', 'firstYear' => 380000, 'renewal' => 452000],
+ ['tld' => '.me', 'setupFee' => 'Miễn phí', 'firstYear' => 1029000, 'renewal' => 1029000],
+ ['tld' => '.us', 'setupFee' => 'Miễn phí', 'firstYear' => 323000, 'renewal' => 323000],
+ ];
+
+ private const DEFAULT_SERVICES = [
+ ['name' => 'Chứng thực Rapid SSL', 'setupFee' => 230000, 'maintenance' => 750000, 'note' => '01 Domain'],
+ ['name' => 'Chứng thực Quick SSL', 'setupFee' => 230000, 'maintenance' => 2300000, 'note' => '01 Domain'],
+ ['name' => 'Chứng thực Wildcard SSL', 'setupFee' => 230000, 'maintenance' => 3300000, 'note' => 'Unlimited Subdomain'],
+ ['name' => 'Chứng thực True BusinessID', 'setupFee' => 540000, 'maintenance' => 3900000, 'note' => '01 Domain'],
+ ['name' => 'Chứng thực True BusinessID WildCard', 'setupFee' => 900000, 'maintenance' => 15000000, 'note' => 'Unlimited Subdomain'],
+ ['name' => 'Đăng ký DNS riêng', 'setupFee' => 200000, 'maintenance' => 'Miễn phí', 'note' => "dns1.tenmiencuaban.com\ndns2.tenmiencuaban.com"],
+ ['name' => 'Bảo mật thông tin tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 90000, 'note' => 'Toàn bộ thông tin Whois tên miền của bạn sẽ được giấu kín hoàn toàn với mọi công cụ Whois'],
+ ['name' => 'Khóa tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 'Miễn phí', 'note' => 'Tăng cường bảo mật cho tên miền'],
+ ];
/**
* GET /administrator/domain-pricing
*/
public function index(): JsonResponse
{
- $setting = Setting::where('key', 'domain_pricing')->first();
-
- if ($setting && !empty($setting->value)) {
- $value = json_decode((string) $setting->value, true);
- if (is_array($value) && !empty($value)) {
- return response()->json([
- 'error' => false,
- 'data' => $value,
- 'message' => 'Lấy bảng giá tên miền thành công.',
- ]);
- }
- }
-
return response()->json([
'error' => false,
- 'data' => $this->getDefaultData(),
- 'message' => 'Lấy bảng giá tên miền thành công (mặc định).',
+ 'data' => DomainPricing::toResponseArray(),
+ 'message' => 'Lấy bảng giá tên miền thành công.',
]);
}
/**
* PUT /administrator/domain-pricing
+ * Full replace of domains/services, mirroring the admin table's "save whole blob" UX.
*/
public function update(Request $request): JsonResponse
{
$validator = Validator::make($request->all(), [
- 'domains' => 'sometimes|array',
- 'services' => 'sometimes|array',
+ 'domains' => 'sometimes|array',
+ 'services' => 'sometimes|array',
]);
if ($validator->fails()) {
@@ -93,25 +75,22 @@ class AdminDomainPricingController extends Controller
], 422);
}
- $current = Setting::where('key', 'domain_pricing')->first();
- $existing = $current ? json_decode((string) $current->value, true) : $this->getDefaultData();
+ DB::transaction(function () use ($request) {
+ if ($request->has('domains')) {
+ DomainPricing::domains()->delete();
+ $this->insertDomains($request->input('domains'));
+ }
- if ($request->has('domains')) {
- $existing['domains'] = $request->input('domains');
- }
- if ($request->has('services')) {
- $existing['services'] = $request->input('services');
- }
-
- Setting::updateOrCreate(
- ['key' => 'domain_pricing'],
- ['value' => json_encode($existing, JSON_UNESCAPED_UNICODE)]
- );
+ if ($request->has('services')) {
+ DomainPricing::services()->delete();
+ $this->insertServices($request->input('services'));
+ }
+ });
return response()->json([
'error' => false,
'message' => 'Cập nhật bảng giá tên miền thành công.',
- 'data' => $existing,
+ 'data' => DomainPricing::toResponseArray(),
]);
}
@@ -121,17 +100,44 @@ class AdminDomainPricingController extends Controller
*/
public function reset(): JsonResponse
{
- $default = $this->getDefaultData();
-
- Setting::updateOrCreate(
- ['key' => 'domain_pricing'],
- ['value' => json_encode($default, JSON_UNESCAPED_UNICODE)]
- );
+ DB::transaction(function () {
+ DomainPricing::query()->delete();
+ $this->insertDomains(self::DEFAULT_DOMAINS);
+ $this->insertServices(self::DEFAULT_SERVICES);
+ });
return response()->json([
'error' => false,
'message' => 'Đã reset bảng giá tên miền về mặc định.',
- 'data' => $default,
+ 'data' => DomainPricing::toResponseArray(),
]);
}
+
+ private function insertDomains(array $domains): void
+ {
+ foreach (array_values($domains) as $i => $d) {
+ DomainPricing::create([
+ 'type' => 'domain',
+ 'tld' => $d['tld'] ?? null,
+ 'setup_fee' => (string) ($d['setupFee'] ?? 'Miễn phí'),
+ 'first_year' => (int) ($d['firstYear'] ?? 0),
+ 'recurring_fee' => (string) ($d['renewal'] ?? 0),
+ 'sort_order' => $i,
+ ]);
+ }
+ }
+
+ private function insertServices(array $services): void
+ {
+ foreach (array_values($services) as $i => $s) {
+ DomainPricing::create([
+ 'type' => 'service',
+ 'name' => $s['name'] ?? null,
+ 'setup_fee' => (string) ($s['setupFee'] ?? 'Miễn phí'),
+ 'recurring_fee' => (string) ($s['maintenance'] ?? 'Miễn phí'),
+ 'note' => $s['note'] ?? null,
+ 'sort_order' => $i,
+ ]);
+ }
+ }
}
diff --git a/app/Http/Controllers/Api/Admin/Invoice/InvoiceTestController.php b/app/Http/Controllers/Api/Admin/Invoice/InvoiceTestController.php
deleted file mode 100644
index 2ea4c58..0000000
--- a/app/Http/Controllers/Api/Admin/Invoice/InvoiceTestController.php
+++ /dev/null
@@ -1,131 +0,0 @@
-where('invoice_number', $invoiceNumber)
- ->first();
-
- if (!$invoice) {
- return response()->json([
- 'success' => false,
- 'message' => 'Không tìm thấy hóa đơn.',
- ], 404);
- }
-
- $customer = $invoice->customer;
- if (!$customer?->email) {
- return response()->json([
- 'success' => false,
- 'message' => 'Khách hàng không có email.',
- ], 400);
- }
-
- try {
- $pdfService = app(InvoicePdfService::class);
- $invoicePdf = $pdfService->generate($invoice);
- $invoicePdfFileName = $pdfService->generateFileName($invoice);
- } catch (\Throwable $e) {
- return response()->json([
- 'success' => false,
- 'message' => 'Lỗi tạo PDF: ' . $e->getMessage(),
- ], 500);
- }
-
- $vm = ModuleVmware::with(['customer', 'os.osGroup', 'order.orderItems'])
- ->where('order_id', $invoice->order_id)
- ->first();
-
- if ($vm) {
- SendVmCredentialsEmailJob::dispatch($vm->id);
-
- return response()->json([
- 'success' => true,
- 'message' => 'Đã dispatch SendVmCredentialsEmailJob (có PDF hóa đơn đính kèm).',
- 'data' => [
- 'invoice_id' => $invoice->id,
- 'invoice_number' => $invoice->invoice_number,
- 'customer_email' => $customer->email,
- 'vm_id' => $vm->id,
- 'pdf_file_name' => $invoicePdfFileName,
- ],
- ]);
- }
-
- $setting = Setting::where('key', 'setting')->first();
- $settingValue = $setting ? json_decode((string) $setting->value) : null;
-
- $emailData = [
- 'company_name' => $settingValue->company ?? 'Thế Giới Số',
- 'company_logo' => $settingValue->logo ?? '',
- 'client_name' => trim($customer->firstname . ' ' . $customer->lastname),
- 'product_name' => $invoice->invoiceItems->first()?->product?->name ?? 'Dịch vụ',
- 'product_billing' => 'N/A',
- 'product_price' => number_format((float) $invoice->total, 0, ',', '.'),
- 'service_host' => 'N/A',
- 'service_username' => 'N/A',
- 'service_port' => 'N/A',
- 'service_password' => 'N/A',
- 'os_name' => 'N/A',
- 'service_due_exp' => $invoice->due_date ? $invoice->due_date->format('d/m/Y') : 'N/A',
- ];
-
- SendInformationVirtualMachine::dispatch($emailData, $customer, $invoicePdf, $invoicePdfFileName);
-
- return response()->json([
- 'success' => true,
- 'message' => 'Đã gửi email hóa đơn (không có VM liên kết, sử dụng thông tin hóa đơn).',
- 'data' => [
- 'invoice_id' => $invoice->id,
- 'invoice_number' => $invoice->invoice_number,
- 'customer_email' => $customer->email,
- 'pdf_file_name' => $invoicePdfFileName,
- ],
- ]);
- }
-
- public function previewInvoicePdf(Request $request, string $invoiceNumber)
- {
- $invoice = Invoice::with(['invoiceItems.product', 'customer', 'order.orderItems'])
- ->where('invoice_number', $invoiceNumber)
- ->first();
-
- if (!$invoice) {
- return response()->json([
- 'success' => false,
- 'message' => 'Không tìm thấy hóa đơn.',
- ], 404);
- }
-
- $setting = Setting::where('key', 'setting')->first();
- $settingValue = $setting ? json_decode((string) $setting->value) : null;
-
- return Pdf::loadView('pdf.invoice', [
- 'invoice' => $invoice,
- 'invoiceItems' => $invoice->invoiceItems,
- 'customer' => $invoice->customer,
- 'order' => $invoice->order,
- 'companyName' => $settingValue->company ?? 'Thế Giới Số',
- 'companyAddress' => $settingValue->address ?? '',
- 'companyPhone' => $settingValue->phone ?? '',
- 'companyEmail' => $settingValue->email ?? '',
- ])
- ->setPaper('a4', 'portrait')
- ->stream($invoice->invoice_number . '.pdf');
- }
-}
diff --git a/app/Http/Controllers/Api/Admin/Payment/PaymentController.php b/app/Http/Controllers/Api/Admin/Payment/PaymentController.php
index 06901a0..4fdf516 100644
--- a/app/Http/Controllers/Api/Admin/Payment/PaymentController.php
+++ b/app/Http/Controllers/Api/Admin/Payment/PaymentController.php
@@ -43,11 +43,9 @@ class PaymentController extends Controller
public function store(PaymentRequest $request): JsonResponse
{
- $request->validated();
-
try {
DB::beginTransaction();
- $payment = $this->paymentRepository->create($request->all());
+ $payment = $this->paymentRepository->create($request->validated());
DB::commit();
return response()->json([
diff --git a/app/Http/Controllers/Api/Admin/Report/ExportController.php b/app/Http/Controllers/Api/Admin/Report/ExportController.php
new file mode 100644
index 0000000..d457ae5
--- /dev/null
+++ b/app/Http/Controllers/Api/Admin/Report/ExportController.php
@@ -0,0 +1,313 @@
+validate([
+ 'type' => 'required|string',
+ 'filters' => 'array',
+ 'fields' => 'array',
+ ]);
+
+ $query = $this->baseQuery($data['type'], $data['filters'] ?? []);
+ $total = (clone $query)->count();
+ $items = $query->limit(self::PREVIEW_LIMIT)->get()
+ ->map(fn ($model) => $this->rowValues($model, $data['type']));
+
+ return response()->json([
+ 'error' => false,
+ 'data' => ['items' => $items, 'total' => $total],
+ ]);
+ }
+
+ public function store(Request $request): JsonResponse
+ {
+ $data = $request->validate([
+ 'type' => 'required|string',
+ 'format' => 'required|in:csv,xlsx',
+ 'filters' => 'array',
+ 'fields' => 'array',
+ ]);
+
+ $labels = $this->fieldLabels($data['type']);
+ $fields = !empty($data['fields']) ? $data['fields'] : array_keys($labels);
+
+ $rows = $this->baseQuery($data['type'], $data['filters'] ?? [])->get()
+ ->map(fn ($model) => $this->rowValues($model, $data['type']));
+
+ $uuid = (string) Str::uuid();
+ $filename = "exports/{$data['type']}_{$uuid}.{$data['format']}";
+ $writerType = $data['format'] === 'xlsx' ? ExcelWriter::XLSX : ExcelWriter::CSV;
+
+ Excel::store(new ReportRowsExport($rows, $fields, $labels), $filename, 'local', $writerType);
+
+ $job = ExportJob::create([
+ 'uuid' => $uuid,
+ 'type' => $data['type'],
+ 'format' => $data['format'],
+ 'filters' => $data['filters'] ?? [],
+ 'fields' => $fields,
+ 'status' => 'completed',
+ 'file_path' => $filename,
+ 'total_rows' => $rows->count(),
+ 'created_by' => auth('admin')->id(),
+ 'completed_at' => now(),
+ ]);
+
+ return response()->json(['error' => false, 'data' => ['id' => $job->uuid]]);
+ }
+
+ public function show(string $uuid): JsonResponse
+ {
+ $job = ExportJob::where('uuid', $uuid)->firstOrFail();
+
+ return response()->json([
+ 'error' => false,
+ 'data' => [
+ 'id' => $job->uuid,
+ 'type' => $job->type,
+ 'format' => $job->format,
+ 'status' => $job->status,
+ 'progress' => $job->status === 'completed' ? 100 : 0,
+ 'processed' => $job->total_rows ?? 0,
+ 'total' => $job->total_rows ?? 0,
+ 'error' => $job->error,
+ ],
+ ]);
+ }
+
+ public function history(Request $request): JsonResponse
+ {
+ $perPage = min((int) $request->get('per_page', 15), 100);
+
+ $query = ExportJob::query()->with('creator')->latest();
+
+ if ($request->filled('filter')) {
+ $query->where('type', 'like', '%' . $request->get('filter') . '%');
+ }
+ if ($request->filled('status')) {
+ $query->where('status', $request->get('status'));
+ }
+
+ $paginator = $query->paginate($perPage);
+
+ return response()->json([
+ 'error' => false,
+ 'data' => [
+ 'items' => $paginator->getCollection()->map(fn (ExportJob $job) => [
+ 'id' => $job->uuid,
+ 'type' => $job->type,
+ 'format' => $job->format,
+ 'records' => $job->total_rows ?? 0,
+ 'created_by' => $job->creator?->username ?? '—',
+ 'created_at' => $job->created_at?->toIso8601String(),
+ 'status' => $job->status,
+ ]),
+ 'total' => $paginator->total(),
+ ],
+ ]);
+ }
+
+ public function download(string $uuid)
+ {
+ $job = ExportJob::where('uuid', $uuid)->firstOrFail();
+ abort_unless($job->status === 'completed' && $job->file_path, 404);
+
+ return Storage::disk('local')->download($job->file_path, basename($job->file_path));
+ }
+
+ private function baseQuery(string $type, array $filters): Builder
+ {
+ return match ($type) {
+ 'client', 'clients' => Customer::query()
+ ->when($type === 'client', fn (Builder $q) => $q->where('id', $filters['client_id'] ?? 0))
+ ->when(!empty($filters['date_from']), fn (Builder $q) => $q->whereDate('created_at', '>=', $filters['date_from']))
+ ->when(!empty($filters['date_to']), fn (Builder $q) => $q->whereDate('created_at', '<=', $filters['date_to'])),
+
+ 'domains' => DnsDomain::query()
+ ->with(['customer', 'server', 'ns'])
+ ->when(!empty($filters['date_from']), fn (Builder $q) => $q->whereDate('created_at', '>=', $filters['date_from']))
+ ->when(!empty($filters['date_to']), fn (Builder $q) => $q->whereDate('created_at', '<=', $filters['date_to'])),
+
+ 'invoices' => Invoice::query()
+ ->when(!empty($filters['date_from']), fn (Builder $q) => $q->whereDate('created_at', '>=', $filters['date_from']))
+ ->when(!empty($filters['date_to']), fn (Builder $q) => $q->whereDate('created_at', '<=', $filters['date_to']))
+ ->when(!empty($filters['invoice_status']), fn (Builder $q) => $q->where(
+ 'status',
+ $filters['invoice_status'] === 'paid' ? InvoiceStatus::PAID->value : InvoiceStatus::UNPAID->value
+ )),
+
+ 'services' => Service::query()
+ ->with(['product', 'orderItem'])
+ ->when(!empty($filters['date_from']), fn (Builder $q) => $q->whereDate('created_at', '>=', $filters['date_from']))
+ ->when(!empty($filters['date_to']), fn (Builder $q) => $q->whereDate('created_at', '<=', $filters['date_to'])),
+
+ 'transactions' => Transaction::query()
+ ->when(!empty($filters['date_from']), fn (Builder $q) => $q->whereDate('created_at', '>=', $filters['date_from']))
+ ->when(!empty($filters['date_to']), fn (Builder $q) => $q->whereDate('created_at', '<=', $filters['date_to'])),
+
+ default => throw new \InvalidArgumentException("Loại xuất dữ liệu không được hỗ trợ: {$type}"),
+ };
+ }
+
+ private function rowValues($model, string $type): array
+ {
+ return match ($type) {
+ 'client', 'clients' => [
+ 'id' => $model->id,
+ 'name' => $model->fullname,
+ 'company' => $model->company,
+ 'email' => $model->email,
+ 'phone' => $model->phone,
+ 'address' => $model->address,
+ 'city' => $model->city,
+ 'state' => $model->state,
+ 'postcode' => $model->postcode,
+ 'country' => $model->country,
+ 'status' => match ((int) $model->status) {
+ 1 => 'Actived',
+ 2 => 'De-actived',
+ default => 'Banned',
+ },
+ 'created_at' => optional($model->created_at)->format('Y-m-d H:i'),
+ ],
+
+ 'domains' => [
+ 'domain' => $model->domain_name,
+ 'customer' => $model->customer
+ ->map(fn ($c) => $c->email ? "{$c->fullname} ({$c->email})" : $c->fullname)
+ ->filter()
+ ->implode(', '),
+ 'server' => $model->server?->name,
+ 'nameservers' => $model->ns->pluck('value')->filter()->implode(', '),
+ 'status' => match ((int) $model->is_created) {
+ 1 => 'Đã khởi tạo',
+ 2 => 'Không hoạt động',
+ default => 'Chưa khởi tạo',
+ },
+ 'created_at' => optional($model->created_at)->format('Y-m-d H:i'),
+ ],
+
+ 'invoices' => [
+ 'id' => $model->id,
+ 'client_id' => $model->customer_id,
+ 'invoice_number' => $model->invoice_number,
+ 'date' => optional($model->created_at)->format('Y-m-d'),
+ 'due_date' => optional($model->due_date)->format('Y-m-d'),
+ 'subtotal' => $model->sub_total,
+ 'tax' => $model->tax,
+ 'total' => $model->total,
+ 'status' => InvoiceStatus::tryFrom((int) $model->status)?->label() ?? $model->status,
+ 'payment_method' => $model->payment_method,
+ ],
+
+ 'services' => [
+ 'id' => $model->id,
+ 'client_id' => $model->customer_id,
+ 'product_id' => $model->product_id,
+ 'product_name' => $model->product?->name,
+ 'domain' => $model->server_name,
+ 'billing_cycle' => $model->orderItem?->billing_cycle,
+ 'amount' => $model->orderItem?->price,
+ 'next_due_date' => optional($model->expires_at)->format('Y-m-d'),
+ 'status' => $model->status,
+ ],
+
+ 'transactions' => [
+ 'id' => $model->id,
+ 'client_id' => $model->customer_id,
+ 'date' => optional($model->created_at)->format('Y-m-d H:i'),
+ 'description' => $model->trans,
+ 'amount' => $model->amount,
+ 'gateway' => $model->type,
+ 'status' => TransactionStatus::tryFrom((int) $model->status)?->label() ?? $model->status,
+ ],
+
+ default => [],
+ };
+ }
+
+ private function fieldLabels(string $type): array
+ {
+ return match ($type) {
+ 'client', 'clients' => [
+ 'id' => 'Client ID',
+ 'name' => 'Name',
+ 'company' => 'Company',
+ 'email' => 'Email',
+ 'phone' => 'Phone',
+ 'address' => 'Address',
+ 'city' => 'City',
+ 'state' => 'State',
+ 'postcode' => 'Postcode',
+ 'country' => 'Country',
+ 'status' => 'Status',
+ 'created_at' => 'Created At',
+ ],
+ 'domains' => [
+ 'domain' => 'Domain',
+ 'customer' => 'Khách hàng',
+ 'server' => 'DNS Server',
+ 'nameservers' => 'NS',
+ 'status' => 'Trạng thái',
+ 'created_at' => 'Ngày tạo',
+ ],
+ 'invoices' => [
+ 'id' => 'Invoice ID',
+ 'client_id' => 'Client ID',
+ 'invoice_number' => 'Invoice Number',
+ 'date' => 'Date',
+ 'due_date' => 'Due Date',
+ 'subtotal' => 'Subtotal',
+ 'tax' => 'Tax',
+ 'total' => 'Total',
+ 'status' => 'Status',
+ 'payment_method' => 'Payment Method',
+ ],
+ 'services' => [
+ 'id' => 'Service ID',
+ 'client_id' => 'Client ID',
+ 'product_id' => 'Product ID',
+ 'product_name' => 'Product Name',
+ 'domain' => 'Domain',
+ 'billing_cycle' => 'Billing Cycle',
+ 'amount' => 'Amount',
+ 'next_due_date' => 'Next Due Date',
+ 'status' => 'Status',
+ ],
+ 'transactions' => [
+ 'id' => 'Transaction ID',
+ 'client_id' => 'Client ID',
+ 'date' => 'Date',
+ 'description' => 'Description',
+ 'amount' => 'Amount',
+ 'gateway' => 'Gateway',
+ 'status' => 'Status',
+ ],
+ default => [],
+ };
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/Role/RolePermissionController.php b/app/Http/Controllers/Api/Admin/Role/RolePermissionController.php
index e619c07..22b6601 100644
--- a/app/Http/Controllers/Api/Admin/Role/RolePermissionController.php
+++ b/app/Http/Controllers/Api/Admin/Role/RolePermissionController.php
@@ -103,6 +103,41 @@ class RolePermissionController extends Controller
]);
}
+ /**
+ * POST /v1/administrator/role-permissions
+ * Tạo một vai trò mới (chỉ Full Administrator)
+ */
+ public function store(Request $request): JsonResponse
+ {
+ if (!$this->ensureFullAdmin()) {
+ return response()->json(['error' => true, 'message' => 'Bạn không có quyền truy cập'], 403);
+ }
+
+ $name = trim($request->input('name', ''));
+ if (!$name) {
+ return response()->json(['error' => true, 'message' => 'Tên vai trò không được trống'], 422);
+ }
+
+ if ($name === 'Full Administrator') {
+ return response()->json(['error' => true, 'message' => 'Tên vai trò không hợp lệ'], 422);
+ }
+
+ if (Role::where('guard_name', 'admin')->where('name', $name)->exists()) {
+ return response()->json(['error' => true, 'message' => "Vai trò \"{$name}\" đã tồn tại"], 422);
+ }
+
+ $role = Role::create(['name' => $name, 'guard_name' => 'admin']);
+
+ return response()->json([
+ 'error' => false,
+ 'data' => [
+ 'id' => $role->id,
+ 'name' => $role->name,
+ 'permissions' => [],
+ ],
+ ], 201);
+ }
+
/**
* POST /v1/administrator/role-permissions/permissions
* Tạo một nhóm permissions mới theo module (chỉ Full Administrator)
diff --git a/app/Http/Controllers/Api/Client/Domain/DomainOrderController.php b/app/Http/Controllers/Api/Client/Domain/DomainOrderController.php
index 303f9ad..d24eb54 100644
--- a/app/Http/Controllers/Api/Client/Domain/DomainOrderController.php
+++ b/app/Http/Controllers/Api/Client/Domain/DomainOrderController.php
@@ -7,9 +7,10 @@ namespace App\Http\Controllers\Api\Client\Domain;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Client\Domain\StoreDomainOrderRequest;
use App\Mail\Admin\DomainOrderNewNotification;
+use App\Mail\Customer\DomainOrderReceivedNotification;
+use App\Models\Customer;
use App\Models\Notification;
use App\Models\Setting;
-use App\Models\Template;
use App\Services\Api\Domain\DomainOrderService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
@@ -26,7 +27,7 @@ class DomainOrderController extends Controller
try {
$result = $this->domainOrderService->placeOrder($request->validated(), $request->user());
- $this->notifyAdminNewDomainOrder($request->user(), $result);
+ $this->notifyDomainOrderPlaced($request->user(), $result);
return response()->json([
'success' => true,
@@ -59,11 +60,12 @@ class DomainOrderController extends Controller
}
}
- private function notifyAdminNewDomainOrder($customer, array $result): void
+ private function notifyDomainOrderPlaced(Customer $customer, array $result): void
{
$domainName = $result['order']->orderItems->first()?->domain_name ?? '';
$orderNum = $result['order']->order_num;
$total = number_format((int) $result['invoice']->total) . ' VNĐ';
+ $createdAt = now()->format('d/m/Y H:i:s');
Notification::notifyAdmins([
'type' => 'domain_order_new',
@@ -72,35 +74,41 @@ class DomainOrderController extends Controller
'link' => '/domain-orders',
]);
+ $setting = Setting::where('key', 'setting')->first();
+ $settingValue = $setting ? json_decode((string) $setting->value, true) : [];
+ $companyName = $settingValue['company'] ?? config('app.name');
+ $adminEmail = $settingValue['admin_email'] ?? null;
+
+ $mailData = [
+ 'company_name' => $companyName,
+ 'domain_name' => $domainName,
+ 'customer_name' => $customer->fullname,
+ 'customer_email' => $customer->email,
+ 'order_num' => $orderNum,
+ 'order_total' => $total,
+ 'created_at' => $createdAt,
+ ];
+
+ if (!empty($adminEmail)) {
+ try {
+ $adminUrl = rtrim(env('ADMIN_URL', config('app.url')), '/') . '/domain-orders';
+
+ Mail::to($adminEmail)->send(
+ new DomainOrderNewNotification([...$mailData, 'admin_url' => $adminUrl])
+ );
+ } catch (\Throwable $e) {
+ Log::error('DomainOrderController: không thể gửi email admin: ' . $e->getMessage());
+ }
+ }
+
try {
- $setting = Setting::where('key', 'setting')->first();
- $settingValue = $setting ? json_decode((string) $setting->value, true) : [];
- $adminEmail = $settingValue['admin_email'] ?? null;
+ $orderUrl = rtrim(config('app.frontend_url', ''), '/') . '/don-hang';
- if (empty($adminEmail)) {
- return;
- }
-
- $template = Template::where('name', 'Domain Order New')->first();
- if (!$template) {
- return;
- }
-
- $adminUrl = rtrim(env('ADMIN_URL', config('app.url')), '/') . '/domain-orders';
-
- Mail::to($adminEmail)->send(new DomainOrderNewNotification($template, [
- 'company_name' => $settingValue['company'] ?? config('app.name'),
- 'company_logo' => $settingValue['logo'] ?? '',
- 'domain_name' => $domainName,
- 'customer_name' => $customer->fullname,
- 'customer_email' => $customer->email,
- 'order_num' => $orderNum,
- 'order_total' => $total,
- 'created_at' => now()->format('d/m/Y H:i:s'),
- 'admin_url' => $adminUrl,
- ]));
+ Mail::to($customer->email)->send(
+ new DomainOrderReceivedNotification([...$mailData, 'order_url' => $orderUrl])
+ );
} catch (\Throwable $e) {
- Log::error('DomainOrderController: không thể gửi email admin: ' . $e->getMessage());
+ Log::error('DomainOrderController: không thể gửi email khách hàng: ' . $e->getMessage());
}
}
}
diff --git a/app/Http/Controllers/Api/Client/Domain/DomainPricingController.php b/app/Http/Controllers/Api/Client/Domain/DomainPricingController.php
index 8b36057..3d9eb83 100644
--- a/app/Http/Controllers/Api/Client/Domain/DomainPricingController.php
+++ b/app/Http/Controllers/Api/Client/Domain/DomainPricingController.php
@@ -3,50 +3,11 @@
namespace App\Http\Controllers\Api\Client\Domain;
use App\Http\Controllers\Controller;
-use App\Models\Setting;
+use App\Models\DomainPricing;
use Illuminate\Http\JsonResponse;
class DomainPricingController extends Controller
{
- /**
- * Default domain pricing data (fallback when settings not configured)
- */
- private function getDefaultDomainPricing(): array
- {
- return [
- 'domains' => [
- ['tld' => '.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 400000, 'renewal' => 400000],
- ['tld' => '.com.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000],
- ['tld' => '.net.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000],
- ['tld' => '.org.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000],
- ['tld' => '.com', 'setupFee' => 'Miễn phí', 'firstYear' => 320000, 'renewal' => 340000],
- ['tld' => '.net', 'setupFee' => 'Miễn phí', 'firstYear' => 369000, 'renewal' => 409000],
- ['tld' => '.org', 'setupFee' => 'Miễn phí', 'firstYear' => 339000, 'renewal' => 369000],
- ['tld' => '.biz', 'setupFee' => 'Miễn phí', 'firstYear' => 399000, 'renewal' => 409000],
- ['tld' => '.info', 'setupFee' => 'Miễn phí', 'firstYear' => 490000, 'renewal' => 509000],
- ['tld' => '.pro', 'setupFee' => 'Miễn phí', 'firstYear' => 469000, 'renewal' => 489000],
- ['tld' => '.name', 'setupFee' => 'Miễn phí', 'firstYear' => 259000, 'renewal' => 259000],
- ['tld' => '.ws', 'setupFee' => 'Miễn phí', 'firstYear' => 909000, 'renewal' => 909000],
- ['tld' => '.tv', 'setupFee' => 'Miễn phí', 'firstYear' => 765000, 'renewal' => 819000],
- ['tld' => '.mobi', 'setupFee' => 'Miễn phí', 'firstYear' => 589000, 'renewal' => 589000],
- ['tld' => '.uk', 'setupFee' => 'Miễn phí', 'firstYear' => 537000, 'renewal' => 537000],
- ['tld' => '.asia', 'setupFee' => 'Miễn phí', 'firstYear' => 380000, 'renewal' => 452000],
- ['tld' => '.me', 'setupFee' => 'Miễn phí', 'firstYear' => 1029000, 'renewal' => 1029000],
- ['tld' => '.us', 'setupFee' => 'Miễn phí', 'firstYear' => 323000, 'renewal' => 323000],
- ],
- 'services' => [
- ['name' => 'Chứng thực Rapid SSL', 'setupFee' => 230000, 'maintenance' => 750000, 'note' => '01 Domain'],
- ['name' => 'Chứng thực Quick SSL', 'setupFee' => 230000, 'maintenance' => 2300000, 'note' => '01 Domain'],
- ['name' => 'Chứng thực Wildcard SSL', 'setupFee' => 230000, 'maintenance' => 3300000, 'note' => 'Unlimited Subdomain'],
- ['name' => 'Chứng thực True BusinessID', 'setupFee' => 540000, 'maintenance' => 3900000, 'note' => '01 Domain'],
- ['name' => 'Chứng thực True BusinessID WildCard', 'setupFee' => 900000, 'maintenance' => 15000000, 'note' => 'Unlimited Subdomain'],
- ['name' => 'Đăng ký DNS riêng', 'setupFee' => 200000, 'maintenance' => 'Miễn phí', 'note' => "dns1.tenmiencuaban.com\ndns2.tenmiencuaban.com"],
- ['name' => 'Bảo mật thông tin tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 90000, 'note' => 'Toàn bộ thông tin Whois tên miền của bạn sẽ được giấu kín hoàn toàn với mọi công cụ Whois'],
- ['name' => 'Khóa tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 'Miễn phí', 'note' => 'Tăng cường bảo mật cho tên miền'],
- ],
- ];
- }
-
/**
* GET /v1/domain-pricing
* Returns domain pricing list and addon services.
@@ -54,24 +15,10 @@ class DomainPricingController extends Controller
*/
public function index(): JsonResponse
{
- $setting = Setting::where('key', 'domain_pricing')->first();
-
- if ($setting && !empty($setting->value)) {
- $value = json_decode((string) $setting->value, true);
- if (is_array($value) && !empty($value)) {
- return response()->json([
- 'error' => false,
- 'data' => $value,
- 'message' => 'Lấy bảng giá tên miền thành công.',
- ]);
- }
- }
-
- // Fallback to default data
return response()->json([
'error' => false,
- 'data' => $this->getDefaultDomainPricing(),
- 'message' => 'Lấy bảng giá tên miền thành công (mặc định).',
+ 'data' => DomainPricing::toResponseArray(),
+ 'message' => 'Lấy bảng giá tên miền thành công.',
]);
}
}
diff --git a/app/Http/Controllers/Api/Client/Service/ServiceController.php b/app/Http/Controllers/Api/Client/Service/ServiceController.php
index a5eaf7d..46c7810 100644
--- a/app/Http/Controllers/Api/Client/Service/ServiceController.php
+++ b/app/Http/Controllers/Api/Client/Service/ServiceController.php
@@ -4,12 +4,14 @@ namespace App\Http\Controllers\Api\Client\Service;
use App\Http\Controllers\Controller;
use App\Http\Resources\Api\Service\VpsServiceCollection;
+use App\Http\Resources\Api\Service\Order\InvoiceSummaryResource;
use App\Http\Resources\Api\Client\Service\ServiceClientDetailResource;
use App\Http\Services\vCenter;
use App\Models\Order;
use App\Models\Service;
use App\Repositories\Api\ServiceClient\ServiceClientRepository;
use App\Services\ActivityLogger;
+use App\Services\Api\Order\OrderQueryService;
use App\Services\Api\ServiceClient\ServiceClientService;
use App\Services\VirtualMachine\Rebuild\VmRebuildService;
use Illuminate\Http\JsonResponse;
@@ -66,6 +68,23 @@ class ServiceController extends Controller
->response();
}
+ public function overview(Request $request, OrderQueryService $orderQueryService): JsonResponse
+ {
+ $customerId = (int) $request->user()->id;
+
+ $services = $this->serviceClientService->getActiveServices($customerId, null, 5);
+ $invoices = $orderQueryService->getCustomerInvoices($customerId)->take(5);
+
+ return response()->json([
+ 'error' => false,
+ 'message' => 'Lấy dữ liệu thành công',
+ 'data' => [
+ 'services' => (new VpsServiceCollection($services))->collection,
+ 'invoices' => InvoiceSummaryResource::collection($invoices),
+ ],
+ ]);
+ }
+
public function indexWebDesign(Request $request): JsonResponse
{
$perPage = min((int) $request->get('per_page', 15), 100);
diff --git a/app/Http/Controllers/Api/Service/Concerns/ResolvesVm.php b/app/Http/Controllers/Api/Service/Concerns/ResolvesVm.php
index da398a9..835e055 100644
--- a/app/Http/Controllers/Api/Service/Concerns/ResolvesVm.php
+++ b/app/Http/Controllers/Api/Service/Concerns/ResolvesVm.php
@@ -24,7 +24,9 @@ trait ResolvesVm
}
} elseif (is_numeric($productId)) {
$vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter'])
- ->find((int) $productId);
+ ->where('id', (int) $productId)
+ ->where('customer_id', $customer->id)
+ ->first();
} else {
$vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter'])
->where('vmware_id', $productId)
diff --git a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php
index ab7f47b..cad5df2 100644
--- a/app/Http/Controllers/Api/Service/OrderControllerRefactor.php
+++ b/app/Http/Controllers/Api/Service/OrderControllerRefactor.php
@@ -33,14 +33,13 @@ class OrderControllerRefactor extends Controller
private readonly OrderQueryService $orderQueryService,
private readonly ActivityLogger $activityLogger,
private readonly InvoicePaymentService $invoicePaymentService,
- ) {
- }
+ ) {}
public function getList(Request $request): JsonResponse
{
$orders = $this->orderQueryService->getOrdersForCustomer(
(int) $request->user()->id,
- ClientOrderFilters::fromArray($request->only(['total_price', 'pending']))
+ ClientOrderFilters::fromArray($request->only(['total_price', 'pending'])),
);
return response()->json(['success' => true, 'data' => $orders]);
@@ -67,7 +66,7 @@ class OrderControllerRefactor extends Controller
{
$order = $this->orderQueryService->findCustomerOrder((int) $request->user()->id, $id);
- if (! $order) {
+ if (!$order) {
return response()->json(['success' => false, 'message' => 'Đơn hàng không tồn tại.'], 404);
}
@@ -81,8 +80,11 @@ class OrderControllerRefactor extends Controller
$data = $request->validated();
$product = \App\Models\Product::where('uuid', $data['product_id'])->first();
- if (! $product) {
- return response()->json(['success' => false, 'message' => 'Sản phẩm không tìm thấy hoặc đã bị xóa.'], 404);
+ if (!$product) {
+ return response()->json(
+ ['success' => false, 'message' => 'Sản phẩm không tìm thấy hoặc đã bị xóa.'],
+ 404,
+ );
}
$data['product_id'] = $product->id;
@@ -90,16 +92,19 @@ class OrderControllerRefactor extends Controller
$order = $result['order']->load(['orderItems.product', 'invoice', 'customer']);
- return response()->json([
- 'success' => true,
- 'message' => 'Tạo đơn hàng thành công.',
- 'notice' => 'Dịch vụ VPS sẽ được khởi tạo trong 5-10 phút sau khi thanh toán.',
- 'data' => [
- 'order' => new OrderDetailResource($order),
- 'invoice' => new InvoiceDetailResource($result['invoice']->load(['customer', 'order'])),
- 'items' => OrderItemResource::collection($order->orderItems),
+ return response()->json(
+ [
+ 'success' => true,
+ 'message' => 'Tạo đơn hàng thành công.',
+ 'notice' => 'Dịch vụ VPS sẽ được khởi tạo trong 5-10 phút sau khi thanh toán.',
+ 'data' => [
+ 'order' => new OrderDetailResource($order),
+ 'invoice' => new InvoiceDetailResource($result['invoice']->load(['customer', 'order'])),
+ 'items' => OrderItemResource::collection($order->orderItems),
+ ],
],
- ], 201);
+ 201,
+ );
} catch (\Throwable $e) {
return response()->json(['success' => false, 'message' => $e->getMessage()], 400);
}
@@ -109,7 +114,7 @@ class OrderControllerRefactor extends Controller
{
$order = $this->orderRepository->findByOrderNum($orderNum, (int) $request->user()->id);
- if (! $order) {
+ if (!$order) {
return response()->json(['success' => false, 'message' => 'Đơn hàng không tồn tại.'], 404);
}
@@ -131,13 +136,13 @@ class OrderControllerRefactor extends Controller
$customerId = (int) $request->user()->id;
$order = $this->orderRepository->findForCustomer($id, $customerId);
- if (! $order) {
+ if (!$order) {
return response()->json(['success' => false, 'message' => 'Đơn hàng không tồn tại.'], 404);
}
$result = $this->orderQueryService->retryProvisioning($order, $customerId);
- if (! $result['ok']) {
+ if (!$result['ok']) {
return response()->json(['success' => false, 'message' => $result['message']], $result['code']);
}
@@ -162,7 +167,7 @@ class OrderControllerRefactor extends Controller
{
$invoice = $this->orderQueryService->findCustomerInvoice((int) $request->user()->id, $id);
- if (! $invoice) {
+ if (!$invoice) {
return response()->json(['success' => false, 'message' => 'Hóa đơn không tồn tại.'], 404);
}
@@ -173,20 +178,23 @@ class OrderControllerRefactor extends Controller
{
$order = $this->orderQueryService->findCustomerInvoiceByOrderNum((int) $request->user()->id, $orderNumber);
- if (! $order?->invoice) {
+ if (!$order?->invoice) {
return response()->json(['success' => false, 'message' => 'Hóa đơn không tồn tại.'], 404);
}
$order->load('vmWare.os.osGroup', 'vmWare.virtualMachine', 'vmWare.service');
- return response()->json(['success' => true, 'data' => new InvoiceDetailResource($order->invoice->setRelation('order', $order))]);
+ return response()->json([
+ 'success' => true,
+ 'data' => new InvoiceDetailResource($order->invoice->setRelation('order', $order)),
+ ]);
}
public function getClientInvoiceDetailByNumber(Request $request, string $invoiceNumber): JsonResponse
{
$invoice = $this->orderQueryService->findCustomerInvoiceByNumber((int) $request->user()->id, $invoiceNumber);
- if (! $invoice) {
+ if (!$invoice) {
return response()->json(['success' => false, 'message' => 'Hóa đơn không tồn tại.'], 404);
}
@@ -201,7 +209,7 @@ class OrderControllerRefactor extends Controller
{
$invoice = $this->orderQueryService->findCustomerInvoiceByNumber((int) $request->user()->id, $invoiceNumber);
- if (! $invoice) {
+ if (!$invoice) {
return response()->json(['success' => false, 'message' => 'Hóa đơn không tồn tại.'], 404);
}
@@ -230,26 +238,29 @@ class OrderControllerRefactor extends Controller
{
$order = $this->orderQueryService->findCustomerInvoiceByOrderNum((int) $request->user()->id, $orderNum);
- if (! $order) {
+ if (!$order) {
return response()->json(['success' => false, 'message' => 'Đơn hàng không tồn tại.'], 404);
}
return response()->json(['success' => true, 'data' => new OrderDetailResource($order)]);
}
- public function downloadInvoicePdfByOrderNum(Request $request, string $orderNum, InvoicePdfService $pdfService): Response
- {
+ public function downloadInvoicePdfByOrderNum(
+ Request $request,
+ string $orderNum,
+ InvoicePdfService $pdfService,
+ ): Response {
$order = $this->orderQueryService->findCustomerInvoiceByOrderNum((int) $request->user()->id, $orderNum);
- if (! $order?->invoice) {
+ if (!$order?->invoice) {
abort(404, 'Hóa đơn không tồn tại.');
}
- $pdf = $pdfService->generate($order->invoice);
+ $pdf = $pdfService->generate($order->invoice);
$filename = $pdfService->generateFileName($order->invoice);
return response($pdf, 200, [
- 'Content-Type' => 'application/pdf',
+ 'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}
@@ -265,7 +276,7 @@ class OrderControllerRefactor extends Controller
{
$service = $this->orderQueryService->findCustomerService((int) $request->user()->id, $uuid);
- if (! $service) {
+ if (!$service) {
return response()->json(['success' => false, 'message' => 'Không tìm thấy dịch vụ.'], 404);
}
@@ -276,12 +287,9 @@ class OrderControllerRefactor extends Controller
{
$validated = $request->validate(['auto_renew' => ['required', 'boolean']]);
- $service = \App\Models\Service::query()
- ->where('id', $id)
- ->where('customer_id', $request->user()->id)
- ->first();
+ $service = \App\Models\Service::query()->where('id', $id)->where('customer_id', $request->user()->id)->first();
- if (! $service) {
+ if (!$service) {
return response()->json(['success' => false, 'message' => 'Không tìm thấy dịch vụ.'], 404);
}
@@ -290,19 +298,29 @@ class OrderControllerRefactor extends Controller
$this->activityLogger->log(
event: 'updated',
logName: 'Service',
- description: "Customer {$request->user()->email} " . ($validated['auto_renew'] ? 'bật' : 'tắt') . " tự động gia hạn dịch vụ #{$service->id}",
+ description: "Customer {$request->user()->email} " .
+ ($validated['auto_renew'] ? 'bật' : 'tắt') .
+ " tự động gia hạn dịch vụ #{$service->id}",
subject: $service,
newValues: ['auto_renew' => (bool) $validated['auto_renew']],
);
- return response()->json(['success' => true, 'message' => 'Cập nhật tự động gia hạn thành công.', 'data' => ['id' => $service->id, 'auto_renew' => (bool) $service->auto_renew]]);
+ return response()->json([
+ 'success' => true,
+ 'message' => 'Cập nhật tự động gia hạn thành công.',
+ 'data' => ['id' => $service->id, 'auto_renew' => (bool) $service->auto_renew],
+ ]);
}
public function renewService(RenewServiceRequest $request, int $id): JsonResponse
{
- $result = $this->orderQueryService->renewService($id, (int) $request->user()->id, (int) $request->validated()['months']);
+ $result = $this->orderQueryService->renewService(
+ $id,
+ (int) $request->user()->id,
+ (int) $request->validated()['months'],
+ );
- if (! $result['ok']) {
+ if (!$result['ok']) {
return response()->json(['success' => false, 'message' => $result['message']], $result['code']);
}
diff --git a/app/Http/Controllers/Api/Service/VmController.php b/app/Http/Controllers/Api/Service/VmController.php
index dce35d1..6b1989a 100644
--- a/app/Http/Controllers/Api/Service/VmController.php
+++ b/app/Http/Controllers/Api/Service/VmController.php
@@ -484,7 +484,9 @@ class VmController extends Controller
} elseif (is_numeric($productId)) {
// Numeric ID
$vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter'])
- ->find((int) $productId);
+ ->where('id', (int) $productId)
+ ->where('customer_id', $customer->id)
+ ->first();
} else {
// vmware_id string (e.g., "vm-3238")
$vm = ModuleVmware::with(['vCenter', 'ipAddress.host.datacenter.vCenter'])
diff --git a/app/Http/Requests/Api/Admin/Domain/UpdateRegistrantInfoRequest.php b/app/Http/Requests/Api/Admin/Domain/UpdateRegistrantInfoRequest.php
new file mode 100644
index 0000000..ff04955
--- /dev/null
+++ b/app/Http/Requests/Api/Admin/Domain/UpdateRegistrantInfoRequest.php
@@ -0,0 +1,21 @@
+ ['required', 'in:individual,organization'],
+ 'registrant_info' => ['required', 'array'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/UpdateProfileRequest.php b/app/Http/Requests/UpdateProfileRequest.php
new file mode 100644
index 0000000..8859796
--- /dev/null
+++ b/app/Http/Requests/UpdateProfileRequest.php
@@ -0,0 +1,25 @@
+ 'required|string|max:255',
+ 'lastname' => 'required|string|max:255',
+ 'phone' => 'nullable|string|max:20',
+ 'department' => ['nullable', Rule::in(['IT', 'Sale', 'Marketing', 'Customer Service', 'Accounting', 'Management'])],
+ 'start_date' => 'nullable|date',
+ ];
+ }
+}
diff --git a/app/Http/Resources/Api/Admin/AdminUserResource.php b/app/Http/Resources/Api/Admin/AdminUserResource.php
index 9ca7015..a5f70da 100644
--- a/app/Http/Resources/Api/Admin/AdminUserResource.php
+++ b/app/Http/Resources/Api/Admin/AdminUserResource.php
@@ -13,9 +13,12 @@ class AdminUserResource extends JsonResource
'id' => $this->id,
'username' => $this->username,
'email' => $this->email,
- 'firstname' => $this->firstname,
- 'lastname' => $this->lastname,
- 'roles' => $this->getRoleNames(),
+ 'firstname' => $this->firstname,
+ 'lastname' => $this->lastname,
+ 'phone' => $this->phone,
+ 'department' => $this->department,
+ 'start_date' => $this->start_date?->toDateString(),
+ 'roles' => $this->getRoleNames(),
];
}
}
diff --git a/app/Mail/Admin/DomainOrderNewNotification.php b/app/Mail/Admin/DomainOrderNewNotification.php
index ed64fe0..a1d975b 100644
--- a/app/Mail/Admin/DomainOrderNewNotification.php
+++ b/app/Mail/Admin/DomainOrderNewNotification.php
@@ -2,42 +2,37 @@
namespace App\Mail\Admin;
-use App\Models\Template;
use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
-class DomainOrderNewNotification extends Mailable
+class DomainOrderNewNotification extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public function __construct(
- protected Template $template,
protected array $data,
) {}
public function envelope(): Envelope
{
return new Envelope(
- subject: str_replace('{\$domain_name}', $this->data['domain_name'] ?? '', $this->template->subject),
+ subject: "[ADMIN] Đơn đăng ký tên miền mới - {$this->data['domain_name']}",
);
}
public function content(): Content
{
- $message = str_replace(
- ['{\$company_name}', '{\$company_logo}', '{\$domain_name}', '{\$customer_name}', '{\$customer_email}', '{\$order_num}', '{\$order_total}', '{\$created_at}', '{\$admin_url}'],
- [$this->data['company_name'] ?? '', $this->data['company_logo'] ?? '', $this->data['domain_name'] ?? '', $this->data['customer_name'] ?? '', $this->data['customer_email'] ?? '', $this->data['order_num'] ?? '', $this->data['order_total'] ?? '', $this->data['created_at'] ?? '', $this->data['admin_url'] ?? ''],
- $this->template->message
+ return new Content(
+ view: 'emails.layouts.notification',
+ with: [
+ 'companyName' => $this->data['company_name'] ?? config('app.name'),
+ 'title' => 'Thông báo đơn đăng ký tên miền mới',
+ 'body' => view('emails.admin.domain-order-new', $this->data)->render(),
+ ],
);
-
- return new Content(view: 'emails.template', with: ['template' => $message]);
- }
-
- public function attachments(): array
- {
- return [];
}
}
diff --git a/app/Mail/Customer/DomainOrderReceivedNotification.php b/app/Mail/Customer/DomainOrderReceivedNotification.php
new file mode 100644
index 0000000..77c0768
--- /dev/null
+++ b/app/Mail/Customer/DomainOrderReceivedNotification.php
@@ -0,0 +1,38 @@
+data['domain_name']} đã được ghi nhận",
+ );
+ }
+
+ public function content(): Content
+ {
+ return new Content(
+ view: 'emails.layouts.notification',
+ with: [
+ 'companyName' => $this->data['company_name'] ?? config('app.name'),
+ 'title' => 'Đơn đăng ký tên miền đã được ghi nhận',
+ 'body' => view('emails.customer.domain-order-received', $this->data)->render(),
+ ],
+ );
+ }
+}
diff --git a/app/Mail/Customer/DomainOrderStatusMail.php b/app/Mail/Customer/DomainOrderStatusMail.php
index 8ab28e9..9a7f105 100644
--- a/app/Mail/Customer/DomainOrderStatusMail.php
+++ b/app/Mail/Customer/DomainOrderStatusMail.php
@@ -4,12 +4,13 @@ namespace App\Mail\Customer;
use App\Models\Template;
use Illuminate\Bus\Queueable;
+use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
-class DomainOrderStatusMail extends Mailable
+class DomainOrderStatusMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
@@ -21,14 +22,14 @@ class DomainOrderStatusMail extends Mailable
public function envelope(): Envelope
{
return new Envelope(
- subject: str_replace('{\$domain_name}', $this->data['domain_name'] ?? '', $this->template->subject),
+ subject: str_replace('{$domain_name}', $this->data['domain_name'] ?? '', $this->template->subject),
);
}
public function content(): Content
{
$message = str_replace(
- ['{\$company_name}', '{\$company_logo}', '{\$domain_name}', '{\$client_name}', '{\$order_num}', '{\$reason}'],
+ ['{$company_name}', '{$company_logo}', '{$domain_name}', '{$client_name}', '{$order_num}', '{$reason}'],
[$this->data['company_name'] ?? '', $this->data['company_logo'] ?? '', $this->data['domain_name'] ?? '', $this->data['client_name'] ?? '', $this->data['order_num'] ?? '', $this->data['reason'] ?? ''],
$this->template->message
);
diff --git a/app/Models/DomainPricing.php b/app/Models/DomainPricing.php
new file mode 100644
index 0000000..17407d6
--- /dev/null
+++ b/app/Models/DomainPricing.php
@@ -0,0 +1,96 @@
+ 'boolean',
+ 'first_year' => 'integer',
+ 'sort_order' => 'integer',
+ ];
+
+ public function scopeDomains($query)
+ {
+ return $query->where('type', 'domain');
+ }
+
+ public function scopeServices($query)
+ {
+ return $query->where('type', 'service');
+ }
+
+ /** Cast to int when the value is a plain number (e.g. "300000"), otherwise keep as-is (e.g. "Miễn phí"). */
+ private static function numericOrString(?string $value): string|int
+ {
+ return is_numeric($value) ? (int) $value : (string) $value;
+ }
+
+ public function toDomainArray(): array
+ {
+ return [
+ 'tld' => $this->tld,
+ 'setupFee' => self::numericOrString($this->setup_fee),
+ 'firstYear' => (int) $this->first_year,
+ 'renewal' => (int) $this->recurring_fee,
+ ];
+ }
+
+ public function toServiceArray(): array
+ {
+ return [
+ 'name' => $this->name,
+ 'setupFee' => self::numericOrString($this->setup_fee),
+ 'maintenance' => self::numericOrString($this->recurring_fee),
+ 'note' => (string) $this->note,
+ ];
+ }
+
+ /** Shape consumed by both the client and admin domain-pricing endpoints. */
+ public static function toResponseArray(): array
+ {
+ $rows = static::query()->where('is_active', true)->orderBy('sort_order')->get();
+
+ return [
+ 'domains' => $rows->where('type', 'domain')->map->toDomainArray()->values()->all(),
+ 'services' => $rows->where('type', 'service')->map->toServiceArray()->values()->all(),
+ ];
+ }
+
+ /**
+ * Longest-TLD-match lookup used when pricing a domain order.
+ *
+ * @throws \InvalidArgumentException if no matching TLD is configured
+ */
+ public static function findByDomain(string $domain): self
+ {
+ $candidates = static::domains()->where('is_active', true)->get()
+ ->sortByDesc(fn (self $row) => strlen((string) $row->tld));
+
+ foreach ($candidates as $row) {
+ $tld = ltrim((string) $row->tld, '.');
+ if ($tld !== '' && str_ends_with($domain, '.' . $tld)) {
+ return $row;
+ }
+ }
+
+ throw new \InvalidArgumentException('Tên miền không được hỗ trợ. Vui lòng chọn đuôi tên miền có trong danh sách.');
+ }
+}
diff --git a/app/Models/ExportJob.php b/app/Models/ExportJob.php
new file mode 100644
index 0000000..7af092a
--- /dev/null
+++ b/app/Models/ExportJob.php
@@ -0,0 +1,23 @@
+ 'array',
+ 'fields' => 'array',
+ 'completed_at' => 'datetime',
+ ];
+
+ public function creator()
+ {
+ return $this->belongsTo(User::class, 'created_by');
+ }
+}
diff --git a/app/Models/Service.php b/app/Models/Service.php
index b6cf1e9..bc498e3 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -20,7 +20,30 @@ class Service extends Model
protected $table = 'services';
- protected $guarded = [];
+ protected $fillable = [
+ 'uuid',
+ 'customer_id',
+ 'product_id',
+ 'type',
+ 'order_item_id',
+ 'invoice_id',
+ 'vm_id',
+ 'server_name',
+ 'status',
+ 'auto_renew',
+ 'started_at',
+ 'expires_at',
+ 'suspended_at',
+ 'cancelled_at',
+ 'refunded_at',
+ 'retry_requested_at',
+ 'note',
+ 'registrar_reference',
+ 'kyc_url',
+ 'rejection_reason',
+ 'registrant_type',
+ 'registrant_info',
+ ];
protected $casts = [
'auto_renew' => 'boolean',
@@ -30,6 +53,7 @@ class Service extends Model
'cancelled_at' => 'datetime',
'refunded_at' => 'datetime',
'retry_requested_at' => 'datetime',
+ 'registrant_info' => 'array',
];
// -------------------------------------------------------------------------
diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php
index 76e3b34..939cd01 100644
--- a/app/Models/Transaction.php
+++ b/app/Models/Transaction.php
@@ -6,6 +6,7 @@ use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
+use Illuminate\Support\Facades\Storage;
use App\Traits\HasUuid;
class Transaction extends Model
@@ -26,6 +27,7 @@ class Transaction extends Model
'proof_image',
];
+ protected $appends = ['direction', 'proof_image_urls'];
public function customer()
{
@@ -36,4 +38,48 @@ class Transaction extends Model
{
return $this->belongsTo(User::class, 'user_id');
}
+
+ /**
+ * Whether this transaction added ('credit'), removed ('debit') or left
+ * ('neutral') the customer's balance untouched. `amount` is always stored
+ * positive, so the direction has to be derived from `type` (and, for
+ * 'invoice', from the note) to mirror the actual balance mutation logic
+ * in InvoicePaymentService / CustomerController / AdminTransactionController.
+ */
+ public function getDirectionAttribute(): string
+ {
+ return match ($this->type) {
+ 'admin_add_total', 'admin_add_credit', 'manual', 'refund' => 'credit',
+ 'admin_deduct_credit' => 'debit',
+ 'balance' => 'debit',
+ 'transfer' => 'neutral',
+ 'invoice' => str_starts_with((string) $this->note, 'Admin xác nhận')
+ ? 'neutral'
+ : 'debit',
+ default => 'credit',
+ };
+ }
+
+ /**
+ * `proof_image` stores either a single relative storage path, or a
+ * JSON-encoded array of paths (admin-created service with multiple
+ * proofs). Resolve both shapes into absolute, browsable URLs so the
+ * raw path string never leaks into the frontend as a literal href.
+ */
+ public function getProofImageUrlsAttribute(): array
+ {
+ if (! $this->proof_image) {
+ return [];
+ }
+
+ $paths = json_decode($this->proof_image, true);
+ if (! is_array($paths)) {
+ $paths = [$this->proof_image];
+ }
+
+ return array_values(array_map(
+ fn (string $path) => Storage::disk('public')->url($path),
+ array_filter($paths, fn ($path) => is_string($path) && $path !== '')
+ ));
+ }
}
diff --git a/app/Models/User.php b/app/Models/User.php
index 959620e..ff5341b 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -32,6 +32,8 @@ class User extends Authenticatable implements JWTSubject
'birthday',
'address',
'phone',
+ 'department',
+ 'start_date',
];
/**
@@ -51,6 +53,7 @@ class User extends Authenticatable implements JWTSubject
*/
protected $casts = [
'email_verified_at' => 'datetime',
+ 'start_date' => 'date',
];
public function getJWTIdentifier()
diff --git a/app/Repositories/Customer/CustomerRepository.php b/app/Repositories/Customer/CustomerRepository.php
index 75322a0..237b599 100644
--- a/app/Repositories/Customer/CustomerRepository.php
+++ b/app/Repositories/Customer/CustomerRepository.php
@@ -52,6 +52,30 @@ class CustomerRepository extends BaseRepository implements CustomerRepositoryInt
if (isset($data['phone']) && ! empty($data['phone'])) {
$model = $model->where("phone", "LIKE", "%" . $data['phone'] . "%");
}
+ if (isset($data['company']) && ! empty($data['company'])) {
+ $model = $model->where('company', 'LIKE', '%' . $data['company'] . '%');
+ }
+ if (isset($data['city']) && ! empty($data['city'])) {
+ $model = $model->where('city', 'LIKE', '%' . $data['city'] . '%');
+ }
+ if (isset($data['country']) && ! empty($data['country'])) {
+ $model = $model->where('country', 'LIKE', '%' . $data['country'] . '%');
+ }
+ if (isset($data['note']) && ! empty($data['note'])) {
+ $model = $model->where('note', 'LIKE', '%' . $data['note'] . '%');
+ }
+ if (isset($data['total_min']) && $data['total_min'] !== '') {
+ $model = $model->where('total', '>=', (int) $data['total_min']);
+ }
+ if (isset($data['total_max']) && $data['total_max'] !== '') {
+ $model = $model->where('total', '<=', (int) $data['total_max']);
+ }
+ if (isset($data['created_from']) && ! empty($data['created_from'])) {
+ $model = $model->whereDate('created_at', '>=', $data['created_from']);
+ }
+ if (isset($data['created_to']) && ! empty($data['created_to'])) {
+ $model = $model->whereDate('created_at', '<=', $data['created_to']);
+ }
if (isset($data['service_type']) && ! empty($data['service_type'])) {
$types = is_array($data['service_type'])
? $data['service_type']
diff --git a/app/Services/Api/Domain/DomainOrderService.php b/app/Services/Api/Domain/DomainOrderService.php
index 1a6ac8f..9a04f1f 100644
--- a/app/Services/Api/Domain/DomainOrderService.php
+++ b/app/Services/Api/Domain/DomainOrderService.php
@@ -13,11 +13,11 @@ use App\Models\DnsDomain;
use App\Models\Invoice;
use App\Models\InvoiceItem;
use App\Models\Log as CustomerActivityLog;
+use App\Models\DomainPricing;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\Service;
-use App\Models\Setting;
use App\Models\Transaction;
use App\Repositories\Api\Domain\DomainOrderRepository;
use Illuminate\Support\Facades\DB;
@@ -53,6 +53,10 @@ class DomainOrderService
return DB::transaction(function () use ($customer, $product, $domainName, $price, $months, $years) {
$lockedCustomer = Customer::lockForUpdate()->find($customer->id);
+ if (!$lockedCustomer->front_identity_number || !$lockedCustomer->back_identity_number) {
+ throw new \InvalidArgumentException('Vui lòng bổ sung ảnh CMND/CCCD (2 mặt) trước khi đặt mua tên miền.');
+ }
+
if ((int) $lockedCustomer->total < $price) {
throw new \InvalidArgumentException('Số dư không đủ để đặt mua tên miền. Vui lòng nạp thêm tiền vào tài khoản.');
}
@@ -367,29 +371,7 @@ class DomainOrderService
*/
private function findDomainPricing(string $domain): array
{
- $setting = Setting::where('key', 'domain_pricing')->first();
-
- if (!$setting || empty($setting->value)) {
- throw new \InvalidArgumentException('Không tìm thấy cấu hình giá tên miền.');
- }
-
- $pricingData = is_string($setting->value)
- ? json_decode($setting->value, true)
- : (array) $setting->value;
-
- $domains = $pricingData['domains'] ?? [];
-
- // Longest-match: sort TLDs by length descending so ".com.vn" beats ".vn"
- usort($domains, fn ($a, $b) => strlen($b['tld']) - strlen($a['tld']));
-
- foreach ($domains as $entry) {
- $tld = ltrim($entry['tld'], '.');
- if (str_ends_with($domain, '.' . $tld)) {
- return $entry;
- }
- }
-
- throw new \InvalidArgumentException("Tên miền không được hỗ trợ. Vui lòng chọn đuôi tên miền có trong danh sách.");
+ return DomainPricing::findByDomain($domain)->toDomainArray();
}
/**
diff --git a/app/Services/Api/Domain/DomainRegistrantService.php b/app/Services/Api/Domain/DomainRegistrantService.php
new file mode 100644
index 0000000..2f4e7bb
--- /dev/null
+++ b/app/Services/Api/Domain/DomainRegistrantService.php
@@ -0,0 +1,131 @@
+}
+ */
+ public function get(Service $service, ?Customer $customer): array
+ {
+ if ($service->registrant_info) {
+ return [
+ 'type' => $service->registrant_type ?? 'individual',
+ 'info' => $service->registrant_info,
+ ];
+ }
+
+ $isOrganization = $customer && filled($customer->company);
+
+ return [
+ 'type' => $isOrganization ? 'organization' : 'individual',
+ 'info' => $isOrganization ? $this->defaultOrganizationInfo($customer) : $this->defaultIndividualInfo($customer),
+ ];
+ }
+
+ public function update(Service $service, string $type, array $info): Service
+ {
+ $service->update([
+ 'registrant_type' => $type,
+ 'registrant_info' => $info,
+ ]);
+
+ return $service->fresh();
+ }
+
+ /**
+ * Render the VNNIC domain registration form as a .docx and return its binary contents.
+ */
+ public function renderDocx(string $domainName, string $type, array $info): string
+ {
+ $view = $type === 'organization' ? 'domain.registrant-tochuc' : 'domain.registrant-canhan';
+ $html = view($view, ['domain' => $domainName, 'r' => $info])->render();
+
+ $phpWord = new PhpWord();
+ $phpWord->setDefaultFontName('Times New Roman');
+ $phpWord->setDefaultFontSize(13);
+ $section = $phpWord->addSection(['marginLeft' => 1000, 'marginRight' => 1000]);
+ Html::addHtml($section, $html, false, false);
+
+ $writer = new Word2007($phpWord);
+ $tmp = tempnam(sys_get_temp_dir(), 'domain_reg_');
+ $writer->save($tmp);
+ $contents = file_get_contents($tmp);
+ unlink($tmp);
+
+ return $contents;
+ }
+
+ private function defaultIndividualInfo(?Customer $customer): array
+ {
+ return [
+ 'fullname' => $customer?->fullname,
+ 'birth_date' => null,
+ 'identity_number' => $customer?->identity_number,
+ 'perm_address' => $customer?->address,
+ 'perm_ward' => $customer?->ward?->name,
+ 'perm_province' => $customer?->province?->name,
+ 'temp_address' => null,
+ 'temp_ward' => null,
+ 'temp_province' => null,
+ 'phone' => $customer?->phone,
+ 'email' => $customer?->email,
+ 'sign_place' => $customer?->province?->name,
+ 'sign_date' => now()->toDateString(),
+ ];
+ }
+
+ private function defaultOrganizationInfo(?Customer $customer): array
+ {
+ $address = $customer?->address;
+ $ward = $customer?->ward?->name;
+ $province = $customer?->province?->name;
+
+ return [
+ 'org_name' => $customer?->company,
+ 'org_tax_code' => null,
+ 'org_address' => $address,
+ 'org_ward' => $ward,
+ 'org_province' => $province,
+ 'org_phone' => $customer?->phone,
+ 'org_email' => $customer?->email,
+
+ 'manager_name' => $customer?->fullname,
+ 'manager_identity_number' => $customer?->identity_number,
+ 'manager_address' => $address,
+ 'manager_ward' => $ward,
+ 'manager_province' => $province,
+ 'manager_phone' => $customer?->phone,
+ 'manager_email' => $customer?->email,
+
+ 'tech_name' => $customer?->fullname,
+ 'tech_address' => $address,
+ 'tech_ward' => $ward,
+ 'tech_province' => $province,
+ 'tech_phone' => $customer?->phone,
+ 'tech_email' => $customer?->email,
+
+ 'payer_org_name' => $customer?->company,
+ 'payer_name' => $customer?->fullname,
+ 'payer_address' => $address,
+ 'payer_ward' => $ward,
+ 'payer_province' => $province,
+ 'payer_phone' => $customer?->phone,
+ 'payer_email' => $customer?->email,
+
+ 'signer_title' => null,
+ 'sign_place' => $province,
+ 'sign_date' => now()->toDateString(),
+ ];
+ }
+}
diff --git a/app/Services/Api/Order/OrderQueryService.php b/app/Services/Api/Order/OrderQueryService.php
index d0bbac4..a0b31eb 100644
--- a/app/Services/Api/Order/OrderQueryService.php
+++ b/app/Services/Api/Order/OrderQueryService.php
@@ -17,14 +17,17 @@ use App\Models\OrderItem;
use App\Models\Service;
use App\Models\VirtualMachines;
use App\Repositories\Api\Order\OrderRepository;
+use App\Repositories\Api\Service\ServiceRepositoryInterface;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class OrderQueryService
{
- public function __construct(private readonly OrderRepository $orderRepository)
- {
+ public function __construct(
+ private readonly OrderRepository $orderRepository,
+ private readonly ServiceRepositoryInterface $productRepo,
+ ) {
}
public function getOrdersForCustomer(int $customerId, ClientOrderFilters $filters): LengthAwarePaginator
@@ -198,11 +201,18 @@ class OrderQueryService
}
$orderItem = $service->orderItem;
- if (! $orderItem || (int) $orderItem->price <= 0) {
+ if (! $orderItem) {
return ['ok' => false, 'message' => 'Không thể xác định giá gia hạn dịch vụ.', 'code' => 400];
}
- $price = (int) $orderItem->price * $months;
+ // Gia hạn luôn dùng giá sản phẩm hiện tại trong bảng giá, không dùng giá đã
+ // chốt lúc đặt mua ban đầu — giá cũ chỉ để hiển thị trên đơn hàng/hoá đơn gốc.
+ $currentPrice = $this->productRepo->getPricing((int) $service->product_id, $orderItem->billing_cycle);
+ if ($currentPrice === null) {
+ return ['ok' => false, 'message' => 'Sản phẩm hiện không còn bán, không thể gia hạn.', 'code' => 400];
+ }
+
+ $price = (int) $currentPrice * $months;
if ($service->invoice_id && Invoice::where('id', $service->invoice_id)
->where('invoice_type', 2)
diff --git a/composer.json b/composer.json
index 16733b5..4f12cd4 100644
--- a/composer.json
+++ b/composer.json
@@ -20,6 +20,7 @@
"maatwebsite/excel": "^3.1",
"nwidart/laravel-modules": "^10.0",
"opcodesio/log-viewer": "^3.18",
+ "phpoffice/phpword": "^1.4",
"phpseclib/phpseclib": "*",
"plesk/ext-laravel-integration": "^7.0",
"predis/predis": "^2.2",
diff --git a/composer.lock b/composer.lock
index 1f0a54e..0b1f45a 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "4996106ea97079078f843b42993edb6f",
+ "content-hash": "862dcec8a5b4f25a9fa0f58eb3dd1556",
"packages": [
{
"name": "barryvdh/laravel-dompdf",
@@ -3888,6 +3888,58 @@
},
"time": "2020-10-15T08:29:30+00:00"
},
+ {
+ "name": "phpoffice/math",
+ "version": "0.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPOffice/Math.git",
+ "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPOffice/Math/zipball/fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a",
+ "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-xml": "*",
+ "php": "^7.1|^8.0"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "^0.12.88 || ^1.0.0",
+ "phpunit/phpunit": "^7.0 || ^9.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PhpOffice\\Math\\": "src/Math/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Progi1984",
+ "homepage": "https://lefevre.dev"
+ }
+ ],
+ "description": "Math - Manipulate Math Formula",
+ "homepage": "https://phpoffice.github.io/Math/",
+ "keywords": [
+ "MathML",
+ "officemathml",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPOffice/Math/issues",
+ "source": "https://github.com/PHPOffice/Math/tree/0.3.0"
+ },
+ "time": "2025-05-29T08:31:49+00:00"
+ },
{
"name": "phpoffice/phpspreadsheet",
"version": "1.30.5",
@@ -3996,6 +4048,114 @@
},
"time": "2026-05-31T05:13:11+00:00"
},
+ {
+ "name": "phpoffice/phpword",
+ "version": "1.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/PHPOffice/PHPWord.git",
+ "reference": "6d75328229bc93790b37e93741adf70646cea958"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/6d75328229bc93790b37e93741adf70646cea958",
+ "reference": "6d75328229bc93790b37e93741adf70646cea958",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-gd": "*",
+ "ext-json": "*",
+ "ext-xml": "*",
+ "ext-zip": "*",
+ "php": "^7.1|^8.0",
+ "phpoffice/math": "^0.3"
+ },
+ "require-dev": {
+ "dompdf/dompdf": "^2.0 || ^3.0",
+ "ext-libxml": "*",
+ "friendsofphp/php-cs-fixer": "^3.3",
+ "mpdf/mpdf": "^7.0 || ^8.0",
+ "phpmd/phpmd": "^2.13",
+ "phpstan/phpstan": "^0.12.88 || ^1.0.0",
+ "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
+ "phpunit/phpunit": ">=7.0",
+ "symfony/process": "^4.4 || ^5.0",
+ "tecnickcom/tcpdf": "^6.5"
+ },
+ "suggest": {
+ "dompdf/dompdf": "Allows writing PDF",
+ "ext-xmlwriter": "Allows writing OOXML and ODF",
+ "ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "PhpOffice\\PhpWord\\": "src/PhpWord"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "LGPL-3.0-only"
+ ],
+ "authors": [
+ {
+ "name": "Mark Baker"
+ },
+ {
+ "name": "Gabriel Bull",
+ "email": "me@gabrielbull.com",
+ "homepage": "http://gabrielbull.com/"
+ },
+ {
+ "name": "Franck Lefevre",
+ "homepage": "https://rootslabs.net/blog/"
+ },
+ {
+ "name": "Ivan Lanin",
+ "homepage": "http://ivan.lanin.org"
+ },
+ {
+ "name": "Roman Syroeshko",
+ "homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/"
+ },
+ {
+ "name": "Antoine de Troostembergh"
+ }
+ ],
+ "description": "PHPWord - A pure PHP library for reading and writing word processing documents (OOXML, ODF, RTF, HTML, PDF)",
+ "homepage": "https://phpoffice.github.io/PHPWord/",
+ "keywords": [
+ "ISO IEC 29500",
+ "OOXML",
+ "Office Open XML",
+ "OpenDocument",
+ "OpenXML",
+ "PhpOffice",
+ "PhpWord",
+ "Rich Text Format",
+ "WordprocessingML",
+ "doc",
+ "docx",
+ "html",
+ "odf",
+ "odt",
+ "office",
+ "pdf",
+ "php",
+ "reader",
+ "rtf",
+ "template",
+ "template processor",
+ "word",
+ "writer"
+ ],
+ "support": {
+ "issues": "https://github.com/PHPOffice/PHPWord/issues",
+ "source": "https://github.com/PHPOffice/PHPWord/tree/1.4.0"
+ },
+ "time": "2025-06-05T10:32:36+00:00"
+ },
{
"name": "phpoption/phpoption",
"version": "1.9.5",
diff --git a/database/migrations/2026_08_22_000001_add_department_and_start_date_to_user_table.php b/database/migrations/2026_08_22_000001_add_department_and_start_date_to_user_table.php
new file mode 100644
index 0000000..48bfbd0
--- /dev/null
+++ b/database/migrations/2026_08_22_000001_add_department_and_start_date_to_user_table.php
@@ -0,0 +1,23 @@
+string('department')->nullable()->after('address');
+ $table->date('start_date')->nullable()->after('department');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('user', function (Blueprint $table) {
+ $table->dropColumn(['department', 'start_date']);
+ });
+ }
+};
diff --git a/database/migrations/2026_08_22_000001_create_export_jobs_table.php b/database/migrations/2026_08_22_000001_create_export_jobs_table.php
new file mode 100644
index 0000000..c891699
--- /dev/null
+++ b/database/migrations/2026_08_22_000001_create_export_jobs_table.php
@@ -0,0 +1,32 @@
+id();
+ $table->uuid('uuid')->unique();
+ $table->string('type', 30);
+ $table->string('format', 10);
+ $table->json('filters')->nullable();
+ $table->json('fields')->nullable();
+ $table->string('status', 20)->default('completed');
+ $table->string('file_path')->nullable();
+ $table->unsignedInteger('total_rows')->nullable();
+ $table->text('error')->nullable();
+ $table->bigInteger('created_by')->nullable()->index();
+ $table->timestamp('completed_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('export_jobs');
+ }
+};
diff --git a/database/migrations/2026_08_22_160227_create_domain_pricings_table.php b/database/migrations/2026_08_22_160227_create_domain_pricings_table.php
new file mode 100644
index 0000000..d226339
--- /dev/null
+++ b/database/migrations/2026_08_22_160227_create_domain_pricings_table.php
@@ -0,0 +1,128 @@
+id();
+ $table->enum('type', ['domain', 'service'])->default('domain');
+ $table->string('tld')->nullable(); // domain rows, e.g. ".com.vn"
+ $table->string('name')->nullable(); // service rows, e.g. "Chứng thực Rapid SSL"
+ $table->string('setup_fee')->default('Miễn phí'); // "Miễn phí" or a numeric string
+ $table->unsignedBigInteger('first_year')->nullable(); // domain only
+ $table->string('recurring_fee')->nullable(); // domain: renewal (numeric). service: maintenance ("Miễn phí" or numeric)
+ $table->text('note')->nullable(); // service only
+ $table->unsignedInteger('sort_order')->default(0);
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+
+ $this->seedFromExistingSetting();
+ }
+
+ /**
+ * Carry over whatever is in the `domain_pricing` Setting (admin-customized
+ * or the built-in defaults) so switching storage doesn't lose data.
+ */
+ private function seedFromExistingSetting(): void
+ {
+ $setting = DB::table('setting')->where('key', 'domain_pricing')->first();
+ $value = $setting ? json_decode((string) $setting->value, true) : null;
+
+ if (!is_array($value) || empty($value)) {
+ $value = $this->defaultData();
+ }
+
+ $now = now();
+ $rows = [];
+
+ foreach (($value['domains'] ?? []) as $i => $d) {
+ $rows[] = [
+ 'type' => 'domain',
+ 'tld' => $d['tld'] ?? null,
+ 'name' => null,
+ 'setup_fee' => (string) ($d['setupFee'] ?? 'Miễn phí'),
+ 'first_year' => $d['firstYear'] ?? 0,
+ 'recurring_fee' => (string) ($d['renewal'] ?? 0),
+ 'note' => null,
+ 'sort_order' => $i,
+ 'is_active' => true,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ];
+ }
+
+ foreach (($value['services'] ?? []) as $i => $s) {
+ $rows[] = [
+ 'type' => 'service',
+ 'tld' => null,
+ 'name' => $s['name'] ?? null,
+ 'setup_fee' => (string) ($s['setupFee'] ?? 'Miễn phí'),
+ 'first_year' => null,
+ 'recurring_fee' => (string) ($s['maintenance'] ?? 'Miễn phí'),
+ 'note' => $s['note'] ?? null,
+ 'sort_order' => $i,
+ 'is_active' => true,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ];
+ }
+
+ if (!empty($rows)) {
+ DB::table('domain_pricings')->insert($rows);
+ }
+ }
+
+ private function defaultData(): array
+ {
+ return [
+ 'domains' => [
+ ['tld' => '.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 400000, 'renewal' => 400000],
+ ['tld' => '.com.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000],
+ ['tld' => '.net.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000],
+ ['tld' => '.org.vn', 'setupFee' => 'Miễn phí', 'firstYear' => 300000, 'renewal' => 300000],
+ ['tld' => '.com', 'setupFee' => 'Miễn phí', 'firstYear' => 320000, 'renewal' => 340000],
+ ['tld' => '.net', 'setupFee' => 'Miễn phí', 'firstYear' => 369000, 'renewal' => 409000],
+ ['tld' => '.org', 'setupFee' => 'Miễn phí', 'firstYear' => 339000, 'renewal' => 369000],
+ ['tld' => '.biz', 'setupFee' => 'Miễn phí', 'firstYear' => 399000, 'renewal' => 409000],
+ ['tld' => '.info', 'setupFee' => 'Miễn phí', 'firstYear' => 490000, 'renewal' => 509000],
+ ['tld' => '.pro', 'setupFee' => 'Miễn phí', 'firstYear' => 469000, 'renewal' => 489000],
+ ['tld' => '.name', 'setupFee' => 'Miễn phí', 'firstYear' => 259000, 'renewal' => 259000],
+ ['tld' => '.ws', 'setupFee' => 'Miễn phí', 'firstYear' => 909000, 'renewal' => 909000],
+ ['tld' => '.tv', 'setupFee' => 'Miễn phí', 'firstYear' => 765000, 'renewal' => 819000],
+ ['tld' => '.mobi', 'setupFee' => 'Miễn phí', 'firstYear' => 589000, 'renewal' => 589000],
+ ['tld' => '.uk', 'setupFee' => 'Miễn phí', 'firstYear' => 537000, 'renewal' => 537000],
+ ['tld' => '.asia', 'setupFee' => 'Miễn phí', 'firstYear' => 380000, 'renewal' => 452000],
+ ['tld' => '.me', 'setupFee' => 'Miễn phí', 'firstYear' => 1029000, 'renewal' => 1029000],
+ ['tld' => '.us', 'setupFee' => 'Miễn phí', 'firstYear' => 323000, 'renewal' => 323000],
+ ],
+ 'services' => [
+ ['name' => 'Chứng thực Rapid SSL', 'setupFee' => 230000, 'maintenance' => 750000, 'note' => '01 Domain'],
+ ['name' => 'Chứng thực Quick SSL', 'setupFee' => 230000, 'maintenance' => 2300000, 'note' => '01 Domain'],
+ ['name' => 'Chứng thực Wildcard SSL', 'setupFee' => 230000, 'maintenance' => 3300000, 'note' => 'Unlimited Subdomain'],
+ ['name' => 'Chứng thực True BusinessID', 'setupFee' => 540000, 'maintenance' => 3900000, 'note' => '01 Domain'],
+ ['name' => 'Chứng thực True BusinessID WildCard', 'setupFee' => 900000, 'maintenance' => 15000000, 'note' => 'Unlimited Subdomain'],
+ ['name' => 'Đăng ký DNS riêng', 'setupFee' => 200000, 'maintenance' => 'Miễn phí', 'note' => "dns1.tenmiencuaban.com\ndns2.tenmiencuaban.com"],
+ ['name' => 'Bảo mật thông tin tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 90000, 'note' => 'Toàn bộ thông tin Whois tên miền của bạn sẽ được giấu kín hoàn toàn với mọi công cụ Whois'],
+ ['name' => 'Khóa tên miền', 'setupFee' => 'Miễn phí', 'maintenance' => 'Miễn phí', 'note' => 'Tăng cường bảo mật cho tên miền'],
+ ],
+ ];
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('domain_pricings');
+ }
+};
diff --git a/database/migrations/2026_08_22_170000_add_registrant_info_to_services_table.php b/database/migrations/2026_08_22_170000_add_registrant_info_to_services_table.php
new file mode 100644
index 0000000..cc209e2
--- /dev/null
+++ b/database/migrations/2026_08_22_170000_add_registrant_info_to_services_table.php
@@ -0,0 +1,23 @@
+string('registrant_type', 20)->nullable()->after('rejection_reason');
+ $table->json('registrant_info')->nullable()->after('registrant_type');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('services', function (Blueprint $table) {
+ $table->dropColumn(['registrant_type', 'registrant_info']);
+ });
+ }
+};
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..1f7e045
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,31 @@
+
+
CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM
Độc lập - Tự do - Hạnh phúc
BẢN KHAI ĐĂNG KÝ TÊN MIỀN QUỐC TẾ
(Dành cho cá nhân đăng ký, sử dụng tên miền)
1. Tên miền đăng ký | ${domain_name} |
2. Nhà đăng ký tên miền | Công ty TNHH phần mềm Nhân Hòa |
3. Thông tin chủ thể | |
Họ và tên cá nhân *: | ${customer.fullname} |
Ngày tháng năm sinh *: | ${customer.birth_date} |
Số định danh cá nhân/Hộ chiếu *: | ${customer.indentity_number} |
Địa chỉ thường trú: | ${customer.address} |
Số nhà, tên đường/thôn xóm *: | |
Xã/phường *: | ${customer.ward} |
Tỉnh/thành phố trực thuộc trung ương *: | ${customer.province} |
Quốc gia *: | Vietnam |
Địa chỉ tạm trú: | |
Số nhà, tên đường/thôn xóm *: | |
Xã/phường *: | |
Tỉnh/thành phố trực thuộc trung ương *: | |
Quốc gia *: | |
Số điện thoại *: | ${customer.phone} |
Email *: | admin@tgs.com.vn |
Lưu ý: - Các thông tin có dấu (*) là bắt buộc, trường hợp chủ thể có đăng ký tạm trú thì thông tin (**) là bắt buộc.
Chủ thể đăng ký sử dụng tên miền cam kết tuân thủ các quy định của pháp luật về quản lý và sử dụng tài nguyên Internet và các quy định pháp luật khác có liên quan.
Xác nhận của chủ thể đăng ký tên miền (Chủ thể ký và ghi rõ họ tên hoặc ký số) ${customer.full_name} |
CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM
+Độc lập - Tự do - Hạnh phúc
+BẢN KHAI ĐĂNG KÝ TÊN MIỀN QUỐC TẾ
+(Dành cho cá nhân đăng ký, sử dụng tên miền)
+ +1. Tên miền đăng ký: {{ $domain }}
+2. Nhà đăng ký tên miền: Công ty TNHH phần mềm Nhân Hòa
+ +3. Thông tin chủ thể
+Họ và tên cá nhân *: {{ $r['fullname'] ?? '' }}
+Ngày tháng năm sinh *: {{ $r['birth_date'] ?? '' }}
+Số định danh cá nhân/Hộ chiếu *: {{ $r['identity_number'] ?? '' }}
+Địa chỉ thường trú:
+Số nhà, tên đường/thôn xóm *: {{ $r['perm_address'] ?? '' }}
+Xã/phường *: {{ $r['perm_ward'] ?? '' }}
+Tỉnh/thành phố trực thuộc trung ương *: {{ $r['perm_province'] ?? '' }}
+Quốc gia *: Vietnam
+Địa chỉ tạm trú:
+Số nhà, tên đường/thôn xóm: {{ $r['temp_address'] ?? '' }}
+Xã/phường: {{ $r['temp_ward'] ?? '' }}
+Tỉnh/thành phố trực thuộc trung ương: {{ $r['temp_province'] ?? '' }}
+Số điện thoại *: {{ $r['phone'] ?? '' }}
+Email *: {{ $r['email'] ?? '' }}
+ +Lưu ý: Các thông tin có dấu (*) là bắt buộc, trường hợp chủ thể có đăng ký tạm trú thì thông tin (**) là bắt buộc.
+Chủ thể đăng ký sử dụng tên miền cam kết tuân thủ các quy định của pháp luật về quản lý và sử dụng tài nguyên Internet và các quy định pháp luật khác có liên quan.
+ ++
{{ $r['sign_place'] ?? '' }}, ngày {{ $signDate->format('d') }} tháng {{ $signDate->format('m') }} năm {{ $signDate->format('Y') }}
+Xác nhận của chủ thể đăng ký tên miền
+(Chủ thể ký và ghi rõ họ tên hoặc ký số)
++
+
{{ $r['fullname'] ?? '' }}
diff --git a/resources/views/domain/registrant-tochuc.blade.php b/resources/views/domain/registrant-tochuc.blade.php new file mode 100644 index 0000000..a179ae4 --- /dev/null +++ b/resources/views/domain/registrant-tochuc.blade.php @@ -0,0 +1,62 @@ +@php + $signDate = \Illuminate\Support\Carbon::parse($r['sign_date'] ?? now()); +@endphp +CỘNG HÒA XÃ HỘI CHỦ NGHĨA VIỆT NAM
+Độc lập - Tự do - Hạnh phúc
+BẢN KHAI ĐĂNG KÝ TÊN MIỀN QUỐC TẾ
+(Dành cho cơ quan, tổ chức, doanh nghiệp đăng ký, sử dụng tên miền)
+ +1. Tên miền đăng ký: {{ $domain }}
+2. Nhà đăng ký tên miền: Công ty TNHH phần mềm Nhân Hòa
+ +3. Chủ thể đăng ký tên miền
+Tên cơ quan, tổ chức, doanh nghiệp *: {{ $r['org_name'] ?? '' }}
+Mã định danh điện tử/Mã số doanh nghiệp/Mã số thuế *: {{ $r['org_tax_code'] ?? '' }}
+Số nhà, tên đường/thôn xóm *: {{ $r['org_address'] ?? '' }}
+Xã/phường *: {{ $r['org_ward'] ?? '' }}
+Tỉnh/thành phố trực thuộc trung ương *: {{ $r['org_province'] ?? '' }}
+Quốc gia *: Vietnam
+Số điện thoại *: {{ $r['org_phone'] ?? '' }}
+Email *: {{ $r['org_email'] ?? '' }}
+ +4. Thông tin người quản lý tên miền (người đại diện theo pháp luật của cơ quan, tổ chức, doanh nghiệp là chủ thể tên miền)
+Họ và tên *: {{ $r['manager_name'] ?? '' }}
+Số định danh cá nhân/Hộ chiếu *: {{ $r['manager_identity_number'] ?? '' }}
+Số nhà, tên đường/thôn xóm *: {{ $r['manager_address'] ?? '' }}
+Xã/phường *: {{ $r['manager_ward'] ?? '' }}
+Tỉnh/thành phố trực thuộc trung ương *: {{ $r['manager_province'] ?? '' }}
+Quốc gia *: Vietnam
+Số điện thoại *: {{ $r['manager_phone'] ?? '' }}
+Email *: {{ $r['manager_email'] ?? '' }}
+ +5. Thông tin người quản lý kỹ thuật
+Họ và tên *: {{ $r['tech_name'] ?? '' }}
+Số nhà, tên đường/thôn xóm *: {{ $r['tech_address'] ?? '' }}
+Xã/phường *: {{ $r['tech_ward'] ?? '' }}
+Tỉnh/thành phố trực thuộc trung ương *: {{ $r['tech_province'] ?? '' }}
+Quốc gia *: Vietnam
+Số điện thoại *: {{ $r['tech_phone'] ?? '' }}
+Email *: {{ $r['tech_email'] ?? '' }}
+ +6. Thông tin của tổ chức hoặc cá nhân nộp phí, lệ phí đăng ký sử dụng tên miền
+Tên tổ chức nộp phí, lệ phí *: {{ $r['payer_org_name'] ?? '' }}
+Họ và tên người nộp phí, lệ phí *: {{ $r['payer_name'] ?? '' }}
+Số nhà, tên đường/thôn xóm *: {{ $r['payer_address'] ?? '' }}
+Xã/phường *: {{ $r['payer_ward'] ?? '' }}
+Tỉnh/thành phố trực thuộc trung ương *: {{ $r['payer_province'] ?? '' }}
+Quốc gia *: Vietnam
+Số điện thoại *: {{ $r['payer_phone'] ?? '' }}
+Email *: {{ $r['payer_email'] ?? '' }}
+ +Lưu ý: Các thông tin có dấu (*) là bắt buộc.
+Chủ thể đăng ký tên miền cam kết tuân thủ các quy định của pháp luật về quản lý và sử dụng tài nguyên Internet.
+ ++
{{ $r['sign_place'] ?? '' }}, ngày {{ $signDate->format('d') }} tháng {{ $signDate->format('m') }} năm {{ $signDate->format('Y') }}
+Xác nhận của cơ quan, tổ chức, doanh nghiệp
+NGƯỜI ĐẠI DIỆN THEO PHÁP LUẬT/ NGƯỜI ĐỨNG ĐẦU CƠ QUAN, TỔ CHỨC, DOANH NGHIỆP
+(Ký, ghi rõ họ tên, chức danh và đóng dấu/ký số)
++
+
{{ $r['manager_name'] ?? '' }}
+{{ $r['signer_title'] ?? '' }}
diff --git a/resources/views/emails/admin/domain-order-new.blade.php b/resources/views/emails/admin/domain-order-new.blade.php new file mode 100644 index 0000000..96dbdaa --- /dev/null +++ b/resources/views/emails/admin/domain-order-new.blade.php @@ -0,0 +1,25 @@ +Có một đơn đăng ký tên miền mới vừa được đặt tại {{ $company_name }}.
+ +Thông tin đơn hàng:
+| + XEM ĐƠN HÀNG + | +
+ Lưu ý: Đây là tin nhắn tự động. Vui lòng không trả lời tin nhắn này! +
diff --git a/resources/views/emails/customer/domain-order-received.blade.php b/resources/views/emails/customer/domain-order-received.blade.php new file mode 100644 index 0000000..bfe3e9d --- /dev/null +++ b/resources/views/emails/customer/domain-order-received.blade.php @@ -0,0 +1,33 @@ ++ Kính chào {{ $customer_name }}, +
+ ++ Cảm ơn Quý khách đã đặt mua tên miền tại {{ $company_name }}. Đơn hàng của Quý khách đang chờ đội ngũ kỹ thuật kiểm tra và đăng ký thủ công. +
+ +Thông tin đơn hàng:
++ Lưu ý: việc đặt mua không đồng nghĩa tên miền đã được đăng ký thành công. Nếu tên miền không còn khả dụng, số dư đã thanh toán sẽ được hoàn lại vào tài khoản. Quý khách sẽ nhận được email khi tên miền được đăng ký thành công, kèm theo các thông tin KYC cần bổ sung (nếu có). +
+ +| + XEM ĐƠN HÀNG CỦA TÔI + | +
+ Email này được gửi tự động. Vui lòng không trả lời email này. +
diff --git a/routes/api.php b/routes/api.php index 7426860..bd83a2f 100644 --- a/routes/api.php +++ b/routes/api.php @@ -12,7 +12,7 @@ use App\Http\Controllers\Api\Admin\Customer\CustomerController; use App\Http\Controllers\Api\Admin\Employee\EmployeeController; use App\Http\Controllers\Api\Admin\Role\RolePermissionController; use App\Http\Controllers\Api\Admin\Invoice\InvoiceController; -use App\Http\Controllers\Api\Admin\Invoice\InvoiceTestController; +use App\Http\Controllers\Api\Admin\Report\ExportController; use App\Http\Controllers\Api\Admin\Order\OrderController; use App\Http\Controllers\Api\Admin\Payment\PaymentController; use App\Http\Controllers\Api\Admin\Addon\AddonController; @@ -50,7 +50,6 @@ use App\Models\ModuleVmware; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; -use Barryvdh\DomPDF\Facade\Pdf; /* |-------------------------------------------------------------------------- @@ -64,24 +63,16 @@ use Barryvdh\DomPDF\Facade\Pdf; */ Route::prefix('v1') - ->group(function () { - // Invoice Test — Send invoice email with PDF attachment - Route::prefix('invoice-test') - ->controller(InvoiceTestController::class) - ->group(function () { - Route::post('{invoiceNumber}/send-email', 'sendInvoiceEmail'); - Route::get('{invoiceNumber}/preview-pdf', 'previewInvoicePdf'); - }); - }) /* Api for Admin */ ->group(function () { Route::prefix('administrator')->group(function () { - Route::post('login', [AuthController::class, 'login']); + Route::post('login', [AuthController::class, 'login'])->middleware('throttle:5,1'); Route::middleware('api.auth:admin')->group(function () { Route::post('logout', [AuthController::class, 'logout']); Route::post('refresh', [AuthController::class, 'refresh']); Route::get('me', [AuthController::class, 'me']); + Route::put('profile', [AuthController::class, 'updateProfile']); // Role Permissions Route::prefix('role-permissions')->group(function () { @@ -89,6 +80,7 @@ Route::prefix('v1') Route::get('mine', [RolePermissionController::class, 'mine']); // Các endpoint sau chỉ dành cho Full Administrator (kiểm tra trong controller) Route::get('/', [RolePermissionController::class, 'index']); + Route::post('/', [RolePermissionController::class, 'store']); Route::get('all-permissions', [RolePermissionController::class, 'allPermissions']); Route::post('permissions', [RolePermissionController::class, 'createPermissionGroup']); Route::delete('permissions/group/{module}', [ @@ -327,6 +319,18 @@ Route::prefix('v1') Route::post('{id}/send-mail', 'sendMail')->middleware('perm:invoice.edit'); }); + // Reports / Data exports + Route::prefix('reports/export') + ->controller(ExportController::class) + ->middleware('perm:report.view') + ->group(function () { + Route::get('history', 'history'); + Route::post('preview', 'preview'); + Route::post('/', 'store'); + Route::get('{uuid}', 'show'); + Route::get('{uuid}/download', 'download'); + }); + // Payments (module: payment) Route::prefix('payments') ->controller(PaymentController::class) @@ -471,11 +475,15 @@ Route::prefix('v1') Route::patch('{id}/kyc', 'updateKyc')->middleware('perm:order.edit'); Route::patch('{id}/activate', 'activate')->middleware('perm:order.edit'); Route::post('{id}/reject-and-refund', 'rejectAndRefund')->middleware('perm:order.edit'); + Route::get('{id}/registrant', 'getRegistrant')->middleware('perm:order.view'); + Route::patch('{id}/registrant', 'updateRegistrant')->middleware('perm:order.edit'); + Route::get('{id}/registrant/docx', 'downloadRegistrantDocx')->middleware('perm:order.view'); }); // Domain Pricing (module: settings) Route::prefix('domain-pricing') ->controller(AdminDomainPricingController::class) + ->middleware('perm:setting.manager') ->group(function () { Route::get('/', 'index'); Route::put('/', 'update'); @@ -496,6 +504,7 @@ Route::prefix('v1') // Settings (module: administrator) Route::prefix('settings') ->controller(AdminSettingController::class) + ->middleware('perm:setting.manager') ->group(function () { Route::get('/', 'index'); Route::put('/', 'update'); @@ -577,6 +586,7 @@ Route::prefix('v1') // Banks — deposit bank accounts (module: settings) Route::prefix('banks') ->controller(BankController::class) + ->middleware('perm:setting.manager') ->group(function () { Route::get('/', 'index'); Route::post('/', 'store'); @@ -588,6 +598,7 @@ Route::prefix('v1') // Email Templates (module: settings) Route::prefix('email-templates') ->controller(EmailTemplateController::class) + ->middleware('perm:email-template.manager') ->group(function () { Route::get('/', 'index'); Route::post('/', 'store'); @@ -602,6 +613,7 @@ Route::prefix('v1') // Notification Email Templates — block-based editor Route::prefix('notification-templates') ->controller(EmailNotificationTemplateController::class) + ->middleware('perm:email-template.manager') ->group(function () { Route::get('/', 'index'); Route::post('/', 'store'); @@ -658,10 +670,10 @@ Route::prefix('v1') Route::prefix('tickets') ->controller(AdminTicketController::class) ->group(function () { - Route::get('/', 'index'); - Route::get('{ticket_num}', 'show'); - Route::patch('{ticket_num}/status', 'updateStatus'); - Route::post('{ticket_num}/reply', 'reply'); + Route::get('/', 'index')->middleware('perm:support.view'); + Route::get('{ticket_num}', 'show')->middleware('perm:support.view'); + Route::patch('{ticket_num}/status', 'updateStatus')->middleware('perm:support.edit'); + Route::post('{ticket_num}/reply', 'reply')->middleware('perm:support.edit'); }); Route::prefix('notifications') @@ -685,6 +697,7 @@ Route::prefix('v1') // Activity Logs Route::prefix('activity-logs') ->controller(ActivityLogController::class) + ->middleware('perm:system-log.manager') ->group(function () { Route::get('/', 'index'); Route::get('modules', 'modules'); @@ -695,6 +708,7 @@ Route::prefix('v1') // Email Logs Route::prefix('email-logs') ->controller(EmailLogController::class) + ->middleware('perm:system-log.manager') ->group(function () { Route::get('/', 'index'); Route::get('{id}', 'show')->whereNumber('id'); diff --git a/routes/client.php b/routes/client.php index 432e53e..7b90a89 100644 --- a/routes/client.php +++ b/routes/client.php @@ -31,7 +31,7 @@ Route::prefix('v1') ->as('client.') ->group(function () { Route::controller(ClientController::class)->group(function () { - Route::post('login', 'login')->name('login'); + Route::post('login', 'login')->middleware('throttle:5,1')->name('login'); Route::post('register', 'register')->name('register'); Route::post('verify-email', 'verifyEmail')->name('verify-email'); Route::post('resend-verify', 'resendVerifyCode')->name('resend-verify'); @@ -89,6 +89,10 @@ Route::prefix('v1') Route::delete('{domainId}/records/{recordId}', 'destroyRecord')->name('records.destroy'); }); + Route::middleware(['api.auth']) + ->get('overview', [ClientServiceController::class, 'overview']) + ->name('overview'); + Route::middleware(['api.auth']) ->prefix('client-service') ->controller(ClientServiceController::class) diff --git a/storage/app/exports/client_421c4d18-98c9-4049-8008-f4e862cb0b1e.csv b/storage/app/exports/client_421c4d18-98c9-4049-8008-f4e862cb0b1e.csv new file mode 100644 index 0000000..e4f0d26 --- /dev/null +++ b/storage/app/exports/client_421c4d18-98c9-4049-8008-f4e862cb0b1e.csv @@ -0,0 +1,2 @@ +"Client ID","Name","Company","Email","Phone","Address","City","State","Postcode","Country","Status","Created At" +"1","Bảo","","pbao.dev@gmail.com","0929768789","","","","","","Actived","2026-07-21 14:49" diff --git a/storage/app/exports/client_ae4b6dd7-3f1d-4064-a085-9be28114753d.csv b/storage/app/exports/client_ae4b6dd7-3f1d-4064-a085-9be28114753d.csv new file mode 100644 index 0000000..e4f0d26 --- /dev/null +++ b/storage/app/exports/client_ae4b6dd7-3f1d-4064-a085-9be28114753d.csv @@ -0,0 +1,2 @@ +"Client ID","Name","Company","Email","Phone","Address","City","State","Postcode","Country","Status","Created At" +"1","Bảo","","pbao.dev@gmail.com","0929768789","","","","","","Actived","2026-07-21 14:49" diff --git a/storage/app/exports/clients_8e6fbfd6-c9b1-46d2-9454-640f59df0a33.csv b/storage/app/exports/clients_8e6fbfd6-c9b1-46d2-9454-640f59df0a33.csv new file mode 100644 index 0000000..5bd269b --- /dev/null +++ b/storage/app/exports/clients_8e6fbfd6-c9b1-46d2-9454-640f59df0a33.csv @@ -0,0 +1,87 @@ +"Client ID","Name","Company","Email","Phone","Address","City","State","Postcode","Country","Status","Created At" +"1","Bảo","","pbao.dev@gmail.com","0929768789","","","","","","Actived","2026-07-21 14:49" +"2","Doan Anh Khoa","","Khoa.doananh@voicecloud.vn","","","","","","","Actived","2026-07-25 08:24" +"3","Thế Giới Số","","info@tgs.com.vn","","","","","","","Actived","2025-01-16 00:53" +"7","Admin Domain TGS","","sang@tgs.com.vn","","","","","","","Actived","2025-02-05 01:39" +"8","binhphan.ttv@gmail.com","","binhphan.ttv@gmail.com","","","","","","","Actived","2025-02-05 01:47" +"9","Nguyễn Lê Anh Tuân","","kelvin@overate-vntech.com","","","","","","","Actived","2025-02-14 07:36" +"10","Nguyễn Hữu Dương","","nhd.hsevn@gmail.com","","","","","","","Actived","2025-02-25 02:11" +"11","Phạm Văn Lịch","","vienkhoahoc8@gmail.com","","","","","","","Actived","2025-02-26 02:39" +"12","Chị Phương Mai","","humspavn@gmail.com","","","","","","","Actived","2025-02-26 02:48" +"13","Anh Phạm Quốc Duy","","inbaobidinhphongold@gmail.com","","","","","","","Actived","2025-03-03 09:14" +"14","CÔNG TY TNHH VẬT LIỆU XÂY DỰNG VÂN ĐAN","","ktnguyendan2019@gmail.com","","","","","","","Actived","2025-03-04 04:43" +"16","Nguyễn Quang Huy","","webmythuat@gmail.com","","","","","","","Actived","2025-03-05 09:56" +"17","Trần Thị Thanh Tâm","","4thouses@gmail.com","","","","","","","Actived","2025-03-07 02:24" +"18","FoodPassion.vn","","logan.tran@foodpassion.vn","","","","","","","Actived","2025-03-14 01:54" +"19","Phan Hữu Tuấn","","phanhuutuan1010@gmail.com","","","","","","","Actived","2025-03-14 03:36" +"20","CÔNG TY CỔ PHẦN KỸ THUẬT A&A","","Danh.cc@minhlamcons.com","","","","","","","Actived","2025-03-17 01:58" +"21","CÔNG TY TNHH SẢN XUẤT THƯƠNG MẠI NHẬT BẢO PHÁT","","Quatangcatansa@gmail.com","","","","","","","Actived","2025-03-21 04:22" +"22","CÔNG TY TNHH GIÁO DỤC VIỆT ĐỨC","","Mandarinuc@yahoo.com","","","","","","","Actived","2025-03-24 09:34" +"24","Anh Khổng Minh Châu","","admin@chaukhongmedia.vn","","","","","","","Actived","2025-04-01 09:48" +"25","Nguyễn Thị Thủy","","Nguyenthuy312@gmail.com","","","","","","","Actived","2025-04-15 04:17" +"26","Nguyễn Khánh Nguyên","","nguyennkvn16@gmail.com","","","","","","","Actived","2025-04-15 06:49" +"27","Đinh Hoàng Nam","","Ths.dinhhoangnam@gmail.com","","","","","","","Actived","2025-04-17 14:30" +"28","H.A.I","","Huynhtronghien1405@gmail.com","","","","","","","Actived","2025-04-21 12:17" +"29","CÔNG TY TNHH NỆM THẮNG LỢI","","Tiennguyen4798@gmail.com","","","","","","","Actived","2025-04-25 08:53" +"30","CÔNG TY TNHH ĐẦU TƯ PHÁT TRIỂN VÀ DỊCH VỤ TAM ANH","","Phamvannang@gmail.com","","","","","","","Actived","2025-05-02 14:59" +"31","CÔNG TY TNHH SCAR CENTER","","Nguyenthuhongdl@gmail.com","","","","","","","Actived","2025-05-05 10:28" +"32","Đinh Văn Trường","","dinhtruong27091988@gmail.com","","","","","","","Actived","2025-05-13 02:47" +"35","sangdev","","sangdev@gmail.com","","","","","","","Actived","2025-06-03 08:44" +"36","Nguyễn Thị Thu Hồng","","nguyenthuhongdl2@gmail.com","","","","","","","Actived","2025-06-03 08:53" +"37","Công ty TNHH THƯƠNG MẠI DỊCH VỤ QUỐC TẾ SEN VÀNG","","Connect@senvangpackaging.com","","","","","","","Actived","2025-06-06 03:06" +"38","CÔNG TY CỔ PHẦN CÔNG NGHỆ HÓA SINH SINOTEC","","Buitruong.vt@gmail.com","","","","","","","Actived","2025-06-07 05:13" +"39","Võ Thành An","","thanhan29@gmail.com","","","","","","","Actived","2025-06-14 09:24" +"40","Bảo","","pbao.business@gmail.com","","","","","","","Actived","2025-06-16 10:28" +"41","Anh Mười","","muoi@nhonmy.com","","","","","","","Actived","2025-06-19 03:31" +"42","Anh Bình - BFM","","Thuythanhbinh1983@gmail.com","","","","","","","Actived","2025-06-19 11:06" +"43","CÔNG TY CP XÂY DỰNG MỸ PHÚ GIA","","myphugia.info@gmail.com","","","","","","","Actived","2025-07-18 01:50" +"44","CÔNG TY TNHH DAKIA GROUP","","phungnhatthegts@gmail.com","","","","","","","Actived","2025-07-21 04:20" +"45","CÔNG TY CỔ PHẦN NỘI THẤT JOY VIỆT","","giap.nguyen@jvf-jsc.com","","","","","","","Actived","2025-07-24 08:29" +"46","CÔNG TY CỔ PHẦN HOÀN CẦU NHA TRANG","","itm@diamondbaycondotelresort.vn","","","","","","","Actived","2025-08-01 04:36" +"47","CÔNG TY TNHH TRUNG HOA LOGISTICS","","Hovangioi1202@gmail.com","","","","","","","Actived","2025-08-05 01:21" +"48","Trịnh Nhật Linh","","jennytrinh83@gmail.com","","","","","","","Actived","2025-08-08 09:45" +"49","CÔNG TY CỔ PHẦN THƯƠNG MẠI DỊCH VỤ VÀ VẬN TẢI ANTINA","","Acc@antinatransport.com","","","","","","","Actived","2025-08-18 07:55" +"50","CÔNG TY TNHH VI MINH LOGISTICS","","Mrdangtuankiet@gmail.com","","","","","","","Actived","2025-09-03 12:44" +"51","Võ Thuỵ Thuý Vi","","Lyont.art@gmail.com","","","","","","","Actived","2025-09-04 06:57" +"52","Phan Thanh Bình","","info@gilink.vn","","","","","","","Actived","2025-09-12 10:20" +"53","Huỳnh Thị Thu","","info@tennisxuxu.vn","","","","","","","Actived","2025-09-15 03:29" +"54","INDUSTRIALFLOOR PRODUCTS","","Tu.pham@infloor.com.vn","","","","","","","Actived","2025-09-19 08:11" +"55","CÔNG TY TNHH TK LINK LOGISTICS","","Qingshuichen@gmail.com","","","","","","","Actived","2025-10-07 02:18" +"56","Tăng Trọng Hiếu","","Tanghieu83@gmail.com","","","","","","","Actived","2025-10-07 03:22" +"57","CÔNG TY TNHH TONYLE","","study@tonyledu.com","","","","","","","Actived","2025-10-08 14:51" +"58","CÔNG TY TNHH SEA QUEEN DMC","","seaqueendmc@gmail.com","","","","","","","Actived","2025-10-09 04:21" +"59","Nguyễn Thái Dương","","thaiduongitc198@yahoo.com.vn","","","","","","","Actived","2025-10-13 07:21" +"60","Asian Dragon","","info@asiandragon.com.vn","","","","","","","Actived","2025-10-14 04:13" +"61","Phạm Thị Huyền","","nadi@nadi.com.vn","","","","","","","Actived","2025-10-14 04:18" +"62","CÔNG TY TNHH GIẢI PHÁP THƯƠNG HIỆU AMWIND","","jo@amwind.vn","","","","","","","Actived","2025-10-24 02:21" +"63","Hoàng Ngọc Tuấn Khoa","","tuankhoa.hoangngoc@gmail.com","","","","","","","Actived","2025-11-05 08:14" +"64","Nguyễn Thị Cẩm Giang","","Chebaba678@gmail.com","","","","","","","Actived","2025-11-06 01:37" +"65","CÔNG TY TNHH U&L LOGISTICS","","Leuyen1531984@gmail.com","","","","","","","Actived","2025-11-11 09:54" +"66","Gia Minh","","Gmc.giaminh@gmail.com","","","","","","","Actived","2025-11-13 01:05" +"67","nemcaocap.vn","","Tiennguyen47981@gmail.com","","","","","","","Actived","2025-11-17 09:21" +"68","Yến Sào Việt Nam","","trantaitham@gmail.com","","","","","","","Actived","2025-12-02 09:10" +"69","CÔNG TY TRÁCH NHIỆM HỮU HẠN NGÂN PHÁT TÀI","","Quiphamnhu@gmail.com","","","","","","","Actived","2025-12-03 10:22" +"70","Trần Tài Thâm","","trantaithamm@gmail.com","","","","","","","Actived","2025-12-05 10:28" +"71","CÔNG TY TNHH BIỂN VÀ BẦU TRỜI","","Vietnamvisa@skysea.vn","","","","","","","Actived","2025-12-11 08:31" +"72","Dương Hoàng Giang","","Tiennguyen4798@gmail.comm","","","","","","","Actived","2026-01-06 06:59" +"73","VIETPRODEV","","tanhhkhcndn@gmail.com","","","","","","","Actived","2026-01-14 08:01" +"74","Nguyenconghiep.com","","nguyenconghiep@gmail.com","","","","","","","Actived","2026-01-29 03:48" +"75","CÔNG TY LUẬT TRÁCH NHIỆM HỮU HẠN MẮT LUẬT","","matluatlawfirm@gmail.com","","","","","","","Actived","2026-02-02 07:47" +"76","CÔNG TY TNHH COLONY VIETNAM","","lucy.nguyen@colony.com.my","","","","","","","Actived","2026-02-26 08:07" +"77","CÔNG TY TNHH BAO BÌ ĐĨNH PHONG","","inbaobidinhphong@gmail.com","","","","","","","Actived","2026-03-03 04:51" +"78","Phạm Thị Phương Mai","","Phammai0672@gmail.com","","","","","","","Actived","2026-03-03 07:48" +"79","May Mặc Nadi","","marketinganhoavn@gmail.com","","","","","","","Actived","2026-03-05 06:43" +"80","CÔNG TY CỔ PHẦN TITAFA","","cskh.titafa@gmail.com","","","","","","","Actived","2026-03-11 07:09" +"81","Viện Phát triển Giáo dục Sài Gòn","","tanhhkhcndnn@gmail.com","","","","","","","Actived","2026-03-14 12:39" +"82","Nguyễn Trí Dũng","","dungdolby@gmail.com","","","","","","","Actived","2026-03-26 04:49" +"83","Trần Trịnh Quốc Long","","Trantrinhquoclong@gmail.com","","","","","","","Actived","2026-05-05 08:06" +"84","Nguyễn Mạnh Cường","","manhcuongntvn@gmail.com","","","","","","","Actived","2026-05-15 04:39" +"85","Trần Thị Khánh Châu","","chaut2635@gmail.com","","","","","","","Actived","2026-06-06 08:31" +"86","CÔNG TY TNHH KAO HOLDINGS VIỆT NAM","","Dannytuankao@gmail.com","","","","","","","Actived","2026-06-15 01:20" +"87","CÔNG TY TNHH CT EPARK PLAYWORKS","","admin@epark.vn","","","","","","","Actived","2026-06-15 11:59" +"88","Trương Trần Hoàng Huân","","Yen.nn.cbsxdh1@gmail.com","","","","","","","Actived","2026-06-25 03:24" +"89","Anh Bao","","baoqtt@gmail.com","","","","","","","Actived","2026-06-26 09:33" +"90","Anh Luan","","Luantran.365zina@gmail.com","","","","","","","Actived","2026-07-01 02:34" +"91","Lê Trọng Đôn","","trongthang2406@gmail.com","","","","","","","Actived","2026-07-01 08:09" +"92","Phạm Hồng Thịnh","","phamthinh0103@gmail.com","","","","","","","Actived","2026-07-13 10:33" +"93","Nguyễn Văn Chơn","","chonvnagst@gmail.com","","","","","","","Actived","2026-07-14 07:58" diff --git a/storage/app/exports/domains_be87dfb1-99a9-4f11-a2ba-3731c7d3ce80.csv b/storage/app/exports/domains_be87dfb1-99a9-4f11-a2ba-3731c7d3ce80.csv new file mode 100644 index 0000000..f5f0caa --- /dev/null +++ b/storage/app/exports/domains_be87dfb1-99a9-4f11-a2ba-3731c7d3ce80.csv @@ -0,0 +1 @@ +"Domain ID","Client ID","Domain","Expiry Date","Status" diff --git a/tests/Feature/Api/Admin/Customer/CustomerMassAssignmentTest.php b/tests/Feature/Api/Admin/Customer/CustomerMassAssignmentTest.php new file mode 100644 index 0000000..8a489f6 --- /dev/null +++ b/tests/Feature/Api/Admin/Customer/CustomerMassAssignmentTest.php @@ -0,0 +1,74 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + } + + public function test_store_ignores_fields_not_declared_in_validation_rules(): void + { + $response = $this->actingAs($this->admin, 'admin')->postJson('/api/v1/administrator/customers', [ + 'fullname' => 'Nguyen Van A', + 'email' => 'nva@example.com', + 'password' => 'secret123', + // Các field này nằm trong $fillable của Customer nhưng KHÔNG có trong + // CreateCustomerRequest::rules() — không được phép set qua endpoint tạo mới. + 'credit' => 999_999_999, + 'total' => 999_999_999, + 'status' => 1, + 'email_verified_at' => now()->toDateTimeString(), + ]); + + $response->assertCreated(); + + $customer = Customer::where('email', 'nva@example.com')->firstOrFail(); + $this->assertSame(0, (int) $customer->credit); + $this->assertSame(0, (int) $customer->total); + $this->assertNull($customer->email_verified_at); + } + + public function test_update_ignores_fields_not_declared_in_validation_rules(): void + { + $customer = Customer::factory()->create([ + 'credit' => 0, + 'total' => 0, + ]); + + $response = $this->actingAs($this->admin, 'admin')->putJson( + "/api/v1/administrator/customers/{$customer->uuid}", + [ + 'fullname' => 'Updated Name', + 'email' => $customer->email, + 'credit' => 999_999_999, + 'total' => 999_999_999, + ], + ); + + $response->assertOk(); + + $customer->refresh(); + $this->assertSame(0, (int) $customer->credit); + $this->assertSame(0, (int) $customer->total); + $this->assertSame('Updated Name', $customer->fullname); + } +} diff --git a/tests/Feature/Api/Admin/Customer/ResetPasswordTest.php b/tests/Feature/Api/Admin/Customer/ResetPasswordTest.php new file mode 100644 index 0000000..a115f63 --- /dev/null +++ b/tests/Feature/Api/Admin/Customer/ResetPasswordTest.php @@ -0,0 +1,54 @@ + 'Full Administrator', 'guard_name' => 'admin']); + $this->admin = User::factory()->create(); + $this->admin->assignRole('Full Administrator'); + + $this->customer = Customer::factory()->create([ + 'password' => Hash::make('old-password'), + ]); + } + + public function test_reset_password_sends_email_and_does_not_leak_password_in_response(): void + { + Mail::fake(); + $oldHash = $this->customer->password; + + $response = $this->actingAs($this->admin, 'admin') + ->postJson("/api/v1/administrator/customers/{$this->customer->uuid}/reset-password"); + + $response->assertOk(); + $response->assertJsonMissingPath('data.new_password'); + $response->assertJsonMissing(['new_password']); + + Mail::assertSent(PasswordResetByAdmin::class, function (PasswordResetByAdmin $mail) { + return $mail->hasTo($this->customer->email); + }); + + $this->assertNotSame($oldHash, $this->customer->fresh()->password); + } +} diff --git a/tests/Feature/Api/Admin/InvoiceTestRouteRemovedTest.php b/tests/Feature/Api/Admin/InvoiceTestRouteRemovedTest.php new file mode 100644 index 0000000..38e1bae --- /dev/null +++ b/tests/Feature/Api/Admin/InvoiceTestRouteRemovedTest.php @@ -0,0 +1,19 @@ +getJson('/api/v1/invoice-test/INV-000001/preview-pdf')->assertStatus(404); + $this->postJson('/api/v1/invoice-test/INV-000001/send-email')->assertStatus(404); + } +} diff --git a/tests/Feature/Api/Admin/LoginThrottleTest.php b/tests/Feature/Api/Admin/LoginThrottleTest.php new file mode 100644 index 0000000..50af3d9 --- /dev/null +++ b/tests/Feature/Api/Admin/LoginThrottleTest.php @@ -0,0 +1,40 @@ +postJson('/api/v1/administrator/login', [ + 'email' => 'nobody@example.com', + 'password' => 'wrong-password', + ]); + $this->assertNotSame(429, $response->status()); + } + + $response = $this->postJson('/api/v1/administrator/login', [ + 'email' => 'nobody@example.com', + 'password' => 'wrong-password', + ]); + + $response->assertStatus(429); + } +} diff --git a/tests/Feature/Api/Admin/PermissionGatingTest.php b/tests/Feature/Api/Admin/PermissionGatingTest.php new file mode 100644 index 0000000..440e847 --- /dev/null +++ b/tests/Feature/Api/Admin/PermissionGatingTest.php @@ -0,0 +1,80 @@ + $permission, 'guard_name' => 'admin']); + } + Permission::firstOrCreate(['name' => 'some-other.manager', 'guard_name' => 'admin']); + } + + private function makeAdminWithPermissions(array $permissions): User + { + $role = Role::create(['name' => 'role-' . uniqid(), 'guard_name' => 'admin']); + foreach ($permissions as $permission) { + $role->givePermissionTo( + Permission::firstOrCreate(['name' => $permission, 'guard_name' => 'admin']), + ); + } + + $admin = User::factory()->create(); + $admin->assignRole($role); + + return $admin; + } + + /** @dataProvider gatedRoutesProvider */ + public function test_route_requires_expected_permission(string $method, string $uri, string $requiredPermission): void + { + $unauthorized = $this->makeAdminWithPermissions(['some-other.manager']); + $this->actingAs($unauthorized, 'admin')->json($method, $uri)->assertStatus(403); + + $authorized = $this->makeAdminWithPermissions([$requiredPermission]); + $response = $this->actingAs($authorized, 'admin')->json($method, $uri); + $this->assertNotSame(403, $response->status(), "Expected {$uri} to be accessible with '{$requiredPermission}', got 403."); + } + + public static function gatedRoutesProvider(): array + { + return [ + 'settings index' => ['GET', '/api/v1/administrator/settings', 'setting.manager'], + 'banks index' => ['GET', '/api/v1/administrator/banks', 'setting.manager'], + 'domain-pricing index' => ['GET', '/api/v1/administrator/domain-pricing', 'setting.manager'], + 'email-templates index' => ['GET', '/api/v1/administrator/email-templates', 'email-template.manager'], + 'notification-templates' => ['GET', '/api/v1/administrator/notification-templates', 'email-template.manager'], + 'tickets index' => ['GET', '/api/v1/administrator/tickets', 'support.view'], + 'activity-logs index' => ['GET', '/api/v1/administrator/activity-logs', 'system-log.manager'], + 'email-logs index' => ['GET', '/api/v1/administrator/email-logs', 'system-log.manager'], + ]; + } + + public function test_full_administrator_bypasses_all_permission_gates(): void + { + Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']); + $admin = User::factory()->create(); + $admin->assignRole('Full Administrator'); + + foreach (self::gatedRoutesProvider() as [$method, $uri]) { + $response = $this->actingAs($admin, 'admin')->json($method, $uri); + $this->assertNotSame(403, $response->status(), "Full Administrator should not be blocked from {$uri}."); + } + } +} diff --git a/tests/Feature/Api/Service/RenewServiceTest.php b/tests/Feature/Api/Service/RenewServiceTest.php new file mode 100644 index 0000000..ee66ea4 --- /dev/null +++ b/tests/Feature/Api/Service/RenewServiceTest.php @@ -0,0 +1,126 @@ +customer = Customer::factory()->create(); + $this->product = Product::factory()->create([ + 'product_type' => 'hosting', + 'is_active' => true, + ]); + } + + private function actingAsCustomer(): static + { + return $this->actingAs($this->customer, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]); + } + + /** Encrypts the shared secret the same way ClientAccessTokenCheck expects, for test requests. */ + private function buildClientAccessToken(): string + { + $key = hex2bin((string) config('services.client_api.key')); + $secret = (string) config('services.client_api.secret'); + $iv = random_bytes(16); + $ciphertext = openssl_encrypt($secret, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); + + return base64_encode($iv . $ciphertext); + } + + private function createServiceWithOldPrice(int $oldPrice): Service + { + $order = Order::create([ + 'order_num' => 'ORD-' . uniqid(), + 'customer_id' => $this->customer->id, + 'sub_total' => $oldPrice, + 'total' => $oldPrice, + 'status' => OrderStatus::ACTIVE->value, + ]); + + $orderItem = OrderItem::create([ + 'order_id' => $order->id, + 'product_id' => $this->product->id, + 'billing_cycle' => 'monthly', + 'months' => 1, + 'quantity' => 1, + 'price' => $oldPrice, + ]); + + return Service::create([ + 'customer_id' => $this->customer->id, + 'product_id' => $this->product->id, + 'order_item_id' => $orderItem->id, + 'status' => 'active', + 'server_name' => 'test-server', + ]); + } + + public function test_renew_uses_current_catalog_price_not_the_original_order_price(): void + { + $service = $this->createServiceWithOldPrice(90_000); + + // Giá sản phẩm hiện tại đã tăng lên 100.000 kể từ lúc khách đặt mua. + ProductPricings::create([ + 'product_id' => $this->product->id, + 'billing_cycle' => 'monthly', + 'price' => 100_000, + 'is_active' => true, + ]); + + $response = $this->actingAsCustomer()->postJson( + "/v1/client/dich-vu/{$service->id}/renew", + ['months' => 2], + ); + + $response->assertOk(); + $response->assertJsonPath('data.total', 200_000); + + $this->assertDatabaseHas('invoices', [ + 'id' => $response->json('data.invoice_id'), + 'total' => 200_000, + ]); + } + + public function test_renew_fails_when_product_pricing_is_no_longer_active(): void + { + $service = $this->createServiceWithOldPrice(90_000); + + ProductPricings::create([ + 'product_id' => $this->product->id, + 'billing_cycle' => 'monthly', + 'price' => 100_000, + 'is_active' => false, + ]); + + $response = $this->actingAsCustomer()->postJson( + "/v1/client/dich-vu/{$service->id}/renew", + ['months' => 1], + ); + + $response->assertStatus(400); + $this->assertSame(0, Invoice::where('customer_id', $this->customer->id)->count()); + } +} diff --git a/tests/Feature/Api/Service/VmOwnershipTest.php b/tests/Feature/Api/Service/VmOwnershipTest.php new file mode 100644 index 0000000..609958d --- /dev/null +++ b/tests/Feature/Api/Service/VmOwnershipTest.php @@ -0,0 +1,47 @@ +create(); + $attacker = Customer::factory()->create(); + + $vm = ModuleVmware::create([ + 'name' => 'owner-vm', + 'customer_id' => $owner->id, + 'vmware_id' => 'vm-1001', + 'status' => 1, + ]); + + $response = $this->actingAs($attacker, 'client') + ->withHeaders(['x-access-token' => $this->buildClientAccessToken()]) + ->getJson('/v1/system/vm-power-state?product=' . $vm->id); + + // Query đã lọc theo customer_id nên VM của người khác không được tìm thấy (404) + // thay vì tìm thấy rồi mới bị chặn bởi check quyền sở hữu phía sau (403) — cả hai đều + // an toàn, nhưng 404 xác nhận query đã được scope đúng ngay từ đầu (defense-in-depth). + $response->assertStatus(404); + } +} diff --git a/tests/Unit/ServiceFillableTest.php b/tests/Unit/ServiceFillableTest.php new file mode 100644 index 0000000..cc58ac6 --- /dev/null +++ b/tests/Unit/ServiceFillableTest.php @@ -0,0 +1,31 @@ +create(); + + $service = Service::create([ + 'customer_id' => $customer->id, + 'server_name' => 'test-server', + 'status' => 'active', + // Không nằm trong $fillable — không được set qua mass-assignment. + 'legacy_id' => 999999, + ]); + + $this->assertSame('test-server', $service->server_name); + $this->assertNull($service->legacy_id); + } +}