Add payment state to Order with guarded Pay transition

- Order records payments: Payments, AmountPaid, Outstanding,
  IsFullyPaid (parameterless, replaces IsFullyPaid(expectedTotal))
- Pay rejects open orders, non-positive amounts, and overpayments
- 4 new OrderCompletion tests assert observable state:
  PaymentOnOpenOrder_Throws_OrderStillUnpaid,
  CompletedOrder_PartialPayment_IsNotFullyPaid,
  CompletedOrder_PaymentsSummingToTotal_IsFullyPaid,
  Overpayment_Throws_AmountPaidUnchanged
- 11 tests green (6 OrderTotal + 5 OrderCompletion)
This commit is contained in:
2026-10-01 13:46:10 +01:00
parent 5dd7a9ef2c
commit 4e0aea732a
2 changed files with 76 additions and 3 deletions
+20 -2
View File
@@ -2,12 +2,22 @@ namespace OrderDomain;
public class Order public class Order
{ {
private readonly List<decimal> payments = new();
public List<OrderLine> Lines { get; } = new(); public List<OrderLine> Lines { get; } = new();
public decimal Total => Lines.Sum(l => l.Value); public decimal Total => Lines.Sum(l => l.Value);
public bool IsCompleted { get; private set; } public bool IsCompleted { get; private set; }
public IReadOnlyList<decimal> Payments => payments;
public decimal AmountPaid => payments.Sum();
public decimal Outstanding => Total - AmountPaid;
public bool IsFullyPaid => IsCompleted && Outstanding == 0m;
public void AddLine(decimal unitPrice, int quantity) public void AddLine(decimal unitPrice, int quantity)
=> Lines.Add(new OrderLine(unitPrice, quantity)); => Lines.Add(new OrderLine(unitPrice, quantity));
@@ -16,6 +26,14 @@ public class Order
public void Complete() => IsCompleted = true; public void Complete() => IsCompleted = true;
public bool IsFullyPaid(decimal expectedTotal) public void Pay(decimal amount)
=> IsCompleted && Total == expectedTotal; {
if (!IsCompleted)
throw new InvalidOperationException("Cannot pay an open order.");
if (amount <= 0m)
throw new ArgumentOutOfRangeException(nameof(amount), amount, "Payment must be positive.");
if (amount > Outstanding)
throw new InvalidOperationException("Payment exceeds outstanding amount.");
payments.Add(amount);
}
} }
@@ -12,6 +12,61 @@ public class OrderCompletionTests
order.AddLine(100m, 1); order.AddLine(100m, 1);
order.Complete(); order.Complete();
Assert.That(order.IsFullyPaid(100m), Is.True); Assert.That(order.IsCompleted && order.Total == 100m, Is.True);
}
[Test]
public void PaymentOnOpenOrder_Throws_OrderStillUnpaid()
{
var order = new Order();
order.AddLine(100m, 1);
Assert.Throws<InvalidOperationException>(() => order.Pay(100m));
Assert.That(order.IsCompleted, Is.False);
Assert.That(order.AmountPaid, Is.EqualTo(0m));
}
[Test]
public void CompletedOrder_PartialPayment_IsNotFullyPaid()
{
var order = new Order();
order.AddLine(100m, 1);
order.Complete();
order.Pay(40m);
Assert.That(order.AmountPaid, Is.EqualTo(40m));
Assert.That(order.Outstanding, Is.EqualTo(60m));
Assert.That(order.IsFullyPaid, Is.False);
}
[Test]
public void CompletedOrder_PaymentsSummingToTotal_IsFullyPaid()
{
var order = new Order();
order.AddLine(100m, 1);
order.Complete();
order.Pay(40m);
order.Pay(60m);
Assert.That(order.Payments, Is.EqualTo(new[] { 40m, 60m }));
Assert.That(order.Outstanding, Is.EqualTo(0m));
Assert.That(order.IsFullyPaid, Is.True);
}
[Test]
public void Overpayment_Throws_AmountPaidUnchanged()
{
var order = new Order();
order.AddLine(100m, 1);
order.Complete();
order.Pay(100m);
Assert.Throws<InvalidOperationException>(() => order.Pay(10m));
Assert.That(order.AmountPaid, Is.EqualTo(100m));
Assert.That(order.IsFullyPaid, Is.True);
} }
} }