Files
ladill-hosting/app/Services/ResellerClub/ResellerClubProductService.php
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

1250 lines
43 KiB
PHP

<?php
namespace App\Services\ResellerClub;
use App\Models\RcServiceOrder;
use App\Services\Domain\DomainRegistryService;
use Carbon\Carbon;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ResellerClubProductService
{
private const DEFAULT_API_URL = 'https://httpapi.com/api';
private ?string $lastSyncError = null;
private string $apiUrl;
private string $authUserId;
private string $apiKey;
private string $sellingCurrency;
public function __construct()
{
$this->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<string, mixed> $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<string, array<string, mixed>>
*/
public function definitions(): array
{
return config('mailinfra.resellerclub_products', []);
}
public function supportsCategory(string $category): bool
{
return isset($this->definitions()[$category]);
}
/**
* @return array<string, mixed>
*/
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<string, mixed>
*/
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, array<int, string>|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<string, mixed> $input
* @return array{success: bool, order_id?: string, message?: string, raw?: array<string, mixed>}
*/
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<string, mixed>, 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<string, mixed> $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<string, mixed> $rawOrder
* @return array<string, mixed>
*/
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<string, mixed> $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<int, array<string, mixed>>, 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), '<!doctype html')
|| str_starts_with(strtolower($body), '<html');
}
private function normalizeStatus(string $status): string
{
return match (strtolower(trim($status))) {
'active' => 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<int, array<string, mixed>>
*/
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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed> $catalog
* @param array<int, string> $keys
* @return array<string, mixed>
*/
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<string, mixed> $definition
* @return array<int, string>
*/
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<string, mixed> $planDetailsEntry
* @param array<string, mixed> $pricingEntry
* @return array<int, array<string, mixed>>
*/
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<string, mixed> $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<string, mixed> $entry
* @return array<string, array<string, mixed>>
*/
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<string, mixed> $entry
* @return array<string, array<string, mixed>>
*/
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<string, mixed> $planPricing
* @return array<string, float>
*/
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<string, mixed> $candidate
* @return array<string, float>
*/
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<string, mixed> $payload
* @param array<int, array<string, float>> $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<string, mixed> $payload
* @param array<int, array<string, float>> $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<int, array<string, float>> $candidates
* @return array<string, float>
*/
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<string, float> $priceValues
* @return array{monthly: array<string, string>, totals: array<string, string>}
*/
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<string, string>, totals: array<string, string>} $formatted
*/
private function startingTerm(array $formatted): ?string
{
$terms = array_keys($formatted['monthly'] ?? []);
return $terms[0] ?? null;
}
/**
* @param array{monthly: array<string, string>, totals: array<string, string>} $formatted
*/
private function startingPrice(array $formatted): ?string
{
$term = $this->startingTerm($formatted);
return $term !== null ? ($formatted['monthly'][$term] ?? null) : null;
}
/**
* @param array<string, float> $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<string, float> $priceValues
* @return array<string, int>
*/
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<string, mixed> $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),
];
}
}