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",)models/library_loan.yaml:
model: library_loanlabel: model.library_loanfields: copy_id: { type: many2one, target: library_copy, required: true } member_id: { type: many2one, target: library_member, required: true } taken_on: { type: date, required: true } due_on: { type: date, required: true } returned_on: { type: date } state: { type: selection, choices: [[out, Out], [returned, Returned], [lost, Lost]], default: out } fee: { type: monetary }The monetary type does both: it creates the three columns and it registers the fee
aspect in the model’s monetary fields.
model library_loan "model.library_loan" { field copy_id link to library_copy required field member_id link to library_member required field taken_on date required field due_on date required field returned_on date field state choice out "Out" | returned "Returned" | lost "Lost" default out field fee money}money is the KM word for the YAML’s monetary type. Same declaration, same three columns.
Three columns, and why three
kewos reconcile --confirmmain [active]: reconciled (0 migration(s), 3 column(s))| Column | What it carries |
|---|---|
fee_amount | the amount, in its own currency |
fee_currency_id | which one, into kws_currency |
fee_amount_company | the 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:
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.feeThat 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:
kewos currency fetch-ratesA 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.