From f75a0990ab118f43b56ce9b987c2db7e53a16385 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 25 Oct 2026 06:11:04 +0000 Subject: [PATCH 1/2] Add Ligare Hotels Connect hosted payment and multi-LLM gateway. Tax-inclusive Connect totals, connect_payment_sessions with charge/authorize/guarantee modes, idempotent confirm and PSP reconcile, gateway allow-list with prepareBooking/confirmPayment, and MCP/OpenAPI for ChatGPT, Claude, and Gemini. Co-authored-by: telivity-otaip --- README.md | 8 +- .../connect/connect-booking.service.ts | 150 ++++- .../connect/connect-payment.helpers.ts | 31 + .../connect/connect-payment.service.spec.ts | 34 ++ .../connect/connect-payment.service.ts | 561 ++++++++++++++++++ .../connect/connect.controller.spec.ts | 8 +- .../src/modules/connect/connect.controller.ts | 32 + .../api/src/modules/connect/connect.module.ts | 23 +- .../src/modules/connect/dto/agent-book.dto.ts | 36 +- ...nnect-checkout-stripe-handler.interface.ts | 10 + .../payment/stripe-webhook.controller.ts | 26 + docs/test-stats.json | 6 +- .../0029_connect_payment_sessions.sql | 43 ++ packages/database/src/schema/connect.ts | 39 ++ packages/database/src/schema/index.ts | 5 + tools/haip-connect-gpt/.env.example | 28 +- tools/haip-connect-gpt/CHATGPT-GPT.md | 70 +-- tools/haip-connect-gpt/LIGARE-HOTELS.md | 73 +++ tools/haip-connect-gpt/package-lock.json | 39 -- tools/haip-connect-gpt/scripts/e2e-smoke.mjs | 92 +++ tools/haip-connect-gpt/src/allow-list.test.ts | 42 ++ tools/haip-connect-gpt/src/allow-list.ts | 137 +++++ tools/haip-connect-gpt/src/app.auth.test.ts | 3 +- tools/haip-connect-gpt/src/app.ts | 201 +++++-- tools/haip-connect-gpt/src/events.ts | 4 +- .../src/haip-connect-adapter.ts | 32 +- tools/haip-connect-gpt/src/mcp.ts | 241 ++++++++ tools/haip-connect-gpt/src/openapi.test.ts | 29 + tools/haip-connect-gpt/src/openapi.ts | 42 +- tools/haip-connect-gpt/src/pages.ts | 17 +- 30 files changed, 1845 insertions(+), 217 deletions(-) create mode 100644 apps/api/src/modules/connect/connect-payment.helpers.ts create mode 100644 apps/api/src/modules/connect/connect-payment.service.spec.ts create mode 100644 apps/api/src/modules/connect/connect-payment.service.ts create mode 100644 apps/api/src/modules/payment/connect-checkout-stripe-handler.interface.ts create mode 100644 packages/database/src/migrations/0029_connect_payment_sessions.sql create mode 100644 tools/haip-connect-gpt/LIGARE-HOTELS.md create mode 100644 tools/haip-connect-gpt/scripts/e2e-smoke.mjs create mode 100644 tools/haip-connect-gpt/src/allow-list.test.ts create mode 100644 tools/haip-connect-gpt/src/allow-list.ts create mode 100644 tools/haip-connect-gpt/src/mcp.ts create mode 100644 tools/haip-connect-gpt/src/openapi.test.ts diff --git a/README.md b/README.md index 82533296..6d011553 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License - 2599 Tests Passing 12 AI Agents + 2617 Tests Passing 12 AI Agents

@@ -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.

Available operations: searchHotels, getProperty, - createReservation, getReservation, + prepareBooking, confirmPayment, getReservation, modifyReservation, cancelReservation.

© Telivity. HAIP is an open-source, API-first hotel PMS.
@@ -48,7 +49,7 @@ export const privacyHtml = ` - Privacy — Telivity HAIP + Privacy — Ligare Hotels