Files
ladill-servers/app/Services/ResellerClub/ResellerClubCustomerService.php
isaaccladandCursor b6c8ac343f Extract Ladill Servers as standalone app at servers.ladill.com.
VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 19:18:30 +00:00

656 lines
23 KiB
PHP

<?php
namespace App\Services\ResellerClub;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ResellerClubCustomerService
{
private const DEFAULT_API_URL = 'https://httpapi.com/api';
private string $apiUrl;
private string $authUserId;
private string $apiKey;
public function __construct()
{
$this->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<string, mixed> $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<int, array{label: string, status: string, message: string}>
* }
*/
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<string, mixed> $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<string, mixed>|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, '<!doctype html')
|| str_starts_with($normalized, '<html')
|| str_contains($normalized, '<body')
|| str_contains($normalized, '<head')
|| str_contains($normalized, '<title>')
|| 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<int, array{label: string, status: string, message: string}> $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<int, array{label: string, status: string, message: string}>
* }
*/
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,
];
}
}