laravel-shop/docs/03-purchasing.md

13 KiB

Purchasing Products

Setup

First, add the HasShoppingCapabilities trait to your User model (or any model that should purchase products):

use Blax\Shop\Traits\HasShoppingCapabilities;

class User extends Authenticatable
{
    use HasShoppingCapabilities;
}

Direct Purchase

Simple Purchase

$user = auth()->user();
$product = Product::find($productId);

try {
    $purchase = $user->purchase($product, quantity: 1);
    
    // Purchase successful
    return response()->json([
        'success' => true,
        'purchase_id' => $purchase->id,
    ]);
} catch (\Exception $e) {
    return response()->json([
        'error' => $e->getMessage()
    ], 400);
}

Purchase with Options

$purchase = $user->purchase($product, quantity: 2, options: [
    'price_id' => $priceId,        // Use specific price
    'charge_id' => $paymentId,     // Associate with payment
    'cart_id' => $cartId,          // Associate with cart
    'status' => 'pending',         // Custom status
]);

Check Purchase History

// Check if user has purchased a product
if ($user->hasPurchased($product)) {
    // User has purchased this product
}

// Get purchase history for a product
$history = $user->getPurchaseHistory($product);

// Get all completed purchases
$purchases = $user->completedPurchases()->get();

Shopping Cart

Add to Cart

$user = auth()->user();
$product = Product::find($productId);

try {
    $cartItem = $user->addToCart($product, quantity: 1);
    
    return response()->json([
        'success' => true,
        'cart_item' => $cartItem,
        'cart_total' => $user->getCartTotal(),
        'cart_count' => $user->getCartItemsCount(),
    ]);
} catch (\Exception $e) {
    return response()->json(['error' => $e->getMessage()], 400);
}

Update Cart Quantity

$cartItem = ProductPurchase::find($cartItemId);

try {
    $user->updateCartQuantity($cartItem, quantity: 3);
    
    return response()->json([
        'success' => true,
        'cart_total' => $user->getCartTotal(),
    ]);
} catch (\Exception $e) {
    return response()->json(['error' => $e->getMessage()], 400);
}

Remove from Cart

$cartItem = ProductPurchase::find($cartItemId);

$user->removeFromCart($cartItem);

Get Cart Information

// Get all cart items
$cartItems = $user->cartItems()->with('product')->get();

// Get cart total
$total = $user->getCartTotal();

// Get items count
$count = $user->getCartItemsCount();

// Clear cart
$user->clearCart();

Checkout

try {
    $completedPurchases = $user->checkout(options: [
        'charge_id' => $paymentIntent->id,
    ]);
    
    return response()->json([
        'success' => true,
        'purchases' => $completedPurchases,
        'total' => $completedPurchases->sum('amount'),
    ]);
} catch (\Exception $e) {
    return response()->json(['error' => $e->getMessage()], 400);
}

Refunds

$purchase = ProductPurchase::find($purchaseId);
$user = $purchase->purchasable;

try {
    $user->refundPurchase($purchase, options: [
        'refund_id' => $refundId,
        'reason' => 'Customer request',
    ]);
    
    return response()->json(['success' => true]);
} catch (\Exception $e) {
    return response()->json(['error' => $e->getMessage()], 400);
}

Purchase Statistics

$stats = $user->getPurchaseStats();

// Returns:
// [
//     'total_purchases' => 10,
//     'total_spent' => 299.90,
//     'total_items' => 15,
//     'cart_items' => 2,
//     'cart_total' => 49.98,
// ]

Basic Purchase Flow

1. Check Product Availability

use Blax\Shop\Models\Product;

$product = Product::find($productId);
$quantity = 1;

// Check if product is available
if (!$product->isVisible()) {
    return response()->json(['error' => 'Product not available'], 404);
}

// Check stock
if ($product->manage_stock) {
    $available = $product->getAvailableStock();
    
    if ($available < $quantity) {
        return response()->json([
            'error' => 'Insufficient stock',
            'available' => $available
        ], 400);
    }
}

2. Reserve Stock (Optional)

Reserve stock during checkout process:

// Reserve for 15 minutes
$reservation = $product->reserveStock(
    quantity: $quantity,
    reference: auth()->user(),
    until: now()->addMinutes(15),
    note: 'Checkout reservation'
);

if (!$reservation) {
    return response()->json(['error' => 'Unable to reserve stock'], 400);
}

// Store reservation ID in session
session(['stock_reservation_id' => $reservation->id]);

3. Process Payment

// Your payment processing logic
$payment = PaymentService::process([
    'amount' => $product->getCurrentPrice() * $quantity,
    'currency' => 'USD',
    'product_id' => $product->id,
]);

if ($payment->failed()) {
    // Release reservation
    $reservation->update(['status' => 'cancelled']);
    return response()->json(['error' => 'Payment failed'], 400);
}

4. Complete Purchase

use Blax\Shop\Models\ProductPurchase;

// Decrease stock
$product->decreaseStock($quantity);

// Create purchase record
$purchase = ProductPurchase::create([
    'product_id' => $product->id,
    'purchasable_type' => get_class(auth()->user()),
    'purchasable_id' => auth()->id(),
    'quantity' => $quantity,
    'status' => 'completed',
    'meta' => [
        'payment_id' => $payment->id,
        'price_paid' => $product->getCurrentPrice(),
        'currency' => 'USD',
    ],
]);

// Complete reservation
if ($reservation) {
    $reservation->update(['status' => 'completed']);
}

// Trigger product actions
$product->callActions('purchased', $purchase, [
    'user' => auth()->user(),
    'payment' => $payment,
]);

return response()->json([
    'success' => true,
    'purchase_id' => $purchase->id,
]);

Shopping Cart Implementation

Cart Item Model

// app/Models/CartItem.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Blax\Shop\Models\Product;

class CartItem extends Model
{
    protected $fillable = [
        'cart_id',
        'product_id',
        'quantity',
        'price',
    ];

    protected $casts = [
        'price' => 'decimal:2',
    ];

    public function product()
    {
        return $this->belongsTo(Product::class);
    }

    public function getSubtotal()
    {
        return $this->price * $this->quantity;
    }
}

Cart Service

// app/Services/CartService.php
namespace App\Services;

use App\Models\CartItem;
use Blax\Shop\Models\Product;

class CartService
{
    public function add(Product $product, int $quantity = 1)
    {
        $cart = $this->getCart();

        // Check stock
        if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
            throw new \Exception('Insufficient stock');
        }

        // Check if item already in cart
        $cartItem = $cart->items()->where('product_id', $product->id)->first();

        if ($cartItem) {
            $newQuantity = $cartItem->quantity + $quantity;
            
            // Check stock for new quantity
            if ($product->manage_stock && $product->getAvailableStock() < $newQuantity) {
                throw new \Exception('Insufficient stock for requested quantity');
            }
            
            $cartItem->update(['quantity' => $newQuantity]);
        } else {
            $cartItem = $cart->items()->create([
                'product_id' => $product->id,
                'quantity' => $quantity,
                'price' => $product->getCurrentPrice(),
            ]);
        }

        return $cartItem;
    }

    public function update(CartItem $cartItem, int $quantity)
    {
        $product = $cartItem->product;

        // Check stock
        if ($product->manage_stock && $product->getAvailableStock() < $quantity) {
            throw new \Exception('Insufficient stock');
        }

        $cartItem->update(['quantity' => $quantity]);

        return $cartItem;
    }

    public function remove(CartItem $cartItem)
    {
        $cartItem->delete();
    }

    public function clear()
    {
        $cart = $this->getCart();
        $cart->items()->delete();
    }

    public function getTotal()
    {
        $cart = $this->getCart();
        return $cart->items->sum(fn($item) => $item->getSubtotal());
    }

    public function checkout()
    {
        $cart = $this->getCart();
        $items = $cart->items()->with('product')->get();

        // Reserve stock for all items
        $reservations = [];
        foreach ($items as $item) {
            $reservation = $item->product->reserveStock(
                $item->quantity,
                $cart,
                now()->addMinutes(15)
            );

            if (!$reservation) {
                // Rollback previous reservations
                foreach ($reservations as $res) {
                    $res->update(['status' => 'cancelled']);
                }
                throw new \Exception('Unable to reserve stock for: ' . $item->product->getLocalized('name'));
            }

            $reservations[] = $reservation;
        }

        return [
            'items' => $items,
            'reservations' => $reservations,
            'total' => $this->getTotal(),
        ];
    }

    protected function getCart()
    {
        // Implementation depends on your cart system
        // Could be session-based or user-based
        return auth()->user()->cart ?? session()->get('cart');
    }
}

Cart Controller

// app/Http/Controllers/CartController.php
namespace App\Http\Controllers;

use App\Services\CartService;
use Blax\Shop\Models\Product;
use Illuminate\Http\Request;

class CartController extends Controller
{
    public function __construct(
        protected CartService $cartService
    ) {}

    public function add(Request $request, Product $product)
    {
        $validated = $request->validate([
            'quantity' => 'required|integer|min:1',
        ]);

        try {
            $cartItem = $this->cartService->add($product, $validated['quantity']);

            return response()->json([
                'success' => true,
                'cart_item' => $cartItem,
                'cart_total' => $this->cartService->getTotal(),
            ]);
        } catch (\Exception $e) {
            return response()->json([
                'error' => $e->getMessage()
            ], 400);
        }
    }

    public function update(Request $request, $cartItemId)
    {
        $validated = $request->validate([
            'quantity' => 'required|integer|min:1',
        ]);

        $cartItem = CartItem::findOrFail($cartItemId);

        try {
            $this->cartService->update($cartItem, $validated['quantity']);

            return response()->json([
                'success' => true,
                'cart_total' => $this->cartService->getTotal(),
            ]);
        } catch (\Exception $e) {
            return response()->json([
                'error' => $e->getMessage()
            ], 400);
        }
    }

    public function remove($cartItemId)
    {
        $cartItem = CartItem::findOrFail($cartItemId);
        $this->cartService->remove($cartItem);

        return response()->json([
            'success' => true,
            'cart_total' => $this->cartService->getTotal(),
        ]);
    }

    public function checkout()
    {
        try {
            $checkoutData = $this->cartService->checkout();

            return response()->json([
                'success' => true,
                'checkout' => $checkoutData,
            ]);
        } catch (\Exception $e) {
            return response()->json([
                'error' => $e->getMessage()
            ], 400);
        }
    }
}

Handling Refunds

public function refund($purchaseId)
{
    $purchase = ProductPurchase::findOrFail($purchaseId);
    $product = $purchase->product;

    // Process refund with payment processor
    $refund = PaymentService::refund($purchase->meta['payment_id']);

    if ($refund->success) {
        // Return stock
        $product->increaseStock($purchase->quantity);

        // Update purchase status
        $purchase->update([
            'status' => 'refunded',
            'meta' => array_merge($purchase->meta, [
                'refund_id' => $refund->id,
                'refunded_at' => now(),
            ]),
        ]);

        // Trigger refund actions
        $product->callActions('refunded', $purchase, [
            'refund' => $refund,
        ]);

        return response()->json(['success' => true]);
    }

    return response()->json(['error' => 'Refund failed'], 400);
}

Product Actions on Purchase

Product actions allow you to execute custom logic when products are purchased:

use Blax\Shop\Models\ProductAction;

// Create action to grant access to a course
ProductAction::create([
    'product_id' => $product->id,
    'action_type' => 'grant_access',
    'event' => 'purchased',
    'config' => [
        'resource_type' => 'course',
        'resource_id' => 123,
    ],
    'active' => true,
]);

// Action is automatically triggered when product is purchased
// Implement the action handler in your application

See Product Actions documentation for more details.