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.
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.
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)
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
| Term | Plain-English meaning | Where you'll see it |
|---|---|---|
| Value object | An immutable object whose identity is defined by its values (like Money(10, "USD")). | Money class |
| Operator overloading | Making +, -, *, ==, < work for your own class via dunder methods. | Program 2β4 |
| Dunder method | A "double underscore" hook such as __add__ or __eq__ that Python calls for you. | Every program |
NotImplemented | A 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 |
Decimal | A fixed-point numeric type from the decimal module. Exact, unlike float. | Money storage |
| Immutable | An object whose attributes cannot be reassigned after creation. | Money design |
| Hash | An integer derived from an object's value, used by sets and dict keys. | __hash__ |
| Exchange rate | The multiplier to go from one currency to another. | Converter |
| Polymorphic method | A 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 methodRead 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.
| Message | What it means | Fix |
|---|---|---|
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 USD | Your own _check raised it β you tried to add mixed currencies. | Convert first, then add. |
TypeError: unhashable type | You 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.InvalidOperation | You 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)π 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.
| Input | Wrong way: Decimal(19.99) | Right way: Decimal("19.99") |
|---|---|---|
19.99 | 19.989999999999998436β¦ | 19.99 |
0.1 | 0.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
from decimal import Decimal, ROUND_HALF_UPβ imports the fixed-point type and the rounding mode.ROUND_HALF_UPmeans 2.5 becomes 3, which matches how money is normally rounded.class Money:β a plain class, no inheritance needed. A value object stands alone.def __init__(self, amount, currency="USD"):β the constructor takes any amount-like input and a currency code that defaults to USD.Decimal(str(amount))β converts the input to a string first, then to a Decimal. This is the safety step that prevents float contamination..quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)β snaps the value to two decimal places using banker-safe rounding.self.currency = currency.upper()β normalizes currency codes so"usd"and"USD"are treated as the same.def _check(self, other)β a private helper. RaisesValueErrorif the two objects don't agree on currency. Note the leading underscore by convention.def __add__(self, other)β hooked to+. First rejects non-Money operands viaNotImplemented. Then checks currencies. Then returns a newMoney.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).def __eq__(self, other)β compares both amount and currency.Money("1.00") == Money("1.00", "EUR")correctly returnsFalse.def __hash__(self)β without this, the object is unhashable once__eq__is defined. It uses the same tuple so equal objects hash the same.def __repr__(self)β for developers. Shows the exact fields, useful in debugging.def __str__(self)β for users. Formats with thousands separators and two decimals.Money("0.10") + Money("0.20")β the classic float-fail test. PrintsUSD 0.30β exactly right.- The final
try/exceptβ attempts to add USD and EUR. Our_checkraisesValueErrorwith 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 doesROUND_HALF_UPproduce? - 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)π Deep dive β how the operators resolve
Follow each expression to its underlying method calls.
| You write | Python tries | Result |
|---|---|---|
price * 3 | price.__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 ValueError | Prevents 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
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.@total_orderingβ must appear directly above the class definition. It rewrites the class to add missing comparison methods.def __mul__(self, factor)β accepts any number-like factor. Returns a newMoneywithself.amount * factor.Decimal(str(factor))β converts the factor safely to Decimal. If factor is a float, we still capture its human form.__rmul__ = __mul__β the reflected form. It is the same function because multiplication is commutative. When Python tries3 * price,int.__mul__fails and the reflected method is called.def __lt__(self, other)β the one ordering primitive@total_orderingneeds. It rejects non-Money and checks currencies.self.amount < other.amountβ Decimal supports comparison, so this just works.- The demonstration lines β
price * 3and3 * priceboth produceUSD 59.97.minandmaxuse__lt__.sortedarranges the list by amount. Money("5") < Money("10")β the direct call.True.Money("5") <= Money("5")β__le__was synthesized by@total_ordering.True.- The final test β mixed-currency comparison raises
ValueError, notTypeErrorβ 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__forprice / 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')}")π Deep dive β why return a new object?
You could design convert to mutate. Don't. Here is why immutability pays off.
| Design | Mutating | Returning new |
|---|---|---|
| Called twice | Converts twice β 5000 β 280000 β 15,680,000 (bug!) | Same result each time. |
| Original salary | Silently changed | Preserved. |
| Thread-safe? | No | Yes. |
| 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
class CurrencyConverter:β a pure data holder. No inheritance needed. The design is a table plus a lookup.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.rates or { ... }β Python's idiom for defaults. Ifratesis falsy (None or empty), use the literal.("USD", "PHP"): Decimal("56.00")β a tuple key. The pair("USD", "PHP")is one lookup key.Decimal("1") / Decimal("56.00")β the reverse rate is computed once at init. This is more accurate than typing0.017857.def rate(self, source, target)β public helper. Normalizes both currency codes, handles the trivial same-currency case, and raisesKeyErrorfor unknown pairs.if source == target: return Decimal("1")β you don't need a table entry for a no-op conversion.if key not in self.rates: raise KeyError(...)β explicit failure. The error message tells you exactly what's missing.def convert(self, money, target)β one line of logic: look up the rate, multiply, wrap in Money. TheMoneyconstructor rounds and normalizes the currency.return Money(money.amount * rate, target)β the new object. Originalmoneyis never touched.- The demonstration lines β one salary, four targets. Each
convertcall is independent. Money("5000", "USD")β even though the amount is a whole number, it still becomesUSD 5,000.00because we round to two decimals uniformly.Productclass β very simple, but it demonstrates the real pattern: the converter slots right into a shopping cart.- 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
ratesread from environment variables. Then a single deployment works against staging and production rate servers. - Add a
convert_all(money, targets)that returns adictfor multi-currency display. - Add an optional
timestampto 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)π Deep dive β the two legitimate designs
| Question | Refuse mixed add | Auto-convert |
|---|---|---|
| Predictable? | Yes β nothing hidden. | No β the rate table becomes a hidden dependency. |
| Silent wrong answer? | Impossible β raises. | Possible if rate is stale. |
| Great for | Payment math, accounting | Display, "show me everything in my home currency" |
| Where | Inside Money | Inside 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
def __add__raising on mixed currencies β the class-level policy. Same design as Program 1.class Walletβ the composer. It holds multiple currency amounts and knows a converter. This is aggregation (has-a), not inheritance.self.balances = {}β a dictionary keyed by currency code. Amounts are Money objects, not floats.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.+ moneyβ the (same-currency) Money add. This triggers__add__, which passes because currencies match.def total(self, target)β starts with a zero Money in the target currency. Then adds each converted balance.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.class Ratesβ a simpler converter used only by the wallet. It keeps the demo self-contained.if money.currency == target: return moneyβ short-circuit. No lookup needed for same-currency.source_to_usdandusd_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.wallet.add(Money("100", "USD"))β adds $100. Thenwallet.add(Money("5600", "PHP"))adds 5600 PHP. Then 50 EUR.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.totalto accept a list of targets and return a dict of totals. - Change
Walletto usedefaultdict. What constructor argument do you need? - Add a
__repr__toWalletthat 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"))π Deep dive β how total() composes
Each step in the checkout chain is a polymorphic dispatch on Money:
| Step | What runs | Dunder |
|---|---|---|
item.subtotal() | unit_price * quantity | __mul__ |
subtotal + item | two Money objects add | __add__ |
subtotal() * rate | Money Γ Decimal | __mul__ |
subtotal + tax | Money + Money | __add__ |
total - discount | Money - 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
@total_orderingand the full dunder set β same Money class as before. No new operators; we're just using them all together now.class LineItemβ a small data holder.subtotal()uses Money Γ int.class Cartβ the orchestrator. It contains items and policy (tax, discount).def __init__(self, tax_rate=Decimal("0.12"))β a default 12% tax rate. The default is aDecimalso it never drifts.def add(self, item)β appends and returnsself. This is a fluent interface; you can chaincart.add(a).add(b).def apply_discount(self, amount)β stores a Money object. Returnsselffor chaining.def subtotal(self)β sums allitem.subtotal()starting from a zero Money in the item's currency. If the cart is empty, defaults to USD zero.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.total = total + item.subtotal()β the polymorphic add. Works only because all items share a currency.def tax(self)β money Γ Decimal. Simple.def total(self)β subtotal + tax, then optionally subtract discount. Three operator calls in two lines.def receipt(self)β the display. Uses format specs for column alignment.f"{item.name:<14}"β left-align to 14 chars.f"{item.quantity:>3}"β right-align to 3.- 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.
- The final two lines β comparison with Money.
$91.35 < $100.00is 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")π 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.
| Step | What runs | Where |
|---|---|---|
| 1. Compute fee | self.fee(amount) | Overridden per subclass |
| 2. Add fee | amount + fee | Shared, in base class |
| 3. Format receipt | f-string | Shared, 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
- Money class β a minimal version for this demo. Only what we need:
+,*,str. class PaymentMethodβ the base. Provides a fullcheckout()and a placeholderfee().def fee(self, amount): raise NotImplementedErrorβ explicit failure. Calling it signals a subclass bug.def checkout(self, amount)β the Template Method. Three steps: fee, add, format.self.fee(amount)will dispatch to the subclass.type(self).__name__β gets the class name as a string. SoCardPayment,CashPayment, etc. work without hard-coding.class CardPayment(PaymentMethod)β inherits from the base. Only overridesfee.amount * Decimal("0.029") + Money("15.00", amount.currency)β a percentage plus a fixed amount. Note thatMoney(...)needs the same currency asamount.CashPayment.feeβ a zero Money. Usesamount.currencyso it works with any input.WalletPayment.feeandCryptoPayment.feeβ pure percentages. Small changes, big variety.methods = [...]β the polymorphic list. Each object knows its own fee.- The for loop β one line, four behaviors. Prints a full receipt per method.
PaymentMethod().checkout(amount)β instantiating the base directly.self.fee(amount)hits the base method and raisesNotImplementedError.
β οΈ 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
BankTransferwith a flat 25 fee. How many other lines did you touch? - Make the base
PaymentMethoduseabc.ABC+@abstractmethod. What changes when you instantiate the base? - Add a
refundmethod to the base that returns a negative Money β and let subclasses override if refunds differ. - Add a
fee_currencyattribute 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()π Line-by-line walkthrough
fetch_live_ratesβ tries to reach the API. Usesurllibto 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.HistoryManagerβ Pyodide creates an in-memory virtual file system. Writing toconversion_history.csvandconversion_history.txtworks during your session, but resets when you refresh the page.log_conversionβ saves the data in two formats. The CSV is machine-readable (Excel); the TXT is human-readable.while Trueloop β keeps the menu running until the user types "3".choice == "2"β triggersshow_history(), which reads the last 10 lines from the TXT log.amount = Decimal(amount_str)β ensures the amount is exact. Errors (like "abc") are caught and handled cleanly.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
InvalidOperationexception handling in action. - Add "CHF" (Swiss Franc) to the
self.ratesdictionary, then convert to it.
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-upTask: 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")))__sub__ takes a second argument (binary). __neg__ takes only self (unary). Both return new Money objects, preserving immutability.
Exercise 2 β Division for Splitting Bills
MediumTask: 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)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
MediumTask: 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)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
MediumTask: 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)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
MediumTask: 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()}%")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
MediumTask: 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}))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.
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.
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
| Rule | Why |
|---|---|
| Decimal, not float | Float lies |
| str β Decimal | Preserves human form |
| Round on construction | One source of truth |
| Immutable | Predictable results |
| Refuse mismatches | No silent errors |
| Define __eq__ & __hash__ | Set and dict safety |
β Dunders You Need
| Operator | Method |
|---|---|
| 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
| Mistake | Fix |
|---|---|
| Decimal(float) | Decimal(str(float)) |
| Float tax rate | Decimal("0.12") |
| __eq__ without __hash__ | Define both |
| Mixed-currency add | _check helper |
| Mutating Money | Return new objects |
| Missing __radd__ | Only if needed |
π§° Handy Tools
| Tool | Purpose |
|---|---|
| Decimal | Exact fixed-point math |
| ROUND_HALF_UP | Banker-safe rounding |
| @total_ordering | Derive 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.
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.
Key Takeaways
- Money must be Decimal, not float. Floats lie. Always
str()beforeDecimal(). - 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_orderingto derive comparisons from__eq__+__lt__. - Compose, don't inherit.
Wallethas 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
NotImplementedfrom 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
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.
π Your email is never shared or sold.