Low Level Design: Returns Processing System

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:

  1. Warehouse scans return tracking number → system marks status = received.
  2. Inspector opens return in WMS UI → grades each item condition: new / like_new / damaged.
  3. 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.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What eligibility checks are required before approving a return?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “The system checks that the item was purchased within the return window (typically 30 days), the item is not in an exclusion list (digital goods, perishables, etc.), and the quantity requested does not exceed the quantity originally ordered.”
}
},
{
“@type”: “Question”,
“name”: “How does warehouse inspection determine a return's resolution?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A warehouse inspector grades each returned item as new, like_new, or damaged. A policy rules engine then decides the resolution: new or like_new items are restocked and a refund or exchange is issued; damaged items go to liquidation and refunds may be rejected if damage was customer-caused.”
}
},
{
“@type”: “Question”,
“name”: “How does a returns system detect return fraud?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “A background job counts returns per user over a rolling 90-day window. Users exceeding three returns are flagged for manual review and may have auto-approve disabled. Additional signals include repeated returns of the same item, returns clustered around sale events, and weight mismatches from carrier scan data.”
}
},
{
“@type”: “Question”,
“name”: “How does restocking work after a return is inspected?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “If the returned item's condition is new or like_new, the inventory count for that product is incremented and any reservation is released, making it available for sale. Damaged items are routed to a liquidation partner or disposed of rather than returned to sellable inventory.”
}
}
]
}

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

Scroll to Top