ATM System in Python OOP: Encapsulation Masterclass

Build a production-ready Python ATM while mastering encapsulation, security, patterns, and testing.

Written by Glenn Junsay Pansensoy  ·  Programming  ·  54 min read
ATM System in Python OOP – Master Encapsulation | Poetic Bytes
  • Back to Encapsulation Hub
Encapsulation in Action · Production-Ready ATM

ATM System —
Mastering Encapsulation
Through Real Banking Software

Build a complete, professional ATM system in Python. Learn encapsulation, security, design patterns, and testing — all while creating a live interactive simulator you can use right in your browser.

📅 February 28, 2026 ⏱ 3–4 hours 🎓 Intermediate–Advanced Python 3.9+
Advertisement

Why an ATM System?

The Perfect Vehicle for Encapsulation

Every person on the planet has used an ATM. That familiarity is your superpower — you already understand the expected behavior, which means you can focus entirely on the engineering rather than the domain. But ATMs are deceptively complex: they involve authentication, transaction processing, concurrent state management, security hardening, audit trails, hardware abstraction, and strict business rules. These are exactly the same challenges you'll face in any serious application — from e‑commerce to AI systems.

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 will teach you how to think like a senior engineer, not just how to write code.

What you'll learn: Encapsulation, SHA‑256 security, Command & Observer design patterns, unit testing, and production‑grade error handling — all through one cohesive project.

Prerequisites & Who This Is For

You should know basic Python syntax (variables, loops, functions, and maybe a little about classes). You don't need to be an OOP expert — that's what this tutorial is for. Estimated time: 3–4 hours for reading, plus experimentation with the live simulator.

  • You're a self‑taught developer looking to level up from scripts to systems.
  • You're a CS student bridging the gap between theory and practice.
  • You're a working programmer sharpening your OOP and testing skills.
  • You want to see how encapsulation is applied in a real, working project.

🎮 Live ATM Simulator

Try the ATM yourself! Use card 1234 with PIN 1234. The simulator mirrors the exact Python logic we'll dissect below — test invalid PINs, insufficient funds, daily limits, PIN changes, and more.

ATM Simulator — Interactive Session
================================== POETIC BYTES ATM v2.0 ================================== Welcome to the ATM Simulator → Press START to begin your session → Test card: 1234 → PIN: 1234 → Balance: ₱12,500.75 This simulator mirrors the exact logic of our Python implementation below.

💻 Complete Production‑Ready 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 detail in the sections that follow.

atm_system.py
import hashlib
import datetime
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

🔬 Understanding Every Design Decision

🏛️

Separation of Concerns

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.

🔐

Security Architecture

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.

📊

State Management

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.

💼

Business Rules Encapsulation

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.

🏗️ Professional Design Patterns in Action

Our ATM system demonstrates several Gang of Four design patterns that appear constantly in professional software:

⚡

Command Pattern

Each ATM operation (deposit, withdraw, balance check) could be encapsulated as a command object — enabling queued transactions, undo operations, and complete audit logging.

Behavioral
👀

Observer Pattern

Transaction history demonstrates Observer‑like behavior — accounts notify interested parties (ATM screen, receipt printer, fraud detector) when state changes, decoupling account from presentation.

Behavioral
🏭

Factory Pattern

Account creation could use a factory to handle different account types (savings, checking, business) each with different rules. The factory centralises all creation logic.

Creational
🎯

Strategy Pattern

Withdrawal rules could be swapped out based on account type. A savings account has different limits from a business account — Strategy makes these interchangeable and independently testable.

Behavioral

💡 Key Insight: 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. This decoupling is the hallmark of maintainable software.

🧪 Comprehensive Testing Strategy

Professional code requires professional testing. Here's how we'd test our ATM system — covering the four most critical scenarios:

test_atm.py
import unittest

class TestATM(unittest.TestCase):
    def setUp(self):
        self.atm = ATM()

    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)
        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"])
        self.assertFalse(self.atm.withdraw(999999)["success"])
        self.assertFalse(self.atm.withdraw(150)["success"])
        self.assertTrue(self.atm.withdraw(1000)["success"])

    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())

Why these tests? 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, database integration, and mocked external API calls. Aim for >90% code coverage and run these tests automatically on every commit via CI/CD pipelines.

The Vital Role of Encapsulation in the Age of AI

Encapsulation is not a relic of classical software engineering — it is more relevant than ever in the age of machine learning, large language models, and AI‑driven systems. Here's why.

🧠

ML Model Abstraction

Libraries like PyTorch and TensorFlow use deep encapsulation. nn.Module encapsulates weights, forward pass logic, and gradient tracking — you call model(x) without touching any internals.

🔐

Data Privacy & GDPR

AI systems processing personal data must protect it rigorously. Encapsulation enforces access control — PII fields remain private, only accessible through validated, logged accessor methods that satisfy compliance requirements.

🔄

Model Versioning

When you swap a model from BERT to GPT‑4 internally, encapsulation ensures nothing outside breaks. The interface stays constant — only the encapsulated implementation changes. This is the foundation of agile AI deployment.

📦

Microservice Design

Modern AI microservices are essentially encapsulated objects at scale. Each service hides its implementation, exposes only a clean API, and can be updated independently — classical encapsulation applied at the architectural level.

⚡

Prompt Engineering

LLM wrapper classes encapsulate prompt templates, retry logic, token counting, and caching behind a single .ask() method. The caller never needs to know about rate limits or context windows.

🛡️

Safety Guardrails

AI safety systems use encapsulation to enforce content filters and output validation at the class level — ensuring no output can bypass safety checks regardless of how the model is called internally.

Connecting the dots: The ATM system you just built uses the same encapsulation principles that power production AI systems. The Account class is like a neural network — it hides its internal state (weights, biases) and only exposes controlled methods (forward pass). The ATM class is like the API layer — it coordinates requests without exposing internals. This is how professional software is built, whether for banking or artificial intelligence.

❓ Common Questions About OOP & Encapsulation

Why can't we just use plain functions instead of classes?

+

You can, but classes give you encapsulation — the ability to bundle data with the functions that operate on it, and control access to that data. In the ATM system, the Account class ensures that only its own methods can modify the balance. With plain functions, any part of the program could accidentally corrupt the balance. Classes also enable inheritance and polymorphism, which are essential for large‑scale systems.

Is SHA‑256 secure enough for PINs in production?

+

SHA‑256 is a cryptographic hash, but it's fast — which is actually a weakness for password hashing. In production, you'd use bcrypt, Argon2, or PBKDF2 with a per‑user salt and a work factor (cost) that slows down brute‑force attacks. However, SHA‑256 is fine for learning and demonstrates the core principle of never storing plaintext secrets.

How does encapsulation relate to SOLID principles?

+

Encapsulation is the foundation for the Single Responsibility Principle (SRP) and the Open‑Closed Principle (OCP). By hiding internal details, you can change the implementation of a class without affecting its users (OCP). And by grouping related data and behaviour together, each class has a single, well‑defined responsibility (SRP). The ATM system shows SRP: Account handles account logic, ATM handles session coordination.

Why use tuples to return results instead of exceptions?

+

Returning (bool, str) makes it explicit that a failure is an expected outcome, not an exceptional one. In banking, failed PINs and insufficient funds are common — they aren't bugs, they're normal flow. Using tuples forces the caller to handle both success and failure paths, leading to more robust code. Exceptions should be reserved for truly unexpected errors (e.g., network failure, database corruption).

How would you extend this ATM to support multiple currencies?

+

You'd add a currency attribute to Account and a CurrencyConverter class. The withdraw method would use the converter to ensure the amount is in the account's currency. This demonstrates the Strategy pattern — you'd inject the converter as a dependency, making the system flexible and testable.

🏋️ Hands‑On Exercises

Reinforce your understanding by extending the ATM system. Each exercise builds on the previous one and explores a different aspect of encapsulation and OOP.

1️⃣ Add a Minimum Balance Rule

Modify the Account class so that a savings account must maintain a minimum balance of ₱500. If a withdrawal would bring the balance below ₱500, reject it with an appropriate message.

  • Hint: Add a minimum_balance attribute to Account.
  • Update can_withdraw() to check self.balance - amount >= self.minimum_balance.
💡 Think about where to place this check — after the "positive amount" check but before the "sufficient funds" check. This ensures the minimum balance is enforced even when the account has enough raw funds.

2️⃣ Implement a Transfer Feature

Add a transfer() method to the ATM class that moves money from the current account to another account (by account number). The method should:

  • Validate that the target account exists.
  • Check that the source account has enough funds (respecting daily limits).
  • Debit the source and credit the target atomically (both or neither succeed).
💡 Use the existing withdraw() and deposit() methods, but wrap them in a single transaction. If either fails, rollback the changes.

3️⃣ Add a Transaction Fee for Withdrawals

Modify the withdrawal logic so that each withdrawal incurs a fee of ₱15.00 if the account is a checking account, and ₱5.00 if it's a savings account. The fee should be deducted from the balance after the withdrawal.

  • Hint: Extend the withdraw() method to compute the fee based on self.account_type.
  • Ensure the fee is logged in the transaction history.
💡 Be careful with the fee logic — the fee should be applied after the withdrawal amount is validated, but you should check that the balance can cover both the withdrawal and the fee.

4️⃣ Create a Savings Goal Feature

Extend the Account class to include a savings_goal attribute. Add a method check_goal_progress() that returns the percentage of the goal achieved. Also, add a reset_goal() method.

  • Goal can be set via a new method set_goal(amount).
  • Progress = (current balance / goal) * 100.
💡 This exercise is about encapsulation — the goal and the progress logic belong inside the Account class, not the ATM class.

🎯 Knowledge Check

Reinforce what you've learned. Each question targets a core encapsulation or ATM concept from this guide.

0/5

Great effort — review any missed questions above.

Share This Guide

💬 Comments

Leave a Comment

Sort by:

Subscribe to Poetic Bytes

Level up your Python knowledge.

In‑depth Python tutorials, AI insights, and software engineering guides — delivered straight to your inbox. No spam, ever.

Weekly Python tutorials AI & ML deep‑dives OOP series updates Free, cancel anytime

🔒 Join 8,400+ developers. Your email is never shared or sold.

Advertisement

Tags: PythonOOPEncapsulationATM SystemSHA-256Design PatternsUnit TestingBanking SoftwarePython TutorialObject-Oriented Programming
Comments
© 2026 Glenn Junsay Pansensoy. All rights reserved.
Published on poetic-bytes.blogspot.com  ·  #Python #OOP #Encapsulation #ATMSystem #SHA-256

Comments

Popular posts from this blog

The Golden Kingdom of Butuan: Precolonial Mindanao’s Lost Trade Empire

The Passover

Pre-European South East Asia