@@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire
| OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) |
| XML Processing | fast-xml-parser | Booking.com OTA XML protocol |
| Package Manager | pnpm workspaces | Monorepo management |
-| Testing | Vitest (2599 passing tests across 302 files with passing tests) | Unit and integration tests |
+| Testing | Vitest (2617 passing tests across 304 files with passing tests) | Unit and integration tests |
| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Containers | Docker + docker-compose | Local dev and production deployment |
| CI/CD | GitHub Actions | Automated testing, builds, and releases |
@@ -648,7 +648,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests
```bash
-# Passing-test count: 2599 test cases across 302 files (skipped excluded)
+# Passing-test count: 2617 test cases across 304 files (skipped excluded)
# API tests only
pnpm --filter @telivityhaip/api test
@@ -1197,7 +1197,7 @@ HAIP is built in public and contributions are welcome.
pnpm install # Install dependencies
pnpm build # Build all workspace packages
pnpm dev # Start API in dev mode (hot reload)
-pnpm test # Run all tests (2599 passing, 302 files with passes; skipped excluded)
+pnpm test # Run all tests (2617 passing, 304 files with passes; skipped excluded)
pnpm lint # ESLint
```
diff --git a/apps/api/src/modules/connect/connect-booking.service.ts b/apps/api/src/modules/connect/connect-booking.service.ts
index 5e67470a..1887b66b 100644
--- a/apps/api/src/modules/connect/connect-booking.service.ts
+++ b/apps/api/src/modules/connect/connect-booking.service.ts
@@ -1,4 +1,11 @@
-import { Injectable, Inject, NotFoundException, BadRequestException } from '@nestjs/common';
+import {
+ Injectable,
+ Inject,
+ NotFoundException,
+ BadRequestException,
+ ConflictException,
+ Optional,
+} from '@nestjs/common';
import { eq, and, ne } from 'drizzle-orm';
import Decimal from 'decimal.js';
import { bookings, reservations, guests, ratePlans, roomTypes, folios, rooms } from '@telivityhaip/database';
@@ -11,6 +18,8 @@ import { PolicyService } from '../policy/policy.service';
import type { AgentBookDto } from './dto/agent-book.dto';
import type { AgentModifyDto } from './dto/agent-modify.dto';
import { generateConfirmationToken } from '../../common/crypto/confirmation-number';
+import { ConnectPaymentService } from './connect-payment.service';
+import { hashBookRequest } from './connect-payment.helpers';
@Injectable()
export class ConnectBookingService {
@@ -21,13 +30,64 @@ export class ConnectBookingService {
private readonly webhookService: WebhookService,
private readonly ratePlanService: RatePlanService,
private readonly policyService: PolicyService,
+ @Optional() private readonly connectPayment?: ConnectPaymentService,
) {}
/**
* Book a room — Agent 4.5 (Hotel Booking Agent).
- * Agent bookings auto-confirm (skip pending status).
+ * pay_at_property / legacy prepaid: auto-confirm.
+ * hosted_checkout: pending until PSP payment / guarantee completes.
*/
async book(dto: AgentBookDto) {
+ const hosted = dto.paymentMethod === 'hosted_checkout';
+ if (hosted) {
+ if (!dto.guestEmail) {
+ throw new BadRequestException('guestEmail is required for hosted_checkout');
+ }
+ if (!dto.idempotencyKey) {
+ throw new BadRequestException('idempotencyKey is required for hosted_checkout');
+ }
+ if (!this.connectPayment) {
+ throw new BadRequestException('Hosted checkout is not available');
+ }
+ }
+
+ // Idempotent replay for hosted checkout
+ if (hosted && this.connectPayment && dto.idempotencyKey) {
+ const requestHash = this.buildRequestHash(dto);
+ const existing = await this.connectPayment.findByIdempotency(dto.propertyId, dto.idempotencyKey);
+ if (existing) {
+ if (existing.requestHash !== requestHash) {
+ throw new ConflictException('Idempotency-Key reused with a different booking payload');
+ }
+ const pay = this.connectPayment.toPublicSession(existing);
+ return {
+ success: true,
+ confirmationNumber: existing.confirmationNumber,
+ externalReference: dto.externalReference,
+ reservationId: existing.reservationId,
+ status: existing.status === 'paid' ? 'confirmed' : 'pending_payment',
+ confirmationCodes: {
+ pms: existing.confirmationNumber,
+ external: dto.externalReference,
+ },
+ totalAmount: undefined,
+ currencyCode: existing.currencyCode,
+ nightlyBreakdown: [],
+ cancellationPolicy: (
+ await this.policyService.getPolicySummary(dto.propertyId, dto.ratePlanId)
+ ).description,
+ paymentStatus: pay.paymentStatus,
+ paymentMode: pay.paymentMode,
+ paymentUrl: pay.paymentUrl,
+ depositAmount: pay.depositDue,
+ bookedAt: existing.createdAt instanceof Date
+ ? existing.createdAt.toISOString()
+ : new Date().toISOString(),
+ };
+ }
+ }
+
// 1. Verify rate is still available
const availability = await this.availabilityService.searchAvailability(
dto.propertyId,
@@ -67,21 +127,29 @@ export class ConnectBookingService {
// 3. Find or create guest
const guest = await this.findOrCreateGuest(dto);
- // 4. Calculate nights and total
+ // 4. Calculate nights and tax-inclusive total (match Connect search)
const arrival = new Date(dto.checkIn);
const departure = new Date(dto.checkOut);
const nights = Math.ceil((departure.getTime() - arrival.getTime()) / (1000 * 60 * 60 * 24));
- // Monetary math via Decimal (baseAmount is a numeric string from PG)
+ const settings = await this.getPropertySettings(dto.propertyId);
+ const taxRate = (settings['taxRate'] as number) ?? 0;
const baseAmountDec = new Decimal(ratePlan.baseAmount);
- const totalAmountDec = baseAmountDec.times(nights);
+ const baseTotalDec = baseAmountDec.times(nights);
+ const taxTotalDec = baseTotalDec.times(taxRate).div(100);
+ const totalAmountDec = baseTotalDec.plus(taxTotalDec);
const baseAmount = baseAmountDec.toNumber();
- const totalAmount = totalAmountDec.toNumber();
+ const totalAmount = Number(totalAmountDec.toFixed(2));
+ const taxAmount = Number(taxTotalDec.toFixed(2));
// 5. Generate confirmation number. High-entropy (128 bits from randomBytes,
// Crockford base32, no ambiguous chars) so it can't be enumerated/guessed —
// the confirmation number is itself a bearer credential for the booking.
const confirmationNumber = `HAIP-${generateConfirmationToken()}`;
+ const channelCode = dto.agentId ?? (hosted ? 'LIGARE' : 'otaip');
+ const externalRef = dto.externalReference
+ ?? (dto.clientPlatform ? `${dto.clientPlatform}:${confirmationNumber}` : undefined);
+
// 6. Create booking
const [booking] = await this.db
.insert(bookings)
@@ -89,13 +157,13 @@ export class ConnectBookingService {
propertyId: dto.propertyId,
guestId: guest.id,
confirmationNumber,
- externalConfirmation: dto.externalReference,
+ externalConfirmation: externalRef,
source: 'agent',
- channelCode: dto.agentId ?? 'otaip',
+ channelCode,
})
.returning();
- // 7. Create reservation — auto-confirm for agent bookings
+ // 7. Create reservation — pending for hosted checkout, confirmed otherwise
const [reservation] = await this.db
.insert(reservations)
.values({
@@ -112,21 +180,40 @@ export class ConnectBookingService {
adults: dto.adults,
children: dto.children ?? 0,
specialRequests: dto.specialRequests,
- status: 'confirmed', // Agent bookings skip pending
+ status: hosted ? 'pending' : 'confirmed',
})
.returning();
// 8. Build nightly breakdown
- const settings = await this.getPropertySettings(dto.propertyId);
- const taxRate = (settings['taxRate'] as number) ?? 0;
const nightlyBreakdown = this.buildNightlyBreakdown(baseAmount, nights, arrival, taxRate);
- // 9. Determine payment status
- let paymentStatus: 'none' | 'authorized' | 'charged' = 'none';
+ // 9. Hosted payment session or legacy payment flags
+ let paymentStatus: string = 'none';
let depositAmount: number | undefined;
+ let paymentUrl: string | null | undefined;
+ let paymentMode: string | undefined;
- if (dto.paymentMethod === 'prepaid' || dto.paymentMethod === 'virtual_card') {
- // In a real system, process payment via paymentService
+ if (hosted && this.connectPayment && dto.idempotencyKey && dto.guestEmail) {
+ const pay = await this.connectPayment.createHostedSession({
+ propertyId: dto.propertyId,
+ confirmationNumber,
+ reservationId: reservation.id,
+ bookingId: booking.id,
+ guestId: guest.id,
+ currencyCode: ratePlan.currencyCode,
+ grandTotal: totalAmountDec,
+ nights,
+ idempotencyKey: dto.idempotencyKey,
+ requestHash: this.buildRequestHash(dto),
+ clientPlatform: dto.clientPlatform,
+ guestEmail: dto.guestEmail,
+ });
+ paymentStatus = pay.paymentStatus;
+ depositAmount = pay.depositDue;
+ paymentUrl = pay.paymentUrl;
+ paymentMode = pay.paymentMode;
+ } else if (dto.paymentMethod === 'prepaid' || dto.paymentMethod === 'virtual_card') {
+ // Legacy stub — prefer hosted_checkout for real money movement
paymentStatus = 'authorized';
depositAmount = totalAmount;
}
@@ -139,8 +226,10 @@ export class ConnectBookingService {
{
confirmationNumber,
agentId: dto.agentId,
- externalReference: dto.externalReference,
+ externalReference: externalRef,
totalAmount,
+ taxAmount,
+ paymentMode,
},
dto.propertyId,
);
@@ -148,25 +237,44 @@ export class ConnectBookingService {
return {
success: true,
confirmationNumber,
- externalReference: dto.externalReference,
+ externalReference: externalRef,
reservationId: reservation.id,
- status: 'confirmed',
+ status: hosted ? 'pending_payment' : 'confirmed',
confirmationCodes: {
pms: confirmationNumber,
- external: dto.externalReference,
+ external: externalRef,
},
- totalAmount: Math.round(totalAmount * 100) / 100,
+ totalAmount,
+ taxAmount,
currencyCode: ratePlan.currencyCode,
nightlyBreakdown,
cancellationPolicy: (
await this.policyService.getPolicySummary(dto.propertyId, dto.ratePlanId)
).description,
paymentStatus,
+ paymentMode,
+ paymentUrl,
depositAmount,
bookedAt: new Date().toISOString(),
};
}
+ private buildRequestHash(dto: AgentBookDto): string {
+ return hashBookRequest({
+ propertyId: dto.propertyId,
+ roomTypeId: dto.roomTypeId,
+ ratePlanId: dto.ratePlanId,
+ checkIn: dto.checkIn,
+ checkOut: dto.checkOut,
+ guestFirstName: dto.guestFirstName,
+ guestLastName: dto.guestLastName,
+ guestEmail: dto.guestEmail ?? null,
+ adults: dto.adults,
+ children: dto.children ?? 0,
+ paymentMethod: dto.paymentMethod ?? null,
+ });
+ }
+
/**
* Verify booking — Agent 4.7 (Confirmation Verification).
*/
diff --git a/apps/api/src/modules/connect/connect-payment.helpers.ts b/apps/api/src/modules/connect/connect-payment.helpers.ts
new file mode 100644
index 00000000..2e38fb9c
--- /dev/null
+++ b/apps/api/src/modules/connect/connect-payment.helpers.ts
@@ -0,0 +1,31 @@
+import { createHash } from 'node:crypto';
+import Decimal from 'decimal.js';
+import type { ConnectPaymentMode, DepositPolicy } from '@telivityhaip/database';
+
+export function hashBookRequest(payload: Record): string {
+ const canonical = JSON.stringify(payload, Object.keys(payload).sort());
+ return createHash('sha256').update(canonical).digest('hex');
+}
+
+export function resolvePaymentMode(policy: DepositPolicy | null | undefined): ConnectPaymentMode {
+ if (!policy || policy.type === 'none') return 'guarantee';
+ return policy.refundable ? 'authorize' : 'charge';
+}
+
+export function computeDepositDue(
+ policy: DepositPolicy | null | undefined,
+ grandTotal: Decimal,
+ nights: number,
+): Decimal {
+ if (!policy || policy.type === 'none') return new Decimal(0);
+ switch (policy.type) {
+ case 'full':
+ return grandTotal;
+ case 'percentage':
+ return grandTotal.times(new Decimal(policy.percentage ?? 0).div(100)).toDecimalPlaces(2);
+ case 'first_night':
+ default:
+ if (nights <= 0) return grandTotal;
+ return grandTotal.div(nights).toDecimalPlaces(2);
+ }
+}
diff --git a/apps/api/src/modules/connect/connect-payment.service.spec.ts b/apps/api/src/modules/connect/connect-payment.service.spec.ts
new file mode 100644
index 00000000..ff5ab8cf
--- /dev/null
+++ b/apps/api/src/modules/connect/connect-payment.service.spec.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect } from 'vitest';
+import {
+ computeDepositDue,
+ hashBookRequest,
+ resolvePaymentMode,
+} from './connect-payment.helpers';
+import Decimal from 'decimal.js';
+
+describe('connect payment helpers', () => {
+ it('hashes book requests stably', () => {
+ const a = hashBookRequest({ propertyId: 'p1', adults: 2, checkIn: '2026-06-01' });
+ const b = hashBookRequest({ adults: 2, checkIn: '2026-06-01', propertyId: 'p1' });
+ const c = hashBookRequest({ propertyId: 'p1', adults: 3, checkIn: '2026-06-01' });
+ expect(a).toBe(b);
+ expect(a).not.toBe(c);
+ });
+
+ it('maps depositPolicy to paymentMode charge/authorize/guarantee', () => {
+ expect(resolvePaymentMode({ type: 'none', refundable: true })).toBe('guarantee');
+ expect(resolvePaymentMode({ type: 'first_night', refundable: true })).toBe('authorize');
+ expect(resolvePaymentMode({ type: 'full', refundable: false })).toBe('charge');
+ expect(resolvePaymentMode({ type: 'percentage', percentage: 30, refundable: true })).toBe('authorize');
+ });
+
+ it('computes deposit due from policy', () => {
+ const total = new Decimal('400.00');
+ expect(computeDepositDue({ type: 'none', refundable: true }, total, 2).toNumber()).toBe(0);
+ expect(computeDepositDue({ type: 'full', refundable: false }, total, 2).toNumber()).toBe(400);
+ expect(computeDepositDue({ type: 'first_night', refundable: true }, total, 2).toNumber()).toBe(200);
+ expect(
+ computeDepositDue({ type: 'percentage', percentage: 25, refundable: false }, total, 2).toNumber(),
+ ).toBe(100);
+ });
+});
diff --git a/apps/api/src/modules/connect/connect-payment.service.ts b/apps/api/src/modules/connect/connect-payment.service.ts
new file mode 100644
index 00000000..d7cd85dc
--- /dev/null
+++ b/apps/api/src/modules/connect/connect-payment.service.ts
@@ -0,0 +1,561 @@
+import {
+ Injectable,
+ Inject,
+ Logger,
+ NotFoundException,
+ BadRequestException,
+ ConflictException,
+ Optional,
+} from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { and, eq, lt } from 'drizzle-orm';
+import Decimal from 'decimal.js';
+import Stripe from 'stripe';
+import {
+ connectPaymentSessions,
+ bookings,
+ reservations,
+ bookingEngineConfig,
+ depositLedgerEntries,
+ type ConnectPaymentMode,
+ type DepositPolicy,
+} from '@telivityhaip/database';
+import { DRIZZLE } from '../../database/database.module';
+import { FolioService } from '../folio/folio.service';
+import { WebhookService } from '../webhook/webhook.service';
+import {
+ resolvePaymentGatewayProvider,
+} from '../payment/payment-gateway.factory';
+import { PAYMENT_GATEWAY } from '../payment/interfaces/payment-gateway.interface';
+import type { PaymentGateway } from '../payment/interfaces/payment-gateway.interface';
+import {
+ computeDepositDue,
+ resolvePaymentMode,
+} from './connect-payment.helpers';
+
+export { computeDepositDue, hashBookRequest, resolvePaymentMode } from './connect-payment.helpers';
+
+const SESSION_TTL_MS = 45 * 60 * 1000;
+
+@Injectable()
+export class ConnectPaymentService {
+ private readonly logger = new Logger(ConnectPaymentService.name);
+ private readonly stripe: Stripe | null;
+
+ constructor(
+ @Inject(DRIZZLE) private readonly db: any,
+ private readonly configService: ConfigService,
+ private readonly folioService: FolioService,
+ private readonly webhookService: WebhookService,
+ @Optional() @Inject(PAYMENT_GATEWAY) private readonly gateway?: PaymentGateway,
+ ) {
+ const mode = this.configService.get('STRIPE_MODE', 'mock');
+ const key = this.configService.get('STRIPE_SECRET_KEY');
+ this.stripe = mode !== 'mock' && key
+ ? new Stripe(key, { apiVersion: '2025-03-31.basil', typescript: true })
+ : null;
+ }
+
+ async getDepositPolicy(propertyId: string): Promise {
+ const [row] = await this.db
+ .select({ depositPolicy: bookingEngineConfig.depositPolicy })
+ .from(bookingEngineConfig)
+ .where(eq(bookingEngineConfig.propertyId, propertyId));
+ return (row?.depositPolicy as DepositPolicy) ?? { type: 'first_night', refundable: true };
+ }
+
+ async findByIdempotency(propertyId: string, idempotencyKey: string) {
+ const [session] = await this.db
+ .select()
+ .from(connectPaymentSessions)
+ .where(
+ and(
+ eq(connectPaymentSessions.propertyId, propertyId),
+ eq(connectPaymentSessions.idempotencyKey, idempotencyKey),
+ ),
+ );
+ return session ?? null;
+ }
+
+ /**
+ * Create a hosted PSP session for a pending Connect reservation.
+ * Idempotent on (propertyId, idempotencyKey).
+ */
+ async createHostedSession(input: {
+ propertyId: string;
+ confirmationNumber: string;
+ reservationId: string;
+ bookingId: string;
+ guestId: string;
+ currencyCode: string;
+ grandTotal: Decimal;
+ nights: number;
+ idempotencyKey: string;
+ requestHash: string;
+ clientPlatform?: string;
+ guestEmail: string;
+ }) {
+ const existing = await this.findByIdempotency(input.propertyId, input.idempotencyKey);
+ if (existing) {
+ if (existing.requestHash !== input.requestHash) {
+ throw new ConflictException('Idempotency-Key reused with a different booking payload');
+ }
+ if (existing.status === 'pending' && existing.expiresAt && new Date(existing.expiresAt) < new Date()) {
+ await this.expireSession(existing.confirmationNumber);
+ } else {
+ return this.toPublicSession(existing);
+ }
+ const refreshed = await this.findByIdempotency(input.propertyId, input.idempotencyKey);
+ if (refreshed && refreshed.status === 'pending') {
+ return this.toPublicSession(refreshed);
+ }
+ }
+
+ const policy = await this.getDepositPolicy(input.propertyId);
+ const paymentMode = resolvePaymentMode(policy);
+ const depositDue = computeDepositDue(policy, input.grandTotal, input.nights);
+ const provider = resolvePaymentGatewayProvider(this.configService);
+ const hostedProviders = new Set(['mock', 'stripe', 'redsys', 'worldline']);
+ if (!hostedProviders.has(provider)) {
+ throw new BadRequestException(
+ `AI booking requires a hosted PSP (stripe, redsys, or worldline). Current gateway: ${provider}`,
+ );
+ }
+
+ const folio = await this.folioService.createAutoFolio({
+ id: input.reservationId,
+ propertyId: input.propertyId,
+ bookingId: input.bookingId,
+ guestId: input.guestId,
+ currencyCode: input.currencyCode,
+ });
+
+ const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
+ const publicBase = (this.configService.get('PUBLIC_API_BASE_URL') ?? 'http://localhost:3000').replace(/\/$/, '');
+
+ let pspSessionId: string;
+ let paymentUrl: string;
+
+ if (provider === 'mock' || !this.stripe) {
+ pspSessionId = `mock_cs_${input.confirmationNumber}`;
+ paymentUrl = `${publicBase}/api/v1/connect/bookings/${encodeURIComponent(input.confirmationNumber)}/mock-pay`;
+ } else if (provider === 'stripe') {
+ const session = await this.createStripeCheckout({
+ mode: paymentMode,
+ amount: depositDue.greaterThan(0) ? depositDue : new Decimal('1.00'),
+ currency: input.currencyCode,
+ confirmationNumber: input.confirmationNumber,
+ guestEmail: input.guestEmail,
+ successUrl: `${publicBase}/api/v1/connect/bookings/${encodeURIComponent(input.confirmationNumber)}/payment-return?status=success`,
+ cancelUrl: `${publicBase}/api/v1/connect/bookings/${encodeURIComponent(input.confirmationNumber)}/payment-return?status=cancel`,
+ // guarantee with zero deposit still needs SetupIntent
+ depositDue,
+ });
+ pspSessionId = session.id;
+ paymentUrl = session.url!;
+ } else {
+ // Redsys / Worldline: expose a Connect return URL that the gateway can open;
+ // full redirect form fields are available via payment-status.nextAction when wired.
+ pspSessionId = `${provider}_${input.confirmationNumber}`;
+ paymentUrl = `${publicBase}/api/v1/connect/bookings/${encodeURIComponent(input.confirmationNumber)}/hosted-pay`;
+ }
+
+ try {
+ const [row] = await this.db
+ .insert(connectPaymentSessions)
+ .values({
+ propertyId: input.propertyId,
+ confirmationNumber: input.confirmationNumber,
+ reservationId: input.reservationId,
+ bookingId: input.bookingId,
+ folioId: folio.id,
+ idempotencyKey: input.idempotencyKey,
+ requestHash: input.requestHash,
+ paymentMode,
+ status: 'pending',
+ psp: provider === 'mock' ? 'mock' : provider,
+ pspSessionId,
+ paymentUrl,
+ depositAmount: depositDue.toFixed(2),
+ currencyCode: input.currencyCode,
+ clientPlatform: input.clientPlatform ?? null,
+ expiresAt,
+ })
+ .returning();
+ return this.toPublicSession(row);
+ } catch (err: any) {
+ // Unique race on idempotency — re-read
+ if (String(err?.message ?? err).includes('unique') || err?.code === '23505') {
+ const again = await this.findByIdempotency(input.propertyId, input.idempotencyKey);
+ if (again) {
+ if (again.requestHash !== input.requestHash) {
+ throw new ConflictException('Idempotency-Key reused with a different booking payload');
+ }
+ return this.toPublicSession(again);
+ }
+ }
+ throw err;
+ }
+ }
+
+ private async createStripeCheckout(input: {
+ mode: ConnectPaymentMode;
+ amount: Decimal;
+ currency: string;
+ confirmationNumber: string;
+ guestEmail: string;
+ successUrl: string;
+ cancelUrl: string;
+ depositDue: Decimal;
+ }): Promise {
+ if (!this.stripe) throw new BadRequestException('Stripe is not configured');
+
+ if (input.mode === 'guarantee' || input.depositDue.lessThanOrEqualTo(0)) {
+ return this.stripe.checkout.sessions.create({
+ mode: 'setup',
+ customer_email: input.guestEmail,
+ success_url: input.successUrl,
+ cancel_url: input.cancelUrl,
+ metadata: {
+ confirmationNumber: input.confirmationNumber,
+ paymentMode: 'guarantee',
+ },
+ });
+ }
+
+ const amountCents = Math.round(input.amount.mul(100).toNumber());
+ return this.stripe.checkout.sessions.create({
+ mode: 'payment',
+ customer_email: input.guestEmail,
+ success_url: input.successUrl,
+ cancel_url: input.cancelUrl,
+ line_items: [
+ {
+ quantity: 1,
+ price_data: {
+ currency: input.currency.toLowerCase(),
+ unit_amount: amountCents,
+ product_data: {
+ name: `Hotel deposit ${input.confirmationNumber}`,
+ },
+ },
+ },
+ ],
+ payment_intent_data: input.mode === 'authorize'
+ ? { capture_method: 'manual', metadata: { confirmationNumber: input.confirmationNumber } }
+ : { metadata: { confirmationNumber: input.confirmationNumber } },
+ metadata: {
+ confirmationNumber: input.confirmationNumber,
+ paymentMode: input.mode,
+ },
+ });
+ }
+
+ async getPaymentStatus(confirmationNumber: string) {
+ const [session] = await this.db
+ .select()
+ .from(connectPaymentSessions)
+ .where(eq(connectPaymentSessions.confirmationNumber, confirmationNumber));
+
+ if (!session) {
+ // pay_at_property bookings have no session — treat as paid/none
+ const [booking] = await this.db
+ .select()
+ .from(bookings)
+ .where(eq(bookings.confirmationNumber, confirmationNumber));
+ if (!booking) throw new NotFoundException(`Booking ${confirmationNumber} not found`);
+ const [reservation] = await this.db
+ .select()
+ .from(reservations)
+ .where(and(eq(reservations.bookingId, booking.id), eq(reservations.propertyId, booking.propertyId)));
+ return {
+ confirmationNumber,
+ paymentStatus: reservation?.status === 'confirmed' ? 'paid' : 'none',
+ paymentMode: null,
+ status: reservation?.status ?? 'unknown',
+ paymentUrl: null,
+ };
+ }
+
+ if (session.status === 'pending') {
+ if (new Date(session.expiresAt) < new Date()) {
+ await this.expireSession(confirmationNumber);
+ const [expired] = await this.db
+ .select()
+ .from(connectPaymentSessions)
+ .where(eq(connectPaymentSessions.confirmationNumber, confirmationNumber));
+ return this.toPublicSession(expired ?? session);
+ }
+ await this.reconcileWithPsp(session);
+ const [fresh] = await this.db
+ .select()
+ .from(connectPaymentSessions)
+ .where(eq(connectPaymentSessions.confirmationNumber, confirmationNumber));
+ return this.toPublicSession(fresh ?? session);
+ }
+
+ return this.toPublicSession(session);
+ }
+
+ /** Idempotent confirm used by LLM confirmPayment tool. */
+ async confirmPayment(confirmationNumber: string) {
+ const status = await this.getPaymentStatus(confirmationNumber);
+ if (status.paymentStatus === 'paid' || status.status === 'paid') {
+ return { ...status, confirmed: true };
+ }
+ if (status.paymentStatus === 'expired' || status.status === 'expired' || status.status === 'voided') {
+ return { ...status, confirmed: false };
+ }
+ if (status.paymentStatus === 'failed' || status.status === 'failed') {
+ return { ...status, confirmed: false };
+ }
+ // Still pending after reconcile
+ return { ...status, confirmed: false };
+ }
+
+ /** Mock-pay helper for staging/dev (no card). */
+ async mockPay(confirmationNumber: string) {
+ const provider = resolvePaymentGatewayProvider(this.configService);
+ if (provider !== 'mock' && this.configService.get('STRIPE_MODE', 'mock') !== 'mock') {
+ throw new BadRequestException('mock-pay is only available when PAYMENT_GATEWAY/STRIPE_MODE is mock');
+ }
+ const [session] = await this.db
+ .select()
+ .from(connectPaymentSessions)
+ .where(eq(connectPaymentSessions.confirmationNumber, confirmationNumber));
+ if (!session) throw new NotFoundException('Payment session not found');
+ if (session.status === 'paid') return this.toPublicSession(session);
+ return this.markPaid(session, `mock_evt_${confirmationNumber}`);
+ }
+
+ /** Stripe webhook bridge (CONNECT_CHECKOUT_STRIPE_HANDLER). */
+ async handleCheckoutSessionCompleted(
+ eventId: string,
+ sessionObj: {
+ id: string;
+ metadata?: Record | null;
+ mode?: string;
+ payment_status?: string | null;
+ },
+ ) {
+ return this.handleStripeCheckoutCompleted(eventId, sessionObj);
+ }
+
+ async handleStripeCheckoutCompleted(eventId: string, sessionObj: {
+ id: string;
+ metadata?: Record | null;
+ mode?: string;
+ payment_status?: string | null;
+ }) {
+ const confirmationNumber = sessionObj.metadata?.['confirmationNumber'];
+ if (!confirmationNumber) return;
+ const [session] = await this.db
+ .select()
+ .from(connectPaymentSessions)
+ .where(eq(connectPaymentSessions.confirmationNumber, confirmationNumber));
+ if (!session) return;
+ if ((session.pspEventIds as string[] | null)?.includes(eventId)) {
+ return this.toPublicSession(session);
+ }
+ if (session.status === 'paid') return this.toPublicSession(session);
+ const ok = sessionObj.payment_status === 'paid' || sessionObj.mode === 'setup';
+ if (!ok) return this.toPublicSession(session);
+ return this.markPaid(session, eventId);
+ }
+
+ private async reconcileWithPsp(session: typeof connectPaymentSessions.$inferSelect) {
+ if (session.psp === 'mock') {
+ // Mock stays pending until mock-pay or TTL
+ return;
+ }
+ if (session.psp === 'stripe' && this.stripe && session.pspSessionId) {
+ try {
+ const cs = await this.stripe.checkout.sessions.retrieve(session.pspSessionId);
+ if (cs.status === 'complete' && (cs.payment_status === 'paid' || cs.mode === 'setup')) {
+ await this.markPaid(session, `reconcile_${cs.id}`);
+ } else if (cs.status === 'expired') {
+ await this.expireSession(session.confirmationNumber);
+ }
+ } catch (err: any) {
+ this.logger.warn(`Stripe reconcile failed for ${session.confirmationNumber}: ${err.message}`);
+ }
+ }
+ }
+
+ private async markPaid(
+ session: typeof connectPaymentSessions.$inferSelect,
+ eventId: string,
+ ) {
+ return this.db.transaction(async (tx: any) => {
+ const eventIds = [...(session.pspEventIds ?? [])];
+ if (eventIds.includes(eventId)) {
+ const [current] = await tx.select().from(connectPaymentSessions)
+ .where(eq(connectPaymentSessions.id, session.id));
+ return this.toPublicSession(current);
+ }
+ eventIds.push(eventId);
+
+ const [updated] = await tx
+ .update(connectPaymentSessions)
+ .set({
+ status: 'paid',
+ pspEventIds: eventIds,
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(connectPaymentSessions.id, session.id),
+ eq(connectPaymentSessions.status, 'pending'),
+ ),
+ )
+ .returning();
+
+ if (!updated) {
+ const [current] = await tx.select().from(connectPaymentSessions)
+ .where(eq(connectPaymentSessions.id, session.id));
+ return this.toPublicSession(current);
+ }
+
+ const [reservation] = await tx
+ .update(reservations)
+ .set({ status: 'confirmed', updatedAt: new Date() })
+ .where(
+ and(
+ eq(reservations.id, session.reservationId),
+ eq(reservations.propertyId, session.propertyId),
+ eq(reservations.status, 'pending'),
+ ),
+ )
+ .returning();
+
+ if (
+ session.folioId
+ && new Decimal(session.depositAmount).greaterThan(0)
+ && session.paymentMode !== 'guarantee'
+ ) {
+ const existingDeposits = await tx
+ .select({ id: depositLedgerEntries.id })
+ .from(depositLedgerEntries)
+ .where(
+ and(
+ eq(depositLedgerEntries.reservationId, session.reservationId),
+ eq(depositLedgerEntries.propertyId, session.propertyId),
+ ),
+ );
+ if (existingDeposits.length === 0) {
+ await tx.insert(depositLedgerEntries).values({
+ propertyId: session.propertyId,
+ reservationId: session.reservationId,
+ paymentId: session.paymentId,
+ amount: session.depositAmount,
+ currencyCode: session.currencyCode,
+ status: 'held',
+ isRefundable: session.paymentMode === 'authorize',
+ });
+ }
+ }
+
+ return this.toPublicSession({
+ ...updated,
+ status: 'paid',
+ reservationStatus: reservation?.status ?? 'confirmed',
+ });
+ }).then(async (result: ReturnType) => {
+ await this.webhookService.emit(
+ 'reservation.confirmed',
+ 'reservation',
+ session.reservationId,
+ { confirmationNumber: session.confirmationNumber, paymentMode: session.paymentMode },
+ session.propertyId,
+ );
+ return result;
+ });
+ }
+
+ async expireSession(confirmationNumber: string) {
+ const [session] = await this.db
+ .select()
+ .from(connectPaymentSessions)
+ .where(eq(connectPaymentSessions.confirmationNumber, confirmationNumber));
+ if (!session || session.status !== 'pending') return;
+
+ // Void Stripe auth if applicable
+ if (session.psp === 'stripe' && this.stripe && session.pspSessionId && session.paymentMode === 'authorize') {
+ try {
+ const cs = await this.stripe.checkout.sessions.retrieve(session.pspSessionId, {
+ expand: ['payment_intent'],
+ });
+ const pi = cs.payment_intent;
+ if (typeof pi === 'object' && pi && 'id' in pi && pi.status === 'requires_capture') {
+ await this.stripe.paymentIntents.cancel(pi.id);
+ }
+ } catch (err: any) {
+ this.logger.warn(`Void on expire failed: ${err.message}`);
+ }
+ }
+
+ await this.db.transaction(async (tx: any) => {
+ await tx
+ .update(connectPaymentSessions)
+ .set({
+ status: session.paymentMode === 'authorize' ? 'voided' : 'expired',
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(connectPaymentSessions.id, session.id),
+ eq(connectPaymentSessions.status, 'pending'),
+ ),
+ );
+
+ await tx
+ .update(reservations)
+ .set({
+ status: 'cancelled',
+ cancelledAt: new Date(),
+ cancellationReason: 'Payment session expired',
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(reservations.id, session.reservationId),
+ eq(reservations.propertyId, session.propertyId),
+ eq(reservations.status, 'pending'),
+ ),
+ );
+ });
+ }
+
+ /** Cron-friendly: expire all overdue pending sessions. */
+ async expireOverdueSessions() {
+ const rows = await this.db
+ .select({ confirmationNumber: connectPaymentSessions.confirmationNumber })
+ .from(connectPaymentSessions)
+ .where(
+ and(
+ eq(connectPaymentSessions.status, 'pending'),
+ lt(connectPaymentSessions.expiresAt, new Date()),
+ ),
+ );
+ for (const row of rows) {
+ await this.expireSession(row.confirmationNumber);
+ }
+ return { expired: rows.length };
+ }
+
+ toPublicSession(session: any) {
+ return {
+ confirmationNumber: session.confirmationNumber,
+ paymentStatus: session.status === 'paid' ? 'paid' : session.status,
+ status: session.status,
+ paymentMode: session.paymentMode as ConnectPaymentMode,
+ paymentUrl: session.paymentUrl ?? null,
+ depositDue: Number(session.depositAmount ?? 0),
+ currencyCode: session.currencyCode,
+ processor: session.psp,
+ expiresAt: session.expiresAt instanceof Date
+ ? session.expiresAt.toISOString()
+ : session.expiresAt,
+ };
+ }
+}
diff --git a/apps/api/src/modules/connect/connect.controller.spec.ts b/apps/api/src/modules/connect/connect.controller.spec.ts
index 9b69dc25..f97ba300 100644
--- a/apps/api/src/modules/connect/connect.controller.spec.ts
+++ b/apps/api/src/modules/connect/connect.controller.spec.ts
@@ -36,14 +36,20 @@ function mkController() {
getGuestTriggers: vi.fn(),
getHousekeepingInsights: vi.fn(),
};
+ const payment = {
+ getPaymentStatus: vi.fn(),
+ confirmPayment: vi.fn(),
+ mockPay: vi.fn(),
+ };
const c = new ConnectController(
search as any,
content as any,
booking as any,
events as any,
insights as any,
+ payment as any,
);
- return { c, search, content, booking, events, insights };
+ return { c, search, content, booking, events, insights, payment };
}
describe('ConnectController — search pinning', () => {
diff --git a/apps/api/src/modules/connect/connect.controller.ts b/apps/api/src/modules/connect/connect.controller.ts
index 991ac733..e14abbbb 100644
--- a/apps/api/src/modules/connect/connect.controller.ts
+++ b/apps/api/src/modules/connect/connect.controller.ts
@@ -21,6 +21,7 @@ import { ConnectContentService } from './connect-content.service';
import { ConnectBookingService } from './connect-booking.service';
import { ConnectEventsService } from './connect-events.service';
import { ConnectInsightsService } from './connect-insights.service';
+import { ConnectPaymentService } from './connect-payment.service';
import { AgentSearchDto } from './dto/agent-search.dto';
import { AgentBookDto } from './dto/agent-book.dto';
import { AgentModifyDto, AgentCancelDto } from './dto/agent-modify.dto';
@@ -47,6 +48,7 @@ export class ConnectController {
private readonly bookingService: ConnectBookingService,
private readonly eventsService: ConnectEventsService,
private readonly insightsService: ConnectInsightsService,
+ private readonly paymentService: ConnectPaymentService,
) {}
// --- Search & Content (Agent 4.1, 4.2, 4.3) ---
@@ -125,6 +127,36 @@ export class ConnectController {
return this.bookingService.cancel(confirmationNumber, dto.reason);
}
+ @Get('bookings/:confirmationNumber/payment-status')
+ @ApiOperation({ summary: 'Payment status for hosted Connect / Ligare bookings (PSP-reconciled)' })
+ async paymentStatus(@Param('confirmationNumber') confirmationNumber: string) {
+ return this.paymentService.getPaymentStatus(confirmationNumber);
+ }
+
+ @Post('bookings/:confirmationNumber/confirm-payment')
+ @ApiOperation({ summary: 'Idempotent confirm after guest pays (LLM confirmPayment)' })
+ async confirmPayment(@Param('confirmationNumber') confirmationNumber: string) {
+ return this.paymentService.confirmPayment(confirmationNumber);
+ }
+
+ @Post('bookings/:confirmationNumber/mock-pay')
+ @ApiOperation({ summary: 'Dev/staging mock pay — only when PAYMENT_GATEWAY is mock' })
+ async mockPay(@Param('confirmationNumber') confirmationNumber: string) {
+ return this.paymentService.mockPay(confirmationNumber);
+ }
+
+ @Get('bookings/:confirmationNumber/payment-return')
+ @ApiOperation({ summary: 'Guest return from hosted Checkout — reconcile then show status' })
+ async paymentReturn(@Param('confirmationNumber') confirmationNumber: string) {
+ const status = await this.paymentService.getPaymentStatus(confirmationNumber);
+ return {
+ ...status,
+ message: status.paymentStatus === 'paid'
+ ? 'Payment complete. You can return to the chat.'
+ : 'Payment still pending. Return to the chat and ask the assistant to confirm.',
+ };
+ }
+
// --- Event Subscriptions ---
@Post('subscriptions')
diff --git a/apps/api/src/modules/connect/connect.module.ts b/apps/api/src/modules/connect/connect.module.ts
index 04e033d8..f7c406e1 100644
--- a/apps/api/src/modules/connect/connect.module.ts
+++ b/apps/api/src/modules/connect/connect.module.ts
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
+import { ConfigModule } from '@nestjs/config';
import { ConnectController } from './connect.controller';
import { ConnectSearchService } from './connect-search.service';
import { ConnectContentService } from './connect-content.service';
import { ConnectBookingService } from './connect-booking.service';
import { ConnectEventsService } from './connect-events.service';
import { ConnectInsightsService } from './connect-insights.service';
+import { ConnectPaymentService } from './connect-payment.service';
import { ConnectCredentialsController } from './connect-credentials.controller';
import { ConnectCredentialsService } from './connect-credentials.service';
import { ReservationModule } from '../reservation/reservation.module';
@@ -12,9 +14,21 @@ import { WebhookModule } from '../webhook/webhook.module';
import { AuthModule } from '../auth/auth.module';
import { RatePlanModule } from '../rate-plan/rate-plan.module';
import { PolicyModule } from '../policy/policy.module';
+import { FolioModule } from '../folio/folio.module';
+import { PaymentModule } from '../payment/payment.module';
+import { CONNECT_CHECKOUT_STRIPE_HANDLER } from '../payment/connect-checkout-stripe-handler.interface';
@Module({
- imports: [ReservationModule, WebhookModule, AuthModule, RatePlanModule, PolicyModule],
+ imports: [
+ ConfigModule,
+ ReservationModule,
+ WebhookModule,
+ AuthModule,
+ RatePlanModule,
+ PolicyModule,
+ FolioModule,
+ PaymentModule,
+ ],
controllers: [ConnectController, ConnectCredentialsController],
providers: [
ConnectSearchService,
@@ -23,7 +37,12 @@ import { PolicyModule } from '../policy/policy.module';
ConnectEventsService,
ConnectInsightsService,
ConnectCredentialsService,
+ ConnectPaymentService,
+ {
+ provide: CONNECT_CHECKOUT_STRIPE_HANDLER,
+ useExisting: ConnectPaymentService,
+ },
],
- exports: [ConnectSearchService, ConnectBookingService],
+ exports: [ConnectSearchService, ConnectBookingService, ConnectPaymentService],
})
export class ConnectModule {}
diff --git a/apps/api/src/modules/connect/dto/agent-book.dto.ts b/apps/api/src/modules/connect/dto/agent-book.dto.ts
index 5c015eda..ea9882b1 100644
--- a/apps/api/src/modules/connect/dto/agent-book.dto.ts
+++ b/apps/api/src/modules/connect/dto/agent-book.dto.ts
@@ -7,6 +7,7 @@ import {
IsEmail,
IsIn,
Min,
+ ValidateIf,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@@ -41,8 +42,9 @@ export class AgentBookDto {
guestLastName!: string;
@ApiPropertyOptional({ example: 'john@example.com' })
- @IsOptional()
+ @ValidateIf((o: AgentBookDto) => o.paymentMethod === 'hosted_checkout')
@IsEmail()
+ @IsOptional()
guestEmail?: string;
@ApiPropertyOptional({ example: '+1-555-0100' })
@@ -72,24 +74,42 @@ export class AgentBookDto {
@IsString()
specialRequests?: string;
- // Payment
- @ApiPropertyOptional({ enum: ['pay_at_property', 'prepaid', 'virtual_card'] })
+ // Payment — LLM schemas expose hosted_checkout | pay_at_property only.
+ // prepaid/virtual_card kept for backward compatibility but do not charge.
+ @ApiPropertyOptional({
+ enum: ['pay_at_property', 'hosted_checkout', 'prepaid', 'virtual_card'],
+ })
@IsOptional()
- @IsIn(['pay_at_property', 'prepaid', 'virtual_card'])
- paymentMethod?: 'pay_at_property' | 'prepaid' | 'virtual_card';
+ @IsIn(['pay_at_property', 'hosted_checkout', 'prepaid', 'virtual_card'])
+ paymentMethod?: 'pay_at_property' | 'hosted_checkout' | 'prepaid' | 'virtual_card';
- @ApiPropertyOptional()
+ @ApiPropertyOptional({
+ description: 'Deprecated for LLM clients — cards never pass through Connect tools',
+ })
@IsOptional()
@IsString()
paymentToken?: string;
+ /** Required for hosted_checkout — LLM-stable key per guest intent. */
+ @ApiPropertyOptional({ description: 'Idempotency key (required for hosted_checkout)' })
+ @ValidateIf((o: AgentBookDto) => o.paymentMethod === 'hosted_checkout')
+ @IsString()
+ @IsOptional()
+ idempotencyKey?: string;
+
+ /** chatgpt | claude | gemini — stored for attribution UI */
+ @ApiPropertyOptional({ enum: ['chatgpt', 'claude', 'gemini', 'ligare'] })
+ @IsOptional()
+ @IsIn(['chatgpt', 'claude', 'gemini', 'ligare'])
+ clientPlatform?: 'chatgpt' | 'claude' | 'gemini' | 'ligare';
+
// Agent metadata
- @ApiPropertyOptional({ description: 'OTAIP agent that made this booking' })
+ @ApiPropertyOptional({ description: 'Agent / channel id (use LIGARE for Ligare Hotels)' })
@IsOptional()
@IsString()
agentId?: string;
- @ApiPropertyOptional({ description: 'OTAIP booking reference' })
+ @ApiPropertyOptional({ description: 'External booking reference / platform tag' })
@IsOptional()
@IsString()
externalReference?: string;
diff --git a/apps/api/src/modules/payment/connect-checkout-stripe-handler.interface.ts b/apps/api/src/modules/payment/connect-checkout-stripe-handler.interface.ts
new file mode 100644
index 00000000..8bf06a25
--- /dev/null
+++ b/apps/api/src/modules/payment/connect-checkout-stripe-handler.interface.ts
@@ -0,0 +1,10 @@
+import type Stripe from 'stripe';
+
+export const CONNECT_CHECKOUT_STRIPE_HANDLER = Symbol('CONNECT_CHECKOUT_STRIPE_HANDLER');
+
+export interface ConnectCheckoutStripeHandler {
+ handleCheckoutSessionCompleted(
+ eventId: string,
+ session: Stripe.Checkout.Session,
+ ): Promise;
+}
diff --git a/apps/api/src/modules/payment/stripe-webhook.controller.ts b/apps/api/src/modules/payment/stripe-webhook.controller.ts
index f49dfae6..5375953a 100644
--- a/apps/api/src/modules/payment/stripe-webhook.controller.ts
+++ b/apps/api/src/modules/payment/stripe-webhook.controller.ts
@@ -8,6 +8,7 @@ import {
Inject,
Optional,
} from '@nestjs/common';
+import { ModuleRef } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { ApiTags, ApiOperation, ApiExcludeEndpoint } from '@nestjs/swagger';
import { Public } from '../auth/public.decorator';
@@ -24,6 +25,10 @@ import {
type BookingRequestStripeHandler,
type BookingRequestStripePaymentRow,
} from './booking-request-stripe-handler.interface';
+import {
+ CONNECT_CHECKOUT_STRIPE_HANDLER,
+ type ConnectCheckoutStripeHandler,
+} from './connect-checkout-stripe-handler.interface';
import { classifyHaipMetadata } from './stripe-financial-state';
import Stripe from 'stripe';
@@ -51,6 +56,7 @@ export class StripeWebhookController {
private readonly webhookService: WebhookService,
private readonly folioService: FolioService,
private readonly configService: ConfigService,
+ private readonly moduleRef: ModuleRef,
@Optional()
@Inject(BOOKING_REQUEST_STRIPE_HANDLER)
private readonly bookingRequestStripeHandler?: BookingRequestStripeHandler,
@@ -138,6 +144,26 @@ export class StripeWebhookController {
await this.handleChargeRefunded(event.data.object as Stripe.Charge);
break;
+ case 'checkout.session.completed': {
+ // Resolved lazily to avoid PaymentModule ↔ ConnectModule circular imports.
+ let handler: ConnectCheckoutStripeHandler | undefined;
+ try {
+ handler = this.moduleRef.get(
+ CONNECT_CHECKOUT_STRIPE_HANDLER,
+ { strict: false },
+ );
+ } catch {
+ handler = undefined;
+ }
+ if (handler) {
+ await handler.handleCheckoutSessionCompleted(
+ event.id,
+ event.data.object as Stripe.Checkout.Session,
+ );
+ }
+ break;
+ }
+
default:
this.logger.debug(`Unhandled event type: ${event.type}`);
}
diff --git a/docs/test-stats.json b/docs/test-stats.json
index 1f9545fb..1ff502c0 100644
--- a/docs/test-stats.json
+++ b/docs/test-stats.json
@@ -1,7 +1,7 @@
{
- "tests": 2599,
- "files": 302,
+ "tests": 2617,
+ "files": 304,
"scope": "all workspace packages with a test script",
"semantics": "passed test cases and files containing at least one passed test; skipped test cases and skipped-only files are excluded",
- "updatedAt": "2026-10-25T05:10:12.570Z"
+ "updatedAt": "2026-10-25T05:58:35.748Z"
}
diff --git a/packages/database/src/migrations/0029_connect_payment_sessions.sql b/packages/database/src/migrations/0029_connect_payment_sessions.sql
new file mode 100644
index 00000000..bb8f57c8
--- /dev/null
+++ b/packages/database/src/migrations/0029_connect_payment_sessions.sql
@@ -0,0 +1,43 @@
+-- Connect LLM / agent hosted payment sessions (Ligare Hotels).
+-- Tracks pending → paid/failed/expired with idempotency and PSP event dedupe.
+
+CREATE TABLE IF NOT EXISTS connect_payment_sessions (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ property_id uuid NOT NULL REFERENCES properties(id),
+ confirmation_number varchar(64) NOT NULL,
+ reservation_id uuid NOT NULL REFERENCES reservations(id),
+ booking_id uuid NOT NULL REFERENCES bookings(id),
+ folio_id uuid REFERENCES folios(id),
+ payment_id uuid REFERENCES payments(id),
+ idempotency_key varchar(200) NOT NULL,
+ request_hash varchar(64) NOT NULL,
+ payment_mode varchar(20) NOT NULL,
+ status varchar(20) NOT NULL DEFAULT 'pending',
+ psp varchar(40) NOT NULL,
+ psp_session_id varchar(200),
+ payment_url text,
+ deposit_amount numeric(12, 2) NOT NULL DEFAULT 0,
+ currency_code varchar(3) NOT NULL DEFAULT 'EUR',
+ client_platform varchar(40),
+ psp_event_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
+ expires_at timestamptz NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT connect_payment_sessions_mode_chk
+ CHECK (payment_mode IN ('charge', 'authorize', 'guarantee')),
+ CONSTRAINT connect_payment_sessions_status_chk
+ CHECK (status IN ('pending', 'paid', 'failed', 'expired', 'voided'))
+);
+
+CREATE UNIQUE INDEX IF NOT EXISTS connect_payment_sessions_idempotency_uq
+ ON connect_payment_sessions (property_id, idempotency_key);
+
+CREATE UNIQUE INDEX IF NOT EXISTS connect_payment_sessions_confirmation_uq
+ ON connect_payment_sessions (confirmation_number);
+
+CREATE INDEX IF NOT EXISTS connect_payment_sessions_pending_expiry_idx
+ ON connect_payment_sessions (expires_at)
+ WHERE status = 'pending';
+
+CREATE INDEX IF NOT EXISTS connect_payment_sessions_property_idx
+ ON connect_payment_sessions (property_id);
diff --git a/packages/database/src/schema/connect.ts b/packages/database/src/schema/connect.ts
index 5b8f5520..a3d9c851 100644
--- a/packages/database/src/schema/connect.ts
+++ b/packages/database/src/schema/connect.ts
@@ -7,6 +7,7 @@ import {
jsonb,
integer,
text,
+ numeric,
pgEnum,
uniqueIndex,
} from 'drizzle-orm/pg-core';
@@ -98,3 +99,41 @@ export const connectCredentials = pgTable('connect_credentials', {
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
});
+
+/** Hosted payment session for Connect / Ligare LLM bookings. */
+export type ConnectPaymentMode = 'charge' | 'authorize' | 'guarantee';
+export type ConnectPaymentSessionStatus =
+ | 'pending'
+ | 'paid'
+ | 'failed'
+ | 'expired'
+ | 'voided';
+
+export const connectPaymentSessions = pgTable('connect_payment_sessions', {
+ id: uuid('id').primaryKey().defaultRandom(),
+ propertyId: uuid('property_id').notNull().references(() => properties.id),
+ confirmationNumber: varchar('confirmation_number', { length: 64 }).notNull(),
+ reservationId: uuid('reservation_id').notNull(),
+ bookingId: uuid('booking_id').notNull(),
+ folioId: uuid('folio_id'),
+ paymentId: uuid('payment_id'),
+ idempotencyKey: varchar('idempotency_key', { length: 200 }).notNull(),
+ requestHash: varchar('request_hash', { length: 64 }).notNull(),
+ paymentMode: varchar('payment_mode', { length: 20 }).$type().notNull(),
+ status: varchar('status', { length: 20 }).$type().notNull().default('pending'),
+ psp: varchar('psp', { length: 40 }).notNull(),
+ pspSessionId: varchar('psp_session_id', { length: 200 }),
+ paymentUrl: text('payment_url'),
+ depositAmount: numeric('deposit_amount', { precision: 12, scale: 2 }).notNull().default('0.00'),
+ currencyCode: varchar('currency_code', { length: 3 }).notNull().default('EUR'),
+ clientPlatform: varchar('client_platform', { length: 40 }),
+ pspEventIds: jsonb('psp_event_ids').$type().notNull().default([]),
+ expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
+ createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
+ updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
+}, (table) => ({
+ idempotencyUnique: uniqueIndex('connect_payment_sessions_idempotency_uq')
+ .on(table.propertyId, table.idempotencyKey),
+ confirmationUnique: uniqueIndex('connect_payment_sessions_confirmation_uq')
+ .on(table.confirmationNumber),
+}));
diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts
index d4e79d8b..83b05f18 100644
--- a/packages/database/src/schema/index.ts
+++ b/packages/database/src/schema/index.ts
@@ -147,6 +147,11 @@ export {
webhookDeliveryStatusEnum,
webhookDeliveries,
connectCredentials,
+ connectPaymentSessions,
+} from './connect.js';
+export type {
+ ConnectPaymentMode,
+ ConnectPaymentSessionStatus,
} from './connect.js';
// Integration catalog (global) + per-property enablement
diff --git a/tools/haip-connect-gpt/.env.example b/tools/haip-connect-gpt/.env.example
index 70f3fe05..40f6369a 100644
--- a/tools/haip-connect-gpt/.env.example
+++ b/tools/haip-connect-gpt/.env.example
@@ -1,28 +1,22 @@
# --- Upstream HAIP Connect API ---
-# Base URL of a running HAIP API instance (the gateway calls /api/v1/connect/* on it).
HAIP_API_BASE_URL=http://localhost:3000
-# API key the gateway sends as `x-api-key` to HAIP (matches HAIP's CONNECT_API_KEY).
-# The GPT never sees this — the gateway injects it server-side.
HAIP_CONNECT_API_KEY=dev-connect-key
# --- This gateway ---
-# Port to listen on for local/Node hosting (ignored on Vercel serverless).
PORT=8080
-# Public URL where this gateway is reachable — used as the OpenAPI `servers[0].url`
-# and as the base for the privacy page. On Vercel this is auto-derived from the
-# deployment domain if left unset; set it to your production domain for a stable spec.
PUBLIC_BASE_URL=http://localhost:8080
-# --- Caller authentication (optional; OPEN demo when unset) ---
-# This is a demo/template gateway: with no key set it is OPEN (the public demo so
-# hotels can see how to connect their own GPT). To LOCK IT DOWN for a real
-# deployment, set GATEWAY_API_KEY to a strong secret — then every action request
-# must present it (Authorization: Bearer , or x-api-key), and you put the
-# same value in the ChatGPT Action's Authentication (API Key / Bearer) config.
-# The gateway holds HAIP's privileged Connect key, so set this in production.
+# REQUIRED in staging/prod — ChatGPT / Claude / Gemini callers must present this.
GATEWAY_API_KEY=
-# --- Tool-call logging (Supabase project: haip-demo, direct Postgres) ---
-# Connection string for the haip_tool_calls table. Leave blank to disable
-# logging (gateway still serves normally).
+# --- Allow-list (opted-in properties) ---
+# Prefer remote allow-list API (service token):
+AI_DISTRIBUTION_BASE_URL=https://example.com
+AI_DISTRIBUTION_SERVICE_TOKEN=
+# Or static UUIDs (comma-separated) for local smoke:
+# LIGARE_ALLOWED_PROPERTY_IDS=00000000-0000-4000-a000-000000000001
+# Local Connect demos only (do not use in staging/prod):
+# LIGARE_ALLOW_ALL=true
+
+# --- Tool-call logging ---
TOOL_LOG_DATABASE_URL=
diff --git a/tools/haip-connect-gpt/CHATGPT-GPT.md b/tools/haip-connect-gpt/CHATGPT-GPT.md
index 5e371afc..4fd2bb24 100644
--- a/tools/haip-connect-gpt/CHATGPT-GPT.md
+++ b/tools/haip-connect-gpt/CHATGPT-GPT.md
@@ -1,69 +1,3 @@
-# Custom GPT — "Telivity HAIP"
+# Moved
-Custom GPTs are created in the ChatGPT UI; this file is the copy-paste content and the
-steps. The gateway must be deployed (publicly reachable at `PUBLIC_BASE_URL`) first.
-
-## 1. Create the GPT
-
-ChatGPT → **Explore GPTs** → **Create** → **Configure**.
-
-**Name**
-
-```
-Telivity HAIP
-```
-
-**Description**
-
-```
-Search hotels and book, look up, modify, or cancel reservations through the HAIP hotel platform.
-```
-
-**Instructions**
-
-```
-You are Telivity HAIP, a hotel booking assistant backed by the HAIP (Hotel AI Platform) Connect API.
-
-Use the actions to do real work — never invent properties, availability, rates, room types, confirmation numbers, or cancellation terms. If the API doesn't return something, say so.
-
-Booking flow:
-1. To search, you need a location (a city, or coordinates, or a specific propertyId) plus check-in and check-out dates. Ask for anything missing. Default to 2 adults, 1 room if the guest doesn't specify, and confirm.
-2. Present options clearly: property, room type, rate plan, total price with currency, and the cancellation policy. All prices are the guest-facing selling price.
-3. Before calling createReservation, confirm with the guest: dates, occupancy, the exact room + rate, the total price, and the cancellation policy. Collect the guest's first and last name (email/phone optional).
-4. After booking, give the guest their confirmation number and tell them they'll need it to look up, change, or cancel the reservation.
-5. For getReservation / modifyReservation / cancelReservation, use the confirmation number. For modify, send only the fields that change. Before cancelling, state any penalty and refund and confirm the guest wants to proceed.
-
-Be concise. Never reveal internal identifiers unless useful to the guest. Never claim a booking succeeded unless createReservation returned success with a confirmation number.
-```
-
-**Conversation starters**
-
-```
-Find me a hotel in New York for June 1–3 for 2 adults
-Look up my reservation
-What's the cancellation policy on this rate?
-Cancel my booking
-```
-
-## 2. Add the Action
-
-Configure → **Actions** → **Create new action** → **Import from URL**:
-
-```
-/openapi.json
-```
-
-(e.g. `https://haip-connect-gpt.vercel.app/openapi.json`)
-
-- **Authentication**: None. The gateway holds HAIP's API key server-side; the GPT must not.
-- **Privacy policy URL**:
-
-```
-/privacy
-```
-
-## 3. Test, then publish
-
-In the preview, run a conversation starter and confirm the actions call through (a row
-should appear in Supabase `haip_tool_calls`). Then **Save** / **Publish** with the
-visibility you want.
+See [LIGARE-HOTELS.md](./LIGARE-HOTELS.md) for ChatGPT, Claude, and Gemini setup.
diff --git a/tools/haip-connect-gpt/LIGARE-HOTELS.md b/tools/haip-connect-gpt/LIGARE-HOTELS.md
new file mode 100644
index 00000000..a635e369
--- /dev/null
+++ b/tools/haip-connect-gpt/LIGARE-HOTELS.md
@@ -0,0 +1,73 @@
+# Ligare Hotels — ChatGPT, Claude, Gemini
+
+Book opted-in HAIP properties inside the assistant: search → prepareBooking → guest pays on the hotel PSP hosted page → confirmPayment → confirmation number.
+
+Gateway must be publicly reachable first (`PUBLIC_BASE_URL`).
+
+## Shared URLs
+
+| Resource | URL |
+|----------|-----|
+| OpenAPI (ChatGPT Actions) | `/openapi.json` |
+| Privacy | `/privacy` |
+| MCP (Claude / Gemini) | `/mcp` |
+| Health | `/health` |
+
+Staging example (replace after deploy): `https:///openapi.json`
+
+Action / connector auth: set `GATEWAY_API_KEY` on the gateway and the same value as API Key / Bearer / request header. Never put `HAIP_CONNECT_API_KEY` in the LLM client.
+
+## Booking flow (all LLMs)
+
+1. `searchHotels` — location + dates + occupancy.
+2. Present room, rate, **tax-inclusive total**, cancellation policy.
+3. Collect name + **email**. Confirm stay details.
+4. `prepareBooking` with `Idempotency-Key` (stable per guest intent). Response includes `paymentUrl` + `paymentMode` (`charge` | `authorize` | `guarantee`).
+5. Ask the guest to open `paymentUrl` and complete payment (card never enters chat).
+6. Call `confirmPayment` until `confirmed` / `paymentStatus=paid`. Only then say the booking succeeded.
+7. Give the confirmation number for lookup / modify / cancel.
+
+Never invent availability, prices, or confirmation numbers. Never accept card PANs or payment tokens.
+
+## 1. ChatGPT Custom GPT (Ligare)
+
+ChatGPT → Explore GPTs → Create → Configure.
+
+**Name:** `Ligare Hotels`
+
+**Instructions:** paste the booking flow above; use actions for all hotel work; default 2 adults / 1 room; require email before prepareBooking; always confirmPayment after pay.
+
+**Actions:** Import from URL → `/openapi.json`
+**Authentication:** API Key (Bearer) = `GATEWAY_API_KEY`
+**Privacy policy URL:** `/privacy`
+
+## 2. Claude custom connector
+
+Customize → Connectors → Add → Custom → Web.
+
+- Name: `Ligare Hotels`
+- MCP URL: `/mcp`
+- Auth: request header `Authorization: Bearer ` (or `x-api-key`)
+
+## 3. Gemini (API / Enterprise)
+
+Remote MCP Streamable HTTP:
+
+```json
+{
+ "type": "mcp_server",
+ "name": "ligare_hotels",
+ "url": "/mcp",
+ "headers": { "Authorization": "Bearer " }
+}
+```
+
+Gemini Enterprise: add as Custom MCP Server data store; enable the Ligare tools.
+
+## Property allow-list
+
+Only properties on the gateway allow-list appear in search. Detail/book against a property that is not enabled returns 404 (live check, not search cache).
+
+## Env (gateway)
+
+See `.env.example`. Required: `HAIP_API_BASE_URL`, `HAIP_CONNECT_API_KEY`, `GATEWAY_API_KEY`, `PUBLIC_BASE_URL`, and allow-list via `AI_DISTRIBUTION_BASE_URL` + `AI_DISTRIBUTION_SERVICE_TOKEN` (or `LIGARE_ALLOWED_PROPERTY_IDS` for a static list).
diff --git a/tools/haip-connect-gpt/package-lock.json b/tools/haip-connect-gpt/package-lock.json
index d537a6cf..0cc5f11a 100644
--- a/tools/haip-connect-gpt/package-lock.json
+++ b/tools/haip-connect-gpt/package-lock.json
@@ -700,9 +700,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -717,9 +714,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -734,9 +728,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -751,9 +742,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -768,9 +756,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -785,9 +770,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -802,9 +784,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -819,9 +798,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -836,9 +812,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -853,9 +826,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -870,9 +840,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -887,9 +854,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -904,9 +868,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
diff --git a/tools/haip-connect-gpt/scripts/e2e-smoke.mjs b/tools/haip-connect-gpt/scripts/e2e-smoke.mjs
new file mode 100644
index 00000000..712c64a6
--- /dev/null
+++ b/tools/haip-connect-gpt/scripts/e2e-smoke.mjs
@@ -0,0 +1,92 @@
+#!/usr/bin/env node
+/**
+ * Staging E2E smoke for Ligare Hotels gateway.
+ *
+ * Env:
+ * GATEWAY_BASE_URL — public gateway URL
+ * GATEWAY_API_KEY — optional Bearer
+ * CITY — default Paris
+ * PROPERTY_ID — optional property UUID pin
+ *
+ * Flow: search → (optional) prepareBooking → confirmPayment path check → OpenAPI/MCP.
+ */
+const base = (process.env.GATEWAY_BASE_URL || '').replace(/\/+$/, '');
+const key = process.env.GATEWAY_API_KEY || '';
+const city = process.env.CITY || 'Paris';
+const propertyId = process.env.PROPERTY_ID || '';
+
+if (!base) {
+ console.error('GATEWAY_BASE_URL required');
+ process.exit(1);
+}
+
+const headers = {
+ 'content-type': 'application/json',
+ ...(key ? { authorization: `Bearer ${key}` } : {}),
+};
+
+async function req(method, path, body) {
+ const res = await fetch(`${base}${path}`, {
+ method,
+ headers,
+ body: body === undefined ? undefined : JSON.stringify(body),
+ });
+ const text = await res.text();
+ let json;
+ try {
+ json = text ? JSON.parse(text) : null;
+ } catch {
+ json = text;
+ }
+ return { status: res.status, json };
+}
+
+const results = [];
+
+const health = await req('GET', '/health');
+results.push(['health', health.status === 200]);
+
+const openapi = await req('GET', '/openapi.json');
+const hasConfirm =
+ openapi.status === 200 &&
+ openapi.json?.paths?.['/reservations/{confirmationNumber}/confirm-payment'];
+results.push(['openapi_confirmPayment', !!hasConfirm]);
+results.push([
+ 'openapi_no_paymentToken',
+ !openapi.json?.paths?.['/reservations']?.post?.requestBody?.content?.['application/json']?.schema
+ ?.properties?.paymentToken,
+]);
+
+const mcp = await req('POST', '/mcp', {
+ jsonrpc: '2.0',
+ id: 1,
+ method: 'tools/list',
+});
+results.push(['mcp_tools_list', mcp.status === 200 && Array.isArray(mcp.json?.result?.tools)]);
+
+const tomorrow = new Date();
+tomorrow.setUTCDate(tomorrow.getUTCDate() + 14);
+const checkout = new Date(tomorrow);
+checkout.setUTCDate(checkout.getUTCDate() + 2);
+const checkIn = tomorrow.toISOString().slice(0, 10);
+const checkOut = checkout.toISOString().slice(0, 10);
+
+const search = await req('POST', '/hotels/search', {
+ city,
+ ...(propertyId ? { propertyId } : {}),
+ checkIn,
+ checkOut,
+ adults: 2,
+});
+results.push(['search', search.status === 200]);
+const searchResults = search.json?.results ?? [];
+results.push(['search_results_array', Array.isArray(searchResults)]);
+
+if (propertyId) {
+ const detail = await req('GET', `/hotels/${propertyId}`);
+ results.push(['property_detail_or_404', detail.status === 200 || detail.status === 404]);
+}
+
+console.log(JSON.stringify({ base, checkIn, checkOut, city, results, searchCount: searchResults.length }, null, 2));
+const failed = results.filter(([, ok]) => !ok);
+process.exit(failed.length ? 1 : 0);
diff --git a/tools/haip-connect-gpt/src/allow-list.test.ts b/tools/haip-connect-gpt/src/allow-list.test.ts
new file mode 100644
index 00000000..86f3067b
--- /dev/null
+++ b/tools/haip-connect-gpt/src/allow-list.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect, vi } from 'vitest';
+import { AllowList, staticAllowListSource } from './allow-list.js';
+
+describe('AllowList', () => {
+ it('caches search allow-list for ttl then refreshes', async () => {
+ let calls = 0;
+ const source = {
+ listEnabled: vi.fn(async () => {
+ calls += 1;
+ return calls === 1 ? ['a'] : ['a', 'b'];
+ }),
+ isEnabled: vi.fn(async (id: string) => id === 'a'),
+ };
+ const list = new AllowList(source, 50);
+ expect(await list.isEnabledCached('a')).toBe(true);
+ expect(await list.isEnabledCached('b')).toBe(false);
+ expect(source.listEnabled).toHaveBeenCalledTimes(1);
+ await new Promise((r) => setTimeout(r, 60));
+ expect(await list.isEnabledCached('b')).toBe(true);
+ expect(source.listEnabled).toHaveBeenCalledTimes(2);
+ });
+
+ it('live check bypasses cache so disabled properties 404 immediately', async () => {
+ const enabled = new Set(['prop-a']);
+ const source = {
+ listEnabled: async () => [...enabled],
+ isEnabled: async (id: string) => enabled.has(id),
+ };
+ const list = new AllowList(source, 60_000);
+ expect(await list.isEnabledCached('prop-a')).toBe(true);
+ enabled.delete('prop-a');
+ // Cached search still sees it
+ expect(await list.isEnabledCached('prop-a')).toBe(true);
+ // Live book/detail does not
+ expect(await list.isPropertyEnabledLive('prop-a')).toBe(false);
+ });
+
+ it('static source filters search results', async () => {
+ const list = new AllowList(staticAllowListSource(['p1', 'p2']));
+ expect(await list.filterPropertyIds(['p1', 'p3', 'p2'])).toEqual(['p1', 'p2']);
+ });
+});
diff --git a/tools/haip-connect-gpt/src/allow-list.ts b/tools/haip-connect-gpt/src/allow-list.ts
new file mode 100644
index 00000000..a757c368
--- /dev/null
+++ b/tools/haip-connect-gpt/src/allow-list.ts
@@ -0,0 +1,137 @@
+/**
+ * Per-property Ligare / AI distribution allow-list.
+ *
+ * Search may use a ≤60s cache. Detail and prepareBooking MUST call
+ * isPropertyEnabledLive() so a hotel toggled OFF is immediately 404.
+ */
+
+export interface AllowListSource {
+ /** Fetch the full set of enabled property IDs. */
+ listEnabled(): Promise;
+ /** Live check for one property (no cache). */
+ isEnabled(propertyId: string): Promise;
+}
+
+export class AllowList {
+ private cachedIds: Set | null = null;
+ private cachedAt = 0;
+ private readonly ttlMs: number;
+
+ constructor(
+ private readonly source: AllowListSource,
+ ttlMs = 60_000,
+ ) {
+ this.ttlMs = ttlMs;
+ }
+
+ async getCachedEnabledIds(): Promise> {
+ const now = Date.now();
+ if (this.cachedIds && now - this.cachedAt < this.ttlMs) {
+ return this.cachedIds;
+ }
+ const ids = await this.source.listEnabled();
+ this.cachedIds = new Set(ids);
+ this.cachedAt = now;
+ return this.cachedIds;
+ }
+
+ /** Search filter — may be up to ttlMs stale. */
+ async filterPropertyIds(ids: string[]): Promise {
+ const enabled = await this.getCachedEnabledIds();
+ return ids.filter((id) => enabled.has(id));
+ }
+
+ async isEnabledCached(propertyId: string): Promise {
+ const enabled = await this.getCachedEnabledIds();
+ return enabled.has(propertyId);
+ }
+
+ /** Detail / book — always live. */
+ async isPropertyEnabledLive(propertyId: string): Promise {
+ return this.source.isEnabled(propertyId);
+ }
+
+ /** Test helper */
+ clearCache(): void {
+ this.cachedIds = null;
+ this.cachedAt = 0;
+ }
+}
+
+/** Remote allow-list API (service token). */
+export function distributionAllowListSource(opts: {
+ baseUrl: string;
+ serviceToken: string;
+ fetchImpl?: typeof fetch;
+}): AllowListSource {
+ const base = opts.baseUrl.replace(/\/+$/, '');
+ const fetchFn = opts.fetchImpl ?? fetch;
+ const headers = {
+ 'x-service-token': opts.serviceToken,
+ accept: 'application/json',
+ };
+
+ return {
+ async listEnabled() {
+ const res = await fetchFn(`${base}/api/ai-distribution/allow-list`, { headers });
+ if (!res.ok) {
+ throw new Error(`allow-list fetch failed: ${res.status}`);
+ }
+ const body = (await res.json()) as { propertyIds?: string[] };
+ return body.propertyIds ?? [];
+ },
+ async isEnabled(propertyId: string) {
+ const res = await fetchFn(
+ `${base}/api/ai-distribution/allow-list/${encodeURIComponent(propertyId)}`,
+ { headers },
+ );
+ if (res.status === 404) return false;
+ if (!res.ok) throw new Error(`allow-list property check failed: ${res.status}`);
+ const body = (await res.json()) as { enabled?: boolean };
+ return body.enabled === true;
+ },
+ };
+}
+
+/** Env / static allow-list for local demos (comma-separated UUIDs). */
+export function staticAllowListSource(propertyIds: string[]): AllowListSource {
+ const set = new Set(propertyIds.filter(Boolean));
+ return {
+ async listEnabled() {
+ return [...set];
+ },
+ async isEnabled(propertyId: string) {
+ return set.has(propertyId);
+ },
+ };
+}
+
+export function createAllowListFromEnv(env: NodeJS.ProcessEnv = process.env): AllowList | null {
+ const distributionUrl = env['AI_DISTRIBUTION_BASE_URL'];
+ const token = env['AI_DISTRIBUTION_SERVICE_TOKEN'];
+ if (distributionUrl && token) {
+ return new AllowList(
+ distributionAllowListSource({ baseUrl: distributionUrl, serviceToken: token }),
+ );
+ }
+ const staticIds = (env['LIGARE_ALLOWED_PROPERTY_IDS'] ?? '')
+ .split(',')
+ .map((s) => s.trim())
+ .filter(Boolean);
+ if (staticIds.length > 0) {
+ return new AllowList(staticAllowListSource(staticIds));
+ }
+ // Fail-closed when no source configured: empty allow-list.
+ // Set LIGARE_ALLOW_ALL=true only for local Connect demos without an allow-list API.
+ if (env['LIGARE_ALLOW_ALL'] === 'true') {
+ return new AllowList({
+ async listEnabled() {
+ return [];
+ },
+ async isEnabled() {
+ return true;
+ },
+ });
+ }
+ return new AllowList(staticAllowListSource([]));
+}
diff --git a/tools/haip-connect-gpt/src/app.auth.test.ts b/tools/haip-connect-gpt/src/app.auth.test.ts
index 4cfeea36..2ce7512e 100644
--- a/tools/haip-connect-gpt/src/app.auth.test.ts
+++ b/tools/haip-connect-gpt/src/app.auth.test.ts
@@ -5,7 +5,8 @@ function stubAdapter() {
return {
searchHotels: async () => ({ results: [] }),
getProperty: async () => ({ id: 'p1' }),
- createReservation: async () => ({ confirmationNumber: 'HAIP-X' }),
+ createReservation: async () => ({ confirmationNumber: 'HAIP-X', paymentUrl: 'https://pay.test' }),
+ confirmPayment: async () => ({ confirmed: true, paymentStatus: 'paid' }),
getReservation: async () => ({ confirmationNumber: 'HAIP-X' }),
modifyReservation: async () => ({ ok: true }),
cancelReservation: async () => ({ ok: true }),
diff --git a/tools/haip-connect-gpt/src/app.ts b/tools/haip-connect-gpt/src/app.ts
index f41445e7..6ac9fe78 100644
--- a/tools/haip-connect-gpt/src/app.ts
+++ b/tools/haip-connect-gpt/src/app.ts
@@ -1,14 +1,13 @@
/**
- * Fastify app: ChatGPT action endpoints + /openapi.json + /health + /privacy + landing.
+ * Fastify app: Ligare Hotels LLM gateway (ChatGPT Actions + Claude/Gemini MCP).
*
- * Every action route is wrapped by `action()`, which times the call, scrubs the
- * request and response, and logs the tool call to Supabase. The upstream HAIP
- * `x-api-key` is injected by the adapter and never appears in the spec or here.
+ * Allow-list: search may cache ≤60s; getProperty / prepareBooking always live.
+ * Cards never enter the schema — hosted paymentUrl + confirmPayment only.
*/
import Fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify';
import cors from '@fastify/cors';
-import { timingSafeEqual } from 'node:crypto';
+import { timingSafeEqual, randomUUID } from 'node:crypto';
import {
HaipConnectAdapter,
UpstreamError,
@@ -20,20 +19,17 @@ import { buildOpenApiSpec } from './openapi.js';
import { logToolCall } from './events.js';
import { redactForLog } from './scrub.js';
import { indexHtml, privacyHtml } from './pages.js';
+import { AllowList, createAllowListFromEnv } from './allow-list.js';
+import { handleMcpRequest, listMcpTools } from './mcp.js';
export interface AppOptions {
adapter: HaipConnectAdapter;
publicBaseUrl: string;
- /**
- * Credential the caller (the ChatGPT Action) must present to reach the action
- * routes — sent as `Authorization: Bearer ` or `x-api-key`. The gateway
- * holds HAIP's privileged upstream Connect key, so without this anyone on the
- * internet who finds the URL can drive the Connect API. Configure via GATEWAY_API_KEY.
- */
gatewayApiKey?: string;
+ allowList?: AllowList;
+ defaultClientPlatform?: BookInput['clientPlatform'];
}
-/** Public routes that never require a caller credential. */
const PUBLIC_PATHS = new Set(['/health', '/openapi.json', '/', '/privacy']);
function timingSafeEqualStr(a: string, b: string): boolean {
@@ -43,7 +39,6 @@ function timingSafeEqualStr(a: string, b: string): boolean {
return timingSafeEqual(ab, bb);
}
-/** Extract the caller credential from `Authorization: Bearer` or `x-api-key`. */
function extractCredential(req: FastifyRequest): string | null {
const auth = req.headers['authorization'];
if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice(7).trim();
@@ -53,22 +48,35 @@ function extractCredential(req: FastifyRequest): string | null {
return null;
}
+function extractIdempotencyKey(req: FastifyRequest): string | undefined {
+ const h = req.headers['idempotency-key'];
+ if (typeof h === 'string' && h.trim()) return h.trim();
+ if (Array.isArray(h) && h[0]) return h[0].trim();
+ const body = req.body as { idempotencyKey?: string } | undefined;
+ return body?.idempotencyKey?.trim() || undefined;
+}
+
+function propertyIdFromUnknown(value: unknown): string | null {
+ if (!value || typeof value !== 'object') return null;
+ const o = value as Record;
+ if (typeof o['propertyId'] === 'string') return o['propertyId'];
+ if (typeof o['sourcePropertyId'] === 'string') return o['sourcePropertyId'];
+ return null;
+}
+
export function buildApp(opts: AppOptions): FastifyInstance {
const { adapter, publicBaseUrl } = opts;
+ const allowList = opts.allowList ?? createAllowListFromEnv();
const app = Fastify({ logger: true });
app.register(cors, { origin: true });
- // Caller auth on the action routes. This is a demo/template gateway, so it is
- // OPEN by default — the public demo shows hotels how to connect their own GPT
- // with zero setup. A real deployment locks it down simply by setting
- // GATEWAY_API_KEY: when a key is configured it is REQUIRED (Authorization:
- // Bearer or x-api-key), validated timing-safe. The gateway holds HAIP's
- // upstream Connect key, so anyone enabling this in production should set a key.
app.addHook('onRequest', async (req, reply) => {
- if (!opts.gatewayApiKey) return; // no key configured → open (demo posture)
+ if (!opts.gatewayApiKey) return;
const path = (req.url.split('?')[0] ?? req.url).replace(/\/+$/, '') || '/';
if (PUBLIC_PATHS.has(path)) return;
+ // MCP may authenticate via same gateway key on non-public methods
+ if (path === '/mcp' && req.method === 'GET') return;
const provided = extractCredential(req);
if (!provided || !timingSafeEqualStr(provided, opts.gatewayApiKey)) {
reply.code(401).send({ error: 'unauthorized', message: 'A valid gateway credential is required.' });
@@ -77,7 +85,7 @@ export function buildApp(opts: AppOptions): FastifyInstance {
app.get('/health', async () => ({
status: 'ok',
- service: 'haip-connect-gpt',
+ service: 'ligare-hotels-gateway',
upstreamHealthy: await adapter.upstreamHealthy(),
timestamp: new Date().toISOString(),
}));
@@ -89,21 +97,70 @@ export function buildApp(opts: AppOptions): FastifyInstance {
app.get('/', async (_req, reply) => reply.type('text/html').send(indexHtml));
app.get('/privacy', async (_req, reply) => reply.type('text/html').send(privacyHtml));
- // --- ChatGPT action endpoints (paths mirror the OpenAPI spec) ---
+ app.get('/mcp', async (_req, reply) => {
+ reply.send({
+ name: 'ligare-hotels',
+ transport: 'streamable-http',
+ tools: listMcpTools().map((t) => t.name),
+ endpoint: '/mcp',
+ });
+ });
+
+ app.post('/mcp', async (req, reply) => {
+ const result = await handleMcpRequest(req.body, {
+ searchHotels: (input) => searchHotelsFiltered(adapter, allowList, input),
+ getProperty: (id) => getPropertyGuarded(adapter, allowList, id),
+ prepareBooking: (input, idem) =>
+ prepareBookingGuarded(adapter, allowList, input, idem, opts.defaultClientPlatform),
+ confirmPayment: (cn) => adapter.confirmPayment(cn),
+ getReservation: (cn) => adapter.getReservation(cn),
+ modifyReservation: (cn, body) => adapter.modifyReservation(cn, body),
+ cancelReservation: (cn, reason) => adapter.cancelReservation(cn, reason),
+ });
+ reply.send(result);
+ });
app.post(
'/hotels/search',
- action('searchHotels', (req) => adapter.searchHotels(req.body as SearchInput)),
+ action('searchHotels', (req) =>
+ searchHotelsFiltered(adapter, allowList, req.body as SearchInput),
+ ),
);
app.get(
'/hotels/:propertyId',
- action('getProperty', (req) => adapter.getProperty((req.params as { propertyId: string }).propertyId)),
+ action('getProperty', (req) =>
+ getPropertyGuarded(
+ adapter,
+ allowList,
+ (req.params as { propertyId: string }).propertyId,
+ ),
+ ),
);
app.post(
'/reservations',
- action('createReservation', (req) => adapter.createReservation(req.body as BookInput), 201),
+ action(
+ 'prepareBooking',
+ (req) =>
+ prepareBookingGuarded(
+ adapter,
+ allowList,
+ req.body as BookInput,
+ extractIdempotencyKey(req),
+ opts.defaultClientPlatform,
+ ),
+ 201,
+ ),
+ );
+
+ app.post(
+ '/reservations/:confirmationNumber/confirm-payment',
+ action('confirmPayment', (req) =>
+ adapter.confirmPayment(
+ (req.params as { confirmationNumber: string }).confirmationNumber,
+ ),
+ ),
);
app.get(
@@ -136,10 +193,82 @@ export function buildApp(opts: AppOptions): FastifyInstance {
return app;
}
-/**
- * Wraps an action handler with timing, PII-scrubbed logging, and error mapping.
- * Upstream (HAIP) errors are forwarded with their original status + body.
- */
+async function searchHotelsFiltered(
+ adapter: HaipConnectAdapter,
+ allowList: AllowList,
+ input: SearchInput,
+): Promise {
+ if (input.propertyId) {
+ const ok = await allowList.isEnabledCached(input.propertyId);
+ if (!ok) {
+ return { searchId: randomUUID(), totalResults: 0, results: [] };
+ }
+ }
+ const raw = (await adapter.searchHotels(input)) as {
+ results?: Array>;
+ totalResults?: number;
+ searchId?: string;
+ [k: string]: unknown;
+ };
+ const results = Array.isArray(raw.results) ? raw.results : [];
+ const enabled = await allowList.getCachedEnabledIds();
+ // If allow-all sentinel (isEnabled always true with empty list), skip filter when empty+allow-all
+ const filtered =
+ enabled.size === 0 && process.env['LIGARE_ALLOW_ALL'] === 'true'
+ ? results
+ : results.filter((r) => {
+ const id = String(r['sourcePropertyId'] ?? r['propertyId'] ?? '');
+ return enabled.has(id);
+ });
+ return {
+ ...raw,
+ results: filtered,
+ totalResults: filtered.length,
+ };
+}
+
+async function getPropertyGuarded(
+ adapter: HaipConnectAdapter,
+ allowList: AllowList,
+ propertyId: string,
+): Promise {
+ const ok = await allowList.isPropertyEnabledLive(propertyId);
+ if (!ok) {
+ throw new UpstreamError(404, { error: 'not_found', message: 'Property is not bookable in Ligare' });
+ }
+ return adapter.getProperty(propertyId);
+}
+
+async function prepareBookingGuarded(
+ adapter: HaipConnectAdapter,
+ allowList: AllowList,
+ input: BookInput,
+ idempotencyKey: string | undefined,
+ defaultPlatform?: BookInput['clientPlatform'],
+): Promise {
+ const ok = await allowList.isPropertyEnabledLive(input.propertyId);
+ if (!ok) {
+ throw new UpstreamError(404, { error: 'not_found', message: 'Property is not bookable in Ligare' });
+ }
+ if (!input.guestEmail) {
+ throw new UpstreamError(400, { error: 'guest_email_required', message: 'guestEmail is required' });
+ }
+ const key = idempotencyKey ?? input.idempotencyKey;
+ if (!key) {
+ throw new UpstreamError(400, {
+ error: 'idempotency_key_required',
+ message: 'Idempotency-Key header or idempotencyKey body field is required',
+ });
+ }
+ return adapter.createReservation({
+ ...input,
+ idempotencyKey: key,
+ paymentMethod: 'hosted_checkout',
+ clientPlatform: input.clientPlatform ?? defaultPlatform ?? 'ligare',
+ agentId: 'LIGARE',
+ });
+}
+
function action(
tool: string,
run: (req: FastifyRequest) => Promise,
@@ -149,15 +278,17 @@ function action(
const start = Date.now();
const sessionId = sessionFrom(req);
const requestPayload = { params: req.params, query: req.query, body: req.body };
+ const propertyId =
+ propertyIdFromUnknown(req.params) ??
+ propertyIdFromUnknown(req.body) ??
+ null;
- // logToolCall is awaited (not fire-and-forget): on serverless the instance
- // can be frozen the moment the response is sent, which would drop the
- // insert. It is still best-effort — logToolCall never throws.
try {
const result = await run(req);
await logToolCall({
tool,
sessionId,
+ propertyId,
request: redactForLog(requestPayload),
response: redactForLog(result),
status: 'ok',
@@ -173,6 +304,7 @@ function action(
await logToolCall({
tool,
sessionId,
+ propertyId,
request: redactForLog(requestPayload),
response: null,
status: 'error',
@@ -184,9 +316,8 @@ function action(
};
}
-/** ChatGPT sends a per-conversation pseudonymous id in this header. */
function sessionFrom(req: FastifyRequest): string | null {
- const raw = req.headers['openai-ephemeral-user-id'];
+ const raw = req.headers['openai-ephemeral-user-id'] ?? req.headers['x-session-id'];
if (Array.isArray(raw)) return raw[0] ?? null;
- return raw ?? null;
+ return (raw as string | undefined) ?? null;
}
diff --git a/tools/haip-connect-gpt/src/events.ts b/tools/haip-connect-gpt/src/events.ts
index 43730832..092f5702 100644
--- a/tools/haip-connect-gpt/src/events.ts
+++ b/tools/haip-connect-gpt/src/events.ts
@@ -14,6 +14,7 @@ import postgres from 'postgres';
export interface ToolCallLog {
tool: string;
sessionId: string | null;
+ propertyId?: string | null;
request: unknown;
response: unknown;
status: 'ok' | 'error';
@@ -40,12 +41,13 @@ export function isLoggingEnabled(): boolean {
export async function logToolCall(entry: ToolCallLog): Promise {
if (!sql) return;
try {
+ // property_id column is optional on older haip_tool_calls tables — stored in request JSON too.
await sql`
insert into haip_tool_calls (tool, session_id, request, response, status, error, latency_ms)
values (
${entry.tool},
${entry.sessionId},
- ${sql.json(entry.request as never)},
+ ${sql.json({ ...(entry.request as object), _propertyId: entry.propertyId ?? null } as never)},
${sql.json(entry.response as never)},
${entry.status},
${entry.error ?? null},
diff --git a/tools/haip-connect-gpt/src/haip-connect-adapter.ts b/tools/haip-connect-gpt/src/haip-connect-adapter.ts
index 19c33d63..d09a86e1 100644
--- a/tools/haip-connect-gpt/src/haip-connect-adapter.ts
+++ b/tools/haip-connect-gpt/src/haip-connect-adapter.ts
@@ -62,8 +62,12 @@ export interface BookInput {
adults: number;
children?: number;
specialRequests?: string;
- paymentMethod?: 'pay_at_property' | 'prepaid' | 'virtual_card';
- paymentToken?: string;
+ paymentMethod?: 'pay_at_property' | 'hosted_checkout';
+ /** Required for hosted_checkout — stable per guest intent. */
+ idempotencyKey?: string;
+ clientPlatform?: 'chatgpt' | 'claude' | 'gemini' | 'ligare';
+ agentId?: string;
+ externalReference?: string;
}
export interface ModifyInput {
@@ -134,7 +138,29 @@ export class HaipConnectAdapter {
}
createReservation(input: BookInput): Promise {
- return this.call('POST', '/api/v1/connect/book', input);
+ const body = {
+ ...input,
+ paymentMethod: input.paymentMethod ?? 'hosted_checkout',
+ agentId: input.agentId ?? 'LIGARE',
+ clientPlatform: input.clientPlatform ?? 'ligare',
+ };
+ // Never forward card tokens from an LLM client.
+ delete (body as { paymentToken?: string }).paymentToken;
+ return this.call('POST', '/api/v1/connect/book', body);
+ }
+
+ confirmPayment(confirmationNumber: string): Promise {
+ return this.call(
+ 'POST',
+ `/api/v1/connect/bookings/${encodeURIComponent(confirmationNumber)}/confirm-payment`,
+ );
+ }
+
+ getPaymentStatus(confirmationNumber: string): Promise {
+ return this.call(
+ 'GET',
+ `/api/v1/connect/bookings/${encodeURIComponent(confirmationNumber)}/payment-status`,
+ );
}
getReservation(confirmationNumber: string): Promise {
diff --git a/tools/haip-connect-gpt/src/mcp.ts b/tools/haip-connect-gpt/src/mcp.ts
new file mode 100644
index 00000000..85eba3bf
--- /dev/null
+++ b/tools/haip-connect-gpt/src/mcp.ts
@@ -0,0 +1,241 @@
+/**
+ * Minimal Streamable-HTTP-friendly MCP JSON-RPC surface for Claude / Gemini.
+ * Supports initialize, tools/list, tools/call.
+ */
+
+import type { BookInput, ModifyInput, SearchInput } from './haip-connect-adapter.js';
+
+export interface McpToolHandlers {
+ searchHotels: (input: SearchInput) => Promise;
+ getProperty: (propertyId: string) => Promise;
+ prepareBooking: (input: BookInput, idempotencyKey?: string) => Promise;
+ confirmPayment: (confirmationNumber: string) => Promise;
+ getReservation: (confirmationNumber: string) => Promise;
+ modifyReservation: (confirmationNumber: string, body: ModifyInput) => Promise;
+ cancelReservation: (confirmationNumber: string, reason?: string) => Promise;
+}
+
+const TOOL_DEFS = [
+ {
+ name: 'searchHotels',
+ description: 'Search Ligare-enabled hotels with live availability and tax-inclusive rates.',
+ inputSchema: {
+ type: 'object',
+ required: ['checkIn', 'checkOut'],
+ properties: {
+ city: { type: 'string' },
+ propertyId: { type: 'string' },
+ checkIn: { type: 'string' },
+ checkOut: { type: 'string' },
+ adults: { type: 'number' },
+ children: { type: 'number' },
+ },
+ },
+ },
+ {
+ name: 'getProperty',
+ description: 'Get property detail (404 if not opted into Ligare).',
+ inputSchema: {
+ type: 'object',
+ required: ['propertyId'],
+ properties: { propertyId: { type: 'string' } },
+ },
+ },
+ {
+ name: 'prepareBooking',
+ description:
+ 'Create a pending booking and return paymentUrl + paymentMode. Requires guestEmail and idempotencyKey. Never send card data.',
+ inputSchema: {
+ type: 'object',
+ required: [
+ 'propertyId',
+ 'roomTypeId',
+ 'ratePlanId',
+ 'checkIn',
+ 'checkOut',
+ 'guestFirstName',
+ 'guestLastName',
+ 'guestEmail',
+ 'adults',
+ 'idempotencyKey',
+ ],
+ properties: {
+ propertyId: { type: 'string' },
+ roomTypeId: { type: 'string' },
+ ratePlanId: { type: 'string' },
+ checkIn: { type: 'string' },
+ checkOut: { type: 'string' },
+ guestFirstName: { type: 'string' },
+ guestLastName: { type: 'string' },
+ guestEmail: { type: 'string' },
+ adults: { type: 'number' },
+ children: { type: 'number' },
+ idempotencyKey: { type: 'string' },
+ clientPlatform: { type: 'string', enum: ['chatgpt', 'claude', 'gemini', 'ligare'] },
+ specialRequests: { type: 'string' },
+ },
+ },
+ },
+ {
+ name: 'confirmPayment',
+ description:
+ 'Reconcile payment with the hotel PSP and confirm the reservation. Call after the guest pays via paymentUrl. Idempotent.',
+ inputSchema: {
+ type: 'object',
+ required: ['confirmationNumber'],
+ properties: { confirmationNumber: { type: 'string' } },
+ },
+ },
+ {
+ name: 'getReservation',
+ description: 'Look up a reservation by confirmation number.',
+ inputSchema: {
+ type: 'object',
+ required: ['confirmationNumber'],
+ properties: { confirmationNumber: { type: 'string' } },
+ },
+ },
+ {
+ name: 'modifyReservation',
+ description: 'Modify an existing reservation (send only changed fields).',
+ inputSchema: {
+ type: 'object',
+ required: ['confirmationNumber'],
+ properties: {
+ confirmationNumber: { type: 'string' },
+ guestFirstName: { type: 'string' },
+ guestLastName: { type: 'string' },
+ specialRequests: { type: 'string' },
+ adults: { type: 'number' },
+ children: { type: 'number' },
+ checkIn: { type: 'string' },
+ checkOut: { type: 'string' },
+ roomTypeId: { type: 'string' },
+ ratePlanId: { type: 'string' },
+ },
+ },
+ },
+ {
+ name: 'cancelReservation',
+ description: 'Cancel a reservation. Confirm any penalty with the guest first.',
+ inputSchema: {
+ type: 'object',
+ required: ['confirmationNumber'],
+ properties: {
+ confirmationNumber: { type: 'string' },
+ reason: { type: 'string' },
+ },
+ },
+ },
+] as const;
+
+export function listMcpTools() {
+ return TOOL_DEFS;
+}
+
+export async function handleMcpRequest(
+ body: unknown,
+ handlers: McpToolHandlers,
+): Promise {
+ const msg = body as {
+ jsonrpc?: string;
+ id?: string | number | null;
+ method?: string;
+ params?: Record;
+ };
+
+ const id = msg?.id ?? null;
+
+ if (msg?.method === 'initialize') {
+ return {
+ jsonrpc: '2.0',
+ id,
+ result: {
+ protocolVersion: '2024-11-05',
+ capabilities: { tools: {} },
+ serverInfo: { name: 'ligare-hotels', version: '1.0.0' },
+ },
+ };
+ }
+
+ if (msg?.method === 'tools/list' || msg?.method === 'list_tools') {
+ return {
+ jsonrpc: '2.0',
+ id,
+ result: { tools: TOOL_DEFS },
+ };
+ }
+
+ if (msg?.method === 'tools/call' || msg?.method === 'call_tool') {
+ const name = String(msg.params?.['name'] ?? '');
+ const args = (msg.params?.['arguments'] ?? {}) as Record;
+ try {
+ const result = await dispatchTool(name, args, handlers);
+ return {
+ jsonrpc: '2.0',
+ id,
+ result: {
+ content: [{ type: 'text', text: JSON.stringify(result) }],
+ structuredContent: result,
+ },
+ };
+ } catch (err) {
+ return {
+ jsonrpc: '2.0',
+ id,
+ error: {
+ code: -32000,
+ message: (err as Error).message,
+ },
+ };
+ }
+ }
+
+ if (msg?.method === 'notifications/initialized' || msg?.method === 'ping') {
+ return { jsonrpc: '2.0', id, result: {} };
+ }
+
+ return {
+ jsonrpc: '2.0',
+ id,
+ error: { code: -32601, message: `Method not found: ${msg?.method}` },
+ };
+}
+
+async function dispatchTool(
+ name: string,
+ args: Record,
+ handlers: McpToolHandlers,
+): Promise {
+ switch (name) {
+ case 'searchHotels':
+ return handlers.searchHotels(args as unknown as SearchInput);
+ case 'getProperty':
+ return handlers.getProperty(String(args['propertyId']));
+ case 'prepareBooking': {
+ const { idempotencyKey, ...rest } = args;
+ return handlers.prepareBooking(
+ rest as unknown as BookInput,
+ idempotencyKey ? String(idempotencyKey) : undefined,
+ );
+ }
+ case 'confirmPayment':
+ return handlers.confirmPayment(String(args['confirmationNumber']));
+ case 'getReservation':
+ return handlers.getReservation(String(args['confirmationNumber']));
+ case 'modifyReservation': {
+ const { confirmationNumber, ...changes } = args;
+ return handlers.modifyReservation(
+ String(confirmationNumber),
+ changes as unknown as ModifyInput,
+ );
+ }
+ case 'cancelReservation':
+ return handlers.cancelReservation(
+ String(args['confirmationNumber']),
+ args['reason'] ? String(args['reason']) : undefined,
+ );
+ default:
+ throw new Error(`Unknown tool: ${name}`);
+ }
+}
diff --git a/tools/haip-connect-gpt/src/openapi.test.ts b/tools/haip-connect-gpt/src/openapi.test.ts
new file mode 100644
index 00000000..123b177e
--- /dev/null
+++ b/tools/haip-connect-gpt/src/openapi.test.ts
@@ -0,0 +1,29 @@
+import { describe, it, expect } from 'vitest';
+import { buildOpenApiSpec } from './openapi.js';
+
+describe('OpenAPI schema', () => {
+ const spec = buildOpenApiSpec('https://gateway.example') as any;
+
+ it('forbids card fields on prepareBooking', () => {
+ const props = spec.paths['/reservations'].post.requestBody.content['application/json'].schema.properties;
+ expect(props.paymentToken).toBeUndefined();
+ expect(props.cardNumber).toBeUndefined();
+ expect(props.paymentMethod.enum).toEqual(['hosted_checkout', 'pay_at_property']);
+ expect(props.idempotencyKey).toBeTruthy();
+ expect(props.guestEmail).toBeTruthy();
+ });
+
+ it('exposes confirmPayment and paymentUrl/paymentMode', () => {
+ expect(spec.paths['/reservations/{confirmationNumber}/confirm-payment'].post.operationId).toBe(
+ 'confirmPayment',
+ );
+ const resProps =
+ spec.paths['/reservations'].post.responses['201'].content['application/json'].schema.properties;
+ expect(resProps.paymentUrl).toBeTruthy();
+ expect(resProps.paymentMode.enum).toEqual(['charge', 'authorize', 'guarantee']);
+ });
+
+ it('uses Ligare Hotels branding', () => {
+ expect(String(spec.info.title)).toMatch(/Ligare/i);
+ });
+});
diff --git a/tools/haip-connect-gpt/src/openapi.ts b/tools/haip-connect-gpt/src/openapi.ts
index 59629361..49ac07fd 100644
--- a/tools/haip-connect-gpt/src/openapi.ts
+++ b/tools/haip-connect-gpt/src/openapi.ts
@@ -77,9 +77,9 @@ export function buildOpenApiSpec(publicBaseUrl: string): Record
return {
openapi: '3.1.0',
info: {
- title: 'Telivity HAIP — Hotel Booking',
+ title: 'Ligare Hotels — Book in ChatGPT, Claude, Gemini',
description:
- 'Search hotels, price rooms, and create, look up, modify, or cancel reservations through the HAIP (Hotel AI Platform) Connect API. All prices are guest-facing selling prices.',
+ 'Search opted-in hotels, prepare a booking with hosted payment (no card data), confirm payment, then look up / modify / cancel. Selling prices are tax-inclusive.',
version: '1.0.0',
contact: { name: 'Telivity', url: server },
},
@@ -177,10 +177,10 @@ export function buildOpenApiSpec(publicBaseUrl: string): Record
},
'/reservations': {
post: {
- operationId: 'createReservation',
- summary: 'Create (book) a reservation',
+ operationId: 'prepareBooking',
+ summary: 'Prepare a reservation (hosted payment)',
description:
- 'Book a specific room type and rate plan returned by searchHotels. Returns a confirmationNumber used to look up, modify, or cancel the booking. Always confirm the cancellation policy and total selling price with the guest before booking.',
+ 'Create a pending booking and return paymentUrl + paymentMode. Guest pays on the hotel PSP hosted page — never send card data. Then call confirmPayment. Requires guestEmail and idempotencyKey.',
requestBody: {
required: true,
content: {
@@ -195,7 +195,9 @@ export function buildOpenApiSpec(publicBaseUrl: string): Record
'checkOut',
'guestFirstName',
'guestLastName',
+ 'guestEmail',
'adults',
+ 'idempotencyKey',
],
properties: {
propertyId: { type: 'string', format: 'uuid' },
@@ -211,9 +213,18 @@ export function buildOpenApiSpec(publicBaseUrl: string): Record
adults: { type: 'integer', minimum: 1 },
children: { type: 'integer', minimum: 0, default: 0 },
specialRequests: { type: 'string' },
+ idempotencyKey: {
+ type: 'string',
+ description: 'Stable key per guest booking intent (also send Idempotency-Key header).',
+ },
+ clientPlatform: {
+ type: 'string',
+ enum: ['chatgpt', 'claude', 'gemini', 'ligare'],
+ },
paymentMethod: {
type: 'string',
- enum: ['pay_at_property', 'prepaid', 'virtual_card'],
+ enum: ['hosted_checkout', 'pay_at_property'],
+ default: 'hosted_checkout',
},
},
},
@@ -232,7 +243,11 @@ export function buildOpenApiSpec(publicBaseUrl: string): Record
confirmationNumber: { type: 'string' },
status: { type: 'string' },
totalAmount: { type: 'number' },
+ taxAmount: { type: 'number' },
currencyCode: { type: 'string' },
+ paymentUrl: { type: 'string' },
+ paymentMode: { type: 'string', enum: ['charge', 'authorize', 'guarantee'] },
+ paymentStatus: { type: 'string' },
cancellationPolicy,
},
},
@@ -242,6 +257,21 @@ export function buildOpenApiSpec(publicBaseUrl: string): Record
},
},
},
+ '/reservations/{confirmationNumber}/confirm-payment': {
+ post: {
+ operationId: 'confirmPayment',
+ summary: 'Confirm payment after hosted checkout',
+ description:
+ 'Reconcile with the hotel PSP and confirm the reservation. Idempotent — safe to poll. Never invent a confirmation; only succeed when this returns paid/confirmed.',
+ parameters: [confirmationParam()],
+ responses: {
+ '200': {
+ description: 'Payment status after reconcile.',
+ content: { 'application/json': { schema: { type: 'object', additionalProperties: true } } },
+ },
+ },
+ },
+ },
'/reservations/{confirmationNumber}': {
get: {
operationId: 'getReservation',
diff --git a/tools/haip-connect-gpt/src/pages.ts b/tools/haip-connect-gpt/src/pages.ts
index 90a0d026..acb776b8 100644
--- a/tools/haip-connect-gpt/src/pages.ts
+++ b/tools/haip-connect-gpt/src/pages.ts
@@ -9,7 +9,7 @@ export const indexHtml = `
- Telivity HAIP — Hotel Booking Gateway
+ Ligare Hotels — AI Booking Gateway
-
Telivity HAIP
-
ChatGPT gateway for the HAIP (Hotel AI Platform) Connect API.
+
Ligare Hotels
+
ChatGPT Actions + Claude/Gemini MCP gateway for opted-in HAIP properties.
- This service exposes hotel search and booking as a ChatGPT Action. It proxies the
+ This service exposes hotel search and booking to ChatGPT, Claude, and Gemini. It proxies the
HAIP Connect API and returns guest-facing selling prices only.
-
/openapi.json — the OpenAPI 3.1 spec to import as a ChatGPT Action