apiUrl = rtrim($this->configuredApiUrl(), '/'); $this->authUserId = (string) config('mailinfra.registrar_auth_userid'); $this->apiKey = (string) config('mailinfra.registrar_api_key'); $this->sellingCurrency = strtoupper(trim((string) config('mailinfra.registrar_selling_currency', 'GHS'))); } public function isConfigured(): bool { return $this->authUserId !== '' && $this->apiKey !== ''; } public function lastSyncError(): ?string { return $this->lastSyncError; } /** * Build a URL with all parameters as query string values, including auth. * Handles array values as repeated keys (e.g. addon=a&addon=b) as required by the RC API. * * @param array $params */ private function buildQueryUrl(string $endpoint, array $params = []): string { $all = array_merge([ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, ], $params); $segments = []; foreach ($all 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 $endpoint; } $separator = str_contains($endpoint, '?') ? '&' : '?'; return $endpoint.$separator.implode('&', $segments); } /** * @return array> */ public function definitions(): array { return config('mailinfra.resellerclub_products', []); } public function supportsCategory(string $category): bool { return isset($this->definitions()[$category]); } /** * @return array */ public function definition(string $category): array { return $this->definitions()[$category] ?? []; } public function packageLabel(string $category, string $planId, ?string $fallback = null): string { foreach ($this->packagesForCategory($category) as $package) { if ($package['value'] === $planId) { return $package['label']; } } return $fallback ?: 'Standard package'; } /** * @return array */ public function formDefinition(string $category): array { $definition = $this->definition($category); $packages = $this->packagesForCategory($category); return [ 'packages' => $packages, 'has_packages' => $packages !== [], 'region' => (string) ($definition['region'] ?? 'US'), 'requires_domain' => (bool) ($definition['requires_domain'] ?? true), 'supports_os' => (bool) ($definition['supports_os'] ?? false), 'os_options' => (array) ($definition['os_options'] ?? []), 'supports_addons' => (bool) ($definition['supports_addons'] ?? false), 'addon_options' => (array) ($definition['addon_options'] ?? []), 'supports_ssl_toggle' => (bool) ($definition['supports_ssl_toggle'] ?? false), 'supports_dedicated_ip' => (bool) ($definition['supports_dedicated_ip'] ?? false), ]; } /** * @return array|string> */ public function validationRules(string $category): array { $definition = $this->formDefinition($category); $rules = [ 'plan_id' => ['required', 'string', 'max:120'], 'months' => ['required', 'integer', 'min:1', 'max:120'], ]; if ($definition['requires_domain']) { $rules['domain_name'] = ['required', 'string', 'max:253']; } if ($definition['supports_os']) { $rules['os'] = ['nullable', 'string', 'max:120']; } if ($definition['supports_addons']) { $rules['addons'] = ['nullable', 'array']; $rules['addons.*'] = ['string', 'max:120']; } if ($definition['supports_ssl_toggle']) { $rules['enable_ssl'] = ['nullable', 'boolean']; } if ($definition['supports_dedicated_ip']) { $rules['add_dedicated_ip'] = ['nullable', 'boolean']; } return $rules; } /** * @param array $input * @return array{success: bool, order_id?: string, message?: string, raw?: array} */ public function order(string $category, string $customerId, array $input): array { $definition = $this->definition($category); if ($definition === []) { return ['success' => false, 'message' => 'This product is not available right now.']; } $payload = [ 'customer-id' => $customerId, 'months' => (int) ($input['months'] ?? 1), 'plan-id' => (string) ($input['plan_id'] ?? ''), 'auto-renew' => 'true', 'invoice-option' => 'NoInvoice', ]; if (($definition['requires_domain'] ?? true) === true) { $payload['domain-name'] = strtolower(trim((string) ($input['domain_name'] ?? ''))); } if (($definition['supports_os'] ?? false) && ! empty($input['os'])) { $payload['os'] = (string) $input['os']; } if (($definition['supports_addons'] ?? false) && ! empty($input['addons'])) { $payload['addon'] = array_values((array) $input['addons']); } if (($definition['supports_ssl_toggle'] ?? false)) { $payload['enable-ssl'] = ! empty($input['enable_ssl']) ? 'true' : 'false'; } if (($definition['supports_dedicated_ip'] ?? false) && ! empty($input['add_dedicated_ip'])) { $payload['add-dedicated-ip'] = 'true'; } $endpoint = $this->endpoint($definition, 'add'); try { $response = Http::timeout(30)->post($this->buildQueryUrl($endpoint, $payload)); $data = $response->json(); if ($response->failed()) { return [ 'success' => false, 'message' => $this->responseMessage($response, $data, 'The order could not be placed.'), ]; } return [ 'success' => true, 'order_id' => (string) ($data['entityid'] ?? $data['orderid'] ?? ''), 'raw' => is_array($data) ? $data : [], ]; } catch (\Throwable $e) { Log::error('ResellerClubProductService: order failed', [ 'category' => $category, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'The order failed due to a network error.']; } } /** * @return array{success: bool, amount_minor?: int, currency?: string, plan_name?: string, message?: string} */ public function quoteRenewal(string $category, string $planId, int $months): array { $definition = $this->definition($category); if ($definition === []) { return ['success' => false, 'message' => 'This product is not available for renewal.']; } $packages = $this->packagesForCategory($category); $package = collect($packages)->firstWhere('value', $planId) ?? $packages[0] ?? null; if (! is_array($package)) { return ['success' => false, 'message' => 'Could not determine renewal pricing for this plan.']; } $amountsMinor = (array) ($package['renew_amounts_minor'] ?? $package['amounts_minor'] ?? []); $amountMinor = (int) ($amountsMinor[(string) $months] ?? $amountsMinor['12'] ?? $amountsMinor['1'] ?? 0); if ($amountMinor <= 0) { return ['success' => false, 'message' => 'Renewal pricing is unavailable for the selected term.']; } return [ 'success' => true, 'amount_minor' => $amountMinor, 'currency' => $this->sellingCurrency, 'plan_name' => (string) ($package['label'] ?? 'Service renewal'), ]; } /** * @return array{success: bool, message?: string} */ public function renewOrder(string $category, string $rcOrderId, int $months): array { $definition = $this->definition($category); if ($definition === []) { return ['success' => false, 'message' => 'This product is not available for renewal.']; } try { $response = Http::timeout(30)->post($this->buildQueryUrl($this->endpoint($definition, 'renew'), [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'order-id' => $rcOrderId, 'months' => max(1, $months), 'invoice-option' => 'NoInvoice', ])); $data = $response->json(); if ($response->failed()) { return [ 'success' => false, 'message' => $this->responseMessage($response, $data, 'Renewal could not be completed.'), ]; } return ['success' => true]; } catch (\Throwable $e) { Log::error('ResellerClubProductService: renewOrder failed', [ 'category' => $category, 'order_id' => $rcOrderId, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Renewal failed due to a network error.']; } } /** * @return array{success: bool, order?: array, message?: string} */ public function getOrderDetails(string $category, string $orderId): array { $definition = $this->definition($category); if ($definition === []) { return ['success' => false, 'message' => 'Unsupported product category.']; } try { $response = Http::timeout(20)->get($this->endpoint($definition, 'details'), [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'order-id' => $orderId, ]); $data = $response->json(); if ($response->failed() || ! is_array($data)) { return [ 'success' => false, 'message' => $this->responseMessage($response, $data, 'Could not fetch the latest order details.'), ]; } return [ 'success' => true, 'order' => $this->normalizeOrder($category, $data), ]; } catch (\Throwable $e) { Log::error('ResellerClubProductService: details failed', [ 'category' => $category, 'order_id' => $orderId, 'error' => $e->getMessage(), ]); return ['success' => false, 'message' => 'Could not fetch the latest order details.']; } } /** * Sync supported non-hosting RC service orders for a specific customer. * * @return int Number of orders synced. */ public function syncCustomerOrders(string $customerId, int $userId): int { $this->lastSyncError = null; if (! $this->isConfigured()) { return 0; } $synced = 0; foreach (array_keys($this->definitions()) as $category) { if ($category === 'single-domain-hosting') { continue; } $page = 1; $categorySynced = 0; do { $result = $this->searchOrdersByCustomer($category, $customerId, $page, 50); if (! ($result['success'] ?? false) || empty($result['orders'])) { break; } foreach ((array) $result['orders'] as $order) { $rawOrder = is_array($order) ? $order : []; $orderId = (string) ($rawOrder['entityid'] ?? $rawOrder['orderid'] ?? $rawOrder['entity-id'] ?? ''); if ($orderId !== '') { $details = $this->getOrderDetails($category, $orderId); if (($details['success'] ?? false) && is_array($details['order'] ?? null)) { $rawOrder = (array) ($details['order']['meta'] ?? $details['order']); } } $this->syncOrderToLocal($category, $rawOrder, $userId, $customerId); $synced++; $categorySynced++; } $page++; } while ($categorySynced < ($result['total'] ?? 0)); } return $synced; } /** * @param array $rawOrder */ public function syncOrderToLocal( string $category, array $rawOrder, int $userId, string $customerId, ?RcServiceOrder $existingOrder = null ): RcServiceOrder { $normalized = $this->normalizeOrder($category, $rawOrder); if ($existingOrder) { $existingOrder->forceFill([ 'category' => $category, 'order_type' => RcServiceOrder::TYPE_SERVICE, 'domain_name' => $normalized['domain_name'], 'rc_order_id' => $normalized['rc_order_id'], 'rc_customer_id' => $customerId, 'plan_id' => $normalized['plan_id'], 'plan_name' => $normalized['plan_name'], 'status' => $normalized['status'], 'server_location' => $normalized['server_location'], 'ip_address' => $normalized['ip_address'], 'access_url' => $normalized['access_url'], 'access_username' => $normalized['access_username'], 'expires_at' => $normalized['expires_at'], 'provisioned_at' => $normalized['provisioned_at'], 'fulfillment_status' => $normalized['status'] === RcServiceOrder::STATUS_ACTIVE ? RcServiceOrder::FULFILLMENT_FULFILLED : $existingOrder->fulfillment_status, 'last_synced_at' => now(), 'meta' => $normalized['meta'], ])->save(); $order = $existingOrder->fresh(); $this->syncPurchasedDomainRegistry($category, $order, $normalized['status']); return $order; } $order = RcServiceOrder::query()->updateOrCreate( [ 'user_id' => $userId, 'category' => $category, 'rc_order_id' => $normalized['rc_order_id'], ], [ 'order_type' => RcServiceOrder::TYPE_SERVICE, 'domain_name' => $normalized['domain_name'], 'rc_customer_id' => $customerId, 'plan_id' => $normalized['plan_id'], 'plan_name' => $normalized['plan_name'], 'status' => $normalized['status'], 'server_location' => $normalized['server_location'], 'ip_address' => $normalized['ip_address'], 'access_url' => $normalized['access_url'], 'access_username' => $normalized['access_username'], 'expires_at' => $normalized['expires_at'], 'provisioned_at' => $normalized['provisioned_at'], 'payment_status' => RcServiceOrder::PAYMENT_STATUS_PAID, 'fulfillment_status' => $normalized['status'] === RcServiceOrder::STATUS_ACTIVE ? RcServiceOrder::FULFILLMENT_FULFILLED : RcServiceOrder::FULFILLMENT_AWAITING_VENDOR, 'currency' => $this->sellingCurrency, 'submitted_at' => now(), 'last_synced_at' => now(), 'meta' => $normalized['meta'], ] ); $this->syncPurchasedDomainRegistry($category, $order, $normalized['status']); return $order; } private function syncPurchasedDomainRegistry(string $category, RcServiceOrder $order, string $normalizedStatus): void { if (! in_array($category, ['domain-registration', 'domain-transfer'], true)) { return; } if (! \App\Support\ResellerClubLegacy::integrationEnabled()) { return; } app(DomainRegistryService::class)->recordFromRcServiceOrder( $order, verified: $normalizedStatus === RcServiceOrder::STATUS_ACTIVE, ); } /** * @param array $rawOrder * @return array */ private function normalizeOrder(string $category, array $rawOrder): array { $domainName = (string) ($rawOrder['domain_name'] ?? $rawOrder['domainname'] ?? $rawOrder['domain-name'] ?? $rawOrder['domain'] ?? $rawOrder['hostname'] ?? $rawOrder['description'] ?? $category); $status = (string) ($rawOrder['currentstatus'] ?? $rawOrder['orderstatus'] ?? $rawOrder['status'] ?? 'Pending'); $planId = (string) ($rawOrder['plan_id'] ?? $rawOrder['planid'] ?? $rawOrder['plan-id'] ?? $rawOrder['productkey'] ?? ''); $planName = (string) ($rawOrder['plan_name'] ?? $rawOrder['productkey'] ?? $rawOrder['planname'] ?? $rawOrder['description'] ?? $planId); $serverLocation = (string) ($rawOrder['location'] ?? Arr::get($rawOrder, 'meta.location', '') ?? ''); return [ 'rc_order_id' => (string) ($rawOrder['rc_order_id'] ?? $rawOrder['entityid'] ?? $rawOrder['orderid'] ?? $rawOrder['entity-id'] ?? ''), 'domain_name' => $domainName !== '' ? $domainName : $category, 'plan_id' => $planId, 'plan_name' => $this->packageLabel($category, $planId, $planName), 'status' => $this->normalizeStatus($status), 'server_location' => (string) ($rawOrder['server_location'] ?? $serverLocation), 'ip_address' => (string) ($rawOrder['ip_address'] ?? $rawOrder['ipaddress'] ?? $rawOrder['ipAddress'] ?? ''), 'access_url' => (string) ($rawOrder['access_url'] ?? $rawOrder['cpanelurl'] ?? $rawOrder['controlpanelurl'] ?? $rawOrder['url'] ?? ''), 'access_username' => (string) ($rawOrder['access_username'] ?? $rawOrder['cpanelusername'] ?? $rawOrder['siteadminusername'] ?? $rawOrder['username'] ?? ''), 'expires_at' => $this->parseTimestamp($rawOrder['expires_at'] ?? $rawOrder['endtime'] ?? $rawOrder['expirytime'] ?? $rawOrder['expiry-date'] ?? null), 'provisioned_at' => $this->parseTimestamp($rawOrder['provisioned_at'] ?? $rawOrder['creationtime'] ?? $rawOrder['creation-date'] ?? null), 'meta' => $rawOrder, ]; } /** * @param array $definition */ private function endpoint(array $definition, string $action): string { return sprintf('%s/%s/%s.json', $this->apiUrl, trim((string) $definition['endpoint'], '/'), $action); } /** * @return array{success: bool, orders?: array>, total?: int, message?: string} */ private function searchOrdersByCustomer(string $category, string $customerId, int $page = 1, int $perPage = 50): array { $this->lastSyncError = null; $definition = $this->definition($category); if ($definition === []) { $message = 'Unsupported product category.'; $this->lastSyncError = $message; return ['success' => false, 'message' => $message]; } try { $response = Http::timeout(20)->get($this->endpoint($definition, 'search'), [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, 'customer-id' => $customerId, 'no-of-records' => $perPage, 'page-no' => $page, ]); $data = $response->json(); if ($response->failed() || ! is_array($data)) { $message = $this->responseMessage($response, $data, 'Could not fetch service orders.'); $this->lastSyncError = $message; return [ 'success' => false, 'message' => $message, ]; } $countOnPage = (int) ($data['recsonpage'] ?? 0); $recordCount = (int) ($data['recsindb'] ?? 0); $orders = []; for ($i = 1; $i <= $countOnPage; $i++) { if (isset($data[(string) $i]) && is_array($data[(string) $i])) { $orders[] = $data[(string) $i]; } } return [ 'success' => true, 'orders' => $orders, 'total' => $recordCount, ]; } catch (\Throwable $e) { Log::error('ResellerClubProductService: customer search failed', [ 'category' => $category, 'customer_id' => $customerId, 'error' => $e->getMessage(), ]); $message = $this->syncErrorMessage($e, 'Could not fetch service orders.'); $this->lastSyncError = $message; return ['success' => false, 'message' => $message]; } } private function configuredApiUrl(): string { $value = trim((string) config('mailinfra.registrar_api_url')); return $value !== '' ? $value : self::DEFAULT_API_URL; } /** * @param mixed $data */ private function responseMessage(Response $response, mixed $data, string $fallback): string { if ($this->looksLikeHtmlError($response)) { return 'The upstream provisioning service temporarily blocked or rejected the request. Please try again in a moment.'; } if (is_array($data)) { return (string) ($data['message'] ?? $data['error'] ?? $fallback); } return $fallback; } private function looksLikeHtmlError(Response $response): bool { $contentType = strtolower((string) $response->header('Content-Type')); $body = trim((string) $response->body()); return str_contains($contentType, 'text/html') || str_starts_with(strtolower($body), ' RcServiceOrder::STATUS_ACTIVE, 'inactive', 'suspended' => RcServiceOrder::STATUS_SUSPENDED, 'deleted', 'cancelled' => RcServiceOrder::STATUS_CANCELLED, 'expired' => RcServiceOrder::STATUS_EXPIRED, 'failed' => RcServiceOrder::STATUS_FAILED, default => RcServiceOrder::STATUS_PENDING, }; } private function parseTimestamp(mixed $value): ?Carbon { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return Carbon::createFromTimestamp((int) $value); } try { return Carbon::parse((string) $value); } catch (\Throwable) { return null; } } private function syncErrorMessage(\Throwable $e, string $fallback): string { $message = $e->getMessage(); if (str_contains($message, 'Could not resolve host')) { return 'Could not reach the ResellerClub API host. Check server DNS/network access to httpapi.com.'; } return $fallback; } /** * @return array> */ private function packagesForCategory(string $category): array { if (! $this->isConfigured()) { return []; } $definition = $this->definition($category); if ($definition === []) { return []; } $cacheKey = 'rc-catalog-packages:'.$category.':'.md5(json_encode([ 'catalog_keys' => $definition['catalog_keys'] ?? [], 'selling_currency' => $this->sellingCurrency, ])); return Cache::remember( $cacheKey, now()->addMinutes($this->catalogCacheTtlMinutes()), function () use ($category, $definition): array { $catalogKeys = $this->catalogLookupKeys($definition); $planDetailsEntry = $this->catalogEntry($this->fetchPlanDetailsCatalog(), $catalogKeys); $pricingEntry = $this->catalogEntry($this->fetchCustomerPricingCatalog(), $catalogKeys); return $this->buildPackages($category, $planDetailsEntry, $pricingEntry); } ); } /** * @return array */ private function fetchPlanDetailsCatalog(): array { return Cache::remember( 'rc-catalog-plan-details', now()->addMinutes($this->catalogCacheTtlMinutes()), function (): array { try { $response = Http::timeout(30)->get($this->apiUrl.'/products/plan-details.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, ]); $data = $response->json(); return $response->successful() && is_array($data) ? $data : []; } catch (\Throwable $e) { Log::warning('ResellerClubProductService: plan-details catalog fetch failed', [ 'error' => $e->getMessage(), ]); return []; } } ); } /** * @return array */ private function fetchCustomerPricingCatalog(): array { return Cache::remember( 'rc-catalog-customer-pricing', now()->addMinutes($this->catalogCacheTtlMinutes()), function (): array { try { $response = Http::timeout(30)->get($this->apiUrl.'/products/customer-price.json', [ 'auth-userid' => $this->authUserId, 'api-key' => $this->apiKey, ]); $data = $response->json(); return $response->successful() && is_array($data) ? $data : []; } catch (\Throwable $e) { Log::warning('ResellerClubProductService: customer-price catalog fetch failed', [ 'error' => $e->getMessage(), ]); return []; } } ); } /** * @param array $catalog * @param array $keys * @return array */ private function catalogEntry(array $catalog, array $keys): array { foreach ($keys as $key) { $entry = $catalog[$key] ?? null; if (is_array($entry)) { return $entry; } } return []; } /** * @param array $definition * @return array */ private function catalogLookupKeys(array $definition): array { $keys = []; foreach ((array) ($definition['catalog_keys'] ?? []) as $key) { $normalized = strtolower(trim((string) $key)); if ($normalized !== '') { $keys[] = $normalized; } } $endpoint = trim((string) ($definition['endpoint'] ?? ''), '/'); if ($endpoint !== '') { $keys[] = strtolower(str_replace('/', '', $endpoint)); $firstSegment = strtolower((string) strtok($endpoint, '/')); if ($firstSegment !== '') { $keys[] = $firstSegment; } } return array_values(array_unique($keys)); } /** * @param array $planDetailsEntry * @param array $pricingEntry * @return array> */ private function buildPackages(string $category, array $planDetailsEntry, array $pricingEntry): array { $packages = []; $planDetails = $this->normalizePlanDetailsEntry($planDetailsEntry); $pricingPlans = $this->normalizePricingEntry($pricingEntry); foreach ($planDetails as $planId => $detail) { $planId = (string) $planId; if ($planId === '' || ! is_array($detail)) { continue; } $priceValues = $this->extractPlanPriceValues($pricingPlans[$planId] ?? [], 'add'); $renewPriceValues = $this->extractPlanPriceValues($pricingPlans[$planId] ?? [], 'renew'); $formatted = $this->formatPlanPrices($priceValues); $renewFormatted = $this->formatPlanPrices($renewPriceValues); $package = [ 'value' => $planId, 'label' => trim((string) ($detail['name'] ?? $detail['planname'] ?? $detail['plan_name'] ?? 'Plan '.$planId)), 'prices' => $formatted['monthly'], 'totals' => $formatted['totals'], 'renew_prices' => $renewFormatted['monthly'], 'renew_totals' => $renewFormatted['totals'], 'amounts_minor' => $this->amountsMinor($priceValues), 'renew_amounts_minor' => $this->amountsMinor($renewPriceValues), 'currency' => $this->sellingCurrency, 'starting_term' => $this->startingTerm($formatted), 'starting_price' => $this->startingPrice($formatted), 'starting_amount' => $this->startingAmount($priceValues), 'summary' => null, ]; $package['summary'] = $this->packageSummary($category, $package['label'], $detail); $package['starting_label'] = $package['starting_term'] !== null && $package['starting_price'] !== null ? sprintf('%s/mo', $package['starting_price']) : null; $packages[] = $package; } if ($packages === []) { foreach ($pricingPlans as $planId => $pricingDetail) { $planId = (string) $planId; $priceValues = $this->extractPlanPriceValues(is_array($pricingDetail) ? $pricingDetail : [], 'add'); $renewPriceValues = $this->extractPlanPriceValues(is_array($pricingDetail) ? $pricingDetail : [], 'renew'); $formatted = $this->formatPlanPrices($priceValues); $renewFormatted = $this->formatPlanPrices($renewPriceValues); if (($formatted['monthly'] ?? []) === []) { continue; } $packages[] = [ 'value' => $planId, 'label' => 'Plan '.$planId, 'prices' => $formatted['monthly'], 'totals' => $formatted['totals'], 'renew_prices' => $renewFormatted['monthly'], 'renew_totals' => $renewFormatted['totals'], 'amounts_minor' => $this->amountsMinor($priceValues), 'renew_amounts_minor' => $this->amountsMinor($renewPriceValues), 'currency' => $this->sellingCurrency, 'starting_term' => $this->startingTerm($formatted), 'starting_price' => $this->startingPrice($formatted), 'starting_amount' => $this->startingAmount($priceValues), 'starting_label' => $this->startingTerm($formatted) !== null && $this->startingPrice($formatted) !== null ? sprintf('%s/mo', $this->startingPrice($formatted)) : null, 'summary' => null, ]; } } return collect($packages) ->filter(fn (array $package) => ($package['prices'] ?? []) !== []) ->sortBy(fn (array $package) => [$package['starting_amount'] ?? INF, $package['label']]) ->values() ->all(); } /** * @param array $detail */ private function packageSummary(string $category, string $label, array $detail): ?string { $normalizedLabel = strtolower(trim($label)); return match ($category) { 'multi-domain-hosting' => match ($normalizedLabel) { 'business' => 'For up to 3 domains', 'pro' => 'For unlimited domains', default => $this->planSummary($detail), }, default => $this->planSummary($detail), }; } /** * @param array $entry * @return array> */ private function normalizePlanDetailsEntry(array $entry): array { if (isset($entry['plans']) && is_array($entry['plans'])) { return array_filter($entry['plans'], 'is_array'); } return array_filter($entry, 'is_array'); } /** * @param array $entry * @return array> */ private function normalizePricingEntry(array $entry): array { if (isset($entry['plans']) && is_array($entry['plans'])) { return array_filter($entry['plans'], 'is_array'); } return array_filter($entry, 'is_array'); } /** * @param array $planPricing * @return array */ private function extractPlanPriceValues(array $planPricing, string $action = 'add'): array { $directCandidates = [ $this->normalizeTermPriceMap((array) ($planPricing[$action] ?? [])), $this->normalizeTermPriceMap((array) Arr::get($planPricing, 'pricing.'.$action, [])), $this->normalizeTermPriceMap((array) Arr::get($planPricing, 'prices.'.$action, [])), ]; $directMatch = $this->bestTermPriceMap($directCandidates); if ($directMatch !== []) { return $directMatch; } $candidates = []; $this->collectActionTermPriceMaps($planPricing, $action, $candidates); $actionMatch = $this->bestTermPriceMap($candidates); if ($actionMatch !== []) { return $actionMatch; } if ($action !== 'add') { return []; } $candidates = []; $this->collectTermPriceMaps($planPricing, $candidates); return $this->bestTermPriceMap($candidates); } /** * @param array $candidate * @return array */ private function normalizeTermPriceMap(array $candidate): array { $prices = []; foreach ($candidate as $term => $price) { if (! is_numeric($term) || ! is_numeric($price)) { continue; } $prices[(string) $term] = round((float) $price, 2); } ksort($prices, SORT_NATURAL); return $prices; } /** * @param array $payload * @param array> $candidates */ private function collectTermPriceMaps(array $payload, array &$candidates): void { $normalized = $this->normalizeTermPriceMap($payload); if ($normalized !== []) { $candidates[] = $normalized; } foreach ($payload as $value) { if (is_array($value)) { $this->collectTermPriceMaps($value, $candidates); } } } /** * @param array $payload * @param array> $candidates */ private function collectActionTermPriceMaps(array $payload, string $action, array &$candidates): void { $candidate = $payload[$action] ?? null; if (is_array($candidate)) { $normalized = $this->normalizeTermPriceMap($candidate); if ($normalized !== []) { $candidates[] = $normalized; } } foreach ($payload as $value) { if (is_array($value)) { $this->collectActionTermPriceMaps($value, $action, $candidates); } } } /** * @param array> $candidates * @return array */ private function bestTermPriceMap(array $candidates): array { $best = []; $bestScore = -1; foreach ($candidates as $candidate) { if ($candidate === []) { continue; } $terms = array_map(static fn (string $term): int => (int) $term, array_keys($candidate)); $score = (count($candidate) * 1000) + max($terms); if ($score > $bestScore) { $best = $candidate; $bestScore = $score; } } return $best; } /** * RC telescopic product prices are per-month frontfacing prices for each term. * * @param array $priceValues * @return array{monthly: array, totals: array} */ private function formatPlanPrices(array $priceValues): array { $monthly = []; $totals = []; foreach ($priceValues as $term => $value) { $months = max(1, (int) $term); $monthly[(string) $term] = $this->formatDisplayPrice((float) $value); $totals[(string) $term] = $this->formatDisplayPrice(round((float) $value * $months, 2)); } return ['monthly' => $monthly, 'totals' => $totals]; } /** * @param array{monthly: array, totals: array} $formatted */ private function startingTerm(array $formatted): ?string { $terms = array_keys($formatted['monthly'] ?? []); return $terms[0] ?? null; } /** * @param array{monthly: array, totals: array} $formatted */ private function startingPrice(array $formatted): ?string { $term = $this->startingTerm($formatted); return $term !== null ? ($formatted['monthly'][$term] ?? null) : null; } /** * @param array $priceValues */ private function startingAmount(array $priceValues): ?float { $term = array_key_first($priceValues); if ($term === null) { return null; } return round((float) ($priceValues[$term] ?? 0), 2); } /** * @param array $priceValues * @return array */ private function amountsMinor(array $priceValues): array { $amounts = []; foreach ($priceValues as $term => $value) { $months = max(1, (int) $term); $amounts[(string) $term] = (int) round($value * $months * 100); } return $amounts; } /** * @param array $detail */ private function planSummary(array $detail): ?string { $parts = []; foreach (['diskspace', 'disk_space', 'storage', 'bandwidth', 'ram', 'memory', 'vcpus', 'emails'] as $field) { $value = $this->stringifySummaryField($field, $detail[$field] ?? null); if ($value === null) { continue; } $parts[] = ucfirst(str_replace('_', ' ', $field)).': '.$value; } if ($parts === []) { $description = $this->stringifyPlanField($detail['description'] ?? null); if ($description !== null) { return $description; } } return $parts !== [] ? implode(' • ', $parts) : null; } private function stringifySummaryField(string $field, mixed $value): ?string { $normalized = $this->stringifyPlanField($value); if ($normalized === null) { return null; } if (in_array($field, ['diskspace', 'disk_space', 'storage', 'bandwidth', 'emails'], true) && in_array($normalized, ['-1', '-1.0'], true)) { return 'Unlimited'; } return $normalized; } private function stringifyPlanField(mixed $value): ?string { if ($value === null) { return null; } if (is_string($value)) { $normalized = trim($value); return $normalized !== '' ? $normalized : null; } if (is_numeric($value) || is_bool($value)) { return (string) $value; } if (! is_array($value)) { return null; } $parts = []; foreach ($value as $item) { $normalized = $this->stringifyPlanField($item); if ($normalized !== null) { $parts[] = $normalized; } } $parts = array_values(array_unique($parts)); return $parts !== [] ? implode(', ', $parts) : null; } private function catalogCacheTtlMinutes(): int { return max(1, (int) config('mailinfra.resellerclub_catalog_cache_ttl_minutes', 30)); } private function formatDisplayPrice(float $amount): string { return $this->sellingCurrency.' '.number_format($amount, 2, '.', ','); } /** * @return array{success: bool, amount_minor?: int, currency?: string, plan_name?: string, message?: string} */ public function quoteForSelection(string $category, string $planId, int $months): array { $package = collect($this->packagesForCategory($category)) ->first(fn (array $item) => (string) ($item['value'] ?? '') === $planId); if (! $package) { return ['success' => false, 'message' => 'The selected package is no longer available.']; } $term = (string) max(1, $months); $amountMinor = (int) (($package['amounts_minor'][$term] ?? 0)); if ($amountMinor <= 0) { return ['success' => false, 'message' => 'The selected billing term is no longer available.']; } return [ 'success' => true, 'amount_minor' => $amountMinor, 'currency' => (string) ($package['currency'] ?? $this->sellingCurrency), 'plan_name' => (string) ($package['label'] ?? $planId), ]; } }