ASP.NET Core 9: Building a Order Processing Application with ASP.NET Core 9 and TickerQ


In this article, we will build a complete Order Processing Application using ASP.NET Core 9 with TickerQ — a modern, source-generated background job scheduler for .NET. The application demonstrates how to accept orders through a web UI, store them in SQL Server, and automatically process them on a timed schedule using TickerQ's cron-based execution engine.

What is TickerQ?

TickerQ is a high-performance, modern job scheduler for .NET applications. Unlike traditional approaches such as IHostedService with Timer, or Hangfire, TickerQ leverages source generators at compile time — meaning zero runtime reflection, full AOT compatibility, and maximum performance.

TickerQ supports two types of scheduled jobs:

  • Time Tickers — One-off jobs scheduled to run at a specific future time.
  • Cron Tickers — Recurring jobs defined using cron expressions (e.g., */1 * * * * for every minute).

Key features of TickerQ include:

  • Source-generated function registration : No magic strings or runtime discovery. The [TickerFunction] attribute is processed at compile time.
  • EF Core persistence : Job state is persisted to your database (SQL Server, PostgreSQL, SQLite, MySQL) using Entity Framework Core.
  • Built-in dashboard : Real-time SignalR-based monitoring UI.
  • First-class DI support : Constructor injection works naturally in ticker function classes.
  • Multi-node coordination : Distributed locking via Redis for scaled-out deployments.
  • Retry & throttling : Configurable retry policies with backoff strategies.

Advantages of Using TickerQ

Feature TickerQ Traditional Timer/IHostedService
Persistence Built-in EF Core persistence No persistence — jobs lost on restart
Scheduling Cron expressions + one-off time-based Manual Timer intervals only
Discovery Source-generated at compile time Manual registration required
AOT Ready Yes — no reflection Depends on implementation
Retry Logic Built-in configurable retries Must implement manually
Monitoring Built-in SignalR dashboard None
Multi-Node Redis-based coordination Not supported

Application Overview

Our OrderProcessor application implements the following workflow:

  1. End users submit orders (single or batch) through Razor Views.
  2. Orders are saved to SQL Server with status "Pending".
  3. TickerQ runs a scheduled cron job that reads all pending orders.
  4. For each order, it checks: Is AdvancePaid >= 35% of TotalAmount?
  5. If yes → Approved. If no → Rejected.
  6. The updated status is saved to the database and reflected on the UI.
  7. Approved/Rejected orders are skipped in subsequent scans.

Architecture Diagram

 


Flow Explanation:
  1. Step 1: User submits order(s) via Razor form in the browser.
  2. Step 2: MVC OrderController saves order to SQL Server with Status = "Pending".
  3. Step 3: TickerQ's CronTickerEntity triggers the [TickerFunction] on the configured schedule.
  4. Step 4: The TickerFunction calls OrderProcessingService.
  5. Step 5: Service reads all pending orders from the database.
  6. Step 6: Applies the 35% advance rule — Approve or Reject each order.
  7. Step 7: Updated statuses are reflected on the UI after automatic page reload.

Prerequisites

  • .NET 9 SDK
  • SQL Server (LocalDB or full instance)
  • Visual Studio 2022 / VS Code
  • EF Core CLI Tools (dotnet tool install --global dotnet-ef)

Step-by-Step Implementation

Step 1: Create the Project and Install NuGet Packages

Open a terminal and run:

dotnet new webapi -n OrderProcessor --framework net9.0 --use-controllers
cd OrderProcessor

# Install EF Core for SQL Server
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Microsoft.EntityFrameworkCore.Design

# Install TickerQ and EF Core provider
dotnet add package TickerQ --version 9.*
dotnet add package TickerQ.EntityFrameworkCore --version 9.*

The project file (OrderProcessor.csproj) should look like this:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.*" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.*" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.*" />
    <PackageReference Include="TickerQ" Version="9.*" />
    <PackageReference Include="TickerQ.EntityFrameworkCore" Version="9.*" />
  </ItemGroup>

</Project>

Step 2: Create the Project Folder Structure

Create the following folders inside the project:

mkdir Models Data Services Tickers Controllers
mkdir Views Views\Order Views\Shared
mkdir wwwroot wwwroot\css

Step 3: Create the Order Model

File: Models/Order.cs

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace OrderProcessor.Models;

public class Order
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int OrderId { get; set; }

    [Required]
    [MaxLength(100)]
    public string CustomerName { get; set; } = string.Empty;

    [Required]
    [MaxLength(200)]
    public string ItemName { get; set; } = string.Empty;

    [Range(1, int.MaxValue)]
    public int Quantity { get; set; }

    [Column(TypeName = "decimal(18,2)")]
    public decimal UnitPrice { get; set; }

    [Column(TypeName = "decimal(18,2)")]
    public decimal TotalAmount { get; set; }

    [Column(TypeName = "decimal(18,2)")]
    public decimal AdvancePaid { get; set; }

    [MaxLength(20)]
    public string OrderStatus { get; set; } = "Pending";

    public DateTime OrderDate { get; set; } = DateTime.Now;
}

Step 4: Create the Order DTO

File: Models/OrderDto.cs

using System.ComponentModel.DataAnnotations;

namespace OrderProcessor.Models;

public class OrderDto
{
    [Required]
    [MaxLength(100)]
    public string CustomerName { get; set; } = string.Empty;

    [Required]
    [MaxLength(200)]
    public string ItemName { get; set; } = string.Empty;

    [Range(1, int.MaxValue)]
    public int Quantity { get; set; }

    [Range(0.01, double.MaxValue)]
    public decimal UnitPrice { get; set; }

    [Range(0, double.MaxValue)]
    public decimal AdvancePaid { get; set; }
}

Step 5: Create the AppDbContext

File: Data/AppDbContext.cs

using Microsoft.EntityFrameworkCore;
using OrderProcessor.Models;

namespace OrderProcessor.Data;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Order> Orders { get; set; }
}

Important Point: When using UseApplicationDbContext<AppDbContext> with TickerQ, the TickerQ tables (CronTickers, TimeTickers, CronTickerOccurrences) are automatically added to this same DbContext via the Model Customizer. This means a single dotnet ef database update creates both your application tables and TickerQ's operational store tables.

Step 6: Create the OrderProcessingService

This is the core business logic — shared between the TickerQ background ticker and the API endpoint.

File: Services/OrderProcessingService.cs

using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using OrderProcessor.Data;

namespace OrderProcessor.Services;

public class OrderProcessingService
{
    private readonly AppDbContext _context;
    private readonly ILogger<OrderProcessingService> _logger;

    public OrderProcessingService(AppDbContext context,
        ILogger<OrderProcessingService> logger)
    {
        _context = context;
        _logger = logger;
    }

    public async Task<ProcessingResult> ProcessPendingOrdersAsync(
        CancellationToken cancellationToken = default)
    {
        _logger.LogInformation("Order Processing: Starting scan at {Time}", DateTime.Now);

        var pendingOrders = await _context.Orders
            .Where(o => o.OrderStatus == "Pending")
            .ToListAsync(cancellationToken);

        if (pendingOrders.Count == 0)
        {
            _logger.LogInformation("Order Processing: No pending orders found.");
            return new ProcessingResult();
        }

        int approved = 0, rejected = 0;

        foreach (var order in pendingOrders)
        {
            decimal requiredAdvance = order.TotalAmount * 0.35m;

            if (order.AdvancePaid >= requiredAdvance)
            {
                order.OrderStatus = "Approved";
                approved++;
                _logger.LogInformation(
                    "Order {OrderId} APPROVED - Advance: {Advance:C}, Required: {Required:C}",
                    order.OrderId, order.AdvancePaid, requiredAdvance);
            }
            else
            {
                order.OrderStatus = "Rejected";
                rejected++;
                _logger.LogInformation(
                    "Order {OrderId} REJECTED - Advance: {Advance:C}, Required: {Required:C}",
                    order.OrderId, order.AdvancePaid, requiredAdvance);
            }
        }

        await _context.SaveChangesAsync(cancellationToken);

        return new ProcessingResult { Approved = approved, Rejected = rejected };
    }
}

public class ProcessingResult
{
    public int Approved { get; set; }
    public int Rejected { get; set; }
    public int Total => Approved + Rejected;
}

Step 7: Create the TickerQ Ticker Function

This is the class that TickerQ discovers at compile time via the [TickerFunction] attribute. It delegates to the shared OrderProcessingService.

File: Tickers/OrderProcessingTicker.cs

using Microsoft.Extensions.DependencyInjection;
using OrderProcessor.Services;
using TickerQ.Utilities.Base;

namespace OrderProcessor.Tickers;

public class OrderProcessingTicker
{
    private readonly IServiceScopeFactory _scopeFactory;

    public OrderProcessingTicker(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    [TickerFunction("ProcessPendingOrders")]
    public async Task ProcessPendingOrders(
        TickerFunctionContext context, CancellationToken cancellationToken)
    {
        using var scope = _scopeFactory.CreateScope();
        var service = scope.ServiceProvider
            .GetRequiredService<OrderProcessingService>();
        await service.ProcessPendingOrdersAsync(cancellationToken);
    }
}
Important Key Points:
  • The [TickerFunction("ProcessPendingOrders")] attribute registers this method with TickerQ's source generator.
  • The method receives a TickerFunctionContext (job metadata) and a CancellationToken.
  • We use IServiceScopeFactory to create a new DI scope, because the DbContext is scoped.

Step 8: Create the API Controller (OrdersController)

File: Controllers/OrdersController.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OrderProcessor.Data;
using OrderProcessor.Models;
using OrderProcessor.Services;

namespace OrderProcessor.Controllers;

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly AppDbContext _context;
    private readonly OrderProcessingService _processingService;

    public OrdersController(AppDbContext context,
        OrderProcessingService processingService)
    {
        _context = context;
        _processingService = processingService;
    }

    // POST: api/orders
    [HttpPost]
    public async Task<IActionResult> CreateOrder([FromBody] OrderDto dto)
    {
        var order = new Order
        {
            CustomerName = dto.CustomerName,
            ItemName = dto.ItemName,
            Quantity = dto.Quantity,
            UnitPrice = dto.UnitPrice,
            TotalAmount = dto.Quantity * dto.UnitPrice,
            AdvancePaid = dto.AdvancePaid,
            OrderStatus = "Pending",
            OrderDate = DateTime.Now
        };

        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
        return CreatedAtAction(nameof(GetOrder), new { id = order.OrderId }, order);
    }

    // POST: api/orders/batch
    [HttpPost("batch")]
    public async Task<IActionResult> CreateOrders([FromBody] List<OrderDto> dtos)
    {
        var orders = dtos.Select(dto => new Order
        {
            CustomerName = dto.CustomerName,
            ItemName = dto.ItemName,
            Quantity = dto.Quantity,
            UnitPrice = dto.UnitPrice,
            TotalAmount = dto.Quantity * dto.UnitPrice,
            AdvancePaid = dto.AdvancePaid,
            OrderStatus = "Pending",
            OrderDate = DateTime.Now
        }).ToList();

        _context.Orders.AddRange(orders);
        await _context.SaveChangesAsync();
        return Ok(new { Message = $"{orders.Count} orders created.", Orders = orders });
    }

    // GET: api/orders
    [HttpGet]
    public async Task<IActionResult> GetOrders()
    {
        var orders = await _context.Orders
            .OrderByDescending(o => o.OrderDate).ToListAsync();
        return Ok(orders);
    }

    // GET: api/orders/{id}
    [HttpGet("{id}")]
    public async Task<IActionResult> GetOrder(int id)
    {
        var order = await _context.Orders.FindAsync(id);
        if (order == null)
            return NotFound(new { Message = $"Order {id} not found." });
        return Ok(order);
    }

    // GET: api/orders/pending
    [HttpGet("pending")]
    public async Task<IActionResult> GetPendingOrders()
    {
        var orders = await _context.Orders
            .Where(o => o.OrderStatus == "Pending")
            .OrderBy(o => o.OrderDate).ToListAsync();
        return Ok(orders);
    }

    // POST: api/orders/process - Trigger processing from UI
    [HttpPost("process")]
    public async Task<IActionResult> ProcessOrders()
    {
        var result = await _processingService.ProcessPendingOrdersAsync();
        return Ok(result);
    }
}

Step 9: Create the MVC Controller (OrderController)

This controller serves the Razor Views for the web UI.

File: Controllers/OrderController.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using OrderProcessor.Data;
using OrderProcessor.Models;

namespace OrderProcessor.Controllers;

public class OrderController : Controller
{
    private readonly AppDbContext _context;

    public OrderController(AppDbContext context) { _context = context; }

    public async Task<IActionResult> Index(string status = "All")
    {
        var query = _context.Orders.AsQueryable();
        if (status != "All")
            query = query.Where(o => o.OrderStatus == status);

        var orders = await query.OrderByDescending(o => o.OrderDate).ToListAsync();

        ViewBag.CurrentStatus = status;
        ViewBag.StatusCounts = new Dictionary<string, int>
        {
            ["All"] = await _context.Orders.CountAsync(),
            ["Pending"] = await _context.Orders.CountAsync(o => o.OrderStatus == "Pending"),
            ["Approved"] = await _context.Orders.CountAsync(o => o.OrderStatus == "Approved"),
            ["Rejected"] = await _context.Orders.CountAsync(o => o.OrderStatus == "Rejected")
        };
        return View(orders);
    }

    public IActionResult Create() => View();

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Create(OrderDto dto)
    {
        if (!ModelState.IsValid) return View(dto);

        var order = new Order
        {
            CustomerName = dto.CustomerName, ItemName = dto.ItemName,
            Quantity = dto.Quantity, UnitPrice = dto.UnitPrice,
            TotalAmount = dto.Quantity * dto.UnitPrice,
            AdvancePaid = dto.AdvancePaid,
            OrderStatus = "Pending", OrderDate = DateTime.Now
        };

        _context.Orders.Add(order);
        await _context.SaveChangesAsync();
        TempData["Success"] = $"Order #{order.OrderId} created. Total: {order.TotalAmount:C}";
        return RedirectToAction(nameof(Index));
    }

    public IActionResult BatchCreate() => View();

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> BatchCreate(List<OrderDto> orders)
    {
        if (orders == null || orders.Count == 0)
        {
            ModelState.AddModelError("", "Please add at least one order.");
            return View();
        }
        var newOrders = orders.Select(dto => new Order
        {
            CustomerName = dto.CustomerName, ItemName = dto.ItemName,
            Quantity = dto.Quantity, UnitPrice = dto.UnitPrice,
            TotalAmount = dto.Quantity * dto.UnitPrice,
            AdvancePaid = dto.AdvancePaid,
            OrderStatus = "Pending", OrderDate = DateTime.Now
        }).ToList();

        _context.Orders.AddRange(newOrders);
        await _context.SaveChangesAsync();
        TempData["Success"] = $"{newOrders.Count} orders created successfully.";
        return RedirectToAction(nameof(Index));
    }

    public async Task<IActionResult> Details(int id)
    {
        var order = await _context.Orders.FindAsync(id);
        if (order == null) return NotFound();
        return View(order);
    }
}

Step 10: Configure Program.cs

File: Program.cs — This is the application entry point where we wire up EF Core, TickerQ, and MVC.

using Microsoft.EntityFrameworkCore;
using OrderProcessor.Data;
using OrderProcessor.Services;
using TickerQ.DependencyInjection;
using TickerQ.EntityFrameworkCore.Customizer;
using TickerQ.EntityFrameworkCore.DependencyInjection;
using TickerQ.Utilities.Entities;
using TickerQ.Utilities.Interfaces.Managers;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();
builder.Services.AddOpenApi();

// EF Core with SQL Server
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("AppDb")));

// Register order processing service
builder.Services.AddScoped<OrderProcessingService>();

// Register TickerQ with EF Core persistence using the application DbContext
builder.Services.AddTickerQ(options =>
{
    options.AddOperationalStore(efOptions =>
    {
        efOptions.UseApplicationDbContext<AppDbContext>(
            ConfigurationType.UseModelCustomizer);
    });
});

var app = builder.Build();

// Apply migrations on startup
using (var scope = app.Services.CreateScope())
{
    var appDb = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    appDb.Database.Migrate();

    // Schedule the cron ticker for order processing
    var cronManager = scope.ServiceProvider
        .GetRequiredService<ICronTickerManager<CronTickerEntity>>();
    try
    {
        await cronManager.AddAsync(new CronTickerEntity
        {
            Function = "ProcessPendingOrders",
            Expression = "*/1 * * * *",
            IsEnabled = true
        });
        Console.WriteLine("TickerQ: Cron ticker scheduled.");
    }
    catch
    {
        Console.WriteLine("TickerQ: Cron ticker already registered.");
    }
}

if (app.Environment.IsDevelopment())
    app.MapOpenApi();

app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.UseTickerQ();

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Order}/{action=Index}/{id?}");
app.MapControllers();

app.Run();

Step 11: Configure appsettings.json

File: appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "AppDb": "Server=.;Database=OrderProcessorDb;Trusted_Connection=True;TrustServerCertificate=True;"
  }
}

Step 12: Create the Razor Views

12a. Views/_ViewImports.cshtml

@using OrderProcessor.Models
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

12b. Views/_ViewStart.cshtml

@{
    Layout = "_Layout";
}

12c. Views/Shared/_Layout.cshtml

The shared layout provides Bootstrap 5 styling, navigation bar, and success alert rendering.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>@ViewData["Title"] - OrderProcessor</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
          rel="stylesheet" />
    <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
          rel="stylesheet" />
    <style>
        .badge-pending { background-color: #ffc107; color: #000; }
        .badge-approved { background-color: #198754; }
        .badge-rejected { background-color: #dc3545; }
        .stat-card { border-left: 4px solid; }
        .stat-card.all { border-color: #0d6efd; }
        .stat-card.pending { border-color: #ffc107; }
        .stat-card.approved { border-color: #198754; }
        .stat-card.rejected { border-color: #dc3545; }
    </style>
</head>
<body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
        <div class="container">
            <a class="navbar-brand" asp-controller="Order" asp-action="Index">
                <i class="bi bi-box-seam"></i> OrderProcessor
            </a>
            <div class="collapse navbar-collapse" id="navbarNav">
                <ul class="navbar-nav ms-auto">
                    <li class="nav-item">
                        <a class="nav-link" asp-controller="Order" asp-action="Index">
                            <i class="bi bi-list-ul"></i> All Orders</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" asp-controller="Order" asp-action="Create">
                            <i class="bi bi-plus-circle"></i> New Order</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" asp-controller="Order" asp-action="BatchCreate">
                            <i class="bi bi-stack"></i> Batch Orders</a>
                    </li>
                </ul>
            </div>
        </div>
    </nav>
    <div class="container">
        @if (TempData["Success"] != null)
        {
            <div class="alert alert-success alert-dismissible fade show">
                <i class="bi bi-check-circle-fill"></i> @TempData["Success"]
                <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
            </div>
        }
        @RenderBody()
    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
    @await RenderSectionAsync("Scripts", required: false)
</body>
</html>

12d. Views/Order/Index.cshtml — Order Dashboard with TickerQ Progress Bar

This is the main view. It shows status dashboard cards, a TickerQ processing progress bar with countdown timer, and the orders table. When the timer completes, it calls POST /api/orders/process to trigger server-side processing, then reloads the page.

@model List<Order>
@{
    ViewData["Title"] = "Orders";
    var statusCounts = ViewBag.StatusCounts as Dictionary<string, int>;
    var currentStatus = ViewBag.CurrentStatus as string;
    var hasPending = statusCounts != null && statusCounts["Pending"] > 0;
}

<!-- TickerQ Processing Progress Bar (visible only when pending orders exist) -->
@if (hasPending)
{
    <div class="card border-warning mb-4 shadow-sm">
        <div class="card-body py-3">
            <div class="d-flex align-items-center mb-2">
                <i class="bi bi-gear-wide-connected fs-4 text-warning me-2 spinner-icon"></i>
                <div>
                    <strong>TickerQ Order Processing</strong>
                    <span class="text-muted ms-2">
                        @statusCounts!["Pending"] pending order(s) awaiting processing
                    </span>
                </div>
                <div class="ms-auto text-end">
                    <small class="text-muted">Next scan in: </small>
                    <span class="badge bg-warning text-dark fs-6" id="countdown">--:--</span>
                </div>
            </div>
            <div class="progress" style="height: 8px;">
                <div class="progress-bar progress-bar-striped progress-bar-animated bg-warning"
                     id="progressBar" role="progressbar" style="width: 0%"></div>
            </div>
            <small class="text-muted mt-1 d-block">
                TickerQ scans every 15 seconds.
                Orders with Advance >= 35% of Total will be Approved, otherwise Rejected.
            </small>
        </div>
    </div>
}

<!-- Status Dashboard Cards -->
<div class="row mb-4">
    @foreach (var item in new[] {
        ("All", "collection", "text-primary", "all"),
        ("Pending", "clock-history", "text-warning", "pending"),
        ("Approved", "check-circle", "text-success", "approved"),
        ("Rejected", "x-circle", "text-danger", "rejected") })
    {
        <div class="col-md-3">
            <div class="card stat-card @item.Item4 shadow-sm">
                <div class="card-body py-2">
                    <div class="d-flex justify-content-between align-items-center">
                        <div>
                            <div class="text-muted small">@item.Item1</div>
                            <div class="fs-4 fw-bold">@statusCounts![item.Item1]</div>
                        </div>
                        <i class="bi bi-@item.Item2 fs-2 @item.Item3"></i>
                    </div>
                </div>
            </div>
        </div>
    }
</div>

<!-- Orders Table with Status Filter -->
<div class="card shadow-sm">
    <div class="card-header bg-white d-flex justify-content-between align-items-center">
        <h5 class="mb-0"><i class="bi bi-table"></i> Orders</h5>
        <div>
            @foreach (var s in new[] { "All", "Pending", "Approved", "Rejected" })
            {
                <a asp-action="Index" asp-route-status="@s"
                   class="btn btn-sm @(currentStatus == s ? "btn-primary" : "btn-outline-secondary") me-1">
                    @s
                </a>
            }
        </div>
    </div>
    <div class="card-body p-0">
        @if (Model.Count == 0)
        {
            <div class="text-center py-5 text-muted">
                <i class="bi bi-inbox fs-1"></i>
                <p class="mt-2">No orders found.</p>
                <a asp-action="Create" class="btn btn-primary btn-sm">Create First Order</a>
            </div>
        }
        else
        {
            <div class="table-responsive">
                <table class="table table-hover mb-0 align-middle">
                    <thead class="table-light">
                        <tr>
                            <th>#</th><th>Customer</th><th>Item</th>
                            <th class="text-center">Qty</th>
                            <th class="text-end">Unit Price</th>
                            <th class="text-end">Total</th>
                            <th class="text-end">Advance Paid</th>
                            <th class="text-center">Status</th>
                            <th>Date</th><th></th>
                        </tr>
                    </thead>
                    <tbody>
                        @foreach (var order in Model)
                        {
                            var badgeClass = order.OrderStatus switch
                            {
                                "Approved" => "badge-approved",
                                "Rejected" => "badge-rejected",
                                _ => "badge-pending"
                            };
                            <tr data-order-id="@order.OrderId" data-status="@order.OrderStatus">
                                <td><strong>@order.OrderId</strong></td>
                                <td>@order.CustomerName</td>
                                <td>@order.ItemName</td>
                                <td class="text-center">@order.Quantity</td>
                                <td class="text-end">@order.UnitPrice.ToString("N2")</td>
                                <td class="text-end fw-bold">@order.TotalAmount.ToString("N2")</td>
                                <td class="text-end">@order.AdvancePaid.ToString("N2")</td>
                                <td class="text-center status-cell">
                                    @if (order.OrderStatus == "Pending")
                                    {
                                        <span class="badge badge-pending">
                                            <span class="spinner-border spinner-border-sm me-1"
                                                  style="width:0.7rem;height:0.7rem;"></span>
                                            Processing...
                                        </span>
                                    }
                                    else
                                    {
                                        <span class="badge @badgeClass">@order.OrderStatus</span>
                                    }
                                </td>
                                <td><small>@order.OrderDate.ToString("dd-MMM-yyyy HH:mm")</small></td>
                                <td>
                                    <a asp-action="Details" asp-route-id="@order.OrderId"
                                       class="btn btn-sm btn-outline-info">
                                        <i class="bi bi-eye"></i>
                                    </a>
                                </td>
                            </tr>
                        }
                    </tbody>
                </table>
            </div>
        }
    </div>
</div>

@section Scripts {
    <style>
        .spinner-icon { animation: spin 2s linear infinite; }
        @@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
    </style>

    <script>
        (function () {
            const CYCLE_SECONDS = 15;
            let elapsed = 0;
            let processing = false;

            const progressBar = document.getElementById('progressBar');
            const countdown = document.getElementById('countdown');
            const hasPending = document.querySelectorAll('tr[data-status="Pending"]').length > 0;

            if (!hasPending || !progressBar || !countdown) return;

            const timer = setInterval(function () {
                elapsed++;
                if (elapsed >= CYCLE_SECONDS) {
                    clearInterval(timer);
                    if (!processing) {
                        processing = true;
                        progressBar.style.width = '100%';
                        progressBar.classList.remove('bg-warning');
                        progressBar.classList.add('bg-info');
                        progressBar.classList.remove('progress-bar-animated');
                        countdown.textContent = 'Processing...';

                        // Call server to process pending orders
                        fetch('/api/orders/process', { method: 'POST' })
                            .then(function (r) { return r.json(); })
                            .then(function (result) {
                                var total = (result.approved || 0) + (result.rejected || 0);
                                progressBar.classList.remove('bg-info');
                                progressBar.classList.add('bg-success');
                                countdown.textContent = total > 0
                                    ? (total + ' order(s) processed!') : 'No pending orders';
                                setTimeout(function () { location.reload(); }, 1500);
                            })
                            .catch(function () {
                                countdown.textContent = 'Retrying...';
                                setTimeout(function () { location.reload(); }, 1500);
                            });
                    }
                    return;
                }
                var pct = (elapsed / CYCLE_SECONDS) * 100;
                progressBar.style.width = pct + '%';
                var remaining = CYCLE_SECONDS - elapsed;
                var mins = Math.floor(remaining / 60);
                var secs = remaining % 60;
                countdown.textContent = mins + ':' + (secs < 10 ? '0' : '') + secs;
            }, 1000);
        })();
    </script>
}

12e. Views/Order/Create.cshtml — Single Order Form

The create form shows a live calculation preview of Total Amount and Advance Paid as the user types.

@model OrderDto
@{ ViewData["Title"] = "Create Order"; }

<div class="row justify-content-center">
    <div class="col-md-7">
        <div class="card shadow-sm">
            <div class="card-header bg-white">
                <h5 class="mb-0"><i class="bi bi-plus-circle"></i> Create New Order</h5>
            </div>
            <div class="card-body">
                <form asp-action="Create" method="post">
                    <div class="mb-3">
                        <label asp-for="CustomerName" class="form-label">Customer Name</label>
                        <input asp-for="CustomerName" class="form-control" />
                        <span asp-validation-for="CustomerName" class="text-danger"></span>
                    </div>
                    <div class="mb-3">
                        <label asp-for="ItemName" class="form-label">Item Name</label>
                        <input asp-for="ItemName" class="form-control" />
                        <span asp-validation-for="ItemName" class="text-danger"></span>
                    </div>
                    <div class="row">
                        <div class="col-md-6 mb-3">
                            <label asp-for="Quantity" class="form-label">Quantity</label>
                            <input asp-for="Quantity" class="form-control" type="number" min="1" value="1" />
                        </div>
                        <div class="col-md-6 mb-3">
                            <label asp-for="UnitPrice" class="form-label">Unit Price</label>
                            <input asp-for="UnitPrice" class="form-control" type="number" step="0.01" />
                        </div>
                    </div>
                    <div class="mb-3">
                        <label asp-for="AdvancePaid" class="form-label">Advance Paid</label>
                        <input asp-for="AdvancePaid" class="form-control" type="number" step="0.01" />
                    </div>
                    <div id="calcPreview" class="alert alert-info d-none mb-3">
                        <div class="row text-center">
                            <div class="col-6">
                                <small class="text-muted">Total Amount</small>
                                <div class="fw-bold" id="totalAmount">--</div>
                            </div>
                            <div class="col-6">
                                <small class="text-muted">Advance Paid</small>
                                <div class="fw-bold" id="advanceDisplay">--</div>
                            </div>
                        </div>
                    </div>
                    <div class="d-flex gap-2">
                        <button type="submit" class="btn btn-primary">
                            <i class="bi bi-check-lg"></i> Submit Order</button>
                        <a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
                    </div>
                </form>
            </div>
        </div>
    </div>
</div>

@section Scripts {
    <script>
        document.addEventListener('input', function () {
            const qty = parseFloat(document.querySelector('[name="Quantity"]').value) || 0;
            const price = parseFloat(document.querySelector('[name="UnitPrice"]').value) || 0;
            const advance = parseFloat(document.querySelector('[name="AdvancePaid"]').value) || 0;
            const total = qty * price;
            const preview = document.getElementById('calcPreview');
            if (total > 0) {
                preview.classList.remove('d-none');
                document.getElementById('totalAmount').textContent = total.toFixed(2);
                document.getElementById('advanceDisplay').textContent = advance.toFixed(2);
            } else { preview.classList.add('d-none'); }
        });
    </script>
}

12f. Views/Order/BatchCreate.cshtml — Batch Order Form

Users can dynamically add/remove order rows and submit them all at once.

@{ ViewData["Title"] = "Batch Create Orders"; }

<div class="row justify-content-center">
    <div class="col-md-10">
        <div class="card shadow-sm">
            <div class="card-header bg-white d-flex justify-content-between">
                <h5 class="mb-0"><i class="bi bi-stack"></i> Batch Create Orders</h5>
                <button type="button" class="btn btn-sm btn-success" id="addRow">
                    <i class="bi bi-plus-lg"></i> Add Order Row</button>
            </div>
            <div class="card-body">
                <form asp-action="BatchCreate" method="post">
                    <table class="table align-middle" id="ordersTable">
                        <thead class="table-light">
                            <tr>
                                <th>#</th><th>Customer</th><th>Item</th>
                                <th>Qty</th><th>Unit Price</th>
                                <th>Advance</th><th>Total</th><th></th>
                            </tr>
                        </thead>
                        <tbody id="orderRows"></tbody>
                    </table>
                    <div class="d-flex gap-2 mt-3">
                        <button type="submit" class="btn btn-primary" id="submitBtn" disabled>
                            <i class="bi bi-check-lg"></i> Submit All Orders</button>
                        <a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
                    </div>
                </form>
            </div>
        </div>
    </div>
</div>

@section Scripts {
    <script>
        let rowIndex = 0;
        function addRow() {
            const i = rowIndex++;
            const tr = document.createElement('tr');
            tr.id = 'row-' + i;
            tr.innerHTML = `
                <td class="fw-bold">${i+1}</td>
                <td><input name="orders[${i}].CustomerName" class="form-control form-control-sm"
                     required placeholder="Customer" /></td>
                <td><input name="orders[${i}].ItemName" class="form-control form-control-sm"
                     required placeholder="Item" /></td>
                <td><input name="orders[${i}].Quantity" type="number" min="1" value="1"
                     class="form-control form-control-sm qty" data-row="${i}"
                     required style="width:70px" /></td>
                <td><input name="orders[${i}].UnitPrice" type="number" step="0.01" min="0.01"
                     class="form-control form-control-sm price" data-row="${i}"
                     required style="width:110px" /></td>
                <td><input name="orders[${i}].AdvancePaid" type="number" step="0.01" min="0"
                     value="0" class="form-control form-control-sm advance" data-row="${i}"
                     required style="width:110px" /></td>
                <td class="fw-bold" id="total-${i}">0.00</td>
                <td><button type="button" class="btn btn-sm btn-outline-danger remove-row"
                     data-row="${i}"><i class="bi bi-trash"></i></button></td>`;
            document.getElementById('orderRows').appendChild(tr);
            document.getElementById('submitBtn').disabled = false;
        }
        document.getElementById('addRow').addEventListener('click', addRow);
        document.getElementById('ordersTable').addEventListener('input', function(e) {
            const r = e.target.dataset.row;
            if (r === undefined) return;
            const qty = parseFloat(document.querySelector('.qty[data-row="'+r+'"]')?.value)||0;
            const price = parseFloat(document.querySelector('.price[data-row="'+r+'"]')?.value)||0;
            document.getElementById('total-'+r).textContent = (qty*price).toFixed(2);
        });
        document.getElementById('ordersTable').addEventListener('click', function(e) {
            const btn = e.target.closest('.remove-row');
            if (btn) {
                document.getElementById('row-'+btn.dataset.row)?.remove();
                document.getElementById('submitBtn').disabled =
                    document.querySelectorAll('#orderRows tr').length === 0;
            }
        });
        addRow(); // Start with one row
    </script>
}

12g. Views/Order/Details.cshtml — Order Details View

Shows full order details with the 35% advance check result.

@model Order
@{
    ViewData["Title"] = $"Order #{Model.OrderId}";
    var required = Model.TotalAmount * 0.35m;
    var badgeClass = Model.OrderStatus switch
    {
        "Approved" => "badge-approved",
        "Rejected" => "badge-rejected",
        _ => "badge-pending"
    };
    var meetsRequirement = Model.AdvancePaid >= required;
}

<div class="row justify-content-center">
    <div class="col-md-7">
        <div class="card shadow-sm">
            <div class="card-header bg-white d-flex justify-content-between">
                <h5 class="mb-0"><i class="bi bi-receipt"></i> Order #@Model.OrderId</h5>
                <span class="badge @badgeClass fs-6">@Model.OrderStatus</span>
            </div>
            <div class="card-body">
                <table class="table table-borderless mb-0">
                    <tr><th class="text-muted">Customer</th><td>@Model.CustomerName</td></tr>
                    <tr><th class="text-muted">Item</th><td>@Model.ItemName</td></tr>
                    <tr><th class="text-muted">Quantity</th><td>@Model.Quantity</td></tr>
                    <tr><th class="text-muted">Unit Price</th><td>@Model.UnitPrice.ToString("N2")</td></tr>
                    <tr><th class="text-muted">Total Amount</th>
                        <td class="fw-bold fs-5">@Model.TotalAmount.ToString("N2")</td></tr>
                    <tr><th class="text-muted">Advance Paid</th>
                        <td class="@(meetsRequirement ? "text-success" : "text-danger") fw-bold">
                            @Model.AdvancePaid.ToString("N2")</td></tr>
                    <tr><th class="text-muted">35% Required</th>
                        <td>@required.ToString("N2")</td></tr>
                    <tr><th class="text-muted">Advance Check</th>
                        <td>
                            @if (meetsRequirement)
                            { <span class="text-success"><i class="bi bi-check-circle-fill"></i>
                                Advance >= 35% - Meets requirement</span> }
                            else
                            { <span class="text-danger"><i class="bi bi-x-circle-fill"></i>
                                Advance < 35% - Does not meet requirement</span> }
                        </td></tr>
                    <tr><th class="text-muted">Order Date</th>
                        <td>@Model.OrderDate.ToString("dd-MMM-yyyy HH:mm:ss")</td></tr>
                </table>
            </div>
            <div class="card-footer bg-white">
                <a asp-action="Index" class="btn btn-outline-secondary btn-sm">
                    <i class="bi bi-arrow-left"></i> Back to Orders</a>
            </div>
        </div>
    </div>
</div>

Step 13: Create Migration and Update Database

# Create migration (includes both Orders table and TickerQ tables)
dotnet ef migrations add InitialCreate --context AppDbContext

# Apply migration to SQL Server
dotnet ef database update --context AppDbContext

This single migration creates:

  • dbo.Orders — Your application order data
  • ticker.CronTickers — TickerQ cron job definitions
  • ticker.TimeTickers — TickerQ time-based jobs
  • ticker.CronTickerOccurrences — TickerQ cron execution history

Step 14: Run the Application

dotnet run

The browser will open automatically at http://localhost:5025. You will see the Order Dashboard.


How It Works — End to End

  1. Create Orders: Use the "New Order" or "Batch Orders" navigation to submit orders. Each order is saved with Status = Pending.
  2. Progress Bar Appears: On the Index page, a TickerQ progress bar starts a 15-second countdown with an animated striped bar.
  3. Timer Completes: When the countdown reaches 0, the UI calls POST /api/orders/process.
  4. Server Processes: The OrderProcessingService reads all Pending orders and applies the 35% advance rule:
    • If AdvancePaid >= 35% of TotalAmount → Status changed to Approved
    • If AdvancePaid < 35% of TotalAmount → Status changed to Rejected
  5. UI Updates: The progress bar turns green, shows "X order(s) processed!", and the page reloads to show updated statuses.
  6. Subsequent Scans: Approved and Rejected orders are skipped — only Pending orders are processed.

Example

OrderTotal35% RequiredAdvance PaidResult
Laptop (1 x 500,000) 500,000.00 175,000.00 200,000.00 Approved
Bulb (200 x 500) 100,000.00 35,000.00 500.00 Rejected

After Running the Application, the Result will be shown as explain in the Video. 



Conclusion

In this article, we built a complete Order Processing Application using ASP.NET Core 9 with TickerQ. We demonstrated:

  • How to integrate TickerQ with Entity Framework Core and SQL Server for persistent job scheduling.
  • How to use the [TickerFunction] attribute for source-generated function discovery.
  • How to register CronTickerEntity for recurring scheduled execution.
  • How to build a responsive Razor Views UI with Bootstrap 5, including a real-time progress bar that triggers server-side processing.
  • How to apply business rules (35% advance validation) in a shared service consumed by both the background ticker and the API.
Code for this article can be downloaded from this link.

TickerQ provides a modern, compile-time-safe, and persistence-backed alternative to traditional background job approaches in .NET   making it an excellent choice for applications that need reliable scheduled task execution.

Popular posts from this blog

Azure AI Building RAG Application Solution: Using Azure AI Search Service and Creating Data Source, Index, and Indexer

ASP.NET Core 8 API: How to implement REPR Pattern Endpoints in ASP.NET Core 8 API

Azure AI Document Intelligence: Processing an Invoice and Saving it in Azure SQL Server Database using Azure Functions