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.

  • 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 and . That matters because it keeps every endpoint speaking the same “response language,” which makes clients and UI integration much easier.
Cancel Endpoint: Route Handler

This code lives in src/app/api/orders/[id]/cancel/route.ts. It follows the exact same structure as the pay route, which is a deliberate design choice: consistent endpoint structure reduces bugs and makes the codebase easier to extend.

  • Just like the pay route, the cancel route validates the UUID before calling into the service layer. This ensures malformed IDs are rejected consistently across all order action endpoints.
  • The handler does not try to interpret business rules like “can we cancel this order?” That logic belongs in cancelOrderService, so it remains consistent no matter how the service is called.
  • The route returns the updated order object on success, which is helpful for UIs that want to immediately reflect the new status after the action completes.
  • The try/catch around the handler ensures unexpected runtime errors become a structured INTERNAL_ERROR response rather than crashing the route or returning an inconsistent payload.
Paying an Order: Service Rules and State Transition

The pay logic lives in src/lib/services/ordersService.ts in payOrderService. This function enforces the “only pending orders can be paid” rule and performs the transition using the repository helper setOrderStatus.

  • The service begins by loading the current order via getOrderById(id). This “read first, then transition” pattern is essential because whether you can pay depends on the current status, not just the request intent.
  • If the order doesn’t exist, the service returns a NOT_FOUND error with a 404. This is a normal business outcome (not an exception), which is why it’s expressed as a ServiceResult rather than a thrown error.
  • The transition rule is explicit: only pending can become paid. Any other status returns a 409 Conflict, because the request is valid in shape (UUID is fine) but invalid in business state.
  • setOrderStatus(id, 'paid') performs the actual update. The extra check looks redundant at first, but it’s a defensive guard against edge cases like the order being deleted between the read and the update.
Cancelling an Order: Service Rules and Edge Cases

Cancellation also lives in src/lib/services/ordersService.ts, in cancelOrderService. It allows cancellation in most states, but explicitly blocks cancelling orders that are already shipped or already cancelled.

  • The service again starts with getOrderById(id) so the decision is based on real current state. That’s especially important for cancellation, because “cancel” is only meaningful when the order hasn’t progressed too far.
  • The blocking rule is: you cannot cancel an order that is shipped or already cancelled. This matches the current project behavior exactly and prevents repeated cancellations or cancelling after fulfillment is underway.
  • Notably, this logic does not block cancelling a paid order. That might not match every real-world business, but it is the rule as implemented here—and learners should understand that rules live in the service layer and can be refined later.
  • Like payOrderService, it uses setOrderStatus to perform the update, and it double-checks the update result to handle rare race conditions cleanly.
Orders Service Overview: How the Pieces Fit Together

src/lib/services/ordersService.ts is the central place where order-related business rules are enforced and where repository failures are translated into API-safe results. You’ve already worked with listOrdersService, getOrderService, and checkoutService—pay and cancel follow the same patterns.

Here’s a focused look at the file’s structure, with small snippets to orient you:

Shared error mapping for consistent API behavior

  • This helper is why service functions can stay readable: they focus on business logic while handleException standardizes failure behavior.
  • It also cleanly separates “expected business errors” (like checkout failures) from unexpected runtime/database failures, which prevents leaking raw errors to clients.

Read operations: list and fetch

  • These services normalize inputs and convert “missing data” into a clear 404. They’re thin by design, because the heavy logic belongs either in repositories (DB access) or in state transition rules.

Write/action operations: checkout, pay, cancel

  • Checkout is transaction-heavy and lives mostly in the repository; the service primarily generates an ID and maps errors.
Recap

In this lesson, you completed the order lifecycle by adding two action endpoints:

  • POST /api/orders/:id/pay updates an order to paid, but only if it’s currently pending.
  • POST /api/orders/:id/cancel updates an order to cancelled, except when it’s already shipped or already cancelled.

You also reinforced the project’s structure:

  • Routes validate inputs (isUUID) and return consistent envelopes (success / error).
  • Services enforce business rules and return consistent ServiceResults (ok / fail).
  • State transition rules are explicit and centralized in payOrderService and cancelOrderService, making future refinement straightforward.
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