*/ 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, 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, 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|EloquentCollection $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 */ 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 $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 $metadata * @return EloquentCollection */ 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) )); } } }