31.6. Orders
Table of Contents
Understanding Orders in an E‑Commerce Backend
In an e‑commerce system, orders are the heart of the business. Everything else, such as products, inventory, payments, and users, exists so that customers can place and receive orders. In this chapter you will focus on how to design and implement the order part of the backend, and how it connects to the rest of the system.
You will see examples, but the exact technology stack is not important here. The ideas apply whether you use FastAPI, Django, Node.js, or any other backend framework.
Key idea: An order is a snapshot of what the customer agreed to buy, at specific prices, under specific conditions, at a specific time.
If you remember this, many design decisions become easier.
The Order Lifecycle
An order moves through a series of states from the moment the customer confirms the purchase until the order is delivered or canceled. You can think of this as a finite state machine.
Typical states:
| State | Description |
|---|---|
PENDING | Created but not yet paid |
PAID | Payment confirmed |
PROCESSING | Being prepared for shipment |
SHIPPED | Handed over to logistics / carrier |
DELIVERED | Confirmed delivered to customer |
CANCELLED | Canceled before shipment |
REFUNDED | Fully refunded (optionally after being paid or shipped) |
You can start with fewer states, for example only PENDING, PAID, CANCELLED, COMPLETED, and expand later.
Example state transitions:
- Cart is checked out → order created with state
PENDING. - Payment succeeds → order moves to
PAID. - Warehouse picks and packs items →
PROCESSING. - Carrier picks parcel →
SHIPPED. - Carrier confirms delivery or customer confirms →
DELIVERED. - Customer cancels before shipping →
CANCELLED.
You typically enforce which transitions are allowed. For example, you might not allow going from DELIVERED back to PENDING.
Rule: Only allow valid state transitions and always record who or what triggered the change (user, admin, system).
You can store order events in a separate order_events table to have an audit trail:
| id | order_id | from_status | to_status | changed_by | changed_at | note |
|---|---|---|---|---|---|---|
| 1 | 42 | NULL | PENDING | system | 2026-01-01 10:00 | order created from cart |
| 2 | 42 | PENDING | PAID | system | 2026-01-01 10:01 | payment intent 123 success |
Order vs Cart
The cart and the order are related but not the same thing.
- A cart is temporary and editable.
- An order is final in terms of what was purchased and at which prices.
Typical flow:
- User adds items to a cart.
- User updates quantities, applies discount codes.
- User fills in shipping address and chooses shipping method.
- User clicks "Place order".
- Backend:
- Validates stock.
- Freezes prices and discounts.
- Creates an order.
- Starts payment process.
After step 5, you should treat the order as an independent record, not just a reference to the cart.
Rule: Do not depend on the cart to reconstruct an order. Copy all relevant data from cart and products into the order and order items.
This means that when the customer or admin opens the order page months later, they see exactly what was purchased, even if product prices or names have changed.
Order Data Model
At minimum, you will have two main tables (or collections):
orders1 row per order.order_itemsmultiple rows per order, one per product (or SKU).
You can have more tables for shipping, payments, etc., but these two are the core.
Example `orders` table
| Column | Type | Description |
|---|---|---|
| id | UUID / bigint PK | Order identifier |
| user_id | FK to users | Customer who placed the order |
| status | enum / text | PENDING, PAID, ... |
| total_items_price | numeric | Sum of item line totals before discounts |
| discount_total | numeric | Sum of applied discounts |
| shipping_price | numeric | Shipping cost charged |
| tax_total | numeric | Total tax for this order |
| grand_total | numeric | Final amount to pay: items + shipping + tax - discounts |
| currency | text | E.g. USD, EUR |
| shipping_address_json | jsonb / text | Snapshot of shipping address |
| billing_address_json | jsonb / text | Snapshot of billing address |
| shipping_method | text | E.g. standard, express |
| payment_status | enum / text | PENDING, PAID, FAILED, REFUNDED |
| created_at | timestamp | When the order was created |
| updated_at | timestamp | Last update time |
Here, addresses are stored as JSON snapshots so that if the user changes their default address in their profile later, the order still shows the original address.
Example `order_items` table
| Column | Type | Description |
|---|---|---|
| id | UUID / bigint PK | Order item id |
| order_id | FK to orders | Which order this line belongs to |
| product_id | FK to products | Product reference (for internal usage and analytics) |
| product_name | text | Copy of the name at order time |
| sku | text | Stock keeping unit or variant id |
| unit_price | numeric | Price per unit at order time (before item-specific discount) |
| quantity | integer | Number of units ordered |
| discount_amount | numeric | Total discount applied to this line |
| tax_amount | numeric | Total tax for this line |
| line_total | numeric | Final line total = unit_price * quantity - discount + tax |
You may also store:
product_thumbnail_urlvariant_attributes_json(size: M, color: red, etc.)currency
So that order details can be rendered even if the product is later removed or changed.
Formula: For each order item, a typical calculation is
$$\text{line\_total} = \text{unit\_price} \times \text{quantity} - \text{discount\_amount} + \text{tax\_amount}$$
At the order level you usually have:
$$\text{grand\_total} = \text{total\_items\_price} - \text{discount\_total} + \text{shipping\_price} + \text{tax\_total}$$
Creating an Order from a Cart
When the user clicks "Place order", you typically have:
- The authenticated
user_id. - A
cart_idor cart contents in the session. - Selected shipping method and address.
- Optional coupon code.
A simplified flow in pseudocode:
def create_order_from_cart(user_id: int, cart_id: int) -> Order:
cart = get_cart_for_user(user_id, cart_id)
if not cart.items:
raise EmptyCartError()
# Re-validate data to avoid tampering
products = load_products([item.product_id for item in cart.items])
# Begin database transaction
with db.transaction():
order = create_order_record(
user_id=user_id,
status="PENDING",
shipping_address=cart.shipping_address,
billing_address=cart.billing_address,
shipping_method=cart.shipping_method,
currency="USD",
)
total_items_price = 0
total_tax = 0
for cart_item in cart.items:
product = products[cart_item.product_id]
# Use backend trusted price
unit_price = product.price
# Example: simple tax calculation (10 %)
tax_rate = 0.10
line_subtotal = unit_price * cart_item.quantity
line_tax = line_subtotal * tax_rate
create_order_item(
order_id=order.id,
product_id=product.id,
product_name=product.name,
sku=product.sku,
unit_price=unit_price,
quantity=cart_item.quantity,
tax_amount=line_tax,
line_total=line_subtotal + line_tax,
)
total_items_price += line_subtotal
total_tax += line_tax
# Apply discounts, coupons, shipping, etc.
discount_total = calculate_discounts(order, cart, total_items_price)
shipping_price = calculate_shipping(order, cart)
grand_total = total_items_price - discount_total + shipping_price + total_tax
update_order_totals(
order_id=order.id,
total_items_price=total_items_price,
discount_total=discount_total,
shipping_price=shipping_price,
tax_total=total_tax,
grand_total=grand_total,
)
# Optionally clear the cart
clear_cart(cart.id)
return get_order(order.id)Important checks during creation:
- Ensure all products still exist and are active.
- Revalidate prices on the server, do not trust values from the client.
- Optionally check stock levels.
- Use a transaction so that either the whole order is created or none of it.
Orders and Inventory
Orders and inventory are tightly connected. Inventory is described in a separate chapter, so here focus only on how orders should interact with it.
There are two common moments when you adjust stock:
- When the order is placed (reservation).
- When the order is shipped (final deduction).
A simple beginner friendly approach is:
- Decrease stock when the order is paid.
- Increase stock when the order is canceled or refunded before shipping.
Example logic:
def mark_order_as_paid(order_id: int):
order = get_order(order_id)
if order.status != "PENDING":
raise InvalidState("Order must be pending to be paid")
with db.transaction():
# Update status
update_order_status(order_id, "PAID")
# Decrease inventory
for item in order.items:
decrease_stock(item.sku, item.quantity)If you want to support preorders or reservations, you might:
- Reserve stock at
PENDINGstate. - Finalize or release reservation based on payment result.
When canceling an unpaid order you might release reserved stock. When refunding a paid order you might increase stock only if the items are returned.
Rule: Always update inventory in the same transaction when you update the order status that affects stock.
This helps you avoid inconsistent states where an order says "PAID" but inventory was not updated.
Orders and Payments
Payments are complex and have their own chapter, but here is how orders usually connect to them.
You normally have a payments table that references the orders table.
Example payments table:
| Column | Type | Description |
|---|---|---|
| id | UUID / bigint | Payment id |
| order_id | FK to orders | Which order this payment belongs to |
| provider | text | E.g. stripe, paypal |
| provider_id | text | Provider specific payment identifier |
| amount | numeric | Amount that the provider is charging |
| currency | text | Should match order currency |
| status | text | PENDING, SUCCEEDED, FAILED, REFUNDED |
| created_at | timestamp | When the payment was initiated |
| updated_at | timestamp | Last update |
Basic flow:
- Create order with status
PENDING. - Create payment record with
PENDING. - Redirect user or use payment widget.
- Payment provider sends webhook:
- If success: mark payment
SUCCEEDEDand orderPAID. - If failure: mark payment
FAILEDand maybe cancel order after some time.
Example webhook handler:
def handle_payment_webhook(payload: dict):
provider_id = payload["payment_intent_id"]
status = payload["status"]
payment = get_payment_by_provider_id(provider_id)
order = get_order(payment.order_id)
with db.transaction():
update_payment_status(payment.id, status)
if status == "SUCCEEDED" and order.status == "PENDING":
update_order_status(order.id, "PAID")
decrease_stock_for_order(order.id)
elif status == "FAILED" and order.status == "PENDING":
update_order_status(order.id, "CANCELLED")You should always verify webhook signatures and not trust client side reports of success.
Rule: Order payment status must be updated only by trusted server side events, typically payment provider webhooks, not by client side calls.
Order APIs
For the e‑commerce backend project, you will typically implement API endpoints to:
- Create an order from a cart.
- List a user’s orders.
- Retrieve order details.
- Allow a user to cancel their own order (if allowed).
- Admin endpoints to view and manage all orders.
Example public endpoints
| Method | Path | Description |
|---|---|---|
| POST | /orders | Create an order from current cart |
| GET | /orders | List current user’s orders |
| GET | /orders/{id} | Get details of a specific order |
| POST | /orders/{id}/cancel | Request cancellation |
You must ensure that users can only see and modify their own orders.
Example authorization check in pseudocode:
def get_order_for_user(order_id: int, user_id: int) -> Order:
order = get_order(order_id)
if order.user_id != user_id:
raise ForbiddenError()
return orderExample admin endpoints
| Method | Path | Description |
|---|---|---|
| GET | /admin/orders | List all orders (with filters) |
| GET | /admin/orders/{id} | Get any order details |
| POST | /admin/orders/{id}/status | Change order status (processing, shipped, etc.) |
Admin endpoints should be protected by role based authorization.
Canceling and Refunding Orders
Cancellation and refunds affect:
- Order status.
- Payment status.
- Inventory.
- Possibly other systems like invoices or accounting.
You need clear rules. For example:
- A user can cancel an order only if:
- Status is
PENDINGorPAID, and - Not yet
SHIPPED. - A refund can only be processed by admins.
Example cancellation logic:
def cancel_order_by_user(order_id: int, user_id: int):
order = get_order_for_user(order_id, user_id)
if order.status not in ["PENDING", "PAID"]:
raise InvalidState("Order cannot be canceled")
with db.transaction():
update_order_status(order.id, "CANCELLED")
if order.status == "PAID":
# Start refund flow in payment provider asynchronously
enqueue_refund_job(order.id)
# Optionally adjust stock
increase_stock_for_order(order.id)
You can also support partial refunds where only some items are refunded. In that case you might add a refunded_quantity and refunded_amount fields in order_items and adjust totals accordingly.
Order History, Tracking, and Notifications
Customers expect to track order progress. You can implement this with:
- An
order_eventstable to record state changes and notes. - Notification system that sends emails when status changes.
Example order_events table:
| Column | Description |
|---|---|
| id | Event id |
| order_id | Order reference |
| type | E.g. status_changed, note_added |
| data | JSON with details |
| created_by | User id or system |
| created_at | Timestamp |
When status changes from PAID to SHIPPED:
- Insert event
status_changedwithfrom=PAID,to=SHIPPED. - Send email: "Your order has been shipped".
- Possibly include tracking number in the event data and email.
On the front end, "Order History" page can show a timeline built from order_events.
Performance, Pagination, and Filtering
Orders can grow to large numbers over time, so you need to think about:
- Pagination.
- Indexes.
- Filtering.
Common queries:
- "List my last 10 orders."
- "List all orders created in the last 30 days."
- "List all
PENDINGorPAIDorders for admin dashboard." - "Find order by id, payment id, or user email."
You should:
- Add indexes on
user_id,status,created_at. - Always paginate.
Example listing endpoint for current user's orders:
GET /orders?limit=20&offset=0
Query example in SQL:
SELECT *
FROM orders
WHERE user_id = :user_id
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset;For admin:
GET /admin/orders?status=PAID&from=2026-01-01&to=2026-01-31
And then filter by these parameters in the query.
Putting It Together in the E‑Commerce Project
Within the context of the full e‑commerce backend project:
- User Management gives you
user_idfor each order. - Product Management and Inventory provide product details and stock.
- Shopping Cart (covered under "Shopping Cart") feeds order creation.
- Payments handle payment provider interactions.
- Background Jobs can process asynchronous tasks such as sending order confirmation emails and handling payment webhooks.
Typical sequence when a customer checks out:
- User logs in and fills cart.
- User goes to checkout, provides shipping address, chooses shipping method.
- Backend:
- Creates
ordersandorder_itemsfrom cart inPENDINGstate. - Starts payment and returns payment session info.
- User pays on the provider page or in widget.
- Provider sends webhook:
- Backend marks payment as
SUCCEEDED. - Backend marks order as
PAID, updates inventory. - Background job sends "Order confirmation" email.
- Warehouse processes paid orders:
- Admin system or worker moves status to
PROCESSINGand thenSHIPPED. - When shipped, tracking email is sent.
- After delivery, order is marked
DELIVERED.
By keeping the concepts in this chapter in mind, you can build an order system that is reliable, auditable, and easy to extend.
Views: 8
KAHIBARO