Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
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:
@@ -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
Reference in New Issue
Block a user