diff --git a/README.md b/README.md
index 6bbf789c..1a0146e1 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -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 (1598 tests across 221 test files) | Unit and integration tests || Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
+| Testing | Vitest (1602 tests across 223 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 |
@@ -642,7 +642,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests
```bash
-# All tests (1598 tests across 221 test files)
+# All tests (1602 tests across 223 test files)
# API tests only
pnpm --filter @telivityhaip/api test
@@ -1190,7 +1190,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 (1598 tests, 221 files)
+pnpm test # Run all tests (1602 tests, 223 files)
pnpm lint # ESLint
```
diff --git a/apps/api/src/common/crypto/confirmation-number.spec.ts b/apps/api/src/common/crypto/confirmation-number.spec.ts
new file mode 100644
index 00000000..e2e1abef
--- /dev/null
+++ b/apps/api/src/common/crypto/confirmation-number.spec.ts
@@ -0,0 +1,13 @@
+import { describe, expect, it, vi } from 'vitest';
+import { generateConfirmationNumber } from './confirmation-number';
+
+describe('generateConfirmationNumber', () => {
+ it('uses cryptographically random 128-bit entropy via an injectable seam', () => {
+ const entropy = vi.fn((bytes: number) => Buffer.alloc(bytes, 0xa5));
+
+ const confirmation = generateConfirmationNumber(entropy);
+
+ expect(entropy).toHaveBeenCalledWith(16);
+ expect(confirmation).toMatch(/^HAIP-[0-9A-HJKMNP-TV-Z]{32}$/);
+ });
+});
diff --git a/apps/api/src/common/crypto/confirmation-number.ts b/apps/api/src/common/crypto/confirmation-number.ts
new file mode 100644
index 00000000..67d56458
--- /dev/null
+++ b/apps/api/src/common/crypto/confirmation-number.ts
@@ -0,0 +1,29 @@
+import { randomBytes } from 'node:crypto';
+
+const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+
+export type ConfirmationEntropy = (bytes: number) => Uint8Array;
+
+/** A cryptographically random guest-facing bearer credential (128 bits of entropy). */
+export function generateConfirmationNumber(
+ entropy: ConfirmationEntropy = randomBytes,
+): string {
+ const bytes = entropy(16);
+ if (bytes.length !== 16) {
+ throw new Error('Confirmation entropy source must return 16 bytes');
+ }
+ let token = '';
+ for (const byte of bytes) {
+ token += CROCKFORD[byte & 0x1f];
+ token += CROCKFORD[(byte >> 5) & 0x1f];
+ }
+ return `HAIP-${token}`;
+}
+
+/** Crockford base32 token without the HAIP- prefix (for channel/connect prefixes). */
+export function generateConfirmationToken(
+ entropy: ConfirmationEntropy = randomBytes,
+): string {
+ const confirmation = generateConfirmationNumber(entropy);
+ return confirmation.startsWith('HAIP-') ? confirmation.slice(5) : confirmation;
+}
diff --git a/apps/api/src/common/date/property-business-date.spec.ts b/apps/api/src/common/date/property-business-date.spec.ts
new file mode 100644
index 00000000..177f4daf
--- /dev/null
+++ b/apps/api/src/common/date/property-business-date.spec.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+import { calendarDateInTimeZone } from './property-business-date';
+
+describe('calendarDateInTimeZone', () => {
+ it('returns the property calendar date at a UTC midnight boundary', () => {
+ // 2024-06-02 00:30 UTC is still 2024-06-01 evening in US Eastern.
+ const instant = new Date('2024-06-02T00:30:00.000Z');
+ expect(calendarDateInTimeZone(instant, 'America/New_York')).toBe('2024-06-01');
+ expect(calendarDateInTimeZone(instant, 'UTC')).toBe('2024-06-02');
+ });
+
+ it('falls back to UTC when the timezone is invalid', () => {
+ const instant = new Date('2024-06-02T12:00:00.000Z');
+ expect(calendarDateInTimeZone(instant, 'Not/A_Timezone')).toBe('2024-06-02');
+ });
+});
diff --git a/apps/api/src/common/date/property-business-date.ts b/apps/api/src/common/date/property-business-date.ts
new file mode 100644
index 00000000..20f05b8f
--- /dev/null
+++ b/apps/api/src/common/date/property-business-date.ts
@@ -0,0 +1,18 @@
+/** Resolve a YYYY-MM-DD calendar date in an IANA timezone without server-UTC leakage. */
+export function calendarDateInTimeZone(now: Date, timeZone: string): string {
+ try {
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).formatToParts(now);
+ const value = Object.fromEntries(parts.map((part) => [part.type, part.value]));
+ if (value['year'] && value['month'] && value['day']) {
+ return `${value['year']}-${value['month']}-${value['day']}`;
+ }
+ } catch {
+ // Invalid legacy timezone values fall back to UTC, matching other PMS dates.
+ }
+ return now.toISOString().slice(0, 10);
+}
diff --git a/apps/api/src/common/validation/is-money-string.validator.spec.ts b/apps/api/src/common/validation/is-money-string.validator.spec.ts
index 90e98b5c..28028693 100644
--- a/apps/api/src/common/validation/is-money-string.validator.spec.ts
+++ b/apps/api/src/common/validation/is-money-string.validator.spec.ts
@@ -15,6 +15,11 @@ class Signed {
amount!: string;
}
+class Bounded {
+ @IsMoneyString({ maximum: '100.00' })
+ amount!: string;
+}
+
async function fails(obj: any): Promise