Split tax math only after receipt outputs are pinned. A model draft is a proposal, not a merge. The pins decide whether that draft can land.
A handler may parse, tax, write, and format in one function. Those jobs share mutable lists and raw strings. One eager extract can change cents without a loud error.
This note shows a small loop you can re-run. You freeze the current outputs before any move. You lift one pure function only after that.
Models got faster. Contracts did not.
Coding models can draft an extract faster than before. The handler's contract did not get clearer by itself. Speed without fixtures just ships the wrong cents sooner.
Treat the model strictly as a patch proposer. Treat the fixture file as the merge gate. If those two roles blur, stop and split them again.
What stays unsafe
Do not start the work from a model diff. Start from rows you can re-run tomorrow. A new helper can pass while the handler breaks.
Watch three failure modes before you trust a green helper. Each one can hide inside a casual review. None of them announce themselves in the diff.
- Rounding can switch from half-up to banker's rounding.
- A missing tax code can become zero, not an error.
- Ledger text can change while the numeric total stays.
Each mode can still pass a casual review. Each mode still fails a pinned receipt row. Pin the row before you argue about the diff.
The sample module
The sample below is a proposal, not a live ledger. It is small enough to run on one machine. It is messy on purpose so the split has a target.
The function mixes parsing, tax math, ledger writes, and status text. The smallest safe move is the tax step alone. Parsing and ledger writes stay in the handler.
The sample targets Python 3 with the standard library only. No third-party package is required for the fixture run. Use a virtual environment if your base image is shared.
Treat this module as unexecuted proposal code only. Run it yourself before you trust any expected row. Print each due string before you lock the fixture.
# receipt_handler.py
# Unexecuted proposal. Not a live ledger.
from decimal import Decimal, ROUND_HALF_UP
def handle_receipt(payload, ledger):
lines = payload.get("lines") or []
rate = Decimal(payload.get("tax_code") or "0")
total = Decimal("0")
for line in lines:
qty = Decimal(str(line.get("qty", "0")))
price = Decimal(str(line.get("price", "0")))
total += (qty * price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
tax = (total * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
due = total + tax
text = f"due={due}"
ledger.append({"due": str(due), "text": text, "lines": len(lines)})
return {"due": str(due), "text": text, "status": "ok" if lines else "empty"}
Six fixtures, not a vibe check
Six fixtures cover the branches this sample actually has. Empty input, one line, a multi-line sum, and a zero rate are in. Add a fixture when your domain grows a new branch.
These expected strings follow the sample rules above. They were not executed in this draft, so recompute them. A mismatch means the fixture or the rule is wrong.
These fixtures are characterization tests, not new specs. They record what the handler emits today, unchanged. They do not decide what the handler should emit later.
| Case | Input sketch | Expected due | Status |
|---|---|---|---|
| Empty lines | tax code 0.10 | 0.00 | empty |
| One line | 1 x 10.00 at 0.10 | 11.00 | ok |
| Half-up line | 2 x 1.005 at 0.00 | 2.01 | ok |
| Two lines | 0.10 + 0.20 at 0.05 | 0.32 | ok |
| Three units | 3 x 9.99 at 0.20 | 35.96 | ok |
| Tiny tax | 1 x 0.01 at 0.10 | 0.01 | ok |
One row, traced
Take the two-line fixture and compute it by hand. The first line is 1 times 0.10, which is 0.10. The second line is 1 times 0.20, which is 0.20.
The line sum is 0.30 before tax is applied. Tax is 0.30 times 0.05, which equals 0.015. Half-up rounding to cents turns 0.015 into 0.02.
Due is 0.30 plus 0.02, which is 0.32. Status stays ok because the line list is not empty. Ledger text must read due=0.32 with no extra fields.
Line totals quantize to cents before the tax step. Tax then quantizes to cents before the final sum. Empty lines yield status empty and due 0.00.
Save the fixture runner next to the handler. Keep both files in one directory for the commands below. The runner imports the handler by module name.
# test_receipt_pins.py
# Unexecuted proposal. Recompute expected dues before you trust them.
import unittest
from receipt_handler import handle_receipt
PINS = [
({"lines": [], "tax_code": "0.10"}, "0.00", "empty"),
({"lines": [{"qty": "1", "price": "10.00"}], "tax_code": "0.10"}, "11.00", "ok"),
({"lines": [{"qty": "2", "price": "1.005"}], "tax_code": "0.00"}, "2.01", "ok"),
({"lines": [{"qty": "1", "price": "0.10"}, {"qty": "1", "price": "0.20"}], "tax_code": "0.05"}, "0.32", "ok"),
({"lines": [{"qty": "3", "price": "9.99"}], "tax_code": "0.20"}, "35.96", "ok"),
({"lines": [{"qty": "1", "price": "0.01"}], "tax_code": "0.10"}, "0.01", "ok"),
]
class PinReceiptOutputs(unittest.TestCase):
def test_pins(self):
for payload, due, status in PINS:
ledger = []
got = handle_receipt(payload, ledger)
self.assertEqual(got["due"], due)
self.assertEqual(got["status"], status)
self.assertEqual(len(ledger), 1)
self.assertEqual(ledger[0]["text"], f"due={due}")
self.assertEqual(ledger[0]["due"], due)
if __name__ == "__main__":
unittest.main()
Decide what may move
Use a table before you touch the file. The table states what may move in this pass. Anything unmarked stays exactly where it is.
That table is the entire change budget here. One yes-row is the whole budget for now. A second yes-row waits for a later pass.
| Unit | What is pinned | Move now | Why |
|---|---|---|---|
| Line sum and tax quantize | due string | Yes | Pure math, no I/O |
| Status word | status field | No | Tied to the empty check |
| Ledger append | ledger text and due | No | Side effect and order |
| Broader input cleanup | not pinned | No | Needs its own fixtures |
1. Run the fixtures on the current function
Run the fixture file against the untouched handler. Stop the session if any assertion fails now. Fix the fixture or the bug before any extract.
Do not split a function during a red run. A red row is evidence, not a prompt to rewrite. Capture the failure text before you edit anything.
python -m unittest test_receipt_pins.py -v
2. Name the move in one sentence
Write the intended move in a single sentence. Move only the line-sum and tax quantize steps. Leave the ledger writes and status text untouched.
Put that sentence at the top of the change note. If the diff does more, the note wins. Revert the file until the diff matches the sentence.
3. Add one pure function
Add the pure function in the same module. Keep the old exception behavior for bad tax codes. Do not add new rounding modes in this pass.
def line_due(lines, tax_code):
rate = Decimal(tax_code or "0")
total = Decimal("0")
for line in lines:
qty = Decimal(str(line.get("qty", "0")))
price = Decimal(str(line.get("price", "0")))
total += (qty * price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
tax = (total * rate).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return total + tax
Wire the handler through that one function only. Do not rename keys while you wire the call. A rename is a second change, so it waits.
def handle_receipt(payload, ledger):
lines = payload.get("lines") or []
tax_code = payload.get("tax_code") or "0"
due = line_due(lines, tax_code)
text = f"due={due}"
ledger.append({"due": str(due), "text": text, "lines": len(lines)})
return {"due": str(due), "text": text, "status": "ok" if lines else "empty"}
The call shape stays boring on purpose here. Boring is how you keep the pins honest. Clever helpers can wait until the gate is green.
4. Re-run the same fixtures
Run the same file and the same expected strings. A diff in due, status, or ledger text means revert. Do not update a pin just to match a surprise.
python -m unittest test_receipt_pins.py -v
Compare the failure to the one-sentence move. If the failure is a rounding change, the extract drifted. Restore the file and ask for a smaller patch.
5. Reject extra edits
A model may rename keys or change rounding. It may also reshape the ledger entry itself. Those edits are out of scope for this pass.
Revert them even if unpinned checks still pass. Unpinned checks are not this contract at all. The contract is the six rows you already froze.
Where a free model fits
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode provides free model access for this kind of draft. It also provides a free server option for the run.
Both claims are operator-supplied for this article only. Model names, quotas, hardware, and duration are omitted here. Those details change, and this draft does not verify them.
Read the current project docs before you depend on either option. Use the free model as a drafter, not as a reviewer. Paste the handler and the fixture file into the prompt.
Ask for one extract of the tax step. Apply the patch on a branch, then run the fixtures. A free server can host that loop when a laptop is busy.
It does not replace the fixtures or approve the diff. If you want that drafting setup, read MonkeyCode's current docs and confirm today's limits. Skip the product if the docs do not match your constraint.
Limits of the method
Fixtures lock today's behavior, including today's known bugs. A wrong total in a fixture will survive the extract. Mark suspect rows before you treat them as a contract.
Six fixtures do not cover every tax code. A new code needs a new fixture before the next split. Do not grow the refactor while coverage stays still.
The sample uses Decimal with half-up rounding rules. Your ledger may use integer cents or another mode. Do not copy these numbers into a different ruleset.
Four groups should skip this path entirely here. The list below is a filter, not a ranking. Read it before you copy the sample into a real repo.
- Payment code with no finance reviewer on the change.
- Repos that cannot produce stable outputs at all.
- Teams that merge model diffs without a re-run.
- Changes that must alter behavior, not preserve it.
Behavior changes need a written delta, not a frozen fixture. Using pins to sneak in a new rule hides the change. Write the delta, then replace the affected fixtures.
What the fixtures do not prove
A passing fixture set does not prove thread safety. The ledger list remains shared mutable state here. A passing set also does not prove tax compliance.
The set proves one narrower claim for this sample. The sample still emits the fixture strings after the move. That is the whole gate for this pass.
Keep the next change equally small and local. Extract status text only after every status word is pinned. Leave the ledger write until order and failures are pinned.
Commands to keep
Keep these two commands in the change notes. The first command re-runs the fixture file. The second command shows whether the diff stayed small.
python -m unittest test_receipt_pins.py -v
git diff -- receipt_handler.py
Review the diff for one moved block only. If the diff touches ledger shape, stop and restore. Ask for a smaller patch rather than a wide hand edit.
Close
Freeze the receipt outputs before any split begins. Lift one tax function after that gate is green. Keep the model on the draft side of the gate.
Top comments (0)