diff --git a/.env.example b/.env.example index ece62dc5..8a6acf07 100644 --- a/.env.example +++ b/.env.example @@ -67,10 +67,17 @@ KEYCLOAK_URL=http://localhost:8080 # GOOGLE_WALLET_SERVICE_ACCOUNT_PRIVATE_KEY= # Door lock vendor (Nuki / TTLock / Salto KS / webhook / console). Per-room # rooms.external_lock_id overrides the env default lock/site id. +# In production/staging, missing vendors fail closed (no console PIN fallback). # DOOR_LOCK_PROVIDER=console # NUKI_API_TOKEN= # NUKI_SMARTLOCK_ID= +# AES-256 key (64 hex chars) for encrypting guest ID photos / ID numbers at rest. +# Required in production/staging (assertSecureConfig). +# ID_ENCRYPTION_KEY= # LOCAL_STORAGE_PATH=./data/uploads +# Tight rate limit for POST /guest-check-in/request-link (default 10/min). +# CHECK_IN_RATE_LIMIT_MAX=10 +# CHECK_IN_RATE_LIMIT_WINDOW_MS=60000 # Connect API (OTAIP agents) — comma-separated list of valid API keys. # When AUTH_ENABLED != 'false' and this is unset/empty, ConnectController diff --git a/README.md b/README.md index ee9360f3..82533296 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ NestJS PostgreSQL Apache 2.0 License - 2584 Tests Passing 12 AI Agents + 2599 Tests Passing 12 AI Agents

@@ -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 (2584 passing tests across 301 files with passing tests) | Unit and integration tests | +| Testing | Vitest (2599 passing tests across 302 files with passing tests) | Unit and integration tests | | Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds | | Containers | Docker + docker-compose | Local dev and production deployment | | CI/CD | GitHub Actions | Automated testing, builds, and releases | @@ -648,7 +648,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment. ### Run tests ```bash -# Passing-test count: 2584 test cases across 301 files (skipped excluded) +# Passing-test count: 2599 test cases across 302 files (skipped excluded) # API tests only pnpm --filter @telivityhaip/api test @@ -1197,7 +1197,7 @@ HAIP is built in public and contributions are welcome. pnpm install # Install dependencies pnpm build # Build all workspace packages pnpm dev # Start API in dev mode (hot reload) -pnpm test # Run all tests (2584 passing, 301 files with passes; skipped excluded) +pnpm test # Run all tests (2599 passing, 302 files with passes; skipped excluded) pnpm lint # ESLint ``` diff --git a/apps/api/src/common/config/assert-secure-config.spec.ts b/apps/api/src/common/config/assert-secure-config.spec.ts index c7074bf3..31242c09 100644 --- a/apps/api/src/common/config/assert-secure-config.spec.ts +++ b/apps/api/src/common/config/assert-secure-config.spec.ts @@ -21,6 +21,7 @@ describe('assertSecureConfig', () => { AUTH_ENABLED: 'true', STRIPE_MODE: 'mock', PAYMENT_GATEWAY: 'adyen', + ID_ENCRYPTION_KEY: 'a'.repeat(64), } as any), ).not.toThrow(); }); @@ -35,7 +36,20 @@ describe('assertSecureConfig', () => { ).not.toThrow(); }); + it('refuses to boot in production without ID_ENCRYPTION_KEY', () => { + expect(() => + assertSecureConfig({ NODE_ENV: 'production', AUTH_ENABLED: 'true', STRIPE_MODE: 'live' } as any), + ).toThrow(/ID_ENCRYPTION_KEY/); + }); + it('passes a properly-configured production', () => { - expect(() => assertSecureConfig({ NODE_ENV: 'production', AUTH_ENABLED: 'true', STRIPE_MODE: 'live' } as any)).not.toThrow(); + expect(() => + assertSecureConfig({ + NODE_ENV: 'production', + AUTH_ENABLED: 'true', + STRIPE_MODE: 'live', + ID_ENCRYPTION_KEY: 'a'.repeat(64), + } as any), + ).not.toThrow(); }); }); diff --git a/apps/api/src/common/config/assert-secure-config.ts b/apps/api/src/common/config/assert-secure-config.ts index 7f94bdb2..9004f19a 100644 --- a/apps/api/src/common/config/assert-secure-config.ts +++ b/apps/api/src/common/config/assert-secure-config.ts @@ -22,6 +22,9 @@ export function assertSecureConfig(env: NodeJS.ProcessEnv = process.env): void { if (isPaymentGatewayMockMode(env)) { problems.push('PAYMENT_GATEWAY=mock (or STRIPE_MODE=mock when PAYMENT_GATEWAY unset)'); } + if (!env['ID_ENCRYPTION_KEY']?.trim()) { + problems.push('ID_ENCRYPTION_KEY is unset (required to encrypt guest ID photos at rest)'); + } if (problems.length > 0) { throw new Error( `Refusing to start in production with insecure config: ${problems.join(', ')}. ` + diff --git a/apps/api/src/modules/door-lock/lock-provider.factory.spec.ts b/apps/api/src/modules/door-lock/lock-provider.factory.spec.ts index f9dfd0b9..892c9fd5 100644 --- a/apps/api/src/modules/door-lock/lock-provider.factory.spec.ts +++ b/apps/api/src/modules/door-lock/lock-provider.factory.spec.ts @@ -12,15 +12,22 @@ function stubProvider(name: string, configured: boolean): LockProvider { } describe('LockProviderFactory', () => { - const prev = process.env['DOOR_LOCK_PROVIDER']; + const prevProvider = process.env['DOOR_LOCK_PROVIDER']; + const prevEnv = process.env['NODE_ENV']; + const prevInsecure = process.env['HAIP_ALLOW_INSECURE']; afterEach(() => { - if (prev === undefined) delete process.env['DOOR_LOCK_PROVIDER']; - else process.env['DOOR_LOCK_PROVIDER'] = prev; + if (prevProvider === undefined) delete process.env['DOOR_LOCK_PROVIDER']; + else process.env['DOOR_LOCK_PROVIDER'] = prevProvider; + if (prevEnv === undefined) delete process.env['NODE_ENV']; + else process.env['NODE_ENV'] = prevEnv; + if (prevInsecure === undefined) delete process.env['HAIP_ALLOW_INSECURE']; + else process.env['HAIP_ALLOW_INSECURE'] = prevInsecure; }); it('defaults to webhook when configured', () => { delete process.env['DOOR_LOCK_PROVIDER']; + process.env['NODE_ENV'] = 'test'; const webhook = stubProvider('webhook', true); const consoleProvider = stubProvider('console', true); const factory = new LockProviderFactory([webhook, consoleProvider]); @@ -29,22 +36,53 @@ describe('LockProviderFactory', () => { it('selects nuki when configured', () => { process.env['DOOR_LOCK_PROVIDER'] = 'nuki'; + process.env['NODE_ENV'] = 'test'; const nuki = stubProvider('nuki', true); const consoleProvider = stubProvider('console', true); const factory = new LockProviderFactory([nuki, consoleProvider]); expect(factory.resolve()).toBe(nuki); }); - it('falls back to console when vendor is not configured', () => { + it('falls back to console when vendor is not configured in non-production', () => { process.env['DOOR_LOCK_PROVIDER'] = 'ttlock'; + process.env['NODE_ENV'] = 'development'; const ttlock = stubProvider('ttlock', false); const consoleProvider = stubProvider('console', true); const factory = new LockProviderFactory([ttlock, consoleProvider]); expect(factory.resolve()).toBe(consoleProvider); }); - it('falls back to console for unknown provider names', () => { + it('falls back to console for unknown provider names in non-production', () => { process.env['DOOR_LOCK_PROVIDER'] = 'unknown-vendor'; + process.env['NODE_ENV'] = 'test'; + const consoleProvider = stubProvider('console', true); + const factory = new LockProviderFactory([consoleProvider]); + expect(factory.resolve()).toBe(consoleProvider); + }); + + it('fails closed in production when vendor is not configured', () => { + process.env['DOOR_LOCK_PROVIDER'] = 'nuki'; + process.env['NODE_ENV'] = 'production'; + delete process.env['HAIP_ALLOW_INSECURE']; + const nuki = stubProvider('nuki', false); + const consoleProvider = stubProvider('console', true); + const factory = new LockProviderFactory([nuki, consoleProvider]); + expect(() => factory.resolve()).toThrow(/not configured|refusing console/i); + }); + + it('refuses explicit console provider in production', () => { + process.env['DOOR_LOCK_PROVIDER'] = 'console'; + process.env['NODE_ENV'] = 'production'; + delete process.env['HAIP_ALLOW_INSECURE']; + const consoleProvider = stubProvider('console', true); + const factory = new LockProviderFactory([consoleProvider]); + expect(() => factory.resolve()).toThrow(/not allowed in production/i); + }); + + it('allows console in production when HAIP_ALLOW_INSECURE=true', () => { + process.env['DOOR_LOCK_PROVIDER'] = 'console'; + process.env['NODE_ENV'] = 'production'; + process.env['HAIP_ALLOW_INSECURE'] = 'true'; const consoleProvider = stubProvider('console', true); const factory = new LockProviderFactory([consoleProvider]); expect(factory.resolve()).toBe(consoleProvider); diff --git a/apps/api/src/modules/door-lock/lock-provider.factory.ts b/apps/api/src/modules/door-lock/lock-provider.factory.ts index 0e1296eb..1a39bbc9 100644 --- a/apps/api/src/modules/door-lock/lock-provider.factory.ts +++ b/apps/api/src/modules/door-lock/lock-provider.factory.ts @@ -5,7 +5,8 @@ import { LOCK_PROVIDERS, type LockProvider } from './lock-provider.interface'; * Selects the active door-lock adapter from env and registered providers. * * `DOOR_LOCK_PROVIDER` names the adapter (`webhook`, `nuki`, `ttlock`, `salto_ks`, `console`). - * When the requested vendor is not configured, falls back to `console` (logged-only PIN). + * In production/staging (without HAIP_ALLOW_INSECURE), missing vendors fail closed — + * no silent console PIN fallback. Dev/test may fall back to console for local demos. */ @Injectable() export class LockProviderFactory { @@ -33,26 +34,56 @@ export class LockProviderFactory { return this.resolveNamed(name); } + /** True when console fallback / explicit console must be refused. */ + private failClosed(): boolean { + const nodeEnv = process.env['NODE_ENV']; + const productionLike = nodeEnv === 'production' || nodeEnv === 'staging'; + if (!productionLike) return false; + return process.env['HAIP_ALLOW_INSECURE'] !== 'true'; + } + private resolveNamed(requestedRaw: string): LockProvider { const requested = requestedRaw.replace(/-/g, '_'); const byName = new Map(this.providers.map((p) => [p.name, p])); const chosen = byName.get(requested); if (chosen?.isConfigured()) { + if (requested === 'console' && this.failClosed()) { + throw new Error('Console door lock provider is not allowed in production'); + } this.logger.log(`Door lock provider: ${chosen.name}`); return chosen; } + if (requested === 'console') { + const consoleProvider = byName.get('console'); + if (!consoleProvider) throw new Error('Console lock provider not registered'); + if (this.failClosed()) { + throw new Error('Console door lock provider is not allowed in production'); + } + this.logger.warn('Using console door lock provider (non-production)'); + return consoleProvider; + } + if (chosen && !chosen.isConfigured()) { - this.logger.warn( - `Door lock provider "${requested}" is not configured — falling back to console adapter`, - ); + this.logger.warn(`Door lock provider "${requested}" is not configured`); } else if (!chosen) { - this.logger.warn(`Unknown door lock provider "${requested}" — falling back to console adapter`); + this.logger.warn(`Unknown door lock provider "${requested}"`); + } + + if (this.failClosed()) { + throw new Error( + `Door lock provider "${requested}" is not configured — refusing console fallback in production`, + ); } const consoleProvider = byName.get('console'); - if (consoleProvider) return consoleProvider; + if (consoleProvider) { + this.logger.warn( + `Falling back to console adapter for "${requested}" (non-production only)`, + ); + return consoleProvider; + } const webhook = byName.get('webhook'); if (webhook) return webhook; diff --git a/apps/api/src/modules/guest-check-in/check-in-magic.guard.spec.ts b/apps/api/src/modules/guest-check-in/check-in-magic.guard.spec.ts index d1fbe07f..505eec73 100644 --- a/apps/api/src/modules/guest-check-in/check-in-magic.guard.spec.ts +++ b/apps/api/src/modules/guest-check-in/check-in-magic.guard.spec.ts @@ -98,10 +98,43 @@ describe('CheckInMagicGuard', () => { 'other-secret', ); const req: any = { + method: 'GET', headers: { 'x-check-in-token': good }, query: { token: bad }, }; expect(guard.canActivate(ctx(req))).toBe(true); expect(req.checkIn.reservationId).toBe('from-header'); }); + + it('accepts query token on GET for magic-link bootstrap', () => { + const guard = new CheckInMagicGuard(config); + const token = signCheckInToken( + { + v: 1, + reservationId: 'r1', + propertyId: 'p1', + exp: Math.floor(Date.now() / 1000) + 3600, + }, + secret, + ); + const req: any = { method: 'GET', headers: {}, query: { token } }; + expect(guard.canActivate(ctx(req))).toBe(true); + expect(req.checkIn.reservationId).toBe('r1'); + }); + + it('rejects query token on mutating POST', () => { + const guard = new CheckInMagicGuard(config); + const token = signCheckInToken( + { + v: 1, + reservationId: 'r1', + propertyId: 'p1', + exp: Math.floor(Date.now() / 1000) + 3600, + }, + secret, + ); + expect(() => + guard.canActivate(ctx({ method: 'POST', headers: {}, query: { token } })), + ).toThrow(UnauthorizedException); + }); }); diff --git a/apps/api/src/modules/guest-check-in/check-in-magic.guard.ts b/apps/api/src/modules/guest-check-in/check-in-magic.guard.ts index fa7020d8..8f31d769 100644 --- a/apps/api/src/modules/guest-check-in/check-in-magic.guard.ts +++ b/apps/api/src/modules/guest-check-in/check-in-magic.guard.ts @@ -35,8 +35,13 @@ export class CheckInMagicGuard implements CanActivate { if (typeof auth === 'string' && auth.toLowerCase().startsWith('bearer ')) { return auth.slice(7).trim(); } - const q = req.query?.token; - if (typeof q === 'string' && q.length > 0) return q; + // Query tokens are bootstrap-only (magic link / payment return). Mutating + // methods must use the header after the booking UI strips ?token=. + const method = String(req.method || 'GET').toUpperCase(); + if (method === 'GET') { + const q = req.query?.token; + if (typeof q === 'string' && q.length > 0) return q; + } return undefined; } } diff --git a/apps/api/src/modules/guest-check-in/check-in-throttle.guard.ts b/apps/api/src/modules/guest-check-in/check-in-throttle.guard.ts new file mode 100644 index 00000000..b43da20c --- /dev/null +++ b/apps/api/src/modules/guest-check-in/check-in-throttle.guard.ts @@ -0,0 +1,71 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + HttpException, + HttpStatus, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +/** + * Tight per-route rate limiter for public check-in link re-request + * (`POST /guest-check-in/request-link`). Keyed by IP + propertyId. + * + * Tunable: CHECK_IN_RATE_LIMIT_MAX (default 10) per CHECK_IN_RATE_LIMIT_WINDOW_MS + * (default 60000). Disabled when NODE_ENV=test or RATE_LIMIT_DISABLED=true. + */ +@Injectable() +export class CheckInThrottleGuard implements CanActivate { + private readonly hits = new Map(); + private readonly max: number; + private readonly windowMs: number; + private readonly disabled: boolean; + private readonly trustProxy: boolean; + + constructor(private readonly configService: ConfigService) { + this.max = Number(this.configService.get('CHECK_IN_RATE_LIMIT_MAX', '10')) || 10; + this.windowMs = + Number(this.configService.get('CHECK_IN_RATE_LIMIT_WINDOW_MS', '60000')) || 60000; + this.disabled = + process.env['NODE_ENV'] === 'test' || + this.configService.get('RATE_LIMIT_DISABLED', 'false') === 'true'; + this.trustProxy = + this.configService.get('RATE_LIMIT_TRUST_PROXY', 'false') === 'true'; + } + + canActivate(context: ExecutionContext): boolean { + if (this.disabled) return true; + const req = context.switchToHttp().getRequest(); + if (!req) return true; + + const propertyId = req.body?.propertyId ?? 'unknown'; + const key = `${this.clientIp(req)}:${propertyId}`; + const now = Date.now(); + const entry = this.hits.get(key); + if (!entry || now >= entry.resetAt) { + this.hits.set(key, { count: 1, resetAt: now + this.windowMs }); + this.sweep(now); + return true; + } + entry.count++; + if (entry.count > this.max) { + throw new HttpException('Too many check-in link requests', HttpStatus.TOO_MANY_REQUESTS); + } + return true; + } + + private clientIp(req: any): string { + if (this.trustProxy) { + const fwd = req.headers?.['x-forwarded-for']; + if (typeof fwd === 'string' && fwd.length > 0) return fwd.split(',')[0]!.trim(); + } + return req.ip ?? req.socket?.remoteAddress ?? 'unknown'; + } + + private sweep(now: number): void { + if (this.hits.size < 10_000) return; + for (const [k, v] of this.hits) { + if (now >= v.resetAt) this.hits.delete(k); + } + } +} diff --git a/apps/api/src/modules/guest-check-in/check-in-token.spec.ts b/apps/api/src/modules/guest-check-in/check-in-token.spec.ts index 8a88aedf..23991d3d 100644 --- a/apps/api/src/modules/guest-check-in/check-in-token.spec.ts +++ b/apps/api/src/modules/guest-check-in/check-in-token.spec.ts @@ -1,9 +1,19 @@ import { describe, expect, it } from 'vitest'; -import { signCheckInToken, verifyCheckInToken } from './check-in-token'; +import { + defaultTokenTtlSeconds, + maxTokenTtlSeconds, + signCheckInToken, + verifyCheckInToken, +} from './check-in-token'; describe('check-in-token', () => { const secret = 'test-secret-for-check-in'; + it('uses a 48h default TTL capped at 7 days', () => { + expect(defaultTokenTtlSeconds()).toBe(48 * 60 * 60); + expect(maxTokenTtlSeconds()).toBe(7 * 24 * 60 * 60); + }); + it('round-trips a valid payload', () => { const payload = { v: 1 as const, diff --git a/apps/api/src/modules/guest-check-in/check-in-token.ts b/apps/api/src/modules/guest-check-in/check-in-token.ts index 7b2b1e67..7946b935 100644 --- a/apps/api/src/modules/guest-check-in/check-in-token.ts +++ b/apps/api/src/modules/guest-check-in/check-in-token.ts @@ -56,7 +56,12 @@ export function verifyCheckInToken(token: string, secret: string): CheckInTokenP } } +/** Default magic-link lifetime (48 hours). Staff may request up to 7 days. */ export function defaultTokenTtlSeconds(): number { - // 14 days — covers typical pre-arrival window. - return 14 * 24 * 60 * 60; + return 48 * 60 * 60; +} + +/** Upper bound for staff-issued check-in link TTL (7 days). */ +export function maxTokenTtlSeconds(): number { + return 7 * 24 * 60 * 60; } diff --git a/apps/api/src/modules/guest-check-in/dto/guest-check-in.dto.ts b/apps/api/src/modules/guest-check-in/dto/guest-check-in.dto.ts index 9d8013f9..50ded6b8 100644 --- a/apps/api/src/modules/guest-check-in/dto/guest-check-in.dto.ts +++ b/apps/api/src/modules/guest-check-in/dto/guest-check-in.dto.ts @@ -6,6 +6,7 @@ import { IsOptional, IsString, IsUUID, + Max, MaxLength, Min, } from 'class-validator'; @@ -112,14 +113,19 @@ export class IssueCheckInLinkDto { @IsUUID() reservationId!: string; - @ApiPropertyOptional({ description: 'Token TTL in seconds (default 14 days)' }) + @ApiPropertyOptional({ description: 'Token TTL in seconds (default 48 hours, max 7 days)' }) @IsOptional() @IsNumber() @Min(60) + @Max(7 * 24 * 60 * 60) ttlSeconds?: number; } export class RequestCheckInLinkDto { + @ApiProperty({ description: 'Property id (scopes confirmation lookup to this hotel)' }) + @IsUUID() + propertyId!: string; + @ApiProperty({ description: 'Reservation confirmation number' }) @IsString() @MaxLength(40) diff --git a/apps/api/src/modules/guest-check-in/guest-check-in.controller.ts b/apps/api/src/modules/guest-check-in/guest-check-in.controller.ts index 31042135..3379070e 100644 --- a/apps/api/src/modules/guest-check-in/guest-check-in.controller.ts +++ b/apps/api/src/modules/guest-check-in/guest-check-in.controller.ts @@ -15,6 +15,7 @@ import type { Response } from 'express'; import { Public } from '../auth/public.decorator'; import { RequirePermissions } from '../auth/permissions.decorator'; import { CheckInMagicGuard, type CheckInPrincipal } from './check-in-magic.guard'; +import { CheckInThrottleGuard } from './check-in-throttle.guard'; import { GuestCheckInService } from './guest-check-in.service'; import { GuestAcceptUpsellDto, @@ -46,6 +47,7 @@ export class GuestCheckInController { @Post('request-link') @Public() + @UseGuards(CheckInThrottleGuard) @ApiOperation({ summary: 'Guest: request a new check-in link by confirmation number + email', }) diff --git a/apps/api/src/modules/guest-check-in/guest-check-in.module.ts b/apps/api/src/modules/guest-check-in/guest-check-in.module.ts index 73343aab..7b55d45c 100644 --- a/apps/api/src/modules/guest-check-in/guest-check-in.module.ts +++ b/apps/api/src/modules/guest-check-in/guest-check-in.module.ts @@ -8,6 +8,7 @@ import { EmailModule } from '../agent/guest-comms/email.module'; import { GuestCheckInController } from './guest-check-in.controller'; import { GuestCheckInService } from './guest-check-in.service'; import { CheckInMagicGuard } from './check-in-magic.guard'; +import { CheckInThrottleGuard } from './check-in-throttle.guard'; import { WalletPassService } from './wallet-pass.service'; @Module({ @@ -20,7 +21,12 @@ import { WalletPassService } from './wallet-pass.service'; EmailModule, ], controllers: [GuestCheckInController], - providers: [GuestCheckInService, CheckInMagicGuard, WalletPassService], + providers: [ + GuestCheckInService, + CheckInMagicGuard, + CheckInThrottleGuard, + WalletPassService, + ], exports: [GuestCheckInService], }) export class GuestCheckInModule {} diff --git a/apps/api/src/modules/guest-check-in/guest-check-in.service.spec.ts b/apps/api/src/modules/guest-check-in/guest-check-in.service.spec.ts index bc4e4be9..893b1399 100644 --- a/apps/api/src/modules/guest-check-in/guest-check-in.service.spec.ts +++ b/apps/api/src/modules/guest-check-in/guest-check-in.service.spec.ts @@ -136,12 +136,14 @@ describe('GuestCheckInService', () => { }), }; config = { - get: vi.fn((key: string) => { + get: vi.fn((key: string, def?: string) => { if (key === 'CHECK_IN_MAGIC_SECRET') return 'test-secret'; if (key === 'NODE_ENV') return 'test'; if (key === 'STRIPE_PUBLISHABLE_KEY') return null; if (key === 'BOOKING_APP_ORIGIN') return 'http://localhost:5174'; - return undefined; + if (key === 'PAYMENT_GATEWAY') return undefined; + if (key === 'STRIPE_MODE') return def ?? 'mock'; + return def; }), }; @@ -239,6 +241,25 @@ describe('GuestCheckInService', () => { ).rejects.toThrow(BadRequestException); }); + it('rejects France fiche pre-register when nationality is empty', async () => { + mockLoadContext({ guest: { ...baseGuest, nationality: null as any } }); + await expect( + service.preRegister(principal, { + registrationSigned: true, + registrationData: { + firstName: 'Ada', + lastName: 'Lovelace', + email: 'ada@example.com', + phone: '+331', + address: 'Paris', + dateOfBirth: '1990-01-01', + placeOfBirth: 'London', + }, + idImageBase64: `data:image/jpeg;base64,${Buffer.alloc(64, 1).toString('base64')}`, + }), + ).rejects.toThrow(/nationality/i); + }); + it('stores ID photo via StorageService and persists imageMediaId', async () => { mockLoadContext(); reservations.preRegister.mockResolvedValue({ @@ -280,6 +301,16 @@ describe('GuestCheckInService', () => { }); it('pay passes redirect URLs and returns nextAction without treating failure as success', async () => { + const prevGateway = process.env['PAYMENT_GATEWAY']; + process.env['PAYMENT_GATEWAY'] = 'redsys'; + config.get.mockImplementation((key: string, def?: string) => { + if (key === 'PAYMENT_GATEWAY') return 'redsys'; + if (key === 'CHECK_IN_MAGIC_SECRET') return 'test-secret'; + if (key === 'STRIPE_MODE') return 'live'; + if (key === 'BOOKING_APP_ORIGIN') return 'http://localhost:5174'; + return def; + }); + mockLoadContext({ reservation: { registrationSubmittedAt: new Date(), @@ -324,21 +355,64 @@ describe('GuestCheckInService', () => { return chain([]); }); - const result = await service.pay(principal, { - amount: 50, - gatewayPaymentToken: 'redsys_redirect', - gatewayProvider: 'redsys', - returnUrl: 'https://book.example/check-in?token=abc&step=ready', + try { + const result = await service.pay(principal, { + amount: 50, + gatewayPaymentToken: 'redsys_redirect', + gatewayProvider: 'redsys', + returnUrl: 'https://book.example/check-in?token=abc&step=ready', + }); + + expect(payments.authorizePayment).toHaveBeenCalledWith( + expect.objectContaining({ + redirectUrlOk: 'https://book.example/check-in?token=abc&step=ready', + redirectUrlKo: 'https://book.example/check-in?token=abc&step=ready', + gatewayProvider: 'redsys', + }), + ); + expect(result.nextAction?.url).toContain('sis.example'); + } finally { + if (prevGateway === undefined) delete process.env['PAYMENT_GATEWAY']; + else process.env['PAYMENT_GATEWAY'] = prevGateway; + } + }); + + it('rejects mock gatewayProvider outside mock mode', async () => { + const prevGateway = process.env['PAYMENT_GATEWAY']; + process.env['PAYMENT_GATEWAY'] = 'stripe'; + config.get.mockImplementation((key: string, def?: string) => { + if (key === 'PAYMENT_GATEWAY') return 'stripe'; + if (key === 'STRIPE_MODE') return 'live'; + return def; }); + mockLoadContext({ + folios: [{ id: 'folio-1', status: 'open', balance: '10.00' }], + }); + try { + await expect( + service.pay(principal, { + amount: 10, + gatewayPaymentToken: 'tok_demo', + gatewayProvider: 'mock', + }), + ).rejects.toThrow(/Mock payment gateway is not enabled/i); + } finally { + if (prevGateway === undefined) delete process.env['PAYMENT_GATEWAY']; + else process.env['PAYMENT_GATEWAY'] = prevGateway; + } + }); - expect(payments.authorizePayment).toHaveBeenCalledWith( - expect.objectContaining({ - redirectUrlOk: 'https://book.example/check-in?token=abc&step=ready', - redirectUrlKo: 'https://book.example/check-in?token=abc&step=ready', - gatewayProvider: 'redsys', + it('rejects payment amount above folio balance', async () => { + mockLoadContext({ + folios: [{ id: 'folio-1', status: 'open', balance: '10.00' }], + }); + await expect( + service.pay(principal, { + amount: 50, + gatewayPaymentToken: 'tok_demo', + gatewayProvider: 'mock', }), - ); - expect(result.nextAction?.url).toContain('sis.example'); + ).rejects.toThrow(/exceeds outstanding balance/i); }); it('pay failure does not call getSession success path inconsistently', async () => { @@ -355,6 +429,34 @@ describe('GuestCheckInService', () => { ).rejects.toThrow(/Authorization failed|declined/i); }); + it('getAccess refuses checked_out stays and suppresses console PIN in smart_lock mode', async () => { + mockLoadContext({ + reservation: { status: 'checked_out' }, + }); + await expect(service.getAccess(principal)).rejects.toThrow(/no longer available/i); + + mockLoadContext({ + reservation: { + status: 'checked_in', + roomId: 'room-1', + checkedInAt: new Date(), + }, + room: { id: 'room-1', number: '101', status: 'occupied' }, + property: { + settings: { + onlineCheckIn: { accessMode: 'smart_lock', walletEnabled: true }, + }, + }, + }); + doorLock.findByReservation.mockResolvedValue({ + accessCode: '999999', + provider: 'console', + }); + const access = await service.getAccess(principal); + expect(access.accessCode).toBeNull(); + expect(access.accessUnavailableReason).toMatch(/unavailable/i); + }); + it('findAssignableRooms excludes rooms held by overlapping assigned stays', async () => { const rooms = [ { id: 'room-free', number: '102', status: 'vacant_clean' }, diff --git a/apps/api/src/modules/guest-check-in/guest-check-in.service.ts b/apps/api/src/modules/guest-check-in/guest-check-in.service.ts index 79853a10..0523767e 100644 --- a/apps/api/src/modules/guest-check-in/guest-check-in.service.ts +++ b/apps/api/src/modules/guest-check-in/guest-check-in.service.ts @@ -25,11 +25,20 @@ import { StorageService } from '../media/storage/storage.service'; import { EmailService } from '../agent/guest-comms/email.service'; import { defaultTokenTtlSeconds, + maxTokenTtlSeconds, signCheckInToken, } from './check-in-token'; import { resolveCheckInMagicSecret } from './check-in-secret'; import { buildCheckInAppUrl } from './check-in-app-url'; import { WalletPassService } from './wallet-pass.service'; +import { + decryptIdImageBuffer, + encryptIdImageBuffer, +} from './id-image-encryption'; +import { + isPaymentGatewayMockMode, + resolvePaymentGatewayProvider, +} from '../payment/payment-gateway.factory'; import type { GuestAcceptUpsellDto, GuestPayDto, @@ -95,7 +104,10 @@ export class GuestCheckInService { .where(eq(properties.id, propertyId)); if (!property) throw new NotFoundException('Property not found'); - const ttl = ttlSeconds ?? defaultTokenTtlSeconds(); + const ttl = Math.min( + Math.max(ttlSeconds ?? defaultTokenTtlSeconds(), 60), + maxTokenTtlSeconds(), + ); const exp = Math.floor(Date.now() / 1000) + ttl; const token = signCheckInToken( { v: 1, reservationId, propertyId, exp }, @@ -381,6 +393,7 @@ export class GuestCheckInService { let imageMediaId: string | undefined; let imageStorageKey: string | undefined; + let imageEncrypted = false; if (dto.idImageBase64) { const stored = await this.storeIdImage( principal.propertyId, @@ -389,6 +402,7 @@ export class GuestCheckInService { ); imageMediaId = stored.mediaId; imageStorageKey = stored.storageKey; + imageEncrypted = stored.encrypted; } else if (ctx.online.requireIdPhoto !== false) { throw new BadRequestException('ID photo is required'); } @@ -427,6 +441,7 @@ export class GuestCheckInService { imageMediaId, imageStorageKey, imageStored: true, + imageEncrypted, }, updatedAt: new Date(), }) @@ -462,7 +477,7 @@ export class GuestCheckInService { propertyId: string, reservationId: string, dataUrlOrBase64: string, - ): Promise<{ mediaId: string; storageKey: string }> { + ): Promise<{ mediaId: string; storageKey: string; encrypted: boolean }> { const match = /^data:([^;]+);base64,(.+)$/i.exec(dataUrlOrBase64); const contentType = match?.[1] || 'image/jpeg'; const b64 = match?.[2] || dataUrlOrBase64; @@ -473,14 +488,19 @@ export class GuestCheckInService { if (buffer.length > 8 * 1024 * 1024) { throw new BadRequestException('ID photo must be under 8MB'); } + const sealed = encryptIdImageBuffer(buffer, contentType); const ext = contentType.includes('png') ? 'png' : contentType.includes('webp') ? 'webp' : 'jpg'; - const put = await this.storage.put(buffer, { + const put = await this.storage.put(sealed.buffer, { propertyId, - contentType, - filename: `guest-id-${reservationId}.${ext}`, + contentType: sealed.encrypted ? 'application/octet-stream' : contentType, + filename: `guest-id-${reservationId}.${sealed.encrypted ? 'bin' : ext}`, }); // mediaId = storage key (guest-scoped; not the staff media table). - return { mediaId: put.storageKey, storageKey: put.storageKey }; + return { + mediaId: put.storageKey, + storageKey: put.storageKey, + encrypted: sealed.encrypted, + }; } async listUpsells(principal: CheckInPrincipal) { @@ -553,56 +573,70 @@ export class GuestCheckInService { async requestLink(dto: RequestCheckInLinkDto) { const confirmation = dto.confirmationNumber.trim(); const email = dto.email.trim().toLowerCase(); + const propertyId = dto.propertyId; + const generic = { + ok: true as const, + message: 'If we find a matching reservation, a new check-in link will be emailed shortly.', + }; + + const [property] = await this.db + .select() + .from(properties) + .where(eq(properties.id, propertyId)) + .limit(1); + if (!property) return generic; + + const online = ((property.settings ?? {}) as { onlineCheckIn?: OnlineCheckInSettings }) + .onlineCheckIn; + if (online?.enabled === false) return generic; + // Confirmation numbers live on bookings (party wrapper), not reservations. const [booking] = await this.db .select() .from(bookings) - .where(eq(bookings.confirmationNumber, confirmation)) + .where( + and( + eq(bookings.confirmationNumber, confirmation), + eq(bookings.propertyId, propertyId), + ), + ) .limit(1); - if (booking) { - const [reservation] = await this.db - .select() - .from(reservations) - .where( - and( - eq(reservations.bookingId, booking.id), - eq(reservations.propertyId, booking.propertyId), - ), - ) - .limit(1); - if (reservation) { - const [guest] = await this.db - .select() - .from(guests) - .where(eq(guests.id, reservation.guestId)) - .limit(1); - if (guest?.email && String(guest.email).trim().toLowerCase() === email) { - const link = await this.issueLinkForReservation( - reservation.propertyId, - reservation.id, - ); - const [property] = await this.db - .select() - .from(properties) - .where(eq(properties.id, reservation.propertyId)); - const hotel = property?.staffDisplayName || property?.name || 'your hotel'; - try { - await this.email.send({ - to: email, - subject: `Your check-in link for ${hotel}`, - text: `Here is your online check-in link:\n\n${link.url}\n\nThis link expires soon. If you did not request it, you can ignore this email.`, - html: `

Here is your online check-in link for ${hotel}:

Continue check-in

This link expires soon. If you did not request it, you can ignore this email.

`, - }); - } catch { - // Still return generic OK — do not leak delivery failures. - } - } - } + if (!booking) return generic; + + const [reservation] = await this.db + .select() + .from(reservations) + .where( + and( + eq(reservations.bookingId, booking.id), + eq(reservations.propertyId, propertyId), + ), + ) + .limit(1); + if (!reservation) return generic; + + const [guest] = await this.db + .select() + .from(guests) + .where(eq(guests.id, reservation.guestId)) + .limit(1); + if (!guest?.email || String(guest.email).trim().toLowerCase() !== email) { + return generic; } - return { - ok: true, - message: 'If we find a matching reservation, a new check-in link will be emailed shortly.', - }; + + const link = await this.issueLinkForReservation(propertyId, reservation.id); + const hotel = property.staffDisplayName || property.name || 'your hotel'; + try { + await this.email.send({ + to: email, + subject: `Your check-in link for ${hotel}`, + text: `Here is your online check-in link:\n\n${link.url}\n\nThis link expires soon. If you did not request it, you can ignore this email.`, + html: `

Here is your online check-in link for ${hotel}:

Continue check-in

This link expires soon. If you did not request it, you can ignore this email.

`, + }); + } catch { + // Still return generic OK — do not leak delivery failures. + } + return generic; } async acceptUpsell(principal: CheckInPrincipal, dto: GuestAcceptUpsellDto) { @@ -709,6 +743,14 @@ export class GuestCheckInService { throw new BadRequestException('paymentMethodId or gatewayPaymentToken is required'); } + const configuredProvider = resolvePaymentGatewayProvider(this.config); + const requestedProvider = (dto.gatewayProvider ?? configuredProvider).toLowerCase(); + if (requestedProvider === 'mock' && !isPaymentGatewayMockMode()) { + throw new BadRequestException('Mock payment gateway is not enabled'); + } + // Prefer server-configured gateway; mock only when mock mode is enabled. + const gatewayProvider = isPaymentGatewayMockMode() ? 'mock' : configuredProvider; + let folio = ( await this.db .select() @@ -733,13 +775,24 @@ export class GuestCheckInService { }); } + const balance = Number(folio.balance ?? 0); + const amount = Number(dto.amount); + if (!Number.isFinite(amount) || amount < 0.01) { + throw new BadRequestException('Payment amount is invalid'); + } + if (amount > balance + 0.009) { + throw new BadRequestException( + `Payment amount exceeds outstanding balance (${balance.toFixed(2)})`, + ); + } + const returnUrl = dto.returnUrl?.trim(); const result = await this.payments.authorizePayment({ folioId: folio.id, propertyId: principal.propertyId, - amount: Number(dto.amount).toFixed(2), + amount: amount.toFixed(2), currencyCode: ctx.reservation.currencyCode || ctx.property.currencyCode, - gatewayProvider: dto.gatewayProvider ?? 'stripe', + gatewayProvider, gatewayPaymentToken: token, cardLastFour: dto.cardLastFour, cardBrand: dto.cardBrand, @@ -876,6 +929,9 @@ export class GuestCheckInService { async getAccess(principal: CheckInPrincipal) { const ctx = await this.loadContext(principal); + if (['checked_out', 'cancelled', 'no_show'].includes(ctx.reservation.status)) { + throw new BadRequestException('Room access is no longer available for this stay'); + } if (!['checked_in', 'stayover', 'due_out'].includes(ctx.reservation.status)) { throw new BadRequestException('Access is available after check-in'); } @@ -906,9 +962,21 @@ export class GuestCheckInService { } } - const accessCode = credential?.accessCode ?? null; - // Only surface mobileKey when the lock provider actually issued one. - const mobileKey = credential?.mobileKey ?? null; + // Never surface a console/demo PIN as a smart-lock digital key. + let accessCode = credential?.accessCode ?? null; + let mobileKey = credential?.mobileKey ?? null; + let provider = credential?.provider ?? null; + let accessUnavailableReason: string | null = null; + if (accessMode === 'smart_lock') { + const providerName = String(provider || '').toLowerCase(); + if (!accessCode || providerName === 'console' || !credential) { + accessCode = null; + mobileKey = null; + provider = providerName === 'console' ? null : provider; + accessUnavailableReason = + 'Digital room key is unavailable. Please contact the front desk.'; + } + } const wallet = ctx.online.walletEnabled === false @@ -935,11 +1003,12 @@ export class GuestCheckInService { accessCode, accessMode, instructions: ctx.online.accessInstructions ?? null, - provider: credential?.provider ?? null, + provider, mobileKey, wallet, reservationId: principal.reservationId, status: ctx.reservation.status, + ...(accessUnavailableReason ? { accessUnavailableReason } : {}), }; } @@ -972,12 +1041,21 @@ export class GuestCheckInService { const key = String(doc['imageStorageKey'] || doc['imageMediaId'] || ''); if (!key) throw new NotFoundException('No ID photo on file'); - const buffer = await this.storage.get(key); - if (!buffer) throw new NotFoundException('ID photo file missing'); - const ext = key.split('.').pop()?.toLowerCase(); + const stored = await this.storage.get(key); + if (!stored) throw new NotFoundException('ID photo file missing'); + const decrypted = decryptIdImageBuffer(stored); + const buffer = decrypted.buffer; + const extFromKey = key.split('.').pop()?.toLowerCase(); const contentType = - ext === 'png' ? 'image/png' : ext === 'webp' ? 'image/webp' : 'image/jpeg'; - return { buffer, contentType, filename: `guest-id-${reservationId}.${ext || 'jpg'}` }; + decrypted.contentType || + (extFromKey === 'png' + ? 'image/png' + : extFromKey === 'webp' + ? 'image/webp' + : 'image/jpeg'); + const ext = + contentType.includes('png') ? 'png' : contentType.includes('webp') ? 'webp' : 'jpg'; + return { buffer, contentType, filename: `guest-id-${reservationId}.${ext}` }; } private assertFranceFicheFields( @@ -993,9 +1071,11 @@ export class GuestCheckInService { .trim() .toUpperCase(); const isFr = nationality === 'FR' || nationality === 'FRA' || nationality === 'FRANCE'; - if (!nationality || isFr) return; + // Empty nationality is treated as incomplete (matches front-desk fiche guards). + if (isFr) return; const missing: string[] = []; + if (!nationality) missing.push('nationality'); const need = (key: string, label: string) => { if (!String(rd[key] ?? '').trim()) missing.push(label); }; diff --git a/apps/api/src/modules/guest-check-in/id-image-encryption.spec.ts b/apps/api/src/modules/guest-check-in/id-image-encryption.spec.ts new file mode 100644 index 00000000..56316ff1 --- /dev/null +++ b/apps/api/src/modules/guest-check-in/id-image-encryption.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { + decryptIdImageBuffer, + encryptIdImageBuffer, +} from './id-image-encryption'; + +describe('id-image-encryption', () => { + const key = 'b'.repeat(64); + + it('stores plaintext when ID_ENCRYPTION_KEY is unset', () => { + const plain = Buffer.from('hello-id-photo-bytes-xxxxxxxx'); + const sealed = encryptIdImageBuffer(plain, 'image/jpeg', {}); + expect(sealed.encrypted).toBe(false); + expect(sealed.buffer.equals(plain)).toBe(true); + }); + + it('round-trips encrypted ID photo bytes', () => { + const plain = Buffer.from('hello-id-photo-bytes-xxxxxxxx'); + const sealed = encryptIdImageBuffer(plain, 'image/png', { + ID_ENCRYPTION_KEY: key, + }); + expect(sealed.encrypted).toBe(true); + expect(sealed.buffer.equals(plain)).toBe(false); + const opened = decryptIdImageBuffer(sealed.buffer, { ID_ENCRYPTION_KEY: key }); + expect(opened.buffer.equals(plain)).toBe(true); + expect(opened.contentType).toBe('image/png'); + }); + + it('returns legacy plaintext buffers unchanged', () => { + const legacy = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3, 4]); + const opened = decryptIdImageBuffer(legacy, { ID_ENCRYPTION_KEY: key }); + expect(opened.buffer.equals(legacy)).toBe(true); + }); +}); diff --git a/apps/api/src/modules/guest-check-in/id-image-encryption.ts b/apps/api/src/modules/guest-check-in/id-image-encryption.ts new file mode 100644 index 00000000..8d600f29 --- /dev/null +++ b/apps/api/src/modules/guest-check-in/id-image-encryption.ts @@ -0,0 +1,82 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; + +const AES_ALGO = 'aes-256-gcm'; +const IV_BYTES = 16; +const KEY_BYTES = 32; +const ENVELOPE_MARKER = 'haip-id-image-v1'; + +export interface IdImageEnvelope { + v: typeof ENVELOPE_MARKER; + iv: string; + authTag: string; + ciphertext: string; + contentType: string; +} + +export function resolveIdEncryptionKey( + env: NodeJS.ProcessEnv = process.env, +): Buffer | null { + const hex = env['ID_ENCRYPTION_KEY']?.trim(); + if (!hex) return null; + const key = Buffer.from(hex, 'hex'); + if (key.length !== KEY_BYTES) { + throw new Error( + `ID_ENCRYPTION_KEY must be ${KEY_BYTES * 2} hex characters (${KEY_BYTES} bytes)`, + ); + } + return key; +} + +/** Encrypt ID photo bytes. Returns a UTF-8 JSON envelope as a Buffer. */ +export function encryptIdImageBuffer( + plaintext: Buffer, + contentType: string, + env: NodeJS.ProcessEnv = process.env, +): { buffer: Buffer; encrypted: true } | { buffer: Buffer; encrypted: false } { + const key = resolveIdEncryptionKey(env); + if (!key) { + return { buffer: plaintext, encrypted: false }; + } + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv(AES_ALGO, key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const envelope: IdImageEnvelope = { + v: ENVELOPE_MARKER, + iv: iv.toString('hex'), + authTag: cipher.getAuthTag().toString('hex'), + ciphertext: ciphertext.toString('base64'), + contentType, + }; + return { buffer: Buffer.from(JSON.stringify(envelope), 'utf8'), encrypted: true }; +} + +/** Decrypt a stored envelope, or return the raw buffer if legacy plaintext. */ +export function decryptIdImageBuffer( + stored: Buffer, + env: NodeJS.ProcessEnv = process.env, +): { buffer: Buffer; contentType?: string } { + const asText = stored.toString('utf8'); + if (!asText.startsWith('{') || !asText.includes(ENVELOPE_MARKER)) { + return { buffer: stored }; + } + let parsed: IdImageEnvelope; + try { + parsed = JSON.parse(asText) as IdImageEnvelope; + } catch { + return { buffer: stored }; + } + if (parsed?.v !== ENVELOPE_MARKER || !parsed.ciphertext || !parsed.iv || !parsed.authTag) { + return { buffer: stored }; + } + const key = resolveIdEncryptionKey(env); + if (!key) { + throw new Error('ID photo is encrypted but ID_ENCRYPTION_KEY is not configured'); + } + const decipher = createDecipheriv(AES_ALGO, key, Buffer.from(parsed.iv, 'hex')); + decipher.setAuthTag(Buffer.from(parsed.authTag, 'hex')); + const buffer = Buffer.concat([ + decipher.update(Buffer.from(parsed.ciphertext, 'base64')), + decipher.final(), + ]); + return { buffer, contentType: parsed.contentType }; +} diff --git a/apps/booking/src/api/checkInClient.ts b/apps/booking/src/api/checkInClient.ts index 66078b86..6feff126 100644 --- a/apps/booking/src/api/checkInClient.ts +++ b/apps/booking/src/api/checkInClient.ts @@ -98,6 +98,7 @@ export interface RoomAccess { accessCode: string | null; accessMode: string; instructions: string | null; + accessUnavailableReason?: string | null; provider: string | null; mobileKey: { type?: string; deepLink?: string; label?: string } | null; wallet: { @@ -176,6 +177,7 @@ export const checkInApi = { return data; }, requestLink: async (body: { + propertyId: string; confirmationNumber: string; email: string; }): Promise<{ ok: boolean; message: string }> => { diff --git a/apps/booking/src/api/types.ts b/apps/booking/src/api/types.ts index e7112a9e..1b734d56 100644 --- a/apps/booking/src/api/types.ts +++ b/apps/booking/src/api/types.ts @@ -50,6 +50,7 @@ export type BookingApplicationAnswer = string | string[] | boolean; export type BookingApplicationAnswers = Record; export interface BookingConfig { + propertyId: string; isEnabled: boolean; displayName?: string | null; logoMediaId?: string | null; diff --git a/apps/booking/src/pages/CheckIn.payment.test.tsx b/apps/booking/src/pages/CheckIn.payment.test.tsx index b85f12e1..2d187e8e 100644 --- a/apps/booking/src/pages/CheckIn.payment.test.tsx +++ b/apps/booking/src/pages/CheckIn.payment.test.tsx @@ -133,7 +133,7 @@ describe('CheckIn payment modes', () => { payMock.mockResolvedValue({ ...baseSession, nextAction }); renderPay({ ...baseSession, - payment: { provider: 'redsys', clientMode: 'redsys' }, + payment: { provider: 'redsys', clientMode: 'redsys', redsysConfigured: true }, }); await waitFor(() => expect(screen.getByRole('button', { name: /Pay securely via Redsys/i })).toBeTruthy(), @@ -155,4 +155,15 @@ describe('CheckIn payment modes', () => { ); expect(screen.queryByRole('button', { name: /Pay \(demo\)/i })).toBeNull(); }); + + it('hides redsys pay when credentials are missing', async () => { + renderPay({ + ...baseSession, + payment: { provider: 'redsys', clientMode: 'redsys', redsysConfigured: false }, + }); + await waitFor(() => + expect(screen.getByText(/Payment is not configured/i)).toBeTruthy(), + ); + expect(screen.queryByRole('button', { name: /Redsys/i })).toBeNull(); + }); }); diff --git a/apps/booking/src/pages/CheckIn.tsx b/apps/booking/src/pages/CheckIn.tsx index 90df38d4..c3ca487f 100644 --- a/apps/booking/src/pages/CheckIn.tsx +++ b/apps/booking/src/pages/CheckIn.tsx @@ -15,6 +15,7 @@ import { Button } from '../components/Button'; import { Field, inputClass } from '../components/Field'; import { StripeCard, type PaymentResult } from '../components/StripeCard'; import { WorldlineCard } from '../components/WorldlineCard'; +import { useConfig } from '../context/ConfigContext'; import { money, stayDateRange } from '../lib/format'; import { checkInCopy, resolveCheckInLocale } from '../lib/checkInCopy'; import { resizeImageToDataUrl } from '../lib/resizeImage'; @@ -41,8 +42,11 @@ function walletAsset(name: string): string { } export function CheckIn() { - const [params] = useSearchParams(); - const token = params.get('token') ?? ''; + const [params, setParams] = useSearchParams(); + const urlToken = params.get('token') ?? ''; + const [heldToken, setHeldToken] = useState(''); + const token = heldToken || urlToken; + const { config } = useConfig(); const queryClient = useQueryClient(); const copy = useMemo(() => checkInCopy(resolveCheckInLocale()), []); const bootstrapped = useRef(false); @@ -71,9 +75,15 @@ export function CheckIn() { const [payAmount, setPayAmount] = useState(''); const fileRef = useRef(null); + // Bootstrap from magic-link / payment-return ?token=, then strip it from the URL. useEffect(() => { - if (token) setCheckInToken(token); - }, [token]); + if (!urlToken) return; + setCheckInToken(urlToken); + setHeldToken(urlToken); + const next = new URLSearchParams(params); + next.delete('token'); + setParams(next, { replace: true }); + }, [urlToken, params, setParams]); const sessionQuery = useQuery({ queryKey: ['check-in-session', token], @@ -191,14 +201,34 @@ export function CheckIn() { }); const requestLink = useMutation({ - mutationFn: () => - checkInApi.requestLink({ + mutationFn: () => { + const propertyId = config?.propertyId; + if (!propertyId) { + throw new Error('Property is still loading. Try again in a moment.'); + } + return checkInApi.requestLink({ + propertyId, confirmationNumber: reqConfirmation, email: reqEmail, - }), + }); + }, onSuccess: () => setReqDone(true), }); + const ficheNeeded = + Boolean(sessionQuery.data?.francePackEnabled) && + (!nationality || (nationality !== 'FR' && nationality !== 'FRA' && nationality !== 'FRANCE')); + + const paymentUiReady = useMemo(() => { + const p = sessionQuery.data?.payment; + if (!p) return false; + if (p.clientMode === 'mock') return true; + if (p.clientMode === 'stripe') return Boolean(sessionQuery.data?.stripePublishableKey); + if (p.clientMode === 'redsys') return p.redsysConfigured === true; + if (p.clientMode === 'worldline') return p.worldlineConfigured === true; + return false; + }, [sessionQuery.data]); + const worldlineSession = useCallback(() => checkInApi.worldlineTokenization(), []); const stripePromise = useMemo(() => { @@ -275,7 +305,12 @@ export function CheckIn() {