Returns Portal System Low-Level Design

What is a Returns Portal?

A returns portal allows customers to self-service returns and exchanges: select items to return, choose a reason, get a prepaid shipping label, and track the return status. Amazon, Shopify merchants, and Zappos are known for seamless return experiences that drive customer loyalty. The system must handle the full lifecycle: return request to label generation to item receipt to inspection to refund or exchange processing. Returns typically represent 15-30% of e-commerce orders.

Requirements

  • Customer initiates return on eligible orders (within return window, e.g., 30 days)
  • Return methods: prepaid label (mail), drop-off at retail location
  • Reasons: defective, not as described, changed mind, wrong size
  • Return outcome: refund to original payment, store credit, or exchange
  • Warehouse receives and inspects returned item; triggers refund on approval
  • Track return status: label created, shipped, received, inspected, refunded

Data Model

ReturnRequest(
    return_id       UUID PRIMARY KEY,
    order_id        UUID NOT NULL,
    customer_id     UUID NOT NULL,
    status          ENUM(REQUESTED, LABEL_SENT, SHIPPED, RECEIVED, INSPECTING,
                         APPROVED, REFUNDED, REJECTED, EXCHANGED),
    items           JSONB,          -- [{order_item_id, quantity, reason, outcome}]
    return_method   ENUM(PREPAID_LABEL, DROP_OFF),
    label_url       VARCHAR,        -- presigned URL for shipping label PDF
    tracking_number VARCHAR,
    carrier         VARCHAR,
    received_at     TIMESTAMPTZ,
    inspection_notes TEXT,
    refund_amount   DECIMAL(10,2),
    refund_method   ENUM(ORIGINAL_PAYMENT, STORE_CREDIT),
    refunded_at     TIMESTAMPTZ,
    created_at      TIMESTAMPTZ
)

ReturnEligibilityRule(
    rule_id         UUID PRIMARY KEY,
    product_category VARCHAR,
    return_window_days INT,
    restockable     BOOL,
    exceptions      JSONB
)

Eligibility Check

def check_return_eligibility(order_id, item_ids):
    order = db.get_order(order_id)
    items = [i for i in order.items if i.id in item_ids]
    ineligible = []

    for item in items:
        rule = get_rule(item.product_category)
        days_since_order = (now() - order.placed_at).days

        if days_since_order > rule.return_window_days:
            ineligible.append({'item': item.id,
                'reason': f'Return window of {rule.return_window_days} days has passed'})
            continue

        if item.metadata.get('final_sale'):
            ineligible.append({'item': item.id,
                'reason': 'Item is marked final sale'})
            continue

    return {'eligible': [i for i in items if i.id not in
                         [x['item'] for x in ineligible]],
            'ineligible': ineligible}

Label Generation and Refund Flow

def create_return(order_id, item_ids, reasons, outcome, method):
    eligibility = check_return_eligibility(order_id, item_ids)
    if eligibility['ineligible']:
        raise IneligibleItems(eligibility['ineligible'])

    return_request = db.create(ReturnRequest(
        order_id=order_id, status='REQUESTED',
        items=[{'order_item_id': id, 'reason': reasons[id], 'outcome': outcome}
               for id in item_ids],
        return_method=method
    ))

    if method == 'PREPAID_LABEL':
        label = carrier_api.create_return_label(
            from_address=get_order_shipping_address(order_id),
            to_address=warehouse.address,
            package=estimate_package(item_ids)
        )
        db.update(return_request.id, status='LABEL_SENT',
                  label_url=label.url, tracking_number=label.tracking_number)

    return return_request

def process_inspection(return_id, condition, inspector_notes):
    return_req = db.get(return_id)
    approved = condition in ('new', 'like_new', 'good')

    if approved:
        order = db.get_order(return_req.order_id)
        refund_amount = calculate_refund(return_req.items, order)
        payment_service.refund(order.payment_id, refund_amount)
        if condition == 'new':
            inventory_service.restock(return_req.items)
        db.update(return_id, status='REFUNDED',
                  refund_amount=refund_amount, refunded_at=now())
    else:
        db.update(return_id, status='REJECTED', inspection_notes=inspector_notes)

Key Design Decisions

  • Items as JSONB on ReturnRequest — a return covers multiple items; avoids a join table for a simple list
  • Eligibility rules table — configurable per product category without code deploys
  • State machine for return lifecycle — prevents invalid transitions (cannot refund before receiving)
  • Carrier API for prepaid labels — same abstraction as outbound shipping; EasyPost supports return labels
  • Decouple inspection from refund — warehouse staff inspect; refund triggers automatically on approval

Returns portal and e-commerce return system design is discussed in Amazon system design interview questions.

Returns portal and refund system design is covered in Shopify system design interview preparation.

Returns processing and refund system design is discussed in Stripe system design interview guide.

Scroll to Top