Skip to main content

Command Palette

Search for a command to run...

Vending Machine OOD: The One Rule Interviewers Won’t Let You Skip—Refund Is a State, Not an Afterthought

Published
4 min read
Vending Machine OOD: The One Rule Interviewers Won’t Let You Skip—Refund Is a State, Not an Afterthought
B

bugfree.ai is an advanced AI-powered platform designed to help software engineers master system design and behavioral interviews. Whether you’re preparing for your first interview or aiming to elevate your skills, bugfree.ai provides a robust toolkit tailored to your needs. Key Features:

150+ system design questions: Master challenges across all difficulty levels and problem types, including 30+ object-oriented design and 20+ machine learning design problems. Targeted practice: Sharpen your skills with focused exercises tailored to real-world interview scenarios. In-depth feedback: Get instant, detailed evaluations to refine your approach and level up your solutions. Expert guidance: Dive deep into walkthroughs of all system design solutions like design Twitter, TinyURL, and task schedulers. Learning materials: Access comprehensive guides, cheat sheets, and tutorials to deepen your understanding of system design concepts, from beginner to advanced. AI-powered mock interview: Practice in a realistic interview setting with AI-driven feedback to identify your strengths and areas for improvement.

bugfree.ai goes beyond traditional interview prep tools by combining a vast question library, detailed feedback, and interactive AI simulations. It’s the perfect platform to build confidence, hone your skills, and stand out in today’s competitive job market. Suitable for:

New graduates looking to crack their first system design interview. Experienced engineers seeking advanced practice and fine-tuning of skills. Career changers transitioning into technical roles with a need for structured learning and preparation.

Vending Machine Transaction State Diagram

Vending Machine OOD: Make Refund a First-Class Transaction State

When designing a vending machine (or any payment+fulfillment system), model a refund as a first-class state in the Transaction lifecycle — not as an ad-hoc side effect.

Why this matters

  • Failures happen after money moves: the payment gateway can accept funds, then the dispenser jams or a network timeout interrupts dispensing. If you don't model that possibility explicitly, you can't reliably enforce idempotency, inventory correctness, or accurate logs.
  • If refund is an afterthought, you risk double-decrementing stock, missing refunds, inconsistent logs, and hard-to-reproduce charge disputes.

Core rule (the interviewer favorite)

  • Decrement stock only after the item is successfully DISPENSED.
  • Trigger a refund only when the Transaction is in PAID and dispensing cannot be completed (FAILED).

Transaction state machine (recommended)

  • INITIATED -> PAID -> DISPENSED
  • PAID -> FAILED -> REFUNDED
  • (Optionally) PAID -> CANCELLED -> REFUNDED

In other words:

  • INITIATED: user started purchase, awaiting payment.
  • PAID: payment confirmed, awaiting dispensing.
  • DISPENSED: physical item delivered — now safe to change inventory.
  • FAILED: dispensing failed (jam, timeout, etc.). From here, the system should either attempt retries or move to REFUNDED.
  • REFUNDED: payment reversed (or credit issued).

Best practices and implementation notes

  1. Idempotency and single source of truth

  2. Use a Transaction record as the single source of truth for each purchase attempt (tx_id, user_id, item_id, state, timestamps, gateway_ids, attempts).

  3. All operations (decrement inventory, call dispenser, call refund API) should be guarded by the Transaction state. If a request is retried, examine the tx state and perform only the actions that haven't been done yet.

  4. Decrement inventory only on DISPENSED

  5. Do not decrement stock when payment succeeds. Only mark inventory as sold once DISPENSED is recorded.

  6. This avoids inventory drift if dispensing fails.

  7. Refund only from PAID (or FAILED) state

  8. When dispensing fails, transition PAID -> FAILED, then trigger refund flow and transition to REFUNDED once the refund is confirmed (or scheduled).

  9. Make refund calls idempotent (use payment gateway idempotency keys) and persist external refund ids on the Transaction.

  10. Concurrency and consistency

  11. Use optimistic locking (version numbers) or DB transactions when updating inventory and transaction states to avoid race conditions.

  12. Consider reservation patterns: optionally reserve stock on PAID but keep a separate reserved_count that is reconciled only on DISPENSED vs REFUNDED.

  13. Asynchronous flows and retries

  14. Use an event queue for physical dispense attempts and refund requests. The queue consumer reads tx state, attempts the operation, updates state, and emits events.

  15. Implement retry/backoff logic for hardware and network errors, then escalate to FAILED -> REFUNDED after a threshold.

  16. Logging, monitoring, and reconciliation

  17. Emit events for state transitions (PAID, DISPENSED, FAILED, REFUNDED). Track metrics like failed_dispenses, refund_rate, and time-to-refund.

  18. Reconcile transactions daily: ensure every PAID without DISPENSED has either an open retry, FAILED, or REFUNDED state.

  19. Edge cases to handle

  20. Partial refunds (e.g., user charged extra fees): model refund amount on the Transaction.

  21. Manual refunds: allow support to move a FAILED transaction to REFUNDED with an audit trail.
  22. Payment confirmations delayed: ensure PAID is only set when the payment gateway returns a confirmed status (or after robust webhooks).

Minimal pseudocode example

Transaction purchase(user, item):
  tx = Transaction.create({state: INITIATED, user, item})
  payment = PaymentGateway.charge(user, item.price, idempotency_key=tx.id)
  if payment.success:
    tx.update(state=PAID, payment_id=payment.id)
    enqueue(DispenseWorker, tx.id)
  else:
    tx.update(state=FAILED)
    // no inventory change

DispenseWorker(tx_id):
  tx = Transaction.fetch(tx_id)
  if tx.state != PAID: return
  result = Dispenser.tryDispense(tx.item)
  if result.success:
    tx.update(state=DISPENSED, dispensed_at=now)
    Inventory.decrement(tx.item_id)
  else:
    tx.update(state=FAILED, failure_reason=result.error)
    enqueue(RefundWorker, tx.id)

RefundWorker(tx_id):
  tx = Transaction.fetch(tx_id)
  if tx.state != FAILED and tx.state != PAID: return
  if tx.refund_id: return  // idempotent
  refund = PaymentGateway.refund(tx.payment_id, idempotency_key=tx.id + "-refund")
  if refund.success:
    tx.update(state=REFUNDED, refund_id=refund.id)

Testing checklist

  • Simulate successful payment + successful dispense (inventory decremented once).
  • Simulate successful payment + dispenser failure + refund (inventory unchanged, refund occurred).
  • Retry paths: repeated charging attempts, repeated dispense calls, repeated refund calls (idempotency).
  • Concurrency: simultaneous purchase requests for last item.

Summary (short)

Treat REFUND as a state in your Transaction model. Only decrement stock after DISPENSED, and only trigger refunds from PAID/FAILED. This simple rule prevents inventory drift, enforces idempotency, and makes reconciliation and monitoring tractable.

#SystemDesign #OOD #SoftwareEngineering

More from this blog

B

bugfree.ai

417 posts

bugfree.ai is an advanced AI-powered platform designed to help software engineers and data scientist to master system design and behavioral and data interviews.