MobileShop Website Part 21 | Update Order Status Step-by-Step in ASP.NET Core MVC

 


Welcome to Part 21 of our series! Today, we are taking our administrative workflow to the next level by building the Order Status Update & Tracking feature.

In Step 1, we implement the Details GET action method inside our Admin OrdersController. This serves as the data retrieval gateway, fetching comprehensive details about an order and exposing available lifecycle states so an administrator can manually advance its fulfillment status.

Part 21: Step 1 — The Administrative Inspection Engine

This method handles deep relational mapping to build out a complete inspection profile while dynamically projecting the business workflow boundaries onto our view layer.

C# / Areas/Admin/Controllers/OrdersController.cs (Details Action)
public async Task<IActionResult> Details(int id)
{
    var order = await _context.Orders
        .Include(o => o.User)
        .Include(o => o.OrderItems)
            .ThenInclude(oi => oi.Product)
        .FirstOrDefaultAsync(o => o.Id == id);

    if (order == null)
        return NotFound();

    ViewBag.Statuses = Enum.GetValues(typeof(OrderStatus)).Cast<OrderStatus>().ToList();
    return View(order);
}

Core Action Logic Breakdown

  • Relational Graph Aggregation (Include + ThenInclude): Just like our invoice workflow, a deep lookup is required here. The query eagerly grabs the customer profile (.Include(o => o.User)), pulls the collection of items inside the purchase packet (.Include(o => o.OrderItems)), and steps deep into each row to link the target product specifications (.ThenInclude(oi => oi.Product)). This prevents runtime layout exceptions when rendering product names and stock specifications side-by-side.

  • LINQ Execution Safety Defenses (FirstOrDefaultAsync): Instead of generic array parsing, FirstOrDefaultAsync evaluates against the primary key tracking index (o.Id == id). If a malicious input or broken backlink passes a non-existent lookup value, the system intercepts the null state and securely outputs a clean HTTP NotFound() error response.

  • Enum Reflection for State Machine Selection:

    ViewBag.Statuses = Enum.GetValues(typeof(OrderStatus)).Cast<OrderStatus>().ToList();

    This line dynamically reads your C# OrderStatus enum definitions (e.g., Pending, Processing, Shipped, Delivered, Cancelled) and packs them into an easily queryable list inside the view context.

    By extracting these values directly from the enum source using Reflection, you guarantee a single source of truth. If your business model shifts in the future and you add a new step like InTransit, you only have to update your C# enum class—this administrative dropdown controller logic will automatically pick up the new state without any code rewrites.

Step 2 — Advanced Order Operations Dashboard

This presentation layer leverages compound Bootstrap 5 grids, multi-form targets, embedded page-level Razor functions, and explicit null-coalescing string defenses.

Razor / Areas/Admin/Views/Orders/Details.cshtml
@model Order
@{
    ViewData["Title"] = $"Order {Model.OrderNumber}";
}

<div class="d-flex justify-content-between align-items-center mb-4">
    <h3><i class="bi bi-receipt"></i> Order @Model.OrderNumber</h3>
    <div>
        <a asp-action="Invoice" asp-route-id="@Model.Id" class="btn btn-info" target="_blank">
            <i class="bi bi-printer"></i> Print Invoice
        </a>
        <a asp-action="Index" class="btn btn-secondary">
            <i class="bi bi-arrow-left"></i> Back
        </a>
    </div>
</div>

<div class="row">
    <div class="col-lg-8">
        <!-- Order Items -->
        <div class="card shadow-sm mb-4">
            <div class="card-header">
                <h5 class="mb-0">Order Items</h5>
            </div>
            <div class="card-body">
                <div class="table-responsive">
                    <table class="table">
                        <thead>
                            <tr>
                                <th>Product</th>
                                <th class="text-center">Quantity</th>
                                <th class="text-end">Unit Price</th>
                                <th class="text-end">Total</th>
                            </tr>
                        </thead>
                        <tbody>
                            @foreach (var item in Model.OrderItems)
                            {
                                <tr>
                                    <td>
                                        <div class="d-flex align-items-center">
                                            <img src="@(item.Product?.MainImageUrl ?? "https://via.placeholder.com/50x50?text=No+Image")" 
                                                 style="width: 50px; height: 50px; object-fit: cover;" class="rounded me-3" />
                                            <div>
                                                <h6 class="mb-0">@item.Product?.Name</h6>
                                                <small class="text-muted">@item.Product?.Model</small>
                                            </div>
                                        </div>
                                    </td>
                                    <td class="text-center">@item.Quantity</td>
                                    <td class="text-end">RS @item.UnitPrice.ToString("N0")</td>
                                    <td class="text-end">RS @item.TotalPrice.ToString("N0")</td>
                                </tr>
                            }
                        </tbody>
                        <tfoot>
                            <tr>
                                <td colspan="3" class="text-end"><strong>Subtotal:</strong></td>
                                <td class="text-end">RS @Model.Subtotal.ToString("N0")</td>
                            </tr>
                            <tr>
                                <td colspan="3" class="text-end"><strong>Tax:</strong></td>
                                <td class="text-end">RS @Model.TaxAmount.ToString("N0")</td>
                            </tr>
                            <tr>
                                <td colspan="3" class="text-end"><strong>Shipping:</strong></td>
                                <td class="text-end">RS @Model.ShippingCost.ToString("N0")</td>
                            </tr>
                            @if (Model.DiscountAmount > 0)
                            {
                                <tr>
                                    <td colspan="3" class="text-end"><strong>Discount:</strong></td>
                                    <td class="text-end text-danger">-RS @Model.DiscountAmount.ToString("N0")</td>
                                </tr>
                            }
                            <tr class="table-primary">
                                <td colspan="3" class="text-end"><h5 class="mb-0">Total:</h5></td>
                                <td class="text-end"><h5 class="mb-0">RS @Model.TotalAmount.ToString("N0")</h5></td>
                            </tr>
                        </tfoot>
                    </table>
                </div>
            </div>
        </div>

        <!-- Update Status -->
        <div class="card shadow-sm mb-4">
            <div class="card-header">
                <h5 class="mb-0">Update Status</h5>
            </div>
            <div class="card-body">
                <form asp-action="UpdateStatus" method="post">
                    <input type="hidden" name="id" value="@Model.Id" />
                    <div class="row g-3 align-items-end">
                        <div class="col-md-6">
                            <label class="form-label">Order Status</label>
                            <select name="status" class="form-select">
                                @foreach (var status in ViewBag.Statuses)
                                {
                                    <option value="@status" selected="@(Model.Status.ToString() == Convert.ToString(status) ? "selected" : null)">@status</option>
                                }
                            </select>
                        </div>
                        <div class="col-md-6">
                            <button type="submit" class="btn btn-primary">
                                <i class="bi bi-arrow-clockwise"></i> Update Status
                            </button>
                            @if (Model.Status != OrderStatus.Cancelled && Model.Status != OrderStatus.Delivered)
                            {
                                <button type="submit" asp-action="CancelOrder" class="btn btn-danger ms-2" onclick="return confirm('Are you sure you want to cancel this order?');">
                                    <i class="bi bi-x-circle"></i> Cancel Order
                                </button>
                            }
                        </div>
                    </div>
                </form>
            </div>
        </div>

        <!-- Order Current Status info -->
        <div class="card shadow-sm mb-4">
            <div class="card-header bg-primary text-white">
                <h5 class="mb-0">Order Tracking Overview</h5>
            </div>
            <div class="card-body">
                <div class="row">
                    <div class="col-md-6">
                        <p><strong>Order Date:</strong> @Model.OrderDate.ToString("MMM dd, yyyy HH:mm")</p>
                        <p>
                            <strong>Order Status:</strong>
                            <span class="badge bg-@GetStatusColor(Model.Status)">@Model.Status</span>
                        </p>
                        <p>
                            <strong>Payment Method:</strong> @Model.PaymentMethod
                        </p>
                        <p>
                            <strong>Payment Status:</strong>
                            <span class="badge bg-@(Model.PaymentStatus == PaymentStatus.Paid ? "success" : 
                                                    Model.PaymentStatus == PaymentStatus.Failed ? "danger" : "warning")">
                                @Model.PaymentStatus
                            </span>
                            @if (Model.PaymentStatus == PaymentStatus.Pending && Model.PaymentMethod == PaymentMethod.CashOnDelivery)
                            {
                                <small class="text-muted d-block">Will be marked as Paid on delivery</small>
                            }
                        </p>
                    </div>
                </div>
            </div>
        </div>

        @functions {
            string GetStatusColor(OrderStatus status) => status switch
            {
                OrderStatus.Pending => "warning",
                OrderStatus.Processing => "info",
                OrderStatus.Shipped => "primary",
                OrderStatus.Delivered => "success",
                OrderStatus.Cancelled => "danger",
                OrderStatus.Refunded => "secondary",
                _ => "secondary"
            };
        }
    </div>

    <div class="col-lg-4">
        <!-- Order Info -->
        <div class="card shadow-sm mb-4">
            <div class="card-header">
                <h5 class="mb-0">Order Information</h5>
            </div>
            <div class="card-body">
                <p class="mb-2"><strong>Order Number:</strong> @Model.OrderNumber</p>
                <p class="mb-2"><strong>Order Date:</strong> @Model.OrderDate.ToString("MMM dd, yyyy HH:mm")</p>
                <p class="mb-2"><strong>Status:</strong> <span class="badge @(Model.Status switch {
                    OrderStatus.Pending => "bg-warning",
                    OrderStatus.Processing => "bg-info",
                    OrderStatus.Shipped => "bg-primary",
                    OrderStatus.Delivered => "bg-success",
                    OrderStatus.Cancelled => "bg-danger",
                    _ => "bg-secondary"
                })">@Model.Status</span></p>
                <p class="mb-2"><strong>Payment Status:</strong> <span class="badge @(Model.PaymentStatus == PaymentStatus.Paid ? "bg-success" : "bg-warning")">@Model.PaymentStatus</span></p>
                <p class="mb-2"><strong>Payment Method:</strong> @Model.PaymentMethod</p>
                @if (!string.IsNullOrEmpty(Model.TransactionId))
                {
                    <p class="mb-0"><strong>Transaction ID:</strong> @Model.TransactionId</p>
                }
            </div>
        </div>

        <!-- Customer Info -->
        <div class="card shadow-sm mb-4">
            <div class="card-header">
                <h5 class="mb-0">Customer Information</h5>
            </div>
            <div class="card-body">
                @if (Model.User != null)
                {
                    <p class="mb-2"><strong>Name:</strong> @Model.User.FullName</p>
                    <p class="mb-2"><strong>Email:</strong> @Model.User.Email</p>
                    <p class="mb-0"><strong>Phone:</strong> @(Model.User.PhoneNumber ?? "N/A")</p>
                }
                else
                {
                    <p class="text-muted">Guest checkout</p>
                }
            </div>
        </div>

        <!-- Shipping Address -->
        <div class="card shadow-sm">
            <div class="card-header">
                <h5 class="mb-0">Shipping Address</h5>
            </div>
            <div class="card-body">
                <p class="mb-1">@Model.ShippingAddress</p>
                <p class="mb-1">@Model.ShippingCity, @Model.ShippingPostalCode</p>
                <p class="mb-1">@Model.ShippingCountry</p>
                @if (!string.IsNullOrEmpty(Model.ShippingPhone))
                {
                    <p class="mb-0"><strong>Phone:</strong> @Model.ShippingPhone</p>
                }
                @if (!string.IsNullOrEmpty(Model.Notes))
                {
                    <hr />
                    <p class="mb-0"><strong>Notes:</strong> @Model.Notes</p>
                }
            </div>
        </div>
    </div>
</div>

Core UI Template Framework Patterns Explained

  • High-Efficiency 2-Column Asymmetric Layout Grid: The view implements a row containing a split grid configuration (col-lg-8 and col-lg-4). This design optimizes cognitive hierarchy for shop operators. The left, wider pane contains high-density transactional line items and administrative status form triggers. The right, narrower column groups contextual reference profiles (Core Order Info, Customer Contact Data, and Logistics Shipping addresses) cleanly into vertical cards.

  • Embedded View-Level Razor Helpers (@functions):

    @functions {
        string GetStatusColor(OrderStatus status) => status switch { ... };
    }
    

    Instead of rewriting multi-line pattern matching logic blocks across different elements on the page, the view introduces a local @functions code segment. This creates a reusable, strongly-typed local visual helper method (GetStatusColor) that outputs contextual color modifier strings dynamically for our badges, keeping the main layout codebase clean and maintainable.

  • Fulfillment Lifecycle State Barriers:

    @if (Model.Status != OrderStatus.Cancelled && Model.Status != OrderStatus.Delivered) { ... }

    This conditional statement acts as an essential business logic gatekeeper right in the UI layer. Once an order is formally finalized (Delivered) or fully dropped (Cancelled), the application strips the "Cancel Order" button entirely out of the rendered HTML. This prevents administrative workers from making accidental state updates or processing impossible cancellations on completed invoices.

  • Image Placeholder Fail-safes:

    src="@(item.Product?.MainImageUrl ?? "https://via.placeholder.com/50x50?text=No+Image")"

    To prevent raw database synchronization errors from throwing breaking layout issues, item loops use the null-coalescing operator (??). If a mobile product image reference is missing or empty, the view automatically renders an inline, stylized CSS placeholder, ensuring the layout grid remains stable.

Step 3 — The Order Status Mutation Architecture

This method processes state variations securely via asynchronous request-response life cycles while maintaining high data consistency through cross-service logic synchronizations.

C# / Areas/Admin/Controllers/OrdersController.cs (UpdateStatus Action)
[HttpPost]
public async Task<IActionResult> UpdateStatus(int id, OrderStatus status)
{
    var order = await _context.Orders.FindAsync(id);
    if (order == null)
        return NotFound();

    var result = await _orderService.UpdateOrderStatusAsync(id, status);
   
    if (result)
    {
        // FIX: Update payment status to Paid for COD/UPI/Cash orders when delivered
        if (status == OrderStatus.Delivered &&
            (order.PaymentMethod == PaymentMethod.CashOnDelivery ||
             order.PaymentMethod == PaymentMethod.UPI ||
             order.PaymentMethod == PaymentMethod.CreditCard ||
             order.PaymentMethod == PaymentMethod.DebitCard))
        {
            if (order.PaymentStatus != PaymentStatus.Paid)
            {
                //await _orderService.ProcessPaymentAsync(order.Id, $"DEMO-{Guid.NewGuid()}");
                order.PaymentStatus = PaymentStatus.Paid;
                await _context.SaveChangesAsync();
            }
        }

        // Send email notification
        if (order.UserId != null)
        {
            var user = await _context.Users.FindAsync(order.UserId);
            if (user != null)
            {
                await _emailService.SendOrderStatusUpdateAsync(user.Email!, order.OrderNumber, status.ToString());
            }
        }

        TempData["Success"] = $"Order status updated to {status}.";
        
        // Add payment status message if updated
        if (status == OrderStatus.Delivered && order.PaymentStatus == PaymentStatus.Paid)
        {
            TempData["Success"] += " Payment marked as Paid.";
        }
    }
    else
    {
        TempData["Error"] = "Failed to update order status.";
    }

    return RedirectToAction(nameof(Details), new { id });
}

Core Backend Processing Mechanics Explained

  • Secure HttpPost Verb Decoration:

    [HttpPost]

    Decorating this action with [HttpPost] is an essential security practice. State mutations—such as updating an order from Processing to Shipped—should never be executed via a GET link, which could be accidentally crawled by search bots or pre-fetched by web browsers.

  • Decoupled Business Logic Delegation Layer: Instead of hardcoding raw data column changes directly in the web controller, it routes data through a background layer via _orderService.UpdateOrderStatusAsync(id, status). This separation keeps your code organized and reusable across multiple endpoints.

  • Conditional Automated Payment Sync (The Delivery/COD Fix): This conditional statement handles payment processing anomalies smoothly:

    if (status == OrderStatus.Delivered && (order.PaymentMethod == PaymentMethod.CashOnDelivery || ...))

    When handling offline payment pipelines like Cash on Delivery (COD), the system must reconcile state variations. Once an administrator shifts the primary status flag to Delivered, the codebase steps in to update the associated property (order.PaymentStatus = PaymentStatus.Paid;). It commits this shift inside your DbContext tracking collection before running an explicit .SaveChangesAsync() persistence block to close out the invoice balance.

  • Background Asynchronous Notification Systems:

    await _emailService.SendOrderStatusUpdateAsync(user.Email!, order.OrderNumber, status.ToString());

    The code includes an elegant user experience element. The action searches for the matching customer account and fires off an automated email notification detailing the new status shift (Shipped, Delivered, etc.). Using asynchronous processing with await prevents your thread pool from locking up during external SMTP network requests.

  • Stateful Toast Messages & PRG UX Flow:

    TempData["Success"] = $"Order status updated to {status}.";
    return RedirectToAction(nameof(Details), new { id });
    

    To prevent double-post submissions (which happen when a user manually refreshes their browser page), the application follows the Post/Redirect/Get (PRG) Pattern. It stores text messages inside short-lived cookie segments (TempData), and then returns a 302 redirection back to your original inspection action route.

Step 4 — The Inventory-Safe Cancellation Pipeline

This backend method shifts our fulfillment pipeline records down to Cancelled while executing an integrated database loop to safely replenish warehouse product allocations.

C# / Areas/Admin/Controllers/OrdersController.cs (CancelOrder Action)
[HttpPost]
public async Task<IActionResult> CancelOrder(int id)
{
    var result = await _orderService.UpdateOrderStatusAsync(id, OrderStatus.Cancelled);
    if (result)
    {
        // Restore stock
        var order = await _context.Orders
            .Include(o => o.OrderItems)
            .FirstOrDefaultAsync(o => o.Id == id);

        if (order != null)
        {
            foreach (var item in order.OrderItems)
            {
                var product = await _context.Products.FindAsync(item.ProductId);
                if (product != null)
                {
                    product.StockQuantity += item.Quantity;
                }
            }
            await _context.SaveChangesAsync();
        }

        TempData["Success"] = "Order cancelled successfully.";
    }
    else
    {
        TempData["Error"] = "Failed to cancel order.";
    }

    return RedirectToAction(nameof(Index));
}

Core Data Integrity Mechanics Explained

  • Safe State Transition Processing:

    var result = await _orderService.UpdateOrderStatusAsync(id, OrderStatus.Cancelled);

    The logic begins by asking the business layer service to validate and update the core transaction row. If the order is already in a state that cannot be modified (like already being delivered or archived), the service returns false, preventing corrupted operations.

  • Eager Data Aggregation Loop for Line Items:

    var order = await _context.Orders.Include(o => o.OrderItems)...

    To properly reverse an entire order, we cannot look at the root invoice table alone. We must eagerly pull the dependent database records using .Include(o => o.OrderItems). This steps inside the target order schema to discover exactly how many separate hardware lines and quantity units need to be replenished.

  • Thread-Safe Stock Inventory Reinstatement Engine:

    foreach (var item in order.OrderItems) {
        var product = await _context.Products.FindAsync(item.ProductId);
        if (product != null) { product.StockQuantity += item.Quantity; }
    }
    

    This is the most critical technical pattern in this step. If an e-commerce platform fails to restore stock levels during cancellations, its active storefront display metrics will drift out of synchronization, leading to phantom product shortages. The code loops through each line item record, targets the product table index via FindAsync(item.ProductId), and safely increments the inventory count back up:


  • Atomic Database State Persistence Commit:

    Instead of saving changes repeatedly inside the foreach item loop—which would cause multiple slow database network round-trips—the script holds the structural tracking shifts in memory. It fires one unified .SaveChangesAsync() statement at the very end. This atomic commit structure groups all row modifications into a single SQL transaction block.

  • Redirect-to-Index Safety Routing:

    Unlike standard status updates that keep you on the same item inspector page, a full cancellation effectively ends the processing pipeline. The engine uses RedirectToAction(nameof(Index)) to cleanly bounce the admin worker back out to the main administrative dashboard tracking grid.

Comments