Skip to main content

Lab 05 — Bank Account System

Hands-on Lab 60 min Intermediate

Objectives

  1. Design a class hierarchy with abstract base class
  2. Implement properties with validation
  3. Override dunder methods (__str__, __repr__, __eq__, __lt__)
  4. Use super() properly in child classes
  5. Apply @dataclass for simple records

Step 1 — Create the Project

mkdir ~/python-course/lab-05 && cd ~/python-course/lab-05

Create bank.py:

#!/usr/bin/env python3
"""
Lab 05 — Bank Account System (OOP)
"""

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from functools import total_ordering


@dataclass
class Transaction:
"""Represents a single account transaction."""
amount: float
type: str
timestamp: str = field(default_factory=lambda: datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
description: str = ""

def __str__(self):
sign = "+" if self.amount >= 0 else ""
return f"[{self.timestamp}] {self.type:<10} {sign}{self.amount:>10.2f} {self.description}"


@total_ordering
class Account(ABC):
"""Abstract base class for all account types."""

_next_id = 1000

def __init__(self, owner: str, initial_balance: float = 0.0):
self._id = Account._next_id
Account._next_id += 1
self._owner = owner
self._balance = 0.0
self._transactions: list[Transaction] = []

if initial_balance > 0:
self._deposit_raw(initial_balance, "Initial deposit")

def _deposit_raw(self, amount: float, description=""):
self._balance += amount
self._transactions.append(
Transaction(amount, "DEPOSIT", description=description)
)

def _withdraw_raw(self, amount: float, description=""):
self._balance -= amount
self._transactions.append(
Transaction(-amount, "WITHDRAW", description=description)
)

@property
def balance(self) -> float:
return self._balance

@property
def owner(self) -> str:
return self._owner

@property
def account_id(self) -> int:
return self._id

@abstractmethod
def deposit(self, amount: float, description: str = "") -> float:
...

@abstractmethod
def withdraw(self, amount: float, description: str = "") -> float:
...

@abstractmethod
def account_type(self) -> str:
...

def transfer_to(self, target: "Account", amount: float) -> bool:
"""Transfer money to another account."""
try:
self.withdraw(amount, f"Transfer to #{target.account_id}")
target.deposit(amount, f"Transfer from #{self.account_id}")
return True
except ValueError:
return False

def statement(self):
"""Print account statement."""
print(f"\n{'='*60}")
print(f" {self.account_type()}{self._owner} (#{self._id})")
print(f"{'='*60}")
for tx in self._transactions:
print(f" {tx}")
print(f"{'─'*60}")
print(f" {'Current Balance':>30}: ${self._balance:>12,.2f}")
print()

def __repr__(self):
return f"{self.__class__.__name__}(id={self._id}, owner='{self._owner}', balance={self._balance:.2f})"

def __str__(self):
return f"{self.account_type()} #{self._id} ({self._owner}) — ${self._balance:,.2f}"

def __eq__(self, other):
if not isinstance(other, Account):
return NotImplemented
return self._id == other._id

def __lt__(self, other):
if not isinstance(other, Account):
return NotImplemented
return self._balance < other._balance


class CheckingAccount(Account):
"""Checking account with overdraft protection."""

def __init__(self, owner, initial_balance=0.0, overdraft_limit=500.0):
super().__init__(owner, initial_balance)
self._overdraft_limit = overdraft_limit

def account_type(self):
return "Checking Account"

def deposit(self, amount, description=""):
if amount <= 0:
raise ValueError("Deposit must be positive")
self._deposit_raw(amount, description)
return self._balance

def withdraw(self, amount, description=""):
if amount <= 0:
raise ValueError("Withdrawal must be positive")
if self._balance - amount < -self._overdraft_limit:
raise ValueError(f"Exceeds overdraft limit of ${self._overdraft_limit:.2f}")
self._withdraw_raw(amount, description)
return self._balance


class SavingsAccount(Account):
"""Savings account with interest and withdrawal limits."""

def __init__(self, owner, initial_balance=0.0, interest_rate=0.02):
super().__init__(owner, initial_balance)
self._interest_rate = interest_rate
self._withdrawals_this_month = 0
self._max_monthly_withdrawals = 6

def account_type(self):
return "Savings Account"

def deposit(self, amount, description=""):
if amount <= 0:
raise ValueError("Deposit must be positive")
self._deposit_raw(amount, description)
return self._balance

def withdraw(self, amount, description=""):
if amount <= 0:
raise ValueError("Withdrawal must be positive")
if amount > self._balance:
raise ValueError("Insufficient funds")
if self._withdrawals_this_month >= self._max_monthly_withdrawals:
raise ValueError("Monthly withdrawal limit reached")
self._withdraw_raw(amount, description)
self._withdrawals_this_month += 1
return self._balance

def apply_interest(self):
interest = round(self._balance * self._interest_rate, 2)
self._deposit_raw(interest, f"Interest ({self._interest_rate:.1%})")
return interest


def demo():
print("🏦 Python National Bank — Lab 05\n")

checking = CheckingAccount("Alice Martin", initial_balance=2000, overdraft_limit=500)
savings = SavingsAccount("Alice Martin", initial_balance=5000, interest_rate=0.03)

checking.deposit(1500, "Salary")
checking.withdraw(800, "Rent")
checking.withdraw(150, "Groceries")

savings.deposit(500, "Monthly savings")
interest = savings.apply_interest()
print(f" Interest earned: ${interest:.2f}")

success = checking.transfer_to(savings, 300)
print(f" Transfer: {'✅' if success else '❌'}")

checking.statement()
savings.statement()

accounts = [checking, savings]
print(f"\n Accounts sorted by balance:")
for a in sorted(accounts):
print(f" {a}")

print(f"\n checking == savings: {checking == savings}")
print(f" checking < savings : {checking < savings}")


if __name__ == "__main__":
demo()

Step 2 — Run

python3 bank.py

Verification ✅

🏦  Python National Bank — Lab 05

Interest earned: $165.00
Transfer: ✅

Checking Account — Alice Martin (#1000)
============================================================
[2026-03-29 10:00:00] DEPOSIT + 2000.00 Initial deposit
[2026-03-29 10:00:00] DEPOSIT + 1500.00 Salary
...

Summary

  • Abstract base class Account with @abstractmethod forces subclasses to implement deposit/withdraw
  • @dataclass for the simple Transaction record
  • @property for controlled access to _balance, _owner
  • @total_ordering generates all comparison operators from __eq__ + __lt__
  • super().__init__(...) initializes parent state in all subclass constructors
  • transfer_to() demonstrates polymorphism — works with any Account subclass