My App
Intégrations

Mews adapter

Mapping Mews API → types normalisés Bell, endpoints utilisés, canal tracking via CommanderOrigin

Mews est l'adapter de référence. 8 capabilities implémentées, éprouvé contre la sandbox Mews. Point de départ pour comprendre comment intégrer un autre PMS.

Identité Mews

  • Éditeur : Mews Systems (Amsterdam, NL)
  • API : Mews Connector API
  • Auth : 2 tokens en body de chaque requête (ClientToken + AccessToken)
  • Format : JSON-only, POST partout (Mews n'utilise pas REST classique)
  • Version : on cible 2023-06-06 (stable)

Capabilities supportées

Les 8 capabilities sont toutes implémentées :

CapabilityMews endpointMéthode adapter
roomsPOST /resources/getAll/2023-06-06getRooms()
guestsPOST /customers/getAll/2023-06-06getGuests(ids) + getGuestById(id)
reservationsPOST /reservations/getAll/2023-06-06getReservations(opts) + getReservationById(id)
webhooksIntegration Webhooks (général)parseWebhookEvent() + verifyWebhookSignature()
checkinPOST /reservations/start/2023-06-06startReservation(id)
checkoutPOST /reservations/process/2023-06-06processReservation(id)
chargesPOST /orders/add/2023-06-06addOrder(params)
room-statusPOST /resources/update/2023-06-06updateRoomStatus(params)

Mapping des états

Réservation (Mews State → normalisé)

Mews StateNormalisé
Confirmedconfirmed
Startedchecked_in
Processedchecked_out
Optionalcanceled (ou inquiry selon contexte)
Canceledcanceled

Room state (Mews State → normalisé)

Mews StateNormalisé
Dirtycleaning
Cleanavailable
Inspectedavailable
OutOfServicemaintenance
OutOfOrdermaintenance

Channel inference (feature différenciante)

Le CommanderOrigin est un champ Mews qui indique d'où vient la réservation. C'est ce qui nous permet de détecter Booking/Expedia/Airbnb sans intégrer ces OTAs nous-mêmes.

packages/api/src/services/integrations/adapters/mews.ts
export function inferChannel(mewsOrigin: string | undefined): BookingChannel {
  if (!mewsOrigin) return "direct";
  const o = mewsOrigin.toLowerCase();
  if (o.includes("booking")) return "booking";
  if (o.includes("expedia")) return "expedia";
  if (o.includes("airbnb")) return "airbnb";
  if (o.includes("hrs")) return "hrs";
  if (o.includes("hotelbeds")) return "hotelbeds";
  if (o.includes("agoda")) return "agoda";
  if (o.includes("mews")) return "mews";
  if (o.includes("direct") || o === "") return "direct";
  return "other";
}

Stocké dans guest.externalChannel au moment du sync. Utilisé par le dashboard staff pour les badges colorés et les analytics.

Structure de l'adapter

packages/api/src/services/integrations/adapters/mews.ts (squelette)
import type { IntegrationAdapter, NormalizedGuest, NormalizedRoom, NormalizedReservation } from "../types";

interface MewsCredentials {
  clientToken: string;
  accessToken: string;
  client: string;                    // "Bell by HOAIY Demo 1.0"
}

export class MewsAdapter implements IntegrationAdapter {
  readonly provider = "mews";
  readonly capabilities = new Set([
    "rooms", "guests", "reservations", "webhooks",
    "checkin", "checkout", "charges", "room-status",
  ] as const);

  constructor(
    private credentials: MewsCredentials,
    private platformUrl: string,  // "https://api.mews-demo.com"
  ) {}

  private async post<T>(path: string, body: Record<string, unknown>): Promise<T> {
    const res = await fetch(`${this.platformUrl}${path}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        ...this.credentials,
        ...body,
      }),
    });
    if (!res.ok) {
      const text = await res.text();
      throw new MewsApiError(res.status, text);
    }
    return res.json();
  }

  async testConnection() {
    const data = await this.post<{ Enterprise: { Name: string; Id: string } }>(
      "/api/connector/v1/configuration/get",
      {},
    );
    return {
      success: true,
      propertyName: data.Enterprise.Name,
      propertyId: data.Enterprise.Id,
    };
  }

  async getRooms(): Promise<NormalizedRoom[]> {
    const data = await this.post<{ Resources: MewsResource[] }>(
      "/api/connector/v1/resources/getAll/2023-06-06",
      { Limitation: { Count: 1000 } },
    );
    return data.Resources.map((r) => ({
      externalId: r.Id,
      number: r.Name,
      floor: r.Data?.FloorName,
      type: r.Data?.CategoryName,
      status: mapMewsRoomState(r.State),
    }));
  }

  async getGuests(ids?: string[]): Promise<NormalizedGuest[]> {
    const data = await this.post<{ Customers: MewsCustomer[] }>(
      "/api/connector/v1/customers/getAll/2023-06-06",
      ids ? { CustomerIds: ids } : { Limitation: { Count: 1000 } },
    );
    return data.Customers.map((c) => ({
      externalId: c.Id,
      firstName: c.FirstName ?? "Guest",
      lastName: c.LastName ?? "",
      email: c.Email ?? undefined,
      phone: c.Phone ?? undefined,
      isVip: c.Classifications?.some((cl) => cl.toLowerCase().includes("vip")) ?? false,
    }));
  }

  async getReservations(opts: { states?: ReservationState[] }): Promise<NormalizedReservation[]> {
    const now = new Date();
    const in60days = new Date(now.getTime() + 60 * 24 * 60 * 60 * 1000);

    const data = await this.post<{ Reservations: MewsReservation[] }>(
      "/api/connector/v1/reservations/getAll/2023-06-06",
      {
        ScheduledStartUtc: {
          StartUtc: now.toISOString(),
          EndUtc: in60days.toISOString(),
        },
        States: opts.states?.map(mapNormalizedToMewsState),
        Limitation: { Count: 1000 },
        Extent: { Reservations: true, Customers: true },
      },
    );

    return data.Reservations.map((r) => ({
      externalId: r.Id,
      guestExternalId: r.CustomerId,
      roomExternalId: r.AssignedResourceId ?? undefined,
      state: mapMewsResState(r.State),
      checkIn: r.StartUtc,
      checkOut: r.EndUtc,
      source: "mews",
      channel: inferChannel(r.CommanderOrigin),
      confirmationNumber: r.Number,
    }));
  }

  parseWebhookEvent(body: unknown, headers?: Record<string, string>) {
    const mwEvent = body as MewsWebhookEvent;
    return mewsEventsToNormalized(mwEvent);
  }

  verifyWebhookSignature(rawBody: string, signature: string, secret: string): boolean {
    const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
    return timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
  }

  async startReservation(reservationId: string) {
    await this.post("/api/connector/v1/reservations/start/2023-06-06", {
      ReservationIds: [reservationId],
    });
  }

  async processReservation(reservationId: string) {
    await this.post("/api/connector/v1/reservations/process/2023-06-06", {
      ReservationIds: [reservationId],
    });
  }

  async addOrder(params: { guestId: string; items: NormalizedOrderItem[]; reservationId?: string }) {
    const data = await this.post<{ OrderId: string }>(
      "/api/connector/v1/orders/add/2023-06-06",
      {
        AccountId: params.guestId,
        BillId: params.reservationId,
        Items: params.items.map((i) => ({
          Name: i.name,
          UnitCount: i.unitCount,
          UnitAmount: { Currency: i.currency, NetValue: i.unitAmount },
          Notes: i.notes,
        })),
      },
    );
    return { orderId: data.OrderId };
  }

  async updateRoomStatus(params: { roomId: string; status: RoomStatus }) {
    await this.post("/api/connector/v1/resources/update/2023-06-06", {
      ResourceUpdates: [{
        ResourceId: params.roomId,
        State: mapNormalizedToMewsRoomState(params.status),
      }],
    });
  }
}

Webhooks Mews supportés

Events qu'on consomme :

Mews EventAction Bell
ReservationCreatedUpsert reservation + guest
ReservationUpdatedUpdate reservation
ReservationStateChangedUpdate state (ex: Confirmed → Started)
CustomerUpdatedUpdate guest
ResourceUpdatedUpdate room status
ServiceOrderUpdatedSync order (post-charges confirmation)

Limitations connues

  • getReservations prend un window de 3 mois max côté Mews. On limite à 60 jours pour pagination.
  • Le champ State peut être Optional dans des cas bizarres (brouillon) — on mappe vers canceled par défaut.
  • CustomersGetAll limite à 1000 customers par appel → on batch dans IntegrationSyncService.
  • Certains endpoints (orders/add) nécessitent AccountId (le guest), pas ReservationId. Erreur fréquente au début.

Tests

Contract test + happy path. Mocks via msw pour ne pas hit la vraie sandbox en CI :

packages/api/src/services/integrations/adapters/mews.test.ts
import { runAdapterContractTests } from "../testing/contract";
import { MewsAdapter } from "./mews";
import { mewsFixtures } from "./mews.fixtures";

runAdapterContractTests({
  name: "MewsAdapter",
  build: () => new MewsAdapter(mewsFixtures.credentials, "https://api.mews-demo.com"),
  expectedCapabilities: ["rooms", "guests", "reservations", "webhooks", "checkin", "checkout", "charges", "room-status"],
  mocks: {
    "/api/connector/v1/configuration/get": mewsFixtures.configurationGet,
    "/api/connector/v1/resources/getAll/2023-06-06": mewsFixtures.resourcesGetAll,
    // ...
  },
});

test("inferChannel maps Booking.com CommanderOrigin to 'booking'", () => {
  expect(inferChannel("Booking.com")).toBe("booking");
  expect(inferChannel("booking.com")).toBe("booking");
  expect(inferChannel("Expedia Partner Central")).toBe("expedia");
  expect(inferChannel(undefined)).toBe("direct");
  expect(inferChannel("Random OTA")).toBe("other");
});

Tests d'intégration (bout en bout) : un script scripts/test-mews-sandbox.ts fait un vrai roundtrip contre la sandbox Mews avec les credentials dev, utilisé uniquement en local pour valider après une refacto.

Credentials sandbox (dev)

Fournis par Mews au onboarding. Stockés chiffrés dans integration.credentials (JSONB + chiffrement AES avec ENCRYPTION_KEY). Jamais dans un .env.

Format :

{
  "clientToken": "...",
  "accessToken": "...",
  "client": "Bell by HOAIY Demo 1.0"
}

Pour setup en dev local, admin HOAIY utilise /admin/system/integrations → "Add Mews" → colle les credentials → "Test connection" → "Save".

Roadmap

  • Opera : c'est le prochain adapter (gros marché hôtellerie haut-de-gamme). Oracle PMS, API OPERA Cloud.
  • Cloudbeds : PMS midmarket, API REST classique.
  • Protel : gros en Europe, API SOAP (attention).
  • Apaleo : challenger Mews, API propre.

Chaque ajout = 1 semaine environ via le pattern + skill bell-pms-adapter.

Lien

On this page