Build an Incremental Loyalty-Points Ledger with Scheduled, Cancelable Transfers
AirbnbSoftware Engineer · Online AssessmentLast updated
Implement an in-memory loyalty-points ledger that grows in three stages: atomic transfers between accounts, transfers scheduled for a future point on a simulated clock, and cancellation of a transfer before it fires. Tests incremental state-machine design, validating before mutating, and keeping point totals correct across every sequence of calls.
Design an in-memory loyalty-points ledger as a single class. Three independent test suites exercise it, one per stage below. Code added for a later stage should leave every earlier stage's passing tests exactly as green as they were before.
class PointsLedger:
def open_account(self, account_id: str, starting_points: int = 0) -> bool: ...
def send(self, sender_id: str, recipient_id: str, points: int) -> bool: ...
def schedule_send(self, sender_id: str, recipient_id: str, points: int, execute_at: int) -> str | None: ...
def advance_clock(self, to_time: int) -> list[str]: ...
def cancel_scheduled(self, transfer_id: str) -> bool: ...
There is no wall-clock time. The ledger tracks its own integer clock, starting at 0, which only moves forward when advance_clock is called.
Part 1 — Accounts and immediate transfers
open_account creates an account with the given starting balance and returns False if the ID already exists. send moves points between two existing, distinct accounts right away: either both balances change or neither does. It returns False, with no change to either balance, for an unknown account, a non-positive point amount, a transfer to oneself, or a sender balance that is too low.
Part 2 — Scheduled transfers
schedule_send records a transfer that should fire once the ledger's clock reaches execute_at, and returns a transfer-ID string, or None for the same invalid inputs send rejects — except that the sender's balance is not checked or set aside at scheduling time. advance_clock(to_time) moves the clock forward and applies every pending transfer whose execute_at is now due, processed in ascending order of execute_at and then of transfer ID, returning the list of IDs actually applied. A due transfer whose sender lacks enough points at the moment it fires is quietly dropped instead of applied, and is left out of the returned list.
Part 3 — Cancellation
cancel_scheduled(transfer_id) removes a transfer that is still pending and returns True. It returns False if the ID was never issued, already fired, or was already canceled.
Example
open_account("amy", 100) # True
open_account("ben", 0) # True
send("amy", "ben", 30) # True -> amy=70, ben=30
send("amy", "ben", 1000) # False -> balances unchanged
schedule_send("amy", "ben", 20, execute_at=3) # "T1"
schedule_send("ben", "amy", 10, execute_at=5) # "T2"
cancel_scheduled("T2") # True
advance_clock(3) # ["T1"] -> amy=50, ben=50
advance_clock(10) # [] (T2 was canceled earlier)
Constraints
- Account IDs and transfer IDs are opaque strings; you choose the format of generated transfer IDs.
execute_atvalues passed toschedule_sendnever move backward relative to the current clock when the call is made.- The total number of points across all accounts must equal the sum of starting balances at every point in the operation sequence — no operation may create or destroy points.
Hints
Hint 1
Keep balances, pending transfers, and the clock as three separate pieces of state, and let each method touch only what it needs.
Hint 2
For Part 1, run every validation check before touching either balance, so a rejected transfer leaves both accounts untouched.
Hint 3
For Part 2, store each pending transfer with its target time in one dictionary; `advance_clock` just needs to filter and sort that dictionary once per call.
Hint 4
A transfer that is canceled or has already fired should stop appearing as 'pending' — if that's the only place you check membership, removing it from one dictionary is enough.
Follow-up questions
- How would you support closing an account, given that it might still be named in pending scheduled transfers?
- If `advance_clock`, `schedule_send`, and `cancel_scheduled` could be called concurrently from multiple threads, what would you change to keep the ledger consistent?
- How would you let a dropped, under-funded scheduled transfer retry automatically on a future `advance_clock` call instead of being discarded?
- How would you add an audit log that reconstructs every account's balance history from the sequence of calls alone?
Solution walkthrough is for members
Get the full walkthrough with reference code and complexity analysis, plus Kitty Coach at 300 messages a day. Hints, the code runner and community answers stay free.