Skip to content

How-to : Grand livre multi-devises avec centimes entiers

Référence FT : FT262 (NENE2-FT/moneylog) — API de grand livre multi-devises utilisant des unités mineures entières (centimes) et un objet de valeur Money

Démontre une API de grand livre de type double entrée qui stocke les montants monétaires comme des unités mineures entières (centimes, yen, pence) pour éviter les erreurs de précision en virgule flottante. Un objet de valeur Money applique les invariants : montant positif et code de devise ISO 4217 à 3 caractères. Le solde par devise est calculé avec SUM(CASE WHEN type = 'credit' ...) dans une seule requête SQL.


Routes

MéthodeCheminDescription
POST/entriesCréer une écriture comptable (crédit ou débit)
GET/entriesLister les écritures (paginées)
GET/entries/{id}Obtenir une seule écriture
GET/balanceSolde par devise (crédit − débit)

Schéma

sql
CREATE TABLE IF NOT EXISTS entries (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    description  TEXT    NOT NULL,
    amount_cents INTEGER NOT NULL CHECK(amount_cents > 0),
    currency     TEXT    NOT NULL CHECK(length(currency) = 3),
    type         TEXT    NOT NULL CHECK(type IN ('credit', 'debit')),
    created_at   TEXT    NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_entries_currency ON entries(currency);
CREATE INDEX IF NOT EXISTS idx_entries_created  ON entries(created_at);

CHECK(amount_cents > 0) applique les montants positifs au niveau DB — filet de sécurité pour les bugs ou l'accès direct à la DB. CHECK(length(currency) = 3) applique le format ISO 4217. CHECK(type IN ('credit', 'debit')) prévient les états invalides.


Pourquoi des centimes entiers, pas des floats ?

php
// ❌ L'arithmétique float perd de la précision
var_dump(0.1 + 0.2);  // float(0.30000000000000004)

// ✅ L'arithmétique entière est exacte
$total = 10 + 20;     // int(30) — toujours exact

Les montants monétaires stockés comme FLOAT accumulent des erreurs d'arrondi sur les sommes et ne peuvent pas être comparés de façon fiable avec ===. Les unités mineures entières (centimes pour USD/EUR, yen pour JPY) sont toujours exactes. La conversion d'affichage ($cents / 100.0) n'a lieu qu'à la sérialisation, pas dans la logique métier.

Mise en garde : JPY et les devises similaires à zéro décimale stockent les unités entières comme "centimes" (c.-à-d., ¥1000 = 1000 centimes). formatDecimal() dans ce FT utilise 2 décimales par défaut ; une implémentation en production devrait rechercher les décimales de la devise.


Objet de valeur Money

php
final readonly class Money
{
    public function __construct(
        public int    $amountCents,
        public string $currency,
    ) {
        if ($amountCents <= 0) {
            throw new \InvalidArgumentException("amountCents must be positive, got {$amountCents}.");
        }
        if (strlen($currency) !== 3) {
            throw new \InvalidArgumentException("currency must be a 3-character ISO 4217 code, got '{$currency}'.");
        }
    }

    public function formatDecimal(): string
    {
        return number_format($this->amountCents / 100, 2, '.', '');
    }
}

Le constructeur valide ses propres invariants. Un objet Money qui existe est toujours valide — les appelants n'ont jamais besoin de revérifier les valeurs. readonly empêche la mutation après construction.

formatDecimal() est uniquement pour l'affichage. Ne jamais stocker ou comparer la chaîne formatée ; toujours comparer les entiers amountCents.


Enum backed EntryType

php
enum EntryType: string
{
    case Credit = 'credit';
    case Debit  = 'debit';
}

EntryType::from('credit') dans l'hydratation convertit la chaîne DB en enum. Si la DB contient une valeur inattendue, from() lève une exception — pas de corruption silencieuse.

EntryType::tryFrom($value) dans le contrôleur retourne null pour les valeurs inconnues, que la vérification d'erreur de validation capture ensuite :

php
$type = $typeValue !== null ? EntryType::tryFrom($typeValue) : null;
if ($type === null) {
    $errors[] = new ValidationError('type', "type must be 'credit' or 'debit'.", 'invalid');
}

Solde par devise : SUM(CASE WHEN ...)

php
public function balanceByCurrency(): array
{
    $rows = $this->executor->fetchAll(
        "SELECT currency,
            SUM(CASE WHEN type = 'credit' THEN amount_cents ELSE 0 END) AS credit_cents,
            SUM(CASE WHEN type = 'debit'  THEN amount_cents ELSE 0 END) AS debit_cents,
            SUM(CASE WHEN type = 'credit' THEN amount_cents ELSE -amount_cents END) AS balance_cents
         FROM entries
         GROUP BY currency
         ORDER BY currency ASC",
        [],
    );
    // ...
}

Une seule requête calcule trois agrégats par devise :

  • credit_cents : total des crédits
  • debit_cents : total des débits
  • balance_cents : solde net (crédit − débit)

CASE WHEN type = 'credit' THEN amount_cents ELSE -amount_cents END utilise un changement de signe pour calculer le solde net en un seul passage. Un balance_cents négatif signifie que les débits dépassent les crédits.

Alternative : deux requêtes (SELECT SUM WHERE type = 'credit' et SELECT SUM WHERE type = 'debit'), fusionnées en PHP. L'approche à requête unique est plus efficace et garde la soustraction dans SQL.


Contrôleur : normalisation de devise

php
$money = new Money(
    (int) $body['amount_cents'],
    strtoupper((string) $body['currency']),  // ← normaliser en majuscules
);

strtoupper() normalise le code de devise pour que usd, USD et Usd soient tous stockés comme USD. Sans normalisation, USD et usd apparaîtraient comme des devises séparées dans la requête de solde.


Sérialisation : centimes et décimal

php
private function serialize(Entry $entry): array
{
    return [
        'id'           => $entry->id,
        'description'  => $entry->description,
        'amount_cents' => $entry->money->amountCents,   // lisible par machine : entier exact
        'amount'       => $entry->money->formatDecimal(), // lisible par humain : "10.50"
        'currency'     => $entry->money->currency,
        'type'         => $entry->type->value,
        'created_at'   => $entry->createdAt,
    ];
}

amount_cents (entier) et amount (décimal formaté) sont tous deux retournés. Les clients effectuant des calculs devraient utiliser amount_cents ; les UIs d'affichage peuvent utiliser amount.


Exemple : réponse de solde

Requête : GET /balance

json
{
  "balances": [
    {"currency": "EUR", "credit_cents": 50000, "debit_cents": 20000, "balance_cents": 30000},
    {"currency": "JPY", "credit_cents": 100000, "debit_cents": 0, "balance_cents": 100000},
    {"currency": "USD", "credit_cents": 150000, "debit_cents": 75000, "balance_cents": 75000}
  ]
}

Solde EUR : 500.00 − 200.00 = 300.00 EUR. Solde USD : 1500.00 − 750.00 = 750.00 USD.


Comparaison de designs

Approche de stockagePrécisionCompromis
Centimes INTEGERExactNécessite une conversion d'affichage ; la devise doit spécifier les décimales
DECIMAL(19,4)ExactNatif DB ; pas disponible dans SQLite ; formater pour l'affichage
FLOAT/REALImprécisNe jamais utiliser pour l'argent — les erreurs d'arrondi s'accumulent
TEXT ("10.50")N/ALe tri et la somme nécessitent un cast ; pas d'arithmétique en SQL

L'INTEGER SQLite avec centimes est l'approche sûre la plus simple pour les APIs basées sur SQLite. Pour MySQL/PostgreSQL, DECIMAL(19,4) est plus conventionnel.


Howtos associés

Publié sous licence MIT.