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
| State | Description | Inventory Impact |
|---|
pending | Order created, awaiting payment | None |
completed | Payment confirmed on blockchain | Stock decremented |
refunded | Payment returned to customer | Stock restored |
failed | Transaction failed or rejected | None |
cancelled | Order cancelled by user or system | None |
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
- 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
- No Inventory Reservation: Stock is NOT decremented when order is created
- Payment Initiated: User submits payment via
/api/orders/:id/pay
- 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:
- Transaction is confirmed on blockchain (21 confirmations)
- Transaction type is
purchase
- 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
- Inventory Update: Product stock is decremented by purchased quantities
- User Notification: Real-time Socket.io event emitted
- 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
- No Inventory Change: Stock remains unchanged
- Order Unlocked: User can retry with a new order
- 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:
- A refund transaction is confirmed on blockchain
- Transaction type is
refund
- 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
- Inventory Restoration: Product stock is incremented by refunded quantities
- Payment Returned: TRX is sent back to customer wallet
- 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
- No Inventory Change: Stock was never decremented
- No Refund Needed: Payment was never completed
- 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 }
});