Aller au contenu

Folio NF203

Le folio est le compte courant de séjour du client — l’équivalent de la note d’hôtel. C’est un agrégat append-only : on n’y efface rien, on n’y modifie rien.

Il existe trois kind de folio :

  • stay — folio standard associé à un Stay
  • master_event — folio d’événement MICE (masterFolio = folio ordinaire, ADR-D24)
  • reservation_deposit — folio d’acompte/arrhes préalable à l’arrivée

Chaque opération produit une ligne immuable dans pms.folio_entries :

chargeNatureCompteDescription
revenue706xxxRecette d’hébergement ou prestation
disbursement625xxxDébours refacturé
collected_tax4471Taxe de séjour (hors recette, hors TVA)
penalty706xxxPénalité d’annulation (exclue du void-all)

Chaque chargeType porte en plus : amountHt, taxRate, taxAmount, description, businessDate, idempotencyKey.

Règle d’or : void = contre-écriture (Invariant I1)

Section intitulée « Règle d’or : void = contre-écriture (Invariant I1) »

Il n’existe pas de DELETE sur un folio entry. Pour annuler une ligne, on poste une contre-écriture de signe opposé :

Ligne originale : +12 500 centimes HT (charge mini-bar)
Contre-écriture : -12 500 centimes HT (void de la charge)
Solde net visible : 0 (mais les deux lignes restent dans le journal)

Le void-all annule toutes les lignes sauf celles de chargeNature = 'penalty' (DEFAULT_EXCLUDE = ['penalty']).

La businessDate d’une entrée de folio est toujours résolue côté serveur :

operatingMandateId → MandateEstablishmentReader → BusinessDateProvider

Le serveur connaît la businessDate courante de l’établissement (avancée par le Night Audit). Entre minuit et le Night Audit, on est encore sur J.

Les fronts ne doivent jamais envoyer new Date() comme businessDate — la valeur null signifie “NA non initialisé” et affiche un message d’avertissement, jamais un fallback silencieux.

La garde FOLIO_BUSINESS_DATE_UNRESOLVABLE fait échouer (Result.fail) si la businessDate ne peut pas être résolue.

La taxe de séjour est une ligne chargeNature = 'collected_tax' (compte 4471). Elle est :

  • Hors recette (ne rentre pas dans le CA)
  • Hors base de calcul TVA (elle n’est pas soumise à TVA)
  • Postée par le Night Audit via postTourismTax
  • Restitution dans le reporting (NightAuditCompleted → consumer reporting)

Un règlement poste une entrée de type payment :

  • method : cash/card/transfer/online
  • amount : Money bigint centimes
  • paymentReference : référence PSP opaque (jamais de numéro de carte — PCI SAQ A)

Le solde du folio = Σ charges (HT) - Σ règlements. Un folio est solde-nul avant de pouvoir être clos.

Un folio clos (status = 'closed') :

  • Ne peut plus recevoir de nouvelles charges ou règlements
  • Le void-all est désactivé en UI et en store (double couche, NF203)
  • Les lignes voidées restent visibles (barrées dans l’UI)

Deux natures fiscales distinctes selon MandateFiscalProfile.depositNature :

NatureBase légaleTVAComptabilité
acompteCGI 269-2-cTVA à l’encaissementCRÉDIT 419 + 4457x (jamais 706 immédiat)
arrhesArt. 1590 CcHors champAucune TVA encaissée

La facture finale déduit l’acompte (anti-double TVA). MandateFiscalProfile est fail-closed zéro-défaut : si le profil est absent, les opérations d’encaissement échouent.

Le grand total NF203 (numérotation séquentielle des factures, chaîne HMAC) est par mandat fiscal (operating_mandate_id), pas par établissement. Si un changement de gestionnaire intervient, le grand total et la chaîne repartent de zéro sur le nouveau mandat.

Cette règle n’est pas vérifiée par une fitness function. Elle doit être contrôlée manuellement sur tout nouveau use-case impliquant un folio ou une facture.

Tous les montants sont des bigint centimes côté backend. Côté API et fronts :

  • L’API expose amountCents: string (jamais un number — les int64 JavaScript ne peuvent pas représenter tous les centimes d’une grande facture fidèlement)
  • Les fronts utilisent eurosToCents(str) BigInt-exact (pas de parseFloat)
  • L’affichage utilise Intl.NumberFormat — jamais de division flottante
// Correct
const cents = BigInt(response.amountCents); // string → bigint
// Faux (perte de précision possible sur grands montants)
const cents = Number(response.amountCents);

pms_app n’a que SELECT + INSERT sur :

  • pms.folio_entries
  • pms.invoice*

UPDATE(status) uniquement pour la clôture du folio (status : open → closed). Aucun DELETE.