Aller au contenu

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",
)

Trois colonnes, et pourquoi trois

Fenêtre de terminal
kewos reconcile --confirm
main [active]: reconciled (0 migration(s), 3 column(s))
ColonneCe qu’elle porte
fee_amountle montant, dans sa propre devise
fee_currency_idlaquelle, vers kws_currency
fee_amount_companyle 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 :

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 }
}

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.fee

C’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 :

Fenêtre de terminal
kewos currency fetch-rates

Un 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.