Design a bank with scheduled payments and merges
Company: HubSpot
Role: Software Engineer
Category: Coding & Algorithms
Difficulty: Medium
Interview Round: Take-home Project
Quick Answer: This question evaluates data-structure and algorithm design competencies with emphasis on concurrency, time-based scheduling, aggregation (TopK), transactional consistency, idempotency, and merge semantics for an in-memory banking service, falling under the Coding & Algorithms and system-design domain.
In-Memory Bank — Level 1: Accounts, Deposits, Transfers
Constraints
- accountId values are strings (or any hashable id).
- Balances and amounts are non-negative integers for valid operations.
- A deposit or transfer with amount <= 0 is rejected.
- Transfers to the same account (srcId == dstId) are rejected.
- Total outgoing is incremented on the source only on a successful transfer.
Examples
Input: ([['CREATE', 'a'], ['CREATE', 'b'], ['DEPOSIT', 'a', 100], ['TRANSFER', 'a', 'b', 40], ['TRANSFER', 'a', 'b', 200], ['CREATE', 'a']],)
Expected Output: [True, True, 100, True, False, False]
Explanation: Create a,b succeed. Deposit 100 into a returns new balance 100. Transfer 40 a->b succeeds. Transfer 200 fails (a only has 60). Re-creating 'a' fails (already exists).
Input: ([['DEPOSIT', 'x', 50], ['CREATE', 'x'], ['DEPOSIT', 'x', -5], ['DEPOSIT', 'x', 30], ['TRANSFER', 'x', 'x', 10]],)
Expected Output: [None, True, None, 30, False]
Explanation: Deposit before create returns null. Create x succeeds. Negative deposit returns null. Deposit 30 returns balance 30. Self-transfer is rejected (false).
Input: ([],)
Expected Output: []
Explanation: Empty operation list yields an empty result array.
Hints
- Keep two hash maps: one for balance, one for cumulative outgoing per account.
- Validate every precondition (account existence, positive amount, sufficient balance, src != dst) before mutating state.
- DEPOSIT returns the new balance; CREATE/TRANSFER return booleans; invalid DEPOSIT returns null.
In-Memory Bank — Level 2: Top-K by Outgoing Total
Constraints
- All Level 1 constraints apply.
- TOPK ordering is by outgoing total descending, then account id ascending.
- k <= 0 returns an empty list; k larger than account count returns all accounts.
Examples
Input: ([['CREATE', 'a'], ['CREATE', 'b'], ['CREATE', 'c'], ['DEPOSIT', 'a', 100], ['DEPOSIT', 'b', 100], ['TRANSFER', 'a', 'c', 60], ['TRANSFER', 'b', 'c', 60], ['TRANSFER', 'a', 'b', 10], ['TOPK', 2]],)
Expected Output: [True, True, True, 100, 100, True, True, True, ['a', 'b']]
Explanation: a sends 60+10=70 out, b sends 60 out, c sends 0. Top 2 by outgoing: a (70) then b (60).
Input: ([['CREATE', 'a'], ['CREATE', 'b'], ['TOPK', 5], ['TOPK', 0]],)
Expected Output: [True, True, ['a', 'b'], []]
Explanation: Both accounts have outgoing 0, so the tie-break (id ascending) orders them a,b. k=5 returns all; k=0 returns empty.
Input: ([['CREATE', 'z'], ['CREATE', 'a'], ['DEPOSIT', 'z', 50], ['DEPOSIT', 'a', 50], ['TRANSFER', 'z', 'a', 20], ['TRANSFER', 'a', 'z', 20], ['TOPK', 3]],)
Expected Output: [True, True, 50, 50, True, True, ['a', 'z']]
Explanation: Both z and a have outgoing 20 — a tie. Tie-break by id ascending puts 'a' before 'z'.
Hints
- A successful transfer is the only event that changes an outgoing total.
- For the tie-break, sort by a composite key (-totalOut, accountId).
- If TOPK is called far more often than transfers, maintain a sorted multiset/BST keyed by (totalOut, id) and update it on each transfer instead of sorting on demand.
In-Memory Bank — Level 3: Scheduled Payments on a Timer
Constraints
- Clock is monotonic: TICK never moves time backward (use max(now, given)).
- A due payment executes at most once; if skipped (insufficient/missing/self) it is consumed, never retried.
- Execution preconditions are re-evaluated at TICK time, not at SCHEDULE time.
- Due ordering: (dueTime ascending, then schedule insertion order).
Examples
Input: ([['CREATE', 'a'], ['CREATE', 'b'], ['DEPOSIT', 'a', 100], ['SCHEDULE', 'a', 'b', 30, 1000], ['TICK', 500], ['TICK', 1000], ['TOPK', 1]],)
Expected Output: [True, True, 100, True, [], [True], ['a']]
Explanation: Payment due at 1000. TICK 500 is before due time, so nothing runs ([]). TICK 1000 executes it (a has 100 >= 30) -> [True]. a now has outgoing 30, leading TOPK.
Input: ([['CREATE', 'a'], ['CREATE', 'b'], ['DEPOSIT', 'a', 20], ['SCHEDULE', 'a', 'b', 50, 100], ['SCHEDULE', 'a', 'b', 10, 100], ['TICK', 100]],)
Expected Output: [True, True, 20, True, True, [False, True]]
Explanation: Both due at 100. The first (amount 50) runs first by insertion order but a only has 20 -> skipped (False). The second (amount 10) then succeeds (True). Skip-not-retry: the 50 payment is consumed.
Input: ([['CREATE', 'a'], ['CREATE', 'b'], ['DEPOSIT', 'a', 100], ['SCHEDULE', 'a', 'a', 10, 50], ['SCHEDULE', 'a', 'b', -5, 50], ['SCHEDULE', 'x', 'b', 5, 50], ['TICK', 50]],)
Expected Output: [True, True, 100, False, False, False, []]
Explanation: All three SCHEDULE calls are rejected at registration: self-payment (a->a), non-positive amount (-5), and missing source account (x). Nothing is queued, so TICK 50 runs nothing ([]).
Input: ([['CREATE', 'a'], ['CREATE', 'b'], ['DEPOSIT', 'a', 100], ['SCHEDULE', 'a', 'b', 40, 200], ['TICK', 100], ['SCHEDULE', 'a', 'b', 40, 150], ['TICK', 250]],)
Expected Output: [True, True, 100, True, [], True, [True, True]]
Explanation: First payment due 200 not yet due at TICK 100 ([]). A second payment due 150 is scheduled. TICK 250 drains both (due 150 then due 200): a has 100, pays 40 then 40, both succeed. Flags returned in schedule (insertion) order -> [True, True].
Hints
- Store pending payments in a min-heap keyed by (dueTime, insertionSeq) so the earliest-due payment is always on top.
- On TICK, pop while the top's dueTime <= now; that drains exactly the payments that became due.
- Re-check balance/existence at execution time — a payment that was valid when scheduled can still be skipped if the balance dropped.
- Return the per-payment executed/skipped flags in original schedule order, not heap-pop order.
In-Memory Bank — Level 4: Merging Accounts
Constraints
- MERGE keeps id aId; bId is removed. Balances and outgoing totals are summed.
- MERGE fails if either account is missing or aId == bId.
- Pending payments referencing bId are redirected to aId and resolved at execution time.
- A payment whose resolved source equals its resolved destination is skipped at execution.
- All Level 1-3 rules still hold (skip-not-retry, monotonic clock, tie-broken TopK).
Examples
Input: ([['CREATE', 'a'], ['CREATE', 'b'], ['DEPOSIT', 'a', 100], ['DEPOSIT', 'b', 50], ['TRANSFER', 'a', 'b', 30], ['TRANSFER', 'b', 'a', 10], ['MERGE', 'a', 'b'], ['TOPK', 5]],)
Expected Output: [True, True, 100, 50, True, True, True, ['a']]
Explanation: Before merge: out[a]=30, out[b]=10. MERGE folds b into a: balance 70+? and out[a]=30+10=40, b removed. Only account 'a' remains, so TOPK returns ['a'].
Input: ([['CREATE', 'a'], ['CREATE', 'b'], ['CREATE', 'c'], ['DEPOSIT', 'b', 100], ['SCHEDULE', 'b', 'c', 40, 100], ['MERGE', 'a', 'b'], ['TICK', 100], ['TOPK', 5]],)
Expected Output: [True, True, True, 100, True, True, [True], ['a', 'c']]
Explanation: b is scheduled to pay c, then b merges into a (b's 100 balance moves to a). At TICK 100 the payment's source b re-points to a: a (100) pays 40 to c, out[a]=40. TOPK: a (40) before c (0).
Input: ([['CREATE', 'a'], ['CREATE', 'b'], ['MERGE', 'a', 'a'], ['MERGE', 'a', 'x'], ['MERGE', 'a', 'b'], ['DEPOSIT', 'b', 10]],)
Expected Output: [True, True, False, False, True, None]
Explanation: MERGE a,a fails (same id). MERGE a,x fails (x missing). MERGE a,b succeeds and removes b. A later DEPOSIT to the now-gone 'b' returns null.
Input: ([['CREATE', 'a'], ['CREATE', 'b'], ['CREATE', 'c'], ['DEPOSIT', 'c', 100], ['SCHEDULE', 'c', 'b', 40, 100], ['MERGE', 'a', 'b'], ['TICK', 100], ['TOPK', 3]],)
Expected Output: [True, True, True, 100, True, True, [True], ['c', 'a']]
Explanation: Payment c->b is scheduled, then b merges into a. At TICK 100 the destination b re-points to a: c (100) pays 40 to a, out[c]=40. TOPK: c (40) before a (0).
Hints
- Don't scan the heap on MERGE — record merged[b] = a and resolve endpoints when the payment finally executes.
- Combine both balance and outgoing total of b into a, then delete b's entries.
- Follow the forwarding chain (like union-find find) so a payment scheduled before several merges still lands on the surviving account.
- Guard execution with src != dst after remapping: two endpoints can collapse into the same account post-merge.