Initial Ladill Hosting app with Gitea deploy pipeline.
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>
This commit is contained in:
isaacclad
2026-06-06 16:24:20 +00:00
co-authored by Cursor
commit e251a4cf60
367 changed files with 66268 additions and 0 deletions
@@ -0,0 +1,587 @@
<?php
namespace App\Services\ResellerClub;
use App\Models\Domain;
use App\Models\PromoBanner;
use App\Models\RcServiceOrder;
use App\Models\User;
use App\Services\Domain\DomainRegistrarService;
use App\Support\ResellerClubLegacy;
use Carbon\Carbon;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Crypt;
use RuntimeException;
class RcOrderCartService
{
/**
* @return array<int, string>
*/
public function cartStatuses(): array
{
return [
RcServiceOrder::STATUS_CART,
RcServiceOrder::STATUS_PENDING_PAYMENT,
];
}
public function cartItemsForUser(User $user): EloquentCollection
{
return RcServiceOrder::query()
->where('user_id', $user->id)
->whereIn('status', $this->cartStatuses())
->latest()
->get();
}
public function cartCountForUser(User $user): int
{
return RcServiceOrder::query()
->where('user_id', $user->id)
->whereIn('status', $this->cartStatuses())
->count();
}
/**
* @return array{items: EloquentCollection<int, RcServiceOrder>, count: int, reference: string|null}|null
*/
public function pendingCheckoutForUser(User $user): ?array
{
$items = RcServiceOrder::query()
->where('user_id', $user->id)
->where('status', RcServiceOrder::STATUS_PENDING_PAYMENT)
->latest()
->get();
if ($items->isEmpty()) {
return null;
}
return [
'items' => $items,
'count' => $items->count(),
'reference' => $items->pluck('payment_reference')->filter()->first(),
];
}
public function recentOrdersForUser(User $user, int $limit = 10): EloquentCollection
{
return RcServiceOrder::query()
->where('user_id', $user->id)
->whereNotIn('status', $this->cartStatuses())
->latest()
->limit($limit)
->get();
}
public function addProductOrder(User $user, string $category, array $input, ResellerClubProductService $products): RcServiceOrder
{
ResellerClubLegacy::assertNewSaleAllowed();
$quote = $products->quoteForSelection(
$category,
(string) ($input['plan_id'] ?? ''),
(int) ($input['months'] ?? 0)
);
if (! ($quote['success'] ?? false)) {
throw new RuntimeException($quote['message'] ?? 'This product cannot be added to the cart right now.');
}
$currency = strtoupper((string) ($quote['currency'] ?? 'GHS'));
$this->assertCurrencyCompatibility($user, $currency);
$domainName = strtolower(trim((string) ($input['domain_name'] ?? $category)));
return $this->upsertCartItem(
$user,
[
'category' => $category,
'order_type' => RcServiceOrder::TYPE_SERVICE,
'domain_name' => $domainName,
'plan_id' => (string) ($input['plan_id'] ?? ''),
'plan_name' => (string) ($quote['plan_name'] ?? $products->packageLabel($category, (string) ($input['plan_id'] ?? ''))),
'amount_minor' => (int) ($quote['amount_minor'] ?? 0),
'currency' => $currency,
'term_months' => (int) ($input['months'] ?? 1),
'term_years' => null,
'meta' => [
'order_input' => $input,
'quote' => $quote,
],
]
);
}
public function addDomainRegistration(User $user, string $domain, DomainRegistrarService $registrar): RcServiceOrder
{
ResellerClubLegacy::assertNewSaleAllowed();
$domainName = strtolower(trim($domain));
if (Domain::query()->where('host', $domainName)->exists()) {
throw new RuntimeException('This domain is already registered in our system.');
}
$quote = $registrar->quoteDomainRegistration($domainName);
if (! ($quote['success'] ?? false)) {
throw new RuntimeException($quote['message'] ?? 'Domain pricing is unavailable right now.');
}
$currency = strtoupper((string) ($quote['currency'] ?? 'GHS'));
$this->assertCurrencyCompatibility($user, $currency);
return $this->upsertCartItem(
$user,
[
'category' => 'domain-registration',
'order_type' => RcServiceOrder::TYPE_DOMAIN_REGISTRATION,
'domain_name' => $domainName,
'plan_id' => null,
'plan_name' => '1 year registration',
'amount_minor' => (int) ($quote['amount_minor'] ?? 0),
'currency' => $currency,
'term_months' => null,
'term_years' => 1,
'meta' => [
'quote' => $quote,
],
]
);
}
public function addDomainTransfer(User $user, string $domain, ?string $authCode, DomainRegistrarService $registrar): RcServiceOrder
{
ResellerClubLegacy::assertNewSaleAllowed();
$domainName = strtolower(trim($domain));
$quote = $registrar->quoteDomainTransfer($domainName);
if (! ($quote['success'] ?? false)) {
throw new RuntimeException($quote['message'] ?? 'This domain cannot be transferred right now.');
}
$currency = strtoupper((string) ($quote['currency'] ?? 'GHS'));
$this->assertCurrencyCompatibility($user, $currency);
$meta = [
'quote' => $quote,
];
$authCode = trim((string) $authCode);
if ($authCode !== '') {
$meta['transfer_auth_code_encrypted'] = Crypt::encryptString($authCode);
}
return $this->upsertCartItem(
$user,
[
'category' => 'domain-transfer',
'order_type' => RcServiceOrder::TYPE_DOMAIN_TRANSFER,
'domain_name' => $domainName,
'plan_id' => null,
'plan_name' => 'Domain transfer',
'amount_minor' => (int) ($quote['amount_minor'] ?? 0),
'currency' => $currency,
'term_months' => null,
'term_years' => 1,
'meta' => $meta,
]
);
}
public function addServiceRenewal(
User $user,
RcServiceOrder $existingOrder,
int $months,
ResellerClubProductService $products
): RcServiceOrder {
ResellerClubLegacy::assertRenewalsAllowed();
if ((int) $existingOrder->user_id !== (int) $user->id) {
throw new RuntimeException('You can only renew your own services.');
}
if (! filled($existingOrder->rc_order_id)) {
throw new RuntimeException('This service cannot be renewed online yet. Please contact support.');
}
if (! $products->supportsCategory($existingOrder->category)) {
throw new RuntimeException('Renewals are not available for this product type.');
}
$months = max(1, min(120, $months));
$planId = (string) ($existingOrder->plan_id ?? '');
$quote = $products->quoteRenewal($existingOrder->category, $planId, $months);
if (! ($quote['success'] ?? false)) {
throw new RuntimeException($quote['message'] ?? 'Renewal pricing is unavailable right now.');
}
$currency = strtoupper((string) ($quote['currency'] ?? 'GHS'));
$this->assertCurrencyCompatibility($user, $currency);
return $this->upsertCartItem(
$user,
[
'category' => $existingOrder->category,
'order_type' => RcServiceOrder::TYPE_RENEWAL,
'domain_name' => $existingOrder->domain_name,
'plan_id' => $planId !== '' ? $planId : null,
'plan_name' => (string) ($quote['plan_name'] ?? $existingOrder->plan_name ?? 'Service renewal'),
'amount_minor' => (int) ($quote['amount_minor'] ?? 0),
'currency' => $currency,
'term_months' => $months,
'term_years' => null,
'meta' => [
'is_renewal' => true,
'renew_rc_order_id' => (string) $existingOrder->rc_order_id,
'renew_source_order_id' => $existingOrder->id,
'quote' => $quote,
],
]
);
}
public function addDomainRenewal(
User $user,
string $domain,
int $years,
DomainRegistrarService $registrar
): RcServiceOrder {
ResellerClubLegacy::assertRenewalsAllowed();
$domainName = strtolower(trim($domain));
$years = max(1, min(10, $years));
$quote = $registrar->quoteDomainRenewal($domainName, $years);
if (! ($quote['success'] ?? false)) {
throw new RuntimeException($quote['message'] ?? 'Domain renewal pricing is unavailable right now.');
}
$currency = strtoupper((string) ($quote['currency'] ?? 'GHS'));
$this->assertCurrencyCompatibility($user, $currency);
return $this->upsertCartItem(
$user,
[
'category' => 'domain-renewal',
'order_type' => RcServiceOrder::TYPE_RENEWAL,
'domain_name' => $domainName,
'plan_id' => null,
'plan_name' => $years === 1 ? '1 year renewal' : "{$years} year renewal",
'amount_minor' => (int) ($quote['amount_minor'] ?? 0),
'currency' => $currency,
'term_months' => null,
'term_years' => $years,
'meta' => [
'is_renewal' => true,
'renew_rc_order_id' => (string) ($quote['rc_order_id'] ?? ''),
'quote' => $quote,
],
]
);
}
/**
* @return array{items: EloquentCollection<int, RcServiceOrder>, total_minor: int, currency: string, subtotal_minor: int, discount_minor: int, applied_promos: array, promo_name: string|null}
*/
public function checkoutSummaryForUser(User $user): array
{
$items = $this->cartItemsForUser($user);
ResellerClubLegacy::assertCartCheckoutAllowed($items);
$currency = strtoupper((string) $items->first()->currency);
if ($items->contains(fn (RcServiceOrder $item) => strtoupper((string) $item->currency) !== $currency)) {
throw new RuntimeException('Your cart contains items in more than one currency. Clear it and try again with a single currency.');
}
$subtotalMinor = (int) $items->sum('amount_minor');
// Get all active promos with discounts
$activePromos = PromoBanner::active()
->whereNotNull('discount_percent')
->where('discount_percent', '>', 0)
->get();
// Calculate per-item discounts based on product-specific promos
$appliedPromos = [];
$totalDiscountMinor = 0;
foreach ($items as $item) {
$itemPromo = $this->findMatchingPromoForItem($item, $activePromos);
if ($itemPromo) {
$discountPercent = (int) $itemPromo->discount_percent;
$itemDiscount = (int) round($item->amount_minor * ($discountPercent / 100));
$totalDiscountMinor += $itemDiscount;
// Track applied promos for display
$promoKey = $itemPromo->id;
if (!isset($appliedPromos[$promoKey])) {
$appliedPromos[$promoKey] = [
'id' => $itemPromo->id,
'name' => $itemPromo->name,
'title' => $itemPromo->title ?: $itemPromo->name,
'product_type' => $itemPromo->product_type,
'discount_percent' => $discountPercent,
'discount_minor' => 0,
'items' => [],
];
}
$appliedPromos[$promoKey]['discount_minor'] += $itemDiscount;
$appliedPromos[$promoKey]['items'][] = [
'id' => $item->id,
'domain_name' => $item->domain_name,
'category' => $item->category,
'original_amount' => $item->amount_minor,
'discount_amount' => $itemDiscount,
];
}
}
$totalMinor = max(0, $subtotalMinor - $totalDiscountMinor);
// For backward compatibility, provide a single promo name if only one promo applied
$promoName = count($appliedPromos) === 1 ? array_values($appliedPromos)[0]['name'] : null;
$discountPercent = count($appliedPromos) === 1 ? array_values($appliedPromos)[0]['discount_percent'] : null;
return [
'items' => $items,
'currency' => $currency,
'subtotal_minor' => $subtotalMinor,
'discount_minor' => $totalDiscountMinor,
'discount_percent' => $discountPercent,
'promo_name' => $promoName,
'applied_promos' => array_values($appliedPromos),
'total_minor' => $totalMinor,
];
}
/**
* Find the best matching promo for a cart item based on product type.
*/
private function findMatchingPromoForItem(RcServiceOrder $item, $promos): ?PromoBanner
{
$itemProductType = $this->mapItemToProductType($item);
// First, try to find a product-specific promo
$specificPromo = $promos->first(function (PromoBanner $promo) use ($itemProductType) {
return $promo->product_type && $promo->product_type === $itemProductType;
});
if ($specificPromo) {
return $specificPromo;
}
// Fall back to general promos (no product_type set or product_type is 'general')
return $promos->first(function (PromoBanner $promo) {
return !$promo->product_type || $promo->product_type === PromoBanner::TYPE_GENERAL;
});
}
/**
* Map a cart item to a promo product type.
*/
private function mapItemToProductType(RcServiceOrder $item): string
{
// Domain orders
if (in_array($item->order_type, [RcServiceOrder::TYPE_DOMAIN_REGISTRATION, RcServiceOrder::TYPE_DOMAIN_TRANSFER, RcServiceOrder::TYPE_RENEWAL], true)
&& str_contains((string) $item->category, 'domain')) {
return PromoBanner::TYPE_DOMAINS;
}
// Map category to product type
$categoryMap = [
'domain-registration' => PromoBanner::TYPE_DOMAINS,
'domain-transfer' => PromoBanner::TYPE_DOMAINS,
'single-domain-hosting' => PromoBanner::TYPE_HOSTING,
'multi-domain-hosting' => PromoBanner::TYPE_HOSTING,
'reseller-hosting' => PromoBanner::TYPE_HOSTING,
'cloud-hosting' => PromoBanner::TYPE_HOSTING,
'vps' => PromoBanner::TYPE_VPS,
'dedicated-server' => PromoBanner::TYPE_DEDICATED,
'email' => PromoBanner::TYPE_EMAIL,
];
return $categoryMap[$item->category] ?? PromoBanner::TYPE_GENERAL;
}
/**
* @param Collection<int, RcServiceOrder>|EloquentCollection<int, RcServiceOrder> $items
*/
public function markCheckoutPending(iterable $items, string $reference): void
{
foreach ($items as $item) {
$item->forceFill([
'status' => RcServiceOrder::STATUS_PENDING_PAYMENT,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_PENDING,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_PAYMENT_PENDING,
'payment_reference' => $reference,
])->save();
}
}
/**
* @return EloquentCollection<int, RcServiceOrder>
*/
public function markPaymentSuccessful(array $metadata, string $reference, ?Carbon $paidAt = null): EloquentCollection
{
$orders = $this->ordersForPaymentMetadata($metadata, $reference);
$transitioned = new EloquentCollection();
foreach ($orders as $order) {
if ($order->payment_status === RcServiceOrder::PAYMENT_STATUS_PAID) {
continue;
}
$order->forceFill([
'status' => RcServiceOrder::STATUS_PROCESSING,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_PAID,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_PAYMENT_RECEIVED,
'payment_reference' => $reference,
'paid_at' => $paidAt ?: now(),
])->save();
$transitioned->push($order->fresh());
}
return $transitioned;
}
public function markPaymentFailed(array $metadata, string $reference): void
{
$orders = $this->ordersForPaymentMetadata($metadata, $reference);
foreach ($orders as $order) {
if ($order->payment_status === RcServiceOrder::PAYMENT_STATUS_PAID) {
continue;
}
$order->forceFill([
'status' => RcServiceOrder::STATUS_CART,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_FAILED,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_CART,
'payment_reference' => null,
])->save();
}
}
public function returnPendingCheckoutToCart(User $user): int
{
return RcServiceOrder::query()
->where('user_id', $user->id)
->where('status', RcServiceOrder::STATUS_PENDING_PAYMENT)
->update([
'status' => RcServiceOrder::STATUS_CART,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_UNPAID,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_CART,
'payment_reference' => null,
]);
}
public function removeCartItem(User $user, RcServiceOrder $order): void
{
if ((int) $order->user_id !== (int) $user->id || ! in_array($order->status, $this->cartStatuses(), true)) {
throw new RuntimeException('This cart item cannot be removed.');
}
$order->delete();
}
public function clearCart(User $user): int
{
return RcServiceOrder::query()
->where('user_id', $user->id)
->whereIn('status', $this->cartStatuses())
->delete();
}
/**
* @param array<string, mixed> $attributes
*/
private function upsertCartItem(User $user, array $attributes): RcServiceOrder
{
$query = RcServiceOrder::query()
->where('user_id', $user->id)
->where('order_type', (string) $attributes['order_type'])
->where('category', (string) $attributes['category'])
->where('domain_name', (string) $attributes['domain_name'])
->whereIn('status', $this->cartStatuses());
if (isset($attributes['plan_id']) && $attributes['plan_id'] !== null) {
$query->where('plan_id', (string) $attributes['plan_id']);
} else {
$query->whereNull('plan_id');
}
if (isset($attributes['term_months']) && $attributes['term_months'] !== null) {
$query->where('term_months', (int) $attributes['term_months']);
} else {
$query->whereNull('term_months');
}
$existing = $query->latest('id')->first();
$payload = array_merge($attributes, [
'user_id' => $user->id,
'status' => RcServiceOrder::STATUS_CART,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_UNPAID,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_CART,
'payment_reference' => null,
'paid_at' => null,
'submitted_at' => null,
'last_synced_at' => null,
]);
if ($existing) {
$existing->forceFill($payload)->save();
return $existing->fresh();
}
return RcServiceOrder::query()->create($payload);
}
/**
* @param array<string, mixed> $metadata
* @return EloquentCollection<int, RcServiceOrder>
*/
private function ordersForPaymentMetadata(array $metadata, string $reference): EloquentCollection
{
$ids = collect($metadata['rc_service_order_ids'] ?? [])
->map(fn ($value) => (int) $value)
->filter()
->values();
if ($ids->isNotEmpty()) {
return RcServiceOrder::query()
->whereIn('id', $ids->all())
->get();
}
return RcServiceOrder::query()
->where('payment_reference', $reference)
->get();
}
private function assertCurrencyCompatibility(User $user, string $currency): void
{
$cartCurrency = RcServiceOrder::query()
->where('user_id', $user->id)
->whereIn('status', $this->cartStatuses())
->value('currency');
if ($cartCurrency && strtoupper((string) $cartCurrency) !== strtoupper($currency)) {
throw new RuntimeException(sprintf(
'Your cart already contains %s items. Complete or clear that checkout before adding a %s item.',
strtoupper((string) $cartCurrency),
strtoupper($currency)
));
}
}
}
@@ -0,0 +1,275 @@
<?php
namespace App\Services\ResellerClub;
use App\Models\RcServiceOrder;
use App\Services\Domain\DomainRegistrarService;
use App\Support\ResellerClubLegacy;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
class RcOrderFulfillmentService
{
public function __construct(
private readonly ResellerClubCustomerService $customers,
private readonly ResellerClubProductService $products,
private readonly DomainRegistrarService $registrar,
) {
}
public function processPaidOrder(int|RcServiceOrder $order): void
{
$order = $order instanceof RcServiceOrder
? $order->fresh(['user'])
: RcServiceOrder::query()->with('user')->find($order);
if (! $order || $order->payment_status !== RcServiceOrder::PAYMENT_STATUS_PAID) {
return;
}
if (in_array($order->fulfillment_status, [RcServiceOrder::FULFILLMENT_SUBMITTING, RcServiceOrder::FULFILLMENT_FULFILLED], true)) {
return;
}
if (! ResellerClubLegacy::canAutomateFulfillment($order)) {
$message = ResellerClubLegacy::isRenewalRcServiceOrder($order)
? ResellerClubLegacy::renewalsDisabledMessage()
: ResellerClubLegacy::fulfillmentDisabledMessage();
$this->markManualReview($order, $message);
return;
}
$order->forceFill([
'status' => RcServiceOrder::STATUS_PROCESSING,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_SUBMITTING,
])->save();
$resolved = $this->customers->resolveCustomerForUser($order->user);
if (! ($resolved['success'] ?? false)) {
$this->markManualReview($order, $resolved['message'] ?? 'Could not prepare the ResellerClub customer.');
return;
}
$customerId = (string) ($resolved['customer_id'] ?? '');
match ($order->order_type) {
RcServiceOrder::TYPE_DOMAIN_REGISTRATION => $this->submitDomainRegistration($order, $customerId),
RcServiceOrder::TYPE_DOMAIN_TRANSFER => $this->submitDomainTransfer($order, $customerId),
RcServiceOrder::TYPE_RENEWAL => $this->submitRenewal($order, $customerId),
default => $this->submitServiceOrder($order, $customerId),
};
}
private function submitDomainRegistration(RcServiceOrder $order, string $customerId): void
{
$contact = $this->registrar->resolveContactForCustomer($order->user, $customerId);
if (! ($contact['success'] ?? false)) {
$this->markManualReview($order, $contact['message'] ?? 'Could not prepare the domain contact.');
return;
}
$registration = $this->registrar->registerDomain(
$order->domain_name,
max(1, (int) ($order->term_years ?: 1)),
$customerId,
(string) ($contact['contact_id'] ?? '')
);
if (! ($registration['success'] ?? false)) {
$this->markManualReview($order, $registration['message'] ?? 'The registrar did not accept the domain order.');
return;
}
$this->markAwaitingVendor($order, $customerId, (string) ($registration['order_id'] ?? ''));
}
private function submitDomainTransfer(RcServiceOrder $order, string $customerId): void
{
$contact = $this->registrar->resolveContactForCustomer($order->user, $customerId);
if (! ($contact['success'] ?? false)) {
$this->markManualReview($order, $contact['message'] ?? 'Could not prepare the domain contact.');
return;
}
$encryptedAuthCode = (string) Arr::get($order->meta ?? [], 'transfer_auth_code_encrypted', '');
$authCode = $encryptedAuthCode !== '' ? Crypt::decryptString($encryptedAuthCode) : null;
$transfer = $this->registrar->transferDomain(
$order->domain_name,
$authCode,
$customerId,
(string) ($contact['contact_id'] ?? '')
);
if (! ($transfer['success'] ?? false)) {
$this->markManualReview($order, $transfer['message'] ?? 'The registrar did not accept the transfer request.');
return;
}
$this->markAwaitingVendor($order, $customerId, (string) ($transfer['order_id'] ?? ''));
}
private function submitRenewal(RcServiceOrder $order, string $customerId): void
{
$rcOrderId = (string) data_get($order->meta, 'renew_rc_order_id', $order->rc_order_id ?? '');
if ($rcOrderId === '') {
$this->markManualReview($order, 'Renewal is missing the upstream ResellerClub order id.');
return;
}
if (in_array($order->category, ['domain-renewal', 'domain-registration', 'domain-transfer'], true)
&& $order->order_type === RcServiceOrder::TYPE_RENEWAL) {
$years = max(1, (int) ($order->term_years ?: 1));
$renewal = $this->registrar->renewDomain(
$order->domain_name,
$years,
$customerId
);
if (! ($renewal['success'] ?? false)) {
$this->markManualReview($order, $renewal['message'] ?? 'The registrar did not accept the domain renewal.');
return;
}
$this->markRenewalComplete($order, $customerId, (string) ($renewal['order_id'] ?? $rcOrderId));
return;
}
$months = max(1, (int) ($order->term_months ?: 1));
$renewal = $this->products->renewOrder($order->category, $rcOrderId, $months);
if (! ($renewal['success'] ?? false)) {
$this->markManualReview($order, $renewal['message'] ?? 'The registrar did not accept the renewal.');
return;
}
$this->markRenewalComplete($order, $customerId, $rcOrderId);
}
private function markRenewalComplete(RcServiceOrder $order, string $customerId, string $rcOrderId): void
{
$details = $this->products->getOrderDetails($order->category === 'domain-renewal' ? 'domain-registration' : $order->category, $rcOrderId);
if ($details['success'] ?? false) {
$synced = $this->products->syncOrderToLocal(
$order->category === 'domain-renewal' ? 'domain-registration' : $order->category,
(array) ($details['order'] ?? []),
$order->user_id,
$customerId,
$order
);
$synced->forceFill([
'status' => $synced->status === RcServiceOrder::STATUS_ACTIVE
? RcServiceOrder::STATUS_ACTIVE
: RcServiceOrder::STATUS_PROCESSING,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_PAID,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_FULFILLED,
'rc_order_id' => $rcOrderId,
'submitted_at' => $synced->submitted_at ?: now(),
'last_synced_at' => now(),
])->save();
return;
}
$order->forceFill([
'rc_customer_id' => $customerId,
'rc_order_id' => $rcOrderId,
'status' => RcServiceOrder::STATUS_ACTIVE,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_FULFILLED,
'submitted_at' => $order->submitted_at ?: now(),
'last_synced_at' => now(),
])->save();
}
private function submitServiceOrder(RcServiceOrder $order, string $customerId): void
{
$input = (array) Arr::get($order->meta ?? [], 'order_input', []);
$input['domain_name'] = $input['domain_name'] ?? $order->domain_name;
$input['plan_id'] = $input['plan_id'] ?? $order->plan_id;
$input['months'] = $input['months'] ?? $order->term_months ?? 1;
$result = $this->products->order($order->category, $customerId, $input);
if (! ($result['success'] ?? false)) {
$this->markManualReview($order, $result['message'] ?? 'The provisioning request could not be submitted.');
return;
}
$orderId = (string) ($result['order_id'] ?? '');
if ($orderId === '') {
$this->markManualReview($order, 'The provisioning request did not return an upstream order id.');
return;
}
$details = $this->products->getOrderDetails($order->category, $orderId);
if ($details['success'] ?? false) {
$synced = $this->products->syncOrderToLocal(
$order->category,
(array) ($details['order'] ?? []),
$order->user_id,
$customerId,
$order
);
$synced->forceFill([
'status' => $synced->status === RcServiceOrder::STATUS_ACTIVE
? RcServiceOrder::STATUS_ACTIVE
: RcServiceOrder::STATUS_PROCESSING,
'payment_status' => RcServiceOrder::PAYMENT_STATUS_PAID,
'fulfillment_status' => $synced->status === RcServiceOrder::STATUS_ACTIVE
? RcServiceOrder::FULFILLMENT_FULFILLED
: RcServiceOrder::FULFILLMENT_AWAITING_VENDOR,
'submitted_at' => $synced->submitted_at ?: now(),
'last_synced_at' => now(),
])->save();
return;
}
$this->markAwaitingVendor($order, $customerId, $orderId, $details['message'] ?? null);
}
private function markAwaitingVendor(RcServiceOrder $order, string $customerId, string $orderId, ?string $message = null): void
{
$meta = (array) ($order->meta ?? []);
if ($message !== null && $message !== '') {
$meta['last_message'] = $message;
}
$order->forceFill([
'rc_customer_id' => $customerId,
'rc_order_id' => $orderId !== '' ? $orderId : $order->rc_order_id,
'status' => RcServiceOrder::STATUS_PROCESSING,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_AWAITING_VENDOR,
'submitted_at' => $order->submitted_at ?: now(),
'last_synced_at' => now(),
'meta' => $meta,
])->save();
}
private function markManualReview(RcServiceOrder $order, string $message): void
{
$meta = (array) ($order->meta ?? []);
$meta['last_message'] = $message;
$order->forceFill([
'status' => RcServiceOrder::STATUS_PROCESSING,
'fulfillment_status' => RcServiceOrder::FULFILLMENT_MANUAL_REVIEW,
'meta' => $meta,
])->save();
}
}
@@ -0,0 +1,328 @@
<?php
namespace App\Services\ResellerClub;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ResellerClubConnectivityDiagnosticsService
{
private const DEFAULT_API_URL = 'https://httpapi.com/api';
private string $apiUrl;
private string $authUserId;
private string $apiKey;
public function __construct()
{
$value = trim((string) config('mailinfra.registrar_api_url'));
$this->apiUrl = rtrim($value !== '' ? $value : self::DEFAULT_API_URL, '/');
$this->authUserId = (string) config('mailinfra.registrar_auth_userid');
$this->apiKey = (string) config('mailinfra.registrar_api_key');
}
/**
* @return array{
* configured: bool,
* api_url: string,
* email: string,
* initial_customer_id: string,
* resolved_customer_id: string,
* probes: array<int, array{
* label: string,
* status: string,
* method: string,
* endpoint: string,
* request: array<string, string>,
* http_status: string,
* summary: string
* }>
* }
*/
public function run(string $email, ?string $customerId, string $ipAddress): array
{
$email = strtolower(trim($email));
$customerId = trim((string) $customerId);
$probes = [];
if ($this->authUserId === '' || $this->apiKey === '') {
$probes[] = $this->probeResult(
'Configuration',
'error',
'LOCAL',
'configuration',
[],
null,
'ResellerClub credentials are missing.'
);
return [
'configured' => false,
'api_url' => $this->apiUrl,
'email' => $email,
'initial_customer_id' => $customerId,
'resolved_customer_id' => '',
'probes' => $probes,
];
}
$probes[] = $this->probeResult(
'Configuration',
'success',
'LOCAL',
'configuration',
['api-url' => $this->apiUrl, 'auth-userid' => $this->maskValue($this->authUserId)],
null,
'ResellerClub credentials are configured.'
);
$resolvedCustomerId = $customerId;
if ($email !== '') {
$lookup = $this->probeLookup($email);
$probes[] = $lookup['probe'];
if (($lookup['customer_id'] ?? '') !== '') {
$resolvedCustomerId = (string) $lookup['customer_id'];
}
}
if ($resolvedCustomerId !== '') {
$probes[] = $this->probeGenerateToken($resolvedCustomerId, $ipAddress);
}
return [
'configured' => true,
'api_url' => $this->apiUrl,
'email' => $email,
'initial_customer_id' => $customerId,
'resolved_customer_id' => $resolvedCustomerId,
'probes' => $probes,
];
}
/**
* @return array{
* probe: array{
* label: string,
* status: string,
* method: string,
* endpoint: string,
* request: array<string, string>,
* http_status: string,
* summary: string
* },
* customer_id?: string
* }
*/
private function probeLookup(string $email): array
{
$endpoint = $this->apiUrl.'/customers/details.json';
$request = [
'auth-userid' => $this->maskValue($this->authUserId),
'username' => $email,
];
try {
$response = Http::timeout(15)->get($endpoint, [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'username' => $email,
]);
$data = $response->json();
$summary = $this->summarizeResponse($data, (string) $response->body());
$customerId = is_array($data) ? (string) ($data['customerid'] ?? $data['customer-id'] ?? '') : '';
return [
'probe' => $this->probeResult(
'Customer lookup',
$response->successful() ? 'success' : 'error',
'GET',
$endpoint,
$request,
$response->status(),
$summary !== '' ? $summary : 'No response summary returned.'
),
'customer_id' => $customerId,
];
} catch (\Throwable $e) {
Log::warning('ResellerClub connectivity diagnostics: customer lookup failed', [
'email' => $email,
'error' => $e->getMessage(),
]);
return [
'probe' => $this->probeResult(
'Customer lookup',
'error',
'GET',
$endpoint,
$request,
null,
$this->sanitizeText($e->getMessage())
),
];
}
}
/**
* @return array{
* label: string,
* status: string,
* method: string,
* endpoint: string,
* request: array<string, string>,
* http_status: string,
* summary: string
* }
*/
private function probeGenerateToken(string $customerId, string $ipAddress): array
{
$endpoint = $this->apiUrl.'/customers/generate-login-token.json';
$request = [
'auth-userid' => $this->maskValue($this->authUserId),
'customer-id' => $customerId,
'ip' => $ipAddress,
];
try {
$response = Http::timeout(15)->get($endpoint, [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'customer-id' => $customerId,
'ip' => $ipAddress,
]);
$data = $response->json();
$summary = $this->summarizeResponse($data, (string) $response->body());
return $this->probeResult(
'Generate login token',
$response->successful() ? 'success' : 'error',
'GET',
$endpoint,
$request,
$response->status(),
$summary !== '' ? $summary : 'No response summary returned.'
);
} catch (\Throwable $e) {
Log::warning('ResellerClub connectivity diagnostics: token generation failed', [
'customer_id' => $customerId,
'error' => $e->getMessage(),
]);
return $this->probeResult(
'Generate login token',
'error',
'GET',
$endpoint,
$request,
null,
$this->sanitizeText($e->getMessage())
);
}
}
/**
* @param array<string, mixed>|mixed $data
*/
private function summarizeResponse(mixed $data, string $body): string
{
if (is_array($data)) {
foreach (['message', 'error', 'description', 'msg'] as $key) {
$value = $this->sanitizeText((string) ($data[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
if (($data['customerid'] ?? null) !== null) {
return 'Customer found: '.(string) $data['customerid'];
}
if (($data['token'] ?? null) !== null || ($data['loginid'] ?? null) !== null || ($data['userLoginId'] ?? null) !== null) {
return 'Login token generated successfully.';
}
}
return $this->sanitizeText($body);
}
private function sanitizeText(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if ($this->looksLikeHtmlDocument($value)) {
return 'HTML error page from upstream. ResellerClub temporarily blocked or rejected the request.';
}
$plain = trim(preg_replace('/\s+/', ' ', strip_tags($value)) ?? '');
return mb_strimwidth($plain, 0, 240, '...');
}
private function looksLikeHtmlDocument(string $value): bool
{
$normalized = strtolower(ltrim($value));
return str_starts_with($normalized, '<!doctype html')
|| str_starts_with($normalized, '<html')
|| str_contains($normalized, '<body')
|| str_contains($normalized, '<head')
|| str_contains($normalized, 'cloudflare');
}
/**
* @param array<string, string> $request
* @return array{
* label: string,
* status: string,
* method: string,
* endpoint: string,
* request: array<string, string>,
* http_status: string,
* summary: string
* }
*/
private function probeResult(
string $label,
string $status,
string $method,
string $endpoint,
array $request,
?int $httpStatus,
string $summary
): array {
return [
'label' => $label,
'status' => $status,
'method' => $method,
'endpoint' => $endpoint,
'request' => $request,
'http_status' => $httpStatus !== null ? (string) $httpStatus : 'n/a',
'summary' => $summary,
];
}
private function maskValue(string $value): string
{
$value = trim($value);
if ($value === '') {
return 'missing';
}
if (strlen($value) <= 4) {
return str_repeat('*', strlen($value));
}
return substr($value, 0, 2).str_repeat('*', max(strlen($value) - 4, 2)).substr($value, -2);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Services\ResellerClub;
class ResellerClubConsoleService
{
private const DEFAULT_CONTROL_PANEL_URL = 'https://manage.resellerclub.com/customer';
public const SERVICE_DNS = 'dns';
public const SERVICE_WEB_HOSTING = 'webhosting';
public const SERVICE_MAIL_HOSTING = 'mailhosting';
public const SERVICE_WEBSITE_BUILDER = 'websitebuilder';
public function launchUrl(): string
{
return rtrim($this->configuredControlPanelUrl(), '/').'/servlet/ManageServiceServletForAPI';
}
/**
* @return array<string, string>
*/
public function buildManagedServicePayload(string $loginToken, string $orderId, string $serviceName): array
{
return [
'loginid' => $loginToken,
'orderid' => $orderId,
'service-name' => $serviceName,
];
}
public function autoLoginUrl(string $loginToken, string $role = 'customer'): string
{
return sprintf(
'%s/servlet/AutoLoginServlet?userLoginId=%s&role=%s',
rtrim($this->configuredControlPanelUrl(), '/'),
urlencode($loginToken),
urlencode($role)
);
}
private function configuredControlPanelUrl(): string
{
$value = trim((string) config('mailinfra.resellerclub_control_panel_url'));
return $value !== '' ? $value : self::DEFAULT_CONTROL_PANEL_URL;
}
}
@@ -0,0 +1,655 @@
<?php
namespace App\Services\ResellerClub;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ResellerClubCustomerService
{
private const DEFAULT_API_URL = 'https://httpapi.com/api';
private string $apiUrl;
private string $authUserId;
private string $apiKey;
public function __construct()
{
$this->apiUrl = rtrim($this->configuredApiUrl(), '/');
$this->authUserId = (string) config('mailinfra.registrar_auth_userid');
$this->apiKey = (string) config('mailinfra.registrar_api_key');
}
public function isConfigured(): bool
{
return $this->authUserId !== '' && $this->apiKey !== '';
}
/**
* Build a URL with all parameters as query string values, including auth.
*
* @param array<string, mixed> $params
*/
private function buildQueryUrl(string $endpoint, array $params = []): string
{
$all = array_merge([
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
], $params);
$separator = str_contains($endpoint, '?') ? '&' : '?';
return $endpoint.$separator.http_build_query($all);
}
/**
* @return array{success: bool, customer_id?: string, created?: bool, message?: string}
*/
public function resolveCustomerForUser(User $user): array
{
if (! $this->isConfigured()) {
return ['success' => false, 'message' => 'ResellerClub is not configured.'];
}
if ($user->hasResellerClubCustomer()) {
return [
'success' => true,
'customer_id' => (string) $user->rc_customer_id,
'created' => false,
];
}
$email = strtolower(trim((string) $user->email));
if ($email === '') {
return ['success' => false, 'message' => 'User email is required to provision a ResellerClub customer.'];
}
$lookup = $this->findCustomerByEmail($email);
if (! ($lookup['success'] ?? false)) {
return ['success' => false, 'message' => $lookup['message'] ?? 'Could not verify the ResellerClub customer.'];
}
if ($lookup['found'] ?? false) {
return $this->attachCustomerToUser(
$user,
(string) $lookup['customer_id'],
$email,
false
);
}
$created = $this->createCustomerForUser($user);
if (! ($created['success'] ?? false)) {
return ['success' => false, 'message' => $created['message'] ?? 'Could not create the ResellerClub customer.'];
}
$attached = $this->attachCustomerToUser(
$user,
(string) $created['customer_id'],
$email,
false
);
if (! ($attached['success'] ?? false)) {
return $attached;
}
return [
'success' => true,
'customer_id' => (string) $attached['customer_id'],
'created' => true,
];
}
/**
* Best-effort customer provisioning for user creation hooks.
*/
public function provisionCustomerForUser(User $user): ?string
{
$result = $this->resolveCustomerForUser($user);
if (! ($result['success'] ?? false)) {
Log::warning('ResellerClubCustomerService: automatic provisioning skipped', [
'user_id' => $user->id,
'message' => $result['message'] ?? 'Unknown error.',
]);
return null;
}
return (string) ($result['customer_id'] ?? '');
}
/**
* @return array{success: bool, customer_id?: string, found?: bool, message?: string}
*/
public function findCustomerByEmail(string $email): array
{
try {
$response = Http::timeout(15)->get($this->apiUrl.'/customers/details.json', [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'username' => strtolower(trim($email)),
]);
$data = $response->json();
if ($response->successful() && is_array($data)) {
$customerId = (string) ($data['customerid'] ?? $data['customer-id'] ?? '');
if ($customerId !== '') {
return [
'success' => true,
'found' => true,
'customer_id' => $customerId,
];
}
}
if (in_array($response->status(), [400, 404], true)) {
return [
'success' => true,
'found' => false,
];
}
$message = $this->extractErrorMessage(is_array($data) ? $data : [], $response->body());
if ($message !== '' && $this->looksLikeMissingCustomerMessage($message)) {
return [
'success' => true,
'found' => false,
];
}
Log::warning('ResellerClubCustomerService: customer lookup failed', [
'email' => $email,
'status' => $response->status(),
'response' => $data ?: $response->body(),
]);
return [
'success' => false,
'message' => $message !== '' ? $message : 'Could not look up the ResellerClub customer.',
];
} catch (\Throwable $e) {
Log::error('ResellerClubCustomerService: findCustomerByEmail failed', [
'email' => $email,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Could not look up the ResellerClub customer.'];
}
}
/**
* @return array{success: bool, customer_id?: string, message?: string}
*/
public function createCustomerForUser(User $user): array
{
$defaults = (array) config('mailinfra.resellerclub_customer_defaults', []);
$email = strtolower(trim((string) $user->email));
$name = trim(implode(' ', array_filter([
trim((string) ($user->first_name ?? '')),
trim((string) ($user->last_name ?? '')),
])));
try {
$response = Http::timeout(30)->post($this->buildQueryUrl($this->apiUrl.'/customers/v2/signup.xml', [
'username' => $email,
'passwd' => $this->generatePlatformPassword(),
'name' => $name !== '' ? $name : (trim((string) $user->name) !== '' ? (string) $user->name : $email),
'company' => trim((string) ($user->company ?? '')) !== '' ? (string) $user->company : (string) ($defaults['company'] ?? config('app.name', 'Ladill')),
'address-line-1' => trim((string) ($user->address_line_1 ?? '')) !== '' ? (string) $user->address_line_1 : (string) ($defaults['address_line_1'] ?? 'Not provided'),
'city' => trim((string) ($user->city ?? '')) !== '' ? (string) $user->city : (string) ($defaults['city'] ?? 'Accra'),
'state' => trim((string) ($user->state ?? '')) !== '' ? (string) $user->state : (string) ($defaults['state'] ?? 'Not Applicable'),
'country' => trim((string) ($user->country ?? '')) !== '' ? strtoupper((string) $user->country) : (string) ($defaults['country'] ?? 'GH'),
'zipcode' => trim((string) ($user->zipcode ?? '')) !== '' ? (string) $user->zipcode : (string) ($defaults['zipcode'] ?? '00000'),
'phone-cc' => trim((string) ($user->phone_cc ?? '')) !== '' ? ltrim((string) $user->phone_cc, '+') : (string) ($defaults['phone_cc'] ?? '233'),
'phone' => trim((string) ($user->phone ?? '')) !== '' ? (string) $user->phone : (string) ($defaults['phone'] ?? '000000000'),
'lang-pref' => (string) ($defaults['lang_pref'] ?? 'en'),
'address-line-2' => trim((string) ($user->address_line_2 ?? '')) !== '' ? (string) $user->address_line_2 : (string) ($defaults['address_line_2'] ?? ''),
'address-line-3' => (string) ($defaults['address_line_3'] ?? ''),
]));
$body = trim((string) $response->body());
$customerId = $this->extractCustomerIdFromBody($body);
if ($response->failed() || $customerId === '') {
$message = $this->extractXmlErrorMessage($body);
Log::warning('ResellerClubCustomerService: customer signup failed', [
'user_id' => $user->id,
'email' => $email,
'status' => $response->status(),
'body' => $body,
]);
return [
'success' => false,
'message' => $message !== '' ? $message : 'Could not create the ResellerClub customer.',
];
}
return [
'success' => true,
'customer_id' => $customerId,
];
} catch (\Throwable $e) {
Log::error('ResellerClubCustomerService: createCustomerForUser failed', [
'user_id' => $user->id,
'email' => $email,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Could not create the ResellerClub customer.'];
}
}
/**
* @return array{success: bool, customer_id?: string, message?: string}
*/
public function attachCustomerToUser(User $user, string $customerId, ?string $email = null, bool $markLinked = false): array
{
$customerId = trim($customerId);
if ($customerId === '') {
return ['success' => false, 'message' => 'A valid ResellerClub customer ID is required.'];
}
$existing = User::query()
->where('rc_customer_id', $customerId)
->where('id', '!=', $user->id)
->first();
if ($existing) {
return ['success' => false, 'message' => 'This ResellerClub account is already linked to another Ladill account.'];
}
$payload = [
'rc_customer_id' => $customerId,
'rc_email' => $email ?: $user->rc_email ?: $user->email,
];
if ($markLinked) {
$payload['rc_linked_at'] = now();
}
$user->forceFill($payload)->save();
return [
'success' => true,
'customer_id' => $customerId,
];
}
/**
* @return array{success: bool, token?: string, message?: string}
*/
public function generateLoginToken(User $user, string $ipAddress): array
{
if (! $user->hasResellerClubCustomer()) {
return ['success' => false, 'message' => 'ResellerClub customer is not set for this user.'];
}
try {
$response = Http::timeout(15)->get($this->apiUrl.'/customers/generate-login-token.json', [
'auth-userid' => $this->authUserId,
'api-key' => $this->apiKey,
'customer-id' => (string) $user->rc_customer_id,
'ip' => $ipAddress,
]);
$data = $response->json();
$rawBody = trim((string) $response->body());
if ($response->failed()) {
return [
'success' => false,
'message' => $this->extractErrorMessage(is_array($data) ? $data : [], $rawBody) ?: 'Could not create the ResellerClub login token.',
];
}
$token = is_array($data)
? (string) ($data['token'] ?? $data['loginid'] ?? $data['userLoginId'] ?? '')
: trim($rawBody, "\"'");
if ($token === '') {
return ['success' => false, 'message' => 'Could not create the ResellerClub login token.'];
}
return ['success' => true, 'token' => $token];
} catch (\Throwable $e) {
Log::error('ResellerClubCustomerService: generateLoginToken failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
return ['success' => false, 'message' => 'Could not create the ResellerClub login token.'];
}
}
public function generatePlatformPassword(): string
{
$upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
$lower = 'abcdefghijkmnopqrstuvwxyz';
$numbers = '23456789';
$special = '!@$#%_+?';
$pool = $upper.$lower.$numbers.$special;
$password = [
$upper[random_int(0, strlen($upper) - 1)],
$lower[random_int(0, strlen($lower) - 1)],
$numbers[random_int(0, strlen($numbers) - 1)],
$special[random_int(0, strlen($special) - 1)],
];
while (count($password) < 12) {
$password[] = $pool[random_int(0, strlen($pool) - 1)];
}
shuffle($password);
return implode('', $password);
}
/**
* @return array{
* success: bool,
* configured: bool,
* api_url: string,
* auth_userid_present: bool,
* current_customer_id: string,
* current_rc_email: string,
* linked: bool,
* steps: array<int, array{label: string, status: string, message: string}>
* }
*/
public function runDiagnostics(User $user, string $ipAddress): array
{
$steps = [];
if (! $this->isConfigured()) {
$steps[] = $this->diagnosticStep('Configuration', 'error', 'ResellerClub is not configured.');
return $this->diagnosticSummary($user, $steps, false);
}
$steps[] = $this->diagnosticStep('Configuration', 'success', 'ResellerClub credentials are configured.');
if ($user->hasResellerClubCustomer()) {
$steps[] = $this->diagnosticStep(
'Stored customer',
'success',
'Current user already has ResellerClub customer ID '.$user->rc_customer_id.'.'
);
} else {
$email = strtolower(trim((string) $user->email));
if ($email === '') {
$steps[] = $this->diagnosticStep('Customer lookup', 'error', 'User email is required to provision a ResellerClub customer.');
return $this->diagnosticSummary($user, $steps, false);
}
$lookup = $this->findCustomerByEmail($email);
if (! ($lookup['success'] ?? false)) {
$steps[] = $this->diagnosticStep(
'Customer lookup',
'error',
$lookup['message'] ?? 'Could not look up the ResellerClub customer.'
);
return $this->diagnosticSummary($user, $steps, false);
}
if ($lookup['found'] ?? false) {
$customerId = (string) ($lookup['customer_id'] ?? '');
$steps[] = $this->diagnosticStep(
'Customer lookup',
'success',
'Existing ResellerClub customer found: '.$customerId.'.'
);
$attached = $this->attachCustomerToUser($user, $customerId, $email, false);
if (! ($attached['success'] ?? false)) {
$steps[] = $this->diagnosticStep(
'Attach customer',
'error',
$attached['message'] ?? 'Could not attach the ResellerClub customer to this Ladill account.'
);
return $this->diagnosticSummary($user, $steps, false);
}
$steps[] = $this->diagnosticStep('Attach customer', 'success', 'Existing ResellerClub customer attached to this Ladill account.');
$user->refresh();
} else {
$steps[] = $this->diagnosticStep('Customer lookup', 'warning', 'No existing ResellerClub customer was found for this email.');
$created = $this->createCustomerForUser($user);
if (! ($created['success'] ?? false)) {
$steps[] = $this->diagnosticStep(
'Create customer',
'error',
$created['message'] ?? 'Could not create the ResellerClub customer.'
);
return $this->diagnosticSummary($user, $steps, false);
}
$customerId = (string) ($created['customer_id'] ?? '');
$steps[] = $this->diagnosticStep('Create customer', 'success', 'New ResellerClub customer created: '.$customerId.'.');
$attached = $this->attachCustomerToUser($user, $customerId, $email, false);
if (! ($attached['success'] ?? false)) {
$steps[] = $this->diagnosticStep(
'Attach customer',
'error',
$attached['message'] ?? 'Could not attach the new ResellerClub customer to this Ladill account.'
);
return $this->diagnosticSummary($user, $steps, false);
}
$steps[] = $this->diagnosticStep('Attach customer', 'success', 'New ResellerClub customer attached to this Ladill account.');
$user->refresh();
}
}
$token = $this->generateLoginToken($user, $ipAddress);
if (! ($token['success'] ?? false)) {
$steps[] = $this->diagnosticStep(
'Generate login token',
'error',
$token['message'] ?? 'Could not create the ResellerClub login token.'
);
return $this->diagnosticSummary($user, $steps, false);
}
$steps[] = $this->diagnosticStep('Generate login token', 'success', 'ResellerClub login token generated successfully.');
return $this->diagnosticSummary($user, $steps, true);
}
private function configuredApiUrl(): string
{
$value = trim((string) config('mailinfra.registrar_api_url'));
return $value !== '' ? $value : self::DEFAULT_API_URL;
}
private function looksLikeMissingCustomerMessage(string $message): bool
{
$value = strtolower(trim($message));
return str_contains($value, 'not found')
|| str_contains($value, 'does not exist')
|| str_contains($value, 'no customer')
|| str_contains($value, 'unable to find')
|| str_contains($value, 'entity not found')
|| str_contains($value, 'no entity found');
}
/**
* @param array<string, mixed> $data
*/
private function extractErrorMessage(array $data, string $fallbackBody = ''): string
{
foreach (['message', 'error', 'description', 'msg'] as $key) {
$value = $this->sanitizeErrorText((string) ($data[$key] ?? ''));
if ($value !== '') {
return $value;
}
}
return $this->sanitizeErrorText($fallbackBody);
}
private function extractCustomerIdFromBody(string $body): string
{
if ($body !== '' && preg_match('/^\d+$/', $body) === 1) {
return $body;
}
$xml = $this->parseXml($body);
if ($xml === null) {
return '';
}
$flattened = strtolower(preg_replace('/\s+/', ' ', strip_tags($body)) ?? '');
if (str_contains($flattened, 'error')) {
return '';
}
foreach (['customerid', 'customer-id', 'entityid', 'entity-id', 'id', 'description'] as $key) {
$value = trim((string) ($xml[$key] ?? ''));
if ($value !== '' && preg_match('/^\d+$/', $value) === 1) {
return $value;
}
}
if (preg_match('/\b(\d{3,})\b/', trim(strip_tags($body)), $matches) === 1) {
return $matches[1];
}
return '';
}
private function extractXmlErrorMessage(string $body): string
{
$xml = $this->parseXml($body);
if ($xml !== null) {
foreach (['message', 'error', 'description', 'msg', 'status'] as $key) {
$value = $this->sanitizeErrorText((string) ($xml[$key] ?? ''));
if ($value !== '' && ! preg_match('/^\d+$/', $value)) {
return $value;
}
}
}
return $this->sanitizeErrorText($body);
}
/**
* @return array<string, mixed>|null
*/
private function parseXml(string $body): ?array
{
$body = trim($body);
if ($body === '' || ! str_starts_with($body, '<')) {
return null;
}
$previous = libxml_use_internal_errors(true);
try {
$xml = simplexml_load_string($body, 'SimpleXMLElement', LIBXML_NOCDATA | LIBXML_NONET);
if (! $xml instanceof \SimpleXMLElement) {
return null;
}
$data = json_decode(json_encode($xml, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
return is_array($data) ? $data : null;
} catch (\Throwable) {
return null;
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
}
private function sanitizeErrorText(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if ($this->looksLikeHtmlDocument($value)) {
return 'ResellerClub temporarily blocked or rejected the request. Please try again in a moment.';
}
$plainText = trim(preg_replace('/\s+/', ' ', strip_tags($value)) ?? '');
return $plainText;
}
private function looksLikeHtmlDocument(string $value): bool
{
$normalized = strtolower(ltrim($value));
return str_starts_with($normalized, '<!doctype html')
|| str_starts_with($normalized, '<html')
|| str_contains($normalized, '<body')
|| str_contains($normalized, '<head')
|| str_contains($normalized, '<title>')
|| str_contains($normalized, 'cloudflare');
}
/**
* @return array{label: string, status: string, message: string}
*/
private function diagnosticStep(string $label, string $status, string $message): array
{
return [
'label' => $label,
'status' => $status,
'message' => $message,
];
}
/**
* @param array<int, array{label: string, status: string, message: string}> $steps
* @return array{
* success: bool,
* configured: bool,
* api_url: string,
* auth_userid_present: bool,
* current_customer_id: string,
* current_rc_email: string,
* linked: bool,
* steps: array<int, array{label: string, status: string, message: string}>
* }
*/
private function diagnosticSummary(User $user, array $steps, bool $success): array
{
$user->refresh();
return [
'success' => $success,
'configured' => $this->isConfigured(),
'api_url' => $this->apiUrl,
'auth_userid_present' => $this->authUserId !== '',
'current_customer_id' => (string) ($user->rc_customer_id ?? ''),
'current_rc_email' => (string) ($user->rc_email ?? ''),
'linked' => $user->hasLinkedResellerClubAccount(),
'steps' => $steps,
];
}
}
File diff suppressed because it is too large Load Diff