An order has three items; the customer returns only one. Refund, payment capture, wallet credit, and inventory must stay consistent. How do you design this across services?
How I frame it
Partial return is a saga on a subset of order lines. Money, stock, and order line state must move together via compensable steps — never "refund API then hope inventory increments."
Domain model
- Order lines:
qty_ordered,qty_returned,qty_refundable. return_requests(id, order_id, lines[], status, idempotency_key)— one return attempt is one saga.- Refund amount = sum(line price × qty × policy) − proportional tax/shipping rules.
Saga steps
- Validate: Line returnable (window, condition). Persist return
APPROVED_PENDING. - Restock (or quarantine): If sellable,
inventory.credit(sku, qty, return_id)idempotent. If damaged → scrap warehouse bin, no sellable credit. - Refund money: PSP refund or wallet credit with idempotency key =
return_id. Partial capture/refund against original payment intent. - Update order: Increment
qty_returned; order statusPARTIALLY_REFUNDED/CLOSED. - Notify via outbox.
Return R88 line=SKU-A qty=1 1. Validate ✓ 2. Inventory +1 (key R88) 3. Refund ₹799 (key R88) → PSP 4. Order line returned=1 If 3 fails after 2: compensate inventory −1 OR leave overstock + manual Prefer: money step before restock-for-sale if fraud high; warehousing ops dictate order
Failure rules
- PSP refund UNKNOWN → do not mark return COMPLETE; reconcile; stock may stay in quarantine.
- Never double-refund: unique
return_id/ refund idempotency at PSP. - Second return of same unit blocked by qty math on line.
Weak answer
"Call refund and inventory APIs from the controller with no saga state." Mid-crash leaves free money or phantom stock.
Follow-up questions
Promo / coupon on original order?
Recompute refund with same promotion engine rules; store snapshotted line discounts at order time — do not re-price at return with new catalog.
Exchange instead of refund?
Different saga: restock A + reserve B + net payment adjustment. Still one return_id / exchange_id.