From Cart to Order

Introduction to Checkout in Our API

Checkout is where an e-commerce backend stops being “a shopping list” and starts being “a real transaction.” In this lesson we’ll implement (and understand) the exact workflow our codebase uses to convert an open cart into a pending order, while keeping the data consistent even if two requests happen at the same time.

You’ll see how the checkout logic is organized across a repository layer and a service layer, and how a Next.js route turns service results into a consistent HTTP response. By the end, you’ll know exactly where “snapshotting” happens, why we use database locks during checkout, and what rules the system enforces before it will create an order.

Snapshotting: Why Orders Copy Cart Items

A cart is editable, temporary, and “live.” An order is a historical record that should not change just because product prices or inventories change later.

In our project, snapshotting happens by copying each cart_items row into a new order_items row at the moment of checkout. The copied data includes the product_id, quantity, and the unit_price_cents that the cart item had at that moment. That gives us a reliable receipt-like record for the order.

Where the Checkout Workflow Lives

Checkout is implemented across three layers:

  • Repository layer: src/lib/repositories/ordersRepo.ts Talks to PostgreSQL, performs transactional work, and snapshots cart items into order items.
  • Service layer: src/lib/services/ordersService.ts Converts repository errors into API-friendly ServiceResults and enforces order-state rules for pay/cancel.
  • Route handler: src/app/api/carts/[id]/checkout/route.ts Validates the URL param, calls the service, and returns standardized success/error envelopes.

Let’s walk through each part, starting with the repository, because that’s where the most important checkout guarantees are enforced.

Reading Orders and Mapping Database Rows

This first part of src/lib/repositories/ordersRepo.ts defines row shapes (what Postgres returns) and maps them into our domain types (Order and OrderItem). These helpers keep the rest of the repo code clean and ensure our API deals with consistent, typed objects.

// src/lib/repositories/ordersRepo.ts
import { DbClient } from '@/lib/db/types';
import { query, withTransaction } from '@/lib/db/client';
import { Order, OrderItem } from '@/lib/types/domain';
import { randomUUID } from 'node:crypto';
import { computeTaxCents } from '@/lib/money/tax';

export interface OrderRow {
  id: string;
  cart_id: string;
  status: string;
  subtotal_cents: number;
  tax_cents: number;
  total_cents: number;
  currency: string;
  created_at: string;
  updated_at: string;
}

export interface OrderItemRow {
  id: string;
  order_id: string;
  product_id: string;
  quantity: number;
  unit_price_cents: number;
  created_at: string;
  updated_at: string;
}

function mapOrderRow(r: OrderRow): Order {
  return {
    id: r.id,
    cart_id: r.cart_id,
    status: ['pending', 'paid', 'shipped', 'cancelled'].includes(r.status) ? (r.status as Order['status']) : 'pending',
    subtotal_cents: r.subtotal_cents,
    tax_cents: r.tax_cents,
    total_cents: r.total_cents,
    currency: 'USD',
    created_at: r.created_at,
    updated_at: r.updated_at,
  };
}

function mapItemRow(r: OrderItemRow): OrderItem {
  return {
    id: r.id,
    order_id: r.order_id,
    product_id: r.product_id,
    quantity: r.quantity,
    unit_price_cents: r.unit_price_cents,
    created_at: r.created_at,
    updated_at: r.updated_at,
  };
}
  • OrderRow and OrderItemRow describe the exact columns we read from PostgreSQL. This repo works directly with SQL, so having explicit row shapes prevents accidental field mismatches and makes mapping predictable.
  • mapOrderRow() is where the DB-facing status: string becomes a safe domain value. If the status in the database isn’t one of our supported values (pending, paid, shipped, cancelled), we default to 'pending' so downstream code doesn’t crash on unknown strings.
  • mapItemRow() does the same for items, turning raw DB rows into domain OrderItem objects with consistent naming and structure.
  • You’ll notice computeTaxCents is imported here, but checkout currently sets tax to 0 later. That’s intentional in the current code: tax logic is a placeholder to be refined, so the import hints at planned evolution without changing current behavior.
Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal