Inheritance Project · Payment Methods

💳 Payment Method Hierarchy

A deep-dive into Python inheritance using a real-world payment processing domain.

🐍 Python 3.11+ 🧬 Inheritance & super() 🏦 Polymorphism 📐 Design Patterns 🧪 Testing
Back to Inheritance Guide Hub
Introduction Motivation Diagram Base CreditCard Full Comparison Pitfalls Testing Composition Dataclass Exercises Extensions Takeaways Glossary

🧬 Why Inheritance Matters — A Comprehensive Introduction

Every programmer should learn inheritance because it is the foundation upon which scalable, maintainable, and extensible software is built. It is not merely a language feature — it is a way of thinking about code that mirrors how we naturally categorize the world.

The Significance of Inheritance in Programming

Inheritance is one of the four pillars of Object-Oriented Programming (OOP), alongside encapsulation, abstraction, and polymorphism. It allows a class to inherit attributes and methods from another class, creating a parent-child relationship. This is not just about code reuse — it is about modeling real-world relationships in your codebase.

In the programming world, inheritance is what enables:

  • Code Reuse: Write once, use everywhere. Shared logic lives in the parent class, eliminating duplication.
  • Extensibility: New features can be added by creating new subclasses without modifying existing, tested code.
  • Polymorphism: Treat different objects uniformly through a common interface, enabling flexible and decoupled systems.
  • Maintainability: Changes to shared behavior are made in one place, automatically propagating to all subclasses.
  • Natural Modeling: Reflects how we think about categories — a Dog is an Animal, a CreditCard is a PaymentMethod.

Why Every Programmer Must Master Inheritance

🏗️
Architectural Foundation
Inheritance is the backbone of frameworks like Django, PyTorch, and Flask. Understanding it unlocks the ability to extend these frameworks effectively.
🧩
Design Patterns
Many classic design patterns (Template Method, Factory, Strategy) rely on inheritance. It's essential for professional software design.
🚀
Scalability
As codebases grow, inheritance provides a structured way to manage complexity and avoid the "copy-paste" trap that plagues procedural code.
🤖
AI & ML Systems
Modern AI frameworks like PyTorch use inheritance extensively — every custom neural network layer subclasses nn.Module.

This Project: Learning Inheritance Through Payment Methods

This project is specifically designed to teach inheritance through a real-world domain that every developer can relate to: payment processing. Payment methods share common behavior (amount, currency, processing) but differ dramatically in implementation. This makes them a perfect candidate for inheritance.

By building a PaymentMethod hierarchy with CreditCard, BankTransfer, and Wallet subclasses, you will learn:

  • How to define an abstract base class with ABC and @abstractmethod
  • How to use super() to call parent constructors and methods
  • How to override methods to provide specialized behavior
  • How polymorphism enables treating different objects uniformly
  • How to test an inheritance hierarchy with parametrized tests
  • When to choose composition (Strategy pattern) over inheritance
  • How to refactor to use dataclasses for better type safety

🎯 The Big Picture: By the end of this project, you will not only understand inheritance syntactically but also when and why to use it in real software — and when to choose alternatives.

🏦 Real‑World Payment Hierarchies

Payment processing is one of the most common domains where inheritance shines. In production systems like Stripe, Braintree, and Adyen, you'll find classes like PaymentIntent, Charge, or Transaction that use inheritance to handle different payment methods while sharing common logic for idempotency, logging, and webhook delivery.

Before: The if/elif Mess

def process_payment(method, amount, **kwargs):
    if method == 'credit_card':
        # 20 lines of card logic
        return {'status': 'approved'}
    elif method == 'bank_transfer':
        # 15 lines of ACH logic
        return {'status': 'pending'}
    elif method == 'wallet':
        # 12 lines of wallet logic
        return {'status': 'completed'}
    else:
        raise ValueError("Unknown method")

This works — until you add a fourth method, or a fifth. Each new method adds another branch, and shared logic (logging, validation, idempotency) gets copy‑pasted or tangled. The inheritance version centralizes the shared parts and isolates the differences.

Business Requirements That Drive the Design

  • Credit cards: need authorization (near‑real‑time), card‑network communication, CVV checks, and settlement later.
  • Bank transfers: are slower (hours to days), require routing numbers, and generate a reference for reconciliation.
  • Wallets: are instant, in‑app, and update a local balance without external calls.

These different latencies, risk profiles, and compliance needs make payment methods a perfect fit for inheritance: the shared contract (process()) stays the same, but each implementation handles its own domain specifics.

📐 Inheritance Structure

PaymentMethod (ABC)
│ ▲
│ └── @abstractmethod process()
│
├── CreditCard ── implements process() → auth_code
├── BankTransfer ── implements process() → reference
└── Wallet ── implements process() → balance_after
▲ Single inheritance · method override · polymorphic loop

Key insight: The base class defines the contract — every subclass must implement process(). The shared state (amount, currency, timestamp) lives in the base, so subclasses only add what's unique.

🧱 The PaymentMethod Abstract Base

Why an abstract base class? It defines a template — a shared interface that all payment methods must follow. Using ABC and @abstractmethod ensures that no subclass can be instantiated without implementing process().
📖 Why This Code Was Used

This code establishes the foundation of the entire hierarchy. The PaymentMethod class is designed to be a blueprint — it defines what every payment method must have and do, without implementing the specific details. This is the essence of abstraction: define the what, leave the how to subclasses.

The currency parameter defaults to 'PHP' (Philippine Peso) to reflect a real-world use case in the Philippines.

python · base class
from abc import ABC, abstractmethod
from datetime import datetime

class PaymentMethod(ABC):
    def __init__(self, amount: float, currency: str = 'PHP'):
        self.amount = amount
        self.currency = currency
        self.timestamp = datetime.now().isoformat()

    @abstractmethod
    def process(self) -> dict:
        '''Process the payment and return a result dict.'''
        pass

    def summary(self) -> str:
        return f"{self.__class__.__name__}: {self.currency} {self.amount:.2f} at {self.timestamp}"
⚙️ How It Operates in the Program

When Python encounters class PaymentMethod(ABC), it creates an abstract base class. The @abstractmethod decorator marks process() as a method that must be overridden by any concrete subclass. The __init__ method sets up shared state, and summary() provides a reusable string representation that all subclasses inherit.

🎯 Impact on Output

If you try to instantiate PaymentMethod directly, Python raises a TypeError because process() is abstract. This protects the developer from creating an incomplete object. The summary() method, when called on a subclass, will dynamically reflect the subclass name — demonstrating polymorphism in action.

🏛️ Role in the Program

The PaymentMethod class serves as the contract that all payment methods must adhere to. It enforces a consistent interface (process()) and provides shared infrastructure (state, timestamp, summary). This is the architectural anchor of the entire hierarchy.

🔍 Design decisions:
  • Shared state lives in the base — amount, currency, and timestamp are defined once. The default currency is now PHP (Philippine Peso).
  • summary() uses self.__class__.__name__ — this means it always shows the actual subclass name, even when called on a CreditCard or Wallet instance.
  • Return type is a dict — flexible but implicit. Later we'll refactor to a PaymentResult dataclass for better type safety.

💳 Adding CreditCard

Now we implement our first concrete payment method. CreditCard adds card_number and cvv, calls super().__init__() to reuse the base setup, and overrides process() with credit-card-specific logic.
📖 Why This Code Was Used

This demonstrates the core inheritance pattern: extending a base class by adding new attributes and overriding behavior. The CreditCard class shows how to specialize a general concept (payment) into a concrete implementation.

The super().__init__() call is critical — it ensures the base class's initialization runs, so amount, currency, and timestamp are set up correctly. Without this, the subclass would be broken.

python · CreditCard subclass
class CreditCard(PaymentMethod):
    def __init__(self, amount: float, card_number: str, cvv: str, currency: str = 'PHP'):
        super().__init__(amount, currency)
        self.card_number = card_number
        self.cvv = cvv

    def process(self) -> dict:
        auth_code = random.randint(100000, 999999)
        return {
            'status': 'approved',
            'method': 'credit_card',
            'auth_code': auth_code,
            'message': f'Card ending {self.card_number[-4:]} authorized.'
        }
⚙️ How It Operates in the Program

When CreditCard is instantiated, it calls super().__init__() first, which runs PaymentMethod.__init__(). This sets up amount, currency, and timestamp. Then it adds its own attributes: card_number and cvv.

The process() method generates a random authorization code and returns a structured dict with a status of 'approved'. This simulates a successful credit card authorization.

🎯 Impact on Output

The output shows the summary() method — inherited from the base class — printing "CreditCard: PHP 150.00 at [timestamp]". Then process() returns a dict with the authorization details. The output demonstrates that the subclass has correctly inherited and extended the base class.

🏛️ Role in the Program

CreditCard is the first concrete implementation of the PaymentMethod contract. It shows how to add domain-specific attributes and override the abstract method. It also establishes the pattern that all other subclasses will follow.

✅ super().__init__() is the key: It calls PaymentMethod.__init__() to set up amount, currency, and timestamp. Without this, the subclass would have to duplicate that logic — a maintenance trap.

⚡ What if you forget super().__init__()?

Then self.amount, self.currency, and self.timestamp would not exist. Any call to summary() or process() that uses them would raise an AttributeError. Always call super() first when you add new attributes in __init__.

🏦 BankTransfer and Wallet

Now we add the remaining subclasses. Each one calls super().__init__() and implements process() with its own logic. Notice how the interface stays consistent while the implementation varies.
📖 Why This Code Was Used

This completes the hierarchy, showing polymorphism in action. Each subclass (BankTransfer, Wallet) shares the same interface (process()) but implements it differently. This demonstrates the power of inheritance: you can treat all payment methods uniformly while they behave differently.

The BankTransfer class simulates a slower, batch-oriented process with a 'pending' status and a reference number. The Wallet class simulates an instant, in-app transaction with a 'completed' status and an updated balance.

python · all three subclasses
class BankTransfer(PaymentMethod):
    def __init__(self, amount: float, account_number: str, routing: str, currency: str = 'PHP'):
        super().__init__(amount, currency)
        self.account_number = account_number
        self.routing = routing

    def process(self) -> dict:
        ref = 'TX' + str(random.randint(1000000, 9999999))
        return {'status': 'pending', 'method': 'bank_transfer', 'reference': ref}

class Wallet(PaymentMethod):
    def __init__(self, amount: float, wallet_id: str, currency: str = 'PHP'):
        super().__init__(amount, currency)
        self.wallet_id = wallet_id

    def process(self) -> dict:
        return {'status': 'completed', 'method': 'wallet', 'balance_after': round(random.uniform(10, 500), 2)}
⚙️ How It Operates in the Program

The program creates a list of three payment objects — one of each subclass. The for pm in payments loop then iterates over them, calling pm.summary() and pm.process() on each.

Because of polymorphism, Python dynamically dispatches the correct process() implementation based on the actual type of each object. The loop doesn't need to know whether it's a credit card, bank transfer, or wallet — it just calls the method and gets a result.

🎯 Impact on Output

The output shows three distinct payment results, each with a different status and method-specific data. The summary() method (inherited from the base) correctly reflects the subclass name each time. This demonstrates that the hierarchy works as intended — each subclass behaves correctly when treated uniformly.

🏛️ Role in the Program

This is the culmination of the inheritance design. The full hierarchy shows how a single, clean interface (process()) can support multiple implementations. The polymorphic loop is the payoff — it's the reason we invested in building the hierarchy in the first place.

🌀 The polymorphic loop: The for pm in payments loop calls pm.process() without knowing whether pm is a CreditCard, BankTransfer, or Wallet. Python dynamically dispatches to the correct implementation — that's polymorphism.

📊 Subclass Feature Comparison

Each payment method has the same interface but different behavior. Here's how they differ:

FeatureCreditCardBankTransferWallet
Statusapprovedpendingcompleted
Key extra dataauth_codereferencebalance_after
Typical latencynear-real-timehours–daysinstant
Requires external authYes (card network)Yes (bank)No (in-app)
Idempotency keyauth_codereferencewallet_id + timestamp
Risk levelhigh (fraud)mediumlow

Design note: Returning a dict is flexible but implicit. In production, you'd likely use a PaymentResult dataclass for type safety and discoverability. We'll refactor to that later.

⚠️ What Can Go Wrong?

1. Forgetting super().__init__()

class BrokenCard(PaymentMethod):
    def __init__(self, amount, card_number):
        self.card_number = card_number

    def process(self):
        return {'status': 'ok'}

Fix: Always call super().__init__(amount, currency) first in your subclass __init__.

2. Implementing process() with a different signature

class BadWallet(PaymentMethod):
    def process(self, extra_param: str) -> dict:
        return {'status': 'ok'}

Fix: Keep the method signature consistent. If you need extra data, pass it via the constructor or store it as an attribute.

3. Over-using inheritance (composition vs inheritance)

If your payment method shares no behavior and only needs the process() contract, consider using an interface (ABC) without shared state. Composition — holding a PaymentProcessor instance — is often more flexible for cross-cutting concerns like logging or retries.

4. Liskov Substitution Principle (LSP) Violations

If a subclass overrides process() to return a different type, or raises unexpected exceptions, the polymorphic loop breaks. Always ensure that a subclass substitutes for its parent without changing the expected behavior contract.

class BadWallet(PaymentMethod):
    def process(self) -> str:
        return "Completed"

🧪 Unit Testing the Hierarchy

One of the biggest advantages of a well-designed inheritance hierarchy is that you can write one test that works for every subclass. This demonstrates the power of the shared interface.

python · pytest tests
import pytest
from payments import CreditCard, BankTransfer, Wallet

def test_credit_card_returns_approved():
    cc = CreditCard(100.00, '4111-1111-1111-1111', '123')
    result = cc.process()
    assert result['status'] == 'approved'
    assert 'auth_code' in result

def test_bank_transfer_returns_pending():
    bt = BankTransfer(500.00, '987654321', '021000021')
    result = bt.process()
    assert result['status'] == 'pending'
    assert 'reference' in result

@pytest.mark.parametrize('payment_class,args', [
    (CreditCard, (100.00, '4111-1111-1111-1111', '123')),
    (BankTransfer, (500.00, '987654321', '021000021')),
    (Wallet, (25.50, 'wallet_xyz')),
])
def test_all_subclasses_have_summary_and_process(payment_class, args):
    pm = payment_class(*args)
    assert hasattr(pm, 'summary')
    assert hasattr(pm, 'process')
    result = pm.process()
    assert isinstance(result, dict)
    assert 'status' in result

✅ Parametrized tests are a superpower: One test function runs against every subclass. If you add a CryptoPayment later, the same test automatically verifies it — no extra code needed.

🔀 Inheritance vs. Strategy Pattern

Inheritance isn't the only way. The Strategy pattern uses composition — you inject a processor into a context class. This is more flexible when behaviors don't share state or when you need to swap them at runtime.

python · Strategy pattern
class PaymentContext:
    def __init__(self, processor: PaymentProcessor):
        self.processor = processor

    def execute(self, amount: float, currency: str = 'PHP') -> dict:
        return self.processor.process(amount, currency)

Pros / Cons Comparison

AspectInheritanceStrategy (Composition)
Shared stateEasy — put it in the baseHarder — context holds it, or pass to process()
Runtime swapImpossible after creationTrivial — change the processor
Code reuseHigh via base methodsLower — shared logic must be delegated
FlexibilityLower — hierarchy is fixedHigher — compose any processor
TestingParametrized tests work wellMock the processor easily

When to choose which: Use inheritance when payment methods share significant state and behavior. Use Strategy when you need to swap processors at runtime or when the "is-a" relationship feels forced.

📦 Using a PaymentResult Dataclass

Returning a plain dict is flexible, but it's hard to document, discoverable, and type‑safe. A @dataclass gives you named fields, default values, and better IDE support.

python · PaymentResult dataclass
from dataclasses import dataclass

@dataclass
class PaymentResult:
    status: str
    method: str
    extra: dict
    message: str = ''

def process(self) -> PaymentResult:
    return PaymentResult(
        status='approved',
        method='credit_card',
        extra={'auth_code': random.randint(100000, 999999)},
        message=f'Card ending {self.card_number[-4:]} authorized.'
    )

✅ Benefits: Type checking, autocomplete, explicit fields, and you can add methods to PaymentResult (e.g., to_json()). The polymorphic loop still works seamlessly.

🧪 Practice Challenges

Try these exercises to deepen your understanding. Click Show solution for hints or full implementations.

1. Add a CryptoPayment subclass

Create a new subclass that accepts a wallet_address and a network (e.g., "Ethereum", "Solana"). Override process() to return a transaction hash (simulated) and a status of "pending".

💡 Use hashlib to generate a fake transaction hash.
import hashlib, time

class CryptoPayment(PaymentMethod):
    def __init__(self, amount, wallet_address, network, currency='PHP'):
        super().__init__(amount, currency)
        self.wallet_address = wallet_address
        self.network = network

    def process(self):
        tx_hash = hashlib.sha256(f"{self.wallet_address}{time.time()}".encode()).hexdigest()
        return {
            'status': 'pending',
            'method': 'crypto',
            'network': self.network,
            'tx_hash': tx_hash
        }

2. Add a custom exception hierarchy

Define PaymentError, InsufficientFunds, and InvalidCard. Raise them in the appropriate subclasses and update the demo loop to handle them.

💡 Raise InvalidCard if the card number is too short.
class PaymentError(Exception):
    pass

class InsufficientFunds(PaymentError):
    pass

class InvalidCard(PaymentError):
    pass

class CreditCard(PaymentMethod):
    def __init__(self, amount, card_number, cvv, currency='PHP'):
        super().__init__(amount, currency)
        if len(card_number) < 16:
            raise InvalidCard("Card number must be at least 16 digits")
        self.card_number = card_number
        self.cvv = cvv

3. Implement a refund() method

Add an abstract refund() method to the base class. Implement it in each subclass with appropriate logic (e.g., credit cards refund to the card, wallets credit the balance).

💡 refund() should accept an amount and return a result dict.
class PaymentMethod(ABC):
    def refund(self, amount: float) -> dict:
        return {
            'status': 'refunded',
            'amount': amount,
            'method': self.__class__.__name__
        }

4. Refactor to PaymentResult dataclass

Take the full hierarchy from Step 3 and update it to return PaymentResult instead of dict. Update the polymorphic loop accordingly.

💡 Define PaymentResult with status, method, extra, and message.
from dataclasses import dataclass

@dataclass
class PaymentResult:
    status: str
    method: str
    extra: dict
    message: str = ''

def process(self) -> PaymentResult:
    return PaymentResult(
        status='approved',
        method='credit_card',
        extra={'auth_code': random.randint(100000, 999999)},
        message=f'Card ending {self.card_number[-4:]} authorized.'
    )

🚀 Taking It Further

🔌 Real-world payment libraries

Stripe, Braintree, and Adyen all expose similar interfaces — a PaymentMethod object with a charge() or process() method. The inheritance pattern you've just built mirrors how these SDKs structure their PaymentIntent or Transaction classes.

🧩 Strategy Pattern alternative

Instead of inheritance, you could use the Strategy pattern: pass a PaymentProcessor strategy object to a context class. This is more flexible when payment methods don't share state, and it's easier to swap behaviors at runtime.

When to choose composition over inheritance: When the "is-a" relationship feels forced, or when you need to change behavior dynamically. For payment methods, both approaches are valid — inheritance works well because the relationship is natural and the state is shared.

📈 Adding logging and audit trails

You could add a _log() method to the base class that subclasses can call inside process(). This keeps logging consistent across all payment methods without duplicating code.

def _log(self, message: str) -> None:
    print(f"[{self.timestamp}] {message}")

🏷️ Factory / Registry pattern

Production systems often use a registry that maps a string (e.g., "credit_card") to a class. This lets you instantiate the right subclass dynamically from an API request.

class PaymentRegistry:
    _registry = {}

    @classmethod
    def register(cls, name: str, payment_class):
        cls._registry[name] = payment_class

    @classmethod
    def get(cls, name: str):
        return cls._registry[name]

PaymentRegistry.register('credit_card', CreditCard)

📌 Key Takeaways

  • Inheritance models "is-a" relationships: A CreditCard is a PaymentMethod — the hierarchy reflects the real world.
  • super() enables clean extension: Subclasses reuse parent logic and add only what's new.
  • Abstract base classes enforce contracts: @abstractmethod guarantees that every subclass implements process().
  • Polymorphism is automatic: The loop works with any subclass without modification.
  • Design trade-offs matter: Inheritance is powerful, but composition (Strategy pattern) is often more flexible for cross-cutting concerns.
  • Testing is easier with a shared interface: Parametrized tests can verify every subclass with one function.
  • Refactor to dataclasses for type safety: PaymentResult gives you named fields, autocomplete, and better documentation.
  • Real-world SDKs use these patterns: Stripe, PyTorch, and Django all rely on inheritance for extensibility.

📖 Key Terms

ABC
Abstract Base Class — a class that cannot be instantiated and is meant to be subclassed.
abstractmethod
A decorator that marks a method as required to be implemented by subclasses.
super()
A function that returns a proxy object for calling parent class methods.
Polymorphism
The ability to treat objects of different subclasses through a common interface.
LSP
Liskov Substitution Principle — subclasses must be substitutable for their parent class.
Strategy Pattern
A behavioral pattern that uses composition to swap algorithms at runtime.
MRO
Method Resolution Order — the order Python searches for methods in a class hierarchy.
Dataclass
A decorator that automatically adds __init__, __repr__, and other methods to a class.