Your team is moving away from a monolith and has been asked to build a new Payments Service. Your piece is the Dasher payout logic.
Build an API with one endpoint:
POST payoutdasherIdThe endpoint has to request the Dasher's delivery information from a separate upstream endpoint. The first version of the payment model is based only on how much time the Dasher spends fulfilling each order. Given the sequence of order activities for one Dasher on one day, calculate the pay with these rules:
$0.30 per minuteThe statement is short on purpose. Input format, output format, the data model, and most edge-case rules are yours to propose and agree on with the interviewer, the way you would with a teammate. The code has to run.
The question builds in parts, followed by a verbal discussion about running this in production:
ACCEPTED, FULFILLED, and CANCELED events.ARRIVED and PICKED_UP statuses, where time spent waiting in a store does not count toward other orders.Many interviewers skip Part 2 and go straight from base pay to peak-hour pay.
Time is the constraint, not difficulty. Clarifying the vague statement takes a large part of the hour, and the later parts only come up if the base version is running by then. Agree on the data shape quickly, write the main logic directly, and aim to pass your tests within one or two runs.
The interviewer expects these questions and often has no fixed answer, so propose something reasonable and confirm it:
main is faster than parsing JSON.2026-3-16 12:00, so know how to parse a datetime in your language. Looking up the syntax is allowed.ACCEPTED. Interviewers have allowed the assumption that timestamps are in order.CANCELED event ends the order exactly like FULFILLED, so the Dasher keeps the time-based pay up to that point; or a canceled order earns a fixed compensation instead. Ask which one applies.Each event has an order id, a Dasher id, a timestamp, and a status. Compute the total pay for one Dasher.
[
{ "orderId": "1", "dasherId": "1", "timestamp": 15, "status": "ACCEPTED" },
{ "orderId": "2", "dasherId": "1", "timestamp": 20, "status": "ACCEPTED" },
{ "orderId": "1", "dasherId": "1", "timestamp": 26, "status": "FULFILLED" },
{ "orderId": "2", "dasherId": "1", "timestamp": 35, "status"
An order is ongoing from its ACCEPTED event until its FULFILLED or CANCELED event. While k orders are ongoing, the Dasher earns k × $0.30 per minute.
This write-up assumes integer minute timestamps, a single dasherId as input, and CANCELED ending the order like FULFILLED.
from dataclasses import dataclass
BASE_RATE_CENTS = 30 # $0.30 per minute
@dataclass(frozen=True)
class Event:
order_id: str
dasher_id: str
timestamp: int # minutes
status: str
SAMPLE_EVENTS = [
Event("1", "1", 15, "ACCEPTED"),
Event("2", "1", 20, "ACCEPTED"),
Event("1", "1", 26, "FULFILLED"),
Event("2", "1", 35, "CANCELED"),
]
def fetch_dasher_events(dasher_id: str) -> list[Event]:
"""Stand-in for the upstream delivery endpoint."""
return [event for event in SAMPLE_EVENTS if event.dasher_id == dasher_id]
Money is kept in integer cents. 0.3 has no exact binary representation, and summing floats produces totals like 7.799999999999999.
If timestamps arrive as strings, convert them once at the boundary and keep the rest of the code on integers:
from datetime import datetime
EPOCH = datetime(1970, 1, 1)
def to_minutes(timestamp: str) -> int:
parsed = datetime.strptime(timestamp, "%Y-%m-%d %H:%M")
return int((parsed - EPOCH).total_seconds() // 60)
Walk the events in time order and keep the set of ongoing orders. Between two consecutive events the set does not change, so that stretch pays ongoing orders × minutes × rate.
END_STATUSES = {"FULFILLED", "CANCELED"}
def calculate_pay_cents(events: list[Event], rate_cents: int = BASE_RATE_CENTS) -> int:
ongoing: set[str] = set()
total = 0
previous = None
for event in sorted(events, key=lambda e: e.timestamp):
if previous is not None:
total += len(ongoing) * (event.timestamp - previous) * rate_cents
previous = event.timestamp
if event.status == "ACCEPTED":
ongoing.add(event.order_id)
elif event.status in END_STATUSES:
ongoing.discard(event.order_id)
return total
def payout(dasher_id: str) -> str:
cents = calculate_pay_cents(fetch_dasher_events(dasher_id))
return f"{cents // 100}.{cents % 100:02d}"
If the interviewer confirms the events are already sorted, drop the sorted call. Two events with the same timestamp are harmless: the stretch between them is zero minutes long.
For the sample input:
| Minutes | Ongoing orders | Pay |
|---|---|---|
| 15 to 20 | 1 | 5 × 1 × 30 = 150 cents |
| 20 to 26 | 2 | 6 × 2 × 30 = 360 cents |
| 26 to 35 | 1 | 9 × 1 × 30 = 270 cents |
payout("1")
# '7.80'
Paying k times the rate while k orders overlap is the same as paying each order the base rate for its own duration and summing. Order 1 lasts 11 minutes and order 2 lasts 15 minutes, and (11 + 15) × 30 is the same 780 cents. Overlapping time is meant to be paid once per order, so there is nothing to merge and no double counting to remove.
Under the base rules a map from order id to accept time and a running total is therefore a complete solution. Say so, because it shows you understand the rule. Then keep the sweep above anyway: the interviewer may ask for it, and Part 2 depends on which other orders are ongoing in each stretch of time, which the per-order sum does not track.
def test_sample():
assert calculate_pay_cents(SAMPLE_EVENTS) == 780
def test_single_order():
events = [Event("1", "d", 0, "ACCEPTED"), Event("1", "d", 10, "FULFILLED")]
assert calculate_pay_cents(events) == 300
def test_gap_between_orders_is_unpaid():
events = [
Event("1", "d", 0, "ACCEPTED"),
Event("1", "d", 10, "FULFILLED"),
Event("2", "d", 30, "ACCEPTED"),
Event("2", "d", 40, "FULFILLED"),
]
assert calculate_pay_cents(events) == 600
def test_no_events():
assert calculate_pay_cents([]) == 0
Let E be the number of events.
K orders ongoing at onceTwo statuses are added between ACCEPTED and FULFILLED: ARRIVED, when the Dasher reaches the store, and PICKED_UP, when the Dasher leaves with the order. The time spent waiting in a store must not be counted toward the Dasher's other orders.
The rule beyond that sentence is not known. This write-up reads it as: while the Dasher is waiting in a store for one or more orders, only those orders earn pay for that stretch. The other ongoing orders pause until the pickup. Confirm the exact rule before coding it.
Track a second set for the orders being waited on. A stretch pays for the waiting orders when there are any, and for all ongoing orders otherwise.
def calculate_pay_cents(events: list[Event], rate_cents: int = BASE_RATE_CENTS) -> int:
ongoing: set[str] = set()
waiting: set[str] = set()
total = 0
previous = None
for event in sorted(events, key=lambda e: e.timestamp):
if previous is not None:
paid_orders = len(waiting) if waiting else len(ongoing)
total += paid_orders * (event.timestamp - previous) * rate_cents
previous = event.timestamp
if event.status == "ACCEPTED":
ongoing.add(event.order_id)
elif event.status == "ARRIVED":
waiting.add(event.order_id)
elif event.status == "PICKED_UP":
waiting.discard(event.order_id)
elif event.status in END_STATUSES:
waiting.discard(event.order_id)
ongoing.discard(event.order_id)
return total
events = [
Event("1", "d", 0, "ACCEPTED"),
Event("2", "d", 5, "ACCEPTED"),
Event("1", "d", 10, "ARRIVED"),
Event("1", "d", 20, "PICKED_UP"),
Event("1", "d", 30, "FULFILLED"),
Event("2", "d", 40, "FULFILLED"),
]
calculate_pay_cents(events)
# 1650
| Minutes | Paid orders | Pay |
|---|---|---|
| 0 to 5 | 1 | 150 cents |
| 5 to 10 | 2 | 300 cents |
| 10 to 20 | 1, waiting in the store for order 1 | 300 cents |
| 20 to 30 | 2 | 600 cents |
| 30 to 40 | 1 | 300 cents |
Without the wait rule the stretch from 10 to 20 would pay for both orders and the total would be 1,950 cents.
Unchanged from Part 1: O(E log E) time with the sort, and O(K) space for the two sets.
One or more peak windows are given, for example 5pm to 7pm. Minutes inside a peak window pay double. A stretch between two events can straddle a window boundary, so it has to be split and each piece paid at its own rate.
A minute inside a peak window is worth two minutes of pay. So the pay for a stretch is paid orders × (minutes + peak minutes in the stretch) × rate, and the only new work is measuring the overlap between a stretch and the peak windows.
Events are already in time order and so are the windows, so do not merge the boundaries into the event list and sort again. Keep one pointer into the window list and advance it as the sweep moves forward.
from typing import Sequence
def calculate_pay_cents(
events: list[Event],
peak_windows: Sequence[tuple[int, int]] = (),
rate_cents: int = BASE_RATE_CENTS,
) -> int:
"""peak_windows: sorted, non-overlapping [start, end) pairs in minutes."""
ongoing: set[str] = set()
waiting: set[str] = set()
total = 0
previous = None
first_window = 0
for event in sorted(events, key=lambda e: e.timestamp):
paid_orders = len(waiting) if waiting else len(ongoing)
if previous is not None and paid_orders:
start, end = previous, event.timestamp
# Windows that end before this stretch can never matter again.
while first_window < len(peak_windows) and peak_windows[first_window][1] <= start:
first_window += 1
peak_minutes = 0
index = first_window
while index < len(peak_windows) and peak_windows[index][0] < end:
window_start, window_end = peak_windows[index]
peak_minutes += min(end, window_end) - max(start, window_start)
index +=
total += paid_orders * ((end - start) + peak_minutes) * rate_cents
previous = event.timestamp
event.status == :
ongoing.add(event.order_id)
event.status == :
waiting.add(event.order_id)
event.status == :
waiting.discard(event.order_id)
event.status END_STATUSES:
waiting.discard(event.order_id)
ongoing.discard(event.order_id)
total
If Part 2 was skipped, drop the waiting set and use len(ongoing) as the paid-order count.
With the Part 1 sample events and a peak window from minute 18 to minute 30:
| Minutes | Paid orders | Peak minutes | Pay |
|---|---|---|---|
| 15 to 20 | 1 | 2 | (5 + 2) × 1 × 30 = 210 cents |
| 20 to 26 | 2 | 6 | (6 + 6) × 2 × 30 = 720 cents |
| 26 to 35 | 1 | 4 | (9 + 4) × 1 × 30 = 390 cents |
calculate_pay_cents(SAMPLE_EVENTS, peak_windows=[(18, 30)])
# 1320
With W peak windows, the pointer only moves forward, so the sweep is O(E + W) after sorting.
An alternative worth mentioning: build a prefix sum of peak minutes over the 1,440 minutes of the day. The peak minutes in any stretch are then one subtraction, at the cost of a fixed-size array.
FAILED, CANCELED, and RETURN, where the outcome depends on who caused it: the customer, the merchant, or the Dasher. The pay rules for these are not known and this page does not cover them. Expect to define them with the interviewer.Once the code runs, the rest of the hour is verbal, and it can take 20 to 30 minutes. Some interviewers stop coding after Part 1 and spend all the remaining time here. These are the questions that come up.
Set a timeout on every call. Retry transient failures a bounded number of times with exponential backoff and jitter, so many callers do not retry in step. Put a circuit breaker in front of a dependency that keeps failing. For asynchronous payout jobs, park requests that still fail in a dead-letter queue for inspection and replay.
Idempotency: however many times the API is called, the result is the same. Duplicate requests can come from a retry, from a user pressing pay repeatedly on a bad network, or from the same upstream data being delivered twice. Give each payout a stable key, record it before moving money, and return the stored result when the same key arrives again.
Caching the delivery data is the obvious answer and gets rejected, because the data cannot be out of date. Decouple instead: the upstream publishes delivery events to a queue such as Kafka and this service consumes them at its own pace, so a slow upstream no longer slows the payout request.
The requirement is to neither skip the payment nor pay twice. Retry with the same idempotency key, so a retry after a lost response cannot produce a second payment, and only mark the payout as paid once the transfer is confirmed.
Decide per case and say which you chose: an end event with no ACCEPTED, a duplicate ACCEPTED, an order that never ends, or events out of order. The reference code ignores an end event for an unknown order through discard and pays an unterminated order only up to the last event seen.
The sweep needs only the ongoing set and one previous timestamp, so it works as a streaming computation. Partition events by Dasher id so each Dasher's events are processed in order. If a batch arrives unsorted and is too large to sort in memory, use an external sort. Add rate limiting and backpressure so a burst from the upstream cannot overwhelm the consumers.
Wrap the calculation in the POST payout handler and add the retry and failure handling above around the upstream call.
Be ready to compare them. Payout records need durability and transactions, which points to a relational database. Redis fits short-lived state such as a cache or a lock.
Persist one row per completed order and query that store, with pagination.
Two concurrent payout requests for the same Dasher must not both pay. The idempotency key settles the race: one request records the key and the other returns the stored result.