We split Account (data + individual operations) from ATM (system-wide coordination). Each class has exactly one reason to change — a core SRP principle that makes maintenance straightforward.
Python · OOP · Banking Software
The most comprehensive ATM programming tutorial on the web — covering Python OOP, security architecture, design patterns, unit testing, and a live interactive simulator.This is an intensive method of learning to grasp the profound conceps of encapsulation in Python.Think of Object-Oriented Programming (OOP) as a way of organizing code that mimics how we perceive the real world. Instead of writing a long list of instructions for the computer to follow (procedural programming), you create "objects" that interact with one another.
Introduction
"This isn't just another 'hello world' example — we're building a complete, production-inspired banking system that teaches you real-world software development.Object-Oriented Programming (OOP) is a programming paradigm based on the concept of "objects," which can contain data and code. Data in the form of fields (often known as attributes or properties), and code in the form of procedures (often known as methods). Instead of thinking about a program as a list of steps (procedural), OOP encourages you to think about it as a collection of interacting entities."— Valleys & Bytes
Whether you're a self-taught developer trying to level up, a CS student bridging the gap between theory and practice, or a working programmer sharpening your OOP instincts — this guide was written specifically for you.
By the time you finish, you won't just have working code — you'll have a mental model for how professionals think about designing software systems. That mental model is what separates a junior developer from a senior one.
An ATM system is the perfect learning vehicle because every person on the planet has used one. That familiarity is an advantage — you already understand the expected behavior, which means you can focus on the engineering rather than the domain. ATMs are deceptively complex: they involve authentication, transaction processing, concurrent state management, security hardening, audit trails, hardware abstraction, and strict business rules — exactly the same challenges you'll face in any serious application.
Banking software has zero tolerance for bugs. A mistake in your ATM code doesn't just break a feature — it could drain someone's account or lock them out of their money. Building in this mindset trains you to write better software in every domain.
This tutorial assumes basic Python syntax: variables, loops, functions, and maybe a little about classes. You don't need to be an OOP expert — that's what we're building. Estimated time: 2–3 hours for reading, plus however long you spend experimenting with the live simulator.
Interactive Demo
Try the ATM yourself! Use card 1234 with PIN 1234. The simulator mirrors the exact Python logic implemented below — test invalid PINs, insufficient funds, daily limits, PIN changes, and more.
Test card: 1234 · PIN: 1234 · Balance: ₱12,500.75
Implementation
Below is our ATM system with professional features: multi-account support, transaction limits, audit logging, and comprehensive error handling. Every design decision is explained in the sections that follow.
# ── atm_system.py ────────────────────────────────────────────── import hashlib import datetime import json from typing import Dict, List, Optional, Tuple class Account: def __init__(self, account_number: str, pin_hash: str, holder_name: str, initial_balance: float = 0.0, account_type: str = "savings"): self.account_number = account_number self.pin_hash = pin_hash self.holder_name = holder_name self.balance = initial_balance self.account_type = account_type self.is_locked = False self.failed_attempts = 0 self.transaction_history: List[Dict] = [] self.daily_withdrawal_total = 0.0 self.last_transaction_date = datetime.date.today() def verify_pin(self, plain_pin: str) -> bool: input_hash = hashlib.sha256(plain_pin.encode()).hexdigest() return input_hash == self.pin_hash def can_withdraw(self, amount: float, daily_limit: float = 10000.0) -> Tuple[bool, str]: if self.last_transaction_date < datetime.date.today(): self.daily_withdrawal_total = 0.0 self.last_transaction_date = datetime.date.today() if self.is_locked: return False, "Account is locked. Please contact support." if amount <= 0: return False, "Amount must be positive." if amount > self.balance: return False, "Insufficient funds." if amount % 100 != 0: return False, "Withdrawals must be in multiples of 100." if self.daily_withdrawal_total + amount > daily_limit: return False, f"Daily withdrawal limit of ₱{daily_limit:,.2f} exceeded." return True, "Approved" def withdraw(self, amount: float) -> Dict: can_withdraw, message = self.can_withdraw(amount) if not can_withdraw: return {"success": False, "message": message} self.balance -= amount self.daily_withdrawal_total += amount transaction = { "type": "withdrawal", "amount": amount, "balance_after": self.balance, "timestamp": datetime.datetime.now().isoformat() } self.transaction_history.append(transaction) return {"success": True, "message": f"Withdrawn ₱{amount:,.2f}", "new_balance": self.balance, "transaction": transaction} class ATM: DAILY_WITHDRAWAL_LIMIT = 10000.0 MAX_FAILED_ATTEMPTS = 3 def __init__(self): self.accounts: Dict[str, Account] = {} self.current_account: Optional[Account] = None self.session_start = None self._initialize_test_accounts() def _initialize_test_accounts(self): demo_hash = hashlib.sha256("1234".encode()).hexdigest() self.accounts["1234"] = Account("1234", demo_hash, "Glenn Junsay Pansensoy", 12500.75, "savings") self.accounts["5678"] = Account("5678", demo_hash, "Maria Santos", 25000.00, "checking") def authenticate(self, card_number: str, pin: str) -> Tuple[bool, str]: if card_number not in self.accounts: return False, "Card not recognized" account = self.accounts[card_number] if account.is_locked: return False, "Account locked. Please contact customer service." if account.verify_pin(pin): account.failed_attempts = 0 self.current_account = account self.session_start = datetime.datetime.now() return True, f"Welcome, {account.holder_name}!" account.failed_attempts += 1 if account.failed_attempts >= self.MAX_FAILED_ATTEMPTS: account.is_locked = True return False, "Too many failed attempts. Account locked." remaining = self.MAX_FAILED_ATTEMPTS - account.failed_attempts return False, f"Incorrect PIN. {remaining} attempt(s) remaining." def check_balance(self) -> str: if not self.current_account: return "No active session" return f"Current Balance: ₱{self.current_account.balance:,.2f}" def deposit(self, amount: float) -> Dict: if not self.current_account: return {"success": False, "message": "No active session"} if amount <= 0: return {"success": False, "message": "Deposit must be positive"} if amount > 50000: return {"success": False, "message": "Over ₱50,000 requires teller"} self.current_account.balance += amount tx = {"type": "deposit", "amount": amount, "balance_after": self.current_account.balance, "timestamp": datetime.datetime.now().isoformat()} self.current_account.transaction_history.append(tx) return {"success": True, "message": f"Deposited ₱{amount:,.2f}", "new_balance": self.current_account.balance, "transaction": tx} def withdraw(self, amount: float) -> Dict: if not self.current_account: return {"success": False, "message": "No active session"} return self.current_account.withdraw(amount) def change_pin(self, old_pin: str, new_pin: str) -> Dict: if not self.current_account: return {"success": False, "message": "No active session"} if not self.current_account.verify_pin(old_pin): return {"success": False, "message": "Current PIN is incorrect"} if len(new_pin) != 4 or not new_pin.isdigit(): return {"success": False, "message": "PIN must be exactly 4 digits"} if new_pin == old_pin: return {"success": False, "message": "New PIN must differ from old"} self.current_account.pin_hash = hashlib.sha256(new_pin.encode()).hexdigest() return {"success": True, "message": "PIN changed successfully."} def get_transaction_history(self, limit: int = 10) -> List[Dict]: if not self.current_account: return [] return self.current_account.transaction_history[-limit:] def logout(self): self.current_account = None self.session_start = None
Deep Dive
We split Account (data + individual operations) from ATM (system-wide coordination). Each class has exactly one reason to change — a core SRP principle that makes maintenance straightforward.
Never store plaintext PINs. SHA-256 hashing here for clarity; real systems use bcrypt or Argon2 with per-user salts. Failed attempt tracking stops brute force: 3 wrong PINs locks the account permanently.
Each account tracks its own state: balance, lock status, failed attempts, and daily withdrawal total. This encapsulation means one account's state can never corrupt another's — naturally thread-safe at the account level.
All withdrawal rules live in can_withdraw(). When compliance says "add a minimum withdrawal of ₱200," there's exactly one place to make that change — no scattered checks to miss.
authenticate()authenticate() performs a careful sequence of checks. First it verifies the card exists — then checks if the account is already locked. This order matters: you should never reveal whether a locked account's PIN is correct, since that leaks security-sensitive information.
PIN verification uses hashed comparison — the actual PIN is never stored or transmitted in plaintext. Even if an attacker accessed the database directly, they'd only see cryptographic hashes.
Tuple[bool, str] return types?The can_withdraw() method checks conditions in priority order. First: is today a new calendar day? If so, reset the daily counter. Real ATMs do exactly this — your daily limit refreshes at midnight, not 24 hours after your last withdrawal.
Five conditions are checked in order: account lock, positive amount, sufficient balance, denomination validity (multiples of ₱100), and daily limit. The denomination check (amount % 100 !== 0) is especially realistic — physical ATMs can only dispense whole bills, so only amounts divisible by the smallest denomination are valid.
DAILY_WITHDRAWAL_LIMIT = 10000.0 and MAX_FAILED_ATTEMPTS = 3 are declared as class-level constants. When a business requirement changes — say, the daily limit increases to ₱15,000 — you change it in exactly one place, and every method picks up the new value automatically.
Every successful operation appends a structured record to transaction_history: type, amount, resulting balance, and an ISO 8601 timestamp. This isn't optional in banking — regulators require complete audit trails. Design your public methods to be implementation-agnostic, so you can swap the storage backend later without changing callers.
Design Patterns
Our ATM system demonstrates several Gang of Four design patterns that appear constantly in professional software:
"A withdrawal command (Command) uses a withdrawal strategy (Strategy) that checks business rules, then notifies observers (Observer) — all without the Account class knowing any of those systems exist."— Valleys & Bytes, on decoupling
Being able to name and apply these patterns in an interview — with a concrete example like this ATM — immediately signals that you think at an architectural level, not just a "make the test pass" level.
Testing Strategy
Professional code requires professional testing. Here's how we'd test our ATM system — covering the four most critical scenarios:
import unittest class TestATM(unittest.TestCase): def setUp(self): self.atm = ATM() # fresh ATM before every test def test_successful_authentication(self): success, message = self.atm.authenticate("1234", "1234") self.assertTrue(success) self.assertIn("Welcome", message) def test_failed_authentication_locks_account(self): for i in range(3): success, _ = self.atm.authenticate("1234", "wrong") self.assertFalse(success) # correct PIN must now fail because account is locked success, message = self.atm.authenticate("1234", "1234") self.assertFalse(success) self.assertIn("locked", message.lower()) def test_withdrawal_rules(self): self.atm.authenticate("1234", "1234") self.assertFalse(self.atm.withdraw(-100)["success"]) # negative amount self.assertFalse(self.atm.withdraw(999999)["success"]) # exceeds balance self.assertFalse(self.atm.withdraw(150)["success"]) # not multiple of 100 self.assertTrue(self.atm.withdraw(1000)["success"]) # valid ✓ def test_daily_limit(self): self.atm.authenticate("1234", "1234") self.assertTrue(self.atm.withdraw(5000)["success"]) self.assertTrue(self.atm.withdraw(5000)["success"]) result = self.atm.withdraw(1000) self.assertFalse(result["success"]) self.assertIn("daily withdrawal limit", result["message"].lower()) def test_transaction_history(self): self.atm.authenticate("1234", "1234") self.atm.deposit(1000) self.atm.withdraw(500) history = self.atm.get_transaction_history() self.assertEqual(len(history), 2) self.assertEqual(history[0]["type"], "deposit") self.assertEqual(history[1]["type"], "withdrawal")
Notice how each test is self-contained — setUp() creates a fresh ATM before every test, so a failure in one test can never corrupt another. This isolation is the difference between tests you trust and tests that give you false confidence.
In a real project, you'd also test concurrent access (two withdrawals submitted at the same millisecond), database integration, and mocked external API calls. Aim for >90% code coverage and run these tests automatically on every commit via CI/CD pipelines like GitHub Actions or Jenkins.
Production Considerations
Join 5,000+ developers who receive our weekly deep dives on Python, OOP, system design, and more.
No spam. Unsubscribe at any time.
Discussion
Leave a Comment