laravel-shop/docs/03-purchasing.md

17 KiB

Purchasing & Shopping Cart

Setup

Add the HasShoppingCapabilities trait to your User model (or any model that should be able to purchase products):

use Blax\Shop\Traits\HasShoppingCapabilities;

class User extends Authenticatable
{
    use HasShoppingCapabilities;
}

This trait provides methods for:

  • Direct product purchases
  • Shopping cart management
  • Purchase history
  • Cart checkout

Direct Purchase

Purchase a Product

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

// Product must have a default price
try {
    $purchase = $user->purchase($product);
    
    // Purchase successful
    return response()->json([
        'success' => true,
        'purchase_id' => $purchase->id,
        'amount' => $purchase->amount,
    ]);
} catch (\Exception $e) {
    return response()->json([
        'error' => $e->getMessage()
    ], 400);
}

Purchase with Specific Price

$price = ProductPrice::find($priceId);

$purchase = $user->purchase(
    $price,
    quantity: 2
);

Purchase with Metadata

$purchase = $user->purchase(
    $product,
    quantity: 1,
    meta: [
        'gift' => true,
        'message' => 'Happy Birthday!',
        'gift_recipient' => 'john@example.com',
    ]
);

Important Notes

  • Product must have at least one default price
  • Product must not have multiple default prices (will throw MultiplePurchaseOptions exception)
  • If stock management is enabled, sufficient stock must be available
  • Product must be visible (published, visible flag, and published_at date)
  • Purchase automatically decreases stock if manage_stock is enabled
  • Product actions are automatically triggered on purchase

Shopping Cart

Add to Cart

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

try {
    // For regular products
    $cartItem = $user->addToCart($product, quantity: 1);
    
    // For booking products (requires dates)
    $from = Carbon::parse('2025-01-15');
    $until = Carbon::parse('2025-01-20');
    $cartItem = $user->addToCart($product, quantity: 1, parameters: [
        'from' => $from,
        'until' => $until,
    ]);
    
    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);
}

Add with Parameters

$cartItem = $user->addToCart(
    $product,
    quantity: 2,
    parameters: [
        'color' => 'blue',
        'size' => 'large',
    ]
);

Get Cart Items

$cartItems = $user->cartItems()->get();

foreach ($cartItems as $item) {
    echo $item->purchasable->getLocalized('name');
    echo $item->quantity;
    echo $item->price;
    echo $item->subtotal;
}

Update Cart Item Quantity

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

try {
    $updatedItem = $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 = CartItem::find($cartItemId);

$user->removeFromCart($cartItem);

return response()->json([
    'success' => true,
    'cart_total' => $user->getCartTotal(),
    'cart_count' => $user->getCartItemsCount(),
]);

Clear Cart

$count = $user->clearCart();

return response()->json([
    'success' => true,
    'removed_items' => $count,
]);

Get Cart Totals

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

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

// Get cart stats
$stats = [
    'total' => $user->getCartTotal(),
    'count' => $user->getCartItemsCount(),
    'items' => $user->cartItems()->with('purchasable')->get(),
];

Cart Checkout

Checkout Cart

try {
    // Get current cart
    $cart = $user->currentCart();
    
    // Checkout (creates purchases and order)
    $cart->checkout();
    
    // Access the order
    $order = $cart->order;
    
    return response()->json([
        'success' => true,
        'order' => $order,
        'order_number' => $order->order_number,
    ]);
} catch (\Exception $e) {
    return response()->json([
        'error' => $e->getMessage()
    ], 400);
}

What Happens During Checkout

  1. Validates Cart

    • Checks that cart is not empty
    • Validates all items have required information
    • For booking products: validates dates are set
  2. Claims Stock

    • Claims stock for booking/pool products
    • Validates stock availability
  3. Creates Order

    • Generates order number
    • Creates Order record linked to cart
    • Copies cart total to order amounts
  4. Creates Purchases

    • Creates ProductPurchase records for each cart item
    • Links purchases to order
  5. Converts Cart

    • Marks cart as CONVERTED
    • Sets converted_at timestamp

Important Notes

  • Stock is claimed at checkout time (not add-to-cart time for bookings)
  • Cart items remain in database but are marked as converted
  • Order is created with PENDING status by default

Purchase History

Get All Purchases

// Get all purchases (any status)
$allPurchases = $user->purchases()->get();

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

// Get purchases for specific product
$productPurchases = $user->purchases()
    ->where('purchasable_id', $product->id)
    ->where('purchasable_type', Product::class)
    ->get();

Order Management

Get All Orders

// Get all orders
$orders = $user->orders()->get();

// Get orders with specific status
use Blax\Shop\Enums\OrderStatus;

$pendingOrders = $user->pendingOrders()->get();
$processingOrders = $user->processingOrders()->get();
$completedOrders = $user->completedOrders()->get();

// Get active orders (not completed/cancelled/refunded)
$activeOrders = $user->activeOrders()->get();

Order Status Flow

Orders progress through these statuses:

  1. PENDING - Order received but awaiting payment confirmation
  2. PROCESSING - Payment received and order is being processed
  3. ON_HOLD - Order on hold, awaiting further action
  4. IN_PREPARATION - Order being prepared (packing, manufacturing)
  5. READY_FOR_PICKUP - Order ready for pickup (for local pickup orders)
  6. SHIPPED - Order has been shipped and is in transit
  7. DELIVERED - Order delivered to customer
  8. COMPLETED - Order complete, all actions fulfilled
  9. CANCELLED - Order was cancelled
  10. REFUNDED - Order was refunded
  11. FAILED - Payment or processing failed

Get Order by Number

$order = $user->findOrderByNumber('ORD-2025-0001');

if ($order) {
    echo "Order found: {$order->order_number}";
}

Order Details

$order = Order::find($orderId);

// Order properties
$order->order_number;       // Unique order number
$order->status;             // OrderStatus enum
$order->amount_total;       // Total amount (in cents)
$order->amount_paid;        // Amount paid (in cents)
$order->amount_subtotal;    // Subtotal before tax/shipping
$order->amount_tax;         // Tax amount
$order->amount_shipping;    // Shipping cost
$order->amount_discount;    // Discount applied
$order->amount_refunded;    // Amount refunded

// Dates
$order->created_at;         // When order was created
$order->paid_at;            // When payment was received
$order->shipped_at;         // When order was shipped
$order->delivered_at;       // When order was delivered
$order->completed_at;       // When order was completed
$order->cancelled_at;       // When order was cancelled
$order->refunded_at;        // When order was refunded

// Additional info
$order->payment_method;     // Payment method used
$order->payment_provider;   // Payment provider (e.g., 'stripe')
$order->payment_reference;  // Provider reference ID
$order->billing_address;    // Billing address object
$order->shipping_address;   // Shipping address object
$order->customer_note;      // Customer's note
$order->internal_note;      // Internal staff note

Order Relationships

// Get order customer
$customer = $order->customer;

// Get order purchases (line items)
$purchases = $order->purchases()->get();

// Get original cart
$cart = $order->cart;

// Get order notes
$notes = $order->notes()->get();

Order Statistics

// Total spent across all orders
$totalSpent = $user->total_spent; // Accessor in cents

// Number of orders
$orderCount = $user->order_count;

// Number of completed orders
$completedCount = $user->completed_order_count;

// Check if user has any orders
if ($user->hasOrders()) {
    echo "Customer has placed orders";
}

// Check if user has active orders
if ($user->hasActiveOrders()) {
    echo "Customer has orders in progress";
}

// Get latest order
$latestOrder = $user->latestOrder();

Filter Orders by Date

$from = Carbon::parse('2025-01-01');
$to = Carbon::parse('2025-12-31');

$ordersThisYear = $user->ordersBetween($from, $to)->get();

Order Payment Status

// Check if order is paid
if ($order->is_paid) {
    echo "Order has been paid";
}

// Check if fully paid
if ($order->is_fully_paid) {
    echo "Order is fully paid";
}

// Get outstanding amount
$outstanding = $order->amount_outstanding; // In cents

Update Order Status

use Blax\Shop\Enums\OrderStatus;

// Update order status
$order->update(['status' => OrderStatus::PROCESSING]);

// Mark as shipped
$order->update([
    'status' => OrderStatus::SHIPPED,
    'shipped_at' => now(),
]);

// Mark as delivered
$order->update([
    'status' => OrderStatus::DELIVERED,
    'delivered_at' => now(),
]);

// Mark as completed
$order->update([
    'status' => OrderStatus::COMPLETED,
    'completed_at' => now(),
]);

Add Order Notes

use Blax\Shop\Models\OrderNote;

// Add customer-visible note
OrderNote::create([
    'order_id' => $order->id,
    'content' => 'Your order has been shipped!',
    'is_customer_note' => true,
]);

// Add internal note
OrderNote::create([
    'order_id' => $order->id,
    'content' => 'Customer requested gift wrapping',
    'is_customer_note' => false,
]);

// Get all notes
$allNotes = $order->notes()->get();

// Get customer-visible notes only
$customerNotes = $order->notes()->where('is_customer_note', true)->get();

Refunds

Refund an Order

use Blax\Shop\Enums\OrderStatus;

$order = Order::find($orderId);

// Mark order as refunded
$order->update([
    'status' => OrderStatus::REFUNDED,
    'refunded_at' => now(),
    'amount_refunded' => $order->amount_total,
]);

// Stock will be released back from associated purchases

Cart Model

Get Current Cart

// Get or create current active cart
$cart = $user->currentCart();

// Cart properties
$cart->session_id;      // Session ID for guest carts
$cart->customer_id;     // User ID
$cart->customer_type;   // User model class
$cart->currency;        // Cart currency (default: USD)
$cart->status;          // active, abandoned, converted, expired
$cart->converted_at;    // When cart was checked out
$cart->expires_at;      // Cart expiration date
$cart->last_activity_at; // Last activity timestamp

Cart Relationships

// Get cart items
$items = $cart->items()->get();

// Get cart order (if converted)
$order = $cart->order;

// Get cart customer (user)
$customer = $cart->customer;

Cart Methods

// Get cart total
$total = $cart->getTotal();

// Get total items
$itemCount = $cart->getTotalItems();

// Check if cart is expired
if ($cart->isExpired()) {
    // Cart has expired
}

// Check if cart is converted
if ($cart->isConverted()) {
    // Cart has been checked out
}

Add Items to Cart Directly

use Blax\Shop\Models\Cart;

$cart = Cart::find($cartId);

$cartItem = $cart->addToCart(
    $product, // or $productPrice
    quantity: 2,
    parameters: ['size' => 'L']
);

Product Purchase Model

Purchase Properties

$purchase = ProductPurchase::find($purchaseId);

$purchase->status;           // pending, unpaid, completed, refunded, failed
$purchase->cart_id;          // Associated cart ID
$purchase->price_id;         // Associated price ID
$purchase->purchasable_id;   // Product ID
$purchase->purchasable_type; // Product class
$purchase->purchaser_id;     // User ID
$purchase->purchaser_type;   // User class
$purchase->quantity;         // Quantity purchased
$purchase->amount;           // Total amount (in cents)
$purchase->amount_paid;      // Amount paid (in cents)
$purchase->charge_id;        // Payment charge ID
$purchase->from;             // Booking start date (for bookings)
$purchase->until;            // Booking end date (for bookings)
$purchase->meta;             // Additional metadata

Purchase Relationships

// Get purchased product
$product = $purchase->purchasable;

// Get purchaser (user)
$user = $purchase->purchaser;

// Get associated cart item
$cartItem = $purchase->cartItem;

// Get associated order
$order = $purchase->order;

Purchase Scopes

use Blax\Shop\Enums\PurchaseStatus;

// Get completed purchases
$completed = ProductPurchase::where('status', PurchaseStatus::COMPLETED)->get();

// Get pending purchases
$pending = ProductPurchase::where('status', PurchaseStatus::PENDING)->get();

Stock Claims

When adding booking products to cart, stock is claimed at checkout time:

// For booking products, stock is NOT claimed when adding to cart
$cartItem = $user->addToCart($bookingProduct, quantity: 1, parameters: [
    'from' => Carbon::parse('2025-01-15'),
    'until' => Carbon::parse('2025-01-20'),
]);

// Stock is validated and claimed during checkout
$cart = $user->currentCart();
$cart->checkout(); // Claims stock at this point

// For regular products, stock is claimed immediately when adding to cart
$cartItem = $user->addToCart($regularProduct, quantity: 2);
// Stock is claimed immediately for non-booking products

Error Handling

Common Exceptions

use Blax\Shop\Exceptions\NotPurchasable;
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
use Blax\Shop\Exceptions\NotEnoughStockException;

try {
    $purchase = $user->purchase($product);
} catch (NotPurchasable $e) {
    // Product has no default price
} catch (MultiplePurchaseOptions $e) {
    // Product has multiple default prices - need to specify which one
    $price = $product->prices()->where('currency', 'USD')->first();
    $purchase = $user->purchase($price);
} catch (NotEnoughStockException $e) {
    // Insufficient stock available
    $available = $product->getAvailableStock();
    echo "Only {$available} items available";
} catch (\Exception $e) {
    // General error
    echo $e->getMessage();
}

Complete Example

// Product listing
Route::get('/products', function () {
    $products = Product::visible()
        ->inStock()
        ->with(['prices' => fn($q) => $q->where('is_default', true)])
        ->get();
    
    return view('products.index', compact('products'));
});

// Add to cart
Route::post('/cart/add/{product}', function (Product $product) {
    $user = auth()->user();
    
    try {
        $cartItem = $user->addToCart($product, quantity: 1);
        
        return redirect()->back()->with('success', 'Product added to cart!');
    } catch (\Exception $e) {
        return redirect()->back()->with('error', $e->getMessage());
    }
});

// View cart
Route::get('/cart', function () {
    $user = auth()->user();
    
    $cartItems = $user->cartItems()->with('purchasable')->get();
    $cartTotal = $user->getCartTotal();
    $cartCount = $user->getCartItemsCount();
    
    return view('cart.index', compact('cartItems', 'cartTotal', 'cartCount'));
});

// Checkout
Route::post('/checkout', function () {
    $user = auth()->user();
    
    try {
        $cart = $user->currentCart();
        $cart->checkout();
        
        // Access the created order
        $order = $cart->order;
        
        return redirect()->route('orders.success', ['order' => $order->id])
            ->with('success', "Order {$order->order_number} placed successfully!");
    } catch (\Exception $e) {
        return redirect()->back()->with('error', $e->getMessage());
    }
});

// Order history
Route::get('/orders', function () {
    $user = auth()->user();
    
    $orders = $user->orders()
        ->with(['purchases.purchasable'])
        ->orderBy('created_at', 'desc')
        ->get();
    
    return view('orders.index', compact('orders'));
});

// View specific order
Route::get('/orders/{order}', function (Order $order) {
    $user = auth()->user();
    
    // Ensure user owns this order
    if ($order->customer_id !== $user->id) {
        abort(403);
    }
    
    $order->load(['purchases.purchasable', 'notes']);
    
    return view('orders.show', compact('order'));
});