Skip to content

Amounts and currencies

Step 2 set out half the rule: an amount is never a float. Here is the other half, the one people usually discover too late.

An amount without its currency is not an amount. 4.50 means nothing. And a report that re-converts yesterday’s amounts at today’s rate produces a different figure every morning for operations that have not moved.

The kernel answers both with the same declaration.

The library charges for late returns

models/loan.py:

from kewos.currency import monetary_columns
registry.register_model(
"library_loan",
module="library",
fields=[
Link(name="copy_id", target_model="library_copy", nullable=False),
Link(name="member_id", target_model="library_member", nullable=False),
DateField(name="taken_on", nullable=False),
DateField(name="due_on", nullable=False),
DateField(name="returned_on", nullable=True),
SelectionField(name="state", choices=STATES, default="out"),
# The late fee: three columns, one declaration.
*monetary_columns("fee"),
],
# ... and the model says which of its aspects are money (#28), plus the
# date whose rate freezes the company conversion (#29).
monetary_fields=("fee",),
currency_rate_date_field="taken_on",
)

Three columns, and why three

Fenêtre de terminal
kewos reconcile --confirm
main [active]: reconciled (0 migration(s), 3 column(s))
ColumnWhat it carries
fee_amountthe amount, in its own currency
fee_currency_idwhich one, into kws_currency
fee_amount_companythe same amount in the company’s currency, frozen

The third one is the surprising one, and the most important. Without it, adding up fees in euros and dollars would mean converting at read time, hence at the rate of the day you read: a closed period’s total would change every morning. The kernel converts at write time, once, and never recomputes. That is the project’s interdiction #29: historical entries are immutable.

currency_rate_date_field="taken_on" says which date picks the rate. Without it the kernel uses the row’s creation date, which is rarely the date of the operation.

At write time

A fee of 4.50 in euros, on an instance whose company keeps its books in euros:

Fenêtre de terminal
curl -X PATCH http://127.0.0.1:8080/api/v1/library_loan/3 \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"fee_amount":"6.00","fee_currency_id":1}'
{
"id": 3, "taken_on": "2026-08-28",
"fee_amount": "6.0000",
"fee_currency_id": 1,
"fee_amount_company": "6.0000",
"fee_amount__currency": { "code": "EUR", "symbol": "€", "precision": 2 },
"fee_amount_company__currency": { "code": "EUR", "symbol": "€", "precision": 2 }
}

You did not write fee_amount_company: the kernel filled it. Here the conversion is the identity, because both currencies are the same.

The two __currency fields are not in your model either. The server adds them to the response so the screen can show “€6.00” without guessing: that is what tells an amount apart from a number with four decimals.

The trap: no rate, no conversion

The same fee, in dollars, on an instance with no rates loaded:

{
"fee_amount": "4.5000",
"fee_currency_id": 2,
"fee_amount_company": null,
"fee_amount__currency": { "code": "USD", "symbol": "$", "precision": 2 }
}

The write goes through. The amount is there, its currency is there, but the company leg is empty, so this fee will count in no total at all. The only trace is a line in the logs:

monetary conversion skipped: no USD->EUR rate at 2026-08-05 for library_loan.fee

That is a deliberate kernel choice: refusing the write would lose a user’s input over a missing piece of reference data on the administration side. But it is on you to know the line exists. Rates are loaded with:

Fenêtre de terminal
kewos currency fetch-rates

A computed total is not a triplet

What a member owes is the sum of their loans’ fees. A sum has no currency of its own: it is in the company’s currency, by construction.

Backlink(name="loan_ids", target_model="library_loan", inverse_field="member_id"),
AggregateField(
name="fees_due",
base_field=DecimalField(name="fees_due", precision=19, scale=4),
over="loan_ids",
of="fee_amount",
func="sum",
),

One column, then. The model still has to say that it is money:

company_currency_fields=("fees_due",),
{
"id": 3, "name": "Priya Raman",
"fees_due": "6.0000",
"fees_due__currency": { "code": "EUR", "symbol": "€", "precision": 2 }
}

Without that line the column exists, the sum is right, and the screen shows 6.0000. Four decimals, no symbol, and a user wondering whether those are euros. It is a defect that really did reach production elsewhere in the product.

The rule, in one sentence

Every amount is declared: monetary when it carries its own currency, company_currency_fields when it is already in the company’s. A bare DecimalField holding money is an oversight, not a shortcut.

Next

Back to the walkthrough: Ship it, or the invariant that refuses an incoherent write, Business rules.