apiUrl = rtrim($this->configuredApiUrl(), '/'); $this->authUserId = (string) config('mailinfra.registrar_auth_userid'); $this->apiKey = (string) config('mailinfra.registrar_api_key'); } public function isConfigured(): bool { return $this->authUserId !== '' && $this->apiKey !== ''; } /** * Build a URL with all parameters as query string values, including auth. * * @param array $params */ private function buildQueryUrl(string $endpoint, array $params = []): string { $all = array_merge([ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, ], $params); $separator = str_contains($endpoint, '?') ? '&' : '?'; return $endpoint.$separator.http_build_query($all); } /** * @return array{success: bool, customer_id?: string, created?: bool, message?: string} */ public function resolveCustomerForUser(User $user): array { if (! $this->isConfigured()) { return ['success' => false, 'message' => 'ResellerClub is not configured.']; } if ($user->hasResellerClubCustomer()) { return [ 'success' => true, 'customer_id' => (string) $user->rc_customer_id, 'created' => false, ]; } $email = strtolower(trim((string) $user->email)); if ($email === '') { return ['success' => false, 'message' => 'User email is required to provision a ResellerClub customer.']; } $lookup = $this->findCustomerByEmail($email); if (! ($lookup['success'] ?? false)) { return ['success' => false, 'message' => $lookup['message'] ?? 'Could not verify the ResellerClub customer.']; } if ($lookup['found'] ?? false) { return $this->attachCustomerToUser( $user, (string) $lookup['customer_id'], $email, false ); } $created = $this->createCustomerForUser($user); if (! ($created['success'] ?? false)) { return ['success' => false, 'message' => $created['message'] ?? 'Could not create the ResellerClub customer.']; } $attached = $this->attachCustomerToUser( $user, (string) $created['customer_id'], $email, false ); if (! ($attached['success'] ?? false)) { return $attached; } return [ 'success' => true, 'customer_id' => (string) $attached['customer_id'], 'created' => true, ]; } /** * Best-effort customer provisioning for user creation hooks. */ public function provisionCustomerForUser(User $user): ?string { $result = $this->resolveCustomerForUser($user); if (! ($result['success'] ?? false)) { Log::warning('ResellerClubCustomerService: automatic provisioning skipped', [ 'user_id' => $user->id, 'message' => $result['message'] ?? 'Unknown error.', ]); return null; } return (string) ($result['customer_id'] ?? ''); } /** * @return array{success: bool, customer_id?: string, found?: bool, message?: string} */ public function findCustomerByEmail(string $email): array { try { $response = Http::timeout(15)->get($this->apiUrl.'/customers/details.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'username' => strtolower(trim($email)), ]); $data = $response->json(); if ($response->successful() && is_array($data)) { $customerId = (string) ($data['customerid'] ?? $data['customer-id'] ?? ''); if ($customerId !== '') { return [ 'success' => true, 'found' => true, 'customer_id' => $customerId, ]; } } if (in_array($response->status(), [400, 404], true)) { return [ 'success' => true, 'found' => false, ]; } $message = $this->extractErrorMessage(is_array($data) ? $data : [], $response->body()); if ($message !== '' && $this->looksLikeMissingCustomerMessage($message)) { return [ 'success' => true, 'found' => false, ]; } Log::warning('ResellerClubCustomerService: customer lookup failed', [ 'email' => $email, 'status' => $response->status(), 'response' => $data ?: $response->body(), ]); return [ 'success' => false, 'message' => $message !== '' ? $message : 'Could not look up the ResellerClub customer.', ]; } catch (\Throwable $e) { Log::error('ResellerClubCustomerService: findCustomerByEmail failed', [ 'email' => $email, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Could not look up the ResellerClub customer.']; } } /** * @return array{success: bool, customer_id?: string, message?: string} */ public function createCustomerForUser(User $user): array { $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 ?? '')), ]))); try { $response = Http::timeout(30)->post($this->buildQueryUrl($this->apiUrl.'/customers/v2/signup.xml', [ 'username' => $email, 'passwd' => $this->generatePlatformPassword(), 'name' => $name !== '' ? $name : (trim((string) $user->name) !== '' ? (string) $user->name : $email), 'company' => trim((string) ($user->company ?? '')) !== '' ? (string) $user->company : (string) ($defaults['company'] ?? config('app.name', 'Ladill')), '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'), 'lang-pref' => (string) ($defaults['lang_pref'] ?? 'en'), 'address-line-2' => trim((string) ($user->address_line_2 ?? '')) !== '' ? (string) $user->address_line_2 : (string) ($defaults['address_line_2'] ?? ''), 'address-line-3' => (string) ($defaults['address_line_3'] ?? ''), ])); $body = trim((string) $response->body()); $customerId = $this->extractCustomerIdFromBody($body); if ($response->failed() || $customerId === '') { $message = $this->extractXmlErrorMessage($body); Log::warning('ResellerClubCustomerService: customer signup failed', [ 'user_id' => $user->id, 'email' => $email, 'status' => $response->status(), 'body' => $body, ]); return [ 'success' => false, 'message' => $message !== '' ? $message : 'Could not create the ResellerClub customer.', ]; } return [ 'success' => true, 'customer_id' => $customerId, ]; } catch (\Throwable $e) { Log::error('ResellerClubCustomerService: createCustomerForUser failed', [ 'user_id' => $user->id, 'email' => $email, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Could not create the ResellerClub customer.']; } } /** * @return array{success: bool, customer_id?: string, message?: string} */ public function attachCustomerToUser(User $user, string $customerId, ?string $email = null, bool $markLinked = false): array { $customerId = trim($customerId); if ($customerId === '') { return ['success' => false, 'message' => 'A valid ResellerClub customer ID is required.']; } $existing = User::query() ->where('rc_customer_id', $customerId) ->where('id', '!=', $user->id) ->first(); if ($existing) { return ['success' => false, 'message' => 'This ResellerClub account is already linked to another Ladill account.']; } $payload = [ 'rc_customer_id' => $customerId, 'rc_email' => $email ?: $user->rc_email ?: $user->email, ]; if ($markLinked) { $payload['rc_linked_at'] = now(); } $user->forceFill($payload)->save(); return [ 'success' => true, 'customer_id' => $customerId, ]; } /** * @return array{success: bool, token?: string, message?: string} */ public function generateLoginToken(User $user, string $ipAddress): array { if (! $user->hasResellerClubCustomer()) { return ['success' => false, 'message' => 'ResellerClub customer is not set for this user.']; } try { $response = Http::timeout(15)->get($this->apiUrl.'/customers/generate-login-token.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'customer-id' => (string) $user->rc_customer_id, 'ip' => $ipAddress, ]); $data = $response->json(); $rawBody = trim((string) $response->body()); if ($response->failed()) { return [ 'success' => false, 'message' => $this->extractErrorMessage(is_array($data) ? $data : [], $rawBody) ?: 'Could not create the ResellerClub login token.', ]; } $token = is_array($data) ? (string) ($data['token'] ?? $data['loginid'] ?? $data['userLoginId'] ?? '') : trim($rawBody, "\"'"); if ($token === '') { return ['success' => false, 'message' => 'Could not create the ResellerClub login token.']; } return ['success' => true, 'token' => $token]; } catch (\Throwable $e) { Log::error('ResellerClubCustomerService: generateLoginToken failed', [ 'user_id' => $user->id, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Could not create the ResellerClub login token.']; } } public function generatePlatformPassword(): string { $upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; $lower = 'abcdefghijkmnopqrstuvwxyz'; $numbers = '23456789'; $special = '!@$#%_+?'; $pool = $upper.$lower.$numbers.$special; $password = [ $upper[random_int(0, strlen($upper) - 1)], $lower[random_int(0, strlen($lower) - 1)], $numbers[random_int(0, strlen($numbers) - 1)], $special[random_int(0, strlen($special) - 1)], ]; while (count($password) < 12) { $password[] = $pool[random_int(0, strlen($pool) - 1)]; } shuffle($password); return implode('', $password); } /** * @return array{ * success: bool, * configured: bool, * api_url: string, * auth_userid_present: bool, * current_customer_id: string, * current_rc_email: string, * linked: bool, * steps: array * } */ public function runDiagnostics(User $user, string $ipAddress): array { $steps = []; if (! $this->isConfigured()) { $steps[] = $this->diagnosticStep('Configuration', 'error', 'ResellerClub is not configured.'); return $this->diagnosticSummary($user, $steps, false); } $steps[] = $this->diagnosticStep('Configuration', 'success', 'ResellerClub credentials are configured.'); if ($user->hasResellerClubCustomer()) { $steps[] = $this->diagnosticStep( 'Stored customer', 'success', 'Current user already has ResellerClub customer ID '.$user->rc_customer_id.'.' ); } else { $email = strtolower(trim((string) $user->email)); if ($email === '') { $steps[] = $this->diagnosticStep('Customer lookup', 'error', 'User email is required to provision a ResellerClub customer.'); return $this->diagnosticSummary($user, $steps, false); } $lookup = $this->findCustomerByEmail($email); if (! ($lookup['success'] ?? false)) { $steps[] = $this->diagnosticStep( 'Customer lookup', 'error', $lookup['message'] ?? 'Could not look up the ResellerClub customer.' ); return $this->diagnosticSummary($user, $steps, false); } if ($lookup['found'] ?? false) { $customerId = (string) ($lookup['customer_id'] ?? ''); $steps[] = $this->diagnosticStep( 'Customer lookup', 'success', 'Existing ResellerClub customer found: '.$customerId.'.' ); $attached = $this->attachCustomerToUser($user, $customerId, $email, false); if (! ($attached['success'] ?? false)) { $steps[] = $this->diagnosticStep( 'Attach customer', 'error', $attached['message'] ?? 'Could not attach the ResellerClub customer to this Ladill account.' ); return $this->diagnosticSummary($user, $steps, false); } $steps[] = $this->diagnosticStep('Attach customer', 'success', 'Existing ResellerClub customer attached to this Ladill account.'); $user->refresh(); } else { $steps[] = $this->diagnosticStep('Customer lookup', 'warning', 'No existing ResellerClub customer was found for this email.'); $created = $this->createCustomerForUser($user); if (! ($created['success'] ?? false)) { $steps[] = $this->diagnosticStep( 'Create customer', 'error', $created['message'] ?? 'Could not create the ResellerClub customer.' ); return $this->diagnosticSummary($user, $steps, false); } $customerId = (string) ($created['customer_id'] ?? ''); $steps[] = $this->diagnosticStep('Create customer', 'success', 'New ResellerClub customer created: '.$customerId.'.'); $attached = $this->attachCustomerToUser($user, $customerId, $email, false); if (! ($attached['success'] ?? false)) { $steps[] = $this->diagnosticStep( 'Attach customer', 'error', $attached['message'] ?? 'Could not attach the new ResellerClub customer to this Ladill account.' ); return $this->diagnosticSummary($user, $steps, false); } $steps[] = $this->diagnosticStep('Attach customer', 'success', 'New ResellerClub customer attached to this Ladill account.'); $user->refresh(); } } $token = $this->generateLoginToken($user, $ipAddress); if (! ($token['success'] ?? false)) { $steps[] = $this->diagnosticStep( 'Generate login token', 'error', $token['message'] ?? 'Could not create the ResellerClub login token.' ); return $this->diagnosticSummary($user, $steps, false); } $steps[] = $this->diagnosticStep('Generate login token', 'success', 'ResellerClub login token generated successfully.'); return $this->diagnosticSummary($user, $steps, true); } private function configuredApiUrl(): string { $value = trim((string) config('mailinfra.registrar_api_url')); return $value !== '' ? $value : self::DEFAULT_API_URL; } private function looksLikeMissingCustomerMessage(string $message): bool { $value = strtolower(trim($message)); return str_contains($value, 'not found') || str_contains($value, 'does not exist') || str_contains($value, 'no customer') || str_contains($value, 'unable to find') || str_contains($value, 'entity not found') || str_contains($value, 'no entity found'); } /** * @param array $data */ private function extractErrorMessage(array $data, string $fallbackBody = ''): string { foreach (['message', 'error', 'description', 'msg'] as $key) { $value = $this->sanitizeErrorText((string) ($data[$key] ?? '')); if ($value !== '') { return $value; } } return $this->sanitizeErrorText($fallbackBody); } private function extractCustomerIdFromBody(string $body): string { if ($body !== '' && preg_match('/^\d+$/', $body) === 1) { return $body; } $xml = $this->parseXml($body); if ($xml === null) { return ''; } $flattened = strtolower(preg_replace('/\s+/', ' ', strip_tags($body)) ?? ''); if (str_contains($flattened, 'error')) { return ''; } foreach (['customerid', 'customer-id', 'entityid', 'entity-id', 'id', 'description'] as $key) { $value = trim((string) ($xml[$key] ?? '')); if ($value !== '' && preg_match('/^\d+$/', $value) === 1) { return $value; } } if (preg_match('/\b(\d{3,})\b/', trim(strip_tags($body)), $matches) === 1) { return $matches[1]; } return ''; } private function extractXmlErrorMessage(string $body): string { $xml = $this->parseXml($body); if ($xml !== null) { foreach (['message', 'error', 'description', 'msg', 'status'] as $key) { $value = $this->sanitizeErrorText((string) ($xml[$key] ?? '')); if ($value !== '' && ! preg_match('/^\d+$/', $value)) { return $value; } } } return $this->sanitizeErrorText($body); } /** * @return array|null */ private function parseXml(string $body): ?array { $body = trim($body); if ($body === '' || ! str_starts_with($body, '<')) { return null; } $previous = libxml_use_internal_errors(true); try { $xml = simplexml_load_string($body, 'SimpleXMLElement', LIBXML_NOCDATA | LIBXML_NONET); if (! $xml instanceof \SimpleXMLElement) { return null; } $data = json_decode(json_encode($xml, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR); return is_array($data) ? $data : null; } catch (\Throwable) { return null; } finally { libxml_clear_errors(); libxml_use_internal_errors($previous); } } private function sanitizeErrorText(string $value): string { $value = trim($value); if ($value === '') { return ''; } if ($this->looksLikeHtmlDocument($value)) { return 'ResellerClub temporarily blocked or rejected the request. Please try again in a moment.'; } $plainText = trim(preg_replace('/\s+/', ' ', strip_tags($value)) ?? ''); return $plainText; } private function looksLikeHtmlDocument(string $value): bool { $normalized = strtolower(ltrim($value)); return str_starts_with($normalized, '') || str_contains($normalized, 'cloudflare'); } /** * @return array{label: string, status: string, message: string} */ private function diagnosticStep(string $label, string $status, string $message): array { return [ 'label' => $label, 'status' => $status, 'message' => $message, ]; } /** * @param array $steps * @return array{ * success: bool, * configured: bool, * api_url: string, * auth_userid_present: bool, * current_customer_id: string, * current_rc_email: string, * linked: bool, * steps: array * } */ private function diagnosticSummary(User $user, array $steps, bool $success): array { $user->refresh(); return [ 'success' => $success, 'configured' => $this->isConfigured(), 'api_url' => $this->apiUrl, 'auth_userid_present' => $this->authUserId !== '', 'current_customer_id' => (string) ($user->rc_customer_id ?? ''), 'current_rc_email' => (string) ($user->rc_email ?? ''), 'linked' => $user->hasLinkedResellerClubAccount(), 'steps' => $steps, ]; } }