Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
<img src="https://img.shields.io/badge/NestJS-framework-E0234E?logo=nestjs&logoColor=white" alt="NestJS" />
<img src="https://img.shields.io/badge/PostgreSQL-database-4169E1?logo=postgresql&logoColor=white" alt="PostgreSQL" />
<img src="https://img.shields.io/badge/License-Apache%202.0-blue" alt="Apache 2.0 License" />
<img src="https://img.shields.io/badge/Tests-2599%20passing-brightgreen" alt="2599 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
<img src="https://img.shields.io/badge/Tests-2602%20passing-brightgreen" alt="2602 Tests Passing" /> <img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
</p>

<p align="center">
Expand Down Expand Up @@ -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 (2602 passing tests across 303 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 |
Expand Down Expand Up @@ -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: 2602 test cases across 303 files (skipped excluded)

# API tests only
pnpm --filter @telivityhaip/api test
Expand Down Expand Up @@ -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 (2602 passing, 303 files with passes; skipped excluded)
pnpm lint # ESLint
```

Expand Down
150 changes: 129 additions & 21 deletions apps/api/src/modules/connect/connect-booking.service.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -67,35 +127,43 @@ 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)
.values({
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({
Expand All @@ -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;
}
Expand All @@ -139,34 +226,55 @@ export class ConnectBookingService {
{
confirmationNumber,
agentId: dto.agentId,
externalReference: dto.externalReference,
externalReference: externalRef,
totalAmount,
taxAmount,
paymentMode,
},
dto.propertyId,
);

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).
*/
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/modules/connect/connect-payment.helpers.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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);
}
}
34 changes: 34 additions & 0 deletions apps/api/src/modules/connect/connect-payment.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading