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
@propertywith a setter to enforce validation at the attribute boundary - Using
@classmethodto 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)amountmust be positive. RaiseValueErrorif a non-positive amount is passed.- Transactions are immutable after creation. Do not allow external modification of
amountortransaction_type.
R2 \text{---} Account base class
- Attributes:
account_number(string),owner(string),_balance(float, private \text{---} always initialise to 0.0) balancemust be a read-only@property. There is no setter. Balance changes only throughdepositandwithdraw.deposit(amount, description=""): adds funds, records aTransaction, returns the new balance. RaisesValueErrorifamount <= 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 butcreateis the public API.
R3 - CheckingAccount subclass
- Inherits from
Account. - Additional attribute:
overdraft_limit(float, default500.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, raiseInsufficientFundsError(a custom exception you define). Records the transaction only if successful.apply_monthly_fee(fee=15.0): deducts a monthly service fee and records aTransactionof 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.045for 4.5%),minimum_balance(float, default100.0). withdraw(amount, description=""): raisesInsufficientFundsErrorif the withdrawal would leave the balance belowminimum_balance. No overdraft is permitted on savings accounts.apply_monthly_interest(): calculates one month of interest (balance * interest_rate / 12), adds it viadeposit, records aTransactionof type"interest". Only applies if the current balance is aboveminimum_balance.get_interest_projection(months): returns a list of projected balance values for the nextmonthsmonths, 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 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
| Concept | Where it appears |
|---|---|
| Inheritance | CheckingAccount(Account), SavingsAccount(Account) |
| Method overriding | withdraw() on both subclasses |
@property (read-only) | balance on Account |
@classmethod | Account.create() and inherited by subclasses |
__repr__ | All three main classes and Transaction |
| Encapsulation | _balance, _history are private; only modified through methods |
| Custom exceptions | InsufficientFundsError with custom __str__ |
| Class attributes | _next_account_number shared counter |
| Polymorphism | withdraw() 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.
