Returns Processing System: Low Level Design
Core Data Model
Two primary tables capture return requests and the individual items within them:
-- Returns table
CREATE TABLE returns (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'requested',
-- requested | approved | label_sent | received | inspecting | resolved
reason_code VARCHAR(30) NOT NULL,
-- wrong_item | defective | not_as_described | changed_mind | damaged_in_transit | ...
resolution VARCHAR(20),
-- refund | exchange | store_credit | rejected
requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Return items table
CREATE TABLE return_items (
id BIGSERIAL PRIMARY KEY,
return_id BIGINT NOT NULL REFERENCES returns(id),
order_item_id BIGINT NOT NULL,
quantity INT NOT NULL,
condition VARCHAR(20), -- new | like_new | damaged
inspection_notes TEXT
);
Return Request Flow
Customer initiates return
→ Eligibility check:
- Item purchased within 30 days?
- Item not in exclusion list (digital goods, perishables)?
- Quantity requested ≤ quantity ordered?
→ Auto-approve if: value < $150 AND reason_code in (wrong_item, defective, damaged_in_transit)
→ Manual review queue if: value ≥ $150 OR reason_code = changed_mind
→ Approved → generate prepaid return shipping label
→ Email label PDF to customer, update status = label_sent
Return Label Generation
On approval, the system calls the carrier API (same carrier used for outbound where possible) to generate a prepaid return label:
CarrierAPI.createReturnLabel({
from: customer_address,
to: warehouse_address,
service: 'ground',
reference: return_id
})
→ Store label_url, tracking_number on returns record
→ Email label to customer
→ Update status = 'label_sent'
Warehouse Inspection Workflow
When the return arrives at the warehouse:
- Warehouse scans return tracking number → system marks
status = received. - Inspector opens return in WMS UI → grades each item condition:
new/like_new/damaged. - Inspector adds notes and submits →
status = inspecting→ system applies resolution policy rules.
Resolution Policy Rules
Policy engine evaluates per return_item:
condition = 'new' → restock + refund approved
condition = 'like_new' → restock + refund approved (if within policy window)
condition = 'damaged' → route to liquidation/disposal
refund approved if damage caused by carrier/defect
refund rejected if customer-caused damage
reason_code = 'changed_mind' + condition = 'damaged' → resolution = rejected
Final resolution set on returns.resolution:
refund | exchange | store_credit | rejected
Refund Processing
IF resolution = 'refund':
PaymentAPI.refund(order.payment_intent_id, refund_amount_cents)
→ Update order status = 'refunded' (or partially_refunded)
→ Write order event log entry
→ Send refund confirmation email
IF resolution = 'exchange':
OrderService.createOrder(user_id, exchange_items, payment_total=0)
→ New order created with $0 charge
→ Send exchange confirmation email with new order number
IF resolution = 'store_credit':
WalletService.credit(user_id, amount_cents)
→ Send store credit notification email
Restocking Logic
IF return_item.condition IN ('new', 'like_new'):
InventoryService.incrementStock(product_id, warehouse_id, quantity)
InventoryService.releaseReservation(order_item_id)
-- Item is sellable again
IF return_item.condition = 'damaged':
LiquidationService.createRecord(return_item_id)
-- Item routed to liquidation partner or disposed
Fraud Detection
A background job checks return patterns on every new return request:
SELECT COUNT(*) FROM returns
WHERE user_id = ? AND requested_at > NOW() - INTERVAL '90 days'
IF count > 3:
Flag return for manual review
Add user_id to returns_review_queue with reason = 'high_return_rate'
Optionally: restrict auto-approve for this user_id
Fraud signals also include: same item returned multiple times, returns clustered around sale events, mismatch between returned item weight and expected weight (carrier scan data).
Analytics & Merchandising Insights
-- Return rate by product
SELECT product_id,
COUNT(ri.id)::FLOAT / SUM(oi.quantity) AS return_rate
FROM return_items ri
JOIN order_items oi ON ri.order_item_id = oi.id
GROUP BY product_id
ORDER BY return_rate DESC;
-- Return rate by reason code
SELECT reason_code, COUNT(*) AS returns
FROM returns
WHERE requested_at > NOW() - INTERVAL '30 days'
GROUP BY reason_code
ORDER BY returns DESC;
High return rates on a product surface in the merchandising dashboard as a signal to review product listings, sizing guides, or supplier quality.
Key API Endpoints
POST /returns -- customer initiates return
GET /returns/{id} -- get return status + items
POST /returns/{id}/approve -- manual approval (agent)
POST /returns/{id}/inspect -- warehouse submits inspection grades
GET /returns?user_id=&status= -- list returns (admin)
GET /returns/analytics -- return rate reports
Key Design Decisions
- Eligibility and policy rules are externalized to configuration so merchandising can change return windows without a code deploy.
- Resolution policy is deterministic and auditable — every decision is logged with the rule that triggered it.
- Refund and exchange flows reuse existing PaymentAPI and OrderService to avoid duplicated logic.
- Fraud threshold (3 returns / 90 days) is configurable per user segment (e.g., higher threshold for loyalty members).
- Append-only return event log mirrors the order event pattern for consistent auditability.
See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering
See also: Shopify Interview Guide
See also: Stripe Interview Guide 2026: Process, Bug Bash Round, and Payment Systems