Skip to content

fix: move inventory when an order ships or is cancelled - #85

Merged
bgard68 merged 1 commit into
mainfrom
claude/reservation-lifecycle
Aug 25, 2026
Merged

fix: move inventory when an order ships or is cancelled#85
bgard68 merged 1 commit into
mainfrom
claude/reservation-lifecycle

Conversation

@bgard68

@bgard68 bgard68 commented Aug 25, 2026

Copy link
Copy Markdown
Owner

The bug

quantity_reserved goes up in TryPlaceAsync and comes down in exactly one place: MarkPaymentFailedAsync. Nothing released it along the fulfilment path. Against the real transition table (Paid → Shipped|Cancelled, Shipped → Delivered):

Transition Correct Actual Consequence
Paid → Cancelled release reservation reservation kept stock permanently unsellable, no route back but an admin edit
Paid → Shipped on_hand -N, reserved -N neither on_hand counts goods that left the warehouse; reserved grows unbounded

The shipped case hid itself: availability is on_hand - reserved, and both stayed wrong by the same N — so the storefront number looked correct while the warehouse number drifted, visible only at a physical stock count.

The fix

UpdateStatusAsync now takes the transitioned order and writes the status together with the inventory movement it implies, in one transaction:

  • Shipped → both columns fall by the line quantity (reservation becomes a real decrement)
  • Cancelled → the hold is released
  • Delivered → no movement; shipping already did it

Splitting those two writes would let a crash between them leave a shipped order still holding its stock — precisely the drift being fixed. Guards keep both columns off negative, and MarkPaymentFailedAsync now shares the release statement rather than repeating it.

The in-memory fake mirrors the same movement, skipping widgets absent from its store so existing lifecycle tests are unaffected.

Tests

Five new, covering the behaviour rather than the implementation:

  • Integration (real Postgres): shipping decrements both columns; cancelling restores full availability; delivery moves nothing
  • Unit (handler path): ship and cancel through UpdateOrderStatusHandler

⚠️ Verification

Not run locally. This container has no .NET SDK and no Postgres, so CI is the first execution of any of this. Given it touches inventory on the payment path, it deserves a human read before it reaches production.

Not in this PR

The reservation reaper for orders stranded in AwaitingPayment (a settlement webhook that never arrives holds stock forever). That needs a hosted service, options binding and DI — this repo has no BackgroundService yet — so it lands as its own change rather than riding along untested here.


Generated by Claude Code

quantity_reserved went up in TryPlaceAsync and came down in exactly one
place, MarkPaymentFailedAsync. Nothing released it along the fulfilment
path, so against the real transition table:

  Paid -> Cancelled  kept the reservation, and the stock became permanently
                     unsellable with no route back but an admin edit.
  Paid -> Shipped    decremented nothing, so quantity_on_hand kept counting
                     goods that had left the warehouse and quantity_reserved
                     grew without bound.

The shipped case hid itself. Availability is on_hand - reserved, and both
stayed wrong by the same amount, so the storefront number looked right while
the warehouse number drifted - visible only at a stock count.

UpdateStatusAsync now takes the transitioned order and writes the status
together with the movement it implies, in one transaction: shipping turns the
reservation into a real decrement of both columns, cancelling releases the
hold, delivery moves nothing because shipping already did. Splitting the two
writes would let a crash between them leave a shipped order still holding its
stock, which is the drift being fixed. The guards keep both columns off
negative, and MarkPaymentFailedAsync now shares the release statement instead
of repeating it.

The in-memory fake mirrors the same movement, skipping widgets absent from
its store so existing lifecycle tests are unaffected.

Five tests cover it: ship, cancel and deliver against real Postgres, and the
handler path for ship and cancel.

Not verified locally - this container has no .NET SDK and no Postgres, so CI
is the first execution of any of it.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EA4mmpcb1rcvNntHR1iG6j
@bgard68
bgard68 merged commit 61397a4 into main Aug 25, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants