Skip to content
advanced Phase 103 · Commerce Design

Cart and Checkout Design

1h 30m
2 problems
Topic Progress 0%

Cart and Checkout Overview

Cart System Architecture

Cart Components:
├── Quote (cart data)
│   ├── Items
│   ├── Addresses (billing, shipping, multi-ship)
│   ├── Payment method + token
│   ├── Shipping method
│   ├── Totals + taxes
│   └── Metadata (session, customer, timestamps)
├── Cart operations
│   ├── Add/remove items
│   ├── Update quantities (with stock check)
│   ├── Apply coupons
│   ├── Merge carts (guest → logged-in)
│   └── Calculate totals (with precision guards)
├── Concurrency layer
│   ├── Optimistic locking on quote
│   ├── Pessimistic locks on inventory
│   ├── Idempotency keys on payment
│   └── Stale cart detection
└── Persistence
    ├── Session storage (guest, transient)
    ├── Database storage (persistent)
    ├── Redis cache (hot carts)
    └── Quote ID management (signed tokens)

Quote Data Model

-- Quote table with concurrency fields
CREATE TABLE quote (
    entity_id INT AUTO_INCREMENT PRIMARY KEY,
    store_id INT,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    converted_at TIMESTAMP NULL,
    is_active TINYINT(1) DEFAULT 1,
    is_virtual TINYINT(1) DEFAULT 0,
    items_count INT DEFAULT 0,
    items_qty DECIMAL(12,4) DEFAULT 0,
    customer_id INT,
    customer_email VARCHAR(255),
    customer_group_id INT,
    customer_firstname VARCHAR(255),
    customer_lastname VARCHAR(255),
    -- Concurrency: version counter for optimistic locking
    version INT DEFAULT 0,
    -- Stale cart detection
    last_active_at TIMESTAMP NULL,
    -- Idempotency: prevents double place-order
    order_placed TINYINT(1) DEFAULT 0
);

-- Quote item table
CREATE TABLE quote_item (
    item_id INT AUTO_INCREMENT PRIMARY KEY,
    quote_id INT,
    parent_item_id INT NULL,
    product_id INT,
    sku VARCHAR(255),
    name VARCHAR(255),
    qty DECIMAL(12,4),
    price DECIMAL(12,4),
    base_price DECIMAL(12,4),
    discount_amount DECIMAL(12,4) DEFAULT 0,
    row_total DECIMAL(12,4),
    base_row_total DECIMAL(12,4),
    options TEXT,
    -- Stock reservation ID (held during checkout)
    reservation_id VARCHAR(64) NULL,
    reservation_expires_at TIMESTAMP NULL
);

-- Quote address table (supports multi-address)
CREATE TABLE quote_address (
    address_id INT AUTO_INCREMENT PRIMARY KEY,
    quote_id INT,
    address_type ENUM('billing','shipping'),
    customer_id INT,
    firstname VARCHAR(255),
    lastname VARCHAR(255),
    street VARCHAR(255),
    city VARCHAR(255),
    region VARCHAR(255),
    postcode VARCHAR(255),
    country_id VARCHAR(2),
    telephone VARCHAR(255),
    email VARCHAR(255),
    -- Multi-address: which items ship to this address
    items TEXT NULL
);

-- Inventory reservation table (prevents overselling)
CREATE TABLE inventory_reservation (
    reservation_id VARCHAR(64) PRIMARY KEY,
    quote_id INT,
    sku VARCHAR(255),
    qty DECIMAL(12,4),
    reserved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP,
    order_id INT NULL,
    status ENUM('active','consumed','expired','released') DEFAULT 'active'
);

Guest vs Logged-In Checkout: Key Differences

Aspect Guest Checkout Logged-In Checkout
Cart persistence Session + signed token Database, cross-device
Address book None; enter each time Saved addresses available
Payment tokens Not saved Can save for reuse
Order history No link Auto-linked
Cart merge N/A Guest cart merged on login
Fraud risk Higher (no history) Lower (behavioral data)
Abandonment recovery Email only (if captured) Email + push + in-app
Checkout speed Slower (manual entry) Faster (prefill)

Trade-Off: Guest Checkout

Pros:
├── Lower friction → higher conversion
├── No account required → wider reach
└── Privacy-friendly

Cons:
├── No saved payment tokens → repeat entry
├── No address book → manual entry each time
├── Harder abandonment recovery
├── Higher fraud risk (no behavioral history)
└── Cart lost across devices

Recommendation:
- Offer guest checkout by default
- Prompt account creation POST-purchase (order confirmation page)
- Capture email early in checkout for abandonment recovery

Quote Management with Concurrency

Quote Operations

// Create quote with optimistic locking support
$quote = $this->quoteFactory->create();
$quote->setStoreId($storeId);
$quote->assignCustomer($customer);
$quote->setVersion(0); // Initial version

// Add product to quote with stock validation
$item = $quote->addProduct($product, $qty);

// Set addresses
$quote->getShippingAddress()
    ->setFirstname('John')
    ->setLastname('Doe')
    ->setStreet(['123 Main St'])
    ->setCity('New York')
    ->setRegionId(43)
    ->setPostcode('10001')
    ->setCountryId('US')
    ->setTelephone('555-0123')
    ->setEmail('john@example.com');

// Calculate totals
$quote->collectTotals();

// Save with version check (optimistic lock)
$quote->save();

Optimistic Locking on Quote

// Prevents lost updates when two tabs/processes modify same quote
public function saveQuoteWithLock($quote)
{
    $originalVersion = $quote->getVersion();
    $quote->setVersion($originalVersion + 1);
    
    try {
        $this->resource->save($quote);
    } catch (\Exception $e) {
        // Another process modified this quote
        if (str_contains($e->getMessage(), 'version')) {
            throw new ConcurrentModificationException(
                'Quote was modified by another process. Please refresh.'
            );
        }
        throw $e;
    }
}

// SQL-level enforcement (MySQL)
// UPDATE quote SET version = version + 1, ... WHERE entity_id = ? AND version = ?
// If affected_rows == 0 → version mismatch → concurrent modification detected

Customer Quote Merge (Login/Guest Transition)

public function mergeGuestQuoteOnLogin($customer, $guestQuote)
{
    $existingQuote = $this->quoteRepository->getActiveForCustomer(
        $customer->getId()
    );
    
    if (!$existingQuote) {
        // No active quote; just assign guest quote to customer
        $guestQuote->assignCustomer($customer);
        $this->quoteRepository->save($guestQuote);
        return $guestQuote;
    }
    
    // Merge: add guest items into existing quote
    foreach ($guestQuote->getItems() as $guestItem) {
        $existingItem = $existingQuote->getItemByProduct($guestItem->getProduct());
        
        if ($existingItem) {
            // Same product in both: take higher quantity
            $maxQty = max($existingItem->getQty(), $guestItem->getQty());
            $existingItem->setQty($maxQty);
        } else {
            // New product: add to existing quote
            $existingQuote->addProduct(
                $guestItem->getProduct(),
                $guestItem->getQty()
            );
        }
    }
    
    // Deactivate guest quote
    $guestQuote->setIsActive(false);
    $this->quoteRepository->save($guestQuote);
    
    // Save merged quote
    $existingQuote->collectTotals();
    $this->quoteRepository->save($existingQuote);
    
    return $existingQuote;
}

Stale Cart Detection & Cleanup

// Detect carts abandoned during checkout
public function getStaleCarts($inactiveMinutes = 30)
{
    $cutoff = date('Y-m-d H:i:s', strtotime("-{$inactiveMinutes} minutes"));
    
    return $this->quoteRepository->getList(
        $this->filterBuilder
            ->create()
            ->addFieldToFilter('is_active', 1)
            ->addFieldToFilter('items_count', ['gt' => 0])
            ->addFieldToFilter('last_active_at', ['lt' => $cutoff])
            ->create()
    );
}

// Cleanup strategy:
// 1. < 2 hours inactive: keep (customer may return)
// 2. 2-24 hours: release inventory reservations
// 3. 24-72 hours: send abandonment recovery email
// 4. > 72 hours: deactivate quote, release all reservations

Quote Totals Calculation

// Total calculation order (deterministic, no float issues)
$totals = [
    'subtotal',           // Sum of item prices (integer cents)
    'discount',           // Coupon/discount rules
    'shipping',           // Shipping cost
n    'tax',                // Tax calculation
    'grand_total'         // Final total
];

// Custom total collector with precision guard
class CustomDiscountTotal extends AbstractTotal
{
    public function collectAddressTotals($quote, $address)
    {
        $total = $address->getTotalAmount($this->getCode());
        
        // Apply custom discount logic
        $discount = $this->calculateDiscount($address);
        
        // Round to avoid floating-point drift
        $discount = round($discount, 2);
        
        $address->setTotalAmount($this->getCode(), $discount);
        $address->setBaseTotalAmount($this->getCode(), $discount);
    }
}

Checkout Flow with Race Condition Prevention

One-Page Checkout (with Concurrency Safeguards)

Step 1: Shipping Address
├── Collect address
├── Validate address (async, non-blocking)
├── Get shipping methods
├── Select shipping method
└── 🔒 Lock: Begin inventory reservation (TTL 10 min)

Step 2: Review & Payment
├── Display order summary
├── Select payment method
├── Enter payment details
├── Apply discount code
├── Review totals
└── 🔒 Validate: Re-check stock before proceeding

Step 3: Place Order (Idempotent)
├── Validate all data
├── Generate idempotency key (client-supplied or server-generated)
├── Process payment (with idempotency key)
├── Create order
├── Consume inventory reservation
├── Clear cart (mark order_placed = 1)
├── Send confirmation (async)
└── Redirect to success

Failure Paths:
├── Stock unavailable → Release reservation, show error, suggest alternatives
├── Payment declined → Release reservation, show error, retry with different method
├── Payment timeout → Hold reservation 15 min, async verification, retry
├── Duplicate request → Idempotency key returns existing order (no double charge)
└── Network failure → Client retries with same idempotency key

Inventory Reservation System

// Reserve inventory when customer enters checkout
public function reserveInventory($quote)
{
    $reservationId = bin2hex(random_bytes(16));
    
    foreach ($quote->getItems() as $item) {
        $available = $this->stockService->getAvailableQty($item->getSku());
        
        if ($available < $item->getQty()) {
            // Race condition: another user grabbed the last item
            throw new InsufficientStockException(
                "{$item->getSku()} only {$available} available"
            );
        }
        
        // Pessimistic lock: reserve with row-level lock
        $this->db->beginTransaction();
        try {
            $this->db->query(
                "UPDATE cataloginventory_stock_item 
                 SET qty = qty - ? 
                 WHERE product_id = ? AND qty >= ?",
                [$item->getQty(), $item->getProductId(), $item->getQty()]
            );
            
            // Check affected rows
            if ($this->db->getAffectedRows() === 0) {
                throw new InsufficientStockException(
                    "{$item->getSku()} went out of stock"
                );
            }
            
            // Record reservation
            $this->reservationRepo->save([
                'reservation_id' => $reservationId,
                'quote_id' => $quote->getId(),
                'sku' => $item->getSku(),
                'qty' => $item->getQty(),
                'expires_at' => date('Y-m-d H:i:s', strtotime('+10 minutes')),
                'status' => 'active'
            ]);
            
            $this->db->commit();
        } catch (\Exception $e) {
            $this->db->rollBack();
            throw $e;
        }
    }
    
    return $reservationId;
}

// Release reservation on failure or timeout
public function releaseReservation($reservationId)
{
    $reservations = $this->reservationRepo->findByReservationId($reservationId);
    
    $this->db->beginTransaction();
    try {
        foreach ($reservations as $reservation) {
            // Return stock
            $this->db->query(
                "UPDATE cataloginventory_stock_item 
                 SET qty = qty + ? 
                 WHERE product_id = ?",
                [$reservation->getQty(), $reservation->getProductId()]
            );
            
            $reservation->setStatus('released');
            $this->reservationRepo->save($reservation);
        }
        $this->db->commit();
    } catch (\Exception $e) {
        $this->db->rollBack();
        throw $e;
    }
}

Race Condition: Two Users Buying Last Item

Scenario: 1 unit of SKU-123 remaining

Timeline (without protection):
──────────────────────────────────────────────
User A                    User B
──────                    ──────
Check stock: 1 avail      Check stock: 1 avail
Add to cart                Add to cart
Place order                Place order
  → Order created           → Order created
  → Stock: -1 = 0           → Stock: -1 = -1 ← OVERSOLD
──────────────────────────────────────────────

Timeline (with reservation lock):
──────────────────────────────────────────────
User A                    User B
──────                    ──────
BEGIN TRANSACTION         BEGIN TRANSACTION
SELECT qty FOR UPDATE     SELECT qty FOR UPDATE
  → qty=1, lock acquired   → qty=1, waiting...
UPDATE qty SET qty=0       → (blocked)
COMMIT                     → lock released
  → Reservation created     SELECT qty FOR UPDATE
                             → qty=0
                           InsufficientStockException
──────────────────────────────────────────────

Implementation: row-level SELECT FOR UPDATE during reservation

Checkout API

// Checkout service interface
class CheckoutService
{
    public function getShippingMethods($cartId);
    public function setShippingMethod($cartId, $methodCode);
    public function getPaymentMethods($cartId);
    public function setPaymentMethod($cartId, $methodCode);
    public function validateCheckout($cartId);
    public function placeOrder($cartId, $idempotencyKey = null);
}

// Place order with idempotency and rollback
public function placeOrder($cartId, $idempotencyKey = null)
{
    // Idempotency: check if order already placed for this key
    if ($idempotencyKey) {
        $existingOrder = $this->orderRepo->findByIdempotencyKey($idempotencyKey);
        if ($existingOrder) {
            return $existingOrder; // Return existing, no double charge
        }
    }
    
    $quote = $this->quoteRepository->get($cartId);
    
    // 1. Validate quote
    $this->validateQuote($quote);
    
    // 2. Verify inventory reservation still valid
    if (!$this->reservationService->isReservationValid($quote)) {
        throw new ReservationExpiredException(
            'Your reserved items expired. Please restart checkout.'
        );
    }
    
    // 3. Process payment with idempotency
    try {
        $payment = $this->paymentService->process(
            $quote,
            $idempotencyKey
        );
    } catch (PaymentDeclinedException $e) {
        $this->reservationService->releaseReservation($quote);
        throw $e;
    } catch (PaymentTimeoutException $e) {
        // Hold reservation for async verification
        $this->reservationService->extendReservation($quote, 900);
        throw $e;
    }
    
    // 4. Create order
    $order = $this->orderService->create($quote, $payment);
    
    // 5. Consume reservation (mark as consumed, don't release)
    $this->reservationService->consumeReservation($quote, $order->getId());
    
    // 6. Mark quote as ordered (prevents double place-order)
    $quote->setOrderPlaced(true);
    $quote->setConvertedOrderId($order->getId());
    $this->quoteRepository->save($quote);
    
    // 7. Send confirmation (async)
    $this->queue->sendMessage('order.confirmation', [
        'order_id' => $order->getId()
    ]);
    
    return $order;
}

Address Validation

// Address validation service
public function validateAddress($address)
{
    $errors = [];
    
    if (empty($address->getFirstname())) {
        $errors[] = 'First name is required';
    }
    
    if (empty($address->getStreet())) {
        $errors[] = 'Street address is required';
    }
    
    if (empty($address->getCity())) {
        $errors[] = 'City is required';
    }
    
    if (empty($address->getPostcode())) {
        $errors[] = 'Postal code is required';
    }
    
    // Country-specific validation
    $countryRules = $this->getCountryRules($address->getCountryId());
    if ($countryRules->requiresRegion && empty($address->getRegionId())) {
        $errors[] = 'Region/state is required for ' . $address->getCountryId();
    }
    
    // USPS/international format validation
    if ($address->getCountryId() === 'US') {
        if (!preg_match('/^\d{5}(-\d{4})?$/', $address->getPostcode())) {
            $errors[] = 'Invalid US ZIP code format';
        }
    }
    
    return $errors;
}

Payment Processing with Idempotency

Payment Flow (Idempotent)

1. Client generates idempotency_key (UUID or order-specific hash)
2. Client sends place_order request with idempotency_key
3. Server checks: does idempotency_key already exist?
   ├── YES → Return existing order (no new payment)
   └── NO → Continue
4. Validate payment data
5. Send to payment gateway WITH idempotency_key
6. Gateway checks: duplicate transaction?
   ├── YES → Return original result (no double charge)
   └── NO → Process payment
7. Receive authorization/capture
8. Create order, store idempotency_key
9. Update order status
10. Send confirmation

Idempotency key storage:
├── Server-side: order table → idempotency_key column (unique index)
├── Gateway-side: Stripe/PayPal reject duplicate keys
└── Client-side: retry with SAME key on network failure

Payment Gateway Integration (with Idempotency)

// Payment gateway interface
class PaymentGateway
{
    public function authorize($payment, $amount, $idempotencyKey = null);
    public function capture($payment, $amount, $idempotencyKey = null);
    public function void($payment, $idempotencyKey = null);
    public function refund($payment, $amount, $idempotencyKey = null);
}

// Stripe integration with idempotency
class StripeGateway implements PaymentGateway
{
    public function authorize($payment, $amount, $idempotencyKey = null)
    {
        $params = [
            'amount' => (int) ($amount * 100), // cents
            'currency' => $payment->getOrder()->getStoreCurrencyCode(),
            'source' => $payment->getToken(),
            'capture' => false,
            'description' => 'Order #' . $payment->getOrder()->getIncrementId()
        ];
        
        // Idempotency key: Stripe guarantees no double charge
        if ($idempotencyKey) {
            $params['idempotency_key'] = $idempotencyKey;
        }
        
        try {
            $charge = $this->stripe->charges->create($params);
            
            $payment->setTransactionId($charge->id);
            $payment->setIsTransactionClosed(false);
            
            return $charge;
        } catch (\Stripe\Exception\CardException $e) {
            // Card declined
            throw new PaymentDeclinedException($e->getMessage());
        } catch (\Stripe\Exception\InvalidRequestException $e) {
            // Invalid parameters
            throw new PaymentException('Payment gateway error');
        } catch (\Stripe\Exception\AuthenticationException $e) {
            // Auth failure → developer issue
            throw new PaymentConfigurationException('Gateway authentication failed');
        } catch (\Stripe\Exception\RateLimitException $e) {
            // Too many requests → retry after delay
            throw new PaymentRetryableException('Gateway rate limited');
        } catch (\Stripe\Exception\APIConnectionException $e) {
            // Network issue → retry
            throw new PaymentRetryableException('Gateway unreachable');
        }
    }
    
    public function capture($payment, $amount, $idempotencyKey = null)
    {
        $params = ['amount' => (int) ($amount * 100)];
        
        if ($idempotencyKey) {
            $params['idempotency_key'] = $idempotencyKey;
        }
        
        $charge = $this->stripe->charges->capture(
            $payment->getTransactionId(),
            $params
        );
        
        $payment->setIsTransactionClosed(true);
        return $charge;
    }
}

Double Charge Prevention: Complete Flow

Scenario: Network failure after gateway charge but before order creation

Timeline:
──────────────────────────────────────────────
Client                     Server                    Gateway
──────                     ──────                    ───────
Place order
  idempotency_key=abc123   
                           Validate quote
                           Charge gateway
                             idempotency_key=abc123
                             → $99.99 charged
                             → charge_id=ch_xxx
                           ← success
                           CREATE ORDER... ← network crash
                           → order NOT created

Client retries (same key)
                           
                           Check idempotency_key=abc123
                           → No existing order found
                           Charge gateway
                             idempotency_key=abc123
                             → Gateway returns ORIGINAL charge
                             → $0.00 new charge (idempotent)
                           Create order
                           → Order #1001 created
──────────────────────────────────────────────

Key insight: idempotency_key prevents double charge at gateway level,
even if server-side logic fails.

Payment Failure Scenarios & Recovery

Scenario 1: Card Declined
├── Action: Show error, release reservation
├── Recovery: Customer retries with different card
└── No inventory impact (reservation held during attempt)

Scenario 2: 3D Secure Timeout
├── Action: Hold reservation 15 min
├── Recovery: Async webhook from gateway → retry or fail
└── Risk: Customer may close browser, reservation expires

Scenario 3: Gateway Timeout
├── Action: Retry up to 3 times with exponential backoff
├── Recovery: If all retries fail, hold reservation, show pending
└── Risk: Double charge possible → idempotency key critical

Scenario 4: Order Created, Email Failed
├── Action: Order is safe, email is retried from queue
├── Recovery: No action needed
└── Risk: Customer doesn't know order succeeded → support call

Scenario 5: Payment Succeeded, Order Creation Failed
├── Action: Idempotency key stored with charge, retry order creation
├── Recovery: Background job retries with same idempotency key
└── Risk: Money taken, no order → critical, requires reconciliation

Scenario 6: Duplicate Submit (Double Click)
├── Action: Idempotency key prevents second charge
├── Recovery: Return existing order on duplicate key
└── Risk: None (idempotency handles this)

Fraud Prevention

// Fraud check service
public function checkFraud($order)
{
    $riskScore = 0;
    $reasons = [];
    
    // Check: Amount too high
    if ($order->getGrandTotal() > 500) {
        $riskScore += 20;
        $reasons[] = 'high_value_order';
    }
    
    // Check: Shipping/billing address mismatch
    if ($order->getShippingAddress() != $order->getBillingAddress()) {
        $riskScore += 15;
        $reasons[] = 'address_mismatch';
    }
    
    // Check: Velocity (too many orders from same IP)
    $recentOrders = $this->getRecentOrdersByIp($order->getCustomerIp());
    if ($recentOrders > 5) {
        $riskScore += 30;
        $reasons[] = 'velocity_anomaly';
    }
    
    // Check: Guest checkout with high value
    if (!$order->getCustomerId() && $order->getGrandTotal() > 200) {
        $riskScore += 25;
        $reasons[] = 'guest_high_value';
    }
    
    // Check: New account with high value
    if ($order->getCustomerId()) {
        $customer = $this->customerRepo->getById($order->getCustomerId());
        $accountAge = (time() - strtotime($customer->getCreatedAt())) / 86400;
        if ($accountAge < 1 && $order->getGrandTotal() > 100) {
            $riskScore += 20;
            $reasons[] = 'new_account_high_value';
        }
    }
    
    // Decision
    if ($riskScore >= 50) {
        return ['action' => 'hold', 'reasons' => $reasons];
    } elseif ($riskScore >= 30) {
        return ['action' => 'review', 'reasons' => $reasons];
    }
    
    return ['action' => 'approve', 'reasons' => []];
}

Cart Abandonment Recovery

Abandonment Detection

Cart States:
├── Active: Customer browsing, items in cart
├── Checkout Started: Customer entered checkout flow
├── Payment Initiated: Customer submitted payment
├── Order Placed: Successfully completed
└── Abandoned: Checkout started but not completed

Detection Rules:
├── Cart inactive > 30 min during checkout → abandoned
├── Payment initiated but no order after 15 min → failed checkout
├── Email captured but no order in 24 hours → abandoned
└── Guest cart with email, no order in 1 hour → abandoned

Recovery Actions (Timed):
├── 1 hour: Send "complete your order" email
├── 24 hours: Send reminder with discount incentive
├── 72 hours: Send final reminder (urgency)
└── 7 days: Deactivate cart, release reservations

Abandonment Recovery Implementation

// Abandonment detection service
class CartAbandonmentService
{
    public function detectAbandonedCarts()
    {
        $abandoned = [];
        
        // Check 1: Checkout started but not completed (30+ min)
        $checkoutCarts = $this->getCheckoutCarts(
            inactiveMinutes: 30,
            hasEmail: true,
            maxAge: 48 // hours
        );
        
        foreach ($checkoutCarts as $cart) {
            $abandoned[] = [
                'cart' => $cart,
                'type' => 'checkout_started',
                'email' => $cart->getCustomerEmail(),
                'last_active' => $cart->getLastActiveAt(),
                'items_count' => $cart->getItemsCount(),
                'total' => $cart->getGrandTotal()
            ];
        }
        
        // Check 2: Payment initiated but no order (15+ min)
        $paymentFailed = $this->getPaymentInitiatedCarts(
            inactiveMinutes: 15
        );
        
        foreach ($paymentFailed as $cart) {
            $abandoned[] = [
                'cart' => $cart,
                'type' => 'payment_failed',
                'email' => $cart->getCustomerEmail(),
                'reason' => $this->getPaymentFailureReason($cart)
            ];
        }
        
        return $abandoned;
    }
    
    public function sendRecoveryEmail($abandonedCart)
    {
        // Personalize based on abandonment stage
        $template = match($abandonedCart['type']) {
            'checkout_started' => 'abandonment_checkout',
            'payment_failed' => 'abandonment_payment',
            default => 'abandonment_general'
        };
        
        $this->mailer->send([
            'to' => $abandonedCart['email'],
            'template' => $template,
            'variables' => [
                'items' => $abandonedCart['cart']->getItems(),
                'total' => $abandonedCart['cart']->getGrandTotal(),
                'recovery_url' => $this->generateRecoveryUrl($abandonedCart['cart']),
                'discount_code' => $this->getRecoveryDiscount($abandonedCart)
            ]
        ]);
    }
}

Abandonment Recovery Trade-Offs

Timing:
├── Too early (< 1 hour): Customer might still return, annoying
├── Sweet spot (1-24 hours): Customer has left, still remembers
├── Too late (> 72 hours): Customer moved on, low conversion
└── Recommendation: 1hr + 24hr + 72hr drip sequence

Incentives:
├── No discount: Preserve margin, lower recovery rate
├── Small discount (5%): Moderate recovery, some margin loss
├── Free shipping: Higher recovery, predictable cost
├── Large discount (20%+): High recovery, margin destruction
└── Recommendation: Free shipping for first attempt, 10% for second

Privacy & Compliance:
├── Must have email BEFORE abandonment (not retroactive)
├── GDPR: Opt-in required for recovery emails
├── CAN-SPAM: Unsubscribe link required
├── Don't store payment info in abandoned cart data
└── Log consent timestamp for audit trail

Metrics:
├── Recovery rate: recovered / abandoned
├── Revenue recovered: sum of orders from recovery
├── Cost of recovery: discount + email cost
└── ROI: (revenue - cost) / cost

Multi-Address Checkout Complexity

Multi-Address Checkout Flow

Standard Checkout:
├── One shipping address for entire cart
├── One shipping method
└── Single order

Multi-Address Checkout:
├── Multiple shipping addresses (split cart)
├── Different shipping methods per address
├── Different delivery dates per address
├── Single payment for all shipments
├── Multiple orders (one per address) OR single order with shipments
└── Complex tax calculation (origin-based per shipment)

Split Cart Logic:
├── Group items by destination address
├── Calculate shipping per group
├── Calculate tax per group (destination-based tax)
├── Generate child orders or shipments
└── Single payment authorization (total of all shipments)

Multi-Address Implementation

// Split cart by shipping address
class MultiAddressCheckout
{
    public function splitCartByAddress($quote)
    {
        $addressGroups = [];
        
        foreach ($quote->getItems() as $item) {
            $addressId = $item->getShippingAddressId();
            
            if (!isset($addressGroups[$addressId])) {
                $addressGroups[$addressId] = [
                    'address' => $quote->getAddressById($addressId),
                    'items' => [],
                    'shipping_method' => null,
                    'shipping_cost' => 0,
                    'tax' => 0,
                    'subtotal' => 0
                ];
            }
            
            $addressGroups[$addressId]['items'][] = $item;
            $addressGroups[$addressId]['subtotal'] += $item->getRowTotal();
        }
        
        // Calculate shipping + tax per group
        foreach ($addressGroups as $addressId => &$group) {
            $group['shipping_cost'] = $this->shippingService->getRate(
                $group['address'],
                $group['items'],
                $group['shipping_method']
            );
            
            $group['tax'] = $this->taxService->calculate(
                $group['address'],
                $group['items'],
                $group['shipping_cost']
            );
        }
        
        return $addressGroups;
    }
    
    public function placeMultiAddressOrder($quote, $addressGroups)
    {
        $orders = [];
        $totalCharged = 0;
        
        try {
            // Single payment for all shipments
            $totalAmount = array_sum(array_map(fn($g) => 
                $g['subtotal'] + $g['shipping_cost'] + $g['tax'],
                $addressGroups
            ));
            
            $payment = $this->paymentService->process($quote, $totalAmount);
            $totalCharged = $totalAmount;
            
            // Create separate order per address
            foreach ($addressGroups as $addressId => $group) {
                $order = $this->orderService->createFromQuoteItems(
                    $quote,
                    $group['items'],
                    $group['address'],
                    $group['shipping_method'],
                    $payment
                );
                $orders[] = $order;
            }
            
            return $orders;
            
        } catch (\Exception $e) {
            // Rollback: refund if payment was processed
            if ($totalCharged > 0) {
                $this->paymentService->refund($payment, $totalCharged);
            }
            throw $e;
        }
    }
}

Multi-Address Trade-Offs

Complexity Cost:
├── Tax calculation: origin-based per shipment (different rules per state)
├── Shipping rates: per-group, not per-cart
├── Inventory: reserve per-group, partial reservation failure = all fail
├── Refunds: partial refunds per shipment, not per order
├── Customer service: harder to track, multiple order numbers
└── Returns: per-shipment RMA, not per-order

Alternatives:
├── Option A: Single order, multiple shipments (simpler)
│   └── One order number, split shipments, easier to manage
├── Option B: Multiple orders (true multi-address)
│   └── Separate orders, separate invoices, complex for customer
├── Option C: Ship-to-multiple only for physical goods
│   └── Digital goods stay on single order
└── Recommendation: Option A unless business requires separate invoices

UX Considerations:
├── Let customer assign items to addresses via drag-and-drop
├── Show per-address totals (shipping + tax)
├── Allow different shipping methods per address
├── Single payment summary at bottom
└── Clear communication: "3 orders will be placed"

Practice Problems

0 / 2 solved
Checkout Flow with Concurrency

Design a checkout flow that handles two users buying the last item simultaneously, prevents double charges on payment retry, and supports cart abandonment recovery.

Solution
// Flow:
// 1. Entry: Reserve inventory (SELECT FOR UPDATE, TTL 10min)
// 2. Guest: Capture email early for recovery
// 3. Logged-in: Prefill addresses, save payment tokens
// 4. Address: Validate, get shipping methods
// 5. Payment: Generate idempotency_key, process with gateway
// 6. Idempotency: Check existing order before new charge
// 7. Failure paths:
//    - Stock gone: Release reservation, show error
//    - Payment declined: Release reservation, retry different method
//    - Gateway timeout: Hold reservation 15min, async verify
//    - Network crash: Retry with same idempotency_key
// 8. Success: Consume reservation, create order, clear cart
// 9. Abandonment: Detect inactive checkout >30min, send drip emails
// 10. Multi-address: Split cart, single payment, separate orders
Payment Idempotency Implementation

Implement a payment processing system that guarantees no double charges even under network failures, gateway timeouts, and client retries.

Solution
// Implementation:
// 1. Client generates UUID idempotency_key
// 2. Server stores key in pending_payments table
// 3. Send to gateway with idempotency_key
// 4. Gateway returns original result for duplicates
// 5. On success: create order, link key to order (unique index)
// 6. On network failure: client retries SAME key
// 7. On timeout: async webhook verifies, retry or fail
// 8. Table: pending_payments(id, key, status, order_id, created_at)
// 9. Cleanup: expire pending_payments after 24 hours
// 10. Monitoring: alert on high retry rate (possible issue)

Quiz

1. What is a quote in Magento?

Question 1 options

2. What is an idempotency key and why is it critical for payment processing?

Question 2 options

3. How does optimistic locking prevent race conditions on quotes?

Question 3 options

4. What is the correct sequence when placing an order?

Question 4 options

5. What happens when two users try to buy the last item simultaneously?

Question 5 options

6. What is the key difference between guest and logged-in checkout?

Question 6 options

7. Why should you reserve inventory before processing payment?

Question 7 options

8. In multi-address checkout, how should payment be handled?

Question 8 options

Flashcards

Question

Quote purpose?

Answer

Shopping cart data before becoming an order. Contains items, addresses, payment, shipping, and totals.

Question

Checkout steps?

Answer

1) Shipping Address → 2) Review & Payment → 3) Place Order. With concurrency: reserve stock at step 1, consume reservation at step 3.

Question

Payment idempotency?

Answer

Using a unique key per payment request so retries don't cause double charges. Gateway returns original result for duplicate keys.

Question

Optimistic vs pessimistic locking?

Answer

Optimistic: version counter, detect conflicts on save. Pessimistic: row-level locks (SELECT FOR UPDATE), prevent conflicts. Use pessimistic for inventory, optimistic for quotes.

Question

Race condition: two users, last item?

Answer

Row-level lock (SELECT FOR UPDATE) during reservation. First transaction commits, second sees qty=0 and gets InsufficientStockException.

Question

Guest vs logged-in checkout trade-offs?

Answer

Guest: lower friction, higher conversion, but no saved data, harder recovery. Logged-in: faster (prefill), easier recovery, lower fraud risk.

Question

Cart abandonment recovery timing?

Answer

1hr: gentle reminder. 24hr: discount incentive. 72hr: final urgency. >7 days: deactivate cart, release reservations.

Question

Multi-address checkout complexity?

Answer

Split cart by address, calculate shipping/tax per group, single payment for total, create separate orders or shipments. Tax rules vary by origin.

Question

Inventory reservation lifecycle?

Answer

Created on checkout entry (TTL 10min) → Consumed on order placement → Released on failure/timeout → Expired if abandoned.

Question

Payment failure: gateway timeout?

Answer

Retry up to 3x with exponential backoff. Hold reservation. If all retries fail, show pending state. Idempotency key prevents double charge on retry.

Revision Notes

Key Takeaways

  • 1. Quote: Shopping cart data before order, with version field for optimistic locking
  • 2. Checkout: 3 steps with concurrency safeguards: Reserve → Pay → Consume
  • 3. Idempotency key: Prevents double charges on payment retry (client + gateway level)
  • 4. Optimistic locking: Version counter detects concurrent modifications on quotes
  • 5. Pessimistic locking: SELECT FOR UPDATE prevents overselling during inventory reservation
  • 6. Guest checkout: Lower friction but harder recovery and higher fraud risk
  • 7. Cart abandonment: Timed drip sequence (1hr, 24hr, 72hr) with escalating incentives
  • 8. Multi-address: Split cart by address, single payment, separate orders/shipments
  • 9. Reservation lifecycle: Create → Consume (success) or Release (failure/timeout)
  • 10. Payment failures: Declined (release), timeout (hold + retry), network (idempotency key)

Interview Tips

  • Explain the race condition scenario: two users, last item, how locking prevents overselling
  • Describe idempotency key flow: client generates key → gateway checks duplicates → no double charge
  • Discuss optimistic vs pessimistic locking trade-offs and when to use each
  • Walk through guest vs logged-in checkout: conversion vs recovery vs fraud implications
  • Explain inventory reservation lifecycle: when created, consumed, released, expired
  • Describe cart abandonment strategy: timing, incentives, compliance (GDPR/CAN-SPAM)
  • Discuss multi-address complexity: tax calculation, shipping per group, single payment
  • Know the payment failure scenarios: declined, timeout, network crash, duplicate submit

Cheat Sheet

Cart & Checkout (Advanced)

  • Quote: Cart data + version field (optimistic lock)
  • Checkout Flow: Reserve stock → Validate → Pay (idempotent) → Create order → Consume reservation
  • Idempotency Key: UUID per payment → prevents double charge on retry
  • Optimistic Lock: version counter, detect conflict on save
  • Pessimistic Lock: SELECT FOR UPDATE, prevent overselling
  • Race Condition: Last item → row lock → one succeeds, one fails gracefully
  • Guest Checkout: No saved data, higher conversion, harder recovery
  • Abandonment: 1hr/24hr/72hr drip, free shipping → 10% discount escalation
  • Multi-Address: Split cart → ship per group → single payment → separate orders
  • Reservation: Created (10min TTL) → Consumed (order placed) or Released (failure)
  • Payment Failures: Declined=release, Timeout=hold+retry, Network=idempotency key
  • Fraud: Amount + address mismatch + velocity + guest_high_value scoring