ASP.NET Core 9: Building a Time-Based 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

ASP.NET Core 8: Creating Custom Authentication Handler for Authenticating Users for the Minimal APIs

ASP.NET Core 7: Using PostgreSQL to store Identity Information

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