feat(service): huỷ dịch vụ, SSL, đổi mật khẩu hosting, lịch sử thanh toán theo hình thức

- Lịch sử thanh toán hoá đơn trả thêm `method` (tự động gia hạn / số dư / admin / chuyển khoản / hoàn tiền), bỏ đuôi `(method)` khỏi ghi chú
- `DnsServer::panelUrl()` = https://<domain>/ dùng cho email cấp hosting và nút Plesk của khách
- Order item trả thêm `uuid`/`type` của dịch vụ để FE dẫn tới trang quản lý
- Yêu cầu huỷ dịch vụ, chứng chỉ SSL, lịch sử gia hạn, đổi mật khẩu hosting cho khách

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
baodev0
2026-10-28 16:40:24 +07:00
co-authored by Claude Sonnet 5.5
parent a6b641015e
commit b44a88bb52
72 changed files with 1701 additions and 231 deletions
+1
View File
@@ -93,3 +93,4 @@ CLIENT_API_ACCESS_KEY=
CLIENT_API_ACCESS_SECRET=
WMKS_HOST=10.10.10.120
CLIENT_RESPONSE_KEY=
+14
View File
@@ -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))
@@ -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
);
@@ -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',
@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Api\Admin\Service;
use App\Http\Controllers\Controller;
use App\Models\Notification;
use App\Models\Service;
use App\Models\ServiceCancellationRequest;
use App\Support\ServiceCancellation;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class ServiceCancellationRequestController extends Controller
{
public function index(int $id): JsonResponse
{
$requests = ServiceCancellationRequest::where('service_id', $id)
->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',
]);
}
}
@@ -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);
@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Api\Admin\Service;
use App\Http\Controllers\Controller;
use App\Models\Service;
use App\Models\ServiceSslCertificate;
use App\Support\ServiceGroup;
use App\Support\SslBundleExtractor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use InvalidArgumentException;
class SslCertificateController extends Controller
{
public function show(int $id): JsonResponse
{
return $this->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()]);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Api\Client\Service;
use App\Http\Controllers\Controller;
use App\Models\ModuleHostingPleskClient;
use App\Models\Service;
use App\Services\Plesk\PleskClientService;
use App\Support\ClientPassword;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class HostingPasswordController extends Controller
{
public function __invoke(Request $request, string $uuid, PleskClientService $plesk): JsonResponse
{
$data = $request->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']);
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Api\Client\Service;
use App\Http\Controllers\Controller;
use App\Models\Service;
use App\Models\ServiceCancellationRequest;
use App\Services\ServiceCancellationNotifier;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceCancellationController extends Controller
{
private const CLOSED_STATUSES = ['cancelled', 'terminated'];
public function __construct(private readonly ServiceCancellationNotifier $notifier) {}
public function store(Request $request, string $uuid): JsonResponse
{
$service = $this->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();
}
}
@@ -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)
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api\Client\Service;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Service;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ServiceRenewalHistoryController extends Controller
{
private const CAUSER_LABELS = [
'customer' => '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]);
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Api\Client\Service;
use App\Http\Controllers\Controller;
use App\Models\ServiceSslCertificate;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use ZipArchive;
class SslArchiveController extends Controller
{
public function __invoke(Request $request, string $uuid)
{
$certificate = ServiceSslCertificate::query()
->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();
}
}
-2
View File
@@ -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,
];
}
-56
View File
@@ -1,56 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Support\ResponseCipher;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class SealResponse
{
public const KEY_HEADER = 'x-seal-key';
public const OPT_IN_HEADER = 'x-seal';
private const SEALED_ATTRIBUTE = 'response_sealed';
public function handle(Request $request, Closure $next, ?string $mode = null)
{
$key = ResponseCipher::keyFrom($request->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;
}
}
@@ -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] ?? []);
}
}
@@ -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;
@@ -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(),
@@ -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 ? [
@@ -0,0 +1,23 @@
<?php
namespace App\Mail\Admin;
use App\Mail\TemplateMailable;
use Illuminate\Bus\Queueable;
class ServiceCancellationRequestMail extends TemplateMailable
{
use Queueable;
public function __construct(public array $data) {}
protected function scenario(): string
{
return 'admin_service_cancellation_request';
}
protected function variables(): array
{
return $this->data;
}
}
+5
View File
@@ -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');
+8
View File
@@ -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();
}
}
+12
View File
@@ -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',
+8
View File
@@ -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);
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ServiceCancellationRequest extends Model
{
public const STATUS_PENDING = 'pending';
public const STATUS_APPROVED = 'approved';
public const STATUS_REJECTED = 'rejected';
protected $casts = ['resolved_at' => '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);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ServiceSslCertificate extends Model
{
public const MAX_ENTRIES = 30;
public const MAX_FILE_BYTES = 524288;
protected $guarded = [];
protected $casts = ['data' => 'encrypted:array'];
public function service()
{
return $this->belongsTo(Service::class);
}
public static function emptyData(): array
{
return ['fields' => [], 'files' => []];
}
}
@@ -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));
}
@@ -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);
}
@@ -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);
+1 -1
View File
@@ -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) {
@@ -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);
}
/**
@@ -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));
}
/**
@@ -51,6 +51,7 @@ class CustomerServiceProvisioningService
);
ModuleVmware::resolveVirtualMachineIds($services->pluck('vmware'));
\App\Support\HostingCredentials::attach($services);
return $services;
}
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Services;
use App\Mail\ServiceRenewedMail;
use App\Models\Invoice;
use App\Models\Notification;
use App\Models\Service;
use App\Models\Setting;
use App\Support\AutoEmailGate;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class CustomerRenewalNotifier
{
public const SCENARIO = 'admin_customer_service_renewed';
public const SOURCE_CUSTOMER = 'customer';
public const SOURCE_SYSTEM = 'system';
public function notify(Service $service, Invoice $invoice, int $months, ?CarbonInterface $previousExpiresAt, string $source): void
{
$service->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()]);
}
}
}
+11
View File
@@ -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 {
+1 -2
View File
@@ -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'),
];
@@ -0,0 +1,51 @@
<?php
namespace App\Services;
use App\Mail\Admin\ServiceCancellationRequestMail;
use App\Models\Notification;
use App\Models\ServiceCancellationRequest;
use App\Models\Setting;
use App\Support\AutoEmailGate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class ServiceCancellationNotifier
{
public function notify(ServiceCancellationRequest $request, ?string $clientIp): void
{
$request->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()]);
}
}
}
@@ -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')
+42 -3
View File
@@ -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,
];
+8
View File
@@ -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'],
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Support;
use App\Models\ModuleHostingPleskClient;
use App\Models\Service;
use Illuminate\Support\Facades\Crypt;
class HostingCredentials
{
public static function attach(iterable $services): void
{
foreach ($services as $service) {
$service->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),
];
}
}
+13 -32
View File
@@ -1,44 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support;
use RuntimeException;
class ResponseCipher
{
private const CIPHER = 'aes-256-gcm';
public static function keyFrom(?string $encoded): ?string
public static function encrypt(?string $value): ?string
{
$key = $encoded ? base64_decode($encoded, true) : false;
return $key !== false && strlen($key) === 32 ? $key : null;
}
public static function seal(string $plain, string $key): string
{
$iv = random_bytes(12);
$cipherText = openssl_encrypt($plain, self::CIPHER, $key, OPENSSL_RAW_DATA, $iv, $tag);
return base64_encode($iv . $cipherText . $tag);
}
public static function open(string $sealed, string $key): ?string
{
$raw = base64_decode($sealed, true);
if ($raw === false || strlen($raw) < 29) {
return null;
if ($value === null || $value === '') {
return $value;
}
$plain = openssl_decrypt(
substr($raw, 12, -16),
self::CIPHER,
$key,
OPENSSL_RAW_DATA,
substr($raw, 0, 12),
substr($raw, -16),
);
$key = (string) config('services.client_api.response_key');
if (!preg_match('/^[a-fA-F0-9]{64}$/', $key)) {
throw new RuntimeException('CLIENT_RESPONSE_KEY must be a 64-character hex string');
}
return $plain === false ? null : $plain;
$iv = random_bytes(12);
$ciphertext = openssl_encrypt($value, 'aes-256-gcm', hex2bin($key), OPENSSL_RAW_DATA, $iv, $tag);
return base64_encode($iv . $ciphertext . $tag);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace App\Support;
use App\Enums\Invoice\InvoiceStatus;
use App\Jobs\VirtualMachine\Action\VMJobs;
use App\Models\Invoice;
use App\Models\Service;
class ServiceCancellation
{
public const SCHEDULED = 'scheduled';
public const IMMEDIATE = 'immediate';
public static function approve(Service $service): string
{
self::voidOpenRenewalInvoices($service);
if (! $service->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]));
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Support;
use Illuminate\Database\Eloquent\Builder;
class ServiceGroup
{
public const SSL = 'ssl';
public const COLO = 'colo';
public const DEDICATED = 'dedicated';
public const EMAIL = 'email';
public const ALL = [self::SSL, self::COLO, self::DEDICATED, self::EMAIL];
private const UNTYPED_PRODUCT_TYPES = ['other', 'server_rental'];
public static function of(?string $productType, ?string $productName): ?string
{
$name = mb_strtolower(trim((string) $productName));
return match (true) {
$productType === 'email' => 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'),
};
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace App\Support;
use App\Models\ServiceSslCertificate;
use Illuminate\Http\UploadedFile;
use InvalidArgumentException;
use ZipArchive;
class SslBundleExtractor
{
private const MAX_TOTAL_BYTES = 2097152;
public static function extract(UploadedFile $archive): array
{
$zip = new ZipArchive();
if ($zip->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');
}
}
+1
View File
@@ -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'),
],
];
@@ -0,0 +1,14 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
(new TemplateSeeder())->seedServiceLifecycleTemplates();
}
public function down(): void {}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('service_attachments', function (Blueprint $table) {
$table->string('audience', 20)->default('admin')->after('service_id')->index();
});
}
public function down(): void
{
Schema::table('service_attachments', function (Blueprint $table) {
$table->dropColumn('audience');
});
}
};
@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('service_ssl_certificates', function (Blueprint $table) {
$table->id();
$table->foreignId('service_id')->unique()->constrained('services')->cascadeOnDelete();
$table->longText('data');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('service_ssl_certificates');
}
};
@@ -0,0 +1,31 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('service_cancellation_requests', function (Blueprint $table) {
$table->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();
}
};
@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('service_cancellation_requests', function (Blueprint $table) {
$table->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']);
});
}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('services', function (Blueprint $table) {
$table->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');
});
}
};
@@ -0,0 +1,18 @@
<?php
use Database\Seeders\TemplateSeeder;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
(new TemplateSeeder())->seedScenarioIfMissing('admin_customer_service_renewed');
}
public function down(): void
{
DB::table('template')->where('scenario', 'admin_customer_service_renewed')->delete();
}
};
@@ -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 <strong>{{company_name}}</strong> 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 <strong>{{company_name}}</strong>. 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ị',
@@ -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 = '<a style="color: #c2410c;" href="{{login_url}}" target="_blank" rel="noopener">{{login_url}}</a>';
$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, <strong>{{app_name}}</strong> 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 <strong>"Quên mật khẩu"</strong> 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 = '';
@@ -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'],
@@ -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'],
+13
View File
@@ -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');
+16 -5
View File
@@ -1,11 +1,16 @@
<?php
use App\Support\ServiceGroup;
use App\Http\Controllers\Api\Setting\SettingController;
use App\Http\Controllers\Api\Client\ClientController;
use App\Http\Controllers\Api\Client\Domain\DomainOrderController as ClientDomainOrderController;
use App\Http\Controllers\Api\Client\Domain\DomainPricingController;
use App\Http\Controllers\Api\Client\Service\DomainController as ClientDomainController;
use App\Http\Controllers\Api\Client\Service\ServiceController as ClientServiceController;
use App\Http\Controllers\Api\Client\Service\SslArchiveController;
use App\Http\Controllers\Api\Client\Service\ServiceRenewalHistoryController;
use App\Http\Controllers\Api\Client\Service\HostingPasswordController;
use App\Http\Controllers\Api\Client\Service\ServiceCancellationController;
use App\Http\Controllers\Api\Service\CouponPreviewController;
use App\Http\Controllers\Api\Service\OrderControllerRefactor;
use App\Http\Controllers\Api\Service\ProductCategoryController;
@@ -122,15 +127,21 @@ Route::prefix('v1')
Route::get('hosting', 'indexHosting')->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');
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -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();
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Product;
use App\Models\Product;
use App\Models\ProductCategories;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
class ProductCategoryFilterTest extends TestCase
{
use RefreshDatabase;
public function test_category_filter_applies_before_pagination(): void
{
Role::firstOrCreate(['name' => '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);
}
}
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Admin\Service;
use App\Http\Resources\Api\Client\Service\ServiceClientDetailResource;
use App\Models\Customer;
use App\Models\Product;
use App\Models\Service;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Http\Request;
use Spatie\Permission\Models\Role;
use Tests\TestCase;
use ZipArchive;
class SslCertificateTest extends TestCase
{
use RefreshDatabase;
private User $admin;
protected function setUp(): void
{
parent::setUp();
Role::firstOrCreate(['name' => '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();
}
}
@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\Client;
use App\Mail\Admin\ServiceCancellationRequestMail;
use App\Models\Customer;
use App\Models\Notification;
use App\Models\Service;
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 ServiceCancellationTest extends TestCase
{
use RefreshDatabase;
private Customer $owner;
private Service $service;
protected function setUp(): void
{
parent::setUp();
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->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);
}
}
@@ -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());
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Tests\Feature;
use App\Models\Product;
use App\Support\ServiceGroup;
use Tests\TestCase;
class ServiceGroupTest extends TestCase
{
public function test_of_classifies_by_type_then_name(): void
{
$this->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);
}
}
}
+1 -1
View File
@@ -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);
-85
View File
@@ -1,85 +0,0 @@
<?php
namespace Tests\Unit;
use App\Http\Middleware\SealResponse;
use App\Support\ResponseCipher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Tests\TestCase;
class ResponseCipherTest extends TestCase
{
private function key(): string
{
return str_repeat('k', 32);
}
private function request(array $body = [], bool $optIn = false, ?string $key = null): Request
{
$request = Request::create('/x', 'POST', [], [], [], ['CONTENT_TYPE' => '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());
}
}