Paying and Cancelling Orders

Paying and Cancelling Orders

At this point, your backend can create orders (via checkout) and let clients browse them (list + fetch). Now we’ll complete the basic order lifecycle by adding two actions that transition an order from one status to another: pay and cancel.

In this lesson you’ll see how our codebase models state transitions safely: the API routes validate the order ID and call service functions, and the service layer enforces which transitions are allowed (pending → paid, and “cancel unless shipped/already cancelled”). You’ll also learn why we use dedicated action routes like /pay and /cancel instead of a generic “update order” endpoint.

Previously: Creating and Reading Orders

In the earlier lessons, we built checkout to convert an open cart into a pending order, snapshotting cart items into order_items and marking the cart as checked_out. Then we added read endpoints so clients can list orders with pagination and fetch a single order by its UUID.

Now that orders exist and are visible, we’re ready to let clients move them forward through the lifecycle.

Why “Action Routes” for State Transitions

Paying and cancelling aren’t just ordinary updates like “change a shipping address.” They’re business actions with strict rules and side effects (even in simplified form). That’s why this project uses action-based endpoints:

  • POST /api/orders/:id/pay
  • POST /api/orders/:id/cancel

These endpoints say exactly what the client intends to do. They also make it easy to enforce rules like “only pending orders can be paid” without exposing a general “set status to anything” API.

Pay Endpoint: Route Handler

This code lives in src/app/api/orders/[id]/pay/route.ts. Its responsibility is to extract the id from the dynamic route, validate it, call the service, and return a consistent API response.

// src/app/api/orders/[id]/pay/route.ts
import { NextRequest } from 'next/server';
import { success, error } from '@/lib/http/response';
import { payOrderService } from '@/lib/services/ordersService';
import { isUUID } from '@/lib/http/validation';

type RouteContext = { params: Promise<{ id: string }> };

export async function POST(_req: NextRequest, context: RouteContext) {
  try {
    const { id } = await context.params;

    if (!isUUID(id)) return error('VALIDATION_ERROR', 'Invalid id', undefined, 400);

    const order = await payOrderService(id);
    if (!order.ok) return error(order.error.code, order.error.message, order.error.details, order.error.httpStatus);

    return success(order.value);
  } catch (e: unknown) {
    return error('INTERNAL_ERROR', (e as Error).message, undefined, 500);
  }
}
  • The dynamic segment [id] is accessed using the codebase’s modern pattern: const { id } = await context.params;. In this project, params is typed as a Promise, so this is the correct way to read it.
  • isUUID(id) is a fast validation guard that stops malformed IDs early. This prevents wasted database work and ensures invalid inputs consistently return a 400 with a validation-style error code.
  • The route calls payOrderService(id) and then simply forwards the ServiceResult. This keeps the route “thin,” with business rules living in the service layer instead of being duplicated in HTTP handlers.
  • Both success and failure responses go through success(...) and error(...). That matters because it keeps every endpoint speaking the same “response language,” which makes clients and UI integration much easier.
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