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

Filter by extension

Filter by extension

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

<p align="center">
Expand Down Expand Up @@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire
| OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) |
| XML Processing | fast-xml-parser | Booking.com OTA XML protocol |
| Package Manager | pnpm workspaces | Monorepo management |
| Testing | Vitest (1520 tests across 215 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Testing | Vitest (1527 tests across 215 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 @@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests

```bash
# All tests (1520 tests across 215 test files)
# All tests (1527 tests across 215 test files)

# API tests only
pnpm --filter @telivityhaip/api test
Expand Down Expand Up @@ -1188,7 +1188,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 (1520 tests, 215 files)
pnpm test # Run all tests (1527 tests, 215 files)
pnpm lint # ESLint
```

Expand Down
114 changes: 108 additions & 6 deletions apps/api/src/modules/rate-plan/assert-sellable.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,38 @@ import { RatePlanService } from './rate-plan.service';
const PROPERTY = '11111111-1111-1111-1111-111111111111';
const RATE_PLAN = '22222222-2222-2222-2222-222222222222';
const CHECK_IN = '2026-07-10';
const CHECK_OUT = '2026-07-12'; // 2 nights
const CHECK_OUT = '2026-07-12'; // 2 nights: July 10 and July 11
const LAST_NIGHT = '2026-07-11';

/** db whose select().from().where() resolves to the given restriction rows. */
function svcWith(restrictions: any[]): RatePlanService {
const db = {
select: () => ({ from: () => ({ where: () => Promise.resolve(restrictions) }) }),
const DEFAULT_PLAN = {
id: RATE_PLAN,
propertyId: PROPERTY,
isActive: true,
validFrom: null,
validTo: null,
};

/**
* db whose select().from().where() returns the property-scoped plan or
* restriction rows according to the queried table.
*/
function dbFor(plan: any | null, restrictions: any[]) {
return {
select: () => ({
from: (table: { startDate?: unknown }) => ({
where: () =>
Promise.resolve(table.startDate !== undefined ? restrictions : plan ? [plan] : []),
}),
}),
};
return new RatePlanService(db as any);
}

function svcWith(restrictions: any[]): RatePlanService {
return new RatePlanService(dbFor(DEFAULT_PLAN, restrictions) as any);
}

function svcWithPlan(plan: any, restrictions: any[] = []): RatePlanService {
return new RatePlanService(dbFor(plan, restrictions) as any);
}

const span = { startDate: '2026-07-01', endDate: '2026-07-31' };
Expand Down Expand Up @@ -76,4 +100,82 @@ describe('RatePlanService.assertSellable', () => {
),
).resolves.toBeUndefined();
});

it('rejects an inactive rate plan', async () => {
await expect(
svcWithPlan({
id: RATE_PLAN,
propertyId: PROPERTY,
isActive: false,
validFrom: null,
validTo: null,
}).assertSellable(PROPERTY, RATE_PLAN, CHECK_IN, CHECK_OUT),
).rejects.toThrow(/inactive/);
});

it('rejects a stay that arrives before the rate plan valid-from date', async () => {
await expect(
svcWithPlan({
id: RATE_PLAN,
propertyId: PROPERTY,
isActive: true,
validFrom: '2026-07-11',
validTo: null,
}).assertSellable(PROPERTY, RATE_PLAN, CHECK_IN, CHECK_OUT),
).rejects.toThrow(/not valid for the complete stay/);
});

it('accepts a stay that arrives on the rate plan valid-from date', async () => {
await expect(
svcWithPlan({
id: RATE_PLAN,
propertyId: PROPERTY,
isActive: true,
validFrom: CHECK_IN,
validTo: null,
}).assertSellable(PROPERTY, RATE_PLAN, CHECK_IN, CHECK_OUT),
).resolves.toBeUndefined();
});

it('rejects a stay that consumes a night after the rate plan valid-to date', async () => {
await expect(
svcWithPlan({
id: RATE_PLAN,
propertyId: PROPERTY,
isActive: true,
validFrom: null,
validTo: CHECK_IN,
}).assertSellable(PROPERTY, RATE_PLAN, CHECK_IN, CHECK_OUT),
).rejects.toThrow(/not valid for the complete stay/);
});

it('accepts a stay whose last consumed night equals the inclusive valid-to date', async () => {
await expect(
svcWithPlan({
id: RATE_PLAN,
propertyId: PROPERTY,
isActive: true,
validFrom: null,
validTo: LAST_NIGHT,
}).assertSellable(PROPERTY, RATE_PLAN, CHECK_IN, CHECK_OUT),
).resolves.toBeUndefined();
});

it('accepts a stay that departs on valid-to (departure date is exclusive)', async () => {
await expect(
svcWithPlan({
id: RATE_PLAN,
propertyId: PROPERTY,
isActive: true,
validFrom: null,
validTo: CHECK_OUT,
}).assertSellable(PROPERTY, RATE_PLAN, CHECK_IN, CHECK_OUT),
).resolves.toBeUndefined();
});

it('rejects when the rate plan is not found at the caller-supplied property', async () => {
await expect(
svcWithPlan(null).assertSellable(PROPERTY, RATE_PLAN, CHECK_IN, CHECK_OUT),
).rejects.toThrow(/not found/);
});
});
33 changes: 29 additions & 4 deletions apps/api/src/modules/rate-plan/rate-plan.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,15 @@ export class RatePlanService {
) {}

/**
* Enforce rate-plan restrictions for a stay [checkIn, checkOut). Throws 400 if
* the plan is not sellable: stop-sell (closed), closed-to-arrival on the
* check-in date, closed-to-departure on the check-out date, or a min/max
* length-of-stay violation.
* Enforce rate-plan sellability for a stay [checkIn, checkOut). Throws 400 if
* the plan is inactive, the stay is outside validFrom/validTo, or a restriction
* blocks it: stop-sell (closed), closed-to-arrival on the check-in date,
* closed-to-departure on the check-out date, or a min/max length-of-stay
* violation.
*
* validTo is the inclusive last consumed night. Checkout is exclusive, so a
* stay departing the day after validTo is still sellable; a stay that consumes
* any night after validTo is not.
*
* The BOOK path MUST call this. SEARCH only *surfaces* restrictions (it doesn't
* hard-block CTA/CTD), so without this guard a direct create-reservation call —
Expand All @@ -63,6 +68,26 @@ export class RatePlanService {
throw new BadRequestException('Check-out must be after check-in');
}

// Plan lookup is scoped by both ids — never infer propertyId from the row.
const [plan] = await this.db
.select()
.from(ratePlans)
.where(and(eq(ratePlans.id, ratePlanId), eq(ratePlans.propertyId, propertyId)));
if (!plan) {
throw new NotFoundException(`Rate plan ${ratePlanId} not found`);
}
if (plan.isActive === false) {
throw new BadRequestException('Rate plan is inactive');
}

const lastConsumedNight = this.addDaysIso(checkOut, -1);
if (
(plan.validFrom && checkIn < plan.validFrom) ||
(plan.validTo && lastConsumedNight > plan.validTo)
) {
throw new BadRequestException('Rate plan is not valid for the complete stay');
}

// Restrictions overlapping the stay (scoped by property — multi-tenancy).
const restrictions = await this.db
.select()
Expand Down
4 changes: 2 additions & 2 deletions docs/test-stats.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"tests": 1520,
"tests": 1527,
"files": 215,
"updatedAt": "2026-08-16T00:29:05.317Z"
"updatedAt": "2026-08-20T14:00:58.884Z"
}
Loading