accelerando.wiki ↗ app ↗ github

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:

  1. Refund first, RMA later. charge.refunded webhook arrives → PaymentEvent recorded → merchant clicks *Issue refund* on the RMA and pastes the event id → link stored.
  2. RMA first, refund later. Merchant clicks *Issue refund* → RMA flips to refunded immediately → the actual Stripe refund happens through the tenant's Stripe dashboard → charge.refunded arrives → 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:

  1. Order historygit log data/<tenant>/EcomOrder/<id>.agi
  2. Shipment historygit log data/<tenant>/Shipment/*.agi | grep <order_id>
  3. RMA historygit log data/<tenant>/ReturnAuthorization/<rma_id>.agi
  4. 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