DEV Community

Cover image for My Sale Ended at the Wrong Midnight: 6 Bugs in Time-Limited Discount Codes in .NET
MICHAEL-MAURICE
MICHAEL-MAURICE

Posted on

My Sale Ended at the Wrong Midnight: 6 Bugs in Time-Limited Discount Codes in .NET

"40% off until Sunday" sounds like one if statement. Then a customer in another time zone gets refused at 11 PM their time, a $9.99 item charges $5.99 on one screen and $6.00 on another, and a monthly plan keeps the discount forever because nobody decided what "until Sunday" means for a subscription.

I'm running exactly this kind of promo right now, so here are the six bugs I check for whenever a time-limited code goes into a .NET checkout. The code below is trimmed to the shape; the trimmed parts are marked.

Tech Skill Builder is $5/month for a very limited time (normally $10). Grab it now before it goes back to $10. And until Sunday, November 2 (end of day, Cairo time), the code HALLOWEEN40 takes 40% off everything in the store, including the membership. See Tech Skill Builder

Bug 1: "End of day" in the server's time zone

DateTime.Now > promo.EndsAt uses whatever zone the server runs in. In a container that's usually UTC, so a Cairo "end of Sunday" ends two hours early.

Store the end as an instant, computed once from the zone the business promised:

var cairo = TimeZoneInfo.FindSystemTimeZoneById("Africa/Cairo");
var endLocal = new DateTime(2026, 11, 2, 23, 59, 59, DateTimeKind.Unspecified);
DateTimeOffset endsAt = TimeZoneInfo.ConvertTimeToUtc(endLocal, cairo); // persist this
Enter fullscreen mode Exit fullscreen mode

Then compare with an injected TimeProvider, never DateTime.Now:

public bool IsActive(Promo p, TimeProvider clock) =>
    clock.GetUtcNow() >= p.StartsAt && clock.GetUtcNow() < p.EndsAt;
Enter fullscreen mode Exit fullscreen mode

TimeProvider also makes the next bug testable with FakeTimeProvider.

Bug 2: Nobody tested the boundary

Write tests for one second before the end, the exact end and one second after. With FakeTimeProvider from Microsoft.Extensions.TimeProvider.Testing that's three lines each. Also test a date across a DST change if your zone has one.

var clock = new FakeTimeProvider(promo.EndsAt.AddSeconds(-1));
Assert.True(service.IsActive(promo, clock));
clock.Advance(TimeSpan.FromSeconds(1));
Assert.False(service.IsActive(promo, clock));
Enter fullscreen mode Exit fullscreen mode

Bug 3: Rounding in three different places

The cart rounds per line, the summary rounds the total, the payment provider rounds again. Pick one rule, use decimal, and round once with an explicit mode:

static long DiscountedCents(long priceCents, int percentOff) =>
    (long)Math.Round(priceCents * (100 - percentOff) / 100m, MidpointRounding.AwayFromZero);
Enter fullscreen mode Exit fullscreen mode

Work in minor units (cents) end to end, and show the customer the same number you charge.

Bug 4: The subscription keeps the discount forever

For a one-off product, "valid until Sunday" is clear. For a monthly plan it's three possible rules: the first payment only, N months, or every cycle for as long as they stay. All three are fine; leaving it undecided is the bug. Model it explicitly:

public enum DiscountDuration { FirstPaymentOnly, Months, Forever }
// ... the promo carries Duration + DurationMonths, and the billing job reads them
Enter fullscreen mode Exit fullscreen mode

And say in the promo text which rule applies.

Bug 5: Max-uses check that races

if (promo.TimesUsed < promo.MaxUses) { ...; promo.TimesUsed++; } lets two parallel checkouts both pass. Make the increment the check:

UPDATE Promos SET TimesUsed = TimesUsed + 1
WHERE Id = @id AND (MaxUses IS NULL OR TimesUsed < MaxUses);
-- 0 rows affected = sold out
Enter fullscreen mode Exit fullscreen mode

In EF Core 10 that's one ExecuteUpdateAsync with the same Where. Do it inside the same transaction as the order, or release the slot when payment fails.

Bug 6: Validating the code only when it's typed

A code is checked when the customer applies it, then the payment page sits open for 40 minutes and the sale ends. Re-validate at the moment you create the payment (or the Stripe Checkout session), not only on "Apply". The same goes for extending a sale: if you move the end date, every place that cached the old one (banners, emails, the validation service) needs the new value.

The short checklist

  • End time stored as a UTC instant, computed from the zone you promised
  • TimeProvider everywhere, boundary tests with FakeTimeProvider
  • One rounding rule, in cents, applied once
  • Subscription discount duration decided and written down
  • Atomic max-uses increment
  • Re-validate when the payment is created

Want the full projects?

The snippets above are skeletons. The complete, tested code is in the store: the Stripe Integration for .NET 10 kit covers checkout and webhooks end to end, and Tech Skill Builder adds a new full-source .NET + AI project with a step-by-step guide every day.

Tech Skill Builder is $5/month for a very limited time (normally $10). Grab it now before it goes back to $10. Until Sunday, November 2 (end of day, Cairo time), the code HALLOWEEN40 takes 40% off everything in the Elite Solutions store.

Which of these six has bitten you? Tell me in the comments.

Top comments (0)