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
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 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?
2. What is an idempotency key and why is it critical for payment processing?
3. How does optimistic locking prevent race conditions on quotes?
4. What is the correct sequence when placing an order?
5. What happens when two users try to buy the last item simultaneously?
6. What is the key difference between guest and logged-in checkout?
7. Why should you reserve inventory before processing payment?
8. In multi-address checkout, how should payment be handled?
Flashcards
Question
Quote purpose?
Click to reveal answer
Answer
Shopping cart data before becoming an order. Contains items, addresses, payment, shipping, and totals.
Question
Checkout steps?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
Answer
1hr: gentle reminder. 24hr: discount incentive. 72hr: final urgency. >7 days: deactivate cart, release reservations.
Question
Multi-address checkout complexity?
Click to reveal answer
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?
Click to reveal answer
Answer
Created on checkout entry (TTL 10min) → Consumed on order placement → Released on failure/timeout → Expired if abandoned.
Question
Payment failure: gateway timeout?
Click to reveal answer
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