Une date métier en JS envoyée avec toISOString() peut reculer d’un jour : la méthode convertit en UTC, et une date choisie à minuit heure de Paris devient la veille à 22:00 UTC. Dans un SaaS d’abonnements, une opération payée le 01/10/2023 pour une clôture de période au 30/09/2023 était refusée avec paymentDate: La date de paiement doit être après la date de cloture. Le front avait envoyé 2023-09-30, soit le jour de clôture lui-même. Le correctif : formater la date en heure locale avec date-fns plutôt que découper une chaîne UTC.
Le symptôme : une date au lendemain de la clôture refusée
Le contexte : le tunnel de clôture de période d’un SaaS d’abonnements. Une étape permet d’ajouter les opérations facturées pendant la période et payées après. La règle côté serveur est simple : la date de paiement doit être strictement postérieure à la date de clôture, comparée sans l’heure.
DateHelper::compareDatesWithoutTime($value->paymentDate, $periodClose->getCloseDate()) === 1
Le ticket décrivait deux cas : un paiement au 01/10/23 rejeté avec le message de validation ci-dessus, et un paiement au 15/10/23 qui finissait en Internal Server Error. Le front utilise React 18.2, le DatePicker de MUI X 7.26 avec AdapterDateFns et date-fns 2.29.1.
La fausse piste : « je ne reproduis pas »
Le premier retour côté back a été une non-reproduction : une opération avec une date de facture dans la période et un paiement dans la période suivante passait sans erreur. Le test était juste, mais il ne visait pas la bonne date.
En réalité, le ticket mélangeait deux bugs distincts :
| Cas | Date saisie | Envoyée | Résultat | Origine |
|---|---|---|---|---|
| J+1 de la clôture | 01/10/23 | 2023-09-30 | refus de validation | front, fuseau horaire |
| Plus tard | 15/10/23 | 2023-10-14 | 500 | back, période suivante absente |
Le 500 venait d’un traitement serveur qui cherchait la période suivante pour y passer une écriture : sans période suivante, une LogicException non gérée remontait. Ce cas-là ne dépendait pas de la date et je l’ai transmis à l’équipe back. Le premier cas, lui, ne se déclenche qu’à la frontière : une date plus loin dans la période suivante perd aussi un jour, mais reste après la clôture, donc la validation passe.
Pourquoi toISOString() décale la date d’un jour ?
Le payload était construit ainsi :
const payload: CreateOperationPayload = {
periodCloseId,
invoiceDate: data.invoiceDate?.toISOString().split("T")[0] ?? "",
amount: data.amount ?? "",
paymentDate: data.paymentDate?.toISOString().split("T")[0],
};
Le DatePicker produit une Date à minuit heure locale. toISOString() renvoie l’instant équivalent en UTC, au format ISO 8601. En France le 1er octobre 2023, on est encore à l’heure d’été (UTC+2) :
$ TZ=Europe/Paris node -e '
const { format } = require("date-fns");
const d = new Date(2023, 9, 1);
console.log(d.toISOString(), d.toISOString().split("T")[0], format(d, "yyyy-MM-dd"));'
2023-09-30T22:00:00.000Z 2023-09-30 2023-10-01
La chaîne envoyée vaut 2023-09-30. Le validateur compare 30/09 et 30/09 : pas strictement après, refus. Le message correspond mot pour mot à celui du ticket.
Une date métier (un jour, sans heure) n’a pas de fuseau ;
toISOString()lui en impose un, UTC, et le jour change.
La date de facture avait le même défaut latent, pas encore signalé : un 01/01 aurait été envoyé comme le 31/12 de l’année précédente, donc hors de la période. Les dates à minuit pile, celles que produit un sélecteur de date, sont exactement celles qui basculent.
Le correctif : formater en heure locale avec date-fns
Le projet avait déjà un helper qui fait la bonne chose :
import { format } from "date-fns";
export const toDateString = (d: unknown) =>
d instanceof Date ? format(d, "yyyy-MM-dd") : "";
format de date-fns lit les composants locaux de la Date : le jour sélectionné reste le jour envoyé. Le correctif remplace les deux découpages :
invoiceDate: data.invoiceDate ? toDateString(data.invoiceDate) : "",
paymentDate: data.paymentDate ? toDateString(data.paymentDate) : undefined,
Le commit tient en cinq lignes ajoutées et deux retirées. J’ai vérifié le comportement avec un test unitaire temporaire (le helper garde le jour local, le chemin toISOString() diverge), puis le lint et le build. Le parsing côté PHP a ses propres pièges, détaillés dans le parser de date d/m/Y et le modificateur !.
Lessons learned
toISOString()n’est pas un formateur de date métier. Il convertit en UTC : à Paris, minuit local devient la veille à 22:00 ou 23:00 UTC.- Une date sans heure se formate avec les composants locaux.
format(date, "yyyy-MM-dd")de date-fns, ougetFullYear()/getMonth()/getDate(). - Un décalage d’un jour ne se voit qu’aux bornes. Il faut tester le lendemain d’une clôture, le 1er janvier, le premier jour d’une période.
- Un ticket peut cacher deux bugs. Séparer les cas par condition de déclenchement évite qu’une non-reproduction sur l’un fasse classer l’autre.
- Le fuseau du poste de test change le résultat. Le même code est juste à New York et faux à Paris :
TZ=Europe/Parisdans les tests rend le bug reproductible.