Les montants et les devises
L’étape 2 a posé la moitié de la règle : un montant n’est jamais un flottant. Voici l’autre moitié, celle qu’on découvre en général trop tard.
Un montant sans sa devise n’est pas un montant. 4.50 ne veut rien dire. Et un rapport
qui reconvertit les montants d’hier au taux d’aujourd’hui donne un chiffre différent chaque
matin pour des opérations qui n’ont pas bougé.
Le noyau répond aux deux avec la même déclaration.
La bibliothèque facture les retards
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 }Le type monetary fait les deux : il crée les trois colonnes et il inscrit l’aspect
fee dans les champs monétaires du modèle.
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 est le mot KM pour le type monetary du YAML. Même déclaration, mêmes trois
colonnes.
Trois colonnes, et pourquoi trois
kewos reconcile --confirmmain [active]: reconciled (0 migration(s), 3 column(s))| Colonne | Ce qu’elle porte |
|---|---|
fee_amount | le montant, dans sa propre devise |
fee_currency_id | laquelle, vers kws_currency |
fee_amount_company | le même montant dans la devise de la société, figé |
La troisième est celle qui surprend, et c’est la plus importante. Sans elle, additionner des amendes en euros et en dollars demanderait de reconvertir à la lecture, donc au taux du jour de la lecture : le total d’un exercice clos changerait tous les matins. Le noyau convertit à l’écriture, une fois, et ne recalcule plus. C’est l’interdiction n°29 du projet : les écritures historiques sont immuables.
currency_rate_date_field="taken_on" dit quelle date choisit le taux. Sans lui, le noyau
prend la date de création de la ligne, ce qui est rarement la date de l’opération.
À l’écriture
Une amende de 4,50 en euros, sur une instance dont la société tient ses comptes en 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 }}Vous n’avez pas écrit fee_amount_company : le noyau l’a rempli. Ici la conversion est
l’identité, parce que les deux devises sont la même.
Les deux champs __currency ne sont pas dans votre modèle non plus. Le serveur les ajoute à la
réponse pour que l’écran affiche « 6,00 € » sans avoir à deviner : c’est ce qui distingue un
montant d’un nombre à quatre décimales.
Le piège : pas de taux, pas de conversion
La même amende, en dollars, sur une instance qui n’a aucun taux en base :
{ "fee_amount": "4.5000", "fee_currency_id": 2, "fee_amount_company": null, "fee_amount__currency": { "code": "USD", "symbol": "$", "precision": 2 }}L’écriture passe. Le montant est là, sa devise est là, mais la jambe société est vide, donc cette amende ne comptera dans aucun total. La seule trace est une ligne dans les journaux :
monetary conversion skipped: no USD->EUR rate at 2026-08-05 for library_loan.feeC’est un choix assumé du noyau : refuser l’écriture ferait perdre la saisie d’un utilisateur à cause d’une donnée de référence manquante côté administration. Mais c’est à vous de savoir que la ligne existe. Les taux se chargent avec :
kewos currency fetch-ratesUn total calculé n’est pas un triplet
Ce que doit un adhérent, c’est la somme des amendes de ses prêts. Une somme n’a pas de devise à elle : elle est dans la devise de la société, par 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",),Une seule colonne, donc. Le modèle doit quand même dire que c’est de l’argent :
company_currency_fields=("fees_due",),{ "id": 3, "name": "Priya Raman", "fees_due": "6.0000", "fees_due__currency": { "code": "EUR", "symbol": "€", "precision": 2 }}Sans cette ligne, la colonne existe, la somme est juste, et l’écran affiche 6.0000. Quatre
décimales, aucun symbole, et un utilisateur qui se demande si c’est des euros. C’est un défaut
qui a réellement traversé jusqu’en production ailleurs dans le produit.
La règle, en une phrase
Tout montant se déclare : monetary s’il a sa propre devise, company_currency_fields
s’il est déjà dans celle de la société. Un DecimalField nu qui contient de l’argent est un
oubli, pas un raccourci.
Et ensuite
Retour au parcours : Livrer, ou l’invariant qui refuse une écriture incohérente, Les règles métier.