DEV Community

Dakota Liu
Dakota Liu

Posted on

Case Study: A Three-Line Cart Priced in Minor Units, Not Floats

You should lock currency scale, rounding mode, and step order before a coding agent touches invoice math. A three-line cart can look correct in a demo and still miss the ledger by one cent after tax. This case study builds a small pricing helper, a decision table, and tests you can rerun without a shared database. You can remove every tool name later and the contract, the code, and the checks still stand on their own.

Background

Your checkout charges a card and then renders a PDF invoice that finance reconciles against the ledger the next morning. The cart in this study has one 19.99 line, a quantity of three, a ten percent discount, and an 8.25 percent tax rate. An unlabeled float draft can return success and still disagree with minor-unit pricing of that same cart. That drift is the bug you freeze out of the prompt, rather than a bug you hope a later review will catch.

The carts below are a constructed example for you to rerun, not a customer incident and not a published benchmark. You should treat every total in this article as an assertion you can recompute, not as a metric from a live store. If your own rate or step order differs, you change the frozen rule before you change the code. The point is the sequence of decisions, not a claim that this helper already runs in production.

Why the draft is the risky part

Agent drafts are now a normal way to get a first implementation, which is useful only after the money policy is already written down. A trending demo can show a total on screen and still hide the cent that finance will dispute during reconciliation tomorrow morning. You apply that caution to a cart you can recompute by hand, instead of to a demo whose total you cannot audit.

Goal

You want a pure function that prices one cart in integer minor units and fails closed when the currency scale is unknown. You want the discount applied to the net, then tax applied to the discounted net, with rounding after each step. You do not want a tax jurisdiction engine, a payment capture client, or a claim that generated code is already reviewed. Success is a green local test run, plus the same file passing on a disposable server you can delete the same day.

Contract you freeze first

Before you prompt, you write the pricing rules into the repository so the agent cannot invent a friendlier policy. The rules below are a product choice for this helper, not a claim about every accounting standard in the world. You keep them short so a reviewer can reject a diff that violates one line without rereading the model output. A chat transcript is not the contract, because the next session will not scroll back to the sentence you liked.

Five rules

  1. Represent money as integer minor units at the boundary, and use decimal quantization only inside the helper.
  2. USD uses scale 2, JPY uses scale 0, and KWD uses scale 3; any other code raises UnknownCurrency.
  3. Round half up after the line extension, after the discount, and after the tax, and never only at the end.
  4. Reject an empty cart, a non-integer quantity, a quantity below one, and any rate below zero or above one.
  5. Return net, discount, tax, and total as minor-unit integers so a renderer cannot reintroduce binary floats.

Sample numbers you pin

You also freeze the sample numbers so a passing test cannot quietly change the fixture to match a wrong implementation. The expected USD total for the sample cart is 5842 minor units, which is 58.42 after the rules above. If an agent returns 58.41 or 58.43, the test fails, and you do not rewrite the assertion to match the draft. That refusal is the point of freezing the contract before any implementation is allowed to exist.

Worked arithmetic for the sample cart

The line extension is 19.99 times three, which is 59.97 dollars, or 5997 minor units, before any discount is applied. Ten percent of 59.97 is 5.997, and half-up rounding at scale 2 makes the discount 6.00, or 600 minor units. Tax is 8.25 percent of the remaining 53.97, which is 4.452525, and half-up rounding at scale 2 makes tax 4.45. Adding 53.97 and 4.45 yields 58.42, so the fixture asserts 5842 and fails if a draft returns either neighboring cent.

Implementation

You keep the helper in one module so the agent has a narrow place to write and you have a narrow place to review. The scale table is explicit, and an unknown currency raises before any multiplication can hide the mistake inside a total. Rates are parsed as decimals, and a rate below zero or above one raises InvalidRate instead of clamping to a convenient value. Line extensions are quantized before they enter the net, which stops a later step from inheriting an extra fraction.

Save this as invoice_total.py next to the test file:

from decimal import Decimal, ROUND_HALF_UP, localcontext
from dataclasses import dataclass


SCALES = {"USD": 2, "JPY": 0, "KWD": 3}


class UnknownCurrency(ValueError):
    pass


class InvalidRate(ValueError):
    pass


class InvalidQuantity(ValueError):
    pass


def _quantizer(currency):
    try:
        scale = SCALES[currency]
    except KeyError as exc:
        raise UnknownCurrency(currency) from exc
    return Decimal("1").scaleb(-scale)


def _quantize(amount, currency):
    with localcontext() as ctx:
        ctx.rounding = ROUND_HALF_UP
        return amount.quantize(_quantizer(currency))


def parse_rate(raw):
    rate = Decimal(raw)
    if rate < 0 or rate > 1:
        raise InvalidRate(raw)
    return rate


@dataclass(frozen=True)
class Line:
    unit_price: str
    quantity: int


@dataclass(frozen=True)
class Cart:
    currency: str
    lines: tuple
    discount_rate: str
    tax_rate: str


def invoice_total(cart):
    if not cart.lines:
        raise ValueError("empty cart")
    net = Decimal("0")
    for line in cart.lines:
        if isinstance(line.quantity, bool) or not isinstance(line.quantity, int):
            raise InvalidQuantity(str(line.quantity))
        if line.quantity < 1:
            raise InvalidQuantity(str(line.quantity))
        unit = _quantize(Decimal(line.unit_price), cart.currency)
        net += _quantize(unit * line.quantity, cart.currency)
    discount = _quantize(net * parse_rate(cart.discount_rate), cart.currency)
    taxable = _quantize(net - discount, cart.currency)
    tax = _quantize(taxable * parse_rate(cart.tax_rate), cart.currency)
    total = _quantize(taxable + tax, cart.currency)
    return {
        "currency": cart.currency,
        "net_minor": _minor(net, cart.currency),
        "discount_minor": _minor(discount, cart.currency),
        "tax_minor": _minor(tax, cart.currency),
        "total_minor": _minor(total, cart.currency),
    }


def _minor(amount, currency):
    scale = SCALES[currency]
    shifted = amount * (Decimal(10) ** scale)
    return int(shifted.to_integral_value(rounding=ROUND_HALF_UP))
Enter fullscreen mode Exit fullscreen mode

You should treat the listing as a reproducible example, not as a record of a production incident or a measured outage. Read the quantize calls before you trust the function, because a missing quantize is how the one-cent drift returns. If your finance partner wants half-even rounding, you change the frozen rule and the fixtures together in one review. You do not let an agent swap the rounding mode in a drive-by refactor while it is cleaning up the file.

A float expression you do not promote

A one-shot float expression is not this contract, because it skips the per-step quantize that the rules require. You may print that expression while learning, but you must not paste its output into the fixture as a convenience. If the printed float total differs, that difference is a reason to keep the decimal path, not a tie to split. Leave the comparison in a comment or a scratch cell so it cannot become an accidental second source of truth.

# Scratch only. Not the contract, and not an assertion.
print(round(19.99 * 3 * 0.9 * 1.0825, 2))
Enter fullscreen mode Exit fullscreen mode

Test plan

You pin the USD sample, a JPY zero-scale cart, an unknown currency, and a negative discount that must not be clamped. You also pin the half-up boundary, because a later edit can switch rounding and still pass the happy-path cart. The commands below assume an activated virtual environment and pytest, and they do not call a network or a payment provider. Run them locally first so a later server run is a confirmation, not your only source of truth.

python -m venv .venv
python -m pip install pytest
python -m pytest test_invoice_total.py -q
Enter fullscreen mode Exit fullscreen mode

You commit the tests before you ask for an implementation, so a green run cannot be manufactured by editing the expected total. If the import path differs on your machine, you change the import in the test, not the expected minor units. Keep both files in the same directory for this example so pytest can import the helper without a package install. Put the decision table in that same change so a reviewer sees the policy and the checker together.

Save this as test_invoice_total.py:

from decimal import Decimal

import pytest

from invoice_total import (
    Cart,
    InvalidQuantity,
    InvalidRate,
    Line,
    UnknownCurrency,
    _quantize,
    invoice_total,
)


def test_usd_sample_cart_is_5842_minor():
    cart = Cart(
        currency="USD",
        lines=(Line("19.99", 3),),
        discount_rate="0.10",
        tax_rate="0.0825",
    )
    assert invoice_total(cart) == {
        "currency": "USD",
        "net_minor": 5997,
        "discount_minor": 600,
        "tax_minor": 445,
        "total_minor": 5842,
    }


def test_jpy_zero_scale_tax():
    cart = Cart("JPY", (Line("1000", 1),), "0", "0.10")
    got = invoice_total(cart)
    assert got["tax_minor"] == 100
    assert got["total_minor"] == 1100


def test_unknown_currency_fails_closed():
    cart = Cart("BHD", (Line("1.000", 1),), "0", "0")
    with pytest.raises(UnknownCurrency):
        invoice_total(cart)


def test_negative_discount_is_rejected():
    cart = Cart("USD", (Line("10.00", 1),), "-0.01", "0")
    with pytest.raises(InvalidRate):
        invoice_total(cart)


def test_rate_above_one_is_rejected():
    cart = Cart("USD", (Line("10.00", 1),), "0", "1.01")
    with pytest.raises(InvalidRate):
        invoice_total(cart)


def test_empty_cart_is_rejected():
    with pytest.raises(ValueError):
        invoice_total(Cart("USD", (), "0", "0"))


def test_zero_quantity_is_rejected():
    cart = Cart("USD", (Line("10.00", 0),), "0", "0")
    with pytest.raises(InvalidQuantity):
        invoice_total(cart)


def test_half_up_boundary_on_usd_and_kwd():
    assert _quantize(Decimal("1.005"), "USD") == Decimal("1.01")
    assert _quantize(Decimal("1.004"), "USD") == Decimal("1.00")
    assert _quantize(Decimal("1.2345"), "KWD") == Decimal("1.235")
Enter fullscreen mode Exit fullscreen mode

Decision table

You paste a decision table into the pull request so the reviewer checks policy, not only whether the file compiles. A green test with a rewritten fixture is a failed review, even when the agent explains the change with confidence. You keep the table next to the code, because a chat log is not something the next teammate will reliably find. The rows below match the tests, and you should update a row only when finance changes the written rule.

Case Frozen behavior Why it is here
USD sample cart total_minor is 5842 Pins step order and half-up
JPY 1000, 10 percent tax, no discount tax_minor is 100 and total_minor is 1100 Scale 0 must not invent cents
Currency BHD UnknownCurrency Unknown scale fails closed
discount_rate -0.01 InvalidRate No silent clamp
tax_rate 1.01 InvalidRate A rate above one is not a bonus
Empty lines ValueError Zero is not a valid invoice here
Quantity 0 InvalidQuantity A free line is not a silent skip
Unit price 1.005 USD Quantizes to 1.01 Half-up boundary stays explicit
Amount 1.2345 KWD Quantizes to 1.235 Scale 3 is represented, not guessed

Repeatable review workflow

You repeat the same five steps whenever an agent is allowed to touch this helper, including a second session next week. The order matters, because a prompt written before the tests exist invites the model to choose both the policy and the proof. A disposable server is only step four, and it runs the file you already trusted on your laptop. You merge only after a person reads the diff against the five rules, not after the tool reports a clean run.

  1. Commit the rules, a stub that raises NotImplementedError, and the tests that pin 5842 for the sample cart.
  2. Prompt for an implementation that must not edit the tests, the scale table, or the expected minor units.
  3. Run pytest in the local virtual environment and stop if the failure is a changed assertion rather than bad code.
  4. Run that same pytest command on the free server option when you want a second machine that you can discard.
  5. Read the diff for unknown scales, float literals, and any rounding call that is not half up at each step.

Where free model access fits

You can ask MonkeyCode's free model access for the draft, and you do that only after the rules and fixtures are committed. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The free server option matters when you want the same pytest file executed away from a shared continuous-integration runner. Neither option supplies the rounding policy, the sample total, or a reason to skip reading the diff.

Your prompt should quote the five rules and ask for code that makes the existing tests pass without editing them. You reject any draft that adds a currency by guessing its scale, even when the guess matches a public reference you trust. You also reject a draft that switches to floats for simplicity, because that is the failure this study is built to prevent. If the tool cannot see your repository, you paste the contract into the prompt and you still review the patch yourself.

Results you can actually claim

This example is not a production billing migration, so you should not quote it as an uptime, revenue, or accuracy study. What you can verify is narrower and fully local: the USD fixture expects 5842 and the JPY fixture expects 1100. Unknown currencies must raise, and a negative rate must raise, which you can see by running the two rejection tests. If an assertion fails, you fix the implementation or you revise the frozen rule, and you do not average the two totals.

Limitations and who should skip this

This helper is the wrong tool when tax depends on address, product class, or a jurisdiction service you have not integrated. You should not use it for foreign-exchange conversion, because no rate source and no timestamp rule are frozen here. Teams that need an audited ledger, cash rounding to five cents, or tax-inclusive prices should not adopt this step order. Free model access and a free server option can change, and this article states no quota, hardware profile, or permanence promise.

You should not point an agent at a live payment database and ask it to backfill historical totals with this function. A backfill needs its own idempotency and reconciliation plan, which is outside this cart helper on purpose. If you cannot name the currency scale, you are not ready to automate the total, with or without a model in the loop. Skip the workflow when the only goal is a demo screenshot, because the tests are the product rather than the plot.

Lessons learned

The useful lesson is that invoice math fails at policy boundaries, not at the moment a function finally returns a number. You save review time by forbidding unknown scales, rather than by asking an agent to explain a confident but wrong cent. A second run on another machine confirms execution, and it does not replace the fixture you froze in git. Keep the decision table in the repository so the next session starts from the contract instead of from scrollback.

What you do next

If your money rules already live in the repository, rehearse the draft on free model access before anyone touches a ledger. A free server is enough for that second pytest run, and you should still merge only after a human reads the diff. Start from the contract in this case study, then replace the sample cart with one your finance partner has already signed.

Top comments (0)