DiscountedOrder domain type + DiscountCalculator (half-up, strict barrier) with unit tests

yak: discountedorder-discountcalculator-with-unit-tests-zrtn
This commit is contained in:
2026-09-16 18:40:34 +01:00
parent 803580786b
commit d39426d328
3 changed files with 231 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
namespace After;
/// <summary>
/// Domain service computing a discount for an <see cref="Order"/>. Pure
/// calculation: it reads the order and returns a <see cref="DiscountedOrder"/>;
/// 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).
/// </summary>
public static class DiscountCalculator
{
/// <summary>
/// Calculate the discount for <paramref name="order"/>. The discount
/// applies only when the order's total is <em>strictly greater</em> than
/// the barrier — an order at exactly the barrier gets none.
/// </summary>
/// <param name="order">The order to discount; its <see cref="Order.TotalInCents"/> is the input.</param>
/// <param name="discountBarrierCents">Minimum total (in cents) required to qualify; the total must exceed it.</param>
/// <param name="discountPercentage">The discount as an integer percent (10 means 10 %), 0–100.</param>
/// <returns>
/// A <see cref="DiscountedOrder"/> correlated to the order by id.
/// <see cref="DiscountedOrder.DiscountPercentage"/> is 0 when the barrier
/// was not passed, and <see cref="DiscountedOrder.DiscountedTotalInCents"/>
/// equals the total in that case.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="order"/> is null.</exception>
/// <exception cref="ArgumentOutOfRangeException">
/// The total or barrier is negative, or the percentage is outside 0–100.
/// </exception>
/// <remarks>
/// The discount amount is rounded <b>half-up</b> with pure integer math —
/// <c>(total × percentage + 50) / 100</c> — so 10 % of 155 cents is a
/// 16-cent discount (15.5 rounds up), and no floating point is involved.
/// </remarks>
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,
};
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace After;
/// <summary>
/// The result of discounting an <see cref="Order"/>: a separate domain type,
/// not a modified order. The discount is derived data — computed on demand,
/// correlated to its order by <see cref="OrderId"/>. What a caller does with
/// it (and what of it ever reaches <see cref="OrderDto"/> or
/// <see cref="OrderData"/>) is deliberately left to the caller.
/// </summary>
public sealed record DiscountedOrder
{
/// <summary>Primary key of the discounted <see cref="Order"/>.</summary>
public Guid OrderId { get; init; }
/// <summary>The order's total in cents, as read from it (the untouched input).</summary>
public int TotalInCents { get; init; }
/// <summary>
/// The percentage actually applied, as an integer percent. 0 when the
/// order did not pass the discount barrier.
/// </summary>
public int DiscountPercentage { get; init; }
/// <summary>The total after the discount, in cents.</summary>
public int DiscountedTotalInCents { get; init; }
}