apiUrl = rtrim($this->configuredApiUrl(), '/'); $this->hostingEndpoint = trim((string) config('mailinfra.hosting_endpoint', 'singledomainhosting/linux/us'), '/'); $this->authUserId = (string) config('mailinfra.registrar_auth_userid'); $this->apiKey = (string) config('mailinfra.registrar_api_key'); $this->customerId = (string) config('mailinfra.registrar_customer_id'); } public function isConfigured(): bool { return $this->authUserId !== '' && $this->apiKey !== ''; } public function lastSyncError(): ?string { return $this->lastSyncError; } /** * Build a URL with all parameters as query string values, including auth. * * @param array $params */ private function buildQueryUrl(string $endpoint, array $params = []): string { $all = array_merge([ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, ], $params); $separator = str_contains($endpoint, '?') ? '&' : '?'; return $endpoint.$separator.http_build_query($all); } /** * Authenticate a ResellerClub customer by email and password. * Returns the customer ID on success, or null on failure. * * @return array{success: bool, customer_id?: string, message?: string} */ public function authenticateCustomer(string $email, string $password): array { try { $response = Http::timeout(15)->post($this->buildQueryUrl($this->apiUrl.'/customers/authenticate.json', [ 'username' => $email, 'passwd' => $password, ])); $data = $response->json(); if ($response->failed()) { $message = $data['message'] ?? $data['error'] ?? 'Authentication failed.'; return ['success' => false, 'message' => $message]; } // RC returns the customer ID directly as the response body on success $customerId = is_array($data) ? (string) ($data['customerid'] ?? '') : (string) $data; if ($customerId === '' || $customerId === 'false') { return ['success' => false, 'message' => 'Invalid email or password.']; } return ['success' => true, 'customer_id' => $customerId]; } catch (\Throwable $e) { Log::error('HostingService: authenticateCustomer failed', ['error' => $e->getMessage()]); return ['success' => false, 'message' => 'Could not verify ResellerClub credentials.']; } } /** * Get customer details by customer ID. * * @return array{success: bool, customer?: array, message?: string} */ public function getCustomerDetails(string $customerId): array { try { $response = Http::timeout(15)->get($this->apiUrl.'/customers/details-by-id.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'customer-id' => $customerId, ]); $data = $response->json(); if ($response->failed() || ! is_array($data)) { return ['success' => false, 'message' => 'Could not fetch customer details.']; } return ['success' => true, 'customer' => $data]; } catch (\Throwable $e) { Log::error('HostingService: getCustomerDetails failed', ['error' => $e->getMessage()]); return ['success' => false, 'message' => 'Could not fetch customer details.']; } } /** * Search hosting orders for a specific customer. * * @return array{success: bool, orders?: array, total?: int, message?: string} */ public function searchOrdersByCustomer(string $customerId, int $page = 1, int $perPage = 50): array { $this->lastSyncError = null; try { $params = [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'no-of-records' => $perPage, 'page-no' => $page, 'customer-id' => $customerId, ]; $response = Http::timeout(15)->get($this->endpoint('search'), $params); $data = $response->json(); if ($response->failed() || ! is_array($data)) { $message = $this->responseMessage($response, $data, 'Failed to search hosting orders.'); $this->lastSyncError = $message; Log::warning('HostingService: hosting search unsuccessful response', [ 'customer_id' => $customerId, 'status' => $response->status(), 'body' => $this->sanitizeErrorText((string) $response->body()), ]); return ['success' => false, 'message' => $message]; } $total = (int) ($data['recsonpage'] ?? 0); $orders = []; $recordCount = (int) ($data['recsindb'] ?? 0); for ($i = 1; $i <= $total; $i++) { if (isset($data[(string) $i])) { $orders[] = $this->normalizeOrderData($data[(string) $i]); } } return [ 'success' => true, 'orders' => $orders, 'total' => $recordCount, ]; } catch (\Throwable $e) { Log::error('HostingService: searchOrdersByCustomer failed', ['error' => $e->getMessage()]); $message = $this->syncErrorMessage($e, 'Could not fetch hosting orders.'); $this->lastSyncError = $message; return ['success' => false, 'message' => $message]; } } /** * Sync all orders for a specific RC customer into a Ladill user account. * * @return int Number of orders synced. */ public function syncCustomerOrders(string $customerId, int $userId): int { $this->lastSyncError = null; $page = 1; $synced = 0; do { $result = $this->searchOrdersByCustomer($customerId, $page, 50); if (! ($result['success'] ?? false) || empty($result['orders'])) { break; } foreach ($result['orders'] as $order) { $payload = is_array($order) ? ($order['raw'] ?? $order) : []; $orderId = (string) ($payload['orderid'] ?? $payload['entityid'] ?? $order['order_id'] ?? ''); if ($orderId !== '') { $details = $this->getOrderDetails($orderId); if (($details['success'] ?? false) && is_array($details['order'] ?? null)) { $payload = (array) ($details['order']['raw'] ?? $details['order']); } } $this->syncOrderToLocal((array) $payload, $userId); $synced++; } $page++; } while ($synced < ($result['total'] ?? 0)); return $synced; } /** * Search hosting orders under the reseller account. * * @return array{success: bool, orders?: array, total?: int, message?: string} */ public function searchOrders(int $page = 1, int $perPage = 25, ?string $domainName = null, ?string $status = null): array { try { $params = [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'no-of-records' => $perPage, 'page-no' => $page, ]; if ($domainName) { $params['domain-name'] = $domainName; } if ($status) { $params['status'] = $status; } if ($this->customerId) { $params['customer-id'] = $this->customerId; } $response = Http::timeout(15)->get($this->endpoint('search'), $params); $data = $response->json(); if ($response->failed() || ! is_array($data)) { $message = $this->responseMessage($response, $data, 'Failed to search hosting orders.'); Log::warning('HostingService: hosting search unsuccessful response', [ 'status' => $response->status(), 'body' => $this->sanitizeErrorText((string) $response->body()), ]); return ['success' => false, 'message' => $message]; } $total = (int) ($data['recsonpage'] ?? 0); $orders = []; // RC returns numbered keys for each record $recordCount = (int) ($data['recsindb'] ?? 0); for ($i = 1; $i <= $total; $i++) { if (isset($data[(string) $i])) { $orders[] = $this->normalizeOrderData($data[(string) $i]); } } return [ 'success' => true, 'orders' => $orders, 'total' => $recordCount, ]; } catch (\Throwable $e) { Log::error('HostingService: searchOrders failed', ['error' => $e->getMessage()]); return ['success' => false, 'message' => 'Could not fetch hosting orders.']; } } /** * Get details of a specific hosting order by order ID. * * @return array{success: bool, order?: array, message?: string} */ public function getOrderDetails(string $orderId): array { try { $response = Http::timeout(15)->get($this->endpoint('details'), [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'order-id' => $orderId, ]); $data = $response->json(); if ($response->failed() || ! is_array($data)) { return ['success' => false, 'message' => 'Failed to fetch order details.']; } return [ 'success' => true, 'order' => $this->normalizeOrderData($data), ]; } catch (\Throwable $e) { Log::error('HostingService: getOrderDetails failed', [ 'order_id' => $orderId, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Could not fetch order details.']; } } private function configuredApiUrl(): string { $value = trim((string) config('mailinfra.hosting_api_url')); return $value !== '' ? $value : self::DEFAULT_API_URL; } private function endpoint(string $action): string { return sprintf('%s/%s/%s.json', $this->apiUrl, $this->hostingEndpoint, $action); } /** * Order a new single-domain Linux hosting plan. * * @return array{success: bool, order_id?: string, message?: string} */ public function orderHosting(string $domainName, string $planId, string $customerId, int $months = 1): array { try { $response = Http::timeout(30)->post($this->buildQueryUrl($this->endpoint('add'), [ 'domain-name' => $domainName, 'customer-id' => $customerId, 'months' => $months, 'plan-id' => $planId, 'invoice-option' => 'NoInvoice', ])); $data = $response->json(); if ($response->failed()) { $message = $data['message'] ?? $data['error'] ?? 'Hosting order failed.'; Log::warning('HostingService: orderHosting failed', [ 'domain' => $domainName, 'status' => $response->status(), 'response' => $data, ]); return ['success' => false, 'message' => $message]; } Log::info('HostingService: hosting ordered', [ 'domain' => $domainName, 'order_id' => $data['entityid'] ?? null, ]); return [ 'success' => true, 'order_id' => (string) ($data['entityid'] ?? ''), ]; } catch (\Throwable $e) { Log::error('HostingService: orderHosting exception', [ 'domain' => $domainName, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Hosting order failed due to a network error.']; } } /** * Renew a hosting order. * * @return array{success: bool, message?: string} */ public function renewOrder(string $orderId, int $months = 1, string $invoiceOption = 'NoInvoice'): array { try { $response = Http::timeout(30)->post($this->buildQueryUrl($this->endpoint('renew'), [ 'order-id' => $orderId, 'months' => $months, 'invoice-option' => $invoiceOption, ])); $data = $response->json(); if ($response->failed()) { return ['success' => false, 'message' => $data['message'] ?? 'Renewal failed.']; } return ['success' => true]; } catch (\Throwable $e) { Log::error('HostingService: renewOrder failed', [ 'order_id' => $orderId, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Renewal failed due to a network error.']; } } /** * @return array{success: bool, message?: string} */ public function changePanelPassword(string $orderId, string $password): array { try { $response = Http::timeout(15)->post($this->buildQueryUrl($this->endpoint('change-password'), [ 'order-id' => $orderId, 'new-passwd' => $password, ])); $data = $response->json(); if ($response->failed()) { return ['success' => false, 'message' => $data['message'] ?? $data['error'] ?? 'Could not update the hosting panel password.']; } return ['success' => true]; } catch (\Throwable $e) { Log::error('HostingService: changePanelPassword failed', [ 'order_id' => $orderId, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Could not update the hosting panel password.']; } } /** * Delete (cancel) a hosting order. * * @return array{success: bool, message?: string} */ public function deleteOrder(string $orderId): array { try { $response = Http::timeout(15)->post($this->buildQueryUrl($this->endpoint('delete'), [ 'order-id' => $orderId, ])); $data = $response->json(); if ($response->failed()) { return ['success' => false, 'message' => $data['message'] ?? 'Cancellation failed.']; } return ['success' => true]; } catch (\Throwable $e) { Log::error('HostingService: deleteOrder failed', [ 'order_id' => $orderId, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Cancellation failed due to a network error.']; } } /** * Sync a hosting order from ResellerClub into the local database. */ public function syncOrderToLocal(array $rcOrder, int $userId, ?int $domainId = null): HostingOrder { $raw = is_array($rcOrder['raw'] ?? null) ? (array) $rcOrder['raw'] : []; return HostingOrder::query()->updateOrCreate( ['rc_order_id' => (string) ($rcOrder['orderid'] ?? $rcOrder['order_id'] ?? $rcOrder['entityid'] ?? Arr::get($raw, 'orders.orderid') ?? Arr::get($raw, 'entity.entityid') ?? $raw['orderid'] ?? $raw['entityid'] ?? '')], [ 'user_id' => $userId, 'domain_id' => $domainId, 'domain_name' => $rcOrder['domain_name'] ?? $rcOrder['domainname'] ?? Arr::get($raw, 'entity.description') ?? Arr::get($raw, 'domainname') ?? Arr::get($raw, 'domain-name') ?? '', 'rc_customer_id' => (string) ($rcOrder['customerid'] ?? $rcOrder['customer_id'] ?? $rcOrder['customer-id'] ?? Arr::get($raw, 'entity.customerid') ?? $raw['customerid'] ?? $raw['customer-id'] ?? ''), 'plan_name' => $rcOrder['plan_name'] ?? $rcOrder['planname'] ?? $rcOrder['productcategory'] ?? Arr::get($raw, 'entitytype.entitytypename') ?? Arr::get($raw, 'entitytype.entitytypekey') ?? $raw['planname'] ?? $raw['productcategory'] ?? null, 'hosting_type' => HostingOrder::TYPE_SINGLE_DOMAIN, 'status' => $this->mapRcStatus($rcOrder['currentstatus'] ?? $rcOrder['orderstatus'] ?? $rcOrder['status'] ?? Arr::get($raw, 'entity.currentstatus') ?? Arr::get($raw, 'orders.orderstatus') ?? 'pending'), 'ip_address' => $rcOrder['ipaddress'] ?? $rcOrder['ip'] ?? $rcOrder['ip_address'] ?? Arr::get($raw, 'server.ipaddress') ?? Arr::get($raw, 'server.ip') ?? $raw['ipaddress'] ?? $raw['ip'] ?? null, 'cpanel_url' => $rcOrder['cpanelurl'] ?? $rcOrder['cpanel_url'] ?? $rcOrder['tempurl'] ?? Arr::get($raw, 'server.cpanelurl') ?? Arr::get($raw, 'server.controlpanelurl') ?? Arr::get($raw, 'server.tempurl') ?? $raw['cpanelurl'] ?? $raw['tempurl'] ?? null, 'cpanel_username' => $rcOrder['cpanelusername'] ?? $rcOrder['cpanel_username'] ?? Arr::get($raw, 'server.cpanelusername') ?? Arr::get($raw, 'server.username') ?? $raw['cpanelusername'] ?? null, 'bandwidth_mb' => $this->unsignedLimitOrNull($rcOrder['bandwidth'] ?? Arr::get($raw, 'plan.bandwidth') ?? $raw['bandwidth'] ?? null), 'disk_space_mb' => $this->unsignedLimitOrNull($rcOrder['diskspace'] ?? Arr::get($raw, 'plan.diskspace') ?? $raw['diskspace'] ?? null), 'expires_at' => $this->hostingTimestamp($rcOrder['endtime'] ?? $rcOrder['end_time'] ?? Arr::get($raw, 'orders.endtime') ?? $raw['endtime'] ?? null), 'provisioned_at' => $this->hostingTimestamp($rcOrder['creationtime'] ?? $rcOrder['creation_time'] ?? Arr::get($raw, 'orders.creationtime') ?? $raw['creationtime'] ?? null), 'meta' => $raw !== [] ? $raw : $rcOrder, ] ); } /** * Sync all orders from ResellerClub for a user. * If user has a linked RC account, only syncs that customer's orders. * Otherwise syncs all orders under the reseller account. * * @return int Number of orders synced. */ public function syncAllOrders(int $userId, ?string $rcCustomerId = null): int { if ($rcCustomerId) { return $this->syncCustomerOrders($rcCustomerId, $userId); } $page = 1; $synced = 0; do { $result = $this->searchOrders($page, 50); if (! ($result['success'] ?? false) || empty($result['orders'])) { break; } foreach ($result['orders'] as $order) { $this->syncOrderToLocal($order, $userId); $synced++; } $page++; } while ($synced < ($result['total'] ?? 0)); return $synced; } private function normalizeOrderData(array $data): array { return [ 'order_id' => (string) ($data['orderid'] ?? $data['entityid'] ?? $data['order-id'] ?? $data['entity-id'] ?? Arr::get($data, 'orders.orderid') ?? Arr::get($data, 'entity.entityid') ?? ''), 'domain_name' => $data['domainname'] ?? $data['domain-name'] ?? $data['domain_name'] ?? $data['domain'] ?? $data['hostname'] ?? $data['description'] ?? Arr::get($data, 'entity.description') ?? '', 'customer_id' => (string) ($data['customerid'] ?? $data['customer-id'] ?? $data['customer_id'] ?? Arr::get($data, 'entity.customerid') ?? ''), 'plan_name' => $data['planname'] ?? $data['plan_name'] ?? $data['productcategory'] ?? $data['productkey'] ?? $data['description'] ?? Arr::get($data, 'entitytype.entitytypename') ?? Arr::get($data, 'entitytype.entitytypekey') ?? '', 'status' => $data['currentstatus'] ?? $data['orderstatus'] ?? $data['status'] ?? Arr::get($data, 'entity.currentstatus') ?? Arr::get($data, 'orders.orderstatus') ?? 'Unknown', 'ip_address' => $data['ipaddress'] ?? $data['ip_address'] ?? $data['ip'] ?? Arr::get($data, 'server.ipaddress') ?? Arr::get($data, 'server.ip') ?? null, 'cpanel_url' => $data['cpanelurl'] ?? $data['cpanel_url'] ?? $data['controlpanelurl'] ?? $data['tempurl'] ?? Arr::get($data, 'server.cpanelurl') ?? Arr::get($data, 'server.controlpanelurl') ?? Arr::get($data, 'server.tempurl') ?? null, 'cpanel_username' => $data['cpanelusername'] ?? $data['cpanel_username'] ?? $data['username'] ?? Arr::get($data, 'server.cpanelusername') ?? Arr::get($data, 'server.username') ?? null, 'bandwidth' => $data['bandwidth'] ?? $data['bandwidth_mb'] ?? Arr::get($data, 'plan.bandwidth') ?? null, 'disk_space' => $data['diskspace'] ?? $data['disk_space_mb'] ?? Arr::get($data, 'plan.diskspace') ?? null, 'creation_time' => $data['creationtime'] ?? $data['creation_time'] ?? Arr::get($data, 'orders.creationtime') ?? null, 'end_time' => $data['endtime'] ?? $data['end_time'] ?? $data['expirytime'] ?? Arr::get($data, 'orders.endtime') ?? null, 'raw' => $data, ]; } private function mapRcStatus(string $rcStatus): string { return match (strtolower($rcStatus)) { 'active', 'active (paid)' => HostingOrder::STATUS_ACTIVE, 'suspended' => HostingOrder::STATUS_SUSPENDED, 'deleted', 'cancelled' => HostingOrder::STATUS_CANCELLED, 'expired' => HostingOrder::STATUS_EXPIRED, 'pending', 'inactive' => HostingOrder::STATUS_PENDING, default => HostingOrder::STATUS_PENDING, }; } private function syncErrorMessage(\Throwable $e, string $fallback): string { $message = $e->getMessage(); if (str_contains($message, 'Could not resolve host')) { return 'Could not reach the ResellerClub API host. Check server DNS/network access to httpapi.com.'; } return $fallback; } /** * @param mixed $data */ private function responseMessage(Response $response, mixed $data, string $fallback): string { if ($this->looksLikeHtmlError($response)) { return 'The upstream provisioning service temporarily blocked or rejected the request. Please try again in a moment.'; } if (is_array($data)) { foreach (['message', 'error', 'description', 'msg'] as $key) { $value = $this->sanitizeErrorText((string) ($data[$key] ?? '')); if ($value !== '') { return $value; } } } $rawBody = $this->sanitizeErrorText((string) $response->body()); return $rawBody !== '' ? $rawBody : $fallback; } private function sanitizeErrorText(string $value): string { $value = trim($value); if ($value === '') { return ''; } if ($this->looksLikeHtmlDocument($value)) { return 'ResellerClub temporarily blocked or rejected the request. Please try again in a moment.'; } return trim(preg_replace('/\s+/', ' ', strip_tags($value)) ?? ''); } private function looksLikeHtmlError(Response $response): bool { $contentType = strtolower((string) $response->header('Content-Type')); $body = trim((string) $response->body()); return str_contains($contentType, 'text/html') || $this->looksLikeHtmlDocument($body); } private function looksLikeHtmlDocument(string $value): bool { $normalized = strtolower(ltrim($value)); return str_starts_with($normalized, '') || str_contains($normalized, 'cloudflare'); } private function hostingTimestamp(mixed $value): ?\Carbon\Carbon { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return \Carbon\Carbon::createFromTimestamp((int) $value); } try { return \Carbon\Carbon::parse((string) $value); } catch (\Throwable) { return null; } } private function unsignedLimitOrNull(mixed $value): ?int { if (! is_numeric($value)) { return null; } $normalized = (int) $value; return $normalized >= 0 ? $normalized : null; } }