From d39426d3281e0481b3747b780ea643c1a139401f Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Wed, 16 Sep 2026 18:40:34 +0100 Subject: [PATCH] DiscountedOrder domain type + DiscountCalculator (half-up, strict barrier) with unit tests yak: discountedorder-discountcalculator-with-unit-tests-zrtn --- src/After/DiscountCalculator.cs | 64 ++++++++ src/After/DiscountedOrder.cs | 26 ++++ .../DiscountCalculatorTests.cs | 141 ++++++++++++++++++ 3 files changed, 231 insertions(+) create mode 100644 src/After/DiscountCalculator.cs create mode 100644 src/After/DiscountedOrder.cs create mode 100644 tests/BeforeAfter.Tests/DiscountCalculatorTests.cs diff --git a/src/After/DiscountCalculator.cs b/src/After/DiscountCalculator.cs new file mode 100644 index 0000000..8b749f7 --- /dev/null +++ b/src/After/DiscountCalculator.cs @@ -0,0 +1,64 @@ +namespace After; + +/// +/// Domain service computing a discount for an . Pure +/// calculation: it reads the order and returns a ; +/// it never mutates the order. Copying the result onto the order — and what +/// that does or does not persist — is the exercise (see EXERCISE.md). +/// +public static class DiscountCalculator +{ + /// + /// Calculate the discount for . The discount + /// applies only when the order's total is strictly greater than + /// the barrier — an order at exactly the barrier gets none. + /// + /// The order to discount; its is the input. + /// Minimum total (in cents) required to qualify; the total must exceed it. + /// The discount as an integer percent (10 means 10 %), 0–100. + /// + /// A correlated to the order by id. + /// is 0 when the barrier + /// was not passed, and + /// equals the total in that case. + /// + /// is null. + /// + /// The total or barrier is negative, or the percentage is outside 0–100. + /// + /// + /// The discount amount is rounded half-up with pure integer math — + /// (total × percentage + 50) / 100 — so 10 % of 155 cents is a + /// 16-cent discount (15.5 rounds up), and no floating point is involved. + /// + public static DiscountedOrder CalculateDiscount(Order order, int discountBarrierCents, int discountPercentage) + { + ArgumentNullException.ThrowIfNull(order); + ArgumentOutOfRangeException.ThrowIfNegative(order.TotalInCents); + ArgumentOutOfRangeException.ThrowIfNegative(discountBarrierCents); + ArgumentOutOfRangeException.ThrowIfLessThan(discountPercentage, 0); + ArgumentOutOfRangeException.ThrowIfGreaterThan(discountPercentage, 100); + + if (order.TotalInCents <= discountBarrierCents) + return new DiscountedOrder + { + OrderId = order.Id, + TotalInCents = order.TotalInCents, + DiscountPercentage = 0, + DiscountedTotalInCents = order.TotalInCents, + }; + + // Half-up rounding in integers: add half of the divisor before the + // integer division. 64-bit intermediate: cents × percent can overflow + // 32 bits for large orders. + var discount = (int)(((long)order.TotalInCents * discountPercentage + 50) / 100); + + return new DiscountedOrder + { + OrderId = order.Id, + TotalInCents = order.TotalInCents, + DiscountPercentage = discountPercentage, + DiscountedTotalInCents = order.TotalInCents - discount, + }; + } +} diff --git a/src/After/DiscountedOrder.cs b/src/After/DiscountedOrder.cs new file mode 100644 index 0000000..51adc54 --- /dev/null +++ b/src/After/DiscountedOrder.cs @@ -0,0 +1,26 @@ +namespace After; + +/// +/// The result of discounting an : a separate domain type, +/// not a modified order. The discount is derived data — computed on demand, +/// correlated to its order by . What a caller does with +/// it (and what of it ever reaches or +/// ) is deliberately left to the caller. +/// +public sealed record DiscountedOrder +{ + /// Primary key of the discounted . + public Guid OrderId { get; init; } + + /// The order's total in cents, as read from it (the untouched input). + public int TotalInCents { get; init; } + + /// + /// The percentage actually applied, as an integer percent. 0 when the + /// order did not pass the discount barrier. + /// + public int DiscountPercentage { get; init; } + + /// The total after the discount, in cents. + public int DiscountedTotalInCents { get; init; } +} diff --git a/tests/BeforeAfter.Tests/DiscountCalculatorTests.cs b/tests/BeforeAfter.Tests/DiscountCalculatorTests.cs new file mode 100644 index 0000000..aa3b2e3 --- /dev/null +++ b/tests/BeforeAfter.Tests/DiscountCalculatorTests.cs @@ -0,0 +1,141 @@ +using After; + +namespace BeforeAfter.Tests; + +/// +/// Unit tests for : +/// barrier semantics (strictly greater), integer-percent units, half-up +/// rounding of the discount amount, input validation, and the guarantee that +/// the calculation never mutates the order. +/// +public class DiscountCalculatorTests +{ + // --------------------------------------------------------------------- + // The barrier: only a total strictly greater than it is discounted. + // --------------------------------------------------------------------- + [Fact] + public void A_total_below_the_barrier_gets_no_discount() + { + var order = new Order { Id = Guid.NewGuid(), Description = "small", TotalInCents = 50_00 }; + + var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10); + + Assert.Equal(0, discounted.DiscountPercentage); + Assert.Equal(50_00, discounted.DiscountedTotalInCents); + Assert.Equal(50_00, discounted.TotalInCents); + } + + [Fact] + public void A_total_exactly_at_the_barrier_gets_no_discount() + { + var order = new Order { Id = Guid.NewGuid(), TotalInCents = 100_00 }; + + var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10); + + Assert.Equal(0, discounted.DiscountPercentage); + Assert.Equal(100_00, discounted.DiscountedTotalInCents); + } + + // --------------------------------------------------------------------- + // Above the barrier: percentage applied, total reduced. + // --------------------------------------------------------------------- + [Fact] + public void A_total_above_the_barrier_gets_the_percentage_discount() + { + var order = new Order { Id = Guid.NewGuid(), TotalInCents = 150_00 }; + + var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10); + + Assert.Equal(10, discounted.DiscountPercentage); + Assert.Equal(135_00, discounted.DiscountedTotalInCents); // 10 % of 150 = 15 off + Assert.Equal(150_00, discounted.TotalInCents); + } + + [Fact] + public void A_zero_percentage_discounts_nothing_even_above_the_barrier() + { + var order = new Order { Id = Guid.NewGuid(), TotalInCents = 150_00 }; + + var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 0); + + Assert.Equal(0, discounted.DiscountPercentage); + Assert.Equal(150_00, discounted.DiscountedTotalInCents); + } + + [Fact] + public void A_hundred_percent_discount_leaves_nothing() + { + var order = new Order { Id = Guid.NewGuid(), TotalInCents = 150_00 }; + + var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 100); + + Assert.Equal(100, discounted.DiscountPercentage); + Assert.Equal(0, discounted.DiscountedTotalInCents); + } + + // --------------------------------------------------------------------- + // Half-up rounding of the discount amount (integer math, no float). + // --------------------------------------------------------------------- + [Theory] + [InlineData(155, 16, 139)] // 15.5 rounds UP to 16 + [InlineData(145, 15, 130)] // 14.5 rounds UP to 15 + [InlineData(154, 15, 139)] // 15.4 rounds down to 15 + [InlineData(151, 15, 136)] // 15.1 rounds down to 15 + public void The_discount_amount_is_rounded_half_up(int totalInCents, int expectedDiscountCents, int expectedDiscountedTotal) + { + var order = new Order { Id = Guid.NewGuid(), TotalInCents = totalInCents }; + + var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100, discountPercentage: 10); + + Assert.Equal(totalInCents - expectedDiscountCents, discounted.DiscountedTotalInCents); + Assert.Equal(expectedDiscountedTotal, discounted.DiscountedTotalInCents); + } + + // --------------------------------------------------------------------- + // Correlation and purity. + // --------------------------------------------------------------------- + [Fact] + public void The_result_is_correlated_to_the_order_by_id() + { + var id = Guid.NewGuid(); + var order = new Order { Id = id, TotalInCents = 150_00 }; + + var discounted = DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10); + + Assert.Equal(id, discounted.OrderId); + } + + [Fact] + public void Calculating_does_not_mutate_the_order() + { + var order = new Order { Id = Guid.NewGuid(), Description = "one", TotalInCents = 150_00 }; + + DiscountCalculator.CalculateDiscount(order, discountBarrierCents: 100_00, discountPercentage: 10); + + Assert.Equal("one", order.Description); + Assert.Equal(150_00, order.TotalInCents); + } + + // --------------------------------------------------------------------- + // Input validation: invalid arguments throw (loudly, not silently). + // --------------------------------------------------------------------- + [Theory] + [InlineData(-1, 100_00, 10)] // negative total + [InlineData(150_00, -1, 10)] // negative barrier + [InlineData(150_00, 100_00, -1)]// percentage below 0 + [InlineData(150_00, 100_00, 101)]// percentage above 100 + public void Invalid_arguments_throw(int totalInCents, int discountBarrierCents, int discountPercentage) + { + var order = new Order { Id = Guid.NewGuid(), TotalInCents = totalInCents }; + + Assert.Throws( + () => DiscountCalculator.CalculateDiscount(order, discountBarrierCents, discountPercentage)); + } + + [Fact] + public void A_null_order_throws() + { + Assert.Throws( + () => DiscountCalculator.CalculateDiscount(null!, discountBarrierCents: 100_00, discountPercentage: 10)); + } +}