DEV Community

Cover image for ASP.NET Core promo codes: validating discounts safely with EF Core
GANESH KUMAR
GANESH KUMAR

Posted on

ASP.NET Core promo codes: validating discounts safely with EF Core

A promo code looks like a small feature: a text box and a percentage. Then a code meant for 100 people gets used 3,000 times, an expired code still works because of a time-zone bug, or a 10% discount turns a $0.05 item into a negative total.

Here's how I model and apply promo codes in eCommerceMVCCore, my ASP.NET Core e-commerce store, with simplified code.

The model

public class PromoCode
{
    public int Id { get; set; }
    public string Code { get; set; } = "";          // stored upper-case
    public DiscountType Type { get; set; }           // Percent or FixedAmount
    public decimal Value { get; set; }
    public decimal MinimumOrder { get; set; }
    public DateTime? StartsAtUtc { get; set; }
    public DateTime? ExpiresAtUtc { get; set; }
    public int? MaxUses { get; set; }                // null = unlimited
    public int TimesUsed { get; set; }
    public bool IsActive { get; set; } = true;
}
Enter fullscreen mode Exit fullscreen mode

Give Code a unique index, and store it upper-case so summer10 and SUMMER10 are the same code on every database. (PostgreSQL compares strings case-sensitively by default, so this matters.)

Validate everything on the server

The browser only ever sends the code. The server looks it up and recalculates the cart from the database, every time the total is shown and again at checkout:

public async Task<PromoResult> ValidateAsync(string input, decimal subtotal)
{
    var code = input.Trim().ToUpperInvariant();
    var now = DateTime.UtcNow;

    var promo = await db.PromoCodes.AsNoTracking().SingleOrDefaultAsync(p => p.Code == code);

    if (promo is null || !promo.IsActive)                  return PromoResult.Invalid("This code isn't valid.");
    if (promo.StartsAtUtc > now)                            return PromoResult.Invalid("This code isn't active yet.");
    if (promo.ExpiresAtUtc <= now)                          return PromoResult.Invalid("This code has expired.");
    if (promo.MaxUses is int max && promo.TimesUsed >= max) return PromoResult.Invalid("This code has been fully used.");
    if (subtotal < promo.MinimumOrder)                      return PromoResult.Invalid($"Spend at least {promo.MinimumOrder:0.00} to use this code.");

    return PromoResult.Valid(promo, CalculateDiscount(promo, subtotal));
}
Enter fullscreen mode Exit fullscreen mode

Store dates in UTC and compare with DateTime.UtcNow. "Expires at midnight" in the admin's time zone should be converted to UTC once, when the code is saved.

Round once, and never go below zero

static decimal CalculateDiscount(PromoCode promo, decimal subtotal)
{
    var discount = promo.Type == DiscountType.Percent
        ? subtotal * promo.Value / 100m
        : promo.Value;

    discount = Math.Round(discount, 2, MidpointRounding.AwayFromZero);
    return Math.Min(discount, subtotal);   // a discount can't make the total negative
}
Enter fullscreen mode Exit fullscreen mode

Use decimal, never double, for money. And round at one well-defined point. Rounding every line and then the total again gives totals that are off by a cent, and payment providers reject orders whose lines don't add up.

Count usage atomically

The classic bug: two shoppers check out at the same moment with the last use of a code. Both read TimesUsed = 99, both pass the check, both save 100.

Fix it with a conditional update, so the database does the check and the increment in one statement:

var updated = await db.PromoCodes
    .Where(p => p.Id == promoId && (p.MaxUses == null || p.TimesUsed < p.MaxUses))
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.TimesUsed, p => p.TimesUsed + 1));

if (updated == 0)
    return CheckoutResult.Failed("This code has just been used up.");
Enter fullscreen mode Exit fullscreen mode

ExecuteUpdateAsync works the same on SQL Server, PostgreSQL, MySQL and SQLite. If the update touches zero rows, someone got there first.

Do this as part of placing the order, in the same transaction as saving it, so a failed order doesn't burn a use.

See it in action: in the ASP.NET Core e-commerce platform, promo codes are managed from the admin dashboard and applied at checkout.

Save what was applied

Store the code and the discount amount on the order itself, not just a link to the promo. If the admin later edits the code from 10% to 20%, old orders and invoices must still show what the customer actually paid.

Small touches that reduce support emails

  • Explain why a code failed: expired, not active yet, minimum not reached.
  • Re-validate when the cart changes. Removing an item can drop the subtotal below the minimum.
  • Let admins deactivate a code instantly instead of deleting it, so order history stays intact.

FAQ

How do I stop a coupon code being used too many times?

Increment the usage count with a conditional update, WHERE TimesUsed < MaxUses, in the same transaction that saves the order. If it updates zero rows, the code is used up.

Should discounts be calculated in JavaScript or on the server?

On the server. JavaScript can show a preview, but the total that's charged must be rebuilt from the database at checkout. In eCommerceMVCCore, prices are re-checked on the server before any payment.

Should I use decimal or double for prices in C#?

Always decimal. double stores binary fractions, so values like 0.1 can't be represented exactly, and totals drift by a cent. Set an explicit precision on money columns too.

Get the source code

Promo codes and discounts, managed from the admin dashboard and checked on the server, are part of eCommerceMVCCore. Get the ASP.NET Core 10 e-commerce source code.

What's the strangest promo-code abuse you've seen? Share it in the comments.

Top comments (0)