L'annulation
L'expert métier appelle.
"Un client s'est trompé de semaine. Il veut annuler. Je fais comment ?"
Aujourd'hui : rien. On ne peut pas annuler. Il n'y a même pas de quoi désigner une réservation.
Posons les questions avant de coder. Comme au chapitre 1.
L'entretien
Qui peut annuler ? Le vacancier qui a réservé. Le propriétaire aussi, mais c'est un autre sujet — il doit prévenir, il y a un dédommagement. On verra plus tard.
Jusqu'à quand ? Tant que le séjour n'a pas commencé. Le jour de l'arrivée, c'est trop tard : le ménage est fait, la semaine est perdue.
Le logement redevient-il disponible ? Évidemment. C'est même tout l'intérêt.
La réservation disparaît-elle ?
"Surtout pas. Je veux savoir qui annule, et combien de fois. J'ai eu un client qui a réservé et annulé quatre fois en un mois."
Voilà. Le chapitre 7 l'avait anticipé, sans savoir qu'il avait raison :
une réservation annulée disparaît-elle du calendrier, ou y reste-t-elle avec un statut
cancelled? La réponse est presque toujours la seconde — on veut garder la trace.
Retenons le principe, il dépasse largement ce cas : on ne supprime pas, on change d'état. Une ligne effacée est une question à laquelle vous ne pourrez plus jamais répondre.
Ce qui manque : une identité
Pour annuler une réservation, il faut la désigner.
cancelBooking({ bookingId: "???" })Nos réservations n'en ont pas. Le chapitre 32 s'en est aperçu en bricolant une clé React à partir du logement et de la date.
Retournons au chapitre 3 :
Une entité a une identité propre, indépendante de ses caractéristiques.
Une réservation est une entité. Nous l'avons traitée quinze chapitres durant comme un objet littéral. Ça a tenu tant que nous ne faisions que la créer et la lire.
D'où vient l'identifiant ?
Trois candidats.
La base de données (SERIAL, AUTO_INCREMENT). C'est le réflexe le plus répandu, et il a un défaut lourd : l'objet n'a pas d'identité tant qu'il n'est pas enregistré. La commande ne peut rien retourner avant l'aller-retour, on ne peut pas préparer deux objets liés avant de les sauver, et l'identifiant devient une propriété de l'infrastructure plutôt que du métier.
Une combinaison de champs (accommodationId + from). Séduisant, et c'est un piège classique. Une clé métier finit toujours par changer : le jour où l'on autorise le décalage d'une réservation, l'identité change avec la date. Une identité qui change n'est pas une identité.
Un identifiant tiré au sort, généré par l'application avant l'enregistrement. C'est notre choix.
Il donne trois choses immédiatement : la commande peut retourner l'identifiant sans attendre la base ; on peut construire un objet complet avant de le sauver ; et deux serveurs peuvent créer des réservations sans se concerter.
Mais tirer au sort, c'est encore une source d'imprévisibilité. Comme l'horloge du chapitre 17, comme le jeton du chapitre 30. Donc :
// infra/testIdProvider.js
export const testIdProvider = () => {
let counter = 0;
return { newId: () => `booking-${++counter}` };
};
// infra/uuidProvider.js
export const uuidProvider = { newId: () => crypto.randomUUID() };Vous connaissez la règle maintenant, elle ne change pas d'un chapitre à l'autre : tout ce qui donne un résultat différent à chaque appel est une dépendance. L'horloge, l'aléa, le réseau, le système de fichiers. Le reste est pur, et le reste vit dans le domaine.
(Une note pratique : pour une clé primaire, préférez un UUID v7 ou un ULID à un UUID v4. Ils commencent par un horodatage, donc les valeurs successives se suivent, et l'index de la base ne se fragmente pas. Sur une table qui grossit, la différence est mesurable — et invisible tant que la table est petite.)
Booking devient une entité
// domain/entities/Booking.js
export const bookingStatus = {
confirmed: "confirmed",
cancelled: "cancelled",
};
export class Booking {
#id;
#tenantId;
#accommodationId;
#guests;
#stay;
#status;
constructor({ id, tenantId, accommodationId, guests, stay, status }) {
this.#id = id;
this.#tenantId = tenantId;
this.#accommodationId = accommodationId;
this.#guests = guests;
this.#stay = stay;
this.#status = status;
Object.freeze(this);
}
static confirm({ id, tenantId, accommodationId, guests, stay }) {
return new Booking({ id, tenantId, accommodationId, guests, stay,
status: bookingStatus.confirmed });
}
get id() { return this.#id; }
get tenantId() { return this.#tenantId; }
get accommodationId() { return this.#accommodationId; }
get guests() { return this.#guests; }
get stay() { return this.#stay; }
get status() { return this.#status; }
isActive() { return this.#status === bookingStatus.confirmed; }
belongsTo(user) { return this.#tenantId === user.id; }
/** Retourne la même réservation, annulée. */
cancel() {
return new Booking({
id: this.#id, tenantId: this.#tenantId, accommodationId: this.#accommodationId,
guests: this.#guests, stay: this.#stay, status: bookingStatus.cancelled,
});
}
}Un Object.freeze dans une entité ? Le chapitre 3 disait pourtant :
Une entité est mutable. On la crée, on la modifie, puis on la conserve.
Précisons, parce que c'est une confusion très répandue.
Ce qui est mutable, c'est la réservation dans le système : son état change au fil du temps, et c'est ce qui la distingue d'une valeur. Ce n'est pas une obligation de muter l'objet JavaScript en mémoire.
cancel() retourne une nouvelle instance, avec le même id. C'est la même réservation — l'identité le dit — dans un autre état.
Qu'y gagne-t-on ? Qu'aucun code ne puisse écrire booking.status = "cancelled" en douce, sans passer par la règle. Ce que nous avions obtenu sur les valeurs au chapitre 21, appliqué à une entité. Le repository, lui, ne verra pas la différence : il reçoit un objet, il l'enregistre.
Notez enfin ce que la classe n'expose pas : aucun setStatus. La seule transition possible est celle qui a un nom métier.
La commande
// domain/usecases/cancelBooking.js
export function cancelBooking(payload) {
const { bookingId } = payload;
return async function (dependencies, context) {
const user = context.loggedUser;
if (!user) {
return context.withError(shouldBeLogged());
}
const booking = await dependencies.bookings.findById(bookingId);
if (!booking) {
return context.withError(UnknownBooking(bookingId));
}
// On n'annule que ses propres réservations.
if (!booking.belongsTo(user)) {
return context.withError(UnknownBooking(bookingId));
}
if (!booking.isActive()) {
return context.withError(BookingAlreadyCancelled(bookingId));
}
const today = dependencies.dateProvider.today();
if (!booking.stay.startsAfter(today)) {
return context.withError(StayAlreadyStarted(bookingId));
}
await dependencies.bookings.save(booking.cancel());
return context;
};
}Le troisième if mérite qu'on s'y arrête, parce qu'il a l'air d'une faute de frappe.
Une réservation qui ne m'appartient pas retourne UnknownBooking, pas NotYourBooking.
C'est délibéré, et c'est la même logique qu'InvalidCredentials au chapitre 30. Répondre "cette réservation existe, mais elle n'est pas à vous" confirme l'existence de l'identifiant. En enchaînant les tentatives, on cartographie ainsi les réservations de la plateforme.
Pour un appelant qui n'y a pas droit, une ressource n'existe pas.
Et remarquez que startsAfter sert ici une deuxième règle métier, complètement différente de la première. Écrite au chapitre 21 pour le préavis d'un jour à la réservation, elle exprime aujourd'hui la limite d'annulation. Nous n'avons rien ajouté à Stay.
C'est le signe qu'un Value Object est bien découpé : ses méthodes répondent à des questions, pas à des cas d'usage.
Les tests
it("A tenant can cancel a booking before it starts", async () => {
const app = new App(testDependencies());
const session = await app.run([
login({ email: "faketenant@mail.com", password: "secret" }),
book({ accommodationId: "accommodation-1", adults: 2, children: 0,
from: "2024-06-02", to: "2024-06-04" }),
]);
const cancelled = await app.run([
authenticate(session.token),
cancelBooking({ bookingId: "booking-1" }),
]);
expect(cancelled.error).toBeUndefined();
// Le logement est de nouveau proposé sur la période
const free = await app.dependencies.bookings.getAvailableAccommodations(
Stay.parse({ from: "2024-06-02", to: "2024-06-04" }).value
);
expect(free.some((a) => a.id === "accommodation-1")).toBe(true);
// Mais la trace demeure
const all = await app.dependencies.bookings.listBookingsForTenantId("tenant-1");
expect(all).toHaveLength(1);
expect(all[0].status).toBe(bookingStatus.cancelled);
});C'est le test le plus dense que nous ayons écrit, et il tient en trois assertions : l'opération réussit, le calendrier se libère, la trace reste. Trois affirmations de l'expert métier, trois lignes.
Notez bookingId: "booking-1" écrit en dur. C'est notre testIdProvider qui le garantit — un identifiant aléatoire aurait rendu ce test impossible à écrire simplement. C'est le bénéfice, très concret, d'avoir traité l'aléa comme une dépendance.
Et les trois refus :
it("A tenant cannot cancel someone else's booking", async () => { /* ... UnknownBooking */ });
it("A tenant cannot cancel twice", async () => { /* ... BookingAlreadyCancelled */ });
it("A tenant cannot cancel a stay that has started", async () => { /* ... StayAlreadyStarted */ });Le bug que le statut vient de créer
Relisez le test ci-dessus. Il vérifie que le logement redevient disponible.
Pourquoi cette assertion, alors que nous n'avons touché à aucune requête ?
Parce que sans elle, la fonctionnalité serait fausse. Nos requêtes ne connaissent pas le statut : elles considèrent toutes les réservations, y compris annulées. Un logement annulé resterait bloqué pour toujours.
C'est le prix de tout nouvel état : chaque endroit qui lisait "les réservations" doit maintenant décider s'il parle des réservations actives ou de toutes.
async findOverlapping(accommodationId, stay) {
return this._bookings.filter(
(booking) =>
booking.isActive() && // <-- ajouté
booking.accommodationId === accommodationId &&
booking.stay.overlaps(stay)
);
}
async getAvailableAccommodations(stay, occupancy) {
const bookedIds = this._bookings
.filter((booking) => booking.isActive() && booking.stay.overlaps(stay)) // <-- ajouté
.map((booking) => booking.accommodationId);
// ...
}Deux isActive(). Faciles à écrire, faciles à oublier.
Le remède n'est pas la vigilance, c'est le test. Chacun de ces deux oublis fait échouer une assertion du test d'annulation. C'est exactement ce à quoi sert une suite de tests : rendre bruyantes les conséquences lointaines d'un changement local.
save doit cesser d'empiler
Un piège attend dans le repository :
async save(booking) {
this._bookings.push(booking); // hmm
}Annuler une réservation la sauvegarde à nouveau. Avec cette implémentation, nous obtenons deux réservations portant le même identifiant : l'ancienne, confirmée, et la nouvelle, annulée. Et findOverlapping retrouvera l'ancienne.
Notre repository n'enregistrait que des créations. Il doit maintenant enregistrer des états :
async save(booking) {
const index = this._bookings.findIndex((b) => b.id === booking.id);
if (index === -1) this._bookings.push(booking);
else this._bookings[index] = booking;
}
async findById(id) {
return this._bookings.find((booking) => booking.id === id) ?? null;
}C'est un upsert, et c'est la sémantique normale d'un save piloté par une identité fournie par l'application. En SQL, ce sera un insert ... on conflict (id) do update. En mémoire, quatre lignes.
Le chapitre 20 le disait : coder soi-même la version en mémoire, c'est découvrir ce qu'on attend vraiment de la vraie base.
Le bouton
Côté page "mes réservations", il faut savoir si une réservation est annulable — sinon on affiche un bouton qui échouera.
Retour au partage du chapitre 28 : la requête filtre, la commande refuse. Une seule règle, extraite :
// domain/rules/canBeCancelled.js
export function canBeCancelled(booking, today) {
return booking.isActive() && booking.stay.startsAfter(today);
}La commande s'en sert pour refuser, la vue pour afficher (ou non) le bouton :
return {
id: booking.id,
status: booking.status,
cancellable: canBeCancelled(booking, today),
// ...
};Et l'action, exactement au même schéma que la réservation — invoquer, gérer l'erreur, rafraîchir :
const onCancel = async (bookingId) => {
const context = await app.run([
authenticate(session.token),
cancelBooking({ bookingId }),
]);
if (context.error) { setError(context.error.message); return; }
setError(null);
await refresh();
};Et voilà le key du chapitre 32 qui trouve enfin sa valeur naturelle : key={booking.id}.
Un dernier mot sur l'affichage. Une réservation annulée reste dans la liste — l'expert métier veut la trace, l'utilisateur aussi. Barrée, grisée, avec la mention "Annulée". Ne la masquez pas : un utilisateur qui ne retrouve pas sa réservation annulée croit qu'elle est toujours active.
L'annulation fonctionne. Il reste à prévenir les gens.
Le code de cette étape est disponible ici (opens in a new tab).