PMS adapter pattern
Pattern adapter qui permet à Bell d'être branché à n'importe quel PMS (Mews, Opera, Cloudbeds, ...) via une interface commune
Pattern Adapter : un seul code de sync, branché à N PMS via des adapters qui implémentent une interface commune. C'est le différenciateur technique central de Bell.
Architecture
L'interface IntegrationAdapter
export type AdapterCapability =
| "rooms"
| "guests"
| "reservations"
| "webhooks"
| "checkin" // peut déclencher un check-in PMS
| "checkout" // peut déclencher un check-out PMS
| "charges" // peut poster des charges
| "room-status"; // peut updater le status room
export interface IntegrationAdapter {
readonly provider: string;
readonly capabilities: Set<AdapterCapability>;
// Santé / connexion
testConnection(): Promise<{ success: boolean; propertyName: string; propertyId: string }>;
// Lecture (sync)
getRooms?(): Promise<NormalizedRoom[]>;
getGuests?(ids?: string[]): Promise<NormalizedGuest[]>;
getReservations?(opts: { states?: ReservationState[] }): Promise<NormalizedReservation[]>;
getGuestById?(id: string): Promise<NormalizedGuest | null>;
getReservationById?(id: string): Promise<NormalizedReservation | null>;
// Webhooks entrants (parser des events)
parseWebhookEvent?(body: unknown, headers?: Record<string, string>): NormalizedWebhookEvent[];
verifyWebhookSignature?(body: string, signature: string, secret: string): boolean;
// Écriture (bridges)
startReservation?(reservationId: string): Promise<void>; // check-in PMS
processReservation?(reservationId: string): Promise<void>; // check-out PMS
addOrder?(params: { guestId: string; items: NormalizedOrderItem[]; reservationId?: string }): Promise<{ orderId?: string }>;
updateRoomStatus?(params: { roomId: string; status: RoomStatus }): Promise<void>;
}Les types normalisés
export type RoomStatus = "available" | "occupied" | "cleaning" | "maintenance" | "reserved";
export type ReservationState = "inquiry" | "confirmed" | "checked_in" | "checked_out" | "canceled";
export type BookingChannel =
| "booking" | "expedia" | "airbnb" | "hrs" | "hotelbeds" | "agoda"
| "direct" | "mews" | "other";
export interface NormalizedGuest {
externalId: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
isVip?: boolean;
}
export interface NormalizedRoom {
externalId: string;
number: string;
floor?: string;
type?: string;
status: RoomStatus;
}
export interface NormalizedReservation {
externalId: string;
guestExternalId: string;
roomExternalId?: string;
state: ReservationState;
checkIn: string; // ISO date
checkOut: string;
source?: string;
channel?: BookingChannel;
confirmationNumber?: string;
}
export interface NormalizedOrderItem {
name: string;
unitCount: number;
unitAmount: number;
currency: string;
notes?: string;
}
export interface NormalizedWebhookEvent {
entityType: "guest" | "reservation" | "room" | "order";
action: "created" | "updated" | "deleted";
externalId: string;
data?: unknown;
}Ces types sont la lingua franca : tous les adapters normalisent vers ces formes, tout le code Bell consomme ces formes. Aucun adapter ne fuit ses types propriétaires vers le core.
Factory
import { MewsAdapter } from "./adapters/mews";
import { OperaAdapter } from "./adapters/opera";
export function createAdapter(integration: Integration): IntegrationAdapter {
switch (integration.provider) {
case "mews":
return new MewsAdapter(integration.credentials, integration.platformUrl);
case "opera":
return new OperaAdapter(integration.credentials, integration.platformUrl);
case "cloudbeds":
return new CloudbedsAdapter(integration.credentials, integration.platformUrl);
default:
throw new UnsupportedProviderError(integration.provider);
}
}Pas de dynamic import ou magic — chaque provider est une entry explicite. Le skill bell-pms-adapter automatise l'ajout.
Sync service
IntegrationSyncService est le code commun qui utilise n'importe quel adapter :
export class IntegrationSyncService {
constructor(
private adapter: IntegrationAdapter,
private organizationId: string,
private integrationId: string,
) {}
async fullSync() {
if (this.adapter.capabilities.has("rooms") && this.adapter.getRooms) {
const rooms = await this.adapter.getRooms();
for (const r of rooms) await this.syncRoom(r);
}
if (this.adapter.capabilities.has("reservations") && this.adapter.getReservations) {
const reservations = await this.adapter.getReservations({ states: ["confirmed", "checked_in"] });
// Batch guests pour éviter rate limits
const uniqueGuestIds = [...new Set(reservations.map((r) => r.guestExternalId))];
const guestMap = new Map<string, NormalizedGuest>();
if (this.adapter.getGuests) {
for (let i = 0; i < uniqueGuestIds.length; i += 1000) {
const chunk = uniqueGuestIds.slice(i, i + 1000);
const guests = await this.adapter.getGuests(chunk);
for (const g of guests) guestMap.set(g.externalId, g);
}
}
for (const res of reservations) {
await this.syncReservationWithGuest(res, guestMap.get(res.guestExternalId) ?? null);
}
}
}
private async syncGuest(n: NormalizedGuest) { /* upsert par (externalId, source) ou fallback email */ }
private async syncRoom(n: NormalizedRoom) { /* upsert par externalRoomId ou fallback roomNumber */ }
private async syncReservationWithGuest(r: NormalizedReservation, g: NormalizedGuest | null) { /* ... */ }
}Idempotent : on peut le relancer N fois, il upsert sans duper.
Bridge functions
Le chemin inverse : Bell → PMS. Fire-and-forget via BullMQ pour résilience.
import { queues } from "../../jobs/queues";
export async function bridgeCheckIn(organizationId: string, guestId: string) {
await queues.pmsBridge.add("check-in", { type: "check-in", organizationId, guestId }, {
attempts: 5,
backoff: { type: "exponential", delay: 5000 },
});
}
export async function bridgeCheckOut(organizationId: string, guestId: string) {
await queues.pmsBridge.add("check-out", { type: "check-out", organizationId, guestId });
}
export async function bridgePostCharges(
organizationId: string,
guestId: string,
items: NormalizedOrderItem[],
) {
await queues.pmsBridge.add("post-charges", { type: "post-charges", organizationId, guestId, items });
}
export async function bridgeUpdateRoomStatus(
organizationId: string,
roomId: string,
status: RoomStatus,
) {
await queues.pmsBridge.add("update-room-status", {
type: "update-room-status",
organizationId,
roomId,
status,
});
}Le worker apps/worker consomme queues.pmsBridge et dispatche vers les méthodes correspondantes de l'adapter actif de l'org.
Webhooks entrants
Pour chaque provider qui supporte les webhooks :
.post("/webhooks/:provider", async ({ params, body, headers, set }) => {
const { provider } = params;
// 1. Identifier l'intégration (provider + propertyId extrait du body)
const integration = await findIntegrationByWebhookPayload(provider, body);
if (!integration) {
set.status = 404;
return { error: "Integration not found" };
}
// 2. Créer l'adapter
const adapter = createAdapter(integration);
// 3. Verify HMAC
const signature = headers["x-signature"] ?? headers["webhook-signature"] ?? "";
if (!adapter.verifyWebhookSignature?.(JSON.stringify(body), signature, integration.webhookSecret)) {
set.status = 401;
return { error: "Invalid signature" };
}
// 4. Parser les events
const events = adapter.parseWebhookEvent?.(body, headers) ?? [];
// 5. Enqueue sync pour chaque event (async, response 200 immédiat)
for (const ev of events) {
await queues.pmsSync.add("webhook-entity", {
integrationId: integration.id,
event: ev,
});
}
return { ok: true, processed: events.length };
});Ajouter un nouveau PMS
Workflow standardisé (cf. skill bell-pms-adapter) :
- Créer
packages/api/src/services/integrations/adapters/<pms>.tsavec classe<Pms>Adapter implements IntegrationAdapter - Déclarer les
capabilitiesqu'on supporte (pas obligé de tout implémenter) - Mapper l'API du provider vers les types normalisés (
inferChannel()si OTA tracking pertinent) - Ajouter le cas à la factory
createAdapter() - Écrire le contract test qui vérifie l'interface :
// packages/api/src/services/integrations/adapters/opera.test.ts
import { runAdapterContractTests } from "../testing/contract";
import { OperaAdapter } from "./opera";
runAdapterContractTests({
name: "OperaAdapter",
build: () => new OperaAdapter(mockedCredentials),
expectedCapabilities: ["rooms", "guests", "reservations", "checkin", "checkout"],
mocks: operaFixtures,
});Le helper runAdapterContractTests est partagé : il teste que chaque capability déclarée est bien implémentée, que les normalized returns matchent les types, etc.
Aucune capability = hôtel sans PMS
Un hôtel sans PMS fonctionne : pas d'intégration active, pas de sync, les guests sont créés manuellement dans le cardex Bell. Les bridges bridge* sont no-op si aucun adapter actif pour l'org.
Pourquoi ce pattern, pas autre chose
- Plugin system (découverte dynamique d'adapters via filesystem) — rejeté, trop magique, types cassés
- Composition fonctionnelle (pas de classes, juste des objets de méthodes) — acceptable mais les classes sont plus lisibles ici
- Config-driven (adapter entièrement décrit dans un JSON) — impossible, chaque API PMS a sa spécificité runtime
Classes + interface + factory = simple, typé, extensible. Jamais un PMS n'a forcé à casser le pattern jusqu'ici.
Lien
- Mews — l'adapter de référence
- Jobs — BullMQ queues + workers
- Database schema — tables
integration+integration_sync_log - ADR-04 jobs hybrides