~/systems/bank-system
Bank system
Accounts, payments, effects that land later and balances at any past time. Validate first, drain what's due at the top of every call, and keep a history.
Check every rule before changing a balance. Keep delayed effects (cashback, holds that expire) in a min-heap of (due, seq, ...) and apply everything due at the start of each method. Append (time, balance) on every change and binary search it for past balances.
The multi-level bank OA and its cousins: deposits, transfers, transfers that must be accepted, cashback, account merges, top spenders and balance history.
O(log n) per call, plus effects that come due
O(changes + pending effects)
You’ll recognise it when
- The class is a bank, wallet or ledger: create accounts, deposit, withdraw, transfer, pay.
- Failed operations return
NoneorFalseand must change nothing. - Something happens later: cashback lands after a delay, a pending transfer expires and is refunded.
- A level asks for the balance at an earlier time, the top spenders, or merging two accounts.
It’s a close cousin of OOP design: same habits, but with time-delayed effects and history as the two recurring twists.
The idea
A bank teller’s day has a rule: before serving the next customer, process the inbox. Cashback that came due at 10:05, a hold that expired at 10:07: those are applied first, each with its own timestamp, and only then does the 10:10 customer get served. The teller also keeps a ledger of every balance change with its time, so “what was the balance at 9:30” is a lookup, not a reconstruction.
In code: every method begins by draining a min-heap of pending effects, applying each one that is due by now at its own due time. Then it validates the request completely, and only then changes state. Every change appends (time, balance) to the account’s history, which stays in time order, so past balances are a binary search.
How it works
Bank(cashback_percent, cashback_delay): a payment returns a percentage of its amount as cashback, credited cashback_delay later. Times strictly increase across calls.
- Drain first.
_process_due(now)pops every(due, seq, account, amount)withdue <= now, credits it, and records the new balance inhistoryat timedue, notnow. The heap pops in order ofdue, thenseq(the payment number), so effects due at the same moment apply in a fixed order. - Validate before changing.
pay(t, acc, amount)returnsNoneif the account doesn’t exist or holds less thanamount, and touches nothing. Only then does it subtract and record. - Schedule the effect. The cashback is
amount * percent // 100, rounded down in whole units, and goes on the heap withdue = t + delay. A payment of 0 cashback schedules nothing. - Record every change.
_recordupdates the balance and appends(t, new balance). Opening an account records(t, 0), so a time before the account existed has no entry at all. - Answer the past.
balance_at(t, acc, at)drains first (an effect due beforeatmust be in the history), then finds the last history entry at or beforeatwithbisect_right. No entry means the account didn’t exist yet.
With 10% cashback after 100 time units:
| time | call | result |
|---|---|---|
| 1, 2 | open ann, deposit 500 | balance 500 |
| 3 | pay 300 | p1; cashback 30 due at 103 |
| 4 | pay 250 | refused: only 200 left |
| 103 | balance | 230: the cashback landed at 103 first |
| 150 | pay 230 | p2; cashback 23 due at 250 |
| 160 | balance at 2, at 3, at 103, at 0 | 500, 200, 230, none |
Why it’s correct: effects are applied in due order, and before any operation at or after their due time, so every operation sees the balance it would see if effects were applied by a clock running in the background. Since times only go up and each effect is due after the previous call (otherwise that call would have applied it), recording at the due time keeps history sorted.
import heapq
from bisect import bisect_right
class Bank:
"""Accounts with payments, delayed cashback and balance history.
Every method takes the time first; times strictly increase across calls."""
def __init__(self, cashback_percent, cashback_delay):
self.percent = cashback_percent
self.delay = cashback_delay
self.balance = {} # account -> current balance
self.history = {} # account -> [(time, balance after a change)]
self.pending = [] # heap of (due, seq, account, amount)
self.payments = 0
def _record(self, t, acc, delta):
self.balance[acc] += delta
self.history[acc].append((t, self.balance[acc]))
def _process_due(self, now):
# Runs first in every method: effects due at or before now happen first,
# each at its own due time, so history stays in time order.
while self.pending and self.pending[0][0] <= now:
due, _, acc, amount = heapq.heappop(self.pending)
self._record(due, acc, amount)
def open(self, t, acc):
self._process_due(t)
if acc in self.balance:
return False
self.balance[acc] = 0
self.history[acc] = [(t, 0)]
return True
def deposit(self, t, acc, amount):
self._process_due(t)
if acc not in self.balance:
return None
self._record(t, acc, amount)
return self.balance[acc]
def pay(self, t, acc, amount):
self._process_due(t)
if acc not in self.balance or self.balance[acc] < amount:
return None # validate first, change nothing
self._record(t, acc, -amount)
self.payments += 1
cashback = amount * self.percent // 100 # whole units, rounded down
if cashback > 0:
heapq.heappush(self.pending, (t + self.delay, self.payments, acc, cashback))
return f"p{self.payments}"
def get_balance(self, t, acc):
self._process_due(t)
return self.balance.get(acc)
def balance_at(self, t, acc, at):
self._process_due(t)
entries = self.history.get(acc, [])
i = bisect_right(entries, (at, float("inf"))) - 1 # last change at or before `at`
return entries[i][1] if i >= 0 else None#include <algorithm>
#include <functional>
#include <optional>
#include <queue>
#include <string>
#include <tuple>
#include <unordered_map>
#include <utility>
#include <vector>
using namespace std;
// Accounts with payments, delayed cashback and balance history.
// Every method takes the time first; times strictly increase across calls.
class Bank {
long long percent, delay;
unordered_map<string, long long> balance; // account -> current balance
unordered_map<string, vector<pair<long long, long long>>> history; // account -> (time, balance after)
using Due = tuple<long long, long long, string, long long>; // (due, seq, account, amount)
priority_queue<Due, vector<Due>, greater<>> pending;
long long payments = 0;
void record(long long t, const string& acc, long long delta) {
balance[acc] += delta;
history[acc].push_back({t, balance[acc]});
}
// Runs first in every method: effects due at or before now happen first,
// each at its own due time, so history stays in time order.
void process_due(long long now) {
while (!pending.empty() && get<0>(pending.top()) <= now) {
auto [due, seq, acc, amount] = pending.top();
pending.pop();
record(due, acc, amount);
}
}
public:
Bank(long long cashback_percent, long long cashback_delay)
: percent(cashback_percent), delay(cashback_delay) {}
bool open(long long t, const string& acc) {
process_due(t);
if (balance.count(acc)) return false;
balance[acc] = 0;
history[acc] = {{t, 0}};
return true;
}
optional<long long> deposit(long long t, const string& acc, long long amount) {
process_due(t);
if (!balance.count(acc)) return nullopt;
record(t, acc, amount);
return balance[acc];
}
optional<string> pay(long long t, const string& acc, long long amount) {
process_due(t);
auto it = balance.find(acc);
if (it == balance.end() || it->second < amount) return nullopt; // validate first, change nothing
record(t, acc, -amount);
payments++;
long long cashback = amount * percent / 100; // whole units, rounded down
if (cashback > 0) pending.push({t + delay, payments, acc, cashback});
return "p" + to_string(payments);
}
optional<long long> get_balance(long long t, const string& acc) {
process_due(t);
auto it = balance.find(acc);
if (it == balance.end()) return nullopt;
return it->second;
}
optional<long long> balance_at(long long t, const string& acc, long long at) {
process_due(t);
auto it = history.find(acc);
if (it == history.end()) return nullopt;
auto& entries = it->second;
// the last change at or before `at`
auto pos = upper_bound(entries.begin(), entries.end(), at,
[](long long x, const pair<long long, long long>& e) { return x < e.first; });
if (pos == entries.begin()) return nullopt;
return prev(pos)->second;
}
};import java.util.*;
// Accounts with payments, delayed cashback and balance history.
// Every method takes the time first; times strictly increase across calls.
class Bank {
private record Due(long due, long seq, String account, long amount) {}
private final long percent, delay;
private final Map<String, Long> balance = new HashMap<>(); // account -> current balance
private final Map<String, List<long[]>> history = new HashMap<>(); // account -> {time, balance after}
private final PriorityQueue<Due> pending = new PriorityQueue<>(
Comparator.comparingLong(Due::due).thenComparingLong(Due::seq));
private long payments = 0;
Bank(long cashbackPercent, long cashbackDelay) {
this.percent = cashbackPercent;
this.delay = cashbackDelay;
}
private void record(long t, String acc, long delta) {
long now = balance.merge(acc, delta, Long::sum);
history.get(acc).add(new long[]{t, now});
}
// Runs first in every method: effects due at or before now happen first,
// each at its own due time, so history stays in time order.
private void processDue(long now) {
while (!pending.isEmpty() && pending.peek().due() <= now) {
Due d = pending.poll();
record(d.due(), d.account(), d.amount());
}
}
boolean open(long t, String acc) {
processDue(t);
if (balance.containsKey(acc)) return false;
balance.put(acc, 0L);
history.put(acc, new ArrayList<>(List.of(new long[]{t, 0})));
return true;
}
Long deposit(long t, String acc, long amount) {
processDue(t);
if (!balance.containsKey(acc)) return null;
record(t, acc, amount);
return balance.get(acc);
}
String pay(long t, String acc, long amount) {
processDue(t);
Long current = balance.get(acc);
if (current == null || current < amount) return null; // validate first, change nothing
record(t, acc, -amount);
payments++;
long cashback = amount * percent / 100; // whole units, rounded down
if (cashback > 0) pending.add(new Due(t + delay, payments, acc, cashback));
return "p" + payments;
}
Long getBalance(long t, String acc) {
processDue(t);
return balance.get(acc);
}
Long balanceAt(long t, String acc, long at) {
processDue(t);
List<long[]> entries = history.get(acc);
if (entries == null) return null;
int lo = 0, hi = entries.size(); // the last change at or before `at`
while (lo < hi) {
int mid = (lo + hi) >>> 1;
if (entries.get(mid)[0] <= at) lo = mid + 1;
else hi = mid;
}
return lo == 0 ? null : entries.get(lo - 1)[1];
}
}Merges: move references, not data
Merging account B into A means A’s balance grows by B’s, and everything that pointed at B (pending transfers, scheduled cashback) now belongs to A. Don’t copy and rewrite every record: store the owner on the pending item, or keep a map from merged id to surviving id and look it up when the item is processed. Then decide what B’s history means after the merge (usually: B’s balance up to the merge, and nothing after).
Why it’s O(log n)
Each payment pushes at most one effect, O(log n), and each effect is popped once, O(log n), however many calls pass in between. Validation and recording are O(1). balance_at is a binary search over the account’s history, O(log h). So every call is O(log n) amortized, plus the effects that happen to come due during it. Space is one history entry per change and one heap entry per pending effect.
A “top k spenders” query adds a total per account and a sort with a tie-break, O(a log a) for a accounts, or a heap of size k for O(a log k).
Common mistakes
Applying due effects after the operation
If pay checks the balance before crediting cashback that came due earlier, a payment that should succeed is refused.
if self.balance[acc] < amount: return None # ✗ cashback due earlier not counted yet
self._process_due(t) # ✓ first line of every method
Recording the effect at the current time
Cashback due at 103 but processed by a call at 150 happened at 103. Recording it at 150 makes balance_at(120) wrong.
self._record(now, acc, amount) # ✗ when we noticed it
self._record(due, acc, amount) # ✓ when it happened
Half-applied operations
A transfer that subtracts from the source and then finds the target missing has destroyed money. Every check comes before the first change.
self.balance[src] -= amount
if dst not in self.balance: return None # ✗ src already charged
if src not in self.balance or dst not in self.balance or self.balance[src] < amount:
return None # ✓ all checks, then change both
Floats for money
0.1 + 0.2 isn’t 0.3 in floating point, and percentages of floats round unpredictably. Keep integer amounts in the smallest unit and round explicitly, as the statement says.
int(amount * 0.29) # ✗ 28 for 100: 100 * 0.29 is 28.999999999999996
amount * 29 // 100 # ✓ 29, integer arithmetic
Variations
- Transfers that must be accepted. The money leaves the source as a hold, and the target accepts within a deadline or the hold is refunded. The expiry is another effect on the heap, drained at the top of every call.
- Top spenders. Keep each account’s outgoing total, updated in
payand transfers. Decide whether refunded or failed operations count, and sort by(-total, account_id). - Batches. All-or-nothing: validate the whole batch against a scratch copy of the affected balances, then apply it in one go, or report the first failing item.
- Reusing ids after a merge. If a closed account’s id can be opened again, history lookups must know which account had the id at each time: keep a list of
(from_time, account object)per id. - Interest and fees. Recurring effects (monthly fees) push their next occurrence when they fire.
Climb the ladder
Our bank-system problems in ladder order.
- Accounts with validated deposits and withdrawals: every check before the change.
- Settle a night’s tab all-or-nothing: a batch that applies completely or not at all.
- Bank system: the four-level OA, from transfers to balance history, transfers that must be accepted and merges.
Check yourself
5 quick questions. Pick an answer to see why it's right or wrong.
-
1
An account holds 10, and cashback of 20 is due at time 5. A payment of 25 arrives at time 5. The statement says effects due at time T apply before operations at T. What happens?
Draining due effects is the first thing every method does, so the cashback lands at 5, the balance is 30, and the payment of 25 fits. Checking the balance before draining refuses a payment the rules allow; going negative breaks the main invariant; and nothing in these systems waits.
-
2
Before a cashback of 30, an account’s balance was 200. The cashback was due at 103, but the first call after that came at 150. If the code records the credit at time 150 instead of 103, what does
balance_at(120)return?The cashback happened at 103, so at 120 the balance was 230. Recording it when it was noticed (150) puts it after 120 in the history, and the lookup finds the older entry, 200. Record each effect at its own due time.
-
3
The account was opened at time 1. What does this print?
from bisect import bisect_righthistory = [(1, 0), (2, 500), (3, 200), (103, 230)] # (time, balance after)def at(t):i = bisect_right(history, (t, float("inf"))) - 1return history[i][1] if i >= 0 else Noneprint(at(0), at(2), at(50), at(103))Searching for
(t, inf)lands just after every entry at timetor earlier, so minus one is the latest change at or beforet. At 0 the account didn’t exist yet: no entry, soNonerather than 0. At 50 the latest change was at 3, and at 103 the entry at exactly 103 counts. -
4
Delayed effects sit in a heap of
(due, seq, account, amount)tuples, whereseqis the payment number. Why putseqsecond?Tuples compare field by field. Without
seq, two effects due at the same time would be ordered by account name and amount, which is an accident of the data rather than the order they were created.heapqaccepts equal keys; it just needs a rule to break the tie. -
5
Cashback is 2%, rounded down to a whole cent. What does this print?
amount = 175print(amount * 2 // 100, round(amount * 0.02), int(amount * 0.02))2% of 175 is 3.5. Integer arithmetic,
175 * 2 // 100, rounds down to 3, as the rule says.roundrounds a half to the nearest even number, giving 4.intdrops the fraction here, but on a float like 28.999999999999996 it would also lose a whole unit. Keep money in integers.
Practice problems
Solve these right here, in Python, C++ or Java. Tests run as you go.