~/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.

what

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.

use when

The multi-level bank OA and its cousins: deposits, transfers, transfers that must be accepted, cashback, account merges, top spenders and balance history.

time

O(log n) per call, plus effects that come due

space

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 None or False and 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.

  1. Drain first. _process_due(now) pops every (due, seq, account, amount) with due <= now, credits it, and records the new balance in history at time due, not now. The heap pops in order of due, then seq (the payment number), so effects due at the same moment apply in a fixed order.
  2. Validate before changing. pay(t, acc, amount) returns None if the account doesn’t exist or holds less than amount, and touches nothing. Only then does it subtract and record.
  3. Schedule the effect. The cashback is amount * percent // 100, rounded down in whole units, and goes on the heap with due = t + delay. A payment of 0 cashback schedules nothing.
  4. Record every change. _record updates 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.
  5. Answer the past. balance_at(t, acc, at) drains first (an effect due before at must be in the history), then finds the last history entry at or before at with bisect_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 pay and 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.

  1. Accounts with validated deposits and withdrawals: every check before the change.
  2. Settle a night’s tab all-or-nothing: a batch that applies completely or not at all.
  3. 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. 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?

  2. 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?

  3. 3

    The account was opened at time 1. What does this print?

    from bisect import bisect_right
    history = [(1, 0), (2, 500), (3, 200), (103, 230)] # (time, balance after)
    def at(t):
    i = bisect_right(history, (t, float("inf"))) - 1
    return history[i][1] if i >= 0 else None
    print(at(0), at(2), at(50), at(103))
  4. 4

    Delayed effects sit in a heap of (due, seq, account, amount) tuples, where seq is the payment number. Why put seq second?

  5. 5

    Cashback is 2%, rounded down to a whole cent. What does this print?

    amount = 175
    print(amount * 2 // 100, round(amount * 0.02), int(amount * 0.02))

Practice problems

Solve these right here, in Python, C++ or Java. Tests run as you go.

Further reading

esc