Cart & Order Lifecycle
Every state transition is a named refuse. Every inventory move is a git-log entry with a reason.
The state-machine slice of the ecommerce vertical. Where Catalog owns the merchant's SKUs, Orders owns the *funnel* — from shopper intent (Cart) through commitment (Order) through fulfillment through delivery. Payment state comes from Stripe (Slice M's boundary); the state machine here honors that boundary without depending on it.
The three views
Order dashboard — funnel counts + revenue by state, plus the abandoned-cart signal. Seven state cards (Awaiting payment / Paid / Fulfilled / Delivered / Completed / Cancelled / Returned) with count and revenue each, plus an *Open carts* and *Abandoned carts* pair below.
Orders — full queue across the funnel with status filter. Inline transition buttons per row: *Mark paid* on awaiting_payment, *Fulfill* on paid, *Delivered* on fulfilled, *Cancel* on any pre-fulfilled state, *Return* on delivered or completed. Refuse messages surface the exact rule name.
Carts — every open cart with last-activity age. Abandoned filter for the > 24h no-activity set. Click a cart to open the drawer, add lines from the variants picker, and place the order.
The state machine
Cart (open)
│ place_order → decrement inventory, snapshot lines
▼
awaiting_payment
│ mark_order_paid (Stripe webhook in Slice M)
▼
paid
│ fulfill_order (tracking_number optional)
▼
fulfilled
│ mark_order_delivered
▼
delivered
│ auto-complete after N days (Slice N) or return_order → restore inventory
▼
completed returned
Cancel is available on awaiting_payment or paid only. Once an order is fulfilled it can't be cancelled — it can be *returned*, but not cancelled. Both cancel and return restore inventory by writing an InventoryAdjustment with reason=return and a reference to the order id.
Inventory reservation: place-time, not cart-time
Add-to-cart validates against available stock but *doesn't* reserve. This is the Shopify default and the least-surprising path for merchants: many carts sit half-full for days. Reserving would starve stock.
Inventory decrements happen at place_order:
- Every line is re-validated against current stock. If any variant has gone out of stock since add-to-cart, the entire place_order refuses with
insufficient_stock_at_checkout. - If validation passes, decrement every variant by its line quantity and write an
InventoryAdjustmentper variant withreason=saleandreference=<order_id>. - Snapshot every line into an
EcomOrderLine— capturing product name, variant name, SKU, unit price at the moment of purchase. Future product edits do not silently rewrite the receipt.
The receipt is immutable. Merchant renames the product a week later? The order still shows the name it had at checkout. That's what audit-log-as-DB gets you.
The four PRIORITY 100 refuses
| Refuse | When | |---|---| | insufficient_stock | Add-to-cart with quantity that exceeds available stock (including what's already in the cart) | | cart_is_empty | place_order on a cart with no lines | | insufficient_stock_at_checkout | place_order and inventory has drained between add-to-cart and now | | order_already_fulfilled_cannot_cancel_use_return | cancel_order on an already-shipped order |
Plus every state-machine transition refuses with its own named rule (order_not_awaiting_payment, order_not_paid, order_not_fulfilled, order_not_delivered) when called out of sequence. No silent state jumps.
What's wired
| Entity | Purpose | |---|---| | Cart | Shopper intent with subtotal + last_activity_at (feeds abandonment) | | CartLine | Line items in a cart | | EcomOrder | Placed order with the full state machine | | EcomOrderLine | Snapshotted line item — captures product/variant state at purchase time |
Tools (13): list_carts, create_cart, add_to_cart, update_cart_line, remove_from_cart, place_order, list_orders, mark_order_paid, fulfill_order, mark_order_delivered, cancel_order, return_order, order_dashboard.
The chargeback-defense payoff
This is the demo moment. A customer disputes a charge:
git log data/<tenant>/EcomOrder/<id>.agishows every state transition with actor + timestamp.git show <commit-at-order-time>:data/<tenant>/Product/<sku>.agishows the product listing as it was when they clicked buy.InventoryAdjustmentrecords show the decrement at place-time (reason=sale, reference=<order_id>).- If fulfilled: the tracking_number is on the order record.
- If returned: the restore adjustment is right there with its own timestamp.
The complete evidence bundle for a chargeback dispute is *one filesystem walk*, not a subpoena to Shopify.
What's NOT in this slice
- Stripe payment integration — Slice M. The
mark_order_paidtool is the webhook target; until then it's a manual button. - Shipping label generation — Slice N. Right now
fulfill_ordertakes a manual tracking_number field. - Discounts, coupons, promo codes — Slice O. Order subtotals here are pre-discount.
- Tax calculation — Slice O. Same subtotal-only shape for now.
- Automated completion after N delivered days — Slice N. Merchants can manually complete via a future button; auto-transition is scheduled work.
- Multi-currency conversion — currency is per-cart, per-order; no conversion. That's a separate concern from ecommerce plumbing.