Aller au contenu

Invariants money & date

Tout montant monétaire est représenté en centimes comme bigint — aucune exception.

JavaScript number est un IEEE 754 double (53 bits de mantisse). Pour les montants en centimes :

  • 9_007_199_254_740_991 (max safe integer) = ~90 milliards d’euros
  • La précision est perdue sur les opérations arithmétiques au-delà de ~9 quadrillions de centimes
  • Plus fondamentalement : 0.1 + 0.2 !== 0.3 en float — inacceptable pour de la comptabilité

bigint garantit une précision exacte pour toute opération entière.

// @pms/shared-kernel/Money
const a = Money.of(12000n, currency('EUR')); // 120,00 €
const b = Money.of(9000n, currency('EUR')); // 90,00 €
a.add(b) // ok → 210,00 €
a.subtract(b) // ok → 30,00 €
a.multiply(3n) // ok → 360,00 € (entier uniquement)
// a.multiply(1.5) → erreur TS — pas de multiplication par flottant
// Devise-safe : EUR + GBP → exception (pas de conversion cross-devise en V1)
Money.of(100n, currency('EUR')).add(Money.of(100n, currency('GBP'))); // throw

Pour les calculs de taux (TVA, commission) :

import { applyBasisPoints, applyBasisPointsCeil, decomposeFromTtc } from '@pms/shared-kernel';
// TVA 20% = 2000 bp
applyBasisPoints(Money.of(10000n, currency('EUR')), 2000n); // 2000 centimes (arrondi half-up)
// Taxe DGFiP → toujours plafond
applyBasisPointsCeil(base, rate);
// Décomposition TTC → HT + TVA (exact, net + tax === ttc, vérifié fast-check)
decomposeFromTtc(Money.of(12000n, currency('EUR')), 2000n);
// → { ht: 10000n, tax: 2000n } (12000 = 10000 + 2000 ✓)
  • L’API expose les montants comme amountCents: string (JSON — pas de number pour éviter la perte de précision des int64)
  • Les fronts ne castent pas string → number pour les montants
  • Conversion string → bigint : BigInt(response.amountCents)
  • Conversion euros → centimes (saisie utilisateur) : eurosToCents(str) BigInt-exact (pas de parseFloat)
  • Affichage : Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }) avec division entière
// Correct (BigInt-exact)
function eurosToCents(str: string): bigint {
const [euros, cents = '00'] = str.replace(',', '.').split('.');
return BigInt(euros) * 100n + BigInt(cents.padEnd(2, '0').slice(0, 2));
}
// Faux (float, perte de précision possible)
function eurosToCents_WRONG(str: string): bigint {
return BigInt(Math.round(parseFloat(str) * 100));
}

La businessDate est toujours résolue côté serveur (ADR-00D78). Les fronts ne calculent jamais la date métier eux-mêmes.

operatingMandateId
→ MandateEstablishmentReader.getEstablishmentId()
→ BusinessDateProvider.getCurrentBusinessDate(establishmentId)
→ date métier de l'établissement (stockée en DB, avancée par Night Audit)

Si la résolution échoue : Result.fail('folio', 'FOLIO_BUSINESS_DATE_UNRESOLVABLE', ...).

Entre minuit et 3h00 (heure du Night Audit), la businessDate reste sur J. Les opérations effectuées dans ce créneau appartiennent comptablement à J, même si l’horloge dit J+1.

Les fronts consomment la businessDate via GET /v1/meEstablishmentContextService. Valeur null = établissement non initialisé, affiche un message — pas de fallback sur new Date().toISOString().slice(0, 10).

Le grand total NF203, la numérotation des factures et la chaîne HMAC sont par mandat fiscal, pas par établissement.

Un même établissement peut changer de gestionnaire (SAS exploitante cède à une autre). Dans ce cas :

  • Le nouveau gestionnaire ouvre un nouveau OperatingMandate
  • Le grand total repart de 1 sur le nouveau mandat
  • La chaîne HMAC repart de son genesis
  • L’ancienne chaîne reste intacte et vérifiable

Si on utilisait l’establishmentId, un changement de gestionnaire serait invisible dans la numérotation — violation NF203.

Sur tout nouveau use-case impliquant un folio, une facture ou une entrée d’audit :

  1. Identifier le operating_mandate_id associé (via la réservation ou le mandat actif de l’établissement)
  2. Utiliser operating_mandate_id comme clé de partitionnement NF203
  3. Il n’y a pas de fitness function qui vérifie cela automatiquement — contrôle manuel obligatoire

La taxe de séjour respecte des invariants distincts des autres charges :

  • chargeNature = 'collected_tax' (pas 'revenue')
  • Compte 4471 (dette envers la commune, pas du CA)
  • Hors base de calcul TVA
  • Barème effective-dated (la commune peut changer son barème — la taxe appliquée est celle valide à la date du séjour)
  • Inaltérable après posting (pas de void de la taxe de séjour — contra bon sens, mais réglementaire)
  • Dans le reporting : restitution (lecture depuis NightAuditCompleted), jamais recalculée a posteriori

L’aide CAF (VACAF) est une ventilation de paiement — une partie du règlement vient de la CAF directement. Ce n’est pas une remise sur le prix.

Conséquence comptable :

  • Le folio reste à son montant plein
  • Le règlement est fractionné : X € client + Y € CAF
  • La recette de l’établissement est identique (X + Y)

Si VACAF était traitée comme une remise, la recette serait minorée — incorrection fiscale.

Déterminé par MandateFiscalProfile.depositNature (fail-closed, zéro défaut) :

ACOMPTE (CGI 269-2-c)ARRHES (art. 1590 Cc)
TVAExigible à l’encaissementHors champ
Non-présentation clientRemboursable en principeAcquis par l’établissement
ComptabilisationCRÉDIT 419 + 4457xCompte spécifique sans TVA
Facture finaleDéduit (anti-double TVA)Régularisation spécifique

La nature ACOMPTE/ARRHES est figée par le MandateFiscalProfile de l’établissement — il n’est pas possible de choisir au cas par cas.