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

<img src="https://bugfree-s3.s3.amazonaws.com/mermaid_diagrams/image_1767809763914.png" alt="Vending Machine Transaction State Diagram" style="max-width:600px; width:100%; height:auto; display:block; margin:0 auto 1rem;" />

# 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

- 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).
- 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.

2. Decrement inventory only on DISPENSED

- Do not decrement stock when payment succeeds. Only mark inventory as sold once DISPENSED is recorded.
- This avoids inventory drift if dispensing fails.

3. Refund only from PAID (or FAILED) state

- When dispensing fails, transition PAID -> FAILED, then trigger refund flow and transition to REFUNDED once the refund is confirmed (or scheduled).
- Make refund calls idempotent (use payment gateway idempotency keys) and persist external refund ids on the Transaction.

4. Concurrency and consistency

- Use optimistic locking (version numbers) or DB transactions when updating inventory and transaction states to avoid race conditions.
- Consider reservation patterns: optionally reserve stock on PAID but keep a separate reserved_count that is reconciled only on DISPENSED vs REFUNDED.

5. Asynchronous flows and retries

- 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.
- Implement retry/backoff logic for hardware and network errors, then escalate to FAILED -> REFUNDED after a threshold.

6. Logging, monitoring, and reconciliation

- Emit events for state transitions (PAID, DISPENSED, FAILED, REFUNDED). Track metrics like failed_dispenses, refund_rate, and time-to-refund.
- Reconcile transactions daily: ensure every PAID without DISPENSED has either an open retry, FAILED, or REFUNDED state.

7. Edge cases to handle

- Partial refunds (e.g., user charged extra fees): model refund amount on the Transaction.
- Manual refunds: allow support to move a FAILED transaction to REFUNDED with an audit trail.
- 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
