diff --git a/.env.example b/.env.example
index 6fa2783..c5add21 100644
--- a/.env.example
+++ b/.env.example
@@ -93,3 +93,4 @@ CLIENT_API_ACCESS_KEY=
CLIENT_API_ACCESS_SECRET=
WMKS_HOST=10.10.10.120
+CLIENT_RESPONSE_KEY=
diff --git a/app/Console/Commands/ServiceExpiry.php b/app/Console/Commands/ServiceExpiry.php
index bab48ac..29057d2 100644
--- a/app/Console/Commands/ServiceExpiry.php
+++ b/app/Console/Commands/ServiceExpiry.php
@@ -15,6 +15,7 @@ use App\Services\AutoRenewFailureNotifier;
use App\Services\InvoicePaymentService;
use App\Services\RenewalInvoiceNotifier;
use App\Support\RenewalPricing;
+use App\Support\ServiceCancellation;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -33,6 +34,7 @@ class ServiceExpiry extends Command
->with(['orderItem.product', 'customer'])
->whereNotNull('expires_at')
->whereBetween('expires_at', [now(), now()->addDays($renewalWindowDays)])
+ ->where('cancel_at_expiry', false)
->whereNotIn('status', ['terminated', 'cancelled'])
->get();
@@ -63,6 +65,17 @@ class ServiceExpiry extends Command
foreach ($expired as $service) {
try {
+ if ($service->cancel_at_expiry) {
+ if ($dryRun) {
+ $this->line(" [dry-run:cancel] Service #{$service->id}");
+ } else {
+ ServiceCancellation::finalize($service);
+ Log::info('Service cancelled at expiry', ['service_id' => $service->id]);
+ $this->line(" [cancelled] Service #{$service->id}");
+ }
+ continue;
+ }
+
// Hướng tính tường minh (mốc quá khứ → now), absolute: true để không phụ thuộc
// việc Carbon 2 trả trị tuyệt đối — Carbon 3 trả số âm, điều kiện >= 30 sẽ
// không bao giờ đúng và không dịch vụ nào bị terminate nữa, hoàn toàn im lặng.
@@ -132,6 +145,7 @@ class ServiceExpiry extends Command
$services = Service::query()
->with('customer')
->where('auto_renew', true)
+ ->where('cancel_at_expiry', false)
->whereNotNull('invoice_id')
->whereNotNull('expires_at')
->where('expires_at', '<=', now()->addDays($windowDays))
diff --git a/app/Http/Controllers/Api/Admin/Product/ProductController.php b/app/Http/Controllers/Api/Admin/Product/ProductController.php
index da57f5b..6fb1418 100644
--- a/app/Http/Controllers/Api/Admin/Product/ProductController.php
+++ b/app/Http/Controllers/Api/Admin/Product/ProductController.php
@@ -30,7 +30,7 @@ class ProductController extends Controller
{
$perPage = min((int) $request->get('per_page', 15), 200);
$paginator = $this->products->paginate(
- $request->only('filter', 'type', 'is_active'),
+ $request->only('filter', 'type', 'is_active', 'product_categories_id'),
$perPage
);
diff --git a/app/Http/Controllers/Api/Admin/Service/Concerns/HandlesServiceAttachments.php b/app/Http/Controllers/Api/Admin/Service/Concerns/HandlesServiceAttachments.php
index cdc77bf..fcb18a3 100644
--- a/app/Http/Controllers/Api/Admin/Service/Concerns/HandlesServiceAttachments.php
+++ b/app/Http/Controllers/Api/Admin/Service/Concerns/HandlesServiceAttachments.php
@@ -28,11 +28,15 @@ trait HandlesServiceAttachments
public function uploadAttachment(Request $request, int $id): JsonResponse
{
Service::findOrFail($id);
- $request->validate(['file' => ServiceAttachment::UPLOAD_RULE]);
+ $audience = $request->validate([
+ 'file' => ServiceAttachment::UPLOAD_RULE,
+ 'audience' => 'nullable|in:' . ServiceAttachment::AUDIENCE_ADMIN . ',' . ServiceAttachment::AUDIENCE_CUSTOMER,
+ ])['audience'] ?? ServiceAttachment::AUDIENCE_ADMIN;
$file = $request->file('file');
$attachment = ServiceAttachment::create([
'service_id' => $id,
+ 'audience' => $audience,
'filename' => $file->getClientOriginalName(),
'path' => $file->store("service-attachments/{$id}", 'public'),
'disk' => 'public',
diff --git a/app/Http/Controllers/Api/Admin/Service/ServiceCancellationRequestController.php b/app/Http/Controllers/Api/Admin/Service/ServiceCancellationRequestController.php
new file mode 100644
index 0000000..2a02cad
--- /dev/null
+++ b/app/Http/Controllers/Api/Admin/Service/ServiceCancellationRequestController.php
@@ -0,0 +1,91 @@
+latest('id')
+ ->limit(20)
+ ->get(['id', 'reason_code', 'reason', 'status', 'admin_note', 'resolved_at', 'resolved_by', 'created_at']);
+
+ return response()->json(['error' => false, 'data' => $requests]);
+ }
+
+ public function resolve(Request $request, int $id, int $requestId): JsonResponse
+ {
+ $data = $request->validate([
+ 'action' => 'required|in:approve,reject',
+ 'note' => 'required_if:action,reject|nullable|string|max:1000',
+ ]);
+ $approve = $data['action'] === 'approve';
+
+ $result = DB::transaction(function () use ($id, $requestId, $data, $approve, $request) {
+ $cancellation = ServiceCancellationRequest::where('service_id', $id)->lockForUpdate()->findOrFail($requestId);
+ if ($cancellation->status !== ServiceCancellationRequest::STATUS_PENDING) {
+ return null;
+ }
+
+ $admin = $request->user();
+ $cancellation->update([
+ 'status' => $approve ? ServiceCancellationRequest::STATUS_APPROVED : ServiceCancellationRequest::STATUS_REJECTED,
+ 'admin_note' => filled($data['note'] ?? null) ? trim($data['note']) : null,
+ 'resolved_at' => now(),
+ 'resolved_by' => (string) ($admin?->fullname ?: $admin?->username ?: 'admin'),
+ ]);
+
+ $outcome = $approve ? ServiceCancellation::approve(Service::with('orderItem')->findOrFail($id)) : null;
+
+ return [$cancellation, $outcome];
+ });
+
+ [$result, $outcome] = $result ?? [null, null];
+
+ if ($result === null) {
+ return response()->json(['error' => true, 'message' => 'Yêu cầu này đã được xử lý.'], 422);
+ }
+
+ $this->notifyCustomer($result, $approve, $outcome);
+
+ $message = match (true) {
+ ! $approve => 'Đã từ chối yêu cầu hủy.',
+ $outcome === ServiceCancellation::SCHEDULED => 'Đã duyệt: dịch vụ dùng tới hết hạn rồi tự hủy.',
+ default => 'Đã duyệt và hủy dịch vụ.',
+ };
+
+ return response()->json(['error' => false, 'message' => $message, 'data' => $result]);
+ }
+
+ private function notifyCustomer(ServiceCancellationRequest $cancellation, bool $approve, ?string $outcome): void
+ {
+ $cancellation->loadMissing(['customer', 'service']);
+ if (! $cancellation->customer) {
+ return;
+ }
+
+ $name = $cancellation->service->server_name ?: 'dịch vụ của bạn';
+ $expiresAt = $cancellation->service->expires_at?->format('d/m/Y');
+ $approvedBody = $outcome === ServiceCancellation::SCHEDULED
+ ? "Dịch vụ {$name} sẽ không gia hạn nữa và vẫn sử dụng được đến hết {$expiresAt}."
+ : "Dịch vụ {$name} đã được hủy theo yêu cầu của bạn.";
+ $reason = $cancellation->admin_note ? " Ghi chú: {$cancellation->admin_note}" : '';
+
+ Notification::notifyCustomer($cancellation->customer, [
+ 'type' => 'service_cancellation_resolved',
+ 'title' => $approve ? 'Yêu cầu hủy dịch vụ đã được duyệt' : 'Yêu cầu hủy dịch vụ bị từ chối',
+ 'body' => ($approve ? $approvedBody : "Yêu cầu hủy dịch vụ {$name} chưa được chấp nhận.") . $reason,
+ 'link' => '/dich-vu',
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/Service/ServiceController.php b/app/Http/Controllers/Api/Admin/Service/ServiceController.php
index bbe7392..9f2eb1c 100644
--- a/app/Http/Controllers/Api/Admin/Service/ServiceController.php
+++ b/app/Http/Controllers/Api/Admin/Service/ServiceController.php
@@ -31,6 +31,7 @@ class ServiceController extends Controller
$perPage = min((int) ($request->per_page ?? 15), 100);
$query = Service::with(['customer:id,uuid,fullname,email', 'product:id,uuid,name,product_type', 'invoice:id,invoice_number', 'vmware:id,name,module_ip_address', 'orderItem:id,order_id', 'orderItem.order:id,order_num', 'tags'])
+ ->withExists(['cancellationRequests as pending_cancellation' => fn ($q) => $q->pending()])
->marked($request->all())
->where(fn($q) => $q->whereNull('order_item_id')->orWhereNull('type')->orWhere('type', '!=', 'domain'))
->when($request->filter, fn($q, $v) => $q->where('server_name', 'like', "%{$v}%"))
@@ -93,6 +94,7 @@ class ServiceController extends Controller
->findOrFail($id);
ModuleVmware::resolveVirtualMachineIds([$service->vmware]);
+ \App\Support\HostingCredentials::attach([$service]);
$invoices = $this->linkedInvoices(collect([$service]));
$payments = InvoicePaymentHistory::forInvoices($invoices);
diff --git a/app/Http/Controllers/Api/Admin/Service/SslCertificateController.php b/app/Http/Controllers/Api/Admin/Service/SslCertificateController.php
new file mode 100644
index 0000000..bfe9e18
--- /dev/null
+++ b/app/Http/Controllers/Api/Admin/Service/SslCertificateController.php
@@ -0,0 +1,84 @@
+respond($this->sslService($id)->sslCertificate?->data);
+ }
+
+ public function update(Request $request, int $id): JsonResponse
+ {
+ $service = $this->sslService($id);
+ $max = ServiceSslCertificate::MAX_ENTRIES;
+
+ $data = $request->validate([
+ 'fields' => "present|array|max:{$max}",
+ 'fields.*.label' => 'required|string|max:100',
+ 'fields.*.value' => 'nullable|string|max:5000',
+ 'files' => "present|array|max:{$max}",
+ 'files.*.name' => 'required|string|max:150',
+ 'files.*.content' => 'required|string|max:' . ServiceSslCertificate::MAX_FILE_BYTES,
+ ]);
+
+ return $this->respond($this->store($service, [
+ 'fields' => array_map(fn ($field) => ['label' => $field['label'], 'value' => (string) ($field['value'] ?? '')], $data['fields']),
+ 'files' => array_map(fn ($file) => ['name' => basename($file['name']), 'content' => $file['content']], $data['files']),
+ ]));
+ }
+
+ public function uploadArchive(Request $request, int $id): JsonResponse
+ {
+ $service = $this->sslService($id);
+ $request->validate(['archive' => 'required|file|mimes:zip|max:10240']);
+
+ try {
+ $extracted = SslBundleExtractor::extract($request->file('archive'));
+ } catch (InvalidArgumentException $exception) {
+ return response()->json(['error' => true, 'message' => $exception->getMessage()], 422);
+ }
+
+ $current = $service->sslCertificate?->data ?? ServiceSslCertificate::emptyData();
+ $byName = collect($current['files'])->keyBy('name');
+ foreach ($extracted as $file) {
+ $byName[$file['name']] = $file;
+ }
+
+ if ($byName->count() > ServiceSslCertificate::MAX_ENTRIES) {
+ return response()->json(['error' => true, 'message' => 'Quá nhiều tệp chứng chỉ, hãy xoá bớt trước khi tải thêm.'], 422);
+ }
+
+ return $this->respond($this->store($service, ['fields' => $current['fields'], 'files' => $byName->values()->all()]));
+ }
+
+ private function sslService(int $id): Service
+ {
+ $service = Service::with(['product:id,name,product_type', 'sslCertificate'])->findOrFail($id);
+ abort_unless(ServiceGroup::of($service->product?->product_type ?? $service->type, $service->product?->name) === ServiceGroup::SSL, 422, 'Dịch vụ này không phải SSL.');
+
+ return $service;
+ }
+
+ private function store(Service $service, array $data): array
+ {
+ ServiceSslCertificate::updateOrCreate(['service_id' => $service->id], ['data' => $data]);
+
+ return $data;
+ }
+
+ private function respond(?array $data): JsonResponse
+ {
+ return response()->json(['error' => false, 'data' => $data ?? ServiceSslCertificate::emptyData()]);
+ }
+}
diff --git a/app/Http/Controllers/Api/Client/Service/HostingPasswordController.php b/app/Http/Controllers/Api/Client/Service/HostingPasswordController.php
new file mode 100644
index 0000000..6f3ff93
--- /dev/null
+++ b/app/Http/Controllers/Api/Client/Service/HostingPasswordController.php
@@ -0,0 +1,40 @@
+validate(
+ ['password' => ['required', 'string', 'max:64', 'confirmed', ClientPassword::rule()]],
+ ['password.min' => ClientPassword::minMessage()],
+ );
+
+ $hosting = Service::with('hostingAccount.server')
+ ->where('uuid', $uuid)
+ ->where('customer_id', $request->user()->id)
+ ->where('status', 'active')
+ ->first()?->hostingAccount;
+
+ $client = $hosting ? ModuleHostingPleskClient::forHosting($hosting) : null;
+
+ if (!$client || !$hosting->server) {
+ return response()->json(['error' => true, 'message' => 'Không tìm thấy dịch vụ hosting'], 404);
+ }
+
+ if (!$plesk->changePassword($hosting->server, $client, $data['password'])) {
+ return response()->json(['error' => true, 'message' => 'Plesk từ chối mật khẩu mới. Hãy dùng mật khẩu mạnh hơn (chữ hoa, chữ thường, số, ký tự đặc biệt).'], 422);
+ }
+
+ return response()->json(['error' => false, 'message' => 'Đã đổi mật khẩu Plesk']);
+ }
+}
diff --git a/app/Http/Controllers/Api/Client/Service/ServiceCancellationController.php b/app/Http/Controllers/Api/Client/Service/ServiceCancellationController.php
new file mode 100644
index 0000000..43c1db6
--- /dev/null
+++ b/app/Http/Controllers/Api/Client/Service/ServiceCancellationController.php
@@ -0,0 +1,68 @@
+ownedService($request, $uuid);
+ $data = $request->validate([
+ 'reason_code' => 'nullable|string|max:40',
+ 'reason' => 'required|string|min:3|max:1000',
+ ]);
+
+ if (in_array($service->status, self::CLOSED_STATUSES, true)) {
+ return response()->json(['error' => true, 'message' => 'Dịch vụ đã được hủy.'], 422);
+ }
+
+ if ($service->cancel_at_expiry) {
+ return response()->json(['error' => true, 'message' => 'Dịch vụ đã được lên lịch hủy khi hết hạn.'], 422);
+ }
+
+ if ($service->cancellationRequests()->pending()->exists()) {
+ return response()->json(['error' => true, 'message' => 'Bạn đã gửi yêu cầu hủy dịch vụ này, vui lòng chờ nhân viên xử lý.'], 422);
+ }
+
+ $cancellation = $service->cancellationRequests()->create([
+ 'customer_id' => $service->customer_id,
+ 'reason_code' => $data['reason_code'] ?? null,
+ 'reason' => trim($data['reason']),
+ ]);
+
+ $this->notifier->notify($cancellation, $request->ip());
+
+ return response()->json(['error' => false, 'message' => 'Đã gửi yêu cầu hủy dịch vụ.', 'data' => $this->payload($cancellation)], 201);
+ }
+
+ public function destroy(Request $request, string $uuid): JsonResponse
+ {
+ $this->ownedService($request, $uuid)->cancellationRequests()->pending()->delete();
+
+ return response()->json(['error' => false, 'message' => 'Đã rút yêu cầu hủy dịch vụ.']);
+ }
+
+ public static function payload(?ServiceCancellationRequest $request): ?array
+ {
+ return $request ? [
+ 'reason' => $request->reason,
+ 'requested_at' => $request->created_at?->toISOString(),
+ ] : null;
+ }
+
+ private function ownedService(Request $request, string $uuid): Service
+ {
+ return Service::where('uuid', $uuid)->where('customer_id', $request->user()->id)->firstOrFail();
+ }
+}
diff --git a/app/Http/Controllers/Api/Client/Service/ServiceController.php b/app/Http/Controllers/Api/Client/Service/ServiceController.php
index 86cc05d..96df498 100644
--- a/app/Http/Controllers/Api/Client/Service/ServiceController.php
+++ b/app/Http/Controllers/Api/Client/Service/ServiceController.php
@@ -84,6 +84,21 @@ class ServiceController extends Controller
->response();
}
+ public function indexGroup(Request $request, string $group): JsonResponse
+ {
+ $perPage = min((int) $request->get('per_page', 15), 100);
+
+ $services = $this->serviceClientService->getActiveServices($request->user()->id, null, $perPage, $group);
+
+ return (new VpsServiceCollection($services))
+ ->additional([
+ 'error' => false,
+ 'message' => 'Lấy dữ liệu thành công',
+ 'page_type' => 'service_' . $group,
+ ])
+ ->response();
+ }
+
public function indexDomain(Request $request): JsonResponse
{
$customerId = $request->user()->id;
@@ -102,11 +117,12 @@ class ServiceController extends Controller
->where('customer_id', $customerId)
->where('type', 'domain')
->orderByDesc('id')
- ->get(['uuid', 'server_name', 'status', 'started_at', 'expires_at'])
+ ->get(['uuid', 'server_name', 'status', 'started_at', 'expires_at', 'cancel_at_expiry'])
->map(fn (Service $service) => [
'domain_name' => $service->server_name,
'service_uuid' => $service->uuid,
'status' => $service->status,
+ 'cancel_at_expiry' => (bool) $service->cancel_at_expiry,
'started_at' => $service->started_at?->toISOString(),
'expires_at' => $service->expires_at?->toISOString(),
...$zoneFields($zones->pull(mb_strtolower(trim((string) $service->server_name)))),
@@ -194,20 +210,16 @@ class ServiceController extends Controller
]);
}
- public function getVmPassword(Request $request, string $uuid)
+ public function downloadAttachment(Request $request, string $uuid, int $attachmentId)
{
- $service = Service::with('vmware')
- ->where('uuid', $uuid)
- ->where('customer_id', $request->user()->id)
- ->first();
+ $attachment = \App\Models\ServiceAttachment::forCustomer()
+ ->whereKey($attachmentId)
+ ->whereHas('service', fn ($query) => $query
+ ->where('uuid', $uuid)
+ ->where('customer_id', $request->user()->id))
+ ->firstOrFail();
- $vm = $service?->vmware;
-
- if (!$vm) {
- return response()->json(['error' => true, 'message' => 'Không tìm thấy dịch vụ'], 404);
- }
-
- return response()->json(['error' => false, 'data' => ['password' => $vm->plain_password]]);
+ return \Illuminate\Support\Facades\Storage::disk($attachment->disk)->response($attachment->path, $attachment->filename);
}
public function getConsoleTicket(Request $request, string $uuid, vCenter $serviceVCenter)
diff --git a/app/Http/Controllers/Api/Client/Service/ServiceRenewalHistoryController.php b/app/Http/Controllers/Api/Client/Service/ServiceRenewalHistoryController.php
new file mode 100644
index 0000000..2378e9b
--- /dev/null
+++ b/app/Http/Controllers/Api/Client/Service/ServiceRenewalHistoryController.php
@@ -0,0 +1,43 @@
+ 'Bạn',
+ 'admin' => 'Nhân viên hỗ trợ',
+ 'system' => 'Tự động gia hạn',
+ ];
+
+ public function __invoke(Request $request, string $uuid): JsonResponse
+ {
+ $service = Service::where('uuid', $uuid)->where('customer_id', $request->user()->id)->firstOrFail();
+
+ $items = ActivityLog::where('subject_type', Service::class)
+ ->where('subject_id', $service->id)
+ ->where('event', 'renewed')
+ ->latest('id')
+ ->limit(100)
+ ->get(['causer_type', 'old_values', 'new_values', 'created_at'])
+ ->map(fn (ActivityLog $log) => [
+ 'renewed_at' => $log->created_at?->toISOString(),
+ 'months' => (int) ($log->new_values['months'] ?? 0),
+ 'amount' => (int) ($log->new_values['amount'] ?? 0),
+ 'invoice_number' => $log->new_values['invoice_number'] ?? null,
+ 'paid_by' => ($log->new_values['payment_method'] ?? null) === 'balance' ? 'Số dư tài khoản' : 'Thanh toán khác',
+ 'old_expires_at' => $log->old_values['expires_at'] ?? null,
+ 'new_expires_at' => $log->new_values['expires_at'] ?? null,
+ 'by' => self::CAUSER_LABELS[$log->causer_type] ?? self::CAUSER_LABELS['system'],
+ ])
+ ->values();
+
+ return response()->json(['error' => false, 'data' => $items]);
+ }
+}
diff --git a/app/Http/Controllers/Api/Client/Service/SslArchiveController.php b/app/Http/Controllers/Api/Client/Service/SslArchiveController.php
new file mode 100644
index 0000000..d9207b9
--- /dev/null
+++ b/app/Http/Controllers/Api/Client/Service/SslArchiveController.php
@@ -0,0 +1,42 @@
+whereHas('service', fn ($query) => $query
+ ->where('uuid', $uuid)
+ ->where('customer_id', $request->user()->id))
+ ->with('service:id,uuid,server_name')
+ ->firstOrFail();
+
+ $path = tempnam(sys_get_temp_dir(), 'ssl');
+ $zip = new ZipArchive();
+ $zip->open($path, ZipArchive::OVERWRITE);
+
+ foreach ($certificate->data['files'] as $file) {
+ $zip->addFromString($file['name'], $file['content']);
+ }
+
+ if ($certificate->data['fields'] !== []) {
+ $zip->addFromString('thong-tin.txt', collect($certificate->data['fields'])
+ ->map(fn ($field) => "{$field['label']}: {$field['value']}")
+ ->implode("\n\n"));
+ }
+
+ $zip->close();
+
+ $name = Str::slug($certificate->service->server_name ?: 'ssl') . '-ssl.zip';
+
+ return response()->download($path, $name)->deleteFileAfterSend();
+ }
+}
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index cab454f..d01dcc2 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -17,7 +17,6 @@ class Kernel extends HttpKernel
\App\Http\Middleware\TrustProxies::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
- \App\Http\Middleware\SealResponse::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
\Illuminate\Http\Middleware\HandleCors::class
@@ -89,7 +88,6 @@ class Kernel extends HttpKernel
'perm' => \App\Http\Middleware\CheckPermission::class,
'restrict.local' => \App\Http\Middleware\RestrictToLocalEnvironment::class,
'portal-monitor.service' => \App\Http\Middleware\AuthenticatePortalMonitor::class,
- 'seal.response' => \App\Http\Middleware\SealResponse::class,
'turnstile' => \App\Http\Middleware\VerifyTurnstile::class,
];
}
diff --git a/app/Http/Middleware/SealResponse.php b/app/Http/Middleware/SealResponse.php
deleted file mode 100644
index d8831f9..0000000
--- a/app/Http/Middleware/SealResponse.php
+++ /dev/null
@@ -1,56 +0,0 @@
-headers->get(self::KEY_HEADER));
-
- if ($this->openRequest($request, $key) === false) {
- return new JsonResponse(['message' => 'Khoá phiên không khớp, vui lòng thử lại.'], 428);
- }
-
- $response = $next($request);
-
- $wanted = $mode === 'always' || $request->headers->get(self::OPT_IN_HEADER) === '1';
- if (! $key || ! $wanted || $request->attributes->has(self::SEALED_ATTRIBUTE) || ! $response instanceof JsonResponse) {
- return $response;
- }
-
- $request->attributes->set(self::SEALED_ATTRIBUTE, true);
- $response->setData(['enc' => ResponseCipher::seal($response->getContent(), $key)]);
-
- return $response;
- }
-
- private function openRequest(Request $request, ?string $key): ?bool
- {
- $sealed = $request->isJson() ? $request->json('enc') : null;
- if (! is_string($sealed)) {
- return null;
- }
-
- $data = $key ? json_decode((string) ResponseCipher::open($sealed, $key), true) : null;
- if (! is_array($data)) {
- return false;
- }
-
- $request->json()->replace($data);
- $request->request->replace($data);
-
- return true;
- }
-}
diff --git a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php
index a704a7b..5d749cb 100644
--- a/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php
+++ b/app/Http/Resources/Api/Client/Service/ServiceClientDetailResource.php
@@ -3,31 +3,34 @@
namespace App\Http\Resources\Api\Client\Service;
use App\Models\DnsDomain;
+use App\Support\ResponseCipher;
+use App\Support\ServiceGroup;
use App\Models\ModuleHostingPleskClient;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
class ServiceClientDetailResource extends JsonResource
{
- public const MASKED_PASSWORD = '*****';
+ private const IRRELEVANT_KEYS = [
+ 'hosting' => ['os', 'specs', 'vm', 'custom_product', 'ssl_certificate', 'dns_domain_id', 'dns_cancelled'],
+ 'vps' => ['hosting', 'service_plan', 'ssl_certificate', 'dns_domain_id', 'dns_cancelled'],
+ 'cloud' => ['hosting', 'service_plan', 'ssl_certificate', 'dns_domain_id', 'dns_cancelled'],
+ 'domain' => ['os', 'specs', 'vm', 'hosting', 'service_plan', 'custom_product', 'ssl_certificate'],
+ 'ssl' => ['os', 'specs', 'vm', 'hosting', 'service_plan', 'custom_product', 'dns_domain_id', 'dns_cancelled'],
+ ];
/** Plesk panel login (persisted, reusable) — separate from the one-time FTP password, which is never stored. */
private function resolvePleskCredentials($hosting): ?array
{
- $client = ModuleHostingPleskClient::query()
- ->where('customer_id', $hosting->customer_id)
- ->where('dns_manager_server_id', $hosting->dns_manager_server_id)
- ->first();
+ $client = ModuleHostingPleskClient::forHosting($hosting);
if (!$client) {
return null;
}
- return [
- 'login' => $client->login,
- 'password' => Crypt::decryptString($client->password),
- ];
+ return ['login' => $client->login, 'password' => Crypt::decryptString($client->password)];
}
private function resolveDnsZone(): ?DnsDomain
@@ -101,9 +104,10 @@ class ServiceClientDetailResource extends JsonResource
$dnsZone = $this->resolveDnsZone();
$dnsCancelled = $dnsZone !== null && (int) $dnsZone->is_created === DnsDomain::STATUS_CANCELLED;
- return [
+ $payload = [
'uuid' => $this->uuid,
'type' => $this->type,
+ 'group' => ServiceGroup::of($this->orderItem?->product?->product_type ?? $this->product?->product_type, $this->orderItem?->product?->name ?? $this->product?->name),
'server_name' => $this->server_name,
'status' => $this->status,
'auto_renew' => $this->auto_renew,
@@ -132,8 +136,8 @@ class ServiceClientDetailResource extends JsonResource
'vm' => $vm ? [
'ip_address' => $vm->ipAddress?->ip ?? $vm->ip,
'hostname' => $vm->hostname,
- 'username' => $vm->user,
- 'password' => $vm->plain_password ? self::MASKED_PASSWORD : null,
+ 'username' => ResponseCipher::encrypt($vm->user),
+ 'password' => ResponseCipher::encrypt($vm->plain_password),
'port' => $vm->port,
'status' => $vm->status,
'ip' => $vm->ip ?? null,
@@ -141,17 +145,29 @@ class ServiceClientDetailResource extends JsonResource
] : null,
'hosting' => $hosting ? [
'ip_address' => $hosting->server?->publicHost(),
- 'plesk_url' => $hosting->server
- ? "http://{$hosting->server->publicHost()}:{$hosting->server->panel_port}/"
- : null,
- 'hosting_plan' => $hosting->hosting_plan,
- 'ftp_username' => $hosting->ftp_username,
- 'username' => $pleskCredentials['login'] ?? null,
- 'password' => $pleskCredentials['password'] ?? null,
- 'status' => $hosting->status,
+ 'plesk_url' => $hosting->server?->panelUrl(),
+ 'username' => ResponseCipher::encrypt($pleskCredentials['login'] ?? null),
+ 'password' => ResponseCipher::encrypt($pleskCredentials['password'] ?? null),
] : null,
+ 'attachments' => \App\Models\ServiceAttachment::forCustomer()
+ ->where('service_id', $this->resource->id)
+ ->latest()
+ ->get()
+ ->map(fn ($file) => [
+ 'id' => $file->id,
+ 'filename' => $file->filename,
+ 'mime_type' => $file->mime_type,
+ 'size' => (int) $file->size,
+ 'uploaded_at' => $file->created_at?->toISOString(),
+ ])
+ ->values(),
+ 'cancel_at_expiry' => (bool) $this->cancel_at_expiry,
+ 'cancellation_request' => \App\Http\Controllers\Api\Client\Service\ServiceCancellationController::payload($this->resource->cancellationRequests()->pending()->latest()->first()),
+ 'ssl_certificate' => $this->resource->sslCertificate?->data,
'dns_domain_id' => $dnsZone && !$dnsCancelled ? $dnsZone->id : null,
'dns_cancelled' => $dnsCancelled,
];
+
+ return Arr::except($payload, self::IRRELEVANT_KEYS[$this->type] ?? []);
}
}
diff --git a/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php b/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php
index aa0598d..a6065b1 100644
--- a/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php
+++ b/app/Http/Resources/Api/Service/Order/InvoiceDetailResource.php
@@ -7,6 +7,7 @@ use App\Enums\Order\OrderStatus;
use App\Models\InvoiceAttachment;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
+use Illuminate\Support\Carbon;
class InvoiceDetailResource extends JsonResource
{
@@ -33,6 +34,9 @@ class InvoiceDetailResource extends JsonResource
'auto_renew' => (bool) $this->auto_renew,
'created_at' => $this->created_at?->toISOString(),
'due_date' => $this->due_date?->toISOString(),
+ 'billing_period_start' => $this->billing_period_start ? Carbon::parse($this->billing_period_start)->toDateString() : null,
+ 'billing_period_end' => $this->billing_period_end ? Carbon::parse($this->billing_period_end)->toDateString() : null,
+ 'next_renewal_date' => $this->whenLoaded('order', fn () => $this->nextRenewalDate()),
'paid_at' => $this->paid_at?->toISOString(),
'customer' => $this->whenLoaded('customer', fn () => [
'name' => $this->customer->fullname,
@@ -46,7 +50,7 @@ class InvoiceDetailResource extends JsonResource
'total' => (float) $this->order->total,
'created_at' => $this->order->created_at?->toISOString(),
]),
- 'items' => $this->whenLoaded('order', fn () => $this->order->orderItems->map(fn ($item) => [
+ 'items' => $this->whenLoaded('order', fn () => $this->isRenewal() ? $this->renewalItems() : $this->order->orderItems->map(fn ($item) => [
'product_name' => $item->product?->name,
'server_name' => $item->server_name,
'billing_cycle' => $item->billing_cycle,
@@ -70,19 +74,59 @@ class InvoiceDetailResource extends JsonResource
];
}
- private function resolveService(): ?array
+ private function isRenewal(): bool
+ {
+ return (int) $this->invoice_type === 2;
+ }
+
+ private function resolveOrderItem(): mixed
{
$items = $this->order->orderItems;
$item = $this->service_id
? $items->first(fn ($i) => (int) $i->service?->id === (int) $this->service_id)
: null;
- $item ??= $items->first();
+
+ return $item ?? $items->first();
+ }
+
+ private function renewalMonths(): ?int
+ {
+ return $this->invoiceItems->firstWhere('type', 'renewal')?->months;
+ }
+
+ private function renewalItems(): array
+ {
+ $serverName = $this->resolveOrderItem()?->server_name;
+
+ return $this->invoiceItems->map(fn ($item) => [
+ 'product_name' => $item->description,
+ 'server_name' => $serverName,
+ 'billing_cycle' => $item->months ? 'monthly' : null,
+ 'price' => (float) $item->total_price,
+ 'months' => $item->months,
+ 'os_id' => null,
+ ])->values()->all();
+ }
+
+ private function nextRenewalDate(): ?string
+ {
+ if ($this->isRenewal() && $this->billing_period_end) {
+ return Carbon::parse($this->billing_period_end)->toDateString();
+ }
+
+ return $this->resolveOrderItem()?->service?->expires_at?->toDateString();
+ }
+
+ private function resolveService(): ?array
+ {
+ $item = $this->resolveOrderItem();
+ $renewalMonths = $this->isRenewal() ? $this->renewalMonths() : null;
return $item ? [
'product_id' => $item->product_id,
'name' => $item->product?->name,
- 'billing_cycle' => $item->billing_cycle,
- 'months' => $item->months,
+ 'billing_cycle' => $renewalMonths ? 'monthly' : $item->billing_cycle,
+ 'months' => $renewalMonths ?: $item->months,
'server_name' => $item->server_name,
'product_type' => $item->product?->product_type,
] : null;
diff --git a/app/Http/Resources/Api/Service/Order/OrderItemResource.php b/app/Http/Resources/Api/Service/Order/OrderItemResource.php
index a821db9..1399922 100644
--- a/app/Http/Resources/Api/Service/Order/OrderItemResource.php
+++ b/app/Http/Resources/Api/Service/Order/OrderItemResource.php
@@ -18,6 +18,8 @@ class OrderItemResource extends JsonResource
'price' => (float) $this->price,
'service' => $this->whenLoaded('service', fn () => [
'id' => $this->service->id,
+ 'uuid' => $this->service->uuid,
+ 'type' => $this->service->type,
'status' => $this->service->status,
'status_text' => $this->service->status_label,
'retry_requested_at' => $this->service->retry_requested_at?->toISOString(),
diff --git a/app/Http/Resources/Api/Service/VpsServiceResource.php b/app/Http/Resources/Api/Service/VpsServiceResource.php
index 76b9273..1755071 100644
--- a/app/Http/Resources/Api/Service/VpsServiceResource.php
+++ b/app/Http/Resources/Api/Service/VpsServiceResource.php
@@ -3,6 +3,7 @@
namespace App\Http\Resources\Api\Service;
use App\Models\Product;
+use App\Support\ServiceGroup;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -38,12 +39,14 @@ class VpsServiceResource extends JsonResource
return [
'uuid' => $this->uuid,
'type' => $type,
+ 'group' => ServiceGroup::of($product?->product_type, $product?->name),
'server_name' => $this->server_name,
'invoice_num' => $invoice?->invoice_number,
'order_num' => $order?->order_num,
'status' => $this->status,
'status_label' => $this->status_label,
'auto_renew' => (bool) $this->auto_renew,
+ 'cancel_at_expiry' => (bool) $this->cancel_at_expiry,
'started_at' => $this->started_at?->toISOString(),
'expires_at' => $this->expires_at?->toISOString(),
'product' => $product ? [
diff --git a/app/Mail/Admin/ServiceCancellationRequestMail.php b/app/Mail/Admin/ServiceCancellationRequestMail.php
new file mode 100644
index 0000000..6ccf6ab
--- /dev/null
+++ b/app/Mail/Admin/ServiceCancellationRequestMail.php
@@ -0,0 +1,23 @@
+data;
+ }
+}
diff --git a/app/Models/DnsServer.php b/app/Models/DnsServer.php
index ca3bd15..27976cf 100644
--- a/app/Models/DnsServer.php
+++ b/app/Models/DnsServer.php
@@ -37,6 +37,11 @@ class DnsServer extends Model
return $this->domain ?: $this->ip_address;
}
+ public function panelUrl(): string
+ {
+ return 'https://' . $this->publicHost() . '/';
+ }
+
protected function scopeIsActive($query)
{
return $query->where('status', 'activated');
diff --git a/app/Models/ModuleHostingPleskClient.php b/app/Models/ModuleHostingPleskClient.php
index a996a3a..68e83e8 100644
--- a/app/Models/ModuleHostingPleskClient.php
+++ b/app/Models/ModuleHostingPleskClient.php
@@ -28,4 +28,12 @@ class ModuleHostingPleskClient extends Model
{
return $this->belongsTo(DnsServer::class, 'dns_manager_server_id');
}
+
+ public static function forHosting($hosting): ?self
+ {
+ return static::query()
+ ->where('customer_id', $hosting->customer_id)
+ ->where('dns_manager_server_id', $hosting->dns_manager_server_id)
+ ->first();
+ }
}
diff --git a/app/Models/Service.php b/app/Models/Service.php
index b5fb1a5..d84aec3 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -36,6 +36,16 @@ class Service extends Model
protected $table = 'services';
+ public function cancellationRequests()
+ {
+ return $this->hasMany(ServiceCancellationRequest::class);
+ }
+
+ public function sslCertificate()
+ {
+ return $this->hasOne(ServiceSslCertificate::class);
+ }
+
public const HOSTING_ACCOUNT_RELATIONS = [
'hostingAccount.server:id,name,ip_address,domain,panel_port',
'hostingAccount.domain:id,domain_name',
@@ -53,6 +63,7 @@ class Service extends Model
'server_name',
'status',
'auto_renew',
+ 'cancel_at_expiry',
'started_at',
'expires_at',
'suspended_at',
@@ -71,6 +82,7 @@ class Service extends Model
protected $casts = [
'auto_renew' => 'boolean',
+ 'cancel_at_expiry' => 'boolean',
'started_at' => 'datetime',
'expires_at' => 'datetime',
'suspended_at' => 'datetime',
diff --git a/app/Models/ServiceAttachment.php b/app/Models/ServiceAttachment.php
index 16986d9..bf9f5c3 100644
--- a/app/Models/ServiceAttachment.php
+++ b/app/Models/ServiceAttachment.php
@@ -9,10 +9,18 @@ class ServiceAttachment extends Model
{
public const UPLOAD_RULE = 'required|file|mimes:jpg,jpeg,png,gif,webp,pdf,doc,docx,xls,xlsx,zip|max:51200';
+ public const AUDIENCE_ADMIN = 'admin';
+ public const AUDIENCE_CUSTOMER = 'customer';
+
protected $guarded = [];
protected $appends = ['url'];
+ public function scopeForCustomer($query)
+ {
+ return $query->where('audience', self::AUDIENCE_CUSTOMER);
+ }
+
public function service()
{
return $this->belongsTo(Service::class);
diff --git a/app/Models/ServiceCancellationRequest.php b/app/Models/ServiceCancellationRequest.php
new file mode 100644
index 0000000..586404d
--- /dev/null
+++ b/app/Models/ServiceCancellationRequest.php
@@ -0,0 +1,33 @@
+ 'datetime'];
+
+ protected $guarded = [];
+
+ public function service()
+ {
+ return $this->belongsTo(Service::class);
+ }
+
+ public function customer()
+ {
+ return $this->belongsTo(Customer::class);
+ }
+
+ public function scopePending($query)
+ {
+ return $query->where('status', self::STATUS_PENDING);
+ }
+}
diff --git a/app/Models/ServiceSslCertificate.php b/app/Models/ServiceSslCertificate.php
new file mode 100644
index 0000000..97e5557
--- /dev/null
+++ b/app/Models/ServiceSslCertificate.php
@@ -0,0 +1,26 @@
+ 'encrypted:array'];
+
+ public function service()
+ {
+ return $this->belongsTo(Service::class);
+ }
+
+ public static function emptyData(): array
+ {
+ return ['fields' => [], 'files' => []];
+ }
+}
diff --git a/app/Repositories/Admin/Product/AdminProductRepository.php b/app/Repositories/Admin/Product/AdminProductRepository.php
index f1fba16..964892a 100644
--- a/app/Repositories/Admin/Product/AdminProductRepository.php
+++ b/app/Repositories/Admin/Product/AdminProductRepository.php
@@ -20,6 +20,10 @@ class AdminProductRepository implements AdminProductRepositoryInterface
$query->where('product_type', $type);
}
+ if ($categoryId = $filters['product_categories_id'] ?? null) {
+ $query->where('product_categories_id', $categoryId);
+ }
+
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN));
}
diff --git a/app/Repositories/Api/ServiceClient/ServiceClientRepository.php b/app/Repositories/Api/ServiceClient/ServiceClientRepository.php
index 1758134..2e79b23 100644
--- a/app/Repositories/Api/ServiceClient/ServiceClientRepository.php
+++ b/app/Repositories/Api/ServiceClient/ServiceClientRepository.php
@@ -3,6 +3,7 @@
namespace App\Repositories\Api\ServiceClient;
use App\Support\ProductBillingCycles;
+use App\Support\ServiceGroup;
use App\Models\Service;
use App\Repositories\Api\BaseApiRepository;
@@ -16,7 +17,7 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient
/**
* Lấy toàn bộ dịch vụ của customer (mọi trạng thái)
*/
- public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10)
+ public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10, ?string $group = null)
{
$query = $this->model
->with([
@@ -33,6 +34,10 @@ class ServiceClientRepository extends BaseApiRepository implements ServiceClient
$query->where('type', $productType);
}
+ if ($group) {
+ $query->whereHas('product', fn ($products) => ServiceGroup::constrain($products, $group));
+ }
+
return $limit === null ? $query->get() : $query->paginate($limit);
}
diff --git a/app/Repositories/Api/ServiceClient/ServiceClientRepositoryInterface.php b/app/Repositories/Api/ServiceClient/ServiceClientRepositoryInterface.php
index 5892d42..f6d36a2 100644
--- a/app/Repositories/Api/ServiceClient/ServiceClientRepositoryInterface.php
+++ b/app/Repositories/Api/ServiceClient/ServiceClientRepositoryInterface.php
@@ -9,7 +9,7 @@ interface ServiceClientRepositoryInterface extends BaseRepositoryInterface
/**
* Lấy danh sách sản phẩm đang hoạt động
*/
- public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10);
+ public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10, ?string $group = null);
public function findActiveBySlug(string $slug);
diff --git a/app/Services/Api/Order/OrderQueryService.php b/app/Services/Api/Order/OrderQueryService.php
index 0f7e142..334ac1b 100644
--- a/app/Services/Api/Order/OrderQueryService.php
+++ b/app/Services/Api/Order/OrderQueryService.php
@@ -220,7 +220,7 @@ class OrderQueryService
->with(['orderItem.product', 'customProduct'])
->where('id', $serviceId)
->where('customer_id', $customer->id)
- ->whereNotIn('status', ['terminated', 'cancelled'])
+ ->where('status', '!=', 'terminated')
->first();
if (! $service) {
diff --git a/app/Services/Api/ServiceClient/ServiceClientService.php b/app/Services/Api/ServiceClient/ServiceClientService.php
index 2341360..67a0834 100644
--- a/app/Services/Api/ServiceClient/ServiceClientService.php
+++ b/app/Services/Api/ServiceClient/ServiceClientService.php
@@ -18,9 +18,9 @@ class ServiceClientService
/**
* Lấy danh sách dịch vụ đang hoạt động
*/
- public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10)
+ public function getActiveServices(int $customerId, ?string $productType = null, ?int $limit = 10, ?string $group = null)
{
- return $this->serviceClientRepository->getActiveServices($customerId, $productType, $limit);
+ return $this->serviceClientRepository->getActiveServices($customerId, $productType, $limit, $group);
}
/**
diff --git a/app/Services/Concerns/ActivatesPaidInvoices.php b/app/Services/Concerns/ActivatesPaidInvoices.php
index ad7a257..601be6e 100644
--- a/app/Services/Concerns/ActivatesPaidInvoices.php
+++ b/app/Services/Concerns/ActivatesPaidInvoices.php
@@ -15,7 +15,9 @@ use App\Models\OrderItem;
use App\Models\Service;
use App\Models\ServiceAddon;
use App\Services\ActivityLogger;
+use App\Services\CustomerRenewalNotifier;
use Carbon\Carbon;
+use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
@@ -94,13 +96,30 @@ trait ActivatesPaidInvoices
: now();
$expiresAt = Carbon::parse($baseDate)->addMonths($months);
$previousExpiresAt = $service->expires_at;
+ $previousStatus = $service->status;
+ $wasCancelled = $previousStatus === 'cancelled';
+ $wasScheduledCancel = (bool) $service->cancel_at_expiry;
$service->update([
'status' => 'active',
'expires_at' => $expiresAt,
'suspended_at' => null,
+ 'cancel_at_expiry' => false,
+ 'cancelled_at' => null,
]);
+ if ($wasCancelled || $wasScheduledCancel) {
+ app(ActivityLogger::class)->log(
+ event: 'cancellation_revoked',
+ logName: 'Service',
+ description: "Dịch vụ #{$service->id} được gia hạn nên huỷ bỏ việc hủy dịch vụ"
+ .($wasCancelled ? ' (đã hủy → hoạt động lại)' : ' (hủy cuối kỳ)'),
+ subject: $service,
+ oldValues: ['status' => $previousStatus, 'cancel_at_expiry' => $wasScheduledCancel],
+ newValues: ['status' => 'active', 'cancel_at_expiry' => false, 'invoice_number' => $invoice->invoice_number],
+ );
+ }
+
app(ActivityLogger::class)->log(
event: 'renewed',
logName: 'Service',
@@ -119,6 +138,21 @@ trait ActivatesPaidInvoices
if ($service->vm_id) {
dispatch(new VMJobs((int) $service->vm_id, 'Start', false))->delay(now()->addSeconds(5));
}
+
+ $this->notifyAdminsOfRenewal($service, $invoice, $months, $previousExpiresAt);
+ }
+
+ private function notifyAdminsOfRenewal(Service $service, Invoice $invoice, int $months, ?CarbonInterface $previousExpiresAt): void
+ {
+ if (auth()->guard('admin')->check()) {
+ return;
+ }
+
+ $source = auth()->guard('client')->check()
+ ? CustomerRenewalNotifier::SOURCE_CUSTOMER
+ : CustomerRenewalNotifier::SOURCE_SYSTEM;
+
+ DB::afterCommit(fn () => app(CustomerRenewalNotifier::class)->notify($service->fresh(), $invoice, $months, $previousExpiresAt, $source));
}
/**
diff --git a/app/Services/Customer/CustomerServiceProvisioningService.php b/app/Services/Customer/CustomerServiceProvisioningService.php
index f41f74b..bc0f512 100644
--- a/app/Services/Customer/CustomerServiceProvisioningService.php
+++ b/app/Services/Customer/CustomerServiceProvisioningService.php
@@ -51,6 +51,7 @@ class CustomerServiceProvisioningService
);
ModuleVmware::resolveVirtualMachineIds($services->pluck('vmware'));
+ \App\Support\HostingCredentials::attach($services);
return $services;
}
diff --git a/app/Services/CustomerRenewalNotifier.php b/app/Services/CustomerRenewalNotifier.php
new file mode 100644
index 0000000..66e1477
--- /dev/null
+++ b/app/Services/CustomerRenewalNotifier.php
@@ -0,0 +1,69 @@
+loadMissing(['customer', 'product']);
+ $customer = $service->customer;
+ if (! $customer) {
+ return;
+ }
+
+ $productName = $service->product?->name ?? 'Dịch vụ';
+ $serverName = (string) $service->server_name;
+ $expiresAt = $service->expires_at?->format('d/m/Y') ?? 'N/A';
+ $auto = $source === self::SOURCE_SYSTEM;
+ $amount = number_format((float) $invoice->total, 0, ',', '.') . ' VNĐ';
+
+ Notification::notifyAdmins([
+ 'type' => 'service_renewed_by_customer',
+ 'title' => $auto ? 'Dịch vụ được tự động gia hạn' : 'Khách hàng gia hạn dịch vụ',
+ 'body' => "{$customer->fullname} " . ($auto ? 'được tự động gia hạn' : 'đã gia hạn') . " {$productName}" . ($serverName !== '' ? " ({$serverName})" : '') . " thêm {$months} tháng ({$amount}), hạn mới {$expiresAt}.",
+ 'link' => '/services/' . $service->id,
+ 'data' => ['service_id' => $service->id, 'invoice_id' => $invoice->id, 'months' => $months],
+ ]);
+
+ $adminEmail = json_decode((string) Setting::where('key', 'setting')->value('value'), true)['admin_email'] ?? null;
+ if (! $adminEmail || ! AutoEmailGate::allows(self::SCENARIO)) {
+ return;
+ }
+
+ try {
+ Mail::to($adminEmail)->queue(new ServiceRenewedMail(self::SCENARIO, [
+ 'customer_name' => (string) $customer->fullname,
+ 'customer_email' => (string) $customer->email,
+ 'product_name' => $productName,
+ 'server_name_suffix' => $serverName !== '' ? ' (' . e($serverName) . ')' : '',
+ 'months_label' => "{$months} tháng",
+ 'old_expires_at' => $previousExpiresAt?->format('d/m/Y') ?? 'N/A',
+ 'expires_at' => $expiresAt,
+ 'invoice_number' => (string) $invoice->invoice_number,
+ 'amount_formatted' => $amount,
+ 'payment_method_label' => $invoice->payment_method === 'balance' ? 'Trừ số dư tài khoản' : (string) ($invoice->payment_method ?: 'N/A'),
+ 'renewal_source_label' => $auto ? 'Tự động gia hạn (hệ thống)' : 'Khách hàng tự gia hạn',
+ 'admin_url' => rtrim(env('ADMIN_URL', config('app.url')), '/') . '/services/' . $service->id,
+ ]));
+ } catch (\Throwable $e) {
+ Log::error('CustomerRenewalNotifier: không gửi được email admin', ['service_id' => $service->id, 'error' => $e->getMessage()]);
+ }
+ }
+}
diff --git a/app/Services/Plesk/PleskClientService.php b/app/Services/Plesk/PleskClientService.php
index 87bb181..eec4dde 100644
--- a/app/Services/Plesk/PleskClientService.php
+++ b/app/Services/Plesk/PleskClientService.php
@@ -129,6 +129,17 @@ class PleskClientService
}
}
+ public function changePassword(DnsServer $server, ModuleHostingPleskClient $client, string $password): bool
+ {
+ if (!$this->resetPassword($server, (int) $client->plesk_client_id, $password)) {
+ return false;
+ }
+
+ $client->update(['password' => Crypt::encryptString($password)]);
+
+ return true;
+ }
+
private function resetPassword(DnsServer $server, int $pleskClientId, string $password): bool
{
try {
diff --git a/app/Services/Plesk/PleskHostingService.php b/app/Services/Plesk/PleskHostingService.php
index bc5afaf..d5d0b68 100644
--- a/app/Services/Plesk/PleskHostingService.php
+++ b/app/Services/Plesk/PleskHostingService.php
@@ -138,8 +138,7 @@ class PleskHostingService
'ftp_password' => $ftpPassword,
'plesk_login' => $pleskLogin,
'plesk_password' => $pleskPassword,
- // Plesk's actual login page — not the :8443 REST API entry point.
- 'plesk_panel_url' => 'https://' . $server->publicHost() . '/login_up.php',
+ 'plesk_panel_url' => $server->panelUrl(),
'created_at' => now()->format('d/m/Y H:i:s'),
];
diff --git a/app/Services/ServiceCancellationNotifier.php b/app/Services/ServiceCancellationNotifier.php
new file mode 100644
index 0000000..0ab1520
--- /dev/null
+++ b/app/Services/ServiceCancellationNotifier.php
@@ -0,0 +1,51 @@
+loadMissing(['service.product', 'customer']);
+ $service = $request->service;
+ $customer = $request->customer;
+ $serviceName = $service->server_name ?: ($service->product?->name ?? 'Dịch vụ');
+
+ Notification::notifyAdmins([
+ 'type' => 'service_cancellation_request',
+ 'title' => 'Khách hàng yêu cầu hủy dịch vụ',
+ 'body' => "{$customer->fullname} yêu cầu hủy dịch vụ {$serviceName}. Lý do: {$request->reason}",
+ 'link' => '/services/' . $service->id,
+ 'data' => ['service_id' => $service->id, 'request_id' => $request->id],
+ ]);
+
+ $adminEmail = json_decode((string) Setting::where('key', 'setting')->value('value'), true)['admin_email'] ?? null;
+ if (! $adminEmail || ! AutoEmailGate::allows('admin_service_cancellation_request')) {
+ return;
+ }
+
+ try {
+ Mail::to($adminEmail)->queue(new ServiceCancellationRequestMail([
+ 'customer_name' => (string) $customer->fullname,
+ 'customer_email' => (string) $customer->email,
+ 'product_name' => (string) ($service->product?->name ?? 'Dịch vụ'),
+ 'server_name' => (string) $service->server_name,
+ 'expires_at' => $service->expires_at?->format('d/m/Y') ?? 'N/A',
+ 'cancel_reason' => $request->reason,
+ 'client_ip' => (string) $clientIp,
+ 'created_at' => $request->created_at->format('d/m/Y H:i:s'),
+ 'admin_url' => rtrim(env('ADMIN_URL', config('app.url')), '/') . '/services/' . $service->id,
+ ]));
+ } catch (\Throwable $e) {
+ Log::error('Không gửi được email yêu cầu hủy dịch vụ', ['service_id' => $service->id, 'error' => $e->getMessage()]);
+ }
+ }
+}
diff --git a/app/Services/ServiceRenewalReminderService.php b/app/Services/ServiceRenewalReminderService.php
index 1f96576..517f2d0 100644
--- a/app/Services/ServiceRenewalReminderService.php
+++ b/app/Services/ServiceRenewalReminderService.php
@@ -27,6 +27,7 @@ class ServiceRenewalReminderService
{
return Service::with(['customer:id,uuid,fullname,email', 'product:id,name'])
->where('status', 'active')
+ ->where('cancel_at_expiry', false)
->whereHas('customer')
->whereBetween('expires_at', [now()->startOfDay(), now()->addDays($days)->endOfDay()])
->orderBy('expires_at')
diff --git a/app/Support/Billing/InvoicePaymentHistory.php b/app/Support/Billing/InvoicePaymentHistory.php
index bbb25b4..9c7bb8e 100644
--- a/app/Support/Billing/InvoicePaymentHistory.php
+++ b/app/Support/Billing/InvoicePaymentHistory.php
@@ -2,7 +2,9 @@
namespace App\Support\Billing;
+use App\Models\ActivityLog;
use App\Models\Invoice;
+use App\Models\Service;
use App\Models\Transaction;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
@@ -43,11 +45,12 @@ class InvoicePaymentHistory
->get();
$invoiceByNumber = $invoices->keyBy('invoice_number');
+ $renewalCausers = self::renewalCausers($invoices);
foreach ($transactions as $transaction) {
$invoice = self::matchInvoice($transaction, $invoiceByNumber, $invoiceByOrderNum);
if ($invoice) {
- $history[$invoice->id][] = self::present($transaction);
+ $history[$invoice->id][] = self::present($transaction, $renewalCausers[$invoice->invoice_number] ?? null);
}
}
@@ -65,7 +68,42 @@ class InvoicePaymentHistory
return $invoiceByNumber->get($number);
}
- private static function present(Transaction $transaction): array
+ private static function renewalCausers(Collection $invoices): array
+ {
+ $numbers = $invoices->where('invoice_type', 2)->pluck('invoice_number')->all();
+ if ($numbers === []) {
+ return [];
+ }
+
+ return ActivityLog::where('subject_type', Service::class)
+ ->where('event', 'renewed')
+ ->whereIn('new_values->invoice_number', $numbers)
+ ->get(['causer_type', 'new_values'])
+ ->mapWithKeys(fn (ActivityLog $log) => [$log->new_values['invoice_number'] => $log->causer_type])
+ ->all();
+ }
+
+ private static function method(Transaction $transaction, bool $isRefund, ?string $renewalCauser): string
+ {
+ if ($isRefund) {
+ return 'refund';
+ }
+
+ if (in_array($transaction->type, ['transfer', 'balance'], true)) {
+ return $transaction->type;
+ }
+
+ if (preg_match('/\(([a-z_]+)\)$/', (string) $transaction->note, $match) !== 1) {
+ return 'other';
+ }
+
+ return match (true) {
+ $match[1] === 'balance' && $renewalCauser === 'system' => 'auto_renew',
+ default => $match[1],
+ };
+ }
+
+ private static function present(Transaction $transaction, ?string $renewalCauser): array
{
$isRefund = Str::endsWith((string) $transaction->trans, '-REFUND');
@@ -75,7 +113,8 @@ class InvoicePaymentHistory
'kind' => $isRefund ? 'refund' : 'payment',
'amount' => abs((int) $transaction->amount),
'type' => $transaction->type,
- 'note' => $transaction->note,
+ 'method' => self::method($transaction, $isRefund, $renewalCauser),
+ 'note' => preg_replace('/ \([a-z_]+\)$/', '', (string) $transaction->note) ?: null,
'recorded_by' => $transaction->user?->fullname ?: $transaction->user?->username,
'created_at' => $transaction->created_at,
];
diff --git a/app/Support/EmailScenarioRegistry.php b/app/Support/EmailScenarioRegistry.php
index 224fb94..2554eea 100644
--- a/app/Support/EmailScenarioRegistry.php
+++ b/app/Support/EmailScenarioRegistry.php
@@ -189,6 +189,14 @@ final class EmailScenarioRegistry
'label' => '[Admin] Ticket hỗ trợ mới', 'category' => self::INTERNAL, 'group' => 'SUPPORT',
'required_variables' => ['ticket_num'],
],
+ 'admin_customer_service_renewed' => [
+ 'label' => '[Admin] Khách hàng gia hạn dịch vụ', 'category' => self::INTERNAL, 'group' => 'SERVICE',
+ 'required_variables' => ['product_name', 'expires_at'],
+ ],
+ 'admin_service_cancellation_request' => [
+ 'label' => '[Admin] Khách yêu cầu hủy dịch vụ', 'category' => self::INTERNAL, 'group' => 'SERVICE',
+ 'required_variables' => ['customer_name', 'cancel_reason'],
+ ],
'admin_login_alert' => [
'label' => '[Admin] Có người đăng nhập trang quản trị', 'category' => self::INTERNAL, 'group' => 'SECURITY',
'required_variables' => ['admin_username', 'client_ip', 'login_time'],
diff --git a/app/Support/HostingCredentials.php b/app/Support/HostingCredentials.php
new file mode 100644
index 0000000..1edf5b5
--- /dev/null
+++ b/app/Support/HostingCredentials.php
@@ -0,0 +1,35 @@
+setAttribute('hosting_credentials', self::for($service));
+ }
+ }
+
+ public static function for(Service $service): ?array
+ {
+ $hosting = $service->hostingAccount;
+ $client = $hosting ? ModuleHostingPleskClient::forHosting($hosting) : null;
+
+ if (!$client) {
+ return null;
+ }
+
+ return [
+ 'host' => $hosting->server?->publicHost(),
+ 'ip' => $hosting->server?->ip_address,
+ 'panel_url' => $hosting->server?->panelUrl(),
+ 'username' => $client->login,
+ 'password' => Crypt::decryptString($client->password),
+ ];
+ }
+}
diff --git a/app/Support/ResponseCipher.php b/app/Support/ResponseCipher.php
index 477eec4..1bb2458 100644
--- a/app/Support/ResponseCipher.php
+++ b/app/Support/ResponseCipher.php
@@ -1,44 +1,25 @@
expires_at || $service->expires_at->isPast()) {
+ $service->update(['status' => 'cancelled', 'cancelled_at' => now(), 'auto_renew' => false]);
+
+ return self::IMMEDIATE;
+ }
+
+ $service->update(['cancel_at_expiry' => true, 'auto_renew' => false]);
+
+ return self::SCHEDULED;
+ }
+
+ public static function finalize(Service $service): void
+ {
+ $service->update(['status' => 'cancelled', 'cancelled_at' => now()]);
+
+ if ($service->vm_id) {
+ dispatch(new VMJobs((int) $service->vm_id, 'PowerOff', false));
+ }
+ }
+
+ private static function voidOpenRenewalInvoices(Service $service): void
+ {
+ $orderId = $service->orderItem?->order_id;
+ if (! $orderId) {
+ return;
+ }
+
+ Invoice::where('order_id', $orderId)
+ ->where('invoice_type', 2)
+ ->whereIn('status', [InvoiceStatus::UNPAID->value, InvoiceStatus::OVERDUE->value])
+ ->where(fn ($query) => $query->whereNull('amount_paid')->orWhere('amount_paid', 0))
+ ->get()
+ ->each(fn (Invoice $invoice) => $invoice->update(['status' => InvoiceStatus::CANCELLED->value]));
+ }
+}
diff --git a/app/Support/ServiceGroup.php b/app/Support/ServiceGroup.php
new file mode 100644
index 0000000..d8a4e81
--- /dev/null
+++ b/app/Support/ServiceGroup.php
@@ -0,0 +1,58 @@
+ self::EMAIL,
+ $productType === 'ssl' => self::SSL,
+ ! in_array($productType, self::UNTYPED_PRODUCT_TYPES, true) => null,
+ str_starts_with($name, 'ssl') => self::SSL,
+ str_contains($name, 'colo') || str_contains($name, 'chỗ đặt') => self::COLO,
+ $productType === 'server_rental' || str_contains($name, 'dedicated') => self::DEDICATED,
+ default => null,
+ };
+ }
+
+ public static function constrain(Builder $products, string $group): Builder
+ {
+ $untyped = fn (Builder $query) => $query->whereIn('product_type', self::UNTYPED_PRODUCT_TYPES);
+ $notSsl = fn (Builder $query) => $query->whereRaw("TRIM(name) NOT LIKE 'ssl%'");
+ $notColo = fn (Builder $query) => $query->where('name', 'not like', '%colo%')->where('name', 'not like', '%chỗ đặt%');
+
+ return match ($group) {
+ self::EMAIL => $products->where('product_type', 'email'),
+ self::SSL => $products->where(fn (Builder $query) => $query
+ ->where('product_type', 'ssl')
+ ->orWhere(fn (Builder $other) => $untyped($other)->whereRaw("TRIM(name) LIKE 'ssl%'"))),
+ self::COLO => $untyped($products)->tap($notSsl)->where(fn (Builder $query) => $query
+ ->where('name', 'like', '%colo%')
+ ->orWhere('name', 'like', '%chỗ đặt%')),
+ self::DEDICATED => $untyped($products)->tap($notSsl)->tap($notColo)->where(fn (Builder $query) => $query
+ ->where('product_type', 'server_rental')
+ ->orWhere('name', 'like', '%dedicated%')),
+ default => $products->whereRaw('1 = 0'),
+ };
+ }
+}
diff --git a/app/Support/SslBundleExtractor.php b/app/Support/SslBundleExtractor.php
new file mode 100644
index 0000000..5cd4f6e
--- /dev/null
+++ b/app/Support/SslBundleExtractor.php
@@ -0,0 +1,67 @@
+open($archive->getRealPath(), ZipArchive::RDONLY) !== true) {
+ throw new InvalidArgumentException('Không đọc được file zip.');
+ }
+
+ $files = [];
+ $total = 0;
+
+ for ($index = 0; $index < $zip->numFiles; $index++) {
+ $name = self::cleanName($zip->getNameIndex($index));
+ if ($name === null) {
+ continue;
+ }
+
+ $content = $zip->getFromIndex($index, ServiceSslCertificate::MAX_FILE_BYTES + 1);
+ if ($content === false || strlen($content) > ServiceSslCertificate::MAX_FILE_BYTES || ! self::isText($content)) {
+ continue;
+ }
+
+ $total += strlen($content);
+ if ($total > self::MAX_TOTAL_BYTES || count($files) >= ServiceSslCertificate::MAX_ENTRIES) {
+ throw new InvalidArgumentException('File zip chứa quá nhiều dữ liệu.');
+ }
+
+ $files[$name] = ['name' => $name, 'content' => $content];
+ }
+
+ $zip->close();
+
+ if ($files === []) {
+ throw new InvalidArgumentException('File zip không có tệp văn bản nào (pem, crt, key, txt...).');
+ }
+
+ return array_values($files);
+ }
+
+ private static function cleanName(string $entry): ?string
+ {
+ if (str_ends_with($entry, '/') || str_contains($entry, '__MACOSX/')) {
+ return null;
+ }
+
+ $name = trim(basename($entry));
+
+ return $name === '' || str_starts_with($name, '.') ? null : mb_substr($name, 0, 150);
+ }
+
+ private static function isText(string $content): bool
+ {
+ return ! str_contains($content, "\0") && mb_check_encoding($content, 'UTF-8');
+ }
+}
diff --git a/config/services.php b/config/services.php
index 3acfc1f..08cd46b 100644
--- a/config/services.php
+++ b/config/services.php
@@ -48,6 +48,7 @@ return [
// openssl rand -hex 32
'key' => env('CLIENT_API_ACCESS_KEY'),
'secret' => env('CLIENT_API_ACCESS_SECRET'),
+ 'response_key' => env('CLIENT_RESPONSE_KEY'),
],
];
diff --git a/database/migrations/2026_10_27_000004_seed_resend_account_templates.php b/database/migrations/2026_10_27_000004_seed_resend_account_templates.php
new file mode 100644
index 0000000..7066ad7
--- /dev/null
+++ b/database/migrations/2026_10_27_000004_seed_resend_account_templates.php
@@ -0,0 +1,14 @@
+seedServiceLifecycleTemplates();
+ }
+
+ public function down(): void {}
+};
diff --git a/database/migrations/2026_10_27_000005_add_audience_to_service_attachments_table.php b/database/migrations/2026_10_27_000005_add_audience_to_service_attachments_table.php
new file mode 100644
index 0000000..bccb613
--- /dev/null
+++ b/database/migrations/2026_10_27_000005_add_audience_to_service_attachments_table.php
@@ -0,0 +1,22 @@
+string('audience', 20)->default('admin')->after('service_id')->index();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('service_attachments', function (Blueprint $table) {
+ $table->dropColumn('audience');
+ });
+ }
+};
diff --git a/database/migrations/2026_10_28_000001_create_service_ssl_certificates_table.php b/database/migrations/2026_10_28_000001_create_service_ssl_certificates_table.php
new file mode 100644
index 0000000..971bbe6
--- /dev/null
+++ b/database/migrations/2026_10_28_000001_create_service_ssl_certificates_table.php
@@ -0,0 +1,23 @@
+id();
+ $table->foreignId('service_id')->unique()->constrained('services')->cascadeOnDelete();
+ $table->longText('data');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('service_ssl_certificates');
+ }
+};
diff --git a/database/migrations/2026_10_28_000002_create_service_cancellation_requests_table.php b/database/migrations/2026_10_28_000002_create_service_cancellation_requests_table.php
new file mode 100644
index 0000000..39fe6c5
--- /dev/null
+++ b/database/migrations/2026_10_28_000002_create_service_cancellation_requests_table.php
@@ -0,0 +1,31 @@
+id();
+ $table->foreignId('service_id')->constrained('services')->cascadeOnDelete();
+ $table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete();
+ $table->string('reason_code', 40)->nullable();
+ $table->text('reason');
+ $table->string('status', 20)->default('pending')->index();
+ $table->timestamps();
+ });
+
+ (new TemplateSeeder())->seedScenarioIfMissing('admin_service_cancellation_request');
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('service_cancellation_requests');
+ DB::table('template')->where('scenario', 'admin_service_cancellation_request')->delete();
+ }
+};
diff --git a/database/migrations/2026_10_28_000003_add_resolution_to_service_cancellation_requests.php b/database/migrations/2026_10_28_000003_add_resolution_to_service_cancellation_requests.php
new file mode 100644
index 0000000..37c94e4
--- /dev/null
+++ b/database/migrations/2026_10_28_000003_add_resolution_to_service_cancellation_requests.php
@@ -0,0 +1,24 @@
+text('admin_note')->nullable()->after('reason');
+ $table->timestamp('resolved_at')->nullable()->after('status');
+ $table->string('resolved_by', 100)->nullable()->after('resolved_at');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('service_cancellation_requests', function (Blueprint $table) {
+ $table->dropColumn(['admin_note', 'resolved_at', 'resolved_by']);
+ });
+ }
+};
diff --git a/database/migrations/2026_10_28_000004_add_cancel_at_expiry_to_services_table.php b/database/migrations/2026_10_28_000004_add_cancel_at_expiry_to_services_table.php
new file mode 100644
index 0000000..0ad3360
--- /dev/null
+++ b/database/migrations/2026_10_28_000004_add_cancel_at_expiry_to_services_table.php
@@ -0,0 +1,22 @@
+boolean('cancel_at_expiry')->default(false)->after('auto_renew')->index();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('services', function (Blueprint $table) {
+ $table->dropColumn('cancel_at_expiry');
+ });
+ }
+};
diff --git a/database/migrations/2026_10_28_000005_seed_admin_customer_service_renewed_template.php b/database/migrations/2026_10_28_000005_seed_admin_customer_service_renewed_template.php
new file mode 100644
index 0000000..7e78077
--- /dev/null
+++ b/database/migrations/2026_10_28_000005_seed_admin_customer_service_renewed_template.php
@@ -0,0 +1,18 @@
+seedScenarioIfMissing('admin_customer_service_renewed');
+ }
+
+ public function down(): void
+ {
+ DB::table('template')->where('scenario', 'admin_customer_service_renewed')->delete();
+ }
+};
diff --git a/database/seeders/EmailTemplates/AdminTemplates.php b/database/seeders/EmailTemplates/AdminTemplates.php
index 91679ca..f21fb6f 100644
--- a/database/seeders/EmailTemplates/AdminTemplates.php
+++ b/database/seeders/EmailTemplates/AdminTemplates.php
@@ -166,6 +166,44 @@ final class AdminTemplates
. T::note('{{description}}', 'Nội dung:')
. T::button('{{ticket_url}}', 'XEM TICKET'),
],
+ [
+ 'scenario' => 'admin_customer_service_renewed',
+ 'name' => '[Admin] Khách hàng gia hạn dịch vụ',
+ 'subject' => '[ADMIN] {{customer_name}} gia hạn dịch vụ {{product_name}}',
+ 'body' => T::title('Khách hàng vừa gia hạn dịch vụ')
+ . T::paragraph('Một dịch vụ trên hệ thống {{company_name}} vừa được gia hạn thành công.')
+ . T::infoTable([
+ 'Hình thức' => '{{renewal_source_label}}',
+ 'Khách hàng' => '{{customer_name}}',
+ 'Email' => '{{customer_email}}',
+ 'Dịch vụ' => '{{product_name}}{{server_name_suffix}}',
+ 'Thời gian gia hạn' => '{{months_label}}',
+ 'Hạn sử dụng trước' => '{{old_expires_at}}',
+ 'Hạn sử dụng mới' => '{{expires_at}}',
+ 'Hoá đơn' => '{{invoice_number}}',
+ 'Số tiền' => '{{amount_formatted}}',
+ 'Thanh toán' => '{{payment_method_label}}',
+ ])
+ . T::button('{{admin_url}}', 'XEM DỊCH VỤ'),
+ ],
+ [
+ 'scenario' => 'admin_service_cancellation_request',
+ 'name' => '[Admin] Khách yêu cầu hủy dịch vụ',
+ 'subject' => '[ADMIN] {{customer_name}} yêu cầu hủy dịch vụ {{product_name}}',
+ 'body' => T::title('Khách hàng yêu cầu hủy dịch vụ')
+ . T::paragraph('Khách hàng vừa gửi yêu cầu hủy dịch vụ trên hệ thống {{company_name}}. Dịch vụ chưa bị hủy, cần nhân viên liên hệ và xử lý.')
+ . T::infoTable([
+ 'Khách hàng' => '{{customer_name}}',
+ 'Email' => '{{customer_email}}',
+ 'Dịch vụ' => '{{product_name}}',
+ 'Tên dịch vụ' => '{{server_name}}',
+ 'Ngày hết hạn' => '{{expires_at}}',
+ 'Lý do hủy' => '{{cancel_reason}}',
+ 'Thời gian' => '{{created_at}}',
+ 'Địa chỉ IP' => '{{client_ip}}',
+ ])
+ . T::button('{{admin_url}}', 'XEM DỊCH VỤ'),
+ ],
[
'scenario' => 'admin_login_alert',
'name' => '[Admin] Có người đăng nhập trang quản trị',
diff --git a/database/seeders/EmailTemplates/ServiceLifecycleTemplates.php b/database/seeders/EmailTemplates/ServiceLifecycleTemplates.php
index d6f1a66..665934a 100644
--- a/database/seeders/EmailTemplates/ServiceLifecycleTemplates.php
+++ b/database/seeders/EmailTemplates/ServiceLifecycleTemplates.php
@@ -132,6 +132,10 @@ final class ServiceLifecycleTemplates
$items[] = self::genericNewService($hasAccount);
}
+ foreach ([true, false] as $withPassword) {
+ $items[] = self::resendAccount($withPassword);
+ }
+
return $items;
}
@@ -193,6 +197,27 @@ final class ServiceLifecycleTemplates
);
}
+ private static function resendAccount(bool $withPassword): array
+ {
+ $loginLink = '{{login_url}}';
+ $rows = ['Trang đăng nhập' => $loginLink, 'Email đăng nhập' => '{{account_email}}'];
+ $rows['Mật khẩu'] = $withPassword ? '{{account_pass}}' : 'Mật khẩu hiện tại của Quý khách';
+
+ return self::template(
+ 'Tài khoản — Gửi lại thông tin tài khoản (' . ($withPassword ? 'kèm mật khẩu' : 'không kèm mật khẩu') . ')',
+ 'V/v: Thế Giới Số gửi lại thông tin tài khoản | {{client_name}}',
+ T::greeting('{{client_name}}')
+ . T::paragraph('Theo yêu cầu của Quý khách, {{app_name}} xin gửi lại thông tin tài khoản đăng nhập như sau:')
+ . T::infoTable($rows)
+ . ($withPassword
+ ? T::note('Quý khách vui lòng đổi mật khẩu ngay sau khi đăng nhập và không chia sẻ thông tin tài khoản cho người khác.', 'Bảo mật:')
+ : T::note('Nếu không nhớ mật khẩu, Quý khách chọn "Quên mật khẩu" tại trang đăng nhập để đặt lại.'))
+ . T::button('{{login_url}}', 'ĐĂNG NHẬP')
+ . T::supportLine()
+ . T::paragraph('Trân trọng,'),
+ );
+ }
+
private static function genericNewService(bool $hasAccount): array
{
$accessRows = '';
diff --git a/database/seeders/EmailTemplates/SubjectFormat.php b/database/seeders/EmailTemplates/SubjectFormat.php
index 4ba61c3..2e66552 100644
--- a/database/seeders/EmailTemplates/SubjectFormat.php
+++ b/database/seeders/EmailTemplates/SubjectFormat.php
@@ -39,6 +39,8 @@ final class SubjectFormat
'admin_trial_registration_request' => ['[ADMIN] yêu cầu dùng thử Cloud VPS', null, 'customer_name'],
'admin_manual_deposit_pending' => ['[ADMIN] yêu cầu nạp tiền chờ duyệt {{transaction_code}}', null, 'customer_name'],
'admin_ticket_notification' => ['[ADMIN] ticket mới #{{ticket_num}}', null, 'customer_name'],
+ 'admin_customer_service_renewed' => ['[ADMIN] {{customer_name}} gia hạn dịch vụ {{product_name}}', null, null],
+ 'admin_service_cancellation_request' => ['[ADMIN] {{customer_name}} yêu cầu hủy dịch vụ {{product_name}}', null, null],
'admin_login_alert' => ['[ADMIN] {{admin_username}} đăng nhập trang quản trị từ IP {{client_ip}}', null, null],
'admin_payment_received' => ['[ADMIN] khách hàng thanh toán {{payment_type}} #{{reference}}', null, 'customer_name'],
'admin_service_renewed' => ['[ADMIN] {{admin_name}} gia hạn dịch vụ', 'product_name', 'customer_name'],
diff --git a/database/seeders/EmailTemplates/TemplateVariables.php b/database/seeders/EmailTemplates/TemplateVariables.php
index 9c04fea..1aec148 100644
--- a/database/seeders/EmailTemplates/TemplateVariables.php
+++ b/database/seeders/EmailTemplates/TemplateVariables.php
@@ -112,6 +112,8 @@ final class TemplateVariables
'status_line' => ['Tình trạng hạn thanh toán', 'string', 'Hoá đơn sẽ đến hạn sau 3 ngày.'],
'paid_row' => ['[Tự động] Dòng đã thanh toán', 'html', ''],
'transaction_code' => ['Mã giao dịch', 'string', 'NAP20261008001'],
+ 'renewal_source_label' => ['Hình thức gia hạn', 'string', 'Khách hàng tự gia hạn'],
+ 'cancel_reason' => ['Lý do hủy dịch vụ', 'string', 'Không còn nhu cầu sử dụng'],
'admin_username' => ['Tài khoản quản trị', 'string', 'admin'],
'login_time' => ['Thời gian đăng nhập', 'string', '23/10/2026 08:30:00'],
'client_ip' => ['Địa chỉ IP', 'string', '203.113.10.20'],
diff --git a/routes/api/admin/customers.php b/routes/api/admin/customers.php
index ce7ba4d..9340a18 100644
--- a/routes/api/admin/customers.php
+++ b/routes/api/admin/customers.php
@@ -2,8 +2,10 @@
use App\Http\Controllers\Api\Admin\Customer\CustomerBillingProfileController;
use App\Http\Controllers\Api\Admin\Customer\CustomerController;
+use App\Http\Controllers\Api\Admin\Service\ServiceCancellationRequestController;
use App\Http\Controllers\Api\Admin\Service\ServiceController;
use App\Http\Controllers\Api\Admin\Service\ServiceRenewalReminderController;
+use App\Http\Controllers\Api\Admin\Service\SslCertificateController;
use Illuminate\Support\Facades\Route;
// Customers (module: customer)
@@ -98,6 +100,17 @@ Route::prefix('services')->controller(ServiceController::class)->group(function
Route::post('{id}/attachments/{attachmentId}/copy-to-invoice', 'copyAttachmentToInvoice')->middleware('perm:invoice.edit');
});
+Route::prefix('services/{id}/cancellation-requests')->controller(ServiceCancellationRequestController::class)->group(function () {
+ Route::get('/', 'index')->middleware('perm:customer.view');
+ Route::post('{requestId}/resolve', 'resolve')->middleware('perm:customer.edit');
+});
+
+Route::prefix('services/{id}/ssl-info')->controller(SslCertificateController::class)->group(function () {
+ Route::get('/', 'show')->middleware('perm:customer.view');
+ Route::put('/', 'update')->middleware('perm:customer.edit');
+ Route::post('archive', 'uploadArchive')->middleware('perm:customer.edit');
+});
+
Route::prefix('customers/{uuid}/billing-profile')->controller(CustomerBillingProfileController::class)->group(function () {
Route::get('/', 'show')->middleware('perm:customer.view');
Route::put('/', 'update')->middleware('perm:customer.edit');
diff --git a/routes/client.php b/routes/client.php
index 848519c..ce3ddf5 100644
--- a/routes/client.php
+++ b/routes/client.php
@@ -1,11 +1,16 @@
name('hosting.list');
Route::get('web-design', 'indexWebDesign')->name('web-design.list');
Route::get('domain', 'indexDomain')->name('domain.list');
- Route::get('{uuid}', 'getDetail')->middleware('seal.response:always')->name('detail');
- Route::get('{uuid}/vm-password', 'getVmPassword')->middleware(['throttle:20,1', 'seal.response:always'])->name('vm-password');
- Route::get('{uuid}/console-ticket', 'getConsoleTicket')->middleware('seal.response:always')->name('console-ticket');
+ Route::get('group/{group}', 'indexGroup')->whereIn('group', ServiceGroup::ALL)->name('group.list');
+ Route::get('{uuid}', 'getDetail')->name('detail');
+ Route::get('{uuid}/attachments/{attachmentId}', 'downloadAttachment')->whereNumber('attachmentId')->name('attachments.download');
+ Route::post('{uuid}/cancel-request', [ServiceCancellationController::class, 'store'])->middleware('throttle:5,1')->name('cancel-request.store');
+ Route::delete('{uuid}/cancel-request', [ServiceCancellationController::class, 'destroy'])->name('cancel-request.destroy');
+ Route::get('{uuid}/renewals', ServiceRenewalHistoryController::class)->name('renewals');
+ Route::get('{uuid}/ssl-archive', SslArchiveController::class)->name('ssl-archive');
+ Route::put('{uuid}/hosting-password', HostingPasswordController::class)->middleware('throttle:5,1')->name('hosting-password.update');
+ Route::get('{uuid}/console-ticket', 'getConsoleTicket')->name('console-ticket');
Route::get('{uuid}/available-os', 'getAvailableOs')->name('available-os');
Route::post('{uuid}/rebuild', 'rebuild')->name('rebuild');
Route::patch('{uuid}/auto-renew', 'updateAutoRenew')->name('auto-renew.update');
});
- Route::middleware(['api.auth', 'seal.response:always'])
+ Route::middleware(['api.auth'])
->get('get-ticket-console', [ClientServiceController::class, 'getTicketByOrderNum'])
->name('console-ticket');
@@ -325,7 +336,7 @@ Route::prefix('v1/system')
Route::post('vm-change-ip', 'changeIp')->name('vm.change-ip');
Route::post('vm-change-port', 'changePort')->name('vm.change-port');
Route::post('vm-change-password', 'changePassword')->name('vm.change-password');
- Route::get('get-ticket-console', 'consoleTicket')->middleware('seal.response:always')->name('vm.console-ticket');
+ Route::get('get-ticket-console', 'consoleTicket')->name('vm.console-ticket');
Route::post('vm-restart', 'restart')->name('vm.restart');
});
diff --git a/storage/app/mail-campaigns/14d9c910-767c-4893-82c1-e34baf22bf8f/0JEpgCG7ebKtAJ60uzEWBrpKF8PP92e8zFj9hsKD.zip b/storage/app/mail-campaigns/14d9c910-767c-4893-82c1-e34baf22bf8f/0JEpgCG7ebKtAJ60uzEWBrpKF8PP92e8zFj9hsKD.zip
new file mode 100644
index 0000000..94165aa
Binary files /dev/null and b/storage/app/mail-campaigns/14d9c910-767c-4893-82c1-e34baf22bf8f/0JEpgCG7ebKtAJ60uzEWBrpKF8PP92e8zFj9hsKD.zip differ
diff --git a/storage/app/mail-campaigns/14d9c910-767c-4893-82c1-e34baf22bf8f/N4TwOA7gRUc34EQ4G5Zcu8vs2BqenTM5krMDga0q.zip b/storage/app/mail-campaigns/14d9c910-767c-4893-82c1-e34baf22bf8f/N4TwOA7gRUc34EQ4G5Zcu8vs2BqenTM5krMDga0q.zip
new file mode 100644
index 0000000..e8af415
Binary files /dev/null and b/storage/app/mail-campaigns/14d9c910-767c-4893-82c1-e34baf22bf8f/N4TwOA7gRUc34EQ4G5Zcu8vs2BqenTM5krMDga0q.zip differ
diff --git a/storage/app/mail-campaigns/a082fe43-4913-40fb-a274-987e3e8049c6/WTqbHB8xjOvzKDB9VfqOUJIpW9vZ84pW17YX2j5u.zip b/storage/app/mail-campaigns/a082fe43-4913-40fb-a274-987e3e8049c6/WTqbHB8xjOvzKDB9VfqOUJIpW9vZ84pW17YX2j5u.zip
new file mode 100644
index 0000000..e8af415
Binary files /dev/null and b/storage/app/mail-campaigns/a082fe43-4913-40fb-a274-987e3e8049c6/WTqbHB8xjOvzKDB9VfqOUJIpW9vZ84pW17YX2j5u.zip differ
diff --git a/storage/app/mail-campaigns/a082fe43-4913-40fb-a274-987e3e8049c6/ecWXApUP78eyigDTvhI1eHVlY0watGZGLdJq2kyN.zip b/storage/app/mail-campaigns/a082fe43-4913-40fb-a274-987e3e8049c6/ecWXApUP78eyigDTvhI1eHVlY0watGZGLdJq2kyN.zip
new file mode 100644
index 0000000..94165aa
Binary files /dev/null and b/storage/app/mail-campaigns/a082fe43-4913-40fb-a274-987e3e8049c6/ecWXApUP78eyigDTvhI1eHVlY0watGZGLdJq2kyN.zip differ
diff --git a/storage/app/public/payment-proofs/085A5NerMKqSYwQl7636y7OHcxPdgvFGWrsz3WXn.png b/storage/app/public/payment-proofs/085A5NerMKqSYwQl7636y7OHcxPdgvFGWrsz3WXn.png
new file mode 100644
index 0000000..1823e8e
Binary files /dev/null and b/storage/app/public/payment-proofs/085A5NerMKqSYwQl7636y7OHcxPdgvFGWrsz3WXn.png differ
diff --git a/storage/app/public/payment-proofs/ZN25GsGRhD7KG2xQO2o3K6uM6BPGCiC7OjyR4gi3.png b/storage/app/public/payment-proofs/ZN25GsGRhD7KG2xQO2o3K6uM6BPGCiC7OjyR4gi3.png
new file mode 100644
index 0000000..2dc6030
Binary files /dev/null and b/storage/app/public/payment-proofs/ZN25GsGRhD7KG2xQO2o3K6uM6BPGCiC7OjyR4gi3.png differ
diff --git a/tests/Feature/Api/Admin/DnsManager/DnsServerDomainTest.php b/tests/Feature/Api/Admin/DnsManager/DnsServerDomainTest.php
index 7938f1a..e65119e 100644
--- a/tests/Feature/Api/Admin/DnsManager/DnsServerDomainTest.php
+++ b/tests/Feature/Api/Admin/DnsManager/DnsServerDomainTest.php
@@ -32,6 +32,13 @@ class DnsServerDomainTest extends TestCase
$this->assertSame('103.42.58.99', $server->publicHost());
}
+ public function test_panel_url_is_https_on_public_host_without_port_or_script(): void
+ {
+ $server = new DnsServer(['ip_address' => '103.42.58.99', 'domain' => 'login.tgs.com.vn', 'panel_port' => 8880]);
+
+ $this->assertSame('https://login.tgs.com.vn/', $server->panelUrl());
+ }
+
public function test_store_saves_domain_and_rejects_invalid_hostname(): void
{
$admin = $this->admin();
diff --git a/tests/Feature/Api/Admin/Product/ProductCategoryFilterTest.php b/tests/Feature/Api/Admin/Product/ProductCategoryFilterTest.php
new file mode 100644
index 0000000..45b597f
--- /dev/null
+++ b/tests/Feature/Api/Admin/Product/ProductCategoryFilterTest.php
@@ -0,0 +1,49 @@
+ 'Full Administrator', 'guard_name' => 'admin']);
+ $admin = User::factory()->create();
+ $admin->assignRole('Full Administrator');
+
+ $wordpress = ProductCategories::create([
+ 'name' => 'HOSTING WORDPRESS',
+ 'slug' => 'wordpress-hosting',
+ 'type' => 'hosting',
+ 'is_active' => true,
+ 'sort_order' => 1,
+ ]);
+ $other = ProductCategories::create([
+ 'name' => 'Other',
+ 'slug' => 'other',
+ 'type' => 'hosting',
+ 'is_active' => true,
+ 'sort_order' => 2,
+ ]);
+
+ $expected = Product::factory()->create(['product_categories_id' => $wordpress->id]);
+ Product::factory()->count(16)->create(['product_categories_id' => $other->id]);
+
+ $this->actingAs($admin, 'admin')
+ ->getJson("/api/v1/administrator/products?per_page=15&product_categories_id={$wordpress->id}")
+ ->assertOk()
+ ->assertJsonPath('data.total', 1)
+ ->assertJsonCount(1, 'data.items')
+ ->assertJsonPath('data.items.0.id', $expected->id);
+ }
+}
diff --git a/tests/Feature/Api/Admin/Service/SslCertificateTest.php b/tests/Feature/Api/Admin/Service/SslCertificateTest.php
new file mode 100644
index 0000000..5ccb83e
--- /dev/null
+++ b/tests/Feature/Api/Admin/Service/SslCertificateTest.php
@@ -0,0 +1,117 @@
+ 'Full Administrator', 'guard_name' => 'admin']);
+ $this->admin = User::factory()->create();
+ $this->admin->assignRole('Full Administrator');
+ }
+
+ private function service(string $productType = 'ssl'): Service
+ {
+ $product = Product::factory()->create(['product_type' => $productType, 'name' => 'SSL - Positive']);
+
+ return Service::create(['customer_id' => Customer::factory()->create()->id, 'product_id' => $product->id, 'status' => 'active']);
+ }
+
+ private function zip(array $entries): UploadedFile
+ {
+ $path = tempnam(sys_get_temp_dir(), 'ssl') . '.zip';
+ $zip = new ZipArchive();
+ $zip->open($path, ZipArchive::CREATE);
+ foreach ($entries as $name => $content) {
+ $zip->addFromString($name, $content);
+ }
+ $zip->close();
+
+ return new UploadedFile($path, 'cert.zip', 'application/zip', null, true);
+ }
+
+ public function test_archive_is_extracted_to_text_files_and_visible_to_client(): void
+ {
+ $service = $this->service();
+ $base = "/api/v1/administrator/services/{$service->id}/ssl-info";
+
+ $this->actingAs($this->admin, 'admin')
+ ->post("{$base}/archive", ['archive' => $this->zip([
+ 'bundle/example.com.crt' => "-----BEGIN CERTIFICATE-----\nabc\n-----END CERTIFICATE-----",
+ '../../evil.key' => 'key-data',
+ '__MACOSX/._example.com.crt' => 'junk',
+ 'logo.png' => "\0\1binary",
+ 'dir/' => '',
+ ])], ['Accept' => 'application/json'])
+ ->assertOk()
+ ->assertJsonCount(2, 'data.files')
+ ->assertJsonPath('data.files.0.name', 'example.com.crt')
+ ->assertJsonPath('data.files.1.name', 'evil.key');
+
+ $this->actingAs($this->admin, 'admin')
+ ->putJson($base, [
+ 'fields' => [['label' => 'CNAME host', 'value' => '_abc.example.com']],
+ 'files' => [['name' => 'a.pem', 'content' => 'pem']],
+ ])
+ ->assertOk();
+
+ $this->actingAs($this->admin, 'admin')->getJson($base)
+ ->assertJsonPath('data.fields.0.value', '_abc.example.com')
+ ->assertJsonCount(1, 'data.files');
+
+ $this->assertStringNotContainsString('pem', (string) \DB::table('service_ssl_certificates')->value('data'));
+
+ $payload = (new ServiceClientDetailResource($service->fresh()))->toArray(new Request());
+ $this->assertSame('a.pem', $payload['ssl_certificate']['files'][0]['name']);
+ }
+
+ public function test_non_ssl_service_is_rejected(): void
+ {
+ $service = $this->service('domain');
+
+ $this->actingAs($this->admin, 'admin')
+ ->getJson("/api/v1/administrator/services/{$service->id}/ssl-info")
+ ->assertStatus(422);
+ }
+
+ public function test_customer_downloads_own_certificate_zip_only(): void
+ {
+ $service = $this->service();
+ $service->sslCertificate()->create(['data' => [
+ 'fields' => [['label' => 'CNAME', 'value' => 'abc']],
+ 'files' => [['name' => 'a.pem', 'content' => 'pem-data']],
+ ]]);
+ $owner = Customer::find($service->customer_id);
+ $path = "/v1/client/client-service/{$service->uuid}/ssl-archive";
+
+ $response = $this->actingAs($owner, 'client')->withHeaders(['x-access-token' => $this->buildClientAccessToken()])->get($path);
+ $response->assertOk();
+
+ $zip = new ZipArchive();
+ $zip->open($response->baseResponse->getFile()->getPathname());
+ $this->assertSame('pem-data', $zip->getFromName('a.pem'));
+ $this->assertStringContainsString('CNAME: abc', $zip->getFromName('thong-tin.txt'));
+
+ $this->actingAs(Customer::factory()->create(), 'client')->withHeaders(['x-access-token' => $this->buildClientAccessToken()])->get($path)->assertNotFound();
+ }
+}
diff --git a/tests/Feature/Api/Client/ServiceCancellationTest.php b/tests/Feature/Api/Client/ServiceCancellationTest.php
new file mode 100644
index 0000000..e1973fa
--- /dev/null
+++ b/tests/Feature/Api/Client/ServiceCancellationTest.php
@@ -0,0 +1,112 @@
+ 'Full Administrator', 'guard_name' => 'admin']);
+ User::factory()->create()->assignRole('Full Administrator');
+ Setting::updateOrCreate(['key' => 'setting'], ['value' => json_encode(['admin_email' => 'admin@example.com'])]);
+
+ $this->owner = Customer::factory()->create();
+ $this->service = Service::create(['customer_id' => $this->owner->id, 'type' => 'ssl', 'status' => 'active', 'server_name' => 'example.com']);
+ }
+
+ private function as(Customer $customer): static
+ {
+ return $this->actingAs($customer, 'client')->withHeaders(['x-access-token' => $this->buildClientAccessToken()]);
+ }
+
+ public function test_request_notifies_admins_and_blocks_duplicates_until_withdrawn(): void
+ {
+ $url = "/v1/client/client-service/{$this->service->uuid}/cancel-request";
+
+ $this->as($this->owner)->postJson($url, ['reason_code' => 'other', 'reason' => 'Chuyển sang nhà cung cấp khác'])
+ ->assertCreated()
+ ->assertJsonPath('data.reason', 'Chuyển sang nhà cung cấp khác');
+
+ $this->assertSame(1, Notification::where('type', 'service_cancellation_request')->where('recipient_type', 'admin')->count());
+ Mail::assertQueued(ServiceCancellationRequestMail::class, fn ($mail) => $mail->hasTo('admin@example.com'));
+
+ $this->as($this->owner)->postJson($url, ['reason' => 'Lặp lại'])->assertStatus(422);
+
+ $this->as($this->owner)->deleteJson($url)->assertOk();
+ $this->as($this->owner)->postJson($url, ['reason' => 'Gửi lại sau khi rút'])->assertCreated();
+ }
+
+ public function test_other_customer_cannot_request_and_reason_is_required(): void
+ {
+ $url = "/v1/client/client-service/{$this->service->uuid}/cancel-request";
+
+ $this->as(Customer::factory()->create())->postJson($url, ['reason' => 'Hủy giúp'])->assertNotFound();
+ $this->as($this->owner)->postJson($url, [])->assertStatus(422);
+ $this->assertSame(0, Notification::where('type', 'service_cancellation_request')->count());
+ }
+
+ public function test_admin_approves_or_rejects_once_and_customer_is_notified(): void
+ {
+ $admin = User::first();
+ $pending = $this->service->cancellationRequests()->create(['customer_id' => $this->owner->id, 'reason' => 'Không dùng nữa']);
+ $base = "/api/v1/administrator/services/{$this->service->id}/cancellation-requests";
+
+ $this->actingAs($admin, 'admin')->postJson("{$base}/{$pending->id}/resolve", ['action' => 'reject'])->assertStatus(422);
+ $this->actingAs($admin, 'admin')->postJson("{$base}/{$pending->id}/resolve", ['action' => 'reject', 'note' => 'Còn hợp đồng'])->assertOk();
+ $this->assertSame('active', $this->service->fresh()->status);
+ $this->actingAs($admin, 'admin')->postJson("{$base}/{$pending->id}/resolve", ['action' => 'approve'])->assertStatus(422);
+
+ $second = $this->service->cancellationRequests()->create(['customer_id' => $this->owner->id, 'reason' => 'Lần hai']);
+ $this->actingAs($admin, 'admin')->postJson("{$base}/{$second->id}/resolve", ['action' => 'approve'])->assertOk();
+
+ $this->assertSame('cancelled', $this->service->fresh()->status);
+ $this->assertSame(2, Notification::where('recipient_type', 'client')->where('type', 'service_cancellation_resolved')->count());
+ $this->actingAs($admin, 'admin')->getJson($base)->assertJsonCount(2, 'data');
+ }
+
+ public function test_approved_cancellation_keeps_service_until_expiry_then_cancels(): void
+ {
+ $this->service->update(['expires_at' => now()->addDays(5), 'auto_renew' => true]);
+ $pending = $this->service->cancellationRequests()->create(['customer_id' => $this->owner->id, 'reason' => 'Không dùng nữa']);
+
+ $this->actingAs(User::first(), 'admin')
+ ->postJson("/api/v1/administrator/services/{$this->service->id}/cancellation-requests/{$pending->id}/resolve", ['action' => 'approve'])
+ ->assertOk();
+
+ $service = $this->service->fresh();
+ $this->assertSame('active', $service->status);
+ $this->assertTrue($service->cancel_at_expiry);
+ $this->assertFalse($service->auto_renew);
+ $this->assertNull($service->cancelled_at);
+
+ $this->as($this->owner)->postJson("/v1/client/client-service/{$service->uuid}/cancel-request", ['reason' => 'Gửi lại'])->assertStatus(422);
+
+ $this->artisan('service:expiry')->assertSuccessful();
+ $this->assertSame('active', $service->fresh()->status);
+
+ $service->update(['expires_at' => now()->subHour()]);
+ $this->artisan('service:expiry')->assertSuccessful();
+ $this->assertSame('cancelled', $service->fresh()->status);
+ $this->assertNotNull($service->fresh()->cancelled_at);
+ }
+}
diff --git a/tests/Feature/Api/Service/RenewServiceTest.php b/tests/Feature/Api/Service/RenewServiceTest.php
index 5f6943c..57e21ea 100644
--- a/tests/Feature/Api/Service/RenewServiceTest.php
+++ b/tests/Feature/Api/Service/RenewServiceTest.php
@@ -12,7 +12,13 @@ use App\Models\OrderItem;
use App\Models\Product;
use App\Models\ProductPricings;
use App\Models\Service;
+use App\Mail\ServiceRenewedMail;
+use App\Models\Notification;
+use App\Models\Setting;
+use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\Mail;
+use Spatie\Permission\Models\Role;
use Tests\TestCase;
class RenewServiceTest extends TestCase
@@ -108,4 +114,51 @@ class RenewServiceTest extends TestCase
$this->renew(2)->assertStatus(422);
$this->assertSame(0, Invoice::where('invoice_type', 2)->count());
}
+
+ public function test_customer_renewal_notifies_admins_by_notification_and_email(): void
+ {
+ Mail::fake();
+ Role::firstOrCreate(['name' => 'Full Administrator', 'guard_name' => 'admin']);
+ User::factory()->create()->assignRole('Full Administrator');
+ Setting::updateOrCreate(['key' => 'setting'], ['value' => json_encode(['admin_email' => 'admin@example.com'])]);
+
+ $this->renew(3)->assertOk();
+
+ $notification = Notification::where('type', 'service_renewed_by_customer')->where('recipient_type', 'admin')->sole();
+ $this->assertSame('/services/' . $this->service->id, $notification->link);
+ Mail::assertQueued(ServiceRenewedMail::class, fn ($mail) => $mail->hasTo('admin@example.com'));
+ }
+
+ public function test_customer_reads_own_renewal_history_only(): void
+ {
+ $this->renew(3)->assertOk();
+ $path = "/v1/client/client-service/{$this->service->uuid}/renewals";
+ $headers = ['x-access-token' => $this->buildClientAccessToken()];
+
+ $this->actingAs($this->customer, 'client')->withHeaders($headers)->getJson($path)
+ ->assertOk()
+ ->assertJsonCount(1, 'data')
+ ->assertJsonPath('data.0.months', 3)
+ ->assertJsonPath('data.0.by', 'Bạn')
+ ->assertJsonPath('data.0.amount', 40000);
+
+ $this->actingAs(Customer::factory()->create(), 'client')->withHeaders($headers)->getJson($path)->assertNotFound();
+ }
+
+ public function test_renewal_invoice_detail_reports_renewal_term_and_next_date(): void
+ {
+ $expiresAt = $this->service->expires_at->copy();
+ $this->renew(3)->assertOk();
+ $invoice = Invoice::where('invoice_type', 2)->sole();
+
+ $this->actingAs($this->customer, 'client')
+ ->withHeaders(['x-access-token' => $this->buildClientAccessToken()])
+ ->getJson("/v1/client/invoices/order/ORD-RENEW-TEST?invoice={$invoice->invoice_number}")
+ ->assertOk()
+ ->assertJsonPath('data.service.months', 3)
+ ->assertJsonPath('data.items.0.months', 3)
+ ->assertJsonPath('data.items.0.price', 40000)
+ ->assertJsonPath('data.billing_period_end', $expiresAt->copy()->addMonths(3)->toDateString())
+ ->assertJsonPath('data.next_renewal_date', $expiresAt->copy()->addMonths(3)->toDateString());
+ }
}
diff --git a/tests/Feature/ServiceGroupTest.php b/tests/Feature/ServiceGroupTest.php
new file mode 100644
index 0000000..cb9fe25
--- /dev/null
+++ b/tests/Feature/ServiceGroupTest.php
@@ -0,0 +1,31 @@
+assertSame('email', ServiceGroup::of('email', 'Email Business'));
+ $this->assertSame('ssl', ServiceGroup::of('ssl', 'Whatever'));
+ $this->assertSame('ssl', ServiceGroup::of('other', 'SSL - Positive'));
+ $this->assertSame('colo', ServiceGroup::of('other', 'COLO VNPT STAR'));
+ $this->assertSame('colo', ServiceGroup::of('server_rental', 'Chỗ đặt 1U'));
+ $this->assertSame('dedicated', ServiceGroup::of('other', 'DEDICATED GOLD'));
+ $this->assertSame('dedicated', ServiceGroup::of('server_rental', 'Máy chủ X'));
+ $this->assertNull(ServiceGroup::of('other', 'Khác'));
+ $this->assertNull(ServiceGroup::of('vps', 'colo vps'));
+ }
+
+ public function test_constrain_sql_matches_of(): void
+ {
+ foreach (ServiceGroup::ALL as $group) {
+ $sql = ServiceGroup::constrain(Product::query(), $group)->toSql();
+ $this->assertStringContainsString('product_type', $sql, $group);
+ }
+ }
+}
diff --git a/tests/Feature/TemplateSeederTest.php b/tests/Feature/TemplateSeederTest.php
index 1072255..a8431dc 100644
--- a/tests/Feature/TemplateSeederTest.php
+++ b/tests/Feature/TemplateSeederTest.php
@@ -45,7 +45,7 @@ class TemplateSeederTest extends TestCase
public function test_service_lifecycle_templates_are_seeded_once_as_marketing(): void
{
$items = ServiceLifecycleTemplates::all();
- $this->assertCount(37, $items);
+ $this->assertCount(39, $items);
$this->seed(TemplateSeeder::class);
$this->seed(TemplateSeeder::class);
diff --git a/tests/Unit/ResponseCipherTest.php b/tests/Unit/ResponseCipherTest.php
deleted file mode 100644
index bb815f2..0000000
--- a/tests/Unit/ResponseCipherTest.php
+++ /dev/null
@@ -1,85 +0,0 @@
- 'application/json'], json_encode($body));
- $request->headers->set('x-seal-key', base64_encode($key ?? $this->key()));
- if ($optIn) {
- $request->headers->set('x-seal', '1');
- }
-
- return $request;
- }
-
- public function test_always_mode_seals_with_session_key_and_is_reversible(): void
- {
- $response = (new SealResponse())->handle(
- $this->request(),
- fn () => new JsonResponse(['data' => ['password' => 'Cloud226*()']]),
- 'always',
- );
-
- $body = $response->getData(true);
- $this->assertArrayNotHasKey('data', $body);
- $this->assertStringNotContainsString('Cloud226', $response->getContent());
- $this->assertSame(
- ['data' => ['password' => 'Cloud226*()']],
- json_decode(ResponseCipher::open($body['enc'], $this->key()), true),
- );
- $this->assertNull(ResponseCipher::open($body['enc'], str_repeat('x', 32)));
- }
-
- public function test_passthrough_without_key_or_opt_in(): void
- {
- $noKey = Request::create('/x');
- $noKey->headers->set('x-seal', '1');
- $plain = (new SealResponse())->handle($noKey, fn () => new JsonResponse(['a' => 1]));
- $this->assertSame(['a' => 1], $plain->getData(true));
-
- $noOptIn = (new SealResponse())->handle($this->request(), fn () => new JsonResponse(['a' => 1]));
- $this->assertSame(['a' => 1], $noOptIn->getData(true));
- }
-
- public function test_opt_in_seals_errors_and_opens_request_body(): void
- {
- $sealed = ['enc' => ResponseCipher::seal(json_encode(['name' => 'An']), $this->key())];
-
- $response = (new SealResponse())->handle(
- $this->request($sealed, optIn: true),
- fn (Request $r) => new JsonResponse(['got' => $r->input('name')], 422),
- );
-
- $this->assertSame(422, $response->getStatusCode());
- $this->assertSame(
- ['got' => 'An'],
- json_decode(ResponseCipher::open($response->getData(true)['enc'], $this->key()), true),
- );
- }
-
- public function test_request_sealed_with_stale_key_gets_428(): void
- {
- $sealed = ['enc' => ResponseCipher::seal('{"a":1}', str_repeat('o', 32))];
-
- $response = (new SealResponse())->handle(
- $this->request($sealed, optIn: true),
- fn () => new JsonResponse(['ok' => true]),
- );
-
- $this->assertSame(428, $response->getStatusCode());
- }
-}