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.
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/payPOST /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.
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,paramsis typed as aPromise, 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 theServiceResult. 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.
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_ERRORresponse rather than crashing the route or returning an inconsistent payload.
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_FOUNDerror with a 404. This is a normal business outcome (not an exception), which is why it’s expressed as aServiceResultrather than a thrown error. - The transition rule is explicit: only
pendingcan becomepaid. 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.
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
shippedor alreadycancelled. 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
paidorder. 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 usessetOrderStatusto perform the update, and it double-checks the update result to handle rare race conditions cleanly.
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
handleExceptionstandardizes 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.
In this lesson, you completed the order lifecycle by adding two action endpoints:
POST /api/orders/:id/payupdates an order topaid, but only if it’s currentlypending.POST /api/orders/:id/cancelupdates an order tocancelled, except when it’s alreadyshippedor alreadycancelled.
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
payOrderServiceandcancelOrderService, making future refinement straightforward.
