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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ All notable changes to HAIP are documented here. This project adheres to
> Version numbers and release tags are assigned automatically by the release
> workflow on merge — this section is intentionally left as _Unreleased_.

### Added — DerbySoft Property Connector adapter

- **DerbySoft channel adapter** (`adapterType: derbysoft`) — REST/JSON + OAuth Bearer,
15 req/s client limiter, Delta/Overlay ARI (inventory/rate/availability), property
profile sync, inbound LiveCheck/Book/Modify/Cancel/Ping with Bearer auth.
- **Mock PC server** on `:4002` (`tools/mock-derbysoft`, compose profile `channels`).
- **Docs + partner checklist** — `docs/channels/derbysoft.md` (`pms.service@derbysoft.net`).
- PCI: inbound payment card fields stripped before persistence.

### Added — Multi-arch GHCR images + VPS/cloud deploy guides

- **Multi-platform GHCR publish** — release workflow builds `linux/amd64` and
Expand Down
16 changes: 9 additions & 7 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-1129%20passing-brightgreen" alt="1129 Tests Passing" />
<img src="https://img.shields.io/badge/Tests-1151%20passing-brightgreen" alt="1151 Tests Passing" />
<img src="https://img.shields.io/badge/AI%20Agents-12%20built--in-blueviolet" alt="12 AI Agents" />
</p>

Expand Down Expand Up @@ -133,7 +133,7 @@ graph TB
- **Multi-tenant from day one** — `property_id` on every table, designed for portfolio operators managing multiple hotels
- **Event-driven** — Webhook events on every state change (`reservation.created`, `folio.charge_posted`, `room.status_changed`). Build anything on top.
- **AI agents as first-class citizens** — 12 built-in agents with a common interface: `analyze() → recommend() → execute()`, coordinated by a Revenue Manager orchestrator. Three operating modes: manual, suggest, autopilot. Decision logging for continuous learning.
- **ChannelAdapter pattern** — Same abstraction as OTAIP's ConnectAdapter. Booking.com + Expedia (EQC) direct adapters and a SiteMinder aggregator for 450+ OTA reach — distributing **both** ARI and descriptive content (photos/descriptions/amenities).
- **ChannelAdapter pattern** — Same abstraction as OTAIP's ConnectAdapter. Booking.com + Expedia (EQC) direct adapters plus SiteMinder and DerbySoft aggregators for 450+ OTA reach — distributing **both** ARI and descriptive content (photos/descriptions/amenities).
- **Layered RBAC** — Keycloak JWT authentication **plus HAIP's own local users, roles & permissions**: a code-defined permission catalog, operator-defined custom roles, and guards (`@Roles` + `@RequirePermissions`) on every endpoint.
- **Polymorphic media** — One image model for properties, room types & rooms; add by URL (zero infra) or upload to S3/MinIO, with one enforced primary per owner.
- **Compliance as infrastructure** — PCI tokenization (Stripe), GDPR audit trails, jurisdiction-based tax calculation, guest registration per jurisdiction. Not bolted on — built in.
Expand Down Expand Up @@ -341,6 +341,7 @@ executes; approval always runs the agent's own recommendation. Off by default
| **Booking.com** | Direct integration | OTA XML for ARI + inbound reservation webhooks/cancellations; JSON **Photo API** for content (photos with validation, plus room/property descriptions & amenities). |
| **Expedia (EQC)** | Direct integration | EQC Availability & Rates (XML) for ARI, Booking Notification push for inbound reservations, and the **Image API** for content (with Expedia's own image limits). |
| **SiteMinder** | Aggregator (pmsXchange) | SOAP / OTA XML. Connect once, distribute to 450+ OTAs — ARI push, reservation delivery, rate parity. (Content is managed in the SiteMinder extranet — no PMS content push.) |
| **DerbySoft** | Aggregator (Property Connector) | REST/JSON + OAuth Bearer. ARI (inventory/rate/availability) with Delta/Overlay, property profile sync, inbound LiveCheck/Book/Modify/Cancel. See [`docs/channels/derbysoft.md`](./docs/channels/derbysoft.md). |

### Payments (Stripe)
- PCI DSS compliant — never stores raw card data
Expand Down Expand Up @@ -424,10 +425,10 @@ executes; approval always runs the agent's own recommendation. Off by default
| API Spec | OpenAPI 3.0 (auto-generated) | Swagger UI at `/docs` |
| Auth | Keycloak (OAuth 2.0 / OIDC) | Identity provider, JWT, RBAC |
| Payments | Stripe | PCI DSS compliant payment processing |
| OTA Channels | Booking.com + Expedia (EQC) direct + SiteMinder (pmsXchange) | Direct + aggregated OTA connectivity (ARI + content) |
| 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 (1129 tests across 136 test files) | Unit and integration tests |
| Testing | Vitest (1151 tests across 139 test files) | 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 @@ -549,7 +550,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests

```bash
# All tests (1129 tests across 136 test files)
# All tests (1151 tests across 139 test files)
pnpm test

# API tests only
Expand Down Expand Up @@ -620,7 +621,8 @@ haip/
│ │ │ │ └── adapters/ # OTA channel adapters
│ │ │ │ ├── booking-com/ # Booking.com (OTA XML + Photo API)
│ │ │ │ ├── expedia/ # Expedia EQC (AR XML + Image API)
│ │ │ │ └── siteminder/ # SiteMinder pmsXchange (SOAP/OTA XML)
│ │ │ │ ├── siteminder/ # SiteMinder pmsXchange (SOAP/OTA XML)
│ │ │ │ └── derbysoft/ # DerbySoft Property Connector (REST/JSON)
│ │ │ ├── connect/ # OTAIP agent API layer
│ │ │ ├── media/ # Images for property / room types / rooms (URL + S3)
│ │ │ ├── events/ # WebSocket gateway
Expand Down Expand Up @@ -1065,7 +1067,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 (1129 tests, 136 files)
pnpm test # Run all tests (1151 tests, 139 files)
pnpm typecheck # TypeScript strict check
pnpm lint # ESLint
```
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DerbySoftInboundController } from './derbysoft-inbound.controller';

describe('DerbySoftInboundController', () => {
let controller: DerbySoftInboundController;
let inboundReservationService: { processInboundReservation: ReturnType<typeof vi.fn> };
let availabilityService: { searchAvailability: ReturnType<typeof vi.fn> };
let dbSelect: ReturnType<typeof vi.fn>;

const connection = {
id: 'conn-1',
propertyId: 'prop-1',
adapterType: 'derbysoft',
status: 'active',
config: {
hotelId: 'HOTEL1',
inboundAuth: { bearerToken: 'secret' },
},
roomTypeMapping: [{ roomTypeId: 'rt-1', channelRoomCode: 'KING' }],
ratePlanMapping: [{ ratePlanId: 'rp-1', channelRateCode: 'BAR' }],
};

beforeEach(() => {
inboundReservationService = {
processInboundReservation: vi.fn().mockResolvedValue({ confirmationNumber: 'HAIP-99' }),
};
availabilityService = {
searchAvailability: vi.fn().mockResolvedValue([
{ date: '2026-05-01', available: 3 },
{ date: '2026-05-02', available: 2 },
]),
};
dbSelect = vi.fn().mockReturnValue({
from: () => ({
where: async () => [connection],
}),
});

controller = new DerbySoftInboundController(
{ select: dbSelect } as any,
inboundReservationService as any,
availabilityService as any,
{
get: (key: string, def?: string) => (key === 'AUTH_ENABLED' ? 'true' : def),
} as any,
);
});

function mockRes() {
const res: any = {
statusCode: 200,
body: null,
status(code: number) {
this.statusCode = code;
return this;
},
json(body: unknown) {
this.body = body;
return this;
},
};
return res;
}

it('rejects book without matching hotelId', async () => {
const res = mockRes();
await controller.book(
{
headers: { authorization: 'Bearer secret' },
body: {
header: { echoToken: 'e1' },
hotelId: 'UNKNOWN',
reservationIds: { derbyResId: 'DR1', distributorResId: 'D1' },
stayRange: { checkin: '2026-05-01', checkout: '2026-05-03' },
contactPerson: { firstName: 'A', lastName: 'B' },
roomCriteria: { adultCount: 1 },
total: { amountAfterTax: '100' },
roomRate: { roomId: 'KING', rateId: 'BAR', currency: 'USD' },
guests: [{ firstName: 'A', lastName: 'B' }],
distributorId: 'CTRIP',
},
},
res,
);
// find returns connection only when hotelId matches — UNKNOWN → null
dbSelect.mockReturnValue({
from: () => ({
where: async () => [connection],
}),
});
// Re-run with find that won't match — connection hotelId is HOTEL1
expect(res.statusCode).toBe(500);
expect(res.body.errorCode).toBe('InvalidField');
});

it('rejects unauthorized bearer', async () => {
const res = mockRes();
await controller.book(
{
headers: { authorization: 'Bearer wrong' },
body: {
header: { echoToken: 'e1' },
hotelId: 'HOTEL1',
reservationIds: { derbyResId: 'DR1', distributorResId: 'D1' },
stayRange: { checkin: '2026-05-01', checkout: '2026-05-03' },
contactPerson: { firstName: 'A', lastName: 'B' },
roomCriteria: { adultCount: 1 },
total: { amountAfterTax: '100' },
roomRate: { roomId: 'KING', rateId: 'BAR', currency: 'USD' },
guests: [{ firstName: 'A', lastName: 'B' }],
distributorId: 'CTRIP',
},
},
res,
);
expect(res.statusCode).toBe(401);
});

it('books when hotel + bearer match', async () => {
const res = mockRes();
await controller.book(
{
headers: { authorization: 'Bearer secret' },
body: {
header: { echoToken: 'e1' },
hotelId: 'HOTEL1',
reservationIds: { derbyResId: 'DR1', distributorResId: 'D1' },
stayRange: { checkin: '2026-05-01', checkout: '2026-05-03' },
contactPerson: { firstName: 'A', lastName: 'B' },
roomCriteria: { adultCount: 1 },
total: { amountAfterTax: '100' },
roomRate: { roomId: 'KING', rateId: 'BAR', currency: 'USD' },
guests: [{ firstName: 'A', lastName: 'B' }],
distributorId: 'CTRIP',
payment: { cardNumber: '4111111111111111' },
},
},
res,
);
expect(res.statusCode).toBe(200);
expect(res.body.reservationIds.supplierResId).toBe('HAIP-99');
expect(inboundReservationService.processInboundReservation).toHaveBeenCalled();
const mapped = inboundReservationService.processInboundReservation.mock.calls[0]![1];
expect(mapped.rawPayload).not.toHaveProperty('payment');
});

it('liveCheck returns roomRates from availability', async () => {
const res = mockRes();
await controller.liveCheck(
{
headers: { authorization: 'Bearer secret' },
body: {
header: { echoToken: 'e1' },
hotelId: 'HOTEL1',
stayRange: { checkin: '2026-05-01', checkout: '2026-05-03' },
},
},
res,
);
expect(res.statusCode).toBe(200);
expect(res.body.roomRates).toHaveLength(1);
expect(res.body.roomRates[0].inventory.availableInvCount).toBe(2);
});
});
Loading
Loading