InvoiceMVCVSAAugust 2026 · 12 min read

MVC Invoice Manager Architecture: VSA, CQRS, and Auto-Calculated Totals

TL;DR

MVC Invoice Manager is built on Vertical Slice Architecture with plain CQRS handlers (no MediatR dependency), an auto-calculated totals engine that computes subtotal/tax/discount/grand total on every save, a state machine enforcing invoice lifecycle transitions, Minimal API endpoints for CRUD, and PDF generation from HTML templates—all in a clean, testable .NET codebase.

Most invoicing systems hide their calculation logic behind stored procedures and ORM magic, making it nearly impossible to understand how a total was derived. MVC Invoice Manager takes the opposite approach: every calculation is explicit C# code organized into vertical slices, every state transition is validated, and every architectural decision serves testability and clarity. This deep dive walks through the folder structure, handler pattern, calculation engine, and export pipeline.

Vertical Slice Architecture: Features, Not Layers

Instead of splitting the codebase horizontally into Controllers, Services, and Repositories, MVC Invoice Manager organizes code vertically by feature. Each feature folder under Features/Invoice/ contains everything a feature needs—command, handler, validator, DTOs, and view model—in one place:

Features/
  Invoice/
    Create/
      CreateInvoiceCommand.cs
      CreateInvoiceHandler.cs
      CreateInvoiceValidator.cs
      CreateInvoiceDto.cs
    Confirm/
      ConfirmInvoiceCommand.cs
      ConfirmInvoiceHandler.cs
      ConfirmInvoiceValidator.cs
    AddPayment/
      AddPaymentCommand.cs
      AddPaymentHandler.cs
      AddPaymentValidator.cs
    List/
      ListInvoicesQuery.cs
      ListInvoicesHandler.cs
      InvoiceListItemDto.cs
    Get/
      GetInvoiceQuery.cs
      GetInvoiceHandler.cs
      InvoiceDetailDto.cs
    Invoice.cs          (domain entity)
    InvoiceLine.cs      (child entity)
    Payment.cs          (child entity)
    InvoiceStatus.cs    (enum)

This structure means you never hunt across three projects to understand how invoice creation works. Open the Create/ folder and the entire vertical is in front of you. Adding a new feature means adding a new folder—no risk of breaking an unrelated service.

CQRS with Plain Handlers (No MediatR)

Commands and queries are separated explicitly, but MVC Invoice Manager avoids the MediatR library. Instead, handlers are plain classes registered directly with the DI container, keeping the call stack shallow and debuggable. A command is a simple record or class carrying input data; its handler contains the business logic:

public class CreateInvoiceHandler
{
    private readonly AppDbContext _db;
    private readonly ITaxService _taxService;

    public CreateInvoiceHandler(AppDbContext db, ITaxService taxService)
    {
        _db = db;
        _taxService = taxService;
    }

    public async Task<int> HandleAsync(CreateInvoiceCommand command)
    {
        var customer = await _db.Customers.FindAsync(command.CustomerId)
            ?? throw new NotFoundException("Customer not found");

        var invoice = new Invoice
        {
            CustomerId = command.CustomerId,
            InvoiceDate = command.InvoiceDate ?? DateTime.UtcNow,
            CurrencyId = command.CurrencyId,
            PaymentTermId = command.PaymentTermId,
            Status = InvoiceStatus.Draft,
            Lines = command.Lines.Select(l => new InvoiceLine
            {
                ProductId = l.ProductId,
                Description = l.Description,
                Quantity = l.Quantity,
                UnitPrice = l.UnitPrice,
                TaxRate = l.TaxRate,
                DiscountPercentage = l.DiscountPercentage
            }).ToList()
        };

        CalculateTotals(invoice);
        SetDueDate(invoice);

        _db.Invoices.Add(invoice);
        await _db.SaveChangesAsync();

        return invoice.Id;
    }
}

Handlers are registered as scoped services in Program.cs and injected directly into controllers or Minimal API endpoints. This eliminates the indirection of IMediator.Send() and makes the dependency graph visible in constructor signatures. Testing a handler is straightforward: instantiate it with an in-memory database, call HandleAsync, and assert the result.

Auto-Calculated Totals Engine

The calculation engine is the core intellectual property of any invoicing system. MVC Invoice Manager centralizes all arithmetic in a single method that runs on every save, ensuring the persisted totals always match the line items:

private void CalculateTotals(Invoice invoice)
{
    decimal subtotal = 0;
    decimal discountTotal = 0;
    decimal taxTotal = 0;

    foreach (var line in invoice.Lines)
    {
        var lineAmount = line.Quantity * line.UnitPrice;
        var lineDiscount = lineAmount * (line.DiscountPercentage / 100m);
        var afterDiscount = lineAmount - lineDiscount;
        var lineTax = afterDiscount * (line.TaxRate / 100m);

        subtotal += lineAmount;
        discountTotal += lineDiscount;
        taxTotal += lineTax;

        line.LineTotal = afterDiscount + lineTax;
    }

    invoice.Subtotal = subtotal;
    invoice.DiscountTotal = discountTotal;
    invoice.TaxTotal = taxTotal;
    invoice.GrandTotal = subtotal - discountTotal + taxTotal;
    invoice.BalanceDue = invoice.GrandTotal - invoice.Payments.Sum(p => p.Amount);
}

The calculation order is deliberate: quantity × unit price → subtract line discount → add tax on the discounted amount. This matches how most tax authorities expect invoices to be computed. The BalanceDue field is recalculated by subtracting all recorded payments from the grand total, giving real-time visibility into what the client still owes.

Invoice Lifecycle State Machine

State transitions are not just enum assignments—they carry validation rules that prevent invalid moves. The state machine enforces the path Draft → Confirmed → PartialPaid → Paid:

public void Confirm()
{
    if (Status != InvoiceStatus.Draft)
        throw new InvalidOperationException(
            $"Cannot confirm invoice in {Status} status. Only Draft invoices can be confirmed.");

    if (!Lines.Any())
        throw new InvalidOperationException(
            "Cannot confirm an invoice with no line items.");

    if (GrandTotal <= 0)
        throw new InvalidOperationException(
            "Cannot confirm an invoice with a zero or negative total.");

    Status = InvoiceStatus.Confirmed;
    ConfirmedAt = DateTime.UtcNow;
}

public void RegisterPayment(decimal amount)
{
    if (Status != InvoiceStatus.Confirmed && Status != InvoiceStatus.PartialPaid)
        throw new InvalidOperationException(
            $"Cannot register payment for invoice in {Status} status.");

    if (amount > BalanceDue)
        throw new InvalidOperationException(
            $"Payment amount {amount:C} exceeds balance due {BalanceDue:C}.");

    Payments.Add(new Payment
    {
        Amount = amount,
        PaymentDate = DateTime.UtcNow
    });

    Status = Payments.Sum(p => p.Amount) >= GrandTotal
        ? InvoiceStatus.Paid
        : InvoiceStatus.PartialPaid;
}

Each method is a domain behavior on the Invoice entity itself, not a separate service. This keeps the logic close to the data it governs and makes the allowed operations explicit. The validation rules are self-documenting: you can read the guard clauses and immediately understand the business constraints.

Minimal API Endpoints for CRUD Operations

While MVC controllers handle the Razor views, CRUD operations exposed to AJAX calls and potential API consumers use Minimal API endpoints. These are defined in a clean, convention-based pattern:

app.MapGet("/api/invoices/{id:int}", async (int id, GetInvoiceHandler handler) =>
{
    var invoice = await handler.HandleAsync(new GetInvoiceQuery(id));
    return invoice is null ? Results.NotFound() : Results.Ok(invoice);
});

app.MapPost("/api/invoices", async (CreateInvoiceCommand cmd, CreateInvoiceHandler handler) =>
{
    var id = await handler.HandleAsync(cmd);
    return Results.Created($"/api/invoices/{id}", new { id });
});

app.MapPost("/api/invoices/{id:int}/confirm", async (int id, ConfirmInvoiceHandler handler) =>
{
    await handler.HandleAsync(new ConfirmInvoiceCommand(id));
    return Results.Ok();
});

Endpoints are organized by resource and action, with handlers injected directly by the DI container. No controller base classes, no attribute routing magic—just functions that take input, call a handler, and return a result. This pattern is lightweight, testable, and maps cleanly to OpenAPI documentation.

PDF Generation Pipeline

PDF invoices are generated by rendering an HTML template with the invoice data, then converting that HTML to PDF. The pipeline has three stages: data hydration, template rendering, and PDF conversion. The HTML template is a standard Razor view stored in the Templates/ folder, styled with CSS for print layout. The conversion step uses a lightweight PDF library that accepts HTML input and produces a byte array:

  • Data hydration — The handler loads the invoice with all related entities (customer, lines, payments, currency, payment terms) and maps them to a flat view model.
  • Template rendering — The view model is passed to a Razor engine service that renders the HTML template to a string. CSS handles page breaks, header/footer, and typography.
  • PDF conversion — The HTML string is fed to the PDF library, which returns a byte array. This byte array is either streamed to the browser as a download or saved to disk for archival.

Because the template is just HTML and CSS, customizing the layout—adding a logo, changing fonts, rearranging sections—requires no changes to the C# code. The separation between data and presentation is preserved end to end.

Excel Export Implementation

Excel export follows a similar pattern: a handler queries the invoices, maps them to a flat DTO, and passes the collection to an Excel writer service. The writer creates a workbook with separate sheets for invoices, line items, and payments, applies column formatting (date, currency, number), and returns the file as a byte array. The export supports filtering by date range, customer, and status, so users can export exactly the data they need for accounting periods or tax filings.

FAQ

How are totals auto-calculated? The CalculateTotals method iterates every line item, computes quantity × unit price − discount + tax per line, and sums into invoice-level subtotal, discount total, tax total, and grand total. It runs on every save, so persisted data always matches computed values.

Can tax rules be customized? Yes. Tax rates are stored in the Taxes master register. Each product references a default tax rate, and each invoice line can override that rate. You can add new tax types (VAT, GST, sales tax) without changing code.

How is PDF generation implemented? Invoice data is hydrated into a view model, rendered through an HTML/Razor template, and converted to PDF by a lightweight library. The template is plain HTML/CSS, fully customizable for branding.

Why no MediatR? Plain handler classes registered directly in DI keep the call stack shallow, make debugging straightforward (F12 navigates to the handler), and remove an external dependency. The trade-off is slightly more explicit DI registration, which is a one-time setup cost.

Want VSA + CQRS invoicing in your codebase today?

MVC EDevKit Basic ships with the full Invoice Manager architecture: VSA folders, plain CQRS handlers, auto-calculated totals, PDF/Excel export, and Minimal API endpoints. $21.

View MVC EDevKit Details