diff --git a/java/order-book-domain/src/main/java/orderbookdomain/Order.java b/java/order-book-domain/src/main/java/orderbookdomain/Order.java new file mode 100644 index 0000000..6395d4d --- /dev/null +++ b/java/order-book-domain/src/main/java/orderbookdomain/Order.java @@ -0,0 +1,42 @@ +package orderbookdomain; + +import java.time.Instant; + +public class Order { + + private final int id; + private final String customer; + + private OrderStatus status; + private Instant finalizedAt; + + Order(int id, String customer) { + this.id = id; + this.customer = customer; + this.status = OrderStatus.PENDING; + } + + public int getId() { + return id; + } + + public String getCustomer() { + return customer; + } + + public OrderStatus getStatus() { + return status; + } + + public Instant getFinalizedAt() { + return finalizedAt; + } + + void setStatus(OrderStatus status) { + this.status = status; + } + + void setFinalizedAt(Instant finalizedAt) { + this.finalizedAt = finalizedAt; + } +} diff --git a/java/order-book-domain/src/main/java/orderbookdomain/OrderBook.java b/java/order-book-domain/src/main/java/orderbookdomain/OrderBook.java new file mode 100644 index 0000000..06bec50 --- /dev/null +++ b/java/order-book-domain/src/main/java/orderbookdomain/OrderBook.java @@ -0,0 +1,81 @@ +package orderbookdomain; + +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class OrderBook { + + private final Duration paymentWindow; + private final Map ordersById = new HashMap<>(); + private int nextId = 1; + + public OrderBook() { + this(Duration.ofDays(14)); + } + + public OrderBook(Duration paymentWindow) { + this.paymentWindow = paymentWindow; + } + + public List getOrders() { + return List.copyOf(ordersById.values()); + } + + public Order openOrder(String customer) { + Order order = new Order(nextId++, customer); + ordersById.put(order.getId(), order); + return order; + } + + public Order getOrder(int orderId) { + Order order = ordersById.get(orderId); + if (order == null) { + throw new IllegalStateException("Unknown order id " + orderId + "."); + } + return order; + } + + public void finalizeOrder(int orderId, Instant finalizedAt) { + Order order = getOrder(orderId); + requireStatus(order, OrderStatus.PENDING, "finalize"); + order.setStatus(OrderStatus.WAITING_FOR_PAYMENT); + order.setFinalizedAt(finalizedAt); + } + + public void payOrder(int orderId, Instant paidAt) { + Order order = getOrder(orderId); + requireStatus(order, OrderStatus.WAITING_FOR_PAYMENT, "pay"); + if (Duration.between(order.getFinalizedAt(), paidAt).compareTo(paymentWindow) > 0) { + order.setStatus(OrderStatus.CANCELLED); + throw new IllegalStateException( + "Payment for order " + orderId + " arrived after the payment window; the order was cancelled."); + } + order.setStatus(OrderStatus.FULFILLING); + } + + public void shipOrder(int orderId) { + Order order = getOrder(orderId); + requireStatus(order, OrderStatus.FULFILLING, "ship"); + order.setStatus(OrderStatus.DELIVERED); + } + + public void cancelOrder(int orderId) { + Order order = getOrder(orderId); + if (order.getStatus() != OrderStatus.PENDING && order.getStatus() != OrderStatus.WAITING_FOR_PAYMENT) { + throw new IllegalStateException( + "Cannot cancel order " + orderId + " in status " + order.getStatus() + "."); + } + order.setStatus(OrderStatus.CANCELLED); + } + + private static void requireStatus(Order order, OrderStatus expected, String action) { + if (order.getStatus() != expected) { + throw new IllegalStateException( + "Cannot " + action + " order " + order.getId() + " in status " + order.getStatus() + + " (expected " + expected + ")."); + } + } +} diff --git a/java/order-book-domain/src/main/java/orderbookdomain/OrderStatus.java b/java/order-book-domain/src/main/java/orderbookdomain/OrderStatus.java new file mode 100644 index 0000000..13b42dc --- /dev/null +++ b/java/order-book-domain/src/main/java/orderbookdomain/OrderStatus.java @@ -0,0 +1,9 @@ +package orderbookdomain; + +public enum OrderStatus { + PENDING, + WAITING_FOR_PAYMENT, + FULFILLING, + DELIVERED, + CANCELLED +} diff --git a/java/order-book-tests/pom.xml b/java/order-book-tests/pom.xml index 820d5c1..321032a 100644 --- a/java/order-book-tests/pom.xml +++ b/java/order-book-tests/pom.xml @@ -25,5 +25,11 @@ ${junit.version} test + + org.assertj + assertj-core + ${assertj.version} + test + diff --git a/java/order-book-tests/src/test/java/orderbooktests/OrderBookLifecycleTests.java b/java/order-book-tests/src/test/java/orderbooktests/OrderBookLifecycleTests.java new file mode 100644 index 0000000..b63c376 --- /dev/null +++ b/java/order-book-tests/src/test/java/orderbooktests/OrderBookLifecycleTests.java @@ -0,0 +1,121 @@ +package orderbooktests; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import org.junit.jupiter.api.Test; + +import orderbookdomain.OrderBook; +import orderbookdomain.OrderStatus; + +class OrderBookLifecycleTests { + + private static final Instant DAY_0 = Instant.parse("2025-06-01T00:00:00Z"); + + private static Instant at(int day) { + return DAY_0.plus(day, ChronoUnit.DAYS); + } + + @Test + void finalizeThenPay_movesOrderThroughTwoStates() { + OrderBook book = new OrderBook(); + var order = book.openOrder("anna"); + + book.finalizeOrder(order.getId(), at(1)); + assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.WAITING_FOR_PAYMENT); + + book.payOrder(order.getId(), at(2)); + assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.FULFILLING); + } + + @Test + void payThenShip_deliversTheOrder() { + OrderBook book = new OrderBook(); + var order = book.openOrder("anna"); + + book.finalizeOrder(order.getId(), at(1)); + assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.WAITING_FOR_PAYMENT); + + book.payOrder(order.getId(), at(2)); + book.shipOrder(order.getId()); + assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.DELIVERED); + } + + @Test + void payWithinWindow_thenLatePayOnSecondOrder_cancelsOnlyThatOrder() { + OrderBook book = new OrderBook(); + var first = book.openOrder("anna"); + var second = book.openOrder("bram"); + + book.finalizeOrder(first.getId(), at(1)); + book.finalizeOrder(second.getId(), at(1)); + assertThat(book.getOrders()).hasSize(2); + + book.payOrder(first.getId(), at(5)); + assertThat(book.getOrder(first.getId()).getStatus()).isEqualTo(OrderStatus.FULFILLING); + + assertThatThrownBy(() -> book.payOrder(second.getId(), at(30))) + .isInstanceOf(IllegalStateException.class); + assertThat(book.getOrder(second.getId()).getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @Test + void payBeforeFinalize_throws_thenFinalizeStillWorks() { + OrderBook book = new OrderBook(); + var order = book.openOrder("anna"); + + assertThatThrownBy(() -> book.payOrder(order.getId(), at(1))) + .isInstanceOf(IllegalStateException.class); + assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.PENDING); + + book.finalizeOrder(order.getId(), at(2)); + assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.WAITING_FOR_PAYMENT); + } + + @Test + void cancelPendingOrder_thenPayAfterCancel_throwsAgain() { + OrderBook book = new OrderBook(); + var order = book.openOrder("anna"); + + book.cancelOrder(order.getId()); + assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.CANCELLED); + + assertThatThrownBy(() -> book.payOrder(order.getId(), at(1))) + .isInstanceOf(IllegalStateException.class); + assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.CANCELLED); + } + + @Test + void shippedOrder_cannotBeCancelled() { + OrderBook book = new OrderBook(); + var order = book.openOrder("anna"); + + book.finalizeOrder(order.getId(), at(1)); + book.payOrder(order.getId(), at(2)); + book.shipOrder(order.getId()); + assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.DELIVERED); + + assertThatThrownBy(() -> book.cancelOrder(order.getId())) + .isInstanceOf(IllegalStateException.class); + assertThat(book.getOrder(order.getId()).getStatus()).isEqualTo(OrderStatus.DELIVERED); + } + + @Test + void twoCustomers_interleaveTransitions_independently() { + OrderBook book = new OrderBook(); + var annas = book.openOrder("anna"); + var brams = book.openOrder("bram"); + + book.finalizeOrder(brams.getId(), at(1)); + assertThat(book.getOrder(annas.getId()).getStatus()).isEqualTo(OrderStatus.PENDING); + assertThat(book.getOrder(brams.getId()).getStatus()).isEqualTo(OrderStatus.WAITING_FOR_PAYMENT); + + book.payOrder(brams.getId(), at(2)); + book.cancelOrder(annas.getId()); + assertThat(book.getOrder(annas.getId()).getStatus()).isEqualTo(OrderStatus.CANCELLED); + assertThat(book.getOrder(brams.getId()).getStatus()).isEqualTo(OrderStatus.FULFILLING); + } +}