laravel-shop/docs/03-purchasing.md

18 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'));
});

Fulfillment via events

A purchase becoming COMPLETED is the moment to grant access, send a receipt, provision a licence, etc. The package exposes that as a first-class, model-agnostic event so host apps don't have to couple to the ProductAction table or to a specific purchasable model:

use Blax\Shop\Events\PurchaseCompleted;

class GrantAccessOnPurchase
{
    public function handle(PurchaseCompleted $event): void
    {
        $purchase = $event->purchase;        // Blax\Shop\Models\ProductPurchase
        $item     = $purchase->purchasable;  // the Product / ProductPrice / host model sold

        // grant roles, unlock content, email a licence key, …
    }
}

PurchaseCompleted fires:

  • when a purchase row is created already COMPLETED (e.g. a paid checkout),
  • when an existing purchase transitions into COMPLETED (PENDING → COMPLETED),

and not on later, unrelated saves of an already-completed purchase. For the broader stream of new rows regardless of status, listen to PurchaseCreated instead.

In addition, any ProductAction configured on the product with the purchased event still runs automatically on completion — the product is resolved via config('shop.models.product') / ...product_price, so apps overriding those models are covered too.