Low-Level Design: Bank Account System — Transactions, Overdraft Protection, and Interest Calculation

Core Entities

Customer: customer_id, name, date_of_birth, ssn_hash, address, kyc_status (PENDING, VERIFIED, REJECTED). Account: account_id, customer_id, account_type (CHECKING, SAVINGS, MONEY_MARKET), status (ACTIVE, FROZEN, CLOSED), balance (NUMERIC(18,2)), available_balance (balance minus pending holds), interest_rate, overdraft_limit, currency, opened_at, closed_at. Transaction: txn_id (UUID), account_id, txn_type (DEPOSIT, WITHDRAWAL, TRANSFER_IN, TRANSFER_OUT, FEE, INTEREST, REVERSAL), amount, direction (CREDIT, DEBIT), balance_after, description, reference_id, status (PENDING, POSTED, REVERSED), initiated_at, posted_at. Hold: hold_id, account_id, amount, reason (PENDING_ACH, DEBIT_CARD_AUTH, LEGAL), expires_at.

Account Balance Design

Two balance concepts: ledger_balance (the official posted balance after all settled transactions) and available_balance (ledger_balance minus active holds). Customers can spend up to available_balance, not ledger_balance. A debit card swipe creates a hold (reduces available_balance) immediately but only posts to ledger_balance when the merchant settles (1-3 business days). An ACH transfer is pending for 1-2 business days before posting. Implementation: available_balance = ledger_balance – SUM(holds.amount WHERE holds.expires_at > NOW() AND account_id = :id). For performance: maintain a cached available_balance column, updated atomically on each hold creation/release and transaction posting.

Transaction Atomicity

class AccountService:
    def transfer(self, from_id: int, to_id: int,
                 amount: Decimal, idempotency_key: str) -> tuple:
        # Idempotency check
        if self.txn_repo.exists(idempotency_key):
            return self.txn_repo.get_by_key(idempotency_key)

        with self.db.transaction():
            # Lock both accounts in consistent order to prevent deadlocks
            accounts = self.repo.lock_accounts(
                sorted([from_id, to_id])  # lock by ascending ID
            )
            src = next(a for a in accounts if a.account_id == from_id)
            dst = next(a for a in accounts if a.account_id == to_id)

            if src.available_balance  overdraft_available:
                    raise InsufficientFundsError()
                # Assess overdraft fee if using overdraft protection
                self._charge_overdraft_fee(src)

            # Post debit
            src.balance -= amount
            debit_txn = Transaction(from_id, 'TRANSFER_OUT', amount,
                                    'DEBIT', src.balance, idempotency_key)
            # Post credit
            dst.balance += amount
            credit_txn = Transaction(to_id, 'TRANSFER_IN', amount,
                                     'CREDIT', dst.balance, idempotency_key)

            self.repo.save_all([src, dst, debit_txn, credit_txn])
            return debit_txn, credit_txn

Interest Calculation

Savings and money market accounts earn interest. Two calculation methods: simple interest (rate * balance * days/365) and compound interest (daily compounding: balance * (1 + rate/365)^days). Daily accrual: a batch job runs nightly for all active savings accounts. Computes the daily interest accrual: accrual = balance * daily_rate. Adds the accrual to an interest_accrued accumulator (not yet the balance — avoids tiny daily transactions). Monthly posting: on the last day of the month, post accumulated interest as a CREDIT transaction and reset the accumulator. This produces one readable interest transaction per month rather than 30 tiny daily ones. Rate tiers: higher balances earn higher rates. Store rate_tiers = [(0, 1000, 0.01), (1000, 10000, 0.02), (10000, None, 0.025)] on the account type. Apply the tier matching the balance for each calculation.

Overdraft Protection and Fees

When a transaction would bring the balance below zero: check overdraft_limit. If amount exceeds available_balance + overdraft_limit: reject with InsufficientFundsError. If within limit: allow the transaction, post an OVERDRAFT_FEE ($35 typically), update overdraft_used. Fee waiver rules: first overdraft per calendar year waived for good-standing customers, or waived if the balance is restored within 24 hours (some banks). Monthly overdraft fee cap: max 3 overdraft fees per day, max 10 per month (regulatory). Track fee_count_today and fee_count_month on the account. Linked account protection: if overdraft_protection_source_account_id is set, automatically transfer the shortfall from the linked account instead of charging a fee (e.g., savings → checking).

Asked at: Stripe Interview Guide

Asked at: Coinbase Interview Guide

Asked at: Shopify Interview Guide

Asked at: Airbnb Interview Guide

Scroll to Top