Skip to main content

Project 01 - Banking System Simulator

Estimated time: 3–5 hours core | Level: Intermediate

Before reading the requirements, consider this question: if CheckingAccount and SavingsAccount are both accounts, what behaviour belongs on the base Account class and what belongs on the subclass? The answer to that question is the entire design challenge of this project.

Learning Objectives

By the time this project is complete, you will have practiced:

  • Designing a meaningful inheritance hierarchy (not just syntactic inheritance)
  • Using @property with a setter to enforce validation at the attribute boundary
  • Using @classmethod to build named constructors (alternate constructors)
  • Implementing __repr__ that is useful for debugging
  • Enforcing encapsulation - balance is read via @property, never written directly
  • Implementing polymorphism through method overriding (deposit, withdraw)
  • Tracking state through a history list without exposing mutation externally

System Overview

You are building a simplified banking back-end. The system handles two account types, records every transaction, enforces business rules (minimum balance, overdraft limits), and calculates interest. No UI is required - the deliverable is a set of classes with a __main__ demonstration block.

Requirements

R1 - Transaction class

  • Stores: transaction_type (string: "deposit", "withdrawal", "interest", "fee"), amount (float), timestamp (datetime, auto-set on creation), description (string, optional)
  • __repr__ must return a string in the format: Transaction(deposit, $250.00, 2024-03-15 09:30:00)
  • amount must be positive. Raise ValueError if a non-positive amount is passed.
  • Transactions are immutable after creation. Do not allow external modification of amount or transaction_type.

R2 \text{---} Account base class

  • Attributes: account_number (string), owner (string), _balance (float, private \text{---} always initialise to 0.0)
  • balance must be a read-only @property. There is no setter. Balance changes only through deposit and withdraw.
  • deposit(amount, description=""): adds funds, records a Transaction, returns the new balance. Raises ValueError if amount <= 0.
  • withdraw(amount, description=""): abstract behaviour \text{---} subclasses override this with their own rules.
  • get_history(): returns a copy of the transaction list (not the list itself \text{---} callers must not be able to mutate history by modifying the returned list).
  • __repr__: Account(#ACC001, owner=Alice, balance=$1500.00)
  • @classmethod create(cls, owner, account_number=None): creates an account, auto-generating a numeric account number if none is provided (e.g., ACC0001, ACC0002, incrementing). This is the intended constructor - __init__ may still be used directly but create is the public API.

R3 - CheckingAccount subclass

  • Inherits from Account.
  • Additional attribute: overdraft_limit (float, default 500.0). This is the maximum amount the account can go negative.
  • withdraw(amount, description=""): allows the balance to go negative down to -overdraft_limit. If the withdrawal would exceed the overdraft limit, raise InsufficientFundsError (a custom exception you define). Records the transaction only if successful.
  • apply_monthly_fee(fee=15.0): deducts a monthly service fee and records a Transaction of type "fee". Does not trigger overdraft - the fee is always deducted even if it takes the account below zero (but not below -(overdraft_limit + fee), which would be an error condition you can choose to handle or document).

R4 - SavingsAccount subclass

  • Inherits from Account.
  • Additional attributes: interest_rate (float, annual rate as a decimal, e.g., 0.045 for 4.5%), minimum_balance (float, default 100.0).
  • withdraw(amount, description=""): raises InsufficientFundsError if the withdrawal would leave the balance below minimum_balance. No overdraft is permitted on savings accounts.
  • apply_monthly_interest(): calculates one month of interest (balance * interest_rate / 12), adds it via deposit, records a Transaction of type "interest". Only applies if the current balance is above minimum_balance.
  • get_interest_projection(months): returns a list of projected balance values for the next months months, assuming no deposits or withdrawals. Does not modify the account - pure calculation only.

R5 - InsufficientFundsError

A custom exception that carries the attempted withdrawal amount and the available amount (balance minus limits). Its __str__ should produce a human-readable message: InsufficientFundsError: attempted $600.00, available $400.00.

R6 - Demonstration block

Your __main__ block must produce the exact expected output shown below. Implement it last, once all classes are working.

Starter Code Skeleton

from datetime import datetime
from typing import Optional


# ── Custom Exceptions ─────────────────────────────────────────────────────────

class InsufficientFundsError(Exception):
def __init__(self, attempted: float, available: float):
self.attempted = attempted
self.available = available

def __str__(self):
# TODO: return human-readable message
pass


# ── Transaction ───────────────────────────────────────────────────────────────

class Transaction:
VALID_TYPES = {"deposit", "withdrawal", "interest", "fee"}

def __init__(self, transaction_type: str, amount: float, description: str = ""):
# TODO: validate transaction_type is in VALID_TYPES
# TODO: validate amount > 0
# TODO: store transaction_type, amount, description
# TODO: store timestamp = datetime.now()
pass

def __repr__(self):
# TODO: return "Transaction(deposit, $250.00, 2024-03-15 09:30:00)"
pass


# ── Account Base Class ────────────────────────────────────────────────────────

class Account:
_next_account_number = 1 # class-level counter for auto-generation

def __init__(self, owner: str, account_number: str):
# TODO: store owner and account_number
# TODO: initialise _balance to 0.0
# TODO: initialise _history as empty list
pass

@property
def balance(self) -> float:
# TODO: return _balance
pass

@classmethod
def create(cls, owner: str, account_number: Optional[str] = None):
# TODO: if account_number is None, auto-generate one using _next_account_number
# TODO: increment _next_account_number
# TODO: return cls(owner, account_number)
pass

def deposit(self, amount: float, description: str = "") -> float:
# TODO: validate amount > 0
# TODO: add amount to _balance
# TODO: create and append a Transaction
# TODO: return new balance
pass

def withdraw(self, amount: float, description: str = "") -> float:
# Subclasses must implement their own withdrawal logic
raise NotImplementedError("Subclasses must implement withdraw()")

def get_history(self) -> list:
# TODO: return a COPY of _history
pass

def __repr__(self):
# TODO: return "Account(#ACC001, owner=Alice, balance=$1500.00)"
pass


# ── CheckingAccount ───────────────────────────────────────────────────────────

class CheckingAccount(Account):
def __init__(self, owner: str, account_number: str, overdraft_limit: float = 500.0):
# TODO: call super().__init__
# TODO: store overdraft_limit
pass

def withdraw(self, amount: float, description: str = "") -> float:
# TODO: check if withdrawal would exceed overdraft limit
# TODO: raise InsufficientFundsError if it would
# TODO: deduct amount from _balance
# TODO: record Transaction of type "withdrawal"
# TODO: return new balance
pass

def apply_monthly_fee(self, fee: float = 15.0) -> float:
# TODO: deduct fee from _balance
# TODO: record Transaction of type "fee"
# TODO: return new balance
pass

def __repr__(self):
# TODO: extend the base repr to include overdraft_limit
# e.g. "CheckingAccount(#ACC001, owner=Alice, balance=$1500.00, overdraft=$500.00)"
pass


# ── SavingsAccount ────────────────────────────────────────────────────────────

class SavingsAccount(Account):
def __init__(
self,
owner: str,
account_number: str,
interest_rate: float = 0.045,
minimum_balance: float = 100.0,
):
# TODO: call super().__init__
# TODO: store interest_rate and minimum_balance
pass

def withdraw(self, amount: float, description: str = "") -> float:
# TODO: check if withdrawal would leave balance below minimum_balance
# TODO: raise InsufficientFundsError if it would
# TODO: deduct amount, record Transaction, return new balance
pass

def apply_monthly_interest(self) -> float:
# TODO: only apply if balance > minimum_balance
# TODO: calculate monthly_interest = balance * interest_rate / 12
# TODO: call self.deposit() with the interest amount, type "interest"
# TODO: return new balance
pass

def get_interest_projection(self, months: int) -> list[float]:
# TODO: do NOT modify the account
# TODO: calculate compound growth for `months` periods
# TODO: return list of length `months` with balance after each month
pass

def __repr__(self):
# TODO: e.g. "SavingsAccount(#ACC002, owner=Bob, balance=$5000.00, rate=4.5\%)"
pass


# ── Main Demonstration ────────────────────────────────────────────────────────

if __name__ == "__main__":
# Create accounts using the classmethod factory
checking = CheckingAccount.create("Alice", overdraft_limit=300.0)
savings = SavingsAccount.create("Bob", interest_rate=0.06, minimum_balance=200.0)

# Fund the accounts
checking.deposit(1000.0, "Opening deposit")
savings.deposit(5000.0, "Opening deposit")

# Checking operations
checking.withdraw(200.0, "Groceries")
checking.withdraw(900.0, "Rent") # goes into overdraft
checking.apply_monthly_fee()

# Savings operations
savings.withdraw(500.0, "Holiday fund")
savings.apply_monthly_interest()

# Print account states
print(checking)
print(savings)

# Print transaction histories
print("\n--- Checking History ---")
for tx in checking.get_history():
print(tx)

print("\n--- Savings History ---")
for tx in savings.get_history():
print(tx)

# Interest projection
print("\n--- 6-Month Interest Projection ---")
for i, projected in enumerate(savings.get_interest_projection(6), 1):
print(f"Month {i}: ${projected:.2f}")

# Test InsufficientFundsError
print("\n--- Overdraft Test ---")
try:
checking.withdraw(500.0, "Exceeds overdraft")
except InsufficientFundsError as e:
print(e)

Expected Output

CheckingAccount(#ACC0001, owner=Alice, balance=$-115.00, overdraft=$300.00)
SavingsAccount(#ACC0002, owner=Bob, balance=$4525.00, rate=6.0\%)

--- Checking History ---
Transaction(deposit, $1000.00, ...)
Transaction(withdrawal, $200.00, ...)
Transaction(withdrawal, $900.00, ...)
Transaction(fee, $15.00, ...)

--- Savings History ---
Transaction(deposit, $5000.00, ...)
Transaction(withdrawal, $500.00, ...)
Transaction(interest, $22.50, ...)

--- 6-Month Interest Projection ---
Month 1: $4547.62
Month 2: $4570.36
Month 3: $4593.21
Month 4: $4616.18
Month 5: $4639.27
Month 6: $4662.47

--- Overdraft Test ---
InsufficientFundsError: attempted $500.00, available $185.00

Note: timestamps in Transaction.__repr__ will differ when you run the code. The ... in the expected output represents any valid datetime string. Everything else must match exactly.

Step-by-Step Hints

Hint 1 - Start with Transaction. It has no dependencies on other classes and you can test it in isolation. Get __repr__ working and confirm it formats amounts with exactly two decimal places before moving on.

Hint 2 - Account._next_account_number is a class attribute, not an instance attribute. When create() auto-generates an account number, it reads cls._next_account_number, formats it as f"ACC{cls._next_account_number:04d}", then increments cls._next_account_number. Because it is a class attribute, the counter is shared across all accounts and all subclasses.

Hint 3 - balance is read-only for a reason. The setter is intentionally absent. This means the only legal way to change the balance is through deposit and withdraw. Any code path that modifies _balance directly inside the class is fine - but the public interface exposes no write access. If you find yourself writing account.balance = 500 in test code, that is a design violation.

Hint 4 - get_history() must return a copy. return self._history[:] or return list(self._history) are both correct. If you return self._history directly, the caller can do account.get_history().append(fake_transaction) which is a history integrity violation. This does not matter for the demo block, but it matters for correctness.

Hint 5 - Inheritance means calling super().__init__. CheckingAccount.__init__ must call super().__init__(owner, account_number) before setting self.overdraft_limit. If you forget, _balance and _history will not exist when deposit is called.

Hint 6 - InsufficientFundsError.available for CheckingAccount. Available funds for a checking account = self._balance + self.overdraft_limit. If balance is 115andoverdraftlimitis-115 and overdraft limit is 300, available is $185. This is the value that goes into the error message.

Hint 7 - apply_monthly_interest should use self.deposit(). Do not write self._balance += interest directly. Call self.deposit(interest, "Monthly interest") so the transaction is recorded automatically. You will need to pass transaction_type="interest" - consider whether deposit needs a transaction_type parameter or whether this needs a separate internal method.

Hint 8 - get_interest_projection must not touch _balance. This is a pure calculation. Start with projected = self._balance and apply the monthly rate in a loop, appending to a list. Return the list. The account state must be identical before and after this method runs.

OOP Concepts Tested

ConceptWhere it appears
InheritanceCheckingAccount(Account), SavingsAccount(Account)
Method overridingwithdraw() on both subclasses
@property (read-only)balance on Account
@classmethodAccount.create() and inherited by subclasses
__repr__All three main classes and Transaction
Encapsulation_balance, _history are private; only modified through methods
Custom exceptionsInsufficientFundsError with custom __str__
Class attributes_next_account_number shared counter
Polymorphismwithdraw() behaves differently per subclass

Extension Challenges

These challenges are intentionally harder than the core requirements. They cover concepts from later lessons.

Extension 1 - Transfer method Add Account.transfer(amount, target_account). It should withdraw from self and deposit into target_account atomically - if the withdrawal fails (InsufficientFundsError), the deposit must not happen. Both transactions should reference the same transfer ID in their descriptions.

Extension 2 - Statement generation Add Account.generate_statement(start_date, end_date) that returns a formatted string listing all transactions in that date range, with a running balance column. The output format should resemble a real bank statement.

Extension 3 - Account freezing Add Account.freeze() and Account.unfreeze() methods. A frozen account raises AccountFrozenError on any deposit or withdrawal attempt. The freeze state is not directly readable from outside (no public is_frozen attribute - access goes through a method or property).

Extension 4 - Interest compounding options Modify SavingsAccount to support daily, monthly, or annual compounding via a compounding parameter. Update get_interest_projection to reflect the correct formula for each compounding type. Use the Enum class to represent compounding options cleanly.

Extension 5 - Serialisation Add Account.to_dict() and Account.from_dict(data) (a classmethod). The round-trip must preserve all transaction history including timestamps. Use this to implement a save_to_file(path) and load_from_file(path) pair using json from the standard library.

© 2026 EngineersOfAI. All rights reserved.