Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ public interface IOrderRepository
/// <summary>Marks the order failed and releases its inventory reservations.</summary>
Task MarkPaymentFailedAsync(Order order, string reason, DateTimeOffset now, CancellationToken ct);

Task UpdateStatusAsync(Guid orderId, string status, string? trackingNumber, DateTimeOffset now, CancellationToken ct);
/// <summary>
/// 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 <see cref="Order.TransitionTo"/> has run - the new
/// status on it decides the movement.
/// </summary>
Task UpdateStatusAsync(Order order, DateTimeOffset now, CancellationToken ct);

Task<Order?> GetByIdAsync(Guid id, CancellationToken ct);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public async Task<Result<OrderView>> 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
{
Expand Down
62 changes: 56 additions & 6 deletions src/WidgetWorks.Infrastructure/Persistence/OrderRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/// <summary>Hands a reservation back: the goods never left, so only the hold is undone.</summary>
private const string ReleaseSql =
@"update widgets set quantity_reserved = quantity_reserved - @Quantity, updated_at = @Now
where id = @WidgetId and quantity_reserved >= @Quantity";

/// <summary>
/// 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.
/// </summary>
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<bool> TryPlaceAsync(Order order, CancellationToken ct)
{
using var db = await factory.OpenAsync(ct);
Expand Down Expand Up @@ -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();
Expand All @@ -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<Order?> GetByIdAsync(Guid id, CancellationToken ct)
Expand Down
60 changes: 59 additions & 1 deletion tests/WidgetWorks.IntegrationTests/OrderRepositoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
31 changes: 26 additions & 5 deletions tests/WidgetWorks.UnitTests/Fakes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
56 changes: 56 additions & 0 deletions tests/WidgetWorks.UnitTests/OrderLifecycleTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,6 +23,61 @@ private static (InMemoryOrderRepository Orders, FakeEmailSender Email, Order Ord
return (orders, new FakeEmailSender(), order);
}

/// <summary>
/// As <see cref="Setup"/>, but the widget is really in stock with the order's units held,
/// so a transition's effect on inventory is observable.
/// </summary>
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<UpdateOrderStatusHandler>.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<UpdateOrderStatusHandler>.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()
{
Expand Down