Design and implement a bank account system
Company: HubSpot
Role: Software Engineer
Category: Coding & Algorithms
Difficulty: Medium
Interview Round: Take-home Project
Quick Answer: This question evaluates a candidate's skills in system design, data modeling, transactional integrity, concurrency control, idempotency, API design, and algorithmic complexity for implementing a minimal banking service.
Bank Service — Level 1: Accounts, Deposit, Withdraw (Overdraft-Safe)
Constraints
- Money is an integer number of cents; amounts for deposit/withdraw must be positive.
- Account ids are unique strings; creating a duplicate id fails (returns False).
- Withdrawals never overdraw the balance.
- initial_balance must be >= 0.
Examples
Input: ([['create', 'A', 100], ['deposit', 'A', 50], ['withdraw', 'A', 30], ['balance', 'A']],)
Expected Output: [True, 150, 120, 120]
Explanation: Create with 100, +50 -> 150, -30 -> 120, balance reads 120.
Input: ([['create', 'A', 100], ['withdraw', 'A', 500]],)
Expected Output: [True, None]
Explanation: Overdraft prevented: withdrawing 500 from 100 fails and returns None.
Input: ([['create', 'A', 100], ['create', 'A', 200]],)
Expected Output: [True, False]
Explanation: Duplicate account id is rejected on the second create.
Input: ([['create', 'A', -5], ['deposit', 'B', 10], ['deposit', 'A', -10]],)
Expected Output: [False, None, None]
Explanation: Negative initial balance fails; deposit to missing account B is None; A was never created.
Input: ([],)
Expected Output: []
Explanation: No operations -> empty result list.
Input: ([['create', 'A', 0], ['withdraw', 'A', 0], ['deposit', 'A', 1000000], ['withdraw', 'A', 1000000], ['balance', 'A']],)
Expected Output: [True, None, 1000000, 0, 0]
Explanation: Zero-amount withdraw is invalid (None); large deposit then full withdraw nets to 0.
Hints
- Use a dict {acct_id -> balance} for O(1) access.
- Validate amount > 0 and (for withdraw) balance >= amount before mutating.
- Return None for any operation on a non-existent account.
Bank Service — Level 2: Transaction History
Constraints
- History is append-only and chronological (no sorting needed).
- A record is [op_type, amount, resulting_balance]; op_type is 'deposit' or 'withdrawal'.
- recent(acct, 0) returns [] (do NOT return the whole list — list[-0:] is a footgun).
- history/recent on a missing account returns None; recent with n < 0 returns None.
Examples
Input: ([['create', 'A', 100], ['deposit', 'A', 50], ['withdraw', 'A', 30], ['history', 'A']],)
Expected Output: [True, 150, 120, [['deposit', 50, 150], ['withdrawal', 30, 120]]]
Explanation: Two mutations produce two chronological records with their resulting balances.
Input: ([['create', 'A', 0], ['deposit', 'A', 10], ['deposit', 'A', 20], ['deposit', 'A', 30], ['recent', 'A', 2]],)
Expected Output: [True, 10, 30, 60, [['deposit', 20, 30], ['deposit', 30, 60]]]
Explanation: recent(2) returns the last two records in chronological order.
Input: ([['create', 'A', 0], ['deposit', 'A', 10], ['recent', 'A', 0]],)
Expected Output: [True, 10, []]
Explanation: recent with n==0 returns an empty list, not the whole history.
Input: ([['create', 'A', 0], ['deposit', 'A', 10], ['recent', 'A', 5]],)
Expected Output: [True, 10, [['deposit', 10, 10]]]
Explanation: n exceeds history length, so the whole (1-record) history is returned.
Input: ([['create', 'A', 100], ['withdraw', 'A', 999], ['history', 'A']],)
Expected Output: [True, None, []]
Explanation: A failed (overdraft) withdraw does NOT append a record; history stays empty.
Input: ([['history', 'Z'], ['recent', 'Z', 3]],)
Expected Output: [None, None]
Explanation: Both history reads on a non-existent account return None.
Input: ([['create', 'A', 50], ['history', 'A'], ['recent', 'A', -1]],)
Expected Output: [True, [], None]
Explanation: Fresh account has empty history; recent with negative n returns None.
Hints
- Record AFTER updating the balance so resulting_balance reflects the new state.
- The n==0 case must be special-cased because list[-0:] equals list[0:] (the whole list).
- Return copies of the records so callers cannot mutate internal state.
Bank Service — Level 3: Two-Phase Transfers (Accept / Reject, Idempotent)
Constraints
- Transfer ids are 't1','t2',... assigned in successful-creation order.
- Creation records 'transfer_pending' but does NOT move money.
- Funds are validated at ACCEPT time; accept on insufficient funds leaves the transfer PENDING.
- accept/reject are idempotent: a terminal status (accepted/rejected) is returned unchanged with no double-apply.
- Self-transfer (from == to), non-existent accounts, and amount <= 0 all return None at creation.
Examples
Input: ([['create', 'A', 100], ['create', 'B', 0], ['transfer', 'A', 'B', 40], ['accept', 't1'], ['balance', 'A'], ['balance', 'B']],)
Expected Output: [True, True, 't1', 'accepted', 60, 40]
Explanation: Happy path: pending then accepted debits A (100->60) and credits B (0->40).
Input: ([['create', 'A', 100], ['create', 'B', 0], ['transfer', 'A', 'B', 40], ['accept', 't1'], ['accept', 't1'], ['balance', 'A'], ['balance', 'B']],)
Expected Output: [True, True, 't1', 'accepted', 'accepted', 60, 40]
Explanation: Idempotent accept: the second accept returns 'accepted' with no double-credit.
Input: ([['create', 'A', 100], ['create', 'B', 0], ['transfer', 'A', 'B', 40], ['reject', 't1'], ['accept', 't1'], ['balance', 'A'], ['balance', 'B']],)
Expected Output: [True, True, 't1', 'rejected', 'rejected', 100, 0]
Explanation: Rejected is terminal; a later accept returns 'rejected' and no money moves.
Input: ([['create', 'A', 30], ['create', 'B', 0], ['transfer', 'A', 'B', 50], ['accept', 't1'], ['balance', 'A']],)
Expected Output: [True, True, 't1', 'insufficient_funds', 30]
Explanation: Funds checked at accept time: A only has 30, so the transfer of 50 cannot be accepted.
Input: ([['create', 'A', 100], ['create', 'B', 0], ['transfer', 'A', 'B', 40], ['history', 'A']],)
Expected Output: [True, True, 't1', [['transfer_pending', 40, 100]]]
Explanation: Creation records intent on the sender without changing the balance (still 100).
Input: ([['create', 'A', 100], ['create', 'B', 0], ['transfer', 'A', 'B', 40], ['accept', 't1'], ['history', 'B']],)
Expected Output: [True, True, 't1', 'accepted', [['transfer_in', 40, 40]]]
Explanation: On accept the recipient gets a 'transfer_in' record with the new balance 40.
Input: ([['create', 'A', 100], ['transfer', 'A', 'A', 10], ['transfer', 'A', 'Z', 10], ['transfer', 'A', 'A', -5], ['accept', 't1'], ['status', 't1']],)
Expected Output: [True, None, None, None, None, None]
Explanation: Self-transfer, missing recipient, and negative amount all fail at creation, so no 't1' exists for accept/status.
Input: ([['create', 'A', 100], ['create', 'B', 0], ['transfer', 'A', 'B', 30], ['status', 't1'], ['reject', 't1'], ['reject', 't1'], ['status', 't1']],)
Expected Output: [True, True, 't1', 'pending', 'rejected', 'rejected', 'rejected']
Explanation: Status starts pending; reject is idempotent and the status stays rejected.
Hints
- Model the transfer as a tiny state machine: pending -> accepted | rejected; terminal states are returned unchanged.
- Check status BEFORE mutating balances so a repeated accept can't double-credit.
- Re-check the sender's balance at accept time — it may have dropped since creation.