2025-11-21 10:49:41 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Blax\Shop\Traits;
|
|
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
|
2025-11-23 14:07:12 +00:00
|
|
|
use Blax\Shop\Exceptions\NotEnoughStockException;
|
2025-11-25 11:33:42 +00:00
|
|
|
use Blax\Shop\Exceptions\NotPurchasable;
|
2025-11-23 14:07:12 +00:00
|
|
|
use Blax\Shop\Models\CartItem;
|
2025-11-21 10:49:41 +00:00
|
|
|
use Blax\Shop\Models\ProductPurchase;
|
|
|
|
|
use Blax\Shop\Models\Product;
|
2025-11-22 14:13:30 +00:00
|
|
|
use Blax\Shop\Models\ProductPrice;
|
2025-11-23 14:07:12 +00:00
|
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
2025-11-21 10:49:41 +00:00
|
|
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|
|
|
|
use Illuminate\Support\Collection;
|
|
|
|
|
|
|
|
|
|
trait HasShoppingCapabilities
|
|
|
|
|
{
|
2025-11-25 23:05:46 +00:00
|
|
|
use HasChargingOptions, HasCart;
|
2025-11-23 14:07:12 +00:00
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
/**
|
|
|
|
|
* Get all purchases made by this entity
|
|
|
|
|
*/
|
|
|
|
|
public function purchases(): MorphMany
|
|
|
|
|
{
|
2025-11-23 14:07:12 +00:00
|
|
|
// This morph represents the purchaser (e.g. User), not the product.
|
2025-11-21 10:49:41 +00:00
|
|
|
return $this->morphMany(
|
|
|
|
|
config('shop.models.product_purchase', ProductPurchase::class),
|
2025-11-23 14:07:12 +00:00
|
|
|
'purchaser'
|
2025-11-21 10:49:41 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get completed purchases
|
|
|
|
|
*/
|
|
|
|
|
public function completedPurchases(): MorphMany
|
|
|
|
|
{
|
|
|
|
|
return $this->purchases()->where('status', 'completed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Purchase a product
|
|
|
|
|
*
|
2025-11-25 11:33:42 +00:00
|
|
|
* @param Product|Product $product_or_price
|
2025-11-21 10:49:41 +00:00
|
|
|
* @param int $quantity
|
2025-11-22 14:13:30 +00:00
|
|
|
*
|
2025-11-21 10:49:41 +00:00
|
|
|
* @return ProductPurchase
|
|
|
|
|
* @throws \Exception
|
|
|
|
|
*/
|
2025-11-22 14:13:30 +00:00
|
|
|
public function purchase(
|
2025-11-25 11:33:42 +00:00
|
|
|
ProductPrice|Product $product_or_price,
|
2025-11-22 14:13:30 +00:00
|
|
|
int $quantity = 1,
|
2025-11-25 11:33:42 +00:00
|
|
|
array|object|null $meta = null
|
2025-11-22 14:13:30 +00:00
|
|
|
): ProductPurchase {
|
|
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
if ($product_or_price instanceof Product) {
|
|
|
|
|
$default_prices = $product_or_price->defaultPrice()->count();
|
2025-11-23 14:07:12 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
if ($default_prices === 0) {
|
|
|
|
|
throw new NotPurchasable("Product has no default price");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($default_prices > 1) {
|
|
|
|
|
throw new MultiplePurchaseOptions("Product has multiple default prices, please specify a price to purchase");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$price = $product_or_price->defaultPrice()->first();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!@$price) {
|
|
|
|
|
$price = ($product_or_price instanceof ProductPrice)
|
|
|
|
|
? $product_or_price
|
|
|
|
|
: throw new NotPurchasable;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$price?->purchasable?->id) {
|
2025-11-22 14:13:30 +00:00
|
|
|
throw new \Exception("Price does not belong to the specified product");
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$product = $price->purchasable;
|
2025-11-23 14:07:12 +00:00
|
|
|
|
|
|
|
|
// product must have interface Purchasable
|
|
|
|
|
if (!in_array('Blax\Shop\Contracts\Purchasable', class_implements($product))) {
|
|
|
|
|
throw new \Exception("The product is not purchasable");
|
|
|
|
|
}
|
2025-11-22 14:13:30 +00:00
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
// Validate stock availability
|
|
|
|
|
if ($product->manage_stock) {
|
|
|
|
|
$available = $product->getAvailableStock();
|
|
|
|
|
if ($available < $quantity) {
|
2025-11-24 06:00:07 +00:00
|
|
|
throw new NotEnoughStockException("Insufficient stock. Available: {$available}, Requested: {$quantity}");
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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([
|
2025-11-23 14:07:12 +00:00
|
|
|
'purchasable_id' => $product->id,
|
|
|
|
|
'purchasable_type' => get_class($product),
|
|
|
|
|
'purchaser_id' => $this->getKey(),
|
|
|
|
|
'purchaser_type' => get_class($this),
|
2025-11-21 10:49:41 +00:00
|
|
|
'quantity' => $quantity,
|
2025-11-22 14:13:30 +00:00
|
|
|
'status' => 'unpaid',
|
2025-11-25 11:33:42 +00:00
|
|
|
'meta' => $meta,
|
|
|
|
|
'amount' => $price->unit_amount * $quantity,
|
2025-11-21 10:49:41 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Trigger product actions
|
|
|
|
|
$product->callActions('purchased', $purchase, [
|
|
|
|
|
'purchaser' => $this,
|
|
|
|
|
]);
|
|
|
|
|
|
2025-11-23 14:07:12 +00:00
|
|
|
$purchase->fresh();
|
|
|
|
|
|
|
|
|
|
if (!$purchase) {
|
|
|
|
|
throw new \Exception("Unable to create purchase record");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!$purchase->purchasable || $purchase->purchasable->id !== $product->id) {
|
|
|
|
|
throw new \Exception("Purchase record does not match the product");
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-21 10:49:41 +00:00
|
|
|
return $purchase;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
{
|
2025-11-23 14:07:12 +00:00
|
|
|
$items = $this->cartItems()
|
|
|
|
|
->with('purchasable')
|
|
|
|
|
->get();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
|
|
|
|
if ($items->isEmpty()) {
|
|
|
|
|
throw new \Exception("Cart is empty");
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-23 14:07:12 +00:00
|
|
|
$purchases = collect();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-24 06:00:07 +00:00
|
|
|
// Create ProductPurchase for each cart item
|
|
|
|
|
foreach ($items as $item) {
|
|
|
|
|
$product = $item->purchasable;
|
|
|
|
|
$quantity = $item->quantity;
|
|
|
|
|
|
|
|
|
|
$purchase = $this->purchase(
|
|
|
|
|
$product->prices()->first(),
|
|
|
|
|
$quantity
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$purchases->push($purchase);
|
|
|
|
|
|
|
|
|
|
// Remove item from cart
|
|
|
|
|
$item->delete();
|
|
|
|
|
}
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$cart = $this->currentCart();
|
|
|
|
|
$cart->update([
|
|
|
|
|
'converted_at' => now(),
|
|
|
|
|
]);
|
|
|
|
|
|
2025-11-23 14:07:12 +00:00
|
|
|
return $purchases;
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if entity has purchased a product
|
|
|
|
|
*
|
2025-11-25 11:33:42 +00:00
|
|
|
* @param Purchasable|int $product
|
2025-11-21 10:49:41 +00:00
|
|
|
* @return bool
|
|
|
|
|
*/
|
2025-11-25 11:33:42 +00:00
|
|
|
public function hasPurchased($purchasable): bool
|
2025-11-21 10:49:41 +00:00
|
|
|
{
|
|
|
|
|
return $this->completedPurchases()
|
2025-11-25 11:33:42 +00:00
|
|
|
->where('purchasable_id', $purchasable->id)
|
2025-11-21 10:49:41 +00:00
|
|
|
->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();
|
|
|
|
|
}
|
|
|
|
|
}
|