Fulfillment & Returns
The RMA workflow that most storefronts pretend doesn't exist. And every audit-log slice we've laid down finally pays off.
The fourth slice of the ecommerce arc, and the one that closes the customer-service loop. Slice L (Orders) handled cart → paid → fulfilled → delivered on the happy path. Slice M (Stripe) recorded payment reality. This slice handles what happens when the package moves through the world and — sometimes — comes back.
Two entities, five verbs, three views. Small surface, big trust dividend.
The two entities
Shipment — order-level, and *many per order are legal*. That's partial fulfillment: three items paid Monday, two shipped Tuesday from the warehouse, the third drop-shipped Friday from the vendor. Each shipment carries its own carrier, tracking number, service level, cost, and status. Statuses: label_created → in_transit → delivered, with sidepaths for lost and returned_to_sender. Delivering the final non-terminal shipment on an order auto-transitions the order itself to delivered. Ops doesn't have to remember to click twice.
ReturnAuthorization (RMA) — the customer-service workflow. Customer requests a return → merchant approves or rejects → package comes back → merchant marks received (with a condition assessment) → refund issued. Each transition is a named state on the record; each refuse is a named error. Five states: requested → approved → rejected → received → refunded.
The merchant-initiated return_order tool from Slice L still exists — that's the "I know this customer personally, I'm handling it" path. RMAs are the paper-trail-required path.
The three views
Fulfillment dashboard — shipments by status, RMAs by status, 30-day refund total, and how many delivered orders are eligible for auto-complete. Ops overview.
Shipments queue — the label-to-delivery timeline. Filter by status; ship or mark-delivered inline; multi-shipment orders visible as multiple rows sharing an order number. Creating a shipment against a paid order also transitions it to fulfilled (idempotent — if it was already fulfilled from a prior shipment, the second call is a no-op).
Returns / RMAs queue — the customer-service surface. Filter by status; unresolved-only toggle. Approve → auto-computes refund amount as subtotal − restocking − shipping, tweakable. Received requires a condition assessment. Refund records the Stripe event id when the refund lands.
The inventory-restore rule
Returned goods restore inventory *only when marked resellable*. damaged or customer_fault receipts flow through refund without restocking — that inventory is gone.
mark_return_received(id, received_condition="resellable") → inventory restored
mark_return_received(id, received_condition="damaged") → refund flows, no restock
Why this matters: the inventory ledger stays honest. A dinged product doesn't reappear on the shelf ready to disappoint the next customer. Same audit-log discipline as the catalog slice's adjustment reasons — every restock has a *why*.
The refunds boundary (again)
Refunds don't get *processed* here. They get *recorded*.
Real refund processing lives with Stripe (Slice M's directive). The issue_refund tool marks the RMA refunded and optionally links to whichever PaymentEvent Stripe fires when the refund completes. Two possible orderings:
- Refund first, RMA later.
charge.refundedwebhook arrives → PaymentEvent recorded → merchant clicks *Issue refund* on the RMA and pastes the event id → link stored. - RMA first, refund later. Merchant clicks *Issue refund* → RMA flips to
refundedimmediately → the actual Stripe refund happens through the tenant's Stripe dashboard →charge.refundedarrives → PaymentEvent recorded.
Either ordering works. The audit log tells the true story in git-commit order.
Named refuses (excerpt)
The whole slice is a state machine. Selected refuses:
| Attempt | Refuse | |---|---| | Ship an order that isn't paid yet | shipment_requires_paid_order | | Request an RMA on an awaiting_payment order | return_requires_delivered_or_completed_order | | Request a second RMA while one is open | return_authorization_already_open | | Approve an already-approved RMA | ra_transition_illegal:approved→approved | | Skip received and go straight to refunded | ra_transition_illegal:approved→refunded | | Mark a shipment delivered after returned_to_sender | shipment_transition_illegal:returned_to_sender→delivered |
Every refuse is a named rule. Every refuse writes a rejection event to the audit log. When something goes sideways in production the merchant sees exactly which rule fired, not a generic "something broke."
Auto-complete window
Slice L left a thread unresolved: delivered orders should auto-transition to completed after N days. shouldAutoComplete in src/fulfillment.ts implements the check (default 30-day window). Wire-up lives with the cron schedule in worker.ts (ecommerce env has crons = [] for the demo; production adds a nightly auto_complete_delivered_orders sweep).
The dashboard surfaces the eligible count. Human can drive it manually until the cron is turned on.
What's wired
| Entity | Purpose | |---|---| | Shipment | One row per package. Many-per-order allowed. | | ReturnAuthorization | RMA record — request through refund. |
Tools (10): list_shipments, create_shipment, update_shipment_status, list_return_authorizations, request_return, approve_return, reject_return, mark_return_received, issue_refund, fulfillment_dashboard.
The audit-log dividend, at last
When a customer disputes a return dispute (yes, that happens), the merchant reconstructs the full timeline from four sources:
- Order history —
git log data/<tenant>/EcomOrder/<id>.agi - Shipment history —
git log data/<tenant>/Shipment/*.agi | grep <order_id> - RMA history —
git log data/<tenant>/ReturnAuthorization/<rma_id>.agi - Payment reality — the linked PaymentEvent timeline from Slice M
That's the complete dispute-defense bundle. Cart placement, product state at checkout, shipment labels, tracking updates, return request, condition assessment, refund event. All git-committed. All from one filesystem walk plus two Stripe reads. No screenshot exports, no CSV downloads, no "let me get back to you on that."
This is the slice where the audit-log-as-database thesis compounds most visibly. Every prior slice's log entry gets pulled into the story a merchant tells the chargeback arbitrator. The bundle *is* the defense.
What's NOT in this slice
- Carrier API integrations — no live UPS/USPS rate quotes, no auto label purchase. Tracking numbers are pasted in. Real carrier integration is a per-tenant configuration that lives outside the framework.
- Multi-warehouse pick lists — Ops fulfills from wherever they fulfill from. Warehouse routing is a separate concern.
- Partial refunds within an RMA — one RMA, one refund amount. If a customer returns 2 of 3 items on an order, that's 2 line items on the RMA, one refund.
- Store credit / gift-card refunds — refund goes back to the original payment method by default. Store-credit workflows are a discount-slice concern (Slice O).