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>
588 lines
21 KiB
PHP
588 lines
21 KiB
PHP
<?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)
|
|
));
|
|
}
|
|
}
|
|
}
|