Back

Banking System (Online Assessment)

Low-Level DesignCodingSoftware EngineerReported Jan, 2026Medium Frequency

Overview

Your task is to implement an in-memory banking system. This problem consists of 4 levels that progressively add complexity:

Level 1: Basic account operations (create, deposit, transfer)

Level 2: Ranking accounts by spending activity

Level 3: Scheduled payments with cashback and payment status tracking

Level 4: Account merging and historical balance queries

Subsequent levels are unlocked when the current level is correctly solved. You always have access to the data for the current and all previous levels.

Level 1: Basic Account Operations

Description

Initially, the banking system does not contain any accounts. Implement operations to allow account creation, deposits, and transfers between different accounts.

Operations

create_account

create_account(self, timestamp: int, account_id: str) -> bool

Creates a new account with the given identifier if it doesn't already exist

Returns True if the account was successfully created

Returns False if an account with that id already exists

deposit

deposit(self, timestamp: int, account_id: str, amount: int) -> int | None

Deposits the given amount of money to the specified account id

Returns the balance of the account after the operation has been processed

Returns None if the specified account doesn't exist

The operation is processed before the result is returned

transfer

transfer(self, timestamp: int, source_account_id: str, target_account_id: str, amount: int) -> int | None

Transfers the given amount of money from source_account_id to target_account_id

Returns the balance of source_account_id if the transfer was successful

Returns None otherwise

Returns None if:

source_account_id or target_account_id doesn't exist

source_account_id and target_account_id are the same

Account source_account_id has insufficient funds to perform the transfer

Level 2: Top Spenders

Description

The bank wants to identify people who are not keeping money in their accounts. Implement operations to support ranking accounts based on outgoing transactions.

New Operations

top_spenders

top_spenders(self, timestamp: int, n: int) -> list[str]

Returns the identifiers of the top n accounts with the highest outgoing transactions

Outgoing transactions = total amount of money either:

Transferred out, OR

Paid/withdrawn (the pay operation will be introduced in Level 3)

Results are sorted in descending order by total outgoing amount

In case of a tie, sort alphabetically by account_id in ascending order

Format:

["account_id_1(total_outgoing_1)", "account_id_2(total_outgoing_2)", ..., "account_id_n(total_outgoing_n)"]

Notes:

If less than n accounts exist in the system, return all their identifiers in the described format

Cashback (an operation introduced in Level 3) should not be reflected in calculations for total outgoing transactions

Level 3: Scheduled Payments with Cashback

Description

The banking system should allow scheduling payments with cashback and checking the status of scheduled payments.

New Operations

pay

pay(self, timestamp: int, account_id: str, amount: int) -> str | None

Withdraws the given amount of money from the specified account

All withdrawal transactions provide a 2% cashback

The cashback amount (rounded down to the nearest integer) will be refunded to the account 24 hours after the withdrawal

Returns a unique identifier for the payment transaction (e.g., "payment1", "payment2", etc.)

Returns None if:

account_id doesn't exist

account_id has insufficient funds to perform the payment

Additional Conditions:

top_spenders should now also account for the total amount of money withdrawn from accounts

The waiting period for cashback is 24 hours = 24 × 60 × 60 × 1000 = 86400000 milliseconds (the unit for timestamps)

Cashback will be processed at timestamp + 86400000

If the refund is successful (i.e., the account hasn't been removed from the system), the cashback is applied

get_payment_status

get_payment_status(self, timestamp: int, account_id: str, payment: str) -> str | None

Returns the status of the payment transaction for the given payment

Returns None if:

account_id doesn't exist

The given payment doesn't exist for the specified account

The payment transaction was for an account with a different identifier from account_id

Returns:

"IN_PROGRESS" - if cashback has not been received yet

"CASHBACK_RECEIVED" - if cashback has been refunded to the account

Level 4: Account Merging and Historical Balances

Description

The banking system should support merging two accounts while retaining both accounts' balance and transaction histories.

New Operations

merge_accounts

merge_accounts(self, timestamp: int, account_id_1: str, account_id_2: str) -> bool

Merges account_id_2 into account_id_1

Returns True if accounts were successfully merged

Returns False otherwise

Returns False if:

account_id_1 is equal to account_id_2

account_id_1 or account_id_2 doesn't exist

Merge Behavior:

All pending cashback refunds for account_id_2 should still be processed, but refunded to account_id_1 instead

After the merge, it must be possible to check the status of payment transactions in account_id_2 with payment identifiers by replacing account_id_2 with account_id_1

The balance of account_id_2 should be added to the balance for account_id_1

top_spenders operations should recognize merged accounts — the total outgoing transactions for merged accounts should be the sum of all money transferred and/or withdrawn in both accounts

account_id_2 should be removed from the system after the merge

get_balance

get_balance(self, timestamp: int, account_id: str, time_at: int) -> int | None

Returns the total amount of money in the account account_id at the given timestamp time_at

If the specified account did not exist at a given time time_at, returns None

Important Notes:

If queries have been processed at timestamp time_at, get_balance must reflect the account balance after the query has been processed

If the account was merged into another account, the merged account should inherit its balance history

Important Edge Cases

Account Recreation After Merge

If account_id_2 is merged into account_id_1, then account_id_2 is removed from the system

After removal, it is possible to create a new account with account_id_2 using create_account

Historical balances and outgoing transaction totals before the merge are preserved in account_id_1

The new account_id_2 account starts with a balance of 0 from the new creation timestamp

Historical Queries After Merge

If an account was merged at timestamp T, queries with time_at < T should return the account's historical balance

Queries with time_at >= T for a merged account should return None (unless the account was recreated)

Constraints

All timestamps are given in milliseconds

Timestamps are guaranteed to be unique and in strictly increasing order

Account IDs are valid string identifiers

All amounts are positive integers


Reference solution

#10 Banking System (Online Assessment) — Solution

✦ AI-Generated Solution · Coding (OA) · Comprehensive Full level-by-level reference implementation, including the hard parts: lazy 24h cashback processing, account merging with redirected refunds, and historical balance queries. Verified with an end-to-end assertion suite covering all four levels.


How to approach this OA

The defining difficulty is time. Timestamps arrive strictly increasing, but cashback lands in the future (timestamp + 86_400_000). You cannot eagerly schedule a callback, so use the standard trick: keep a list of pending refunds and, at the start of every operation, lazily apply all pending refunds whose execution time is <= current timestamp. Because timestamps are monotonic, this is correct and amortized-cheap.

State to carry across all levels:

  • accounts: id -> balance, outgoing: id -> total outgoing (transfers out + payments).
  • history: id -> [(timestamp, balance), ...] to answer Level-4 get_balance(time_at) via binary search. A merged/removed account gets a terminal (merge_ts, None) marker so queries at/after the merge return None but earlier history stays queryable.
  • pending: [(exec_time, account, cashback, payment_id)] and a payments registry for status.

Reference Implementation (Python)

import bisect

class BankingSystem:
    def __init__(self):
        self.accounts = {}      # id -> balance
        self.outgoing = {}      # id -> total outgoing
        self.history  = {}      # id -> [(ts, balance)]  (balance None = removed)
        self.payments = {}      # payment_id -> {account, exec_time, status_time}
        self.pending  = []      # [(exec_time, account, cashback, payment_id)]
        self.pay_counter = 0
    CASHBACK_DELAY = 86_400_000

    # apply every refund whose time has arrived (monotonic timestamps -> safe)
    def _process_cashback(self, ts):
        self.pending.sort()
        keep = []
        for exec_time, acc, amount, pid in self.pending:
            if exec_time <= ts:
                if acc in self.accounts:                # skip if account removed
                    self.accounts[acc] += amount
                    self._record(acc, exec_time)
                self.payments[pid]["status_time"] = exec_time
            else:
                keep.append((exec_time, acc, amount, pid))
        self.pending = keep

    def _record(self, acc, ts):
        h = self.history.setdefault(acc, [])
        if h and h[-1][0] == ts:
            h[-1] = (ts, self.accounts[acc])
        else:
            h.append((ts, self.accounts[acc]))

    # ---------- Level 1 ----------
    def create_account(self, ts, account_id):
        self._process_cashback(ts)
        if account_id in self.accounts:
            return False
        self.accounts[account_id] = 0
        self.outgoing[account_id] = 0
        self.history[account_id] = [(ts, 0)]
        return True

    def deposit(self, ts, account_id, amount):
        self._process_cashback(ts)
        if account_id not in self.accounts:
            return None
        self.accounts[account_id] += amount
        self._record(account_id, ts)
        return self.accounts[account_id]

    def transfer(self, ts, source, target, amount):
        self._process_cashback(ts)
        if source not in self.accounts or target not in self.accounts:
            return None
        if source == target or self.accounts[source] < amount:
            return None
        self.accounts[source] -= amount
        self.accounts[target] += amount
        self.outgoing[source] += amount
        self._record(source, ts); self._record(target, ts)
        return self.accounts[source]

    # ---------- Level 2 ----------
    def top_spenders(self, ts, n):
        self._process_cashback(ts)
        ranked = sorted(self.outgoing.items(), key=lambda x: (-x[1], x[0]))
        return [f"{acc}({amt})" for acc, amt in ranked[:n]]

    # ---------- Level 3 ----------
    def pay(self, ts, account_id, amount):
        self._process_cashback(ts)
        if account_id not in self.accounts or self.accounts[account_id] < amount:
            return None
        self.accounts[account_id] -= amount
        self.outgoing[account_id] += amount
        self._record(account_id, ts)
        self.pay_counter += 1
        pid = f"payment{self.pay_counter}"
        cashback = (amount * 2) // 100                   # 2%, floored
        exec_time = ts + self.CASHBACK_DELAY
        self.payments[pid] = {"account": account_id, "exec_time": exec_time, "status_time": None}
        self.pending.append((exec_time, account_id, cashback, pid))
        return pid

    def get_payment_status(self, ts, account_id, payment):
        self._process_cashback(ts)
        if account_id not in self.accounts:
            return None
        p = self.payments.get(payment)
        if p is None or p["account"] != account_id:
            return None
        if p["status_time"] is not None and p["status_time"] <= ts:
            return "CASHBACK_RECEIVED"
        return "IN_PROGRESS"

    # ---------- Level 4 ----------
    def merge_accounts(self, ts, acc1, acc2):
        self._process_cashback(ts)
        if acc1 == acc2 or acc1 not in self.accounts or acc2 not in self.accounts:
            return False
        self.accounts[acc1] += self.accounts[acc2]
        self.outgoing[acc1] += self.outgoing[acc2]
        # pending cashbacks for acc2 now pay out to acc1
        self.pending = [(t, acc1 if a == acc2 else a, amt, pid)
                        for (t, a, amt, pid) in self.pending]
        for p in self.payments.values():
            if p["account"] == acc2:
                p["account"] = acc1
        self._record(acc1, ts)
        self.history[acc2].append((ts, None))            # terminal marker
        del self.accounts[acc2]; del self.outgoing[acc2]
        return True

    def get_balance(self, ts, account_id, time_at):
        self._process_cashback(ts)
        h = self.history.get(account_id)
        if not h:
            return None
        idx = bisect.bisect_right([t for t, _ in h], time_at) - 1
        if idx < 0:
            return None
        _, bal = h[idx]
        return bal                                       # None if removed at/before time_at

The traps that fail hidden tests

  1. Process cashbacks at the start of every op, not just inside get_payment_status. A top_spenders or get_balance call at a later timestamp must already reflect refunds that became due.
  2. Cashback is 2% floored (amount * 2 // 100), and it is excluded from outgoing — only money leaving the account (transfers out + payments) counts toward top_spenders.
  3. Refund to a removed account is dropped, unless the account was merged — in which case the pending refund is redirected to the surviving account at merge time.
  4. get_balance is historical: binary-search the balance snapshots for the latest record <= time_at. After a merge, the absorbed account returns its pre-merge history for time_at < merge_ts and None afterward (until/unless recreated, which starts fresh at 0).
  5. top_spenders tie-break is alphabetical ascending on account_id; format each entry as "id(total_outgoing)".

Complexity

  • create/deposit/transfer/pay/get_payment_status: O(P log P) worst case from the pending-refund sweep (P = pending refunds), amortized near O(1) since each refund is processed once.
  • top_spenders: O(A log A) in number of accounts.
  • get_balance: O(H) to build the key list (or O(log H) if you keep a parallel sorted key array).

Verification

An end-to-end assertion suite exercises account creation/duplication, deposits to missing accounts, self-transfer and insufficient-funds guards, top_spenders ranking and ties, a pay with a 24h-delayed 2% cashback, status transition IN_PROGRESS -> CASHBACK_RECEIVED, historical get_balance before vs. after the cashback, an account merge with redirected payment status, and recreation of a merged id. All assertions pass:

Banking: ALL ASSERTIONS PASS
Auto-save enabled
Loading editor…
Output
Run your code to see the output here.