Files
ladill-hosting/app/Services/Domain/DomainRegistrarService.php
T
isaaccladandCursor e251a4cf60
Deploy Ladill Hosting / deploy (push) Failing after 17s
Initial Ladill Hosting app with Gitea deploy pipeline.
Shared web hosting extracted from the platform monolith, with CI deploy
to /var/www/ladill-hosting matching Bird/Domains/Email.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 16:24:20 +00:00

2021 lines
68 KiB
PHP

<?php
namespace App\Services\Domain;
use App\Models\User;
use App\Support\DomainConfig;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class DomainRegistrarService
{
private const DEFAULT_API_URL = 'https://httpapi.com/api';
private const DEFAULT_DOMAINCHECK_API_URL = 'https://domaincheck.httpapi.com/api';
private const CUSTOMER_PRICE_TABLE_CACHE_KEY = 'rc:domain-customer-price-table:v1';
private const PRODUCT_DETAILS_CACHE_KEY = 'rc:domain-product-details:v1';
private const CUSTOMER_PRICE_TABLE_TTL_SECONDS = 21600;
private const ORDER_ID_CACHE_TTL_SECONDS = 900;
private const API_BLOCK_CACHE_KEY = 'rc:api-security-blocked';
private const API_BLOCK_TTL_SECONDS = 600;
private const PRODUCT_KEY_CACHE_MISS = '__missing__';
private string $apiUrl;
private string $domainCheckApiUrl;
private string $authUserId;
private string $apiKey;
private string $customerId;
private string $defaultContactId;
private string $sellingCurrency;
/**
* @var array<string, string>
*/
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<int, string> $defaultTlds
* @return array{
* success: bool,
* results?: array<int, array{
* domain: string,
* available: bool,
* transferable: bool,
* status: string,
* status_label: string,
* premium: bool,
* price: string|null,
* transfer_price: string|null,
* currency: string|null,
* price_minor: int|null,
* transfer_price_minor: int|null
* }>,
* exact?: bool,
* keyword?: string,
* tlds?: array<int, string>,
* 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<int, string> $tlds
* @return array{
* success: bool,
* results?: array<int, array{
* domain: string,
* available: bool,
* transferable: bool,
* status: string,
* status_label: string,
* premium: bool,
* price: string|null,
* transfer_price: string|null,
* currency: string|null,
* price_minor: int|null,
* transfer_price_minor: int|null
* }>,
* 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<int, string>,
* 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<string, mixed> $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<string, mixed> $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<string, mixed> $payload
* @param array<int, string> $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<int, string>
*/
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<int, string> $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<int, string> $defaultTlds
* @return array{valid: bool, keyword?: string, tlds?: array<int, string>, 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<int, string> $tlds
* @param array<string, mixed> $data
* @param array<string, array{addnewdomain?: array, addtransferdomain?: array}> $pricingMap
* @return array<int, array{
* domain: string,
* available: bool,
* transferable: bool,
* status: string,
* status_label: string,
* premium: bool,
* price: string|null,
* transfer_price: string|null,
* currency: string|null,
* price_minor: int|null,
* transfer_price_minor: int|null
* }>
*/
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<string, mixed> $data
* @param array<string, array{addnewdomain?: array, addtransferdomain?: array}> $pricingMap
* @return array<int, array{
* domain: string,
* available: bool,
* transferable: bool,
* status: string,
* status_label: string,
* premium: bool,
* price: string|null,
* transfer_price: string|null,
* currency: string|null,
* price_minor: int|null,
* transfer_price_minor: int|null
* }>
*/
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<string, mixed> $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<string, mixed>
*/
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<string, mixed>
*/
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<int, string> $tlds
* @return array<string, array{addnewdomain?: array, addtransferdomain?: 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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $details
* @return list<array{product_key: string, payload: array<string, mixed>}>
*/
private function productDetailsRows(array $details): array
{
$rows = [];
$this->collectProductDetailsRows($details, $rows);
return $rows;
}
/**
* @param array<string, mixed> $node
* @param array<int, array{product_key: string, payload: array<string, mixed>}> $rows
* @param array<string, string> $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<string, string> $context
* @param array<string, mixed> $node
* @return array<string, string>
*/
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<string, mixed> $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<string, mixed> $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<string, mixed> $row
*/
private function detailsRowMatchesTld(array $row, string $tld): bool
{
return $this->detailsRowIsDomainTld($row)
&& $this->detailsRowContainsTld($row, $tld);
}
/**
* @param array<string, mixed> $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<string, mixed> $row
* @param list<string> $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<string, mixed> $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()), '<html')) {
return $fallback;
}
return Str::limit(preg_replace('/\s+/', ' ', $body) ?? $fallback, 180);
}
private function resolveCurrencyCode(string $symbol): string
{
return match (trim($symbol)) {
'GH₵', 'GHS' => '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];
}
}