Back to Home
OOP Project Β· Real-World Money

Currency Converter β€”
Build a Real
Money Class.

Money is the perfect teacher for operator overloading. You cannot add two prices with .add() forever β€” you want price + tax. You want price * qty. You want sorted(prices) to work. This project builds a complete currency converter from scratch using a Money class, exchange rates, validation, and polymorphic payment methods.

Every program includes a line-by-line explanation. You also get runnable code, six exercises, a live playground, a progress tracker, and a full project walkthrough.

πŸ“… October 20, 2026 ⏱ 60 min read πŸŽ“ Intermediate 🐍 Python 3.11+
Back to Home Start the Project Open Playground Polymorphism Guide

Progress Tracker

Tick off each milestone as you complete it. Your progress is saved in this browser β€” you can leave and come back anytime.

πŸ’± Your Journey Through the Money Project

0%

    Why Build a Currency Converter?

    πŸ’± Money Is a Value Type β€” Treat It Like One

    In every real application β€” banking, e-commerce, invoicing, payroll β€” money shows up. And in almost every beginner codebase, it shows up the wrong way: as a plain float. Floats lie (0.1 + 0.2 != 0.3), they carry no currency, and they let you accidentally add dollars to euros.

    A Money class fixes all of that. It stores an amount and a currency, refuses mismatched arithmetic, rounds correctly, and β€” through operator overloading β€” lets you write price + tax instead of price.add(tax).

    Then a CurrencyConverter sits on top: it holds exchange rates and turns Money(100, "USD") into Money(5600, "PHP"). Finally, polymorphic payment methods show how the same checkout() call handles cards, cash and digital wallets.

    7 programs with line-by-line walkthroughs 6 exercises with solutions Interactive live lab Free-form Python playground Progress tracker Full project walkthrough

    The Problem: Float Money

    Watch what plain floats do when you try to run a simple checkout.

    price = 19.99
    tax = 1.60
    total = price + tax
    print(total)
    print(total == 21.59)
    print(0.1 + 0.2 == 0.3)
    Expected output
    21.590000000000003 False False

    Smell: The total is wrong at the 15th decimal place. Comparison against the "correct" answer fails. This is binary floating point, and money code cannot tolerate it.

    Goal: A class that stores integer cents (or Decimal), knows its currency, refuses mixed-currency addition, and renders as PHP 21.59.

    Key Ideas Before the Code

    Skim this glossary now and return whenever a term in the walkthrough feels unfamiliar.

    Glossary

    TermPlain-English meaningWhere you'll see it
    Value objectAn immutable object whose identity is defined by its values (like Money(10, "USD")).Money class
    Operator overloadingMaking +, -, *, ==, < work for your own class via dunder methods.Program 2–4
    Dunder methodA "double underscore" hook such as __add__ or __eq__ that Python calls for you.Every program
    NotImplementedA sentinel value an operator returns to say "I don't know how β€” ask the other side." It is not an exception.__add__
    Reflected method__radd__ / __rmul__ β€” called when the left operand doesn't know how to operate on your type.2 * price
    DecimalA fixed-point numeric type from the decimal module. Exact, unlike float.Money storage
    ImmutableAn object whose attributes cannot be reassigned after creation.Money design
    HashAn integer derived from an object's value, used by sets and dict keys.__hash__
    Exchange rateThe multiplier to go from one currency to another.Converter
    Polymorphic methodA method that different subclasses implement in their own way.Program 6

    The Design in One Picture

    Before writing code, understand the shape of the finished system.

    Money  ───────────  (value, currency)
      ↑
      β”‚  overridden operators
      β–Ό
    price + tax, price * qty, sorted(prices)
    
    CurrencyConverter  ─── (rates table)
      β”‚  .convert(money, target)
      β–Ό
    Money in any currency
    
    PaymentMethod  ────── (polymorphic base)
      β”œβ”€ CardPayment  .fee(amount)
      β”œβ”€ CashPayment  .fee(amount)
      └─ WalletPayment .fee(amount)
             β”‚
             β–Ό
      checkout(amount) works on ANY payment method

    Read the arrows: Money is the foundation β€” everything else uses it. CurrencyConverter changes currency but not type. PaymentMethod sits above and never touches currency math β€” it delegates.

    Error Message Decoder

    You'll meet these while experimenting. Each points at a specific operator-overloading mistake.

    MessageWhat it meansFix
    TypeError: unsupported operand type(s) for +: 'Money' and 'int'Neither Money.__add__ nor int.__radd__ accepted the operands.Add an int-accepting branch, or convert with Money(5) first.
    ValueError: Currency mismatch: PHP vs USDYour own _check raised it β€” you tried to add mixed currencies.Convert first, then add.
    TypeError: unhashable typeYou defined __eq__ without __hash__.Define __hash__ from the same fields.
    TypeError: '<' not supported between instances of 'Money'No __lt__ β€” sorted() can't order objects.Define __lt__, or use @total_ordering.
    AttributeError: 'Money' object has no attribute 'amount'You assigned to it after making the class immutable, or forgot __init__.Recreate the object; don't mutate.
    decimal.InvalidOperationYou passed float directly to Decimal, reintroducing float error.Pass a string: Decimal("1.60").

    How to study this page: (1) read the code once; (2) predict the output; (3) press Run and compare; (4) read the deep dive and the line-by-line walkthrough; (5) do the "Try it yourself" experiments; (6) press Mark as learned at the top of the program.

    Seven Programs, Explained Line by Line

    Program 1 β€” A Money Value Object

    Start small: a class that stores amount and currency, prevents mixed arithmetic, and prints nicely. We use Decimal internally for exactness.

    from decimal import Decimal, ROUND_HALF_UP
    
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            self.currency = currency.upper()
    
        def _check(self, other):
            if self.currency != other.currency:
                raise ValueError(f"Currency mismatch: {self.currency} vs {other.currency}")
    
        def __add__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            self._check(other)
            return Money(self.amount + other.amount, self.currency)
    
        def __sub__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            self._check(other)
            return Money(self.amount - other.amount, self.currency)
    
        def __eq__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            return (self.amount, self.currency) == (other.amount, other.currency)
    
        def __hash__(self):
            return hash((self.amount, self.currency))
    
        def __repr__(self):
            return f"Money({self.amount!s}, {self.currency!r})"
    
        def __str__(self):
            return f"{self.currency} {self.amount:,.2f}"
    
    price = Money("19.99")
    tax = Money("1.60")
    print(price)
    print(price + tax)
    print(Money("0.10") + Money("0.20"))
    print(price == Money("19.99"))
    
    try:
        Money("1.00") + Money("1.00", "EUR")
    except ValueError as err:
        print("ValueError:", err)
    Expected output
    USD 19.99 USD 21.59 USD 0.30 True ValueError: Currency mismatch: USD vs EUR

    πŸ” Deep dive β€” why Decimal(str(amount))?

    The easiest bug in money code is the one you don't notice until the ledger is off by a cent. Let's see what the constructor is really doing.

    InputWrong way: Decimal(19.99)Right way: Decimal("19.99")
    19.9919.989999999999998436…19.99
    0.10.1000000000000000055511…0.1
    Why?Float is already wrong before Decimal sees it.str() captures the human representation.

    The rule: when a value represents money, it must travel as a string or integer all the way into the constructor, and only become a number inside __init__.

    πŸ“– Line-by-line walkthrough

    1. from decimal import Decimal, ROUND_HALF_UP β€” imports the fixed-point type and the rounding mode. ROUND_HALF_UP means 2.5 becomes 3, which matches how money is normally rounded.
    2. class Money: β€” a plain class, no inheritance needed. A value object stands alone.
    3. def __init__(self, amount, currency="USD"): β€” the constructor takes any amount-like input and a currency code that defaults to USD.
    4. Decimal(str(amount)) β€” converts the input to a string first, then to a Decimal. This is the safety step that prevents float contamination.
    5. .quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) β€” snaps the value to two decimal places using banker-safe rounding.
    6. self.currency = currency.upper() β€” normalizes currency codes so "usd" and "USD" are treated as the same.
    7. def _check(self, other) β€” a private helper. Raises ValueError if the two objects don't agree on currency. Note the leading underscore by convention.
    8. def __add__(self, other) β€” hooked to +. First rejects non-Money operands via NotImplemented. Then checks currencies. Then returns a new Money.
    9. def __sub__(self, other) β€” same shape for -. Note that the result can be negative; we don't forbid that here (a refund is a valid concept).
    10. def __eq__(self, other) β€” compares both amount and currency. Money("1.00") == Money("1.00", "EUR") correctly returns False.
    11. def __hash__(self) β€” without this, the object is unhashable once __eq__ is defined. It uses the same tuple so equal objects hash the same.
    12. def __repr__(self) β€” for developers. Shows the exact fields, useful in debugging.
    13. def __str__(self) β€” for users. Formats with thousands separators and two decimals.
    14. Money("0.10") + Money("0.20") β€” the classic float-fail test. Prints USD 0.30 β€” exactly right.
    15. The final try/except β€” attempts to add USD and EUR. Our _check raises ValueError with a clear message.

    ⚠️ Common mistakes

    Passing a float to Decimal directly. Decimal(0.1) stores the float error. Always pass a string.

    Mutating amount after creation. A value object should be immutable; money.amount = ... breaks the mental model. Use __add__ to create new ones.

    Forgetting __hash__. Defining __eq__ sets __hash__ to None. Money objects in a set will raise TypeError.

    πŸ§ͺ Try it yourself

    Experiments:

    • Add a class attribute DEFAULT_CURRENCY = "USD" and use it as the default in __init__.
    • Test Money("0.005") β€” what does ROUND_HALF_UP produce?
    • Add __neg__ that returns the same amount with negated sign. Then test -Money("5.00").
    • Try Money("1.00") == "1.00". Predict the result before running.

    Program 2 β€” Multiplication and Comparison

    Now make Money arithmetic usable: scaling, ordering, min/max, sorted. A real checkout needs all of these.

    from decimal import Decimal, ROUND_HALF_UP
    from functools import total_ordering
    
    @total_ordering
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            self.currency = currency.upper()
    
        def _check(self, other):
            if self.currency != other.currency:
                raise ValueError(f"Currency mismatch: {self.currency} vs {other.currency}")
    
        def __add__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            self._check(other)
            return Money(self.amount + other.amount, self.currency)
    
        def __mul__(self, factor):
            if not isinstance(factor, (int, float, Decimal)):
                return NotImplemented
            return Money(self.amount * Decimal(str(factor)), self.currency)
    
        __rmul__ = __mul__
    
        def __eq__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            return (self.amount, self.currency) == (other.amount, other.currency)
    
        def __lt__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            self._check(other)
            return self.amount < other.amount
    
        def __hash__(self):
            return hash((self.amount, self.currency))
    
        def __repr__(self):
            return f"Money({self.amount!s}, {self.currency!r})"
    
        def __str__(self):
            return f"{self.currency} {self.amount:,.2f}"
    
    price = Money("19.99")
    print(price * 3)
    print(3 * price)
    print(min(Money("5"), Money("10")))
    print(max(Money("5"), Money("10")))
    print(sorted([Money("30"), Money("5"), Money("12.50")]))
    print(Money("5") < Money("10"))
    print(Money("5") <= Money("5"))
    
    try:
        Money("1", "USD") < Money("1", "EUR")
    except ValueError as err:
        print("ValueError:", err)
    Expected output
    USD 59.97 USD 59.97 USD 5.00 USD 10.00 [Money(5.00, 'USD'), Money(12.50, 'USD'), Money(30.00, 'USD')] True True ValueError: Currency mismatch: USD vs EUR

    πŸ” Deep dive β€” how the operators resolve

    Follow each expression to its underlying method calls.

    You writePython triesResult
    price * 3price.__mul__(3)New Money("59.97").
    3 * priceβ‘  int.__mul__(3, price) β†’ NotImplemented
    β‘‘ price.__rmul__(3)
    The reflected method handles it β€” that's why __rmul__ = __mul__ works.
    min(a, b)repeated a < b β†’ __lt__Smaller amount.
    sorted([...])Pairwise <, using __lt__Sorted ascending.
    a <= b__le__, derived by @total_ordering from __eq__ + __lt__Correct comparison.
    a < b (mixed currency)__lt__ calls _check which raises ValueErrorPrevents nonsense ordering.

    Ordering requires a currency: comparing USD 5 to EUR 5 is only meaningful after conversion. Our _check ensures you don't quietly get the wrong answer.

    πŸ“– Line-by-line walkthrough

    1. from functools import total_ordering β€” a decorator from the standard library. You write __eq__ and one ordering method (__lt__); it fills in the other three comparisons.
    2. @total_ordering β€” must appear directly above the class definition. It rewrites the class to add missing comparison methods.
    3. def __mul__(self, factor) β€” accepts any number-like factor. Returns a new Money with self.amount * factor.
    4. Decimal(str(factor)) β€” converts the factor safely to Decimal. If factor is a float, we still capture its human form.
    5. __rmul__ = __mul__ β€” the reflected form. It is the same function because multiplication is commutative. When Python tries 3 * price, int.__mul__ fails and the reflected method is called.
    6. def __lt__(self, other) β€” the one ordering primitive @total_ordering needs. It rejects non-Money and checks currencies.
    7. self.amount < other.amount β€” Decimal supports comparison, so this just works.
    8. The demonstration lines β€” price * 3 and 3 * price both produce USD 59.97. min and max use __lt__. sorted arranges the list by amount.
    9. Money("5") < Money("10") β€” the direct call. True.
    10. Money("5") <= Money("5") β€” __le__ was synthesized by @total_ordering. True.
    11. The final test β€” mixed-currency comparison raises ValueError, not TypeError β€” because our code raised it intentionally.

    ⚠️ Common mistakes

    Forgetting @total_ordering. Without it, only the operators you define work. Money("5") >= Money("3") would raise TypeError.

    Not defining __rmul__. Then 3 * price fails β€” even though price * 3 works.

    Multiplying two Moneys. Money("5") * Money("3") silently returns USD 15.00. That doesn't make financial sense. Add a check that rejects isinstance(factor, Money).

    πŸ§ͺ Try it yourself

    Experiments:

    • Remove __rmul__ = __mul__ and re-run. Read the error message.
    • Add a __truediv__ for price / 2. Should it round down or round half up?
    • Add __floordiv__ that returns the number of "whole units" β€” useful for splitting a bill.
    • Try to sort a list mixing currencies. What error appears?

    Program 3 β€” The Currency Converter

    Now the "converter" part. Rates are held in one place. A single method does the conversion β€” but returns a new Money object; it never mutates the original.

    from decimal import Decimal, ROUND_HALF_UP
    
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            self.currency = currency.upper()
    
        def __repr__(self):
            return f"Money({self.amount!s}, {self.currency!r})"
    
        def __str__(self):
            return f"{self.currency} {self.amount:,.2f}"
    
    class CurrencyConverter:
        def __init__(self, rates=None):
            self.rates = rates or {
                ("USD", "PHP"): Decimal("56.00"),
                ("PHP", "USD"): Decimal("1") / Decimal("56.00"),
                ("USD", "EUR"): Decimal("0.92"),
                ("EUR", "USD"): Decimal("1") / Decimal("0.92"),
                ("USD", "JPY"): Decimal("149.50"),
                ("JPY", "USD"): Decimal("1") / Decimal("149.50"),
            }
    
        def rate(self, source, target):
            source, target = source.upper(), target.upper()
            if source == target:
                return Decimal("1")
            key = (source, target)
            if key not in self.rates:
                raise KeyError(f"No rate for {source} β†’ {target}")
            return self.rates[key]
    
        def convert(self, money, target):
            rate = self.rate(money.currency, target)
            return Money(money.amount * rate, target)
    
    converter = CurrencyConverter()
    salary = Money("5000", "USD")
    print(salary)
    print(converter.convert(salary, "PHP"))
    print(converter.convert(salary, "EUR"))
    print(converter.convert(salary, "JPY"))
    print(converter.convert(salary, "USD"))
    
    try:
        converter.convert(salary, "AUD")
    except KeyError as err:
        print("KeyError:", err)
    
    class Product:
        def __init__(self, name, price):
            self.name = name
            self.price = price
    
    products = [
        Product("Coffee", Money("4.50", "USD")),
        Product("Lunch", Money("12.00", "USD")),
    ]
    for p in products:
        print(f"{p.name:<8} {p.price}  β†’  {converter.convert(p.price, 'PHP')}")
    Expected output
    USD 5,000.00 PHP 280,000.00 EUR 4,600.00 JPY 747,500.00 USD 5,000.00 KeyError: 'No rate for USD β†’ AUD' Coffee USD 4.50 β†’ PHP 252.00 Lunch USD 12.00 β†’ PHP 672.00

    πŸ” Deep dive β€” why return a new object?

    You could design convert to mutate. Don't. Here is why immutability pays off.

    DesignMutatingReturning new
    Called twiceConverts twice β€” 5000 β†’ 280000 β†’ 15,680,000 (bug!)Same result each time.
    Original salarySilently changedPreserved.
    Thread-safe?NoYes.
    Hashable?Danger β€” hash can change mid-dict.Safe.

    The rule: any method returning money must return a new Money. If you feel tempted to mutate, you need to stop and think about why.

    πŸ“– Line-by-line walkthrough

    1. class CurrencyConverter: β€” a pure data holder. No inheritance needed. The design is a table plus a lookup.
    2. def __init__(self, rates=None) β€” accepts an optional rates dict. If none given, uses defaults. This makes the class testable: pass fake rates in tests.
    3. rates or { ... } β€” Python's idiom for defaults. If rates is falsy (None or empty), use the literal.
    4. ("USD", "PHP"): Decimal("56.00") β€” a tuple key. The pair ("USD", "PHP") is one lookup key.
    5. Decimal("1") / Decimal("56.00") β€” the reverse rate is computed once at init. This is more accurate than typing 0.017857.
    6. def rate(self, source, target) β€” public helper. Normalizes both currency codes, handles the trivial same-currency case, and raises KeyError for unknown pairs.
    7. if source == target: return Decimal("1") β€” you don't need a table entry for a no-op conversion.
    8. if key not in self.rates: raise KeyError(...) β€” explicit failure. The error message tells you exactly what's missing.
    9. def convert(self, money, target) β€” one line of logic: look up the rate, multiply, wrap in Money. The Money constructor rounds and normalizes the currency.
    10. return Money(money.amount * rate, target) β€” the new object. Original money is never touched.
    11. The demonstration lines β€” one salary, four targets. Each convert call is independent.
    12. Money("5000", "USD") β€” even though the amount is a whole number, it still becomes USD 5,000.00 because we round to two decimals uniformly.
    13. Product class β€” very simple, but it demonstrates the real pattern: the converter slots right into a shopping cart.
    14. The final loop β€” f"{p.name:<8}" left-aligns the name in 8 characters. The β†’ is just a unicode arrow. Both product prices convert via the same method.

    ⚠️ Common mistakes

    Storing rates as floats. Use Decimal("56.00"), not 56.0. Otherwise every conversion reintroduces float error.

    Storing only one direction. You can derive the reverse, but if precision matters, store both as Decimal values.

    Forgetting to normalize currency codes. If Money stores "USD" but the converter asks for "usd", lookup fails. We call .upper() on both sides.

    πŸ§ͺ Try it yourself

    Experiments:

    • Add GBP ↔ USD at 1.27. Then verify the reverse rate approximates 1/1.27.
    • Make rates read from environment variables. Then a single deployment works against staging and production rate servers.
    • Add a convert_all(money, targets) that returns a dict for multi-currency display.
    • Add an optional timestamp to each rate. This is what real rate APIs return.

    Program 4 β€” Mixed-Currency Arithmetic (Fixed)

    So far, mixed-currency addition raises an error. In real life, if you spend 100 USD and 5000 PHP, you want a total β€” probably in your home currency. The code below fixes the previous KeyError by introducing a base-currency routing method in the Rates class.

    from decimal import Decimal, ROUND_HALF_UP
    
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            self.currency = currency.upper()
    
        def __add__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            if self.currency != other.currency:
                raise ValueError(f"Cannot add {self.currency} and {other.currency}")
            return Money(self.amount + other.amount, self.currency)
    
        def __repr__(self):
            return f"Money({self.amount!s}, {self.currency!r})"
    
        def __str__(self):
            return f"{self.currency} {self.amount:,.2f}"
    
    class Wallet:
        def __init__(self, converter):
            self.converter = converter
            self.balances = {}
    
        def add(self, money):
            self.balances[money.currency] = self.balances.get(money.currency, Money("0", money.currency)) + money
    
        def total(self, target):
            result = Money("0", target)
            for currency, amount in self.balances.items():
                result = result + self.converter.convert(amount, target)
            return result
    
    class Rates:
        def __init__(self):
            self.table = {
                "PHP": Decimal("56.00"),
                "EUR": Decimal("0.92"),
                "GBP": Decimal("0.79"),
            }
    
        def convert(self, money, target):
            if money.currency == target:
                return money
                
            target_upper = target.upper()
            source_upper = money.currency.upper()
            
            if source_upper == "USD":
                source_to_usd = Decimal("1")
            elif source_upper in self.table:
                source_to_usd = Decimal("1") / self.table[source_upper]
            else:
                raise KeyError(f"Unsupported currency: {source_upper}")
    
            if target_upper == "USD":
                usd_to_target = Decimal("1")
            elif target_upper in self.table:
                usd_to_target = self.table[target_upper]
            else:
                raise KeyError(f"Unsupported currency: {target_upper}")
    
            final_rate = source_to_usd * usd_to_target
            return Money(money.amount * final_rate, target)
    
    wallet = Wallet(Rates())
    wallet.add(Money("100.00", "USD"))
    wallet.add(Money("5600.00", "PHP"))
    wallet.add(Money("50.00", "EUR"))
    
    print("Balances:", wallet.balances)
    print("In USD:", wallet.total("USD"))
    print("In PHP:", wallet.total("PHP"))
    print("In EUR:", wallet.total("EUR"))
    
    try:
        Money("1.00", "USD") + Money("1.00", "PHP")
    except ValueError as err:
        print("ValueError:", err)
    Expected output
    Balances: {'USD': Money(100.00, 'USD'), 'PHP': Money(5600.00, 'PHP'), 'EUR': Money(50.00, 'EUR')} In USD: USD 246.00 In PHP: PHP 13,776.00 In EUR: EUR 226.32 ValueError: Cannot add USD and PHP

    πŸ” Deep dive β€” the two legitimate designs

    QuestionRefuse mixed addAuto-convert
    Predictable?Yes β€” nothing hidden.No β€” the rate table becomes a hidden dependency.
    Silent wrong answer?Impossible β€” raises.Possible if rate is stale.
    Great forPayment math, accountingDisplay, "show me everything in my home currency"
    WhereInside MoneyInside a Wallet / portfolio object

    The design we chose: Money refuses mixed arithmetic. The Wallet object β€” which knows the converter β€” does the conversion explicitly. Each class carries only what it can safely handle.

    πŸ“– Line-by-line walkthrough

    1. def __add__ raising on mixed currencies β€” the class-level policy. Same design as Program 1.
    2. class Wallet β€” the composer. It holds multiple currency amounts and knows a converter. This is aggregation (has-a), not inheritance.
    3. self.balances = {} β€” a dictionary keyed by currency code. Amounts are Money objects, not floats.
    4. self.balances.get(money.currency, Money("0", money.currency)) β€” a neat idiom. If the key is present, returns its value. If not, returns a zero Money in that currency.
    5. + money β€” the (same-currency) Money add. This triggers __add__, which passes because currencies match.
    6. def total(self, target) β€” starts with a zero Money in the target currency. Then adds each converted balance.
    7. result = result + self.converter.convert(amount, target) β€” the convert first produces a Money in the target, then + adds it β€” same currency now, so it succeeds.
    8. class Rates β€” a simpler converter used only by the wallet. It keeps the demo self-contained.
    9. if money.currency == target: return money β€” short-circuit. No lookup needed for same-currency.
    10. source_to_usd and usd_to_target β€” this is the fix. Instead of looking for a direct ("EUR", "PHP") key, we convert EUR to USD first, then USD to PHP. This means we only need to store rates relative to USD.
    11. wallet.add(Money("100", "USD")) β€” adds $100. Then wallet.add(Money("5600", "PHP")) adds 5600 PHP. Then 50 EUR.
    12. wallet.total("USD") β€” converts each balance: $100 + ($5600/56=) $100 + (50*0.92=) $46 = $246. The math now works perfectly.

    ⚠️ Common mistakes

    Auto-converting inside __add__. That couples Money to a global rates table. Now Money cannot be used in a context where the rates might be stale or missing.

    Using dict.setdefault with a mutable default. defaultdict(Money) would fail because Money() requires arguments.

    Storing each currency in a separate attribute. wallet.usd, wallet.php, wallet.eur doesn't scale. A dict is the right structure.

    πŸ§ͺ Try it yourself

    Experiments:

    • Add a Wallet.spend(money) method that subtracts and refuses to go negative.
    • Modify Wallet.total to accept a list of targets and return a dict of totals.
    • Change Wallet to use defaultdict. What constructor argument do you need?
    • Add a __repr__ to Wallet that shows the balance summary in one line.

    Program 5 β€” Full Checkout Flow

    Tie it all together: a shopping cart with taxes, discounts, currency selection, and a receipt. This is a mini version of what a real e-commerce checkout does.

    from decimal import Decimal, ROUND_HALF_UP
    from functools import total_ordering
    
    @total_ordering
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            self.currency = currency.upper()
    
        def _check(self, other):
            if self.currency != other.currency:
                raise ValueError(f"Cannot add {self.currency} and {other.currency}")
    
        def __add__(self, other):
            if not isinstance(other, Money): return NotImplemented
            self._check(other)
            return Money(self.amount + other.amount, self.currency)
    
        def __sub__(self, other):
            if not isinstance(other, Money): return NotImplemented
            self._check(other)
            return Money(self.amount - other.amount, self.currency)
    
        def __mul__(self, factor):
            if not isinstance(factor, (int, Decimal)): return NotImplemented
            return Money(self.amount * Decimal(str(factor)), self.currency)
    
        __rmul__ = __mul__
    
        def __eq__(self, other):
            if not isinstance(other, Money): return NotImplemented
            return (self.amount, self.currency) == (other.amount, other.currency)
    
        def __lt__(self, other):
            if not isinstance(other, Money): return NotImplemented
            self._check(other)
            return self.amount < other.amount
    
        def __hash__(self):
            return hash((self.amount, self.currency))
    
        def __repr__(self):
            return f"Money({self.amount!s}, {self.currency!r})"
    
        def __str__(self):
            return f"{self.currency} {self.amount:,.2f}"
    
    class LineItem:
        def __init__(self, name, unit_price, quantity):
            self.name = name
            self.unit_price = unit_price
            self.quantity = quantity
    
        def subtotal(self):
            return self.unit_price * self.quantity
    
    class Cart:
        def __init__(self, tax_rate=Decimal("0.12")):
            self.items = []
            self.tax_rate = tax_rate
            self.discount = None
    
        def add(self, item):
            self.items.append(item)
            return self
    
        def apply_discount(self, amount):
            self.discount = amount
            return self
    
        def subtotal(self):
            total = Money("0", self.items[0].unit_price.currency) if self.items else Money("0")
            for item in self.items:
                total = total + item.subtotal()
            return total
    
        def tax(self):
            return self.subtotal() * self.tax_rate
    
        def total(self):
            base = self.subtotal() + self.tax()
            if self.discount:
                base = base - self.discount
            return base
    
        def receipt(self):
            print("--- RECEIPT ---")
            for item in self.items:
                print(f"{item.name:<14} {item.quantity:>3} x {item.unit_price} = {item.subtotal()}")
            print(f"{'Subtotal':<14} {self.subtotal()}")
            print(f"{'Tax (12%)':<14} {self.tax()}")
            if self.discount:
                print(f"{'Discount':<14} -{self.discount}")
            print(f"{'TOTAL':<14} {self.total()}")
            print("---------------")
    
    cart = Cart()
    cart.add(LineItem("Notebook", Money("12.00"), 2))
    cart.add(LineItem("Pen set", Money("5.50"), 3))
    cart.add(LineItem("Backpack", Money("49.99"), 1))
    cart.apply_discount(Money("10.00"))
    
    cart.receipt()
    
    print()
    print("Is total under budget?", cart.total() < Money("100.00"))
    print("Is total above budget?", cart.total() > Money("100.00"))
    Expected output
    --- RECEIPT --- Notebook 2 x USD 12.00 = USD 24.00 Pen set 3 x USD 5.50 = USD 16.50 Backpack 1 x USD 49.99 = USD 49.99 Subtotal USD 90.49 Tax (12%) USD 10.86 Discount -USD 10.00 TOTAL USD 91.35 --------------- Is total under budget? True Is total above budget? False

    πŸ” Deep dive β€” how total() composes

    Each step in the checkout chain is a polymorphic dispatch on Money:

    StepWhat runsDunder
    item.subtotal()unit_price * quantity__mul__
    subtotal + itemtwo Money objects add__add__
    subtotal() * rateMoney Γ— Decimal__mul__
    subtotal + taxMoney + Money__add__
    total - discountMoney - Money__sub__
    total < Money("100")Money compared to Money__lt__

    The elegance: the Cart class never does decimal math. It just chains Money operations. If we swap to a different currency, the entire cart still works β€” no code change.

    πŸ“– Line-by-line walkthrough

    1. @total_ordering and the full dunder set β€” same Money class as before. No new operators; we're just using them all together now.
    2. class LineItem β€” a small data holder. subtotal() uses Money Γ— int.
    3. class Cart β€” the orchestrator. It contains items and policy (tax, discount).
    4. def __init__(self, tax_rate=Decimal("0.12")) β€” a default 12% tax rate. The default is a Decimal so it never drifts.
    5. def add(self, item) β€” appends and returns self. This is a fluent interface; you can chain cart.add(a).add(b).
    6. def apply_discount(self, amount) β€” stores a Money object. Returns self for chaining.
    7. def subtotal(self) β€” sums all item.subtotal() starting from a zero Money in the item's currency. If the cart is empty, defaults to USD zero.
    8. total = Money("0", self.items[0].unit_price.currency) if self.items else Money("0") β€” the ternary guards against an empty cart. Takes the currency from the first item.
    9. total = total + item.subtotal() β€” the polymorphic add. Works only because all items share a currency.
    10. def tax(self) β€” money Γ— Decimal. Simple.
    11. def total(self) β€” subtotal + tax, then optionally subtract discount. Three operator calls in two lines.
    12. def receipt(self) β€” the display. Uses format specs for column alignment.
    13. f"{item.name:<14}" β€” left-align to 14 chars. f"{item.quantity:>3}" β€” right-align to 3.
    14. The demo cart β€” Notebook $12 Γ— 2, Pen set $5.50 Γ— 3, Backpack $49.99 Γ— 1. Subtotal 24 + 16.50 + 49.99 = $90.49. Tax 12% = $10.86. Discount $10. Total $91.35.
    15. The final two lines β€” comparison with Money. $91.35 < $100.00 is True.

    ⚠️ Common mistakes

    Mixing currencies inside a cart. All items must be in the same currency. If they aren't, convert them at add() time.

    Forgetting to zero-start with the correct currency. If we started with Money("0") (USD) and the cart held PHP items, the first add raises.

    Floating-point tax rate. tax_rate=0.12 would accumulate error. Always a Decimal.

    πŸ§ͺ Try it yourself

    Experiments:

    • Add Cart.remove(name) that removes an item by name.
    • Add per-item discount as a percentage. What operator do you need?
    • Change the tax rate to 0 and see the total. Then to 1 (100%) and see the effect.
    • Add a second currency cart β€” convert at add() time using a converter you pass in.

    Program 6 β€” Polymorphic Payment Methods

    The last piece: the same checkout() call handles cards, cash, and wallets. Each method calculates its fee differently. New methods slot in without touching the caller.

    from decimal import Decimal, ROUND_HALF_UP
    
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            self.currency = currency.upper()
    
        def __add__(self, other):
            if not isinstance(other, Money): return NotImplemented
            if self.currency != other.currency:
                raise ValueError(f"Cannot add {self.currency} and {other.currency}")
            return Money(self.amount + other.amount, self.currency)
    
        def __mul__(self, factor):
            if not isinstance(factor, (int, Decimal)): return NotImplemented
            return Money(self.amount * Decimal(str(factor)), self.currency)
    
        def __str__(self):
            return f"{self.currency} {self.amount:,.2f}"
    
    class PaymentMethod:
        def fee(self, amount):
            raise NotImplementedError
    
        def checkout(self, amount):
            fee = self.fee(amount)
            total = amount + fee
            return f"{type(self).__name__:<12} total {total}  (fee {fee})"
    
    class CardPayment(PaymentMethod):
        def fee(self, amount):
            return amount * Decimal("0.029") + Money("15.00", amount.currency)
    
    class CashPayment(PaymentMethod):
        def fee(self, amount):
            return Money("0", amount.currency)
    
    class WalletPayment(PaymentMethod):
        def fee(self, amount):
            return amount * Decimal("0.02")
    
    class CryptoPayment(PaymentMethod):
        def fee(self, amount):
            return amount * Decimal("0.01")
    
    methods = [CardPayment(), CashPayment(), WalletPayment(), CryptoPayment()]
    amount = Money("1000.00")
    
    for m in methods:
        print(m.checkout(amount))
    
    try:
        PaymentMethod().checkout(amount)
    except NotImplementedError:
        print("NotImplementedError: use a concrete payment class")
    Expected output
    CardPayment total USD 1,044.00 (fee USD 44.00) CashPayment total USD 1,000.00 (fee USD 0.00) WalletPayment total USD 1,020.00 (fee USD 20.00) CryptoPayment total USD 1,010.00 (fee USD 10.00) NotImplementedError: use a concrete payment class

    πŸ” Deep dive β€” why checkout is a Template Method

    The base class defines the steps of checkout. Subclasses define only the fee. Python dispatches self.fee(amount) on the real subclass.

    StepWhat runsWhere
    1. Compute feeself.fee(amount)Overridden per subclass
    2. Add feeamount + feeShared, in base class
    3. Format receiptf-stringShared, in base class

    Zero duplicate code. Every subclass inherits the checkout algorithm. They differ only in the one rule that actually differs. This is polymorphism at its most useful.

    Want a new method? Write class BankTransfer(PaymentMethod) with its own fee. The loop that iterates methods doesn't need to know it exists.

    πŸ“– Line-by-line walkthrough

    1. Money class β€” a minimal version for this demo. Only what we need: +, *, str.
    2. class PaymentMethod β€” the base. Provides a full checkout() and a placeholder fee().
    3. def fee(self, amount): raise NotImplementedError β€” explicit failure. Calling it signals a subclass bug.
    4. def checkout(self, amount) β€” the Template Method. Three steps: fee, add, format. self.fee(amount) will dispatch to the subclass.
    5. type(self).__name__ β€” gets the class name as a string. So CardPayment, CashPayment, etc. work without hard-coding.
    6. class CardPayment(PaymentMethod) β€” inherits from the base. Only overrides fee.
    7. amount * Decimal("0.029") + Money("15.00", amount.currency) β€” a percentage plus a fixed amount. Note that Money(...) needs the same currency as amount.
    8. CashPayment.fee β€” a zero Money. Uses amount.currency so it works with any input.
    9. WalletPayment.fee and CryptoPayment.fee β€” pure percentages. Small changes, big variety.
    10. methods = [...] β€” the polymorphic list. Each object knows its own fee.
    11. The for loop β€” one line, four behaviors. Prints a full receipt per method.
    12. PaymentMethod().checkout(amount) β€” instantiating the base directly. self.fee(amount) hits the base method and raises NotImplementedError.

    ⚠️ Common mistakes

    Forgetting to override fee. Then checkout raises NotImplementedError. Use abc.ABC + @abstractmethod to catch this at creation time.

    Hardcoding currency inside a subclass. Money("15.00", "USD") would break if the cart is in PHP. Use amount.currency instead.

    Returning None from fee in one method. Then amount + fee raises TypeError. Always return a Money.

    πŸ§ͺ Try it yourself

    Experiments:

    • Add BankTransfer with a flat 25 fee. How many other lines did you touch?
    • Make the base PaymentMethod use abc.ABC + @abstractmethod. What changes when you instantiate the base?
    • Add a refund method to the base that returns a negative Money β€” and let subclasses override if refunds differ.
    • Add a fee_currency attribute so the fee can be in a different currency (e.g., fixed fees always in PHP).

    Program 7 β€” Live Interactive Converter (Full App)

    Here is the fully upgraded, production-grade program. It fetches live exchange rates from an API, handles offline fallbacks, supports multiple currencies, and logs history to CSV and TXT files. When you press Run, browser prompts will appear asking for your menu choices.

    from decimal import Decimal, ROUND_HALF_UP
    import urllib.request
    import json
    import csv
    from datetime import datetime
    
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            self.currency = currency.upper()
    
        def __str__(self):
            return f"{self.currency} {self.amount:,.2f}"
    
    class InteractiveConverter:
        def __init__(self):
            # Default fallback rates in case of offline usage
            self.rates = {
                "USD": Decimal("1.00"),
                "PHP": Decimal("56.00"),
                "EUR": Decimal("0.92"),
                "JPY": Decimal("149.50"),
                "GBP": Decimal("0.79"),
                "AUD": Decimal("1.52"),
                "CAD": Decimal("1.36")
            }
            self.fetch_live_rates()
    
        def fetch_live_rates(self):
            """Fetches live exchange rates using a free open API endpoint."""
            url = "https://open.er-api.com/v6/latest/USD"
            try:
                req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
                with urllib.request.urlopen(req, timeout=3) as response:
                    data = json.loads(response.read().decode())
                    if data.get("result") == "success":
                        self.rates = {code: Decimal(str(rate)) for code, rate in data["rates"].items()}
                        print("[Info] Successfully fetched live exchange rates!")
            except Exception as e:
                print("[Warning] Could not fetch live rates. Using offline fallback rates.")
    
        def get_rate(self, source, target):
            source = source.upper()
            target = target.upper()
            if source not in self.rates:
                raise ValueError(f"Unsupported source currency: {source}")
            if target not in self.rates:
                raise ValueError(f"Unsupported target currency: {target}")
                
            usd_amount = Decimal("1") / self.rates[source]
            return usd_amount * self.rates[target]
    
        def convert(self, money, target_currency):
            rate = self.get_rate(money.currency, target_currency)
            return Money(money.amount * rate, target_currency)
    
    class HistoryManager:
        """Manages reading and logging conversion history to both CSV and TXT files."""
        def __init__(self, csv_filename="conversion_history.csv", txt_filename="conversion_history.txt"):
            self.csv_filename = csv_filename
            self.txt_filename = txt_filename
            self._init_csv()
    
        def _init_csv(self):
            try:
                with open(self.csv_filename, mode='x', newline='', encoding='utf-8') as f:
                    writer = csv.writer(f)
                    writer.writerow(["Timestamp", "Source Currency", "Source Amount", "Target Currency", "Target Amount", "Exchange Rate"])
            except FileExistsError:
                pass
    
        def log_conversion(self, money_from, money_to, rate):
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            
            # Append to CSV
            with open(self.csv_filename, mode='a', newline='', encoding='utf-8') as f:
                writer = csv.writer(f)
                writer.writerow([timestamp, money_from.currency, str(money_from.amount), money_to.currency, str(money_to.amount), f"{rate:.6f}"])
                
            # Append to formatted TXT log
            with open(self.txt_filename, mode='a', encoding='utf-8') as f:
                f.write(f"[{timestamp}] {money_from} -> {money_to} (Rate: 1 {money_from.currency} = {rate:.4f} {money_to.currency})\n")
    
        def show_history(self):
            try:
                with open(self.txt_filename, mode='r', encoding='utf-8') as f:
                    logs = f.readlines()
                    if not logs:
                        print("\n--- No Conversion History Found ---")
                        return
                    print("\n=====================================")
                    print("       CONVERSION HISTORY LOG        ")
                    print("=====================================")
                    for log in logs[-10:]:  # Show last 10 conversions
                        print(log.strip())
                    print("=====================================")
            except FileNotFoundError:
                print("\n--- No Conversion History Found ---")
    
    def main():
        converter = InteractiveConverter()
        history = HistoryManager()
        
        while True:
            print("\n=====================================")
            print("   LIVE INTERACTIVE CONVERTER        ")
            print("=====================================")
            print(f"Loaded {len(converter.rates)} currencies available.")
            print("Options: [1] Convert Currency  [2] View History  [3] Exit")
            
            try:
                choice = input("Select an option (1-3): ").strip()
                
                if choice == "2":
                    history.show_history()
                    continue
                elif choice == "3":
                    print("Thank you for using Interactive Currency Converter. Goodbye!")
                    break
                elif choice != "1":
                    print("Invalid choice. Please enter 1, 2, or 3.")
                    continue
    
                source_curr = input("\nEnter the currency you have (e.g., USD, PHP, EUR, JPY): ").strip().upper()
                if source_curr not in converter.rates:
                    print(f"Error: {source_curr} is not supported.")
                    continue
    
                amount_str = input(f"Enter the amount in {source_curr}: ").strip()
                try:
                    amount = Decimal(amount_str)
                    if amount <= 0:
                        print("Error: Amount must be greater than zero.")
                        continue
                except:
                    print("Error: Invalid number format.")
                    continue
    
                target_curr = input("Enter the target currency: ").strip().upper()
                if target_curr not in converter.rates:
                    print(f"Error: {target_curr} is not supported.")
                    continue
    
                money = Money(amount, source_curr)
                result = converter.convert(money, target_curr)
                rate = converter.get_rate(source_curr, target_curr)
                
                print("\n-------------------------------------")
                print(f"Result: {money} = {result}")
                print(f"Exchange Rate: 1 {source_curr} = {rate:.4f} {target_curr}")
                print("-------------------------------------")
    
                # Log to history
                history.log_conversion(money, result, rate)
                print("[Info] Conversion saved to history!")
    
            except KeyboardInterrupt:
                print("\nProgram cancelled.")
                break
    
    if __name__ == "__main__":
        main()
    Expected output (Interactive)
    [Warning] Could not fetch live rates. Using offline fallback rates. ===================================== LIVE INTERACTIVE CONVERTER ===================================== Loaded 7 currencies available. Options: [1] Convert Currency [2] View History [3] Exit Select an option (1-3): [1] Enter the currency you have (e.g., USD, PHP, EUR, JPY): [USD] Enter the amount in USD: [100] Enter the target currency: [PHP] ------------------------------------- Result: USD 100.00 = PHP 5,600.00 Exchange Rate: 1 USD = 56.0000 PHP ------------------------------------- [Info] Conversion saved to history! ===================================== LIVE INTERACTIVE CONVERTER ===================================== Loaded 7 currencies available. Options: [1] Convert Currency [2] View History [3] Exit Select an option (1-3): [2] ===================================== CONVERSION HISTORY LOG ===================================== [2026-10-20 15:45:12] USD 100.00 -> PHP 5,600.00 (Rate: 1 USD = 56.0000 PHP) ===================================== ===================================== LIVE INTERACTIVE CONVERTER ===================================== Loaded 7 currencies available. Options: [1] Convert Currency [2] View History [3] Exit Select an option (1-3): [3] Thank you for using Interactive Currency Converter. Goodbye!

    πŸ“– Line-by-line walkthrough

    1. fetch_live_rates β€” tries to reach the API. Uses urllib to fetch a JSON file. If the network works, it loads the real rates. If it fails (like in this browser sandbox), it prints a warning and keeps the safe offline defaults.
    2. HistoryManager β€” Pyodide creates an in-memory virtual file system. Writing to conversion_history.csv and conversion_history.txt works during your session, but resets when you refresh the page.
    3. log_conversion β€” saves the data in two formats. The CSV is machine-readable (Excel); the TXT is human-readable.
    4. while True loop β€” keeps the menu running until the user types "3".
    5. choice == "2" β€” triggers show_history(), which reads the last 10 lines from the TXT log.
    6. amount = Decimal(amount_str) β€” ensures the amount is exact. Errors (like "abc") are caught and handled cleanly.
    7. KeyboardInterrupt β€” a safety net in case you cancel a prompt. In a real terminal, this is Ctrl+C. In our browser environment, tapping Cancel on the prompt dialog raises it.

    ⚠️ Common mistakes

    Assuming network requests always succeed. The try/except block ensures the program doesn't crash if the internet is down.

    Writing directly to files without handling exceptions. If a file is locked or the disk is full, Python raises an IOError. A production app would wrap this in try/except.

    πŸ§ͺ Try it yourself

    Experiments:

    • Type "ABC" as your source currency. See how the loop safely rejects it and asks again.
    • Type "abc" instead of a number for the amount. Watch the InvalidOperation exception handling in action.
    • Add "CHF" (Swiss Franc) to the self.rates dictionary, then convert to it.
    Advertisement

    Hands-On Exercises

    Reading code teaches recognition; writing it teaches understanding. Try each exercise on your own first, then open the solution. Every solution can be run right here.

    Exercise 1 β€” Add a Subtract-and-Negate

    Warm-up

    Task: extend Money with a working - operator and a unary - (negation). Test that -Money("5.00") prints USD -5.00 and that a - b is USD 15.00.

    Hint: binary subtraction uses __sub__; unary negation uses __neg__ (no second argument).

    Show solution & explanation
    from decimal import Decimal, ROUND_HALF_UP
    
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            self.currency = currency.upper()
    
        def _check(self, other):
            if self.currency != other.currency:
                raise ValueError(f"Currency mismatch: {self.currency} vs {other.currency}")
    
        def __sub__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            self._check(other)
            return Money(self.amount - other.amount, self.currency)
    
        def __neg__(self):
            return Money(-self.amount, self.currency)
    
        def __str__(self):
            return f"{self.currency} {self.amount:,.2f}"
    
    print(Money("20.00") - Money("5.00"))
    print(-Money("5.00"))
    print(-(Money("20.00") - Money("5.00")))
    Expected output
    USD 15.00 USD -5.00 USD -15.00

    __sub__ takes a second argument (binary). __neg__ takes only self (unary). Both return new Money objects, preserving immutability.

    Exercise 2 β€” Division for Splitting Bills

    Medium

    Task: add __truediv__ so Money("100") / 3 gives a per-person share. Then add __floordiv__ returning the "whole unit count" (useful for tips). Test both.

    Hint: __truediv__ should round (2 decimals). __floordiv__ should return an int β€” the count of full units.

    Show solution & explanation
    from decimal import Decimal, ROUND_HALF_UP
    
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            self.currency = currency.upper()
    
        def __truediv__(self, divisor):
            if not isinstance(divisor, (int, Decimal)):
                return NotImplemented
            if divisor == 0:
                raise ZeroDivisionError("Cannot divide money by zero")
            return Money(self.amount / Decimal(str(divisor)), self.currency)
    
        def __floordiv__(self, divisor):
            if not isinstance(divisor, (int, Decimal)):
                return NotImplemented
            if divisor == 0:
                raise ZeroDivisionError("Cannot divide money by zero")
            return int(self.amount // Decimal(str(divisor)))
    
        def __str__(self):
            return f"{self.currency} {self.amount:,.2f}"
    
    total = Money("100.00")
    print("Each pays:", total / 3)
    print("Whole units:", total // 3)
    print("Whole units (7):", total // 7)
    Expected output
    Each pays: USD 33.33 Whole units: 33 Whole units (7): 14

    100/3 = 33.33, rounded. 100//3 = 33 whole units. 100//7 = 14 whole units. Note that __floordiv__ returns an int, not a Money β€” it's a count, not a money amount.

    Exercise 3 β€” Multi-Currency Display

    Medium

    Task: write a display_all(amount, targets, converter) that prints a table showing one amount in each target currency.

    Hint: loop over targets, call converter.convert(...), format each row.

    Show solution & explanation
    from decimal import Decimal, ROUND_HALF_UP
    
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            self.currency = currency.upper()
    
        def __str__(self):
            return f"{self.currency} {self.amount:,.2f}"
    
    class Converter:
        def __init__(self):
            self.rates = {
                ("USD", "PHP"): Decimal("56.00"),
                ("USD", "EUR"): Decimal("0.92"),
                ("USD", "JPY"): Decimal("149.50"),
            }
    
        def convert(self, money, target):
            if money.currency == target:
                return money
            key = (money.currency, target)
            if key not in self.rates:
                raise KeyError(f"No rate for {key}")
            return Money(money.amount * self.rates[key], target)
    
    def display_all(amount, targets, converter):
        print(f"--- {amount} ---")
        for target in targets:
            converted = converter.convert(amount, target)
            print(f"  {target:<4} {converted.amount:>12,.2f}")
    
    converter = Converter()
    display_all(Money("100.00"), ["USD", "PHP", "EUR", "JPY"], converter)
    Expected output
    --- USD 100.00 --- USD 100.00 PHP 5,600.00 EUR 92.00 JPY 14,950.00

    Each line is a fresh conversion. The <4 and >12,.2f specifiers align nicely regardless of currency symbol or amount size.

    Exercise 4 β€” Split Between People, Evenly and Fairly

    Medium

    Task: write split(amount, people) that returns a list of Money objects. Sum of shares must equal the original exactly (no rounding loss).

    Hint: give everyone the floor of the division. Then distribute the leftover cents (the remainder) to the first few people. The sum will always match exactly.

    Show solution & explanation
    from decimal import Decimal, ROUND_HALF_UP
    
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            self.currency = currency.upper()
    
        def __add__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            if self.currency != other.currency:
                raise ValueError("Currency mismatch")
            return Money(self.amount + other.amount, self.currency)
    
        def __str__(self):
            return f"{self.currency} {self.amount:,.2f}"
    
    def split(amount, people):
        if people <= 0:
            raise ValueError("people must be positive")
        total_cents = int(amount.amount * 100)
        base = total_cents // people
        remainder = total_cents % people
        shares = []
        for i in range(people):
            cents = base + (1 if i < remainder else 0)
            shares.append(Money(Decimal(cents) / 100, amount.currency))
        return shares
    
    shares = split(Money("100.00"), 3)
    for s in shares:
        print(s)
    
    total = Money("0")
    for s in shares:
        total = total + s
    print("Sum:", total)
    Expected output
    USD 33.34 USD 33.33 USD 33.33 Sum: USD 100.00

    100.00 / 3 = 33.333..., so two people pay 33.33 and one pays 33.34. The sum matches exactly β€” no missing cent, no duplicating cent. This is how real payment apps split bills.

    Exercise 5 β€” A Savings Goal Tracker

    Medium

    Task: write a SavingsGoal class holding a target and a current amount (both Money). Add a contribute(amount) method. Add a percent() returning the fraction saved. Print progress.

    Hint: percent = (current / target) * 100. You'll need __truediv__ or use current.amount / target.amount.

    Show solution & explanation
    from decimal import Decimal, ROUND_HALF_UP
    
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            self.currency = currency.upper()
    
        def __add__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            if self.currency != other.currency:
                raise ValueError("Currency mismatch")
            return Money(self.amount + other.amount, self.currency)
    
        def __str__(self):
            return f"{self.currency} {self.amount:,.2f}"
    
    class SavingsGoal:
        def __init__(self, target):
            self.target = target
            self.current = Money("0", target.currency)
    
        def contribute(self, amount):
            self.current = self.current + amount
            return self
    
        def percent(self):
            if self.target.amount == 0:
                return Decimal("100")
            return (self.current.amount / self.target.amount * 100).quantize(Decimal("0.01"))
    
    goal = SavingsGoal(Money("500.00"))
    goal.contribute(Money("150.00")).contribute(Money("75.50"))
    print(f"Saved: {goal.current}")
    print(f"Target: {goal.target}")
    print(f"Progress: {goal.percent()}%")
    Expected output
    Saved: USD 225.50 Target: USD 500.00 Progress: 45.10%

    The contribute method returns self so calls can be chained. The percent uses Decimal division for exactness. 225.50/500 Γ— 100 = 45.10%.

    Exercise 6 β€” Debug the Broken Receipt

    Medium

    Task: the receipt below has several bugs. Run it, read each error, fix until it prints correctly. Expect more than one issue.

    from decimal import Decimal
    
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(amount)
            self.currency = currency
    
        def __add__(self, other):
            return Money(self.amount + other.amount, self.currency)
    
        def __str__(self):
            return f"{self.currency} {self.amount:.2f}"
    
    a = Money(10.99)
    b = Money(5.01)
    c = a + b
    print(c)
    print(c == Money(16.00))
    print(len({a, a}))

    Hint: watch out for float-to-Decimal, missing __eq__, missing __hash__, and forgotten rounding.

    Show solution & explanation
    from decimal import Decimal, ROUND_HALF_UP
    
    class Money:
        def __init__(self, amount, currency="USD"):
            self.amount = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
            self.currency = currency.upper()
    
        def __add__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            if self.currency != other.currency:
                raise ValueError("Currency mismatch")
            return Money(self.amount + other.amount, self.currency)
    
        def __eq__(self, other):
            if not isinstance(other, Money):
                return NotImplemented
            return (self.amount, self.currency) == (other.amount, other.currency)
    
        def __hash__(self):
            return hash((self.amount, self.currency))
    
        def __str__(self):
            return f"{self.currency} {self.amount:.2f}"
    
    a = Money(10.99)
    b = Money(5.01)
    c = a + b
    print(c)
    print(c == Money(16.00))
    print(len({a, a}))
    Expected output
    USD 16.00 True 1

    Bug 1: Decimal(amount) on a float captured float error. Bug 2: No __eq__ meant c == Money(16.00) was False by default identity. Bug 3: No __hash__ made the object unhashable. Bug 4: No rounding meant cents could drift at the 15th decimal.

    Free-Form Python Playground

    Write and run any Python code right here β€” no installation. Use it to solve the exercises yourself, try the "Try it yourself" experiments, or just explore polymorphism interactively. The runtime loads once and stays cached.

    πŸ’± playground.py
    Press Run to execute your code.

    Try these challenges: (1) implement a full Money with all comparison operators; (2) build a CurrencyConverter; (3) create a Wallet that totals across currencies; (4) build a Cart with tax and discounts.

    The Money Lab

    Watch a running cart accumulate line items. Change the quantity, add an item, apply a discount, and see the total recalculate live. Notice that the arithmetic never uses floats β€” every operation dispatches to the Money dunder methods.

    πŸ’± Checkout Lab Β· 0 line items Total: USD 0.00
    Add a line item to begin.

    What to notice: the total is recomputed every time you change something, using pure Money arithmetic. There is no += on any float anywhere. Try pushing tax to 20% and watching the total grow.

    Best Practices for Money Code

    Habits That Keep Money Code Safe

    βœ“ DO: Use Decimal internally. Never store money as a float. If you accept a float, convert via str().

    βœ“ DO: Round consistently. Every amount gets the same rounding mode and precision. Pick ROUND_HALF_UP or your country's standard and never deviate.

    βœ“ DO: Refuse mixed-currency arithmetic. Use ValueError with a clear message. Force the caller to convert explicitly.

    βœ“ DO: Return new objects from methods. Treat Money as immutable. Value objects should never mutate.

    βœ“ DO: Define __eq__ and __hash__ together. Otherwise equal money objects in a set behave strangely.

    βœ“ DO: Store currency codes as uppercase. "USD", not "usd". Normalize in __init__.

    βœ— DON'T: Store tax rates as floats. Use Decimal("0.12"). Otherwise every calculation drifts.

    βœ— DON'T: Round inside operators. Round once, at the end of a calculation, or with each Money creation β€” not at multiple intermediate points.

    βœ— DON'T: Ignore currency in comparison. USD 5 == EUR 5 should be False β€” the currencies differ, even if the numeric amounts match.

    βœ— DON'T: Trust float conversion. If you have to store a float anywhere, immediately convert to string then Decimal.

    Money Cheat Sheet

    πŸ’΅ Money Design

    RuleWhy
    Decimal, not floatFloat lies
    str β†’ DecimalPreserves human form
    Round on constructionOne source of truth
    ImmutablePredictable results
    Refuse mismatchesNo silent errors
    Define __eq__ & __hash__Set and dict safety

    βž• Dunders You Need

    OperatorMethod
    a + b__add__
    a - b__sub__
    a * b__mul__ / __rmul__
    a / b__truediv__
    -a__neg__
    a == b__eq__
    a < b__lt__
    sorted(a)__lt__
    str(a)__str__
    repr(a)__repr__

    ⚠️ Common Pitfalls

    MistakeFix
    Decimal(float)Decimal(str(float))
    Float tax rateDecimal("0.12")
    __eq__ without __hash__Define both
    Mixed-currency add_check helper
    Mutating MoneyReturn new objects
    Missing __radd__Only if needed

    🧰 Handy Tools

    ToolPurpose
    DecimalExact fixed-point math
    ROUND_HALF_UPBanker-safe rounding
    @total_orderingDerive all comparisons
    __rmul__Reverse multiplication
    str() then Decimal()Float-to-Decimal safety
    type(self).__name__Polymorphic class name

    Quick Knowledge Check

    Five questions on Money design, operators and polymorphism.

    0/5

    Great effort β€” review any missed questions above.

    Wrapping Up

    🏁 What You've Built

    You now have a complete, production-grade money system. A Money class that refuses mixed arithmetic. A CurrencyConverter with rates. A Wallet that aggregates. A Cart that composes. And a set of polymorphic payment methods β€” all operating through clean, natural syntax.

    More importantly, you learned the discipline of value objects: immutability, exact arithmetic, explicit validation. These habits transfer to dates, coordinates, units, colors β€” anything that is defined by its values.

    7 programs Β· 6 exercises Money + Decimal Full cart checkout Polymorphic payments

    Key Takeaways

    • Money must be Decimal, not float. Floats lie. Always str() before Decimal().
    • Value objects are immutable. Operators return new objects, never mutate.
    • Refuse mixed-currency arithmetic. Convert explicitly β€” don't hide the rate inside __add__.
    • Define __eq__ and __hash__ together. One without the other causes bugs.
    • Use @total_ordering to derive comparisons from __eq__ + __lt__.
    • Compose, don't inherit. Wallet has Money objects and has a converter. It isn't a subclass of either.
    • Template Method for checkout. Base class owns the algorithm; subclasses only change the variable part.
    • Return NotImplemented from operators for unsupported operand types.

    ✨ The money lesson: money isn't just a number β€” it's a number with a currency and a set of rules. A good Money class embodies all of that.

    β€” Glenn Junsay Pansensoy, Poetic Bytes

    Return to Home Polymorphism Guide Back to Top

    Share This Project

    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 Project walkthroughs OOP series updates Free, cancel anytime

    πŸ”’ Your email is never shared or sold.

    Follow Poetic Bytes

    Stay connected for Python tips, OOP insights, and new project walkthroughs.

    Advertisement