Skip to main content

Cart Reservation Guide

Learn how to hold inventory during the checkout window to prevent overselling when consumers are admitted from Fanfare experiences.

Overview

When a consumer is admitted from a queue, draw, or auction, you may want to reserve their selected items until they complete checkout. This guide covers patterns for inventory reservation during the admission window. What you’ll learn:
  • Reservation strategies for different scenarios
  • Implementing time-limited holds
  • Handling reservation expiration
  • Integration with your inventory system
Complexity: Intermediate Time to complete: 30 minutes

Prerequisites

  • Checkout Overview guide completed
  • Understanding of your inventory management system
  • Backend development capabilities

Why Reserve Inventory?

Without reservation, admitted consumers may find their items sold out by the time they reach checkout:
Timeline without reservation:
────────────────────────────────────────────────────────

Consumer A admitted ─────────────────▶ Tries to checkout

Consumer B buys last item ──────────────────────▶


                                    "Out of Stock" ❌
With reservation:
Timeline with reservation:
────────────────────────────────────────────────────────

Consumer A admitted ─▶ Inventory reserved ─▶ Checkout ✓
        │                     │
        └── 10 min hold ──────┘

Reservation Strategies

Strategy 1: Soft Reservation (Optimistic)

Reserve inventory on admission, release if not purchased:
// When consumer is admitted
async function onConsumerAdmitted(consumerId: string, productId: string, quantity: number) {
  const reservation = await createReservation({
    consumerId,
    productId,
    quantity,
    expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
  });

  // Schedule release if not completed
  scheduleReservationRelease(reservation.id, reservation.expiresAt);

  return reservation;
}

// On successful checkout
async function onCheckoutComplete(reservationId: string) {
  await convertReservationToOrder(reservationId);
}

// On expiration or abandonment
async function releaseReservation(reservationId: string) {
  await deleteReservation(reservationId);
  // Inventory is automatically available again
}

Strategy 2: Hard Allocation (Guaranteed)

Deduct from inventory on admission, refund if not purchased:
async function onConsumerAdmitted(consumerId: string, productId: string, quantity: number) {
  // Deduct from available inventory
  const result = await decrementInventory(productId, quantity);

  if (!result.success) {
    throw new Error("Insufficient inventory");
  }

  // Track the allocation
  await createAllocation({
    consumerId,
    productId,
    quantity,
    expiresAt: Date.now() + 10 * 60 * 1000,
  });
}

async function releaseAllocation(allocationId: string) {
  const allocation = await getAllocation(allocationId);

  // Return inventory
  await incrementInventory(allocation.productId, allocation.quantity);

  await deleteAllocation(allocationId);
}

Strategy 3: Queue-Based Allocation

Allocate based on queue position:
// Pre-allocate inventory based on queue size
async function setupQueueInventory(queueId: string, productId: string, totalInventory: number) {
  await setQueueInventoryPool({
    queueId,
    productId,
    available: totalInventory,
    allocated: 0,
  });
}

// Allocate on admission
async function onAdmitted(queueId: string, consumerId: string, productId: string) {
  const pool = await getQueueInventoryPool(queueId, productId);

  if (pool.available <= 0) {
    // No more inventory - consumer can still browse but can't purchase
    return { allocated: false };
  }

  // Atomic allocation
  await atomicAllocate(queueId, consumerId, productId, 1);

  return { allocated: true };
}

Implementation Example

Database Schema

-- Reservations table
CREATE TABLE reservations (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  consumer_id TEXT NOT NULL,
  experience_id TEXT NOT NULL,
  admission_token TEXT NOT NULL,
  product_id TEXT NOT NULL,
  variant_id TEXT,
  quantity INTEGER NOT NULL DEFAULT 1,
  status TEXT NOT NULL DEFAULT 'active', -- active, converted, released, expired
  created_at TIMESTAMP DEFAULT NOW(),
  expires_at TIMESTAMP NOT NULL,
  converted_at TIMESTAMP,
  order_id TEXT
);

-- Index for cleanup job
CREATE INDEX idx_reservations_expires ON reservations(expires_at) WHERE status = 'active';

-- Index for lookups
CREATE INDEX idx_reservations_consumer ON reservations(consumer_id, status);

Reservation Service

// services/reservation.service.ts
import { db } from "./db";

interface CreateReservationParams {
  consumerId: string;
  experienceId: string;
  admissionToken: string;
  productId: string;
  variantId?: string;
  quantity: number;
  expiresInMinutes?: number;
}

export async function createReservation(params: CreateReservationParams) {
  const { consumerId, experienceId, admissionToken, productId, variantId, quantity, expiresInMinutes = 10 } = params;

  const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000);

  // Check available inventory
  const available = await getAvailableInventory(productId, variantId);
  if (available < quantity) {
    throw new Error("Insufficient inventory");
  }

  // Create reservation in transaction
  return await db.transaction(async (tx) => {
    // Decrement available inventory
    await tx
      .update(inventory)
      .set({ reserved: sql`reserved + ${quantity}` })
      .where(eq(inventory.productId, productId));

    // Create reservation record
    const [reservation] = await tx
      .insert(reservations)
      .values({
        consumerId,
        experienceId,
        admissionToken,
        productId,
        variantId,
        quantity,
        expiresAt,
        status: "active",
      })
      .returning();

    return reservation;
  });
}

export async function convertReservation(reservationId: string, orderId: string) {
  return await db.transaction(async (tx) => {
    const [reservation] = await tx.select().from(reservations).where(eq(reservations.id, reservationId)).for("update");

    if (!reservation || reservation.status !== "active") {
      throw new Error("Invalid reservation");
    }

    // Update reservation status
    await tx
      .update(reservations)
      .set({
        status: "converted",
        convertedAt: new Date(),
        orderId,
      })
      .where(eq(reservations.id, reservationId));

    // Update inventory (reserved -> sold)
    await tx
      .update(inventory)
      .set({
        reserved: sql`reserved - ${reservation.quantity}`,
        sold: sql`sold + ${reservation.quantity}`,
      })
      .where(eq(inventory.productId, reservation.productId));

    return reservation;
  });
}

export async function releaseReservation(reservationId: string) {
  return await db.transaction(async (tx) => {
    const [reservation] = await tx.select().from(reservations).where(eq(reservations.id, reservationId)).for("update");

    if (!reservation || reservation.status !== "active") {
      return; // Already released or converted
    }

    // Release the reservation
    await tx.update(reservations).set({ status: "released" }).where(eq(reservations.id, reservationId));

    // Return inventory
    await tx
      .update(inventory)
      .set({ reserved: sql`reserved - ${reservation.quantity}` })
      .where(eq(inventory.productId, reservation.productId));
  });
}

Expiration Worker

// workers/reservation-cleanup.ts
import { releaseReservation } from "../services/reservation.service";

export async function cleanupExpiredReservations() {
  // Find expired reservations
  const expired = await db
    .select()
    .from(reservations)
    .where(and(eq(reservations.status, "active"), lt(reservations.expiresAt, new Date())))
    .limit(100);

  // Release each one
  for (const reservation of expired) {
    try {
      await releaseReservation(reservation.id);
      console.log(`Released expired reservation: ${reservation.id}`);
    } catch (error) {
      console.error(`Failed to release ${reservation.id}:`, error);
    }
  }

  return expired.length;
}

// Run every minute
setInterval(cleanupExpiredReservations, 60 * 1000);

Integration with Checkout

On Admission

// When journey reaches "admitted" state
async function handleAdmission(snapshot: JourneySnapshot) {
  const { admittanceToken, experienceId } = snapshot.context;

  // Get selected products from your cart/session
  const cart = await getConsumerCart(snapshot.context.consumerId);

  // Create reservations for cart items
  for (const item of cart.items) {
    await createReservation({
      consumerId: snapshot.context.consumerId,
      experienceId,
      admissionToken: admittanceToken!,
      productId: item.productId,
      variantId: item.variantId,
      quantity: item.quantity,
    });
  }
}

During Checkout

// Checkout API handler
app.post("/api/checkout", async (req, res) => {
  const { consumerId, cart, admissionToken } = req.body;

  // Verify reservations exist and are valid
  const reservations = await getActiveReservations(consumerId);

  for (const item of cart) {
    const reservation = reservations.find((r) => r.productId === item.productId && r.variantId === item.variantId);

    if (!reservation) {
      return res.status(400).json({
        error: "RESERVATION_MISSING",
        message: `No reservation for ${item.productId}`,
      });
    }

    if (reservation.quantity < item.quantity) {
      return res.status(400).json({
        error: "RESERVATION_INSUFFICIENT",
        message: `Reservation only covers ${reservation.quantity} units`,
      });
    }
  }

  // Process order
  const order = await createOrder(cart, consumerId);

  // Convert reservations
  for (const reservation of reservations) {
    await convertReservation(reservation.id, order.id);
  }

  res.json({ orderId: order.id });
});

On Abandonment

// When admission expires or consumer leaves
async function handleAdmissionExpired(consumerId: string, experienceId: string) {
  const reservations = await db
    .select()
    .from(reservations)
    .where(
      and(
        eq(reservations.consumerId, consumerId),
        eq(reservations.experienceId, experienceId),
        eq(reservations.status, "active")
      )
    );

  for (const reservation of reservations) {
    await releaseReservation(reservation.id);
  }
}

UI Feedback

Show reservation status to consumers:
function ReservationStatus({ productId }: { productId: string }) {
  const { reservation, timeRemaining } = useReservation(productId);

  if (!reservation) {
    return <p className="warning">Item not reserved</p>;
  }

  if (timeRemaining <= 0) {
    return <p className="error">Reservation expired</p>;
  }

  return (
    <div className="reservation-status">
      <span className="icon"></span>
      <span>Reserved for you</span>
      <span className="timer">
        {Math.floor(timeRemaining / 60)}:{(timeRemaining % 60).toString().padStart(2, "0")}
      </span>
    </div>
  );
}

Best Practices

1. Match Reservation to Admission Window

// Reservation should match admission expiry
const admissionExpiresIn = 10 * 60 * 1000; // 10 minutes
const reservationExpiresIn = admissionExpiresIn + 60 * 1000; // +1 minute buffer

2. Handle Race Conditions

// Use database transactions and row locking
await db.transaction(async (tx) => {
  const [product] = await tx.select().from(inventory).where(eq(inventory.productId, productId)).for("update"); // Lock the row

  if (product.available < quantity) {
    throw new Error("Insufficient inventory");
  }

  // Proceed with reservation
});

3. Provide Clear Feedback

function CheckoutButton({ hasReservation, reservationExpired }: Props) {
  if (!hasReservation) {
    return <button disabled>Items not reserved</button>;
  }

  if (reservationExpired) {
    return <button onClick={retryReservation}>Reservation expired - Try again</button>;
  }

  return <button onClick={checkout}>Complete Purchase</button>;
}

4. Monitor Reservation Metrics

// Track reservation outcomes
async function recordReservationOutcome(reservationId: string, outcome: "converted" | "released" | "expired") {
  await analytics.track("reservation_outcome", {
    reservationId,
    outcome,
    timestamp: Date.now(),
  });
}

Troubleshooting

Inventory Drift

If reserved counts become inconsistent:
// Reconciliation job
async function reconcileInventory(productId: string) {
  const activeReservations = await db
    .select({ total: sum(reservations.quantity) })
    .from(reservations)
    .where(and(eq(reservations.productId, productId), eq(reservations.status, "active")));

  const expectedReserved = activeReservations[0]?.total || 0;

  await db.update(inventory).set({ reserved: expectedReserved }).where(eq(inventory.productId, productId));
}

Reservation Not Found

// Always check reservation before checkout
async function validateCheckoutReservations(consumerId: string, cart: CartItem[]) {
  const reservations = await getActiveReservations(consumerId);

  const missing = cart.filter((item) => !reservations.find((r) => r.productId === item.productId));

  if (missing.length > 0) {
    throw new ReservationError("Missing reservations", { missing });
  }
}

What’s Next