*/ private array $tldProductKeys; public function __construct() { $this->apiUrl = rtrim($this->configuredApiUrl(), '/'); $this->domainCheckApiUrl = rtrim($this->configuredDomainCheckApiUrl(), '/'); $this->authUserId = trim((string) config('mailinfra.registrar_auth_userid')); $this->apiKey = trim((string) config('mailinfra.registrar_api_key')); $this->customerId = trim((string) config('mailinfra.registrar_customer_id')); $this->defaultContactId = trim((string) config('mailinfra.registrar_default_contact_id')); $this->sellingCurrency = strtoupper(trim((string) config('mailinfra.registrar_selling_currency', 'GHS'))); $this->tldProductKeys = DomainConfig::resellerClubTldProductKeys(); } public function isConfigured(): bool { return $this->authUserId !== '' && $this->apiKey !== ''; } /** * Resolve (find or create) a ResellerClub contact for the given user and customer. * * @return array{success: bool, contact_id?: string, message?: string} */ public function resolveContactForCustomer(User $user, string $customerId): array { if ($user->rc_contact_id) { return ['success' => true, 'contact_id' => (string) $user->rc_contact_id]; } $defaults = (array) config('mailinfra.resellerclub_customer_defaults', []); $email = strtolower(trim((string) $user->email)); $name = trim(implode(' ', array_filter([ trim((string) ($user->first_name ?? '')), trim((string) ($user->last_name ?? '')), ]))); if ($name === '') { $name = trim((string) $user->name) !== '' ? (string) $user->name : $email; } $found = $this->searchContact($customerId, $email); if ($found !== null) { $user->forceFill(['rc_contact_id' => $found])->save(); return ['success' => true, 'contact_id' => $found]; } try { $response = $this->rcWriteClient()->post( $this->buildRepeatedQueryUrl($this->apiUrl.'/contacts/add.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'name' => $name, 'company' => trim((string) ($user->company ?? '')) !== '' ? (string) $user->company : (string) ($defaults['company'] ?? config('app.name', 'Ladill')), 'email' => $email, 'address-line-1' => trim((string) ($user->address_line_1 ?? '')) !== '' ? (string) $user->address_line_1 : (string) ($defaults['address_line_1'] ?? 'Not provided'), 'city' => trim((string) ($user->city ?? '')) !== '' ? (string) $user->city : (string) ($defaults['city'] ?? 'Accra'), 'state' => trim((string) ($user->state ?? '')) !== '' ? (string) $user->state : (string) ($defaults['state'] ?? 'Not Applicable'), 'country' => trim((string) ($user->country ?? '')) !== '' ? strtoupper((string) $user->country) : (string) ($defaults['country'] ?? 'GH'), 'zipcode' => trim((string) ($user->zipcode ?? '')) !== '' ? (string) $user->zipcode : (string) ($defaults['zipcode'] ?? '00000'), 'phone-cc' => trim((string) ($user->phone_cc ?? '')) !== '' ? ltrim((string) $user->phone_cc, '+') : (string) ($defaults['phone_cc'] ?? '233'), 'phone' => trim((string) ($user->phone ?? '')) !== '' ? (string) $user->phone : (string) ($defaults['phone'] ?? '000000000'), 'customer-id' => $customerId, 'type' => 'Contact', ]) ); $body = trim((string) $response->body()); if ($response->failed()) { Log::warning('DomainRegistrarService: contact creation failed', [ 'user_id' => $user->id, 'customer_id' => $customerId, 'status' => $response->status(), 'body' => $body, ]); return ['success' => false, 'message' => $this->extractResponseMessage($response, 'Could not create the domain contact.')]; } $contactId = trim($body, "\" \t\n\r"); if ($contactId === '' || ! is_numeric($contactId)) { Log::warning('DomainRegistrarService: unexpected contact creation response', [ 'user_id' => $user->id, 'body' => $body, ]); return ['success' => false, 'message' => 'Could not create the domain contact.']; } $user->forceFill(['rc_contact_id' => $contactId])->save(); return ['success' => true, 'contact_id' => $contactId]; } catch (\Throwable $e) { Log::error('DomainRegistrarService: resolveContactForCustomer failed', [ 'user_id' => $user->id, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Could not create the domain contact.']; } } /** * Search for an existing contact under a customer by email. */ private function searchContact(string $customerId, string $email): ?string { try { $response = $this->rcReadClient()->get($this->apiUrl.'/contacts/search.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'customer-id' => $customerId, 'no-of-records' => 1, 'page-no' => 1, 'type' => 'Contact', 'email' => $email, ]); $data = $response->json(); if (! $response->successful() || ! is_array($data)) { return null; } foreach ($data as $row) { if (! is_array($row)) { continue; } $contactId = (string) ($row['entity.entityid'] ?? ''); if ($contactId !== '') { return $contactId; } } return null; } catch (\Throwable $e) { Log::warning('DomainRegistrarService: searchContact failed', [ 'customer_id' => $customerId, 'error' => $e->getMessage(), ]); return null; } } /** * @param array $defaultTlds * @return array{ * success: bool, * results?: array, * exact?: bool, * keyword?: string, * tlds?: array, * message?: string * } */ public function checkAvailability(string $query, array $defaultTlds): array { $parsed = $this->parseSearchQuery($query, $defaultTlds); if (! $parsed['valid']) { return [ 'success' => false, 'message' => $parsed['message'], ]; } try { $response = $this->rcReadClient()->get( $this->buildRepeatedQueryUrl( $this->domainCheckApiUrl.'/domains/available.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'domain-name' => [$parsed['keyword']], 'tlds' => $parsed['tlds'], ] ) ); if ($response->failed()) { Log::warning('DomainRegistrarService: availability request failed', [ 'query' => $query, 'status' => $response->status(), 'response' => Str::limit($response->body(), 500), ]); return [ 'success' => false, 'message' => $this->extractResponseMessage($response, 'Could not check domain availability right now.'), ]; } $data = $response->json(); if (! is_array($data)) { Log::warning('DomainRegistrarService: unexpected availability response', [ 'query' => $query, 'response' => Str::limit($response->body(), 500), ]); return [ 'success' => false, 'message' => 'The registrar returned an unexpected response. Please try again in a moment.', ]; } $pricingMap = $this->preloadPricingForTlds((array) $parsed['tlds']); return [ 'success' => true, 'exact' => $parsed['exact'], 'keyword' => $parsed['keyword'], 'tlds' => $parsed['tlds'], 'results' => $this->mapAvailabilityResults($parsed['keyword'], $parsed['tlds'], $data, $pricingMap), ]; } catch (\Throwable $e) { Log::error('DomainRegistrarService: availability check failed', [ 'query' => $query, 'error' => $e->getMessage(), ]); return [ 'success' => false, 'message' => 'Could not reach the domain registry right now. Please try again in a moment.', ]; } } /** * @param array $tlds * @return array{ * success: bool, * results?: array, * message?: string * } */ public function suggestDomains(string $keyword, array $tlds = [], ?string $excludeDomain = null): array { $keyword = $this->normalizeSearchQuery($keyword); if ($keyword === '' || str_contains($keyword, '.')) { return [ 'success' => false, 'message' => 'Enter a base name such as "ladill" to get suggestions.', ]; } try { $payload = [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'keyword' => $keyword, 'exact-match' => false, 'adult' => false, ]; if ($tlds !== []) { $payload['tld-only'] = array_values(array_unique(array_filter($tlds))); } $response = $this->rcReadClient()->get( $this->buildRepeatedQueryUrl($this->apiUrl.'/domains/v5/suggest-names.json', $payload) ); if ($response->failed()) { return [ 'success' => false, 'message' => $this->extractResponseMessage($response, 'Could not load domain suggestions right now.'), ]; } $data = $response->json(); if (! is_array($data)) { return [ 'success' => false, 'message' => 'The registrar returned an unexpected suggestion response.', ]; } $normalizedTlds = array_values(array_unique(array_map( fn ($tld) => ltrim(strtolower((string) $tld), '.'), array_filter($tlds) ))); $pricingMap = $normalizedTlds !== [] ? $this->preloadPricingForTlds($normalizedTlds) : []; return [ 'success' => true, 'results' => $this->mapSuggestedDomains($data, $excludeDomain, $pricingMap), ]; } catch (\Throwable $e) { Log::error('DomainRegistrarService: suggestion lookup failed', [ 'keyword' => $keyword, 'error' => $e->getMessage(), ]); return [ 'success' => false, 'message' => 'Could not load domain suggestions right now.', ]; } } /** * @return array{success: bool, domain?: string, transferable?: bool, message?: string} */ public function validateTransfer(string $domain): array { $domain = $this->normalizeFullDomain($domain); if ($domain === null) { return [ 'success' => false, 'message' => 'Enter a valid domain name to transfer.', ]; } try { $response = $this->rcReadClient()->get($this->apiUrl.'/domains/validate-transfer.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'domain-name' => $domain, ]); if ($response->failed()) { return [ 'success' => false, 'message' => $this->extractResponseMessage($response, 'Could not validate this transfer right now.'), ]; } $transferable = $this->booleanFromResponse($response); return [ 'success' => true, 'domain' => $domain, 'transferable' => $transferable, 'message' => $transferable ? 'This domain can be transferred.' : 'This domain cannot be transferred right now. Check the auth code, lock status, and recent registration changes.', ]; } catch (\Throwable $e) { Log::error('DomainRegistrarService: transfer validation failed', [ 'domain' => $domain, 'error' => $e->getMessage(), ]); return [ 'success' => false, 'message' => 'Could not validate this transfer right now.', ]; } } /** * Register a domain via the registrar API. * * @return array{success: bool, message?: string, order_id?: string} */ public function registerDomain(string $domain, int $years = 1, ?string $customerId = null, ?string $contactId = null): array { $domain = $this->normalizeFullDomain($domain) ?? $domain; $contact = $contactId ?: $this->defaultContactId; try { $response = $this->rcWriteClient()->post( $this->buildRepeatedQueryUrl($this->apiUrl.'/domains/register.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'domain-name' => $domain, 'years' => $years, 'ns' => (array) config('mailinfra.nameservers'), 'customer-id' => $customerId ?: $this->customerId, 'reg-contact-id' => $contact, 'admin-contact-id' => $contact, 'tech-contact-id' => $contact, 'billing-contact-id' => $contact, 'invoice-option' => 'NoInvoice', 'protect-privacy' => true, 'auto-renew' => true, ]) ); $data = $response->json(); if ($response->failed()) { Log::warning('DomainRegistrarService: registration failed', [ 'domain' => $domain, 'status' => $response->status(), 'response' => $data, ]); return [ 'success' => false, 'message' => $this->extractResponseMessage($response, 'Registration failed.'), ]; } return [ 'success' => true, 'order_id' => (string) ($data['entityid'] ?? ''), ]; } catch (\Throwable $e) { Log::error('DomainRegistrarService: registration exception', [ 'domain' => $domain, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Registration failed due to a network error.']; } } /** * @return array{success: bool, message?: string, order_id?: string} */ public function transferDomain(string $domain, ?string $authCode = null, ?string $customerId = null, ?string $contactId = null): array { $domain = $this->normalizeFullDomain($domain); if ($domain === null) { return ['success' => false, 'message' => 'Enter a valid domain name to transfer.']; } [$adminContactId, $techContactId, $billingContactId] = $this->transferContactProfileForDomain( $domain, $contactId ?: $this->defaultContactId ); $params = [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'domain-name' => $domain, 'ns' => (array) config('mailinfra.nameservers'), 'customer-id' => $customerId ?: $this->customerId, 'reg-contact-id' => $contactId ?: $this->defaultContactId, 'admin-contact-id' => $adminContactId, 'tech-contact-id' => $techContactId, 'billing-contact-id' => $billingContactId, 'invoice-option' => 'NoInvoice', 'purchase-privacy' => true, 'protect-privacy' => true, 'auto-renew' => true, ]; if ($authCode !== null && trim($authCode) !== '') { $params['auth-code'] = trim($authCode); } try { $response = $this->rcWriteClient()->post( $this->buildRepeatedQueryUrl($this->apiUrl.'/domains/transfer.json', $params) ); $data = $response->json(); if ($response->failed()) { return [ 'success' => false, 'message' => $this->extractResponseMessage($response, 'Transfer could not be started right now.'), ]; } return [ 'success' => true, 'order_id' => (string) ($data['entityid'] ?? ''), ]; } catch (\Throwable $e) { Log::error('DomainRegistrarService: transfer exception', [ 'domain' => $domain, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Transfer could not be started due to a network error.']; } } /** * @return array{success: bool, message?: string} */ public function setNameservers(string $domain, array $nameservers): array { $domain = $this->normalizeFullDomain($domain); $nameservers = collect($nameservers) ->map(fn ($value) => strtolower(trim((string) $value, " \t\n\r\0\x0B."))) ->filter() ->unique() ->values() ->all(); if ($domain === null) { return ['success' => false, 'message' => 'Enter a valid domain name.']; } if (count($nameservers) < 2) { return ['success' => false, 'message' => 'Enter at least two nameservers.']; } try { $orderId = $this->getOrderId($domain); if ($orderId === '') { return ['success' => false, 'message' => 'This domain could not be matched to a registrar order.']; } $response = $this->rcWriteClient()->post( $this->buildRepeatedQueryUrl($this->apiUrl.'/domains/modify-ns.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'order-id' => $orderId, 'ns' => $nameservers, ]) ); if ($response->failed()) { return [ 'success' => false, 'message' => $this->extractResponseMessage($response, 'Could not update nameservers right now.'), ]; } return ['success' => true]; } catch (\Throwable $e) { Log::error('DomainRegistrarService: setNameservers failed', [ 'domain' => $domain, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Could not update nameservers right now.']; } } /** * @return array{ * success: bool, * message?: string, * order_id?: string, * auth_code?: string|null, * auth_code_supported?: bool, * privacy_supported?: bool, * privacy_enabled?: bool|null, * transfer_lock_supported?: bool, * transfer_lock_enabled?: bool|null, * nameservers?: array, * status?: string|null * } */ public function getRegistrarControlsSnapshot(string $domain): array { $domain = $this->normalizeFullDomain($domain); if ($domain === null) { return ['success' => false, 'message' => 'Enter a valid domain name.']; } $orderId = $this->getOrderId($domain); if ($orderId === '') { return ['success' => false, 'message' => 'This domain could not be matched to a registrar order.']; } try { $detailsResponse = $this->rcReadClient()->get( $this->buildRepeatedQueryUrl($this->apiUrl.'/domains/details-by-name.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'domain-name' => $domain, 'options' => ['OrderDetails', 'NsDetails', 'DomainStatus'], ]) ); if ($detailsResponse->failed()) { return [ 'success' => false, 'message' => $this->extractResponseMessage($detailsResponse, 'Could not load registrar controls right now.'), ]; } $details = $detailsResponse->json(); if (! is_array($details)) { $details = []; } $locksResponse = $this->rcReadClient()->get($this->apiUrl.'/domains/locks.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'order-id' => $orderId, ]); $locks = []; if ($locksResponse->successful() && is_array($locksResponse->json())) { $locks = $locksResponse->json(); } $tld = $this->extractTld($domain); $rawAuthCode = $this->extractSnapshotValue($details, ['domsecret', 'authcode', 'auth-code', 'eppcode', 'epp-code']); $rawPrivacy = $this->extractSnapshotValue($details, ['isprivacyprotected', 'privacyprotected', 'privacy-protected', 'privacy']); $rawStatus = $this->extractSnapshotValue($details, ['currentstatus', 'status', 'orderstatus']); $rawNameservers = data_get($details, 'ns'); return [ 'success' => true, 'order_id' => $orderId, 'auth_code' => is_scalar($rawAuthCode) ? (string) $rawAuthCode : null, 'auth_code_supported' => $this->authCodeSupportedForTld($tld), 'privacy_supported' => $this->privacySupportedForTld($tld), 'privacy_enabled' => $this->toNullableBool($rawPrivacy), 'transfer_lock_supported' => $this->transferLockSupportedForTld($tld), 'transfer_lock_enabled' => $this->toNullableBool(data_get($locks, 'transferlock')), 'nameservers' => $this->normalizeNameserverList($rawNameservers), 'status' => is_scalar($rawStatus) ? (string) $rawStatus : null, ]; } catch (\Throwable $e) { Log::error('DomainRegistrarService: getRegistrarControlsSnapshot failed', [ 'domain' => $domain, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Could not load registrar controls right now.']; } } /** * @return array{success: bool, message?: string} */ public function updateAuthCode(string $domain, string $authCode): array { $authCode = trim($authCode); if ($authCode === '') { return ['success' => false, 'message' => 'Enter a new auth/EPP code.']; } return $this->performDomainOrderWrite( $domain, '/domains/modify-auth-code.json', ['auth-code' => $authCode], 'Could not update the auth/EPP code right now.' ); } /** * @return array{success: bool, message?: string} */ public function setTransferLock(string $domain, bool $enabled): array { return $this->performDomainOrderWrite( $domain, $enabled ? '/domains/enable-theft-protection.json' : '/domains/disable-theft-protection.json', [], $enabled ? 'Could not enable registrar lock right now.' : 'Could not disable registrar lock right now.' ); } /** * @return array{success: bool, message?: string} */ public function setPrivacyProtection(string $domain, bool $enabled, string $reason = 'Managed from Ladill dashboard.'): array { return $this->performDomainOrderWrite( $domain, '/domains/modify-privacy-protection.json', [ 'protect-privacy' => $enabled, 'reason' => trim($reason) !== '' ? trim($reason) : 'Managed from Ladill dashboard.', ], $enabled ? 'Could not enable privacy protection right now.' : 'Could not disable privacy protection right now.' ); } /** * @return array{success: bool, domain?: string, amount_minor?: int, currency?: string, price_label?: string, message?: string} */ public function quoteDomainRegistration(string $domain): array { $results = $this->checkAvailability($domain, DomainConfig::searchTlds()); if (! ($results['success'] ?? false)) { return ['success' => false, 'message' => $results['message'] ?? 'Could not quote this domain right now.']; } $result = collect((array) ($results['results'] ?? []))->first(); if (! is_array($result) || ! ($result['available'] ?? false)) { return ['success' => false, 'message' => 'This domain is no longer available for registration.']; } $pricing = $this->preferredDomainPricing( (string) ($result['domain'] ?? $domain), 'addnewdomain', [ 'amount_minor' => (int) ($result['price_minor'] ?? 0), 'currency' => (string) ($result['currency'] ?? ''), 'label' => (string) ($result['price'] ?? ''), ] ); $amountMinor = (int) ($pricing['amount_minor'] ?? 0); if ($amountMinor <= 0) { return ['success' => false, 'message' => $this->pricingUnavailableMessage($pricing, 'registration')]; } $currency = (string) ($pricing['currency'] ?? $this->sellingCurrency); return [ 'success' => true, 'domain' => (string) ($result['domain'] ?? $domain), 'amount_minor' => $amountMinor, 'currency' => $currency, 'price_label' => (string) ($pricing['label'] ?? $this->formatMinorLabel($amountMinor, $currency)), ]; } /** * @return array{success: bool, domain?: string, amount_minor?: int, currency?: string, price_label?: string, message?: string} */ public function quoteDomainTransfer(string $domain): array { $validation = $this->validateTransfer($domain); if (! ($validation['success'] ?? false) || ! ($validation['transferable'] ?? false)) { return ['success' => false, 'message' => $validation['message'] ?? 'This domain is not ready for transfer.']; } $results = $this->checkAvailability($domain, DomainConfig::searchTlds()); if (! ($results['success'] ?? false)) { return ['success' => false, 'message' => $results['message'] ?? 'Could not quote this domain transfer right now.']; } $result = collect((array) ($results['results'] ?? []))->first(); if (! is_array($result) || ! ($result['transferable'] ?? false)) { return ['success' => false, 'message' => 'This domain is not ready for transfer.']; } $pricing = $this->preferredDomainPricing( (string) ($result['domain'] ?? $domain), 'addtransferdomain', [ 'amount_minor' => (int) ($result['transfer_price_minor'] ?? 0), 'currency' => (string) ($result['currency'] ?? ''), 'label' => (string) ($result['transfer_price'] ?? ''), ] ); $amountMinor = (int) ($pricing['amount_minor'] ?? 0); if ($amountMinor <= 0) { return ['success' => false, 'message' => $this->pricingUnavailableMessage($pricing, 'transfer')]; } $currency = (string) ($pricing['currency'] ?? $this->sellingCurrency); return [ 'success' => true, 'domain' => (string) ($result['domain'] ?? $domain), 'amount_minor' => $amountMinor, 'currency' => $currency, 'price_label' => (string) ($pricing['label'] ?? $this->formatMinorLabel($amountMinor, $currency)), ]; } /** * @return array{success: bool, domain?: string, amount_minor?: int, currency?: string, price_label?: string, rc_order_id?: string, message?: string} */ public function quoteDomainRenewal(string $domain, int $years = 1): array { $domain = strtolower(trim($domain)); $years = max(1, min(10, $years)); $rcOrderId = $this->getOrderId($domain); if ($rcOrderId === '') { return ['success' => false, 'message' => 'This domain is not registered with ResellerClub in our system.']; } $pricing = $this->preferredDomainPricing($domain, 'renewdomain', []); $amountMinor = (int) ($pricing['amount_minor'] ?? 0) * $years; if ($amountMinor <= 0) { return ['success' => false, 'message' => $this->pricingUnavailableMessage($pricing, 'renewal')]; } $currency = (string) ($pricing['currency'] ?? $this->sellingCurrency); return [ 'success' => true, 'domain' => $domain, 'rc_order_id' => $rcOrderId, 'amount_minor' => $amountMinor, 'currency' => $currency, 'price_label' => $this->formatMinorLabel($amountMinor, $currency), ]; } /** * @return array{success: bool, order_id?: string, message?: string} */ public function renewDomain(string $domain, int $years = 1, ?string $customerId = null): array { $domain = strtolower(trim($domain)); $years = max(1, min(10, $years)); $rcOrderId = $this->getOrderId($domain); if ($rcOrderId === '') { return ['success' => false, 'message' => 'This domain could not be matched to a registrar order.']; } try { $params = [ 'years' => $years, 'invoice-option' => 'NoInvoice', ]; if ($customerId !== null && $customerId !== '') { $params['customer-id'] = $customerId; } $response = $this->rcWriteClient()->post( $this->buildRepeatedQueryUrl($this->apiUrl.'/domains/renew.json', array_merge([ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'order-id' => $rcOrderId, ], $params)) ); if ($response->failed()) { return [ 'success' => false, 'message' => $this->extractResponseMessage($response, 'Domain renewal failed.'), ]; } return [ 'success' => true, 'order_id' => $rcOrderId, ]; } catch (\Throwable $e) { Log::error('DomainRegistrarService: renewDomain failed', [ 'domain' => $domain, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Domain renewal failed due to a network error.']; } } /** * Get the order ID for a domain from the registrar. */ private function getOrderId(string $domain): string { $domain = strtolower(trim($domain)); if ($domain === '') { return ''; } return Cache::remember('rc:order-id:'.$domain, self::ORDER_ID_CACHE_TTL_SECONDS, function () use ($domain) { try { $response = $this->rcReadClient()->get($this->apiUrl.'/domains/orderid.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'domain-name' => $domain, ]); if ($response->failed()) { return ''; } $json = $response->json(); if (is_int($json) || is_float($json) || (is_string($json) && is_numeric($json))) { return (string) $json; } $body = trim(strip_tags((string) $response->body())); return is_numeric($body) ? $body : ''; } catch (\Throwable $e) { Log::error('DomainRegistrarService: getOrderId failed', [ 'domain' => $domain, 'error' => $e->getMessage(), ]); return ''; } }); } /** * @param array $parameters * @return array{success: bool, message?: string} */ private function performDomainOrderWrite(string $domain, string $path, array $parameters, string $fallback): array { $domain = $this->normalizeFullDomain($domain); if ($domain === null) { return ['success' => false, 'message' => 'Enter a valid domain name.']; } try { $orderId = $this->getOrderId($domain); if ($orderId === '') { return ['success' => false, 'message' => 'This domain could not be matched to a registrar order.']; } $response = $this->rcWriteClient()->post( $this->buildRepeatedQueryUrl($this->apiUrl.$path, array_merge([ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'order-id' => $orderId, ], $parameters)) ); if ($response->failed()) { return [ 'success' => false, 'message' => $this->extractResponseMessage($response, $fallback), ]; } return ['success' => true]; } catch (\Throwable $e) { Log::error('DomainRegistrarService: performDomainOrderWrite failed', [ 'domain' => $domain, 'path' => $path, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => $fallback]; } } private function configuredApiUrl(): string { $value = trim((string) config('mailinfra.registrar_api_url')); return $value !== '' ? $value : self::DEFAULT_API_URL; } private function configuredDomainCheckApiUrl(): string { $value = trim((string) config('mailinfra.registrar_domaincheck_api_url')); return $value !== '' ? $value : self::DEFAULT_DOMAINCHECK_API_URL; } private function rcReadClient(): PendingRequest { return Http::acceptJson() ->connectTimeout(3) ->timeout(8) ->retry(2, 250, throw: false); } private function rcWriteClient(): PendingRequest { return Http::acceptJson() ->connectTimeout(5) ->timeout(25) ->retry(1, 300, throw: false); } /** * RC expects repeated keys for array values instead of PHP-style key[0]=value encoding. * * @param array $parameters */ private function buildRepeatedQueryUrl(string $baseUrl, array $parameters): string { $segments = []; foreach ($parameters as $key => $value) { if (is_array($value)) { foreach ($value as $item) { if ($item === null || $item === '') { continue; } $segments[] = rawurlencode((string) $key).'='.rawurlencode((string) $item); } continue; } if ($value === null || $value === '') { continue; } $normalized = is_bool($value) ? ($value ? 'true' : 'false') : (string) $value; $segments[] = rawurlencode((string) $key).'='.rawurlencode($normalized); } if ($segments === []) { return $baseUrl; } return $baseUrl.(Str::contains($baseUrl, '?') ? '&' : '?').implode('&', $segments); } private function normalizeSearchQuery(string $query): string { $query = strtolower(trim($query)); $query = preg_replace('#^https?://#', '', $query) ?? $query; $query = preg_replace('#/.*$#', '', $query) ?? $query; $query = preg_replace('/^www\./', '', $query) ?? $query; return trim($query, " \t\n\r\0\x0B."); } private function normalizeFullDomain(string $domain): ?string { $domain = $this->normalizeSearchQuery($domain); if (! str_contains($domain, '.')) { return null; } return preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9-]{2,63})+$/', $domain) === 1 ? $domain : null; } /** * @param array $payload * @param array $keys */ private function extractSnapshotValue(array $payload, array $keys): mixed { foreach ($keys as $key) { $value = data_get($payload, $key); if ($value !== null && $value !== '') { return $value; } } return null; } private function toNullableBool(mixed $value): ?bool { if (is_bool($value)) { return $value; } if (is_int($value) || is_float($value)) { return (bool) $value; } if (is_string($value)) { $value = strtolower(trim($value)); if (in_array($value, ['true', '1', 'yes', 'enabled', 'active'], true)) { return true; } if (in_array($value, ['false', '0', 'no', 'disabled', 'inactive'], true)) { return false; } } return null; } /** * @return array */ private function normalizeNameserverList(mixed $value): array { if (! is_array($value)) { return []; } return collect($value) ->map(fn ($item) => strtolower(trim((string) $item, " \t\n\r\0\x0B."))) ->filter() ->values() ->all(); } private function authCodeSupportedForTld(string $tld): bool { return ! $this->tldMatchesAny($tld, ['es', 'ru', 'uk', 'web.in', 'at']); } private function transferLockSupportedForTld(string $tld): bool { return ! $this->tldMatchesAny($tld, ['au', 'de', 'es', 'eu', 'fr', 'nl', 'ru', 'uk', 'web.in']); } private function privacySupportedForTld(string $tld): bool { return ! $this->tldMatchesAny($tld, [ 'asia', 'au', 'ca', 'cn', 'org.co', 'mil.co', 'gov.co', 'edu.co', 'de', 'es', 'eu', 'in', 'nl', 'nz', 'pro', 'ru', 'sx', 'tel', 'uk', 'us', 'mx', 'at', 'web.in', ]); } /** * @param array $blockedTlds */ private function tldMatchesAny(string $tld, array $blockedTlds): bool { foreach ($blockedTlds as $blockedTld) { $blockedTld = strtolower(trim($blockedTld)); if ($blockedTld === '') { continue; } if ($tld === $blockedTld || str_ends_with($tld, '.'.$blockedTld)) { return true; } } return false; } /** * @param array $defaultTlds * @return array{valid: bool, keyword?: string, tlds?: array, exact?: bool, message?: string} */ private function parseSearchQuery(string $query, array $defaultTlds): array { $query = $this->normalizeSearchQuery($query); if ($query === '') { return ['valid' => false, 'message' => 'Enter a domain name to search.']; } if (str_contains($query, '.')) { $domain = $this->normalizeFullDomain($query); if ($domain === null) { return ['valid' => false, 'message' => 'Enter a valid domain such as ladill.com.']; } $parts = explode('.', $domain); $keyword = array_shift($parts); $tld = implode('.', $parts); return [ 'valid' => true, 'keyword' => $keyword, 'tlds' => [$tld], 'exact' => true, ]; } if (preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $query) !== 1) { return ['valid' => false, 'message' => 'Enter a valid name such as ladill or ladill.com.']; } return [ 'valid' => true, 'keyword' => $query, 'tlds' => array_values(array_unique(array_map( fn ($tld) => ltrim(strtolower((string) $tld), '.'), array_filter($defaultTlds) ))), 'exact' => false, ]; } /** * @param array $tlds * @param array $data * @param array $pricingMap * @return array */ private function mapAvailabilityResults(string $keyword, array $tlds, array $data, array $pricingMap): array { $results = []; foreach ($tlds as $tld) { $normalizedTld = ltrim(strtolower($tld), '.'); $domain = $keyword.'.'.$normalizedTld; $payload = is_array($data[$domain] ?? null) ? $data[$domain] : []; $status = strtolower((string) ($payload['status'] ?? 'unknown')); $inlineCreateQuote = $this->extractAvailabilityCostHashQuote($payload, 'create'); $inlineTransferQuote = $this->extractAvailabilityCostHashQuote($payload, 'transfer'); $pricedCreateQuote = $status === 'available' ? $this->resolveAvailabilityPriceQuote( $domain, 'addnewdomain', $inlineCreateQuote, $pricingMap[$normalizedTld]['addnewdomain'] ?? [] ) : []; $pricedTransferQuote = $status === 'regthroughothers' ? $this->resolveAvailabilityPriceQuote( $domain, 'addtransferdomain', $inlineTransferQuote, $pricingMap[$normalizedTld]['addtransferdomain'] ?? [] ) : []; $results[] = [ 'domain' => $domain, 'available' => $status === 'available', 'transferable' => $status === 'regthroughothers', 'status' => $status, 'status_label' => $this->availabilityStatusLabel($status), 'premium' => is_array($payload['costhash'] ?? $payload['costHash'] ?? null), 'price' => $pricedCreateQuote['label'] ?? null, 'transfer_price' => $pricedTransferQuote['label'] ?? null, 'currency' => $pricedCreateQuote['currency'] ?? $pricedTransferQuote['currency'] ?? null, 'price_minor' => $pricedCreateQuote['amount_minor'] ?? null, 'transfer_price_minor' => $pricedTransferQuote['amount_minor'] ?? null, ]; } return $results; } /** * @param array $data * @param array $pricingMap * @return array */ private function mapSuggestedDomains(array $data, ?string $excludeDomain = null, array $pricingMap = []): array { $results = []; $excludeDomain = $excludeDomain !== null ? strtolower(trim($excludeDomain)) : null; foreach ($data as $domain => $payload) { if (! is_string($domain) || ! is_array($payload)) { continue; } $normalizedDomain = strtolower(trim($domain)); if ($normalizedDomain === '' || $normalizedDomain === $excludeDomain || isset($results[$normalizedDomain])) { continue; } $status = strtolower((string) ($payload['status'] ?? 'unknown')); $tld = $this->extractTld($normalizedDomain); $inlineCreateQuote = $this->extractAvailabilityCostHashQuote($payload, 'create'); $inlineTransferQuote = $this->extractAvailabilityCostHashQuote($payload, 'transfer'); $pricedCreateQuote = $status === 'available' ? $this->resolveAvailabilityPriceQuote( $normalizedDomain, 'addnewdomain', $inlineCreateQuote, $pricingMap[$tld]['addnewdomain'] ?? [] ) : []; $pricedTransferQuote = $status === 'regthroughothers' ? $this->resolveAvailabilityPriceQuote( $normalizedDomain, 'addtransferdomain', $inlineTransferQuote, $pricingMap[$tld]['addtransferdomain'] ?? [] ) : []; $results[$normalizedDomain] = [ 'domain' => $normalizedDomain, 'available' => $status === 'available', 'transferable' => $status === 'regthroughothers', 'status' => $status, 'status_label' => $this->availabilityStatusLabel($status), 'premium' => is_array($payload['costhash'] ?? $payload['costHash'] ?? null), 'price' => $pricedCreateQuote['label'] ?? null, 'transfer_price' => $pricedTransferQuote['label'] ?? null, 'currency' => $pricedCreateQuote['currency'] ?? $pricedTransferQuote['currency'] ?? null, 'price_minor' => $pricedCreateQuote['amount_minor'] ?? null, 'transfer_price_minor' => $pricedTransferQuote['amount_minor'] ?? null, ]; } return array_values($results); } private function availabilityStatusLabel(string $status): string { return match ($status) { 'available' => 'Available', 'regthroughothers' => 'Taken', 'regthroughus' => 'Already registered', 'unknown' => 'Check again shortly', default => 'Unavailable', }; } /** * @param array $payload * @return array{label?: string, amount_minor?: int, currency?: string} */ private function extractAvailabilityCostHashQuote(array $payload, string $operation): array { $costHash = $payload['costhash'] ?? $payload['costHash'] ?? null; if (! is_array($costHash)) { return []; } $value = $costHash[$operation] ?? null; if (! is_numeric($value)) { return []; } $symbol = trim((string) ($costHash['currency-symbol'] ?? $costHash['currencySymbol'] ?? '')); $currency = strtoupper(trim((string) ($costHash['currency-code'] ?? $costHash['currencyCode'] ?? ''))); if ($currency === '') { $currency = $this->resolveCurrencyCode($symbol); } if ($currency === '') { $currency = $this->sellingCurrency; } $amountMinor = (int) round(((float) $value) * 100); return [ 'amount_minor' => $amountMinor, 'currency' => $currency, 'label' => $this->formatMinorLabel($amountMinor, $currency), ]; } /** * Customer-facing RC domain pricing source: cached customer-price table. * * @return array */ private function getCustomerPriceTable(): array { return Cache::remember(self::CUSTOMER_PRICE_TABLE_CACHE_KEY, self::CUSTOMER_PRICE_TABLE_TTL_SECONDS, function () { if ((bool) Cache::get(self::API_BLOCK_CACHE_KEY, false)) { return []; } try { $response = $this->rcReadClient()->get($this->apiUrl.'/products/customer-price.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, ]); if ($response->failed()) { $blocked = $this->isRegistrarSecurityBlock($response); Log::warning('DomainRegistrarService: customer-price failed', [ 'status' => $response->status(), 'blocked' => $blocked, 'body' => Str::limit($response->body(), 500), ]); if ($blocked) { Cache::put(self::API_BLOCK_CACHE_KEY, true, self::API_BLOCK_TTL_SECONDS); } return []; } $data = $response->json(); return is_array($data) ? $data : []; } catch (\Throwable $e) { Log::warning('DomainRegistrarService: customer-price exception', [ 'error' => $e->getMessage(), ]); return []; } }); } /** * @return array */ private function getProductDetailsCatalog(): array { return Cache::remember( self::PRODUCT_DETAILS_CACHE_KEY, now()->addSeconds(DomainConfig::resellerClubProductKeyCacheTtlSeconds()), function (): array { try { $response = $this->rcReadClient()->get($this->apiUrl.'/products/details.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, ]); if ($response->failed()) { Log::warning('DomainRegistrarService: product-details failed', [ 'status' => $response->status(), 'body' => Str::limit($response->body(), 500), ]); return []; } $data = $response->json(); return is_array($data) ? $data : []; } catch (\Throwable $e) { Log::warning('DomainRegistrarService: product-details exception', [ 'error' => $e->getMessage(), ]); return []; } } ); } /** * @param array $tlds * @return array */ private function preloadPricingForTlds(array $tlds): array { $map = []; $normalizedTlds = array_values(array_unique(array_map( fn ($tld) => ltrim(strtolower((string) $tld), '.'), array_filter($tlds) ))); if ($normalizedTlds === []) { return $map; } $table = $this->getCustomerPriceTable(); foreach ($normalizedTlds as $tld) { $map[$tld] = [ 'addnewdomain' => $this->extractTldPricingFromCustomerTable($table, $tld, 'addnewdomain', 1), 'addtransferdomain' => $this->extractTldPricingFromCustomerTable($table, $tld, 'addtransferdomain', 1), ]; } return $map; } /** * @param array $table * @return array{amount_minor?: int, currency?: string, label?: string} */ private function extractTldPricingFromCustomerTable(array $table, string $tld, string $actionType, int $years = 1): array { if ($tld === '' || $table === []) { return []; } $productKey = $this->resolveProductKeyForTld($tld, $table); if ($productKey === null) { return []; } $entry = $table[$productKey] ?? null; if (! is_array($entry)) { return []; } $actionData = $entry[$actionType] ?? null; if (! is_array($actionData)) { return []; } $price = $actionData[(string) $years] ?? null; if (! is_numeric($price)) { return []; } $amountMinor = (int) round(((float) $price) * 100); return [ 'amount_minor' => $amountMinor, 'currency' => $this->sellingCurrency, 'label' => $this->formatMinorLabel($amountMinor, $this->sellingCurrency), ]; } /** * @param array $table */ private function resolveProductKeyForTld(string $tld, array $table): ?string { $normalizedTld = ltrim(strtolower($tld), '.'); $configured = trim((string) ($this->tldProductKeys[$normalizedTld] ?? '')); if ($normalizedTld === '') { return null; } if ($configured !== '' && isset($table[$configured])) { return $configured; } $resolved = Cache::remember( 'rc:domain-product-key:'.$normalizedTld, now()->addSeconds(DomainConfig::resellerClubProductKeyCacheTtlSeconds()), fn (): string => $this->fetchProductKeyFromApi($normalizedTld, $table) ?? self::PRODUCT_KEY_CACHE_MISS ); return $resolved !== self::PRODUCT_KEY_CACHE_MISS ? $resolved : null; } /** * @param array $table */ private function fetchProductKeyFromApi(string $tld, array $table): ?string { foreach ($this->productDetailsRows($this->getProductDetailsCatalog()) as $row) { if (! $this->detailsRowMatchesTld($row['payload'], $tld)) { continue; } $productKey = $row['product_key']; if ($productKey !== '' && isset($table[$productKey])) { return $productKey; } } return null; } /** * @param array $details * @return list}> */ private function productDetailsRows(array $details): array { $rows = []; $this->collectProductDetailsRows($details, $rows); return $rows; } /** * @param array $node * @param array}> $rows * @param array $context */ private function collectProductDetailsRows(array $node, array &$rows, array $context = []): void { foreach ($node as $key => $value) { if (! is_array($value)) { continue; } $childContext = $this->mergeProductDetailsContext($context, is_string($key) ? $key : null, $value); $payload = array_merge($childContext, $value); $productKey = $this->extractProductKeyFromDetailsRow($payload, is_string($key) ? $key : null); if ($productKey !== null && $this->looksLikeProductDetailsRow($payload)) { $rows[] = [ 'product_key' => $productKey, 'payload' => $payload, ]; } $this->collectProductDetailsRows($value, $rows, $childContext); } } /** * @param array $context * @param array $node * @return array */ private function mergeProductDetailsContext(array $context, ?string $key, array $node): array { $merged = $context; foreach ([ 'category' => ['category', 'categoryname'], 'group' => ['group', 'groupname', 'productgroup', 'type'], ] as $target => $fields) { foreach ($fields as $field) { $value = trim((string) ($node[$field] ?? '')); if ($value !== '') { $merged[$target] = strtolower($value); continue 2; } } } $normalizedKey = strtolower(trim((string) $key)); if (($merged['category'] ?? '') === '' && $normalizedKey === 'domain') { $merged['category'] = 'domain'; } if (($merged['group'] ?? '') === '' && $normalizedKey === 'tld') { $merged['group'] = 'tld'; } return $merged; } /** * @param array $row */ private function looksLikeProductDetailsRow(array $row): bool { foreach ([ 'category', 'categoryname', 'group', 'groupname', 'productgroup', 'type', 'productkey', 'product-key', 'product_key', 'name', 'productname', 'product_name', 'product', 'slug', 'tld', ] as $field) { if (array_key_exists($field, $row)) { return true; } } return false; } /** * @param array $row */ private function extractProductKeyFromDetailsRow(array $row, ?string $fallbackKey = null): ?string { foreach (['productkey', 'product-key', 'product_key', 'key'] as $field) { $candidate = trim((string) ($row[$field] ?? '')); if ($candidate !== '') { return $candidate; } } $candidate = trim((string) $fallbackKey); return $candidate !== '' ? $candidate : null; } /** * @param array $row */ private function detailsRowMatchesTld(array $row, string $tld): bool { return $this->detailsRowIsDomainTld($row) && $this->detailsRowContainsTld($row, $tld); } /** * @param array $row */ private function detailsRowIsDomainTld(array $row): bool { $category = $this->detailsRowFieldContains($row, ['category', 'categoryname'], 'domain'); $group = $this->detailsRowFieldContains($row, ['group', 'groupname', 'productgroup', 'type'], 'tld'); return $category && $group; } /** * @param array $row * @param list $fields */ private function detailsRowFieldContains(array $row, array $fields, string $needle): bool { foreach ($fields as $field) { $value = strtolower(trim((string) ($row[$field] ?? ''))); if ($value !== '' && str_contains($value, $needle)) { return true; } } return false; } /** * @param array $row */ private function detailsRowContainsTld(array $row, string $tld): bool { $pattern = '/(^|[^a-z0-9])\.?'.preg_quote($tld, '/').'($|[^a-z0-9])/i'; foreach (['tld', 'name', 'productname', 'product_name', 'product', 'description', 'slug'] as $field) { $value = trim((string) ($row[$field] ?? '')); if ($value === '') { continue; } if (ltrim(strtolower($value), '.') === $tld || preg_match($pattern, strtolower($value)) === 1) { return true; } } return false; } /** * @param array{amount_minor?: int, currency?: string, label?: string} $inlineQuote * @return array{amount_minor?: int, currency?: string, label?: string, blocked?: bool} */ private function preferredDomainPricing(string $domain, string $actionType, array $inlineQuote = []): array { $tableQuote = $this->extractTldPricingFromCustomerTable( $this->getCustomerPriceTable(), $this->extractTld($domain), $actionType, 1 ); if (($tableQuote['amount_minor'] ?? 0) > 0) { return $tableQuote; } return (bool) Cache::get(self::API_BLOCK_CACHE_KEY, false) ? ['blocked' => true] : []; } /** * @param array{amount_minor?: int, currency?: string, label?: string} $inlineQuote * @param array{amount_minor?: int, currency?: string, label?: string} $prefetchedQuote * @return array{amount_minor?: int, currency?: string, label?: string, blocked?: bool} */ private function resolveAvailabilityPriceQuote( string $domain, string $actionType, array $inlineQuote = [], array $prefetchedQuote = [] ): array { if (($prefetchedQuote['amount_minor'] ?? 0) > 0) { return $prefetchedQuote; } return (bool) Cache::get(self::API_BLOCK_CACHE_KEY, false) ? ['blocked' => true] : []; } private function formatMinorLabel(int $amountMinor, string $currency): string { return strtoupper($currency).' '.number_format($amountMinor / 100, 2); } /** * @param array{blocked?: bool} $pricing */ private function pricingUnavailableMessage(array $pricing, string $context): string { if ((bool) ($pricing['blocked'] ?? false)) { return $context === 'transfer' ? 'Live transfer pricing from ResellerClub is currently blocked by the registrar. Please try again shortly or contact support.' : 'Live domain pricing from ResellerClub is currently blocked by the registrar. Please try again shortly or contact support.'; } return $context === 'transfer' ? 'Transfer pricing is temporarily unavailable. Please try again shortly.' : 'Domain pricing is temporarily unavailable. Please try again shortly.'; } private function isRegistrarSecurityBlock(Response $response): bool { if (! in_array($response->status(), [403, 429, 503], true)) { return false; } $body = strtolower((string) $response->body()); return str_contains($body, 'cloudflare') || str_contains($body, 'attention required') || str_contains($body, 'you have been blocked') || str_contains($body, 'security check'); } /** * Extract the TLD from a full domain name. */ private function extractTld(string $domain): string { $domain = strtolower(trim($domain)); $dot = strpos($domain, '.'); return $dot !== false ? substr($domain, $dot + 1) : ''; } private function booleanFromResponse(Response $response): bool { $json = $response->json(); if (is_bool($json)) { return $json; } if (is_string($json)) { return filter_var($json, FILTER_VALIDATE_BOOLEAN); } $body = strtolower(trim(strip_tags((string) $response->body()))); return in_array($body, ['1', 'true', 'yes'], true); } private function extractResponseMessage(Response $response, string $fallback): string { $json = $response->json(); if (is_array($json)) { foreach (['message', 'error', 'description'] as $key) { $value = trim((string) ($json[$key] ?? '')); if ($value !== '') { return $value; } } } $body = trim(strip_tags((string) $response->body())); if ($body === '' || str_contains(strtolower((string) $response->body()), ' 'GHS', '₦', 'NGN' => 'NGN', 'KSh', 'KES' => 'KES', 'R', 'ZAR' => 'ZAR', '$', 'USD' => 'USD', '€', 'EUR' => 'EUR', '£', 'GBP' => 'GBP', default => '', }; } /** * Certain TLDs require -1 for some contact slots during transfer. * * @return array{0: string, 1: string, 2: string} */ private function transferContactProfileForDomain(string $domain, string $defaultContactId): array { $tld = '.'.strtolower($this->extractTld($domain)); $admin = $defaultContactId; $tech = $defaultContactId; $billing = $defaultContactId; if (in_array($tld, ['.eu', '.ru', '.uk'], true)) { $admin = '-1'; } if (in_array($tld, ['.ru', '.uk'], true)) { $tech = '-1'; } if (in_array($tld, ['.at', '.berlin', '.ca', '.nl', '.nz', '.ru', '.uk'], true)) { $billing = '-1'; } return [$admin, $tech, $billing]; } }