Deploy Ladill Hosting / deploy (push) Failing after 17s
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>
1250 lines
43 KiB
PHP
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),
|
|
];
|
|
}
|
|
}
|