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).
{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “What is the difference between ledger balance and available balance in a bank account?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Ledger balance (or current balance) is the official posted balance after all settled transactions. It reflects only transactions that have fully cleared through the banking system. Available balance is the amount the customer can actually spend: ledger_balance minus any holds (pending authorizations). When you swipe a debit card, the merchant sends an authorization that creates a hold, immediately reducing available_balance. Your ledger_balance doesn’t decrease until the merchant settles the transaction 1-3 days later. When a check is deposited, the ledger_balance may increase immediately, but available_balance may stay the same for 1-2 business days (funds hold while the check clears). Banks are legally required to disclose both balances. Overdraft fees are based on available_balance, not ledger_balance.”
}
},
{
“@type”: “Question”,
“name”: “How does overdraft protection work and how do you implement the fee logic?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “When a transaction would reduce available_balance below zero: check if overdraft_limit > 0 (the account has overdraft protection enabled). If available_balance + overdraft_limit >= transaction_amount: allow the transaction, update overdraft_used = overdraft_used + shortfall, post an OVERDRAFT_FEE transaction (e.g., $35). If not: reject with InsufficientFundsError. Fee limits (regulatory): many US banks cap overdraft fees at 3-5 per day and 10-12 per month (CFPB guidance). Check fee_count_today and fee_count_month before posting. Linked account protection: if savings account is linked, auto-transfer the shortfall from savings to checking instead of charging a fee — better UX, cheaper for customers. Track each overdraft event for regulatory reporting. The CFPB requires clear overdraft fee disclosures.”
}
},
{
“@type”: “Question”,
“name”: “How do you calculate and post monthly interest for savings accounts?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Daily accrual prevents large once-monthly transactions and gives accurate pro-rated interest for partial months. Nightly batch job: for each ACTIVE savings/money market account: daily_rate = annual_rate / 365. accrual = balance * daily_rate. Add accrual to interest_accrued_mtd (month-to-date accumulator). Do NOT post a transaction yet — tiny daily transactions clutter the statement. Monthly posting (last calendar day): if interest_accrued_mtd > minimum_posting_threshold (e.g., $0.01): post a CREDIT transaction for interest_accrued_mtd. Reset interest_accrued_mtd to 0. The balance for interest calculation: use the average daily balance (ADB) method for accuracy. ADB = sum of daily balances / days_in_month. Calculate the accrual based on ADB rather than current balance to prevent gaming (depositing large amounts just before month end).”
}
},
{
“@type”: “Question”,
“name”: “How do you design the transaction history to support pagination and filtering?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Transaction history is a common customer-facing feature. Requirements: display most recent transactions first, filter by date range, transaction type, or amount range. Pagination: keyset pagination (cursor-based) is better than offset pagination for transaction history. Cursor = (posted_at, txn_id). Query: SELECT * FROM transactions WHERE account_id = :id AND (posted_at, txn_id) < (:cursor_date, :cursor_id) ORDER BY posted_at DESC, txn_id DESC LIMIT 25. The (account_id, posted_at DESC, txn_id DESC) composite index supports this pattern efficiently. For statement generation (monthly PDF): query all transactions in a date range, compute opening balance (balance from last statement), running balance per transaction (balance_after stored on each row). Sort ascending for statement order. Store generated statements as S3 PDF files; cache for 24 hours to avoid regeneration."
}
},
{
"@type": "Question",
"name": "How do you handle ACH transfers with multi-day settlement?",
"acceptedAnswer": {
"@type": "Answer",
"text": "ACH (Automated Clearing House) transfers typically settle in 1-2 business days. On ACH initiation: create a pending TRANSFER_OUT transaction (status=PENDING). Reduce available_balance by the amount (hold). The ledger_balance is unchanged until settlement. The ACH batch file is submitted to the Federal Reserve (or ACH processor) by the cut-off time (e.g., 3pm EST). On settlement confirmation (next business day): update the transaction status to POSTED, reduce ledger_balance. Release the hold. On ACH return (rejection — invalid account, insufficient funds): update status to RETURNED, release the hold, restore available_balance, post a RETURN_FEE if applicable, notify the customer. Nacha rules define return codes (R01 = insufficient funds, R02 = account closed). Track return rates per originator — high return rates trigger ACH debit blocks."
}
}
]
}
Asked at: Stripe Interview Guide
Asked at: Coinbase Interview Guide
Asked at: Shopify Interview Guide
Asked at: Airbnb Interview Guide