My App
Données

State machines

Transitions de statut pour check-in, room, bookings, tickets, reservation PMS — validées côté DB et API

Règle (ADR-05) : chaque état métier vit dans un pgEnum côté DB et dans une fonction pure côté API. Toute mutation passe par assertTransitionXxx() avant d'écrire.

Liste des machines

DomaineFichierÉtatsTransitions
Check-in guestdomain/check-in.machine.ts56
Room statusdomain/room-status.machine.ts5graphe libre
Room service orderdomain/room-service.machine.ts55
Laundry orderdomain/laundry.machine.ts55
Restaurant bookingdomain/restaurant.machine.ts67
Spa bookingdomain/spa.machine.ts56
Reservation PMS (miroir)domain/reservation.machine.ts56
Ticketdomain/ticket.machine.ts57

Toutes testées exhaustivement (matrice complète autorisées/interdites) — voir convention testing.

1. Check-in guest

Le cœur du flow Bell. Piloté à la fois par les actions staff et guest.

pending  ──────►  invited  ──────►  completed  ──────►  arrived  ──────►  checked_out
   │                │                                      │
   │                └─ (retour possible si annulation) ─────┘

   │ initial state quand guest créé

États

ÉtatQui le déclencheCe que ça signifie
pendingstaff crée le guestpas d'email envoyé, pas de pré-check-in
invitedstaff clique "Send check-in email"email envoyé, URL unique générée, Reacher OK
completedguest a fini le flow digital (signup → upsells → payment/skip)prêt physiquement à l'hôtel, peut être attendu à la réception
arrivedstaff confirme l'arrivée physique dans le cardexPWA guest débloquée, bridge PMS startReservation(), room passe à occupied
checked_outstaff déclenche le checkoutPWA verrouillée, bridge PMS processReservation(), room passe à cleaning

Table de transitions

export const checkInTransitions = {
  pending:     ["invited"],
  invited:     ["completed", "pending"],   // cancel invite possible
  completed:   ["arrived"],
  arrived:     ["checked_out"],
  checked_out: [],                          // terminal
} as const;

2. Room status

Ne suit pas un cycle linéaire — c'est un graphe libre piloté par les actions guest + staff + PMS.

         ┌──────────────►  occupied  ─┐
         │                            │
   reserved                           ▼
         ▲                       cleaning
         │                            │
      available  ◄───────────────────┘


     maintenance

États

ÉtatQuand
availablechambre prête à recevoir un guest
reservedchambre bloquée pour une réservation future (pas encore arrivée)
occupiedguest à l'intérieur (check-in confirmé)
cleaninghousekeeping en cours (après checkout ou stayover)
maintenancehors service (travaux, panne)

Transitions autorisées

export const roomStatusTransitions = {
  available:   ["reserved", "occupied", "maintenance"],
  reserved:    ["occupied", "available", "maintenance"],
  occupied:    ["cleaning", "maintenance"],
  cleaning:    ["available", "maintenance"],
  maintenance: ["available", "cleaning"],
} as const;

Pas de transition occupied → available directe : il faut passer par cleaning. Pas de transition maintenance → occupied directe : on nettoie d'abord.

3. Room service order

pending  ──►  preparing  ──►  delivering  ──►  delivered
   │             │
   └──► cancelled ◄──┘

(cancelled accessible depuis pending et preparing uniquement)

États

ÉtatQui déclenche
pendingguest passe commande
preparingstaff cuisine accepte (toast dashboard)
deliveringrunner part livrer la chambre
deliveredrunner confirme la livraison (signature ou photo)
cancelledguest ou staff annule (rembourse le PaymentIntent)

Table

export const roomServiceTransitions = {
  pending:    ["preparing", "cancelled"],
  preparing:  ["delivering", "cancelled"],
  delivering: ["delivered"],
  delivered:  [],
  cancelled:  [],
} as const;

Volontairement : pas de delivering → cancelled. Si le runner est parti, le guest paie.

4. Laundry order

pending  ──►  collected  ──►  washing  ──►  ready  ──►  delivered
   │             │              │             │
   └──► cancelled ◄──────────────┘             │

                                          (ready → cancelled
                                           pas autorisé,
                                           tout est lavé)
ÉtatQuand
pendingguest a commandé depuis la PWA
collectedhousekeeping a pris le linge en chambre
washingen cours de lavage
readylavé, prêt à livrer
deliveredlivré en chambre
cancelledavant collection uniquement

5. Restaurant booking

requested  ──►  confirmed  ──►  seated  ──►  done
    │               │             │
    └──►  cancelled  ◄────────────┘

                                  └──► no_show (heure dépassée + pas séjour)
ÉtatQuand
requestedguest demande via PWA, staff n'a pas encore confirmé
confirmedstaff ou IA confirme la dispo
seatedguest présent à sa table (staff clique)
donerepas terminé, facture payée (room charge ou Stripe)
cancelledannulé avant seated
no_showheure + 30 min dépassée, pas présent

6. Spa booking

Similaire à restaurant, mais avec in_progress (soin en cours) :

requested  ──►  confirmed  ──►  in_progress  ──►  done
    │               │              │
    └──►  cancelled ◄──────────────┘

7. Reservation PMS (miroir)

Quand une réservation vient du PMS via sync, on la mappe dans Bell :

inquiry  ──►  confirmed  ──►  checked_in  ──►  checked_out
    │             │
    └─► canceled ◄┘

États normalisés

État normaliséMews (exemple)Signifie
inquiry(rare)demande non confirmée côté PMS
confirmedConfirmedréservation payée/garantie, pas encore arrivé
checked_inStartedarrivé à l'hôtel
checked_outProcessedparti
canceledCanceled / Optionalannulé

Important : cette machine est miroir du PMS. On ne fait pas nous-même une transition confirmed → checked_in — c'est le PMS (via webhook ou sync) qui nous dit. Nos own transitions se font sur guest.check_in_status (cf. machine #1).

8. Ticket

open  ──►  in_progress  ──►  waiting  ──►  resolved  ──►  closed
   │            │               │             │
   └──► cancelled ◄──────────────┴─────────────┘
ÉtatQuand
openstaff crée le ticket depuis un chat
in_progressun staff se l'assigne
waitingbloqué (attend info guest, fournisseur…)
resolvedsolution apportée, guest notifié
closedfermeture définitive, plus de modif
cancelledcréé par erreur

Pattern d'implémentation (référence)

Chaque machine suit cette structure dans packages/api/src/domain/<domain>.machine.ts :

packages/api/src/domain/check-in.machine.ts
import { checkInStatusEnum } from "@bell/db/schema/enums";

export type CheckInStatus = (typeof checkInStatusEnum.enumValues)[number];

export const checkInTransitions = {
  pending:     ["invited"],
  invited:     ["completed", "pending"],
  completed:   ["arrived"],
  arrived:     ["checked_out"],
  checked_out: [],
} as const satisfies Record<CheckInStatus, readonly CheckInStatus[]>;

export function canTransitionCheckIn(from: CheckInStatus, to: CheckInStatus): boolean {
  return checkInTransitions[from].includes(to as never);
}

export class InvalidCheckInTransitionError extends Error {
  readonly code = "INVALID_CHECK_IN_TRANSITION";
  constructor(public from: CheckInStatus, public to: CheckInStatus) {
    super(`Invalid check-in transition: ${from} → ${to}`);
    this.name = "InvalidCheckInTransitionError";
  }
}

export function assertTransitionCheckIn(from: CheckInStatus, to: CheckInStatus): void {
  if (!canTransitionCheckIn(from, to)) {
    throw new InvalidCheckInTransitionError(from, to);
  }
}

Et le test file :

packages/api/src/domain/check-in.machine.test.ts
import { describe, test, expect } from "bun:test";
import { checkInStatusEnum } from "@bell/db/schema/enums";
import {
  canTransitionCheckIn,
  assertTransitionCheckIn,
  InvalidCheckInTransitionError,
  checkInTransitions,
} from "./check-in.machine";

describe("check-in state machine", () => {
  // Matrice exhaustive
  for (const from of checkInStatusEnum.enumValues) {
    for (const to of checkInStatusEnum.enumValues) {
      const allowed = checkInTransitions[from].includes(to as never);
      test(`${allowed ? "allows" : "forbids"} ${from} → ${to}`, () => {
        expect(canTransitionCheckIn(from, to)).toBe(allowed);
      });
    }
  }

  test("assert throws InvalidCheckInTransitionError on forbidden transition", () => {
    expect(() => assertTransitionCheckIn("arrived", "pending"))
      .toThrow(InvalidCheckInTransitionError);
  });

  test("every checkInStatus value is a valid machine key", () => {
    for (const status of checkInStatusEnum.enumValues) {
      expect(checkInTransitions[status]).toBeDefined();
    }
  });
});

Usage dans les services

packages/api/src/modules/cardex/cardex.service.ts
import { assertTransitionCheckIn } from "../../domain/check-in.machine";

export async function confirmGuestArrival(opts: {
  organizationId: string;
  guestId: string;
}) {
  return await db.transaction(async (tx) => {
    const current = await repo.getGuestById(tx, opts);
    if (!current) throw new NotFoundError("guest");

    // Invalide la transition si elle n'est pas autorisée
    assertTransitionCheckIn(current.checkInStatus, "arrived");

    await repo.setCheckInStatus(tx, opts.guestId, "arrived");
    // ... suite
  });
}

Génération automatique (future)

À terme, on peut générer un diagramme Mermaid depuis les tables de transition pour le publier dans Fumadocs. Script dans scripts/generate-state-diagrams.ts qui lit chaque .machine.ts et génère un .mdx avec mermaid. Pas MVP, mais envisagé si on a + de 10 machines.

Lien avec les autres pages

On this page