Aller au contenu
Sylvain Joffraud l4w
Retour

Date métier en JS : `toISOString()` recule d’un jour

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 :

CasDate saisieEnvoyéeRésultatOrigine
J+1 de la clôture01/10/232023-09-30refus de validationfront, fuseau horaire
Plus tard15/10/232023-10-14500back, 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

Questions fréquentes

Pourquoi <code>toISOString()</code> renvoie-t-il la veille de la date saisie ?
toISOString() exprime toujours l’instant en UTC. Un date picker crée une Date à minuit heure locale ; à Paris en heure d’été (UTC+2), le 1er octobre à 00:00 devient 2023-09-30T22:00:00.000Z. Couper la chaîne au T garde donc le 30 septembre. Le jour affiché et le jour envoyé ne sont plus les mêmes.
Comment envoyer une date sans heure au format <code>yyyy-MM-dd</code> en JavaScript ?
Il faut formater la date en heure locale, pas en UTC. Avec date-fns, format(date, "yyyy-MM-dd") lit les composants locaux (année, mois, jour) et renvoie le jour calendaire que l’utilisateur a choisi. Sans librairie, on assemble getFullYear(), getMonth() + 1 et getDate() avec des zéros en tête.
Le décalage d’un jour touche-t-il tous les fuseaux horaires ?
Non. Le recul d’un jour avec toISOString() ne frappe que les fuseaux en avance sur UTC, comme la France : minuit local y correspond à la veille en UTC. À New York, minuit local le 1er octobre donne 2023-10-01T04:00:00.000Z, le bon jour. Un bug qui dépend du fuseau du poste passe facilement inaperçu en recette.
Pourquoi le bug de date n’apparaît-il que sur certaines valeurs ?
Le décalage d’un jour s’applique à toutes les dates, mais il ne devient visible que lorsqu’une règle métier tombe pile sur la frontière. Une date de paiement au 15 envoyée comme le 14 reste valide ; une date au lendemain d’une clôture envoyée comme le jour de clôture est rejetée. Il faut tester les bornes : premier jour de période, 1er janvier.

Partager cet article :

Article précédent
OpenTofu ACME : `min_days_remaining` ne renouvelle rien
Article suivant
mlx_lm.server : les commentaires SSE cassent le client