"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
Then compare with an injected TimeProvider, never DateTime.Now:
public bool IsActive(Promo p, TimeProvider clock) =>
clock.GetUtcNow() >= p.StartsAt && clock.GetUtcNow() < p.EndsAt;
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));
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);
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
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
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
-
TimeProvidereverywhere, boundary tests withFakeTimeProvider - 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)