diff --git a/CHANGELOG.md b/CHANGELOG.md
index 68ff1f0..4b95cf9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index d6731c9..0fc4c50 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -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.
@@ -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
@@ -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 |
@@ -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
@@ -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
@@ -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
```
diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft-inbound.controller.spec.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft-inbound.controller.spec.ts
new file mode 100644
index 0000000..25594ef
--- /dev/null
+++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft-inbound.controller.spec.ts
@@ -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 };
+ let availabilityService: { searchAvailability: ReturnType };
+ let dbSelect: ReturnType;
+
+ 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);
+ });
+});
diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft-inbound.controller.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft-inbound.controller.ts
new file mode 100644
index 0000000..083deb4
--- /dev/null
+++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft-inbound.controller.ts
@@ -0,0 +1,284 @@
+import {
+ Controller,
+ Post,
+ Req,
+ Res,
+ Logger,
+ Inject,
+} from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { ApiTags, ApiOperation, ApiExcludeEndpoint } from '@nestjs/swagger';
+import { and, eq } from 'drizzle-orm';
+import { channelConnections } from '@telivityhaip/database';
+import { Public } from '../../../auth/public.decorator';
+import { DRIZZLE } from '../../../../database/database.module';
+import { InboundReservationService } from '../../inbound-reservation.service';
+import { AvailabilityService } from '../../../reservation/availability.service';
+import {
+ verifyBearerAuth,
+ getInboundAuth,
+ type InboundBearerAuth,
+} from '../inbound-auth.util';
+import {
+ mapDerbySoftReservationToHaip,
+ mapCancelToHaip,
+ buildBookResponse,
+ buildCancelResponse,
+ buildErrorResponse,
+ buildHeader,
+} from './derbysoft.mapper';
+
+/**
+ * DerbySoft Property Connector → PMS webhooks (PUSH/PUSH).
+ *
+ * Paths mirror the PC Integration API (Live Check / Book / Modify / Cancel / Ping).
+ * Auth: Bearer token against the resolved connection's `config.inboundAuth.bearerToken`.
+ * Routing: `hotelId` in body must match connection `config.hotelId`.
+ *
+ * PCI: payment card fields from Book/Modify are never persisted (stripped in mapper).
+ */
+@ApiTags('Channel Manager — DerbySoft')
+@Controller('channels/inbound/derbysoft')
+@Public()
+export class DerbySoftInboundController {
+ private readonly logger = new Logger(DerbySoftInboundController.name);
+
+ constructor(
+ @Inject(DRIZZLE) private readonly db: any,
+ private readonly inboundReservationService: InboundReservationService,
+ private readonly availabilityService: AvailabilityService,
+ private readonly configService: ConfigService,
+ ) {}
+
+ private get authEnabled(): boolean {
+ return this.configService.get('AUTH_ENABLED', 'true') !== 'false';
+ }
+
+ private isAuthorizedFor(connection: any, authHeader: string | undefined): boolean {
+ if (!this.authEnabled) return true;
+ const stored = getInboundAuth(connection.config);
+ return verifyBearerAuth(authHeader, stored);
+ }
+
+ private async findConnection(hotelId: string | undefined) {
+ if (!hotelId) return null;
+ const rows = await this.db
+ .select()
+ .from(channelConnections)
+ .where(
+ and(
+ eq(channelConnections.adapterType, 'derbysoft'),
+ eq(channelConnections.status, 'active'),
+ ),
+ );
+ return (
+ rows.find((c: any) => {
+ const cfg = (c.config ?? {}) as Record;
+ return cfg['hotelId'] === hotelId || cfg['hotelCode'] === hotelId;
+ }) ?? null
+ );
+ }
+
+ private async readJson(req: any): Promise | null> {
+ if (req.body && typeof req.body === 'object' && !Buffer.isBuffer(req.body)) {
+ return req.body as Record;
+ }
+ const chunks: Buffer[] = [];
+ for await (const chunk of req) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ const raw = Buffer.concat(chunks).toString('utf8');
+ if (!raw) return null;
+ try {
+ return JSON.parse(raw) as Record;
+ } catch {
+ return null;
+ }
+ }
+
+ private echoToken(body: Record | null): string | undefined {
+ const header = body?.['header'] as Record | undefined;
+ return header?.['echoToken'] != null ? String(header['echoToken']) : undefined;
+ }
+
+ @Post('ping')
+ @ApiOperation({ summary: 'DerbySoft Ping' })
+ @ApiExcludeEndpoint()
+ async ping(@Req() req: any, @Res() res: any) {
+ const body = (await this.readJson(req)) ?? {};
+ return res.status(200).json({
+ header: { ...buildHeader(this.echoToken(body)), timeStamp: new Date().toISOString() },
+ });
+ }
+
+ /**
+ * Live Check — real-time availability for a stay range.
+ * Returns product-level open/close based on HAIP AvailabilityService.
+ */
+ @Post('availability')
+ @ApiOperation({ summary: 'DerbySoft Live Check' })
+ @ApiExcludeEndpoint()
+ async liveCheck(@Req() req: any, @Res() res: any) {
+ const body = await this.readJson(req);
+ if (!body) {
+ return res.status(500).json(buildErrorResponse(undefined, 'InvalidField', 'Empty body'));
+ }
+
+ const hotelId = body['hotelId'] != null ? String(body['hotelId']) : undefined;
+ const connection = await this.findConnection(hotelId);
+ const authHeader = (req.headers?.['authorization'] ?? req.headers?.['Authorization']) as
+ | string
+ | undefined;
+
+ if (!connection) {
+ return res
+ .status(500)
+ .json(buildErrorResponse(this.echoToken(body), 'InvalidField', 'Unknown hotelId'));
+ }
+ if (!this.isAuthorizedFor(connection, authHeader)) {
+ return res.status(401).json(buildErrorResponse(this.echoToken(body), 'Unauthorized', 'Unauthorized'));
+ }
+
+ const stayRange = (body['stayRange'] ?? {}) as Record;
+ const checkin = String(stayRange['checkin'] ?? '');
+ const checkout = String(stayRange['checkout'] ?? '');
+ const roomMappings = (connection.roomTypeMapping ?? []) as Array<{
+ roomTypeId: string;
+ channelRoomCode: string;
+ }>;
+ const rateMappings = (connection.ratePlanMapping ?? []) as Array<{
+ ratePlanId: string;
+ channelRateCode: string;
+ }>;
+
+ const roomRates: Array> = [];
+ for (const room of roomMappings) {
+ try {
+ const avail = await this.availabilityService.searchAvailability(
+ connection.propertyId,
+ checkin,
+ checkout,
+ room.roomTypeId,
+ );
+ const minAvail = avail.length
+ ? Math.min(...avail.map((a: { available: number }) => a.available))
+ : 0;
+ for (const rate of rateMappings) {
+ roomRates.push({
+ roomId: room.channelRoomCode,
+ rateId: rate.channelRateCode,
+ inventory: { availableInvCount: Math.max(0, minAvail) },
+ status: minAvail > 0 ? 'Open' : 'Close',
+ });
+ }
+ } catch (err: any) {
+ this.logger.warn(`LiveCheck availability failed for ${room.channelRoomCode}: ${err?.message}`);
+ }
+ }
+
+ return res.status(200).json({
+ header: { ...buildHeader(this.echoToken(body)), timeStamp: new Date().toISOString() },
+ hotelId,
+ roomRates,
+ });
+ }
+
+ @Post('book')
+ @ApiOperation({ summary: 'DerbySoft Book' })
+ @ApiExcludeEndpoint()
+ async book(@Req() req: any, @Res() res: any) {
+ return this.handleReservation(req, res, 'new');
+ }
+
+ @Post('modify')
+ @ApiOperation({ summary: 'DerbySoft Modify' })
+ @ApiExcludeEndpoint()
+ async modify(@Req() req: any, @Res() res: any) {
+ return this.handleReservation(req, res, 'modified');
+ }
+
+ @Post('cancel')
+ @ApiOperation({ summary: 'DerbySoft Cancel' })
+ @ApiExcludeEndpoint()
+ async cancel(@Req() req: any, @Res() res: any) {
+ const body = await this.readJson(req);
+ if (!body) {
+ return res.status(500).json(buildErrorResponse(undefined, 'InvalidField', 'Empty body'));
+ }
+
+ const hotelId = body['hotelId'] != null ? String(body['hotelId']) : undefined;
+ const connection = await this.findConnection(hotelId);
+ const authHeader = (req.headers?.['authorization'] ?? req.headers?.['Authorization']) as
+ | string
+ | undefined;
+
+ if (!connection) {
+ return res
+ .status(500)
+ .json(buildErrorResponse(this.echoToken(body), 'InvalidField', 'Unknown hotelId'));
+ }
+ if (!this.isAuthorizedFor(connection, authHeader)) {
+ return res.status(401).json(buildErrorResponse(this.echoToken(body), 'Unauthorized', 'Unauthorized'));
+ }
+
+ try {
+ const reservation = mapCancelToHaip(body);
+ const result = await this.inboundReservationService.processInboundReservation(
+ connection.id,
+ reservation,
+ );
+ return res.status(200).json(buildCancelResponse(body, result.confirmationNumber));
+ } catch (err: any) {
+ this.logger.error(`DerbySoft cancel failed: ${err?.message}`);
+ return res
+ .status(500)
+ .json(buildErrorResponse(this.echoToken(body), 'BusinessError', err?.message ?? 'Cancel failed'));
+ }
+ }
+
+ private async handleReservation(
+ req: any,
+ res: any,
+ status: 'new' | 'modified',
+ ) {
+ const body = await this.readJson(req);
+ if (!body) {
+ return res.status(500).json(buildErrorResponse(undefined, 'InvalidField', 'Empty body'));
+ }
+
+ const hotelId = body['hotelId'] != null ? String(body['hotelId']) : undefined;
+ const connection = await this.findConnection(hotelId);
+ const authHeader = (req.headers?.['authorization'] ?? req.headers?.['Authorization']) as
+ | string
+ | undefined;
+
+ if (!connection) {
+ return res
+ .status(500)
+ .json(buildErrorResponse(this.echoToken(body), 'InvalidField', 'Unknown hotelId'));
+ }
+ if (!this.isAuthorizedFor(connection, authHeader)) {
+ return res.status(401).json(buildErrorResponse(this.echoToken(body), 'Unauthorized', 'Unauthorized'));
+ }
+
+ try {
+ const reservation = mapDerbySoftReservationToHaip(body, status);
+ const result = await this.inboundReservationService.processInboundReservation(
+ connection.id,
+ reservation,
+ );
+ return res.status(200).json(buildBookResponse(body, result.confirmationNumber));
+ } catch (err: any) {
+ this.logger.error(`DerbySoft ${status} failed: ${err?.message}`);
+ return res
+ .status(500)
+ .json(
+ buildErrorResponse(
+ this.echoToken(body),
+ 'BusinessError',
+ err?.message ?? `${status} failed`,
+ ),
+ );
+ }
+ }
+}
diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.adapter.spec.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.adapter.spec.ts
new file mode 100644
index 0000000..f1bf62f
--- /dev/null
+++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.adapter.spec.ts
@@ -0,0 +1,181 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { Test, TestingModule } from '@nestjs/testing';
+import { ConfigService } from '@nestjs/config';
+import { DerbySoftAdapter } from './derbysoft.adapter';
+
+const mockFetch = vi.fn();
+vi.stubGlobal('fetch', mockFetch);
+
+function jsonResponse(body: unknown, status = 200) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: { get: () => null },
+ json: async () => body,
+ text: async () => JSON.stringify(body),
+ };
+}
+
+describe('DerbySoftAdapter', () => {
+ let adapter: DerbySoftAdapter;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ DerbySoftAdapter,
+ {
+ provide: ConfigService,
+ useValue: {
+ get: (key: string, def?: string) => {
+ const config: Record = {
+ DERBYSOFT_HOTEL_ID: 'MOCK_DS_HOTEL',
+ DERBYSOFT_ACCOUNT_ID: 'haip_test',
+ DERBYSOFT_CLIENT_SECRET: 'test_password',
+ DERBYSOFT_TUNNEL_BASE_URL:
+ 'http://localhost:4002/pcapigateway/tunnel/{accountId}',
+ DERBYSOFT_PROFILE_BASE_URL:
+ 'http://localhost:4002/pcapigateway/profile/{accountId}',
+ DERBYSOFT_TOKEN_URL: 'http://localhost:4002/pcapigateway/account/token',
+ // Allow localhost in tests (assertSafeChannelEndpoint)
+ NODE_ENV: 'test',
+ CHANNEL_ALLOW_PRIVATE_ENDPOINTS: 'true',
+ };
+ return config[key] ?? def;
+ },
+ },
+ },
+ ],
+ }).compile();
+ adapter = module.get(DerbySoftAdapter);
+ });
+
+ function mockTokenThen(...responses: ReturnType[]) {
+ mockFetch
+ .mockResolvedValueOnce(jsonResponse({ accessToken: 'tok123', tokenType: 'Bearer' }))
+ .mockImplementation(async () => {
+ const next = responses.shift();
+ return next ?? jsonResponse({ header: {} });
+ });
+ }
+
+ it('has adapterType derbysoft', () => {
+ expect(adapter.adapterType).toBe('derbysoft');
+ });
+
+ it('pushAvailability obtains token and posts inventory', async () => {
+ mockTokenThen(jsonResponse({ header: { echoToken: 'e1' } }));
+
+ const result = await adapter.pushAvailability({
+ propertyId: 'p1',
+ channelConnectionId: 'c1',
+ connectionConfig: { CHANNEL_ALLOW_PRIVATE_ENDPOINTS: true },
+ items: [
+ { channelRoomCode: 'KING', date: '2026-04-01', available: 5, totalInventory: 10 },
+ ],
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.itemsSynced).toBe(1);
+ expect(mockFetch).toHaveBeenCalled();
+ const tokenCall = mockFetch.mock.calls[0]!;
+ expect(String(tokenCall[0])).toContain('/account/token');
+ expect(tokenCall[1].headers.Authorization).toMatch(/^Basic /);
+ const invCall = mockFetch.mock.calls[1]!;
+ expect(String(invCall[0])).toContain('/inventory');
+ expect(invCall[1].headers.Authorization).toBe('Bearer tok123');
+ const body = JSON.parse(invCall[1].body);
+ expect(body.roomId).toBe('KING');
+ expect(body.type).toBe('Delta');
+ });
+
+ it('pushRates posts Overlay when configured', async () => {
+ mockTokenThen(jsonResponse({ header: {} }));
+
+ const result = await adapter.pushRates({
+ propertyId: 'p1',
+ channelConnectionId: 'c1',
+ connectionConfig: { ariUpdateType: 'Overlay' },
+ items: [
+ {
+ channelRoomCode: 'KING',
+ channelRateCode: 'BAR',
+ date: '2026-04-01',
+ amount: 100,
+ currencyCode: 'USD',
+ },
+ ],
+ });
+
+ expect(result.success).toBe(true);
+ const body = JSON.parse(mockFetch.mock.calls[1]![1].body);
+ expect(body.type).toBe('Overlay');
+ expect(String(mockFetch.mock.calls[1]![0])).toContain('/rate');
+ });
+
+ it('pushRestrictions posts to availability endpoint', async () => {
+ mockTokenThen(jsonResponse({ header: {} }));
+
+ const result = await adapter.pushRestrictions({
+ propertyId: 'p1',
+ channelConnectionId: 'c1',
+ items: [
+ {
+ channelRoomCode: 'KING',
+ channelRateCode: 'BAR',
+ date: '2026-04-01',
+ stopSell: false,
+ closedToArrival: true,
+ closedToDeparture: false,
+ },
+ ],
+ });
+
+ expect(result.success).toBe(true);
+ expect(String(mockFetch.mock.calls[1]![0])).toContain('/availability');
+ });
+
+ it('syncProperty posts hotel and roomtype', async () => {
+ mockTokenThen(jsonResponse({ header: {} }), jsonResponse({ header: {} }));
+
+ const result = await adapter.syncProperty({
+ propertyId: 'p1',
+ channelConnectionId: 'c1',
+ property: {
+ name: 'Test Hotel',
+ images: [],
+ },
+ roomTypes: [{ channelRoomCode: 'KING', name: 'King', images: [] }],
+ });
+
+ expect(result.success).toBe(true);
+ expect(result.itemsSynced).toBe(2);
+ expect(String(mockFetch.mock.calls[1]![0])).toContain('/hotel');
+ expect(String(mockFetch.mock.calls[2]![0])).toContain('/roomtype');
+ });
+
+ it('pullReservations returns empty (push model)', async () => {
+ const result = await adapter.pullReservations({
+ propertyId: 'p1',
+ channelConnectionId: 'c1',
+ });
+ expect(result.reservations).toEqual([]);
+ });
+
+ it('testConnection succeeds when token works', async () => {
+ mockFetch.mockResolvedValueOnce(jsonResponse({ accessToken: 'tok', tokenType: 'Bearer' }));
+ const result = await adapter.testConnection({});
+ expect(result.connected).toBe(true);
+ });
+
+ it('confirmReservation posts resStatus', async () => {
+ mockTokenThen(jsonResponse({ header: {} }));
+ const result = await adapter.confirmReservation({
+ channelConnectionId: 'c1',
+ externalConfirmation: 'DR1',
+ pmsConfirmationNumber: 'HAIP-1',
+ });
+ expect(result.success).toBe(true);
+ expect(String(mockFetch.mock.calls[1]![0])).toContain('/resStatus');
+ });
+});
diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.adapter.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.adapter.ts
new file mode 100644
index 0000000..99faf0d
--- /dev/null
+++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.adapter.ts
@@ -0,0 +1,250 @@
+import { Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import type {
+ ChannelAdapter,
+ AvailabilityPushParams,
+ RatePushParams,
+ RestrictionPushParams,
+ ContentPushParams,
+ ReservationPullParams,
+ ConfirmReservationParams,
+ CancelReservationParams,
+ ChannelSyncResult,
+ ChannelReservationResult,
+} from '../../channel-adapter.interface';
+import { DEFAULT_DERBYSOFT_CONFIG, type DerbySoftConfig } from './derbysoft.config';
+import { DerbySoftClient } from './derbysoft.client';
+import {
+ mapAvailabilityToInventory,
+ mapRatesToDerbySoft,
+ mapRestrictionsToAvailability,
+ mapPropertyToHotelUpdate,
+ mapRoomTypesToUpdates,
+ buildHeader,
+ type AriUpdateType,
+} from './derbysoft.mapper';
+
+@Injectable()
+export class DerbySoftAdapter implements ChannelAdapter {
+ readonly adapterType = 'derbysoft';
+ private readonly logger = new Logger(DerbySoftAdapter.name);
+
+ constructor(private readonly configService: ConfigService) {}
+
+ private resolveConfig(connectionConfig?: Record): DerbySoftConfig {
+ const env = {
+ hotelId: this.configService.get('DERBYSOFT_HOTEL_ID', 'MOCK_DS_HOTEL'),
+ accountId: this.configService.get('DERBYSOFT_ACCOUNT_ID', 'haip_test'),
+ clientSecret: this.configService.get('DERBYSOFT_CLIENT_SECRET', 'test_password'),
+ tunnelBaseUrl: this.configService.get(
+ 'DERBYSOFT_TUNNEL_BASE_URL',
+ DEFAULT_DERBYSOFT_CONFIG.tunnelBaseUrl!,
+ ),
+ profileBaseUrl: this.configService.get(
+ 'DERBYSOFT_PROFILE_BASE_URL',
+ DEFAULT_DERBYSOFT_CONFIG.profileBaseUrl!,
+ ),
+ tokenUrl: this.configService.get(
+ 'DERBYSOFT_TOKEN_URL',
+ DEFAULT_DERBYSOFT_CONFIG.tokenUrl!,
+ ),
+ };
+
+ const cfg = { ...DEFAULT_DERBYSOFT_CONFIG, ...env, ...(connectionConfig ?? {}) } as DerbySoftConfig;
+
+ // Accept hotelId aliases used elsewhere in HAIP channel configs.
+ if (!cfg.hotelId && typeof connectionConfig?.['hotelCode'] === 'string') {
+ cfg.hotelId = connectionConfig['hotelCode'] as string;
+ }
+ if (!cfg.accountId && typeof connectionConfig?.['clientId'] === 'string') {
+ cfg.accountId = connectionConfig['clientId'] as string;
+ }
+
+ return cfg;
+ }
+
+ private ariType(config: DerbySoftConfig, override?: AriUpdateType): AriUpdateType {
+ return override ?? config.ariUpdateType ?? 'Delta';
+ }
+
+ private client(config: DerbySoftConfig): DerbySoftClient {
+ return new DerbySoftClient(config);
+ }
+
+ private async pushPayloads(
+ client: DerbySoftClient,
+ baseUrl: string,
+ path: string,
+ payloads: Array>,
+ itemLabel: string,
+ ): Promise {
+ const errors: Array<{ item: string; message: string }> = [];
+ let itemsSynced = 0;
+ const url = `${client.expandUrl(baseUrl).replace(/\/$/, '')}/${path}`;
+
+ for (const payload of payloads) {
+ const res = await client.postJson(url, payload);
+ if (res.ok) {
+ itemsSynced += 1;
+ } else {
+ const code = String(res.data['errorCode'] ?? res.status);
+ const msg = String(res.data['errorMessage'] ?? `HTTP ${res.status}`);
+ errors.push({ item: itemLabel, message: `[${code}] ${msg}` });
+ }
+ }
+
+ return { success: errors.length === 0, itemsSynced, errors };
+ }
+
+ /** HAIP availability → PC Update Inventory. */
+ async pushAvailability(params: AvailabilityPushParams): Promise {
+ const config = this.resolveConfig(params.connectionConfig);
+ const type = this.ariType(config, params.connectionConfig?.['ariUpdateType'] as AriUpdateType | undefined);
+ const payloads = mapAvailabilityToInventory(config.hotelId, params.items, type);
+ if (payloads.length === 0) return { success: true, itemsSynced: 0, errors: [] };
+ return this.pushPayloads(this.client(config), config.tunnelBaseUrl, 'inventory', payloads, 'inventory');
+ }
+
+ async pushRates(params: RatePushParams): Promise {
+ const config = this.resolveConfig(params.connectionConfig);
+ const type = this.ariType(config, params.connectionConfig?.['ariUpdateType'] as AriUpdateType | undefined);
+ const payloads = mapRatesToDerbySoft(config.hotelId, params.items, type);
+ if (payloads.length === 0) return { success: true, itemsSynced: 0, errors: [] };
+ return this.pushPayloads(this.client(config), config.tunnelBaseUrl, 'rate', payloads, 'rate');
+ }
+
+ /** HAIP restrictions → PC Update Availability (product-level). */
+ async pushRestrictions(params: RestrictionPushParams): Promise {
+ const config = this.resolveConfig(params.connectionConfig);
+ const type = this.ariType(config, params.connectionConfig?.['ariUpdateType'] as AriUpdateType | undefined);
+ const payloads = mapRestrictionsToAvailability(config.hotelId, params.items, type);
+ if (payloads.length === 0) return { success: true, itemsSynced: 0, errors: [] };
+ return this.pushPayloads(
+ this.client(config),
+ config.tunnelBaseUrl,
+ 'availability',
+ payloads,
+ 'availability',
+ );
+ }
+
+ /**
+ * Property + room-type profile sync via PC profile APIs.
+ * Photos are not pushed here (DerbySoft content images are channel-specific);
+ * descriptive hotel/room updates are sent.
+ */
+ async pushContent(params: ContentPushParams): Promise {
+ return this.syncProperty(params);
+ }
+
+ /**
+ * Explicit property sync (Update Hotel + Update RoomType).
+ * Used by POST /channels/connections/:id/sync-property.
+ */
+ async syncProperty(params: ContentPushParams): Promise {
+ const config = this.resolveConfig(params.connectionConfig);
+ const client = this.client(config);
+ const profileBase = client.expandUrl(config.profileBaseUrl).replace(/\/$/, '');
+ const errors: Array<{ item: string; message: string }> = [];
+ let itemsSynced = 0;
+
+ const hotelBody = mapPropertyToHotelUpdate(config.hotelId, params.property);
+ const hotelRes = await client.postJson(`${profileBase}/hotel`, hotelBody);
+ if (hotelRes.ok) itemsSynced += 1;
+ else {
+ errors.push({
+ item: 'hotel',
+ message: `[${hotelRes.data['errorCode'] ?? hotelRes.status}] ${hotelRes.data['errorMessage'] ?? 'failed'}`,
+ });
+ }
+
+ for (const roomBody of mapRoomTypesToUpdates(config.hotelId, params.roomTypes)) {
+ const roomRes = await client.postJson(`${profileBase}/roomtype`, roomBody);
+ if (roomRes.ok) itemsSynced += 1;
+ else {
+ errors.push({
+ item: `roomtype:${roomBody['roomId']}`,
+ message: `[${roomRes.data['errorCode'] ?? roomRes.status}] ${roomRes.data['errorMessage'] ?? 'failed'}`,
+ });
+ }
+ }
+
+ return { success: errors.length === 0, itemsSynced, errors };
+ }
+
+ /** Reservations are push (Book/Modify/Cancel webhooks) — no pull. */
+ async pullReservations(_params: ReservationPullParams): Promise {
+ return { success: true, reservations: [], errors: [] };
+ }
+
+ /** Update Reservation Status → PC /resStatus. */
+ async confirmReservation(params: ConfirmReservationParams): Promise {
+ const config = this.resolveConfig(params.connectionConfig);
+ const client = this.client(config);
+ const url = `${client.expandUrl(config.tunnelBaseUrl).replace(/\/$/, '')}/resStatus`;
+ const body = {
+ header: buildHeader(),
+ hotelId: config.hotelId,
+ reservationIds: {
+ derbyResId: params.externalConfirmation,
+ supplierResId: params.pmsConfirmationNumber,
+ },
+ status: 'Confirmed',
+ };
+ const res = await client.postJson(url, body);
+ if (!res.ok) {
+ return {
+ success: false,
+ itemsSynced: 0,
+ errors: [
+ {
+ item: 'resStatus',
+ message: `[${res.data['errorCode'] ?? res.status}] ${res.data['errorMessage'] ?? 'failed'}`,
+ },
+ ],
+ };
+ }
+ return { success: true, itemsSynced: 1, errors: [] };
+ }
+
+ async cancelReservation(params: CancelReservationParams): Promise {
+ const config = this.resolveConfig(params.connectionConfig);
+ const client = this.client(config);
+ const url = `${client.expandUrl(config.tunnelBaseUrl).replace(/\/$/, '')}/resStatus`;
+ const body = {
+ header: buildHeader(),
+ hotelId: config.hotelId,
+ reservationIds: {
+ derbyResId: params.externalConfirmation,
+ },
+ status: 'Cancelled',
+ reason: params.reason,
+ };
+ const res = await client.postJson(url, body);
+ if (!res.ok) {
+ return {
+ success: false,
+ itemsSynced: 0,
+ errors: [
+ {
+ item: 'resStatus',
+ message: `[${res.data['errorCode'] ?? res.status}] ${res.data['errorMessage'] ?? 'failed'}`,
+ },
+ ],
+ };
+ }
+ return { success: true, itemsSynced: 1, errors: [] };
+ }
+
+ async testConnection(config: Record): Promise<{ connected: boolean; message: string }> {
+ try {
+ const resolved = this.resolveConfig(config);
+ const client = this.client(resolved);
+ await client.getAccessToken();
+ return { connected: true, message: 'DerbySoft token obtained successfully' };
+ } catch (err: any) {
+ this.logger.warn(`DerbySoft testConnection failed: ${err?.message}`);
+ return { connected: false, message: err?.message ?? 'Connection test failed' };
+ }
+ }
+}
diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.client.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.client.ts
new file mode 100644
index 0000000..997e98a
--- /dev/null
+++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.client.ts
@@ -0,0 +1,157 @@
+import { Logger } from '@nestjs/common';
+import { assertSafeChannelEndpoint } from '../../../../common/security/url-guard';
+import {
+ DERBYSOFT_RATE_LIMIT_PER_SEC,
+ type DerbySoftConfig,
+} from './derbysoft.config';
+
+interface TokenCache {
+ accessToken: string;
+ /** Epoch ms when we should refresh (tokens last up to 90d; refresh early). */
+ expiresAtMs: number;
+}
+
+/**
+ * HTTP client for DerbySoft Property Connector with:
+ * - OAuth Bearer token (client credentials via Basic → /account/token)
+ * - 15 req/s token-bucket rate limiter
+ * - SSRF guard + redirect:manual
+ */
+export class DerbySoftClient {
+ private readonly logger = new Logger(DerbySoftClient.name);
+ private tokenCache: TokenCache | null = null;
+ private tokens = DERBYSOFT_RATE_LIMIT_PER_SEC;
+ private lastRefill = Date.now();
+
+ constructor(private readonly config: DerbySoftConfig) {}
+
+ expandUrl(template: string): string {
+ return template.replaceAll('{accountId}', encodeURIComponent(this.config.accountId));
+ }
+
+ private async acquireRateToken(): Promise {
+ const now = Date.now();
+ const elapsed = now - this.lastRefill;
+ if (elapsed >= 1000) {
+ this.tokens = DERBYSOFT_RATE_LIMIT_PER_SEC;
+ this.lastRefill = now;
+ }
+ if (this.tokens > 0) {
+ this.tokens -= 1;
+ return;
+ }
+ const waitMs = 1000 - (now - this.lastRefill) + 5;
+ await new Promise((r) => setTimeout(r, Math.max(waitMs, 5)));
+ this.tokens = DERBYSOFT_RATE_LIMIT_PER_SEC - 1;
+ this.lastRefill = Date.now();
+ }
+
+ async getAccessToken(): Promise {
+ if (this.tokenCache && Date.now() < this.tokenCache.expiresAtMs) {
+ return this.tokenCache.accessToken;
+ }
+
+ const tokenUrl = this.expandUrl(this.config.tokenUrl);
+ await assertSafeChannelEndpoint(tokenUrl);
+
+ const basic = Buffer.from(
+ `${this.config.accountId}:${this.config.clientSecret}`,
+ 'utf8',
+ ).toString('base64');
+
+ await this.acquireRateToken();
+ const res = await fetch(tokenUrl, {
+ method: 'POST',
+ headers: {
+ Authorization: `Basic ${basic}`,
+ 'Content-Type': 'application/json;charset=utf-8',
+ Accept: 'application/json',
+ },
+ body: '{}',
+ redirect: 'manual',
+ signal: AbortSignal.timeout(this.config.timeoutMs ?? 30_000),
+ });
+
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ throw new Error(`DerbySoft token request failed: HTTP ${res.status} ${text}`);
+ }
+
+ const json = (await res.json()) as { accessToken?: string; tokenType?: string };
+ if (!json.accessToken) {
+ throw new Error('DerbySoft token response missing accessToken');
+ }
+
+ // Refresh well before the vendor 90-day max (cache 12h for demos/tests).
+ this.tokenCache = {
+ accessToken: json.accessToken,
+ expiresAtMs: Date.now() + 12 * 60 * 60 * 1000,
+ };
+ return json.accessToken;
+ }
+
+ /** Invalidate cached token (e.g. after 401). */
+ clearToken(): void {
+ this.tokenCache = null;
+ }
+
+ async postJson(
+ url: string,
+ body: Record,
+ opts?: { retryOnUnauthorized?: boolean },
+ ): Promise<{ ok: boolean; status: number; data: Record }> {
+ await assertSafeChannelEndpoint(url);
+ const maxRetries = this.config.maxRetries ?? 3;
+ let lastError: Error | null = null;
+
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ try {
+ await this.acquireRateToken();
+ const token = await this.getAccessToken();
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json;charset=utf-8',
+ Accept: 'application/json',
+ },
+ body: JSON.stringify(body),
+ redirect: 'manual',
+ signal: AbortSignal.timeout(this.config.timeoutMs ?? 30_000),
+ });
+
+ if (res.status === 401 && opts?.retryOnUnauthorized !== false) {
+ this.clearToken();
+ if (attempt < maxRetries) continue;
+ }
+
+ if (res.status === 429) {
+ const retryAfter = Number(res.headers.get('retry-after') ?? '1');
+ await new Promise((r) => setTimeout(r, Math.max(retryAfter, 1) * 1000));
+ continue;
+ }
+
+ const data = (await res.json().catch(() => ({}))) as Record;
+ if (!res.ok || data['errorCode']) {
+ return { ok: false, status: res.status, data };
+ }
+ return { ok: true, status: res.status, data };
+ } catch (err: any) {
+ lastError = err;
+ this.logger.warn(`DerbySoft POST ${url} attempt ${attempt + 1} failed: ${err?.message}`);
+ if (attempt < maxRetries) {
+ await new Promise((r) => setTimeout(r, 200 * (attempt + 1)));
+ }
+ }
+ }
+
+ return {
+ ok: false,
+ status: 0,
+ data: {
+ errorCode: 'TransportError',
+ errorMessage: lastError?.message ?? 'Unknown transport error',
+ },
+ };
+ }
+}
diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.config.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.config.ts
new file mode 100644
index 0000000..51c2c95
--- /dev/null
+++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.config.ts
@@ -0,0 +1,62 @@
+/**
+ * Configuration for a DerbySoft Property Connector channel connection.
+ * Stored in channelConnections.config JSON.
+ *
+ * Vendor protocol: REST/JSON, OAuth2 client-credentials Bearer token,
+ * 15 req/s rate limit. See docs/channels/derbysoft.md.
+ */
+export interface DerbySoftConfig {
+ /** PMS hotel id registered with DerbySoft (maps to hotelId in PC API). */
+ hotelId: string;
+
+ /** Account / client id used as Basic auth username for token endpoint. */
+ accountId: string;
+
+ /** Client secret for token endpoint (Basic password). */
+ clientSecret: string;
+
+ /**
+ * Tunnel base URL (ARI + resStatus).
+ * Mock: http://localhost:4002/pcapigateway/tunnel/{accountId}
+ * Test: https://pcendpoint.derbysoft-test.com/pcapigateway/tunnel/{accountId}
+ */
+ tunnelBaseUrl: string;
+
+ /**
+ * Profile base URL (property / content / channel).
+ * Mock: http://localhost:4002/pcapigateway/profile/{accountId}
+ */
+ profileBaseUrl: string;
+
+ /**
+ * Token endpoint.
+ * Mock: http://localhost:4002/pcapigateway/account/token
+ */
+ tokenUrl: string;
+
+ /**
+ * ARI update mode — Delta (incremental) or Overlay (full refresh).
+ * Default Delta; Overlay used for full flush / launch.
+ */
+ ariUpdateType?: 'Delta' | 'Overlay';
+
+ /** Request timeout in milliseconds (default: 30000). */
+ timeoutMs?: number;
+
+ /** Max retry attempts for failed requests (default: 3). */
+ maxRetries?: number;
+}
+
+export const DEFAULT_DERBYSOFT_CONFIG: Partial = {
+ tunnelBaseUrl: 'http://localhost:4002/pcapigateway/tunnel/{accountId}',
+ profileBaseUrl: 'http://localhost:4002/pcapigateway/profile/{accountId}',
+ tokenUrl: 'http://localhost:4002/pcapigateway/account/token',
+ ariUpdateType: 'Delta',
+ timeoutMs: 30_000,
+ maxRetries: 3,
+};
+
+/** DerbySoft Property Connector max sustained request rate. */
+export const DERBYSOFT_RATE_LIMIT_PER_SEC = 15;
+
+export const DERBYSOFT_MESSAGE_VERSION = '0.1';
diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.mapper.spec.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.mapper.spec.ts
new file mode 100644
index 0000000..2ccfe1d
--- /dev/null
+++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.mapper.spec.ts
@@ -0,0 +1,141 @@
+import { describe, it, expect } from 'vitest';
+import {
+ collapseDateRanges,
+ mapAvailabilityToInventory,
+ mapRatesToDerbySoft,
+ mapRestrictionsToAvailability,
+ mapDerbySoftReservationToHaip,
+ mapCancelToHaip,
+} from './derbysoft.mapper';
+
+describe('derbysoft.mapper', () => {
+ describe('collapseDateRanges', () => {
+ it('merges consecutive same-value dates', () => {
+ const ranges = collapseDateRanges(
+ [
+ { date: '2026-04-01', available: 5 },
+ { date: '2026-04-02', available: 5 },
+ { date: '2026-04-03', available: 3 },
+ ],
+ (i) => String(i.available),
+ );
+ expect(ranges).toHaveLength(2);
+ expect(ranges[0]).toMatchObject({ startDate: '2026-04-01', endDate: '2026-04-02' });
+ expect(ranges[1]).toMatchObject({ startDate: '2026-04-03', endDate: '2026-04-03' });
+ });
+ });
+
+ describe('mapAvailabilityToInventory', () => {
+ it('builds inventory payloads per room', () => {
+ const payloads = mapAvailabilityToInventory(
+ 'HOTEL1',
+ [
+ { channelRoomCode: 'KING', date: '2026-04-01', available: 4, totalInventory: 10 },
+ { channelRoomCode: 'KING', date: '2026-04-02', available: 4, totalInventory: 10 },
+ ],
+ 'Delta',
+ );
+ expect(payloads).toHaveLength(1);
+ expect(payloads[0]).toMatchObject({
+ hotelId: 'HOTEL1',
+ roomId: 'KING',
+ type: 'Delta',
+ });
+ expect((payloads[0]!['inventories'] as unknown[]).length).toBe(1);
+ });
+ });
+
+ describe('mapRatesToDerbySoft', () => {
+ it('includes adult occupancy amounts', () => {
+ const payloads = mapRatesToDerbySoft(
+ 'HOTEL1',
+ [
+ {
+ channelRoomCode: 'KING',
+ channelRateCode: 'BAR',
+ date: '2026-04-01',
+ amount: 120,
+ currencyCode: 'USD',
+ },
+ ],
+ 'Overlay',
+ );
+ expect(payloads[0]).toMatchObject({ type: 'Overlay', rateId: 'BAR', currencyCode: 'USD' });
+ const rates = payloads[0]!['rates'] as Array<{ baseByGuestAmts: unknown[] }>;
+ expect(rates[0]!.baseByGuestAmts).toHaveLength(2);
+ });
+ });
+
+ describe('mapRestrictionsToAvailability', () => {
+ it('maps stopSell to masterClose', () => {
+ const payloads = mapRestrictionsToAvailability(
+ 'HOTEL1',
+ [
+ {
+ channelRoomCode: 'KING',
+ channelRateCode: 'BAR',
+ date: '2026-04-01',
+ stopSell: true,
+ closedToArrival: false,
+ closedToDeparture: true,
+ minLos: 2,
+ },
+ ],
+ 'Delta',
+ );
+ const restrictions = payloads[0]!['restrictions'] as Array>;
+ expect(restrictions[0]).toMatchObject({
+ masterClose: true,
+ closedToDeparture: true,
+ minStayArrival: 2,
+ });
+ });
+ });
+
+ describe('mapDerbySoftReservationToHaip', () => {
+ it('maps book payload and strips payment (PCI)', () => {
+ const mapped = mapDerbySoftReservationToHaip(
+ {
+ reservationIds: { distributorResId: 'D1', derbyResId: 'DR1' },
+ distributorId: 'CTRIP',
+ hotelId: 'HOTEL1',
+ stayRange: { checkin: '2026-05-01', checkout: '2026-05-03' },
+ contactPerson: { firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com' },
+ roomCriteria: { adultCount: 2, childCount: 0 },
+ total: { amountAfterTax: '300' },
+ roomRate: { roomId: 'KING', rateId: 'BAR', currency: 'USD' },
+ guests: [{ firstNameRoma: 'ADA', lastNameRoma: 'LOVELACE' }],
+ payment: {
+ cardCode: 'VI',
+ cardNumber: '4111111111111111',
+ cardHolderName: 'Ada',
+ expireDate: '1228',
+ },
+ comments: ['high floor'],
+ },
+ 'new',
+ );
+
+ expect(mapped.externalConfirmation).toBe('DR1');
+ expect(mapped.channelCode).toBe('derbysoft:CTRIP');
+ expect(mapped.channelHotelId).toBe('HOTEL1');
+ expect(mapped.guestFirstName).toBe('ADA');
+ expect(mapped.totalAmount).toBe(300);
+ expect(mapped.specialRequests).toBe('high floor');
+ expect(mapped.rawPayload).not.toHaveProperty('payment');
+ expect(JSON.stringify(mapped.rawPayload)).not.toContain('4111111111111111');
+ });
+ });
+
+ describe('mapCancelToHaip', () => {
+ it('sets cancelled status', () => {
+ const mapped = mapCancelToHaip({
+ reservationIds: { derbyResId: 'DR1' },
+ distributorId: 'EXPEDIA',
+ hotelId: 'HOTEL1',
+ });
+ expect(mapped.status).toBe('cancelled');
+ expect(mapped.channelCode).toBe('derbysoft:EXPEDIA');
+ });
+ });
+});
diff --git a/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.mapper.ts b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.mapper.ts
new file mode 100644
index 0000000..f37d2da
--- /dev/null
+++ b/apps/api/src/modules/channel/adapters/derbysoft/derbysoft.mapper.ts
@@ -0,0 +1,383 @@
+import { randomUUID } from 'node:crypto';
+import type {
+ AvailabilityPushParams,
+ RatePushParams,
+ RestrictionPushParams,
+ ChannelReservation,
+ ContentPushParams,
+} from '../../channel-adapter.interface';
+import { DERBYSOFT_MESSAGE_VERSION } from './derbysoft.config';
+
+export type AriUpdateType = 'Delta' | 'Overlay';
+
+export interface DerbySoftHeader {
+ echoToken: string;
+ timeStamp: string;
+ version: string;
+}
+
+export function buildHeader(echoToken?: string): DerbySoftHeader {
+ return {
+ echoToken: echoToken ?? randomUUID(),
+ timeStamp: new Date().toISOString(),
+ version: DERBYSOFT_MESSAGE_VERSION,
+ };
+}
+
+/** Collapse consecutive same-value dates into [startDate, endDate] ranges. */
+export function collapseDateRanges(
+ items: T[],
+ valueKey: (item: T) => string,
+): Array<{ startDate: string; endDate: string; sample: T }> {
+ if (items.length === 0) return [];
+ const sorted = [...items].sort((a, b) => a.date.localeCompare(b.date));
+ const ranges: Array<{ startDate: string; endDate: string; sample: T }> = [];
+ let start = sorted[0]!;
+ let end = sorted[0]!;
+ let key = valueKey(start);
+
+ for (let i = 1; i < sorted.length; i++) {
+ const cur = sorted[i]!;
+ const curKey = valueKey(cur);
+ const prevDate = new Date(end.date + 'T00:00:00Z');
+ prevDate.setUTCDate(prevDate.getUTCDate() + 1);
+ const expectedNext = prevDate.toISOString().slice(0, 10);
+ if (curKey === key && cur.date === expectedNext) {
+ end = cur;
+ continue;
+ }
+ ranges.push({ startDate: start.date, endDate: end.date, sample: start });
+ start = cur;
+ end = cur;
+ key = curKey;
+ }
+ ranges.push({ startDate: start.date, endDate: end.date, sample: start });
+ return ranges;
+}
+
+/**
+ * HAIP availability → DerbySoft Update Inventory payloads (one per roomId).
+ * ChannelAdapter.pushAvailability maps to PC inventory (availableInvCount).
+ */
+export function mapAvailabilityToInventory(
+ hotelId: string,
+ items: AvailabilityPushParams['items'],
+ type: AriUpdateType,
+): Array> {
+ const byRoom = new Map();
+ for (const item of items) {
+ const list = byRoom.get(item.channelRoomCode) ?? [];
+ list.push(item);
+ byRoom.set(item.channelRoomCode, list);
+ }
+
+ const payloads: Array> = [];
+ for (const [roomId, roomItems] of byRoom) {
+ const ranges = collapseDateRanges(roomItems, (i) => String(i.available));
+ const dates = roomItems.map((i) => i.date).sort();
+ payloads.push({
+ header: buildHeader(),
+ hotelId,
+ roomId,
+ startDate: dates[0],
+ endDate: dates[dates.length - 1],
+ type,
+ inventories: ranges.map((r) => ({
+ startDate: r.startDate,
+ endDate: r.endDate,
+ availableInvCount: r.sample.available,
+ })),
+ });
+ }
+ return payloads;
+}
+
+/**
+ * HAIP rates → DerbySoft Update Rate payloads (one per roomId×rateId).
+ */
+export function mapRatesToDerbySoft(
+ hotelId: string,
+ items: RatePushParams['items'],
+ type: AriUpdateType,
+): Array> {
+ const byKey = new Map();
+ for (const item of items) {
+ const key = `${item.channelRoomCode}|${item.channelRateCode}`;
+ const list = byKey.get(key) ?? [];
+ list.push(item);
+ byKey.set(key, list);
+ }
+
+ const payloads: Array> = [];
+ for (const [, rateItems] of byKey) {
+ const first = rateItems[0]!;
+ const ranges = collapseDateRanges(rateItems, (i) => `${i.amount}|${i.currencyCode}`);
+ const dates = rateItems.map((i) => i.date).sort();
+ payloads.push({
+ header: buildHeader(),
+ hotelId,
+ roomId: first.channelRoomCode,
+ rateId: first.channelRateCode,
+ startDate: dates[0],
+ endDate: dates[dates.length - 1],
+ type,
+ currencyCode: first.currencyCode,
+ rates: ranges.map((r) => {
+ const amt = r.sample.amount;
+ const single = r.sample.singleOccupancy ?? amt;
+ const baseByGuestAmts: Array> = [
+ { ageQualifyingCode: 10, numberOfGuests: 1, amountAfterTax: single },
+ { ageQualifyingCode: 10, numberOfGuests: 2, amountAfterTax: amt },
+ ];
+ const rate: Record = {
+ startDate: r.startDate,
+ endDate: r.endDate,
+ baseByGuestAmts,
+ };
+ if (r.sample.extraAdult != null || r.sample.extraChild != null) {
+ const additional: Array> = [];
+ if (r.sample.extraAdult != null) {
+ additional.push({
+ ageQualifyingCode: 10,
+ amountAfterTax: r.sample.extraAdult,
+ });
+ }
+ if (r.sample.extraChild != null) {
+ additional.push({
+ ageQualifyingCode: 8,
+ amountAfterTax: r.sample.extraChild,
+ });
+ }
+ rate['additionalGuestAmounts'] = additional;
+ }
+ return rate;
+ }),
+ });
+ }
+ return payloads;
+}
+
+/**
+ * HAIP restrictions → DerbySoft Update Availability (product-level) payloads.
+ */
+export function mapRestrictionsToAvailability(
+ hotelId: string,
+ items: RestrictionPushParams['items'],
+ type: AriUpdateType,
+): Array> {
+ const byKey = new Map();
+ for (const item of items) {
+ const key = `${item.channelRoomCode}|${item.channelRateCode}`;
+ const list = byKey.get(key) ?? [];
+ list.push(item);
+ byKey.set(key, list);
+ }
+
+ const payloads: Array> = [];
+ for (const [, restItems] of byKey) {
+ const first = restItems[0]!;
+ const ranges = collapseDateRanges(
+ restItems,
+ (i) =>
+ [
+ i.stopSell,
+ i.closedToArrival,
+ i.closedToDeparture,
+ i.minLos ?? 0,
+ i.maxLos ?? 0,
+ ].join('|'),
+ );
+ const dates = restItems.map((i) => i.date).sort();
+ payloads.push({
+ header: buildHeader(),
+ hotelId,
+ roomId: first.channelRoomCode,
+ rateId: first.channelRateCode,
+ startDate: dates[0],
+ endDate: dates[dates.length - 1],
+ type,
+ restrictions: ranges.map((r) => ({
+ startDate: r.startDate,
+ endDate: r.endDate,
+ masterClose: r.sample.stopSell,
+ closedToArrival: r.sample.closedToArrival,
+ closedToDeparture: r.sample.closedToDeparture,
+ minStayArrival: r.sample.minLos ?? 0,
+ maxStayArrival: r.sample.maxLos ?? 0,
+ })),
+ });
+ }
+ return payloads;
+}
+
+/** Profile Update Hotel body from HAIP property content. */
+export function mapPropertyToHotelUpdate(
+ hotelId: string,
+ property: ContentPushParams['property'],
+): Record {
+ return {
+ header: buildHeader(),
+ hotelId,
+ name: property.name,
+ description: property.description ?? undefined,
+ address: property.address ?? undefined,
+ city: property.city ?? undefined,
+ countryCode: property.countryCode ?? undefined,
+ starRating: property.starRating ?? undefined,
+ amenities: property.amenities ?? [],
+ };
+}
+
+export function mapRoomTypesToUpdates(
+ hotelId: string,
+ roomTypes: ContentPushParams['roomTypes'],
+): Array> {
+ return roomTypes.map((rt) => ({
+ header: buildHeader(),
+ hotelId,
+ roomId: rt.channelRoomCode,
+ name: rt.name,
+ description: rt.description ?? undefined,
+ maxOccupancy: rt.maxOccupancy ?? undefined,
+ bedType: rt.bedType ?? undefined,
+ amenities: rt.amenities ?? [],
+ }));
+}
+
+/**
+ * Map DerbySoft Book/Modify JSON → HAIP ChannelReservation.
+ * PCI: payment card fields are stripped and never copied into rawPayload.
+ */
+export function mapDerbySoftReservationToHaip(
+ body: Record,
+ status: 'new' | 'modified' | 'cancelled',
+): ChannelReservation {
+ const reservationIds = (body['reservationIds'] ?? {}) as Record;
+ const stayRange = (body['stayRange'] ?? {}) as Record;
+ const contact = (body['contactPerson'] ?? {}) as Record;
+ const guests = (body['guests'] as Array> | undefined) ?? [];
+ const primaryGuest = guests[0] ?? contact;
+ const roomCriteria = (body['roomCriteria'] ?? {}) as Record;
+ const roomRate = (body['roomRate'] ?? {}) as Record;
+ const total = (body['total'] ?? {}) as Record;
+ const distributorId = String(body['distributorId'] ?? 'derbysoft');
+
+ const derbyResId = String(reservationIds['derbyResId'] ?? '');
+ const distributorResId = String(reservationIds['distributorResId'] ?? derbyResId);
+
+ const amountAfter = total['amountAfterTax'] ?? total['amountBeforeTax'] ?? '0';
+ const comments = body['comments'];
+ const specialRequests = Array.isArray(comments)
+ ? comments.map(String).join('; ')
+ : undefined;
+
+ // Strip payment — never persist PAN/CVV/expiry (PCI DSS).
+ const safeRaw = { ...body };
+ delete safeRaw['payment'];
+ delete safeRaw['threeDomainSecurity'];
+
+ return {
+ externalConfirmation: derbyResId || distributorResId,
+ channelCode: `derbysoft:${distributorId}`,
+ channelHotelId: body['hotelId'] != null ? String(body['hotelId']) : undefined,
+ guestFirstName: String(
+ primaryGuest['firstNameRoma'] ?? primaryGuest['firstName'] ?? contact['firstNameRoma'] ?? contact['firstName'] ?? 'Guest',
+ ),
+ guestLastName: String(
+ primaryGuest['lastNameRoma'] ?? primaryGuest['lastName'] ?? contact['lastNameRoma'] ?? contact['lastName'] ?? 'Unknown',
+ ),
+ guestEmail: (primaryGuest['email'] ?? contact['email']) as string | undefined,
+ guestPhone: (primaryGuest['phone'] ?? contact['phone']) as string | undefined,
+ channelRoomCode: String(roomRate['roomId'] ?? ''),
+ channelRateCode: String(roomRate['rateId'] ?? ''),
+ arrivalDate: String(stayRange['checkin'] ?? ''),
+ departureDate: String(stayRange['checkout'] ?? ''),
+ adults: Number(roomCriteria['adultCount'] ?? 1),
+ children: Number(roomCriteria['childCount'] ?? 0),
+ totalAmount: Number(amountAfter),
+ currencyCode: String(roomRate['currency'] ?? 'USD'),
+ specialRequests,
+ status,
+ channelBookingDate: new Date(),
+ rawPayload: {
+ ...safeRaw,
+ sourceDistributorId: distributorId,
+ distributorResId,
+ derbyResId,
+ },
+ };
+}
+
+export function mapCancelToHaip(body: Record): ChannelReservation {
+ const reservationIds = (body['reservationIds'] ?? {}) as Record;
+ const derbyResId = String(reservationIds['derbyResId'] ?? '');
+ const distributorId = String(body['distributorId'] ?? 'derbysoft');
+ return {
+ externalConfirmation: derbyResId || String(reservationIds['distributorResId'] ?? ''),
+ channelCode: `derbysoft:${distributorId}`,
+ channelHotelId: body['hotelId'] != null ? String(body['hotelId']) : undefined,
+ guestFirstName: 'Guest',
+ guestLastName: 'Unknown',
+ channelRoomCode: '',
+ channelRateCode: '',
+ arrivalDate: '',
+ departureDate: '',
+ adults: 0,
+ children: 0,
+ totalAmount: 0,
+ currencyCode: 'USD',
+ status: 'cancelled',
+ channelBookingDate: new Date(),
+ rawPayload: {
+ reservationIds,
+ distributorId,
+ hotelId: body['hotelId'],
+ },
+ };
+}
+
+/** Build Book/Modify success response with PMS confirmation. */
+export function buildBookResponse(
+ body: Record,
+ supplierResId: string,
+): Record {
+ const reservationIds = (body['reservationIds'] ?? {}) as Record;
+ const header = (body['header'] as DerbySoftHeader | undefined) ?? buildHeader();
+ return {
+ header: { ...header, timeStamp: new Date().toISOString() },
+ reservationIds: {
+ distributorResId: reservationIds['distributorResId'],
+ derbyResId: reservationIds['derbyResId'],
+ supplierResId,
+ },
+ };
+}
+
+export function buildCancelResponse(
+ body: Record,
+ cancellationId: string,
+): Record {
+ const reservationIds = (body['reservationIds'] ?? {}) as Record;
+ const header = (body['header'] as DerbySoftHeader | undefined) ?? buildHeader();
+ return {
+ header: { ...header, timeStamp: new Date().toISOString() },
+ reservationIds: {
+ distributorResId: reservationIds['distributorResId'],
+ derbyResId: reservationIds['derbyResId'],
+ supplierResId: reservationIds['supplierResId'],
+ cancellationId,
+ },
+ };
+}
+
+export function buildErrorResponse(
+ echoToken: string | undefined,
+ errorCode: string,
+ errorMessage: string,
+): Record {
+ return {
+ header: buildHeader(echoToken),
+ errorCode,
+ errorMessage,
+ };
+}
diff --git a/apps/api/src/modules/channel/adapters/inbound-auth.util.spec.ts b/apps/api/src/modules/channel/adapters/inbound-auth.util.spec.ts
index 52b93d9..4af5d8e 100644
--- a/apps/api/src/modules/channel/adapters/inbound-auth.util.spec.ts
+++ b/apps/api/src/modules/channel/adapters/inbound-auth.util.spec.ts
@@ -1,6 +1,11 @@
import { describe, it, expect } from 'vitest';
import { createHmac } from 'node:crypto';
-import { verifyBasicAuth, verifyHmacSignature, getInboundAuth } from './inbound-auth.util';
+import {
+ verifyBasicAuth,
+ verifyHmacSignature,
+ verifyBearerAuth,
+ getInboundAuth,
+} from './inbound-auth.util';
describe('inbound-auth.util — verifyBasicAuth', () => {
const stored = { username: 'bc_user', password: 'bc_pass' };
@@ -67,6 +72,27 @@ describe('inbound-auth.util — verifyHmacSignature', () => {
});
});
+describe('inbound-auth.util — verifyBearerAuth', () => {
+ const stored = { bearerToken: 'derby-inbound-secret' };
+
+ it('accepts the correct Bearer token', () => {
+ expect(verifyBearerAuth('Bearer derby-inbound-secret', stored)).toBe(true);
+ });
+
+ it('rejects a wrong token', () => {
+ expect(verifyBearerAuth('Bearer other-tenant', stored)).toBe(false);
+ });
+
+ it('rejects missing header or missing stored token', () => {
+ expect(verifyBearerAuth(undefined, stored)).toBe(false);
+ expect(verifyBearerAuth('Bearer derby-inbound-secret', undefined)).toBe(false);
+ });
+
+ it('rejects Basic auth schemes', () => {
+ expect(verifyBearerAuth('Basic abc', stored)).toBe(false);
+ });
+});
+
describe('inbound-auth.util — getInboundAuth', () => {
it('returns the embedded auth object when present', () => {
expect(getInboundAuth({ inboundAuth: { username: 'u', password: 'p' } })).toEqual({ username: 'u', password: 'p' });
diff --git a/apps/api/src/modules/channel/adapters/inbound-auth.util.ts b/apps/api/src/modules/channel/adapters/inbound-auth.util.ts
index 919943e..dfe4549 100644
--- a/apps/api/src/modules/channel/adapters/inbound-auth.util.ts
+++ b/apps/api/src/modules/channel/adapters/inbound-auth.util.ts
@@ -8,6 +8,7 @@ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
*
* { username: '...', password: '...' } // Basic Auth (Booking.com)
* { secret: '...' } // HMAC-SHA256 (Expedia & similar)
+ * { bearerToken: '...' } // Bearer token (DerbySoft → PMS)
*
* The previous code never validated the `Authorization` header at all, so anyone
* who reached the public webhook URL could inject reservations/cancellations.
@@ -23,6 +24,10 @@ export interface InboundHmacAuth {
/** Shared secret used as the HMAC-SHA256 key (raw, not hashed). */
secret: string;
}
+export interface InboundBearerAuth {
+ /** Opaque bearer token DerbySoft presents on LiveCheck/Book/Modify/Cancel. */
+ bearerToken: string;
+}
/**
* Constant-time string compare that does NOT leak the length difference.
@@ -84,8 +89,23 @@ export function verifyHmacSignature(
return constantTimeEqualStr(provided, expected);
}
+/**
+ * Verify `Authorization: Bearer ` against the stored bearer token on a
+ * channel connection. Constant-time; fail closed on missing input.
+ */
+export function verifyBearerAuth(
+ authHeader: string | undefined | null,
+ stored: InboundBearerAuth | undefined,
+): boolean {
+ if (!stored?.bearerToken) return false;
+ if (typeof authHeader !== 'string' || !authHeader) return false;
+ const m = /^Bearer\s+(.+)$/i.exec(authHeader.trim());
+ if (!m) return false;
+ return constantTimeEqualStr(m[1]!.trim(), stored.bearerToken);
+}
+
/** Extract `inboundAuth` from a channel connection's `config` jsonb safely. */
-export function getInboundAuth(
+export function getInboundAuth(
config: unknown,
): T | undefined {
if (!config || typeof config !== 'object') return undefined;
diff --git a/apps/api/src/modules/channel/adapters/mock.adapter.ts b/apps/api/src/modules/channel/adapters/mock.adapter.ts
index 43f451f..256c1fb 100644
--- a/apps/api/src/modules/channel/adapters/mock.adapter.ts
+++ b/apps/api/src/modules/channel/adapters/mock.adapter.ts
@@ -15,7 +15,7 @@ import type {
/**
* MockChannelAdapter — test/development adapter.
* Stores pushed data in memory. Returns canned results.
- * Real adapters (SiteMinder, DerbySoft) replace this.
+ * Real adapters (SiteMinder, DerbySoft, Booking.com, Expedia) replace this.
*/
@Injectable()
export class MockChannelAdapter implements ChannelAdapter {
diff --git a/apps/api/src/modules/channel/ari.service.ts b/apps/api/src/modules/channel/ari.service.ts
index c2282eb..f7d3f4d 100644
--- a/apps/api/src/modules/channel/ari.service.ts
+++ b/apps/api/src/modules/channel/ari.service.ts
@@ -34,6 +34,7 @@ export class AriService {
startDate: string,
endDate: string,
channelConnectionId?: string,
+ ariUpdateType?: 'Delta' | 'Overlay',
) {
const connections = channelConnectionId
? [await this.channelService.findById(channelConnectionId, propertyId)]
@@ -87,10 +88,14 @@ export class AriService {
if (items.length === 0) continue;
const adapter = this.adapterFactory.getAdapter(conn.adapterType);
+ const connectionConfig: Record = {
+ ...((conn.config ?? {}) as Record),
+ ...(ariUpdateType ? { ariUpdateType } : {}),
+ };
const result = await adapter.pushAvailability({
propertyId,
channelConnectionId: conn.id,
- connectionConfig: (conn.config ?? {}) as Record,
+ connectionConfig,
items,
});
@@ -117,6 +122,7 @@ export class AriService {
startDate: string,
endDate: string,
channelConnectionId?: string,
+ ariUpdateType?: 'Delta' | 'Overlay',
) {
const connections = channelConnectionId
? [await this.channelService.findById(channelConnectionId, propertyId)]
@@ -198,7 +204,10 @@ export class AriService {
const adapter = this.adapterFactory.getAdapter(conn.adapterType);
- const connectionConfig = (conn.config ?? {}) as Record;
+ const connectionConfig: Record = {
+ ...((conn.config ?? {}) as Record),
+ ...(ariUpdateType ? { ariUpdateType } : {}),
+ };
const rateResult = rateItems.length > 0
? await adapter.pushRates({ propertyId, channelConnectionId: conn.id, connectionConfig, items: rateItems })
: { success: true, itemsSynced: 0, errors: [] };
@@ -227,19 +236,52 @@ export class AriService {
/**
* Push full ARI (availability + rates + restrictions) — convenience method.
+ * Pass ariUpdateType='Overlay' for DerbySoft full refresh (issue #93 flush).
*/
async pushFullARI(
propertyId: string,
startDate: string,
endDate: string,
channelConnectionId?: string,
+ ariUpdateType?: 'Delta' | 'Overlay',
) {
- const availabilityResults = await this.pushAvailability(propertyId, startDate, endDate, channelConnectionId);
- const rateResults = await this.pushRates(propertyId, startDate, endDate, channelConnectionId);
+ const availabilityResults = await this.pushAvailability(
+ propertyId,
+ startDate,
+ endDate,
+ channelConnectionId,
+ ariUpdateType,
+ );
+ const rateResults = await this.pushRates(
+ propertyId,
+ startDate,
+ endDate,
+ channelConnectionId,
+ ariUpdateType,
+ );
return { availability: availabilityResults, rates: rateResults };
}
+ /**
+ * Schedule a full Overlay ARI flush asynchronously.
+ * Channel BullMQ workers are not wired yet (same pragmatic stance as webhook
+ * delivery) — runs fire-and-forget on the event loop.
+ */
+ scheduleFullFlush(
+ propertyId: string,
+ startDate: string,
+ endDate: string,
+ channelConnectionId?: string,
+ ): { queued: true } {
+ setImmediate(() => {
+ void this.pushFullARI(propertyId, startDate, endDate, channelConnectionId, 'Overlay').catch(
+ () => undefined,
+ );
+ });
+ return { queued: true };
+ }
+
/**
* Get sync logs for a channel connection.
*/
diff --git a/apps/api/src/modules/channel/channel-adapter.factory.ts b/apps/api/src/modules/channel/channel-adapter.factory.ts
index 6961ad0..77ece85 100644
--- a/apps/api/src/modules/channel/channel-adapter.factory.ts
+++ b/apps/api/src/modules/channel/channel-adapter.factory.ts
@@ -7,6 +7,7 @@ import { MockChannelAdapter } from './adapters/mock.adapter';
import { BookingComAdapter } from './adapters/booking-com/booking-com.adapter';
import { SiteMinderAdapter } from './adapters/siteminder/siteminder.adapter';
import { ExpediaAdapter } from './adapters/expedia/expedia.adapter';
+import { DerbySoftAdapter } from './adapters/derbysoft/derbysoft.adapter';
/**
* Factory that maps adapterType strings to ChannelAdapter instances.
@@ -21,12 +22,13 @@ export class ChannelAdapterFactory {
private readonly bookingComAdapter: BookingComAdapter,
private readonly siteMinderAdapter: SiteMinderAdapter,
private readonly expediaAdapter: ExpediaAdapter,
+ private readonly derbySoftAdapter: DerbySoftAdapter,
) {
this.adapters.set('mock', this.mockAdapter);
this.adapters.set('booking_com', this.bookingComAdapter);
this.adapters.set('siteminder', this.siteMinderAdapter);
this.adapters.set('expedia', this.expediaAdapter);
- // Future: this.adapters.set('derbysoft', this.derbysoftAdapter);
+ this.adapters.set('derbysoft', this.derbySoftAdapter);
}
getAdapter(adapterType: string): ChannelAdapter {
diff --git a/apps/api/src/modules/channel/channel.controller.ts b/apps/api/src/modules/channel/channel.controller.ts
index 4d0b2e5..ec0aaaa 100644
--- a/apps/api/src/modules/channel/channel.controller.ts
+++ b/apps/api/src/modules/channel/channel.controller.ts
@@ -102,6 +102,7 @@ export class ChannelController {
dto.startDate,
dto.endDate,
dto.channelConnectionId,
+ dto.ariUpdateType,
);
}
@@ -113,20 +114,54 @@ export class ChannelController {
dto.startDate,
dto.endDate,
dto.channelConnectionId,
+ dto.ariUpdateType,
);
}
@Post('push/full')
@ApiOperation({ summary: 'Push full ARI (availability + rates + restrictions) to channels' })
async pushFullARI(@Body() dto: PushAriDto) {
+ if (dto.asyncFlush) {
+ return this.ariService.scheduleFullFlush(
+ dto.propertyId,
+ dto.startDate,
+ dto.endDate,
+ dto.channelConnectionId,
+ );
+ }
return this.ariService.pushFullARI(
dto.propertyId,
dto.startDate,
dto.endDate,
dto.channelConnectionId,
+ dto.ariUpdateType,
);
}
+ @Post('push/full-flush')
+ @ApiOperation({
+ summary:
+ 'Queue a full Overlay ARI flush (DerbySoft launch/refresh). Async fire-and-forget until BullMQ channel workers land.',
+ })
+ async pushFullFlush(@Body() dto: PushAriDto) {
+ return this.ariService.scheduleFullFlush(
+ dto.propertyId,
+ dto.startDate,
+ dto.endDate,
+ dto.channelConnectionId,
+ );
+ }
+
+ @Post('connections/:id/sync-property')
+ @ApiOperation({ summary: 'Sync property/room profiles to the channel (DerbySoft profile APIs)' })
+ @ApiQuery({ name: 'propertyId', required: true })
+ async syncProperty(
+ @Param('id', ParseUUIDPipe) id: string,
+ @Query('propertyId', ParseUUIDPipe) propertyId: string,
+ ) {
+ return this.contentSyncService.pushContent(propertyId, id);
+ }
+
@Post('push/stop-sell')
@ApiOperation({ summary: 'Push stop-sell (zero availability) to a channel' })
@ApiQuery({ name: 'channelConnectionId', required: true })
diff --git a/apps/api/src/modules/channel/channel.module.ts b/apps/api/src/modules/channel/channel.module.ts
index 9235d7e..b72ebe6 100644
--- a/apps/api/src/modules/channel/channel.module.ts
+++ b/apps/api/src/modules/channel/channel.module.ts
@@ -14,13 +14,20 @@ import { BookingComInboundController } from './adapters/booking-com/booking-com-
import { SiteMinderAdapter } from './adapters/siteminder/siteminder.adapter';
import { ExpediaAdapter } from './adapters/expedia/expedia.adapter';
import { ExpediaInboundController } from './adapters/expedia/expedia-inbound.controller';
+import { DerbySoftAdapter } from './adapters/derbysoft/derbysoft.adapter';
+import { DerbySoftInboundController } from './adapters/derbysoft/derbysoft-inbound.controller';
import { ReservationModule } from '../reservation/reservation.module';
import { WebhookModule } from '../webhook/webhook.module';
import { MediaModule } from '../media/media.module';
@Module({
imports: [ReservationModule, WebhookModule, MediaModule],
- controllers: [ChannelController, BookingComInboundController, ExpediaInboundController],
+ controllers: [
+ ChannelController,
+ BookingComInboundController,
+ ExpediaInboundController,
+ DerbySoftInboundController,
+ ],
providers: [
ChannelService,
AriService,
@@ -32,6 +39,7 @@ import { MediaModule } from '../media/media.module';
BookingComAdapter,
SiteMinderAdapter,
ExpediaAdapter,
+ DerbySoftAdapter,
],
exports: [ChannelService, AriService, InboundReservationService],
})
diff --git a/apps/api/src/modules/channel/dto/push-ari.dto.ts b/apps/api/src/modules/channel/dto/push-ari.dto.ts
index 047ff77..f54ac0f 100644
--- a/apps/api/src/modules/channel/dto/push-ari.dto.ts
+++ b/apps/api/src/modules/channel/dto/push-ari.dto.ts
@@ -1,4 +1,4 @@
-import { IsUUID, IsDateString, IsOptional } from 'class-validator';
+import { IsUUID, IsDateString, IsOptional, IsBoolean, IsIn } from 'class-validator';
export class PushAriDto {
@IsUUID()
@@ -13,4 +13,20 @@ export class PushAriDto {
@IsOptional()
@IsUUID()
channelConnectionId?: string;
-}
+
+ /**
+ * DerbySoft ARI mode. Delta = incremental (default); Overlay = full refresh
+ * (delete+insert for the date window). Other adapters ignore this field.
+ */
+ @IsOptional()
+ @IsIn(['Delta', 'Overlay'])
+ ariUpdateType?: 'Delta' | 'Overlay';
+
+ /**
+ * When true with push/full, run Overlay flush asynchronously (fire-and-forget).
+ * BullMQ channel queues are not wired yet — this is the pragmatic async path.
+ */
+ @IsOptional()
+ @IsBoolean()
+ asyncFlush?: boolean;
+}
\ No newline at end of file
diff --git a/apps/dashboard/src/pages/Channels.tsx b/apps/dashboard/src/pages/Channels.tsx
index 7c09514..32b172a 100644
--- a/apps/dashboard/src/pages/Channels.tsx
+++ b/apps/dashboard/src/pages/Channels.tsx
@@ -25,6 +25,7 @@ const ADAPTER_OPTIONS = [
{ value: 'booking_com', label: 'Booking.com' },
{ value: 'expedia', label: 'Expedia' },
{ value: 'siteminder', label: 'SiteMinder' },
+ { value: 'derbysoft', label: 'DerbySoft' },
{ value: 'mock', label: 'Demo (mock)' },
];
diff --git a/docker-compose.yml b/docker-compose.yml
index 07319cd..2fcff5e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -121,6 +121,12 @@ services:
EXPEDIA_USERNAME: haip_test
EXPEDIA_PASSWORD: test_password
EXPEDIA_HOTEL_ID: MOCK_EXP_HOTEL
+ DERBYSOFT_TUNNEL_BASE_URL: http://mock-derbysoft:4002/pcapigateway/tunnel/{accountId}
+ DERBYSOFT_PROFILE_BASE_URL: http://mock-derbysoft:4002/pcapigateway/profile/{accountId}
+ DERBYSOFT_TOKEN_URL: http://mock-derbysoft:4002/pcapigateway/account/token
+ DERBYSOFT_ACCOUNT_ID: haip_test
+ DERBYSOFT_CLIENT_SECRET: test_password
+ DERBYSOFT_HOTEL_ID: MOCK_DS_HOTEL
ports:
- '3000:3000'
depends_on:
@@ -164,6 +170,21 @@ services:
profiles:
- channels
+ # Mock DerbySoft Property Connector API (optional, for channel testing)
+ mock-derbysoft:
+ build:
+ context: ./tools/mock-derbysoft
+ dockerfile: Dockerfile
+ container_name: haip-mock-derbysoft
+ environment:
+ PORT: 4002
+ CLIENT_ID: haip_test
+ CLIENT_SECRET: test_password
+ ports:
+ - '4002:4002'
+ profiles:
+ - channels
+
# MinIO — S3-compatible object storage for media uploads (optional).
# Behind the `storage` profile so the default `docker compose up` stays lean
# and runs on stock image URLs (no uploads). Enable with:
diff --git a/docs/channels/derbysoft.md b/docs/channels/derbysoft.md
new file mode 100644
index 0000000..f3267e2
--- /dev/null
+++ b/docs/channels/derbysoft.md
@@ -0,0 +1,93 @@
+# DerbySoft Property Connector adapter
+
+HAIP integrates DerbySoft via the **Property Connector (PC) Integration API** — REST/JSON, OAuth Bearer tokens, PUSH/PUSH for ARI and reservations.
+
+Partner onboarding: contact **pms.service@derbysoft.net** for `client_id` / `client_secret` (account credentials).
+
+## What HAIP implements
+
+| Capability | Direction | HAIP surface |
+|------------|-----------|--------------|
+| Token obtain/refresh | PMS → DerbySoft | `POST …/account/token` (Basic → Bearer) |
+| Update Inventory | PMS → DerbySoft | `ChannelAdapter.pushAvailability` |
+| Update Rate | PMS → DerbySoft | `ChannelAdapter.pushRates` |
+| Update Availability (restrictions) | PMS → DerbySoft | `ChannelAdapter.pushRestrictions` |
+| Update Hotel / RoomType | PMS → DerbySoft | `pushContent` / `POST /channels/connections/:id/sync-property` |
+| Update Reservation Status | PMS → DerbySoft | `confirmReservation` / `cancelReservation` |
+| Live Check | DerbySoft → PMS | `POST /api/v1/channels/inbound/derbysoft/availability` |
+| Book / Modify / Cancel | DerbySoft → PMS | `…/book`, `…/modify`, `…/cancel` |
+| Ping | DerbySoft → PMS | `…/ping` |
+| Full Overlay flush | PMS → DerbySoft | `POST /channels/push/full-flush` (async) or `ariUpdateType=Overlay` |
+
+## Connection config
+
+Store on `channelConnections.config`:
+
+```json
+{
+ "hotelId": "YOUR_PMS_HOTEL_ID",
+ "accountId": "client_id_from_derbysoft",
+ "clientSecret": "client_secret_from_derbysoft",
+ "tunnelBaseUrl": "https://pcendpoint.derbysoft-test.com/pcapigateway/tunnel/{accountId}",
+ "profileBaseUrl": "https://pcendpoint.derbysoft-test.com/pcapigateway/profile/{accountId}",
+ "tokenUrl": "https://pcendpoint.derbysoft-test.com/pcapigateway/account/token",
+ "ariUpdateType": "Delta",
+ "inboundAuth": {
+ "bearerToken": "shared-secret-derbysoft-presents-to-haip"
+ },
+ "roomTypeMapping": [{ "roomTypeId": "…", "channelRoomCode": "King" }],
+ "ratePlanMapping": [{ "ratePlanId": "…", "channelRateCode": "BAR" }]
+}
+```
+
+Env defaults (local mock):
+
+| Variable | Default |
+|----------|---------|
+| `DERBYSOFT_TOKEN_URL` | `http://localhost:4002/pcapigateway/account/token` |
+| `DERBYSOFT_TUNNEL_BASE_URL` | `http://localhost:4002/pcapigateway/tunnel/{accountId}` |
+| `DERBYSOFT_PROFILE_BASE_URL` | `http://localhost:4002/pcapigateway/profile/{accountId}` |
+| `DERBYSOFT_ACCOUNT_ID` | `haip_test` |
+| `DERBYSOFT_CLIENT_SECRET` | `test_password` |
+| `DERBYSOFT_HOTEL_ID` | `MOCK_DS_HOTEL` |
+
+## Local mock
+
+```bash
+docker compose --profile channels up -d mock-derbysoft
+# or: node tools/mock-derbysoft/server.mjs # :4002
+```
+
+## Rate limiting
+
+Outbound client enforces **15 requests/second** (vendor limit). HTTP 429 triggers a short backoff + retry.
+
+## Delta vs Overlay
+
+- **Delta** (default): incremental ARI changes.
+- **Overlay**: full refresh for a date window (property launch / manual refresh). Use `ariUpdateType: "Overlay"` on push DTOs or `POST /channels/push/full-flush`.
+
+## Source channel tracking
+
+Inbound reservations set `channelCode` to `derbysoft:` (e.g. `derbysoft:CTRIP`) so multi-OTA traffic through DerbySoft stays distinguishable in bookings.
+
+## PCI
+
+Book/Modify payloads may include card data. HAIP **strips** `payment` and `threeDomainSecurity` before any persistence (`rawPayload`). Never store PAN/CVV.
+
+## Partner checklist
+
+1. Email `pms.service@derbysoft.net` for TEST account (`client_id` / `client_secret`).
+2. Register PMS webhook base URL: `https:///api/v1/channels/inbound/derbysoft` (paths `/availability`, `/book`, `/modify`, `/cancel`, `/ping`).
+3. Share inbound Bearer token; store as `config.inboundAuth.bearerToken`.
+4. Create HAIP channel connection (`adapterType=derbysoft`) with hotel/room/rate mappings.
+5. Run property sync + Overlay ARI flush for the sellable window.
+6. Certify Live Check → Book → Modify → Cancel against TEST endpoint.
+7. Switch tunnel/profile/token URLs to PROD (`pcendpoint.derbysoftsec.com`) when certified.
+
+## Vendor docs
+
+- [API Overview](https://pc.knowledgebase.derbysoftsec.com/en/support/solutions/articles/70000157127-api-overview)
+- [ARI](https://pc.knowledgebase.derbysoftsec.com/en/support/solutions/articles/70000157130-ari)
+- [Reservation](https://pc.knowledgebase.derbysoftsec.com/en/support/solutions/articles/70000157128-reservation)
+- [Account / Token](https://pc.knowledgebase.derbysoftsec.com/en/support/solutions/articles/70000663114)
diff --git a/docs/test-stats.json b/docs/test-stats.json
index c07aa20..f9a5a82 100644
--- a/docs/test-stats.json
+++ b/docs/test-stats.json
@@ -1,5 +1,5 @@
{
- "tests": 1115,
- "files": 134,
- "updatedAt": "2026-07-14T15:02:28.946Z"
+ "tests": 1151,
+ "files": 139,
+ "updatedAt": "2026-07-17T02:31:16.474Z"
}
diff --git a/tools/mock-derbysoft/Dockerfile b/tools/mock-derbysoft/Dockerfile
new file mode 100644
index 0000000..cd96c78
--- /dev/null
+++ b/tools/mock-derbysoft/Dockerfile
@@ -0,0 +1,7 @@
+FROM node:20-alpine
+WORKDIR /app
+COPY package.json ./
+RUN npm install --production || true
+COPY server.mjs ./
+EXPOSE 4002
+CMD ["node", "server.mjs"]
diff --git a/tools/mock-derbysoft/package.json b/tools/mock-derbysoft/package.json
new file mode 100644
index 0000000..d7eedff
--- /dev/null
+++ b/tools/mock-derbysoft/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@telivityhaip/mock-derbysoft",
+ "version": "0.0.1",
+ "private": true,
+ "description": "Mock DerbySoft Property Connector API for development and testing",
+ "type": "module",
+ "scripts": {
+ "start": "node server.mjs",
+ "dev": "node --watch server.mjs"
+ }
+}
diff --git a/tools/mock-derbysoft/server.mjs b/tools/mock-derbysoft/server.mjs
new file mode 100644
index 0000000..62a6170
--- /dev/null
+++ b/tools/mock-derbysoft/server.mjs
@@ -0,0 +1,199 @@
+/**
+ * Mock DerbySoft Property Connector API
+ *
+ * REST/JSON with OAuth Bearer tokens. Mirrors PC Integration API paths used by
+ * HAIP's DerbySoft adapter for local development and tests.
+ *
+ * Usage:
+ * node server.mjs # starts on port 4002
+ * PORT=4003 node server.mjs # custom port
+ */
+
+import http from 'node:http';
+import { randomBytes } from 'node:crypto';
+
+const PORT = parseInt(process.env.PORT ?? '4002', 10);
+const CLIENT_ID = process.env.CLIENT_ID ?? 'haip_test';
+const CLIENT_SECRET = process.env.CLIENT_SECRET ?? 'test_password';
+
+const store = {
+ tokens: new Set(),
+ rates: [],
+ inventories: [],
+ availabilities: [],
+ hotels: [],
+ roomTypes: [],
+ resStatuses: [],
+ requests: [],
+};
+
+// Simple 15 req/s limiter (mirrors vendor)
+let tokens = 15;
+let lastRefill = Date.now();
+function allowRequest() {
+ const now = Date.now();
+ if (now - lastRefill >= 1000) {
+ tokens = 15;
+ lastRefill = now;
+ }
+ if (tokens <= 0) return false;
+ tokens -= 1;
+ return true;
+}
+
+function json(res, status, body) {
+ const payload = JSON.stringify(body);
+ res.writeHead(status, {
+ 'Content-Type': 'application/json;charset=utf-8',
+ 'Content-Length': Buffer.byteLength(payload),
+ });
+ res.end(payload);
+}
+
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString('utf8');
+ if (!raw) return resolve({});
+ try {
+ resolve(JSON.parse(raw));
+ } catch (e) {
+ reject(e);
+ }
+ });
+ req.on('error', reject);
+ });
+}
+
+function verifyBasic(req) {
+ const auth = req.headers.authorization ?? '';
+ if (!auth.startsWith('Basic ')) return false;
+ const decoded = Buffer.from(auth.slice(6), 'base64').toString('utf8');
+ const idx = decoded.indexOf(':');
+ if (idx < 0) return false;
+ return decoded.slice(0, idx) === CLIENT_ID && decoded.slice(idx + 1) === CLIENT_SECRET;
+}
+
+function verifyBearer(req) {
+ const auth = req.headers.authorization ?? '';
+ const m = /^Bearer\s+(.+)$/i.exec(auth);
+ if (!m) return false;
+ return store.tokens.has(m[1].trim());
+}
+
+function echoHeader(body) {
+ const h = body?.header ?? {};
+ return {
+ echoToken: h.echoToken ?? 'mock-echo',
+ timeStamp: new Date().toISOString(),
+ version: h.version ?? '0.1',
+ };
+}
+
+const server = http.createServer(async (req, res) => {
+ const url = new URL(req.url ?? '/', `http://localhost:${PORT}`);
+ const path = url.pathname;
+
+ if (req.method === 'GET' && path === '/health') {
+ return json(res, 200, { status: 'ok', service: 'mock-derbysoft' });
+ }
+ if (req.method === 'GET' && path === '/store') {
+ return json(res, 200, {
+ rates: store.rates.length,
+ inventories: store.inventories.length,
+ availabilities: store.availabilities.length,
+ hotels: store.hotels.length,
+ roomTypes: store.roomTypes.length,
+ resStatuses: store.resStatuses.length,
+ tokens: store.tokens.size,
+ });
+ }
+ if (req.method === 'POST' && path === '/reset') {
+ store.tokens.clear();
+ store.rates = [];
+ store.inventories = [];
+ store.availabilities = [];
+ store.hotels = [];
+ store.roomTypes = [];
+ store.resStatuses = [];
+ store.requests = [];
+ return json(res, 200, { reset: true });
+ }
+
+ if (!allowRequest()) {
+ return json(res, 429, {
+ header: { echoToken: 'rate-limit', timeStamp: new Date().toISOString(), version: '0.1' },
+ errorCode: 'TooManyRequests',
+ errorMessage: 'rate limit is 15 requests per second',
+ });
+ }
+
+ let body = {};
+ try {
+ if (req.method === 'POST' || req.method === 'PUT') body = await readBody(req);
+ } catch {
+ return json(res, 500, {
+ header: echoHeader({}),
+ errorCode: 'InvalidField',
+ errorMessage: 'Invalid JSON',
+ });
+ }
+
+ store.requests.push({ method: req.method, path, at: new Date().toISOString() });
+
+ // Token
+ if (req.method === 'POST' && path === '/pcapigateway/account/token') {
+ if (!verifyBasic(req)) {
+ return json(res, 401, {
+ header: echoHeader(body),
+ errorCode: 'Unauthorized',
+ errorMessage: 'Invalid client credentials',
+ });
+ }
+ const accessToken = randomBytes(24).toString('hex');
+ store.tokens.clear(); // new token invalidates old ones (vendor behavior)
+ store.tokens.add(accessToken);
+ return json(res, 200, { accessToken, tokenType: 'Bearer' });
+ }
+
+ // All other PC endpoints require Bearer
+ if (!verifyBearer(req)) {
+ return json(res, 401, {
+ header: echoHeader(body),
+ errorCode: 'Unauthorized',
+ errorMessage: 'Invalid token',
+ });
+ }
+
+ // Tunnel ARI
+ const tunnelMatch = path.match(/^\/pcapigateway\/tunnel\/[^/]+\/(rate|inventory|availability|resStatus)$/);
+ if (req.method === 'POST' && tunnelMatch) {
+ const kind = tunnelMatch[1];
+ if (kind === 'rate') store.rates.push(body);
+ else if (kind === 'inventory') store.inventories.push(body);
+ else if (kind === 'availability') store.availabilities.push(body);
+ else store.resStatuses.push(body);
+ return json(res, 200, { header: echoHeader(body) });
+ }
+
+ // Profile
+ const profileMatch = path.match(/^\/pcapigateway\/profile\/[^/]+\/(hotel|roomtype|rateplan|product)$/);
+ if (req.method === 'POST' && profileMatch) {
+ const kind = profileMatch[1];
+ if (kind === 'hotel') store.hotels.push(body);
+ else if (kind === 'roomtype') store.roomTypes.push(body);
+ return json(res, 200, { header: echoHeader(body) });
+ }
+
+ return json(res, 404, {
+ header: echoHeader(body),
+ errorCode: 'NotFound',
+ errorMessage: `No mock handler for ${req.method} ${path}`,
+ });
+});
+
+server.listen(PORT, () => {
+ console.log(`mock-derbysoft listening on :${PORT}`);
+});