Katkat

Build a Progressive Banking Ledger

AnthropicSoftware Engineer · Onsite

Last updated

Design and implement an in-memory, timestamped banking ledger supporting atomic transfers, cashback, scheduled payments, account merges with alias resolution, and historical balance lookups.

Problem Statement

Implement an in-memory banking ledger class, BankLedger, that processes financial transactions deterministically over time. Operations are submitted with monotonically non-decreasing timestamps.

Your ledger must support the following methods:

  • create_account(account_id: str) -> bool: Creates an account with an initial balance of 0. Returns False if account_id already exists or has been merged into another account; otherwise creates it and returns True.

  • deposit(timestamp: int, account_id: str, amount: int) -> int | None: Deposits amount into account_id. Returns the updated balance, or None if the account does not exist or amount bool: Transfers amount from source_id to target_id. If source_id has sufficient funds (balance >= amount) and amount > 0, deducts amount from source_id, credits amount to target_id, and immediately credits a 2% cashback ((amount * 2) // 100) back to source_id. Returns True on success, or False if either account does not exist, accounts resolve to the same account, or funds are insufficient.

  • schedule_transfer(timestamp: int, transfer_id: str, source_id: str, target_id: str, amount: int, execute_at: int) -> bool: Schedules a transfer to run at execute_at (execute_at > timestamp). Returns False if transfer_id already exists. The balance is not checked or reserved until execution time. Returns True if scheduled.

  • merge_accounts(timestamp: int, primary_id: str, secondary_id: str) -> bool: Merges secondary_id into primary_id. All funds from secondary_id move to primary_id. secondary_id becomes an alias of primary_id: any subsequent operation targeting secondary_id must resolve to primary_id. Returns False if either account does not exist or they already resolve to the same account; otherwise returns True.

  • get_balance(timestamp: int, account_id: str, at_time: int | None = None) -> int | None: Returns the balance of account_id at timestamp at_time (or current balance at timestamp if at_time is None). If at_time is specified, it reflects the balance immediately after all transactions at or before at_time completed. Returns None if account_id does not exist.

  • get_top_spenders(timestamp: int, k: int) -> list[str]: Returns the top k account IDs ranked by total gross outgoing transfer amount (excluding cashback deductions). Ties are broken lexicographically by canonical account_id.

Deterministic Event Processing Rules

  1. Before processing any method called at timestamp, the ledger must execute all scheduled transfers with execute_at <= timestamp in ascending order of execute_at. If multiple transfers share the same execute_at, execute them in lexicographical order of transfer_id.
  2. If a scheduled transfer fails due to insufficient funds at execution time, it is discarded and does not retry.

Example

ledger = BankLedger()
ledger.create_account("alice")  # True
ledger.create_account("bob")    # True

ledger.deposit(1, "alice", 1000)  # 1000
ledger.schedule_transfer(2, "tx1", "alice", "bob", 500, 10)  # True
ledger.transfer(5, "alice", "bob", 200)  # True
# Alice sent 200, received 4 cashback (200 * 0.02). Alice balance = 804. Bob = 200.

ledger.get_balance(10, "alice")
# Before returning balance at timestamp 10, "tx1" executes.
# Alice sends 500, gets 10 cashback. Alice balance = 804 - 500 + 10 = 314. Bob = 700.
# Returns 314

ledger.get_top_spenders(10, 1)  # ["alice"] (gross spent: 200 + 500 = 700)

ledger.merge_accounts(12, "bob", "alice")  # True: Alice merges into Bob
# Bob now has 700 + 314 = 1014. Alice is an alias for Bob.

ledger.deposit(15, "alice", 100)  # 1114 (deposited into Bob via alias)
ledger.get_balance(15, "bob")     # 1114
ledger.get_balance(15, "alice", at_time=1)  # 1000 (historical balance of Alice at t=1)

Constraints

  • 1 = timestamp_{i-1}).

Hints

Hint 1

Process scheduled events lazily: use a min-heap keyed by `(execute_at, transfer_id)` and drain it up to the current timestamp before executing any operation.

Hint 2

Use a Disjoint Set (Union-Find) or a persistent alias pointer map to resolve accounts to their canonical IDs while keeping historical records intact under their original IDs.

Hint 3

For historical balance queries, record `(timestamp, balance)` snapshots for each account whenever its balance changes, then use binary search (`bisect`) to answer queries for `at_time`.

Follow-up questions

  • How would you handle recurring scheduled transfers (e.g., every N days) without unbounded memory growth?
  • If timestamps could arrive out of order (distributed network delays), how would your event-sourcing model need to change to support rollbacks or compensating transactions?
  • How would you scale this design across multiple shards when transfers frequently cross partition boundaries?