Back to Home
Live Project Β· Operator Overloading

Double-Entry
Ledger
sum(entries) == 0.

Every journal entry is a Debit or a Credit object that knows how to negate, add, compare and hash itself. Balance the books with one expression: sum(entries) == 0. No helper functions, no .total() β€” just operators.

This live project is the operator overloading entry in the Polymorphism series. It uses __neg__, __add__, __eq__, __hash__ and __repr__ so that ordinary Python arithmetic drives a real accounting model.

πŸ“… Updated October 27, 2026 ⏱ ~2 hours with exercises πŸŽ“ Beginner β†’ Intermediate 🐍 Python 3.11+ πŸ§ͺ 19 runnable programs πŸ” 580+ lines explained ❓ 12-question quiz
Back to Home Try the Ledger Open Playground Polymorphism Guide

🎯 By the end you can…

  • Explain debits, credits and why books balance
  • Read every line of the ledger program
  • Write __add__, __neg__, __eq__, __hash__ correctly
  • Use Decimal and frozen dataclasses for money
  • Debug the sum() seed problem
  • Test operators with unittest

πŸ“‹ You should already know

  • Functions, lists and dictionaries
  • What a class and a method are (roughly)

Dataclasses, Decimal and operator methods are taught in the primer; accounting is taught in section 01.

πŸ“ˆ Your progress (saved in this browser)0 / 10 sections complete

What Is a Double-Entry Ledger?

πŸ“’ Every Entry Has Two Sides

Accounting's golden rule: every transaction affects at least two accounts. If you receive cash, you also record where it came from. Debits and credits must balance to zero β€” always. If they don't, something is wrong with your books.

In this project, a Debit is a positive amount and a Credit is the exact same amount, negated. Because __add__ and __neg__ are implemented, adding an entry to its opposite produces exactly zero. A ledger is balanced when sum(entries) == 0.

This is polymorphism through operator overloading: the call site sum(entries) doesn't know or care whether it's holding Debits, Credits, or both. It just calls +, and each object answers with its own arithmetic.

Live ledger simulator Runnable code with walkthrough Dunder method reference Free-form playground Knowledge check

The Shape of the Data

A Debit holds a positive amount. A Credit is a Debit that has been negated β€” literally produced by -debit. Both are entries. Both know how to add, compare and hash themselves. A transaction holds two entries and a description.

Note on the design: a Credit is just a Debit with a negative amount. This keeps the ledger simple β€” we don't need two classes to get two behaviors. The operator does the differentiation.

πŸ“œ Accounting in five minutes

You do not need a business background for this page, only five ideas. Double-entry bookkeeping was described in print by Luca Pacioli in 1494 (although merchants used it earlier), and it has survived five centuries because it makes mistakes detectable: every event is written down twice, from two points of view, and the two must agree.

  1. Accounts are labelled buckets of money: Cash, Supplies, Sales Revenue.
  2. A transaction is one business event. It always touches at least two accounts.
  3. A debit is an entry on the left side of an account; a credit is an entry on the right. The words are bookkeeping labels, not “good” and “bad”.
  4. The golden rule: for every transaction, total debits equal total credits.
  5. The ledger is the collection of all accounts. If the golden rule held for every transaction, the whole ledger balances.

Which side makes an account grow?

Account typeExamplesA debit…A credit…
AssetCash, Supplies, Accounts Receivableincreases itdecreases it
ExpenseRent, Wagesincreases itdecreases it
LiabilityLoans, Accounts Payabledecreases itincreases it
EquityOwner’s Equitydecreases itincreases it
RevenueSales Revenue, Service Revenuedecreases itincreases it

These rules come from the accounting equation Assets = Liabilities + Equity, where revenue adds to equity and expenses subtract from it.

The three sample transactions in bookkeeping language

EventDebitCreditAmountWhy
Cash saleCashSales Revenue250.00An asset grows (cash in) and revenue is earned.
Office suppliesSuppliesCash75.50One asset grows, another shrinks: we swapped cash for supplies.
ConsultingCashService Revenue1,200.00Cash in, service revenue earned.

The same events drawn as T-accounts

A T-account has a debit (left) column and a credit (right) column. Reading each account’s two columns gives its balance.

Cash
250.00
1,200.00
75.50
balance 1,374.50 Dr
Supplies
75.50
 
balance 75.50 Dr
Sales Revenue
 
250.00
balance 250.00 Cr
Service Revenue
 
1,200.00
balance 1,200.00 Cr

Total of the debit balances: 1,374.50 + 75.50 = 1,450.00. Total of the credit balances: 250.00 + 1,200.00 = 1,450.00. Equal — this check is called a trial balance. The program below does the same arithmetic with nothing but a dictionary and a loop.

Modelling choice you should notice: this project stores one signed number: debit = positive, credit = negative. That is why Sales Revenue shows −250.00 in the code even though revenue is good news — a report would flip the sign based on the account type. Real systems often store separate debit and credit columns or integer cents; signed amounts are simply the neatest fit for operator overloading.

Balanced does not mean correct. An invariant that total debits equal total credits catches one-sided entries and arithmetic slips. It does not catch posting 250 to the wrong account on both sides, or forgetting a whole transaction. Treat sum == 0 as a smoke alarm, not a guarantee.

Three Python Ideas Behind Every Line

The ledger uses three features that beginners often meet for the first time here: dataclasses, Decimal and operator methods. If you already know them, skim the cards and jump to the live demo. If not, each idea has a short program that you can run and read line by line.

How to study with this page. β‘  Predict: open “Predict first” and guess the output before pressing Run. β‘‘ Run the program. β‘’ Read the line-by-line explanation: tap any line, or switch on Step mode and press Next to walk through one line at a time while the matching line lights up. β‘£ Change one thing in the playground and predict again.

πŸ“¦

Dataclass

A decorator that writes __init__, __repr__ and __eq__ from a list of fields. With frozen=True the object becomes read-only.

πŸ’°

Decimal

Exact base-10 arithmetic for money. Build it from a string: Decimal("0.10"), never from a float.

βž•

Dunder methods

Methods with double underscores (“dunder”) that Python calls for operators and built-ins: a + b runs a.__add__(b).

πŸ“¦ Program A — what a dataclass writes for you

The same two-field class written three ways: by hand, as a dataclass, and as a frozen dataclass. Compare what print and == do for each.

Takeaway: @dataclass removes boilerplate, and frozen=True adds the safety a ledger entry needs: once created it cannot be changed, and it can be used in sets.

πŸ’° Program B — why money needs Decimal

The reason the ledger imports Decimal in its very first lines.

Rule of thumb: floats are for measurements (height, temperature); Decimal or integer cents are for money. the standard-library documentation presents Decimal as the tool for applications that need exact decimal representation, accounting being the classic case.

βž• Program C — operators are method calls

Watch Python choose between __add__, __radd__ and TypeError. This is the exact protocol the Debit class relies on.

πŸ—ΊοΈ Operator → method cheat sheet

You writePython callsUsed in the ledger?
a + ba.__add__(b), then b.__radd__(a) if that returns NotImplementedYes — adds entries of one account
-aa.__neg__()Yes — creates a credit
a == ba.__eq__(b)Yes — compares account and amount
a != ba.__ne__(b) (by default the inverse of __eq__)Automatic
hash(a), {a}, d[a]a.__hash__()Yes — sets and dict keys
print(a), repr(a)a.__repr__() (or __str__ if defined)Yes — readable output
a - ba.__sub__(b)Challenge 2
a < ba.__lt__(b)Suggested in the outro
abs(a), bool(a), len(a)__abs__, __bool__, __len__Ideas to explore
sum(items)Starts at start (default 0) and applies + repeatedlyYes — see the Bug Hunt

One technical detail: Python looks these methods up on the type of the object, not on the instance. Assigning obj.__add__ = ... on one object does not change how + behaves for it.

Double-Entry Ledger

A working ledger. Pick a debit account, a credit account and an amount, then post the transaction. Watch the journal, the per-account balances (a trial balance) and the running total update. Every transaction posts two entries, +amount and −amount, so the total stays at zero — until you press Corrupt the books, which simulates a bug that records only one side.

πŸ“’ ledger Β· 0 entries sum(entries) = 0.00
Journal
#DescriptionDebitCreditNet
No entries yet β€” post a transaction above.
Account balances (trial balance)
AccountBalanceSide
β€”
Ledger trace: (waiting for entries)
Add a transaction to begin.

What to notice: (1) Each posted transaction adds a +amount entry to one account and a −amount entry to another, so the total never moves from zero. (2) The balances table is a trial balance: its Ξ£ is the same number as the ledger total. (3) After Corrupt the books, the total is no longer zero and the trace shows by how much — that is the smoke alarm described in the introduction.

Experiments:

  • Load the sample and check that the Cash balance matches the T-account in section 01 (1,374.50).
  • Post Rent paid: debit Rent Expense, credit Cash, 500. Which balances change, and does the total?
  • Post a transaction whose debit and credit account are the same. Why does the page refuse it?
  • Corrupt the books twice. Does the imbalance add up? Then clear the ledger.

The demo does its arithmetic in whole cents (integers), mirroring the exactness that Decimal gives the Python code. It is a JavaScript simulation of the Python model, not Python itself; the programs further down run the real thing.

How It Works β€” Line by Line

The full implementation of the ledger. Run it, then read it one line at a time in the walkthrough below.

The Ledger Class

πŸ” Deep dive β€” how sum(entries) stays at zero

EntryaccountamountOperator applied
Cash (debit)Cash+250.00+250.00 + (βˆ’250.00) = 0
Sales Revenue (credit)Sales Revenueβˆ’250.00
Supplies (debit)Supplies+75.50+75.50 + (βˆ’75.50) = 0
Cash (credit)Cashβˆ’75.50
Cash (debit)Cash+1200.00+1200.00 + (βˆ’1200.00) = 0
Service Revenue (credit)Service Revenueβˆ’1200.00

A precision note about sum(entries) == 0. The page’s headline expression is the idea: the total of all signed entries is zero. In the code, the ledger-wide total adds the entry amounts (Decimal numbers), because Debit.__add__ deliberately refuses to combine different accounts. The + operator does its real work within one account — see account_balance() — and the Bug Hunt below explains why.

🐞 Bug hunt: the __start__ seed

The first draft of this page computed the balance with sum(self._entries, start=Debit("__start__", Decimal("0"))) and explained that a neutral “start” account was needed because entries on different accounts cannot be added. Unfortunately the idea fails: that code raises ValueError: Cannot add __start__ to Cash on the very first addition, and no seed would fix it, because the second step then tries to add two different real accounts. The program below reproduces both failures and shows the fix. Detective work like this — read the error, find the first failing step — is a core debugging skill.

What the fix teaches: an operator you define has rules (same account only), and every function that uses it — including built-ins like sum — must respect them. When a rule does not fit a task (summing across accounts), change the data you feed in (use the amounts), not the rule.

πŸ“‹ Summary: the design in fourteen sentences

  1. @dataclass(frozen=True) β€” makes Debit immutable. Once an entry is recorded, it can't be changed. That is exactly what a ledger needs.
  2. account: str, amount: Decimal β€” the two fields. Decimal is used instead of float because money arithmetic must be exact.
  3. __add__ β€” first checks type: return NotImplemented if other isn't a Debit. Then checks the account: adding to a different account raises ValueError. Finally returns a new Debit.
  4. __neg__ β€” returns a new Debit with the amount negated. This is what turns a debit into a credit.
  5. __eq__ β€” compares (account, amount) tuples. Returns NotImplemented for non-Debit types, so Python can fall back to "not equal".
  6. __hash__ β€” hashes the same (account, amount) tuple that __eq__ compares. (A frozen dataclass would generate a matching pair by itself; writing both by hand shows the rule that equal objects must hash equally.)
  7. __repr__ β€” shows the class name and fields. Makes debug output readable.
  8. Credit = Debit β€” a credit is just a debit alias. The actual differentiation happens through the sign, which __neg__ controls.
  9. Transaction β€” a plain dataclass holding description, two account names, and an amount.
  10. Transaction.entries() β€” produces the pair: a debit for the debit_account and a credit (negated debit) for the credit_account. The same amount, opposite signs.
  11. Ledger.post() β€” appends the transaction and extends the entry list with the two entries.
  12. Ledger.balance() β€” sum((e.amount for e in self._entries), start=Decimal("0")). We add the amounts because Debit.__add__ refuses to combine different accounts, so a ledger-wide sum of Debit objects is impossible. The first draft used a __start__ seed instead and crashed; see the Bug Hunt above.
  13. Ledger.is_balanced() β€” self.balance() == 0. That's it. The check is a single comparison.
  14. account_balance() β€” collects one account's entries and adds them with the + operator, seeded with a zero entry for the same account. This is where operator overloading does real work.
  15. total_debits() and total_credits() β€” split the entries by sign. These should be equal in a balanced ledger.

⚠️ Common mistakes

Raising TypeError inside __add__. Return NotImplemented. Let Python build the TypeError itself.

Using float for money. 0.1 + 0.2 is not 0.3. Use Decimal.

Forgetting @dataclass(frozen=True). Then a debit can be mutated after being recorded, and your ledger becomes unreliable.

Mismatched __eq__ and __hash__. Two equal entries must hash the same. Keep them consistent.

Mixing currencies in one ledger. Add a currency field to Debit and check it in __add__, or raise on mismatch. (See the Currency Converter for the full pattern.)

Every mistake above is reproduced as a runnable program in section 07.

πŸ§ͺ Try it yourself

Change one thing at a time:

  • Remove @dataclass(frozen=True) and try to mutate a posted entry. What happens?
  • Add a currency field and raise ValueError when currencies don't match. Try it.
  • Add a Ledger.account_balance() call for "Cash" after the three transactions. What number do you expect?
  • Try Debit("Cash", 10) + 5. Read the error.

βœ… Answers to “Try it yourself”

Show answers and explanations
  • Remove frozen=True and mutate a posted entry. The assignment succeeds silently, and the ledger’s numbers change with no record of it. See Mistake 3 in section 07. (Careful: with a hand-written __hash__ and a mutable object, changing amount after putting the entry in a set breaks the set, because its hash changes.)
  • Add a currency field. Challenge 3 has a full solution.
  • account_balance("Cash") after the three transactions. 250.00 − 75.50 + 1,200.00 = 1374.50. The main program now prints it.
  • Debit("Cash", 10) + 5. __add__ returns NotImplemented, int has no matching reflected method, so Python raises TypeError: unsupported operand type(s) for +: 'Debit' and 'int'. You can reproduce it in the dunder lab below.

The Operators Behind the Ledger

Each dunder method hooks a Python operator into the Debit class. Here's how each one is used in the ledger.

βž• __add__

Hooks a + b. Adds two debits on the same account. Refuses cross-account addition.

Debit("Cash", 10) + Debit("Cash", 5)
# β†’ Debit("Cash", 15)

Debit("Cash", 10) + Debit("AR", 5)
# β†’ ValueError

βž– __neg__

Hooks -a. Returns a new Debit with the negated amount. This is how a credit is created.

-Debit("Sales", 250)
# β†’ Debit("Sales", -250)
#    (a credit)

🟰 __eq__

Hooks a == b. Compares both the account and the amount.

Debit("Cash", 10) == Debit("Cash", 10)
# β†’ True

Debit("Cash", 10) == Debit("Cash", 11)
# β†’ False

πŸ”’ __hash__

Enables entries in sets and as dict keys. Must be consistent with __eq__.

{Debit("Cash", 10),
 Debit("Cash", 10)}
# β†’ {Debit("Cash", 10)}
#    (one entry, deduplicated)

πŸ”¬ Dunder lab — watch every operator run

The four cards above are reference snippets. This program executes every one of those examples, including the failures, so you can compare your prediction with real output. It is also the best place to experiment: change an amount or an account name and predict again.

βš™οΈ Design rules that keep operators trustworthy

  • Return new objects. a + b must not modify a or b. With frozen=True Python enforces this for you.
  • Return NotImplemented for types you do not understand, and never raise TypeError yourself (Mistake 1).
  • Keep __eq__ and __hash__ in agreement. Equal objects must hash equally (Mistake 4).
  • Make operators total or explicit. Either an operator works for every pair of values of the supported types, or it raises a clear, specific error (ValueError for different accounts).
  • Do not be clever. An operator should mean what a reader expects. + that adds money is great; + that posts to a database is a trap.

Why sum(entries) == 0 Always Holds

The Proof

It comes down to one algebraic identity. For every transaction t with amount a:

entries(t) = (Debit(debit_account, a), Debit(credit_account, -a))

Sum of entries across all transactions:

Ξ£ entries = Ξ£ (a) + Ξ£ (-a)
          = Ξ£ (a - a)
          = Ξ£ (0)
          = 0

It's true by construction. Every transaction adds one positive and one negative of the same amount. The ledger's balance is zero before you even look at the number β€” the invariant is baked into how the data is created.

What You Get for Free

TaskCodeWhat it uses
Balance the ledgerledger.is_balanced()__add__, __eq__
Check one accountledger.account_balance("Cash")__eq__, __add__
Deduplicate entriesset(entries)__eq__, __hash__
Group by accountgroupby(entries, key=lambda e: e.account)Attribute access
Find the largest entrymax(entries, key=lambda e: abs(e.amount))Attribute access

Try adding a new feature: compute a trial balance per account. You can do it with a single dict comprehension β€” no new methods needed. Because Debit knows how to add itself, everything else composes naturally.

🎲 Test the proof on 1,000 random transactions

A proof is general; a test is concrete. This program generates a thousand random transactions with a fixed random seed, so the output is reproducible, and checks both the ledger total and the sum of the per-account balances. It ends with a reminder of why the amounts must be Decimal.

🧰 Run every row of the table above

Each task in “What You Get for Free” is demonstrated below on a small set of entries. It deliberately includes a duplicate entry: duplicates are exactly how a real ledger drifts out of balance, and sets make them easy to find.

Do not “fix” the books with set(). Removing duplicate entries would delete legitimate repeated transactions (two identical cash sales are two sales). Use sets to find suspicious duplicates, then investigate.

🚧 What the invariant can and cannot promise

Kind of errorCaught by sum == 0?Why
A one-sided entry (only a debit was recorded)βœ“ YesThe lonely amount has no partner.
A typo on one side (250 vs 205)βœ“ YesThe two sides differ.
The same typo on both sidesβœ— NoBoth sides still match.
Posting to the wrong account on both sidesβœ— NoThe totals are unaffected.
A forgotten transactionβœ— NoNothing is unbalanced, merely missing.
A transaction posted twice (as a whole)βœ— NoBoth copies balance.

This is why real accounting also uses reconciliations, audit trails and review. The invariant makes one whole class of bugs impossible; it does not make the books right.

Four Classic Mistakes, Reproduced

The earlier “Common mistakes” list told you what not to do. Here you can watch each mistake fail. Run the program, read the symptom, then read why it happens. Predict the output first.

1 Β· Raising TypeError instead of returning NotImplemented

The bad version blocks Python’s second chance: asking the right-hand operand to handle the addition.

2 Β· Using float for money

Binary floating point cannot represent 0.10, so tiny errors appear and exact checks fail.

3 Β· Forgetting frozen=True

A posted entry that can be edited is a ledger that can be falsified without a trace.

4 Β· Mismatched __eq__ and __hash__

Three variants: equality alone (unhashable), a consistent pair, and an inconsistent pair that fails silently.

🩺 Error-message decoder

Message (shortened)Most likely causeFix
ValueError: Cannot add Cash to AR: different accountsTwo entries from different accounts were added.Group by account first, or sum the .amount values.
TypeError: unsupported operand type(s) for +: 'Debit' and 'int'A Debit was added to a plain number.Add .amount instead, or support the type deliberately (Challenge 4).
TypeError: unsupported operand type(s) for +: 'int' and 'Debit'sum(entries) without start=: it begins at the integer 0, and Debit has no __radd__.Pass start=Debit(account, Decimal("0")) for one account, or sum the amounts.
FrozenInstanceError: cannot assign to field 'amount'Code tried to modify a frozen entry.Create a new Debit (or post a correcting transaction).
TypeError: unhashable type: 'Debit'__eq__ was defined without __hash__ (or the dataclass is mutable).Define __hash__ consistently, or use frozen=True.
decimal.InvalidOperationA Decimal was built from text that is not a number, such as Decimal("12,50").Validate and clean input; use a dot as the decimal separator.
AttributeError: 'Ledger' object has no attribute 'total_debits'The method is missing from the class (the original playground had exactly this bug).Add the method, or call one that exists.
AssertionErrorAn assert or test expectation failed.Print the two values and decide which is wrong.

Try It in Python

Experiment with the ledger model in a real Python runtime. The editor comes pre-loaded with the complete, working program from section 04. Modify the transactions, add accounts, or break the balance β€” and watch what happens.

πŸ“’ ledger.py
Press Run to execute your code.

Challenges: (1) add a Ledger.trial_balance(); (2) make Debit support __sub__; (3) add a currency field and reject mixed-currency additions; (4) use singledispatch so Debit.__add__ can accept a plain Decimal. Full solutions with line-by-line explanations are in section 09.

πŸ“– Understand the starter code

The editor starts with the same program you studied in section 04 (“The Ledger Class”), so you already have a line-by-line explanation of every line — open it again with Step mode if you need a refresher. Compared with the original draft of this page the starter now includes total_debits() and total_credits(); without them its last print lines crashed with an AttributeError.

Experiments — predict first, then run:

  • Post a fourth transaction, Transaction("Rent", "Rent Expense", "Cash", Decimal("500")). What is the new Cash balance? (Answer: 874.50.)
  • Replace one Decimal("...") amount with a float such as 250.10. Which line shows the damage?
  • Append ledger._entries.append(Debit("Cash", Decimal("10"))) before the report and run again. What does Is balanced? say, and by how much is the total off?
  • Try print(Debit("Cash", Decimal(10)) + Debit("AR", Decimal(5))). Read the error.
  • Write sum(ledger._entries) and read the TypeError. Why does it name 'int' first?

Extend the Ledger

The playground callout suggested four extensions. Try each one yourself first, in the playground above, and open the solution only to compare. Every solution is a complete program with a full line-by-line explanation.

Challenge 1 Β· Trial balance

Add Ledger.trial_balance() that returns a dictionary of account → balance. Hint: a dict comprehension, sum with a same-account seed, and sorted for a stable order.

Show the solution

Challenge 2 Β· Subtraction

Make a - b work. Hint: you already have negation and addition.

Show the solution

Challenge 3 Β· Currencies

Add a currency field and reject mixed-currency additions. Hint: a field with a default value, and remember to carry the currency into every new object.

Show the solution

Challenge 4 Β· Dispatch on type

Use functools.singledispatchmethod so Debit.__add__ accepts a Debit or a plain Decimal. Hint: register the handlers after the class exists.

Show the solution

βœ… Prove it with automated tests

Instead of eyeballing printed output, encode each rule as a test. Seven small tests pin down everything the dunder methods promise — if you change Debit and break a rule, the failing test’s name tells you which.

Habit: whenever you add an operator, add one test for the normal case, one for each error case, and one for the algebra (x + (-x) is zero, -(-x) == x).

Advertisement

Quick Knowledge Check

Twelve questions on the ledger, its operators, Decimal and the bug hunt. Instant feedback after each answer.

0/12

Great effort β€” review any missed questions above.

Wrapping Up

🏁 What You've Learned

You met operator overloading in a real domain: a double-entry ledger where every entry is a Debit and every credit is a Debit negated. Because __add__ and __neg__ are implemented, a single expression balances the books β€” no helper methods, no special counters.

The same technique scales: add a currency field and the pattern becomes a full accounting system. Add __lt__ and you can sort entries by amount. Add __hash__ and entries become set- and dict-friendly.

Live ledger simulator Five dunder methods explained Full code with walkthrough Interactive playground Bug hunt, challenges and tests

Key Takeaways

  • Operator overloading is polymorphism. sum(entries) doesn't know or care what class it's holding β€” it just calls +.
  • __neg__ is the whole trick. A credit is literally a debit negated. The same amount appears with opposite signs, so the total is zero.
  • Return NotImplemented, don't raise. Let Python decide whether an operation is valid across the types involved.
  • Keep __eq__ and __hash__ consistent. Two equal entries must hash the same, or sets and dicts misbehave.
  • Use Decimal for money. Float arithmetic loses cents. Decimal keeps the books exact.
  • Balanced is not the same as correct. The invariant catches one-sided entries and slips, not wrong-account or missing transactions.
  • Read the first failing step. The __start__ bug was found by asking which addition raised first, not by guessing.
  • Operators have rules; honour them everywhere. Built-ins such as sum obey your __add__, including its same-account rule.
  • Invariants are stronger than checks. sum(entries) == 0 is true by construction, not by a runtime assertion β€” the data structure can't produce a wrong answer.

🧭 Where to go next: three build projects

PROJECT A Β· EASY

Bank statement viewer

Read a CSV of transactions, post each as a Transaction, and print a per-account statement with running balances.

PROJECT B Β· MEDIUM

Multi-currency ledger

Combine Challenge 3 with exchange rates. Convert entries to a base currency with a method, and keep + strict for mixed currencies.

PROJECT C Β· HARD

Audit trail

Make the ledger append-only: reversing entries instead of edits, a timestamp and author on each transaction, and a test that proves balance after every post.

πŸ™‹ Frequently asked questions

Why is Credit = Debit not a subclass?

A subclass would let isinstance(x, Credit) work, but it would also let a Credit carry a positive amount, breaking the rule that credits are negative. A single class with a sign cannot be in an inconsistent state. The cost is that you test entry.amount < 0 instead of the type.

Why does Debit define __eq__ and __hash__ when a frozen dataclass would generate them?

For teaching: writing them by hand shows the rule that equality and hashing must agree. In production code you would normally delete both and rely on @dataclass(frozen=True), which generates a consistent pair (the test program does exactly that).

Should a real ledger store a signed amount?

Some do, some store separate debit and credit columns, and many store integer minor units (cents). The best choice depends on reporting needs. Signed amounts make the arithmetic beautifully simple, which is why they suit this lesson; a presentation layer decides how to display them.

Can I use float if I round at the end?

You can, and many programs do, but errors can accumulate and equality checks like balance == 0 become unreliable. Decimal or integer cents let you keep the exact check that makes this design elegant.

Why does sum(entries) fail without start=?

sum begins at the integer 0, so its first step is 0 + entry. int does not know about Debit, and Debit has no __radd__, so Python raises TypeError. Supplying start= with a matching object, or summing numbers, avoids it.

Is operator overloading “too magical”?

It is a tool with a cost: readers must know what + does for your type. It pays off when the operator matches a real mathematical meaning (adding money, negating a debit). Avoid it when a named method like post() would be clearer.

πŸ“š Glossary

Account

A named bucket of money, such as Cash.

Debit / Credit

The two sides of an entry. Here: positive and negative amounts.

Journal entry

One recorded transaction with a debit side and a credit side.

Ledger

The complete set of accounts and entries.

Trial balance

A list of all account balances whose total must be zero.

T-account

A drawing with debits on the left and credits on the right.

Invariant

A condition that must always hold, here: total of entries = 0.

Dunder method

A method with double underscores that Python calls for operators and built-ins.

Operator overloading

Giving your class its own meaning for an operator such as +.

NotImplemented

A return value meaning “I cannot handle this pair of types.”

Reflected method

The right-hand operand’s method (__radd__) tried after the left one declines.

Dataclass

A class whose boilerplate methods are generated from its fields.

Frozen

Read-only after creation.

Decimal

Exact decimal arithmetic for money.

Seed (start)

The initial value for sum().

Hash

A number used by sets and dicts to find equal objects quickly.

πŸ”— Further reading

✨ The accounting insight: debits and credits aren't two types β€” they're two signs of the same number. Operator overloading lets your code reflect that truth directly.

β€” Glenn Junsay Pansensoy, Poetic Bytes

Return to Home Polymorphism Guide Next Project: Currency Converter

Share This Project

Advertisement