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()); - } -}