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 @@
-
+
@@ -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() {