morphMany( config('shop.models.product_purchase', ProductPurchase::class), 'purchasable' ); } /** * Get cart items (purchases with status 'cart') */ public function cartItems(): MorphMany { return $this->purchases()->where('status', 'cart'); } /** * Get completed purchases */ public function completedPurchases(): MorphMany { return $this->purchases()->where('status', 'completed'); } /** * Purchase a product * * @param Product $product * @param int $quantity * * @return ProductPurchase * @throws \Exception */ public function purchase( ProductPrice|string $productPrice, int $quantity = 1, ): ProductPurchase { if ($productPrice instanceof ProductPrice) { } else { $productPrice = ProductPrice::findOrFail($productPrice); } if (!$productPrice?->product?->id) { throw new \Exception("Price does not belong to the specified product"); } $product = $productPrice->product; // Validate stock availability if ($product->manage_stock) { $available = $product->getAvailableStock(); if ($available < $quantity) { throw new \Exception("Insufficient stock. Available: {$available}, Requested: {$quantity}"); } } // Check if product is visible if (!$product->isVisible()) { throw new \Exception("Product is not available for purchase"); } // Decrease stock if (!$product->decreaseStock($quantity)) { throw new \Exception("Unable to decrease stock"); } // Create purchase record $purchase = $this->purchases()->create([ 'product_id' => $product->id, 'quantity' => $quantity, 'status' => 'unpaid', 'meta' => array_merge([ 'price_id' => $productPrice->id, 'price' => $productPrice->price, 'amount' => $productPrice->price * $quantity, ]), ]); // Trigger product actions $product->callActions('purchased', $purchase, [ 'purchaser' => $this, ]); return $purchase; } /** * Add product to cart * * @param Product $product * @param int $quantity * @param array $options * @return ProductPurchase * @throws \Exception */ public function addToCart(Product $product, int $quantity = 1, array $options = []): ProductPurchase { // Check if product already in cart $existingItem = $this->cartItems() ->where('product_id', $product->id) ->first(); if ($existingItem) { return $this->updateCartQuantity($existingItem, $existingItem->quantity + $quantity); } // Validate stock if ($product->manage_stock && $product->getAvailableStock() < $quantity) { throw new \Exception("Insufficient stock available"); } $priceId = $options['price_id'] ?? null; $price = $this->determinePurchasePrice($product, $priceId); return $this->purchases()->create([ 'product_id' => $product->id, 'quantity' => $quantity, 'status' => 'cart', 'meta' => array_merge([ 'price_id' => $priceId, 'price' => $price, 'amount' => $price * $quantity, ], $options['meta'] ?? []), ]); } /** * Update cart item quantity * * @param ProductPurchase $cartItem * @param int $quantity * @return ProductPurchase * @throws \Exception */ public function updateCartQuantity(ProductPurchase $cartItem, int $quantity): ProductPurchase { if ($cartItem->status !== 'cart') { throw new \Exception("Cannot update non-cart item"); } $product = $cartItem->product; // Validate stock if ($product->manage_stock && $product->getAvailableStock() < $quantity) { throw new \Exception("Insufficient stock available"); } $meta = (array) $cartItem->meta; $priceId = $meta['price_id'] ?? null; $price = $this->determinePurchasePrice($product, $priceId); $cartItem->update([ 'quantity' => $quantity, 'meta' => array_merge($meta, [ 'price' => $price, 'amount' => $price * $quantity, ]), ]); return $cartItem->fresh(); } /** * Remove item from cart * * @param ProductPurchase $cartItem * @return bool * @throws \Exception */ public function removeFromCart(ProductPurchase $cartItem): bool { if ($cartItem->status !== 'cart') { throw new \Exception("Cannot remove non-cart item"); } return $cartItem->delete(); } /** * Clear all cart items * * @param string|null $cartId (deprecated - not used) * @return int Number of items removed */ public function clearCart(?string $cartId = null): int { return $this->cartItems()->delete(); } /** * Get cart total * * @param string|null $cartId (deprecated - not used) * @return float */ public function getCartTotal(?string $cartId = null): float { return $this->cartItems()->get()->sum(function ($item) { $meta = (array) $item->meta; return $meta['amount'] ?? 0; }); } /** * Get cart items count * * @param string|null $cartId (deprecated - not used) * @return int */ public function getCartItemsCount(?string $cartId = null): int { return $this->cartItems()->sum('quantity') ?? 0; } /** * Checkout cart - convert cart items to completed purchases * * @param string|null $cartId (deprecated - not used) * @param array $options * @return Collection * @throws \Exception */ public function checkout(?string $cartId = null, array $options = []): Collection { $items = $this->cartItems()->with('product')->get(); if ($items->isEmpty()) { throw new \Exception("Cart is empty"); } // Validate stock for all items foreach ($items as $item) { $product = $item->product; if ($product->manage_stock && $product->getAvailableStock() < $item->quantity) { throw new \Exception("Insufficient stock for: {$product->getLocalized('name')}"); } } // Process each item $completedPurchases = collect(); foreach ($items as $item) { $product = $item->product; // Decrease stock if (!$product->decreaseStock($item->quantity)) { // Rollback previous purchases foreach ($completedPurchases as $purchase) { $purchase->product->increaseStock($purchase->quantity); $purchase->delete(); } throw new \Exception("Unable to process checkout"); } // Update status and store charge info in meta $meta = array_merge((array) $item->meta, [ 'charge_id' => $options['charge_id'] ?? null, 'completed_at' => now()->toISOString(), ]); $item->update([ 'status' => 'completed', 'meta' => $meta, ]); // Trigger actions $product->callActions('purchased', $item, [ 'purchaser' => $this, ...$options, ]); $completedPurchases->push($item); } return $completedPurchases; } /** * Check if entity has purchased a product * * @param Product|int $product * @return bool */ public function hasPurchased($product): bool { $productId = $product instanceof Product ? $product->id : $product; return $this->completedPurchases() ->where('product_id', $productId) ->exists(); } /** * Get purchase history for a product * * @param Product|int $product * @return Collection */ public function getPurchaseHistory($product): Collection { $productId = $product instanceof Product ? $product->id : $product; return $this->purchases() ->where('product_id', $productId) ->orderBy('created_at', 'desc') ->get(); } /** * Refund a purchase * * @param ProductPurchase $purchase * @param array $options * @return bool * @throws \Exception */ public function refundPurchase(ProductPurchase $purchase, array $options = []): bool { if ($purchase->status !== 'completed') { throw new \Exception("Can only refund completed purchases"); } $product = $purchase->product; // Return stock $product->increaseStock($purchase->quantity); // Update purchase $purchase->update([ 'status' => 'refunded', ]); // Trigger refund actions $product->callActions('refunded', $purchase, [ 'purchaser' => $this, ...$options, ]); return true; } /** * Get total spent * * @return float */ public function getTotalSpent(): float { return $this->completedPurchases()->sum('amount') ?? 0; } /** * Get purchase statistics * * @return array */ public function getPurchaseStats(): array { return [ 'total_purchases' => $this->completedPurchases()->count(), 'total_spent' => $this->getTotalSpent(), 'total_items' => $this->completedPurchases()->sum('quantity'), 'cart_items' => $this->getCartItemsCount(), 'cart_total' => $this->getCartTotal(), ]; } /** * Determine purchase price for a product * * @param Product $product * @param string|null $priceId * @return float */ protected function determinePurchasePrice(Product $product, ?string $priceId = null): float { if ($priceId) { $productPrice = $product->prices()->find($priceId); if ($productPrice) { return $productPrice->price; } } return $product->getCurrentPrice(); } /** * Get or generate current cart ID * * @return string */ protected function getCurrentCartId(): string { // Override this method if you need custom cart ID logic return 'cart_' . $this->getKey(); } }