2025-11-21 10:49:41 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Blax\Shop\Traits;
|
|
|
|
|
|
2025-11-29 19:09:19 +00:00
|
|
|
use Blax\Shop\Contracts\Purchasable;
|
2025-12-03 12:59:01 +00:00
|
|
|
use Blax\Shop\Enums\ProductType;
|
|
|
|
|
use Blax\Shop\Enums\PurchaseStatus;
|
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-28 09:24:07 +00:00
|
|
|
use Blax\Shop\Models\Cart;
|
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
|
|
|
|
|
{
|
2025-12-03 12:59:01 +00:00
|
|
|
return $this->purchases()->where('status', PurchaseStatus::COMPLETED->value);
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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-12-03 12:59:01 +00:00
|
|
|
* @param array|object|null $meta
|
|
|
|
|
* @param \DateTimeInterface|null $from Booking start date (for booking products)
|
|
|
|
|
* @param \DateTimeInterface|null $until Booking end date (for booking products)
|
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-12-03 12:59:01 +00:00
|
|
|
array|object|null $meta = null,
|
|
|
|
|
?\DateTimeInterface $from = null,
|
|
|
|
|
?\DateTimeInterface $until = 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-29 11:05:02 +00:00
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-03 12:59:01 +00:00
|
|
|
// Handle booking products
|
|
|
|
|
$isBooking = $product->type === ProductType::BOOKING;
|
|
|
|
|
|
|
|
|
|
if ($isBooking && (!$from || !$until)) {
|
|
|
|
|
throw new \Exception("Booking products require 'from' and 'until' dates");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Decrease stock (for bookings, pass the until date)
|
|
|
|
|
if (!$product->decreaseStock($quantity, $isBooking ? $until : null)) {
|
2025-11-21 10:49:41 +00:00
|
|
|
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-12-03 12:59:01 +00:00
|
|
|
'status' => PurchaseStatus::UNPAID,
|
|
|
|
|
'from' => $from,
|
|
|
|
|
'until' => $until,
|
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
|
2025-11-28 09:24:07 +00:00
|
|
|
* @return Cart
|
2025-11-21 10:49:41 +00:00
|
|
|
* @throws \Exception
|
|
|
|
|
*/
|
2025-11-29 11:05:02 +00:00
|
|
|
public function checkoutCart(?string $cartId = null): Cart
|
2025-11-21 10:49:41 +00:00
|
|
|
{
|
2025-11-29 11:05:02 +00:00
|
|
|
$cart = Cart::where('id', $cartId)
|
|
|
|
|
->where('customer_id', $this->getKey())
|
|
|
|
|
->where('customer_type', get_class($this))
|
|
|
|
|
->first();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-29 11:05:02 +00:00
|
|
|
$cart ??= $this->currentCart();
|
2025-11-25 11:33:42 +00:00
|
|
|
|
2025-11-29 11:05:02 +00:00
|
|
|
return $cart->checkout();
|
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
|
|
|
|
|
{
|
2025-12-03 12:59:01 +00:00
|
|
|
if ($purchase->status !== PurchaseStatus::COMPLETED) {
|
2025-11-21 10:49:41 +00:00
|
|
|
throw new \Exception("Can only refund completed purchases");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$product = $purchase->product;
|
|
|
|
|
|
|
|
|
|
// Return stock
|
|
|
|
|
$product->increaseStock($purchase->quantity);
|
|
|
|
|
|
|
|
|
|
// Update purchase
|
|
|
|
|
$purchase->update([
|
2025-12-03 12:59:01 +00:00
|
|
|
'status' => PurchaseStatus::REFUNDED,
|
2025-11-21 10:49:41 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// 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();
|
|
|
|
|
}
|
|
|
|
|
}
|