Katkat

Build an Incremental Loyalty-Points Ledger with Scheduled, Cancelable Transfers

AirbnbSoftware Engineer · Online Assessment

Last 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_at values passed to schedule_send never 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?