diff --git a/src/WidgetWorks.Application/Abstractions/IOrderRepository.cs b/src/WidgetWorks.Application/Abstractions/IOrderRepository.cs
index 372f362..927ec5d 100644
--- a/src/WidgetWorks.Application/Abstractions/IOrderRepository.cs
+++ b/src/WidgetWorks.Application/Abstractions/IOrderRepository.cs
@@ -15,7 +15,13 @@ public interface IOrderRepository
/// Marks the order failed and releases its inventory reservations.
Task MarkPaymentFailedAsync(Order order, string reason, DateTimeOffset now, CancellationToken ct);
- Task UpdateStatusAsync(Guid orderId, string status, string? trackingNumber, DateTimeOffset now, CancellationToken ct);
+ ///
+ /// Persists a fulfilment transition together with the inventory movement it implies, in one
+ /// transaction: shipping converts the reservation into a real stock decrement, cancelling
+ /// releases it back. Pass the order after has run - the new
+ /// status on it decides the movement.
+ ///
+ Task UpdateStatusAsync(Order order, DateTimeOffset now, CancellationToken ct);
Task GetByIdAsync(Guid id, CancellationToken ct);
diff --git a/src/WidgetWorks.Application/Orders/UpdateStatus/UpdateOrderStatusHandler.cs b/src/WidgetWorks.Application/Orders/UpdateStatus/UpdateOrderStatusHandler.cs
index 79985b8..c956c7a 100644
--- a/src/WidgetWorks.Application/Orders/UpdateStatus/UpdateOrderStatusHandler.cs
+++ b/src/WidgetWorks.Application/Orders/UpdateStatus/UpdateOrderStatusHandler.cs
@@ -36,7 +36,7 @@ public async Task> Handle(UpdateOrderStatusCommand command, Ca
var now = clock.GetUtcNow();
order.TransitionTo(target, command.TrackingNumber, now);
- await orders.UpdateStatusAsync(order.Id, order.Status, order.TrackingNumber, now, ct);
+ await orders.UpdateStatusAsync(order, now, ct);
try
{
diff --git a/src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs b/src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs
index 55b3620..6abbd5a 100644
--- a/src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs
+++ b/src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs
@@ -24,6 +24,22 @@ public sealed class OrderRepository(IDbConnectionFactory factory) : IOrderReposi
@"update widgets set quantity_reserved = quantity_reserved + @Quantity, updated_at = @Now
where id = @WidgetId and (quantity_on_hand - quantity_reserved) >= @Quantity";
+ /// Hands a reservation back: the goods never left, so only the hold is undone.
+ private const string ReleaseSql =
+ @"update widgets set quantity_reserved = quantity_reserved - @Quantity, updated_at = @Now
+ where id = @WidgetId and quantity_reserved >= @Quantity";
+
+ ///
+ /// Turns a reservation into a real decrement when the parcel leaves. Both columns fall by the
+ /// same amount, so availability (on_hand - reserved) is unchanged and the on-hand figure starts
+ /// telling the truth about what is on the shelf. The guards keep either column off negative.
+ ///
+ private const string ShipSql =
+ @"update widgets set quantity_on_hand = quantity_on_hand - @Quantity,
+ quantity_reserved = quantity_reserved - @Quantity,
+ updated_at = @Now
+ where id = @WidgetId and quantity_reserved >= @Quantity and quantity_on_hand >= @Quantity";
+
public async Task TryPlaceAsync(Order order, CancellationToken ct)
{
using var db = await factory.OpenAsync(ct);
@@ -88,8 +104,7 @@ await db.ExecuteAsync(new CommandDefinition(
foreach (var item in order.Items)
{
await db.ExecuteAsync(new CommandDefinition(
- "update widgets set quantity_reserved = quantity_reserved - @Quantity, updated_at = @Now where id = @WidgetId and quantity_reserved >= @Quantity",
- new { item.WidgetId, item.Quantity, Now = now }, tx, cancellationToken: ct));
+ ReleaseSql, new { item.WidgetId, item.Quantity, Now = now }, tx, cancellationToken: ct));
}
tx.Commit();
@@ -101,12 +116,47 @@ await db.ExecuteAsync(new CommandDefinition(
}
}
- public async Task UpdateStatusAsync(Guid orderId, string status, string? trackingNumber, DateTimeOffset now, CancellationToken ct)
+ public async Task UpdateStatusAsync(Order order, DateTimeOffset now, CancellationToken ct)
{
+ // Status and stock move together or not at all. Splitting them would let a crash between
+ // the two leave a shipped order whose goods are still reserved, which is exactly the drift
+ // this method exists to stop.
using var db = await factory.OpenAsync(ct);
- await db.ExecuteAsync(
- "update orders set status = @Status, tracking_number = @Tracking, updated_at = @Now where id = @Id",
- new { Id = orderId, Status = status, Tracking = trackingNumber, Now = now });
+ using var tx = db.BeginTransaction();
+ try
+ {
+ await db.ExecuteAsync(new CommandDefinition(
+ "update orders set status = @Status, tracking_number = @Tracking, updated_at = @Now where id = @Id",
+ new { Id = order.Id, Status = order.Status, Tracking = order.TrackingNumber, Now = now },
+ tx, cancellationToken: ct));
+
+ // Shipping turns a reservation into a real decrement; cancelling hands it back.
+ // Delivered moves no stock - shipping already did.
+ // Explicit string? rather than var: a switch expression mixing string arms with a
+ // null arm has no best common type to infer.
+ string? sql = order.Status switch
+ {
+ OrderStatus.Shipped => ShipSql,
+ OrderStatus.Cancelled => ReleaseSql,
+ _ => null,
+ };
+
+ if (sql is not null)
+ {
+ foreach (var item in order.Items)
+ {
+ await db.ExecuteAsync(new CommandDefinition(
+ sql, new { item.WidgetId, item.Quantity, Now = now }, tx, cancellationToken: ct));
+ }
+ }
+
+ tx.Commit();
+ }
+ catch
+ {
+ tx.Rollback();
+ throw;
+ }
}
public async Task GetByIdAsync(Guid id, CancellationToken ct)
diff --git a/tests/WidgetWorks.IntegrationTests/OrderRepositoryTests.cs b/tests/WidgetWorks.IntegrationTests/OrderRepositoryTests.cs
index 1017609..c558462 100644
--- a/tests/WidgetWorks.IntegrationTests/OrderRepositoryTests.cs
+++ b/tests/WidgetWorks.IntegrationTests/OrderRepositoryTests.cs
@@ -235,13 +235,71 @@ public async Task Updating_status_stores_the_tracking_number()
await Orders.TryPlaceAsync(order, CancellationToken.None);
await Orders.MarkPaidAsync(order.Id, "Mock", "r", Now, CancellationToken.None);
- await Orders.UpdateStatusAsync(order.Id, OrderStatus.Shipped, "1Z-TRACK", Now.AddHours(1), CancellationToken.None);
+ // Set directly rather than via TransitionTo: MarkPaidAsync moved the row, not this
+ // in-memory instance, so the entity would refuse Pending -> Shipped.
+ order.Status = OrderStatus.Shipped;
+ order.TrackingNumber = "1Z-TRACK";
+ await Orders.UpdateStatusAsync(order, Now.AddHours(1), CancellationToken.None);
var stored = await Orders.GetByIdAsync(order.Id, CancellationToken.None);
Assert.Equal(OrderStatus.Shipped, stored!.Status);
Assert.Equal("1Z-TRACK", stored.TrackingNumber);
}
+ [Fact]
+ public async Task Shipping_turns_the_reservation_into_a_real_decrement()
+ {
+ var widget = await GivenWidget(onHand: 5);
+ var order = OrderFor(widget, 2);
+ await Orders.TryPlaceAsync(order, CancellationToken.None);
+
+ var reserved = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None);
+ Assert.Equal(5, reserved!.QuantityOnHand);
+ Assert.Equal(2, reserved.QuantityReserved);
+
+ order.Status = OrderStatus.Shipped;
+ await Orders.UpdateStatusAsync(order, Now.AddHours(1), CancellationToken.None);
+
+ // The goods left the shelf: on-hand falls and the hold is gone, so availability
+ // (on_hand - reserved) is unchanged at 3 while on-hand now tells the truth.
+ var shipped = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None);
+ Assert.Equal(3, shipped!.QuantityOnHand);
+ Assert.Equal(0, shipped.QuantityReserved);
+ }
+
+ [Fact]
+ public async Task Cancelling_hands_the_reservation_back()
+ {
+ var widget = await GivenWidget(onHand: 5);
+ var order = OrderFor(widget, 2);
+ await Orders.TryPlaceAsync(order, CancellationToken.None);
+
+ order.Status = OrderStatus.Cancelled;
+ await Orders.UpdateStatusAsync(order, Now.AddHours(1), CancellationToken.None);
+
+ // Nothing shipped, so the stock returns to sale in full.
+ var cancelled = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None);
+ Assert.Equal(5, cancelled!.QuantityOnHand);
+ Assert.Equal(0, cancelled.QuantityReserved);
+ }
+
+ [Fact]
+ public async Task Delivery_moves_no_stock_because_shipping_already_did()
+ {
+ var widget = await GivenWidget(onHand: 5);
+ var order = OrderFor(widget, 2);
+ await Orders.TryPlaceAsync(order, CancellationToken.None);
+
+ order.Status = OrderStatus.Shipped;
+ await Orders.UpdateStatusAsync(order, Now.AddHours(1), CancellationToken.None);
+ order.Status = OrderStatus.Delivered;
+ await Orders.UpdateStatusAsync(order, Now.AddHours(2), CancellationToken.None);
+
+ var delivered = await Widgets.GetByIdAsync(widget.Id, CancellationToken.None);
+ Assert.Equal(3, delivered!.QuantityOnHand);
+ Assert.Equal(0, delivered.QuantityReserved);
+ }
+
[Fact]
public async Task A_users_orders_come_back_newest_first_with_their_lines()
{
diff --git a/tests/WidgetWorks.UnitTests/Fakes.cs b/tests/WidgetWorks.UnitTests/Fakes.cs
index cca37d7..d9520f1 100644
--- a/tests/WidgetWorks.UnitTests/Fakes.cs
+++ b/tests/WidgetWorks.UnitTests/Fakes.cs
@@ -235,12 +235,33 @@ public Task MarkPaymentFailedAsync(Order order, string reason, DateTimeOffset no
return Task.CompletedTask;
}
- public Task UpdateStatusAsync(Guid orderId, string status, string? trackingNumber, DateTimeOffset now, CancellationToken ct)
+ public Task UpdateStatusAsync(Order order, DateTimeOffset now, CancellationToken ct)
{
- var order = Orders.First(o => o.Id == orderId);
- order.Status = status;
- order.TrackingNumber = trackingNumber;
- order.UpdatedAt = now;
+ var stored = Orders.First(o => o.Id == order.Id);
+ stored.Status = order.Status;
+ stored.TrackingNumber = order.TrackingNumber;
+ stored.UpdatedAt = now;
+
+ // Mirrors OrderRepository: shipping converts the reservation into a real
+ // decrement, cancelling hands it back, delivery moves nothing.
+ foreach (var item in order.Items)
+ {
+ if (!widgets.Store.TryGetValue(item.WidgetId, out var w))
+ {
+ continue;
+ }
+
+ if (order.Status == OrderStatus.Shipped)
+ {
+ w.QuantityOnHand -= item.Quantity;
+ w.QuantityReserved -= item.Quantity;
+ }
+ else if (order.Status == OrderStatus.Cancelled)
+ {
+ w.QuantityReserved -= item.Quantity;
+ }
+ }
+
return Task.CompletedTask;
}
diff --git a/tests/WidgetWorks.UnitTests/OrderLifecycleTests.cs b/tests/WidgetWorks.UnitTests/OrderLifecycleTests.cs
index 4d7b864..a69e831 100644
--- a/tests/WidgetWorks.UnitTests/OrderLifecycleTests.cs
+++ b/tests/WidgetWorks.UnitTests/OrderLifecycleTests.cs
@@ -2,6 +2,7 @@
using Microsoft.Extensions.Time.Testing;
using WidgetWorks.Application.Abstractions;
using WidgetWorks.Application.Orders.UpdateStatus;
+using WidgetWorks.Domain.Catalog;
using WidgetWorks.Domain.Orders;
using WidgetWorks.UnitTests.Fakes;
using Xunit;
@@ -22,6 +23,61 @@ private static (InMemoryOrderRepository Orders, FakeEmailSender Email, Order Ord
return (orders, new FakeEmailSender(), order);
}
+ ///
+ /// As , but the widget is really in stock with the order's units held,
+ /// so a transition's effect on inventory is observable.
+ ///
+ private static (InMemoryOrderRepository Orders, InMemoryWidgetRepository Widgets, Order Order, Guid WidgetId) StockedSetup(int onHand = 5, int quantity = 2)
+ {
+ var widgets = new InMemoryWidgetRepository();
+ var orders = new InMemoryOrderRepository(widgets);
+ var widgetId = Guid.NewGuid();
+ widgets.Store[widgetId] = new Widget
+ {
+ Id = widgetId,
+ Sku = "WW-1",
+ Name = "Gizmo",
+ Price = 10m,
+ IsActive = true,
+ QuantityOnHand = onHand,
+ QuantityReserved = quantity,
+ };
+
+ var order = new Order { Id = Guid.NewGuid(), OrderNumber = "WW-1", Email = "jane@example.com", Status = OrderStatus.Paid, Total = 10m };
+ order.Items.Add(new OrderItem { Id = Guid.NewGuid(), WidgetId = widgetId, Sku = "WW-1", Name = "Gizmo", UnitPrice = 10m, Quantity = quantity, LineSubtotal = 10m });
+ orders.Orders.Add(order);
+ return (orders, widgets, order, widgetId);
+ }
+
+ [Fact]
+ public async Task Shipping_converts_the_reservation_into_a_stock_decrement()
+ {
+ var (orders, widgets, order, widgetId) = StockedSetup();
+ var handler = new UpdateOrderStatusHandler(orders, new FakeEmailSender(), Clock(), NullLogger.Instance);
+
+ var result = await handler.Handle(new UpdateOrderStatusCommand(order.Id, OrderStatus.Shipped, "1Z999"), CancellationToken.None);
+
+ Assert.True(result.IsSuccess);
+ // Both columns fall together, so availability is untouched while on-hand stops
+ // overstating what is physically on the shelf.
+ Assert.Equal(3, widgets.Store[widgetId].QuantityOnHand);
+ Assert.Equal(0, widgets.Store[widgetId].QuantityReserved);
+ }
+
+ [Fact]
+ public async Task Cancelling_returns_the_reserved_stock_to_sale()
+ {
+ var (orders, widgets, order, widgetId) = StockedSetup();
+ var handler = new UpdateOrderStatusHandler(orders, new FakeEmailSender(), Clock(), NullLogger.Instance);
+
+ var result = await handler.Handle(new UpdateOrderStatusCommand(order.Id, OrderStatus.Cancelled, null), CancellationToken.None);
+
+ Assert.True(result.IsSuccess);
+ // Nothing shipped: the hold is released and every unit is sellable again.
+ Assert.Equal(5, widgets.Store[widgetId].QuantityOnHand);
+ Assert.Equal(0, widgets.Store[widgetId].QuantityReserved);
+ }
+
[Fact]
public async Task Paid_to_shipped_sets_tracking_and_emails()
{