House-move inventory: Money, currencies and an approximate euro - Part 3

House-move inventory: Money, currencies and an approximate euro - Part 3

Building Inventory System

~7 min read

Every item in the inventory can have a value, and values are the part of this project where a quiet bug does the most harm: the number ends up on a customs declaration and in an insurance conversation. So the money rules were written down before the first item was added, and they have barely changed since.

The rules

From CLAUDE.md:

  • Money is Decimal end to end, and a string on the wire ("45.00"). Never float - the service actively rejects one.
  • New items default to GBP.
  • ValueSummary.approx_eur is display-only, computed from a configurable, fixed gbp_to_eur_rate setting.

The string-on-the-wire rule is the one people trip over. JSON numbers are floats in most clients, and 0.1 + 0.2 is not 0.3 in a float. If the API returned 45.00 as a number, the browser would parse it as a double and every total computed client-side would inherit that. Returning "45.00" and keeping it a string in the React client (web/src/api.ts never turns money into a number) means the only place arithmetic happens is Python's Decimal.

Totals that refuse to lie

The interesting function is _summarise_value in core.py. A box (or a room, or the whole move) is a list of items, each with a quantity and an optional value in some currency. What should its total be?

The naive answer is sum(value * quantity). That is wrong in two ways, and the function handles both by returning a small structure instead of a number:

valued = [i for i in items if key(i) is not None]
unvalued = len(items) - len(valued)
if not valued:
    return ValueSummary(total=None, mixed_currency=False, ...)
currencies = {i.currency for i in valued}
if len(currencies) > 1:
    return ValueSummary(total=None, mixed_currency=True, ...)
  1. No values yet is not zero. A box of books nobody has priced is "unknown", not "£0". total is None, and the UI shows something different for "nothing priced" than for a genuine zero.
  2. Mixed currencies are reported, not summed. If one item is £10 and another €10, any number I show is a lie. mixed_currency=True with total=None tells the UI to say so. Values are recorded in their original currency; the summary refuses to add across them.

It also keeps valued_lines and unvalued_lines, so a total can honestly say "£1,240.00 across 31 priced lines, 4 not yet priced".

Why the euro is "approximate"

The move is to France, so the French paperwork wants euros. The tempting design is a live exchange-rate lookup. I decided against it:

  • It would add a second outbound network dependency (the only one today is optional French translation, post 8). The whole app is meant to run on a box in the house with no external calls it can't live without.
  • A live rate makes totals move between page loads. A declaration that changes when you refresh it is hard to reason about.
  • The numbers are estimates anyway. Nobody is hedging currency risk on a second-hand sofa.

So gbp_to_eur_rate is a plain setting (default 1.17) editable on the Settings page, and approx_eur restates a single-currency GBP total at that rate. It appears as an ≈ EUR column on the whole-move list and on the printed sheet. The tilde is deliberate: it tells you it's display-only. If I want the rate to reflect the day, I change one number; if I want a fixed rate for the whole declaration, it stays put.

Market value and retail value

Later (Phase 3.18) I added a second optional amount per item. The market value is what it's worth now - what you'd put on insurance. The retail / invoice value is what you paid, which is what an invoice attachment (post 7) actually backs up. Both share the item's currency and go through the same _summarise_value, just with a different key function:

retail_value=_summarise_value(all_items, gbp_to_eur=rate, key=lambda i: i.retail_value)

That one parameter is why the second total cost almost nothing to add, and why it follows exactly the same "unknown is not zero, mixed is not summed" rules. The whole-move summaries stay market-value only; retail value appears where you'd use it, on a box or room sheet.

The cost of defaults

One footnote. Changing a domain default is more expensive than it looks. Moving new items to GBP was a one-line change, but several tests assert exact currency and amount strings, which is why the project notes say to grep tests/ for the old value first. Defaults leak into assertions; that's a reason to keep few of them.

Next: putting a REST API on top, and making its documentation impossible to let rot.


Previous: ← A core that knows nothing about HTTP · Next: A REST API whose docs can't go stale →