Skip to content

← Modelling Data with Dataclasses step 5 of 28

Medium Primitives

Compute Cents from an Amount with __post_init__

A payment API takes amounts in minor units, the smallest unit of a currency, such as cents for US dollars. You keep the amount as the caller wrote it, and you work out the minor units once, when you build the record. The rounding mode goes into that calculation, but the record does not store it.

solve("1.005", "USD", "half_up")
# {
#     "error": "",
#     "field_names": ["amount", "currency", "minor_units"],
#     "as_dict": {"amount": "1.005", "currency": "USD", "minor_units": "101"},
#     "eq_vs_half_even": False,
# }

Two tools for a computed field

__post_init__ is a method that the generated __init__ calls after it sets the fields. Checks and computed fields go there. InitVar marks a constructor argument that __init__ passes to __post_init__ and does not store. So with rounding: InitVar[str] = "half_even", callers can write Money(amount, "USD", "half_up"), and rounding never becomes a field.

Complete Money

Keep Money frozen, and declare these entries in this order:

Entry Declaration
amount a Decimal field that the caller passes in
currency a str field that the caller passes in
minor_units an int field, field(init=False, default=0)
rounding InitVar[str], with the default "half_even"

Write __post_init__(self, rounding: str) -> None, and keep the generated __init__. Check in this order:

  1. If currency is not a key of MINOR_UNIT_EXPONENT, raise ValueError(f"unknown ISO-4217 currency: {self.currency!r}").
  2. If rounding is not a key of ROUNDING_MODES, raise ValueError(f"unknown rounding mode: {rounding!r}").
  3. Otherwise, compute minor_units and set it.

So when both names are wrong, raise the currency error. Names are case-sensitive, so do not strip them or change their case.

Compute minor_units

Use Decimal for every step, and never convert to float. For an exponent of 2:

  • Decimal(1).scaleb(-2) is Decimal("0.01"), the size of one cent.
  • amount.quantize(Decimal("0.01"), rounding=ROUNDING_MODES[rounding]) rounds the amount to two decimal places.
  • quantized.scaleb(2) moves the decimal point two places to the right, and int() turns the result into a whole number of cents.

Set the result with object.__setattr__(self, "minor_units", result). frozen=True blocks self.minor_units = ..., even inside __post_init__, and object.__setattr__ skips the dataclass's check. Leave amount as the caller passed it. Do not replace it with the rounded value.

amount currency rounding minor_units
"10.00" USD half_even 1000
"1.005" USD half_even 100
"1.005" USD half_up 101
"-1.005" USD half_up -101
"1234" JPY half_even 1234
"1.2345" KWD half_up 1235

Both modes round to the nearest value. They differ only at a tie, such as 1.005 dollars, which is halfway between 100 and 101 cents. half_even picks the even last digit. half_up rounds away from zero, so -1.005 becomes -101.

Implement solve

Parse amount with Decimal(amount), and build a Money with the given currency and rounding. If that raises ValueError, return:

{"error": str(exc), "field_names": [], "as_dict": {}, "eq_vs_half_even": False}

Otherwise, return:

  • "error": an empty string.
  • "field_names": the names from fields(money), in order.
  • "as_dict": asdict(money) with str applied to every value. So minor_units becomes "101", and amount is the text of str(Decimal(amount)), such as "10.00".
  • "eq_vs_half_even": whether money equals a second Money built from the same amount and currency with "half_even".

rounding is not a field, so fields(), asdict(), the repr and == all leave it out. It still affects == through minor_units, so two amounts rounded differently are unequal.

Your code must pass mypy --strict. Annotate the rounding parameter of __post_init__ and the return type of solve.

Going further

money.rounding does not tell you which mode built the object. Python finds the class attribute, "half_even". The exercise Update a Frozen Record with replace() shows how that default changes a copy.