Skip to main content
Orders in the Crypto Shop Backend progress through a well-defined lifecycle with five possible states. This guide explains each state, valid transitions, and the triggers that cause status changes.

Order States

The Order model defines five distinct statuses:
// src/models/Order.js
status: {
  type: String,
  enum: ['pending', 'completed', 'refunded', 'failed', 'cancelled'],
  default: 'pending'
}
Reference: src/models/Order.js:45-49

State Definitions

StateDescriptionInventory Impact
pendingOrder created, awaiting paymentNone
completedPayment confirmed on blockchainStock decremented
refundedPayment returned to customerStock restored
failedTransaction failed or rejectedNone
cancelledOrder cancelled by user or systemNone

State Transition Diagram

Pending State

Entry Point

All orders start in the pending state when created:
// POST /api/orders
const order = new Order({
  userId,
  products: orderProducts,
  subtotal,
  networkFee,
  total,
  walletAddress: user.wallet.address,
  merchantAddress,
  status: 'pending'  // Default value
});
await order.save();
Reference: src/api/orders/createOrder.js:56-65

Business Rules

  1. One Pending Order Per User: Users cannot create a new order while they have a pending one
const pendingOrder = await Order.findOne({ userId, status: 'pending' });
if (pendingOrder) {
  return res.status(409).json({ 
    error: 'You have a pending order. Please complete or cancel it first.',
    code: 'PENDING_ORDER_EXISTS',
    pendingOrderId: pendingOrder._id
  });
}
Reference: src/api/orders/createOrder.js:14-21
  1. No Inventory Reservation: Stock is NOT decremented when order is created
  2. Payment Initiated: User submits payment via /api/orders/:id/pay
  3. Transaction Hash Stored: Once blockchain transaction is broadcast
order.transactionHash = txHash;
await order.save();
Reference: src/api/orders/payOrder.js:65-66
Pending orders with no transaction hash after a certain period should be automatically cancelled to prevent abandoned orders from blocking new purchases.

Completed State

Transition Trigger

The transaction listener changes status to completed when:
  1. Transaction is confirmed on blockchain (21 confirmations)
  2. Transaction type is purchase
  3. Current order status is pending
if (transaction.type === 'purchase' && order.status === 'pending') {
  order.status = 'completed';
  order.updatedAt = Date.now();
  await order.save();

  // Decrement stock
  for (const item of order.products) {
    await Product.findByIdAndUpdate(
      item.productId,
      { $inc: { stock: -item.quantity } }
    );
  }

  // Notify user
  emitTransactionConfirmed(
    transaction.userId.toString(),
    transaction.orderId.toString(),
    transaction.transactionHash
  );
}
Reference: src/services/transactionListener.js:59-77

Effects

  1. Inventory Update: Product stock is decremented by purchased quantities
  2. User Notification: Real-time Socket.io event emitted
  3. Order Unlock: User can now create new orders

Inventory Decrement Logic

for (const item of order.products) {
  await Product.findByIdAndUpdate(
    item.productId,
    { $inc: { stock: -item.quantity } }  // Atomic decrement
  );
}
Reference: src/services/transactionListener.js:65-70
Atomic Operations: Using MongoDB’s $inc operator ensures stock updates are atomic and race-condition safe, even if multiple confirmations occur simultaneously.

Failed State

Transition Trigger

An order transitions to failed when the blockchain transaction fails during submission:
try {
  const tx = await sendTRX(
    user.wallet.privateKey,
    order.merchantAddress,
    Math.abs(order.total)
  );
  // ... create transaction record
} catch (txError) {
  order.status = 'failed';
  await order.save();
  throw txError;
}
Reference: src/api/orders/payOrder.js:41-84

Common Failure Reasons

  • Insufficient wallet balance (caught before transaction)
  • Network connectivity issues
  • Invalid wallet private key
  • TRON network congestion or rejection
  • Smart contract revert (if applicable)

Effects

  1. No Inventory Change: Stock remains unchanged
  2. Order Unlocked: User can retry with a new order
  3. Error Logged: Transaction error details stored
Failed orders are terminal states. Users must create a new order to retry the purchase.

Refunded State

Transition Trigger

Orders move to refunded when:
  1. A refund transaction is confirmed on blockchain
  2. Transaction type is refund
  3. Current order status is NOT already refunded
if (transaction.type === 'refund' && order.status !== 'refunded') {
  order.status = 'refunded';
  order.updatedAt = Date.now();
  await order.save();

  // Restore stock
  for (const item of order.products) {
    await Product.findByIdAndUpdate(
      item.productId,
      { $inc: { stock: item.quantity } }  // Add back
    );
  }
}
Reference: src/services/transactionListener.js:79-91

Prerequisites

  • Order must have been completed (stock was decremented)
  • Admin initiates refund transaction on blockchain
  • Refund transaction receives 21 confirmations

Effects

  1. Inventory Restoration: Product stock is incremented by refunded quantities
  2. Payment Returned: TRX is sent back to customer wallet
  3. Permanent State: Cannot transition to other states

Cancelled State

Transition Trigger

Orders can be cancelled by users or admins while in pending state:
// Typically implemented as:
export const cancelOrder = async (req, res) => {
  const order = await Order.findById(req.params.id);
  
  if (order.status !== 'pending') {
    return res.status(400).json({ error: 'Only pending orders can be cancelled' });
  }
  
  order.status = 'cancelled';
  await order.save();
  
  res.json({ message: 'Order cancelled successfully' });
};

Effects

  1. No Inventory Change: Stock was never decremented
  2. No Refund Needed: Payment was never completed
  3. Order Unlocked: User can create new orders
Important: If a transaction was already broadcast to the blockchain before cancellation, the order should NOT be marked as cancelled. Wait for blockchain confirmation to determine final status.

Order Schema Fields

Relevant fields for lifecycle tracking:
{
  orderId: { type: String, unique: true },      // #TRX-1, #TRX-2, etc.
  userId: { type: ObjectId, ref: 'User' },
  status: { 
    type: String, 
    enum: ['pending', 'completed', 'refunded', 'failed', 'cancelled'],
    default: 'pending' 
  },
  transactionHash: { type: String, default: null },
  createdAt: { type: Date, default: Date.now },
  updatedAt: { type: Date, default: Date.now }
}
Reference: src/models/Order.js:3-74

Timestamp Updates

The updatedAt field is manually updated on status changes:
order.status = 'completed';
order.updatedAt = Date.now();
await order.save();
Reference: src/services/transactionListener.js:60-62

State Validation

Payment Endpoint Guards

if (order.status !== 'pending') {
  return res.status(400).json({ error: 'Order is not pending' });
}
Reference: src/api/orders/payOrder.js:21-23 Only pending orders can be paid.

Transaction Listener Guards

if (transaction.type === 'purchase' && order.status === 'pending') {
  // Only complete if still pending
  order.status = 'completed';
}

if (transaction.type === 'refund' && order.status !== 'refunded') {
  // Prevent duplicate refund processing
  order.status = 'refunded';
}
Reference: src/services/transactionListener.js:59-79

Inventory Management

Stock Check at Creation

if (product.stock < item.quantity) {
  return res.status(400).json({ 
    error: `Insufficient stock for ${product.name}` 
  });
}
Reference: src/api/orders/createOrder.js:36-38 Stock is validated but NOT reserved during order creation.

Stock Decrement on Completion

Atomic decrement when order is confirmed:
await Product.findByIdAndUpdate(
  item.productId,
  { $inc: { stock: -item.quantity } }
);
Reference: src/services/transactionListener.js:66-69

Stock Restoration on Refund

Atomic increment when refund is confirmed:
await Product.findByIdAndUpdate(
  item.productId,
  { $inc: { stock: item.quantity } }
);
Reference: src/services/transactionListener.js:86-89
Race Condition Protection: Inventory updates use atomic operations to prevent stock inconsistencies when multiple transactions confirm simultaneously.

Best Practices

1. Always Check Current Status

const order = await Order.findById(orderId);
if (order.status !== expectedStatus) {
  return res.status(400).json({ error: 'Invalid order status' });
}

2. Use Transactions for Multi-Step Updates

const session = await mongoose.startSession();
session.startTransaction();

try {
  await order.save({ session });
  await transaction.save({ session });
  await Product.updateMany(/* ... */, { session });
  await session.commitTransaction();
} catch (error) {
  await session.abortTransaction();
  throw error;
} finally {
  session.endSession();
}

3. Implement Timeout Policies

// Cancel pending orders after 24 hours with no transaction hash
const staleOrders = await Order.find({
  status: 'pending',
  transactionHash: null,
  createdAt: { $lt: new Date(Date.now() - 24 * 60 * 60 * 1000) }
});

for (const order of staleOrders) {
  order.status = 'cancelled';
  await order.save();
}

4. Log State Transitions

const oldStatus = order.status;
order.status = 'completed';
console.log(`Order ${order.orderId}: ${oldStatus} -> completed`);
await order.save();

Query Examples

Find User’s Pending Order

const pendingOrder = await Order.findOne({ 
  userId, 
  status: 'pending' 
});

Get Completed Orders for Revenue Calculation

const completedOrders = await Order.find({ status: 'completed' });
const totalRevenue = completedOrders.reduce(
  (sum, order) => sum + order.total, 
  0
);

Monitor Failed Payments

const failedOrders = await Order.find({ 
  status: 'failed',
  createdAt: { $gte: startOfDay }
});

Build docs developers (and LLMs) love